VEU.WathBody/3-app/2-cli/Server.WatchBody.Web.Tests/MetricsEngineTests.cs

386 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// Интеграционные тесты MetricsEngine (specs m04 01–04) против docker
/// watchbody-pg. Каждый тест — свой атлет (случайный ключ). Тренировки —
/// среда 2026-10-14 (план в активной версии привязан к «wednesday»).
/// </summary>
[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<InvalidOperationException>(
() => 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"));
}
}