Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
32 changes: 25 additions & 7 deletions server/domain/planner/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}."
)


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."
)
8 changes: 5 additions & 3 deletions server/domain/planner/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
107 changes: 103 additions & 4 deletions tests/test_planner_periodisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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