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