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

510 lines
21 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.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;
/// <summary>
/// Интеграционные тесты ProgressionRules/BreakEngine (specs m05 01–05)
/// против docker watchbody-pg. Каждый тест — свой атлет. Сессия плана —
/// «monday»; упражнения библиотеки именованы как exercise_id в body.
/// </summary>
[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,
});
}
/// <summary>Активная версия: понедельник, одно упражнение bench_press,
/// 3 подхода planWeight×planReps.</summary>
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<decimal>());
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<decimal>());
}
[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<decimal>());
}
[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<decimal>());
Assert.Equal(12, exercise["sets"]![0]!["reps"]!.GetValue<int>());
}
[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<decimal>());
}
[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<decimal>());
}
// --- ограничения атлета (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<string>());
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<string>());
Assert.Equal(60.0m, week["sessions"]![0]!["exercises"]![0]!
["sets"]![0]!["weight_kg"]!.GetValue<decimal>());
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<string>());
Assert.Equal(85.0m, week["sessions"]![0]!["exercises"]![0]!
["sets"]![0]!["weight_kg"]!.GetValue<decimal>()); // 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<string>());
Assert.Equal(60.0m, week["sessions"]![0]!["exercises"]![0]!
["sets"]![0]!["weight_kg"]!.GetValue<decimal>()); // болезнь → ×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<string>());
Assert.Equal(100.0m, session["exercises"]![0]!["sets"]![0]!
["weight_kg"]!.GetValue<decimal>()); // 4–6 дней — веса без снижения
Assert.Equal(2, session["exercises"]![0]!["sets"]!.AsArray().Count); // −1 подход
Assert.Equal(5, session["exercises"]![0]!["sets"]![0]!["target_rpe"]!.GetValue<int>());
}
// --- 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);
}