diff --git a/package.json b/package.json index b8eca23..c530a4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "climb-trainer", - "version": "8.14.0", + "version": "8.15.0", "private": true, "description": "Climbing training app — plan generator, guided session player, training diary", "engines": { diff --git a/server/domain/planner/blueprint.py b/server/domain/planner/blueprint.py index 989dbfb..7685dd4 100644 --- a/server/domain/planner/blueprint.py +++ b/server/domain/planner/blueprint.py @@ -28,11 +28,11 @@ ## What `__post_init__` checks, and what it deliberately does not -Only a **schema CHECK**. A blueprint that could not be inserted is worth failing on in the -generator's own tests rather than in #11b's first bulk insert. `String(80)` limits are NOT -checked here: they are a column width, not a CHECK, and the safety guard already asserts -every generated string fits — duplicating it would turn that guard's red into a traceback -from a constructor, which is a worse failure to read. +A schema **CHECK**, cheaper to fail here than in #11b's first bulk insert, plus the one +invariant no CHECK can express because it spans rows: the mesocycles tile the plan's own +`week_count`, each carrying exactly the weeks its span claims. `String(80)` limits are NOT +checked — a width is not a CHECK, `tests/test_planner_safety.py` covers every generated +string, and repeating it would turn that guard's red into a constructor traceback. """ import enum @@ -166,8 +166,7 @@ def __post_init__(self) -> None: if self.scheduled_on.weekday() != self.weekday: raise ValueError( f"scheduled_on {self.scheduled_on} is a {self.scheduled_on.strftime('%A')} " - f"but weekday says {self.weekday}. `planned_session` stores both and nothing " - f"in the schema keeps them in agreement, so the generator has to." + f"but weekday says {self.weekday}." ) @@ -213,6 +212,13 @@ def __post_init__(self) -> None: f"end_week {self.end_week} precedes start_week {self.start_week} " f"(ck_mesocycle_end_week_after_start)." ) + carried = tuple(microcycle.week_no for microcycle in self.microcycles) + if carried != tuple(range(self.start_week, self.end_week + 1)): + raise ValueError( + f"a {self.phase.value} mesocycle claiming weeks {self.start_week}-" + f"{self.end_week} must carry exactly those microcycles in week order; it " + f"carries {carried}." + ) @dataclass(frozen=True, slots=True) @@ -243,3 +249,15 @@ def __post_init__(self) -> None: ) if self.start_date.weekday() != 0: raise ValueError(f"a plan starts on a Monday; {self.start_date} does not.") + carried = tuple( + microcycle.week_no + for mesocycle in self.mesocycles + for microcycle in mesocycle.microcycles + ) + if carried != tuple(range(1, self.week_count + 1)): + raise ValueError( + f"week_count is {self.week_count} but the mesocycles carry {len(carried)} " + f"weeks: {carried}. They must tile 1-{self.week_count} exactly — start at " + f"week 1, no gap, no overlap, nothing past the end — or the plan reports one " + f"length and prescribes another." + ) diff --git a/server/domain/planner/generate.py b/server/domain/planner/generate.py index 595f1c0..4bfa288 100644 --- a/server/domain/planner/generate.py +++ b/server/domain/planner/generate.py @@ -75,7 +75,6 @@ beyond_one_plan_note, block_count_for, mesocycle_spans, - week_count_for, ) from server.domain.planner.progression import progressed from server.domain.planner.schedule import ( @@ -161,7 +160,10 @@ def other_seconds(self) -> int: def generate(planner_input: PlannerInput) -> PlanBlueprint: """Build the whole plan. Raises `CannotPlanError` only for an empty weekday mask.""" gap = planner_input.grade_gap - week_count = week_count_for(gap) + # ONE read of the block count. `week_count` is the spans' own extent so the two cannot + # disagree; `tests/test_planner_periodisation.py` sabotages that read to prove it. + spans = mesocycle_spans(block_count_for(gap)) + week_count = spans[-1].end_week weekdays = choose_weekdays(planner_input.available_weekdays, planner_input.sessions_per_week) mesocycles = tuple( @@ -174,7 +176,7 @@ def generate(planner_input: PlannerInput) -> PlanBlueprint: for week_no in range(span.start_week, span.end_week + 1) ), ) - for span in mesocycle_spans(block_count_for(gap)) + for span in spans ) return PlanBlueprint( diff --git a/tests/test_planner_periodisation.py b/tests/test_planner_periodisation.py index 80edce0..e290e8f 100644 --- a/tests/test_planner_periodisation.py +++ b/tests/test_planner_periodisation.py @@ -5,17 +5,27 @@ maths". Nothing else is here — no assertion that `MIN_BLOCKS` is 2 (a constant table, explicitly on the SKIP list) and no snapshot of a generated plan. The gap table is asserted as a **literal**, not by re-deriving the formula: a test that recomputes it agrees with any typo in -the implementation. The table is the decision. +the implementation. The table is the decision. The four arms that `generate()` a plan sabotage +it rather than snapshot it: a plan's own shape cannot be shown to bite on a hand-built fixture. """ +import sys +from dataclasses import replace from datetime import date, timedelta from itertools import pairwise +from types import ModuleType import pytest from server.domain.grades import GRADES, Discipline, system -from server.domain.planner.blueprint import MAX_WEEK_COUNT, MIN_WEEK_COUNT, NoteKind +from server.domain.planner.blueprint import ( + MAX_WEEK_COUNT, + MIN_WEEK_COUNT, + NoteKind, + PlanBlueprint, +) from server.domain.planner.contract import CannotPlanError, PlannerInput, RefusalReason +from server.domain.planner.generate import generate from server.domain.planner.periodisation import ( GAP_BEYOND_ONE_PLAN, MAX_BLOCKS, @@ -149,6 +159,95 @@ def test_spans_tile_the_plan_exactly_once_starting_at_week_one(blocks: int) -> N assert later.start_week == earlier.end_week + 1, "no gap and no overlap between blocks" +_SPORT_RUNGS = sorted( + {grade.ordinal for grade in GRADES if system(grade.system).discipline is Discipline.SPORT} +) + + +def _plan(rungs: int = 2) -> PlanBlueprint: + """A real generated plan, `rungs` up the sport ladder from its foot and gearless. Generated + rather than hand-built: a shape invariant cannot be shown to bite a fixture built to fit it.""" + return generate( + PlannerInput( + discipline=Discipline.SPORT, + current_ordinal=_SPORT_RUNGS[0], + target_ordinal=_SPORT_RUNGS[rungs], + sessions_per_week=3, + available_weekdays=0b010_0101, + strength_aspect_key=None, + weakness_aspect_key=None, + open_injury_keys=(), + equipment_keys=(), + start_date=_MONDAY, + ) + ) + + +def _weeks_carried(plan: PlanBlueprint) -> int: + """Microcycles actually in the tree, which is what the plan PRESCRIBES.""" + return sum(len(mesocycle.microcycles) for mesocycle in plan.mesocycles) + + +def test_a_plan_whose_mesocycles_do_not_tile_its_week_count_cannot_construct() -> None: + """Every value below is inside `ck_plan_week_count_in_range`, so that CHECK passes and only + the tiling check stands between a plan and reporting one length while prescribing another.""" + plan = _plan() + assert plan.week_count == _weeks_carried(plan) > 0 + for bad in (MIN_WEEK_COUNT, plan.week_count - 1, plan.week_count + 1, MAX_WEEK_COUNT): + with pytest.raises(ValueError, match="must tile"): + replace(plan, week_count=bad) + + +def test_the_week_count_range_check_still_fires_on_its_own_terms() -> None: + """The control: the tiling check must not have swallowed the CHECK it sits behind, or a + 0-week plan would fail for the wrong reason and 53 weeks would still insert.""" + plan = _plan() + for bad in (MIN_WEEK_COUNT - 1, MAX_WEEK_COUNT + 1): + with pytest.raises(ValueError, match="ck_plan_week_count_in_range"): + replace(plan, week_count=bad) + + +def test_a_mesocycle_must_carry_exactly_the_weeks_its_span_claims() -> None: + """The other half of "tile": without it every span could claim weeks 1-3 while the microcycles + still ran 1..N. The empty arm is the anti-vacuity one — a pairwise loop would pass on it.""" + first = _plan().mesocycles[0] + for broken in ( + {"end_week": first.end_week + 1}, + {"start_week": first.start_week + 1}, + {"microcycles": first.microcycles[:-1]}, + {"microcycles": ()}, + {"microcycles": tuple(reversed(first.microcycles))}, + ): + with pytest.raises(ValueError, match="must carry exactly those microcycles"): + replace(first, **broken) + + +def test_the_block_count_is_read_once_so_a_plan_cannot_misreport_its_length() -> None: + """Sabotage the ONE read and both halves move together. The patch has to honour its `gap` + and to reach the module through `sys.modules` — the arm below is why.""" + module = sys.modules[generate.__module__] + monkeypatched = pytest.MonkeyPatch() + monkeypatched.setattr(module, "block_count_for", lambda gap: block_count_for(gap) + 1) + try: + plan = _plan() + finally: + monkeypatched.undo() + expected = (block_count_for(plan.grade_gap) + 1) * WEEKS_PER_BLOCK + assert plan.week_count == expected, "the extra block did not reach the length it reports" + assert _weeks_carried(plan) == expected + assert plan.name.startswith(f"{expected}-week"), "the name is the length the user reads" + + +def test_the_package_reexport_shadows_the_generate_submodule() -> None: + """Why the arm above patches through `sys.modules`: `server/domain/planner/__init__.py` + re-exports the FUNCTION, so patching the shadowed name is a silent no-op.""" + from server.domain import planner + + assert planner.generate is generate, "the attribute is the function, not the submodule" + assert not isinstance(planner.generate, ModuleType) + assert isinstance(sys.modules[generate.__module__], ModuleType) + + @pytest.mark.parametrize("blocks", range(2, MAX_BLOCKS + 1)) def test_deloads_land_on_every_fourth_week_and_the_taper_is_the_last_one(blocks: int) -> None: """A deload is a mesocycle with its own prescriptions, and the taper is the one at the @@ -204,8 +303,8 @@ def test_microcycle_starts_are_seven_days_apart_and_all_mondays() -> None: @pytest.mark.parametrize("weekday", range(7)) def test_a_sessions_date_agrees_with_the_weekday_it_is_stored_against(weekday: int) -> None: - """`planned_session` stores `weekday` AND `scheduled_on` and nothing in the schema keeps - them in agreement, so the generator has to.""" + """`planned_session` stores `weekday` AND `scheduled_on`; no constraint there enforces the + agreement today and the Python check fails earlier, naming the day, so it stays here.""" scheduled = session_date(microcycle_start(_MONDAY, 5), weekday) assert scheduled.weekday() == weekday assert (scheduled - _MONDAY).days == 28 + weekday