using System.Net;
using System.Text;
using System.Text.Json;
using Kit.Helpers.SqlHelperAbstract;
using Server.WatchBody.Core.Dal.Ref.Repository;
using Server.WatchBody.Core.Dal.Src.Domain;
using Server.WatchBody.Core.Dal.Src.Repository;
using Server.WatchBody.Core.Metrics;
using Server.WatchBody.Core.Programs;
using Xunit;
namespace Server.WatchBody.Web.Tests;
///
/// Интеграционные тесты MetricsEngine (specs m04 01–04) против docker
/// watchbody-pg. Каждый тест — свой атлет (случайный ключ). Тренировки —
/// среда 2026-10-14 (план в активной версии привязан к «wednesday»).
///
[Collection("web")]
public class MetricsEngineTests(TestApp app)
{
private static readonly DateTime Wednesday = new(2026, 10, 14, 18, 0, 0, DateTimeKind.Utc);
private static readonly DateOnly Wed = new(2026, 10, 14);
private (MetricsEngine Engine, ProgramStore Store, int AthleteId) NewEngine(
MetricsConfig? config = null)
{
var cs = NewCs();
var athleteId = new AthleteRepository(cs).Insert(new Athlete
{
Name = "Metrics test",
ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(Guid.NewGuid().ToString()),
});
var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs));
var engine = new MetricsEngine(
new WorkoutRepository(cs), new ExerciseRepository(cs),
new ExerciseGroupRepository(cs), new DailyLogRepository(cs),
new WorkoutMetricsRepository(cs), store, config ?? new MetricsConfig());
return (engine, store, athleteId);
}
private ConnectionString NewCs() => new()
{
Type = ConnectionStringType.Postgres,
Value = app.Config.DbConnectionString,
};
private IWorkoutMetricsRepository MetricsRepo() => new WorkoutMetricsRepository(NewCs());
private Guid NewExercise(int athleteId, int groupId = 1)
{
var id = Guid.NewGuid();
new ExerciseRepository(NewCs()).Insert(new Exercise
{
Id = id, AthleteId = athleteId, Name = $"Присед {id:N}",
GroupId = groupId, IsBase = true, IsActive = true, ReplacementIds = [],
});
return id;
}
private Guid NewWorkout(int athleteId, DateTime? startedAt = null) =>
new WorkoutRepository(NewCs()).Insert(athleteId, new Workout
{
Id = Guid.NewGuid(),
AthleteId = athleteId,
StartedAt = startedAt ?? Wednesday,
Status = "finished",
SessionKind = "normal",
Sets = [],
Commands = [],
});
private void NewSet(int athleteId, Guid workoutId, Guid exerciseId,
decimal weight, int reps, bool inPlan = true, int? rpe = null,
int? kneePain = null, DateTime? doneAt = null)
{
new WorkoutSetRepository(NewCs()).Insert(new WorkoutSet
{
Id = Guid.NewGuid(), WorkoutId = workoutId, AthleteId = athleteId,
ExerciseId = exerciseId, Seq = 1, WeightKg = weight, Reps = reps,
DoneAt = doneAt ?? Wednesday, Source = "voice", InPlan = inPlan,
Rpe = rpe, KneePain = kneePain,
});
}
private void NewLog(int athleteId, DateOnly date,
int? bpSys = null, int? bpDias = null, int? hrResting = null, int? wellbeing = null)
{
new DailyLogRepository(NewCs()).Upsert(athleteId, new DailyLog
{
Id = Guid.NewGuid(), AthleteId = athleteId, Date = date,
BpSys = bpSys, BpDias = bpDias, HrResting = hrResting, Wellbeing = wellbeing,
});
}
private static JsonElement Flags(WorkoutMetrics m) =>
JsonDocument.Parse(m.RedFlags).RootElement.Clone();
private static string FlagNames(JsonElement flags) =>
string.Join(",", flags.EnumerateArray().Select(f => f.GetProperty("flag").GetString()));
// --- основные метрики (приёмка 1) ---
[Fact]
public void Compute_BasicMetrics()
{
var (engine, store, athlete) = NewEngine();
var ex = NewExercise(athlete, groupId: 1); // push
store.CreateDraft(athlete, """
{"weeks":[{"week":1,"sessions":[{"day":"wednesday","focus":"push",
"exercises":[{"exercise_id":"bench_press","sets":[
{"weight_kg":80,"reps":8},{"weight_kg":80,"reps":8},{"weight_kg":80,"reps":8}]}]}]}]}
""", "user", "тест");
store.Activate(athlete, store.ListVersions(athlete)[0].Id);
var workout = NewWorkout(athlete);
NewSet(athlete, workout, ex, 100, 6, inPlan: true, rpe: 7);
NewSet(athlete, workout, ex, 60, 10, inPlan: false, rpe: 5);
var m = engine.ComputeForWorkout(athlete, workout);
Assert.Equal(33.3m, m.Adherence); // 1 из 3 плановых
Assert.Equal(1200m, m.Tonnage); // 100×6 + 60×10
Assert.Equal(1200m, JsonDocument.Parse(m.TonnageByGroup)
.RootElement.GetProperty("push").GetDecimal());
Assert.Equal(120.0m, JsonDocument.Parse(m.E1pmByExercise)
.RootElement.GetProperty($"{ex}").GetDecimal());
}
[Fact]
public void Compute_HrMetrics_FallbackToWorkoutFields()
{
var (engine, _, athlete) = NewEngine();
var workoutId = NewWorkout(athlete);
new WorkoutRepository(NewCs()).Update(athlete, new Workout
{
Id = workoutId, AthleteId = athlete, StartedAt = Wednesday,
Status = "finished", SessionKind = "normal", AvgHr = 110,
HrRecoveryS = 45, Sets = [], Commands = [],
});
var m = engine.ComputeForWorkout(athlete, workoutId);
var hr = JsonDocument.Parse(m.HrMetrics).RootElement;
Assert.Equal(110m, hr.GetProperty("avg_hr").GetDecimal());
Assert.Equal(45m, hr.GetProperty("hr_recovery_s").GetDecimal());
}
[Fact]
public void Compute_NoActivePlan_FallbackAdherence()
{
var (engine, _, athlete) = NewEngine();
var ex = NewExercise(athlete);
var workoutId = NewWorkout(athlete);
NewSet(athlete, workoutId, ex, 50, 10, inPlan: true);
NewSet(athlete, workoutId, ex, 50, 8, inPlan: false);
var m = engine.ComputeForWorkout(athlete, workoutId);
Assert.Equal(50.0m, m.Adherence); // 1 из 2 фактических
}
// --- идемпотентность/пересчёт (приёмка 2) ---
[Fact]
public void Compute_Twice_SingleRow()
{
var (engine, _, athlete) = NewEngine();
var ex = NewExercise(athlete);
var workoutId = NewWorkout(athlete);
NewSet(athlete, workoutId, ex, 50, 10);
engine.ComputeForWorkout(athlete, workoutId);
engine.ComputeForWorkout(athlete, workoutId);
var rows = MetricsRepo().Select(new WorkoutMetricsFilter
{ AthleteId = athlete, WorkoutId = workoutId }).ToList();
Assert.Single(rows);
}
// --- красные флаги (приёмка 3, spec 03) ---
[Fact]
public void RedFlags_BpOver150_90()
{
var (engine, _, athlete) = NewEngine();
NewLog(athlete, Wed, bpSys: 160, bpDias: 95);
var workoutId = NewWorkout(athlete);
var flags = Flags(engine.ComputeForWorkout(athlete, workoutId));
Assert.Contains("bp_over_150_90", FlagNames(flags));
Assert.Equal(Wed, DateOnly.FromDateTime(
flags[0].GetProperty("date").GetDateTime()));
}
[Fact]
public void RedFlags_BpElevated()
{
var (engine, _, athlete) = NewEngine();
NewLog(athlete, Wed, bpSys: 145, bpDias: 89); // в коридоре, не >150/90
var workoutId = NewWorkout(athlete);
Assert.Contains("bp_elevated",
FlagNames(Flags(engine.ComputeForWorkout(athlete, workoutId))));
}
[Fact]
public void RedFlags_HrRestingUp_TwoDays()
{
var (engine, _, athlete) = NewEngine();
// базовый: 3 значения по 60 в окне до серии
NewLog(athlete, new DateOnly(2026, 10, 1), hrResting: 60);
NewLog(athlete, new DateOnly(2026, 10, 2), hrResting: 60);
NewLog(athlete, new DateOnly(2026, 10, 3), hrResting: 60);
// серия: 10-13 и 10-14 по 70 ≥ 60+8 (базовый для старта 10-13 —
// окно до него, значения 10-13/14 в него не входят)
NewLog(athlete, new DateOnly(2026, 10, 13), hrResting: 70);
NewLog(athlete, Wed, hrResting: 70);
var workoutId = NewWorkout(athlete);
var flags = Flags(engine.ComputeForWorkout(athlete, workoutId));
var hr = flags.EnumerateArray()
.Single(f => f.GetProperty("flag").GetString() == "hr_resting_up");
Assert.Equal(2, hr.GetProperty("dates").GetArrayLength());
}
[Fact]
public void RedFlags_HrResting_NoBaseline_NoFlag()
{
var (engine, _, athlete) = NewEngine();
NewLog(athlete, new DateOnly(2026, 10, 13), hrResting: 70); // всего 1 значение
NewLog(athlete, Wed, hrResting: 70);
var workoutId = NewWorkout(athlete);
Assert.DoesNotContain("hr_resting_up",
FlagNames(Flags(engine.ComputeForWorkout(athlete, workoutId))));
}
[Fact]
public void RedFlags_WellbeingLow_ThreeDays_Cancel()
{
var (engine, _, athlete) = NewEngine();
NewLog(athlete, new DateOnly(2026, 10, 12), wellbeing: 2);
NewLog(athlete, new DateOnly(2026, 10, 13), wellbeing: 2);
NewLog(athlete, Wed, wellbeing: 2);
var workoutId = NewWorkout(athlete);
var names = FlagNames(Flags(engine.ComputeForWorkout(athlete, workoutId)));
Assert.Contains("wellbeing_low", names);
Assert.Contains("cancel_recommendation", names);
}
[Fact]
public void RedFlags_KneePain_TwoSessions()
{
var (engine, _, athlete) = NewEngine();
var legs = NewExercise(athlete, groupId: 3);
var prev = NewWorkout(athlete, startedAt: new DateTime(2026, 10, 7, 18, 0, 0, DateTimeKind.Utc));
NewSet(athlete, prev, legs, 60, 10, kneePain: 1);
var workoutId = NewWorkout(athlete);
NewSet(athlete, workoutId, legs, 60, 10, kneePain: 2);
Assert.Contains("knee_pain_repeat",
FlagNames(Flags(engine.ComputeForWorkout(athlete, workoutId))));
}
[Fact]
public void RedFlags_KneePain_FirstSession_NoFlag()
{
var (engine, _, athlete) = NewEngine();
var legs = NewExercise(athlete, groupId: 3);
var workoutId = NewWorkout(athlete);
NewSet(athlete, workoutId, legs, 60, 10, kneePain: 2);
Assert.DoesNotContain("knee_pain_repeat",
FlagNames(Flags(engine.ComputeForWorkout(athlete, workoutId))));
}
[Fact]
public void RedFlags_CleanData_Empty()
{
var (engine, _, athlete) = NewEngine();
NewLog(athlete, Wed, bpSys: 120, bpDias: 80, hrResting: 60, wellbeing: 4);
var workoutId = NewWorkout(athlete);
Assert.Equal("[]",
engine.ComputeForWorkout(athlete, workoutId).RedFlags.Replace(" ", ""));
}
// --- изоляция (приёмка 4) и устойчивость (приёмка 5) ---
[Fact]
public void Isolation_ForeignLogsInvisible()
{
var (_, _, other) = NewEngine();
NewLog(other, Wed, bpSys: 170, bpDias: 100); // чужой атлет — высокое АД
var (engine, _, athlete) = NewEngine();
var workoutId = NewWorkout(athlete);
Assert.DoesNotContain("bp_over_150_90",
FlagNames(Flags(engine.ComputeForWorkout(athlete, workoutId))));
}
[Fact]
public void Compute_BrokenActivePlan_Fallback()
{
var (engine, store, athlete) = NewEngine();
store.CreateDraft(athlete, "\"oops\"", "user", "не программа");
store.Activate(athlete, store.ListVersions(athlete)[0].Id);
var ex = NewExercise(athlete);
var workoutId = NewWorkout(athlete);
NewSet(athlete, workoutId, ex, 50, 10, inPlan: true);
NewSet(athlete, workoutId, ex, 50, 8, inPlan: false);
var m = engine.ComputeForWorkout(athlete, workoutId);
Assert.Equal(50.0m, m.Adherence); // план не парсится → доля in_plan
}
[Fact]
public void Compute_ForeignWorkout_Throws()
{
var (_, _, other) = NewEngine();
var foreignWorkout = NewWorkout(other);
var (engine, _, athlete) = NewEngine();
Assert.Throws(
() => engine.ComputeForWorkout(athlete, foreignWorkout));
}
// --- wiring в sync (spec 04): тренировка из пакета получает метрики ---
[Fact]
public async Task Sync_WorkoutPackage_ComputesMetrics()
{
var cs = NewCs();
var key = "metrics-sync-" + Guid.NewGuid().ToString("N")[..12];
var athleteId = new AthleteRepository(cs).Insert(new Athlete
{
Name = "Metrics sync",
ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(key),
});
var ex = NewExercise(athleteId);
var workoutGuid = Guid.NewGuid();
var setGuid = Guid.NewGuid();
var logGuid = Guid.NewGuid();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync")
{
Content = new StringContent($$"""
[
{"client_guid":"{{workoutGuid}}","entity":"workout","op":"upsert",
"data":{"started_at":"2026-10-14T18:00:00Z","ended_at":"2026-10-14T19:00:00Z",
"status":"finished","avg_hr":105} },
{"client_guid":"{{setGuid}}","entity":"workout_set","op":"upsert",
"data":{"workout_id":"{{workoutGuid}}","exercise_id":"{{ex}}","seq":1,
"weight_kg":80,"reps":8,"done_at":"2026-10-14T18:05:00Z",
"source":"voice","in_plan":true,"rpe":7} },
{"client_guid":"{{logGuid}}","entity":"daily_log","op":"upsert",
"data":{"date":"2026-10-14","bp_sys":120,"bp_dias":80,
"hr_resting":60,"wellbeing":4} }
]
""", Encoding.UTF8, "application/json"),
};
request.Headers.Add("X-Api-Key", key);
var response = await app.Client.SendAsync(request);
Assert.True(response.IsSuccessStatusCode,
$"sync: {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
var rows = MetricsRepo().Select(new WorkoutMetricsFilter
{ AthleteId = athleteId, WorkoutId = workoutGuid }).ToList();
var row = Assert.Single(rows);
Assert.Equal(100.0m, row.Adherence); // единственный подход — по плану
Assert.Equal(640.0m, row.Tonnage);
}
[Fact]
public void MetricsConfig_Validate_RejectsBadCorridor()
{
var config = new MetricsConfig { BpElevatedSys = 160, BpCancelSys = 150 };
Assert.Contains(config.Validate(), e => e.Contains("bpElevatedSys"));
}
}