510 lines
21 KiB
C#
510 lines
21 KiB
C#
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);
|
||
}
|