using System.Text; using System.Text.Json; using System.Text.Json.Nodes; 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.Programs; using Xunit; namespace Server.WatchBody.Web.Tests; /// /// Интеграционные тесты ProgressionRules/BreakEngine (specs m05 01–05) /// против docker watchbody-pg. Каждый тест — свой атлет. Сессия плана — /// «monday»; упражнения библиотеки именованы как exercise_id в body. /// [Collection("web")] public class ProgressionRulesTests(TestApp app) { private static readonly DateOnly Monday = new(2026, 10, 12); private static readonly DateOnly NextMonday = new(2026, 10, 19); private (ProgressionRules Rules, BreakEngine Breaks, ProgramStore Store, int AthleteId) NewRules( ProgressionConfig? config = null) { var cs = NewCs(); var athleteId = new AthleteRepository(cs).Insert(new Athlete { Name = "Rules test", ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), }); var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs)); var cfg = config ?? new ProgressionConfig(); var rules = new ProgressionRules( new WorkoutRepository(cs), new ExerciseRepository(cs), store, cfg); var breaks = new BreakEngine( new WorkoutRepository(cs), new BreakRepository(cs), store, cfg); return (rules, breaks, store, athleteId); } private ConnectionString NewCs() => new() { Type = ConnectionStringType.Postgres, Value = app.Config.DbConnectionString, }; private Guid NewExercise(int athleteId, string name, int groupId = 1, bool isBase = true, bool isActive = true) { var id = Guid.NewGuid(); new ExerciseRepository(NewCs()).Insert(new Exercise { Id = id, AthleteId = athleteId, Name = name, GroupId = groupId, IsBase = isBase, IsActive = isActive, ReplacementIds = [], }); return id; } private Guid NewWorkout(int athleteId, DateTime startedAt, string sessionKind = "normal") { var id = Guid.NewGuid(); new WorkoutRepository(NewCs()).Insert(athleteId, new Workout { Id = id, AthleteId = athleteId, StartedAt = startedAt, Status = "finished", SessionKind = sessionKind, Sets = [], Commands = [], }); return id; } private void NewSet(int athleteId, Guid workoutId, Guid exerciseId, decimal weight, int reps, bool inPlan = true, int? rpe = 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 ?? DateTime.UtcNow, Source = "voice", InPlan = inPlan, Rpe = rpe, KneePain = null, }); } /// Активная версия: понедельник, одно упражнение bench_press, /// 3 подхода planWeight×planReps. private Guid ActivatePlan(int athleteId, ProgramStore store, decimal planWeight = 100, int planReps = 8, string exerciseId = "bench_press") { var weight = planWeight.ToString(System.Globalization.CultureInfo.InvariantCulture); store.CreateDraft(athleteId, $$""" {"weeks":[{"week":1,"sessions":[{"day":"monday","focus":"push", "exercises":[{"exercise_id":"{{exerciseId}}","sets":[ {"weight_kg":{{weight}},"reps":{{planReps}},"target_rpe":7}, {"weight_kg":{{weight}},"reps":{{planReps}},"target_rpe":7}, {"weight_kg":{{weight}},"reps":{{planReps}},"target_rpe":7}]}]}]}]} """, "user", "тест"); var version = store.ListVersions(athleteId)[0]; store.Activate(athleteId, version.Id); return version.Id; } private static JsonNode DraftBody(ProgramStore store, int athleteId, ProgramVersion draft) { Assert.Equal("rules", draft.Author); Assert.Equal("draft", draft.Status); return JsonNode.Parse(store.GetVersion(athleteId, draft.Id)!.Body)!; } // --- микроправка: правила 1–5 (приёмка 1, spec 02 §3) --- [Fact] public void Progress_Rule1_TwoClosedEasyWorkouts_StepUp() { var (rules, _, store, athlete) = NewRules(); var ex = NewExercise(athlete, "bench_press"); ActivatePlan(athlete, store); var w1 = NewWorkout(athlete, Monday.AddMonths(-1).ToDateTimeAt(18)); var w2 = NewWorkout(athlete, NextMonday.AddMonths(-1).ToDateTimeAt(18)); foreach (var w in new[] { w1, w2 }) for (var i = 0; i < 3; i++) NewSet(athlete, w, ex, 100, 8, inPlan: true, rpe: 7); var draft = rules.EvaluateAfterWorkout(athlete, w2); Assert.NotNull(draft); var body = DraftBody(store, athlete, draft!); Assert.Equal(102.5m, body["weeks"]![0]!["sessions"]![0]!["exercises"]![0]! ["sets"]![0]!["weight_kg"]!.GetValue()); Assert.Contains("2 тренировки подряд", draft!.Rationale); } [Fact] public void Progress_Rule1_Isolation_IsoStep() { var (rules, _, store, athlete) = NewRules(config: new ProgressionConfig { BaseStepKg = 2.5m, IsoStepKg = 1.0m }); var ex = NewExercise(athlete, "bench_press", isBase: false); ActivatePlan(athlete, store); var w1 = NewWorkout(athlete, Monday.AddMonths(-1).ToDateTimeAt(18)); var w2 = NewWorkout(athlete, NextMonday.AddMonths(-1).ToDateTimeAt(18)); foreach (var w in new[] { w1, w2 }) for (var i = 0; i < 3; i++) NewSet(athlete, w, ex, 100, 8, rpe: 6); var draft = rules.EvaluateAfterWorkout(athlete, w2); var body = DraftBody(store, athlete, draft!); Assert.Equal(101.0m, body["weeks"]![0]!["sessions"]![0]!["exercises"]![0]! ["sets"]![0]!["weight_kg"]!.GetValue()); } [Fact] public void Progress_Rule2_HighRpe_Backoff() { var (rules, _, store, athlete) = NewRules(); var ex = NewExercise(athlete, "bench_press"); ActivatePlan(athlete, store); var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); for (var i = 0; i < 3; i++) NewSet(athlete, w, ex, 100, 8, rpe: i < 2 ? 9 : 6); var draft = rules.EvaluateAfterWorkout(athlete, w); var body = DraftBody(store, athlete, draft!); Assert.Equal(95.0m, body["weeks"]![0]!["sessions"]![0]!["exercises"]![0]! ["sets"]![0]!["weight_kg"]!.GetValue()); } [Fact] public void Progress_Rule3_RepsTop_WeightUpReps12() { var (rules, _, store, athlete) = NewRules(); var ex = NewExercise(athlete, "bench_press"); ActivatePlan(athlete, store, planWeight: 60, planReps: 15); var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); for (var i = 0; i < 3; i++) NewSet(athlete, w, ex, 60, 15, rpe: 7); var draft = rules.EvaluateAfterWorkout(athlete, w); var exercise = DraftBody(store, athlete, draft!)["weeks"]![0]!["sessions"]![0]! ["exercises"]![0]!; Assert.Equal(62.5m, exercise["sets"]![0]!["weight_kg"]!.GetValue()); Assert.Equal(12, exercise["sets"]![0]!["reps"]!.GetValue()); } [Fact] public void Progress_Rule4_Shortfall_Backoff() { var (rules, _, store, athlete) = NewRules(); var ex = NewExercise(athlete, "bench_press"); ActivatePlan(athlete, store, planWeight: 100, planReps: 8); var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); NewSet(athlete, w, ex, 100, 6, rpe: 8); NewSet(athlete, w, ex, 100, 6, rpe: 8); NewSet(athlete, w, ex, 100, 8, rpe: 8); var draft = rules.EvaluateAfterWorkout(athlete, w); var body = DraftBody(store, athlete, draft!); Assert.Equal(95.0m, body["weeks"]![0]!["sessions"]![0]!["exercises"]![0]! ["sets"]![0]!["weight_kg"]!.GetValue()); } [Fact] public void Progress_Rule5_Partial_NoDraft() { var (rules, _, store, athlete) = NewRules(); var ex = NewExercise(athlete, "bench_press"); ActivatePlan(athlete, store, planWeight: 100, planReps: 8); var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); NewSet(athlete, w, ex, 100, 6, rpe: 8); // один недобор (<2) NewSet(athlete, w, ex, 100, 8, rpe: 8); var draft = rules.EvaluateAfterWorkout(athlete, w); Assert.Null(draft); Assert.DoesNotContain(store.ListVersions(athlete), v => v.Author == "rules"); } // --- дедупликация и изоляция (приёмка 2, 5) --- [Fact] public void Progress_Dedup_SecondCall_NoNewDraft() { var (rules, _, store, athlete) = NewRules(); var ex = NewExercise(athlete, "bench_press"); ActivatePlan(athlete, store); var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); for (var i = 0; i < 3; i++) NewSet(athlete, w, ex, 100, 8, rpe: 9); var first = rules.EvaluateAfterWorkout(athlete, w); var second = rules.EvaluateAfterWorkout(athlete, w); Assert.NotNull(first); Assert.Null(second); Assert.Single(store.ListVersions(athlete).Where(v => v.Author == "rules")); } [Fact] public void Progress_Isolation_ForeignWorkoutIgnored() { var (_, _, _, other) = NewRules(); var otherEx = NewExercise(other, "bench_press"); ActivatePlan(other, new ProgramStore(new ProgramRepository(NewCs()), new ProgramVersionRepository(NewCs()))); var otherWorkout = NewWorkout(other, NextMonday.ToDateTimeAt(18)); for (var i = 0; i < 3; i++) NewSet(other, otherWorkout, otherEx, 100, 8, rpe: 6); var (rules, _, store, athlete) = NewRules(); var ex = NewExercise(athlete, "bench_press"); ActivatePlan(athlete, store); var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); // у атлета подходы с RPE 9 → правило 2; чужие подходы не мешают for (var i = 0; i < 3; i++) NewSet(athlete, w, ex, 100, 8, rpe: 9); var draft = rules.EvaluateAfterWorkout(athlete, w); var body = DraftBody(store, athlete, draft!); Assert.Equal(95.0m, body["weeks"]![0]!["sessions"]![0]!["exercises"]![0]! ["sets"]![0]!["weight_kg"]!.GetValue()); } // --- ограничения атлета (module_reqs 2.4) --- [Fact] public void Limits_InactiveExercise_ReplacedInDraft() { var (rules, _, store, athlete) = NewRules(); var squat = NewExercise(athlete, "squat", groupId: 3); var legPress = NewExercise(athlete, "leg_press", groupId: 3); // логическое удаление — единственный путь is_active=false (З-34) new ExerciseRepository(NewCs()).Delete(athlete, squat); new ExerciseReplacementRepository(NewCs()).Insert(athlete, squat, legPress); ActivatePlan(athlete, store, exerciseId: "squat"); var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); NewSet(athlete, w, legPress, 120, 8); var draft = rules.EvaluateAfterWorkout(athlete, w); Assert.NotNull(draft); var exercise = DraftBody(store, athlete, draft!)["weeks"]![0]!["sessions"]![0]! ["exercises"]![0]!; Assert.Equal("leg_press", exercise["exercise_id"]!.GetValue()); Assert.Contains("leg_press", draft!.Rationale); } [Fact] public void Limits_InactiveNoReplacement_SessionDropped() { var (rules, _, store, athlete) = NewRules(); var squat = NewExercise(athlete, "squat", groupId: 3); new ExerciseRepository(NewCs()).Delete(athlete, squat); ActivatePlan(athlete, store, exerciseId: "squat"); var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); NewSet(athlete, w, NewExercise(athlete, "other"), 50, 10); var draft = rules.EvaluateAfterWorkout(athlete, w); Assert.NotNull(draft); var sessions = DraftBody(store, athlete, draft!)["weeks"]![0]!["sessions"]!.AsArray(); Assert.Empty(sessions); } // --- делоад (приёмка 3, spec 03) --- [Fact] public void Deload_AfterMaxWeeks_Proposed() { var (rules, _, store, athlete) = NewRules(); var ex = NewExercise(athlete, "bench_press"); ActivatePlan(athlete, store); NewWorkout(athlete, Monday.AddMonths(-2).ToDateTimeAt(18), sessionKind: "deload"); var recent = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); for (var i = 0; i < 3; i++) NewSet(athlete, recent, ex, 100, 8, rpe: 7); var draft = rules.CheckDeload(athlete, new DateOnly(2026, 10, 18)); Assert.NotNull(draft); var week = DraftBody(store, athlete, draft!)["weeks"]![0]!; Assert.Equal("deload", week["sessions"]![0]!["session_kind"]!.GetValue()); Assert.Equal(60.0m, week["sessions"]![0]!["exercises"]![0]! ["sets"]![0]!["weight_kg"]!.GetValue()); Assert.Equal(2, week["sessions"]![0]!["exercises"]![0]!["sets"]!.AsArray().Count); } [Fact] public void Deload_NotDue_NoDraft() { var (rules, _, store, athlete) = NewRules(); ActivatePlan(athlete, store); NewWorkout(athlete, new DateOnly(2026, 10, 8).ToDateTimeAt(18), sessionKind: "deload"); NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); Assert.Null(rules.CheckDeload(athlete, new DateOnly(2026, 10, 18))); } // --- перерывы (приёмка 4, spec 04) --- [Fact] public void Break_Detected_AfterMissedWindow() { var (rules, breaks, store, athlete) = NewRules(); ActivatePlan(athlete, store, exerciseId: "bench_press"); // день monday var before = new BreakRepository(NewCs()) .Select(new BreakFilter { AthleteId = athlete }).ToList(); var detected = breaks.DetectBreak(athlete, new DateOnly(2026, 10, 20)); // вт Assert.True(detected); var open = new BreakRepository(NewCs()) .Select(new BreakFilter { AthleteId = athlete }) .Single(b => b.EndedOn == null && !before.Any(x => x.Id == b.Id)); Assert.Equal("auto", open.Source); Assert.Equal(new DateOnly(2026, 10, 19), open.StartedOn); // понедельник } [Fact] public void Break_NotDetected_RecentWorkout() { var (_, breaks, store, athlete) = NewRules(); ActivatePlan(athlete, store); NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); Assert.False(breaks.DetectBreak(athlete, new DateOnly(2026, 10, 20))); } [Fact] public void Break_CloseOnReturn_TestSession_After14Days() { var (_, breaks, store, athlete) = NewRules(); ActivatePlan(athlete, store); new BreakRepository(NewCs()).Insert(athlete, new Break { Id = Guid.NewGuid(), AthleteId = athlete, Source = "auto", StartedOn = new DateOnly(2026, 10, 1), Note = "", }); var workout = NewWorkout(athlete, new DateOnly(2026, 10, 15).ToDateTimeAt(18)); var draft = breaks.CloseOnReturn(athlete, workout, new DateOnly(2026, 10, 15)); Assert.NotNull(draft); var closed = new BreakRepository(NewCs()) .Select(new BreakFilter { AthleteId = athlete }).Single(); Assert.Equal(new DateOnly(2026, 10, 15), closed.EndedOn); var week = DraftBody(store, athlete, draft!)["weeks"]![0]!; Assert.Equal("test", week["sessions"]![0]!["session_kind"]!.GetValue()); Assert.Equal(85.0m, week["sessions"]![0]!["exercises"]![0]! ["sets"]![0]!["weight_kg"]!.GetValue()); // 14–27 дней → ×0.85 Assert.Contains("14", draft!.Rationale); } [Fact] public void Break_Illness_ReturnFactor_Min() { var (_, breaks, store, athlete) = NewRules(); ActivatePlan(athlete, store); new BreakRepository(NewCs()).Insert(athlete, new Break { Id = Guid.NewGuid(), AthleteId = athlete, Kind = "illness", Source = "auto", StartedOn = new DateOnly(2026, 10, 10), Note = "", }); var workout = NewWorkout(athlete, new DateOnly(2026, 10, 15).ToDateTimeAt(18)); var draft = breaks.CloseOnReturn(athlete, workout, new DateOnly(2026, 10, 15)); var week = DraftBody(store, athlete, draft!)["weeks"]![0]!; Assert.Equal("return", week["sessions"]![0]!["session_kind"]!.GetValue()); Assert.Equal(60.0m, week["sessions"]![0]!["exercises"]![0]! ["sets"]![0]!["weight_kg"]!.GetValue()); // болезнь → ×0.6 } [Fact] public void Break_ShortReturn_FullWeights_SofterSession() { var (_, breaks, store, athlete) = NewRules(); ActivatePlan(athlete, store); new BreakRepository(NewCs()).Insert(athlete, new Break { Id = Guid.NewGuid(), AthleteId = athlete, Source = "auto", StartedOn = new DateOnly(2026, 10, 14), Note = "", }); var workout = NewWorkout(athlete, new DateOnly(2026, 10, 19).ToDateTimeAt(18)); var draft = breaks.CloseOnReturn(athlete, workout, new DateOnly(2026, 10, 19)); var session = DraftBody(store, athlete, draft!)["weeks"]![0]!["sessions"]![0]!; Assert.Equal("return", session["session_kind"]!.GetValue()); Assert.Equal(100.0m, session["exercises"]![0]!["sets"]![0]! ["weight_kg"]!.GetValue()); // 4–6 дней — веса без снижения Assert.Equal(2, session["exercises"]![0]!["sets"]!.AsArray().Count); // −1 подход Assert.Equal(5, session["exercises"]![0]!["sets"]![0]!["target_rpe"]!.GetValue()); } // --- wiring в sync (spec 05): правила вызываются после пакета --- [Fact] public async Task Sync_Package_CreatesRulesDraft() { var cs = NewCs(); var key = "rules-sync-" + Guid.NewGuid().ToString("N")[..12]; var athleteId = new AthleteRepository(cs).Insert(new Athlete { Name = "Rules sync", ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(key), }); var ex = NewExercise(athleteId, "bench_press"); var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs)); ActivatePlan(athleteId, store); var w1 = Guid.NewGuid(); var w2 = Guid.NewGuid(); var items = new List<(Guid Workout, DateTime Started)> { (w1, Monday.AddMonths(-1).ToDateTimeAt(18)), (w2, NextMonday.AddMonths(-1).ToDateTimeAt(18)), }; var payload = new StringBuilder("["); foreach (var (workout, started) in items) { payload.Append($$""" {"client_guid":"{{workout}}","entity":"workout","op":"upsert", "data":{"started_at":"{{started:yyyy-MM-dd}}T18:00:00Z","status":"finished"} }, """); for (var i = 0; i < 3; i++) payload.Append($$""" {"client_guid":"{{Guid.NewGuid()}}","entity":"workout_set","op":"upsert", "data":{"workout_id":"{{workout}}","exercise_id":"{{ex}}","seq":{{i + 1}}, "weight_kg":100,"reps":8,"done_at":"{{started:yyyy-MM-dd}}T18:0{{i + 1}}:00Z", "source":"voice","in_plan":true,"rpe":6} }, """); } payload.Length -= 1; payload.Append(']'); var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") { Content = new StringContent(payload.ToString(), 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 drafts = store.ListVersions(athleteId).Where(v => v.Author == "rules").ToList(); Assert.Single(drafts); // правило 1: 2 закрытые тренировки → +2.5 Assert.Contains("2 тренировки подряд", store.GetVersion(athleteId, drafts[0].Id)!.Rationale); } [Fact] public void ProgressionConfig_Validate_RejectsIsoStep() { var config = new ProgressionConfig { IsoStepKg = 10m }; Assert.Contains(config.Validate(), e => e.Contains("isoStepKg")); } } internal static class DateOnlyExtensions { public static DateTime ToDateTimeAt(this DateOnly date, int hour) => new(date.Year, date.Month, date.Day, hour, 0, 0, DateTimeKind.Utc); }