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")); } }