386 lines
15 KiB
C#
386 lines
15 KiB
C#
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"));
|
||
}
|
||
}
|