From 93bf5a217e47e0bf02e12a1a2e29d3ffbd84de07 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Sun, 20 Sep 2026 15:28:59 -0400 Subject: [PATCH 01/15] test(ride): pooled mid-ride move diverges from replay (red) --- tests/unit/test_ride_corrections.py | 80 +++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/unit/test_ride_corrections.py b/tests/unit/test_ride_corrections.py index 9ddeb68d..05e810fd 100644 --- a/tests/unit/test_ride_corrections.py +++ b/tests/unit/test_ride_corrections.py @@ -101,6 +101,32 @@ def _pooled_team_roster() -> Roster: return roster +def _two_team_pooled_roster() -> Roster: + """Build a MIXED rider_pooled roster of two two-rider teams. + + Team A's riders hold plates "1" and "2" -- so A's own derived + plate is "1" -- and team B's hold "3" and "4" (derived "3"): two + teams of a pooled ride, the pair E3.1.2's lock matrix keeps + team-to-team rider moves open between while the ride is RUNNING. + """ + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.RIDER_POOLED) + roster.create_team_entry( + display_name="Team A", + riders=[ + Rider(first_name="Ada", last_name="", plate="1"), + Rider(first_name="Bea", last_name="", plate="2"), + ], + ) + roster.create_team_entry( + display_name="Team B", + riders=[ + Rider(first_name="Cleo", last_name="", plate="3"), + Rider(first_name="Dana", last_name="", plate="4"), + ], + ) + return roster + + def _make_engine( *, roster: Roster | None = None, @@ -1048,3 +1074,57 @@ def test_apply_confirm_held_event_finds_the_target_beyond_the_first_crossing() - # The snapshot DNF behavior itself is pinned in tests/unit/test_ride.py # (test_snapshot_includes_dnf_entries_with_dnf_flag); mark_dnf's own # "keeps laps/cards and flips snapshot().dnf" cascade is covered above. + + +# ============== E3.1.2 pooled live move vs replay equivalence + + +def test_ride_move_replay_diverges_red() -> None: + """A RUNNING pooled move leaves the live engine unlike a replay. + + RED -- a de-risk spike for the pooled-live-move work, pinning the + seam that work must close. The engine keys ``_laps``/``_hand`` and + each ``Crossing.entry_id`` by ``entry.plate``, and a + ``rider_pooled`` team's plate is *derived* from its + lowest-numbered member and re-derived by ``Roster.move_rider``. + E3.1.2's lock matrix keeps team-to-team moves open while the ride + is RUNNING, so a mid-ride move re-plates both teams with no engine + notification: the live engine keeps the crossings, the held card + and the credited hand under team A's old plate "1", while a fresh + replay of the same event log against the FINAL roster resolves the + typed plate "2" to team A's new plate "2" and keys them there. + Live and replay must agree; today they do not. + """ + config = _config(hold_short_laps=True) + live_roster = _two_team_pooled_roster() + team_a, team_b = live_roster.entries + live, _ = _make_engine(roster=live_roster, config=config) + live.start(at=_dt(10, 0)) + live.record_crossing("2", at=_dt(10, 0, 30)) # 30 s < min_lap_s -> held + live.record_crossing("2", at=_dt(10, 30)) # 1770 s lap -> credited + + # E3.1.2: a RUNNING rider_pooled ride still allows the move, and + # moving A's anchor re-derives both derived plates -- A "1" -> "2" + # and B "3" -> "1", the very plate the crossings were keyed under. + anchor = next(rider for rider in team_a.riders if rider.plate == "1") + live_roster.move_rider(anchor, to_entry=team_b) + + # The Store.load_engine rebuild: a fresh same-seed shoe and clock, + # the FINAL roster, then the live engine's own event log. + replay_roster = _two_team_pooled_roster() + replay_a, replay_b = replay_roster.entries + replay_anchor = next(rider for rider in replay_a.riders if rider.plate == "1") + replay_roster.move_rider(replay_anchor, to_entry=replay_b) + replayed, _ = _make_engine(roster=replay_roster, config=config) + for event in live.events: + replayed.apply(event) + + assert ( + replayed.crossings, + replayed.held_crossings(), + tuple(replayed.credited_cards(entry.plate) for entry in replayed._roster.entries), + ) == ( + live.crossings, + live.held_crossings(), + tuple(live.credited_cards(entry.plate) for entry in live._roster.entries), + ) From bcf7542be0ffa91b3ddaa854779179d3a538f4f5 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Sun, 20 Sep 2026 17:29:58 -0400 Subject: [PATCH 02/15] feat(ride): key engine state by a stable entry key Add Entry.key (a persisted UUID surrogate) and key the engine's crossings, laps, hands and tie-breaks by it instead of the mutable derived plate. Replay resolves recorded entries by key, so a mid-ride pooled move no longer re-keys the roster's plates out from under the engine. The red replay-divergence test is now green. --- src/rivercrossing/ride.py | 315 ++++++++++++----- src/rivercrossing/roster.py | 31 ++ src/rivercrossing/store/__init__.py | 67 ++-- src/rivercrossing/store/schema.py | 8 +- src/rivercrossing/ui/app.py | 50 ++- .../ui/presenters/data_source.py | 53 ++- src/rivercrossing/ui/views/crossing_detail.py | 57 +++- tests/conftest.py | 31 +- tests/unit/presenters/test_console.py | 25 +- tests/unit/presenters/test_data_source.py | 10 +- tests/unit/presenters/test_results.py | 8 +- tests/unit/presenters/test_simulator.py | 61 ++-- tests/unit/test_ride.py | 319 ++++++++++++------ tests/unit/test_ride_corrections.py | 165 +++++---- tests/unit/test_ride_laps_index.py | 50 ++- tests/unit/test_roster.py | 107 ++++++ tests/unit/test_store.py | 133 ++++++-- tests/unit/ui/test_app_corrections.py | 18 +- tests/unit/ui/test_app_ride_menu.py | 34 +- tests/unit/ui/test_app_store_wiring.py | 4 +- tests/unit/ui/test_crossing_detail.py | 84 +++-- 21 files changed, 1188 insertions(+), 442 deletions(-) diff --git a/src/rivercrossing/ride.py b/src/rivercrossing/ride.py index 7a99d2f1..f7b097ab 100644 --- a/src/rivercrossing/ride.py +++ b/src/rivercrossing/ride.py @@ -713,7 +713,7 @@ def _require_reason(reason: str) -> None: raise ValueError(msg) -def _require_lap_after(entry_id: str, crossed_at: datetime, previous: datetime) -> None: +def _require_lap_after(entry_label: str, crossed_at: datetime, previous: datetime) -> None: """Refuse a lap instant at or before *previous* (Phase 3). A lap that takes no time is not a lap, so the two correction @@ -721,14 +721,16 @@ def _require_lap_after(entry_id: str, crossed_at: datetime, previous: datetime) and ``add_crossing_at`` -- refuse a zero or negative lap time before writing anything. *previous* is the instant the lap would be measured from: the entry's preceding lap, or ``actual_start`` when - there is none. + there is none. *entry_label* is the operator-facing name for the + entry (its plate -- never the internal stable key, whose uuid would + mean nothing at the console). Raises: ValueError: *crossed_at* is at or before *previous*. """ if crossed_at <= previous: msg = ( - f"lap time must be positive for entry {entry_id}: " + f"lap time must be positive for entry {entry_label}: " f"{crossed_at.isoformat()} is not after {previous.isoformat()}" ) raise ValueError(msg) @@ -753,8 +755,11 @@ class Event: class Crossing: """One recorded lap crossing (spec §2 ``crossing`` row, minus ids). - ``seq`` is 1-based per entry; ``entry_id`` is the entry's plate in - this store-less model (doc-silence, ``RideEngine``). Lap times are + ``seq`` is 1-based per entry; ``entry_id`` is the entry's stable + :attr:`~rivercrossing.roster.Entry.key` -- the surrogate no + re-plating can invalidate (E3.1.2's pooled-live-move seam), where + the entry's *plate* is derived from its riders and re-derived by a + mid-ride move. Lap times are never stored -- spec §6 derives them from the entry's previous crossing (or ``actual_start`` for lap 1), so a ``set_start_time`` retro-fix recomputes lap-1 automatically. @@ -884,9 +889,15 @@ class RideEngine: RUNNING; ``start()`` on a RUNNING ride continues it, unlocking entry with ``actual_start`` unchanged ("Continue ride?"). - **entry_id.** The in-memory roster assigns no numeric ids, and - one plate namespace spans the ride (R-20), so the engine uses - the entry's plate as ``entry_id`` until EPIC 5's Store assigns - real ids. + one plate namespace spans the ride (R-20), but a plate is + *mutable*: a ``rider_pooled`` team's plate is derived from its + lowest-numbered member and re-derived by ``Roster.move_rider``, + so it can never be what a recorded lap is filed under. The + engine keys ``_laps``/``_hand``/the held queue and each + ``Crossing.entry_id`` by :attr:`~rivercrossing.roster.Entry.key` + -- the per-entry surrogate the entry table persists -- while a + plate stays the resolution and display value (E3.1.2's + pooled-live-move seam). - **on_course.** Spec §6 names the counter without defining it; a loop timing's natural reading is an ACTIVE entry whose lap count is odd (out on the loop, not yet back). @@ -1303,7 +1314,7 @@ def on_course(self) -> int: return sum( 1 for entry in self._roster.entries - if entry.status.value == "active" and len(self._laps_for(entry.plate)) % 2 == 1 + if entry.status.value == "active" and len(self._laps_for(entry.key)) % 2 == 1 ) @property @@ -1596,14 +1607,30 @@ def record_crossing(self, plate: str, at: datetime | None = None) -> CrossingRes entry = self._roster.resolve_plate(plate) if entry is None: return CrossingResult(accepted=False, plate=plate, reason="unknown_plate") + return self._record_crossing_into(entry, plate, at) + + def _record_crossing_into( + self, entry: Entry, plate: str, at: datetime | None + ) -> CrossingResult: + """Record one lap for the already-resolved *entry*. + + The body of :meth:`record_crossing` past its plate resolution: + ``record_crossing`` supplies the entry it resolved a typed plate + to, and replay supplies the entry its payload's stable key names + (``apply``). *plate* stays the value the operator typed -- it is + the crossing's ``rider_plate`` attribution (J1) and the + ``reason``'s "who crossed for whom" -- while every engine + identity here is the entry's own stable + :attr:`~rivercrossing.roster.Entry.key` + (E3.1.2's pooled-live-move seam). + """ crossed_at = at if at is not None else self._clock() start = self._require_actual_start() - laps = self._laps_for(entry.plate) + entry_key = entry.key + laps = self._laps_for(entry_key) seq = len(laps) + 1 card = self._deal_card() - crossing = Crossing( - entry_id=entry.plate, seq=seq, crossed_at=crossed_at, rider_plate=plate - ) + crossing = Crossing(entry_id=entry_key, seq=seq, crossed_at=crossed_at, rider_plate=plate) self._insert_crossing(crossing) self._dealt[crossing] = card self._roster.mark_has_data(entry) @@ -1615,13 +1642,13 @@ def record_crossing(self, plate: str, at: datetime | None = None) -> CrossingRes self._held[crossing] = card else: # Always-deal policy: a short lap still credits. - self._credit(entry.plate, card, crossing.rider_plate) + self._credit(entry_key, card, crossing.rider_plate) self._append( Event( action="record_crossing", payload={ "plate": plate, - "entry_id": entry.plate, + "entry_id": entry_key, "lap": seq, "crossed_at": crossed_at.isoformat(), # The audit trail's own "who crossed for whom" @@ -1663,18 +1690,19 @@ def held_card_for(self, crossing: Crossing) -> Card | None: """ return self._held.get(crossing) - def credited_cards(self, plate: str) -> tuple[Card, ...]: - """Return the entry's credited (non-held) cards for *plate*. + def credited_cards(self, entry_id: str) -> tuple[Card, ...]: + """Return the entry's credited (non-held) cards for *entry_id*. The console's rider list reads this for its Cards column: the entry's live credited hand -- normal deals plus confirmed-held releases (R-16/R-34), oldest first. A held (unconfirmed), voided or undone card is not credited, so it never appears; - ``plate`` must be the entry's own plate, the key the credited - hand is kept under -- a pooled team's member plates do not - resolve (they return ``()``), so callers pass - ``entry.plate``. An entry that has credited nothing (and an - unknown plate) returns an empty tuple rather than raising. + ``entry_id`` must be the entry's own stable + :attr:`~rivercrossing.roster.Entry.key` + -- the stable identity the credited hand is kept under, never + the mutable display plate -- so callers pass ``entry.key``. An + entry that has credited nothing (and an unknown key) returns an + empty tuple rather than raising. Read-only: unlike :meth:`snapshot` this does not apply ``config.max_cards`` (R-13), which caps the scored hand, not @@ -1682,7 +1710,7 @@ def credited_cards(self, plate: str) -> tuple[Card, ...]: forfeiture, which is a scoring rule (the console's Cards column still shows every card dealt). """ - return tuple(card for card, _rider_plate in self._hand.get(plate, ())) + return tuple(card for card, _rider_plate in self._hand.get(entry_id, ())) def pending_misses(self) -> tuple[PendingMiss, ...]: """Return every pending miss, oldest first, read-only. @@ -1947,19 +1975,30 @@ def deal_manual(self, plate: str, reason: str) -> Event: """ if self._state not in (RideStatus.RUNNING, RideStatus.REOPENED): raise IllegalStateError(f"cannot deal manually from {self._state}") - entry = self._require_entry(plate) + return self._deal_manual_into(self._require_entry(plate), plate, reason) + + def _deal_manual_into(self, entry: Entry, plate: str, reason: str) -> Event: + """Deal one bonus card to the already-resolved *entry*. + + The body of :meth:`deal_manual` past its plate resolution: + ``deal_manual`` supplies the entry it resolved the typed plate + to, and replay supplies the entry the payload's stable key names + (``apply``). *plate* stays the operator's own typed value -- it + rides in the audit payload -- while the credited hand is keyed + by the entry's :attr:`~rivercrossing.roster.Entry.key`. + """ card = self._deal_card() # Entry-scoped, not rider-tagged: a bonus card is not a lap # crossing, so a pooled rider's DNF never forfeits it (the typed # plate rides in the audit payload only). - self._credit(entry.plate, card, None) + self._credit(entry.key, card, None) self._roster.mark_has_data(entry) return self._append( Event( action="deal_manual", payload={ "plate": plate, - "entry_id": entry.plate, + "entry_id": entry.key, "card": card.code(), "reason": reason, }, @@ -2011,7 +2050,12 @@ def edit_crossing( # noqa: PLR0913, PLR0917 laps = self._laps_for(crossing.entry_id) position = laps.index(crossing) previous = laps[position - 1].crossed_at if position > 0 else self._require_actual_start() - _require_lap_after(crossing.entry_id, crossed_at, previous) + # The refusal is operator copy, so it names the entry's plate -- + # never the internal stable key the crossing is filed under. + owning = self._roster.entry_by_key(crossing.entry_id) + _require_lap_after( + owning.plate if owning is not None else crossing.entry_id, crossed_at, previous + ) replacement = Crossing( entry_id=crossing.entry_id, seq=crossing.seq, @@ -2119,8 +2163,23 @@ def add_crossing_at(self, plate: str, crossed_at: datetime, reason: str) -> Even _require_reason(reason) if self._state not in (RideStatus.RUNNING, RideStatus.REOPENED): raise IllegalStateError(f"cannot add crossing from {self._state}") - entry = self._require_entry(plate) - laps = self._laps_for(entry.plate) + return self._add_crossing_at_into(self._require_entry(plate), plate, crossed_at, reason) + + def _add_crossing_at_into( # noqa: PLR0913, PLR0917 -- (entry, plate, at, reason) + self, entry: Entry, plate: str, crossed_at: datetime, reason: str + ) -> Event: + """Record a missed crossing on the already-resolved *entry*. + + The body of :meth:`add_crossing_at` past its plate resolution: + ``add_crossing_at`` supplies the entry it resolved the typed + plate to, and replay supplies the entry the payload's stable key + names (``apply``). The Phase 3 lap-time gate still names + *entry*'s plate in its refusal -- operator copy, never the + internal stable key -- while the laps it counts are keyed by the + entry's ``key``. + """ + entry_key = entry.key + laps = self._laps_for(entry_key) latest = laps[-1].crossed_at if laps else self._require_actual_start() _require_lap_after(entry.plate, crossed_at, latest) self._record_crossing_at(entry, crossed_at, rider_plate=plate) @@ -2129,7 +2188,7 @@ def add_crossing_at(self, plate: str, crossed_at: datetime, reason: str) -> Even action="add_crossing_at", payload={ "plate": plate, - "entry_id": entry.plate, + "entry_id": entry_key, "crossed_at": crossed_at.isoformat(), "reason": reason, }, @@ -2169,22 +2228,47 @@ def assign_plate_to_miss(self, miss_seq: int, new_plate: str, reason: str) -> Ev _require_reason(reason) if self._state not in (RideStatus.RUNNING, RideStatus.REOPENED): raise IllegalStateError(f"cannot assign plate to miss from {self._state}") + miss = self._pending_miss(miss_seq) + return self._assign_plate_to_miss_into( + self._require_entry(new_plate), miss, new_plate, reason + ) + + def _pending_miss(self, miss_seq: int) -> PendingMiss: + """Return the pending miss *miss_seq* names, or raise. + + Raises: + IllegalStateError: no pending miss matches *miss_seq*. + """ miss = next( (candidate for candidate in self._pending_misses if candidate.miss_seq == miss_seq), None, ) if miss is None: raise IllegalStateError(f"no pending miss with miss_seq {miss_seq}") - entry = self._require_entry(new_plate) + return miss + + def _assign_plate_to_miss_into( # noqa: PLR0913, PLR0917 -- (entry, miss, plate, reason) + self, entry: Entry, miss: PendingMiss, new_plate: str, reason: str + ) -> Event: + """Resolve the pending *miss* onto the already-resolved *entry*. + + The body of :meth:`assign_plate_to_miss` past its own + resolution: ``assign_plate_to_miss`` supplies the miss it found + and the entry it resolved *new_plate* to, and replay supplies + the same pair -- the entry from the payload's stable key + (``apply``). *new_plate* stays the operator's typed value (the + audit payload and the crossing's rider attribution) and the + recorded crossing is filed under the entry's ``key``. + """ self._pending_misses.remove(miss) self._record_crossing_at(entry, miss.crossed_at, rider_plate=new_plate) return self._append( Event( action="assign_plate_to_miss", payload={ - "miss_seq": miss_seq, + "miss_seq": miss.miss_seq, "new_plate": new_plate, - "entry_id": entry.plate, + "entry_id": entry.key, "crossed_at": miss.crossed_at.isoformat(), "reason": reason, }, @@ -2203,19 +2287,21 @@ def _record_crossing_at( never routes through R-34's hold queue) and marks the entry has_data. *rider_plate* is the plate the operator typed -- the entry's own when omitted -- so a rider_pooled team's crossing - still attributes the member who actually crossed (J1). + still attributes the member who actually crossed (J1); the + crossing itself, its laps and its credited hand are keyed by the + entry's ``key``. """ - seq = len(self._laps_for(entry.plate)) + 1 + seq = len(self._laps_for(entry.key)) + 1 card = self._deal_card() crossing = Crossing( - entry_id=entry.plate, + entry_id=entry.key, seq=seq, crossed_at=crossed_at, rider_plate=rider_plate if rider_plate is not None else entry.plate, ) self._insert_crossing(crossing) self._dealt[crossing] = card - self._credit(entry.plate, card, crossing.rider_plate) + self._credit(entry.key, card, crossing.rider_plate) self._roster.mark_has_data(entry) return crossing @@ -2259,21 +2345,35 @@ def reassign_crossing(self, seq: int, new_plate: str, reason: str) -> Event: _require_reason(reason) if self._state not in (RideStatus.RUNNING, RideStatus.REOPENED): raise IllegalStateError(f"cannot reassign crossing from {self._state}") + return self._reassign_crossing_into(self._require_entry(new_plate), seq, new_plate, reason) + + def _reassign_crossing_into( # noqa: PLR0913, PLR0917 -- (entry, seq, plate, reason) + self, entry: Entry, seq: int, new_plate: str, reason: str + ) -> Event: + """Reattribute crossing *seq* to the already-resolved *entry*. + + The body of :meth:`reassign_crossing` past its plate + resolution: ``reassign_crossing`` supplies the entry it + resolved *new_plate* to, and replay supplies the entry the + payload's stable key names (``apply``). Every identity here is + a ``key`` -- the source entry the crossing leaves, the + destination it joins -- while *new_plate* stays the operator's + typed value on the crossing's rider attribution. + """ if not 1 <= seq <= len(self._crossings): raise IllegalStateError(f"no crossing at ordinal {seq}") crossing = self._crossings[seq - 1] old_entry_id = crossing.entry_id old_seq = crossing.seq - entry = self._require_entry(new_plate) card = self._dealt[crossing] held = self._held.pop(crossing, None) self._discard_credited(old_entry_id, card) self._dealt.pop(crossing) self._remove_crossing(crossing) self._renumber_later(old_entry_id, old_seq) - new_seq = len(self._laps_for(entry.plate)) + 1 + new_seq = len(self._laps_for(entry.key)) + 1 replacement = Crossing( - entry_id=entry.plate, + entry_id=entry.key, seq=new_seq, crossed_at=crossing.crossed_at, rider_plate=new_plate, @@ -2283,14 +2383,14 @@ def reassign_crossing(self, seq: int, new_plate: str, reason: str) -> Event: if held is not None: self._held[replacement] = held elif not self._is_voided(card): - self._credit(entry.plate, card, replacement.rider_plate) + self._credit(entry.key, card, replacement.rider_plate) return self._append( Event( action="reassign", payload={ "seq": seq, "old_entry_id": old_entry_id, - "new_entry_id": entry.plate, + "new_entry_id": entry.key, "new_plate": new_plate, "reason": reason, }, @@ -2349,7 +2449,7 @@ def _record_dnf( # noqa: PLR0913 -- (entry, plate, rider, reason): the dnf even persisted ``dnf`` event: *rider* True records the member's own plate in the per-rider set, False writes the entry's status. Both scopes audit the same payload shape -- the plate, the - entry it belongs to, the scope, the reason and the marked + entry's stable key, the scope, the reason and the marked target's human display -- so replay rebuilds the identical state without re-deriving the scope from the roster. ``display`` is for the audit trail alone (the trail names the @@ -2374,7 +2474,7 @@ def _record_dnf( # noqa: PLR0913 -- (entry, plate, rider, reason): the dnf even Event( action="dnf", payload={ - "entry_id": entry.plate, + "entry_id": entry.key, "plate": plate, "rider": rider, "reason": reason, @@ -2420,19 +2520,31 @@ def void_card(self, entry_id: str, card: Card, reason: str) -> Event: _require_reason(reason) if self._state not in (RideStatus.RUNNING, RideStatus.REOPENED): raise IllegalStateError(f"cannot void card from {self._state}") - entry = self._require_entry(entry_id) + return self._void_card_into(self._require_entry(entry_id), card, reason) + + def _void_card_into(self, entry: Entry, card: Card, reason: str) -> Event: + """Void *card* from the already-resolved *entry*'s hand. + + The body of :meth:`void_card` past its plate resolution: + ``void_card`` supplies the entry it resolved *entry_id* to, and + replay supplies the entry the payload's stable key names + (``apply``). The hold guard and the credited-hand removal both + run against the entry's stable ``key``, so a re-plating between + the deal and the void cannot strand the card. + """ + entry_key = entry.key for crossing, held_card in self._held.items(): - if crossing.entry_id == entry.plate and held_card is card: + if crossing.entry_id == entry_key and held_card is card: msg = "card is held; confirm or void it through the review panel" raise IllegalStateError(msg) - removed = self._discard_credited(entry.plate, card) + removed = self._discard_credited(entry_key, card) if removed is None: raise IllegalStateError(f"no dealt card {card.code()} credited to {entry.plate}") self._mark_voided(removed) return self._append( Event( action="void_card", - payload={"entry_id": entry.plate, "card": card.code(), "reason": reason}, + payload={"entry_id": entry_key, "card": card.code(), "reason": reason}, ) ) @@ -2630,6 +2742,10 @@ def lap_times(self, entry_id: str) -> tuple[float, ...]: Lap 1 is ``crossed_at - actual_start``; every later lap is ``crossed_at - previous crossing``. Derived, never stored, so :meth:`set_start_time` recomputes lap 1 automatically. + *entry_id* is the entry's stable + :attr:`~rivercrossing.roster.Entry.key` -- the identity its + crossings are filed under -- and an unknown key has no laps, so + it returns the empty tuple. """ laps = self._laps_for(entry_id) if not laps: @@ -2671,14 +2787,14 @@ def snapshot(self) -> list[EntryResult]: for entry in self._roster.entries: dnf = self.entry_is_dnf(entry) kind = entry.type.value - laps = self._laps_for(entry.plate) - times = self.lap_times(entry.plate) - cards = self._scoring_cards(entry.plate) + laps = self._laps_for(entry.key) + times = self.lap_times(entry.key) + cards = self._scoring_cards(entry.key) if self._config.max_cards is not None: cards = cards[: self._config.max_cards] results.append( EntryResult( - entry_id=entry.plate, + entry_id=entry.key, plate=entry.plate, name=entry.display_name, kind=kind, @@ -2693,7 +2809,7 @@ def snapshot(self) -> list[EntryResult]: sex=entry.riders[0].sex if kind == "solo" else None, # R-14: this entry's own recorded draw card, or None # while the draw has not happened (yet). - tiebreak_card=self._tiebreak.get(entry.plate), + tiebreak_card=self._tiebreak.get(entry.key), ) ) return results @@ -2739,8 +2855,11 @@ def _record_tiebreak_draws(self) -> None: The stored cards land in :attr:`_tiebreak` (read by :meth:`snapshot`) and in a ``tiebreak_draw`` event whose ``draws`` rows are JSON-ready card codes; ``summary`` names each - entry's card, because the audit viewer's Entry cell reads a - single ``entry_id``/``plate`` and cannot project a row list. A + entry's card by its plate, because the audit viewer's Entry cell + reads a single ``entry_id``/``plate`` and cannot project a row + list -- and the row's ``entry_id`` is the entry's stable key + (a uuid would mean nothing to the viewer, so the human summary + is what it shows). A ride with no equal-hand group appends nothing. One fresh deck holds 52 naturals, so a field with more tied entries than that takes the cards the deck holds and the rest stay undrawn -- @@ -2754,16 +2873,15 @@ def _record_tiebreak_draws(self) -> None: cards = high_card_draw(seed, sum(map(len, groups))) tied_entries = [result for group in groups for result in group] draws: list[dict[str, str]] = [] + labels: list[str] = [] for result, card in zip(tied_entries, cards, strict=False): - self._tiebreak[result.plate] = card - draws.append({"entry_id": result.plate, "card": card.code()}) + self._tiebreak[result.entry_id] = card + draws.append({"entry_id": result.entry_id, "card": card.code()}) + labels.append(f"{result.plate} · {card.code()}") self._append( Event( action="tiebreak_draw", - payload={ - "draws": draws, - "summary": ", ".join(f"{row['entry_id']} · {row['card']}" for row in draws), - }, + payload={"draws": draws, "summary": ", ".join(labels)}, ) ) @@ -2818,6 +2936,25 @@ def _require_entry(self, plate: str) -> Entry: raise UnknownPlateError(f"unknown plate: {plate}") return entry + def _require_entry_by_key(self, key: str) -> Entry: + """Return the entry *key* names, or raise. + + The replay seam's own resolution: a persisted payload carries + the entry's stable :attr:`~rivercrossing.roster.Entry.key` + (E3.1.2's pooled-live-move seam), so ``apply`` rebuilds the + recorded entry by the identity that was recorded -- never by + re-resolving the payload's plate against the roster it is + replaying onto, which a mid-ride re-plating would take to a + different team. + + Raises: + UnknownPlateError: *key* names no entry in this roster. + """ + entry = self._roster.entry_by_key(key) + if entry is None: + raise UnknownPlateError(f"unknown entry key: {key}") + return entry + def entry_is_dnf(self, entry: Entry) -> bool: """Return whether *entry* is out of the results entirely. @@ -3095,6 +3232,14 @@ def apply(self, event: Event) -> None: # noqa: C901, PLR0912 roster column to come back from. The shoe's open/closed state is part of the reproduced state: ``finish`` closes the fresh shoe and a replayed ``reopen`` opens it again, exactly as live. + Every replayed subject that names an entry is located by the + payload's ``entry_id`` -- the entry's stable + :attr:`~rivercrossing.roster.Entry.key` (E3.1.2's + pooled-live-move seam) -- through :meth:`_require_entry_by_key`, + never by re-resolving a plate against the roster being rebuilt: + a ride saved after a mid-ride rider move has re-derived both + teams' plates, so the plate the operator typed on the night is + exactly what no longer names the team the crossing belongs to. Args: event: The event to re-apply, exactly as persisted. @@ -3105,6 +3250,8 @@ def apply(self, event: Event) -> None: # noqa: C901, PLR0912 ValueError: a replayed correction's reason is empty, or a replayed ``edit_crossing``/``add_crossing_at`` violates Phase 3's zero/negative-lap gate. + UnknownPlateError: a replayed payload's ``entry_id`` names + no entry in this roster (an inconsistent event stream). RideEngineError: ``confirm_held``/``void_held``/ ``return_to_held`` name a crossing this engine never recorded (an inconsistent event stream). @@ -3117,7 +3264,11 @@ def apply(self, event: Event) -> None: # noqa: C901, PLR0912 elif action == "set_start_time": self.set_start_time(_payload_dt(event, "actual_start")) elif action == "record_crossing": - self.record_crossing(str(event.payload["plate"]), at=_payload_dt(event, "crossed_at")) + self._record_crossing_into( + self._require_entry_by_key(str(event.payload["entry_id"])), + str(event.payload["plate"]), + _payload_dt(event, "crossed_at"), + ) elif action == "confirm_held": self.confirm_held(self._crossing_from(event)) elif action == "void_held": @@ -3127,7 +3278,11 @@ def apply(self, event: Event) -> None: # noqa: C901, PLR0912 elif action == "undo": self.undo_last() elif action == "deal_manual": - self.deal_manual(str(event.payload["plate"]), reason=str(event.payload["reason"])) + self._deal_manual_into( + self._require_entry_by_key(str(event.payload["entry_id"])), + str(event.payload["plate"]), + str(event.payload["reason"]), + ) elif action == "edit_crossing": self.edit_crossing( str(event.payload["entry_id"]), @@ -3142,42 +3297,46 @@ def apply(self, event: Event) -> None: # noqa: C901, PLR0912 reason=str(event.payload["reason"]), ) elif action == "add_crossing_at": - self.add_crossing_at( + self._add_crossing_at_into( + self._require_entry_by_key(str(event.payload["entry_id"])), str(event.payload["plate"]), _payload_dt(event, "crossed_at"), - reason=str(event.payload["reason"]), + str(event.payload["reason"]), ) elif action == "record_miss": self.record_miss(_payload_dt(event, "crossed_at"), reason=str(event.payload["reason"])) elif action == "assign_plate_to_miss": - self.assign_plate_to_miss( - _payload_int(event, "miss_seq"), + self._assign_plate_to_miss_into( + self._require_entry_by_key(str(event.payload["entry_id"])), + self._pending_miss(_payload_int(event, "miss_seq")), str(event.payload["new_plate"]), - reason=str(event.payload["reason"]), + str(event.payload["reason"]), ) elif action == "reassign": - self.reassign_crossing( + self._reassign_crossing_into( + self._require_entry_by_key(str(event.payload["new_entry_id"])), _payload_int(event, "seq"), str(event.payload["new_plate"]), - reason=str(event.payload["reason"]), + str(event.payload["reason"]), ) elif action == "dnf": - # The payload's own scope replays -- the entry is located - # by plate like every other replayed subject, but whether - # the mark was a rider's or the entry's is never re-derived - # from the roster: a rider DNF has no roster column to come - # back from, and a member may have changed teams since. The + # The payload's own scope replays -- the entry is located by + # its stable key like every other replayed subject, but + # whether the mark was a rider's or the entry's is never + # re-derived from the roster: a rider DNF has no roster + # column to come back from, and a member may have changed + # teams since. The # payload's audit-only ``display`` is never read either: the # replayed event re-derives its own. self._record_dnf( - self._require_entry(str(event.payload["entry_id"])), + self._require_entry_by_key(str(event.payload["entry_id"])), plate=str(event.payload["plate"]), rider=bool(event.payload["rider"]), reason=str(event.payload["reason"]), ) elif action == "void_card": - self.void_card( - str(event.payload["entry_id"]), + self._void_card_into( + self._require_entry_by_key(str(event.payload["entry_id"])), Card.parse(str(event.payload["card"])), reason=str(event.payload["reason"]), ) diff --git a/src/rivercrossing/roster.py b/src/rivercrossing/roster.py index 2c41d902..3aa58127 100644 --- a/src/rivercrossing/roster.py +++ b/src/rivercrossing/roster.py @@ -61,6 +61,7 @@ """ import random +import uuid from dataclasses import dataclass, field from enum import StrEnum from typing import TYPE_CHECKING, cast @@ -244,6 +245,18 @@ class Entry: ``logo_card`` is Phase 4's team logo: a natural card code, set only through :meth:`Roster.set_team_logo_card`. A team carries no logo image (retired in Phase 3 -- a team's logo is its card). + + ``key`` is the entry's **stable identity** (E3.1.2's + pooled-live-move seam): a surrogate no operator ever sees, minted + once per entry and never edited, where ``plate`` is mutable -- a + pooled team re-derives its own from its lowest-numbered rider + (S1) and a mid-ride move re-derives both teams' plates. The ride + engine files its crossings and credited hands under ``key`` + (``ride.py``), so a re-plating leaves the recorded laps exactly + where they were; ``plate`` stays the display and resolution + value. The field sits last, with its ``default_factory``, so the + declared field order (and every positional construction) is + unchanged, and its uuid4 draw needs no caller. """ plate: str @@ -254,6 +267,7 @@ class Entry: notes: str = "" has_data: bool = False logo_card: str | None = None + key: str = field(default_factory=lambda: uuid.uuid4().hex) @property def team_size(self) -> int: @@ -701,6 +715,23 @@ def resolve_plate(self, plate: str) -> Entry | None: return entry return None + def entry_by_key(self, key: str) -> Entry | None: + """Return the entry *key* names, or None if unknown. + + The stable-identity lookup, ``resolve_plate``'s counterpart: + the ride engine files crossings and credited hands under + :attr:`Entry.key` (E3.1.2's pooled-live-move seam), so + replaying an event resolves its entry by the key the payload + carries -- a lookup no re-plating can invalidate, where the + plate the operator typed at the time is exactly what a move + re-derives. An unknown key is the None case, never an error: + replay's own ``_require_entry_by_key`` is what refuses. + """ + for entry in self._entries: + if entry.key == key: + return entry + return None + def validate_for_start(self) -> list[StartViolation]: """Return every reason this roster is not ready to start. diff --git a/src/rivercrossing/store/__init__.py b/src/rivercrossing/store/__init__.py index 9485506f..e0339d1c 100644 --- a/src/rivercrossing/store/__init__.py +++ b/src/rivercrossing/store/__init__.py @@ -191,6 +191,7 @@ import sqlite3 import tempfile import time +import uuid from dataclasses import dataclass from datetime import UTC, date, datetime from enum import Enum @@ -505,7 +506,9 @@ def _audit_when(epoch: int) -> str: return datetime.fromtimestamp(epoch).strftime("%H:%M:%S") # noqa: DTZ006 -def _audit_entry(action: str, payload: Mapping[str, object]) -> str: +def _audit_entry( + action: str, payload: Mapping[str, object], plates_by_key: Mapping[str, str] +) -> str: """Return one stored audit row's Entry cell (E7.3.1). A ``dnf`` row renders the display the engine recorded with the @@ -517,7 +520,10 @@ def _audit_entry(action: str, payload: Mapping[str, object]) -> str: engine wrote it naming every entry and the card it drew), because that payload's ``draws`` row list is not one entry id and the cell would otherwise stay blank. Every other action projects the - payload's ``entry_id``, falling back to ``plate``, then (for a + payload's ``entry_id`` -- the entry's stable key (E3.1.2's + pooled-live-move seam), rendered as the plate *plates_by_key* maps + it to, never the uuid, and as itself when the map does not know it + -- falling back to ``plate``, then (for a roster plate change, whose payload carries neither) ``old_plate``, ``new_plate`` and ``display_name``, then ``""``. """ @@ -529,9 +535,11 @@ def _audit_entry(action: str, payload: Mapping[str, object]) -> str: summary = payload.get("summary") if summary: return str(summary) + entry_id = payload.get("entry_id") + if entry_id: + return plates_by_key.get(str(entry_id), str(entry_id)) return str( - payload.get("entry_id") - or payload.get("plate") + payload.get("plate") or payload.get("old_plate") or payload.get("new_plate") or payload.get("display_name") @@ -898,7 +906,10 @@ def _load_roster(self, ride_id: int) -> Roster: The one reading both :meth:`roster_for` and :meth:`load_engine` use: the ride row's shape columns build the shell, then every entry (creation order) and its riders (``sort_order``, id tie- - break for a stable order) reconstruct the field. ``has_data`` + break for a stable order) reconstruct the field. Each entry's + ``key`` comes back from its own row, so a replayed event's + stable-identity lookup still resolves (E3.1.2's + pooled-live-move seam). ``has_data`` is derived, never stored (module docstring's E5.4.1 resolution): an entry that owns a crossing or card row has recorded data. @@ -922,7 +933,7 @@ def _load_roster(self, ride_id: int) -> Roster: ) entries: list[Entry] = [] for entry_row in self._conn.execute( - "SELECT id, plate, display_name, type, status, notes, logo_card" + "SELECT id, plate, key, display_name, type, status, notes, logo_card" " FROM entry WHERE ride_id = ? ORDER BY id", (ride_id,), ).fetchall(): @@ -955,6 +966,7 @@ def _load_roster(self, ride_id: int) -> Roster: status=EntryStatus(entry_row["status"]), notes=entry_row["notes"] or "", logo_card=entry_row["logo_card"], + key=entry_row["key"], ) entry.has_data = has_data entries.append(entry) @@ -965,15 +977,18 @@ def save_roster(self, ride_id: int, roster: Roster) -> None: """Persist one ride's roster, replacing any previously saved. E5.4.1's roster persistence into spec §2's entry/rider tables: - every entry (plate, display_name, type, team_size, status, + every entry (plate, key, display_name, type, team_size, status, notes, logo_card -- NULL only when the entry carries no logo) and every rider (first_name, last_name, plate, sex, sort_order) is written in one transaction, after removing any previously saved rows -- a save is a snapshot of the live roster, never an append (the replace semantics the rider - editor's DRAFT edits need). ``has_data`` is deliberately not - stored (derived at load time from recorded rows), and - ``dnf_at``/``emergency_contact``/``waiver_signed``/ + editor's DRAFT edits need). ``key`` is written verbatim: it is + the engine's own identity for the entry (E3.1.2's + pooled-live-move seam), and re-minting one on save would orphan + every crossing already filed under it. ``has_data`` is + deliberately not stored (derived at load time from recorded + rows), and ``dnf_at``/``emergency_contact``/``waiver_signed``/ ``ccn_reg_id`` stay NULL -- the in-memory Roster model carries no such fields (module docstring's E5.4.1 resolutions). @@ -996,12 +1011,13 @@ def save_roster(self, ride_id: int, roster: Roster) -> None: for entry in roster.entries: cursor = self._conn.execute( "INSERT INTO entry" - " (ride_id, plate, display_name, type, team_size, status, dnf_at, notes," + " (ride_id, plate, key, display_name, type, team_size, status, dnf_at, notes," " logo_card)" - " VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)", + " VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)", ( ride_id, entry.plate, + entry.key, entry.display_name, entry.type.value, len(entry.riders), @@ -1451,10 +1467,11 @@ def audit_rows(self, ride_id: int) -> list[AuditRow]: ride recorded, projected to the display :class:`~rivercrossing.ui.presenters.data_source.AuditRow` shape the viewer's list draws -- ``entry`` = the display a - ``dnf`` row carried with its mark, else the payload's - ``entry_id``, falling back to ``plate``, then (for a roster - plate change, whose payload carries neither) ``old_plate``, - ``new_plate`` and ``display_name``, then ``""`` + ``dnf`` row carried with its mark, else the plate the payload's + stable entry key maps to (the entry's uuid is never shown), + falling back to ``entry_id`` itself, then ``plate``, then (for a + roster plate change, whose payload carries neither) + ``old_plate``, ``new_plate`` and ``display_name``, then ``""`` (:func:`_audit_entry`), ``reason`` = the payload's ``reason``, and ``when`` rendered from the stored ``at`` epoch as local ``HH:MM:SS`` (spec §13: stored UTC, displayed local). Newest @@ -1475,6 +1492,7 @@ def audit_rows(self, ride_id: int) -> list[AuditRow]: row = self._conn.execute("SELECT id FROM ride WHERE id = ?", (ride_id,)).fetchone() if row is None: raise RideNotFoundError(f"no ride with id {ride_id}") + plates_by_key = {entry.key: entry.plate for entry in self._load_roster(ride_id).entries} stored = self._conn.execute( "SELECT at, action, payload_json FROM audit WHERE ride_id = ? ORDER BY id DESC", (ride_id,), @@ -1486,7 +1504,7 @@ def audit_rows(self, ride_id: int) -> list[AuditRow]: AuditRow( when=_audit_when(audit_row["at"]), action=audit_row["action"], - entry=_audit_entry(audit_row["action"], payload), + entry=_audit_entry(audit_row["action"], payload, plates_by_key), reason=str(payload.get("reason") or ""), ) ) @@ -1564,8 +1582,12 @@ def _copy_roster_rows(self, new_id: int, ride_id: int) -> None: Copies in creation order -- entries by insert id, each entry's riders by ``sort_order`` then id -- so the copy's roster reads exactly as the source's did. Every rider lands on the entry row - this method just inserted, never on the source's. Runs inside - the caller's transaction: a failure rolls the whole copy back. + this method just inserted, never on the source's. Each copied + entry gets a **fresh** ``key``: the copy is a new ride whose + crossings are its own, so it must never share the source's + entry identities (the fresh-``rng_seed`` rule one level down). + Runs inside the caller's transaction: a failure rolls the whole + copy back. Args: new_id: The ride the rows are copied onto. @@ -1578,12 +1600,15 @@ def _copy_roster_rows(self, new_id: int, ride_id: int) -> None: ).fetchall(): entry_cursor = self._conn.execute( "INSERT INTO entry" - " (ride_id, plate, display_name, type, team_size, status, dnf_at, notes," + " (ride_id, plate, key, display_name, type, team_size, status, dnf_at, notes," " logo_card)" - " VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)", + " VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)", ( new_id, source_entry["plate"], + # The copy's entries are new identities: a fresh key + # per entry, never the source's (the rng_seed rule). + uuid.uuid4().hex, source_entry["display_name"], source_entry["type"], source_entry["team_size"], diff --git a/src/rivercrossing/store/schema.py b/src/rivercrossing/store/schema.py index b411e1d5..56414509 100644 --- a/src/rivercrossing/store/schema.py +++ b/src/rivercrossing/store/schema.py @@ -20,8 +20,13 @@ :func:`ensure_schema` creates this schema on an empty file and refuses any other stamped version. -That flatten folded four changes back into the CREATE: +That flatten folded five changes back into the CREATE: +- ``entry.key`` -- the entry's stable identity (E3.1.2's + pooled-live-move seam), a surrogate the ride engine files crossings + and credited hands under where a derived plate is mutable -- is a + real column, NOT NULL, right after the ``plate`` it is the stable + counterpart of. - ``ride.hold_short_laps`` -- the short-lap card policy -- is a real column (NOT NULL, DEFAULT 1 = hold short-lap cards for review), in the same position ``_INSERT_RIDE_SQL`` lists it. @@ -159,6 +164,7 @@ class SchemaVersionMismatchError(StoreError): id INTEGER PRIMARY KEY, ride_id INTEGER NOT NULL REFERENCES ride(id), plate TEXT NOT NULL, + key TEXT NOT NULL, display_name TEXT NOT NULL, type TEXT NOT NULL CHECK (type IN ('solo', 'team')), team_size INTEGER NOT NULL, diff --git a/src/rivercrossing/ui/app.py b/src/rivercrossing/ui/app.py index a57807e7..c845ec8f 100644 --- a/src/rivercrossing/ui/app.py +++ b/src/rivercrossing/ui/app.py @@ -3453,6 +3453,22 @@ def _wire_rider_open_seam(context: _RouteContext) -> None: _HELD_CANCEL_LABEL = "Cancel" +def _plate_label(crossing: Crossing, roster: Roster) -> str: + """Return the plate *crossing* renders as, never its stable key. + + ``Crossing.entry_id`` is the entry's own + :attr:`~rivercrossing.roster.Entry.key` (E3.1.2's pooled-live-move + seam), so every display or status line renders the plate the + operator typed (J1), or the entry's own when the crossing carries + none. An entry that has left the roster leaves no plate to show, so + the cell reads blank rather than the uuid. + """ + if crossing.rider_plate: + return crossing.rider_plate + entry = roster.entry_by_key(crossing.entry_id) + return entry.plate if entry is not None else "" + + def _held_card_facts(engine: RideEngine, crossing: Crossing, roster: Roster) -> str: """Return the summary line the held-card review confirms carry. @@ -3460,16 +3476,20 @@ def _held_card_facts(engine: RideEngine, crossing: Crossing, roster: Roster) -> question carries the entry, the plate, the lap, that lap's time and the card awaiting disposition. A crossing whose lap is past the entry's recorded times (a stale row) renders the zero duration; a - crossing the hold queue no longer carries renders ``no card``. - """ - entry = roster.resolve_plate(crossing.entry_id) - name = entry.display_name if entry is not None else crossing.entry_id + crossing the hold queue no longer carries renders ``no card``. The + entry is resolved by the crossing's stable key, and the line names + its display name and plate (``_plate_label``) -- never the uuid; an + entry that has left the roster falls back to the plate the operator + typed. + """ + entry = roster.entry_by_key(crossing.entry_id) + name = entry.display_name if entry is not None else _plate_label(crossing, roster) times = engine.lap_times(crossing.entry_id) lap_time = times[crossing.seq - 1] if crossing.seq <= len(times) else 0.0 card = engine.held_card_for(crossing) card_text = card.code() if card is not None else "no card" return ( - f"{name} · plate {crossing.rider_plate or crossing.entry_id} · " + f"{name} · plate {_plate_label(crossing, roster)} · " f"Lap {crossing.seq} · {format_duration(lap_time)} · {card_text}" ) @@ -3489,7 +3509,7 @@ def _review_held_crossing(context: _RouteContext, engine: RideEngine, crossing: from rivercrossing.ui import std_dialogs # noqa: PLC0415 -- deferred, see module docstring facts = _held_card_facts(engine, crossing, context.roster) - plate = crossing.rider_plate or crossing.entry_id + plate = _plate_label(crossing, context.roster) choice = std_dialogs.show_three_choice( context.frame, _HELD_REVIEW_TITLE, @@ -3524,7 +3544,7 @@ def _return_to_held_confirm( from rivercrossing.ui import std_dialogs # noqa: PLC0415 -- deferred, see module docstring facts = _held_card_facts(engine, crossing, context.roster) - plate = crossing.rider_plate or crossing.entry_id + plate = _plate_label(crossing, context.roster) returned = std_dialogs.show_prompt( context.frame, "Return Card to Held", @@ -3540,6 +3560,7 @@ def _return_to_held_confirm( def _flagged_crossing_for( # noqa: PLR0913, PLR0917 -- the seam's own (plate, held) pair source: DataSource, engine: RideEngine, + roster: Roster, plate: str, held: bool, # noqa: FBT001 -- the seam's flag travels positionally ) -> Crossing | None: @@ -3552,9 +3573,10 @@ def _flagged_crossing_for( # noqa: PLR0913, PLR0917 -- the seam's own (plate, h pair's own half of the engine -- the hold queue for a held row, every recorded crossing for a credited one (a duplicate is credited unless its own short lap held it, exactly like any other lap). - Both halves use the feed's own identity, ``crossing.rider_plate or - crossing.entry_id``. ``None`` is a stale row: nothing in the feed - matches the activated pair. + Both halves use the feed's own display plate, + :func:`_plate_label` -- the plate the operator typed, or the + entry's own -- never the crossing's stable key. ``None`` is a stale + row: nothing in the feed matches the activated pair. """ row = next( ( @@ -3578,7 +3600,7 @@ def _flagged_crossing_for( # noqa: PLR0913, PLR0917 -- the seam's own (plate, h ( crossing for crossing in candidates - if (crossing.rider_plate or crossing.entry_id) == plate and crossing.seq == row.lap + if _plate_label(crossing, roster) == plate and crossing.seq == row.lap ), None, ) @@ -3608,7 +3630,9 @@ def _open_flagged_review_for( # noqa: PLR0913, PLR0917 if presenter is None: return engine = presenter.engine - crossing = _flagged_crossing_for(presenter.source, engine, plate, card_status == "held") + crossing = _flagged_crossing_for( + presenter.source, engine, context.roster, plate, card_status == "held" + ) if crossing is None: context.frame.SetStatusText(f"Review — no crossing found for plate {plate}") return @@ -3847,7 +3871,7 @@ def _edit_plate_crossing_for(context: _RouteContext, row: int) -> None: new_plate = run_plate_dialog( context.resource, opener=context.frame, - plate=target.rider_plate or target.entry_id, + plate=_plate_label(target, context.roster), ) if new_plate is None: return diff --git a/src/rivercrossing/ui/presenters/data_source.py b/src/rivercrossing/ui/presenters/data_source.py index 70be5a15..4d5c0277 100644 --- a/src/rivercrossing/ui/presenters/data_source.py +++ b/src/rivercrossing/ui/presenters/data_source.py @@ -38,7 +38,7 @@ from rivercrossing.ui.rider_columns import SOLO_TEAM_TEXT if TYPE_CHECKING: - from collections.abc import Callable, Sequence + from collections.abc import Callable, Mapping, Sequence from rivercrossing.cards import Card from rivercrossing.ride import Crossing, Event, PendingMiss, RideEngine @@ -421,7 +421,7 @@ def _event_time(event: Event) -> str: return "" -def _audit_entry(event: Event) -> str: +def _audit_entry(event: Event, plates_by_key: Mapping[str, str]) -> str: """Return the Entry cell one audit row shows for *event*. A ``dnf`` event carries the marked target's human display -- the @@ -437,6 +437,11 @@ def _audit_entry(event: Event) -> str: that payload's ``draws`` row list is not one entry id and the cell would otherwise stay blank. Every other action reads those two keys and nothing else. + + ``entry_id`` is the entry's stable key (E3.1.2's pooled-live-move + seam), so the cell renders the plate *plates_by_key* maps it to -- + never the uuid. An id the map does not know (a legacy or + hand-written payload) renders as itself. """ if event.action == "dnf": carried = event.payload.get("display") @@ -446,7 +451,10 @@ def _audit_entry(event: Event) -> str: summary = event.payload.get("summary") if summary: return str(summary) - return str(event.payload.get("entry_id") or event.payload.get("plate") or "") + entry_id = event.payload.get("entry_id") + if entry_id: + return plates_by_key.get(str(entry_id), str(entry_id)) + return str(event.payload.get("plate") or "") # E7.3.2: the audited correction actions whose arrival after an export @@ -680,7 +688,11 @@ class _FeedContext: entry lookup maps and the four derived crossing sets) and hands it to :func:`_crossing_feed_row` for every crossing, so the per-row builder takes two arguments rather than eleven. Nothing outside - this module sees it. + this module sees it. ``dnf_entries``, ``times_by_entry`` and + ``totals_by_entry`` are keyed by the entry's stable + :attr:`~rivercrossing.roster.Entry.key`, the identity + ``Crossing.entry_id`` carries; ``dnf_riders`` stays plate-keyed, + because a rider's own number is what marks them (S1). """ engine: RideEngine @@ -740,18 +752,26 @@ def _crossing_feed_row(context: _FeedContext, crossing: Crossing) -> tuple[datet riders' laps, so the Needs Review tab words its row differently (``feed_model.review_issue``). It derives from *flagged* rather than recomputing the short-lap test, so the two bits cannot - disagree; the plate must still resolve to an entry for its type to + disagree; the crossing's entry must still resolve for its type to be readable, and a miss never reaches this builder at all. + + The crossing's ``entry_id`` is the entry's stable key, so the entry + itself comes back through ``Roster.entry_by_key`` -- the same + lookup the engine files the crossing under -- and every rendered + cell carries the entry's own plate or display name, never the key + (E3.1.2's pooled-live-move seam). An entry that has left the + roster leaves the typed plate as the row's only handle, so Plate + and Name both fall back to it. """ engine = context.engine - feed_entry = context.roster.resolve_plate(crossing.entry_id) + feed_entry = context.roster.entry_by_key(crossing.entry_id) times = context.times_by_entry.get(crossing.entry_id, ()) totals = context.totals_by_entry.get(crossing.entry_id, ()) lap_time_s = times[crossing.seq - 1] if crossing.seq <= len(times) else 0.0 total_s = totals[crossing.seq - 1] if crossing.seq <= len(totals) else 0.0 held_card = engine.held_card_for(crossing) rider_name = _rider_name_for(feed_entry, crossing.rider_plate) - entry_name = feed_entry.display_name if feed_entry is not None else crossing.entry_id + entry_name = feed_entry.display_name if feed_entry is not None else crossing.rider_plate or "" team_name = _team_name_for(feed_entry) elapsed_s = _elapsed_seconds(crossing.crossed_at, context.start) rider_plate = crossing.rider_plate @@ -760,7 +780,7 @@ def _crossing_feed_row(context: _FeedContext, crossing: Crossing) -> tuple[datet crossing.crossed_at, FeedRow( time=format_duration(elapsed_s), - plate=rider_plate or crossing.entry_id, + plate=rider_plate or (feed_entry.plate if feed_entry is not None else ""), entry=rider_name or entry_name, team=team_name, lap=crossing.seq, @@ -856,19 +876,19 @@ def feed_rows(self) -> list[FeedRow]: """ engine = self._engine dnf_entries = frozenset( - entry.plate for entry in self._roster.entries if engine.entry_is_dnf(entry) + entry.key for entry in self._roster.entries if engine.entry_is_dnf(entry) ) times_by_entry: dict[str, tuple[float, ...]] = {} totals_by_entry: dict[str, list[float]] = {} for entry in self._roster.entries: - times = engine.lap_times(entry.plate) - times_by_entry[entry.plate] = times + times = engine.lap_times(entry.key) + times_by_entry[entry.key] = times running: list[float] = [] total = 0.0 for lap_time in times: total += lap_time running.append(total) - totals_by_entry[entry.plate] = running + totals_by_entry[entry.key] = running context = _FeedContext( engine=engine, roster=self._roster, @@ -953,7 +973,7 @@ def riders(self) -> list[RiderRow]: engine = self._engine rows: list[RiderRow] = [] for entry in self._roster.entries: - cards = tuple(card.code() for card in engine.credited_cards(entry.plate)) + cards = tuple(card.code() for card in engine.credited_cards(entry.key)) entry_dnf = engine.entry_is_dnf(entry) if entry.type is EntryType.TEAM: rows.extend( @@ -1045,13 +1065,16 @@ def audit_rows(self) -> list[AuditRow]: reads exactly what the store-backed one projects from the persisted payloads (scope 6d). A ``dnf`` row's Entry cell is the target's carried display (:func:`_audit_entry`), never the - bare entry id. + bare entry id; every other row's ``entry_id`` is the entry's + stable key, so the cell renders the plate that key names and + never the uuid. """ + plates_by_key = {entry.key: entry.plate for entry in self._roster.entries} return [ AuditRow( when=_event_time(event), action=event.action, - entry=_audit_entry(event), + entry=_audit_entry(event, plates_by_key), reason=str(event.payload.get("reason") or ""), ) for event in reversed(self._engine.events) diff --git a/src/rivercrossing/ui/views/crossing_detail.py b/src/rivercrossing/ui/views/crossing_detail.py index 1f637e79..7f735ec5 100644 --- a/src/rivercrossing/ui/views/crossing_detail.py +++ b/src/rivercrossing/ui/views/crossing_detail.py @@ -233,7 +233,7 @@ def _rider_name(entry: Entry | None, rider_plate: str | None) -> str | None: return None -def _team_name(entry: Entry | None, entry_id: str) -> str: +def _team_name(entry: Entry | None, entry_plate: str) -> str: """Return the Team field's text for *entry*. A solo rider has no team to name, so the field reads the word @@ -241,16 +241,31 @@ def _team_name(entry: Entry | None, entry_id: str) -> str: repeating the rider's own name from the Rider field -- the word the console feed's Team column shows for the same crossing. A crossing whose entry has left the roster has nothing to type-check, - so it falls back to the raw *entry_id* (``build_fields``' own - ``entry_name`` rule). + so it falls back to the entry's plate, which is what the dialog's + other entry cells show (``build_fields``' own ``entry_name`` rule); + the crossing's stable key is never rendered. """ if entry is None: - return entry_id + return entry_plate if entry.type is EntryType.SOLO: return SOLO_TEAM_TEXT return entry.display_name +def _display_plate(crossing: Crossing, entry: Entry | None) -> str: + """Return the Plate cell's text for *crossing*. + + The plate the operator typed for this crossing (J1), or -- when + the crossing carries none, as a hand-built one might -- the entry's + own. An entry that has left the roster leaves the cell blank rather + than showing the crossing's stable key, a uuid no operator has ever + seen (E3.1.2's pooled-live-move seam). + """ + if crossing.rider_plate: + return crossing.rider_plate + return entry.plate if entry is not None else "" + + def _lap_and_total(engine: RideEngine, crossing: Crossing) -> tuple[float, float]: """Return ``(lap time, running total)`` in seconds for *crossing*. @@ -321,11 +336,17 @@ def build_fields(crossing: Crossing, roster: Roster, engine: RideEngine) -> Cros the hold queue nor the entry's hand -- so its glyph would name a card the entry does not hold. A held, credited or duplicate card keeps the real dealt code's glyph, the feed's own rule. + + The crossing's ``entry_id`` is the entry's stable key, so the entry + comes back through ``Roster.entry_by_key`` (E3.1.2's + pooled-live-move seam) and every rendered cell shows the entry's + plate or display name -- never the uuid. """ - entry = roster.resolve_plate(crossing.entry_id) - entry_name = entry.display_name if entry is not None else crossing.entry_id + entry = roster.entry_by_key(crossing.entry_id) + entry_plate = entry.plate if entry is not None else (crossing.rider_plate or "") + entry_name = entry.display_name if entry is not None else entry_plate rider = _rider_name(entry, crossing.rider_plate) - team = _team_name(entry, crossing.entry_id) + team = _team_name(entry, entry_plate) lap_time, total = _lap_and_total(engine, crossing) # W9's feed rule: a held crossing still shows the real dealt code, # never a placeholder -- held_card_for is that answer. @@ -335,7 +356,7 @@ def build_fields(crossing: Crossing, roster: Roster, engine: RideEngine) -> Cros return CrossingDetailFields( rider=rider or entry_name, team=team, - plate=crossing.rider_plate or crossing.entry_id, + plate=_display_plate(crossing, entry), lap=str(crossing.seq), time=_local_time(crossing.crossed_at), lap_time=_format_lap_time(lap_time), @@ -771,7 +792,7 @@ def _on_edit(self, event: Any) -> None: # noqa: ANN401 -- wx ships no stubs new_plate = run_plate_dialog( wx.xrc.XmlResource.Get(), opener=self.dialog, - plate=crossing.rider_plate or crossing.entry_id, + plate=_display_plate(crossing, self.roster.entry_by_key(crossing.entry_id)), ) if new_plate is None: return @@ -876,7 +897,10 @@ def _on_edit_time(self, event: Any) -> None: # noqa: ANN401 -- wx ships no stub ``edit_crossing`` raises for a lap retimed at or before its predecessor (Phase 3), which would otherwise escape this wx handler as a crash. A crossing the engine no longer holds is - refused the same way, before either. + refused the same way, before either. The engine addresses the + crossing by its entry's stable key and its own ``seq`` -- the + dialog's entry field only ever showed the plate, and stays + locked. """ event.Skip() crossing = self._shown_crossing() @@ -884,7 +908,7 @@ def _on_edit_time(self, event: Any) -> None: # noqa: ANN401 -- wx ships no stub wx.xrc.XmlResource.Get(), frame=self.dialog, adding=False, - plate=crossing.rider_plate or crossing.entry_id, + plate=_display_plate(crossing, self.roster.entry_by_key(crossing.entry_id)), time=_local_time(crossing.crossed_at), seq=crossing.seq, base_date=self.engine.config.event_date, @@ -904,7 +928,10 @@ def _on_edit_time(self, event: Any) -> None: # noqa: ANN401 -- wx ships no stub self.show_refusal(f"Could not edit crossing: {_STALE_CROSSING}") return try: - self.engine.edit_crossing(edit.entry_id, seq, edit.crossed_at, edit.reason) + # The engine addresses a crossing by its entry's stable key + # + seq; the dialog's own entry_id is the read-only plate it + # displayed (the field is locked, `read_only_plate`). + self.engine.edit_crossing(crossing.entry_id, seq, edit.crossed_at, edit.reason) except (RideEngineError, ValueError) as exc: self.show_refusal(f"Could not edit crossing: {exc}") return @@ -959,6 +986,7 @@ def _on_void_card(self, event: Any) -> None: # noqa: ANN401 -- wx ships no stub crossing = self._shown_crossing() card = self.engine.card_for(crossing) entry = build_fields(crossing, self.roster, self.engine) + owning = self.roster.entry_by_key(crossing.entry_id) # xrc-windows.md pins this confirm's label to "the entry the # card was dealt to" ("45 · J. Okafor"), and fields.team reads # "solo" for a solo rider, so the name comes from the rider @@ -967,7 +995,10 @@ def _on_void_card(self, event: Any) -> None: # noqa: ANN401 -- wx ships no stub void = corrections.run_void_card( wx.xrc.XmlResource.Get(), frame=self.dialog, - entry_id=crossing.entry_id, + # void_card resolves the entry from a plate (R-16), so this + # branch's own commit carries the entry's own plate, never + # the crossing's internal stable key. + entry_id=owning.plate if owning is not None else entry.plate, card=card.code(), entry=f"{entry.plate} · {entry.rider}", ) diff --git a/tests/conftest.py b/tests/conftest.py index 7915b904..ad7b57ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,7 @@ from datetime import date, datetime from typing import TYPE_CHECKING -from rivercrossing.ride import RideConfig +from rivercrossing.ride import RideConfig, RideEngine from rivercrossing.roster import EntryMode, PlateModel, Rider, Roster if TYPE_CHECKING: @@ -80,3 +80,32 @@ def _pooled_team_roster() -> Roster: riders=[Rider(first_name="Sarah", plate="45"), Rider(first_name="Priya", plate="9")], ) return roster + + +def entry_key(roster: Roster, plate: str) -> str: + """Return the stable key of the entry *plate* resolves to (arrange). + + The engine files its crossings, laps and credited hands under + ``Entry.key`` -- the stable identity a re-plating cannot move + (E3.1.2's pooled-live-move seam) -- while these tests name entries + by the plate the operator types. This is the bridge between the + two, for the calls that take an entry id (``lap_times``, + ``credited_cards``) and the payloads that now carry one. + """ + entry = roster.resolve_plate(plate) + assert entry is not None + return entry.key + + +def restore_entry_keys(replay: RideEngine, source: RideEngine) -> None: + """Give *replay*'s roster *source*'s entry keys (arrange). + + ``Store.load_engine`` rebuilds a roster from the persisted ``entry`` + rows, so the replayed entries come back under the very keys the + recorded events were filed under. Two hand-built rosters mint their + own keys, so a replay-equivalence test that models the store has to + restore them explicitly -- exactly what + ``Store.save_roster``/``_load_roster`` round-trip. + """ + for restored, live in zip(replay._roster.entries, source._roster.entries, strict=True): + restored.key = live.key diff --git a/tests/unit/presenters/test_console.py b/tests/unit/presenters/test_console.py index 9ca345f4..a0a9fae7 100644 --- a/tests/unit/presenters/test_console.py +++ b/tests/unit/presenters/test_console.py @@ -42,7 +42,7 @@ from hypothesis import given from hypothesis import strategies as st -from conftest import _roster_with_entries, gorba_config +from conftest import _roster_with_entries, entry_key, gorba_config from rivercrossing.cards import Card, Shoe from rivercrossing.hands import SelfTestCheck, SelfTestReport from rivercrossing.ride import ( @@ -516,7 +516,9 @@ def test_engine_data_source_feed_rows_given_credited_short_lap_flags_without_hol assert (feed[0].flagged, feed[0].held) == (True, False) assert feed[0].card == engine.card_for(engine.crossings[-1]).code() - assert tuple(card.code() for card in engine.credited_cards("12")) == (feed[0].card,) + assert tuple( + card.code() for card in engine.credited_cards(entry_key(engine._roster, "12")) + ) == (feed[0].card,) def test_engine_data_source_feed_rows_given_lap_exactly_at_min_lap_flags_nothing() -> None: @@ -677,14 +679,15 @@ def test_engine_data_source_standings_omitted_order_uses_the_default_constant() def _seed_credited_hand(engine: RideEngine, plate: str, cards: list[Card]) -> None: - """Overwrite *plate*'s credited hand with *cards*, no rider tag. + """Overwrite *plate*'s entry hand with *cards*, no rider tag. The private-access seed the hand-tie tests share: the engine's credited hand holds ``(card, rider_plate)`` pairs (the tag a per-rider DNF forfeits on), and a card whose crossing never named a - rider carries ``None`` -- never forfeited by anyone. + rider carries ``None`` -- never forfeited by anyone. The hand is + keyed by the entry's stable key, so the plate resolves first. """ - engine._hand[plate] = [(card, None) for card in cards] + engine._hand[entry_key(engine._roster, plate)] = [(card, None) for card in cards] def _engine_with_a_hand_tie() -> RideEngine: @@ -2466,9 +2469,9 @@ def test_on_plate_entered_given_a_credited_short_lap_lists_it_in_the_flagged_row presenter.on_plate_entered("12") assert [(row.plate, row.held) for row in view.last_flagged] == [("12", False)] - assert tuple(card.code() for card in engine.credited_cards("12")) == ( - view.last_flagged[0].card, - ) + assert tuple( + card.code() for card in engine.credited_cards(entry_key(engine._roster, "12")) + ) == (view.last_flagged[0].card,) def test_tick_given_an_instant_recorded_twice_lists_both_rows_in_the_review_tab() -> None: @@ -3016,7 +3019,7 @@ def test_engine_data_source_feed_rows_given_edited_crossing_marks_only_that_row( _record(engine, clock, "12", lap_time_s=100) engine.finish() engine.reopen() - engine.edit_crossing("12", 1, _dt(10, 31), "mis-keyed time") + engine.edit_crossing(entry_key(engine._roster, "12"), 1, _dt(10, 31), "mis-keyed time") source = EngineDataSource(engine, engine._roster) feed = source.feed_rows() @@ -3087,7 +3090,7 @@ def test_engine_data_source_feed_rows_given_voided_crossing_hides_it_and_marks_n _record(engine, clock, "12", lap_time_s=100) engine.finish() engine.reopen() - engine.void_crossing("12", 1, "double-entry") + engine.void_crossing(entry_key(engine._roster, "12"), 1, "double-entry") source = EngineDataSource(engine, engine._roster) feed = source.feed_rows() @@ -3099,7 +3102,7 @@ def test_engine_data_source_feed_rows_given_correction_while_running_marks_the_r """Corrections are legal in RUNNING too; the marker follows.""" engine, clock = _running_engine() _record(engine, clock, "12", lap_time_s=100) - engine.edit_crossing("12", 1, _dt(10, 31), "mis-keyed time") + engine.edit_crossing(entry_key(engine._roster, "12"), 1, _dt(10, 31), "mis-keyed time") source = EngineDataSource(engine, engine._roster) feed = source.feed_rows() diff --git a/tests/unit/presenters/test_data_source.py b/tests/unit/presenters/test_data_source.py index 663122d8..cc9bc8bb 100644 --- a/tests/unit/presenters/test_data_source.py +++ b/tests/unit/presenters/test_data_source.py @@ -312,8 +312,10 @@ def test_feed_rows_given_a_crossing_whose_entry_left_the_roster_is_blank() -> No The crossing is recorded against the real roster and then read through a source whose roster no longer holds that entry -- the - feed's own "entry left the roster" case, where Plate and Name fall - back to the stored id and the Team cell has nothing to render. + feed's own "entry left the roster" case, where the plate the + operator typed is the row's only handle: Plate and Name both fall + back to it (never the crossing's internal key) and the Team cell + has nothing to render. """ roster = _pooled_team_roster() engine = _running_engine(roster) @@ -322,7 +324,7 @@ def test_feed_rows_given_a_crossing_whose_entry_left_the_roster_is_blank() -> No feed = source.feed_rows() - assert (feed[0].entry, feed[0].team) == ("9", "") + assert (feed[0].plate, feed[0].entry, feed[0].team) == ("45", "45", "") def test_rider_name_for_given_matching_plate_returns_that_riders_full_name() -> None: @@ -1210,6 +1212,6 @@ def test_audit_entry_given_any_payload_renders_a_string( action: str, payload: dict[str, object] ) -> None: """Property (T-7): whatever a payload holds, the cell is text.""" - cell = data_source_module._audit_entry(Event(action=action, payload=payload)) + cell = data_source_module._audit_entry(Event(action=action, payload=payload), {"key": "12"}) assert isinstance(cell, str) diff --git a/tests/unit/presenters/test_results.py b/tests/unit/presenters/test_results.py index ee2fa4de..885760fe 100644 --- a/tests/unit/presenters/test_results.py +++ b/tests/unit/presenters/test_results.py @@ -32,6 +32,7 @@ from hypothesis import given from hypothesis import strategies as st +from conftest import entry_key from rivercrossing.cards import Shoe from rivercrossing.ride import Event, RideConfig, RideEngine from rivercrossing.roster import EntryMode, PlateModel, Roster @@ -379,7 +380,12 @@ def _engine_source_with_correction() -> tuple[RideEngine, EngineDataSource]: ) engine.start() engine.record_crossing("12", at=datetime(2026, 9, 20, 10, 30)) # noqa: DTZ001 - engine.edit_crossing("12", 1, datetime(2026, 9, 20, 10, 31), reason="mis-key") # noqa: DTZ001 + engine.edit_crossing( + entry_key(roster, "12"), + 1, + datetime(2026, 9, 20, 10, 31), # noqa: DTZ001 + reason="mis-key", + ) return engine, EngineDataSource(engine, roster) diff --git a/tests/unit/presenters/test_simulator.py b/tests/unit/presenters/test_simulator.py index 669ebaee..a085d482 100644 --- a/tests/unit/presenters/test_simulator.py +++ b/tests/unit/presenters/test_simulator.py @@ -159,6 +159,21 @@ def _laps_of(engine: RideEngine) -> list[list[Crossing]]: return [laps[seq] for seq in sorted(laps)] +def _race(engine: RideEngine, roster: Roster) -> list[tuple[str, str | None, datetime]]: + """Return *engine*'s crossings by (plate, rider plate, instant). + + A crossing carries its entry's stable ``key``, and two runs over + two hand-built rosters mint their own -- so the two races are + compared by the plate the operator knows, which is what "the same + seed replays the same race" is about. + """ + plates_by_key = {entry.key: entry.plate for entry in roster.entries} + return [ + (plates_by_key[crossing.entry_id], crossing.rider_plate, crossing.crossed_at) + for crossing in engine.crossings + ] + + def _waves_of(engine: RideEngine) -> list[tuple[datetime, datetime]]: """Return each lap's (first, last) crossing, oldest lap first.""" return [ @@ -784,7 +799,7 @@ def test_run_simulation_pooled_team_laps_equal_solo_laps() -> None: presenter.run_simulation(laps=12, interval_minutes=45) - laps_by_entry = {result.entry_id: result.laps for result in engine.snapshot()} + laps_by_entry = {result.plate: result.laps for result in engine.snapshot()} assert laps_by_entry[team.plate] == laps_by_entry[solo.plate] == 12 @@ -817,7 +832,7 @@ def test_run_simulation_no_derived_lap_time_is_zero() -> None: presenter.run_simulation(laps=3, interval_minutes=45) - lap_times = [seconds for entry in roster.entries for seconds in engine.lap_times(entry.plate)] + lap_times = [seconds for entry in roster.entries for seconds in engine.lap_times(entry.key)] assert len(lap_times) == 18 assert min(lap_times) == 45 * 60.0 @@ -938,17 +953,15 @@ def test_run_simulation_pooled_team_rotates_its_representative_each_lap() -> Non def test_run_simulation_same_seed_replays_the_same_race() -> None: """One seed reproduces the crossing order and every instant.""" - first, first_engine, _first_roster = _draft() - second, second_engine, _second_roster = _draft() + first, first_engine, first_roster = _draft() + second, second_engine, second_roster = _draft() first.generate_riders(10, 2, 4, seed=_SEED) second.generate_riders(10, 2, 4, seed=_SEED) first.run_simulation(laps=2, interval_minutes=45) second.run_simulation(laps=2, interval_minutes=45) - first_race = [(c.entry_id, c.rider_plate, c.crossed_at) for c in first_engine.crossings] - second_race = [(c.entry_id, c.rider_plate, c.crossed_at) for c in second_engine.crossings] - assert first_race == second_race + assert _race(first_engine, first_roster) == _race(second_engine, second_roster) def test_run_simulation_leaves_the_ride_running_and_stopped() -> None: @@ -970,7 +983,7 @@ def test_run_simulation_solo_lap_times_match_the_interval() -> None: presenter.run_simulation(laps=3, interval_minutes=1) - assert engine.lap_times(solo.plate) == (60.0, 60.0, 60.0) + assert engine.lap_times(solo.key) == (60.0, 60.0, 60.0) def test_run_simulation_reuses_one_entry_order_for_every_lap() -> None: @@ -1058,7 +1071,7 @@ def test_run_simulation_relay_records_crossings_under_the_entry_plate() -> None: assert outcome == SimOutcome(cancelled=False, recorded=4, blocked=None) assert sorted(crossing.entry_id for crossing in engine.crossings) == sorted( - entry.plate for entry in roster.entries + entry.key for entry in roster.entries ) @@ -1071,7 +1084,7 @@ def test_run_simulation_relay_team_laps_equal_solo_laps() -> None: presenter.run_simulation(laps=3, interval_minutes=1) - laps_by_entry = {result.entry_id: result.laps for result in engine.snapshot()} + laps_by_entry = {result.plate: result.laps for result in engine.snapshot()} assert laps_by_entry[team.plate] == laps_by_entry[solo.plate] == 3 @@ -1120,7 +1133,7 @@ def test_run_simulation_given_a_short_lap_rider_records_its_laps_under_the_minim outcome = presenter.run_simulation(laps=3, interval_minutes=45, short_laps=1) - assert engine.lap_times(roster.entries[0].plate) == (1050.0, 1050.0, 1050.0) + assert engine.lap_times(roster.entries[0].key) == (1050.0, 1050.0, 1050.0) assert len(engine.held_crossings()) == 3 assert outcome == SimOutcome(cancelled=False, recorded=3, blocked=None) @@ -1133,7 +1146,7 @@ def test_run_simulation_given_a_short_lap_rider_and_always_deal_credits_its_card presenter.run_simulation(laps=3, interval_minutes=45, short_laps=1) assert engine.held_crossings() == () - assert len(engine.credited_cards(roster.entries[0].plate)) == 3 + assert len(engine.credited_cards(roster.entries[0].key)) == 3 def test_run_simulation_given_short_lap_riders_shortens_the_orders_leading_entries() -> None: @@ -1152,10 +1165,10 @@ def test_run_simulation_given_short_lap_riders_shortens_the_orders_leading_entri presenter.run_simulation(laps=2, interval_minutes=1, short_laps=2) flagged = {held.crossing.entry_id for held in engine.held_crossings()} - assert flagged == {roster.entries[position].plate for position in order[:2]} - assert all(engine.lap_times(plate) == (30.0, 30.0) for plate in flagged) - others = [entry.plate for entry in roster.entries if entry.plate not in flagged] - assert all(engine.lap_times(plate) == (60.0, 60.0) for plate in others) + assert flagged == {roster.entries[position].key for position in order[:2]} + assert all(engine.lap_times(key) == (30.0, 30.0) for key in flagged) + others = [entry.key for entry in roster.entries if entry.key not in flagged] + assert all(engine.lap_times(key) == (60.0, 60.0) for key in others) def test_run_simulation_without_short_lap_riders_flags_nothing() -> None: @@ -1188,7 +1201,7 @@ def test_run_simulation_given_a_lapped_rider_skips_only_the_first_wave() -> None presenter.run_simulation(laps=3, interval_minutes=45, lapped=1) - instants = [c.crossed_at for c in engine.crossings if c.entry_id == behind.plate] + instants = [c.crossed_at for c in engine.crossings if c.entry_id == behind.key] assert instants == [ _actual_start_of(engine) + timedelta(minutes=90), _actual_start_of(engine) + timedelta(minutes=135), @@ -1234,7 +1247,7 @@ def test_run_simulation_given_a_stopped_team_rider_never_records_it_after_lap_fo presenter.run_simulation(laps=8, interval_minutes=1, team_stop=1) - laps_by_entry = {result.entry_id: result.laps for result in engine.snapshot()} + laps_by_entry = {result.plate: result.laps for result in engine.snapshot()} stopped = team.riders[0].plate after_four = {crossing.rider_plate for crossing in engine.crossings[4:]} assert laps_by_entry[team.plate] == 8 @@ -1280,22 +1293,20 @@ def test_run_simulation_given_a_relay_ride_keeps_team_stop_a_no_op() -> None: presenter.run_simulation(laps=8, interval_minutes=1, team_stop=1) - assert [crossing.entry_id for crossing in engine.crossings] == [roster.entries[0].plate] * 8 + assert [crossing.entry_id for crossing in engine.crossings] == [roster.entries[0].key] * 8 def test_run_simulation_given_every_behaviour_and_one_seed_replays_the_same_race() -> None: """T-7: one seed reproduces a run with all three behaviours.""" - first, first_engine, _first_roster = _draft(min_lap_s=1080) - second, second_engine, _second_roster = _draft(min_lap_s=1080) + first, first_engine, first_roster = _draft(min_lap_s=1080) + second, second_engine, second_roster = _draft(min_lap_s=1080) first.generate_riders(10, 2, 4, seed=_SEED) second.generate_riders(10, 2, 4, seed=_SEED) first.run_simulation(laps=8, interval_minutes=45, short_laps=2, lapped=1, team_stop=2) second.run_simulation(laps=8, interval_minutes=45, short_laps=2, lapped=1, team_stop=2) - first_race = [(c.entry_id, c.rider_plate, c.crossed_at) for c in first_engine.crossings] - second_race = [(c.entry_id, c.rider_plate, c.crossed_at) for c in second_engine.crossings] - assert first_race == second_race + assert _race(first_engine, first_roster) == _race(second_engine, second_roster) def test_run_simulation_given_a_lapped_rider_skips_the_orders_leading_entry() -> None: @@ -1306,7 +1317,7 @@ def test_run_simulation_given_a_lapped_rider_skips_the_orders_leading_entry() -> outcome = presenter.run_simulation(laps=3, interval_minutes=45, lapped=1) - laps_by_entry = {result.entry_id: result.laps for result in engine.snapshot()} + laps_by_entry = {result.plate: result.laps for result in engine.snapshot()} assert laps_by_entry[behind.plate] == 2 assert outcome.recorded == 17 diff --git a/tests/unit/test_ride.py b/tests/unit/test_ride.py index 6e379d15..0daeeb94 100644 --- a/tests/unit/test_ride.py +++ b/tests/unit/test_ride.py @@ -45,7 +45,7 @@ from hypothesis import given from hypothesis import strategies as st -from conftest import _pooled_team_roster, _roster_with_entries +from conftest import _pooled_team_roster, _roster_with_entries, entry_key, restore_entry_keys from rivercrossing import ride as ride_module from rivercrossing.cards import Card, Shoe, ShoeClosedError from rivercrossing.hands import best_hand, compare @@ -435,6 +435,17 @@ def _make_engine( return engine, clock +def _engine_key(engine: RideEngine, plate: str) -> str: + """Return the stable entry key *plate* resolves to (arrange). + + The engine keys its crossings, laps and credited hands by + ``Entry.key`` (E3.1.2's pooled-live-move seam), so a test that + reaches for an entry identity -- ``lap_times``, ``credited_cards``, + a replayed payload -- passes this. + """ + return entry_key(engine._roster, plate) + + # The identity-keyed void tests below need two physically different # cards sharing one code. A two-deck shoe under this seed deals the # same code on two consecutive deals (deals 6 and 7), which the @@ -571,7 +582,7 @@ def test_engine_on_event_receives_the_exact_crossing_payload() -> None: action="record_crossing", payload={ "plate": "12", - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "lap": 1, "crossed_at": "2026-09-20T10:01:00", "reason": "Rider 12 · solo", @@ -854,7 +865,7 @@ def test_set_start_time_recomputes_lap_one_and_writes_audit_row() -> None: event = engine.set_start_time(_dt(9, 55)) - assert engine.lap_times("12") == (500.0,) + assert engine.lap_times(_engine_key(engine, "12")) == (500.0,) assert event == Event( action="set_start_time", payload={ @@ -874,7 +885,7 @@ def test_set_start_time_recomputes_only_lap_one_never_later_laps() -> None: engine.set_start_time(_dt(9, 55)) - assert engine.lap_times("12") == (500.0, 100.0) + assert engine.lap_times(_engine_key(engine, "12")) == (500.0, 100.0) # -------------------------------------------------------- stop/continue @@ -892,7 +903,7 @@ def test_stop_returns_event_and_blocks_crossings_with_refusal_result() -> None: assert result.reason == "ride is stopped" assert result.lap == 0 assert engine.state is RideStatus.RUNNING - assert engine.lap_times("12") == () + assert engine.lap_times(_engine_key(engine, "12")) == () def test_start_after_stop_continues_with_unchanged_actual_start() -> None: @@ -1108,7 +1119,7 @@ def test_record_crossing_credits_one_lap_and_marks_has_data() -> None: action="record_crossing", payload={ "plate": "12", - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "lap": 1, "crossed_at": "2026-09-20T10:02:00", "reason": "Rider 12 · solo", @@ -1255,7 +1266,7 @@ def test_snapshot_keeps_a_dnf_riders_cards_in_the_credited_hand_read() -> None: engine.mark_dnf("45", reason="mechanical failure") - assert engine.credited_cards("9") == (card,) + assert engine.credited_cards(_engine_key(engine, "9")) == (card,) def test_snapshot_sets_a_solo_entries_sex_from_its_lone_rider() -> None: @@ -1302,7 +1313,7 @@ def test_lap_times_empty_for_entry_without_laps() -> None: engine, _ = _make_engine() engine.start() - assert engine.lap_times("34") == () + assert engine.lap_times(_engine_key(engine, "34")) == () def test_on_course_counts_active_entries_with_odd_lap_counts() -> None: @@ -1504,7 +1515,15 @@ def test_record_crossing_pooled_rider_out_lapping_teammates_is_uncapped() -> Non assert results["9"].laps == 7 assert len(results["9"].cards) == 7 - assert engine.lap_times("9") == (60.0, 600.0, 600.0, 600.0, 600.0, 600.0, 600.0) + assert engine.lap_times(_engine_key(engine, "9")) == ( + 60.0, + 600.0, + 600.0, + 600.0, + 600.0, + 600.0, + 600.0, + ) # ----------------------------------------- E4.2 held cards (R-34) @@ -1545,7 +1564,7 @@ def test_confirm_held_returns_audit_event_and_best_hand_improves() -> None: assert event == Event( action="confirm_held", - payload={"entry_id": "12", "seq": 1, "card": held.card.code()}, + payload={"entry_id": _engine_key(engine, "12"), "seq": 1, "card": held.card.code()}, ) results = {entry.plate: entry for entry in engine.snapshot()} assert results["12"].cards == (held.card,) @@ -1563,7 +1582,7 @@ def test_void_held_returns_audit_event_and_hand_stays_empty() -> None: assert event == Event( action="void_held", - payload={"entry_id": "12", "seq": 1, "card": held.card.code()}, + payload={"entry_id": _engine_key(engine, "12"), "seq": 1, "card": held.card.code()}, ) results = {entry.plate: entry for entry in engine.snapshot()} assert results["12"].cards == () @@ -1619,15 +1638,15 @@ def test_return_to_held_moves_a_credited_card_into_the_hold_queue() -> None: engine.start() result = engine.record_crossing("12", at=_dt(10, 30)) crossing = engine.crossings[-1] - assert engine.credited_cards("12") == (result.card,) + assert engine.credited_cards(_engine_key(engine, "12")) == (result.card,) event = engine.return_to_held(crossing) assert event == Event( action="return_to_held", - payload={"entry_id": "12", "seq": 1, "card": result.card.code()}, + payload={"entry_id": _engine_key(engine, "12"), "seq": 1, "card": result.card.code()}, ) - assert engine.credited_cards("12") == () + assert engine.credited_cards(_engine_key(engine, "12")) == () assert engine.held_card_for(crossing) == result.card assert engine.held_crossings() == (HeldCrossing(crossing=crossing, card=result.card),) @@ -1654,7 +1673,7 @@ def test_return_to_held_on_a_void_held_card_deals_a_fresh_card() -> None: assert engine.is_card_voided(old) is True assert event == Event( action="return_to_held", - payload={"entry_id": "12", "seq": 1, "card": fresh.code()}, + payload={"entry_id": _engine_key(engine, "12"), "seq": 1, "card": fresh.code()}, ) @@ -1677,7 +1696,7 @@ def test_return_to_held_on_a_void_card_deals_a_fresh_card() -> None: assert engine.is_card_voided(second.card) is True assert event == Event( action="return_to_held", - payload={"entry_id": "12", "seq": 2, "card": fresh.code()}, + payload={"entry_id": _engine_key(engine, "12"), "seq": 2, "card": fresh.code()}, ) @@ -1863,7 +1882,7 @@ def test_return_to_held_after_a_void_keeps_a_same_code_sibling_credited() -> Non engine.return_to_held(engine.crossings[6]) assert engine.held_card_for(engine.crossings[6]) is second - assert len(engine.credited_cards("12")) == 5 + assert len(engine.credited_cards(_engine_key(engine, "12"))) == 5 # Private read deliberately: the shoe's deal count is exactly what # tells the credited branch from a fresh deal, and it has no public # reader. @@ -1887,8 +1906,8 @@ def test_reassign_recredits_a_same_code_card_of_another_entry_after_a_void() -> engine.reassign_crossing(len(engine.crossings), "56", reason="mis-keyed plate") - assert engine.credited_cards("34") == () - assert engine.credited_cards("56") == (shared_34,) + assert engine.credited_cards(_engine_key(engine, "34")) == () + assert engine.credited_cards(_engine_key(engine, "56")) == (shared_34,) def test_return_to_held_on_another_entrys_alias_takes_the_credited_branch() -> None: @@ -1909,7 +1928,7 @@ def test_return_to_held_on_another_entrys_alias_takes_the_credited_branch() -> N engine.return_to_held(engine.crossings[6]) assert engine.held_card_for(engine.crossings[6]) is shared_34 - assert engine.credited_cards("34") == () + assert engine.credited_cards(_engine_key(engine, "34")) == () # Private read deliberately, as above: the deal count is the only # reader that separates re-holding from re-dealing. assert engine._shoe.dealt == dealt_before @@ -1938,7 +1957,7 @@ def test_void_card_ignores_a_held_same_code_sibling_and_voids_the_credited_card( assert engine.is_card_voided(credited) is True assert engine.is_card_voided(held) is False assert engine.held_card_for(engine.crossings[5]) is held - assert len(engine.credited_cards("12")) == 5 + assert len(engine.credited_cards(_engine_key(engine, "12"))) == 5 # --------------------------------------------- E4.2 undo (R-33) @@ -1956,14 +1975,14 @@ def test_undo_last_removes_lap_restitutes_card_and_audits() -> None: assert event == Event( action="undo", payload={ - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "seq": 2, "crossed_at": "2026-09-20T10:32:00", "card": second.card.code(), "reason": "Undo last crossing", }, ) - assert engine.lap_times("12") == (1800.0,) + assert engine.lap_times(_engine_key(engine, "12")) == (1800.0,) results = {entry.plate: entry for entry in engine.snapshot()} assert results["12"].laps == 1 assert results["12"].cards == (first.card,) @@ -1986,7 +2005,7 @@ def test_undo_last_after_manual_deal_retires_crossing_card_not_front() -> None: assert event.action == "undo" assert event.payload["card"] == crossing.card.code() - assert engine.lap_times("12") == () + assert engine.lap_times(_engine_key(engine, "12")) == () results = {entry.plate: entry for entry in engine.snapshot()} manual_card = Card.parse(str(manual.payload["card"])) assert results["12"].cards == (manual_card,) @@ -2003,7 +2022,9 @@ def test_undo_then_rerecord_deals_the_same_card_from_the_shoe_front() -> None: rerecord = engine.record_crossing("12", at=_dt(10, 31)) assert rerecord.card == original.card - assert engine.lap_times("12") == (1860.0,) # lap 1 again: crossed_at - actual_start + assert engine.lap_times(_engine_key(engine, "12")) == ( + 1860.0, + ) # lap 1 again: crossed_at - actual_start def test_undo_last_held_crossing_releases_hold_and_restitutes_card() -> None: @@ -2016,7 +2037,7 @@ def test_undo_last_held_crossing_releases_hold_and_restitutes_card() -> None: engine.undo_last() assert engine.held_crossings() == () - assert engine.lap_times("12") == () + assert engine.lap_times(_engine_key(engine, "12")) == () redo = engine.record_crossing("12", at=_dt(10, 0, 45)) assert redo.card == held.card @@ -2090,7 +2111,7 @@ def test_undo_last_from_reopened_reverses_the_crossing() -> None: engine.undo_last() - assert engine.lap_times("12") == () + assert engine.lap_times(_engine_key(engine, "12")) == () # -------------------------------------------------- R-31 perf budget @@ -2245,7 +2266,10 @@ def test_deal_manual_credits_card_marks_has_data_and_audits_reason() -> None: event = engine.deal_manual("12", reason="replacement card") assert event.action == "deal_manual" - assert (event.payload["plate"], event.payload["entry_id"]) == ("12", "12") + assert (event.payload["plate"], event.payload["entry_id"]) == ( + "12", + _engine_key(engine, "12"), + ) assert event.payload["reason"] == "replacement card" manual_card = Card.parse(str(event.payload["card"])) results = {entry.plate: entry for entry in engine.snapshot()} @@ -2271,7 +2295,7 @@ def test_deal_manual_pooled_rider_plate_credits_the_team() -> None: event = engine.deal_manual("45", reason="replacement") results = {entry.plate: entry for entry in engine.snapshot()} - assert event.payload["entry_id"] == "9" + assert event.payload["entry_id"] == _engine_key(engine, "9") assert results["9"].cards == (Card.parse(str(event.payload["card"])),) @@ -2430,7 +2454,7 @@ def test_undo_last_from_reopened_after_finish_returns_card_to_the_shoe() -> None engine.undo_last() - assert engine.lap_times("12") == () + assert engine.lap_times(_engine_key(engine, "12")) == () assert engine._shoe.dealt == 0 # reopened shoe: card returned, not retired assert engine._shoe.deal()[0] == crossing.card # next deal reproduces it @@ -2449,7 +2473,7 @@ def test_engine_crossings_property_returns_recorded_crossings_oldest_first() -> assert len(crossings) == 2 assert [c.seq for c in crossings] == [1, 2] - assert crossings[0].entry_id == "12" + assert crossings[0].entry_id == _engine_key(engine, "12") assert crossings[0].crossed_at == _dt(10, 30) @@ -2585,15 +2609,15 @@ def test_credited_cards_given_no_crossings_returns_an_empty_tuple() -> None: engine, _ = _make_engine() engine.start() - assert engine.credited_cards("12") == () + assert engine.credited_cards(_engine_key(engine, "12")) == () -def test_credited_cards_given_an_unknown_plate_returns_an_empty_tuple() -> None: - """Negative: a plate no entry owns credits nothing, never raises.""" +def test_credited_cards_given_an_unknown_entry_id_returns_an_empty_tuple() -> None: + """Negative: an id no entry owns credits nothing, never raises.""" engine, _ = _make_engine() engine.start() - assert engine.credited_cards("999") == () + assert engine.credited_cards("no-such-entry") == () def test_credited_cards_given_one_crossing_returns_the_dealt_card() -> None: @@ -2602,7 +2626,7 @@ def test_credited_cards_given_one_crossing_returns_the_dealt_card() -> None: engine.start() result = engine.record_crossing("12", at=_dt(10, 30)) - assert engine.credited_cards("12") == (result.card,) + assert engine.credited_cards(_engine_key(engine, "12")) == (result.card,) def test_credited_cards_given_many_crossings_returns_them_in_deal_order() -> None: @@ -2617,7 +2641,11 @@ def test_credited_cards_given_many_crossings_returns_them_in_deal_order() -> Non second = engine.record_crossing("12", at=_dt(10, 31)) third = engine.record_crossing("12", at=_dt(10, 32)) - assert engine.credited_cards("12") == (first.card, second.card, third.card) + assert engine.credited_cards(_engine_key(engine, "12")) == ( + first.card, + second.card, + third.card, + ) def test_credited_cards_given_a_held_short_lap_credits_nothing_yet() -> None: @@ -2626,7 +2654,7 @@ def test_credited_cards_given_a_held_short_lap_credits_nothing_yet() -> None: engine.start() engine.record_crossing("12", at=_dt(10, 0, 30)) - assert engine.credited_cards("12") == () + assert engine.credited_cards(_engine_key(engine, "12")) == () def test_credited_cards_after_confirming_a_held_card_credits_it() -> None: @@ -2637,7 +2665,7 @@ def test_credited_cards_after_confirming_a_held_card_credits_it() -> None: held = engine.held_crossings()[0] engine.confirm_held(held.crossing) - assert engine.credited_cards("12") == (held.card,) + assert engine.credited_cards(_engine_key(engine, "12")) == (held.card,) def test_credited_cards_after_voiding_a_held_card_still_credits_nothing() -> None: @@ -2648,7 +2676,7 @@ def test_credited_cards_after_voiding_a_held_card_still_credits_nothing() -> Non held = engine.held_crossings()[0] engine.void_held(held.crossing) - assert engine.credited_cards("12") == () + assert engine.credited_cards(_engine_key(engine, "12")) == () def test_credited_cards_given_a_pooled_team_returns_the_entrys_whole_hand() -> None: @@ -2667,7 +2695,7 @@ def test_credited_cards_given_a_pooled_team_returns_the_entrys_whole_hand() -> N second = engine.record_crossing("9", at=_dt(10, 31)) entry = roster.entries[0] - assert engine.credited_cards(entry.plate) == (first.card, second.card) + assert engine.credited_cards(entry.key) == (first.card, second.card) # --------------------------------------- W9: held-card lookup (R-34) @@ -2812,7 +2840,7 @@ def test_apply_record_crossing_event_credits_lap_and_deals_deterministic_card() action="record_crossing", payload={ "plate": "12", - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "lap": 1, "crossed_at": "2026-09-20T10:02:00", "reason": "Rider 12 · solo", @@ -2821,7 +2849,7 @@ def test_apply_record_crossing_event_credits_lap_and_deals_deterministic_card() engine.apply(event) - assert engine.lap_times("12") == (120.0,) + assert engine.lap_times(_engine_key(engine, "12")) == (120.0,) assert engine.events[-1] == event @@ -2841,7 +2869,7 @@ def test_apply_set_start_time_event_backdates_actual_start() -> None: engine.apply(event) - assert engine.lap_times("12") == (420.0,) + assert engine.lap_times(_engine_key(engine, "12")) == (420.0,) assert engine.events[-1] == event @@ -2853,7 +2881,7 @@ def test_apply_confirm_held_event_releases_held_card_into_the_hand() -> None: held = engine.held_crossings()[0] event = Event( action="confirm_held", - payload={"entry_id": "12", "seq": 1, "card": held.card.code()}, + payload={"entry_id": _engine_key(engine, "12"), "seq": 1, "card": held.card.code()}, ) engine.apply(event) @@ -2871,7 +2899,7 @@ def test_apply_void_held_event_discards_held_card_never_credited() -> None: held = engine.held_crossings()[0] event = Event( action="void_held", - payload={"entry_id": "12", "seq": 1, "card": held.card.code()}, + payload={"entry_id": _engine_key(engine, "12"), "seq": 1, "card": held.card.code()}, ) engine.apply(event) @@ -2889,13 +2917,13 @@ def test_apply_return_to_held_event_re_holds_the_credited_card() -> None: crossing = engine.crossings[0] event = Event( action="return_to_held", - payload={"entry_id": "12", "seq": 1, "card": result.card.code()}, + payload={"entry_id": _engine_key(engine, "12"), "seq": 1, "card": result.card.code()}, ) engine.apply(event) assert engine.held_card_for(crossing) == result.card - assert engine.credited_cards("12") == () + assert engine.credited_cards(_engine_key(engine, "12")) == () assert engine.events[-1] == event @@ -2928,6 +2956,7 @@ def test_apply_replay_return_to_held_reproduces_the_re_held_disposition() -> Non fifth = live.record_crossing("12", at=_dt(12, 0)) # credited -> voided live.void_card("12", fifth.card, reason="wrong card dealt") replayed, _ = _make_engine(config=_config(hold_short_laps=True)) + restore_entry_keys(replayed, live) # logic-coverage-exempt: T-8 -- the loop is pure Arrange # (re-applying the recorded log); assertions run after the loop. for event in live.events: @@ -2953,7 +2982,7 @@ def test_apply_undo_event_reverses_the_last_crossing() -> None: event = Event( action="undo", payload={ - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "seq": 2, "crossed_at": "2026-09-20T10:32:00", "card": second.card.code(), @@ -2963,7 +2992,7 @@ def test_apply_undo_event_reverses_the_last_crossing() -> None: engine.apply(event) - assert engine.lap_times("12") == (1800.0,) + assert engine.lap_times(_engine_key(engine, "12")) == (1800.0,) assert engine.events[-1] == event @@ -2977,7 +3006,7 @@ def test_apply_deal_manual_event_credits_card_with_the_payload_reason() -> None: action="deal_manual", payload={ "plate": "12", - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "card": expected.code(), "reason": "replacement card", }, @@ -3173,6 +3202,7 @@ def test_apply_replay_finish_reopen_deal_manual_is_equivalent() -> None: manual = live.deal_manual("12", reason="replacement") replayed, _ = _make_engine() + restore_entry_keys(replayed, live) for event in live.events: replayed.apply(event) @@ -3222,7 +3252,7 @@ def test_apply_confirm_held_for_an_unrecorded_crossing_raises_clear_error() -> N engine.start(at=_dt(10, 0)) event = Event( action="confirm_held", - payload={"entry_id": "12", "seq": 99, "card": "AS"}, + payload={"entry_id": _engine_key(engine, "12"), "seq": 99, "card": "AS"}, ) with pytest.raises(RideEngineError, match=re.escape("no crossing")): @@ -3235,13 +3265,55 @@ def test_apply_return_to_held_for_an_unrecorded_crossing_raises_clear_error() -> engine.start(at=_dt(10, 0)) event = Event( action="return_to_held", - payload={"entry_id": "12", "seq": 99, "card": "AS"}, + payload={"entry_id": _engine_key(engine, "12"), "seq": 99, "card": "AS"}, ) with pytest.raises(RideEngineError, match=re.escape("no crossing")): engine.apply(event) +def test_apply_record_crossing_given_an_unknown_entry_key_raises_unknown_plate_error() -> None: + """A payload no roster entry owns fails loudly (T-5). + + Replay resolves each event's entry by the stable key it carries + (E3.1.2's pooled-live-move seam), so a key no entry owns is an + inconsistent event stream rather than a plate to re-resolve. + """ + engine, _ = _make_engine() + + with pytest.raises(UnknownPlateError, match=re.escape("unknown entry key")): + engine.apply( + Event( + action="record_crossing", + payload={ + "plate": "12", + "entry_id": "0" * 32, + "lap": 1, + "crossed_at": "2026-09-20T10:02:00", + }, + ) + ) + + +def test_apply_deal_manual_given_an_unknown_entry_key_raises_unknown_plate_error() -> None: + """Every key-resolving replay branch refuses the same way (T-5).""" + engine, _ = _make_engine() + engine.start(at=_dt(10, 0)) + + with pytest.raises(UnknownPlateError, match=re.escape("unknown entry key")): + engine.apply( + Event( + action="deal_manual", + payload={ + "plate": "12", + "entry_id": "0" * 32, + "card": "AS", + "reason": "bonus", + }, + ) + ) + + # ============================================================ D2 # Edit Ride: the engine's config is the live ride's own settings, so an # edit replaces it in place -- the ride, its shoe and its event log are @@ -3370,7 +3442,7 @@ def test_record_crossing_given_pooled_rider_plate_stores_it_as_the_rider_plate() engine.record_crossing("45", at=_dt(10, 2)) assert engine.crossings[-1] == Crossing( - entry_id="9", seq=1, crossed_at=_dt(10, 2), rider_plate="45" + entry_id=_engine_key(engine, "9"), seq=1, crossed_at=_dt(10, 2), rider_plate="45" ) @@ -3389,7 +3461,7 @@ def test_edit_crossing_preserves_the_typing_riders_plate() -> None: engine = _pooled_team_engine() engine.record_crossing("45", at=_dt(10, 2)) - engine.edit_crossing("9", 1, _dt(10, 3), reason="mis-keyed time") + engine.edit_crossing(_engine_key(engine, "9"), 1, _dt(10, 3), reason="mis-keyed time") assert engine.crossings[-1].rider_plate == "45" @@ -3411,7 +3483,9 @@ def test_reassign_crossing_sets_the_new_plate_as_the_rider_plate() -> None: engine.reassign_crossing(1, "34", reason="mis-keyed plate") - moved = [crossing for crossing in engine.crossings if crossing.entry_id == "34"] + moved = [ + crossing for crossing in engine.crossings if crossing.entry_id == _engine_key(engine, "34") + ] assert [crossing.rider_plate for crossing in moved] == ["34"] @@ -3421,7 +3495,7 @@ def test_void_crossing_renumbering_preserves_the_remaining_riders_plate() -> Non engine.record_crossing("9", at=_dt(10, 2)) # Priya, lap 1 engine.record_crossing("45", at=_dt(10, 4)) # Sarah, lap 2 - engine.void_crossing("9", 1, reason="double entry") + engine.void_crossing(_engine_key(engine, "9"), 1, reason="double entry") assert [(crossing.seq, crossing.rider_plate) for crossing in engine.crossings] == [(1, "45")] @@ -3436,7 +3510,9 @@ def test_reassign_crossing_renumbering_preserves_the_remaining_riders_plate() -> engine.reassign_crossing(1, "34", reason="mis-keyed plate") - remaining = [crossing for crossing in engine.crossings if crossing.entry_id == "12"] + remaining = [ + crossing for crossing in engine.crossings if crossing.entry_id == _engine_key(engine, "12") + ] assert [(crossing.seq, crossing.rider_plate) for crossing in remaining] == [(1, "12")] @@ -3449,7 +3525,7 @@ def test_apply_record_crossing_event_rebuilds_the_typed_rider_plate() -> None: action="record_crossing", payload={ "plate": "45", - "entry_id": "9", + "entry_id": _engine_key(engine, "9"), "lap": 1, "crossed_at": "2026-09-20T10:02:00", }, @@ -3524,7 +3600,7 @@ def test_record_miss_deals_no_card_and_leaves_the_crossings_untouched() -> None: engine.record_miss(_dt(10, 5), reason="missed number") assert (engine.crossings, engine.shoe_remaining) == ((), shoe_remaining) - assert engine.credited_cards("12") == () + assert engine.credited_cards(_engine_key(engine, "12")) == () assert engine.held_crossings() == () @@ -3580,14 +3656,16 @@ def test_assign_plate_to_miss_removes_the_miss_and_records_the_crossing() -> Non payload={ "miss_seq": 1, "new_plate": "12", - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "crossed_at": "2026-09-20T10:05:00", "reason": "rider identified", }, ) assert engine.pending_misses() == () assert engine.crossings == ( - Crossing(entry_id="12", seq=1, crossed_at=_dt(10, 5), rider_plate="12"), + Crossing( + entry_id=_engine_key(engine, "12"), seq=1, crossed_at=_dt(10, 5), rider_plate="12" + ), ) assert engine.events[-1] == event @@ -3712,7 +3790,7 @@ def test_apply_assign_plate_to_miss_event_replays_the_crossing() -> None: payload={ "miss_seq": 1, "new_plate": "12", - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "crossed_at": "2026-09-20T10:05:00", "reason": "rider identified", }, @@ -3722,7 +3800,9 @@ def test_apply_assign_plate_to_miss_event_replays_the_crossing() -> None: assert engine.pending_misses() == () assert engine.crossings == ( - Crossing(entry_id="12", seq=1, crossed_at=_dt(10, 5), rider_plate="12"), + Crossing( + entry_id=_engine_key(engine, "12"), seq=1, crossed_at=_dt(10, 5), rider_plate="12" + ), ) assert engine.events[-1] == event @@ -3735,6 +3815,7 @@ def test_apply_replay_record_miss_then_assign_is_equivalent() -> None: live.assign_plate_to_miss(1, "12", reason="rider identified") replayed, _ = _make_engine() + restore_entry_keys(replayed, live) # logic-coverage-exempt: T-8 -- the loop is pure Arrange # (re-applying the recorded log); assertions run after the loop. for event in live.events: @@ -4022,7 +4103,7 @@ def test_edit_crossing_given_a_time_at_the_previous_lap_is_refused() -> None: engine.record_crossing("12", at=_dt(10, 40)) with pytest.raises(ValueError, match=re.escape(_REFUSAL)): - engine.edit_crossing("12", 2, _dt(10, 30), reason="mis-keyed time") + engine.edit_crossing(_engine_key(engine, "12"), 2, _dt(10, 30), reason="mis-keyed time") def test_edit_crossing_given_a_time_before_the_previous_lap_is_refused() -> None: @@ -4033,7 +4114,7 @@ def test_edit_crossing_given_a_time_before_the_previous_lap_is_refused() -> None engine.record_crossing("12", at=_dt(10, 40)) with pytest.raises(ValueError, match=re.escape(_REFUSAL)): - engine.edit_crossing("12", 2, _dt(10, 29), reason="mis-keyed time") + engine.edit_crossing(_engine_key(engine, "12"), 2, _dt(10, 29), reason="mis-keyed time") def test_edit_crossing_given_a_time_at_actual_start_on_lap_one_is_refused() -> None: @@ -4043,7 +4124,7 @@ def test_edit_crossing_given_a_time_at_actual_start_on_lap_one_is_refused() -> N engine.record_crossing("12", at=_dt(10, 30)) with pytest.raises(ValueError, match=re.escape(_REFUSAL)): - engine.edit_crossing("12", 1, _dt(10, 0), reason="mis-keyed time") + engine.edit_crossing(_engine_key(engine, "12"), 1, _dt(10, 0), reason="mis-keyed time") def test_edit_crossing_given_a_time_before_actual_start_on_lap_one_is_refused() -> None: @@ -4053,7 +4134,7 @@ def test_edit_crossing_given_a_time_before_actual_start_on_lap_one_is_refused() engine.record_crossing("12", at=_dt(10, 30)) with pytest.raises(ValueError, match=re.escape(_REFUSAL)): - engine.edit_crossing("12", 1, _dt(9, 59, 59), reason="mis-keyed time") + engine.edit_crossing(_engine_key(engine, "12"), 1, _dt(9, 59, 59), reason="mis-keyed time") def test_edit_crossing_given_a_time_one_second_after_the_previous_lap_is_accepted() -> None: @@ -4063,9 +4144,9 @@ def test_edit_crossing_given_a_time_one_second_after_the_previous_lap_is_accepte engine.record_crossing("12", at=_dt(10, 30)) engine.record_crossing("12", at=_dt(10, 40)) - engine.edit_crossing("12", 2, _dt(10, 30, 1), reason="mis-keyed time") + engine.edit_crossing(_engine_key(engine, "12"), 2, _dt(10, 30, 1), reason="mis-keyed time") - assert engine.lap_times("12") == (1800.0, 1.0) + assert engine.lap_times(_engine_key(engine, "12")) == (1800.0, 1.0) def test_edit_crossing_given_a_later_time_recomputes_the_lap() -> None: @@ -4075,9 +4156,9 @@ def test_edit_crossing_given_a_later_time_recomputes_the_lap() -> None: engine.record_crossing("12", at=_dt(10, 30)) engine.record_crossing("12", at=_dt(10, 40)) - engine.edit_crossing("12", 2, _dt(10, 45), reason="mis-keyed time") + engine.edit_crossing(_engine_key(engine, "12"), 2, _dt(10, 45), reason="mis-keyed time") - assert engine.lap_times("12") == (1800.0, 900.0) + assert engine.lap_times(_engine_key(engine, "12")) == (1800.0, 900.0) def test_edit_crossing_given_a_refused_time_leaves_the_ride_untouched() -> None: @@ -4089,7 +4170,7 @@ def test_edit_crossing_given_a_refused_time_leaves_the_ride_untouched() -> None: events_before = len(engine.events) with pytest.raises(ValueError, match=re.escape(_REFUSAL)): - engine.edit_crossing("12", 2, _dt(10, 30), reason="mis-keyed time") + engine.edit_crossing(_engine_key(engine, "12"), 2, _dt(10, 30), reason="mis-keyed time") assert (len(engine.events), engine.crossings[-1].crossed_at) == ( events_before, @@ -4144,7 +4225,7 @@ def test_add_crossing_at_given_a_time_after_the_latest_crossing_is_accepted() -> engine.add_crossing_at("12", _dt(10, 30, 1), reason="missed crossing") - assert engine.lap_times("12") == (1800.0, 1.0) + assert engine.lap_times(_engine_key(engine, "12")) == (1800.0, 1.0) def test_add_crossing_at_given_a_time_after_actual_start_with_no_crossings_is_accepted() -> None: @@ -4154,7 +4235,7 @@ def test_add_crossing_at_given_a_time_after_actual_start_with_no_crossings_is_ac engine.add_crossing_at("12", _dt(10, 0, 1), reason="missed crossing") - assert engine.lap_times("12") == (1.0,) + assert engine.lap_times(_engine_key(engine, "12")) == (1.0,) def test_add_crossing_at_given_a_refused_time_deals_no_card_and_appends_no_event() -> None: @@ -4240,7 +4321,7 @@ def test_duplicate_crossings_given_one_twin_voided_returns_an_empty_tuple() -> N engine.record_crossing("12", at=_dt(10, 30)) engine.record_crossing("12", at=_dt(10, 30)) - engine.void_crossing("12", 2, reason="double entry") + engine.void_crossing(_engine_key(engine, "12"), 2, reason="double entry") assert engine.duplicate_crossings() == () @@ -4277,7 +4358,7 @@ def test_apply_edit_crossing_given_a_zero_lap_event_raises_value_error() -> None event = Event( action="edit_crossing", payload={ - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "seq": 2, "previous_crossed_at": "2026-09-20T10:40:00", "crossed_at": "2026-09-20T10:30:00", @@ -4298,7 +4379,7 @@ def test_apply_edit_crossing_given_a_negative_lap_event_raises_value_error() -> event = Event( action="edit_crossing", payload={ - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "seq": 2, "previous_crossed_at": "2026-09-20T10:40:00", "crossed_at": "2026-09-20T10:29:00", @@ -4318,7 +4399,7 @@ def test_apply_edit_crossing_given_lap_one_at_actual_start_raises_value_error() event = Event( action="edit_crossing", payload={ - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "seq": 1, "previous_crossed_at": "2026-09-20T10:30:00", "crossed_at": "2026-09-20T10:00:00", @@ -4339,7 +4420,7 @@ def test_apply_add_crossing_at_given_a_time_at_the_latest_crossing_raises_value_ action="add_crossing_at", payload={ "plate": "12", - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "crossed_at": "2026-09-20T10:30:00", "reason": "missed crossing", }, @@ -4358,7 +4439,7 @@ def test_apply_add_crossing_at_given_a_back_dated_event_raises_value_error() -> action="add_crossing_at", payload={ "plate": "12", - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "crossed_at": "2026-09-20T10:25:00", "reason": "missed crossing", }, @@ -4376,7 +4457,7 @@ def test_apply_add_crossing_at_given_a_first_lap_at_actual_start_raises_value_er action="add_crossing_at", payload={ "plate": "12", - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "crossed_at": "2026-09-20T10:00:00", "reason": "missed crossing", }, @@ -4395,7 +4476,7 @@ def test_apply_edit_crossing_given_a_legal_event_still_replays_it() -> None: event = Event( action="edit_crossing", payload={ - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "seq": 2, "previous_crossed_at": "2026-09-20T10:40:00", "crossed_at": "2026-09-20T10:45:00", @@ -4405,7 +4486,10 @@ def test_apply_edit_crossing_given_a_legal_event_still_replays_it() -> None: engine.apply(event) - assert (engine.lap_times("12"), engine.events[-1]) == ((1800.0, 900.0), event) + assert (engine.lap_times(_engine_key(engine, "12")), engine.events[-1]) == ( + (1800.0, 900.0), + event, + ) def test_apply_add_crossing_at_given_a_legal_event_still_replays_it() -> None: @@ -4417,7 +4501,7 @@ def test_apply_add_crossing_at_given_a_legal_event_still_replays_it() -> None: action="add_crossing_at", payload={ "plate": "12", - "entry_id": "12", + "entry_id": _engine_key(engine, "12"), "crossed_at": "2026-09-20T10:35:00", "reason": "missed crossing", }, @@ -4425,7 +4509,10 @@ def test_apply_add_crossing_at_given_a_legal_event_still_replays_it() -> None: engine.apply(event) - assert (engine.lap_times("12"), engine.events[-1]) == ((1800.0, 300.0), event) + assert (engine.lap_times(_engine_key(engine, "12")), engine.events[-1]) == ( + (1800.0, 300.0), + event, + ) # ======================= scope 6d: the audit reason column @@ -4685,10 +4772,10 @@ def _run_reason_carrying(engine: RideEngine, action: str) -> None: engine.deal_manual("12", reason=_TYPED_REASON) case "edit_crossing": engine.record_crossing("12", at=_dt(10, 30)) - engine.edit_crossing("12", 1, _dt(10, 31), reason=_TYPED_REASON) + engine.edit_crossing(_engine_key(engine, "12"), 1, _dt(10, 31), reason=_TYPED_REASON) case "void_crossing": engine.record_crossing("12", at=_dt(10, 30)) - engine.void_crossing("12", 1, reason=_TYPED_REASON) + engine.void_crossing(_engine_key(engine, "12"), 1, reason=_TYPED_REASON) case "add_crossing_at": engine.add_crossing_at("12", _dt(10, 30), reason=_TYPED_REASON) case "assign_plate_to_miss": @@ -4739,7 +4826,10 @@ def test_audit_reason_given_a_reason_carrying_action_keeps_its_own_reason( # The default engine's shoe seed, salted by the draw's own constant, # gives this deck. The default roster has no crossings, so both empty -# hands tie and the pair draws that deck's first two cards. +# hands tie and the pair draws that deck's first two cards. Keyed by +# plate: the engine files a draw under the entry's stable key, and +# :func:`_draws_by_plate` reads it back the way the operator names the +# entry. _DEFAULT_DRAW: dict[str, Card] = { "12": Card.parse("5H"), "34": Card.parse("AH"), @@ -4760,6 +4850,18 @@ def test_audit_reason_given_a_reason_carrying_action_keeps_its_own_reason( } +def _draws_by_plate(engine: RideEngine) -> dict[str, Card]: + """Return *engine*'s drawn tie-break cards keyed by plate. + + The engine files each draw under the entry's stable ``key`` + (``_tiebreak``); the expectation tables above name entries by the + plate the operator knows, so this projection is what they compare + against. + """ + plates_by_key = {entry.key: entry.plate for entry in engine._roster.entries} + return {plates_by_key[key]: card for key, card in engine._tiebreak.items()} + + def test_finish_given_two_equal_hands_draws_one_card_each() -> None: """R-14: a tied pair draws one card each from one fresh deck.""" engine, _ = _make_engine() @@ -4767,7 +4869,7 @@ def test_finish_given_two_equal_hands_draws_one_card_each() -> None: engine.finish() - assert engine._tiebreak == _DEFAULT_DRAW + assert _draws_by_plate(engine) == _DEFAULT_DRAW def test_finish_given_two_equal_hands_appends_a_tiebreak_draw_event() -> None: @@ -4781,8 +4883,8 @@ def test_finish_given_two_equal_hands_appends_a_tiebreak_draw_event() -> None: action="tiebreak_draw", payload={ "draws": [ - {"entry_id": "12", "card": "5H"}, - {"entry_id": "34", "card": "AH"}, + {"entry_id": _engine_key(engine, "12"), "card": "5H"}, + {"entry_id": _engine_key(engine, "34"), "card": "AH"}, ], "summary": "12 · 5H, 34 · AH", }, @@ -4879,7 +4981,7 @@ def test_finish_given_two_tie_groups_hands_out_one_deck_in_order() -> None: engine.finish() - assert engine._tiebreak == _RANK_TIE_DRAW + assert _draws_by_plate(engine) == _RANK_TIE_DRAW def _tied_field(count: int) -> Roster: @@ -4904,7 +5006,7 @@ def test_finish_given_fifty_three_tied_entries_leaves_the_extra_undrawn() -> Non engine.finish() - assert (len(engine._tiebreak), engine._tiebreak.get("53")) == (52, None) + assert (len(engine._tiebreak), _draws_by_plate(engine).get("53")) == (52, None) def test_finish_given_the_same_stored_seed_draws_the_same_cards() -> None: @@ -4917,7 +5019,7 @@ def test_finish_given_the_same_stored_seed_draws_the_same_cards() -> None: second.finish() - assert second._tiebreak == first._tiebreak + assert _draws_by_plate(second) == _draws_by_plate(first) def test_finish_given_a_tie_draws_without_dealing_from_the_shoe() -> None: @@ -4955,7 +5057,10 @@ def test_start_given_a_reopened_ride_clears_the_recorded_draws() -> None: engine.apply( Event( action="tiebreak_draw", - payload={"draws": [{"entry_id": "12", "card": "AS"}], "summary": "12 · AS"}, + payload={ + "draws": [{"entry_id": _engine_key(engine, "12"), "card": "AS"}], + "summary": "12 · AS", + }, ) ) @@ -4971,8 +5076,8 @@ def test_apply_tiebreak_draw_event_restores_the_draws_from_its_payload() -> None action="tiebreak_draw", payload={ "draws": [ - {"entry_id": "12", "card": "AS"}, - {"entry_id": "34", "card": "7C"}, + {"entry_id": _engine_key(engine, "12"), "card": "AS"}, + {"entry_id": _engine_key(engine, "34"), "card": "7C"}, ], "summary": "12 · AS, 34 · 7C", }, @@ -4981,7 +5086,10 @@ def test_apply_tiebreak_draw_event_restores_the_draws_from_its_payload() -> None engine.apply(event) assert (engine._tiebreak, engine.events[-1]) == ( - {"12": Card.parse("AS"), "34": Card.parse("7C")}, + { + _engine_key(engine, "12"): Card.parse("AS"), + _engine_key(engine, "34"): Card.parse("7C"), + }, event, ) @@ -5008,9 +5116,16 @@ def test_apply_tiebreak_draw_given_a_non_row_list_reads_no_draws( assert engine._tiebreak == {} -def _replay(events: tuple[Event, ...]) -> RideEngine: - """Rebuild an engine by applying *events* in order (E5.1.2).""" +def _replay(events: tuple[Event, ...], *, source: RideEngine | None = None) -> RideEngine: + """Rebuild an engine by applying *events* in order (E5.1.2). + + *source*, when given, lends the replayed roster its entry keys -- + what ``Store.load_engine``'s roster carries back from the persisted + ``entry`` rows, and what the recorded events were filed under. + """ engine, _ = _make_engine() + if source is not None: + restore_entry_keys(engine, source) for event in events: engine.apply(event) return engine @@ -5022,7 +5137,7 @@ def test_apply_replay_of_a_finished_ride_reproduces_the_draw() -> None: live.start(at=_dt(10, 0)) live.finish() - replayed = _replay(live.events) + replayed = _replay(live.events, source=live) assert (replayed._tiebreak, replayed.snapshot()) == (live._tiebreak, live.snapshot()) assert replayed.events == live.events diff --git a/tests/unit/test_ride_corrections.py b/tests/unit/test_ride_corrections.py index 05e810fd..3bd16bed 100644 --- a/tests/unit/test_ride_corrections.py +++ b/tests/unit/test_ride_corrections.py @@ -23,7 +23,7 @@ import pytest -from conftest import _roster_with_entries +from conftest import _roster_with_entries, entry_key, restore_entry_keys from rivercrossing.cards import Card, Shoe from rivercrossing.hands import best_hand from rivercrossing.ride import ( @@ -142,6 +142,17 @@ def _make_engine( return engine, clock +def _entry_key(engine: RideEngine, plate: str) -> str: + """Return the stable entry key *plate* resolves to (arrange). + + ``edit_crossing``/``void_crossing`` address a crossing by its + entry's stable key and its own ``seq`` -- the identity a re-plating + leaves alone -- while these tests name entries by the plate the + operator types. + """ + return entry_key(engine._roster, plate) + + def _engine_in(state: str) -> tuple[RideEngine, _FakeClock]: """Build an engine already in one of the lifecycle states.""" engine, clock = _make_engine() @@ -167,12 +178,12 @@ def test_edit_crossing_audits_action_entry_previous_and_new_time() -> None: engine.record_crossing("12", at=_dt(10, 30)) before = len(engine.events) - event = engine.edit_crossing("12", 1, _dt(10, 31), reason="mis-keyed time") + event = engine.edit_crossing(_entry_key(engine, "12"), 1, _dt(10, 31), reason="mis-keyed time") assert event == Event( action="edit_crossing", payload={ - "entry_id": "12", + "entry_id": _entry_key(engine, "12"), "seq": 1, "previous_crossed_at": "2026-09-20T10:30:00", "crossed_at": "2026-09-20T10:31:00", @@ -190,9 +201,12 @@ def test_edit_crossing_changes_only_the_timestamp_and_recomputes_laps() -> None: second = engine.record_crossing("12", at=_dt(10, 40)) dealt_before = engine._shoe.dealt - engine.edit_crossing("12", 2, _dt(10, 35), reason="mis-keyed time") + engine.edit_crossing(_entry_key(engine, "12"), 2, _dt(10, 35), reason="mis-keyed time") - assert engine.lap_times("12") == (1800.0, 300.0) # 10:30-10:00, 10:35-10:30 + assert engine.lap_times(_entry_key(engine, "12")) == ( + 1800.0, + 300.0, + ) # 10:30-10:00, 10:35-10:30 assert engine._shoe.dealt == dealt_before # no re-deal on edit assert engine.crossings[-1].crossed_at == _dt(10, 35) assert engine.card_for(engine.crossings[-1]) == second.card @@ -205,7 +219,7 @@ def test_edit_crossing_held_crossing_keeps_the_card_held() -> None: engine.record_crossing("12", at=_dt(10, 0, 30)) # 30 s < min_lap_s -> held held_before = engine.held_crossings()[0] - engine.edit_crossing("12", 1, _dt(10, 0, 45), reason="mis-keyed time") + engine.edit_crossing(_entry_key(engine, "12"), 1, _dt(10, 0, 45), reason="mis-keyed time") held = engine.held_crossings() assert len(held) == 1 @@ -221,7 +235,7 @@ def test_edit_crossing_empty_reason_is_refused() -> None: engine.record_crossing("12", at=_dt(10, 30)) with pytest.raises(ValueError, match=re.escape("reason must not be empty")): - engine.edit_crossing("12", 1, _dt(10, 31), reason="") + engine.edit_crossing(_entry_key(engine, "12"), 1, _dt(10, 31), reason="") @pytest.mark.parametrize( @@ -239,22 +253,19 @@ def test_edit_crossing_from_non_live_state_raises_illegal_state_error( engine, _ = _engine_in(start_state) with pytest.raises(IllegalStateError, match=re.escape(match)): - engine.edit_crossing("12", 1, _dt(10, 31), reason="mis-keyed time") + engine.edit_crossing(_entry_key(engine, "12"), 1, _dt(10, 31), reason="mis-keyed time") -@pytest.mark.parametrize( - ("seq", "match"), - [(0, "no crossing with entry_id 12 seq 0"), (9, "no crossing with entry_id 12 seq 9")], - ids=["min-1", "max+1"], -) -def test_edit_crossing_unknown_crossing_raises_illegal_state_error(seq: int, match: str) -> None: +@pytest.mark.parametrize("seq", [0, 9], ids=["min-1", "max+1"]) +def test_edit_crossing_unknown_crossing_raises_illegal_state_error(seq: int) -> None: """Editing a crossing the engine never recorded fails loudly.""" engine, _ = _make_engine() engine.start() engine.record_crossing("12", at=_dt(10, 30)) + key = _entry_key(engine, "12") - with pytest.raises(IllegalStateError, match=re.escape(match)): - engine.edit_crossing("12", seq, _dt(10, 31), reason="mis-keyed time") + with pytest.raises(IllegalStateError, match=re.escape(f"no crossing with entry_id {key} seq")): + engine.edit_crossing(key, seq, _dt(10, 31), reason="mis-keyed time") # ==================================================== void_crossing @@ -267,11 +278,11 @@ def test_void_crossing_audits_entry_seq_and_reason() -> None: engine.record_crossing("12", at=_dt(10, 30)) before = len(engine.events) - event = engine.void_crossing("12", 1, reason="double entry") + event = engine.void_crossing(_entry_key(engine, "12"), 1, reason="double entry") assert event == Event( action="void_crossing", - payload={"entry_id": "12", "seq": 1, "reason": "double entry"}, + payload={"entry_id": _entry_key(engine, "12"), "seq": 1, "reason": "double entry"}, ) assert len(engine.events) == before + 1 @@ -284,11 +295,14 @@ def test_void_crossing_voids_card_and_renumbers_later_laps() -> None: second = engine.record_crossing("12", at=_dt(10, 32)) third = engine.record_crossing("12", at=_dt(10, 34)) - engine.void_crossing("12", 2, reason="double entry") + engine.void_crossing(_entry_key(engine, "12"), 2, reason="double entry") assert [c.seq for c in engine.crossings] == [1, 2] assert [c.crossed_at for c in engine.crossings] == [_dt(10, 30), _dt(10, 34)] - assert engine.lap_times("12") == (1800.0, 240.0) # 10:30-10:00, 10:34-10:30 + assert engine.lap_times(_entry_key(engine, "12")) == ( + 1800.0, + 240.0, + ) # 10:30-10:00, 10:34-10:30 results = {entry.plate: entry for entry in engine.snapshot()} assert results["12"].laps == 2 assert results["12"].cards == (first.card, third.card) # second's card voided @@ -302,7 +316,7 @@ def test_void_crossing_does_not_restitute_the_card_to_the_shoe() -> None: engine.record_crossing("12", at=_dt(10, 30)) dealt_before = engine._shoe.dealt - engine.void_crossing("12", 1, reason="double entry") + engine.void_crossing(_entry_key(engine, "12"), 1, reason="double entry") assert engine._shoe.dealt == dealt_before # card retired, not returned @@ -314,7 +328,7 @@ def test_void_crossing_empty_reason_is_refused() -> None: engine.record_crossing("12", at=_dt(10, 30)) with pytest.raises(ValueError, match=re.escape("reason must not be empty")): - engine.void_crossing("12", 1, reason="") + engine.void_crossing(_entry_key(engine, "12"), 1, reason="") @pytest.mark.parametrize( @@ -332,22 +346,19 @@ def test_void_crossing_from_non_live_state_raises_illegal_state_error( engine, _ = _engine_in(start_state) with pytest.raises(IllegalStateError, match=re.escape(match)): - engine.void_crossing("12", 1, reason="double entry") + engine.void_crossing(_entry_key(engine, "12"), 1, reason="double entry") -@pytest.mark.parametrize( - ("seq", "match"), - [(0, "no crossing with entry_id 12 seq 0"), (9, "no crossing with entry_id 12 seq 9")], - ids=["min-1", "max+1"], -) -def test_void_crossing_unknown_crossing_raises_illegal_state_error(seq: int, match: str) -> None: +@pytest.mark.parametrize("seq", [0, 9], ids=["min-1", "max+1"]) +def test_void_crossing_unknown_crossing_raises_illegal_state_error(seq: int) -> None: """Voiding a crossing the engine never recorded fails loudly.""" engine, _ = _make_engine() engine.start() engine.record_crossing("12", at=_dt(10, 30)) + key = _entry_key(engine, "12") - with pytest.raises(IllegalStateError, match=re.escape(match)): - engine.void_crossing("12", seq, reason="double entry") + with pytest.raises(IllegalStateError, match=re.escape(f"no crossing with entry_id {key} seq")): + engine.void_crossing(key, seq, reason="double entry") # =================================================== add_crossing_at @@ -365,7 +376,7 @@ def test_add_crossing_at_audits_plate_entry_crossed_at_and_reason() -> None: action="add_crossing_at", payload={ "plate": "12", - "entry_id": "12", + "entry_id": _entry_key(engine, "12"), "crossed_at": "2026-09-20T10:15:00", "reason": "missed crossing", }, @@ -473,8 +484,8 @@ def test_reassign_crossing_audits_seq_old_entry_new_entry_new_plate_and_reason() action="reassign", payload={ "seq": 2, - "old_entry_id": "12", - "new_entry_id": "34", + "old_entry_id": _entry_key(engine, "12"), + "new_entry_id": _entry_key(engine, "34"), "new_plate": "34", "reason": "mis-keyed plate", }, @@ -493,8 +504,8 @@ def test_reassign_crossing_moves_crossing_and_card_to_the_new_entry() -> None: engine.reassign_crossing(2, "34", reason="mis-keyed plate") - crossings_12 = [c for c in engine.crossings if c.entry_id == "12"] - crossings_34 = [c for c in engine.crossings if c.entry_id == "34"] + crossings_12 = [c for c in engine.crossings if c.entry_id == _entry_key(engine, "12")] + crossings_34 = [c for c in engine.crossings if c.entry_id == _entry_key(engine, "34")] assert [(c.seq, c.crossed_at) for c in crossings_12] == [(1, _dt(10, 30))] assert [(c.seq, c.crossed_at) for c in crossings_34] == [ (1, _dt(10, 34)), @@ -518,7 +529,7 @@ def test_reassign_crossing_held_card_travels_while_still_held() -> None: moved = engine.held_crossings() assert len(moved) == 1 - assert moved[0].crossing.entry_id == "34" + assert moved[0].crossing.entry_id == _entry_key(engine, "34") assert moved[0].card == held.card @@ -632,7 +643,7 @@ def test_mark_dnf_audits_entry_plate_scope_and_reason() -> None: assert event == Event( action="dnf", payload={ - "entry_id": "12", + "entry_id": _entry_key(engine, "12"), "plate": "12", "rider": False, "reason": "mechanical failure", @@ -667,7 +678,7 @@ def test_mark_dnf_pooled_rider_plate_marks_that_rider_not_the_team() -> None: event = engine.mark_dnf("45", reason="mechanical failure") assert event.payload == { - "entry_id": "9", + "entry_id": _entry_key(engine, "9"), "plate": "45", "rider": True, "reason": "mechanical failure", @@ -694,7 +705,7 @@ def test_mark_dnf_relay_team_plate_marks_the_whole_entry() -> None: event = engine.mark_dnf("77", reason="mechanical failure") assert event.payload == { - "entry_id": "77", + "entry_id": _entry_key(engine, "77"), "plate": "77", "rider": False, "reason": "mechanical failure", @@ -754,7 +765,11 @@ def test_void_card_audits_entry_card_and_reason() -> None: assert event == Event( action="void_card", - payload={"entry_id": "12", "card": result.card.code(), "reason": "wrong card dealt"}, + payload={ + "entry_id": _entry_key(engine, "12"), + "card": result.card.code(), + "reason": "wrong card dealt", + }, ) assert len(engine.events) == before + 1 @@ -877,7 +892,7 @@ def test_apply_edit_crossing_event_recomputes_the_timestamp() -> None: event = Event( action="edit_crossing", payload={ - "entry_id": "12", + "entry_id": _entry_key(engine, "12"), "seq": 1, "previous_crossed_at": "2026-09-20T10:30:00", "crossed_at": "2026-09-20T10:35:00", @@ -887,7 +902,7 @@ def test_apply_edit_crossing_event_recomputes_the_timestamp() -> None: engine.apply(event) - assert engine.lap_times("12") == (2100.0,) + assert engine.lap_times(_entry_key(engine, "12")) == (2100.0,) assert engine.events[-1] == event @@ -900,13 +915,13 @@ def test_apply_void_crossing_event_voids_and_renumbers() -> None: engine.record_crossing("12", at=_dt(10, 34)) event = Event( action="void_crossing", - payload={"entry_id": "12", "seq": 2, "reason": "double entry"}, + payload={"entry_id": _entry_key(engine, "12"), "seq": 2, "reason": "double entry"}, ) engine.apply(event) assert [c.seq for c in engine.crossings] == [1, 2] - assert engine.lap_times("12") == (1800.0, 240.0) + assert engine.lap_times(_entry_key(engine, "12")) == (1800.0, 240.0) assert engine.events[-1] == event @@ -918,7 +933,7 @@ def test_apply_add_crossing_at_event_deals_the_next_card() -> None: action="add_crossing_at", payload={ "plate": "12", - "entry_id": "12", + "entry_id": _entry_key(engine, "12"), "crossed_at": "2026-09-20T10:15:00", "reason": "missed crossing", }, @@ -926,7 +941,7 @@ def test_apply_add_crossing_at_event_deals_the_next_card() -> None: engine.apply(event) - assert engine.lap_times("12") == (900.0,) + assert engine.lap_times(_entry_key(engine, "12")) == (900.0,) assert engine.events[-1] == event assert engine._shoe.dealt == 1 @@ -943,8 +958,8 @@ def test_apply_reassign_event_moves_crossing_and_card() -> None: action="reassign", payload={ "seq": 2, - "old_entry_id": "12", - "new_entry_id": "34", + "old_entry_id": _entry_key(engine, "12"), + "new_entry_id": _entry_key(engine, "34"), "new_plate": "34", "reason": "mis-keyed plate", }, @@ -952,7 +967,7 @@ def test_apply_reassign_event_moves_crossing_and_card() -> None: engine.apply(event) - crossings_34 = [c for c in engine.crossings if c.entry_id == "34"] + crossings_34 = [c for c in engine.crossings if c.entry_id == _entry_key(engine, "34")] assert [(c.seq, c.crossed_at) for c in crossings_34] == [ (1, _dt(10, 34)), (2, _dt(10, 32)), @@ -967,7 +982,12 @@ def test_apply_dnf_event_marks_the_entry() -> None: engine.start(at=_dt(10, 0)) event = Event( action="dnf", - payload={"entry_id": "12", "plate": "12", "rider": False, "reason": "mechanical failure"}, + payload={ + "entry_id": _entry_key(engine, "12"), + "plate": "12", + "rider": False, + "reason": "mechanical failure", + }, ) engine.apply(event) @@ -990,7 +1010,7 @@ def test_apply_dnf_event_given_an_audit_only_display_ignores_it() -> None: event = Event( action="dnf", payload={ - "entry_id": "12", + "entry_id": _entry_key(engine, "12"), "plate": "12", "rider": False, "reason": "mechanical failure", @@ -1018,6 +1038,7 @@ def test_apply_dnf_rider_event_marks_the_rider_scope_from_the_payload() -> None: live.record_crossing("9", at=_dt(10, 4)) live.mark_dnf("45", reason="mechanical failure") replayed, _ = _make_engine(roster=_pooled_team_roster(), config=_config(min_lap_s=1)) + restore_entry_keys(replayed, live) for event in live.events: replayed.apply(event) @@ -1032,7 +1053,11 @@ def test_apply_void_card_event_removes_the_card_from_the_hand() -> None: card = engine.card_for(engine.crossings[0]) event = Event( action="void_card", - payload={"entry_id": "12", "card": card.code(), "reason": "wrong card dealt"}, + payload={ + "entry_id": _entry_key(engine, "12"), + "card": card.code(), + "reason": "wrong card dealt", + }, ) engine.apply(event) @@ -1058,7 +1083,7 @@ def test_apply_confirm_held_event_finds_the_target_beyond_the_first_crossing() - held = engine.held_crossings()[0] event = Event( action="confirm_held", - payload={"entry_id": "12", "seq": 2, "card": held.card.code()}, + payload={"entry_id": _entry_key(engine, "12"), "seq": 2, "card": held.card.code()}, ) engine.apply(event) @@ -1079,21 +1104,25 @@ def test_apply_confirm_held_event_finds_the_target_beyond_the_first_crossing() - # ============== E3.1.2 pooled live move vs replay equivalence -def test_ride_move_replay_diverges_red() -> None: - """A RUNNING pooled move leaves the live engine unlike a replay. +def test_ride_move_replay_stays_equivalent_across_a_pooled_move() -> None: + """A RUNNING pooled move leaves the live engine like a replay. - RED -- a de-risk spike for the pooled-live-move work, pinning the - seam that work must close. The engine keys ``_laps``/``_hand`` and - each ``Crossing.entry_id`` by ``entry.plate``, and a + The de-risk spike for the pooled-live-move work, now GREEN: the + engine keys ``_laps``/``_hand`` and each ``Crossing.entry_id`` by + the entry's own stable ``key`` (``Entry.key``), and a ``rider_pooled`` team's plate is *derived* from its lowest-numbered member and re-derived by ``Roster.move_rider``. E3.1.2's lock matrix keeps team-to-team moves open while the ride is RUNNING, so a mid-ride move re-plates both teams with no engine - notification: the live engine keeps the crossings, the held card - and the credited hand under team A's old plate "1", while a fresh - replay of the same event log against the FINAL roster resolves the - typed plate "2" to team A's new plate "2" and keys them there. - Live and replay must agree; today they do not. + notification: because the crossings were filed under the team's + key, not its derived plate, the live crossings, held card and + credited hand stay with team A, and a replay of the same event log + against the FINAL roster resolves the same keys to the same + entries. Live and replay agree. + + The replayed roster's keys are restored from the live one's, which + is what ``Store.load_engine`` does when it rebuilds the roster from + the persisted ``entry`` rows; two hand-built rosters mint their own. """ config = _config(hold_short_laps=True) live_roster = _two_team_pooled_roster() @@ -1110,21 +1139,23 @@ def test_ride_move_replay_diverges_red() -> None: live_roster.move_rider(anchor, to_entry=team_b) # The Store.load_engine rebuild: a fresh same-seed shoe and clock, - # the FINAL roster, then the live engine's own event log. + # the FINAL roster (its entries carrying the persisted keys), then + # the live engine's own event log. replay_roster = _two_team_pooled_roster() replay_a, replay_b = replay_roster.entries replay_anchor = next(rider for rider in replay_a.riders if rider.plate == "1") replay_roster.move_rider(replay_anchor, to_entry=replay_b) replayed, _ = _make_engine(roster=replay_roster, config=config) + restore_entry_keys(replayed, live) for event in live.events: replayed.apply(event) assert ( replayed.crossings, replayed.held_crossings(), - tuple(replayed.credited_cards(entry.plate) for entry in replayed._roster.entries), + tuple(replayed.credited_cards(entry.key) for entry in replayed._roster.entries), ) == ( live.crossings, live.held_crossings(), - tuple(live.credited_cards(entry.plate) for entry in live._roster.entries), + tuple(live.credited_cards(entry.key) for entry in live._roster.entries), ) diff --git a/tests/unit/test_ride_laps_index.py b/tests/unit/test_ride_laps_index.py index 4902cfd3..c9b5f7f4 100644 --- a/tests/unit/test_ride_laps_index.py +++ b/tests/unit/test_ride_laps_index.py @@ -27,7 +27,7 @@ import pytest -from conftest import _roster_with_entries +from conftest import _roster_with_entries, entry_key, restore_entry_keys from rivercrossing.cards import Shoe from rivercrossing.ride import RideConfig, RideEngine from rivercrossing.roster import EntryMode, PlateModel, Roster @@ -105,15 +105,25 @@ def _engine_with_corrected_ride() -> RideEngine: engine.record_crossing("12", at=_dt(10, 30)) engine.record_crossing("12", at=_dt(10, 40)) engine.add_crossing_at("12", _dt(10, 50), reason="missed crossing") - engine.edit_crossing("12", 3, _dt(10, 45), reason="mis-keyed time") + engine.edit_crossing(_entry_key(engine, "12"), 3, _dt(10, 45), reason="mis-keyed time") engine.undo_last() engine.record_crossing("12", at=_dt(10, 50)) engine.record_crossing("34", at=_dt(10, 35)) engine.reassign_crossing(3, "34", reason="mis-keyed plate") - engine.void_crossing("34", 1, reason="double entry") + engine.void_crossing(_entry_key(engine, "34"), 1, reason="double entry") return engine +def _entry_key(engine: RideEngine, plate: str) -> str: + """Return the stable entry key *plate* resolves to (arrange). + + ``_laps_for`` and the correction commands take an entry id, which + is the entry's ``key`` -- the identity a re-plating leaves alone -- + while these tests name entries by the operator's plate. + """ + return entry_key(engine._roster, plate) + + class _ExplodingCrossings(list): """A ``_crossings`` stand-in that fails loudly if scanned. @@ -136,8 +146,8 @@ def test_laps_for_mixed_corrections_returns_sorted_tuple_and_snapshot_matches() """The index agrees with the derived timing after every mutator.""" engine = _engine_with_corrected_ride() - laps_12 = engine._laps_for("12") - laps_34 = engine._laps_for("34") + laps_12 = engine._laps_for(_entry_key(engine, "12")) + laps_34 = engine._laps_for(_entry_key(engine, "34")) assert [(c.seq, c.crossed_at) for c in laps_12] == [(1, _dt(10, 30)), (2, _dt(10, 40))] assert [(c.seq, c.crossed_at) for c in laps_34] == [(1, _dt(10, 50))] @@ -160,7 +170,7 @@ def test_laps_for_entry_without_crossings_returns_empty_tuple() -> None: """An entry with no laps reports an empty index entry.""" engine, _ = _make_engine() - assert engine._laps_for("34") == () + assert engine._laps_for(_entry_key(engine, "34")) == () def test_laps_for_undo_of_out_of_order_crossing_keeps_chronological_order() -> None: @@ -178,10 +188,10 @@ def test_laps_for_undo_of_out_of_order_crossing_keeps_chronological_order() -> N engine.reassign_crossing(2, "34", reason="mis-keyed plate") engine.undo_last() - laps = engine._laps_for("34") + laps = engine._laps_for(_entry_key(engine, "34")) assert laps == (engine.crossings[0],) - assert engine.lap_times("34") == (2400.0,) + assert engine.lap_times(_entry_key(engine, "34")) == (2400.0,) def test_laps_index_tie_at_same_instant_keeps_record_order() -> None: @@ -192,7 +202,7 @@ def test_laps_index_tie_at_same_instant_keeps_record_order() -> None: engine.record_crossing("12", at=_dt(10, 30)) first, second = engine.crossings - laps = engine._laps_for("12") + laps = engine._laps_for(_entry_key(engine, "12")) assert laps == (first, second) @@ -206,9 +216,9 @@ def test_laps_index_renumber_keeps_tied_record_order() -> None: engine.record_crossing("12", at=_dt(10, 30)) engine.record_crossing("12", at=_dt(10, 32)) - engine.void_crossing("12", 1, reason="double entry") + engine.void_crossing(_entry_key(engine, "12"), 1, reason="double entry") - laps = engine._laps_for("12") + laps = engine._laps_for(_entry_key(engine, "12")) assert [(c.seq, c.crossed_at) for c in laps] == [ (1, _dt(10, 30)), (2, _dt(10, 30)), @@ -231,9 +241,9 @@ def test_laps_index_edit_to_tie_with_later_crossing_keeps_record_order() -> None engine.record_crossing("12", at=_dt(10, 31)) engine.record_crossing("12", at=_dt(10, 35)) - engine.edit_crossing("12", 2, _dt(10, 35), reason="mis-keyed time") + engine.edit_crossing(_entry_key(engine, "12"), 2, _dt(10, 35), reason="mis-keyed time") - laps = engine._laps_for("12") + laps = engine._laps_for(_entry_key(engine, "12")) assert [(c.seq, c.crossed_at) for c in laps] == [ (1, _dt(10, 30)), (2, _dt(10, 35)), @@ -245,10 +255,11 @@ def test_laps_index_edit_to_tie_with_later_crossing_keeps_record_order() -> None # ============================================== object identity -@pytest.mark.parametrize("entry_id", ["12", "34"]) -def test_laps_index_holds_the_same_objects_as_crossings(entry_id: str) -> None: +@pytest.mark.parametrize("plate", ["12", "34"]) +def test_laps_index_holds_the_same_objects_as_crossings(plate: str) -> None: """The index aliases _crossings' objects, never copies them.""" engine = _engine_with_corrected_ride() + entry_id = _entry_key(engine, plate) expected = [c for c in engine.crossings if c.entry_id == entry_id] laps = engine._laps_for(entry_id) @@ -276,7 +287,7 @@ def test_laps_for_returns_correct_laps_without_iterating_crossings() -> None: expected = (engine._crossings[0], engine._crossings[1]) engine._crossings = _ExplodingCrossings(engine._crossings) - laps = engine._laps_for("12") + laps = engine._laps_for(_entry_key(engine, "12")) assert laps == expected @@ -289,10 +300,13 @@ def test_laps_index_replay_of_corrected_ride_matches_live_index() -> None: live = _engine_with_corrected_ride() replayed, _ = _make_engine(config=_config(min_lap_s=1)) + restore_entry_keys(replayed, live) for event in live.events: replayed.apply(event) - assert replayed._laps_for("12") == live._laps_for("12") - assert replayed._laps_for("34") == live._laps_for("34") + key_12 = _entry_key(live, "12") + key_34 = _entry_key(live, "34") + assert replayed._laps_for(key_12) == live._laps_for(key_12) + assert replayed._laps_for(key_34) == live._laps_for(key_34) assert replayed.snapshot() == live.snapshot() assert replayed.on_course == live.on_course diff --git a/tests/unit/test_roster.py b/tests/unit/test_roster.py index 96dced37..4b2a5039 100644 --- a/tests/unit/test_roster.py +++ b/tests/unit/test_roster.py @@ -2509,6 +2509,113 @@ def test_resolve_plate_relay_unknown_plate_returns_none() -> None: assert roster.resolve_plate("999") is None +# ------------------------------------------------------- entry key +# The stable entry identity the ride engine keys its crossings and +# credited hands by (E3.1.2's pooled-live-move seam): a plate is +# mutable -- a pooled team re-derives its own from its members, and a +# mid-ride move re-derives it again -- so it can never be what a +# crossing is filed under. ``key`` is a per-entry surrogate: unique +# for the life of the entry, never shown, never edited. + + +def test_entry_bare_construction_carries_a_hex_key() -> None: + """A hand-built Entry still gets a usable stable key.""" + entry = Entry(plate="12", display_name="Alex", type=EntryType.SOLO) + + assert re.fullmatch(r"[0-9a-f]{32}", entry.key) + + +def test_create_solo_entry_assigns_distinct_keys() -> None: + """Each solo entry's key is its own, never a shared default.""" + roster = Roster() + first = roster.create_solo_entry(first_name="Alex", last_name="", plate="1") + second = roster.create_solo_entry(first_name="Bo", last_name="", plate="2") + + assert first.key != second.key + assert len(first.key) == 32 + + +def test_create_team_entry_assigns_distinct_keys() -> None: + """Each team entry's key is its own, never a shared default.""" + roster = Roster(entry_mode=EntryMode.MIXED) + first = roster.create_team_entry( + display_name="Team A", + riders=[Rider(first_name="Alex", plate="1"), Rider(first_name="Bo", plate="2")], + ) + second = roster.create_team_entry( + display_name="Team B", + riders=[Rider(first_name="Cy", plate="3"), Rider(first_name="Di", plate="4")], + ) + + assert first.key != second.key + assert len(second.key) == 32 + + +def test_create_empty_team_assigns_a_key() -> None: + """A riderless team carries a key like any other entry.""" + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.RIDER_POOLED) + + entry = roster.create_empty_team(display_name="Later") + + assert re.fullmatch(r"[0-9a-f]{32}", entry.key) + + +def test_extract_rider_to_solo_assigns_a_key_different_from_the_source_team() -> None: + """The rider's new solo entry is a new identity, not the team's.""" + roster = Roster(entry_mode=EntryMode.MIXED) + team = roster.create_team_entry( + display_name="Team A", + riders=[Rider(first_name="Alex", plate="1"), Rider(first_name="Bo", plate="2")], + ) + rider = team.riders[0] + + solo = roster.extract_rider_to_solo(rider) + + assert solo.key != team.key + assert len(solo.key) == 32 + + +def test_entry_by_key_returns_the_entry_carrying_that_key() -> None: + """The key resolves to its own entry -- the engine's lookup.""" + roster = Roster() + entry = roster.create_solo_entry(first_name="Alex", last_name="", plate="12") + + assert roster.entry_by_key(entry.key) is entry + + +def test_entry_by_key_unknown_key_returns_none() -> None: + """An unknown key resolves to None, not an error.""" + roster = Roster() + roster.create_solo_entry(first_name="Alex", last_name="", plate="12") + + assert roster.entry_by_key("0" * 32) is None + + +def test_entry_by_key_empty_key_returns_none() -> None: + """A present-but-empty key resolves to None.""" + roster = Roster() + roster.create_solo_entry(first_name="Alex", last_name="", plate="12") + + assert roster.entry_by_key("") is None + + +def test_entry_by_key_on_empty_roster_returns_none() -> None: + """No entries means no key resolution.""" + roster = Roster() + + assert roster.entry_by_key("0" * 32) is None + + +def test_entry_by_key_picks_the_matching_entry_among_many() -> None: + """The lookup keys off ``key`` alone, not roster position.""" + roster = Roster() + roster.create_solo_entry(first_name="Alex", last_name="", plate="1") + wanted = roster.create_solo_entry(first_name="Bo", last_name="", plate="2") + roster.create_solo_entry(first_name="Cy", last_name="", plate="3") + + assert roster.entry_by_key(wanted.key) is wanted + + # ======================================================== name split # Phase 1 (rider name split): first/last storage plus the full_name # projection every display/audit site now mirrors. diff --git a/tests/unit/test_store.py b/tests/unit/test_store.py index 89de931c..fde0af7a 100644 --- a/tests/unit/test_store.py +++ b/tests/unit/test_store.py @@ -36,6 +36,7 @@ from platformdirs import user_data_dir import rivercrossing.store as store_module +from conftest import entry_key from rivercrossing.cards import Card, Shoe, ShoeEmpty from rivercrossing.ride import ( DEFAULT_DECK_COUNT, @@ -209,8 +210,8 @@ def test_store_open_applies_spec_pragmas_to_every_connection(tmp_path: Path) -> with pytest.raises(sqlite3.IntegrityError, match=re.escape("FOREIGN KEY")): store._conn.execute( "INSERT INTO entry" - " (ride_id, plate, display_name, type, team_size, status)" - " VALUES (999, 'P1', 'ghost', 'solo', 1, 'active')" + " (ride_id, plate, key, display_name, type, team_size, status)" + " VALUES (999, 'P1', '2f7a', 'ghost', 'solo', 1, 'active')" ) finally: store.close() @@ -765,6 +766,18 @@ def _replay_roster() -> Roster: return roster +def _replay_roster_and_key() -> tuple[Roster, str]: + """Build the replay roster and its sole entry's key (arrange). + + A persisted payload names its entry by the stable key the entry + table carries (E3.1.2's pooled-live-move seam), so a hand-written + ``record_crossing``/``deal_manual`` row has to carry the roster's + own key for replay to resolve it. + """ + roster = _replay_roster() + return roster, entry_key(roster, "12") + + def test_store_append_persists_audit_row_with_event_timestamp(tmp_path: Path) -> None: """Appending writes one audit row; at uses the event time.""" db_path = tmp_path / "rides.db" @@ -981,6 +994,7 @@ def test_store_load_engine_replays_start_and_crossing_into_running_engine( tmp_path: Path, ) -> None: """Loading rebuilds a RUNNING engine with crossings and events.""" + roster, key = _replay_roster_and_key() db_path = tmp_path / "rides.db" store = Store.open(db_path) try: @@ -990,7 +1004,7 @@ def test_store_load_engine_replays_start_and_crossing_into_running_engine( action="record_crossing", payload={ "plate": "12", - "entry_id": "12", + "entry_id": key, "lap": 1, "crossed_at": "2026-09-20T10:02:00", }, @@ -998,13 +1012,13 @@ def test_store_load_engine_replays_start_and_crossing_into_running_engine( store.append(ride_id, start_event) store.append(ride_id, crossing_event) - engine = store.load_engine(ride_id, _replay_roster()) + engine = store.load_engine(ride_id, roster) finally: store.close() assert engine.state is RideStatus.RUNNING assert len(engine.crossings) == 1 - assert engine.crossings[0].entry_id == "12" + assert engine.crossings[0].entry_id == key assert engine.crossings[0].crossed_at == datetime(2026, 9, 20, 10, 2) # noqa: DTZ001 # Replay re-derives each event's own reason from the roster, so the # persisted payloads' actions survive the round trip (scope 6d). @@ -1054,13 +1068,14 @@ def test_store_load_engine_replays_in_append_order_not_at_order(tmp_path: Path) store.append( ride_id, Event(action="start", payload={"actual_start": "2026-09-20T10:00:00"}) ) + roster, key = _replay_roster_and_key() store.append( ride_id, Event( action="record_crossing", payload={ "plate": "12", - "entry_id": "12", + "entry_id": key, "lap": 1, "crossed_at": "2026-09-20T10:02:00", }, @@ -1077,12 +1092,12 @@ def test_store_load_engine_replays_in_append_order_not_at_order(tmp_path: Path) ), ) - engine = store.load_engine(ride_id, _replay_roster()) + engine = store.load_engine(ride_id, roster) finally: store.close() assert [e.action for e in engine.events] == ["start", "record_crossing", "set_start_time"] - assert engine.lap_times("12") == (420.0,) + assert engine.lap_times(key) == (420.0,) def test_store_load_engine_reconstructs_ride_config_from_stored_columns( @@ -1144,15 +1159,16 @@ def test_store_load_engine_builds_shoe_from_the_stored_rng_seed(tmp_path: Path) store.append( ride_id, Event(action="start", payload={"actual_start": "2026-09-20T10:00:00"}) ) + roster, key = _replay_roster_and_key() store.append( ride_id, Event( action="deal_manual", - payload={"plate": "12", "entry_id": "12", "card": "AS", "reason": "manual"}, + payload={"plate": "12", "entry_id": key, "card": "AS", "reason": "manual"}, ), ) - engine = store.load_engine(ride_id, _replay_roster()) + engine = store.load_engine(ride_id, roster) finally: store.close() @@ -2061,8 +2077,9 @@ def test_store_delete_ride_removes_all_dependent_rows(tmp_path: Path) -> None: ) with store._conn: entry_id = store._conn.execute( - "INSERT INTO entry (ride_id, plate, display_name, type, team_size, status)" - " VALUES (?, '12', 'Alice', 'solo', 1, 'active')", + "INSERT INTO entry" + " (ride_id, plate, key, display_name, type, team_size, status)" + " VALUES (?, '12', '2f7a', 'Alice', 'solo', 1, 'active')", (ride_id,), ).lastrowid rider_id = store._conn.execute( @@ -2462,6 +2479,44 @@ def test_store_save_roster_entry_notes_round_trip(tmp_path: Path) -> None: assert [entry.notes for entry in rebuilt.entries] == ["", "Captain's team"] +def test_store_save_roster_round_trips_each_entry_key(tmp_path: Path) -> None: + """Each entry's stable key survives save_roster -> roster_for. + + The engine files crossings and credited hands under ``Entry.key`` + (E3.1.2's pooled-live-move seam), so a reloaded ride's replay can + only resolve them if the key came back from the entry table + unchanged. + """ + db_path = tmp_path / "rides.db" + roster = _pooled_roster() + ride_id = _save_roster_ride( + db_path, + roster, + entry_mode=EntryMode.MIXED, + plate_model=PlateModel.RIDER_POOLED, + ) + + rebuilt = _round_trip_roster(db_path, ride_id) + + assert [entry.key for entry in rebuilt.entries] == [entry.key for entry in roster.entries] + + +def test_store_save_roster_writes_a_distinct_key_per_entry_row(tmp_path: Path) -> None: + """No two persisted entries share a key.""" + db_path = tmp_path / "rides.db" + ride_id = _save_roster_ride( + db_path, + _pooled_roster(), + entry_mode=EntryMode.MIXED, + plate_model=PlateModel.RIDER_POOLED, + ) + + rebuilt = _round_trip_roster(db_path, ride_id) + + keys = [entry.key for entry in rebuilt.entries] + assert len(set(keys)) == len(keys) + + def test_store_save_roster_replaces_the_previous_roster(tmp_path: Path) -> None: """Saving twice keeps one entry set -- the second, not a union.""" db_path = tmp_path / "rides.db" @@ -2525,10 +2580,12 @@ def test_store_load_engine_builds_roster_from_db_and_replays_events( """load_engine with no caller roster rebuilds it from the DB. E5.1.2's equivalence, closed: the engine replays the persisted - start + crossing and resolves the recorded plate "12" through the - reconstructed roster -- with an empty roster the crossing would be - refused (unknown_plate) and never recorded, so this genuinely - proves the roster came back from the entry/rider tables. + start + crossing, resolving the event's ``entry_id`` -- the stable + key ``save_roster`` wrote into the entry table -- through the + reconstructed roster. With an empty roster the replay would be + refused (unknown entry key) and the crossing never recorded, so + this genuinely proves the roster, keys included, came back from the + entry/rider tables. """ db_path = tmp_path / "rides.db" ride_id = _save_roster_ride( @@ -2540,6 +2597,7 @@ def test_store_load_engine_builds_roster_from_db_and_replays_events( ) store = Store.open(db_path) try: + key = entry_key(store.roster_for(ride_id), "12") store.append( ride_id, Event(action="start", payload={"actual_start": "2026-09-20T10:00:00"}), @@ -2550,7 +2608,7 @@ def test_store_load_engine_builds_roster_from_db_and_replays_events( action="record_crossing", payload={ "plate": "12", - "entry_id": "12", + "entry_id": key, "lap": 1, "crossed_at": "2026-09-20T10:02:00", }, @@ -2562,8 +2620,8 @@ def test_store_load_engine_builds_roster_from_db_and_replays_events( store.close() assert engine.state is RideStatus.RUNNING - assert [crossing.entry_id for crossing in engine.crossings] == ["12"] - assert engine.lap_times("12") == (120.0,) + assert [crossing.entry_id for crossing in engine.crossings] == [key] + assert engine.lap_times(key) == (120.0,) def test_store_load_engine_replays_short_lap_holds_when_policy_is_stored_true( @@ -2586,6 +2644,7 @@ def test_store_load_engine_replays_short_lap_holds_when_policy_is_stored_true( ) store = Store.open(db_path) try: + key = entry_key(store.roster_for(ride_id), "12") store.append( ride_id, Event(action="start", payload={"actual_start": "2026-09-20T10:00:00"}), @@ -2596,7 +2655,7 @@ def test_store_load_engine_replays_short_lap_holds_when_policy_is_stored_true( action="record_crossing", payload={ "plate": "12", - "entry_id": "12", + "entry_id": key, "lap": 1, "crossed_at": "2026-09-20T10:00:30", }, @@ -2607,7 +2666,7 @@ def test_store_load_engine_replays_short_lap_holds_when_policy_is_stored_true( ride_id, Event( action="confirm_held", - payload={"entry_id": "12", "seq": 1, "card": card.code()}, + payload={"entry_id": key, "seq": 1, "card": card.code()}, ), ) @@ -2641,7 +2700,7 @@ def _source_ride_with_timing_data(path: Path, roster: Roster) -> int: action="record_crossing", payload={ "plate": "12", - "entry_id": "12", + "entry_id": entry_key(roster, "12"), "lap": 1, "crossed_at": "2026-09-20T10:02:00", }, @@ -2742,7 +2801,8 @@ def test_store_duplicate_ride_accepts_an_explicit_copy_name( def test_store_duplicate_ride_keeps_the_source_untouched(tmp_path: Path) -> None: """Duplicating never mutates the source ride or its timing data.""" db_path = tmp_path / "rides.db" - source_id = _source_ride_with_timing_data(db_path, _pooled_roster()) + roster = _pooled_roster() + source_id = _source_ride_with_timing_data(db_path, roster) store = Store.open(db_path) try: store.duplicate_ride(source_id) @@ -2751,7 +2811,7 @@ def test_store_duplicate_ride_keeps_the_source_untouched(tmp_path: Path) -> None store.close() assert source.state is RideStatus.RUNNING - assert [crossing.entry_id for crossing in source.crossings] == ["12"] + assert [crossing.entry_id for crossing in source.crossings] == [entry_key(roster, "12")] def test_store_duplicate_ride_copies_the_hold_short_laps_policy(tmp_path: Path) -> None: @@ -2793,6 +2853,31 @@ def test_store_duplicate_ride_copies_first_and_last_name_columns(tmp_path: Path) assert [tuple(row) for row in copied] == [("Alice", ""), ("A.", "Roy"), ("K.", "Singh")] +def test_store_duplicate_ride_gives_every_copied_entry_a_fresh_key( + tmp_path: Path, +) -> None: + """R-15: a copy's entries are new identities, never the source's. + + The catalog is the copy's own, and its entries carry their own + stable keys -- the same fresh-``rng_seed`` rule one level down. A + shared key would file the copy's crossings under the source's + entry. + """ + db_path = tmp_path / "rides.db" + source = _pooled_roster() + source_id = _source_ride_with_timing_data(db_path, source) + store = Store.open(db_path) + try: + copy_id = store.duplicate_ride(source_id) + copied = store.roster_for(copy_id) + finally: + store.close() + + copied_keys = [entry.key for entry in copied.entries] + assert len(set(copied_keys)) == len(copied_keys) + assert set(copied_keys).isdisjoint({entry.key for entry in source.entries}) + + def test_store_duplicate_ride_copies_the_entry_logo_card(tmp_path: Path) -> None: """R-15: an entry's logo_card copies over with the roster.""" db_path = tmp_path / "rides.db" diff --git a/tests/unit/ui/test_app_corrections.py b/tests/unit/ui/test_app_corrections.py index 0837befd..91a5476b 100644 --- a/tests/unit/ui/test_app_corrections.py +++ b/tests/unit/ui/test_app_corrections.py @@ -25,7 +25,7 @@ import wx from xrc_fixtures import pin_no_authored_window -from conftest import gorba_config +from conftest import entry_key, gorba_config from rivercrossing.cards import Shoe from rivercrossing.ride import Crossing, RideEngine from rivercrossing.roster import EntryMode, PlateModel, Rider, Roster @@ -611,15 +611,15 @@ def test_held_card_facts_given_a_lap_past_the_recorded_times_renders_a_zero_time """A stale crossing renders a zero duration and no card.""" engine = _running_engine(hold_short_laps=True) engine.record_crossing("12", at=_dt(10, 0, 5)) - stale = Crossing(entry_id="12", seq=99, crossed_at=_dt(10, 0, 5)) + stale = Crossing(entry_id=entry_key(engine._roster, "12"), seq=99, crossed_at=_dt(10, 0, 5)) facts = app_module._held_card_facts(engine, stale, engine._roster) assert facts == f"Rider 12 · plate 12 · Lap 99 · {format_duration(0.0)} · no card" -def test_held_card_facts_given_an_unresolvable_entry_falls_back_to_the_entry_id() -> None: - """An unresolvable entry falls back to the crossing's entry id.""" +def test_held_card_facts_given_an_unresolvable_entry_falls_back_to_the_typed_plate() -> None: + """An unresolvable entry falls back to the typed plate.""" engine = _running_engine(hold_short_laps=True) engine.record_crossing("12", at=_dt(10, 0, 5)) crossing = engine.crossings[-1] @@ -652,7 +652,7 @@ def test_review_held_crossing_given_a_confirmed_card_releases_it( assert message.startswith("Rider 12 · plate 12 · Lap 1 · ") assert message.endswith("\n\nConfirm the card into the entry's hand, or void it.") assert engine.held_crossings() == () - assert engine.credited_cards("12") == (engine.card_for(crossing),) + assert engine.credited_cards(entry_key(engine._roster, "12")) == (engine.card_for(crossing),) assert notices == ["Card confirmed for plate 12"] @@ -672,7 +672,7 @@ def test_review_held_crossing_given_a_denied_choice_voids_the_card( _parent, title, _message = calls[0][0] assert title == "Review Held Card" assert engine.held_crossings() == () - assert engine.credited_cards("12") == () + assert engine.credited_cards(entry_key(engine._roster, "12")) == () assert notices == ["Card voided for plate 12"] @@ -690,7 +690,7 @@ def test_review_held_crossing_given_a_cancelled_choice_keeps_the_card_held( app_module._review_held_crossing(context, engine, crossing) assert [held.crossing for held in engine.held_crossings()] == [crossing] - assert engine.credited_cards("12") == () + assert engine.credited_cards(entry_key(engine._roster, "12")) == () assert notices == [] @@ -717,7 +717,7 @@ def test_return_to_held_confirm_given_a_confirmed_prompt_returns_the_card( assert message.startswith("Rider 12 · plate 12 · Lap 1 · ") assert message.endswith("\n\nReturn this card to held for review?") assert [held.crossing for held in engine.held_crossings()] == [crossing] - assert engine.credited_cards("12") == () + assert engine.credited_cards(entry_key(engine._roster, "12")) == () assert notices == ["Card returned to held for plate 12"] @@ -735,7 +735,7 @@ def test_return_to_held_confirm_given_a_cancelled_prompt_leaves_the_card_credite app_module._return_to_held_confirm(context, engine, crossing) assert engine.held_crossings() == () - assert engine.credited_cards("12") == (engine.card_for(crossing),) + assert engine.credited_cards(entry_key(engine._roster, "12")) == (engine.card_for(crossing),) assert notices == [] diff --git a/tests/unit/ui/test_app_ride_menu.py b/tests/unit/ui/test_app_ride_menu.py index 875ab714..41554635 100644 --- a/tests/unit/ui/test_app_ride_menu.py +++ b/tests/unit/ui/test_app_ride_menu.py @@ -25,7 +25,7 @@ import pytest import wx -from conftest import gorba_config +from conftest import entry_key, gorba_config from rivercrossing.cards import Shoe from rivercrossing.ride import Event, RideEngine, RideStatus from rivercrossing.roster import EntryMode, PlateModel, Roster @@ -42,12 +42,22 @@ from pathlib import Path _START = "2026-09-20T10:00:00" -_CROSSING = { - "plate": "12", - "entry_id": "12", - "lap": 1, - "crossed_at": "2026-09-20T10:02:00", -} + + +def _crossing(roster: Roster) -> dict[str, object]: + """Return the staged ``record_crossing`` payload (arrange). + + ``entry_id`` is the entry's stable key -- the identity the entry + table persists and a replay resolves (E3.1.2's pooled-live-move + seam) -- so a hand-written audit row has to carry the staged + roster's own key. + """ + return { + "plate": "12", + "entry_id": entry_key(roster, "12"), + "lap": 1, + "crossed_at": "2026-09-20T10:02:00", + } class _FakeFrame: @@ -279,12 +289,13 @@ def _live_console( def _stage_running_ride(db_path: Path) -> int: """Stage a RUNNING ride with one crossing and the active marker.""" + roster = _roster() store = Store.open(db_path) try: ride_id = store.create_ride(gorba_config()) - store.save_roster(ride_id, _roster()) + store.save_roster(ride_id, roster) store.append(ride_id, Event(action="start", payload={"actual_start": _START})) - store.append(ride_id, Event(action="record_crossing", payload=dict(_CROSSING))) + store.append(ride_id, Event(action="record_crossing", payload=_crossing(roster))) store.set_active_ride(ride_id) finally: store.close() @@ -374,7 +385,10 @@ def test_handle_clear_ride_route_given_a_cancelled_danger_leaves_the_ride_alone( view = _FakeConsoleView() context = _context(store=store, view=view) context.active_ride_id = ride_id - presenter = _live_console(context, store.load_engine(ride_id, context.roster), view) + # The app's own open path: the engine rebuilds its roster, keys + # included, from the persisted entry rows (``roster_for`` + + # ``load_engine``), so the staged events replay. + presenter = _live_console(context, store.load_engine(ride_id), view) _stub_danger(monkeypatch, result=wx.ID_CANCEL) view.calls.clear() diff --git a/tests/unit/ui/test_app_store_wiring.py b/tests/unit/ui/test_app_store_wiring.py index 5958b261..c67e07bf 100644 --- a/tests/unit/ui/test_app_store_wiring.py +++ b/tests/unit/ui/test_app_store_wiring.py @@ -76,6 +76,8 @@ def clock() -> datetime: def test_wire_store_append_persists_the_crossing_to_the_fake_store() -> None: """A recorded crossing appends exactly (ride_id, event).""" + from conftest import entry_key # noqa: PLC0415 -- the shared key helper + store = _FakeStore() engine = _engine() engine.start() @@ -91,7 +93,7 @@ def test_wire_store_append_persists_the_crossing_to_the_fake_store() -> None: action="record_crossing", payload={ "plate": "12", - "entry_id": "12", + "entry_id": entry_key(engine._roster, "12"), "lap": 1, "crossed_at": "2026-09-20T10:01:00", "reason": "Rider 12 · solo", diff --git a/tests/unit/ui/test_crossing_detail.py b/tests/unit/ui/test_crossing_detail.py index 2e5e817a..402be3e4 100644 --- a/tests/unit/ui/test_crossing_detail.py +++ b/tests/unit/ui/test_crossing_detail.py @@ -50,7 +50,7 @@ from hypothesis import strategies as st from xrc_fixtures import pin_no_authored_window -from conftest import _pooled_team_roster, gorba_config +from conftest import _pooled_team_roster, entry_key, gorba_config from rivercrossing.cards import Card, Shoe from rivercrossing.ride import Crossing, Event, PendingMiss, RideEngine, RideStatus from rivercrossing.roster import EntryMode, PlateModel, Rider, Roster @@ -446,19 +446,23 @@ def duplicate_crossings(self) -> tuple[tuple[Crossing, Crossing], ...]: def test_build_fields_given_a_seq_past_the_recorded_laps_renders_zero_times() -> None: """T-4 boundary: a stale seq cannot index past lap_times.""" - crossing = Crossing(entry_id="12", seq=3, crossed_at=_dt(10, 5), rider_plate="12") + roster = _solo_roster() + crossing = Crossing( + entry_id=entry_key(roster, "12"), seq=3, crossed_at=_dt(10, 5), rider_plate="12" + ) engine = _StubEngine(lap_times=(100.0, 120.0), credited=("AS",)) - fields = crossing_detail.build_fields(crossing, _solo_roster(), engine) + fields = crossing_detail.build_fields(crossing, roster, engine) assert (fields.lap_time, fields.total) == ("0:00", "0:00:00") -def test_build_fields_given_an_unknown_entry_falls_back_to_the_entry_id() -> None: - """A crossing whose entry left the roster still renders its id. +def test_build_fields_given_an_unknown_entry_falls_back_to_the_typed_plate() -> None: + """A crossing whose entry left the roster still renders its plate. T-3 negative of the solo branch: with no entry to type-check, the - Team field falls back to ``crossing.entry_id`` rather than "solo". + Team field falls back to the plate the operator typed rather than + "solo" -- never the crossing's internal stable key. """ crossing = Crossing(entry_id="99", seq=1, crossed_at=_dt(10, 5), rider_plate="99") engine = _StubEngine(lap_times=(60.0,), credited=("AS",)) @@ -470,10 +474,11 @@ def test_build_fields_given_an_unknown_entry_falls_back_to_the_entry_id() -> Non def test_build_fields_given_no_rider_plate_falls_back_to_the_entry_plate() -> None: """A crossing with no typed plate shows the entry's own plate.""" - crossing = Crossing(entry_id="12", seq=1, crossed_at=_dt(10, 5)) + roster = _solo_roster() + crossing = Crossing(entry_id=entry_key(roster, "12"), seq=1, crossed_at=_dt(10, 5)) engine = _StubEngine(lap_times=(60.0,), credited=("AS",)) - fields = crossing_detail.build_fields(crossing, _solo_roster(), engine) + fields = crossing_detail.build_fields(crossing, roster, engine) assert (fields.rider, fields.plate) == ("Amy", "12") @@ -1359,7 +1364,9 @@ def test_on_edit_time_given_a_crossing_opens_the_locked_edit_time_dialog( """T-4 nullable: the dialog opens on the plate the view shows.""" roster = _solo_roster() engine = _running_engine(roster) - crossing = Crossing(entry_id="12", seq=1, crossed_at=_dt(10, 2), rider_plate=rider_plate) + crossing = Crossing( + entry_id=entry_key(roster, "12"), seq=1, crossed_at=_dt(10, 2), rider_plate=rider_plate + ) view = _view(engine, roster=roster, crossing=crossing) calls = _stub_run_edit_crossing(monkeypatch, None) @@ -1601,7 +1608,10 @@ def test_on_void_card_given_a_confirmed_void_passes_the_engines_own_dealt_object view._on_void_card(_RecordingEvent()) assert engine.voided_card is card - assert (view.crossing_held_lbl.value, engine.credited_cards("12")) == ("Void", ()) + assert (view.crossing_held_lbl.value, engine.credited_cards(entry_key(roster, "12"))) == ( + "Void", + (), + ) def test_on_void_card_given_a_held_duplicate_card_keeps_the_held_refusal( @@ -1657,7 +1667,7 @@ def test_on_void_card_given_a_confirmed_void_rerenders_the_crossing_in_place( "entry": "12 · Amy", } ] - assert engine.credited_cards("12") == () + assert engine.credited_cards(entry_key(roster, "12")) == () assert engine.events[-1].action == "void_card" assert engine.events[-1].payload["reason"] == "wrong card" assert (view.dialog.modal_ids, event.skipped) == ([], True) @@ -1691,7 +1701,7 @@ def test_on_void_card_given_a_pooled_team_crossing_names_the_typing_rider( "entry": "45 · Sarah", } ] - assert engine.credited_cards("9") == () + assert engine.credited_cards(entry_key(engine._roster, "9")) == () def test_on_void_card_given_a_cancelled_dialog_leaves_the_ride_alone( @@ -1774,7 +1784,9 @@ def test_on_edit_given_a_confirmed_number_reassigns_and_rerenders_in_place( view._on_edit(event) - assert [(c.entry_id, c.rider_plate) for c in engine.crossings] == [("34", "34")] + assert [(c.entry_id, c.rider_plate) for c in engine.crossings] == [ + (entry_key(roster, "34"), "34") + ] assert engine.events[-1].action == "reassign" assert engine.events[-1].payload["reason"] == crossing_detail.EDIT_REASON assert engine.events[-1].payload["new_plate"] == "34" @@ -1794,9 +1806,12 @@ def test_on_edit_given_a_crossing_prefills_the_number_prompt( expected: str, ) -> None: """T-4 nullable: the prompt opens on the plate the dialog shows.""" - crossing = Crossing(entry_id="12", seq=1, crossed_at=_dt(10, 2), rider_plate=rider_plate) - engine = _running_engine(_solo_roster()) - view = _view(engine, roster=_solo_roster(), crossing=crossing) + roster = _solo_roster() + crossing = Crossing( + entry_id=entry_key(roster, "12"), seq=1, crossed_at=_dt(10, 2), rider_plate=rider_plate + ) + engine = _running_engine(roster) + view = _view(engine, roster=roster, crossing=crossing) calls = _stub_plate_dialog(monkeypatch, None) view._on_edit(_RecordingEvent()) @@ -1906,10 +1921,10 @@ def test_on_edit_given_a_mid_ride_crossing_addresses_it_by_ride_wide_ordinal( view._on_edit(_RecordingEvent()) - assert [c.entry_id for c in engine.crossings] == ["12", "12", "12"] + assert [c.entry_id for c in engine.crossings] == [entry_key(roster, "12")] * 3 assert engine.events[-1].payload["seq"] == 2 - assert engine.events[-1].payload["old_entry_id"] == "34" - assert (view.crossing.entry_id, view.crossing.seq) == ("12", 3) + assert engine.events[-1].payload["old_entry_id"] == entry_key(roster, "34") + assert (view.crossing.entry_id, view.crossing.seq) == (entry_key(roster, "12"), 3) assert view.crossing_time_lbl.value == "10:03:00" @@ -2185,7 +2200,7 @@ def test_on_delete_given_a_confirmed_void_removes_only_that_crossing( ] assert engine.events[-1].action == "void_crossing" assert engine.events[-1].payload == { - "entry_id": "12", + "entry_id": entry_key(engine._roster, "12"), "seq": 2, "reason": crossing_detail.DELETE_REASON, } @@ -2201,7 +2216,10 @@ def test_on_delete_given_a_confirmed_void_voids_the_dealt_card( view._on_delete(_RecordingEvent()) - assert (len(engine.credited_cards("12")), engine.shoe_remaining) == (2, engine.shoe_total - 3) + assert ( + len(engine.credited_cards(entry_key(engine._roster, "12"))), + engine.shoe_remaining, + ) == (2, engine.shoe_total - 3) def test_on_delete_given_an_earlier_crossing_names_the_crossing_it_voids( @@ -2414,7 +2432,9 @@ def test_reassign_crossing_plate_given_a_recorded_crossing_reassigns_it() -> Non refusal = crossing_detail.reassign_crossing_plate(engine, engine.crossings[0], "34") assert refusal is None - assert [(c.entry_id, c.rider_plate) for c in engine.crossings] == [("34", "34")] + assert [(c.entry_id, c.rider_plate) for c in engine.crossings] == [ + (entry_key(roster, "34"), "34") + ] assert engine.events[-1].action == "reassign" assert engine.events[-1].payload["reason"] == crossing_detail.EDIT_REASON assert engine.events[-1].payload["new_plate"] == "34" @@ -2435,9 +2455,9 @@ def test_reassign_crossing_plate_given_a_mid_ride_crossing_uses_the_ride_wide_or refusal = crossing_detail.reassign_crossing_plate(engine, engine.crossings[1], "12") assert refusal is None - assert [c.entry_id for c in engine.crossings] == ["12", "12", "12"] + assert [c.entry_id for c in engine.crossings] == [entry_key(roster, "12")] * 3 assert engine.events[-1].payload["seq"] == 2 - assert engine.events[-1].payload["old_entry_id"] == "34" + assert engine.events[-1].payload["old_entry_id"] == entry_key(roster, "34") def test_reassign_crossing_plate_given_a_stale_crossing_returns_the_refusal() -> None: @@ -2766,9 +2786,12 @@ def test_on_edit_given_a_saved_number_scores_the_miss_and_stays_open( assert view.dialog.modal_ids == [] assert engine.pending_misses() == () assert [(c.entry_id, c.rider_plate, c.crossed_at) for c in engine.crossings] == [ - ("34", "34", _dt(10, 2)) + (entry_key(roster, "34"), "34", _dt(10, 2)) ] - assert (engine.shoe_remaining, len(engine.credited_cards("34"))) == (shoe_before - 1, 1) + assert (engine.shoe_remaining, len(engine.credited_cards(entry_key(roster, "34")))) == ( + shoe_before - 1, + 1, + ) assert engine.events[-1].action == "assign_plate_to_miss" assert engine.events[-1].payload["reason"] == crossing_detail.MISS_EDIT_REASON assert engine.events[-1].payload["new_plate"] == "34" @@ -2798,8 +2821,13 @@ def test_on_edit_given_a_pooled_rider_number_scores_it_of_the_rider( view._on_edit(_RecordingEvent()) - assert [(c.entry_id, c.rider_plate) for c in engine.crossings] == [("9", "45")] - assert (view.dialog.modal_ids, engine.events[-1].payload["entry_id"]) == ([], "9") + assert [(c.entry_id, c.rider_plate) for c in engine.crossings] == [ + (entry_key(roster, "9"), "45") + ] + assert (view.dialog.modal_ids, engine.events[-1].payload["entry_id"]) == ( + [], + entry_key(roster, "9"), + ) assert (view.crossing_rider_lbl.value, view.crossing_team_lbl.value) == ( "Sarah", "Dirt Dynamos", From 1fff34b5dc712fd451377d66a992c478571f515b Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Sun, 20 Sep 2026 17:37:48 -0400 Subject: [PATCH 03/15] feat(roster): allow pooled team->solo and solo->team moves Relax extract_rider_to_solo to can_move_rider (pooled RUNNING/REOPENED) and let move_rider take a solo source so a rider with data can move onto a team without the has-data delete guard. A dissolved solo entry now logs a distinct dissolve_entry action. --- src/rivercrossing/roster.py | 49 +++--- tests/unit/presenters/test_riders.py | 8 +- tests/unit/test_roster.py | 224 +++++++++++++++++++++++++-- 3 files changed, 246 insertions(+), 35 deletions(-) diff --git a/src/rivercrossing/roster.py b/src/rivercrossing/roster.py index 3aa58127..6937512a 100644 --- a/src/rivercrossing/roster.py +++ b/src/rivercrossing/roster.py @@ -1158,10 +1158,14 @@ def mark_has_data(self, entry: Entry) -> None: def move_rider(self, rider: Rider, *, to_entry: Entry) -> None: """Move *rider* onto *to_entry* if the lock matrix allows it. - Both entries must be type TEAM -- a solo entry's one rider - is fixed by definition (S1) -- and the move must keep the - destination within max_team_size (R-12). The source team's - lower bound is a start-time check now + The destination must be type TEAM, keeping within + max_team_size (R-12); the source may be a team or a *solo* + entry. A solo source is one audited move: on a + ``rider_pooled`` ride the rider carries their own plate onto + the team, which recomputes its pooled plate (S1), and the + emptied solo entry dissolves (:meth:`_dissolve_entry`) -- a + solo entry has exactly one rider, so it never survives the + move. The source team's lower bound is a start-time check now (:meth:`validate_for_start`), not a move_rider invariant (2026-08-09 follow-on decision): dropping to a transient size-1 team succeeds; dropping its last rider dissolves the @@ -1177,7 +1181,7 @@ def move_rider(self, rider: Rider, *, to_entry: Entry) -> None: roster. LockedError: the lock matrix forbids a move in the ride's current state and plate model. - InvalidMoveError: either entry is not type TEAM, or the + InvalidMoveError: *to_entry* is not type TEAM, or the move would exceed the destination's max size. """ from_entry = self._find_owning_entry(rider) @@ -1188,8 +1192,8 @@ def move_rider(self, rider: Rider, *, to_entry: Entry) -> None: if not can_move_rider(self._status, self._plate_model): msg = f"rider moves are locked for a {self._plate_model} ride once {self._status}" raise LockedError(msg) - if from_entry.type is not EntryType.TEAM or to_entry.type is not EntryType.TEAM: - msg = "move_rider requires both entries to be team entries" + if to_entry.type is not EntryType.TEAM: + msg = "move_rider's destination must be a team entry" raise InvalidMoveError(msg) if len(to_entry.riders) + 1 > self._max_team_size: msg = "move would exceed the destination team's max size" @@ -1275,15 +1279,19 @@ def add_rider_to_team(self, rider: Rider, *, to_entry: Entry) -> None: def extract_rider_to_solo(self, rider: Rider) -> Entry: """Convert a rider_pooled team member into their own solo entry. - Spec S1 scopes team<->solo conversions to pre-start; R-17's - running carve-out covers only moves between teams, not this. + Gated by :func:`can_move_rider` -- the same (status, + plate_model) carve-out a move uses (R-17): DRAFT always + allows it, ``rider_pooled`` stays open while RUNNING and, as + a correction, once REOPENED, and FINISHED or any + post-start ``team_relay`` state refuses it. *rider*'s own plate becomes the new entry's; the source team recomputes its adopted plate, and dissolves outright if *rider* was its last member (:meth:`_dissolve_entry`). Raises: RiderNotFoundError: *rider* is not on any entry here. - LockedError: the ride has left DRAFT. + LockedError: the lock matrix forbids a conversion in the + ride's current state and plate model. PlateShapeError: this ride's plate_model is not rider_pooled, or *rider*'s entry is not type TEAM. """ @@ -1291,8 +1299,11 @@ def extract_rider_to_solo(self, rider: Rider) -> Entry: if entry is None: msg = "rider is not on any entry in this roster" raise RiderNotFoundError(msg) - if not can_edit_structure(self._status): - msg = f"a rider cannot be extracted to solo once the ride is {self._status}" + if not can_move_rider(self._status, self._plate_model): + msg = ( + f"a rider cannot be extracted to solo on a {self._plate_model} " + f"ride once {self._status}" + ) raise LockedError(msg) if self._plate_model is not PlateModel.RIDER_POOLED or entry.type is not EntryType.TEAM: msg = "extract_rider_to_solo requires a rider_pooled team member" @@ -1469,16 +1480,18 @@ def _delete_refusal(self, entry: Entry) -> str: return f"entries can no longer be deleted once the ride is {self._status}" def _dissolve_entry(self, entry: Entry) -> None: - """Remove *entry* once move_rider has emptied it (E3.2). + """Remove *entry* once a move or removal has emptied it (E3.2). - An empty team has no plate owner and no size-0 representation + An empty entry has no plate owner and no size-0 representation (spec S2); it ceases to exist rather than lingering as an - empty row in :attr:`entries`. + empty row in :attr:`entries`. The logged action names what + dissolved: a TEAM entry logs ``dissolve_team_entry``, any + other entry -- a solo one, once :meth:`move_rider` carries its + single rider away -- logs ``dissolve_entry``. """ self._entries.remove(entry) - self._log( - "dissolve_team_entry", {"plate": entry.plate, "display_name": entry.display_name} - ) + action = "dissolve_team_entry" if entry.type is EntryType.TEAM else "dissolve_entry" + self._log(action, {"plate": entry.plate, "display_name": entry.display_name}) def _require_plate_free_for_change(self, new_plate: str, *, exclude: str) -> None: """Raise unless *new_plate* is free, or equal to *exclude*. diff --git a/tests/unit/presenters/test_riders.py b/tests/unit/presenters/test_riders.py index 60972914..14552950 100644 --- a/tests/unit/presenters/test_riders.py +++ b/tests/unit/presenters/test_riders.py @@ -1577,9 +1577,9 @@ def test_edit_rider_presenter_submit_given_a_relay_member_leaving_shows_validati assert len(roster.entries[0].riders) == 2 -def test_edit_rider_presenter_submit_given_a_post_start_leave_shows_validation() -> None: - """extract_rider_to_solo is DRAFT-only; a running ride refuses.""" - roster = _draft_mixed_roster() +def test_edit_rider_presenter_submit_given_a_post_start_relay_leave_shows_validation() -> None: + """A relay leave is still refused once the ride is RUNNING.""" + roster = _draft_relay_roster() presenter, view = _edit_presenter(roster) roster.status = RideStatus.RUNNING @@ -1590,7 +1590,7 @@ def test_edit_rider_presenter_submit_given_a_post_start_leave_shows_validation() assert view.calls == [ ( "show_validation", - ("a rider cannot be extracted to solo once the ride is running",), + ("a rider cannot be extracted to solo on a team_relay ride once running",), ) ] assert len(roster.entries[0].riders) == 2 diff --git a/tests/unit/test_roster.py b/tests/unit/test_roster.py index 4b2a5039..c4c64ac3 100644 --- a/tests/unit/test_roster.py +++ b/tests/unit/test_roster.py @@ -27,6 +27,14 @@ check, not a construction invariant); moving a team's last rider elsewhere dissolves the now-empty entry; and plates become editable in DRAFT for a solo entry or a pooled rider (spec S3:46). + +The pooled-live-move phase extends R-17's carve-out to the roster's +own membership primitives: ``move_rider`` accepts a SOLO source (one +audited move, the emptied solo entry dissolving), and +``extract_rider_to_solo`` is gated by +:func:`~rivercrossing.roster.can_move_rider` like a move is, so a +pooled team member may leave for solo while the ride is RUNNING or +REOPENED. """ import re @@ -896,6 +904,28 @@ def test_move_rider_into_a_size_one_team_grows_it_to_two() -> None: assert team_c.team_size == 2 +def test_move_rider_filling_a_destination_to_max_team_size_succeeds() -> None: + """T-4: a destination landing exactly on max is admitted.""" + roster = Roster(entry_mode=EntryMode.MIXED, max_team_size=4) + alex = Rider(first_name="Alex", last_name="", plate="1") + roster.create_team_entry( + display_name="Team A", + riders=[alex, Rider(first_name="Bo", last_name="", plate="2")], + ) + team_b = roster.create_team_entry( + display_name="Team B", + riders=[ + Rider(first_name="Cy", last_name="", plate="3"), + Rider(first_name="Do", last_name="", plate="4"), + Rider(first_name="El", last_name="", plate="5"), + ], + ) + + roster.move_rider(alex, to_entry=team_b) + + assert team_b.team_size == 4 + + def test_move_rider_exceeding_destination_team_max_raises_invalid_move_error() -> None: """Moving a rider onto a team already at max_team_size raises.""" roster = Roster(entry_mode=EntryMode.MIXED, max_team_size=4) @@ -951,8 +981,13 @@ def test_move_rider_unknown_destination_entry_raises_entry_not_found_error() -> roster.move_rider(alex, to_entry=foreign) -def test_move_rider_out_of_a_solo_entry_raises_invalid_move_error() -> None: - """move_rider on a solo rider raises (solo is 1 rider).""" +def test_move_rider_out_of_a_solo_entry_onto_a_team_moves_the_rider() -> None: + """A solo source is a legal move: its rider joins the team. + + The rider carries their own plate onto the destination, which + recomputes its pooled plate from all its members (S1); the + emptied solo entry dissolves (spec S2). + """ roster = Roster(entry_mode=EntryMode.MIXED) solo = roster.create_solo_entry(first_name="Alex", last_name="", plate="1") team_b = roster.create_team_entry( @@ -963,12 +998,77 @@ def test_move_rider_out_of_a_solo_entry_raises_invalid_move_error() -> None: ], ) - with pytest.raises(InvalidMoveError, match=re.escape("team entries")): - roster.move_rider(solo.riders[0], to_entry=team_b) + roster.move_rider(solo.riders[0], to_entry=team_b) + + assert [rider.plate for rider in team_b.riders] == ["3", "4", "1"] + assert (team_b.plate, solo in roster.entries) == ("1", False) + + +def test_move_rider_out_of_a_solo_entry_audits_the_move_then_the_dissolve() -> None: + """Solo->team is one audited move, then a ``dissolve_entry``. + + The dissolved entry is a SOLO one, so its audit action must not + claim a team dissolved. + """ + roster = Roster(entry_mode=EntryMode.MIXED) + solo = roster.create_solo_entry(first_name="Alex", last_name="", plate="9") + team_b = roster.create_team_entry( + display_name="Team B", + riders=[ + Rider(first_name="Cy", last_name="", plate="3"), + Rider(first_name="Do", last_name="", plate="4"), + ], + ) + + roster.move_rider(solo.riders[0], to_entry=team_b) + + assert roster.audit_log[-2:] == ( + AuditEvent( + action="move_rider", + payload={"rider_name": "Alex", "from_plate": "9", "to_plate": "3"}, + ), + AuditEvent(action="dissolve_entry", payload={"plate": "9", "display_name": "Alex"}), + ) + + +def test_move_rider_out_of_a_solo_entry_on_a_running_pooled_ride_succeeds() -> None: + """A solo source rides the same R-17 carve-out as a team source.""" + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.RIDER_POOLED) + solo = roster.create_solo_entry(first_name="Alex", last_name="", plate="1") + team_b = roster.create_team_entry( + display_name="Team B", + riders=[ + Rider(first_name="Cy", last_name="", plate="3"), + Rider(first_name="Do", last_name="", plate="4"), + ], + ) + roster.status = RideStatus.RUNNING + + roster.move_rider(solo.riders[0], to_entry=team_b) + + assert [rider.plate for rider in team_b.riders] == ["3", "4", "1"] + + +def test_move_rider_out_of_a_solo_entry_with_recorded_data_still_moves() -> None: + """The has-data guard is delete_entry's, not a move's (R-15).""" + roster = Roster(entry_mode=EntryMode.MIXED) + solo = roster.create_solo_entry(first_name="Alex", last_name="", plate="1") + team_b = roster.create_team_entry( + display_name="Team B", + riders=[ + Rider(first_name="Cy", last_name="", plate="3"), + Rider(first_name="Do", last_name="", plate="4"), + ], + ) + roster.mark_has_data(solo) + + roster.move_rider(solo.riders[0], to_entry=team_b) + + assert [rider.plate for rider in team_b.riders] == ["3", "4", "1"] def test_move_rider_into_a_solo_entry_raises_invalid_move_error() -> None: - """move_rider(to_entry=) raises (solo is 1 rider).""" + """move_rider onto a solo destination raises (solo is 1 rider).""" roster = Roster(entry_mode=EntryMode.MIXED) alex = Rider(first_name="Alex", last_name="", plate="1") roster.create_team_entry( @@ -976,17 +1076,17 @@ def test_move_rider_into_a_solo_entry_raises_invalid_move_error() -> None: ) solo = roster.create_solo_entry(first_name="Cy", last_name="", plate="3") - with pytest.raises(InvalidMoveError, match=re.escape("team entries")): + with pytest.raises(InvalidMoveError, match=re.escape("destination must be a team entry")): roster.move_rider(alex, to_entry=solo) def test_move_rider_between_two_solo_entries_raises_invalid_move_error() -> None: - """Both endpoints solo still raises the same error (R-17).""" + """A solo destination still raises, whatever the source type is.""" roster = Roster(entry_mode=EntryMode.MIXED) solo_a = roster.create_solo_entry(first_name="Alex", last_name="", plate="1") solo_b = roster.create_solo_entry(first_name="Bo", last_name="", plate="2") - with pytest.raises(InvalidMoveError, match=re.escape("team entries")): + with pytest.raises(InvalidMoveError, match=re.escape("destination must be a team entry")): roster.move_rider(solo_a.riders[0], to_entry=solo_b) @@ -2211,16 +2311,114 @@ def test_extract_rider_to_solo_dissolve_appends_a_dissolve_team_entry_audit_even ) -def test_extract_rider_to_solo_after_start_raises_locked_error() -> None: - """Conversions are DRAFT-only; R-17's carve-out is moves only.""" - roster = Roster(entry_mode=EntryMode.MIXED) +def test_extract_rider_to_solo_on_a_running_pooled_ride_creates_the_solo_entry() -> None: + """R-17's pooled carve-out covers team->solo too.""" + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.RIDER_POOLED) alex = Rider(first_name="Alex", last_name="", plate="5") + team = roster.create_team_entry( + display_name="Team A", + riders=[ + alex, + Rider(first_name="Bo", last_name="", plate="9"), + Rider(first_name="Cy", last_name="", plate="1"), + ], + ) + roster.status = RideStatus.RUNNING + + solo = roster.extract_rider_to_solo(alex) + + assert (solo.type, solo.plate, solo.display_name, solo.riders) == ( + EntryType.SOLO, + "5", + "Alex", + [alex], + ) + assert team.plate == "1" + + +def test_extract_rider_to_solo_on_a_reopened_pooled_ride_recomputes_the_source_team() -> None: + """REOPENED is the corrections door for a pooled conversion too.""" + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.RIDER_POOLED) + alex = Rider(first_name="Alex", last_name="", plate="5") + team = roster.create_team_entry( + display_name="Team A", riders=[alex, Rider(first_name="Bo", last_name="", plate="2")] + ) + roster.status = RideStatus.REOPENED + + roster.extract_rider_to_solo(alex) + + assert team.plate == "2" + + +def test_extract_rider_to_solo_on_a_running_pooled_ride_dissolves_the_source_team() -> None: + """A size-1 source still dissolves when the ride is RUNNING.""" + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.RIDER_POOLED) + alex = Rider(first_name="Alex", last_name="", plate="1") + team = roster.create_team_entry_of_one(display_name="Team A", rider=alex) + roster.status = RideStatus.RUNNING + + roster.extract_rider_to_solo(alex) + + assert team not in roster.entries + + +def test_extract_rider_to_solo_on_a_running_relay_ride_raises_locked_error() -> None: + """Relay keeps the R-17 lock once the ride is RUNNING.""" + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.TEAM_RELAY) + alex = Rider(first_name="Alex", last_name="") roster.create_team_entry( - display_name="Team A", riders=[alex, Rider(first_name="Bo", last_name="", plate="9")] + display_name="Team A", riders=[alex, Rider(first_name="Bo", last_name="")], plate="1" ) roster.status = RideStatus.RUNNING - with pytest.raises(LockedError, match=re.escape("running")): + with pytest.raises( + LockedError, match=re.escape("extracted to solo on a team_relay ride once running") + ): + roster.extract_rider_to_solo(alex) + + +def test_extract_rider_to_solo_on_a_reopened_relay_ride_raises_locked_error() -> None: + """Relay's start lock outlasts even a REOPENED correction (R-17).""" + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.TEAM_RELAY) + alex = Rider(first_name="Alex", last_name="") + roster.create_team_entry( + display_name="Team A", riders=[alex, Rider(first_name="Bo", last_name="")], plate="1" + ) + roster.status = RideStatus.REOPENED + + with pytest.raises( + LockedError, match=re.escape("extracted to solo on a team_relay ride once reopened") + ): + roster.extract_rider_to_solo(alex) + + +def test_extract_rider_to_solo_on_a_finished_relay_ride_raises_locked_error() -> None: + """A relay's conversions close at the finish, like every move.""" + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.TEAM_RELAY) + alex = Rider(first_name="Alex", last_name="") + roster.create_team_entry( + display_name="Team A", riders=[alex, Rider(first_name="Bo", last_name="")], plate="1" + ) + roster.status = RideStatus.FINISHED + + with pytest.raises( + LockedError, match=re.escape("extracted to solo on a team_relay ride once finished") + ): + roster.extract_rider_to_solo(alex) + + +def test_extract_rider_to_solo_on_a_finished_pooled_ride_raises_locked_error() -> None: + """FINISHED closes the pooled conversion door (R-17).""" + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.RIDER_POOLED) + alex = Rider(first_name="Alex", last_name="", plate="5") + roster.create_team_entry( + display_name="Team A", riders=[alex, Rider(first_name="Bo", last_name="", plate="9")] + ) + roster.status = RideStatus.FINISHED + + with pytest.raises( + LockedError, match=re.escape("extracted to solo on a rider_pooled ride once finished") + ): roster.extract_rider_to_solo(alex) From b199b7bfb014a0492168d16593cb3b67b7d36191 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Sun, 20 Sep 2026 17:55:18 -0400 Subject: [PATCH 04/15] feat(ride): pooled live rider move with replay Add RideEngine.move_rider (team->team, solo->team) and extract_rider_to_solo (team->solo), gated on Stop/Reopen, that re-attribute the rider's crossings, credited cards, held cards and reset their voided laps onto the destination key, with replayed move_rider/extract_rider_to_solo events. Known gap (documented in the class docstring): a solo->team move that dissolves the source entry cannot reload yet, because the dissolved entry's key is not persisted. --- src/rivercrossing/ride.py | 379 ++++++++++++++++- tests/unit/test_ride_move.py | 801 +++++++++++++++++++++++++++++++++++ 2 files changed, 1168 insertions(+), 12 deletions(-) create mode 100644 tests/unit/test_ride_move.py diff --git a/src/rivercrossing/ride.py b/src/rivercrossing/ride.py index f7b097ab..9880eb7b 100644 --- a/src/rivercrossing/ride.py +++ b/src/rivercrossing/ride.py @@ -58,7 +58,7 @@ from pathlib import Path from rivercrossing.cards import Shoe - from rivercrossing.roster import Entry, EntryMode, PlateModel, Roster + from rivercrossing.roster import Entry, EntryMode, PlateModel, Rider, Roster __all__ = [ "DEFAULT_DECK_COUNT", @@ -516,12 +516,17 @@ def __init__(self, message: str, *, reasons: tuple[str, ...]) -> None: class UnknownPlateError(RideEngineError): - """``deal_manual()`` could not resolve *plate* to any entry. - - A corrections command fails loudly, unlike ``record_crossing``'s - console path, which returns a refusal result so the entry field - can flash its cue; E7's manual-deal dialog surfaces this as the - error for a mistyped plate. + """A caller-named plate, or a move's own target, names nothing. + + ``deal_manual()`` could not resolve *plate* to any entry; the + pooled live move could not resolve its rider plate to a rider (or, + for an extraction, to one on a pooled team) or its *to_team* to a + team. A corrections command fails loudly, unlike + ``record_crossing``'s console path, which returns a refusal result + so the entry field can flash its cue; E7's manual-deal dialog + surfaces this as the error for a mistyped plate. Replay's own + ``_require_entry_by_key`` raises it too, for a payload key this + roster no longer holds. """ @@ -549,6 +554,8 @@ class UnknownPlateError(RideEngineError): "reassign", "dnf", "void_card", + "move_rider", + "extract_rider_to_solo", "stop", "finish", "reopen", @@ -1123,6 +1130,59 @@ class RideEngine: and records (and deals) the real crossing at the miss's original instant, so the card is assigned at edit time, never at record time. + + The pooled live move's own resolutions (E3.1.2, R-17): + + - **The move gate.** ``move_rider``/``extract_rider_to_solo`` are + legal only while the ride is stopped RUNNING (R-35's Stop guard, + so the clock is locked before a re-shuffle) or REOPENED, and + only on a ``rider_pooled`` ride -- the same cell + ``roster.can_move_rider`` opens, read off the stored enum + ``.value`` because ``roster`` is never imported here. The live + RUNNING refusal is exactly "Stop the ride first" (the console's + own instruction); DRAFT and FINISHED name their state. The + engine carries these refusals as ``IllegalStateError`` -- the + roster's own ``LockedError`` can never be imported from below + it. + - **Keys, never entries, name the two sides.** The move's payload + records ``from_key``/``to_key`` (the entries' stable + :attr:`~rivercrossing.roster.Entry.key`) rather than plates: + a pooled move re-derives both teams' plates, and a solo source + is *dissolved* by the move, so a replayed payload must be able + to name an entry the rebuilt roster no longer holds. A + same-team move is refused outright: it can be neither a + membership change nor a re-attribution, and re-keying an entry + onto itself would collide its own lap seqs. + - **Replay never re-applies the roster.** ``apply`` re-runs only + ``_reattribute_rider`` for a replayed move: the replayed roster + is already final (``Store.load_engine`` rebuilds it from the + entry/rider tables, never from the event log), so the recorded + laps re-key onto the destination exactly as they did live. + - **Known gap: a solo-sourced move cannot be replayed.** The + ``record_crossing`` rows recorded before such a move carry the + *dissolved* solo entry's key, and ``apply`` resolves those by + key -- a key the reloaded roster no longer holds, because + ``Store.save_roster`` writes a snapshot of the live entries and + a dissolved entry is not among them. Reopening such a ride + raises ``UnknownPlateError`` from the replay seam. The same gap + already exists for the roster-level solo move Phase 2 opened; + closing it needs a decision above this module (persist a + retired entry row, or re-key the earlier rows), so it is + recorded here rather than invented. + - **A voided lap of the moved rider is re-interpreted, not + restored.** The pre-void held/credited disposition is not + stored, so the restored lap's short-lap disposition is + re-derived against its new predecessor (R-34) -- a short lap + re-enters the hold queue, every other lap credits. Card-level + voids (``void_card``/``void_held``) are untouched: they are + cards on still-live laps. + - **The source renumbers last, highest gap first.** The moved + rider's live laps leave in record order while the source's + remaining laps keep their seqs until every removal has landed; + ``_renumber_later`` then runs from the highest removed seq + down. Each call closes the one gap above the seq it names, so + descending is the only order that leaves the survivors + contiguous 1..N. """ def __init__( # noqa: PLR0913, PLR0917 -- frozen S4 API (config, shoe, clock, roster) @@ -2548,6 +2608,276 @@ def _void_card_into(self, entry: Entry, card: Card, reason: str) -> Event: ) ) + # --------------------------------- E3.1.2 pooled live rider moves + + def move_rider(self, rider_plate: str, *, to_team: str, reason: str) -> Event: + """Move one pooled rider onto another team mid-ride (R-17). + + The engine half of the pooled live move E3.1.2's lock matrix + keeps open: the roster changes the rider's membership, and this + method carries the rider's *data* across with them + (:meth:`_reattribute_rider`) -- their live laps, their held + card and their credited cards, re-keyed to the destination + entry. The source may be a pooled TEAM member or a whole SOLO + entry; a solo entry has exactly one rider, so a move out of one + dissolves it (``Roster.move_rider``), which is why the audit + event records the two *keys* rather than the two entries. + + Legal only while the ride is stopped (RUNNING with R-35's Stop + guard set) or REOPENED, and only on a ``rider_pooled`` ride + (:meth:`_require_move_allowed`): the operator stops the clock + before a mid-ride re-shuffle, and a finished ride is corrected + through Reopen. + + Args: + rider_plate: The moving rider's own plate (R-16). + to_team: The destination team's display name. + reason: Why the rider moved; carried in the audit payload. + + Returns: + The appended ``move_rider`` audit event. + + Raises: + ValueError: *reason* is empty or whitespace-only. + IllegalStateError: the ride is RUNNING and not stopped, is + DRAFT or FINISHED, its plate model is not + ``rider_pooled``, or *to_team* is the rider's own team. + UnknownPlateError: *rider_plate* names no rider in the + roster, or *to_team* names no team. + """ + self._require_move_allowed(reason) + source, rider = self._find_rider(rider_plate) + destination = self._require_team(to_team) + if destination is source: + raise IllegalStateError(f"rider {rider_plate} is already on team {to_team}") + source_key = source.key + destination_key = destination.key + self._roster.move_rider(rider, to_entry=destination) + self._roster.mark_has_data(destination) + self._reattribute_rider(rider_plate, source_key, destination_key) + return self._append( + Event( + action="move_rider", + payload={ + "rider_plate": rider_plate, + "from_key": source_key, + "to_team": to_team, + "to_key": destination_key, + "reason": reason, + }, + ) + ) + + def extract_rider_to_solo(self, rider_plate: str, *, reason: str) -> Event: + """Move one pooled team member into their own solo entry (R-17). + + The team-to-solo half of the pooled live move: the roster + extracts the member into a brand-new solo entry (its own plate + becomes the entry's) and this method carries the rider's data + onto that new entry's key. Gated exactly like + :meth:`move_rider` (:meth:`_require_move_allowed`): stopped + RUNNING or REOPENED, ``rider_pooled`` only. + + Args: + rider_plate: The extracted rider's own plate (R-16). + reason: Why the rider left the team; carried in the audit + payload. + + Returns: + The appended ``extract_rider_to_solo`` audit event. + + Raises: + ValueError: *reason* is empty or whitespace-only. + IllegalStateError: the ride is RUNNING and not stopped, is + DRAFT or FINISHED, or its plate model is not + ``rider_pooled``. + UnknownPlateError: *rider_plate* names no rider, or names + a rider who is not on a pooled team. + """ + self._require_move_allowed(reason) + source, rider = self._find_rider(rider_plate) + if source.type.value != "team": + raise UnknownPlateError(f"plate {rider_plate} is not a pooled team member") + source_key = source.key + solo = self._roster.extract_rider_to_solo(rider) + destination_key = solo.key + self._roster.mark_has_data(solo) + self._reattribute_rider(rider_plate, source_key, destination_key) + return self._append( + Event( + action="extract_rider_to_solo", + payload={ + "rider_plate": rider_plate, + "from_key": source_key, + "to_key": destination_key, + "reason": reason, + }, + ) + ) + + def _require_move_allowed(self, reason: str) -> None: + """Refuse a rider move the ride's state or model forbids. + + The one gate both move primitives share (E3.1.2, R-17): *reason* + must name something, the ride must be stopped RUNNING (R-35's + Stop guard -- the operator locks plate entry before a mid-ride + re-shuffle) or REOPENED, and the ride must be ``rider_pooled``. + A live RUNNING ride is refused with "Stop the ride first", the + console's own instruction; DRAFT and FINISHED name their state. + ``team_relay`` is refused outright: the plate *is* the team + there, so a move would re-key identity itself -- the same + carve-out ``roster.can_move_rider`` makes, read off the stored + ``.value`` because this module never imports ``roster`` at + runtime (module docstring). + + Raises: + ValueError: *reason* is empty or whitespace-only. + IllegalStateError: the ride is RUNNING and not stopped, is + DRAFT or FINISHED, or its plate model is not + ``rider_pooled``. + """ + _require_reason(reason) + if self._state is RideStatus.RUNNING and not self._stopped: + msg = "Stop the ride first" + raise IllegalStateError(msg) + if self._state not in (RideStatus.RUNNING, RideStatus.REOPENED): + raise IllegalStateError(f"cannot move a rider from {self._state}") + if self._config.plate_model.value != "rider_pooled": + raise IllegalStateError( + f"rider moves need a rider_pooled ride, not {self._config.plate_model.value}" + ) + + def _find_rider(self, rider_plate: str) -> tuple[Entry, Rider]: + """Return the ``(entry, rider)`` *rider_plate* names, or raise. + + A rider lookup, never a plate resolution: a pooled team's own + plate is *derived* from its lowest-numbered member (S1), so + resolving *rider_plate* through the roster's plate index could + return the team itself -- and a move's subject must be the + member whose laps and cards travel. Duck-typed over + ``entries``/``riders`` like every other roster read here. + + Raises: + UnknownPlateError: no rider carries *rider_plate*. + """ + for entry in self._roster.entries: + for rider in entry.riders: + if rider.plate == rider_plate: + return entry, rider + raise UnknownPlateError(f"unknown plate: {rider_plate}") + + def _require_team(self, display_name: str) -> Entry: + """Return the TEAM entry named *display_name*, or raise. + + The destination lookup :meth:`move_rider` needs and no plate + resolution can serve: the operator picks a team by the name the + entry list shows, and a pooled team's plate is exactly what a + mid-ride move re-derives, so only the name names the target + (E3.1.2's pooled-live-move seam). + + Raises: + UnknownPlateError: *display_name* names no TEAM entry. + """ + for entry in self._roster.entries: + if entry.type.value == "team" and entry.display_name == display_name: + return entry + raise UnknownPlateError(f"unknown team: {display_name}") + + def _reattribute_rider(self, rider_plate: str, source_key: str, destination_key: str) -> None: + """Re-key *rider_plate*'s ride data from one entry to another. + + The direction-agnostic core of every pooled move: the roster's + membership change belongs to the caller (:meth:`move_rider`, + :meth:`extract_rider_to_solo`, and their replay branches in + :meth:`apply`), while the moved rider's *data* -- credited + cards, live laps, restored voided laps -- follows them here. It + takes **keys**, never ``Entry`` objects, so a replay can pass a + ``from_key`` whose entry the live move dissolved: by the time + the event is re-applied that entry no longer exists to hand + over, and the recorded key is the only identity that survives. + + Three passes, each selecting the moved rider's own data alone: + + - **credited cards.** Every card the source hand tags with + *rider_plate* moves to the destination hand, tag and all (a + manual card's ``None`` tag and a teammate's tag stay put). The + tag is what a per-rider DNF forfeits on, so it must travel. + - **live laps.** In record order, each live crossing of + *rider_plate* moves to the destination as its next lap, its + dealt card (and its hold, when held) travelling with it -- + never a fresh deal, and never a re-credit: a credited card + moved in the first pass and a voided one stays voided. + - **voided laps.** A ``void_crossing`` void *of this rider* is + reversed into the destination instead of staying void: the + card is un-voided and the voided lap re-appends, its + short-lap disposition (R-34) re-derived against its new + predecessor because the pre-void one was never stored. Only + the moved rider's own plate selects a void: a teammate's + voided lap is none of this move's business. + """ + source_hand = self._hand.get(source_key, []) + staying = [(card, tag) for card, tag in source_hand if tag != rider_plate] + travelling = [(card, tag) for card, tag in source_hand if tag == rider_plate] + if travelling: + self._hand[source_key] = staying + self._hand.setdefault(destination_key, []).extend(travelling) + + moved = [ + crossing + for crossing in self._crossings + if crossing.entry_id == source_key and crossing.rider_plate == rider_plate + ] + removed_seqs: list[int] = [] + for crossing in moved: + card = self._dealt.pop(crossing) + held = self._held.pop(crossing, None) + self._remove_crossing(crossing) + removed_seqs.append(crossing.seq) + replacement = Crossing( + entry_id=destination_key, + seq=len(self._laps_for(destination_key)) + 1, + crossed_at=crossing.crossed_at, + rider_plate=rider_plate, + ) + self._insert_crossing(replacement) + self._dealt[replacement] = card + if held is not None: + self._held[replacement] = held + # The source renumbers last: _renumber_later re-keys each later + # crossing through _replace_crossing, which carries the card to + # the replacement -- and the crossing this loop still holds + # would then have no _dealt entry left to look up. Descending + # order is the only one that leaves the survivors contiguous + # 1..N: each call closes the single gap above the seq it names. + for seq in reversed(removed_seqs): + self._renumber_later(source_key, seq) + + # Snapshot: the body takes every matching pair out of _voided. + for pair in list(self._voided): + crossing, card = pair + if crossing.rider_plate != rider_plate: + continue + self._voided.remove(pair) + self._unmark_voided(card) + replacement = Crossing( + entry_id=destination_key, + seq=len(self._laps_for(destination_key)) + 1, + crossed_at=crossing.crossed_at, + rider_plate=rider_plate, + ) + self._insert_crossing(replacement) + self._dealt[replacement] = card + laps = self._laps_for(destination_key) + position = laps.index(replacement) + previous = ( + laps[position - 1].crossed_at if position > 0 else self._require_actual_start() + ) + lap_time = (replacement.crossed_at - previous).total_seconds() + if self._config.hold_short_laps and lap_time < self._config.min_lap_s: + self._held[replacement] = card + else: + self._credit(destination_key, card, rider_plate) + def stop(self) -> Event: """Lock plate entry; the ride stays RUNNING (spec §3, R-35). @@ -3192,11 +3522,11 @@ def _append(self, event: Event) -> Event: # ------------------------------------- E5.1.2 replay seam: apply - # The replay dispatch is inherently one branch per action (20 - # mutations + the unknown-action guard); the cyclomatic count is - # the event vocabulary's size, not a refactorable control-flow - # tangle. - def apply(self, event: Event) -> None: # noqa: C901, PLR0912 + # The replay dispatch is inherently one branch per action (22 + # mutations + the unknown-action guard); the cyclomatic and + # statement counts are the event vocabulary's size, not a + # refactorable control-flow tangle. + def apply(self, event: Event) -> None: # noqa: C901, PLR0912, PLR0915 """Replay one previously-recorded event onto this engine. The store's replay seam: :class:`~rivercrossing.store. @@ -3340,6 +3670,31 @@ def apply(self, event: Event) -> None: # noqa: C901, PLR0912 Card.parse(str(event.payload["card"])), reason=str(event.payload["reason"]), ) + elif action == "move_rider": + # The roster is never re-mutated on replay: it is already + # final (it is rebuilt from the entry/rider tables, not + # from this log), so only the rider's data follows the + # from/to keys the move recorded -- the one identity a + # dissolved source entry cannot be re-derived from. The + # payload's ``to_team``/``reason`` are operator copy, never + # state. + self._reattribute_rider( + str(event.payload["rider_plate"]), + str(event.payload["from_key"]), + str(event.payload["to_key"]), + ) + self._append(event) + elif action == "extract_rider_to_solo": + # Same seam as ``move_rider``: the extracted rider's solo + # entry is already in the replayed roster, so the event + # restores the data under the recorded destination key and + # re-appends its own row. + self._reattribute_rider( + str(event.payload["rider_plate"]), + str(event.payload["from_key"]), + str(event.payload["to_key"]), + ) + self._append(event) elif action == "stop": self.stop() elif action == "finish": diff --git a/tests/unit/test_ride_move.py b/tests/unit/test_ride_move.py new file mode 100644 index 00000000..8353ee60 --- /dev/null +++ b/tests/unit/test_ride_move.py @@ -0,0 +1,801 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Unit tests for RideEngine's pooled live rider moves (E3.1.2, R-17). + +The two engine-level move primitives -- ``move_rider`` (team to team, +or solo to team) and ``extract_rider_to_solo`` (team to solo) -- plus +the direction-agnostic re-attribution core they share. Each is gated +on a stopped-running or REOPENED ride and a ``rider_pooled`` model, +moves the rider's *data* (live laps, held card, credited cards, +restored voided laps) with them, and appends one replayed audit +event. The hard gate is replay equivalence: the live and replayed +crossings, hold queue, credited hands and voided record must be +byte-identical. + +The arrange-time builders (``_config``/``_dt``/``_make_engine``/ +``_two_team_pooled_roster``) are imported from ``test_ride_corrections`` +so the two suites' fixture values cannot drift; this repo otherwise +keeps every test module self-contained. +""" + +import re + +import pytest +from test_ride_corrections import _config, _dt, _make_engine, _two_team_pooled_roster + +from conftest import entry_key, restore_entry_keys +from rivercrossing import ride as ride_module +from rivercrossing.ride import ( + Event, + IllegalStateError, + RideConfig, + RideEngine, + UnknownPlateError, +) +from rivercrossing.roster import EntryMode, PlateModel, Rider, Roster + +# ------------------------------------------------------------- fixtures + + +def _solo_and_team_roster() -> Roster: + """Build a pooled roster: solo rider "5" plus two-rider Team B.""" + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.RIDER_POOLED) + roster.create_solo_entry(first_name="Sol", last_name="", plate="5") + roster.create_team_entry( + display_name="Team B", + riders=[ + Rider(first_name="Cleo", last_name="", plate="3"), + Rider(first_name="Dana", last_name="", plate="4"), + ], + ) + return roster + + +def _started(roster: Roster, *, config: RideConfig | None = None) -> RideEngine: + """Build an engine RUNNING on *roster* since 10:00 (arrange).""" + engine, _clock = _make_engine(roster=roster, config=config) + engine.start(at=_dt(10, 0)) + return engine + + +def _state(engine: RideEngine) -> tuple[object, ...]: + """Return every replayable projection of *engine* (arrange). + + ``Store.load_engine`` must rebuild the identical ride, so these + are compared byte for byte. Every ``Crossing``/``Card`` is a + frozen value, so tuple equality covers an entry's stable key, its + seq, the crossing instant, the rider plate it was filed under and + the card code. + """ + return ( + engine.crossings, + engine.held_crossings(), + tuple(engine.credited_cards(entry.key) for entry in engine._roster.entries), + tuple(engine._voided), + ) + + +def _replay(engine: RideEngine, roster: Roster) -> RideEngine: + """Rebuild *engine* the way ``Store.load_engine`` does (arrange). + + A fresh same-config engine over the FINAL *roster* -- its entry + keys restored from the live engine's, which is what + ``Store.save_roster``/``_load_roster`` round-trip -- with the live + engine's own event log applied. + """ + replayed, _clock = _make_engine(roster=roster, config=engine.config) + restore_entry_keys(replayed, engine) + for event in engine.events: + replayed.apply(event) + return replayed + + +def _final_two_team_roster() -> Roster: + """Build the two-team roster a team-to-team replay is handed. + + The membership change the live roster already carries -- rider "2" + now on Team B -- applied to a fresh roster whose entries are in the + same order, which is what ``Store`` reloads. + """ + roster = _two_team_pooled_roster() + _team_a, team_b = roster.entries + rider = next(member for member in _team_a.riders if member.plate == "2") + roster.move_rider(rider, to_entry=team_b) + return roster + + +def _final_extracted_roster() -> Roster: + """Build the roster a team-to-solo replay is handed. + + Rider "2" already sits in the solo entry the extraction minted, + exactly as ``Store`` reloads it. + """ + roster = _two_team_pooled_roster() + rider = next(member for member in roster.entries[0].riders if member.plate == "2") + roster.extract_rider_to_solo(rider) + return roster + + +# ================================================= team -> team move + + +def test_move_rider_team_to_team_reattributes_the_riders_live_crossings() -> None: + """A stopped pooled move re-keys the rider's laps to the team.""" + roster = _two_team_pooled_roster() + team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("1", at=_dt(10, 30)) + engine.record_crossing("2", at=_dt(11, 0)) + engine.record_crossing("2", at=_dt(11, 30)) + engine.stop() + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert [(c.entry_id, c.rider_plate) for c in engine.crossings] == [ + (team_a.key, "1"), + (team_b.key, "2"), + (team_b.key, "2"), + ] + + +def test_move_rider_team_to_team_renumbers_the_source_teams_later_laps() -> None: + """Two removed laps close both source gaps: survivors stay 1..N.""" + roster = _two_team_pooled_roster() + team_a, _team_b = roster.entries + engine = _started(roster) + engine.record_crossing("2", at=_dt(10, 30)) # Team A seq 1, moves + engine.record_crossing("2", at=_dt(11, 0)) # Team A seq 2, moves + engine.record_crossing("1", at=_dt(11, 30)) # Team A seq 3 + engine.record_crossing("1", at=_dt(12, 0)) # Team A seq 4 + engine.stop() + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert [(c.seq, c.rider_plate) for c in engine._laps_for(team_a.key)] == [ + (1, "1"), + (2, "1"), + ] + + +def test_move_rider_team_to_team_appends_the_arrivals_after_the_destinations_laps() -> None: + """The destination numbers the arrivals after its own laps.""" + roster = _two_team_pooled_roster() + _team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("3", at=_dt(10, 30)) # Team B seq 1 + engine.record_crossing("2", at=_dt(11, 0)) # Team A seq 1, moves + engine.record_crossing("2", at=_dt(11, 30)) # Team A seq 2, moves + engine.stop() + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert [(c.seq, c.rider_plate) for c in engine._laps_for(team_b.key)] == [ + (1, "3"), + (2, "2"), + (3, "2"), + ] + + +def test_move_rider_team_to_team_appends_one_audit_event_naming_both_keys() -> None: + """move_rider writes exactly one event carrying both stable keys.""" + roster = _two_team_pooled_roster() + team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("2", at=_dt(10, 30)) + engine.stop() + before = len(engine.events) + + event = engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert event == Event( + action="move_rider", + payload={ + "rider_plate": "2", + "from_key": team_a.key, + "to_team": "Team B", + "to_key": team_b.key, + "reason": "rider swapped teams", + }, + ) + assert len(engine.events) == before + 1 # exactly one move row + assert engine.events[-1] == event + + +def test_move_rider_team_to_team_moves_a_rider_with_no_recorded_laps() -> None: + """A rider who has not crossed yet moves with an empty record.""" + roster = _two_team_pooled_roster() + _team_a, team_b = roster.entries + engine = _started(roster) + engine.stop() + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert engine.crossings == () + assert engine.credited_cards(team_b.key) == () + assert [rider.plate for rider in team_b.riders] == ["3", "4", "2"] + + +def test_move_rider_team_to_team_leaves_the_other_members_data_alone() -> None: + """Only the moved rider's laps and cards change hands.""" + roster = _two_team_pooled_roster() + team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("1", at=_dt(10, 30)) + engine.record_crossing("2", at=_dt(11, 0)) + engine.stop() + stay_card = engine.card_for(engine.crossings[0]) + move_card = engine.card_for(engine.crossings[1]) + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert [c.rider_plate for c in engine._laps_for(team_a.key)] == ["1"] + assert engine.credited_cards(team_a.key) == (stay_card,) + assert engine.credited_cards(team_b.key) == (move_card,) + + +def test_move_rider_team_to_team_moves_the_credited_cards_and_their_tag() -> None: + """Cards travel with the rider tag a per-rider DNF forfeits on.""" + roster = _two_team_pooled_roster() + _team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("2", at=_dt(10, 30)) + engine.record_crossing("1", at=_dt(11, 0)) + engine.record_crossing("2", at=_dt(11, 30)) + engine.stop() + first = engine.card_for(engine.crossings[0]) + second = engine.card_for(engine.crossings[2]) + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert engine._hand[team_b.key] == [(first, "2"), (second, "2")] + + +def test_move_rider_team_to_team_keeps_a_manual_card_with_its_entry() -> None: + """A bonus card's ``None`` tag keeps it off the move.""" + roster = _two_team_pooled_roster() + team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("2", at=_dt(10, 30)) + crossing_card = engine.card_for(engine.crossings[0]) + engine.deal_manual("2", reason="bonus card") + manual_card = engine.credited_cards(team_a.key)[-1] + engine.stop() + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert engine.credited_cards(team_a.key) == (manual_card,) + assert engine.credited_cards(team_b.key) == (crossing_card,) + + +def test_move_rider_team_to_team_moves_a_held_card_still_held() -> None: + """A short lap's card stays in the hold queue, under the new key.""" + roster = _two_team_pooled_roster() + _team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("2", at=_dt(10, 0, 30)) # 30 s -> held + engine.stop() + held_card = engine.held_crossings()[0].card + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert [ + (item.crossing.entry_id, item.crossing.rider_plate, item.card) + for item in engine.held_crossings() + ] == [(team_b.key, "2", held_card)] + assert engine.credited_cards(team_b.key) == () + + +def test_move_rider_team_to_team_leaves_a_voided_card_voided() -> None: + """A void_card void is card-level: the moved lap stays cardless.""" + roster = _two_team_pooled_roster() + _team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("2", at=_dt(10, 30)) + card = engine.card_for(engine.crossings[0]) + engine.void_card("2", card, reason="wrong card dealt") + engine.stop() + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + moved = engine.crossings[0] + assert (moved.entry_id, engine.card_for(moved)) == (team_b.key, card) + assert engine.is_card_voided(card) is True + assert engine.credited_cards(team_b.key) == () + + +def test_move_rider_team_to_team_restores_a_voided_crossing_to_the_destination() -> None: + """A void_crossing lap of the rider travels and re-credits.""" + roster = _two_team_pooled_roster() + team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("2", at=_dt(10, 30)) + engine.record_crossing("2", at=_dt(11, 0)) + first = engine.card_for(engine.crossings[0]) + voided_card = engine.card_for(engine.crossings[1]) + engine.void_crossing(team_a.key, 2, reason="double entry") + engine.stop() + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert [(c.seq, c.crossed_at) for c in engine._laps_for(team_b.key)] == [ + (1, _dt(10, 30)), + (2, _dt(11, 0)), + ] + assert engine.is_card_voided(voided_card) is False + assert engine.credited_cards(team_b.key) == (first, voided_card) + assert engine._voided == [] + + +def test_move_rider_team_to_team_reholds_a_short_voided_crossing() -> None: + """A restored short lap re-enters the hold queue at the new team.""" + roster = _two_team_pooled_roster() + team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("2", at=_dt(10, 30)) # 1800 s -> credited + engine.record_crossing("2", at=_dt(10, 35)) # 300 s -> held, then voided + first = engine.card_for(engine.crossings[0]) + short_card = engine.card_for(engine.crossings[1]) + engine.void_crossing(team_a.key, 2, reason="double entry") + engine.stop() + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert [(item.crossing.entry_id, item.card) for item in engine.held_crossings()] == [ + (team_b.key, short_card) + ] + assert engine.credited_cards(team_b.key) == (first,) + + +def test_move_rider_team_to_team_credits_a_short_restored_lap_when_hold_is_off() -> None: + """With hold_short_laps off, a restored short lap credits.""" + roster = _two_team_pooled_roster() + team_a, team_b = roster.entries + engine = _started(roster, config=_config(hold_short_laps=False)) + engine.record_crossing("2", at=_dt(10, 30)) + engine.record_crossing("2", at=_dt(10, 35)) # short, credited: always-deal + first = engine.card_for(engine.crossings[0]) + short_card = engine.card_for(engine.crossings[1]) + engine.void_crossing(team_a.key, 2, reason="double entry") + engine.stop() + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert engine.held_crossings() == () + assert engine.credited_cards(team_b.key) == (first, short_card) + + +def test_move_rider_team_to_team_leaves_another_riders_voided_lap_alone() -> None: + """The restore pass selects the moved rider's plate alone.""" + roster = _two_team_pooled_roster() + team_a, _team_b = roster.entries + engine = _started(roster) + engine.record_crossing("1", at=_dt(10, 30)) + engine.record_crossing("2", at=_dt(11, 0)) + stay_card = engine.card_for(engine.crossings[0]) + engine.void_crossing(team_a.key, 1, reason="double entry") + engine.stop() + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert engine.is_card_voided(stay_card) is True + assert len(engine._voided) == 1 + assert engine.credited_cards(team_a.key) == () + + +def test_move_rider_of_the_anchor_rider_rekeys_both_teams_with_no_collision() -> None: + """Moving A's anchor re-derives both plates; the keys stay.""" + roster = _two_team_pooled_roster() + team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("1", at=_dt(10, 30)) + engine.record_crossing("2", at=_dt(11, 0)) + engine.stop() + + engine.move_rider("1", to_team="Team B", reason="rider swapped teams") + + assert [entry.plate for entry in engine._roster.entries] == ["2", "1"] + assert [(c.entry_id, c.rider_plate) for c in engine.crossings] == [ + (team_a.key, "2"), + (team_b.key, "1"), + ] + + +# ================================================= team -> solo move + + +def test_extract_rider_to_solo_moves_the_riders_laps_to_the_new_key() -> None: + """An extracted rider's laps and cards land on the new solo key.""" + roster = _two_team_pooled_roster() + team_a, _team_b = roster.entries + engine = _started(roster) + engine.record_crossing("1", at=_dt(10, 30)) + engine.record_crossing("2", at=_dt(11, 0)) + engine.record_crossing("2", at=_dt(11, 30)) + engine.stop() + stay_card = engine.card_for(engine.crossings[0]) + moved_cards = (engine.card_for(engine.crossings[1]), engine.card_for(engine.crossings[2])) + + engine.extract_rider_to_solo("2", reason="rider rides alone") + + solo_key = entry_key(engine._roster, "2") + assert [(c.entry_id, c.rider_plate) for c in engine.crossings] == [ + (team_a.key, "1"), + (solo_key, "2"), + (solo_key, "2"), + ] + assert engine.credited_cards(solo_key) == moved_cards + assert engine.credited_cards(team_a.key) == (stay_card,) + + +def test_extract_rider_to_solo_appends_one_audit_event_naming_both_keys() -> None: + """extract_rider_to_solo writes one event with both stable keys.""" + roster = _two_team_pooled_roster() + team_a, _team_b = roster.entries + engine = _started(roster) + engine.record_crossing("2", at=_dt(10, 30)) + engine.stop() + + event = engine.extract_rider_to_solo("2", reason="rider rides alone") + + assert event == Event( + action="extract_rider_to_solo", + payload={ + "rider_plate": "2", + "from_key": team_a.key, + "to_key": entry_key(engine._roster, "2"), + "reason": "rider rides alone", + }, + ) + assert engine.events[-1] == event + + +def test_extract_rider_to_solo_leaves_the_other_members_laps_behind() -> None: + """The source team keeps its remaining member's laps and cards.""" + roster = _two_team_pooled_roster() + team_a, _team_b = roster.entries + engine = _started(roster) + engine.record_crossing("1", at=_dt(10, 30)) + engine.record_crossing("2", at=_dt(11, 0)) + engine.stop() + stay_card = engine.card_for(engine.crossings[0]) + + engine.extract_rider_to_solo("2", reason="rider rides alone") + + assert [(c.seq, c.rider_plate) for c in engine._laps_for(team_a.key)] == [(1, "1")] + assert engine.credited_cards(team_a.key) == (stay_card,) + + +# ================================================= solo -> team move + + +def test_move_rider_solo_to_team_moves_the_solo_laps_onto_the_team_key() -> None: + """A whole solo entry's laps and cards move onto the team.""" + roster = _solo_and_team_roster() + solo, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("5", at=_dt(10, 30)) + engine.record_crossing("5", at=_dt(11, 0)) + engine.stop() + cards = (engine.card_for(engine.crossings[0]), engine.card_for(engine.crossings[1])) + + engine.move_rider("5", to_team="Team B", reason="rider joins a team") + + assert [entry.plate for entry in engine._roster.entries] == ["3"] + assert [(c.entry_id, c.seq) for c in engine._laps_for(team_b.key)] == [ + (team_b.key, 1), + (team_b.key, 2), + ] + assert engine._laps_for(solo.key) == () + assert engine.credited_cards(team_b.key) == cards + + +def test_move_rider_solo_to_team_audits_the_dissolved_solo_key() -> None: + """The payload names the solo entry the move dissolved.""" + roster = _solo_and_team_roster() + solo, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("5", at=_dt(10, 30)) + engine.stop() + + event = engine.move_rider("5", to_team="Team B", reason="rider joins a team") + + assert event == Event( + action="move_rider", + payload={ + "rider_plate": "5", + "from_key": solo.key, + "to_team": "Team B", + "to_key": team_b.key, + "reason": "rider joins a team", + }, + ) + assert engine.events[-1] == event + + +# ====================================================== move refusals + + +def test_move_rider_while_running_and_not_stopped_refuses_with_stop_first() -> None: + """The live cell of the gate: stop the clock before a move.""" + engine = _started(_two_team_pooled_roster()) + + with pytest.raises(IllegalStateError, match=re.escape("Stop the ride first")): + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + +def test_extract_rider_to_solo_while_running_and_not_stopped_refuses() -> None: + """Extraction shares the same stopped-running gate.""" + engine = _started(_two_team_pooled_roster()) + + with pytest.raises(IllegalStateError, match=re.escape("Stop the ride first")): + engine.extract_rider_to_solo("2", reason="rider rides alone") + + +def test_move_rider_on_a_draft_ride_raises_illegal_state_error_naming_draft() -> None: + """DRAFT refuses the move and names the state.""" + engine, _clock = _make_engine(roster=_two_team_pooled_roster()) + + with pytest.raises(IllegalStateError, match=re.escape("cannot move a rider from draft")): + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + +def test_extract_rider_to_solo_on_a_draft_ride_raises_illegal_state_error() -> None: + """DRAFT refuses the extraction and names the state.""" + engine, _clock = _make_engine(roster=_two_team_pooled_roster()) + + with pytest.raises(IllegalStateError, match=re.escape("cannot move a rider from draft")): + engine.extract_rider_to_solo("2", reason="rider rides alone") + + +def test_move_rider_on_a_finished_ride_raises_illegal_state_error_naming_finished() -> None: + """FINISHED refuses the move: corrections go through Reopen.""" + engine = _started(_two_team_pooled_roster()) + engine.finish() + + with pytest.raises(IllegalStateError, match=re.escape("cannot move a rider from finished")): + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + +def test_move_rider_on_a_team_relay_ride_raises_illegal_state_error() -> None: + """A relay ride is refused: its plate *is* the team.""" + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.TEAM_RELAY) + roster.create_team_entry( + display_name="Relay A", + riders=[ + Rider(first_name="Ada", last_name="", plate="1"), + Rider(first_name="Bea", last_name="", plate="2"), + ], + plate="10", + ) + engine = _started(roster, config=_config(plate_model=PlateModel.TEAM_RELAY)) + engine.stop() + + with pytest.raises(IllegalStateError, match=re.escape("rider moves need a rider_pooled ride")): + engine.move_rider("10", to_team="Relay A", reason="rider swapped teams") + + +def test_move_rider_onto_the_riders_own_team_raises_illegal_state_error() -> None: + """A same-team move is refused, never a silent re-key.""" + engine = _started(_two_team_pooled_roster()) + engine.stop() + + with pytest.raises(IllegalStateError, match=re.escape("is already on team Team A")): + engine.move_rider("2", to_team="Team A", reason="rider swapped teams") + + +@pytest.mark.parametrize("reason", ["", " ", "\t\n"]) +def test_move_rider_with_a_blank_reason_raises_value_error(reason: str) -> None: + """Every move is audited, so a blank reason is refused.""" + engine = _started(_two_team_pooled_roster()) + engine.stop() + + with pytest.raises(ValueError, match=re.escape("reason must not be empty")): + engine.move_rider("2", to_team="Team B", reason=reason) + + +@pytest.mark.parametrize("reason", ["", " ", "\t\n"]) +def test_extract_rider_to_solo_with_a_blank_reason_raises_value_error(reason: str) -> None: + """Extraction needs a reason too.""" + engine = _started(_two_team_pooled_roster()) + engine.stop() + + with pytest.raises(ValueError, match=re.escape("reason must not be empty")): + engine.extract_rider_to_solo("2", reason=reason) + + +@pytest.mark.parametrize( + ("plate", "message"), + [("99", "unknown plate: 99"), ("", "unknown plate: ")], +) +def test_move_rider_with_an_unknown_rider_plate_raises_unknown_plate_error( + plate: str, message: str +) -> None: + """A mistyped rider number fails loudly, an empty one too.""" + engine = _started(_two_team_pooled_roster()) + engine.stop() + + with pytest.raises(UnknownPlateError, match=re.escape(message)): + engine.move_rider(plate, to_team="Team B", reason="rider swapped teams") + + +@pytest.mark.parametrize( + ("team", "message"), + [("Team Z", "unknown team: Team Z"), ("", "unknown team: ")], +) +def test_move_rider_with_an_unknown_destination_team_raises_unknown_plate_error( + team: str, message: str +) -> None: + """A mistyped team name fails loudly, an empty one too.""" + engine = _started(_two_team_pooled_roster()) + engine.stop() + + with pytest.raises(UnknownPlateError, match=re.escape(message)): + engine.move_rider("2", to_team=team, reason="rider swapped teams") + + +def test_extract_rider_to_solo_from_a_solo_entry_raises_unknown_plate_error() -> None: + """A solo rider has nothing to be extracted from.""" + engine = _started(_solo_and_team_roster()) + engine.stop() + + with pytest.raises(UnknownPlateError, match=re.escape("not a pooled team member")): + engine.extract_rider_to_solo("5", reason="rider rides alone") + + +def test_move_rider_refused_while_running_leaves_the_ride_untouched() -> None: + """A refused move touches no lap, no card and no audit row.""" + roster = _two_team_pooled_roster() + team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("2", at=_dt(10, 30)) + before = (engine.crossings, engine.credited_cards(team_a.key), engine.events) + + with pytest.raises(IllegalStateError, match=re.escape("Stop the ride first")): + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert (engine.crossings, engine.credited_cards(team_a.key), engine.events) == before + assert engine.credited_cards(team_b.key) == () + + +def test_move_rider_on_a_reopened_ride_moves_the_rider() -> None: + """REOPENED is the corrections cell the gate leaves open.""" + roster = _two_team_pooled_roster() + _team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("2", at=_dt(10, 30)) + engine.finish() + engine.reopen() + + engine.move_rider("2", to_team="Team B", reason="rider swapped teams") + + assert [(c.entry_id, c.seq) for c in engine.crossings] == [(team_b.key, 1)] + + +def test_pooled_move_actions_are_replayable() -> None: + """Store.load_engine must replay both move rows, never skip them.""" + assert {"move_rider", "extract_rider_to_solo"} <= ride_module.REPLAY_ACTIONS + + +# ==================================================== replay gate + + +def test_move_rider_replay_reproduces_the_credited_hands() -> None: + """A team-to-team move live equals a replay of its event log. + + The move's own row replays verbatim -- its payload carries the + operator's reason, which no rebuilt roster re-derives -- while the + earlier ``record_crossing`` rows carry a roster-derived ``reason`` + sentence that a replay legitimately recomputes (the class + docstring's own "compares event count/actions, not payload fields" + replay contract), so the whole ``events`` tuple is not compared. + """ + live, _clock = _make_engine(roster=_two_team_pooled_roster(), config=_config()) + live.start(at=_dt(10, 0)) + live.record_crossing("2", at=_dt(10, 30)) + live.record_crossing("1", at=_dt(11, 0)) + live.record_crossing("2", at=_dt(11, 30)) + live.stop() + live.move_rider("2", to_team="Team B", reason="rider swapped teams") + + replayed = _replay(live, _final_two_team_roster()) + + assert _state(replayed) == _state(live) + assert replayed.events[-1] == live.events[-1] + + +def test_move_rider_replay_reproduces_a_held_card_under_the_new_key() -> None: + """A held card's destination key survives the rebuild.""" + live, _clock = _make_engine(roster=_two_team_pooled_roster(), config=_config()) + live.start(at=_dt(10, 0)) + live.record_crossing("2", at=_dt(10, 0, 30)) # held + live.record_crossing("2", at=_dt(10, 30)) # credited + live.stop() + live.move_rider("2", to_team="Team B", reason="rider swapped teams") + + replayed = _replay(live, _final_two_team_roster()) + + assert _state(replayed) == _state(live) + + +def test_move_rider_replay_reproduces_a_restored_voided_lap() -> None: + """The void reset re-derives the same disposition on replay.""" + roster = _two_team_pooled_roster() + team_a, _team_b = roster.entries + live, _clock = _make_engine(roster=roster, config=_config()) + live.start(at=_dt(10, 0)) + live.record_crossing("2", at=_dt(10, 30)) + live.record_crossing("2", at=_dt(10, 35)) # short -> held, then voided + live.void_crossing(team_a.key, 2, reason="double entry") + live.stop() + live.move_rider("2", to_team="Team B", reason="rider swapped teams") + + replayed = _replay(live, _final_two_team_roster()) + + assert _state(replayed) == _state(live) + + +def test_extract_rider_to_solo_replay_stays_equivalent() -> None: + """A team-to-solo extraction live equals a replay of its log. + + The extraction's destination key is the new solo entry's, which the + replayed roster already carries (Store persists a *live* entry), so + the recorded laps re-key onto it exactly as they did live. + """ + live, _clock = _make_engine(roster=_two_team_pooled_roster(), config=_config()) + live.start(at=_dt(10, 0)) + live.record_crossing("2", at=_dt(10, 0, 30)) # held + live.record_crossing("2", at=_dt(10, 30)) # credited + live.record_crossing("1", at=_dt(11, 0)) + live.stop() + live.extract_rider_to_solo("2", reason="rider rides alone") + + replayed = _replay(live, _final_extracted_roster()) + + assert _state(replayed) == _state(live) + assert replayed.events[-1] == live.events[-1] + + +def test_apply_move_rider_event_reattributes_from_the_payload_keys() -> None: + """A hand-built row replays its keys, not the roster's plates.""" + roster = _two_team_pooled_roster() + team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("2", at=_dt(10, 30)) + engine.stop() + event = Event( + action="move_rider", + payload={ + "rider_plate": "2", + "from_key": team_a.key, + "to_team": "Not A Team", + "to_key": team_b.key, + "reason": "", + }, + ) + + engine.apply(event) + + assert [c.entry_id for c in engine.crossings] == [team_b.key] + assert engine.events[-1] == event + + +def test_apply_extract_rider_to_solo_event_reattributes_from_the_payload() -> None: + """The extract row replays onto a destination the roster holds.""" + roster = _two_team_pooled_roster() + team_a, team_b = roster.entries + engine = _started(roster) + engine.record_crossing("2", at=_dt(10, 30)) + engine.stop() + event = Event( + action="extract_rider_to_solo", + payload={ + "rider_plate": "2", + "from_key": team_a.key, + "to_key": team_b.key, + "reason": "rider rides alone", + }, + ) + + engine.apply(event) + + assert [c.entry_id for c in engine.crossings] == [team_b.key] + assert engine.events[-1] == event From 798782cb7f8ccb31d5bfd01718bacd2c8ff180ff Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Sun, 20 Sep 2026 18:43:42 -0400 Subject: [PATCH 05/15] feat(store): schema v2 with retired entry keys and v1 migration Persist dissolved entries that carry recorded data so the pre-move crossings of a solo->team move stay resolvable after a reload; the ride reopens instead of raising UnknownPlateError. Bump SCHEMA_VERSION to 2 and add store/migrations.py, whose v1->v2 step rebuilds the entry table to add the stable key and retired columns and the live-only plate unique index. ensure_schema now migrates older files and still refuses a newer one. --- design/docs-md/module-skeletons.md | 15 +- design/docs-md/spec.md | 4 +- src/rivercrossing/ride.py | 22 +- src/rivercrossing/roster.py | 67 +- src/rivercrossing/store/__init__.py | 234 ++++--- src/rivercrossing/store/migrations.py | 187 ++++++ src/rivercrossing/store/schema.py | 84 ++- src/rivercrossing/ui/app.py | 7 +- tests/unit/test_ride_move.py | 119 +++- tests/unit/test_roster.py | 138 +++- tests/unit/test_store.py | 907 +++++++++++++++++++++++++- 11 files changed, 1624 insertions(+), 160 deletions(-) create mode 100644 src/rivercrossing/store/migrations.py diff --git a/design/docs-md/module-skeletons.md b/design/docs-md/module-skeletons.md index d2fd8d91..30adfed1 100644 --- a/design/docs-md/module-skeletons.md +++ b/design/docs-md/module-skeletons.md @@ -46,8 +46,10 @@ rivercrossing/ │ ├── rider_issues.py # roster defect report (R-78, §S4) │ ├── store/ │ │ ├── __init__.py # Store facade (public API); audit reads via Store.audit_rows -│ │ ├── schema.py # DDL v1 + PRAGMAs (WAL, foreign_keys); one flattened v1 -│ │ │ # baseline — no migrations module (Phase 2, SCHEMA_VERSION=1) +│ │ ├── schema.py # latest DDL + PRAGMAs (WAL, foreign_keys); SCHEMA_VERSION +│ │ │ # gate: create on empty, migrate older, refuse newer +│ │ ├── migrations.py # MIGRATIONS: source version -> the step to the next +│ │ │ # (v1 -> v2 rebuilds entry); run_migrations(conn, from, to) │ │ └── backup.py # open + hourly + manual, keep 20 (R-54) │ ├── csvio.py # §7 import/export, preview-then-commit │ ├── htmlexport.py # §8 Jinja2 renderer (self-contained page; + poster page) @@ -271,8 +273,13 @@ class Store: # facade; sqlite3, WAL, foreign_keys ON # nothing calls it yet, and append() commits synchronously (store/__init__.py) backup.run(path, keep=20) · backup.schedule_hourly(…) · backup.restore(src, dst) schema.py: ride · entry · rider · crossing · card · app_session · audit (+ schema_version) -(columns per Spec §2, incl. status enum with REOPENED, shoe seed, plate_model; one flattened - v1 baseline — no migrations, and no settings table: E8.1.1 keeps settings in a JSON config file) +ensure_schema(conn) -> None # create on an empty file · run MIGRATIONS on an older one · + # no-op on the current one · SchemaVersionMismatchError on newer +migrations.py: MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] · + run_migrations(conn, from_version, to_version) -> None +(columns per Spec §2, incl. status enum with REOPENED, shoe seed, plate_model; SCHEMA_VERSION + is 2 and every schema change ships the step that upgrades an older file — no settings table: + E8.1.1 keeps settings in a JSON config file) ``` rivercrossing.csvio / htmlexport / pdfexport (§7/§8/§8b · R-21/61/62/63) diff --git a/design/docs-md/spec.md b/design/docs-md/spec.md index d32f8cee..5d36ace5 100644 --- a/design/docs-md/spec.md +++ b/design/docs-md/spec.md @@ -16,14 +16,14 @@ PRAGMA journal_mode=WAL · synchronous=NORMAL · foreign_keys=ON. One transactio | Table | Columns (″·″ separated; *italic = nullable*) | |---|---| | ride | id · name · event_date · venue · course_name · lap_km · organizer · scorer · *logo_png BLOB* · planned_start · planned_duration_s · *actual_start* · *finished_at* · status (draft \| running \| finished \| reopened — persisted on every lifecycle event, so the library reads it straight from the row with no replay) · entry_mode (solo \| mixed — **default mixed**; mixed enables teams) · max_team_size (2–10, default 4) · plate_model (rider_pooled \| team_relay — default rider_pooled) · min_lap_s · deck_count · jokers_per_deck (0–10, default 1) · jokers_mode (per_deck \| total — default total) · *max_cards* (NULL = uncapped; the setup Card cap dropdown: Disabled by default, else 5–20) · tiebreak_order JSON · rng_seed · created_at · updated_at · hold_short_laps (W4's per-ride short-lap card policy, NOT NULL DEFAULT 1 = hold short-lap cards for review — see the schema-default note below) | -| entry | id · ride_id → · plate (UNIQUE per ride — one namespace with rider plates; a rider_pooled team entry adopts its lowest-numbered rider's plate) · display_name · type (solo \| team) · team_size (2 ≤ n ≤ ride.max_team_size) · status (active \| dnf) · *dnf_at* · *notes* · *logo_card* (the team logo — a card code alone; Phase 3 retired the *logo_png* image column, folded out of the flattened baseline — no migration — the ride's own organisation *logo_png* above is a different column and stays) | +| entry | id · ride_id → · plate (UNIQUE per ride **among live rows** — one namespace with rider plates; a rider_pooled team entry adopts its lowest-numbered rider's plate, and a retired entry keeps the plate it held) · key (the entry's stable identity — the surrogate the ride engine files crossings and credited hands under, so a re-plating cannot move recorded laps; a uuid4 hex no operator ever sees) · display_name · type (solo \| team) · team_size (2 ≤ n ≤ ride.max_team_size) · status (active \| dnf) · *dnf_at* · *notes* · *logo_card* (the team logo — a card code alone; Phase 3 retired the *logo_png* image column, folded into the v1 baseline — the ride's own organisation *logo_png* above is a different column and stays) · retired (0 for a live entry, 1 once a pooled move dissolved an entry that had recorded data — the row survives so its key still resolves a replayed crossing; the `key`/`retired` pair and the partial unique index are v2's v1 → v2 migration) | | rider | id · entry_id → · first_name · last_name (Phase 1 split of the single `name` — greenfield reset, no migration; `last_name` may be blank, a one-word rider renders as the first name alone) · *plate* (rider_pooled rides: unique per ride; crossings and cards attribute to the rider's plate and pool to the entry) · sort_order · *sex* (M \| F — NULL when unknown) · *emergency_contact* · *waiver_signed* · *ccn_reg_id* (optional fields from race-timing practice; no age/category fields anywhere — out of scope by decision) | | crossing | id · ride_id → · entry_id → · *rider_id* (which team member, if tracked) · seq (1..n per entry) · crossed_at · lap_s · flag (none \| short \| manual) · voided · *void_reason* | | card | id · ride_id → · entry_id → · *crossing_id* (NULL = added manually) · *shoe_index* · rank (2–14, 0 = joker) · *suit* (s h d c) · state (held \| dealt \| voided) · dealt_at | | app_session | id · opened_at · *closed_at* (written on clean exit — NULL means the previous session crashed) · *active_ride_id* · heartbeat_at (touched every 30 s while a ride runs) — how reopening knows a ride was running and whether the close was clean or a crash. | | audit | id · ride_id → · at · action · payload_json — every mutation: record, undo, void, reassign plate, deal, manual card, DNF, setting change. Undo = compensating write, never DELETE. | -The tables above are the single flattened **v1 baseline** — `SCHEMA_VERSION = 1`, edited in place as the schema evolves, never migrated. The v0 → v1 → v2 → v3 migration chain and `store/migrations.py` were removed in Phase 2 (the rider `sex` column): the project is unreleased, so a database file written by an older build is stale and must be recreated, and migrations return after release. `hold_short_laps` is therefore a plain column of the CREATE above, `NOT NULL DEFAULT 1` = hold short-lap cards for review: the default flipped from 0 (always deal) to **1** in the 1.0.17 follow-up, so a fresh ride now holds a short lap's card until the operator confirms or voids it (the operator can still choose Always deal at setup). Schema change from here on means "edit the CREATE in place". +The tables above are the **current schema — `SCHEMA_VERSION = 2`** — and the CREATEs in `src/rivercrossing/store/schema.py` are the latest shape, not a frozen baseline. Versioning policy (product owner, 2026-09-20): **every schema change increments `SCHEMA_VERSION` and ships migration code in `src/rivercrossing/store/migrations.py` that upgrades older database files in place.** `MIGRATIONS` maps a source version to the step that takes it to the next (`MIGRATIONS[1]` is v1 → v2); `ensure_schema` creates the current schema on an empty file, runs that chain on an older one, no-ops on a current one, and **refuses only a file newer than the build** — there is no downgrade path, so a file written by a later build is still politely rejected. A step is frozen history: it describes one jump between two versions once and is never edited to match a later DDL, the next version's step superseding it. v1 was the released flattened baseline (the Phase 1 `rider` name split, `ride.hold_short_laps`, `ride.jokers_mode`, the retired `entry.logo_png` image column and `rider.sex` were all folded into its CREATE while the project was unreleased). **v2** adds `entry.key` — the entry's stable identity, the surrogate the ride engine files crossings and credited hands under where a derived plate is mutable — and `entry.retired` (1 once a pooled move dissolves an entry that had recorded data), and moves the per-ride plate uniqueness off the table into the partial unique index over the live rows alone (`entry_plate_live_unique`, `WHERE retired = 0`); the v1 → v2 step rebuilds `entry` with the standard SQLite table-rebuild procedure, because SQLite cannot drop a table-level `UNIQUE`. `hold_short_laps` is a plain column of the CREATE above, `NOT NULL DEFAULT 1` = hold short-lap cards for review: the default flipped from 0 (always deal) to **1** in the 1.0.17 follow-up, so a fresh ride now holds a short lap's card until the operator confirms or voids it (the operator can still choose Always deal at setup). ### 3 · Ride state machine diff --git a/src/rivercrossing/ride.py b/src/rivercrossing/ride.py index 9880eb7b..ff8ceb3a 100644 --- a/src/rivercrossing/ride.py +++ b/src/rivercrossing/ride.py @@ -1158,17 +1158,17 @@ class RideEngine: is already final (``Store.load_engine`` rebuilds it from the entry/rider tables, never from the event log), so the recorded laps re-key onto the destination exactly as they did live. - - **Known gap: a solo-sourced move cannot be replayed.** The - ``record_crossing`` rows recorded before such a move carry the - *dissolved* solo entry's key, and ``apply`` resolves those by - key -- a key the reloaded roster no longer holds, because - ``Store.save_roster`` writes a snapshot of the live entries and - a dissolved entry is not among them. Reopening such a ride - raises ``UnknownPlateError`` from the replay seam. The same gap - already exists for the roster-level solo move Phase 2 opened; - closing it needs a decision above this module (persist a - retired entry row, or re-key the earlier rows), so it is - recorded here rather than invented. + - **A dissolved source entry is retired, so its key survives.** + The ``record_crossing`` rows recorded before a solo-sourced move + carry the *dissolved* solo entry's key, and ``apply`` resolves + those by key -- so a dissolve keeps an entry that carries + recorded data instead of discarding it (``Roster``'s retired + collection, persisted by ``Store.save_roster`` as + ``entry.retired = 1`` and restored by ``Store._load_roster``). + A reloaded ride therefore still resolves the pre-move rows, and + a replayed ``mark_has_data`` on the retired entry is a known + entry, not a foreign one. An entry dissolved with no recorded + data is still discarded outright: nothing names its key. - **A voided lap of the moved rider is re-interpreted, not restored.** The pre-void held/credited disposition is not stored, so the restored lap's short-lap disposition is diff --git a/src/rivercrossing/roster.py b/src/rivercrossing/roster.py index 6937512a..50e41f20 100644 --- a/src/rivercrossing/roster.py +++ b/src/rivercrossing/roster.py @@ -53,6 +53,14 @@ solo rider straight onto a team, and ``extract_rider_to_solo`` converts a pooled team member into their own solo entry. +A dissolved entry that carries recorded data is **retired**, never +discarded (:attr:`Roster.retired_entries`): the ride engine files its +crossings and credited hands under ``Entry.key`` and replay resolves +those stored rows by key, so the key of the entry a move dissolves +has to stay resolvable -- across a reload too, which is why +:class:`~rivercrossing.store.Store` persists retired entries. +An entry with no recorded data is removed outright: nothing names it. + ``Entry`` and ``Rider`` compare by identity, not by field value (``eq=False``): they are living records a caller holds a reference to across renames and moves, not interchangeable values like @@ -512,6 +520,7 @@ def __init__( # noqa: PLR0913 -- four keyword-only ride settings; bundle with c self._plate_model = plate_model self._status = RideStatus.DRAFT self._entries: list[Entry] = [] + self._retired: list[Entry] = [] self._audit_log: list[AuditEvent] = [] self._team_logo_seed = team_logo_seed self._team_logo_codes = ( @@ -586,6 +595,19 @@ def entries(self) -> tuple[Entry, ...]: """Return every entry, in creation order, read-only.""" return tuple(self._entries) + @property + def retired_entries(self) -> tuple[Entry, ...]: + """Return every retired entry, in dissolve order, read-only. + + A dissolved entry that carried recorded data keeps its + :attr:`Entry.key` resolvable here (:meth:`entry_by_key`) so a + reloaded ride's replay can still resolve the rows recorded + before the move that dissolved it (E3.1.2's pooled-live-move + replay seam). A data-less entry is discarded outright and + never appears. + """ + return tuple(self._retired) + @property def audit_log(self) -> tuple[AuditEvent, ...]: """Return every audit event, oldest first, read-only.""" @@ -622,6 +644,25 @@ def load_entries(self, entries: Sequence[Entry]) -> None: """ self._entries.extend(entries) + def load_retired_entries(self, entries: Sequence[Entry]) -> None: + """Restore persisted retired entries wholesale (store seam). + + :meth:`load_entries`'s counterpart for + :attr:`retired_entries`, with the same + reconstruction-not-mutation contract: appends *entries* with no + validation and no audit logging, because the rows came from a + consistent persisted state and a restore is not a live edit. + ``Store._load_roster`` calls this to rebuild the entries a ride + has retired, which is what keeps their keys resolvable for + replay. A restored retired entry carries no riders and no + ``has_data`` flag -- the store persists neither for a retired + row. + + Args: + entries: The retired entries to restore, in dissolve order. + """ + self._retired.extend(entries) + def next_team_logo_card(self, after: str | None = None) -> str | None: """Return the next unused seeded logo card at/after *after*. @@ -724,10 +765,13 @@ def entry_by_key(self, key: str) -> Entry | None: replaying an event resolves its entry by the key the payload carries -- a lookup no re-plating can invalidate, where the plate the operator typed at the time is exactly what a move - re-derives. An unknown key is the None case, never an error: - replay's own ``_require_entry_by_key`` is what refuses. + re-derives. Retired entries are searched too, because a move + that dissolves a solo entry still leaves the crossings it + recorded filed under that entry's key (:attr:`retired_entries`). + An unknown key is the None case, never an error: replay's own + ``_require_entry_by_key`` is what refuses. """ - for entry in self._entries: + for entry in (*self._entries, *self._retired): if entry.key == key: return entry return None @@ -1488,8 +1532,16 @@ def _dissolve_entry(self, entry: Entry) -> None: dissolved: a TEAM entry logs ``dissolve_team_entry``, any other entry -- a solo one, once :meth:`move_rider` carries its single rider away -- logs ``dissolve_entry``. + + An entry that carries recorded data is *retired* instead of + discarded (:attr:`retired_entries`): replay resolves the + crossings filed under its key, so a key that vanished here + would leave the ride un-reopenable. A data-less entry is + removed outright -- nothing names it. """ self._entries.remove(entry) + if entry.has_data: + self._retired.append(entry) action = "dissolve_team_entry" if entry.type is EntryType.TEAM else "dissolve_entry" self._log(action, {"plate": entry.plate, "display_name": entry.display_name}) @@ -1515,8 +1567,13 @@ def _resolved_logo_card(self, supplied: str | None) -> str | None: return self.next_team_logo_card() def _require_known_entry(self, entry: Entry) -> None: - """Raise EntryNotFoundError unless *entry* is a member here.""" - if entry not in self._entries: + """Raise EntryNotFoundError unless *entry* is known here. + + A retired entry is known: replay marks the retired entry a + pre-move crossing row names (:meth:`mark_has_data`), and that + must not read as a foreign entry. + """ + if entry not in self._entries and entry not in self._retired: msg = "entry is not a member of this roster" raise EntryNotFoundError(msg) diff --git a/src/rivercrossing/store/__init__.py b/src/rivercrossing/store/__init__.py index e0339d1c..0801b6ba 100644 --- a/src/rivercrossing/store/__init__.py +++ b/src/rivercrossing/store/__init__.py @@ -4,9 +4,10 @@ :class:`Store` is the public entry point to the ``rivercrossing.store`` package. It opens one SQLite file per database, applies the spec §2 PRAGMAs (WAL, synchronous NORMAL, -foreign_keys ON) to every connection, ensures the schema is the one -flattened v1 baseline (``store/schema.py``; a file stamped with any -other version is refused), and exposes the ride surface E5.1.1/E5.1.2 +foreign_keys ON) to every connection, and brings the schema to the +current version (``store/schema.py``: an empty or older file is +created or migrated up in place, a file stamped NEWER than this build +is refused), and exposes the ride surface E5.1.1/E5.1.2 own: :meth:`Store.create_ride` and :meth:`Store.rides` (E5.1.1) and the event log -- :meth:`Store.append` persists one :class:`~rivercrossing.ride.Event` @@ -50,9 +51,11 @@ - **transaction shape**: the connection runs in sqlite3's default (legacy) mode. Each operator action is one committed transaction -- ``create_ride`` wraps its insert in ``with conn``; - ``schema.ensure_schema`` wraps the whole v1 CREATE plus its version - record in an explicit BEGIN/COMMIT, because DDL alone autocommits - and the two must stay atomic (see ``schema.py``). + ``schema.ensure_schema`` wraps the whole current CREATE plus its + version record in an explicit BEGIN/COMMIT, and each migration step + in ``schema.migrations`` runs in its own, because DDL alone + autocommits and the shape and its version record must stay atomic + (see ``schema.py``). - **audit at column (E5.1.2)**: ``append`` derives ``audit.at`` from the event's own payload timestamp when it carries one (``actual_start``/``crossed_at``/``stopped_at``/``finished_at``/ @@ -99,6 +102,18 @@ stays NULL and the rider's ``emergency_contact``/``waiver_signed``/ ``ccn_reg_id`` stay NULL -- the in-memory Roster model carries no such fields, so there is nothing honest to store. +- **retired entries (E3.1.2's pooled-live-move seam)**: an entry a + pool move dissolves after it recorded data is persisted with + ``entry.retired = 1``, and ``_load_roster`` restores it into the + roster's own retired collection (riderless, ``has_data`` unset). + The row exists for one reason: replay resolves a stored + ``record_crossing`` by the entry's stable ``key``, so the + dissolved entry's key has to survive the snapshot or the ride + cannot reopen. ``roster.entries`` is unaffected -- a retired entry + is not on the field -- and :meth:`Store.rides` counts the live rows + alone, so the library's Entries column still reads the roster. + ``duplicate_ride`` copies retired rows with a fresh key each, like + every other entry row (:meth:`Store._copy_roster_rows`). - **duplicate name (E5.4.1)**: :meth:`Store.duplicate_ride` names the copy ``f"{source name} (copy)"`` by default (the retired 3d mock drew a "New ride name" input, but the E5.4.1 confirm dialog is @@ -703,8 +718,9 @@ def open(cls, path: str | Path, *, active_ride_id: int | None = None) -> Store: Raises: SchemaVersionMismatchError: If the database is stamped with - a schema version this build does not support (any value - but 1 and the empty/0 case). + a schema version NEWER than this build's (an older + stamp is migrated up in place; see + ``schema.ensure_schema``). sqlite3.IntegrityError: If *active_ride_id* names no ride row (the ``REFERENCES ride(id)`` foreign key). sqlite3.OperationalError: A persistent transient-class @@ -914,6 +930,13 @@ def _load_roster(self, ride_id: int) -> Roster: resolution): an entry that owns a crossing or card row has recorded data. + A row with ``retired = 1`` reconstructs into the roster's own + retired collection instead: its key has to stay resolvable for + replay, and nothing else about it is read -- a retired entry + was emptied by the move that retired it, so it carries no + riders, and its ``has_data`` flag is not re-derived (the + retired flag is that fact, persisted). + Raises: RideNotFoundError: No ``ride`` row has *ride_id*. """ @@ -932,47 +955,70 @@ def _load_roster(self, ride_id: int) -> Roster: team_logo_seed=row["rng_seed"], ) entries: list[Entry] = [] + retired: list[Entry] = [] for entry_row in self._conn.execute( - "SELECT id, plate, key, display_name, type, status, notes, logo_card" + "SELECT id, plate, key, display_name, type, status, notes, logo_card, retired" " FROM entry WHERE ride_id = ? ORDER BY id", (ride_id,), ).fetchall(): - riders = [ - Rider( - first_name=rider_row["first_name"], - last_name=rider_row["last_name"], - plate=rider_row["plate"], - sex=rider_row["sex"], - sort_order=rider_row["sort_order"], - ) - for rider_row in self._conn.execute( - "SELECT first_name, last_name, plate, sex, sort_order FROM rider" - " WHERE entry_id = ? ORDER BY sort_order, id", - (entry_row["id"],), - ).fetchall() - ] - has_data = bool( - self._conn.execute( - "SELECT EXISTS(SELECT 1 FROM crossing WHERE entry_id = ?)" - " OR EXISTS(SELECT 1 FROM card WHERE entry_id = ?)", - (entry_row["id"], entry_row["id"]), - ).fetchone()[0] - ) + is_retired = bool(entry_row["retired"]) + # A retired entry was emptied by the move that retired it. entry = Entry( plate=entry_row["plate"], display_name=entry_row["display_name"], type=EntryType(entry_row["type"]), - riders=riders, + riders=[] if is_retired else self._entry_riders(entry_row["id"]), status=EntryStatus(entry_row["status"]), notes=entry_row["notes"] or "", logo_card=entry_row["logo_card"], key=entry_row["key"], ) - entry.has_data = has_data + if is_retired: + retired.append(entry) + continue + entry.has_data = self._entry_has_recorded_data(entry_row["id"]) entries.append(entry) roster.load_entries(entries) + roster.load_retired_entries(retired) return roster + def _entry_riders(self, entry_id: int) -> list[Rider]: + """Return *entry_id*'s riders, in stored order. + + The reconstruction half of ``_load_roster``: ``sort_order`` then + insert id, so a roster reads back exactly as it was written (a + pooled team's plate derivation depends on that order). + """ + return [ + Rider( + first_name=rider_row["first_name"], + last_name=rider_row["last_name"], + plate=rider_row["plate"], + sex=rider_row["sex"], + sort_order=rider_row["sort_order"], + ) + for rider_row in self._conn.execute( + "SELECT first_name, last_name, plate, sex, sort_order FROM rider" + " WHERE entry_id = ? ORDER BY sort_order, id", + (entry_id,), + ).fetchall() + ] + + def _entry_has_recorded_data(self, entry_id: int) -> bool: + """Return whether *entry_id* owns a crossing or card row. + + ``has_data`` is derived, never stored (module docstring's + E5.4.1 resolution): R-15's permanent delete guard is exactly + "has recorded data", and the recorded rows are the truth. + """ + return bool( + self._conn.execute( + "SELECT EXISTS(SELECT 1 FROM crossing WHERE entry_id = ?)" + " OR EXISTS(SELECT 1 FROM card WHERE entry_id = ?)", + (entry_id, entry_id), + ).fetchone()[0] + ) + def save_roster(self, ride_id: int, roster: Roster) -> None: """Persist one ride's roster, replacing any previously saved. @@ -992,6 +1038,11 @@ def save_roster(self, ride_id: int, roster: Roster) -> None: ``ccn_reg_id`` stay NULL -- the in-memory Roster model carries no such fields (module docstring's E5.4.1 resolutions). + Entries the roster has retired are written too, with + ``retired = 1``: the rows recorded before the move that retired + one are resolved by its key, so it has to survive the snapshot + like any live entry. + Args: ride_id: The ride whose roster to write. roster: The roster to persist. @@ -1009,39 +1060,53 @@ def save_roster(self, ride_id: int, roster: Roster) -> None: ) self._conn.execute("DELETE FROM entry WHERE ride_id = ?", (ride_id,)) for entry in roster.entries: - cursor = self._conn.execute( - "INSERT INTO entry" - " (ride_id, plate, key, display_name, type, team_size, status, dnf_at, notes," - " logo_card)" - " VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)", - ( - ride_id, - entry.plate, - entry.key, - entry.display_name, - entry.type.value, - len(entry.riders), - entry.status.value, - entry.notes, - entry.logo_card, - ), - ) - entry_id = _require_rowid(cursor) - for rider in entry.riders: - self._conn.execute( - "INSERT INTO rider" - " (entry_id, first_name, last_name, plate, sex, sort_order," - " emergency_contact, waiver_signed, ccn_reg_id)" - " VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL)", - ( - entry_id, - rider.first_name, - rider.last_name, - rider.plate, - rider.sex, - rider.sort_order, - ), - ) + self._insert_entry_row(ride_id, entry, retired=0) + for entry in roster.retired_entries: + self._insert_entry_row(ride_id, entry, retired=1) + + def _insert_entry_row(self, ride_id: int, entry: Entry, *, retired: int) -> None: + """Insert one entry row and its riders. + + The write half of :meth:`save_roster`'s snapshot: *retired* is + the stored 0/1 flag, and a retired entry has no riders to write + (a dissolve only ever fires on an emptied entry). Runs inside + the caller's transaction -- a failure rolls the whole snapshot + back. + """ + cursor = self._conn.execute( + "INSERT INTO entry" + " (ride_id, plate, key, display_name, type, team_size, status, dnf_at, notes," + " logo_card, retired)" + " VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)", + ( + ride_id, + entry.plate, + entry.key, + entry.display_name, + entry.type.value, + len(entry.riders), + entry.status.value, + entry.notes, + entry.logo_card, + retired, + ), + ) + entry_id = _require_rowid(cursor) + for rider in entry.riders: + self._conn.execute( + "INSERT INTO rider" + " (entry_id, first_name, last_name, plate, sex, sort_order," + " emergency_contact, waiver_signed, ccn_reg_id)" + " VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL)", + ( + entry_id, + rider.first_name, + rider.last_name, + rider.plate, + rider.sex, + rider.sort_order, + ), + ) def create_ride(self, config: RideConfig, *, rng_seed: int | None = None) -> int: """Persist one ride from its config; return the new ride id. @@ -1105,7 +1170,9 @@ def rides(self) -> list[RideRow]: Each row carries the ride's entry count (the library's Entries column) via a correlated subquery -- cheap, and the schema has - the data. + the data. Retired entries are not part of the field: they are + keys a replay still resolves, not entries on the roster the + library shows. Returns: One :class:`RideRow` per ride, ordered by ``created_at`` @@ -1113,7 +1180,8 @@ def rides(self) -> list[RideRow]: """ rows = self._conn.execute( "SELECT r.id, r.name, r.event_date, r.status," - " (SELECT COUNT(*) FROM entry e WHERE e.ride_id = r.id) AS entries" + " (SELECT COUNT(*) FROM entry e WHERE e.ride_id = r.id AND e.retired = 0)" + " AS entries" " FROM ride r ORDER BY r.created_at, r.id" ).fetchall() return [ @@ -1171,8 +1239,10 @@ def delete_ride(self, ride_id: int, typed_name: str) -> None: Because the schema declares plain ``REFERENCES`` with no ON DELETE CASCADE (module docstring's E5.3.2 resolution), the dependents are removed explicitly, in FK-safe order, in one - transaction: cards, crossings, riders, entries, audit rows; - ``app_session.active_ride_id`` is NULLed; then the ride row. + transaction: cards, crossings, riders, entries (live and + retired alike -- :meth:`Store.save_roster` snapshots both), + audit rows; ``app_session.active_ride_id`` is NULLed; then the + ride row. Args: ride_id: The ride to delete. @@ -1520,12 +1590,12 @@ def duplicate_ride(self, ride_id: int, *, name: str | None = None) -> int: fresh DB-owned ``rng_seed`` (spec §4 -- a new ride gets its own seed, never the source's), status DRAFT and NULL ``actual_start``/``finished_at``, then copies the roster rows - (entries + riders, in creation order). No crossings, cards or - audit rows are written -- the copy has no timing data by - construction. The duplication itself is not audited either - (module docstring's E5.4.1 decision): the audit replay channel - only knows ride mutations, so a ``duplicate_ride`` row would - break :meth:`load_engine`. + (entries -- retired ones included -- plus riders, in creation + order). No crossings, cards or audit rows are written -- the + copy has no timing data by construction. The duplication itself + is not audited either (module docstring's E5.4.1 decision): the + audit replay channel only knows ride mutations, so a + ``duplicate_ride`` row would break :meth:`load_engine`. Args: ride_id: The source ride. @@ -1586,8 +1656,11 @@ def _copy_roster_rows(self, new_id: int, ride_id: int) -> None: entry gets a **fresh** ``key``: the copy is a new ride whose crossings are its own, so it must never share the source's entry identities (the fresh-``rng_seed`` rule one level down). - Runs inside the caller's transaction: a failure rolls the whole - copy back. + Retired rows copy too, flag and all: they are the source's key + history, and a copy whose replay is ever wired to its own log + needs the same retired keys -- under its own fresh ones. Runs + inside the caller's transaction: a failure rolls the whole copy + back. Args: new_id: The ride the rows are copied onto. @@ -1595,14 +1668,14 @@ def _copy_roster_rows(self, new_id: int, ride_id: int) -> None: """ for source_entry in self._conn.execute( "SELECT id, plate, display_name, type, team_size, status, notes," - " logo_card FROM entry WHERE ride_id = ? ORDER BY id", + " logo_card, retired FROM entry WHERE ride_id = ? ORDER BY id", (ride_id,), ).fetchall(): entry_cursor = self._conn.execute( "INSERT INTO entry" " (ride_id, plate, key, display_name, type, team_size, status, dnf_at, notes," - " logo_card)" - " VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)", + " logo_card, retired)" + " VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)", ( new_id, source_entry["plate"], @@ -1615,6 +1688,7 @@ def _copy_roster_rows(self, new_id: int, ride_id: int) -> None: source_entry["status"], source_entry["notes"], source_entry["logo_card"], + source_entry["retired"], ), ) new_entry_id = _require_rowid(entry_cursor) diff --git a/src/rivercrossing/store/migrations.py b/src/rivercrossing/store/migrations.py new file mode 100644 index 00000000..979fe7d2 --- /dev/null +++ b/src/rivercrossing/store/migrations.py @@ -0,0 +1,187 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Versioned schema migrations (spec §2). + +One step per schema version: :data:`MIGRATIONS` maps the version a file +is stamped with to the function that upgrades it to the next one, so +``MIGRATIONS[1]`` takes a v1 file to v2. :func:`run_migrations` walks +that chain in order and stamps the ledger; +:func:`~rivercrossing.store.schema.ensure_schema` calls it whenever it +opens a file older than the build, because the product-owner policy is +that every schema change ships the step that upgrades older files +rather than refusing them. + +A step is **frozen history**: it describes one jump between two +versions once, for good. It is never edited to match a later DDL -- +``schema.py``'s ``SCHEMA_STATEMENTS`` is the latest shape and moves on +without it, while the file on disk still holds exactly what the step +expects. The ``MIGRATIONS``-covers-every-older-version test in +``tests/unit/test_store.py`` is what keeps the two ends honest. +""" + +import sqlite3 +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + +__all__ = ["MIGRATIONS", "run_migrations"] + +# The v2 ``entry`` table, frozen at the version this step targets: v1's +# table plus ``key`` (the entry's stable identity, E3.1.2's +# pooled-live-move seam) and ``retired`` (1 once a pooled move has +# dissolved an entry that recorded data), and without v1's inline +# ``UNIQUE (ride_id, plate)`` -- SQLite cannot drop a table-level +# constraint, which is why the step below is a full table rebuild. +_ENTRY_V2_DDL = """ +CREATE TABLE entry_new ( + id INTEGER PRIMARY KEY, + ride_id INTEGER NOT NULL REFERENCES ride(id), + plate TEXT NOT NULL, + key TEXT NOT NULL, + display_name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('solo', 'team')), + team_size INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'dnf')), + dnf_at INTEGER, + notes TEXT, + logo_card TEXT, + retired INTEGER NOT NULL DEFAULT 0 +) +""" + +# Spec §2's "plate UNIQUE per ride" for the live field alone: a retired +# entry keeps the plate it held, which the destination team of a +# solo->team move may have since adopted (S1's lowest-numbered-rider +# derivation). +_ENTRY_PLATE_LIVE_INDEX_DDL = ( + "CREATE UNIQUE INDEX entry_plate_live_unique ON entry (ride_id, plate) WHERE retired = 0" +) + +_V1_ENTRY_SELECT_SQL = ( + "SELECT id, ride_id, plate, display_name, type, team_size, status," + " dnf_at, notes, logo_card FROM entry ORDER BY id" +) + +_ENTRY_V2_INSERT_SQL = ( + "INSERT INTO entry_new" + " (id, ride_id, plate, key, display_name, type, team_size, status," + " dnf_at, notes, logo_card, retired)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)" +) + + +def _migrate_v1_to_v2(conn: sqlite3.Connection) -> None: + """Rebuild ``entry`` in the v2 shape, minting one fresh key per row. + + SQLite cannot drop v1's inline ``UNIQUE (ride_id, plate)``, so this + is the documented table-rebuild procedure: create the v2 table under + a temporary name, copy every row into it, drop v1's table and rename + the new one into place. + + ``PRAGMA foreign_keys=OFF`` runs before ``BEGIN`` because the pragma + is silently ignored inside a transaction: with enforcement on, + ``DROP TABLE entry`` would clear the child tables' rows and the + rename could rewrite their ``REFERENCES`` clauses. The + ``PRAGMA foreign_key_check`` before the COMMIT is the procedure's + own safety net -- it reports a link the rebuild broke, and any the + file already carried, so the migration aborts and rolls back instead + of shipping a database whose laps point at nothing. + + Each row keeps its ``id``: ``rider``, ``crossing`` and ``card`` + reference it, and a fresh id would orphan every recorded lap. Each + row's ``key`` is drawn here as ``uuid.uuid4().hex`` -- the exact + expression ``roster.Entry.key`` fills its ``default_factory`` from + -- so a migrated key is indistinguishable from one the roster would + have minted itself: 32 lowercase hex digits, version nibble 4. + ``retired`` is 0, because v1 had no retired entries and nothing in a + v1 file could say otherwise. + + Args: + conn: An open connection to a file stamped version 1. + + Raises: + sqlite3.IntegrityError: If ``PRAGMA foreign_key_check`` reports + a row after the rebuild. The transaction is rolled back and + the connection's ``foreign_keys`` setting restored, so the + caller is left with the untouched v1 file. + """ + conn.execute("PRAGMA foreign_keys=OFF") + try: + conn.execute("BEGIN") + try: + conn.execute(_ENTRY_V2_DDL) + for ( + entry_id, + ride_id, + plate, + display_name, + entry_type, + team_size, + status, + dnf_at, + notes, + logo_card, + ) in conn.execute(_V1_ENTRY_SELECT_SQL): + conn.execute( + _ENTRY_V2_INSERT_SQL, + ( + entry_id, + ride_id, + plate, + uuid.uuid4().hex, + display_name, + entry_type, + team_size, + status, + dnf_at, + notes, + logo_card, + ), + ) + conn.execute("DROP TABLE entry") + conn.execute("ALTER TABLE entry_new RENAME TO entry") + conn.execute(_ENTRY_PLATE_LIVE_INDEX_DDL) + violations = conn.execute("PRAGMA foreign_key_check").fetchall() + if violations: + raise sqlite3.IntegrityError( + "v1 -> v2 schema migration left foreign key violations in " + f"{', '.join(sorted({str(row[0]) for row in violations}))}" + ) + except sqlite3.Error: + conn.rollback() + raise + conn.commit() + finally: + conn.execute("PRAGMA foreign_keys=ON") + + +# The chain: source version -> the step that upgrades it to source + 1. +MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = {1: _migrate_v1_to_v2} + + +def run_migrations(conn: sqlite3.Connection, from_version: int, to_version: int) -> None: + """Upgrade a database from *from_version* to *to_version*, in order. + + Each step runs in its own transaction and the ledger is stamped + once, after the last of them has committed, so the version on + record only ever names a chain that finished. + + Args: + conn: An open connection to a file stamped *from_version*. + from_version: The version the file's ledger currently holds. + to_version: The version to reach -- this build's + ``SCHEMA_VERSION``. + + Raises: + KeyError: If *from_version* names no registered step. Version 0 + is an empty file, which ``ensure_schema`` creates rather + than migrates. + """ + for version in range(from_version, to_version): + MIGRATIONS[version](conn) + conn.execute( + "INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, ?)", + (to_version,), + ) + conn.commit() diff --git a/src/rivercrossing/store/schema.py b/src/rivercrossing/store/schema.py index 56414509..d20eb8c4 100644 --- a/src/rivercrossing/store/schema.py +++ b/src/rivercrossing/store/schema.py @@ -11,22 +11,40 @@ file (``rivercrossing.ui.presenters.settings``), not this database -- the schema stays untouched. -**One flattened v1 baseline.** The DDL below is edited in place as the -schema evolves, never migrated. The v0 -> v1 -> v2 -> v3 migration -chain and ``store/migrations.py`` were removed in Phase 2 (the rider -``sex`` column): the project is unreleased, so a database file written -by an older build is stale and has to be recreated, and migrations -return after release. :data:`SCHEMA_VERSION` is 1; -:func:`ensure_schema` creates this schema on an empty file and refuses -any other stamped version. - -That flatten folded five changes back into the CREATE: +**Versioned and migrated in place.** :data:`SCHEMA_VERSION` is 2 and +the DDL below is the *latest* shape, not a frozen baseline. The +product-owner policy (spec §2): every schema change bumps +:data:`SCHEMA_VERSION` and ships the step that upgrades an older file, +in :mod:`rivercrossing.store.migrations`. :func:`ensure_schema` creates +this schema on an empty file (ledger version 0), runs the chain on an +older one, does nothing on a current one, and refuses a file stamped +*newer* than this build -- there is no downgrade path. + +Version 1 was the released flattened baseline, and the changes under +**Version 1** below were folded into its CREATE while the project was +unreleased. **Version 2** is this branch's pooled-live-move seam, and +the v1 -> v2 migration rebuilds ``entry`` for it, because SQLite cannot +drop a table-level ``UNIQUE``. + +**Version 2** adds, both in the table the migration rebuilds: - ``entry.key`` -- the entry's stable identity (E3.1.2's pooled-live-move seam), a surrogate the ride engine files crossings and credited hands under where a derived plate is mutable -- is a real column, NOT NULL, right after the ``plate`` it is the stable counterpart of. +- ``entry.retired`` -- 0 for a live entry, 1 for one a pooled move + dissolved after it had recorded data (the same seam's persistence + half) -- is a real column with a NOT NULL DEFAULT 0, and the + per-ride plate uniqueness moved off the table into a partial unique + index over the live rows alone (``entry_plate_live_unique``, + ``WHERE retired = 0``): spec §2's "plate UNIQUE per ride" still + holds for the field, while a solo->team move may leave the retired + solo row holding the very plate the destination team has since + adopted (S1's lowest-numbered-rider derivation). + +**Version 1** carries: + - ``ride.hold_short_laps`` -- the short-lap card policy -- is a real column (NOT NULL, DEFAULT 1 = hold short-lap cards for review), in the same position ``_INSERT_RIDE_SQL`` lists it. @@ -66,6 +84,8 @@ import sqlite3 +from rivercrossing.store.migrations import run_migrations + __all__ = [ "PRAGMA_STATEMENTS", "SCHEMA_STATEMENTS", @@ -77,10 +97,11 @@ "ensure_schema", ] -# The one schema version this build reads and writes. A file stamped -# with any other value is refused by :func:`ensure_schema` rather than -# read under a shape it was not written with. -SCHEMA_VERSION = 1 +# The schema version this build reads and writes. A file stamped with +# an older one is upgraded in place by ``store.migrations``; a file +# stamped with a newer one is refused by :func:`ensure_schema` rather +# than read under a shape it was not written with. +SCHEMA_VERSION = 2 class StoreError(RuntimeError): @@ -172,9 +193,16 @@ class SchemaVersionMismatchError(StoreError): dnf_at INTEGER, notes TEXT, logo_card TEXT, - UNIQUE (ride_id, plate) + retired INTEGER NOT NULL DEFAULT 0 ) """, + # Spec §2's "plate UNIQUE per ride", enforced for the live field + # alone: a retired entry keeps the plate it held (a solo->team move + # re-derives the destination team's plate to exactly that number). + """ + CREATE UNIQUE INDEX entry_plate_live_unique + ON entry (ride_id, plate) WHERE retired = 0 + """, """ CREATE TABLE rider ( id INTEGER PRIMARY KEY, @@ -258,20 +286,26 @@ def _current_version(conn: sqlite3.Connection) -> int: def ensure_schema(conn: sqlite3.Connection) -> None: - """Create or verify the v1 schema on one connection. + """Bring the database to :data:`SCHEMA_VERSION` on one connection. The ledger table is bootstrapped first (CREATE IF NOT EXISTS, so re-running is harmless), then the stamped version decides: - - ``0`` -- no ledger row yet: create every table and stamp + - ``0`` -- no ledger row yet: create the current schema and stamp :data:`SCHEMA_VERSION`, all inside one explicit BEGIN/COMMIT so a mid-create failure rolls the whole schema back rather than leaving a half-built database behind (DDL alone autocommits in sqlite3, so the version record must share the transaction). - :data:`SCHEMA_VERSION` -- already current: a no-op, which is what makes re-open idempotent. - - anything else -- a file from another build: refuse, naming the - version found and the one expected. + - older -- upgrade in place: run + :func:`~rivercrossing.store.migrations.run_migrations` from the + stamped version to :data:`SCHEMA_VERSION`, then stamp it. Every + schema change ships the step that upgrades a file written before + it (product-owner policy, spec §2), so a file is never refused + for being old. + - newer -- a file from a later build: refuse, naming the version + found and the one expected. There is no downgrade path. Args: conn: The connection to bring to the current schema. Expects @@ -280,18 +314,26 @@ def ensure_schema(conn: sqlite3.Connection) -> None: Raises: SchemaVersionMismatchError: If the database's stamped version - is neither 0 nor :data:`SCHEMA_VERSION`. + is newer than :data:`SCHEMA_VERSION`. """ conn.execute(SCHEMA_VERSION_DDL) current = _current_version(conn) if current == SCHEMA_VERSION: return - if current != 0: + if current == 0: + _create_schema(conn) + return + if current > SCHEMA_VERSION: raise SchemaVersionMismatchError( f"Database schema version {current} does not match this build's " f"schema version {SCHEMA_VERSION}. " "Rename or delete the database file to continue." ) + run_migrations(conn, current, SCHEMA_VERSION) + + +def _create_schema(conn: sqlite3.Connection) -> None: + """Create the current schema and stamp it, in one transaction.""" conn.execute("BEGIN") try: for statement in SCHEMA_STATEMENTS: diff --git a/src/rivercrossing/ui/app.py b/src/rivercrossing/ui/app.py index c845ec8f..01effce2 100644 --- a/src/rivercrossing/ui/app.py +++ b/src/rivercrossing/ui/app.py @@ -5404,9 +5404,10 @@ def main(db_path: Path | None = None) -> int: app.MainLoop() except SchemaVersionMismatchError as exc: # A version mismatch is an expected condition, not a crash: the - # database was written by a different build. Tell the operator - # the way out (rename or delete the file) and exit without - # re-raising, so the crash excepthook files no exception. + # database was written by a NEWER build (an older one is + # migrated in place on open). Tell the operator the way out + # (rename or delete the file) and exit without re-raising, so + # the crash excepthook files no exception. from rivercrossing.ui import std_dialogs # noqa: PLC0415 -- deferred, see module docstring std_dialogs.show_error(None, "Database Mismatch", str(exc)) diff --git a/tests/unit/test_ride_move.py b/tests/unit/test_ride_move.py index 8353ee60..94a69431 100644 --- a/tests/unit/test_ride_move.py +++ b/tests/unit/test_ride_move.py @@ -11,13 +11,18 @@ crossings, hold queue, credited hands and voided record must be byte-identical. -The arrange-time builders (``_config``/``_dt``/``_make_engine``/ +The last section drives that gate through the real +:class:`~rivercrossing.store.Store` -- save the final roster, reload, +replay -- because a move that dissolves a data-carrying entry only +replays if that entry's stable key survives the round trip. The +arrange-time builders (``_config``/``_dt``/``_make_engine``/ ``_two_team_pooled_roster``) are imported from ``test_ride_corrections`` so the two suites' fixture values cannot drift; this repo otherwise keeps every test module self-contained. """ import re +from pathlib import Path # noqa: TC003 -- pytest evaluates test-param annotations at collection import pytest from test_ride_corrections import _config, _dt, _make_engine, _two_team_pooled_roster @@ -25,6 +30,7 @@ from conftest import entry_key, restore_entry_keys from rivercrossing import ride as ride_module from rivercrossing.ride import ( + JOKERS_MODE_PER_DECK, Event, IllegalStateError, RideConfig, @@ -32,6 +38,7 @@ UnknownPlateError, ) from rivercrossing.roster import EntryMode, PlateModel, Rider, Roster +from rivercrossing.store import Store # ------------------------------------------------------------- fixtures @@ -799,3 +806,113 @@ def test_apply_extract_rider_to_solo_event_reattributes_from_the_payload() -> No assert [c.entry_id for c in engine.crossings] == [team_b.key] assert engine.events[-1] == event + + +# =========================== persisted replay (pooled-live-move seam) +# Phase 1 made replay resolve a stored ``record_crossing`` row by the +# entry's stable key, so a key a move dissolves must survive the +# save/reload round trip: the Store persists retired entries too, and +# the reloaded roster resolves them. These two tests drive the real +# Store -- save, reload, replay -- which is the path a ride reopens on. + +# The seed ``test_ride_corrections._make_engine`` builds its shoe from, +# repeated here so a store-created ride's replayed deals are the live +# engine's own. +_SHOE_SEED = 20260920 + + +def _persist(db_path: Path, roster: Roster, events: tuple[Event, ...]) -> int: + """Save *roster* and *events* as one ride on a fresh database. + + The store half of a ride reopen: ``create_ride`` with the live + engine's own shoe seed, one ``save_roster`` snapshot (the final + roster, retired entries included) and one ``append`` per recorded + event, exactly as the app persists them. + """ + store = Store.open(db_path) + try: + ride_id = store.create_ride(_reopen_config(), rng_seed=_SHOE_SEED) + store.save_roster(ride_id, roster) + for event in events: + store.append(ride_id, event) + finally: + store.close() + return ride_id + + +def _reopen_config() -> RideConfig: + """Return the config both sides of a reopen build their shoe from. + + ``test_ride_corrections._make_engine`` builds its shoe without the + ``jokers_total`` keyword (the per-deck default), while + ``Store.load_engine`` rebuilds one with + ``jokers_total=(jokers_mode == "total")``; naming per-deck here is + what makes the live and replayed deal sequences one shoe. + """ + return _config(jokers_mode=JOKERS_MODE_PER_DECK) + + +def test_move_rider_solo_to_team_replays_after_the_ride_is_persisted( + tmp_path: Path, +) -> None: + """Regression: a reloaded solo->team move replays without raising. + + The solo entry's crossings are filed under its key and the move + dissolves that entry, so the key is exactly what the stored + ``record_crossing`` rows name. Before dissolved entries was + persisted, ``Store.load_engine`` raised ``UnknownPlateError`` here + and the ride would not reopen. + """ + db_path = tmp_path / "rides.db" + roster = _solo_and_team_roster() + solo, team_b = roster.entries + config = _reopen_config() + live, _clock = _make_engine(roster=roster, config=config) + live.start(at=_dt(10, 0)) + live.record_crossing("5", at=_dt(10, 30)) + live.stop() + live.move_rider("5", to_team="Team B", reason="rider joins a team") + ride_id = _persist(db_path, roster, live.events) + + store = Store.open(db_path) + try: + replayed = store.load_engine(ride_id) + finally: + store.close() + + assert replayed.crossings == live.crossings + assert replayed.credited_cards(team_b.key) == live.credited_cards(team_b.key) + assert [entry.key for entry in replayed._roster.retired_entries] == [solo.key] + + +def test_extract_rider_to_solo_of_a_final_member_replays_after_the_ride_is_persisted( + tmp_path: Path, +) -> None: + """Regression: the team->solo half reopens too. + + Extracting a team's last member dissolves that team, whose key the + pre-move ``record_crossing`` row names -- so the dissolved team has + to come back as a retired entry for the replay to resolve it. + """ + db_path = tmp_path / "rides.db" + roster = _two_team_pooled_roster() + team_a, _team_b = roster.entries + config = _reopen_config() + live, _clock = _make_engine(roster=roster, config=config) + live.start(at=_dt(10, 0)) + live.record_crossing("1", at=_dt(10, 30)) + live.stop() + live.extract_rider_to_solo("1", reason="rider rides alone") + live.extract_rider_to_solo("2", reason="rider rides alone") + solo_key = entry_key(live._roster, "1") + ride_id = _persist(db_path, roster, live.events) + + store = Store.open(db_path) + try: + replayed = store.load_engine(ride_id) + finally: + store.close() + + assert replayed.crossings == live.crossings + assert replayed.credited_cards(solo_key) == live.credited_cards(solo_key) + assert [entry.key for entry in replayed._roster.retired_entries] == [team_a.key] diff --git a/tests/unit/test_roster.py b/tests/unit/test_roster.py index c4c64ac3..51e1ba79 100644 --- a/tests/unit/test_roster.py +++ b/tests/unit/test_roster.py @@ -34,7 +34,10 @@ ``extract_rider_to_solo`` is gated by :func:`~rivercrossing.roster.can_move_rider` like a move is, so a pooled team member may leave for solo while the ride is RUNNING or -REOPENED. +REOPENED. A dissolve that empties an entry carrying recorded data +*retires* it instead of discarding it (``retired_entries``), so the +key a replayed crossing row names stays resolvable after a reload; +the suite pins both halves of that rule. """ import re @@ -2814,6 +2817,139 @@ def test_entry_by_key_picks_the_matching_entry_among_many() -> None: assert roster.entry_by_key(wanted.key) is wanted +# --------------------------------------- retired entries (E3.1.2) +# A dissolved entry that carries recorded data is *retired*, not +# discarded: the ride engine files crossings under ``Entry.key`` and +# replay resolves them by that key, so the key of an entry a move +# dissolves must stay resolvable after a reload (the pooled-live-move +# replay seam). An entry with no recorded data is still removed +# outright -- nothing names it. + + +def _dissolved_solo(*, has_data: bool) -> tuple[Roster, Entry]: + """Dissolve a solo "1" into Team B, with or without data (arrange). + + The solo entry is emptied by :meth:`Roster.move_rider`, so it + reaches the dissolve either carrying recorded data (``has_data``) + or carrying none. + """ + roster = Roster(entry_mode=EntryMode.MIXED) + solo = roster.create_solo_entry(first_name="Alex", last_name="", plate="1") + team_b = roster.create_team_entry( + display_name="Team B", + riders=[ + Rider(first_name="Cy", last_name="", plate="3"), + Rider(first_name="Do", last_name="", plate="4"), + ], + ) + if has_data: + roster.mark_has_data(solo) + roster.move_rider(solo.riders[0], to_entry=team_b) + return roster, solo + + +def test_retired_entries_is_empty_on_a_fresh_roster() -> None: + """A roster that never dissolved an entry has nothing retired.""" + roster = Roster() + + assert roster.retired_entries == () + + +def test_move_rider_dissolving_a_has_data_solo_entry_retires_it() -> None: + """A dissolved entry carrying recorded data is retired, not lost.""" + roster, solo = _dissolved_solo(has_data=True) + + assert roster.retired_entries == (solo,) + + +def test_move_rider_dissolving_a_has_data_solo_entry_drops_it_from_entries() -> None: + """Retiring is not a revive: the live entries no longer hold it.""" + roster, solo = _dissolved_solo(has_data=True) + + assert solo not in roster.entries + + +def test_move_rider_dissolving_a_data_less_solo_entry_removes_it_outright() -> None: + """An entry with nothing recorded is still discarded outright.""" + roster, solo = _dissolved_solo(has_data=False) + + assert (roster.retired_entries, solo in roster.entries) == ((), False) + + +def test_move_rider_dissolving_a_has_data_solo_entry_keeps_its_key_resolvable() -> None: + """entry_by_key finds the retired entry the move dissolved.""" + roster, solo = _dissolved_solo(has_data=True) + + assert roster.entry_by_key(solo.key) is solo + + +def test_entry_by_key_unknown_key_still_returns_none_with_a_retired_entry() -> None: + """Searching retired entries leaves an unknown key unresolved.""" + roster, _solo = _dissolved_solo(has_data=True) + + assert roster.entry_by_key("0" * 32) is None + + +def test_mark_has_data_accepts_a_retired_entry() -> None: + """Replay's mark_has_data on a retired entry must not raise.""" + roster, solo = _dissolved_solo(has_data=True) + before = len(roster.audit_log) + + roster.mark_has_data(solo) + + assert (len(roster.audit_log), roster.audit_log[-1]) == ( + before + 1, + AuditEvent(action="mark_has_data", payload={"plate": "1"}), + ) + + +def test_move_rider_dissolving_a_has_data_team_entry_logs_dissolve_team_entry() -> None: + """Retiring a TEAM entry keeps the dissolve_team_entry action.""" + roster = Roster(entry_mode=EntryMode.MIXED) + alex = Rider(first_name="Alex", last_name="", plate="1") + team_a = roster.create_team_entry_of_one(display_name="Team A", rider=alex) + team_b = roster.create_team_entry( + display_name="Team B", + riders=[ + Rider(first_name="Cy", last_name="", plate="3"), + Rider(first_name="Do", last_name="", plate="4"), + ], + ) + roster.mark_has_data(team_a) + + roster.move_rider(alex, to_entry=team_b) + + assert roster.audit_log[-1] == AuditEvent( + action="dissolve_team_entry", payload={"plate": "1", "display_name": "Team A"} + ) + + +def test_load_retired_entries_restores_them_without_auditing() -> None: + """The store restore seam appends retired entries, silently.""" + roster = Roster() + retired = Entry(plate="1", display_name="Alex", type=EntryType.SOLO) + + roster.load_retired_entries([retired]) + + assert (roster.retired_entries, roster.entries, roster.audit_log) == ((retired,), (), ()) + + +@pytest.mark.parametrize( + ("count", "expected"), [(0, []), (1, ["0"]), (3, ["0", "1", "2"])], ids=["none", "one", "many"] +) +def test_load_retired_entries_restores_every_given_count(count: int, expected: list[str]) -> None: + """T-4 collections: none, one and many restore in order.""" + entries = [ + Entry(plate=str(number), display_name=f"Rider {number}", type=EntryType.SOLO) + for number in range(count) + ] + roster = Roster() + + roster.load_retired_entries(entries) + + assert [entry.plate for entry in roster.retired_entries] == expected + + # ======================================================== name split # Phase 1 (rider name split): first/last storage plus the full_name # projection every display/audit site now mirrors. diff --git a/tests/unit/test_store.py b/tests/unit/test_store.py index fde0af7a..a211923b 100644 --- a/tests/unit/test_store.py +++ b/tests/unit/test_store.py @@ -1,18 +1,27 @@ # SPDX-License-Identifier: GPL-3.0-only """Unit tests for rivercrossing.store (E5.1.1: schema + version gate). -Tests first (R-70). The store's schema is a single flattened v1 -baseline: ``Store.open`` creates it on a fresh file and stamps -``schema_version.version = 1``, re-opening a v1 file is an idempotent -no-op, and a file stamped with any other version refuses to open with +Tests first (R-70). The store's schema is versioned and migrated: +``Store.open`` creates the current schema on a fresh file and stamps +``SCHEMA_VERSION``, re-opening a current file is an idempotent no-op, +a file stamped OLDER is upgraded in place by the migration chain, and +only a file stamped NEWER than this build refuses to open, with :class:`~rivercrossing.store.SchemaVersionMismatchError` naming what -it found. The v0 -> v1 -> v2 -> v3 migration chain was removed with -the flatten (unreleased code; migrations return post-release). +it found (product-owner policy: every schema change bumps the version +and ships a migration, spec §2). E5.1.1's own surface is small: ``create_ride`` persists a :class:`RideConfig` row (logo BLOB, JSON tiebreak order, DB-owned seed) and ``rides()`` lists the library. +The last section covers the pooled-live-move persistence seam: an +entry a move dissolved after it recorded data is written with +``entry.retired = 1`` and reloads into the roster's retired +collection, which is what keeps its stable key resolvable for a +replayed ``record_crossing``. The per-ride plate uniqueness moved to +a partial unique index over the live rows alone, so a retired row may +hold the plate the destination team has since adopted. + No mocks anywhere: every test drives real sqlite3 against a ``tmp_path`` file (the task's own "no mocks of sqlite3 beyond tmp_path DB files" rule). Assertions that inspect stored columns read @@ -28,6 +37,7 @@ import re import sqlite3 import tempfile +import uuid from contextlib import closing from datetime import UTC, date, datetime from pathlib import Path @@ -59,6 +69,7 @@ StoreError, backup, ) +from rivercrossing.store.migrations import MIGRATIONS, run_migrations from rivercrossing.store.schema import ( SCHEMA_STATEMENTS, SCHEMA_VERSION, @@ -126,12 +137,13 @@ def _deal_all(shoe: Shoe) -> list[Card]: # ------------------------------------------------------------- open -def test_store_open_fresh_db_creates_the_full_v1_schema(tmp_path: Path) -> None: - """A fresh database opens with every spec table at schema version 1. +def test_store_open_fresh_db_creates_the_full_current_schema(tmp_path: Path) -> None: + """A fresh file gets every spec table at the current shape. - The flattened baseline: ``ride.hold_short_laps`` is part of v1 (no - ALTER brings it later), ``entry`` carries no retired ``logo_png`` - image column, and ``rider`` carries the nullable ``sex`` column. + The current (v2) shape: ``ride.hold_short_laps`` and + ``ride.jokers_mode`` are plain columns, ``entry`` carries the stable + ``key`` and the ``retired`` flag, ``rider`` carries the nullable + ``sex``, and no retired ``logo_png`` image column is anywhere. """ db_path = tmp_path / "rides.db" @@ -158,9 +170,9 @@ def test_store_open_fresh_db_creates_the_full_v1_schema(tmp_path: Path) -> None: assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" assert expected <= names - assert "hold_short_laps" in ride_columns + assert {"hold_short_laps", "jokers_mode"} <= ride_columns + assert {"key", "retired", "logo_card"} <= entry_columns assert "logo_png" not in entry_columns - assert "logo_card" in entry_columns assert "sex" in rider_columns @@ -183,8 +195,8 @@ def test_store_open_creates_missing_parent_directories(tmp_path: Path) -> None: assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" -def test_store_open_stamps_the_ledger_at_schema_version_1(tmp_path: Path) -> None: - """schema_version holds exactly one row, stamped at v1.""" +def test_store_open_stamps_the_ledger_at_the_current_schema_version(tmp_path: Path) -> None: + """One ledger row, stamped at the current schema version.""" db_path = tmp_path / "rides.db" Store.open(db_path).close() @@ -193,7 +205,7 @@ def test_store_open_stamps_the_ledger_at_schema_version_1(tmp_path: Path) -> Non version = conn.execute("SELECT version FROM schema_version WHERE id = 1").fetchone()[0] rows = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone()[0] - assert SCHEMA_VERSION == 1 + assert SCHEMA_VERSION == 2 assert (version, rows) == (SCHEMA_VERSION, 1) @@ -220,10 +232,10 @@ def test_store_open_applies_spec_pragmas_to_every_connection(tmp_path: Path) -> assert conn.execute("PRAGMA journal_mode").fetchone()[0] == "wal" -def test_store_open_version_1_database_reopens_as_a_noop_keeping_its_rides( +def test_store_open_current_version_database_reopens_as_a_noop_keeping_its_rides( tmp_path: Path, ) -> None: - """Reopening a version-1 database keeps its rides untouched.""" + """Reopening a current file keeps its rides untouched.""" db_path = tmp_path / "rides.db" store = Store.open(db_path) ride_id = store.create_ride(_config(name="Idempotent")) @@ -250,8 +262,8 @@ def test_store_open_version_1_database_reopens_as_a_noop_keeping_its_rides( assert (version, rows) == (SCHEMA_VERSION, 1) -def test_store_open_given_an_empty_file_creates_the_full_v1_schema(tmp_path: Path) -> None: - """A pre-schema file becomes a full v1 database.""" +def test_store_open_given_an_empty_file_creates_the_full_current_schema(tmp_path: Path) -> None: + """A pre-schema file becomes a full current-version database.""" db_path = tmp_path / "v0.db" sqlite3.connect(str(db_path)).close() @@ -274,18 +286,18 @@ def test_store_open_given_an_empty_file_creates_the_full_v1_schema(tmp_path: Pat assert "hold_short_laps" in columns -@pytest.mark.parametrize("stored_version", [2, 3, 99]) -def test_store_open_given_a_mismatched_version_raises_naming_it( +@pytest.mark.parametrize("stored_version", [3, 99, 999]) +def test_store_open_given_a_newer_version_raises_naming_it( tmp_path: Path, stored_version: int ) -> None: - """A file stamped with any version but 1 refuses to open, naming it. - - The flattened schema has exactly one version -- 1 -- so a file from - an older (v2/v3) or newer (99) build cannot be read honestly. The - refusal names what the ledger holds, what this build expects, and - the way out (rename or delete the file), and it is a - :class:`StoreError` subclass so existing "did it fail" callers keep - working. + """A file stamped above SCHEMA_VERSION refuses to open, naming it. + + Older stamps migrate (there is no reason to refuse a file this build + can upgrade); a NEWER one has no downgrade path, so it is refused + rather than read under a shape it was not written with. The refusal + names what the ledger holds, what this build expects, and the way + out (rename or delete the file), and it is a :class:`StoreError` + subclass so existing "did it fail" callers keep working. """ db_path = tmp_path / f"v{stored_version}.db" conn = sqlite3.connect(str(db_path)) @@ -400,6 +412,607 @@ def test_store_schema_version_mismatch_error_is_a_store_error() -> None: assert issubclass(SchemaVersionMismatchError, StoreError) +# ------------------------------------------------ v1 -> v2 migration +# Product-owner policy (spec §2): every schema change bumps +# SCHEMA_VERSION and ships a migration that upgrades an older file in +# place. These tests hand-build a released-build v1 database -- the +# frozen DDL below, never SCHEMA_STATEMENTS, because what is actually on +# disk in a 1.0.x file is the whole point -- and drive it through +# Store.open. +# +# v1 -> v2 touches ``entry`` alone: it gains the stable ``key`` and the +# ``retired`` flag, and its inline ``UNIQUE (ride_id, plate)`` moves to +# the partial unique index over the live rows (SQLite cannot drop a +# table-level UNIQUE, so the migration is the standard table rebuild). +# Every other table is frozen here unchanged, so a later edit to +# SCHEMA_STATEMENTS can never rewrite what an existing file holds. + +_V1_LEDGER_DDL = ( + "CREATE TABLE schema_version (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL)" +) + +_V1_DDL: tuple[str, ...] = ( + """ + CREATE TABLE ride ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + event_date TEXT NOT NULL, + venue TEXT NOT NULL, + course_name TEXT NOT NULL, + lap_km REAL NOT NULL, + organizer TEXT NOT NULL, + scorer TEXT NOT NULL, + logo_png BLOB, + planned_start INTEGER NOT NULL, + planned_duration_s INTEGER NOT NULL, + actual_start INTEGER, + finished_at INTEGER, + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN + ('draft', 'running', 'finished', 'reopened')), + entry_mode TEXT NOT NULL + CHECK (entry_mode IN ('solo', 'mixed')), + max_team_size INTEGER NOT NULL, + plate_model TEXT NOT NULL + CHECK (plate_model IN + ('rider_pooled', 'team_relay')), + min_lap_s INTEGER NOT NULL, + deck_count INTEGER NOT NULL, + jokers_per_deck INTEGER NOT NULL, + jokers_mode TEXT NOT NULL DEFAULT 'total' + CHECK (jokers_mode IN ('per_deck', 'total')), + max_cards INTEGER, + tiebreak_order TEXT NOT NULL, + rng_seed INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + hold_short_laps INTEGER NOT NULL DEFAULT 1 + ) + """, + """ + CREATE TABLE entry ( + id INTEGER PRIMARY KEY, + ride_id INTEGER NOT NULL REFERENCES ride(id), + plate TEXT NOT NULL, + display_name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('solo', 'team')), + team_size INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'dnf')), + dnf_at INTEGER, + notes TEXT, + logo_card TEXT, + UNIQUE (ride_id, plate) + ) + """, + """ + CREATE TABLE rider ( + id INTEGER PRIMARY KEY, + entry_id INTEGER NOT NULL REFERENCES entry(id), + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + plate TEXT, + sort_order INTEGER NOT NULL, + sex TEXT CHECK (sex IN ('M', 'F')), + emergency_contact TEXT, + waiver_signed INTEGER, + ccn_reg_id TEXT + ) + """, + """ + CREATE TABLE crossing ( + id INTEGER PRIMARY KEY, + ride_id INTEGER NOT NULL REFERENCES ride(id), + entry_id INTEGER NOT NULL REFERENCES entry(id), + rider_id INTEGER REFERENCES rider(id), + seq INTEGER NOT NULL, + crossed_at INTEGER NOT NULL, + lap_s INTEGER NOT NULL, + flag TEXT NOT NULL + CHECK (flag IN ('none', 'short', 'manual')), + voided INTEGER NOT NULL DEFAULT 0, + void_reason TEXT, + UNIQUE (entry_id, seq) + ) + """, + """ + CREATE TABLE card ( + id INTEGER PRIMARY KEY, + ride_id INTEGER NOT NULL REFERENCES ride(id), + entry_id INTEGER NOT NULL REFERENCES entry(id), + crossing_id INTEGER REFERENCES crossing(id), + shoe_index INTEGER, + rank INTEGER NOT NULL + CHECK (rank = 0 OR rank BETWEEN 2 AND 14), + suit TEXT CHECK (suit IN ('s', 'h', 'd', 'c')), + state TEXT NOT NULL + CHECK (state IN ('held', 'dealt', 'voided')), + dealt_at INTEGER NOT NULL + ) + """, + """ + CREATE TABLE app_session ( + id INTEGER PRIMARY KEY, + opened_at INTEGER NOT NULL, + closed_at INTEGER, + active_ride_id INTEGER REFERENCES ride(id), + heartbeat_at INTEGER + ) + """, + """ + CREATE TABLE audit ( + id INTEGER PRIMARY KEY, + ride_id INTEGER NOT NULL REFERENCES ride(id), + at INTEGER NOT NULL, + action TEXT NOT NULL, + payload_json TEXT NOT NULL + ) + """, +) + +_V1_RIDE_ROW = """ + INSERT INTO ride ( + id, name, event_date, venue, course_name, lap_km, organizer, scorer, + logo_png, planned_start, planned_duration_s, actual_start, finished_at, + status, entry_mode, max_team_size, plate_model, min_lap_s, deck_count, + jokers_per_deck, jokers_mode, max_cards, tiebreak_order, rng_seed, + created_at, updated_at, hold_short_laps + ) VALUES ( + 1, 'GORBA EPIC 2026', '2026-09-20', 'Sea to Sky Gondola', 'Gondola Loop', + 8.0, 'GORBA', 'K. Singh', NULL, 1789898400, 21600, 1789898400, NULL, + 'running', 'mixed', 4, 'rider_pooled', 1080, 8, 2, 'total', NULL, + '["laps","total_time","high_card"]', 20260920, 1789898400, 1789898400, 1 + ) +""" + +# Three entries: a solo entry with recorded data, a team entry, and a +# DNF solo -- the id/plate/name/status spread the migration must copy. +_V1_ENTRY_ROWS: tuple[str, ...] = ( + """ + INSERT INTO entry (id, ride_id, plate, display_name, type, team_size, + status, dnf_at, notes, logo_card) + VALUES (1, 1, '12', 'Alice', 'solo', 1, 'active', NULL, '', NULL) + """, + """ + INSERT INTO entry (id, ride_id, plate, display_name, type, team_size, + status, dnf_at, notes, logo_card) + VALUES (2, 1, '45', 'Dirt Dynamos', 'team', 2, 'active', NULL, + 'carry a tail light', NULL) + """, + """ + INSERT INTO entry (id, ride_id, plate, display_name, type, team_size, + status, dnf_at, notes, logo_card) + VALUES (3, 1, '9', 'Bo', 'solo', 1, 'dnf', 1789899000, 'cramp', 'As') + """, +) + +# The child rows whose ``entry_id`` links the entry rebuild must keep +# resolving: 4 riders, 3 crossings, 3 cards, one open app_session and +# one audit row. +_V1_CHILD_ROWS: tuple[str, ...] = ( + """ + INSERT INTO rider (id, entry_id, first_name, last_name, plate, sort_order, + sex, emergency_contact, waiver_signed, ccn_reg_id) + VALUES (1, 1, 'Alice', 'Wong', '12', 0, 'F', '604-555-0101', 1, 'CCN-77') + """, + """ + INSERT INTO rider (id, entry_id, first_name, last_name, plate, sort_order, + sex, emergency_contact, waiver_signed, ccn_reg_id) + VALUES (2, 2, 'Sarah', 'Roy', '45', 0, 'F', NULL, NULL, NULL) + """, + """ + INSERT INTO rider (id, entry_id, first_name, last_name, plate, sort_order, + sex, emergency_contact, waiver_signed, ccn_reg_id) + VALUES (3, 2, 'Priya', 'Nair', '46', 1, 'F', NULL, NULL, NULL) + """, + """ + INSERT INTO rider (id, entry_id, first_name, last_name, plate, sort_order, + sex, emergency_contact, waiver_signed, ccn_reg_id) + VALUES (4, 3, 'Bo', 'Diaz', '9', 0, 'M', NULL, NULL, NULL) + """, + """ + INSERT INTO crossing (id, ride_id, entry_id, rider_id, seq, crossed_at, + lap_s, flag, voided, void_reason) + VALUES (1, 1, 1, 1, 1, 1789898520, 120, 'none', 0, NULL) + """, + """ + INSERT INTO crossing (id, ride_id, entry_id, rider_id, seq, crossed_at, + lap_s, flag, voided, void_reason) + VALUES (2, 1, 1, 1, 2, 1789898640, 132, 'none', 0, NULL) + """, + """ + INSERT INTO crossing (id, ride_id, entry_id, rider_id, seq, crossed_at, + lap_s, flag, voided, void_reason) + VALUES (3, 1, 2, 2, 1, 1789898700, 180, 'short', 1, 'cut the loop') + """, + """ + INSERT INTO card (id, ride_id, entry_id, crossing_id, shoe_index, rank, + suit, state, dealt_at) + VALUES (1, 1, 1, 1, 0, 14, 's', 'dealt', 1789898520) + """, + """ + INSERT INTO card (id, ride_id, entry_id, crossing_id, shoe_index, rank, + suit, state, dealt_at) + VALUES (2, 1, 1, 2, 1, 3, 'h', 'dealt', 1789898640) + """, + """ + INSERT INTO card (id, ride_id, entry_id, crossing_id, shoe_index, rank, + suit, state, dealt_at) + VALUES (3, 1, 2, 3, 2, 0, NULL, 'held', 1789898700) + """, + """ + INSERT INTO app_session (id, opened_at, closed_at, active_ride_id, heartbeat_at) + VALUES (1, 1789898000, NULL, 1, 1789898900) + """, + """ + INSERT INTO audit (id, ride_id, at, action, payload_json) + VALUES (1, 1, 1789898400, 'start_ride', '{"source": "setup"}') + """, +) + +_V1_SEED: tuple[str, ...] = (_V1_RIDE_ROW, *_V1_ENTRY_ROWS, *_V1_CHILD_ROWS) + +# One lone entry, and one no-entry ride: the single/many/empty boundary +# rows a rebuild has to copy (or not) without special-casing. +_V1_SINGLE_ENTRY_SEED: tuple[str, ...] = ( + _V1_RIDE_ROW, + """ + INSERT INTO entry (id, ride_id, plate, display_name, type, team_size, + status, dnf_at, notes, logo_card) + VALUES (1, 1, '12', 'Alice', 'solo', 1, 'active', NULL, '', NULL) + """, +) + +# A v1 file whose rider row points at an entry that does not exist (a +# corruption an FK-off writer can leave behind). The rebuild's +# ``foreign_key_check`` must catch it rather than paper over it. +_V1_ORPHAN_SEED: tuple[str, ...] = ( + _V1_RIDE_ROW, + """ + INSERT INTO rider (id, entry_id, first_name, last_name, plate, sort_order) + VALUES (1, 7, 'Ghost', 'Rider', '7', 0) + """, +) + + +def _write_v1_file(db_path: Path, seed: tuple[str, ...] = ()) -> None: + """Write a released-build v1 database file (arrange). + + The frozen v1 DDL, the ledger stamped 1, then the *seed* rows -- the + exact file a 1.0.x build left on disk. + """ + conn = sqlite3.connect(str(db_path)) + try: + for statement in (*_V1_DDL, _V1_LEDGER_DDL, "INSERT INTO schema_version VALUES (1, 1)"): + conn.execute(statement) + for statement in seed: + conn.execute(statement) + conn.commit() + finally: + conn.close() + + +def _read(db_path: Path, sql: str) -> list[tuple[object, ...]]: + """Run one read-only query against *db_path* (assertion aid). + + Reads through a second, independent connection: the point is what + landed on disk, not what the facade keeps in memory. + """ + with closing(sqlite3.connect(str(db_path))) as conn: + return [tuple(row) for row in conn.execute(sql)] + + +def test_store_open_migrates_a_v1_file_to_the_current_schema_version(tmp_path: Path) -> None: + """A v1 file is upgraded in place, not refused.""" + db_path = tmp_path / "v1.db" + _write_v1_file(db_path, seed=_V1_SEED) + + Store.open(db_path).close() + + assert _read(db_path, "SELECT version FROM schema_version WHERE id = 1") == [(2,)] + + +def test_store_open_v1_migration_rebuilds_entry_in_the_v2_shape(tmp_path: Path) -> None: + """The rebuilt entry table is the v2 column list, in order.""" + db_path = tmp_path / "v1.db" + _write_v1_file(db_path, seed=_V1_SEED) + + Store.open(db_path).close() + + columns = [row[1] for row in _read(db_path, "PRAGMA table_info(entry)")] + + assert columns == [ + "id", + "ride_id", + "plate", + "key", + "display_name", + "type", + "team_size", + "status", + "dnf_at", + "notes", + "logo_card", + "retired", + ] + + +def test_store_open_v1_migration_mints_a_fresh_uuid4_key_per_entry(tmp_path: Path) -> None: + """Every migrated entry gets a fresh key in Entry.key's own format. + + ``uuid.uuid4().hex`` is the expression ``roster.Entry.key`` mints + with (32 lowercase hex digits, version nibble 4), so a migrated key + is indistinguishable from one the roster would have drawn. + """ + db_path = tmp_path / "v1.db" + _write_v1_file(db_path, seed=_V1_SEED) + + Store.open(db_path).close() + + keys = [row[0] for row in _read(db_path, "SELECT key FROM entry ORDER BY id")] + parsed = [uuid.UUID(str(key)) for key in keys] + + assert [(entry.version, entry.hex) for entry in parsed] == [(4, str(key)) for key in keys] + assert len(set(keys)) == len(keys) == 3 + + +def test_store_open_v1_migration_marks_every_migrated_entry_live(tmp_path: Path) -> None: + """Every migrated entry is live: retired = 0.""" + db_path = tmp_path / "v1.db" + _write_v1_file(db_path, seed=_V1_SEED) + + Store.open(db_path).close() + + assert _read(db_path, "SELECT id, retired FROM entry ORDER BY id") == [ + (1, 0), + (2, 0), + (3, 0), + ] + + +def test_store_open_v1_migration_preserves_every_v1_entry_column(tmp_path: Path) -> None: + """Ids, plates, names, types, sizes and notes survive.""" + db_path = tmp_path / "v1.db" + _write_v1_file(db_path, seed=_V1_SEED) + + Store.open(db_path).close() + + rows = _read( + db_path, + "SELECT id, ride_id, plate, display_name, type, team_size, status," + " dnf_at, notes, logo_card FROM entry ORDER BY id", + ) + + assert rows == [ + (1, 1, "12", "Alice", "solo", 1, "active", None, "", None), + (2, 1, "45", "Dirt Dynamos", "team", 2, "active", None, "carry a tail light", None), + (3, 1, "9", "Bo", "solo", 1, "dnf", 1789899000, "cramp", "As"), + ] + + +def test_store_open_v1_migration_preserves_the_links_child_rows_hold(tmp_path: Path) -> None: + """Rider, crossing and card rows still resolve the same entry ids. + + The rebuild keeps each entry's ``id`` because these three tables + reference it; a fresh id would silently orphan every recorded lap. + """ + db_path = tmp_path / "v1.db" + _write_v1_file(db_path, seed=_V1_SEED) + + Store.open(db_path).close() + + violations = _read(db_path, "PRAGMA foreign_key_check") + rider_links = _read(db_path, "SELECT id, entry_id FROM rider ORDER BY id") + crossing_links = _read(db_path, "SELECT id, entry_id FROM crossing ORDER BY id") + card_links = _read(db_path, "SELECT id, entry_id FROM card ORDER BY id") + + assert (violations, rider_links, crossing_links, card_links) == ( + [], + [(1, 1), (2, 2), (3, 2), (4, 3)], + [(1, 1), (2, 1), (3, 2)], + [(1, 1), (2, 1), (3, 2)], + ) + + +def test_store_open_v1_migration_leaves_only_the_live_plate_unique_index(tmp_path: Path) -> None: + """v1's inline UNIQUE is gone; the partial index replaces it.""" + db_path = tmp_path / "v1.db" + _write_v1_file(db_path, seed=_V1_SEED) + + Store.open(db_path).close() + + indexes = {row[1]: (row[2], row[4]) for row in _read(db_path, "PRAGMA index_list(entry)")} + index_columns = [ + row[2] for row in _read(db_path, "PRAGMA index_info(entry_plate_live_unique)") + ] + + assert indexes == {"entry_plate_live_unique": (1, 1)} + assert index_columns == ["ride_id", "plate"] + + +def test_store_open_v1_migration_enforces_plate_uniqueness_among_live_rows( + tmp_path: Path, +) -> None: + """After migration two live entries may not share a ride's plate.""" + db_path = tmp_path / "v1.db" + _write_v1_file(db_path, seed=_V1_SEED) + Store.open(db_path).close() + + with ( + closing(sqlite3.connect(str(db_path))) as conn, + pytest.raises( + sqlite3.IntegrityError, + match=re.escape("UNIQUE constraint failed: entry.ride_id, entry.plate"), + ), + ): + conn.execute( + "INSERT INTO entry (ride_id, plate, key, display_name, type," + " team_size, status, retired)" + " VALUES (1, '12', 'a' * 32, 'Ghost', 'solo', 1, 'active', 0)" + ) + + +def test_store_open_v1_migration_allows_a_retired_row_to_hold_a_live_plate( + tmp_path: Path, +) -> None: + """The partial index leaves retired plates free (S1's move seam).""" + db_path = tmp_path / "v1.db" + _write_v1_file(db_path, seed=_V1_SEED) + Store.open(db_path).close() + + with closing(sqlite3.connect(str(db_path))) as conn: + conn.execute( + "INSERT INTO entry (ride_id, plate, key, display_name, type," + " team_size, status, retired)" + " VALUES (1, '12', 'b' * 32, 'Alice', 'solo', 1, 'active', 1)" + ) + conn.commit() + + assert _read( + db_path, "SELECT plate, retired FROM entry WHERE ride_id = 1 AND plate = '12' ORDER BY id" + ) == [("12", 0), ("12", 1)] + + +def test_store_open_v1_migration_leaves_entry_matching_a_fresh_file(tmp_path: Path) -> None: + """A migrated file's entry shape matches a fresh file's. + + The migration's ``entry`` DDL is frozen at v2 while + ``SCHEMA_STATEMENTS`` is edited in place by later versions, so this + pins the two together: a v3 edit that forgot its own migration (or a + migration drift) fails here rather than in the field. + """ + migrated_path = tmp_path / "v1.db" + _write_v1_file(migrated_path, seed=_V1_SEED) + Store.open(migrated_path).close() + fresh_path = tmp_path / "fresh.db" + Store.open(fresh_path).close() + + migrated = ( + _read(migrated_path, "PRAGMA table_info(entry)"), + sorted(_read(migrated_path, "PRAGMA index_list(entry)")), + ) + fresh = ( + _read(fresh_path, "PRAGMA table_info(entry)"), + sorted(_read(fresh_path, "PRAGMA index_list(entry)")), + ) + + assert migrated == fresh + + +def test_store_open_v1_migration_given_no_entry_rows_migrates_cleanly(tmp_path: Path) -> None: + """No entries at all rebuilds to an empty v2 table.""" + db_path = tmp_path / "v1.db" + _write_v1_file(db_path, seed=(_V1_RIDE_ROW,)) + + Store.open(db_path).close() + + assert ( + _read(db_path, "SELECT COUNT(*) FROM entry"), + _read(db_path, "SELECT version FROM schema_version WHERE id = 1"), + ) == ([(0,)], [(2,)]) + + +def test_store_open_v1_migration_given_one_entry_row_copies_it(tmp_path: Path) -> None: + """A single v1 entry row survives with its id and plate intact.""" + db_path = tmp_path / "v1.db" + _write_v1_file(db_path, seed=_V1_SINGLE_ENTRY_SEED) + + Store.open(db_path).close() + + assert _read(db_path, "SELECT id, plate, retired FROM entry") == [(1, "12", 0)] + + +def test_store_open_v1_migration_with_an_orphan_child_row_raises_and_rolls_back( + tmp_path: Path, +) -> None: + """An FK the rebuild cannot honour aborts the migration.""" + db_path = tmp_path / "orphan.db" + _write_v1_file(db_path, seed=_V1_ORPHAN_SEED) + + with pytest.raises(sqlite3.IntegrityError, match=re.escape("foreign key violations in rider")): + Store.open(db_path) + + version = _read(db_path, "SELECT version FROM schema_version WHERE id = 1") + columns = [row[1] for row in _read(db_path, "PRAGMA table_info(entry)")] + + assert (version, columns) == ( + [(1,)], + [ + "id", + "ride_id", + "plate", + "display_name", + "type", + "team_size", + "status", + "dnf_at", + "notes", + "logo_card", + ], + ) + + +def test_run_migrations_stamps_the_ledger_at_the_target_version(tmp_path: Path) -> None: + """Each step runs in order, then the ledger is stamped.""" + db_path = tmp_path / "v1.db" + _write_v1_file(db_path, seed=_V1_SEED) + + conn = sqlite3.connect(str(db_path)) + try: + run_migrations(conn, 1, SCHEMA_VERSION) + finally: + conn.close() + + assert ( + _read(db_path, "SELECT version FROM schema_version WHERE id = 1"), + _read(db_path, "SELECT COUNT(*) FROM entry WHERE key <> ''"), + ) == ([(2,)], [(3,)]) + + +def test_run_migrations_from_the_target_version_changes_nothing(tmp_path: Path) -> None: + """An empty range is a no-op: the data is untouched.""" + db_path = tmp_path / "v2.db" + Store.open(db_path).close() + before = _read(db_path, "SELECT id, key FROM entry") + + conn = sqlite3.connect(str(db_path)) + try: + run_migrations(conn, SCHEMA_VERSION, SCHEMA_VERSION) + finally: + conn.close() + + assert ( + _read(db_path, "SELECT version FROM schema_version WHERE id = 1"), + _read(db_path, "SELECT id, key FROM entry"), + ) == ([(2,)], before) + + +def test_run_migrations_given_a_version_below_the_first_migration_raises_key_error( + tmp_path: Path, +) -> None: + """Version 0 is the create path, not a migration. + + ``range(0, 2)`` asks for a v0 -> v1 step that cannot exist (v0 + is an empty file, which :func:`ensure_schema` creates rather than + migrates), so the missing registry entry surfaces as a KeyError + naming it. + """ + db_path = tmp_path / "v0.db" + sqlite3.connect(str(db_path)).close() + conn = sqlite3.connect(str(db_path)) + try: + conn.execute(SCHEMA_VERSION_DDL) + + with pytest.raises(KeyError, match=re.escape("0")): + run_migrations(conn, 0, SCHEMA_VERSION) + finally: + conn.close() + + +def test_migrations_cover_every_version_below_the_current_one() -> None: + """Every version below SCHEMA_VERSION has a step.""" + assert sorted(MIGRATIONS) == list(range(1, SCHEMA_VERSION)) + + # --------------------------------------------------------- create_ride @@ -548,7 +1161,7 @@ def test_store_load_engine_given_a_row_without_a_policy_rebuilds_the_hold_defaul try: for statement in (*SCHEMA_STATEMENTS, SCHEMA_VERSION_DDL): conn.execute(statement) - conn.execute("INSERT INTO schema_version (id, version) VALUES (1, 1)") + conn.execute("INSERT INTO schema_version (id, version) VALUES (1, ?)", (SCHEMA_VERSION,)) conn.execute( """ INSERT INTO ride ( @@ -1391,7 +2004,7 @@ def test_store_load_engine_given_a_row_without_a_jokers_mode_rebuilds_total( try: for statement in (*SCHEMA_STATEMENTS, SCHEMA_VERSION_DDL): conn.execute(statement) - conn.execute("INSERT INTO schema_version (id, version) VALUES (1, 1)") + conn.execute("INSERT INTO schema_version (id, version) VALUES (1, ?)", (SCHEMA_VERSION,)) conn.execute( """ INSERT INTO ride ( @@ -3772,3 +4385,233 @@ def test_store_update_ride_config_unknown_ride_raises_naming_it(tmp_path: Path) store.update_ride_config(999, _config()) finally: store.close() + + +# ============================================================ retired +# The pooled-live-move replay seam: a dissolved entry that carries +# recorded data is persisted with ``entry.retired = 1`` so its stable +# key comes back on reload -- the engine's replay resolves every stored +# ``record_crossing`` row by that key. ``retired`` also relaxes the +# per-ride plate uniqueness to live rows (the partial unique index), +# because a solo->team move makes the destination team's derived plate +# equal the retired solo entry's. + + +def _retired_roster() -> Roster: + """Build the roster a solo->team move leaves behind (arrange). + + Solo "1" carries recorded data, so the move that empties it retires + it (never discards it); Team B adopts the lowest-numbered member's + plate, now "1" -- the live/retired plate collision the partial + unique index exists for. + """ + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.RIDER_POOLED) + solo = roster.create_solo_entry(first_name="Alice", last_name="", plate="1") + team = roster.create_team_entry( + display_name="Trail Blazers", + riders=[ + Rider(first_name="A.", last_name="Roy", plate="3"), + Rider(first_name="K.", last_name="Singh", plate="4"), + ], + ) + roster.mark_has_data(solo) + roster.move_rider(solo.riders[0], to_entry=team) + return roster + + +def test_store_save_roster_round_trips_retired_entries_with_their_keys( + tmp_path: Path, +) -> None: + """A retired entry's stable key survives save_roster -> roster_for. + + The key is the replay seam (E3.1.2): without it the reloaded roster + cannot resolve the pre-move ``record_crossing`` rows and the ride + will not reopen. + """ + db_path = tmp_path / "rides.db" + roster = _retired_roster() + (retired,) = roster.retired_entries + ride_id = _save_roster_ride( + db_path, + roster, + entry_mode=EntryMode.MIXED, + plate_model=PlateModel.RIDER_POOLED, + ) + + rebuilt = _round_trip_roster(db_path, ride_id) + + assert [entry.key for entry in rebuilt.retired_entries] == [retired.key] + assert rebuilt.entry_by_key(retired.key) is rebuilt.retired_entries[0] + + +def test_store_save_roster_keeps_a_retired_entry_out_of_the_live_entries( + tmp_path: Path, +) -> None: + """Only the live entry reconstructs into ``entries``.""" + db_path = tmp_path / "rides.db" + ride_id = _save_roster_ride( + db_path, + _retired_roster(), + entry_mode=EntryMode.MIXED, + plate_model=PlateModel.RIDER_POOLED, + ) + + rebuilt = _round_trip_roster(db_path, ride_id) + + assert [(entry.display_name, entry.team_size) for entry in rebuilt.entries] == [ + ("Trail Blazers", 3) + ] + + +def test_store_save_roster_writes_the_retired_flag_on_each_entry_row( + tmp_path: Path, +) -> None: + """The entry rows carry 0 live, 1 retired -- one per entry.""" + db_path = tmp_path / "rides.db" + ride_id = _save_roster_ride( + db_path, + _retired_roster(), + entry_mode=EntryMode.MIXED, + plate_model=PlateModel.RIDER_POOLED, + ) + + with closing(sqlite3.connect(str(db_path))) as conn: + rows = conn.execute( + "SELECT plate, retired FROM entry WHERE ride_id = ? ORDER BY id", (ride_id,) + ).fetchall() + + assert rows == [("1", 0), ("1", 1)] + + +def test_store_save_roster_retains_a_retired_plate_a_live_entry_adopted( + tmp_path: Path, +) -> None: + """The partial index lets a retired plate equal a live one (S1). + + A solo->team move re-derives the destination team's plate to the + lowest-numbered member's -- exactly the plate the dissolved solo + entry still holds. + """ + db_path = tmp_path / "rides.db" + ride_id = _save_roster_ride( + db_path, + _retired_roster(), + entry_mode=EntryMode.MIXED, + plate_model=PlateModel.RIDER_POOLED, + ) + + rebuilt = _round_trip_roster(db_path, ride_id) + + assert [entry.plate for entry in (*rebuilt.entries, *rebuilt.retired_entries)] == ["1", "1"] + + +def test_store_load_roster_gives_a_retired_entry_no_riders(tmp_path: Path) -> None: + """A retired entry was emptied by the move that retired it.""" + db_path = tmp_path / "rides.db" + ride_id = _save_roster_ride( + db_path, + _retired_roster(), + entry_mode=EntryMode.MIXED, + plate_model=PlateModel.RIDER_POOLED, + ) + + rebuilt = _round_trip_roster(db_path, ride_id) + + (retired,) = rebuilt.retired_entries + assert (retired.type.value, retired.riders) == ("solo", []) + + +def test_store_save_roster_replaces_the_previous_retired_entries(tmp_path: Path) -> None: + """A second save is a snapshot: an earlier retired row is gone.""" + db_path = tmp_path / "rides.db" + ride_id = _save_roster_ride( + db_path, + _retired_roster(), + entry_mode=EntryMode.MIXED, + plate_model=PlateModel.RIDER_POOLED, + ) + store = Store.open(db_path) + try: + store.save_roster(ride_id, _pooled_roster()) + finally: + store.close() + + rebuilt = _round_trip_roster(db_path, ride_id) + + assert ( + len(rebuilt.entries), + rebuilt.retired_entries, + ) == (2, ()) + + +def test_store_entry_plate_uniqueness_still_applies_to_live_entries( + tmp_path: Path, +) -> None: + """Two live entries may not share a plate (the partial index).""" + db_path = tmp_path / "rides.db" + ride_id = _save_roster_ride( + db_path, + _solo_roster(), + entry_mode=EntryMode.SOLO, + plate_model=PlateModel.RIDER_POOLED, + ) + store = Store.open(db_path) + try: + with pytest.raises( + sqlite3.IntegrityError, + match=re.escape("UNIQUE constraint failed: entry.ride_id, entry.plate"), + ): + store._conn.execute( + "INSERT INTO entry" + " (ride_id, plate, key, display_name, type, team_size, status, retired)" + " VALUES (?, '12', 'duplicate-key', 'Copy', 'solo', 1, 'active', 0)", + (ride_id,), + ) + finally: + store.close() + + +def test_store_rides_counts_live_entries_not_retired_ones(tmp_path: Path) -> None: + """The library's Entries column counts the live roster alone.""" + db_path = tmp_path / "rides.db" + ride_id = _save_roster_ride( + db_path, + _retired_roster(), + entry_mode=EntryMode.MIXED, + plate_model=PlateModel.RIDER_POOLED, + ) + + store = Store.open(db_path) + try: + counts = {row.id: row.entries for row in store.rides()} + finally: + store.close() + + assert counts[ride_id] == 1 + + +def test_store_duplicate_ride_copies_retired_entries_with_fresh_keys( + tmp_path: Path, +) -> None: + """R-15: the copy keeps the key history, under fresh keys.""" + db_path = tmp_path / "rides.db" + source = _retired_roster() + (source_retired,) = source.retired_entries + source_id = _save_roster_ride( + db_path, + source, + entry_mode=EntryMode.MIXED, + plate_model=PlateModel.RIDER_POOLED, + ) + store = Store.open(db_path) + try: + copy_id = store.duplicate_ride(source_id) + copied = store.roster_for(copy_id) + finally: + store.close() + + (copied_retired,) = copied.retired_entries + assert (copied_retired.plate, copied_retired.display_name) == ("1", "Alice") + assert {copied_retired.key, *(entry.key for entry in copied.entries)}.isdisjoint( + {source_retired.key, *(entry.key for entry in source.entries)} + ) From 341919a466dc9d9696659e89e496a7ac8249688b Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Sun, 20 Sep 2026 19:02:08 -0400 Subject: [PATCH 06/15] feat(ui): route rider editor team changes through the live move A team change in the Rider Editor now confirms first, is refused with 'Stop the ride first' while the ride is live, and routes through the engine move methods when the ride is stopped or reopened. Closing the editor refreshes the console so standings and the review list recalculate. DRAFT editing keeps the roster-only path. --- src/rivercrossing/ui/app.py | 37 +- src/rivercrossing/ui/presenters/riders.py | 243 +++++++++++-- src/rivercrossing/ui/views/rider_editor.py | 87 ++++- tests/unit/presenters/test_data_source.py | 115 ++++++ tests/unit/presenters/test_riders.py | 386 ++++++++++++++++++++- tests/unit/ui/test_app_write_guards.py | 93 ++++- 6 files changed, 903 insertions(+), 58 deletions(-) diff --git a/src/rivercrossing/ui/app.py b/src/rivercrossing/ui/app.py index 01effce2..e79ac663 100644 --- a/src/rivercrossing/ui/app.py +++ b/src/rivercrossing/ui/app.py @@ -1645,13 +1645,20 @@ def _decorate_rider_editor(context: _RouteContext, window: Any) -> Any: # noqa: E5.4.2: the roster is the store's when a store-backed ride is open (E5.4.1's library Open replaced ``context.roster``), and the empty bootstrap roster otherwise -- the editor shows a correct empty - state until a real ride is opened. W7 returns the built view: - :func:`_open_target` persists this editor's changes once its modal - ends (the only route that needs the view after decoration). + state until a real ride is opened. E3.1.2 threads the live + console's engine (when there is one) so the Edit dialog can route a + live team change through the engine's pooled move. W7 returns the + built view: :func:`_open_target` persists this editor's changes + once its modal ends (the only route that needs the view after + decoration). """ from rivercrossing.ui.views.rider_editor import RiderEditor # noqa: PLC0415 -- deferred - return RiderEditor(window, roster=context.roster) + return RiderEditor( + window, + roster=context.roster, + engine=context.presenter.engine if context.presenter is not None else None, + ) # wx ships no stubs; the built view is returned @@ -3228,6 +3235,14 @@ def _persist_rider_editor_changes(context: _RouteContext, view: Any) -> None: # through :func:`_persist_roster_audit`, so the Audit Trail dialog shows it on the next open. + E3.1.2 adds the console refresh on a saved roster, mirroring + :func:`_persist_simulator_changes`: a committed team change on a + live ride goes through the engine's own pooled move, so the + crossing feed, counters, standings and Needs Review list all + recalculate from data the roster alone never touches -- no + ride-state change fires for the editor's modal, so a live console + would otherwise keep its pre-edit render until the next tick. + Args: context: The route context whose store/roster to act on. view: The closed ``RiderEditor`` (or a presenter-shaped @@ -3246,6 +3261,10 @@ def _persist_rider_editor_changes(context: _RouteContext, view: Any) -> None: # context.frame.SetStatusText(f"Could not save riders: {exc}") return _persist_roster_audit(context, store, context.active_ride_id, context.roster) + presenter = context.presenter + if presenter is not None: + _apply_menu_state(context, presenter.engine.state) + presenter.refresh_state() def _persist_team_editor_changes(context: _RouteContext, view: Any) -> None: # noqa: ANN401 @@ -3389,7 +3408,9 @@ def _open_rider_editor_for(context: _RouteContext, plate: str) -> None: path's two gaps against the menu route: it now applies the recorded dialog defaults (``_apply_dialog_defaults`` -- the menu route always had them) and persists the roster when the editor - closes with changes (:func:`_persist_rider_editor_changes`). The + closes with changes (:func:`_persist_rider_editor_changes`). E3.1.2 + threads the live console's engine here too, so an edit opened from + the console's own Riders tab can make a pooled live move. The dialog path mirrors :func:`_open_target`'s own: zoom applied before decoration, shown through ``dialogs.run_dialog``, destroyed in a ``finally`` (Fault A: a decoration raise must not @@ -3416,7 +3437,11 @@ def _open_rider_editor_for(context: _RouteContext, plate: str) -> None: view = None try: zoom.apply_to(window) - view = RiderEditor(window, roster=context.roster) + view = RiderEditor( + window, + roster=context.roster, + engine=context.presenter.engine if context.presenter is not None else None, + ) _apply_dialog_defaults(window, commands.route_for_id("mi_rider_editor")) view.select_rider_by_plate(plate) dialogs.run_dialog(window, opener=context.frame) diff --git a/src/rivercrossing/ui/presenters/riders.py b/src/rivercrossing/ui/presenters/riders.py index 144d14ff..2834dc2d 100644 --- a/src/rivercrossing/ui/presenters/riders.py +++ b/src/rivercrossing/ui/presenters/riders.py @@ -24,6 +24,20 @@ :meth:`RidersPresenter.on_edit_committed`, so the open editor never shows a stale roster. +E3.1.2's pooled live move reaches the operator through this editor: +:class:`EditRiderPresenter` takes the ride's optional ``engine``, and +a *team* change on a ride the engine may move a rider through +(RUNNING+stopped, or REOPENED) routes through +:meth:`~rivercrossing.ride.RideEngine.move_rider` / +:meth:`~rivercrossing.ride.RideEngine.extract_rider_to_solo` behind +:meth:`AddRiderView.confirm` -- because such a move re-attributes the +rider's laps, cards and voids, so the console's standings and Needs +Review list recalculate from it. A live RUNNING ride (R-35's Stop +guard unset) is refused with the engine's own "Stop the ride first" +before anything is asked or changed, and every other ride -- DRAFT, +FINISHED, or an editor opened with no live engine -- keeps the +roster-only write-back exactly as it was. + Add folds a new rider onto an existing team through :meth:`~rivercrossing.roster.Roster.add_rider_to_team` directly. The rider attaches to the chosen team in place, so the pooled @@ -60,6 +74,7 @@ from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable from rivercrossing import csvio +from rivercrossing.ride import RideEngineError, RideStatus from rivercrossing.roster import ( EntryMode, EntryType, @@ -79,6 +94,7 @@ from collections.abc import Sequence from pathlib import Path + from rivercrossing.ride import RideEngine from rivercrossing.roster import Entry, Roster __all__ = [ @@ -369,30 +385,28 @@ def _apply_team_change( # noqa: PLR0913, PLR0917 -- (roster, entry, rider) + th return target -def _apply_form_changes( # noqa: PLR0913, PLR0917 -- (roster, entry, rider) + the form - roster: Roster, entry: Entry, rider: Rider, form: RiderFormValues +def _apply_rider_fields( # noqa: PLR0913, PLR0917 -- (roster, entry, rider) + the form + roster: Roster, entry: Entry, rider: Rider, form: RiderFormValues, *, team_changed: bool ) -> Entry: - """Apply *form* to *rider*'s record; return the rider's entry (B2). - - The one write-back the edit dialog runs, in the order a change - means: the chosen team first (a move changes which entry owns the - plate), then the plate -- skipped on a relay ride when the team - changed, since the plate belongs to the entry and carrying the - form's value over would rewrite the *destination* team's plate -- - then the names. A ``RosterError`` from an earlier step leaves the - later ones untouched, so a refused edit never half-applies. - - The plate step is the roster's own shared - :meth:`~rivercrossing.roster.Roster.change_plate` dispatch (the - same one the rider-issues fixes use), so the shape rule has one - home; a blank or whitespace-only *form.plate* reaches that - dispatch's non-empty guard (``change_*_plate``) and is refused - there -- W7's central fix for the relay-blank hole, so no blank - plate is ever stored through this editor. + """Write *form*'s plate and names onto *rider*'s entry (B2). + + The team-independent half of the edit write-back, split out + (E3.1.2) because a live move has already changed the roster's + membership by the time it runs: the caller owns the team step -- + the roster's own :func:`_apply_team_change` or the engine's pooled + move -- and hands the entry the rider is on *now*. + + *team_changed* skips the plate step on a relay ride, where the + plate belongs to the entry: carrying the form's value over would + rewrite the *destination* team's plate. The plate step is the + roster's own shared :meth:`~rivercrossing.roster.Roster. + change_plate` dispatch (the same one the rider-issues fixes use), + so the shape rule has one home; a blank or whitespace-only + *form.plate* reaches that dispatch's non-empty guard + (``change_*_plate``) and is refused there -- W7's central fix for + the relay-blank hole, so no blank plate is ever stored through + this editor. """ - team_changed = _team_value(entry) != form.team - if team_changed: - entry = _apply_team_change(roster, entry, rider, form.team) if not (team_changed and roster.plate_model is PlateModel.TEAM_RELAY): roster.change_plate(entry, rider, plate=form.plate) _rename_rider( @@ -406,6 +420,61 @@ def _apply_form_changes( # noqa: PLR0913, PLR0917 -- (roster, entry, rider) + t return entry +def _apply_form_changes( # noqa: PLR0913, PLR0917 -- (roster, entry, rider) + the form + roster: Roster, entry: Entry, rider: Rider, form: RiderFormValues +) -> Entry: + """Apply *form* to *rider*'s record; return the rider's entry (B2). + + The roster-only write-back -- every DRAFT, solo-only and + engine-less edit, and a team change the engine's gate does not + own: the chosen team first (a move changes which entry owns the + plate), then the plate and the names + (:func:`_apply_rider_fields`). A ``RosterError`` from an earlier + step leaves the later ones untouched, so a refused edit never + half-applies. + """ + team_changed = _team_value(entry) != form.team + if team_changed: + entry = _apply_team_change(roster, entry, rider, form.team) + return _apply_rider_fields(roster, entry, rider, form, team_changed=team_changed) + + +def _owning_entry(roster: Roster, rider: Rider) -> Entry: + """Return the entry *rider* is on, now (E3.1.2). + + The write-back after a live move reads its entry here rather than + from the dialog's own ``entry``: the engine's move has already + re-filed the rider onto the destination, and a team-to-solo move + has dissolved the entry the dialog opened on. + """ + return next(entry for entry in roster.entries if rider in entry.riders) + + +def _move_reason(chosen: str) -> str: + """Return the audit reason an editor-driven move records (R-17). + + The engine refuses an empty reason and the Audit Trail shows this + text verbatim, so it names the destination the operator picked. + """ + return f"Rider Editor: moved rider to {chosen}" + + +def _move_message(rider: Rider, entry: Entry, chosen: str) -> str: + """Return the confirm's question for moving *rider* off *entry*. + + It names both ends -- the team the rider is on (the literal "solo" + for a solo entry, :func:`_team_cell`) and the operator's own + choice, a team's display name or "solo" -- and the consequence: + the move re-attributes the rider's laps and cards, so the + standings and the Needs Review list recalculate. + """ + return ( + f'Move "{rider.full_name}" from {_team_cell(entry)} to {chosen}? ' + "Their laps and cards will be re-credited; standings and the review list " + "will recalculate." + ) + + @runtime_checkable class AddRiderView(Protocol): """View surface for add_rider_dlg, in either mode (W7, 1.0.12 B2). @@ -414,13 +483,32 @@ class AddRiderView(Protocol): ``show_form`` carries all five fields (Add passes the names blank and the sex unset, Edit preloads the record's), and ``set_plate_enabled`` is the spec S3:46 start lock the editor's - own retired form used to own. + own retired form used to own. The Edit mode's ``confirm`` (E3.1.2) + is the move gate, mirroring ``RidersView.confirm``'s delete gate. """ def show_team_choices(self, names: list[str]) -> None: """Replace team_choice's content with *names*, in order.""" ... + # (title, message) + 2 button labels, mirroring + # std_dialogs.show_confirm + def confirm( # noqa: PLR0913 + self, + title: str, + message: str, + *, + ok_label: str, + cancel_label: str, + ) -> bool: + """Ask whether to make a live team change; return the verdict. + + The view owns the parent window and opens the native confirm + (``ui.std_dialogs.show_confirm``); the presenter reads only the + boolean verdict, so the flow stays headless-testable. + """ + ... + def set_team_ui_visible(self, *, visible: bool) -> None: """Show/hide the Team row (R-11: solo-only rides have none).""" ... @@ -553,15 +641,24 @@ class EditRiderPresenter: (blank name, duplicate plate, a team at max size, the ride having left DRAFT, ...) shows via :meth:`AddRiderView.show_validation` and reports ``False`` so the view leaves the dialog open. + + E3.1.2 adds *engine*: with one, a team change on a ride the engine + may move a rider through goes through the engine's pooled move + behind a confirm (module docstring) instead of the roster-only + mutator, so the rider's laps and cards follow them and the console + recalculates. The engine drives this same roster -- the app builds + one per ride -- so the write-back after a move reads the new + membership straight off it. """ - def __init__( # noqa: PLR0913 -- (view, roster) + the record being edited + def __init__( # noqa: PLR0913 -- (view, roster) + the record being edited + the engine self, view: AddRiderView, roster: Roster, *, entry: Entry, rider: Rider, + engine: RideEngine | None = None, ) -> None: """Store the collaborators and preload the record's fields. @@ -570,11 +667,15 @@ def __init__( # noqa: PLR0913 -- (view, roster) + the record being edited roster: The in-memory roster this presenter reads/writes. entry: The entry *rider* is on when the dialog opens. rider: The rider being edited. + engine: The ride's live engine, or ``None`` when there is + none (a headless caller, or an editor opened with no + store-backed ride). Only a team change consults it. """ self.view = view self.roster = roster self.entry = entry self.rider = rider + self.engine = engine self.view.show_team_choices(_team_choices(self.roster)) self.view.set_team_ui_visible(visible=self.roster.entry_mode is EntryMode.MIXED) # B2's plate lock: the same S3:46 rule the Add mode applies. @@ -590,11 +691,13 @@ def __init__( # noqa: PLR0913 -- (view, roster) + the record being edited def on_submit(self, form: RiderFormValues) -> bool: """Apply *form* to the record, or refuse with a message. - Both names are required, as in the Add mode. A roster refusal - (duplicate plate, a full destination team, the ride having left - DRAFT, ...) shows via :meth:`AddRiderView.show_validation` and - leaves the roster's live records unchanged, never raising past - this handler. + Both names are required, as in the Add mode. A team change the + live engine owns routes through it (:meth:`_commit_live_move`); + every other edit goes through the shared roster write-back. A + refusal (duplicate plate, a full destination team, the ride + having left DRAFT, the engine's own move gate, ...) shows via + :meth:`AddRiderView.show_validation` and leaves the roster's + live records unchanged, never raising past this handler. Returns: True only once the write-back actually applied. @@ -602,6 +705,9 @@ def on_submit(self, form: RiderFormValues) -> bool: if not form.first_name.strip() or not form.last_name.strip(): self.view.show_validation("First name and last name are required") return False + engine = self.engine + if engine is not None and self._is_live_move(engine, form): + return self._commit_live_move(engine, form) try: self.entry = _apply_form_changes(self.roster, self.entry, self.rider, form) except RosterError as exc: @@ -609,6 +715,85 @@ def on_submit(self, form: RiderFormValues) -> bool: return False return True + def _is_live_move(self, engine: RideEngine, form: RiderFormValues) -> bool: + """Return whether *form*'s team change is the engine's to make. + + Two conditions, both required: the form names a different team + than the rider is on (an unchanged team is no move at all), + and the ride is in one of the two cells + :meth:`~rivercrossing.ride.RideEngine._require_move_allowed` + leaves open -- RUNNING (the R-35 Stop guard decides whether + the clock is stopped, in :meth:`_commit_live_move`) or + REOPENED. DRAFT and FINISHED keep the roster-only write-back, + exactly as they did before the engine was threaded here. + """ + return _team_value(self.entry) != form.team and engine.state in ( + RideStatus.RUNNING, + RideStatus.REOPENED, + ) + + def _commit_live_move(self, engine: RideEngine, form: RiderFormValues) -> bool: + """Confirm, then move *rider* through *engine*. + + A live RUNNING ride -- the engine's Stop guard unset -- is + refused up front with the engine's own instruction, before + anything is asked: the operator stops the clock before a + mid-ride re-shuffle (R-35). Otherwise the confirm names the + move and its consequence, and a declined confirm changes + nothing at all. + + On acceptance the engine performs the move -- through + :meth:`~rivercrossing.ride.RideEngine.move_rider` for a team + destination, :meth:`~rivercrossing.ride.RideEngine. + extract_rider_to_solo` for "solo" -- and the form's remaining + fields are written back onto the entry the rider now sits on. + Any refusal the engine or the roster raises (a non- + ``rider_pooled`` ride, a full destination team, a duplicate + plate, a blank reason, ...) shows via + :meth:`AddRiderView.show_validation` and reports ``False``, + never escaping this handler: wx swallows an exception raised + inside an event handler (measured), which would leave the + dialog open with nothing happening. + + Returns: + True only once the move and the write-back both applied. + """ + if engine.state is RideStatus.RUNNING and not engine.stopped: + self.view.show_validation("Stop the ride first") + return False + if not self.view.confirm( + "Move rider?", + _move_message(self.rider, self.entry, form.team), + ok_label="Move", + cancel_label="Cancel", + ): + return False + try: + self._move_through_engine(engine, form.team) + self.entry = _apply_rider_fields( + self.roster, + _owning_entry(self.roster, self.rider), + self.rider, + form, + team_changed=True, + ) + except (RideEngineError, RosterError, ValueError) as exc: + self.view.show_validation(str(exc)) + return False + return True + + def _move_through_engine(self, engine: RideEngine, chosen: str) -> None: + """Drive *engine*'s pooled move of *rider* to *chosen* (R-17). + + The two destinations the editor offers: a team's display name, + or the solo sentinel the roster's own editor writes as "solo". + """ + reason = _move_reason(chosen) + if chosen == SOLO_TEAM_CHOICE: + engine.extract_rider_to_solo(cast("str", self.rider.plate), reason=reason) + return + engine.move_rider(cast("str", self.rider.plate), to_team=chosen, reason=reason) + class RidersPresenter: """Presenter for the rider editor (rider_editor_dlg, R-11/15/20). diff --git a/src/rivercrossing/ui/views/rider_editor.py b/src/rivercrossing/ui/views/rider_editor.py index 929bfe99..d578768a 100644 --- a/src/rivercrossing/ui/views/rider_editor.py +++ b/src/rivercrossing/ui/views/rider_editor.py @@ -36,6 +36,17 @@ delete confirm, :func:`~rivercrossing.ui.std_dialogs.show_confirm`) for ``RidersPresenter.on_delete``. +E3.1.2 threads the ride's live engine down the Edit path: +:class:`RiderEditor` takes ``engine=`` (``None`` with no store-backed +ride open) and hands it to ``run_edit_rider_flow``, so +:class:`AddRiderDialog`'s ``EditRiderPresenter`` can route a live team +change through ``RideEngine.move_rider``/``extract_rider_to_solo``. +That dialog implements ``AddRiderView.confirm`` -- the move's own +confirm, the same native +:func:`~rivercrossing.ui.std_dialogs.show_confirm` -- for exactly that +gate. The Add flow passes no engine: creating a rider is never a +move. + xrc-windows.md section C's code-side footnote puts ``riders_list``'s rows, its Team column's solo-only visibility, ``team_choice``'s content and ``delete_btn``'s has-data gate in code -- ``riders.xrc``'s @@ -118,6 +129,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Sequence + from rivercrossing.ride import RideEngine from rivercrossing.roster import Entry, Rider, Roster from rivercrossing.ui.presenters.data_source import RiderRow from rivercrossing.ui.presenters.riders import CsvPreview @@ -355,7 +367,9 @@ class RiderEditor(DialogFindMixin): # _find: ui.views._support whatever it is told, per module-skeletons.md's MVP split. """ - def __init__(self, dialog: wx.Dialog, *, roster: Roster) -> None: + def __init__( + self, dialog: wx.Dialog, *, roster: Roster, engine: RideEngine | None = None + ) -> None: """Decorate an already-loaded ``rider_editor_dlg`` window. Args: @@ -365,8 +379,13 @@ def __init__(self, dialog: wx.Dialog, *, roster: Roster) -> None: Roster` this editor reads and writes directly -- unlike every other view in this package, never a ``DataSource`` projection of one. + engine: The ride's live engine, or ``None`` when there is + none (a route opened with no store-backed ride). The + Edit dialog gets it, so a live team change can go + through the engine's pooled move (E3.1.2). """ self.dialog = dialog + self.engine = engine self.riders_list = self._find(ids.RIDERS_LIST, wx.dataview.DataViewCtrl) # The operator's current header sort, re-applied whenever the @@ -505,7 +524,10 @@ def _open_edit_dialog(self) -> None: if record is None: return entry, rider = record - if run_edit_rider_flow(self.dialog, self.presenter.roster, editing=(entry, rider)): + committed = run_edit_rider_flow( + self.dialog, self.presenter.roster, editing=(entry, rider), engine=self.engine + ) + if committed: self.presenter.on_edit_committed(rider) def _on_delete(self, event: Any) -> None: # noqa: ANN401 -- wx ships no stubs @@ -730,12 +752,13 @@ class AddRiderDialog(DialogFindMixin): # _find: ui.views._support refreshes the editor's own presenter. """ - def __init__( + def __init__( # noqa: PLR0913 -- (dialog, roster) + editing and the engine self, dialog: wx.Dialog, *, roster: Roster, editing: tuple[Entry, Rider] | None = None, + engine: RideEngine | None = None, ) -> None: """Decorate an already-loaded ``add_rider_dlg`` window. @@ -748,6 +771,10 @@ def __init__( -- only the title and the presenter differ -- because ``riders.xrc`` carries one ``add_rider_dlg`` whose primary button already reads "Save". + engine: The ride's live engine, or ``None``. Only the Edit + mode is given it: a live team change routes through + the engine's pooled move, behind :meth:`confirm` + (E3.1.2). Add never consults it. """ self.dialog = dialog @@ -771,7 +798,9 @@ def __init__( else: entry, rider = editing self.dialog.SetTitle(_EDIT_TITLE) - self.presenter = EditRiderPresenter(self, roster, entry=entry, rider=rider) + self.presenter = EditRiderPresenter( + self, roster, entry=entry, rider=rider, engine=engine + ) self._bind_events() self._apply_min_size() @@ -860,6 +889,25 @@ def set_plate_enabled(self, *, enabled: bool) -> None: """Toggle ``plate_input``'s editability (spec S3:46, B2).""" self.plate_input.Enable(enabled) + def confirm( # noqa: PLR0913 -- mirrors AddRiderView.confirm's own signature + self, + title: str, + message: str, + *, + ok_label: str, + cancel_label: str, + ) -> bool: + """Ask the live-move confirm; return its verdict (E3.1.2). + + This dialog's own window parents the native dialog + (:func:`~rivercrossing.ui.std_dialogs.show_confirm`, whose + Cancel default keeps Enter off a re-attributing move); the + presenter only ever reads the verdict, the same wiring + ``RiderEditor.confirm`` uses for the delete. + """ + result = std_dialogs.show_confirm(self.dialog, title, message, ok_label, cancel_label) + return result == int(wx.ID_OK) + def show_form( # noqa: PLR0913 -- the passive view fills the five form fields verbatim self, *, @@ -1236,8 +1284,12 @@ def run_add_rider_flow(parent: wx.Window, roster: Roster) -> bool: return _run_rider_form_flow(parent, roster, editing=None) -def run_edit_rider_flow( - parent: wx.Window, roster: Roster, *, editing: tuple[Entry, Rider] +def run_edit_rider_flow( # noqa: PLR0913 -- (parent, roster) + the record and the engine + parent: wx.Window, + roster: Roster, + *, + editing: tuple[Entry, Rider], + engine: RideEngine | None = None, ) -> bool: """Open the same dialog in Edit Rider… mode; commit on Save (B2). @@ -1245,8 +1297,10 @@ def run_edit_rider_flow( ``riders_list`` row activation) calls this with the selected record. The dialog pairs with its own :class:`EditRiderPresenter` instance, which preloads *editing* and - writes it back through the shared update mutators; a committed edit - is reported to the caller through + writes it back through the shared update mutators -- or, for a + live team change *engine* owns, through the engine's pooled move + behind a confirm (E3.1.2). A committed edit is reported to the + caller through :meth:`~rivercrossing.ui.presenters.riders.RidersPresenter. on_edit_committed`. @@ -1255,27 +1309,34 @@ def run_edit_rider_flow( ends (module banner comment above). roster: The roster the dialog writes into. editing: The ``(entry, rider)`` record to preload and edit. + engine: The ride's live engine, or ``None`` when there is + none; the engine's own roster is *roster*. Returns: Whether the edit actually committed. """ - return _run_rider_form_flow(parent, roster, editing=editing) + return _run_rider_form_flow(parent, roster, editing=editing, engine=engine) -def _run_rider_form_flow( - parent: wx.Window, roster: Roster, *, editing: tuple[Entry, Rider] | None +def _run_rider_form_flow( # noqa: PLR0913 -- (parent, roster) + the record and the engine + parent: wx.Window, + roster: Roster, + *, + editing: tuple[Entry, Rider] | None, + engine: RideEngine | None = None, ) -> bool: """Load ``add_rider_dlg``, decorate it per mode and run it. The one body :func:`run_add_rider_flow` and :func:`run_edit_rider_flow` share: only ``editing`` (and so the - title and the presenter class) differs between them. + title, the presenter class and whether *engine* is consulted) + differs between them. """ window = load_dialog(wx.xrc.XmlResource.Get(), ids.ADD_RIDER_DLG) if window is None: return False try: - AddRiderDialog(window, roster=roster, editing=editing) + AddRiderDialog(window, roster=roster, editing=editing, engine=engine) default_button = dialogs.default_button_for(ids.ADD_RIDER_DLG) if default_button is not None: dialogs.set_default_button(window, default_button) diff --git a/tests/unit/presenters/test_data_source.py b/tests/unit/presenters/test_data_source.py index cc9bc8bb..00ca31c7 100644 --- a/tests/unit/presenters/test_data_source.py +++ b/tests/unit/presenters/test_data_source.py @@ -14,6 +14,14 @@ to fake or mock (T-10), bar the card-disposition read's scripted engine: a double for the one arm no engine command can still reach, never an I/O boundary. + +E3.1.2's live rider move lands here too: the console the editor's +close-persist refreshes reads this source live, so the standings +credit the destination with the moved rider's laps and the Needs +Review list gains or loses the row the re-derived short-lap flag +decides. Those two tests drive the real engine end to end, which is +what makes the refresh's own claim ("the results recalculate from the +engine") a pinned contract rather than an assumption. """ from dataclasses import replace @@ -27,6 +35,7 @@ from rivercrossing.cards import Card, Shoe from rivercrossing.ride import Crossing, Event, RideEngine from rivercrossing.roster import Entry, EntryMode, EntryType, PlateModel, Rider, Roster +from rivercrossing.ui.feed_model import review_issue from rivercrossing.ui.presenters import data_source as data_source_module from rivercrossing.ui.presenters.data_source import EngineDataSource @@ -1215,3 +1224,109 @@ def test_audit_entry_given_any_payload_renders_a_string( cell = data_source_module._audit_entry(Event(action=action, payload=payload), {"key": "12"}) assert isinstance(cell, str) + + +# --------------------------------- pooled live move re-attribution +# E3.1.2's live move (R-17) re-keys the moved rider's data -- laps, +# held and credited cards -- onto the destination entry, and the +# engine files the moved crossings under the destination's key. +# Every projection this source serves therefore follows the move, +# because each reads the engine and the roster live: the standings +# credit the destination with the laps, and the Needs Review list +# gains or loses the row its re-derived short-lap flag decides +# (``feed_model.review_issue``). Both tests drive the real engine -- +# nothing here is faked (T-10). + + +def _two_team_pooled_roster() -> Roster: + """Build two pooled teams: Dynamos (9, 45) and Trail (7, 6).""" + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.RIDER_POOLED) + roster.create_team_entry( + display_name="Dirt Dynamos", + riders=[Rider(first_name="Priya", plate="9"), Rider(first_name="Sarah", plate="45")], + ) + roster.create_team_entry( + display_name="Trail Blazers", + riders=[Rider(first_name="Tara", plate="7"), Rider(first_name="Uma", plate="6")], + ) + return roster + + +def _review_issues(source: EngineDataSource) -> list[tuple[str, str]]: + """Return each row's plate and issue, plate-sorted (arrange). + + Sorted so these tests read as "which rows are in the review set", + independent of the feed's own newest-first order (pinned elsewhere). + """ + return sorted((row.plate, review_issue(row)) for row in source.feed_rows()) + + +def test_standings_given_a_live_move_credit_the_destination_with_the_laps() -> None: + """A team-to-team move re-attributes the ranked laps (R-17).""" + roster = _two_team_pooled_roster() + engine = _running_engine(roster) + engine.record_crossing("45", at=_dt(10, 0, 5)) + engine.record_crossing("45", at=_dt(10, 0, 10)) + engine.record_crossing("7", at=_dt(10, 0, 6)) + engine.stop() + source = EngineDataSource(engine, roster) + before = [(row.entry, row.laps) for row in source.standings()[0]] + + engine.move_rider("45", to_team="Trail Blazers", reason="rider swapped teams") + + after = [(row.entry, row.laps) for row in source.standings()[0]] + assert (before, after) == ( + [("Dirt Dynamos", 2), ("Trail Blazers", 1)], + [("Trail Blazers", 3), ("Dirt Dynamos", 0)], + ) + + +def test_feed_rows_given_a_move_that_lengthens_the_lap_drops_the_review_item() -> None: + """A moved lap is re-derived, so a team overlap can clear (R-17). + + Sarah's second lap is half a second after Priya's on Dirt Dynamos + (the team-overlap reading); on Trail Blazers' later clock it is a + full lap, so the move takes the row out of the review set. + """ + roster = _two_team_pooled_roster() + engine = _running_engine(roster, hold_short_laps=False) + engine.record_crossing("9", at=_dt(10, 0, 5)) + engine.record_crossing("45", at=_half_a_second_on(_dt(10, 0, 5))) + engine.record_crossing("7", at=_dt(10, 0, 4)) + engine.stop() + source = EngineDataSource(engine, roster) + before = _review_issues(source) + + engine.move_rider("45", to_team="Trail Blazers", reason="rider swapped teams") + + assert (before, _review_issues(source)) == ( + [("45", "Team overlap"), ("7", ""), ("9", "")], + [("45", ""), ("7", ""), ("9", "")], + ) + + +def test_feed_rows_given_a_move_that_restores_a_short_lap_adds_the_review_item() -> None: + """A moved rider's reset void re-enters the review set (R-17). + + Sarah's short second lap is voided, which takes the row out of the + feed; the move resets that void onto Trail Blazers, where the lap + is still short, so the row -- and its Team overlap reading -- comes + back. + """ + roster = _two_team_pooled_roster() + team_a, _team_b = roster.entries + engine = _running_engine(roster) + engine.record_crossing("9", at=_dt(10, 0, 6)) + engine.record_crossing("45", at=_half_a_second_on(_dt(10, 0, 5))) + engine.record_crossing("7", at=_dt(10, 0, 5)) + engine.void_crossing(team_a.key, 2, reason="double entry") + engine.stop() + source = EngineDataSource(engine, roster) + before = _review_issues(source) + + engine.move_rider("45", to_team="Trail Blazers", reason="rider swapped teams") + + assert (before, _review_issues(source)) == ( + [("7", ""), ("9", "")], + [("45", "Team overlap"), ("7", ""), ("9", "")], + ) diff --git a/tests/unit/presenters/test_riders.py b/tests/unit/presenters/test_riders.py index 14552950..e0eebb7d 100644 --- a/tests/unit/presenters/test_riders.py +++ b/tests/unit/presenters/test_riders.py @@ -29,6 +29,18 @@ presenter's own row-index guard, the ``selected`` read the view opens the Edit dialog from, and ``on_edit_committed``'s re-render are pinned here too. + +E3.1.2 threads the ride's live engine into ``EditRiderPresenter``: a +team change on a ride the engine may move a rider through (a stopped +RUNNING, or REOPENED) is confirmed +(``AddRiderView.confirm``) and then driven through +``RideEngine.move_rider`` / ``extract_rider_to_solo``, while DRAFT, +FINISHED, an unchanged team and an engine-less editor keep the +roster-only write-back. ``RecordingMoveEngine`` is a hand-written +spy over that engine seam, recording each call's exact arguments +(same pattern, same reason: no I/O boundary here, T-10), and each of +the engine path's two pure formatters carries a Hypothesis invariant +test (T-7). """ from __future__ import annotations @@ -42,7 +54,7 @@ from hypothesis import strategies as st from rivercrossing import csvio -from rivercrossing.ride import RideStatus +from rivercrossing.ride import Event, IllegalStateError, RideStatus from rivercrossing.roster import ( EntryMode, EntryType, @@ -56,6 +68,7 @@ from rivercrossing.ui.presenters.riders import ( SOLO_TEAM_CHOICE, AddRiderPresenter, + AddRiderView, CsvConflict, CsvPreview, EditRiderPresenter, @@ -63,6 +76,8 @@ RidersPresenter, RidersView, _apply_team_change, + _move_message, + _move_reason, _pair_rows, _rider_pairs, _team_choices, @@ -352,11 +367,16 @@ class RecordingAddRiderView: (1.0.12 B2): the same window, one protocol. ``show_form`` fills all five fields -- the edit mode preloads the record's names and sex, the add mode passes the names blank and the sex unset. + + The move confirm (E3.1.2) is canned ``True`` unless a test flips + :attr:`confirm_result`, so every refusal test still reaches the + engine or roster's own gate. """ def __init__(self) -> None: - """Start with an empty call log.""" + """Start with an empty call log and an accepted confirm.""" self.calls: list[tuple[str, tuple[object, ...]]] = [] + self.confirm_result = True def show_team_choices(self, names: list[str]) -> None: """Record the rendered team_choice content.""" @@ -382,11 +402,59 @@ def show_form( # noqa: PLR0913 -- test spy mirrors the view's five-field contra """Record the prefilled fields, sex included (Phase 3).""" self.calls.append(("show_form", (plate, first_name, last_name, team, sex))) + def confirm( # noqa: PLR0913 -- test spy mirrors the view's confirm contract + self, + title: str, + message: str, + *, + ok_label: str, + cancel_label: str, + ) -> bool: + """Record the move confirm and return its canned verdict.""" + self.calls.append(("confirm", (title, message, ok_label, cancel_label))) + return self.confirm_result + def show_validation(self, message: str) -> None: """Record a refused-operation message.""" self.calls.append(("show_validation", (message,))) +class RecordingMoveEngine: + """A stand-in for the engine's two pooled move primitives (E3.1.2). + + ``EditRiderPresenter``'s own tests are about *routing*: which + primitive it calls, with which exact arguments, and only after + which confirm. This double records each call and returns a canned + event, and it never touches the roster -- the engine's own data + re-attribution (laps, cards, voids) is ``test_ride_move.py``'s + subject, and a live in-memory engine is not an I/O boundary to + mock (T-10). ``state``/``stopped`` pick the Stop/Reopen gate cell; + :attr:`error` stages the refusal the presenter must surface. + """ + + def __init__(self, *, state: RideStatus, stopped: bool = False) -> None: + """Start on *state* (and *stopped*), with an empty call log.""" + self.state = state + self.stopped = stopped + self.calls: list[tuple[str, tuple[str, ...], dict[str, str]]] = [] + self.error: Exception | None = None + + def move_rider(self, rider_plate: str, *, to_team: str, reason: str) -> Event: + """Record the team-to-team move (or raise the staged error).""" + return self._record("move_rider", rider_plate, to_team=to_team, reason=reason) + + def extract_rider_to_solo(self, rider_plate: str, *, reason: str) -> Event: + """Record the extraction (or raise the staged error).""" + return self._record("extract_rider_to_solo", rider_plate, reason=reason) + + def _record(self, action: str, rider_plate: str, **payload: str) -> Event: + """Log one call's exact arguments, then return or raise.""" + self.calls.append((action, (rider_plate,), payload)) + if self.error is not None: + raise self.error + return Event(action=action, payload={"rider_plate": rider_plate, **payload}) + + def test_add_rider_presenter_init_given_a_mixed_roster_renders_the_choices_and_form() -> None: """Construction renders choices, Team UI and the locked form.""" view = RecordingAddRiderView() @@ -660,13 +728,24 @@ def test_add_rider_presenter_submit_given_a_started_pooled_ride_joins_the_existi # the Add dialog's create path. -def _edit_presenter( - roster: Roster, *, entry_index: int = 0, rider_index: int = 0 +def _edit_presenter( # noqa: PLR0913 -- roster + the record's two indexes + the engine + roster: Roster, + *, + entry_index: int = 0, + rider_index: int = 0, + engine: RecordingMoveEngine | None = None, ) -> tuple[EditRiderPresenter, RecordingAddRiderView]: - """Return an edit-dialog presenter over *roster*'s record.""" + """Return an edit-dialog presenter over *roster*'s record. + + *engine* is ``None`` for the engine-less opens (every pre-E3.1.2 + caller); a test passing one stages the live console's own wiring, + where the edit dialog gets the ride's engine. + """ entry = roster.entries[entry_index] view = RecordingAddRiderView() - presenter = EditRiderPresenter(view, roster, entry=entry, rider=entry.riders[rider_index]) + presenter = EditRiderPresenter( + view, roster, entry=entry, rider=entry.riders[rider_index], engine=engine + ) view.calls.clear() return presenter, view @@ -2523,3 +2602,298 @@ def test_on_export_csv_writes_the_rosters_own_header(tmp_path: Path) -> None: path.read_text(encoding="utf-8").splitlines()[0] == "FIRSTNAME,LASTNAME,TYPE,TEAMNAME,PLATE,NOTES,SEX" ) + + +# ------------------------------ pooled live team change (E3.1.2, R-17) +# +# The Edit Rider… dialog is the operator's one team editor, so its own +# team change is where a *live* pooled move is reached. On a ride the +# engine may move a rider through (RUNNING+stopped, or REOPENED) the +# change routes through ``RideEngine.move_rider`` / +# ``extract_rider_to_solo`` behind a confirm, because the move +# re-attributes the rider's laps and cards -- the standings and the +# Needs Review list recalculate, which no roster edit alone signals. A +# live RUNNING ride refuses first ("Stop the ride first", R-35); every +# other ride (DRAFT, FINISHED, or an engine-less editor) keeps the +# roster-only write-back exactly as it was. + +_MOVE_REASON_TEAM = "Rider Editor: moved rider to Moss Ridge" +_MOVE_REASON_SOLO = "Rider Editor: moved rider to solo" +_MOVE_CONSEQUENCE = ( + "Their laps and cards will be re-credited; standings and the review list will recalculate." +) +_MOVE_CONFIRM_TEAM = ( + "Move rider?", + ('Move "A. Roy" from Trail Blazers to Moss Ridge? ' + _MOVE_CONSEQUENCE), + "Move", + "Cancel", +) +_MOVE_CONFIRM_SOLO = ( + "Move rider?", + ('Move "A. Roy" from Trail Blazers to solo? ' + _MOVE_CONSEQUENCE), + "Move", + "Cancel", +) + + +def _live_two_team_roster(status: RideStatus) -> Roster: + """Return the two-team pooled roster a live move edits.""" + roster = _two_team_roster() + roster.status = status + return roster + + +def _move_form( + *, team: str = "Moss Ridge", first_name: str = "A.", last_name: str = "Roy" +) -> RiderFormValues: + """Return the "A. Roy 77 → *team*" edit these tests submit.""" + return RiderFormValues( + plate="77", first_name=first_name, last_name=last_name, team=team, sex=None + ) + + +def test_add_rider_view_protocol_carries_the_move_confirm_seam() -> None: + """The edit dialog's own view asks the move confirm (E3.1.2).""" + assert "confirm" in AddRiderView.__dict__ + + +def test_edit_rider_presenter_submit_given_a_stopped_running_ride_moves_the_rider() -> None: + """The stopped-RUNNING cell drives ``move_rider`` (R-17, R-35).""" + roster = _live_two_team_roster(RideStatus.RUNNING) + engine = RecordingMoveEngine(state=RideStatus.RUNNING, stopped=True) + presenter, _view = _edit_presenter(roster, engine=engine) + + committed = presenter.on_submit(_move_form()) + + assert committed is True + assert engine.calls == [ + ("move_rider", ("77",), {"to_team": "Moss Ridge", "reason": _MOVE_REASON_TEAM}) + ] + + +def test_edit_rider_presenter_submit_given_a_stopped_running_ride_asks_a_confirm_first() -> None: + """The move is confirmed before the engine is asked (E3.1.2).""" + roster = _live_two_team_roster(RideStatus.RUNNING) + engine = RecordingMoveEngine(state=RideStatus.RUNNING, stopped=True) + presenter, view = _edit_presenter(roster, engine=engine) + + presenter.on_submit(_move_form()) + + assert view.calls == [("confirm", _MOVE_CONFIRM_TEAM)] + + +def test_edit_rider_presenter_submit_given_a_stopped_running_ride_extracts_to_solo() -> None: + """The solo sentinel drives ``extract_rider_to_solo`` (R-17).""" + roster = _live_two_team_roster(RideStatus.RUNNING) + engine = RecordingMoveEngine(state=RideStatus.RUNNING, stopped=True) + presenter, view = _edit_presenter(roster, engine=engine) + + committed = presenter.on_submit(_move_form(team=SOLO_TEAM_CHOICE)) + + assert committed is True + assert engine.calls == [("extract_rider_to_solo", ("77",), {"reason": _MOVE_REASON_SOLO})] + assert view.calls == [("confirm", _MOVE_CONFIRM_SOLO)] + + +def test_edit_rider_presenter_submit_given_a_declined_confirm_performs_no_move() -> None: + """Cancel changes nothing: no engine call, no roster write.""" + roster = _live_two_team_roster(RideStatus.RUNNING) + engine = RecordingMoveEngine(state=RideStatus.RUNNING, stopped=True) + presenter, view = _edit_presenter(roster, engine=engine) + view.confirm_result = False + + committed = presenter.on_submit(_move_form()) + + assert committed is False + assert view.calls == [("confirm", _MOVE_CONFIRM_TEAM)] + assert engine.calls == [] + assert [r.full_name for r in roster.entries[0].riders] == ["A. Roy", "K. Singh"] + + +def test_edit_rider_presenter_submit_given_a_live_running_ride_refuses_to_move() -> None: + """R-35: an unstopped ride is refused, unasked and untouched.""" + roster = _live_two_team_roster(RideStatus.RUNNING) + engine = RecordingMoveEngine(state=RideStatus.RUNNING) + presenter, view = _edit_presenter(roster, engine=engine) + + committed = presenter.on_submit(_move_form()) + + assert committed is False + assert view.calls == [("show_validation", ("Stop the ride first",))] + assert engine.calls == [] + + +def test_edit_rider_presenter_submit_given_a_reopened_ride_routes_through_the_engine() -> None: + """A REOPENED ride is the corrections cell the gate leaves open.""" + roster = _live_two_team_roster(RideStatus.REOPENED) + engine = RecordingMoveEngine(state=RideStatus.REOPENED) + presenter, _view = _edit_presenter(roster, engine=engine) + + committed = presenter.on_submit(_move_form()) + + assert committed is True + assert engine.calls == [ + ("move_rider", ("77",), {"to_team": "Moss Ridge", "reason": _MOVE_REASON_TEAM}) + ] + + +def test_edit_rider_presenter_submit_given_a_draft_ride_keeps_the_roster_path() -> None: + """A DRAFT ride's team change stays the roster's own move (B2).""" + roster = _live_two_team_roster(RideStatus.DRAFT) + engine = RecordingMoveEngine(state=RideStatus.DRAFT) + presenter, view = _edit_presenter(roster, engine=engine) + + committed = presenter.on_submit(_move_form()) + + trail, moss = roster.entries + assert committed is True + assert ([r.full_name for r in trail.riders], [r.full_name for r in moss.riders]) == ( + ["K. Singh"], + ["Bo Lindqvist", "Cy Nguyen", "A. Roy"], + ) + assert (engine.calls, view.calls) == ([], []) + + +def test_edit_rider_presenter_submit_given_no_engine_keeps_the_roster_path() -> None: + """T-3: no engine at all is today's DRAFT edit, unchanged.""" + roster = _live_two_team_roster(RideStatus.DRAFT) + presenter, view = _edit_presenter(roster) + + committed = presenter.on_submit(_move_form()) + + assert committed is True + assert [r.full_name for r in roster.entries[1].riders] == [ + "Bo Lindqvist", + "Cy Nguyen", + "A. Roy", + ] + assert view.calls == [] + + +def test_edit_rider_presenter_submit_given_a_finished_ride_keeps_the_roster_path() -> None: + """FINISHED never reaches the engine: the roster refuses.""" + roster = _live_two_team_roster(RideStatus.FINISHED) + engine = RecordingMoveEngine(state=RideStatus.FINISHED) + presenter, view = _edit_presenter(roster, engine=engine) + + committed = presenter.on_submit(_move_form()) + + assert committed is False + assert view.calls == [ + ( + "show_validation", + ("rider moves are locked for a rider_pooled ride once finished",), + ) + ] + assert engine.calls == [] + + +def test_edit_rider_presenter_submit_given_the_records_own_team_never_calls_the_engine() -> None: + """T-3: an unchanged team is no move at all, engine or not.""" + roster = _live_two_team_roster(RideStatus.RUNNING) + engine = RecordingMoveEngine(state=RideStatus.RUNNING, stopped=True) + presenter, view = _edit_presenter(roster, engine=engine) + + committed = presenter.on_submit(_move_form(team="Trail Blazers", first_name="Alex")) + + assert committed is True + assert (engine.calls, view.calls) == ([], []) + assert roster.entries[0].riders[0].first_name == "Alex" + + +def test_edit_rider_presenter_submit_given_a_live_move_still_writes_the_other_fields() -> None: + """The plate and name write-back runs after the move (B2).""" + roster = _live_two_team_roster(RideStatus.RUNNING) + engine = RecordingMoveEngine(state=RideStatus.RUNNING, stopped=True) + presenter, _view = _edit_presenter(roster, engine=engine) + + presenter.on_submit(_move_form(first_name="Alex")) + + assert engine.calls == [ + ("move_rider", ("77",), {"to_team": "Moss Ridge", "reason": _MOVE_REASON_TEAM}) + ] + assert (roster.entries[0].riders[0].full_name, roster.entries[0].riders[0].plate) == ( + "Alex Roy", + "77", + ) + + +@pytest.mark.parametrize( + "error", + [ + IllegalStateError("rider moves need a rider_pooled ride, not team_relay"), + TeamSizeError("team size must be at most 2, got 3"), + ValueError("reason must not be empty"), + ], + ids=["engine-gate", "roster-refusal", "value-error"], +) +def test_edit_rider_presenter_submit_given_a_refused_move_shows_it( + error: Exception, +) -> None: + """T-5/T-12: every refusal surfaces, none escapes the handler.""" + roster = _live_two_team_roster(RideStatus.RUNNING) + engine = RecordingMoveEngine(state=RideStatus.RUNNING, stopped=True) + engine.error = error + presenter, view = _edit_presenter(roster, engine=engine) + + committed = presenter.on_submit(_move_form()) + + assert committed is False + assert view.calls == [("confirm", _MOVE_CONFIRM_TEAM), ("show_validation", (str(error),))] + assert engine.calls == [ + ("move_rider", ("77",), {"to_team": "Moss Ridge", "reason": _MOVE_REASON_TEAM}) + ] + + +def test_edit_rider_presenter_init_given_an_engine_makes_no_move_call() -> None: + """Construction only preloads the record, engine or not (B2).""" + roster = _live_two_team_roster(RideStatus.RUNNING) + engine = RecordingMoveEngine(state=RideStatus.RUNNING, stopped=True) + entry = roster.entries[0] + view = RecordingAddRiderView() + + EditRiderPresenter(view, roster, entry=entry, rider=entry.riders[0], engine=engine) + + assert (engine.calls, view.calls) == ( + [], + [ + ("show_team_choices", (["solo", "Trail Blazers", "Moss Ridge"],)), + ("set_team_ui_visible", (True,)), + ("set_plate_enabled", (False,)), + ("show_form", ("77", "A.", "Roy", "Trail Blazers", None)), + ], + ) + + +# ---------------------- the move's two pure formatters (T-7) +# +# ``_move_reason`` (the audit text) and ``_move_message`` (the confirm's +# question) are pure string builders over plain values, so their +# invariants are pinned by property, not by example: the engine +# refuses an empty reason and the operator must be told both ends of +# the move whatever names they carry. + +_PRINTABLE = st.text( + min_size=1, + alphabet=st.characters(blacklist_categories=("Cs",)), +) + + +@given(chosen=_PRINTABLE) +def test_move_reason_given_any_destination_is_never_empty(chosen: str) -> None: + """Property: the engine's non-empty-reason gate is always met.""" + reason = _move_reason(chosen) + + assert reason != "" + assert reason.endswith(chosen) + + +@given(name=_PRINTABLE, chosen=_PRINTABLE) +def test_move_message_given_any_names_names_both_ends(name: str, chosen: str) -> None: + """Property: the confirm always names both ends of the move.""" + rider = Rider(first_name=name, last_name="") + + message = _move_message(rider, _two_team_roster().entries[0], chosen) + + assert rider.full_name in message + assert chosen in message diff --git a/tests/unit/ui/test_app_write_guards.py b/tests/unit/ui/test_app_write_guards.py index 254e9a06..2aa5900a 100644 --- a/tests/unit/ui/test_app_write_guards.py +++ b/tests/unit/ui/test_app_write_guards.py @@ -1038,8 +1038,8 @@ def test_open_rider_editor_for_close_with_changes_saves_the_roster( monkeypatch.setattr( rider_editor, "RiderEditor", - # the SUT calls roster=; the stub ignores it - lambda _window, *, roster: changed_view, # noqa: ARG005 + # the SUT calls roster= and engine=; the stub ignores both + lambda _window, *, roster, engine: changed_view, # noqa: ARG005 ) monkeypatch.setattr(app_module.zoom, "apply_to", lambda _window: None) monkeypatch.setattr(app_module, "_apply_dialog_defaults", lambda _w, _route: None) @@ -1066,8 +1066,8 @@ def test_open_rider_editor_for_close_without_changes_skips_the_save( monkeypatch.setattr( rider_editor, "RiderEditor", - # the SUT calls roster=; the stub ignores it - lambda _window, *, roster: _EditorViewStub( # noqa: ARG005 + # the SUT calls roster= and engine=; the stub ignores both + lambda _window, *, roster, engine: _EditorViewStub( # noqa: ARG005 roster_changed=False ), ) @@ -1561,6 +1561,91 @@ def test_persist_simulator_changes_without_a_presenter_leaves_the_menu_untouched assert menubar.enabled(ids.MI_UNDO_CROSSING) is None +# ------- the rider editor's close re-applies the live menu + console +# +# E3.1.2: a committed team change on a live ride goes through the +# engine's own pooled move, which re-attributes the rider's laps and +# cards -- a change no roster edit alone signals to the console. Rather +# than a ride-state change, the editor's close-persist mirrors the +# simulator's: it re-applies the menubar from the live engine and +# refreshes the console, so the re-credited field is what the operator +# sees the moment the modal is gone. The tests below stage the app's own +# shape headless (real store, store-replayed engine, a real console +# presenter) and drive the close-persist the route runs. + + +class _LiveRide(NamedTuple): + """One staged open ride: the context, its store, ride id and bar.""" + + context: app_module._RouteContext + store: Store + ride_id: int + menubar: _FakeMenuBar + + +@pytest.fixture +def live_ride(tmp_path: Path) -> Iterator[_LiveRide]: + """Stage a store-backed DRAFT ride with a live console presenter. + + The library Open's own wiring, headless: a ride row is created over + a real store and its console presenter is threaded over the + store-replayed engine, so the editor's close-persist has the live + engine the app's own console holds. + """ + from conftest import gorba_config # noqa: PLC0415 -- the shared live-config fixture + + store = Store.open(tmp_path / "rides.db") + try: + ride_id = store.create_ride(gorba_config()) + roster = store.roster_for(ride_id) + engine = store.load_engine(ride_id, roster) + menubar = _FakeMenuBar() + context = _context(store=store, frame=_MenuFrame(menubar)) + context.roster = roster + context.active_ride_id = ride_id + context.presenter = ConsolePresenter( + _ConsoleViewStub(), engine=engine, source=EngineDataSource(engine, roster) + ) + yield _LiveRide(context=context, store=store, ride_id=ride_id, menubar=menubar) + finally: + store.close() + + +def test_persist_rider_editor_changes_given_a_live_engine_re_applies_the_menu( + live_ride: _LiveRide, +) -> None: + """The editor's close re-applies §15 enablement from the engine.""" + context, _store, _ride_id, menubar = live_ride + + app_module._persist_rider_editor_changes(context, _EditorViewStub(roster_changed=True)) + + assert menubar.enabled(ids.MI_FINISH_RIDE) is False + + +def test_persist_rider_editor_changes_given_a_live_engine_renders_the_console( + live_ride: _LiveRide, +) -> None: + """The editor's close refreshes the console's own render too.""" + context, _store, _ride_id, _menubar = live_ride + view = context.presenter.view # type: ignore[union-attr] -- the fixture threads one + + app_module._persist_rider_editor_changes(context, _EditorViewStub(roster_changed=True)) + + assert (view.last_state, view.entry_locked) == (RideStatus.DRAFT, True) + + +def test_persist_rider_editor_changes_given_no_presenter_leaves_the_menu_untouched( + live_ride: _LiveRide, +) -> None: + """T-3: no live engine means no menu re-apply, store save or not.""" + context, _store, _ride_id, menubar = live_ride + context.presenter = None + + app_module._persist_rider_editor_changes(context, _EditorViewStub(roster_changed=True)) + + assert menubar.enabled(ids.MI_FINISH_RIDE) is None + + # ------------- plan §8: roster plate changes reach the audit table From 1b4a16f6438ab57cf394ac935ceafc314fbff304 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Sun, 20 Sep 2026 20:05:35 -0400 Subject: [PATCH 07/15] fix(csvio): refuse pooled live reshapes on the CSV import A pooled membership reshape is now a preview conflict outside DRAFT, so a mid-ride re-import can no longer move a rider roster-only and strand their crossings on the entry left behind. The Rider Editor remains the one live (Stop/Reopen-gated) move surface. Also surface a roster refusal from a stale commit as a validation message instead of an uncaught raise. --- src/rivercrossing/csvio.py | 65 +++++++----- src/rivercrossing/ui/presenters/riders.py | 22 ++-- tests/unit/presenters/test_riders.py | 124 ++++++++++++++++++++++ tests/unit/test_csvio.py | 120 +++++++++++++++------ 4 files changed, 262 insertions(+), 69 deletions(-) diff --git a/src/rivercrossing/csvio.py b/src/rivercrossing/csvio.py index 77bcbf1b..c0cc98ba 100644 --- a/src/rivercrossing/csvio.py +++ b/src/rivercrossing/csvio.py @@ -92,29 +92,32 @@ applying every change through the roster's own mutators (so it is fully audit-logged) subject to the same lock matrix E3.1.2 already governs edits with: -DRAFT reshapes freely; once started, relay keeps its permanent lock, -while pooled keeps team-to-team moves open per -:func:`~rivercrossing.roster.can_move_rider` (spec S7:171 -- "a changed -team_name is treated as an audited membership move, not a conflict"). A -status/model combination that cannot safely reshape becomes a conflict -at preview time instead of a partial or unaudited mutation. **An entry +DRAFT reshapes freely; once started, relay keeps its permanent lock +and a pooled membership reshape waits for DRAFT. A status/model +combination that cannot safely reshape becomes a conflict at preview +time instead of a partial or unaudited mutation. **An entry present in the roster but absent from the file is left alone** -- neither the spec nor commit ever deletes on that basis; only DNF/void (E4) or the rider editor removes an entry with no row. -**Pooled team<->solo conversions are DRAFT-only (the pooled-reshape -follow-on).** A team member's row losing its team name applies via +**Every pooled membership reshape is DRAFT-only on this surface.** A +team member's row losing its team name applies via :meth:`~rivercrossing.roster.Roster.extract_rider_to_solo`; a brand-new or currently-solo rider's row gaining one applies via :meth:`~rivercrossing.roster.Roster.add_rider_to_team` (a solo rider's -own entry is dissolved first). Both are gated by -:func:`~rivercrossing.roster.can_edit_structure` -- DRAFT only, spec -S1's "convert solo <-> team ... before start" -- **except** a -brand-new plate landing straight on an *existing* team, which stays -open through RUNNING/REOPENED via -:func:`~rivercrossing.roster.can_move_rider`'s own carve-out, same as -a team-to-team move. A conversion the current status locks becomes a -conflict at preview time instead of a partial or unaudited mutation. +own entry is dissolved first); a member's row naming another team +moves them via :meth:`~rivercrossing.roster.Roster.move_rider`. All +three are gated by :func:`~rivercrossing.roster.can_edit_structure` -- +DRAFT only, spec S1's "convert solo <-> team ... before start" -- so a +reshape the current status locks becomes a conflict at preview time +instead of a partial or unaudited mutation. + +The **Rider Editor is the live (Stop/Reopen-gated) move surface**, not +CSV: R-17's move re-attributes the rider's plate, crossings and cards, +and resets their voided laps, while a CSV reshape applies membership +alone. A rider's recorded crossings stay keyed to the entry they were +recorded under, so a mid-ride re-import that moved membership alone +would strand them on the entry left behind. **Team notes (decided 2026-08-09, unified format).** On import, a team's ``notes`` is every non-empty member row's own notes, joined with @@ -174,7 +177,6 @@ Rider, Roster, can_edit_structure, - can_move_rider, canonical_person_name, rider_name_key, team_name_key, @@ -1447,8 +1449,13 @@ def _lowest_rider_plate(riders: Sequence[ParsedRider]) -> str: def _pooled_move_problem(status: RideStatus) -> str: - """Return the conflict text for a pooled move *status* disallows.""" - return f"team change requires DRAFT, RUNNING or REOPENED (ride is {status})" + """Return the conflict text for a pooled membership *status* locks. + + Covers the team-to-team move and a brand-new plate landing on an + existing team: both reshape membership alone, so both wait for + DRAFT like the team<->solo conversions beside them. + """ + return f"a team membership change requires DRAFT (ride is {status})" def _team_to_solo_problem(status: RideStatus) -> str: @@ -1468,13 +1475,15 @@ def _pooled_team_structural_conflicts( ) -> list[ImportConflict]: """Return every conflict this team's own membership reshape has. - A member already on the resolved target needs nothing. One - already on a *different* existing team is a real move, gated by - :func:`~rivercrossing.roster.can_move_rider` (spec S7:171's pooled - exception, also covering a brand-new plate landing straight on an - existing team). A currently-solo rider converting into a team - member is gated by :func:`~rivercrossing.roster.can_edit_structure` - instead -- DRAFT only, in every case, existing or forming target. + A member already on the resolved target needs nothing. Every other + membership reshape is DRAFT-only, gated by + :func:`~rivercrossing.roster.can_edit_structure`: a member already + on a *different* existing team is a real move (spec S7:171's + "audited membership move"), a currently-solo rider converts into a + team member, and a brand-new plate lands straight on an existing + team. Each reshape is membership alone here, so each waits for + DRAFT; R-17's re-attribution belongs to the Rider Editor's + Stop/Reopen-gated move (module docstring). """ riders = [team_row.rider for team_row in rows] target = _pooled_team_target(existing_index, riders) @@ -1485,14 +1494,14 @@ def _pooled_team_structural_conflicts( if owner is not None and owner[0] is target: continue if owner is not None and owner[0].type is EntryType.TEAM: - if not can_move_rider(status, PlateModel.RIDER_POOLED): + if not can_edit_structure(status): conflicts.append(ImportConflict(row_num, _pooled_move_problem(status))) continue if owner is not None: # currently solo, converting to a team member if not can_edit_structure(status): conflicts.append(ImportConflict(row_num, _solo_to_team_problem(status))) continue - if target is not None and not can_move_rider(status, PlateModel.RIDER_POOLED): + if target is not None and not can_edit_structure(status): conflicts.append(ImportConflict(row_num, _pooled_move_problem(status))) return conflicts diff --git a/src/rivercrossing/ui/presenters/riders.py b/src/rivercrossing/ui/presenters/riders.py index 2834dc2d..4eb452cc 100644 --- a/src/rivercrossing/ui/presenters/riders.py +++ b/src/rivercrossing/ui/presenters/riders.py @@ -1039,14 +1039,18 @@ def on_confirm_csv_import(self) -> bool: """Commit the last previewed import (E3.4, R-21). A no-op returning ``False`` if nothing was ever previewed. - A refusal (the roster changed since preview, so conflicts - are present after all) shows via - :meth:`RidersView.show_validation` and returns ``False``, - never raising past this handler -- mirroring - :meth:`on_add_committed`/:meth:`on_delete`'s own - refusal shape. Returns ``True`` once the commit actually - applied, so :class:`~rivercrossing.ui.views.rider_editor. - CsvPreviewDialog` knows whether to end its own modal loop. + A refusal shows via :meth:`RidersView.show_validation` and + returns ``False``, never raising past this handler -- mirroring + :meth:`on_add_committed`/:meth:`on_delete`'s own refusal shape. + Two refusals reach here: the roster changed since preview so + conflicts are present after all + (:class:`~rivercrossing.csvio.ImportConflictsPresentError`), and + a stale preview whose reshape the roster's own lock matrix now + refuses (:class:`~rivercrossing.roster.RosterError` -- a pooled + move committed after the ride started, say). Returns ``True`` + once the commit actually applied, so + :class:`~rivercrossing.ui.views.rider_editor.CsvPreviewDialog` + knows whether to end its own modal loop. Never re-renders ``riders_list``/``team_choice`` on success: this method's only real caller, ``CsvPreviewDialog``, never @@ -1062,7 +1066,7 @@ def on_confirm_csv_import(self) -> bool: return False try: csvio.commit(self._csv_preview) - except csvio.ImportConflictsPresentError as exc: + except (csvio.ImportConflictsPresentError, RosterError) as exc: self.view.show_validation(str(exc)) return False return True diff --git a/tests/unit/presenters/test_riders.py b/tests/unit/presenters/test_riders.py index e0eebb7d..8268605a 100644 --- a/tests/unit/presenters/test_riders.py +++ b/tests/unit/presenters/test_riders.py @@ -2586,6 +2586,130 @@ def test_on_confirm_csv_import_given_conflicts_present_leaves_the_roster_unchang assert roster.entries == () +# ---------------- a started ride refuses a pooled CSV reshape (R-17) +# +# Regression: a rider's recorded crossings stay keyed to the entry they +# were recorded under, so a CSV re-import that moved membership alone +# would strand them on the source entry. Every pooled membership +# reshape is therefore DRAFT-only on this surface -- the Rider Editor +# is the live, Stop/Reopen-gated move surface (csvio module docstring) +# -- and a stale preview the roster itself now refuses is shown, not +# raised. + + +def _roster_names(roster: Roster) -> list[tuple[str, list[str]]]: + """Return each entry's display name and rider names, in order.""" + return [ + (entry.display_name, [rider.full_name for rider in entry.riders]) + for entry in roster.entries + ] + + +def _wolves_and_falcons(status: RideStatus) -> Roster: + """Return the pooled Wolves/Falcons roster, in *status*.""" + roster = Roster(entry_mode=EntryMode.MIXED) + roster.create_team_entry( + display_name="Wolves", + riders=[ + Rider(first_name="Bo", last_name="Lindqvist", plate="2"), + Rider(first_name="Cy", last_name="Nguyen", plate="3"), + Rider(first_name="Zed", last_name="Roy", plate="4"), + ], + ) + roster.create_team_entry( + display_name="Falcons", + riders=[ + Rider(first_name="Do", last_name="Singh", plate="5"), + Rider(first_name="El", last_name="Roy", plate="6"), + ], + ) + roster.status = status + return roster + + +def _write_midride_move_csv(directory: Path) -> Path: + """Write the re-import moving Bo(2) from Wolves to Falcons.""" + return _write_pooled_csv( + directory, + "Bo,Lindqvist,team,Falcons,2,\nCy,Nguyen,team,Wolves,3,\nZed,Roy,team,Wolves,4,\n" + "Do,Singh,team,Falcons,5,\nEl,Roy,team,Falcons,6,\n", + ) + + +@pytest.mark.parametrize("status", [RideStatus.RUNNING, RideStatus.REOPENED]) +def test_on_pick_csv_import_given_a_started_ride_team_change_disables_import( + tmp_path: Path, status: RideStatus +) -> None: + """The mid-ride re-import reports the conflict; Import is off.""" + view = RecordingRidersView() + presenter = RidersPresenter(view, _wolves_and_falcons(status), load=False) + + presenter.on_pick_csv_import(_write_midride_move_csv(tmp_path)) + + assert ( + "show_csv_preview", + ( + CsvPreview( + summary="riders.csv → 5 riders · 2 teams · 1 conflicts", + conflicts=( + CsvConflict( + row=2, + problem=f"a team membership change requires DRAFT (ride is {status})", + ), + ), + ), + ), + ) in view.calls + assert ("set_import_enabled", (False,)) in view.calls + + +@pytest.mark.parametrize("status", [RideStatus.RUNNING, RideStatus.REOPENED]) +def test_on_confirm_csv_import_given_a_started_ride_team_change_keeps_the_roster( + tmp_path: Path, status: RideStatus +) -> None: + """A refused commit leaves every rider on their own entry.""" + roster = _wolves_and_falcons(status) + presenter = RidersPresenter(RecordingRidersView(), roster, load=False) + presenter.on_pick_csv_import(_write_midride_move_csv(tmp_path)) + before = _roster_names(roster) + + result = presenter.on_confirm_csv_import() + + assert result is False + assert _roster_names(roster) == before + + +def test_on_confirm_csv_import_given_a_roster_refusal_shows_validation_not_crash( + tmp_path: Path, +) -> None: + """A roster refusal surfaces through show_validation (E3.4). + + The stale-preview case: this import previewed clean while the ride + was DRAFT, the ride moved on before the operator confirmed, and by + then the roster's own lock matrix refuses the move -- a + ``RosterError`` (``LockedError``), never an + ``ImportConflictsPresentError``. wx swallows an exception that + escapes the presenter's caller (the measured note), so the handler + must catch both and show it. + """ + roster = _wolves_and_falcons(RideStatus.DRAFT) + view = RecordingRidersView() + presenter = RidersPresenter(view, roster, load=False) + presenter.on_pick_csv_import(_write_midride_move_csv(tmp_path)) + view.calls.clear() + roster.status = RideStatus.FINISHED + + result = presenter.on_confirm_csv_import() + + assert result is False + assert view.calls == [ + ( + "show_validation", + ("rider moves are locked for a rider_pooled ride once finished",), + ) + ] + + # -------------------------------------------------------- on_export_csv diff --git a/tests/unit/test_csvio.py b/tests/unit/test_csvio.py index 2f0d31dd..ba628020 100644 --- a/tests/unit/test_csvio.py +++ b/tests/unit/test_csvio.py @@ -10,10 +10,13 @@ conflict found without writing anything -- to the filesystem or to the target roster -- and ``commit`` applies a conflict-free preview through the roster's own mutators, so every change is audit-logged and subject -to the ride's lock matrix (DRAFT reshapes freely; once started, relay -keeps its permanent composition lock while pooled keeps team-to-team -moves and a brand-new plate joining an existing team open through -RUNNING/REOPENED; only solo<->team *conversions* stay DRAFT-only). +to the ride's lock matrix: DRAFT reshapes freely, while once started +relay keeps its permanent composition lock and *every* pooled +membership reshape -- team-to-team, team<->solo, a brand-new plate +landing on an existing team -- waits for DRAFT. The Rider Editor is +the live (Stop/Reopen-gated) move surface, because its move +re-attributes the rider's crossings and cards; a CSV reshape would +move membership alone. The unified contract under test: @@ -94,7 +97,7 @@ _HEADER_PROBLEM = "missing or malformed header: no first or last name column" _MISSING_NAME_PROBLEM = "missing name" _STRUCTURAL_PROBLEM = "only new plates or name fixes are allowed" -_MOVE_NOT_ALLOWED_PROBLEM = "team change requires DRAFT, RUNNING or REOPENED" +_POOLED_MEMBERSHIP_LOCKED_PROBLEM = "a team membership change requires DRAFT" _TEAM_TO_SOLO_LOCKED_PROBLEM = "converting a team member to a solo entry requires DRAFT" _SOLO_TO_TEAM_LOCKED_PROBLEM = "converting a solo rider into a team member requires DRAFT" _NON_DIGIT_PLATE_PROBLEM = "plate '77A' must be a whole number" @@ -1818,6 +1821,15 @@ def _seed_wolves_and_falcons(roster: Roster) -> None: ) +def _team_riders(roster: Roster) -> list[tuple[str, list[str]]]: + """Return each team's name and rider names, in roster order.""" + return [ + (entry.display_name, [rider.full_name for rider in entry.riders]) + for entry in roster.entries + if entry.type is EntryType.TEAM + ] + + def _bo_moves_to_falcons_file(tmp_path: Path) -> Path: """Write the re-import file moving Bo(2) from Wolves to Falcons.""" return _unified_file( @@ -1894,29 +1906,44 @@ def test_commit_pooled_team_move_also_updates_the_targets_notes(tmp_path: Path) assert (falcons.notes, report.moved_count, report.updated_count) == ("flat tire", 1, 1) -def test_preview_pooled_moved_rider_while_running_is_not_a_conflict( - tmp_path: Path, +@pytest.mark.parametrize("status", [RideStatus.RUNNING, RideStatus.REOPENED]) +def test_preview_pooled_moved_rider_after_the_start_is_a_conflict( + tmp_path: Path, status: RideStatus ) -> None: - """The same move previews clean once RUNNING (spec S7:171).""" + """A team-to-team pooled reshape waits for DRAFT. + + Regression: the rider's recorded crossings stay keyed to the entry + they were recorded under, so a CSV re-import that moved membership + alone would strand them. The CSV surface reports the reshape as a + conflict instead -- which is what leaves Import disabled -- while + the Rider Editor's Stop/Reopen-gated move stays the live surface. + """ roster = _pooled_roster() _seed_wolves_and_falcons(roster) - roster.status = RideStatus.RUNNING + roster.status = status result = preview(_bo_moves_to_falcons_file(tmp_path), roster) - assert result.conflicts == () + assert result.conflicts == ( + ImportConflict(row=3, problem=f"{_POOLED_MEMBERSHIP_LOCKED_PROBLEM} (ride is {status})"), + ) -def test_commit_pooled_moved_rider_while_running_succeeds(tmp_path: Path) -> None: - """commit() applies the RUNNING move exactly like a DRAFT one.""" +@pytest.mark.parametrize("status", [RideStatus.RUNNING, RideStatus.REOPENED]) +def test_commit_pooled_moved_rider_after_the_start_leaves_the_membership_alone( + tmp_path: Path, status: RideStatus +) -> None: + """commit() refuses the started-ride move, unmutated (R-17).""" roster = _pooled_roster() _seed_wolves_and_falcons(roster) - roster.status = RideStatus.RUNNING + roster.status = status result = preview(_bo_moves_to_falcons_file(tmp_path), roster) + before = _team_riders(roster) - report = commit(result) + with pytest.raises(ImportConflictsPresentError, match=re.escape("1 conflict")): + commit(result) - assert report.moved_count == 1 + assert _team_riders(roster) == before def test_preview_pooled_moved_rider_while_finished_is_a_conflict(tmp_path: Path) -> None: @@ -1927,8 +1954,23 @@ def test_preview_pooled_moved_rider_while_finished_is_a_conflict(tmp_path: Path) result = preview(_bo_moves_to_falcons_file(tmp_path), roster) - assert len(result.conflicts) == 1 - assert _MOVE_NOT_ALLOWED_PROBLEM in result.conflicts[0].problem + assert result.conflicts == ( + ImportConflict( + row=3, problem=f"{_POOLED_MEMBERSHIP_LOCKED_PROBLEM} (ride is {RideStatus.FINISHED})" + ), + ) + + +@given(status=st.sampled_from(list(RideStatus))) +@settings(max_examples=25, deadline=None) +def test_pooled_move_problem_names_draft_and_the_status_always(status: RideStatus) -> None: + """Every status's refusal names DRAFT and that status (T-7). + + The old text promised the reshape was allowed while RUNNING or + REOPENED; the DRAFT-only contract makes the tail identical for + every ride state, so no status can quietly re-open the door. + """ + assert csvio._pooled_move_problem(status).endswith(f"requires DRAFT (ride is {status})") # ------------------------------------- pooled reshape via re-import @@ -2085,31 +2127,42 @@ def test_preview_pooled_new_rider_joining_an_existing_team_is_not_a_conflict_in_ assert result.conflicts == () -def test_preview_pooled_new_rider_joining_an_existing_team_while_running_is_not_a_conflict( - tmp_path: Path, +@pytest.mark.parametrize("status", [RideStatus.RUNNING, RideStatus.REOPENED]) +def test_preview_pooled_new_rider_joining_an_existing_team_after_the_start_conflicts( + tmp_path: Path, status: RideStatus ) -> None: - """RUNNING keeps this open too (add_rider_to_team's carve-out).""" + """A brand-new plate on an existing team waits for DRAFT too. + + Same reason as the team-to-team move: the reshape is membership + alone on this surface, so it is DRAFT-only rather than the Rider + Editor's live, Stop/Reopen-gated join. + """ roster = _pooled_roster() _falcons_of_two(roster) - roster.status = RideStatus.RUNNING + roster.status = status result = preview(_fay_joins_falcons_file(tmp_path), roster) - assert result.conflicts == () + assert result.conflicts == ( + ImportConflict(row=4, problem=f"{_POOLED_MEMBERSHIP_LOCKED_PROBLEM} (ride is {status})"), + ) def test_preview_pooled_new_rider_joining_an_existing_team_while_finished_conflicts( tmp_path: Path, ) -> None: - """FINISHED closes this door too (can_move_rider is False here).""" + """FINISHED closes this door too (every reshape is DRAFT-only).""" roster = _pooled_roster() _falcons_of_two(roster) roster.status = RideStatus.FINISHED result = preview(_fay_joins_falcons_file(tmp_path), roster) - assert len(result.conflicts) == 1 - assert _MOVE_NOT_ALLOWED_PROBLEM in result.conflicts[0].problem + assert result.conflicts == ( + ImportConflict( + row=4, problem=f"{_POOLED_MEMBERSHIP_LOCKED_PROBLEM} (ride is {RideStatus.FINISHED})" + ), + ) def test_commit_pooled_new_rider_joining_an_existing_team_in_draft( @@ -2129,18 +2182,21 @@ def test_commit_pooled_new_rider_joining_an_existing_team_in_draft( ) -def test_commit_pooled_new_rider_joining_an_existing_team_while_running( - tmp_path: Path, +@pytest.mark.parametrize("status", [RideStatus.RUNNING, RideStatus.REOPENED]) +def test_commit_pooled_new_rider_joining_an_existing_team_after_the_start_refuses( + tmp_path: Path, status: RideStatus ) -> None: - """The same join applies while RUNNING too.""" + """The started-ride join commits nothing; the roster stays put.""" roster = _pooled_roster() _falcons_of_two(roster) - roster.status = RideStatus.RUNNING + roster.status = status result = preview(_fay_joins_falcons_file(tmp_path), roster) + before = _team_riders(roster) - report = commit(result) + with pytest.raises(ImportConflictsPresentError, match=re.escape("1 conflict")): + commit(result) - assert report.joined_count == 1 + assert _team_riders(roster) == before def _alex_joins_falcons_file(tmp_path: Path) -> Path: @@ -2171,7 +2227,7 @@ def test_preview_pooled_solo_rider_joining_an_existing_team_is_not_a_conflict_in def test_preview_pooled_solo_rider_joining_an_existing_team_while_running_conflicts( tmp_path: Path, ) -> None: - """RUNNING refuses the solo->team conversion (the carve-out).""" + """RUNNING refuses the solo->team conversion (DRAFT-only).""" roster = _pooled_roster() roster.create_solo_entry(first_name="Alex", last_name="", plate="1") _falcons_of_two(roster) From 043b8b58b9e8874e49b87b8c2065e009243fb74c Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Sun, 20 Sep 2026 20:08:26 -0400 Subject: [PATCH 08/15] docs(design): correct the pooled-move contract, drop the dead picker The CSV import applies no pooled reshape after the start (DRAFT-only), the Rider Editor is the one live Stop/Reopen-gated move surface, R-15/R-17 name the retired-entry persistence, module-skeletons carry Entry.key, the roster retired members and the engine move signatures, and the dead run_move_rider/RiderMove picker is removed. --- design/docs-md/module-skeletons.md | 13 ++++-- design/docs-md/requirements.md | 4 +- design/docs-md/spec.md | 6 +-- src/rivercrossing/ui/views/corrections.py | 49 +---------------------- 4 files changed, 16 insertions(+), 56 deletions(-) diff --git a/design/docs-md/module-skeletons.md b/design/docs-md/module-skeletons.md index 30adfed1..8e8909cb 100644 --- a/design/docs-md/module-skeletons.md +++ b/design/docs-md/module-skeletons.md @@ -198,7 +198,11 @@ class RideEngine: # pure; wall-clock injected for tests undo_last() -> Event · edit_crossing(entry_id, seq, crossed_at, reason) void_crossing(entry_id, seq, reason) · reassign_crossing(seq, new_plate, reason) deal_manual(plate, reason) · void_card(entry_id, card, reason) · mark_dnf(plate, reason) - # rider moves are not the engine's: Roster.move_rider(rider, *, to_entry); pooled only (R-17) + move_rider(rider_plate: str, *, to_team: str, reason: str) -> Event # team->team, solo->team + extract_rider_to_solo(rider_plate: str, *, reason: str) -> Event # team->solo + # pooled rider moves: the Roster owns membership (move_rider(rider, *, to_entry); + # extract_rider_to_solo -- a solo source allowed); these two engine methods own the + # re-attribution (plate, crossings, cards; voided laps reset), Stop/Reopen-gated (R-17) stop() -> Event · finish(*, self_test_failed_checks=()) -> Event · reopen() -> Event # REOPENED = corrections only; finish() also performs and records R-14's # high-card draw (one tiebreak_draw event) and writes any overridden @@ -230,8 +234,9 @@ rivercrossing.roster — in-memory roster & lock matrix (§1–§2 · R-11/12/15 ``` class EntryMode(StrEnum): SOLO MIXED · class PlateModel(StrEnum): RIDER_POOLED TEAM_RELAY -@dataclass Entry(plate, display_name, type, riders, status, notes, has_data, logo_card) - # identity, not value; has_data is the delete guard (R-15) +@dataclass Entry(plate, display_name, type, riders, status, notes, has_data, logo_card, key) + # identity, not value; key = stable UUID the engine files crossings/hands under + # (not the mutable derived plate); has_data is the delete guard (R-15) @dataclass Rider(first_name, last_name="", plate: str | None = None, sex: str | None = None, sort_order=0) class Roster: # one ride's entries/riders; status set by the E4 engine @@ -242,6 +247,8 @@ class Roster: # one ride's entries/riders; status set by the E4 next_free_plate() -> str # highest numeric + 1 validate_for_start() -> list[StartViolation] # R-12's floor, checked at start entries · audit_log · status · take_audit_log() # audit events persist via the E5 store + entry_by_key(key) -> Entry | None # stable-key lookup; searches retired too + retired_entries · load_retired_entries() # dissolved data-bearing entries, kept for replay can_edit_structure(status) · can_delete_entry(status, has_data) can_move_rider(status, plate_model) · can_add_entry() · can_fix_name() team_name_key(name) -> str # fuzzy team key; the CSV preview and rider_issues share it diff --git a/design/docs-md/requirements.md b/design/docs-md/requirements.md index 0b84db3f..239b9f0f 100644 --- a/design/docs-md/requirements.md +++ b/design/docs-md/requirements.md @@ -27,9 +27,9 @@ Each requirement is testable and traces to the [engineering spec](spec.md) (§) | R-13 | MUST | Shoe config per ride: deck count, jokers per deck (0–10, default 1, jokers wild) and the jokers mode — **total** (the default: those jokers are spent once across the ride, a spent budget dealing naturals only) or **per deck** (re-dealt every cycle); the optional card cap is the setup dialog's **Card cap** dropdown — **Disabled** by default and otherwise a cap N from 5 to 20, where N scores an entry's best 5 of its first N credited cards. Laps past the cap still count. | §4/§5 · [setupdlg](xrc-windows.md) | | R-14 | MUST | Tie-break order (default ① high-card draw ② most laps ③ total time) set at ride setup, draggable, and changeable after the finish with instant re-ranking; the stored order applies to a FINISHED ride's results, while a not-yet-finished ride auto-ranks most laps then total time. The high-card draw is performed at the finish: `RideEngine.finish()` draws one card per tied entry from one fresh 52-card deck seeded from the ride's stored seed (highest card wins — rank first, then suit, spades highest), records it as an audited `tiebreak_draw` event, and the drawn card shows in the results (the Standings window's Draw column and the exports). Reopening or continuing discards the draws, so a corrected ride redraws at its next finish. | §5 · [setupdlg](xrc-windows.md)/[resultsframe](xrc-windows.md) | | R-16 | MUST | Mixed rides choose a plate model: rider plates pooled to the team (**default** — each rider draws against their own unique plate, uncapped: one card per lap for as many laps as they ride; the team hand scores from the pooled cards, with the optional ride-level cap X applying to the pooled total) or team plate (relay — the EPIC's format). | §1/§2 · [setupdlg](xrc-windows.md) | -| R-17 | MUST | Rider-pooled rides remain editable while running: riders move between teams with their plate, crossings and cards; every move audit-logged. Relay rides keep the start lock. | §3/§7 · [entrydetail](xrc-windows.md) | +| R-17 | MUST | Rider-pooled rides stay editable after the start, but only from a **stopped** ride or **REOPENED**: riders move between teams, from a team to solo, or from solo onto a team. A live-RUNNING move is refused — "Stop the ride first" (Stop, R-35, is the entry lock that keeps a new crossing from interleaving). The rider's plate, crossings and cards are re-credited to the destination, the source entry recalculates without them, the rider's voided crossings are reset, and the review surface recomputes; every move is audit-logged, and a source entry the move empties is kept as a **retired entry** so its key and the rider's re-attributed history survive a reload. Relay rides keep the start lock. | §3/§7 · [ridereditor](xrc-windows.md) | | R-18 | MUST | Ride library offers Delete: type the ride's name to confirm, automatic backup written first, never available on a RUNNING ride. | §3 · [librarydlg](xrc-windows.md)/[deletedlg](xrc-windows.md) | -| R-15 | MUST | Rides are duplicable (setup + roster, no timing data). Entries and teams are freely editable and deletable only until the start; after start, DNF/void only (a DNF mark is per rider: a pooled team member's own number drops that rider alone, their cards forfeit from the team hand, and a team drops only when every rider is out) — nothing with recorded data is ever deleted. The Rider Editor's Delete removes the selected *rider* (a team member leaves the team, a solo rider's entry is deleted) — never the whole team. | §3 · [ridereditor](xrc-windows.md)/[librarydlg](xrc-windows.md) | +| R-15 | MUST | Rides are duplicable (setup + roster, no timing data). Entries and teams are freely editable and deletable only until the start; after start, DNF/void only (a DNF mark is per rider: a pooled team member's own number drops that rider alone, their cards forfeit from the team hand, and a team drops only when every rider is out) — nothing with recorded data is ever deleted (a pooled move that empties a data-bearing source entry keeps it as a retired entry so its key survives a reload). The Rider Editor's Delete removes the selected *rider* (a team member leaves the team, a solo rider's entry is deleted) — never the whole team. | §3 · [ridereditor](xrc-windows.md)/[librarydlg](xrc-windows.md) | ### 3 · Riders, teams & CSV diff --git a/design/docs-md/spec.md b/design/docs-md/spec.md index 5d36ace5..2b1224f6 100644 --- a/design/docs-md/spec.md +++ b/design/docs-md/spec.md @@ -46,13 +46,13 @@ REOPENED - **Resume on open:** on launch, ride status = running always opens the resume dialog. Session bookkeeping picks the copy: closed_at present → "You quit at 12:41 — the ride kept running"; closed_at NULL → crash, "closed unexpectedly at 12:41" (last heartbeat). Continue picks up mid-ride; every crossing was committed when it happened. **Launch order (W3):** the normal path presents no modal before `frame.Show()` — `build_main_window` shows nothing, and `main()` shows the frame first, then runs the post-Show `_run_launch_flow` over the visible console: `resume_dlg` when the session warrants it, and nothing at all otherwise (the console stands; the menus are the create/open surface). A resume replay that fails shows an error naming the ride and event, clears the resume marker and stays on the empty console. (A bootstrap raise is the one pre-Show box: a parentless error, re-raised into the crash excepthook.) -- **DRAFT is the roster window:** entries and teams stay fully editable — including delete — via UI or repeated CSV imports, until start. Start locks plates, entry types and membership; after start only new plates and name fixes. Entries with recorded data are never deleted, only DNF'd or voided. **Rider-pooled rides stay editable while RUNNING:** riders may move between teams mid-event (mis-entries, team switches) — the rider's plate, crossings and cards travel with them, every move audit-logged. Relay rides keep the start lock (the plate *is* the team's identity). +- **DRAFT is the roster window:** entries and teams stay fully editable — including delete — via UI or repeated CSV imports, until start. Start locks plates, entry types and membership; after start only new plates and name fixes. Entries with recorded data are never deleted, only DNF'd or voided. **Rider-pooled rides stay editable after the start, from Stop mode or REOPENED:** a rider may move between teams, from a team to solo, or from solo onto a team (mis-entries, team switches) while the ride is stopped or reopened, and a live-RUNNING move is refused ("Stop the ride first") because Stop (R-35) is the entry lock. The rider's plate, crossings and cards are re-credited to the destination, the source entry recalculates without them, the rider's voided laps are reset, and the review list (short laps, duplicates, team overlap, held cards) recomputes for the affected entries; every move is audit-logged. Relay rides keep the start lock (the plate *is* the team's identity). - **Set start time…** retro-fixes `actual_start` and recomputes lap-1 times; logged to audit. - **REOPENED is corrections-only** (a distinct status, not RUNNING): the clock stays closed at the recorded finish (C3) and live plate entry stays off; the operator adds a missed crossing *at an explicit time*, edits/voids crossings and cards, or moves riders (pooled). **Start continues riding out of this state** (C2) — `RideEngine.start()` accepts REOPENED, keeps `actual_start` and returns the ride to RUNNING with the clock live again. Standings recompute on every change and on tie-break reordering; *Finish again* re-locks to FINISHED. Published exports older than the latest correction are flagged stale. -- **Audit trail viewer** (Ride ▸ Audit Trail…): read-only, newest-first table of the audit log — when · action · entry · reason — every column sortable, filterable by entry and action; the row opens unfiltered (the retired Entry Detail window's pre-filtered deep-link went with it). In pooled mode, "Move to team…" on a rider row opens a team picker and lands here too. +- **Audit trail viewer** (Ride ▸ Audit Trail…): read-only, newest-first table of the audit log — when · action · entry · reason — every column sortable, filterable by entry and action; the row opens unfiltered (the retired Entry Detail window's pre-filtered deep-link went with it). A pooled move is made in the Rider Editor's Edit dialog (its Team dropdown), never from this read-only viewer. - **Delete ride** (library only): type the ride's name to confirm; an automatic database backup is written first; a RUNNING ride is never deletable. @@ -190,7 +190,7 @@ One **unified, header-mapped format** for every ride and plate model — Phase 2 - **Plate assignment:** blank PLATE cells auto-assign sequential numeric plates from `Roster.next_free_plate`, skipping every plate already used on the roster or named anywhere else in the file (R-20: one plate namespace per ride). Under `rider_pooled` every row keeps its own plate (a team entry adopts its lowest-numbered member's); under `team_relay` a team's member rows share the team's single plate — the rows must name one plate between them, or none (rows naming two plates are a shape conflict and contribute no entry). A solo row always gets its own plate. -- **Import semantics:** match on plate — an existing entry updates in place (a solo match renames the rider's first/last, the same rename the rider editor performs); a new plate inserts. In DRAFT a re-import applies every reshape the editor can make — convert solo ⇄ team, move riders between teams (a changed team_name is an audited membership move, not a conflict), add brand-new riders onto existing teams; while RUNNING, rider-pooled keeps team-to-team moves and brand-new plates joining a team (both audit-logged) and conversions wait for DRAFT, while team_relay keeps its permanent start lock — a locked reshape becomes a conflict at preview, never a partial or unaudited mutation. An entry present in the roster but absent from the file is left alone — import never deletes on that basis (only DNF/void or the rider editor remove an entry). +- **Import semantics:** match on plate — an existing entry updates in place (a solo match renames the rider's first/last, the same rename the rider editor performs); a new plate inserts. In DRAFT a re-import applies every reshape the editor can make — convert solo ⇄ team, move riders between teams (a changed team_name is an audited membership move, not a conflict), add brand-new riders onto existing teams; after the start a re-import applies no pooled reshape at all — every move and conversion waits for DRAFT, and the Rider Editor is the one live (Stop/Reopen-gated, R-17) move surface, where the rider's crossings and cards re-attribute and voided laps reset; team_relay keeps its permanent start lock, and a blocked reshape becomes a conflict at preview, never a partial or unaudited mutation. An entry present in the roster but absent from the file is left alone — import never deletes on that basis (only DNF/void or the rider editor remove an entry). - **Export** writes the same unified header, one row per rider — under `rider_pooled` each row carries its rider's own plate, under `team_relay` every member row of a team carries the team's single plate; a finished ride appends `laps, cards, best_hand, total_time` after the roster columns. Both writers stage the CSV in a same-directory temp file and swap it over the destination with `os.replace` — never truncating in place (R-52). diff --git a/src/rivercrossing/ui/views/corrections.py b/src/rivercrossing/ui/views/corrections.py index 37f8e963..3fb9956f 100644 --- a/src/rivercrossing/ui/views/corrections.py +++ b/src/rivercrossing/ui/views/corrections.py @@ -43,8 +43,7 @@ an unknown-but-numeric plate is left to the engine's own ``UnknownPlateError`` refusal. -The move-rider "team picker" has no XRC dialog (spec §15b authors -none); :func:`run_move_rider` builds a small native picker in code. +The pooled rider move has no dialog here; the Rider Editor owns it. The Cards/Riders menu row opens ``audit_dlg`` through :func:`run_audit`, which E7.3.1 made real: it binds the @@ -75,12 +74,10 @@ "CrossingEdit", "DnfMark", "ManualDeal", - "RiderMove", "run_audit", "run_dnf", "run_edit_crossing", "run_manual_deal", - "run_move_rider", "run_set_start_time", "run_void_card", ] @@ -146,20 +143,6 @@ class DnfMark: reason: str -@dataclass(frozen=True, slots=True) -class RiderMove: - """One confirmed move-rider picker submission (E7.2.1). - - ``rider_plate`` names the rider being moved (a ``Rider.plate`` on - a rider_pooled team); ``to_team`` names the destination entry by - ``display_name``. The caller resolves both through the roster - before calling :meth:`Roster.move_rider`. - """ - - rider_plate: str - to_team: str - - # The address-reuse poison (views/_support.find_control's docstring) # wraps an XRC control in the wrong Python type (a generic # ``wx.Control`` whose ``SetValue`` does not exist); the settle-retry @@ -668,36 +651,6 @@ def run_set_start_time( dialog.Destroy() -def run_move_rider( - frame: Any, # noqa: ANN401 -- wx ships no stubs - *, - riders: tuple[str, ...], - teams: tuple[str, ...], -) -> RiderMove | None: - """Open the code-built team picker; return the confirmed move. - - Two native single-choice dialogs (which rider, then which - destination team); a cancel at either step is a silent no-op. - """ - if not riders or not teams: - return None - rider_choice = wx.SingleChoiceDialog(frame, "Move which rider?", "Move Rider", list(riders)) - try: - if rider_choice.ShowModal() != wx.ID_OK: - return None - rider_plate = rider_choice.GetStringSelection() - finally: - rider_choice.Destroy() - team_choice = wx.SingleChoiceDialog(frame, "Move to which team?", "Move Rider", list(teams)) - try: - if team_choice.ShowModal() != wx.ID_OK: - return None - to_team = team_choice.GetStringSelection() - finally: - team_choice.Destroy() - return RiderMove(rider_plate=rider_plate, to_team=to_team) - - def run_audit( # noqa: PLR0913 -- (resource, frame) + the viewer's data seams resource: Any, # noqa: ANN401 -- wx ships no stubs *, From 5592dde72a157e21f06ae2ccacab0966930c79e9 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Sun, 20 Sep 2026 20:08:32 -0400 Subject: [PATCH 09/15] docs(ride): note the move seq-order and void_card-boundary risks Record in the engine docstring that a moved crossing's seq is record/void order, not time order, and that void_card's entry_id is a plate live and a key on replay. State only; no behaviour change. --- src/rivercrossing/ride.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/rivercrossing/ride.py b/src/rivercrossing/ride.py index ff8ceb3a..d2417c4b 100644 --- a/src/rivercrossing/ride.py +++ b/src/rivercrossing/ride.py @@ -1169,6 +1169,20 @@ class RideEngine: a replayed ``mark_has_data`` on the retired entry is a known entry, not a foreign one. An entry dissolved with no recorded data is still discarded outright: nothing names its key. + - **A moved crossing's ``seq`` is record order, not time order.** + ``_reattribute_rider`` gives each moved crossing the destination's + next ``seq`` in record/void order, while ``_laps`` stays sorted by + ``crossed_at``; so after a move a destination's ``seq N`` is not + its Nth chronological lap. Laps, times and standings read the + time-sorted index and are unaffected, but corrections address a + crossing by ``(entry_id, seq)``, so a UI that pairs time-sorted + rows with seq labels can mis-target. + - **``void_card``'s ``entry_id`` is a plate live and a key on + replay.** The live command resolves its argument through + ``_require_entry`` (plate -> entry), while the persisted payload + and the replay branch resolve by key; the resulting state is the + same, but a caller must not assume the parameter is one or the + other across the boundary. - **A voided lap of the moved rider is re-interpreted, not restored.** The pre-void held/credited disposition is not stored, so the restored lap's short-lap disposition is From 61b1f26bc10235dea5a42d0ba75e0b10cb2fd6f4 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Mon, 21 Sep 2026 20:00:07 -0400 Subject: [PATCH 10/15] test(store): v2->v3 audit identity rewrite (red) --- tests/unit/test_store.py | 452 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 443 insertions(+), 9 deletions(-) diff --git a/tests/unit/test_store.py b/tests/unit/test_store.py index a211923b..9442621b 100644 --- a/tests/unit/test_store.py +++ b/tests/unit/test_store.py @@ -205,7 +205,7 @@ def test_store_open_stamps_the_ledger_at_the_current_schema_version(tmp_path: Pa version = conn.execute("SELECT version FROM schema_version WHERE id = 1").fetchone()[0] rows = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone()[0] - assert SCHEMA_VERSION == 2 + assert SCHEMA_VERSION == 3 assert (version, rows) == (SCHEMA_VERSION, 1) @@ -286,7 +286,7 @@ def test_store_open_given_an_empty_file_creates_the_full_current_schema(tmp_path assert "hold_short_laps" in columns -@pytest.mark.parametrize("stored_version", [3, 99, 999]) +@pytest.mark.parametrize("stored_version", [4, 99, 999]) def test_store_open_given_a_newer_version_raises_naming_it( tmp_path: Path, stored_version: int ) -> None: @@ -587,7 +587,10 @@ def test_store_schema_version_mismatch_error_is_a_store_error() -> None: # The child rows whose ``entry_id`` links the entry rebuild must keep # resolving: 4 riders, 3 crossings, 3 cards, one open app_session and -# one audit row. +# three audit rows -- the display-only ``start_ride`` a v1 build wrote, +# plus the ``start``/``record_crossing`` pair the replay seam needs, the +# crossing still naming Alice's entry by her plate ("12") the way v1 +# wrote it (the v2 -> v3 step's own seed). _V1_CHILD_ROWS: tuple[str, ...] = ( """ INSERT INTO rider (id, entry_id, first_name, last_name, plate, sort_order, @@ -647,6 +650,16 @@ def test_store_schema_version_mismatch_error_is_a_store_error() -> None: INSERT INTO audit (id, ride_id, at, action, payload_json) VALUES (1, 1, 1789898400, 'start_ride', '{"source": "setup"}') """, + """ + INSERT INTO audit (id, ride_id, at, action, payload_json) + VALUES (2, 1, 1789898400, 'start', '{"actual_start": "2026-09-20T10:00:00"}') + """, + """ + INSERT INTO audit (id, ride_id, at, action, payload_json) + VALUES (3, 1, 1789898520, 'record_crossing', + '{"plate": "12", "entry_id": "12", "lap": 1, + "crossed_at": "2026-09-20T10:02:00", "reason": "Alice"}') + """, ) _V1_SEED: tuple[str, ...] = (_V1_RIDE_ROW, *_V1_ENTRY_ROWS, *_V1_CHILD_ROWS) @@ -691,14 +704,20 @@ def _write_v1_file(db_path: Path, seed: tuple[str, ...] = ()) -> None: conn.close() -def _read(db_path: Path, sql: str) -> list[tuple[object, ...]]: +def _read(db_path: Path, sql: str, params: tuple[object, ...] = ()) -> list[tuple[object, ...]]: """Run one read-only query against *db_path* (assertion aid). Reads through a second, independent connection: the point is what landed on disk, not what the facade keeps in memory. """ with closing(sqlite3.connect(str(db_path))) as conn: - return [tuple(row) for row in conn.execute(sql)] + return [tuple(row) for row in conn.execute(sql, params)] + + +def _audit_payload(db_path: Path, audit_id: int) -> dict[str, object]: + """Return one stored audit payload, decoded (assertion aid).""" + (raw,) = _read(db_path, "SELECT payload_json FROM audit WHERE id = ?", (audit_id,))[0] + return dict(json.loads(str(raw))) def test_store_open_migrates_a_v1_file_to_the_current_schema_version(tmp_path: Path) -> None: @@ -708,7 +727,7 @@ def test_store_open_migrates_a_v1_file_to_the_current_schema_version(tmp_path: P Store.open(db_path).close() - assert _read(db_path, "SELECT version FROM schema_version WHERE id = 1") == [(2,)] + assert _read(db_path, "SELECT version FROM schema_version WHERE id = 1") == [(3,)] def test_store_open_v1_migration_rebuilds_entry_in_the_v2_shape(tmp_path: Path) -> None: @@ -908,7 +927,7 @@ def test_store_open_v1_migration_given_no_entry_rows_migrates_cleanly(tmp_path: assert ( _read(db_path, "SELECT COUNT(*) FROM entry"), _read(db_path, "SELECT version FROM schema_version WHERE id = 1"), - ) == ([(0,)], [(2,)]) + ) == ([(0,)], [(3,)]) def test_store_open_v1_migration_given_one_entry_row_copies_it(tmp_path: Path) -> None: @@ -965,7 +984,7 @@ def test_run_migrations_stamps_the_ledger_at_the_target_version(tmp_path: Path) assert ( _read(db_path, "SELECT version FROM schema_version WHERE id = 1"), _read(db_path, "SELECT COUNT(*) FROM entry WHERE key <> ''"), - ) == ([(2,)], [(3,)]) + ) == ([(3,)], [(3,)]) def test_run_migrations_from_the_target_version_changes_nothing(tmp_path: Path) -> None: @@ -983,7 +1002,7 @@ def test_run_migrations_from_the_target_version_changes_nothing(tmp_path: Path) assert ( _read(db_path, "SELECT version FROM schema_version WHERE id = 1"), _read(db_path, "SELECT id, key FROM entry"), - ) == ([(2,)], before) + ) == ([(3,)], before) def test_run_migrations_given_a_version_below_the_first_migration_raises_key_error( @@ -1013,6 +1032,421 @@ def test_migrations_cover_every_version_below_the_current_one() -> None: assert sorted(MIGRATIONS) == list(range(1, SCHEMA_VERSION)) +# ------------------------------------------------ v2 -> v3 migration +# Product-owner policy (spec §2) again. v2 rebuilt ``entry`` around +# the stable ``key`` but left the ``audit`` payloads alone, so a file +# the pooled-live-move branch wrote still names its entry by plate +# where the replay seam resolves a key -- the "unknown entry key: 93" +# a pre-branch ride refuses to open with. v3 changes no DDL: it +# rewrites those payloads in place. These tests build a v2 file from +# the CURRENT ``SCHEMA_STATEMENTS`` (v3 adds nothing to them) stamped +# 2, and drive it through ``Store.open``, which runs that one step. + +# The stable keys a v2 file's entries carry -- the identity a legacy +# payload has to be rewritten to. The third belongs to a second ride's +# own entry at plate "12": plate numbers are unique per ride. +_V2_ALICE_KEY = "0f1e2d3c4b5a49788796a5b4c3d2e1f0" +_V2_DYNAMOS_KEY = "1a2b3c4d5e6f408192a3b4c5d6e7f809" +_V2_RIDE2_KEY = "2b3c4d5e6f704192a3b4c5d6e7f8091a" + +# ``entry`` rows as (id, ride_id, plate, key, display_name, type, +# team_size, retired). +_V2_ALICE_ENTRY: tuple[object, ...] = (1, 1, "12", _V2_ALICE_KEY, "Alice", "solo", 1, 0) +_V2_DYNAMOS_ENTRY: tuple[object, ...] = (2, 1, "45", _V2_DYNAMOS_KEY, "Dirt Dynamos", "team", 2, 0) +_V2_RETIRED_ALICE_ENTRY: tuple[object, ...] = (1, 1, "12", _V2_ALICE_KEY, "Alice", "solo", 1, 1) +_V2_RIDE2_ALICE_ENTRY: tuple[object, ...] = (3, 2, "12", _V2_RIDE2_KEY, "Alice", "solo", 1, 0) + +# ``audit`` rows as (id, ride_id, at, action, payload_json). The start +# row is what the replay seam needs to reach RUNNING before a crossing, +# and the crossing is the legacy shape under test: the plated identity +# a pre-v2 build wrote beside the plate the operator typed. The last +# row is the same shape on a second ride, whose entry "12" is another +# ride's team entirely. +_V2_START_EVENT: tuple[object, ...] = ( + 1, + 1, + 1789898400, + "start", + '{"actual_start": "2026-09-20T10:00:00"}', +) +_V2_LEGACY_CROSSING_EVENT: tuple[object, ...] = ( + 2, + 1, + 1789898520, + "record_crossing", + ( + '{"plate": "12", "entry_id": "12", "lap": 1,' + ' "crossed_at": "2026-09-20T10:02:00", "reason": "Alice"}' + ), +) +_V2_SECOND_RIDE_CROSSING_EVENT: tuple[object, ...] = ( + 6, + 2, + 1789898520, + "record_crossing", + '{"plate": "12", "entry_id": "12", "lap": 1, "reason": "Alice"}', +) + +# A payload naming a plate no live entry holds, a payload this branch +# itself wrote (its identity is already a key), and a pooled move's own +# row naming both the source and the destination. +_V2_UNKNOWN_IDENTITY_EVENT: tuple[object, ...] = ( + 3, + 1, + 1789898580, + "dnf", + '{"entry_id": "99", "plate": "99", "rider": false, "reason": "no show"}', +) +_V2_ALREADY_KEYED_EVENT: tuple[object, ...] = ( + 4, + 1, + 1789898640, + "void_card", + json.dumps({"entry_id": _V2_ALICE_KEY, "card": "As", "reason": "duplicate"}), +) +# A hand-edited payload whose identity is a number, not text: left as +# stored, like every other value this step cannot resolve. +_V2_NON_STRING_IDENTITY_EVENT: tuple[object, ...] = ( + 5, + 1, + 1789898700, + "record_crossing", + '{"entry_id": 93, "plate": "93", "reason": "hand-edited"}', +) +_V2_REASSIGN_EVENT: tuple[object, ...] = ( + 2, + 1, + 1789898520, + "reassign", + '{"seq": 1, "old_entry_id": "12", "new_entry_id": "45", "new_plate": "45", "reason": "moved"}', +) + +# The two payload shapes the rewrite must decline to touch rather than +# abort the whole file on: text that is not JSON, and JSON that is not +# an object. ``Store.load_engine`` already reports both cleanly. +_V2_MALFORMED_EVENT: tuple[object, ...] = (4, 1, 1789898640, "record_crossing", "not json at all") +_V2_NON_OBJECT_EVENT: tuple[object, ...] = (5, 1, 1789898700, "record_crossing", "[]") + +# A second ride in the same file, copied from the first: plate numbers +# are unique per ride, so the same number names a different entry here. +_V2_SECOND_RIDE_ROW = """ + INSERT INTO ride + SELECT 2, 'GORBA EPIC 2026 (2)', event_date, venue, course_name, lap_km, + organizer, scorer, logo_png, planned_start, planned_duration_s, + actual_start, finished_at, status, entry_mode, max_team_size, + plate_model, min_lap_s, deck_count, jokers_per_deck, jokers_mode, + max_cards, tiebreak_order, rng_seed, created_at, updated_at, + hold_short_laps + FROM ride WHERE id = 1 + """ + +_V2_ENTRY_INSERT_SQL = ( + "INSERT INTO entry (id, ride_id, plate, key, display_name, type, team_size," + " status, dnf_at, notes, logo_card, retired)" + " VALUES (?, ?, ?, ?, ?, ?, ?, 'active', NULL, '', NULL, ?)" +) + +_V2_AUDIT_INSERT_SQL = ( + "INSERT INTO audit (id, ride_id, at, action, payload_json) VALUES (?, ?, ?, ?, ?)" +) + + +def _write_v2_file( # noqa: PLR0913 -- a file's own parts: entries, audit, extra rides + db_path: Path, + *, + entries: tuple[tuple[object, ...], ...], + audit: tuple[tuple[object, ...], ...], + extra_rides: tuple[str, ...] = (), +) -> None: + """Write a v2 database file (arrange). + + The CURRENT ``SCHEMA_STATEMENTS`` -- v3 changes no DDL, so they are + the v2 shape exactly -- stamped 2, then the caller's ``entry`` and + ``audit`` rows: the file this branch left on disk before the audit + rewrite, whose payloads still name entries by plate. ``ride``'s DDL + is unchanged since v1, so the released v1 ride row seeds ride 1, + with *extra_rides* adding any others. + """ + conn = sqlite3.connect(str(db_path)) + try: + for statement in (*SCHEMA_STATEMENTS, SCHEMA_VERSION_DDL): + conn.execute(statement) + conn.execute("INSERT INTO schema_version (id, version) VALUES (1, 2)") + for statement in (_V1_RIDE_ROW, *extra_rides): + conn.execute(statement) + for entry in entries: + conn.execute(_V2_ENTRY_INSERT_SQL, entry) + for audit_row in audit: + conn.execute(_V2_AUDIT_INSERT_SQL, audit_row) + conn.commit() + finally: + conn.close() + + +def test_store_open_v1_migration_rewrites_a_legacy_audit_plate_to_its_key( + tmp_path: Path, +) -> None: + """A v1 file's plated audit identities come back as keys. + + The user-visible symptom is the ride that would not reopen: + ``load_engine`` resolved the payload's legacy plate as a key and + failed on "unknown entry key: 12". + """ + db_path = tmp_path / "v1.db" + _write_v1_file(db_path, seed=_V1_SEED) + + store = Store.open(db_path) + try: + engine = store.load_engine(1) + finally: + store.close() + + (alice_key,) = _read(db_path, "SELECT key FROM entry WHERE ride_id = 1 AND plate = '12'")[0] + + assert (_audit_payload(db_path, 3)["entry_id"], engine.state) == ( + alice_key, + RideStatus.RUNNING, + ) + + +def test_store_open_v2_file_rewrites_a_legacy_audit_plate_to_its_key(tmp_path: Path) -> None: + """The pre-branch user's own state: a file stamped 2 runs v2 -> v3. + + No chain step can precede it on such a file, and the ride has to + come back replayable -- which is the whole point of the step. + """ + db_path = tmp_path / "v2.db" + _write_v2_file( + db_path, + entries=(_V2_ALICE_ENTRY,), + audit=(_V2_START_EVENT, _V2_LEGACY_CROSSING_EVENT), + ) + + store = Store.open(db_path) + try: + engine = store.load_engine(1) + finally: + store.close() + + assert ( + _audit_payload(db_path, 2)["entry_id"], + _read(db_path, "SELECT version FROM schema_version WHERE id = 1"), + engine.state, + ) == (_V2_ALICE_KEY, [(3,)], RideStatus.RUNNING) + + +def test_store_open_v2_to_v3_rewrites_only_a_plated_entry_identity(tmp_path: Path) -> None: + """Only the identity fields change, and only from live plates. + + The operator's typed ``plate`` and ``reason`` survive verbatim, and + a row needing no rewrite is not written back at all -- a value + already holding a key, naming nothing in this ride, or not text at + all is left exactly as it was stored. + """ + db_path = tmp_path / "v2.db" + _write_v2_file( + db_path, + entries=(_V2_ALICE_ENTRY,), + audit=( + _V2_START_EVENT, + _V2_LEGACY_CROSSING_EVENT, + _V2_UNKNOWN_IDENTITY_EVENT, + _V2_ALREADY_KEYED_EVENT, + _V2_NON_STRING_IDENTITY_EVENT, + ), + ) + untouched = _read(db_path, "SELECT id, payload_json FROM audit WHERE id > 2 ORDER BY id") + + Store.open(db_path).close() + + payload = _audit_payload(db_path, 2) + + assert ( + payload["entry_id"], + payload["plate"], + payload["reason"], + _read(db_path, "SELECT id, payload_json FROM audit WHERE id > 2 ORDER BY id"), + ) == (_V2_ALICE_KEY, "12", "Alice", untouched) + + +def test_store_open_v2_to_v3_given_a_second_open_leaves_the_audit_untouched( + tmp_path: Path, +) -> None: + """Re-opening rewrites nothing: the rewritten rows hold keys now.""" + db_path = tmp_path / "v2.db" + _write_v2_file( + db_path, + entries=(_V2_ALICE_ENTRY,), + audit=(_V2_START_EVENT, _V2_LEGACY_CROSSING_EVENT), + ) + Store.open(db_path).close() + rewritten = _read(db_path, "SELECT id, payload_json FROM audit ORDER BY id") + + Store.open(db_path).close() + + assert ( + _audit_payload(db_path, 2)["entry_id"], + _read(db_path, "SELECT id, payload_json FROM audit ORDER BY id"), + ) == (_V2_ALICE_KEY, rewritten) + + +def test_store_open_v2_to_v3_rewrites_both_identities_in_a_reassign_payload( + tmp_path: Path, +) -> None: + """A pooled move's old and new entry ids are rewritten together. + + ``new_plate`` is the operator's typed value, not an identity, so it + is left alone. + """ + db_path = tmp_path / "v2.db" + _write_v2_file( + db_path, + entries=(_V2_ALICE_ENTRY, _V2_DYNAMOS_ENTRY), + audit=(_V2_START_EVENT, _V2_REASSIGN_EVENT), + ) + + Store.open(db_path).close() + + payload = _audit_payload(db_path, 2) + + assert ( + payload["old_entry_id"], + payload["new_entry_id"], + payload["new_plate"], + ) == (_V2_ALICE_KEY, _V2_DYNAMOS_KEY, "45") + + +def test_store_open_v2_to_v3_resolves_each_rides_plates_separately(tmp_path: Path) -> None: + """Plates are unique per ride, so a rewrite is scoped by ride. + + Both rides number an entry "12", and each legacy payload has to + come back as its own ride's key -- never the other ride's. + """ + db_path = tmp_path / "v2.db" + _write_v2_file( + db_path, + entries=(_V2_ALICE_ENTRY, _V2_RIDE2_ALICE_ENTRY), + audit=(_V2_LEGACY_CROSSING_EVENT, _V2_SECOND_RIDE_CROSSING_EVENT), + extra_rides=(_V2_SECOND_RIDE_ROW,), + ) + + Store.open(db_path).close() + + assert ( + _audit_payload(db_path, 2)["entry_id"], + _audit_payload(db_path, 6)["entry_id"], + ) == (_V2_ALICE_KEY, _V2_RIDE2_KEY) + + +def test_store_open_v2_to_v3_skips_a_malformed_payload_without_aborting(tmp_path: Path) -> None: + """One unusable payload neither strands the file nor is corrupted. + + Both shapes are skipped and left exactly as they were written, so + the clean ``StoreError`` replay reports them with still fires -- + never a migration aborted halfway through the table. + """ + db_path = tmp_path / "v2.db" + _write_v2_file( + db_path, + entries=(_V2_ALICE_ENTRY,), + audit=( + _V2_START_EVENT, + _V2_MALFORMED_EVENT, + _V2_LEGACY_CROSSING_EVENT, + _V2_NON_OBJECT_EVENT, + ), + ) + + store = Store.open(db_path) + try: + with pytest.raises(StoreError, match=re.escape("cannot replay audit row 4 for ride 1")): + store.load_engine(1) + finally: + store.close() + + assert ( + _read(db_path, "SELECT id, payload_json FROM audit WHERE id > 3 ORDER BY id"), + _audit_payload(db_path, 2)["entry_id"], + ) == ([(4, "not json at all"), (5, "[]")], _V2_ALICE_KEY) + + +def test_store_open_v2_to_v3_leaves_the_entry_rows_untouched(tmp_path: Path) -> None: + """The step rewrites audit payloads alone: no entry row changes. + + v3 adds no DDL, so a v3 file's ``entry`` rows are the ones it + opened with, and the ledger is stamped 3 once the chain finished. + """ + db_path = tmp_path / "v2.db" + _write_v2_file( + db_path, + entries=(_V2_ALICE_ENTRY, _V2_DYNAMOS_ENTRY), + audit=(_V2_START_EVENT, _V2_LEGACY_CROSSING_EVENT), + ) + + Store.open(db_path).close() + + assert ( + _read(db_path, "SELECT id, plate, key, retired FROM entry ORDER BY id"), + _read(db_path, "SELECT version FROM schema_version WHERE id = 1"), + ) == ( + [(1, "12", _V2_ALICE_KEY, 0), (2, "45", _V2_DYNAMOS_KEY, 0)], + [(3,)], + ) + + +def test_store_open_v2_to_v3_leaves_a_retired_entrys_plate_unresolved(tmp_path: Path) -> None: + """A retired row is no identity source: only live plates map. + + The plate a retired row kept may be the one the destination team + has since adopted, so a legacy value only a retired row could + explain is left exactly as stored rather than guessed at -- while + the same payload's live identity is rewritten as usual. + """ + db_path = tmp_path / "v2.db" + _write_v2_file( + db_path, + entries=(_V2_RETIRED_ALICE_ENTRY, _V2_DYNAMOS_ENTRY), + audit=(_V2_REASSIGN_EVENT,), + ) + + Store.open(db_path).close() + + payload = _audit_payload(db_path, 2) + + assert (payload["old_entry_id"], payload["new_entry_id"]) == ("12", _V2_DYNAMOS_KEY) + + +def test_store_open_v2_to_v3_given_a_failed_rewrite_rolls_the_file_back( + tmp_path: Path, +) -> None: + """A rewrite the step cannot land leaves the v2 file as it was. + + The trigger stands in for any write error (a full disk, a locked + file): the step's own BEGIN/rollback must leave the ledger and the + payloads exactly as the file's owner saved them. + """ + db_path = tmp_path / "v2.db" + _write_v2_file( + db_path, + entries=(_V2_ALICE_ENTRY,), + audit=(_V2_LEGACY_CROSSING_EVENT,), + ) + with closing(sqlite3.connect(str(db_path))) as conn: + conn.execute( + "CREATE TRIGGER audit_payload_frozen BEFORE UPDATE ON audit" + " BEGIN SELECT RAISE(ABORT, 'audit payload frozen'); END" + ) + conn.commit() + + with pytest.raises(sqlite3.IntegrityError, match=re.escape("audit payload frozen")): + Store.open(db_path) + + assert ( + _read(db_path, "SELECT version FROM schema_version WHERE id = 1"), + _audit_payload(db_path, 2)["entry_id"], + ) == ([(2,)], "12") + + # --------------------------------------------------------- create_ride From ad635d087c3ee98a77693cc90858bb304af3866f Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Mon, 21 Sep 2026 20:00:07 -0400 Subject: [PATCH 11/15] feat(store): schema v3 rewrites legacy audit entry ids to keys --- src/rivercrossing/store/migrations.py | 114 +++++++++++++++++++++++++- src/rivercrossing/store/schema.py | 14 ++-- 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/src/rivercrossing/store/migrations.py b/src/rivercrossing/store/migrations.py index 979fe7d2..d60d8907 100644 --- a/src/rivercrossing/store/migrations.py +++ b/src/rivercrossing/store/migrations.py @@ -3,8 +3,9 @@ One step per schema version: :data:`MIGRATIONS` maps the version a file is stamped with to the function that upgrades it to the next one, so -``MIGRATIONS[1]`` takes a v1 file to v2. :func:`run_migrations` walks -that chain in order and stamps the ledger; +``MIGRATIONS[1]`` takes a v1 file to v2 and ``MIGRATIONS[2]`` takes a +v2 file to v3. :func:`run_migrations` walks that chain in order and +stamps the ledger; :func:`~rivercrossing.store.schema.ensure_schema` calls it whenever it opens a file older than the build, because the product-owner policy is that every schema change ships the step that upgrades older files @@ -18,6 +19,7 @@ ``tests/unit/test_store.py`` is what keeps the two ends honest. """ +import json import sqlite3 import uuid from typing import TYPE_CHECKING @@ -156,8 +158,114 @@ def _migrate_v1_to_v2(conn: sqlite3.Connection) -> None: conn.execute("PRAGMA foreign_keys=ON") +# The v2 ``entry`` columns the rewrite resolves against, the ``audit`` +# rows it rewrites, and the payload fields that name an entry -- spelled +# as v1's own events spelled them: a crossing's subject, and a pooled +# move's two sides. +_V2_ENTRY_IDENTITY_SQL = "SELECT ride_id, plate, key, retired FROM entry" +_V2_AUDIT_IDENTITY_SQL = "SELECT id, ride_id, payload_json FROM audit" +_V2_AUDIT_IDENTITY_UPDATE_SQL = "UPDATE audit SET payload_json = ? WHERE id = ?" +_ENTRY_IDENTITY_FIELDS: tuple[str, ...] = ("entry_id", "old_entry_id", "new_entry_id") + + +def _audit_payload_object(payload_json: object) -> dict[str, object] | None: + """Return one stored payload as a mapping, or None when unusable. + + A payload an older or hand-edited file holds in any other shape -- + undecodable text, or JSON that is not an object -- is left exactly + as it is: ``Store.load_engine`` already reports it as the clean + :class:`~rivercrossing.store.StoreError` naming the row, and one + bad row must not strand the file. The parameter is typed loosely + because the column is only declared TEXT, so ``json.loads`` may + raise TypeError for a value that is not text -- declined here the + same way undecodable text is. + """ + try: + payload = json.loads(payload_json) # type: ignore[arg-type] + except json.JSONDecodeError, TypeError: + return None + return payload if isinstance(payload, dict) else None + + +def _rewrite_entry_identities( # noqa: PLR0913, PLR0917 -- the payload + its 3 lookups + payload: dict[str, object], + ride_id: int, + plate_to_key: dict[tuple[int, str], str], + keys: set[str], +) -> bool: + """Rewrite *payload*'s plated entry identities in place. + + Returns: + Whether anything changed, so the caller writes back only the + rows this step actually repaired. + """ + changed = False + for field in _ENTRY_IDENTITY_FIELDS: + value = payload.get(field) + if not isinstance(value, str) or value in keys: + continue + key = plate_to_key.get((ride_id, value)) + if key is None: + continue + payload[field] = key + changed = True + return changed + + +def _migrate_v2_to_v3(conn: sqlite3.Connection) -> None: + """Rewrite legacy plated entry identities in ``audit`` as keys. + + A data-only step: this is the one migration here that changes no + DDL. v2 rebuilt ``entry`` around the stable ``key`` + (:func:`_migrate_v1_to_v2`) but left the ``audit`` payloads alone, + so every event a v1 build recorded still names its entry by the + plate the operator typed. The replay seam resolves an identity + strictly as a key, so such a ride refuses to reopen -- "unknown + entry key: 93", a plate -- until this step has run. + + The plate -> key map is built from the LIVE rows alone, because the + plate a retired row kept may be the one the destination team of a + pooled move has since adopted: a legacy value only a retired row + could explain is left as stored rather than guessed at. A v1 file + has no retired rows at all, so every plate a legacy payload names + is one of these live rows, and a value that is already a key is + skipped -- which makes the step a no-op the second time it runs. + + Args: + conn: An open connection to a file stamped version 2. + + Raises: + sqlite3.Error: A read or write failed. The transaction is + rolled back, so the caller is left with the untouched v2 + file. + """ + plate_to_key: dict[tuple[int, str], str] = {} + keys: set[str] = set() + for ride_id, plate, key, retired in conn.execute(_V2_ENTRY_IDENTITY_SQL): + keys.add(key) + if not retired: + plate_to_key[(ride_id, plate)] = key + rows = conn.execute(_V2_AUDIT_IDENTITY_SQL).fetchall() + + conn.execute("BEGIN") + try: + for row_id, row_ride_id, payload_json in rows: + payload = _audit_payload_object(payload_json) + if payload is None: + continue + if _rewrite_entry_identities(payload, row_ride_id, plate_to_key, keys): + conn.execute(_V2_AUDIT_IDENTITY_UPDATE_SQL, (json.dumps(payload), row_id)) + except sqlite3.Error: + conn.rollback() + raise + conn.commit() + + # The chain: source version -> the step that upgrades it to source + 1. -MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = {1: _migrate_v1_to_v2} +MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = { + 1: _migrate_v1_to_v2, + 2: _migrate_v2_to_v3, +} def run_migrations(conn: sqlite3.Connection, from_version: int, to_version: int) -> None: diff --git a/src/rivercrossing/store/schema.py b/src/rivercrossing/store/schema.py index d20eb8c4..79b2fe72 100644 --- a/src/rivercrossing/store/schema.py +++ b/src/rivercrossing/store/schema.py @@ -11,7 +11,7 @@ file (``rivercrossing.ui.presenters.settings``), not this database -- the schema stays untouched. -**Versioned and migrated in place.** :data:`SCHEMA_VERSION` is 2 and +**Versioned and migrated in place.** :data:`SCHEMA_VERSION` is 3 and the DDL below is the *latest* shape, not a frozen baseline. The product-owner policy (spec §2): every schema change bumps :data:`SCHEMA_VERSION` and ships the step that upgrades an older file, @@ -22,9 +22,13 @@ Version 1 was the released flattened baseline, and the changes under **Version 1** below were folded into its CREATE while the project was -unreleased. **Version 2** is this branch's pooled-live-move seam, and -the v1 -> v2 migration rebuilds ``entry`` for it, because SQLite cannot -drop a table-level ``UNIQUE``. +unreleased. **Version 2** is the pooled-live-move seam, and the +v1 -> v2 migration rebuilds ``entry`` for it, because SQLite cannot +drop a table-level ``UNIQUE``. **Version 3** changes no DDL at all: it +is a data-only step that rewrites the ``audit`` payloads a pre-v2 +build wrote -- the plated ``entry_id``/``old_entry_id``/``new_entry_id`` +the replay seam now resolves as keys -- which is why the DDL below is +still v2's shape. **Version 2** adds, both in the table the migration rebuilds: @@ -101,7 +105,7 @@ # an older one is upgraded in place by ``store.migrations``; a file # stamped with a newer one is refused by :func:`ensure_schema` rather # than read under a shape it was not written with. -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 class StoreError(RuntimeError): From 64542a03899cee9729d6e51f2ef9afb2600a6b52 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Mon, 21 Sep 2026 20:00:07 -0400 Subject: [PATCH 12/15] docs(design): record the schema v3 audit-identity migration --- design/docs-md/module-skeletons.md | 7 ++++--- design/docs-md/spec.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/design/docs-md/module-skeletons.md b/design/docs-md/module-skeletons.md index 8e8909cb..d2b42f43 100644 --- a/design/docs-md/module-skeletons.md +++ b/design/docs-md/module-skeletons.md @@ -49,7 +49,8 @@ rivercrossing/ │ │ ├── schema.py # latest DDL + PRAGMAs (WAL, foreign_keys); SCHEMA_VERSION │ │ │ # gate: create on empty, migrate older, refuse newer │ │ ├── migrations.py # MIGRATIONS: source version -> the step to the next -│ │ │ # (v1 -> v2 rebuilds entry); run_migrations(conn, from, to) +│ │ │ # (v1 -> v2 rebuilds entry; v2 -> v3 rewrites legacy +│ │ │ # audit entry_id -> key); run_migrations(conn, from, to) │ │ └── backup.py # open + hourly + manual, keep 20 (R-54) │ ├── csvio.py # §7 import/export, preview-then-commit │ ├── htmlexport.py # §8 Jinja2 renderer (self-contained page; + poster page) @@ -285,8 +286,8 @@ ensure_schema(conn) -> None # create on an empty file · run MIGRATIONS on an o migrations.py: MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] · run_migrations(conn, from_version, to_version) -> None (columns per Spec §2, incl. status enum with REOPENED, shoe seed, plate_model; SCHEMA_VERSION - is 2 and every schema change ships the step that upgrades an older file — no settings table: - E8.1.1 keeps settings in a JSON config file) + is 3 and every schema change ships the step that upgrades an older file — v3 is a data-only + audit-identity rewrite, no DDL — no settings table: E8.1.1 keeps settings in a JSON config file) ``` rivercrossing.csvio / htmlexport / pdfexport (§7/§8/§8b · R-21/61/62/63) diff --git a/design/docs-md/spec.md b/design/docs-md/spec.md index 2b1224f6..7c5047cc 100644 --- a/design/docs-md/spec.md +++ b/design/docs-md/spec.md @@ -23,7 +23,7 @@ PRAGMA journal_mode=WAL · synchronous=NORMAL · foreign_keys=ON. One transactio | app_session | id · opened_at · *closed_at* (written on clean exit — NULL means the previous session crashed) · *active_ride_id* · heartbeat_at (touched every 30 s while a ride runs) — how reopening knows a ride was running and whether the close was clean or a crash. | | audit | id · ride_id → · at · action · payload_json — every mutation: record, undo, void, reassign plate, deal, manual card, DNF, setting change. Undo = compensating write, never DELETE. | -The tables above are the **current schema — `SCHEMA_VERSION = 2`** — and the CREATEs in `src/rivercrossing/store/schema.py` are the latest shape, not a frozen baseline. Versioning policy (product owner, 2026-09-20): **every schema change increments `SCHEMA_VERSION` and ships migration code in `src/rivercrossing/store/migrations.py` that upgrades older database files in place.** `MIGRATIONS` maps a source version to the step that takes it to the next (`MIGRATIONS[1]` is v1 → v2); `ensure_schema` creates the current schema on an empty file, runs that chain on an older one, no-ops on a current one, and **refuses only a file newer than the build** — there is no downgrade path, so a file written by a later build is still politely rejected. A step is frozen history: it describes one jump between two versions once and is never edited to match a later DDL, the next version's step superseding it. v1 was the released flattened baseline (the Phase 1 `rider` name split, `ride.hold_short_laps`, `ride.jokers_mode`, the retired `entry.logo_png` image column and `rider.sex` were all folded into its CREATE while the project was unreleased). **v2** adds `entry.key` — the entry's stable identity, the surrogate the ride engine files crossings and credited hands under where a derived plate is mutable — and `entry.retired` (1 once a pooled move dissolves an entry that had recorded data), and moves the per-ride plate uniqueness off the table into the partial unique index over the live rows alone (`entry_plate_live_unique`, `WHERE retired = 0`); the v1 → v2 step rebuilds `entry` with the standard SQLite table-rebuild procedure, because SQLite cannot drop a table-level `UNIQUE`. `hold_short_laps` is a plain column of the CREATE above, `NOT NULL DEFAULT 1` = hold short-lap cards for review: the default flipped from 0 (always deal) to **1** in the 1.0.17 follow-up, so a fresh ride now holds a short lap's card until the operator confirms or voids it (the operator can still choose Always deal at setup). +The tables above are the **current schema — `SCHEMA_VERSION = 3`** — and the CREATEs in `src/rivercrossing/store/schema.py` are the latest shape, not a frozen baseline. Versioning policy (product owner, 2026-09-20): **every schema change increments `SCHEMA_VERSION` and ships migration code in `src/rivercrossing/store/migrations.py` that upgrades older database files in place.** `MIGRATIONS` maps a source version to the step that takes it to the next (`MIGRATIONS[1]` is v1 → v2, `MIGRATIONS[2]` is v2 → v3); `ensure_schema` creates the current schema on an empty file, runs that chain on an older one, no-ops on a current one, and **refuses only a file newer than the build** — there is no downgrade path, so a file written by a later build is still politely rejected. A step is frozen history: it describes one jump between two versions once and is never edited to match a later DDL, the next version's step superseding it. v1 was the released flattened baseline (the Phase 1 `rider` name split, `ride.hold_short_laps`, `ride.jokers_mode`, the retired `entry.logo_png` image column and `rider.sex` were all folded into its CREATE while the project was unreleased). **v2** adds `entry.key` — the entry's stable identity, the surrogate the ride engine files crossings and credited hands under where a derived plate is mutable — and `entry.retired` (1 once a pooled move dissolves an entry that had recorded data), and moves the per-ride plate uniqueness off the table into the partial unique index over the live rows alone (`entry_plate_live_unique`, `WHERE retired = 0`); the v1 → v2 step rebuilds `entry` with the standard SQLite table-rebuild procedure, because SQLite cannot drop a table-level `UNIQUE`. **v3** is a data-only step: it rewrites the legacy plate-valued `entry_id`/`old_entry_id`/`new_entry_id` in every `audit` payload to the entry's `key` (v2 minted the keys but left the audit trail on the old plate identity), so a ride recorded before v2 replays under its new keys — no DDL change. `hold_short_laps` is a plain column of the CREATE above, `NOT NULL DEFAULT 1` = hold short-lap cards for review: the default flipped from 0 (always deal) to **1** in the 1.0.17 follow-up, so a fresh ride now holds a short lap's card until the operator confirms or voids it (the operator can still choose Always deal at setup). ### 3 · Ride state machine From 0bfd604c4e129e17fbe137e96b02684d19c09d42 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Mon, 21 Sep 2026 21:31:13 -0400 Subject: [PATCH 13/15] test(pdfexport, htmlexport): podium entire-hand (red) --- tests/unit/test_htmlexport.py | 178 ++++++++++++++++++++++++++++++ tests/unit/test_pdfexport.py | 199 +++++++++++++++++++++++++++++++++- 2 files changed, 376 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_htmlexport.py b/tests/unit/test_htmlexport.py index 40de944c..066facad 100644 --- a/tests/unit/test_htmlexport.py +++ b/tests/unit/test_htmlexport.py @@ -671,6 +671,19 @@ def _drawn_placed(*, code: str = "AH", kind: str = "solo", place: int = 1) -> tu ) +def _whole_hand_line(count: int, *, tone: str) -> str: + """Return the podium card's whole-hand line markup. + + The full-field report's ``drawn_row`` prints the entry's entire + hand in draw order; the podium card now carries the same muted + ``text-xs`` line, toned per card (the dark first-place card takes + ``text-paper/70``, the plain ones ``text-ink/55``). The value is + the line's opening markup up to the chips span, the part the tests + pin. + """ + return f'
All {count} cards, in draw order: ' + + def test_render_public_carries_a_drawn_card_on_the_results_row() -> None: """R-14: the payload records the drawn card as a rank/suit pair.""" html = render(_StubRide(), _drawn_placed(), ExportOptions()) @@ -743,6 +756,104 @@ def test_render_public_given_no_draw_renders_no_badge_on_the_podium() -> None: assert "draw Best hands — top 3") +# -------------------- the podium card's whole hand (drawn_row's design) + + +def test_render_public_renders_the_whole_hand_line_in_the_podium_section() -> None: + """render(): the podium section carries the muted whole-hand line. + + Same content the full-field report's ``drawn_row`` prints -- the + entry's whole hand, in draw order, and nothing else about it. + """ + html = render(_StubRide(), _placed_pair(), ExportOptions()) + + assert "All 5 cards, in draw order:" in _section_after(html, ">Best hands — top 3") + + +def test_render_public_renders_the_podium_whole_hand_chips_in_draw_order() -> None: + """The chips are ``r.drawn`` itself, in draw order, never re-sorted. + + ``_placed_pair``'s leader holds 9S 9D 9C KH 2S -- the same five + codes its best-5 came from, so the line must re-emit them in that + order with the shared chip faces (hearts/diamonds accent, the rest + plain), exactly as ``drawn_row`` does on the full-field page. + """ + html = render(_StubRide(), _placed_pair(), ExportOptions()) + + assert ( + '
All 5 cards, in draw order: ' + '' + '9 ♠9 ♦' + '9 ♣K ♥' + '2 ♠
' + ) in html + + +@pytest.mark.parametrize( + ("place", "tone"), + [(1, "text-paper/70"), (2, "text-ink/55"), (3, "text-ink/55"), (4, "text-ink/55")], +) +def test_render_public_tones_the_whole_hand_line_per_podium_card(place: int, tone: str) -> None: + """The dark first-place card takes paper, every plain card ink. + + T-3 both ways: the ternary's True branch (place 1) and its False + branch (2, 3 and the T-4 max+1 boundary place 4, which the macro + still renders whatever place it is handed). + """ + html = render(_StubRide(), _drawn_placed(place=place), ExportOptions()) + + assert _whole_hand_line(5, tone=tone) in _section_after(html, ">Best hands — top 3") + + +@pytest.mark.parametrize(("codes", "count"), [("2S", 1), ("2S 3D", 2)]) +def test_render_public_prints_the_entries_own_hand_length(codes: str, count: int) -> None: + """T-4 boundary: the count is the hand length, not a fixed 5. + + One card is the smallest non-empty hand a draw can leave and two + the next size up; neither is the shared five-card stub. + """ + placed = ( + Placed( + place=1, + result=_sample_entry("88", "Moss Ridge Riders", 11, codes=codes), + tie_note=None, + draw_required=False, + ), + ) + + html = render(_StubRide(), placed, ExportOptions()) + + assert _whole_hand_line(count, tone="text-paper/70") in html + + +def test_render_public_given_zero_cards_prints_the_zero_count_line() -> None: + """T-4 boundary: a no-show entry's empty hand renders "All 0".""" + result = EntryResult( + entry_id="1", + plate="1", + name="No Show", + kind="solo", + laps=0, + total_time=0.0, + best_lap=0.0, + cards=(), + hand=best_hand(()), + dnf=False, + ) + placed = (Placed(place=1, result=result, tie_note=None, draw_required=False),) + + html = render(_StubRide(), placed, ExportOptions()) + + assert _whole_hand_line(0, tone="text-paper/70") in html + + +def test_render_public_given_all_cards_off_omits_the_podium_whole_hand_line() -> None: + """T-3 negative: all_cards=False leaves the podium best-5 only.""" + html = render(_StubRide(), _placed_pair(), ExportOptions(all_cards=False)) + + assert "All 5 cards, in draw order:" not in _section_after(html, ">Best hands — top 3") + + def test_render_public_renders_the_drawn_card_badge_in_the_team_full_field_row() -> None: """R-14: the Teams full-field row shows it like the solo one.""" html = render(_StubRide(), _drawn_placed(kind="team"), ExportOptions()) @@ -2205,6 +2316,73 @@ def test_render_poster_given_no_draw_renders_no_draw_badge() -> None: assert "draw None: + """render_poster(): each card prints its whole hand, draw order. + + The same muted ``text-xs`` line the results page's podium card now + carries, toned per card: paper on the dark first-place card, ink on + the plain ones. Rendered through the poster's own ``m.chips``. + """ + page = _poster_page(_drawn_placed(place=place)) + + assert _whole_hand_line(5, tone=tone) in page + + +@pytest.mark.parametrize(("codes", "count"), [("2S", 1), ("2S 3D", 2)]) +def test_render_poster_prints_the_entries_own_hand_length(codes: str, count: int) -> None: + """T-4 boundary: the poster's count is the hand length too.""" + placed = ( + Placed( + place=1, + result=_sample_entry("88", "Moss Ridge Riders", 11, codes=codes), + tie_note=None, + draw_required=False, + ), + ) + + page = _poster_page(placed) + + assert _whole_hand_line(count, tone="text-paper/70") in page + + +def test_render_poster_given_a_podium_card_prints_the_chips_in_draw_order() -> None: + """The chips are ``r.drawn``, in order, with the shared faces.""" + page = _poster_page( + ( + Placed( + place=1, + result=_sample_entry("88", "Moss Ridge Riders", 11, codes="9S 9D 9C KH 2S"), + tie_note=None, + draw_required=False, + ), + ) + ) + + assert ( + '
All 5 cards, in draw order: ' + '' + '9 ♠9 ♦' + '9 ♣K ♥' + '2 ♠
' + ) in page + + +def test_render_poster_given_all_cards_off_omits_the_whole_hand_line() -> None: + """T-3 negative: all_cards=False leaves the poster best-5 only.""" + page = _poster_page(_poster_placed(), opts=ExportOptions(all_cards=False)) + + assert "cards, in draw order:" not in page + + def test_render_poster_given_an_unverified_ride_renders_the_self_test_note() -> None: """E6.4.3: the poster's note seam carries the caption.""" page = render_poster( diff --git a/tests/unit/test_pdfexport.py b/tests/unit/test_pdfexport.py index 1f8cbe3c..5c1b58ab 100644 --- a/tests/unit/test_pdfexport.py +++ b/tests/unit/test_pdfexport.py @@ -20,6 +20,14 @@ cards), a solo event's top five at full sizing -- hand prose (D1, not ALL-CAPS), and a credit-line footer with no page count. +The all-cards-drawn ``All N cards, in draw order: …`` sub-row reaches +every podium card on both documents -- the report's own card and all +three of the poster's, at the card's own scale -- so the published +surfaces show the entry's whole deal, not just the best five. The +poster's two card sizings are pinned by a worst-case fit test each: +12-15 card hands plus the organizer logo (and, for solo, the self-test +note) must still end above the footer gap on the single Letter page. + Determinism (R-62, D14) is the load-bearing claim: identical inputs plus the pinned aware-UTC creation stamp produce byte-identical files, and the committed goldens at @@ -54,7 +62,7 @@ from pypdf import PdfReader from rivercrossing import pdfexport -from rivercrossing.cards import Card, Rank, Suit +from rivercrossing.cards import Card, Rank, Shoe, Suit from rivercrossing.hands import best_hand from rivercrossing.htmlexport import SELF_TEST_NOTE, ExportOptions, ResultRow, build_payload from rivercrossing.standings import EntryResult, Placed @@ -211,6 +219,69 @@ def _ranked_solo(count: int) -> tuple[Placed, ...]: ) +# A wide hand's own seed: the whole-hand lines the poster fit tests draw +# must be real 12-15 card hands, not the shared five-card stub. +_WIDE_SEED = 20260921 + + +# (plate, name, kind, cards, shoe): the wide-hand entry's own inputs +def _wide_entry( # noqa: PLR0913 + plate: str, name: str, *, kind: str, cards: int, shoe: Shoe +) -> EntryResult: + """Build one entry whose ENTIRE hand is *cards* cards long. + + Real hands run 9-12 cards, so the drawn whole-hand line is at its + longest here: the fit tests pin the poster's compact geometry + against the widest hand a seated shoe can deal plus the logo. + """ + dealt = tuple(shoe.deal()[0] for _ in range(cards)) + return EntryResult( + entry_id=plate, + plate=plate, + name=name, + kind=kind, + laps=10, + total_time=float(10 * 1800 + 60), + best_lap=1800.0, + cards=dealt, + hand=best_hand(dealt), + dnf=False, + ) + + +def _wide_solo(count: int, cards: int) -> tuple[Placed, ...]: + """Rank *count* solo riders, each drawing a *cards*-long hand.""" + shoe = Shoe(decks=8, jokers_per_deck=2, seed=_WIDE_SEED) + return tuple( + Placed( + place=index + 1, + result=_wide_entry( + str(500 + index), f"Solo {index + 1}", kind="solo", cards=cards, shoe=shoe + ), + tie_note=None, + draw_required=False, + ) + for index in range(count) + ) + + +def _wide_field(cards: int) -> tuple[Placed, ...]: + """Rank three teams then three solo riders, each drawing *cards*.""" + shoe = Shoe(decks=8, jokers_per_deck=2, seed=_WIDE_SEED) + teams = [ + Placed( + place=index + 1, + result=_wide_entry( + str(600 + index), f"Team {index + 1}", kind="team", cards=cards, shoe=shoe + ), + tie_note=None, + draw_required=False, + ) + for index in range(3) + ] + return (*teams, *_wide_solo(3, cards)) + + # (tmp_path, placed, opts, letter, created_at): the render() seam inputs def _render( # noqa: PLR0913 tmp_path: Path, @@ -502,6 +573,33 @@ def test_render_all_cards_on_includes_draw_order_rows(tmp_path: Path) -> None: assert "All 5 cards, in draw order:" in text +def test_render_podium_card_shows_the_whole_hand_when_all_cards_is_on(tmp_path: Path) -> None: + """The podium card carries the report's own whole-hand line. + + The muted "All N cards, in draw order: …" sub-row the full field + draws, on the report's most visible surface: every card the entry + drew, in draw order, indented to the card's name column. + """ + opts = ExportOptions(all_cards=True, full_field=False, laps_board=False, time_board=False) + + text = _section( + _text(_render(tmp_path, _ranked_solo(3), opts)), "Best hands — top 3", "Top ten" + ) + + assert "All 5 cards, in draw order: 9♠ 9♦ 9♣ K♥ 2♠" in text + + +def test_render_podium_card_omits_the_whole_hand_when_all_cards_is_off(tmp_path: Path) -> None: + """T-3 negative: all_cards=False leaves the podium card bare.""" + opts = ExportOptions(all_cards=False, full_field=False, laps_board=False, time_board=False) + + text = _section( + _text(_render(tmp_path, _ranked_solo(3), opts)), "Best hands — top 3", "Top ten" + ) + + assert "in draw order" not in text + + # -------------------------------------------------------------- content @@ -985,6 +1083,55 @@ def test_podium_poster_given_the_note_the_full_field_still_fits_one_page() -> No assert poster.get_y() <= poster.h - pdfexport._FOOTER_GAP_IN +@pytest.mark.parametrize("cards", [12, 15]) +def test_podium_poster_given_wide_hands_and_a_logo_still_fits_one_letter_page( + tmp_path: Path, cards: int +) -> None: + """Worst case: the widest hands AND the organizer logo, one page. + + The whole-hand lines grow every poster card, so the compact + geometry is pinned against the longest hand the real ride deals + (12 cards) and a deliberately wider one (15) with the logo's own + 0.42in of header drawn: six cards and two headings must still end + above the footer gap on the single Letter page. + """ + poster = pdfexport._PosterPDF( + build_ride(), + letter=True, + created_at=FIXED_CREATED, + logo_path=_logo_png(tmp_path), + ) + + poster.build(_wide_field(cards)) + + assert poster.page_no() == 1 + assert poster.get_y() <= poster.h - pdfexport._FOOTER_GAP_IN + + +@pytest.mark.parametrize("note", [False, True]) +def test_podium_poster_solo_event_given_wide_hands_a_logo_and_a_note_fits_one_page( + tmp_path: Path, *, note: bool +) -> None: + """The solo top-five keeps its one page in its own worst case too. + + Five FULL-size cards, the widest hands, the organizer logo and + (note seam on) the self-test caption, all at once: the full sizing + is pinned against every header the poster can carry. + """ + poster = pdfexport._PosterPDF( + build_ride(), + letter=True, + created_at=FIXED_CREATED, + logo_path=_logo_png(tmp_path), + self_test_unverified=note, + ) + + poster.build(_wide_solo(5, 15)) + + assert poster.page_no() == 1 + assert poster.get_y() <= poster.h - pdfexport._FOOTER_GAP_IN + + def test_podium_poster_solo_event_shows_the_top_five_solos(tmp_path: Path) -> None: """A solo poster lists five solos, one page.""" out = _poster(tmp_path, _ranked_solo(7)) @@ -1006,6 +1153,38 @@ def test_podium_poster_omits_the_fourth_place_of_each_kind(tmp_path: Path) -> No assert "#503" not in text +def test_podium_poster_shows_every_drawn_card_in_draw_order(tmp_path: Path) -> None: + """Each poster card carries the report's whole-hand line. + + The same muted DejaVu glyph line the full-field report draws, on + all three of the [5d] poster's cards: every drawn card in draw + order, not just the best five the large faces show. + """ + text = _text(_poster(tmp_path, _placed_three())) + + assert text.count("All 5 cards, in draw order: 9♠ 9♦ 9♣ K♥ 2♠") == 3 + + +def test_podium_poster_drawn_line_names_the_entire_hand_not_the_best_five( + tmp_path: Path, +) -> None: + """The line counts the whole hand, not the best five it displays.""" + text = _text(_poster(tmp_path, _wide_field(15))) + + assert "All 15 cards, in draw order:" in text + + +def test_podium_poster_omits_the_whole_hand_when_all_cards_is_off(tmp_path: Path) -> None: + """T-3 negative: all_cards=False leaves the poster cards bare.""" + out = tmp_path / "poster.pdf" + + pdfexport.podium_poster( + build_ride(), _placed_three(), out, created_at=FIXED_CREATED, all_cards=False + ) + + assert "in draw order" not in _text(out) + + def test_podium_poster_shows_hand_prose_not_all_caps(tmp_path: Path) -> None: """The hand name renders as D1 title-case prose, not ALL-CAPS.""" text = _text(_poster(tmp_path, _placed_three())) @@ -1352,6 +1531,24 @@ def test_pair_text_given_the_ten_pair_renders_the_10_rank_glyph() -> None: assert pdfexport._pair_text(("JK", "j")) == "★ JOKER" +# Natural pairs only: the joker's "★ JOKER" glyph carries a space, so +# a token count would not match the card count for a joker hand. +_NATURAL_PAIR = st.tuples( + st.sampled_from(["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"]), + st.sampled_from(["s", "h", "d", "c"]), +) + + +@given(pairs=st.lists(_NATURAL_PAIR, max_size=24)) +def test_drawn_text_count_matches_the_glyphs_it_lists(pairs: list[tuple[str, str]]) -> None: + """Property: the line's count is exactly the hand it spells out.""" + text = pdfexport._drawn_text(tuple(pairs)) + head, _, run = text.partition(": ") + + assert head == f"All {len(pairs)} cards, in draw order" + assert len(run.split()) == len(pairs) + + def test_rank_letter_given_the_ten_is_the_10_spelling() -> None: """T-3: the report's rank-10 letter is "10"; no "T" survives.""" assert pdfexport._RANK_LETTER[Rank.TEN.value] == "10" From 76dc1144bfd98acf461c9f3744b19f958f481ad9 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Mon, 21 Sep 2026 21:31:14 -0400 Subject: [PATCH 14/15] feat(pdfexport, htmlexport): podium lists each entry's entire hand --- .../htmlexport/templates/macros.html.j2 | 6 +- .../htmlexport/templates/poster.html.j2 | 16 ++- src/rivercrossing/pdfexport.py | 121 ++++++++++++++---- src/rivercrossing/ui/app.py | 1 + .../epic-2026-results-no-times.html | 12 +- .../htmlexport/epic-2026-results-solo.html | 6 +- .../htmlexport/epic-2026-results.html | 12 +- .../fixtures/pdfexport/epic-2026-podium.pdf | Bin 442460 -> 446763 bytes .../fixtures/pdfexport/epic-2026-results.pdf | Bin 534622 -> 535443 bytes 9 files changed, 128 insertions(+), 46 deletions(-) diff --git a/src/rivercrossing/htmlexport/templates/macros.html.j2 b/src/rivercrossing/htmlexport/templates/macros.html.j2 index cc944fb6..5301c0ac 100644 --- a/src/rivercrossing/htmlexport/templates/macros.html.j2 +++ b/src/rivercrossing/htmlexport/templates/macros.html.j2 @@ -16,6 +16,10 @@ first-place card is light where a row's steel-700 badge would vanish. A resolved tie shows its card, and `r.tie` stays reserved for the residual tie a configured order could not separate. + `r.drawn` is the entry's whole hand in draw order, where `r.cards` + is the best 5. The full-field report's `drawn_row` prints it, gated + on `options.all_cards`, and `podium_card` now carries the same line + so the podium shows the whole hand too. autoescape is ON — all text renders through it; no |safe here. -#} {% set SUITS = {'s': '♠', 'h': '♥', 'd': '♦', 'c': '♣'} %} @@ -50,7 +54,7 @@ {%- endmacro %} {% macro podium_card(r, options, show_plate=true) -%} -
{{ r.place }}
{% if show_plate %}#{{ r.plate }} {% endif %}{{ r.entry }}
{{ r.type }} · {{ r.laps }} laps{% if options.show_times %} · {{ r.total }}{% endif %}{% if r.sex %} · {{ r.sex }}{% endif %}
{{ chips(r.cards, big=true) }}
{{ r.hand }}{% if r.draw %} {{ draw_badge(r, true) }}{% endif %}
+
{{ r.place }}
{% if show_plate %}#{{ r.plate }} {% endif %}{{ r.entry }}
{{ r.type }} · {{ r.laps }} laps{% if options.show_times %} · {{ r.total }}{% endif %}{% if r.sex %} · {{ r.sex }}{% endif %}
{{ chips(r.cards, big=true) }}
{{ r.hand }}{% if r.draw %} {{ draw_badge(r, true) }}{% endif %}
{% if options.all_cards %}
All {{ r.drawn | length }} cards, in draw order: {{ chips(r.drawn) }}
{% endif %}
{%- endmacro %} {% macro standings_row(r, options) -%} diff --git a/src/rivercrossing/htmlexport/templates/poster.html.j2 b/src/rivercrossing/htmlexport/templates/poster.html.j2 index 05cc6cc1..78824f3e 100644 --- a/src/rivercrossing/htmlexport/templates/poster.html.j2 +++ b/src/rivercrossing/htmlexport/templates/poster.html.j2 @@ -5,8 +5,10 @@ Context (all required — StrictUndefined): event EventInfo: the ride's own meta, organizer, scorer, title and generated stamp - options ExportOptions: the export flags; only show_times - renders here (the poster shows no boards) + options ExportOptions: the export flags; show_times (the card's + trailing total) and all_cards (the whole-hand line) + render here, and nothing else (the poster shows no + boards) poster_sections (heading, rows) per section, in page order — the per-kind top three on a team event, the top five with no heading on a solo-only field. An empty row tuple @@ -25,10 +27,18 @@ `r.draw` (R-14) is the entry's drawn tie-break card, a pair or None: a card that drew one renders the shared `draw_badge` (the results page's own, `on_card` tone) beside its hand prose. + `r.drawn` is the entry's whole hand in draw order. With + options.all_cards set, each card carries the full-field report's own + muted `drawn_row` line — "All N cards, in draw order" then the chips, + toned per card (`text-paper/70` on the dark first-place card, + `text-ink/55` on the plain ones). It reuses only classes the compiled + CSS already emits: gen_css.py scans base.html.j2/macros.html.j2/ + theme.css, never this file, so a poster-only class would render + unstyled with no gate failing. Regenerate the goldens deliberately only (Spec §8 tests). -#} {% import "macros.html.j2" as m with context -%} {% macro poster_card(r, options) -%} -
{{ r.place }}
{% if r.type != 'TEAM' %}#{{ r.plate }} {% endif %}{{ r.entry }}
{{ 'Team' if r.type == 'TEAM' else 'Solo' }}{% if r.sex %} ({{ r.sex }}){% endif %} — {{ r.entry }} · {{ r.laps }} laps{% if options.show_times %} · {{ r.total }}{% endif %}
{{ m.chips(r.cards, big=true) }}
{{ r.hand }}{% if r.draw %} {{ m.draw_badge(r, true) }}{% endif %}
+
{{ r.place }}
{% if r.type != 'TEAM' %}#{{ r.plate }} {% endif %}{{ r.entry }}
{{ 'Team' if r.type == 'TEAM' else 'Solo' }}{% if r.sex %} ({{ r.sex }}){% endif %} — {{ r.entry }} · {{ r.laps }} laps{% if options.show_times %} · {{ r.total }}{% endif %}
{{ m.chips(r.cards, big=true) }}
{{ r.hand }}{% if r.draw %} {{ m.draw_badge(r, true) }}{% endif %}
{% if options.all_cards %}
All {{ r.drawn | length }} cards, in draw order: {{ m.chips(r.drawn) }}
{% endif %}
{%- endmacro -%} diff --git a/src/rivercrossing/pdfexport.py b/src/rivercrossing/pdfexport.py index 36a033f2..3780bab2 100644 --- a/src/rivercrossing/pdfexport.py +++ b/src/rivercrossing/pdfexport.py @@ -183,6 +183,26 @@ def _pair_is_steel(pair: CardPair) -> bool: return rank == "JK" or suit in ("h", "d") +# The muted whole-hand sub-row's sizing: the DejaVu glyph face -- the +# one that carries the suit glyphs -- at 6.5pt on a 0.12in leading plus +# a 0.04in trailing gap. The poster scales all three by its card +# geometry, whose compact cards are drawn at 68%. +_DRAWN_SIZE = 6.5 +_DRAWN_LEADING = 0.12 +_DRAWN_GAP = 0.04 + + +def _drawn_text(cards: Sequence[CardPair]) -> str: + """Return the whole-hand line the field and both podiums share. + + "All N cards, in draw order: …" -- one spelling for the full + field's sub-row and both podium cards, so the three surfaces can + never drift apart on the wording. + """ + run = " ".join(_pair_text(pair) for pair in cards) + return f"All {len(cards)} cards, in draw order: {run}" + + def _is_team_row(row: ResultRow) -> bool: """Return whether a payload row is a team.""" return row.entry_type.upper().startswith("TEAM") @@ -732,21 +752,25 @@ class _CardGeom: """A poster card's scale and its page-break guard, in inches. ``scale`` multiplies every card dimension (place number, name, - subtitle, hand, card faces); ``guard`` is the height the break - check reserves before a card -- the full [5d] sizing needs 1.9in, - the compact one 1.2in (its own pitch is 1.16in). + subtitle, hand, whole-hand line, card faces); ``guard`` is the + height the break check reserves before a card -- each sizing's own + pitch (1.68 x ``scale``) plus a hair of slack. """ scale: float guard: float -# Full [5d] sizing for a solo event's top five; a 24% downscale so a -# team event's two sections with six cards still fit one Letter page -# (measured: card pitch 1.52in -> 1.155in; six cards + two headings end -# 1.07in above the footer gap, 0.65in once the organizer logo draws). -_CARD_FULL = _CardGeom(1.0, 1.9) -_CARD_COMPACT = _CardGeom(0.76, 1.2) +# The [5d] poster's two card sizings. Every card now carries the +# whole-hand line under its hand line, which lengthens its pitch from +# 1.52 x scale to 1.68 x scale, so both sizings came down to keep the +# poster one Letter page in its worst case -- with the organizer logo's +# own 0.42in of header. Measured with the logo drawn: a team event's +# six compact cards at 0.68 end 0.73in above the footer gap, and a solo +# event's five full cards at 0.92 end 0.25in above it even with the +# self-test note's own 0.20in. +_CARD_FULL = _CardGeom(0.92, 1.62) +_CARD_COMPACT = _CardGeom(0.68, 1.2) def _poster_name(result: EntryResult) -> str: @@ -773,8 +797,8 @@ class _PosterPDF(FPDF): -- no "Page n of N", there is only one page. """ - # (ride, letter, created_at, logo_path, self_test_unverified): the - # poster's state inputs + # (ride, letter, created_at, logo_path, self_test_unverified, + # all_cards): the poster's state inputs def __init__( # noqa: PLR0913 self, ride: _RideLike, @@ -783,6 +807,7 @@ def __init__( # noqa: PLR0913 created_at: datetime, logo_path: Path | str | None, self_test_unverified: bool = False, + all_cards: bool = True, ) -> None: """Open one poster: geometry, fonts, metadata, footer stamp. @@ -796,6 +821,7 @@ def __init__( # noqa: PLR0913 self._generated = htmlexport.format_generated(created_at) self._logo_path = logo_path self._self_test_unverified = self_test_unverified + self._all_cards = all_cards def file_id(self) -> None: """Suppress the trailer /ID (R-62 determinism). @@ -907,11 +933,14 @@ def _cards(self, cards: Sequence[tuple[Placed, ResultRow]], *, geom: _CardGeom) self._podium_card(card, place=index + _FIRST_PLACE, geom=geom) def _podium_card(self, card: tuple[Placed, ResultRow], *, place: int, geom: _CardGeom) -> None: - """Draw one podium card: place, name, run, hand, card faces. + """Draw one podium card: place, name, run, hand, whole hand. *card* is the placement and its shared payload row; the entry supplies the Card objects the large faces need and the row - R-14's drawn marker (the one the report's own card draws). + R-14's drawn marker (the one the report's own card draws). When + ``all_cards`` is on, the row's drawn run is spelled out under + the hand line -- the report's own whole-hand sub-row, scaled to + this card. """ _maybe_page_break(self, geom.guard) entry, row = card @@ -944,10 +973,31 @@ def _podium_card(self, card: tuple[Placed, ResultRow], *, place: int, geom: _Car _draw_marker(row), ) self.ln(0.20 * scale) + if self._all_cards: + self._drawn_row(row.drawn, indent=indent, scale=scale) self.set_x(self.l_margin + indent) self._large_cards(result.hand.best5, size=18 * scale, height=0.30 * scale) self.ln(0.32 * scale) + def _drawn_row(self, cards: Sequence[CardPair], *, indent: float, scale: float) -> None: + """Draw a card's muted whole-hand line, scaled to the card. + + The report's own sub-row wording and DejaVu glyph face, sized, + led and indented by the card's geometry so the line sits in the + card at the same proportion the report's card uses. + """ + self.set_x(self.l_margin + indent) + self.set_font(_FONT_GLYPH, "", _DRAWN_SIZE * scale) + self.set_text_color(*_INK) + self.multi_cell( + self.epw - indent, + _DRAWN_LEADING * scale, + text=_drawn_text(cards), + new_x=XPos.LMARGIN, + new_y=YPos.NEXT, + ) + self.ln(_DRAWN_GAP * scale) + def _large_cards(self, cards: Sequence[Card], *, size: float, height: float) -> None: """Draw best-5 cards large; steel for red suits and jokers.""" self.set_font(_FONT_GLYPH, "", size) @@ -1216,13 +1266,15 @@ def _podiums(self, plan: Sections) -> None: self._podium_card(row) def _podium_card(self, row: ResultRow) -> None: - """Draw one podium card: place, name, run, hand, drawn card. + """Draw one podium card: place, name, run, hand, whole hand. A team card shows no plate -- its section's heading names the kind, mirroring the HTML's ``podium_card`` call. The hand line carries R-14's drawn card, as the top-list and field rows do: the draw is what decides 1st from 2nd, so it belongs on the - most visible surface too. + most visible surface too. ``all_cards`` adds the full field's + own whole-hand sub-row under that line, indented to the card's + name column. """ self._maybe_page_break(1.1) indent = 0.70 @@ -1255,7 +1307,10 @@ def _podium_card(self, row: ResultRow) -> None: ), _draw_marker(row), ) - self.ln(0.32) + self.ln(0.20) + if self._opts.all_cards: + self._drawn_row(row.drawn, indent) + self.ln(0.12) def _top_lists(self, plan: Sections) -> None: """Draw the standings tables (5/5 or the solo ten).""" @@ -1556,20 +1611,24 @@ def _field_row(self, widths: Sequence[float], row: ResultRow) -> None: self._hand_cell(row, max(remaining, 0.0)) self._row_rule() - def _drawn_row(self, cards: Sequence[CardPair]) -> None: - """Draw the muted "All N cards, in draw order" sub-row.""" - run = " ".join(_pair_text(pair) for pair in cards) - # DejaVu for the run: the sub-row spells out the suit glyphs. - self.set_font(_FONT_GLYPH, "", 6.5) + def _drawn_row(self, cards: Sequence[CardPair], indent: float = 0.0) -> None: + """Draw the muted "All N cards, in draw order" sub-row. + + *indent* moves the line's left edge onto a podium card's name + column and narrows it to match; the full field draws it flush + left. The DejaVu face supplies the run's suit glyphs. + """ + self.set_x(self.l_margin + indent) + self.set_font(_FONT_GLYPH, "", _DRAWN_SIZE) self.set_text_color(*_INK) self.multi_cell( - 0, - 0.12, - text=f"All {len(cards)} cards, in draw order: {run}", + self.epw - indent, + _DRAWN_LEADING, + text=_drawn_text(cards), new_x=XPos.LMARGIN, new_y=YPos.NEXT, ) - self.ln(0.04) + self.ln(_DRAWN_GAP) def _atomic_write_bytes(path: Path | str, data: bytes) -> None: @@ -1651,7 +1710,7 @@ def render( # noqa: PLR0913, PLR0917 # module-skeletons.md's frozen (ride, placed, path) plus the -# letter/created_at/logo seams +# letter/created_at/logo/self-test/all-cards seams def podium_poster( # noqa: PLR0913 ride: _RideLike, placed: Sequence[Placed], @@ -1661,6 +1720,7 @@ def podium_poster( # noqa: PLR0913 created_at: datetime | None = None, logo_path: Path | str | None = None, self_test_unverified: bool = False, + all_cards: bool = True, ) -> None: """Write one finished ride's one-page podium poster PDF to *path*. @@ -1671,7 +1731,11 @@ def podium_poster( # noqa: PLR0913 team/solo line, the hand's title-case prose name, and the best-5 cards as large faces (steel accent for hearts/diamonds/jokers). A placing that drew a tie-break card (R-14) renders that card - beside its hand prose, as the report's own podium card does. The + beside its hand prose, as the report's own podium card does. With + ``all_cards`` on (the default) each card also spells out the + entry's ENTIRE hand under its hand line -- the report's own muted + "All N cards, in draw order: …" sub-row, scaled to the card -- so + the poster shows the whole deal, not just the best five faces. The footer is a credit line + generated stamp with no "Page n of N" -- there is only one page. *path* is the caller-supplied full file path; the ``{ride-slug}-podium.pdf`` naming is the menu handler's @@ -1695,6 +1759,8 @@ def podium_poster( # noqa: PLR0913 self_test_unverified: E6.4.3: whether the ride was finished over a failed evaluator self-test, which adds that note to the poster's header block. + all_cards: Whether each card spells out the entry's whole hand + in draw order (R-63's all-cards flag, the report's own). Raises: ValueError: *created_at* is not tz-aware. @@ -1706,6 +1772,7 @@ def podium_poster( # noqa: PLR0913 created_at=stamp, logo_path=logo_path, self_test_unverified=self_test_unverified, + all_cards=all_cards, ) poster.build(placed) data = _store_streams_raw(bytes(poster.output())) diff --git a/src/rivercrossing/ui/app.py b/src/rivercrossing/ui/app.py index e79ac663..abe249f5 100644 --- a/src/rivercrossing/ui/app.py +++ b/src/rivercrossing/ui/app.py @@ -2344,6 +2344,7 @@ def _write_export( # noqa: PLR0913, PLR0917 path, logo_path=config.logo_path, self_test_unverified=self_test_unverified, + all_cards=opts.all_cards, ) elif target == "export_poster_html": # The poster page is the PDF poster's HTML sibling: the same diff --git a/tests/unit/fixtures/htmlexport/epic-2026-results-no-times.html b/tests/unit/fixtures/htmlexport/epic-2026-results-no-times.html index 858ea926..763ddf0e 100644 --- a/tests/unit/fixtures/htmlexport/epic-2026-results-no-times.html +++ b/tests/unit/fixtures/htmlexport/epic-2026-results-no-times.html @@ -72,18 +72,18 @@

GORBA EPIC

Best hands — teams

-
1
Moss Ridge Riders
TEAM ×4 · 11 laps
9 ♠9 ♦9 ♣★ JOKERK ♥
Four of a Kind — Nines
-
2
Dirt Dynamos
TEAM ×3 · 10 laps
Q ♠Q ♦★ JOKER9 ♥9 ♣
Full House — Queens over Nines
-
3
Fat Tire Four
TEAM ×4 · 9 laps
Q ♥Q ♣Q ♠9 ♦9 ♠
Full House — Queens over Nines
+
1
Moss Ridge Riders
TEAM ×4 · 11 laps
9 ♠9 ♦9 ♣★ JOKERK ♥
Four of a Kind — Nines
All 11 cards, in draw order: 9 ♠9 ♦9 ♣★ JOKERK ♥2 ♣5 ♦7 ♥J ♣3 ♠10 ♦
+
2
Dirt Dynamos
TEAM ×3 · 10 laps
Q ♠Q ♦★ JOKER9 ♥9 ♣
Full House — Queens over Nines
All 10 cards, in draw order: Q ♠Q ♦★ JOKER9 ♥9 ♣4 ♣7 ♦2 ♥10 ♠J ♦
+
3
Fat Tire Four
TEAM ×4 · 9 laps
Q ♥Q ♣Q ♠9 ♦9 ♠
Full House — Queens over Nines
All 9 cards, in draw order: Q ♥Q ♣Q ♠9 ♦9 ♠3 ♦6 ♥8 ♣2 ♠

Best hands — solo riders

-
1
#7 Luca Ferrari
SOLO · 10 laps · M
A ♠A ♦★ JOKER4 ♣4 ♥
Full House — Aces over Fours
-
2
#61 Marc Tremblay
SOLO · 10 laps · M
K ♦J ♦8 ♦6 ♦2 ♦
Flush — King high
-
3
#12 Ana Souza
SOLO · 9 laps · F
K ♠K ♥5 ♣5 ♦A ♥
Two Pair — Kings & Fives
+
1
#7 Luca Ferrari
SOLO · 10 laps · M
A ♠A ♦★ JOKER4 ♣4 ♥
Full House — Aces over Fours
All 10 cards, in draw order: A ♠A ♦★ JOKER4 ♣4 ♥8 ♠2 ♦9 ♥Q ♣6 ♠
+
2
#61 Marc Tremblay
SOLO · 10 laps · M
K ♦J ♦8 ♦6 ♦2 ♦
Flush — King high
All 10 cards, in draw order: K ♦J ♦8 ♦6 ♦2 ♦4 ♠9 ♣Q ♥7 ♣10 ♥
+
3
#12 Ana Souza
SOLO · 9 laps · F
K ♠K ♥5 ♣5 ♦A ♥
Two Pair — Kings & Fives
All 9 cards, in draw order: K ♠K ♥5 ♣5 ♦A ♥2 ♠8 ♦4 ♥10 ♣
diff --git a/tests/unit/fixtures/htmlexport/epic-2026-results-solo.html b/tests/unit/fixtures/htmlexport/epic-2026-results-solo.html index 8e27389f..7b30af12 100644 --- a/tests/unit/fixtures/htmlexport/epic-2026-results-solo.html +++ b/tests/unit/fixtures/htmlexport/epic-2026-results-solo.html @@ -72,9 +72,9 @@

GORBA EPIC

Best hands — top 3

-
1
#7 Luca Ferrari
SOLO · 10 laps · M
A ♠A ♦★ JOKER4 ♣4 ♥
Full House — Aces over Fours
-
2
#61 Marc Tremblay
SOLO · 10 laps · M
K ♦J ♦8 ♦6 ♦2 ♦
Flush — King high
-
3
#12 Ana Souza
SOLO · 9 laps · F
K ♠K ♥5 ♣5 ♦A ♥
Two Pair — Kings & Fives
+
1
#7 Luca Ferrari
SOLO · 10 laps · M
A ♠A ♦★ JOKER4 ♣4 ♥
Full House — Aces over Fours
All 10 cards, in draw order: A ♠A ♦★ JOKER4 ♣4 ♥8 ♠2 ♦9 ♥Q ♣6 ♠
+
2
#61 Marc Tremblay
SOLO · 10 laps · M
K ♦J ♦8 ♦6 ♦2 ♦
Flush — King high
All 10 cards, in draw order: K ♦J ♦8 ♦6 ♦2 ♦4 ♠9 ♣Q ♥7 ♣10 ♥
+
3
#12 Ana Souza
SOLO · 9 laps · F
K ♠K ♥5 ♣5 ♦A ♥
Two Pair — Kings & Fives
All 9 cards, in draw order: K ♠K ♥5 ♣5 ♦A ♥2 ♠8 ♦4 ♥10 ♣
diff --git a/tests/unit/fixtures/htmlexport/epic-2026-results.html b/tests/unit/fixtures/htmlexport/epic-2026-results.html index 475c9abe..f1a74418 100644 --- a/tests/unit/fixtures/htmlexport/epic-2026-results.html +++ b/tests/unit/fixtures/htmlexport/epic-2026-results.html @@ -72,18 +72,18 @@

GORBA EPIC

Best hands — teams

-
1
Moss Ridge Riders
TEAM ×4 · 11 laps · 5:52:41
9 ♠9 ♦9 ♣★ JOKERK ♥
Four of a Kind — Nines
-
2
Dirt Dynamos
TEAM ×3 · 10 laps · 5:48:19
Q ♠Q ♦★ JOKER9 ♥9 ♣
Full House — Queens over Nines
-
3
Fat Tire Four
TEAM ×4 · 9 laps · 5:12:44
Q ♥Q ♣Q ♠9 ♦9 ♠
Full House — Queens over Nines
+
1
Moss Ridge Riders
TEAM ×4 · 11 laps · 5:52:41
9 ♠9 ♦9 ♣★ JOKERK ♥
Four of a Kind — Nines
All 11 cards, in draw order: 9 ♠9 ♦9 ♣★ JOKERK ♥2 ♣5 ♦7 ♥J ♣3 ♠10 ♦
+
2
Dirt Dynamos
TEAM ×3 · 10 laps · 5:48:19
Q ♠Q ♦★ JOKER9 ♥9 ♣
Full House — Queens over Nines
All 10 cards, in draw order: Q ♠Q ♦★ JOKER9 ♥9 ♣4 ♣7 ♦2 ♥10 ♠J ♦
+
3
Fat Tire Four
TEAM ×4 · 9 laps · 5:12:44
Q ♥Q ♣Q ♠9 ♦9 ♠
Full House — Queens over Nines
All 9 cards, in draw order: Q ♥Q ♣Q ♠9 ♦9 ♠3 ♦6 ♥8 ♣2 ♠

Best hands — solo riders

-
1
#7 Luca Ferrari
SOLO · 10 laps · 5:41:03 · M
A ♠A ♦★ JOKER4 ♣4 ♥
Full House — Aces over Fours
-
2
#61 Marc Tremblay
SOLO · 10 laps · 5:44:56 · M
K ♦J ♦8 ♦6 ♦2 ♦
Flush — King high
-
3
#12 Ana Souza
SOLO · 9 laps · 5:21:38 · F
K ♠K ♥5 ♣5 ♦A ♥
Two Pair — Kings & Fives
+
1
#7 Luca Ferrari
SOLO · 10 laps · 5:41:03 · M
A ♠A ♦★ JOKER4 ♣4 ♥
Full House — Aces over Fours
All 10 cards, in draw order: A ♠A ♦★ JOKER4 ♣4 ♥8 ♠2 ♦9 ♥Q ♣6 ♠
+
2
#61 Marc Tremblay
SOLO · 10 laps · 5:44:56 · M
K ♦J ♦8 ♦6 ♦2 ♦
Flush — King high
All 10 cards, in draw order: K ♦J ♦8 ♦6 ♦2 ♦4 ♠9 ♣Q ♥7 ♣10 ♥
+
3
#12 Ana Souza
SOLO · 9 laps · 5:21:38 · F
K ♠K ♥5 ♣5 ♦A ♥
Two Pair — Kings & Fives
All 9 cards, in draw order: K ♠K ♥5 ♣5 ♦A ♥2 ♠8 ♦4 ♥10 ♣
diff --git a/tests/unit/fixtures/pdfexport/epic-2026-podium.pdf b/tests/unit/fixtures/pdfexport/epic-2026-podium.pdf index ca63a8ece3fd78cfaa4a7e516d1dc78d47f0524e..888a64401cfaa1fbd14432497bf6051af785bddc 100644 GIT binary patch delta 7891 zcmbtZ4R}*knm+e8H}@tXNt6DNG`VT|3m9_$@*}PdNIODVDOebR!D)pS2bC5P6>&D8 zBL!tw$Dq{}p<+=KbPyQZB7^Y2h%TVxiu(+^GtW3fm2s_DXFJc1k0sgf+?zrh=*%;2 z;k)OabHDTVz2Ezt@80}oooDwB&t8e3Be7UyzeboTuM(1Ev7tq*Y2a6i(;7ro4QX*P zqC`V#B(;zZd*dN(ltNqzNs<_mrH~p;5sWXjmA9lmq+MPu6pf7>DXS<+Aw>bjTfLD` zEPNG(uoQ~Qsi|ynYBM)JwU5!fYDm4xlv-Q~MPxi>l0YQ<6A=YMBL=a^gg9h|8jl3H znxKV27P$Mt-TFftT*pDxjvR1xqCCBH!Mz*hqXJZjir{{{-Z~z6bWKl{*svVzBF^E# zE|Y5&QIbQl3OjILvX-$&EgBDnRlG7k3~{-eZcqt&MO87Y1KR|h5kng~iW*X)!#a2z zSZMAzS|k(>59{D@>{pHxjfNu12%RaTws?6&eM7P+Umbl-(?YVOLvn}7Ife&lN<1X% zaOfUz+~gj^12h?|69enSQrB?ho~wI-CdET)YO2!*PEb3R17KIueeSzEfEN`5xL)Lg zE1?7J*RR7ZBdneS`bf-KV1OErq*l5_Ka6pQDv)lyLrl%)$3j?z`C*d=$9-dM zspU(RSu1JG=F}XkivmNY7SPfd%vCf{l=NO_tfgb%s;F`(ZUp#3*Z9;dTj?0gRTL$p zq$J)4(B`EM+2k?cRb&V%quCn(jQ-B>joEA0q4eulgSSEbepIZ};m1xujI^c>x&Lw` zA4zK9v?7H=5#65tQQfk%hK(vCbzs_Dpn%r#$VeSnY_1N_3S4KzTvwsK!R)LKxZrmS z<&{M6=P6c)-ZYWDe-asmLoKVKgcTh zwC;tMqWRzL55q(c=tM8jiH_TFbT?>kv>Cnv_m`mwt_M>ZMpL6fr`= zfxHQ0_<#gXna%kMU5SFLALLT%8oor&_x!p@|7+;@jQm+Zr^8#&x?&49lkd+6ib zuXJQ6pH?zDvCDA+3C=cfWv9M>XV{jjV7l8;kngW?9?h}JRhD$7qc~K6Fi#i^Co(@zFKg0^~K#{~nVl)=j z2gG%Vk$SI=)6df)8B;sHdY=A_n`+czdM&CLwYXlBvtvy)G~*oy7m?$W(Ws#_-Ds^fYBfepGitSZO_{3K zq_9yl#+D?g$&pCdcxJ3xG4>#3SDk%^R}Ep(7*o>pQPgC1(1=krrk9M(sMCx=jm@ap zj>cv*W80Fk8O7LyG|gB{R`UjZOeL#{HwX(gOeo{);prUFQ>@Jka7QMVZ3#A`gfVsR*_zk8Ly zOO{K2&B}-OurhcfD+lVi;f>9TZ19$5epnK_C6U;eNhQQkNAicXx8sj5ur6mjriS^+ zvzk{dyXSUE#I+9F4t8R4_9s8JAVfAGL>yUi?>!>=QE?n0z7p!`mbPVgtyJ$_ju7oa z2<=|hw5kneG$B;J6zY~`cdluf_LrjFaC<*O1%J7{xoN3o5LLjlevruR&|rOynFeE( zLcR3%yY9Jv`^>!6crK`KxwCc2f~MvF-u5C4028A6yPEEAqi-QU2ZbAe<%+o0`CTkEQOGV9SoSip4ryzTDhwkf8s;g&vv5OX^uC9s!UdmcYy`>@0DBI_er zSBLZ>JJ}S}A60ZLdy#!8Og@ITF$Jg{p(LwrCw33<$z zVpYfcT`Zd%gwgR|9*CF~3d%`Yp`i?eTnoa0OwEcN%9^Y=pqvR(2jT>oofYgXb3o>T zxIvn-QUK)=kY4ilBPzRLZHy(fKyNOUu= zvuDX9iI##BcQGHs_ZKT#pC~4*loe5wbsC}sQzt`E>&=9PM_!hrEjIga>h=l^b$hK1 zi*D&fmdugZf-~10@0T5M2~F-hb6vmejEC!PzQtsx-!s{n_ZE~8i3tLlfNn$!!07j( zN0BL?n25@WiByERiI{z@Wwv!b(L$^y9-tl}tc%fNf(THGLvaTkK}SHN*fdF`we-gy zzjyIgrfldOb$UqImF^@Ok9GWZFZ(vE|MAQ@`e|kWVpu7da?5z4<%s zot8}{KlN@dvy_;Q74Un?z##pD{e57WzQMksKKljy&jCwMxK5&z#A)(0b(%iO zoMcU1zI{G&F|n8^Vco8LMUH4{IpHoZ50;P=L}PhAa=TDaL=HO&$oXW~#_iiTf)M6e z&&_)Gqj6LA-SK;ZNe}!XeKCE3s3$zLo};E5-0{L&Z@sYNAi1Wev^@Q%^jF_soc`*I z&(i-*{4=qJxQ*E9L*384!7AjG0f;@v=peA!guDqG`!M|ydYA-EXjI7AhrrxmcGw9< zH;8RE@Dco_({wQ1eR4nheFn_47R+-Ey%s`r89J1xDDn#hW*d5?fbF$8#0~y~-riu3 zV{?H86;Op%Gbi{d&Q((m7CLpNUj|??vd8-dhX(uY?_ev%9dT@^72)ErFYFJC;Xt^g zx*}1W@Fn~SF%d|V)EC$L>izX%eV{&BQeV+l@knvIuif7+wg(<5d7`4TVxYn|+;_Ow zaF0e`qrXvX47B;${B2@eV4ZKBf1S84P&f($E$RqrEE_B-*VKp-08D}y0-w#G~VR2q8nW`{m zVV4H;Q9F8iu6^UdlbOuP2j94O;^ilw+_me;Cts%CByatuf7j9`VmiUWzv)eB_sR3; zPlCXSr!OoC_HrQ)YD+*uK$tgh8yWX21k-CF-bxnsI(saey&gBoxj9rvI>*&`^+g^B zx*7H}=r?Haud2GZt+=!JSH%OxOf{+|s>y11wTGExn#@f$PvToqE73}}x?4Tw#VfIm z1NoSgx^;CDDl#%*cCscqIkeA`Jl(zg#BED{bw_$IeS)YO`U7F=A$M;4*IpZW>%zk) zRQ2@6M4i9>E9mjIPm&5ysM+&3TwdJn0FI_cZcaW&>`R*N2A(Hyz7Ignh@;f zgt^|_Xl^qD=6PUWOv9r+@JBa(+nHotIHwpfU`aRXAPElD(qIORQ#fI@GYO`iX=K`% z0fse3fe~2OH;~K_=pe-BrC{Pb6iF0OW<=Qtw%z9Fu^i$Fl0!FP($>0QGY=yAGk@3d(fd%#H}w+rwP5Z=fMY`+SA)RZU5?{?~q*=e?V^AzU^?=#df;! z_2#8tWwBii6AO?U&P?R7y-w6?>2Yo@G&{%5p`7lTLLJF03xEK@>Y}x1Et@nYxuiMC zCxx|1OVXOOCC4T0Nyl1eXVE~B1C}#P3gB&pj5%R2gIu*`&+eyEd-kLT2xt1jz}M-o z2nY4M^Y6WP{*UjT_n-E@EYOTaM>aJh(>VH;{DEYX1=bhCm9IRvWR)?+?o;#m%H zwMnOa2#zN(OW$Ds@iV7zj6YDHw>=MAMfZ~tCMlq1`e#hq`f_jYwFe)`3wfynPRGf^ z7x#fdT9z;jOxFt0;RH-qfzBtcvsy@-aI@dXnMo6Wv)}KV#tS|_?S`naiFR#pZz}9{ zBo9mcPsT#vzWKK0m5yB2QRblaBd<2(QN*9(u`+44@) zL&?p}yA!*g`Q-hU19bK4m6Z(*i5UUggl9KxeKQz5tZ578)YX@bD}CyrZF_v!*XJX| zYIGyA5MLx_F!NZ(%$wy;W@z!g~d%4QTg08cF2QXn zLuEvete^sP8B@ZRn98^cb15GXf|fWHp(9+}61LTnH&V59f}X}C*kr<#;HI0W^V5Zd zEn%CF<`eVDdU_5smz`^>=Wd3Ka-OgREhU=Cpz{P8;72zmSg$0lX`k+d3T_f{gCJ$JjgDhl2I9c!Gq5a z@JreXk=Jh^8;ZzRv_;`7NRD1Crd`oD7MGP=y|@HlSVr}g)UX`Ib0kMhEI~2?pJPTV zYHAcd^W?N?68r?4(-wiRhF7-1SIJyG4Zg=*YQyt~A8D^fgX$Izz0 zFB7?X5%^}5)23?CoQ)`9HKJbGRvpVVwW??k(nb%cs#;vinHYX&3d@lW;RstRz?UkS LFDt8=UCaL;x>(qr delta 3720 zcma)9U2GiH6+XK=GqKm}wedRHu(o$^$Y!0i(d7Qjy))BNL(4^#G6sj0?WpE~8a%3OO7)p+Q5VhI zhg@6Hc9QgiY#-%4eZiE&OxVinKyp1>>0`N4F#*YaWa<=7nH-BI%{a9kPGm^=C|^WM zs+EEZ#7XLh|Dmludi7n`d|5iKHj~5 zBt~fmx5EjUr5?y^E-I{#hSUuX?knkmA3fMgip0JFDU!P>TegGVJSfM_f%a#cx9$2e zJNkqCE&A;pu@XCy+P*$B5J+5%DPTwVwvvqkF6jF*FT?m1AmGV2yj>28{NP#po(HF2zAz z?~l_|P}i1DkcwnGER!>vx}=FT*nxxv>$7H((Sd{o>&hk)J|69*?}rr|rqfrg;_m;W zL+D@a$eAA2A`2f+Yzl=KRk;%Sg0)Mpl+~j*;Gn7~{pL8kIu$gh%y#70EZk}lu$tAselK9Y zYrDaI1pOdRUFQ0FGPhd~=J(#*x!E%1>E-M$9Z37jd?iL&CS84d`nu9Ge2iJtV`HV= z!fETnk+cmC3UW)UAW^$^@&B|#ZbakMw>|G`8g{4NEoJ+dD6ES@0xgM_s)>Rzx1JS> zcz$F`sOslW8M_Vy4uvNQ*K^5k5J>jgCUY@JPIBPp=hA*BEre2DE7SwHPVlVOov6Wi4e&MF&ZM#KrBMhl31`n zT!bIq$ASeS^0E!h|m<(dFJhL=EdgVK>1MK_+eIKu!KD$~!2S3CK zJRihY9-LqK_O@&I)5rjri;%#d$$MWbU47%W>fsHmm^80e7dNZ}+XlnX&gzpJ)}zGC z!#djE(SD8g1lp5m8)!dAJ6ApWoHbni&2v_1=?Pp-Fz7I<{YZH2M^6UNfC41=a1h4f zE|`RSDrRN6GFw@w*O5hlV$=JV){!v{?rRO!%A!Io6sIGbtp#!fYi<;WDq)=P=qbK_(@0-BzCDPP@K9`H? Trq)wWuJ zK5wlexZ3X4wiasb$cGL-dc$P+cuLn zZTe`GE}dOX2O_o9TOIVgb*wBiG1(EiEoidU@$nDYF|6Wh4 zD_cFB1g%^)X=Uh8M|A9H!ABGcxF}&H1?4l=Q>4?@-a6V?8TMQ0EN!|aMvqmVAF!Nc zHcK5 ziX97!IhK%I7BJIJ(qa;zPu9*XcFbgoHubiM-ZCu&T=w~_G;M6tsWJzypHMOs{QfYo zGzA=)mTsyE&7iO%|KR5pGdeh?EQV0(b}dQ=uWKYd(>{nfkZ^{o z&u{tQSTQFT0&KKO7A%p}B|j~3#&}X~z$z`@6P9e4%B1jB7DPkYFqH+O7RTu7`kp{4 zW8ml^j66)W&_C*vzLY~(KN@qf6>`CeB_lZ#JGQerhHrVeRpM&MQ7K&6MiMmne;eUs zlTMCDT>a<_P+vHJDTWeWgD4CYo0(*qj!hdo?G;j&hds>`;F1Ov7O_=ev`O|PhHiV- zVtP8#q~)l$w3XH-$5C5@0j&aQ$7sIkfgs(~5E*RP^+4k|Gnvcj`61h`3DePup}`?X zy5qE>F*w+;_otP+0`3#T)dQQFf} zdhNembIHmpOHKW(QV2a~$M!ny16py$(XXf7MwDiEBiz`1`vyRq1Vs0`9c@%~T`eSQ(nyJg z4O*H8^kQE+B?oRk(M{X*ApLxD9L%s->}b?XrBU3R4dansVvPbjbT6``Kh&f z1Ej<|)>O}LgXUbINBr(oS9?a}nt#^CYi@+K zc>20E|Zmgs&cIojM>?{{J7 zw3<>2yEii4kWnR%W!uS=g(0-^YpoNZTnh2wWRe-L-_N>l%T2AkCm*KFyfdbhA3l9B zNQbXjN(&z=qZ!M>@({MKHt93#s^n&}Y1+2BjZSogY5VE~>HCA!+u7|;XVSFt;TVur zJ1(VGf|gAOMpN3BnFZ14?i8(+#!rsO7hpJTJ`tuBlgo#|l3D;~=-`_=9sa~L`sw!N#OrHuTnse8k^9VQ$#WMED^?VT&Bxy;4CgOj3T z*=V$E6k<|al0*8-Vof8ki!Jd1x$n@mW;)hXTcSQLUz?(1wY}+qtA|5Aw2tX~oRAKW*kjY5}aqwaMAOltr=obWghY(|NeD1S`UM zyA8V@Xc)&tG}yw@W`Lr9tK;-!`v%1*MV)t98O#)+AQ?CQsnx;evMXEcfXL>E&Iz18@rpKjn zu&hinm7#Uh+x##9NJ~%9vJ!%UK-z`1K1+h5*uz#P1F1?orzb#5-L(GHPGzfP>F=j5 z#WK`dFYif+d(&D4-E(1>2BwwJ{XN}u|Ia1_uwan5HlvNY9Uy2%f~uwmX^qoOU!GA; zADb11>TB>j8Jn)2#YRIdEziMK7=*{A?WCQ743;ArrE)Ih#jKfhOseW_b4`RTH^u4q zy|Z8!bkW?IintxeqK#+7AYG~%yM7OTc~3oKSRJp|*!zVFI`iS`adP##*NPqFr|~Y1 zpQFV0jOigC-VSM_Hlv>X&}=|-y_|CFb1`>q8*+s$Pq-~SA1EGYffy|L(e>MP*S_hh zb?glMl0ljQOed~r3pkbu%LA4oM2tL(d`paarcEx+clSP5Nq0<(3^rgp`LQiHmb3;8 z9f z%9(@LnY8VRraCaqP@U>Xm}0YA{Z1<7N}n=X-c#wAFx|Op76mD}FMle}w1O#0b5VR~nS zPUWjH#!1tHMS}`XzQ}E31!u!U%^12jOuovSf>-UGi&a9AvS)OA$ecMPfKA8FX{J&#}5b*=Ip?sfrtln6U6+tES zaPg9eZz#eESbMZC z<*=uiH`YxFOTl6q1`RCZQ!k@my<6^c!B4HmX5@1htvjdDYvx1G&yYesT%XE82Mv+E z3YLeSf?i7PjL|iV&-16896dBKMn@Lk>`&Rs4F-4TpKXFm3-3rl?NFh2LI&JE?e z=bu(f_ncdvhxeb`R+obPpB-YuNSO2s5>&k`4mt?Se_xxIq-D#P4r*xgvYY9)^9{Oi zxk<;)pGcdRFOeFX>7jWbL0c|JQrn7dI(|VTtzPj7y5mBFq5~$qdtn`2JaFMKc0PDn zh!QJP^vIGhUAb~SU9_~DnlCcx7fUDn=%S16@&!`4jN|OMvrfBCqjew3y4)Hi{Zi-+ zSiY++{Ul9Z-c3!HS#-UbNte}*u#IAp6r1Yd|}9Ir0A8l zPukI~^!>}7d~@$bmGt*p#?Q`1vX$ul#e#|xc>Rh;so?g}HbG!KM?&F#( zGTtM<{HZG@)WK?YhOk*$!jcEl5JoJZee)d)fGBl7_3b!^1#;!nAc zYpw{uR>Xna zNu7FE&Pv*j(E-0df`D&3w-(h7Yaa~!MvAJ3^(^fuc*W9A1dHnmMok_zXKgz^R9Q5o zsAgC*N15x2rlN27Cn}4&(8IClnxgh$GxF~#SZ)m>)#XLuVPmWChHAOxnzh3^{`u?w zm)8^}hrK5-4C)>R8CVc+tV%9)?I6Nyih!>$g6nm|##a`grk#gHh7ECpkHgv~6yPTX zEL7q!fzhFhjpL+Ee&d9K&qRu>qLhsCx6`lJz%Wi*h|tE*t3#->yvUaLtC3{l5)Yoe zAn0J8fO7_pd<&mj9lny5Tj%^i3gk5b|JD^{ijuVICmnvrw#3ZI@b`Xrb*zEiq9NMc zQSfU09MV40;xjX%-$pb=1)FMU;IUA?jRp9O9U;Ht4cd0i;DjUHt3WH#)YvOm6||E0^vKy$f+#H*%K#F@45+ouR+P9Z^1E? z>%y=B6VZT&{w?fNQxX(ys-vSD%e^THTJ}-H?TpC?6!m=tTf%U;Z#MN$U>ks|qFS7e zHdRy4$3hJEpM_WVT^Ethj3agqkKP>&U@{`>ZfN!c6AZX!Lqw9pusAkLOB5q%Bpi)(EGJB^?u<8SQAlz%11G@}8#v~AJ92GMLu;DTrOyN4c`iWC&;3E(9YHY(1 zzn%)ZwsLT$8P8XF`J0o@6Bni*uy|+)l?p5DE0cX3B;NP=PP)3Y6>dT+9Th>Jm60I) zLYAi<*zHJkdmww59QQoBalDohyCYf~EquP1Uj6bE9--gI`NU?r_tW+6go&es=74I@ zw;Dc5=Erh!9CcOw93d;oSS(ZB_p88X2fu&i{jq$+v(92^GlY^QEcQ*+;z-GfV~twt zg=0pr%Gs#=4ZaWLVwd=<1PW(RkDb+_*5*wF;SXsn(GoeNgxmfMOTs^a#1g9>p-0?bcc-i=Jkv|Fco z4TwZWX~V5y501u0aO6ZBG3jU9tnCeVZ3 zNQ^P6M#rgK{(8Z@-aDCb_#n&9kO%gDq0MKyU3+f?D%G<}*FE|L*voE%OR-Y|CWqIg z&BbzU##h3{!y0DCkm2M)ZgM1apN+QcQ1nk`s(YUi!RFr5{^u&GeKGZkdM8u}M zYXflM-A0)f+Hz9|587u)LhG1-N}i!A#AMi|F$1}1M$OVOqedwUuXtw=HD0j*@>C*L zs&)7fLYGxt_kMMN(olD?9XXt#YoT+jm422$Q2X%rdXi}>A1P)Ps8 zS;|kzvo5y`(IoS~*-(pE>o8RUSouh@!icH#ed%yUqas#UZ75@>SYnw^m~k1_OtF3% zuzC4iQ`MtEd+v&Ot(+%?apOy=QL_4jPWx8W?a$*8@XAv`oN;O0U^GMEO+VA7H=C;h zsP}szcd*RAZMr+Zg2(Qj>N5@L*odk#wf4Fcs!E3H3JJT^W^id2Ff?{AGAQ*bmIyQ{ zYg#?Sre?%R8wXyEmKFUowN^2)5{#*D4M~WY);519;&#GT4142bNk`LnTfDByZI)lA z%n}@l0PDIPK#pYG;;h$OM#=A?Fvta6?9}HQAlugeU~+L5`FzQg5=Ygj8XBqsvMK3} z6r-$5YJjm5kV1n(iBhYooD<6idbA?{j_DuJgmzc#KQ&Yc?D%?}wpF9bzd?A3Aieiv zgRZ})foAVWphQHH5Us~E`*?5M(@c--XlIw2qW6{4tvirWxUQBKd?m9W}5b`UQRLo4o&ODp5)A57#J{nD{-U2RcqSJmz+A56)oK{-?} zzBLg<@$S8o1vTTL_Y33c+^udaq>pN4(evxA&1x(wzl@=Lfe0#N3l+bNBM75OYr}DG z-WCz5CM_Y|e%K88nX1T28VPc&rvw9dwJWm4GYg<_r?tEvJm8RV)XM%uk{Qpc2vb$` zA}S!(C$tXHrE76B~KP(OE{d`H5sfhu5c%2_O{{cLSY2=B%5ivhfjH;wwS`@>>qTwA(i%qpJW zkKGmz(L@Q^f>od$i{^+dZ44IEwL5kC@lU!M_~3@-6%@A?U{&TciABx);X6D`Z`&u9BxEX}=C|+woEz^e|T{JFhCv#anZ8#{b zFi|y}rPME%e%L#tRv^|3k^gH&^(>|pW1d8vFde(0gSs9=`5rXez++RzwlubQ&yy|m zCeJ+;<0a|&d1&RhaQuAw`tPBS0 zG*$g#eKjzHitgRNaw~kqb|`@|_^PPifXj(WNG`zVa-YYZSo;5|@VASK_a!(zPDg${ zi4O1Sr0IK2R1JmcGkY(Aqh3u+20X^C6;f1i_P%y;(-~SfJ-aW?nBaf8REUqip0?~u z$P%YQ)04~mK^mL)waNJBh;9VqQ2OeiSoGLS3(KI>fk71aqQuif%%a~e9dj*tq4VdN z=$h1F9`wK0^S*aOGtGRm6{+DceeB6NO`ldzn;)yEUq0DQV%h{+w>N|f78mD{ciZn6 zd0~aP-~X-XjK$^&6YvSwlqLJA)@Iashs4x){Z7pof^knXjj$IwCwgqkSy z4`tCzhBo|;3(2?qIbNDcIzSwRf0hQGSWS=oVM$*5X(!27O7AP54{}f`dzO027Uv?V ztKRLR)AnPFvM>SnOa7RkTYKtnf4GivPjNoGk=8wR392+2>Ak1ovO*)duNhOOh|8L_ zDRjsF1cdgs<8_RKTmO{cvL+=(aJyyOAKPR_6H5`00YF#>HMHS3A=-}bZ0l{KXMYn6 z+K40ADLT}98D*bdnj5PWNzlu`ZLU-7r*6V(UC8x4w^r>EMGQ z*&wTT-hQ%y-g)K{6e5S|qt7N#gdC*%f7eXAo^6-G)T{P)(FdQ2dm@t-9H^wLo{32K z)#LU2BB{9d{eJMd@(=16YvF^XHGc>o@1RQ24*$7|8lKkaI|r8xc>6us(^U%GJy&cM z=c@iHmQdaN@k47n=nIG9lrKDZ{16}bWNLn{n=XGULYF>wu{hAAg^AFigBm73Kbd&T z>zcbw1RZ9C{AwTx>lH`uVx%5(sUsJW^YV5Ipiq@Xl>I_H&NJ z8!Q{MvPsIu7JBv16>`%}|Gi|@SU0VFNfo#TB!C>~Fa*^rU*O*ilcM15_r06U?rId6fVQeJLMP0=W;%KRWu7m^Wh##eV!^>c(1I5(C8UR*7P|82) zlhvf^zKC)T6-rbKvQZ!p zdCxV{s#h+dJ2zIreCejr=Ub@jF!Mw=4<21lpzO(ZXz$ByK*uKN12^mb^^5+iPcO*`S|J+TTuP(jRBcPMT5cUmBs5F>zpH);`nS%;j z3PVz-NE}Uit&N@#WvE&yr_9kfUHL*6ZGEkq9(|#a-h9oow?Z`OwYc)CKlfsk?ms$7 zDcOo+EH{**fT^Xuo{(j^%U=qU|Mk{UJO|st^&!+^zpn5*f~ruqi3=Lq>*rAKUqS&m z))1aECp2>gaqVAp`mfiQjM)-vlBRJX8!0D&@xi!LnHS|sC`xOR0f@@C!nF0T9;V9Z zmA@wFjhCm=DStDl|CI*%S^U$xK<)_Q&HOf7K$Q~qk8+TLCftU z|4^3OSC1k>{}07>^>0IRRMZ4*BrhOX```X2RD!Bz6g8sT&ym@CkIAo%#&mqM4)^@t z=Hhyz4D|*`6`<%G?{oDTlAoc{POtk=IyrH=QyY1Q8v>4)#d`C?l$*~b~JEU`j#_&xV(PRAQM zImahS(e?S`<@EBtFr3oeZ#4K)X*#q|hpE=+KB@qsXSKZ7hF}j=9BuqffVRKK_RjX+ zgtT1Dl)S|4=d-A*vIE9k4WtFX;Z}7kk;kHB|+^7 zqX4m+$Abt!OISMRo;4>H`ZabEebOmJh@N4*(Z!7D_anMd?Akg9eiINE6>D*pNt@n@ zi(eFLOY`BqlQ(pjyA(Gm0foajC~)EDVr!=igJXxxBi-VfhD4NT(i#`4jUoOJ{ z4s?n;eOgQ$ECU)J{CT&yrd&&CE#hl^T0}lWPmNI55HT53TwRXu?c9(188H6R5{$s! z801}f`v=#-lSW!>#cNs4l%$h66^P2!K^wXrKNd=yAgsct2o3(=;i z$5ZmmUhD%MJF8thSfRCvn>#gCnk(L_0Fg8W!5*_Kfwt>|K--2&?SqPNN~<{hyFVy0 zAr0P6l{QJ-01uC!I1Dyo>rzsSLp%$TTSQC1md{l|`XKZmj%KxD)NL%R(h{Sg4dnsI zZq$rEm$}`*DKq{c1w`SNf&JVnIGF#g4eSL7SvPgpkH!kJk?t6~`OEe9iZVr7;C_S}bhT=82u- zfea2AUo=)^xN_H3SBjckNuyE=%KqUbITH?tjm5zVaLGY_LTX_O2YUH}LyZ!^A8T?2 z@SBMLN&&olL5++jh*Q~xzH0x}d^8yHRT_%o@n)@3Txe@ywD=V+N~2o4>txo7IZ?M? zhNqgPttI{Y13gyND)fTPT9 z;&@a$RYsUUgs^6Zc)H!gO>f5#6?U??QBH!wkmqu-;!Mp#28>TF!( ztq|XdgPtCosx29_jNv`AuJUMvaRTb#e_U-YSq#IZXT_X!R~IKtAth| zlBWX~$s{z-C2hdZ^+^aCzLblgFOQdcJt0o%1`}~bAOwxr*bNo8wM&agUf8|AUaU7@ z)_B2R1;H3)BM)URTkzG!1qp z6H9AXn!C>i5)dvGclLoZ0hNoJEc`S}3kSVw#iwnpBZ9P1p-lse$1=ry8>Ui19G!wy z@XKXX5GX+?7lp(Q%GzY<1?q{X0ykS6ZRrY6*qpqkRz3lXUKQO{3JPnJw8KONJW14i zJ`EmzvcV0V_DqAkhWFDENzlpygUJB$fs;(=BeT`tKm({Uf@i$kJf_H>W{Y&_*X8Pl+) z^(GEz?KF_h(=c0RrLg?!Nq4BZK7Dy^FEB4ycxp;;vvGz z{j%8c>JvF&-YGsf1A9d_EiAr117hb$TI&>tXW)G2WYFgH4v{k}dFTnIpSOBFZdx!C z{L?o}3rjgWB7P;~gJLEwV7MyhU=|qQ7qL(h(VPp7=5jzr=Tc%DK1Dz5jpJudaFt`j zEX@)Z^Z=*N%+i*8aICzg{0Tq$JP0 z(?LR8r{UDTaJr^q>!R`uXl!;GK0F;#`}{Msk2J`470MLGj@WcMYX7*~ClqpKk4{*K z)1)$D*J;r0qxc=D6|=Ik7ZnJw3Ikk^ofC&=<0K`%vS+>zuJbUZ$VmAR(3^SO$>rxs z3!yTOrHV;&fVEH0z+qiG7eupr1~_i(9H6VI7nuC}9FUgETZZINPF#vV^}@}1VD1n# zCg)1`8{rDkcNXaFsyTQkni|B-3`Wm_vB<7F+k%cQR?b1RqJ;$$WDrV|s?P$sxl##Q z-bQtM79IH-Ik9^dj_;dizYk%pm`*7&6q^gdT1el!gdc=PVJqrqfzW_4bJr1;n9~<-9aIz3!W%wZ$gBx*OMHS9*X;iJ5fuI{& zJe|p@i#%o;mG4|yxNNLSLGiGP8>w0{PtdW}ROTtigKG)t5Bvg)&`t+WHul57Ht}O6 z+kYGr=k{w;7!au>oR#hUD5mtf5Rh#4fWaeBZ)p|!B&}FnzesD5v+?3q8u)Hd#tYmR zAnb-ScfW&Ni2}G9TXFe>fT_FPt_#FvEJ4OKCSe?2x&8-?ON_AUbBDTL6h_1y?5}3#}r$GdcgP)<|dw8i7VEkl?nPRSX`)&L*8^+%<=xmE6tpa-*(Aj=T=HzqGz(W6@?k-vG8MzYA9w|S>3@;aNyG968xEj z*WpvRJFlk!)fZB>8o4&7hB^v7LqTaZF{7Z2 z>YJ^gtb!Z`NTd?lcg4F zsy$g*InPvkveaTswI@sM-c(!4C>)yZmfW|fEoJPUwTyhz-IH3IsrHn1Kf!x44d6?T zRC`LFt|0f&W+_M=T3TVvRELItC% z!PdAhqxOq!GlbRiCqOK+PaTD&c4+2y+;=$e6YYWtxCmbXCTqeE547Mmeh`L!N0Cv~ z3AU@j?;(`nmp5~`mfir*h=1~SJg&TVH*)!PY8Vg9`c42{-zz*zZ!Zuy*BIMP8CQSjBu4+Dj$rjlgkveLE7MU z=kRf+gCI;n8w#Y-xe;xaQ#dZ2b8yMYFxC$Ja5|U9uO<%vNH&`>3SXGbW+68U#^p?0 zaU9;3Gwh5xqRoNzm>~g>AS~GomexABrj% zhnFqg^{d<5UM3XjRc>rnPM>?3GnX<5*gHShyk1#P*M+8p-pox6qorbiC6 zQ5iUFvSS))+*ldjX5q(|3eMQEY+$i)oSlMb9-PY(7z52hETGLQJQB3o>9iQ%Q8K&C Q#&1KHwzZwHV0P*M0Aj+{>;M1& delta 23124 zcmbt+3w%`7wRcYDk%WX0m}F*>kjx}A2?Y3rYtgG#p!WJ`Rj?MXZ}8FjqI&;p?{nr6(taN| zzaM1IT6>*+_FjAK_1fzkdMf(-{^*WVHE%YV%eo3QODn4n0+MU~owD#O$hnRFi8R8um(#4~(;Rwj|PKty{cwQ3W1dgUoI zk#bSqK*_+xa_mV9qp|3$w=BA^rj<^c5TIyvS*dQ)_;0t+gX7QirS$~*X_+vuG-J}n zKns0i!c?!FNu=qvz3uefU>UtXVV*Z-B~rAcECLSUVU^%7o^R!_wGs;sr;-I&FJtLw zpe%p8Tng%`Eh|k~bpN=hh*oLMG}IXMTbNTii;?!y{k1xagwM=o!AfTH-Z7mX%P{EB z-WD>0eco&)VT#+PXgt70-i(#dnaQ;H;RzH8l@}qvQ8SUuM9o5~Dg)AHT6tvih-^+J z6Q;bA8RKUXhpi?UWLi=ruA4TAwuF6Zz-AUg$|E_Yda}n%>dfV>YrOPS{S=>RvPi!W ziqO#BGK@Py{S66^X_$22-KabCQUx}aA1^M>Gz>cHp;iyZB3C`BGt12)WJM~INM(VP zJ-ZwZ`LcL^x@gQw9Q;1Rwz1wkw42Ndn%SiLjTCoGo9~IxlrIITH|2Lxyg{Fx6CP{o zW+I!7I)vKXA5bx{X~CedKBsme%UFS&PdUq_llK_iL%l#G5Mnu0W% z$*6*(2F8Ha`1qdVp^3N*{+6an z2=g$#VmVV*6ketvtu5%mNV!jJ{uLcz384e2A6sr|Wag$N^7V%wM!%FJ@ zJZW@^Rz@o)_cA8UDx>Lmg=-nfM8@V`B!$$s^zr1f+&z(zlz^2?CJam61qIkVVFb3N z(us7QL}_1_Gk9FqRlrRYy}}>PK&uo8y8)7Eht`V2P?|IUQgYft#tY9 zkk8JtE>UW3jxTK|jA9LE=TX<37N(V!-yw2z9^GPOjziXt<|&TG z#?v7S##VLUD7{|07x!i0;9%k-vfN51j5PXjX=+DU7MO!{CQk#cvvN(a+$umtN75&0 z3?-{>OG@on1uLHfd+;z*8T*6jOr4!F*k8-(e+ffJ#=Sf?M@81K74q3R%uh4jIVeF@ zS*&lajYX1KkwdUt&UlFBve^SN>U&+9+%XpXfGlQFh~l}Lg(zCL#_LalMT$xq-y5cc zeE`zvrHlTx23GF zBi=Mz2M%$xI@*77J?T?gJ#`o_Mp)Ry`%F*4Gjga2Zb+4djb#@j$I`x85&CG#Ni=!3 zN#{;&kt#0*CBZ7Mh5lpqTu;ifsAhc&?OW9EOCivZp$RQKEm#0OJ10^AHP3A>fClGA zgDH4$sREO(U|uSYD$dHb6gX)mdDMjcdwZ(-@JJ|9bGaukj%nC z%H)*+V4B|9=1FD@($};UIZLOVQdTO_J#_Tefb88RTQ{#kHMML#wRu81#lc^0`z6yU zn%0dzZJqS#ErmWvZ4S9@dKLAbug1)Tn?4_>n)yMeJw{t@_R`|{p+FKopjE&Tz_g^d zjdstCLvkzW@%c^UnWt0Zf_hI9f#i-0TOhPG6q#Q}Uz?|dRxGHe1M>!`YGDU0J4I&` zOFaw9=+;x>IofA&t?X+(HJvI=S}D4__2MY~`|L(qyXaG1h((GH#3ESX0nHX?CA1bS zZ^*s8`n0HfiDfRe911LQ?ix#6XW4p@%PL{%vRX-~OZk9Es)jZ#^ZW9c+xrs^v}9=) z4KArKXj_M+1|=OlWfC1+(oEy7kJ6`3tuHlgDUU~EvNMn6z;t?#d(BQ@vs_rWQTC+> zS!~vw5w5ckz}p2PBVp?6jQK2fIAKk(dOTPOz3{mOIXv9_AxLXaZ>R69gR6GB zPUVlPWgR+QE$j3%*o9+VT~F7XA(zx+vX%Ll7P|7xPkT($q_+lJ=#w+A^_Y5=BWB*N zx$CP>i{!2ml)+Gy<>2PIA=>oWG9T`PQ1s0;=-$s(c;k|pz6Y4J)cPjcjZKixK<=MkT*97moNjdht+ zkqcxBMCobd4UQT>JOGWIHk2hIT}32ZQ`e_Th*Iu;z=D?Ra<8y;r|uzRKmMCS#lX$7l-R!Mhgg$^|oqRy39&|l8) zr9ZB;=+mDIQ0rhn-Ft`JICNw%wslPp-9Bj0;2IdF!OisHnqInfl}Q5^1a=-?^}SNa zftA(^qKb4ZPLEy?qAT$GlVv@0&bbziSuU57?mYL3trzwp(6H$23j>7_KwQ);*4Q(t zqptH=r%m9sr;E>zQ}wo{U$80uIg2J;6oBhJK%Ys)X@9?ude*RBYoJTk#6X** z?|m)?TA0nXI!(U7q}SKh(z*-!>Hb&3w7NUad6S#gO{V1Md*!}ax2Bvnd|uu96n^>E z>(|33TxinU>+5KGuQGWyr!F{GMXN8K0%tFUtWkvSzu31iSQ07;m(+Jgd$Lv{nbJE- zqQEqiG%~hiViCRxxMoQoDrqiii45zZrKA;o6w}0t?r20mttIh|C2d9Mu)MJyjm0;& zmP|s+KnHqlD`_mc#pwz7;?cI1G?ff=mNXR+hq(_m2l?QQxk>+nC;UHqDw$mL5ML40 zRRncoR6M<~8g2ghZ6z&e>M3a`B5xhWpGHuHiYOA`3znq_hdCDWsFyj>iUkEZYW-dP>E5}i}8d@8tM6e3YOZ?xno*afUcn=!JV0si)~U`92vs3GZ(X>=os4y5Y-F z*#i=8T`43T>;iw=fsuqz33TKl#C{<}em3wRU<`A1MY0m>}zA+A8p1$U_GGyEG z^kTn;{7m((t~`y*y3yKQ@CVaKUzVG6^wRQtpPX#dul4$M_~5cVDpoE;s(aH0uOA6F z7bmk7`#1Rg{OpX(xCK+l&KEj+3Z}OXw)#>?8dz-CW+C^`$^W7ycEMU=UjdRUpI=s9 zJy#i|(d;cL#b3_AKKGURzLdo|Urs8xS-i!Y!Ub4qL)vS2(F(AGyF+=%P2<7wc00>4 z4Cf-~9}eY)h*m|bcEMG5X;gmhg-b);Vob}mfeL1ux)Hevq(RnhC>WEIx$BU9!_=vh z4JT!nLh7>6+flIM9#YU*=H9?4xQEjkaF3G(_^B@O;O{v3*F}j{4O(YuN~h;Tt*&Dp zqR2P>4$e&r23?wa?+Cd~P8x7L)m&BO;#|>Vcw4ANCdKaDEbj&=vw#(xF9KyW+|)2cwGUzCu7{ z9IRJU4ArtMxfd#pWw}XaErUs9C<^oL3RYZ}sV;DN<0$0VCz)DSQeIL~G6vU3ui~#) z@qTzC*n!5PEW^yD(Ow#NS%WtTzDWI1wXhcaugf7=(5vaZg-UlMb95X#vbxP<+^lQO zRbz8^6V}7W_b6tMkcf2_#O*fg?9#7It(T;G-icu^Ml3L(*xg+y@jrrrRQa(IU z(k|=pA~I6;?cO^osplI_zGM#L2LG(K^VoIsCMedsbQ~fsp3nMbY`(WG-#1Hf_P46M zMYCzkzlQT2cx;?0zi=sbp4)*^;!xS;P}w;w?dS;HMG6W$#l6zhDW1~H z*G&p0b!>?OouXi{Gw!jJ#alKCcG`|2VIg+ZG$z6T-G4nIt+ZnSAHAT8nzukFxCjmp zzpw==#6`HW%Ly!HHCcB5drLG=0`B>8&5`mEgwiq+S)4PwT|n5FseuYUd}gGSc(9sf z6&hr#*O$lE0=s@gpdy`eXkGo`_w&85hMVu{5}Q7))zgjzQTpg$y%*UKY4y$@1qX*K z1PlZ0!@wm+*$85|_{-C!+YWV<;!OS^tP_H*R4|SP!cCor`#5b!oCeJIj|7d>ZT;v9BpkFUU6Zi?1ZhF`F>1q zcrutzc5?*kl!^T|3rD}Ok6=3O`Br_9s{jgZqkX0S1mp9um?Qx%b;mc@#mk{jvikp?qQmk8U`M z^+WD4D)YSWJ2|R7m$DK*@^cp6SaGZ} zGtxf_k>->*_=id0FB0v!>q^OFFDZk?GmV?qRo8&W5D<#dC z_-MQqhqfr=>M3Q|woM;L*PO!Kt?;r!vqtVzVe5F}7p5CwbuQ zNIRCpc%|lozD2=D)VWI@lnvb$MkS2uWddYka;cf516?g*Q=e8X79_Pwae0?k6Eu@X zkp)w*5?3gE8MTeZB?w5V!~qLfr+hUYu@qX?2^`KTfem{&M&y9*w{MT7pUz}?4d!sn z(BFM8h2$R`?w@bZRfE+!FINbOphU9#WvJON!zo0 z6m0WLV_z$oq~ZZ}%|#k-ZtU^NN-8N)?RQKrfLP-G<&JOxhV4wAy5=<>9lIk^fMMO} z)Mat>FB#{RSy4!}r&ev<0t)cY;|g2;zR2HaA@?Q46atAIExU3o4>OX3=>lKWkT5f{ zU6WR4G{T2LN!`bLR#|hEL%^(n(Ols}L6TF>KyUsG+;kx&eW&gO16>qKX$qmCom`pa z+?-3p+t;YeT=h&)vUymU3?j%UWjWwz>+qI1cTBp$MIRPp7<9nwB|1 zt_v%)D+-o^SgISN4UH6|6w_2D%ZXW)g3Rl|pce-+%C7vz~m&BjvO?mP)W z3+7st61-)1B!@vTYL;eFY)TNucnJBITw4>o;Ni|9!p@`}b)T=YDe!?*7bboD^RnFS zu5s@0fn0?}-#SAxYSY;wb0R^uCSIHKb?|)#N3oYRrRt>2)hRQ=<`@#Fa#@^$+4;je zMQLfu-d$6p-K~iQ6>!^{w=_|Br%v8q*3*kS1F`ZaewjmL7BsSapxp46h)oQ zvp089c2^aBv?~<$AMaL6zg-vstt~uKffqaNvbA2rkcP8adPDT)t~lb<{lAhmV<<*g za?e6=Fih+34e`m(z-X+liX6QKR<};Fjzzptm3%Tx;v&}@e3 zvHRj$h87=aK_a}l)K-@`{d6yp78m;>TDe2?)cq0XigV=p{xX-&snViD&-q=H%9J-b z^bFnSFQZR}#&=}k{1;^8Z3PcYlH08K_H)&PJc{!$Y#i>UTlQdZs_D?4xO+LYw@Fg) z9bB)a4`FCb6{jw{j`Vk{oaVSVG)D7^0l(Je-r$A6UQB%-z|NtnM)j?vfAg9ARh^!7f@@f(LbH!l+ zTKAa0pnfbMHZ9i*5%q4PbbYUXc*Gj9)(UtcX{^q$BC)^-MPfAljW&;M>eTmC%jfb6 z>x6mFQ3IRIfZT~HF~&G&7jlzAszRqndMLFA)h1KN^9eky#r01g>7rll;Sz>=@;q=Z zs!~F<@c~tl!lh;qU3%yNly@BIrKVq3QPYDV)M-kN(f9{rvXFE0uLGr-3|+LRtx-MP zT~_D44Nf3tlmDQdQ!!bpdGL%%q=31$45b=+uI%`_X$gAj!T!<6=x_8thZ3ayc+%KPu9!+^(VO`E=-Sq z?DwRQjvf4v`LTfB`ECD6c|M%*PO77uo@yGe;z-Bzvx1^MkI$Fo7>mDDMzMV{TJUr? z?W~Fur$B%5beMj*?_4-ZH84h9%0D{qnFwvz?^yXA&vaAkzIaZ>m!eXyWnY_w#ep_0 zC|g-gO6AVGh?^ziV5!qA`2Z1UFT>d_&Ye_2{`P~tbk*+$3esjLy!MWIR?E(_h445( zYF{`6JOJu@V!o^^`L`$Hs9~vP!4J~+o``LIE>0)?{v57cXNyd~_&qBRKmF%(&D;zZ zv5a;+(?g&Bub9x6BmasHw18OBf^|mb)~520Du(vgfU4J4rfz12||I> z>&mQ^L$_uaqY7}hAJnPrss1nISNw!$ud)Sdmn*-yT<&;U z`$7oMSRVu^_=j-}Hy-Z>Ci&GX@F7X#`&t^IUz}~j&L1;s*|RaES1W17;YzyxStyOe zUG(g;I{o+I28um*E<4E~y7f87LB9Q^CVJ_)E;{;t7k%paxKny9X3T)c+V)(liWSy> zww79+>j>o?hXM;@+jQNtN^8&!4+ppikzwg1u>uv->ouPzY3NdgZa-KzSd2<|J0h3oFji~X9LWdBv7hn=&e7+wQ;oM;eZz>U`YlW_>sqn z4PGV4=-kX6V=q1adRb{Y15Ikv*f*jy_s^r!q?NSijXJ!i!_J3%I)0T!Rp%m*BGEEm zOHl9Om}J8m{BC*^Gdrx)vv1bZ%$L}TG|_|jRjRd&E`O#jDH0^k`P5CNo{{qM5b@?8VUXIraaj0ZiQWtOiYnr;>7#NLv=Ij;Lu_JIt9YrY9 z?40qa{CSaVa|l7vH{0p-_rkQ|%@`6yaoTYhFFd`e)64HQ&|lv?hu<&@(fT8hdw#L( zNAEY&FOE1aN%hexy5{u|jM4ND>iIw-Lz`c3X2*=S{Mt)fKJey{d=oi4`)1y zsQL)|FQrQ3?)%v1Gc$M<2tMqYwCk<@0xSK$(7n8qoQbQD8_x$yaYW@T@MvIaWswd2 z`0aLj=%XMVemhRuv2HToft$1h=`FZum%hWsshoy~UAD;5p+;1*XzJ#iZ%Ezhr6bCoxL@b>Qs0Ry|?U z>?X6MI5_7{N;XvDLCx0nNyC(QFHT2)R!Lu3Rz(-Q7rXKQXx;Sl_w*b0!QGhozS1VY z!LRG1T+XH-;4X^bo1hjh2 zVe5Opjf>s_;z%iiMu$Ra79{vh>i;k8v4EFXjQBh*vzTNS!ZH~dZ79$h?Mruq5BZt2uApW>pS zT$@P7C;cbqnRUXO#&Jx>iT&kTV^E2ZYl?aA$aom1Y~maZPu$;)ikz=3tGw|v0AEeX ztZ0ap$BnmYx;Rv!HQe~J_CxXAFJGyxVV43Hdee5)0##51kDt48nuvEJ3;%t&?agTWZ2~fR9RH8vr6iKm7N&h(|EKg+bmz5v@nu9K;^GH-bHO zIEX#w*e25Lv7@ZxnjDtu7GhWB&szAi?uUEL~~L_hk)z(u3d&%C7^PO;w$ zNF7@_l9Q{(mNLd;ms{;?4cIV0h(R9qHeh!hjDb;gjasjm8i!b~Y}C$?aiw^sQR@)z z$F-=UL*au(#;@XzZAThL+&xi?yM{eP=C5LI$XU~l-gi#aKPr*s z<(HrFlMx4M@q|TPTCKoa>FxBjd;8DLGv$PL6>lDg6KtcDs&TV3j8SPikhvQATM@9M zsRIK(DFV4^?0~vlal15K;$I_>pIseb%aakvk8>;`&WJ)q;W%D=u@lzpJ5k6F>z*S~ zhzPF-rzCs^(%AS~#*Hm*e%F`5Tfq_o2N~*FONzx&t$kuXWXXM{Cy7ti=wOh#pIs+t z>JE>cG{xpcU>6ET`^1f{+Q4X7<(Mkv>8K-4M~WPoQfz~smSVFphSj+ai3k{Z@yi%k z1(V+`D&t^LcMn#0X&mCuGH)e(3l8aSahRC$C_$l`M(3nVKbD?Sz)OXtJh)rm#4BGE_0VEGMxo&YFxZ#<3D7aRs3p*meXC ziV%ZU>bE+nA+wzptNN651i5^uTU^(r^?yFkj1zVNl?G8PaV3N|k!#vn0r8IG=!(UO zZu?D#c%TP!g$EcG+q=P?U#GAt$GRaU%{F*4yGJ`$#*ku94_c+me9(r_lqVoGoah&W z2?)(^d!R$}p0iKf#MsX#q$qjB0|_lF6EnvN2}&x!h@l<`2@gpa83-LeOIBt<`-m+W zti!Fn7^N(f<*mQ@dVI{M+mdm7z zUADCJehQA08RRsLd#p=rNf)vWr=sYNk0r(BGtl`BX{|Mkr8|yis8vaeTTLwEodpeV1QKww$;`88yX1CddmMXU!z@9?n9> zcF)8Jy!bencxNW0?TjgqwnZmH+HRf#X~Vgt2v2ofql#H@jaE&?o^*WcggARD93L5` z!$ItNiX_ z#wb(gI7+T-ByO0A8DBgfYWwk-V3_k7pI9{)n5L7lfSILd_-W;Anu(7o--(84#civ$3n!rA2Ry8GhsJ43r%l6uR0r1L4jMk zA-2ra+NAp9w?A5a{GlLQ+&>#aeb*vwq8K|z>mQ9ycjB?az(b{?b3UZpXfh9iigm@G*Icj;hg_ZF(z%eQHH$I(p}DT@JBdXJwl6NOoag#Vf0zd@jh#2l>}jA> z67k6rY~IsPfxxl*81UkSu*|pSfziAreJDIkN|37V92jV~-v(oiqXT7XViE>vBKh2w_xckyS}IM0zW#VSag z`>i3IT>0T^yENd|J^0QP^G9612*Sayu!u(%6_szDFfWuOu(FjVU(H>`rAj<1zq;s5 z4gX8=--G|&-HXojZP>cewotx@uTz;golWC=mNp8hY0X1UFdT2^}Z-LLr$X&Wtb+L)8j|H;#$ zCz-ZwD`=L2ECtO5r0YpTL31ROQjnob$Z{Y0rLCz!k6yOYx0~--B9n9Zc+8Bnnjr*2 zLWB6@NI`E|de@SOa1h1ug@STB)_{hMdl5TtxqNOB;?=6%;MaA)@ z1AP1dpBykyqvyp}34F*BO!#WcYHfGR>e8=%BJ|Z-#qKR{mHE>fvl(mvNH+G)MbRhvPEI;dDvc#HXo>>5|x?>0!9Ep2Ztuy1o&lxd*2WJvsd5Ogala zP~2fAqeG(%$Klb#aakL?eHa}yL;;SUuj`p?cKGZ}(*PfeZ#MBB`0&{Qm&EJHMGpZk z1NAZtm(lTF#&Fz5l-wVOv@*kHY(aBEFBaWwWuS+$$KhDF4Wmn&!jjbOF^#>@6b-B W#LTYpIh9sAX^d%Wn|<1xG5-UeHe}EM From 6dcc233ed407bd01155ba25fafc960d4d620b495 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Mon, 21 Sep 2026 21:31:14 -0400 Subject: [PATCH 15/15] docs(design): podium entire-hand contract + template resync --- design/docs-md/requirements.md | 2 +- design/docs-md/spec.md | 4 ++-- design/templates/macros.html.j2 | 29 ++++++++++++++++++++--------- design/templates/poster.html.j2 | 25 +++++++++++++++++++++---- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/design/docs-md/requirements.md b/design/docs-md/requirements.md index 239b9f0f..29339593 100644 --- a/design/docs-md/requirements.md +++ b/design/docs-md/requirements.md @@ -84,7 +84,7 @@ Each requirement is testable and traces to the [engineering spec](spec.md) (§) |---|---|---|---| | R-60 | MUST | Results are computable the moment the ride finishes: top 10 by hand with card graphics — a mixed ride ranks per kind instead, top 5 teams + top 5 solo riders — full field, DNF riders excluded from the ranking. | §5 · [resultsframe](xrc-windows.md) | | R-61 | MUST | HTML export: one self-contained file rendered with Jinja2 (autoescape, StrictUndefined; base template + macros) from frozen payload dataclasses — Tailwind 4 CSS compiled + inlined, results JSON embedded with ` comments in the samples. `htmlexport.render_poster()` renders `poster.html.j2` through that same environment and payload — the poster page (§8b's 5d content: top 3 teams over top 3 solo riders, or a solo-only field's top 5). +- **Render method.** `htmlexport.render()` builds the context dict from the dataclasses (plus the `racejson` filter that escapes `` comments in the samples. `htmlexport.render_poster()` renders `poster.html.j2` through that same environment and payload — the poster page (§8b's 5d content: top 3 teams over top 3 solo riders, or a solo-only field's top 5). The report's `podium_card` and the poster's `poster_card` each also render the entry's entire hand — every drawn card, in draw order — as the muted `All N cards, in draw order` line the full field's `drawn_row` uses, under the best-5 chips and gated on the all-cards-drawn flag. - **Tests.** Golden-file: committed payload fixtures render to committed golden pages byte-for-byte (goldens regenerate deliberately on template changes — via `tools/gen_htmlexport_goldens.py`, value-parity against the samples first, then frozen; the E6.2.2 regeneration policy, TB-5) · JSON round-trip re-parses the embedded record and asserts value-identity with the payload model · CI asserts the export holds zero external references (works from `file://`). Standings CSV (§15) ships from `rivercrossing.csvio.export_standings` (E6.4.2); a ride without a logo exports a transparent 1×1 data URI rather than an empty src (E6.2.2). ### 8b · PDF results export -One shared model, two renderers — the results-refactor decision (2026-09-12): both exporters consume the shared results model (`htmlexport.build_payload`, `htmlexport.sections`, `htmlexport.format_generated`) and render the same per-kind section plan, while each renderer keeps its own layout. `rivercrossing.pdfexport` emits a print-ready PDF with the same sections and flags as the HTML export (podium, top lists, optional laps/time boards, full field, show/hide times, all cards drawn). Engine: **fpdf2** — pure Python, zero native dependencies, so it bundles cleanly into both installers and renders deterministically (same input ⇒ byte-stable output, ideal for golden-file tests). Determinism is byte-level across OSes: streams are stored uncompressed, because deflate output is not canonical across zlib builds (the python.org Windows builds link zlib-ng, the macOS builds use the platform zlib — measured: no zlib-ng level reproduces a macOS-compressed golden), and the trailer `/ID` is suppressed for the same reason (D14 follow-up, 2026-08-29; files run ~10× larger — determinism over size). Letter/A4 selectable; header carries the organizer logo + ride name, footer "Page n of N · generated …"; card graphics drawn as rank + suit glyphs in the steel scheme. *Alternative considered:* WeasyPrint would reuse the HTML template outright (CSS paged media, headers/footers), but drags Pango/Cairo/GDK-PixBuf native libraries into the installers — revisit only if pixel-parity with the web page becomes a requirement. Browser-based converters (Playwright, wkhtmltopdf) are out: no browser dependency in a timing app. Runs off-loop like the HTML export; filename `{ride-slug}-results.pdf`. Finishing no longer publishes anything by itself — the E2 automatic export was removed in the 1.0.17 follow-up, so the operator runs a Results ▸ Export row to write a file. A "Podium poster" flag additionally emits `{ride-slug}-podium.pdf` — one celebratory page: a team event stacks the top 3 teams then the top 3 solo riders with reduced card faces so all six fit the page, and a solo event lists the top 5 solo riders at full card sizing. The same content ships as a page — `htmlexport.render_poster()` renders `poster.html.j2` to `{ride-slug}-podium.html` for Results ▸ Podium Poster HTML…, which carries its own preview row (the results page's HTML export never satisfies it). +One shared model, two renderers — the results-refactor decision (2026-09-12): both exporters consume the shared results model (`htmlexport.build_payload`, `htmlexport.sections`, `htmlexport.format_generated`) and render the same per-kind section plan, while each renderer keeps its own layout. `rivercrossing.pdfexport` emits a print-ready PDF with the same sections and flags as the HTML export (podium, top lists, optional laps/time boards, full field, show/hide times, all cards drawn). Engine: **fpdf2** — pure Python, zero native dependencies, so it bundles cleanly into both installers and renders deterministically (same input ⇒ byte-stable output, ideal for golden-file tests). Determinism is byte-level across OSes: streams are stored uncompressed, because deflate output is not canonical across zlib builds (the python.org Windows builds link zlib-ng, the macOS builds use the platform zlib — measured: no zlib-ng level reproduces a macOS-compressed golden), and the trailer `/ID` is suppressed for the same reason (D14 follow-up, 2026-08-29; files run ~10× larger — determinism over size). Letter/A4 selectable; header carries the organizer logo + ride name, footer "Page n of N · generated …"; card graphics drawn as rank + suit glyphs in the steel scheme. *Alternative considered:* WeasyPrint would reuse the HTML template outright (CSS paged media, headers/footers), but drags Pango/Cairo/GDK-PixBuf native libraries into the installers — revisit only if pixel-parity with the web page becomes a requirement. Browser-based converters (Playwright, wkhtmltopdf) are out: no browser dependency in a timing app. Runs off-loop like the HTML export; filename `{ride-slug}-results.pdf`. Finishing no longer publishes anything by itself — the E2 automatic export was removed in the 1.0.17 follow-up, so the operator runs a Results ▸ Export row to write a file. A "Podium poster" flag additionally emits `{ride-slug}-podium.pdf` — one celebratory page: a team event stacks the top 3 teams then the top 3 solo riders with reduced card faces so all six fit the page, and a solo event lists the top 5 solo riders at the larger card sizing. Every podium card also lists the entry's entire hand — every drawn card, in draw order — under its best-5 faces, gated on the all-cards-drawn flag, and both sizings are trimmed slightly further so the whole-hand lines still fit the one page. The same content ships as a page — `htmlexport.render_poster()` renders `poster.html.j2` to `{ride-slug}-podium.html` for Results ▸ Podium Poster HTML…, which carries its own preview row (the results page's HTML export never satisfies it). ### 9 · Failure matrix diff --git a/design/templates/macros.html.j2 b/design/templates/macros.html.j2 index 683f934d..5301c0ac 100644 --- a/design/templates/macros.html.j2 +++ b/design/templates/macros.html.j2 @@ -9,10 +9,17 @@ _time_row uses) while the solo rows on that board keep `#plate`. `r.draw` (R-14) is the card the row's entry drew for the venue's high-card tie-break, a pair or None: a row that drew one renders a - `draw ` badge beside the existing `tie-break` badge, in the three - row macros that already carry that badge — a resolved tie shows its - card, and `r.tie` stays reserved for the residual tie a configured - order could not separate. + `draw ` badge beside the existing `tie-break` badge, in all four + row macros (`standings_row`, `team_standings_row`, `field_row`, + `team_field_row`) and on `podium_card`, whose badge (`on_card`) carries + no tone class of its own so it inherits its card's — the dark + first-place card is light where a row's steel-700 badge would vanish. + A resolved tie shows its card, and `r.tie` stays reserved for the + residual tie a configured order could not separate. + `r.drawn` is the entry's whole hand in draw order, where `r.cards` + is the best 5. The full-field report's `drawn_row` prints it, gated + on `options.all_cards`, and `podium_card` now carries the same line + so the podium shows the whole hand too. autoescape is ON — all text renders through it; no |safe here. -#} {% set SUITS = {'s': '♠', 'h': '♥', 'd': '♦', 'c': '♣'} %} @@ -25,6 +32,10 @@ {% for c in cards %}{{ chip(c, big) }}{% endfor %} {%- endmacro %} +{% macro draw_badge(r, on_card=false) -%} +draw {{ chip(r.draw) }} +{%- endmacro %} + {% macro event_header(event, options, logo_src, logo_alt) -%}
@@ -43,15 +54,15 @@ {%- endmacro %} {% macro podium_card(r, options, show_plate=true) -%} -
{{ r.place }}
{% if show_plate %}#{{ r.plate }} {% endif %}{{ r.entry }}
{{ r.type }} · {{ r.laps }} laps{% if options.show_times %} · {{ r.total }}{% endif %}{% if r.sex %} · {{ r.sex }}{% endif %}
{{ chips(r.cards, big=true) }}
{{ r.hand }}
+
{{ r.place }}
{% if show_plate %}#{{ r.plate }} {% endif %}{{ r.entry }}
{{ r.type }} · {{ r.laps }} laps{% if options.show_times %} · {{ r.total }}{% endif %}{% if r.sex %} · {{ r.sex }}{% endif %}
{{ chips(r.cards, big=true) }}
{{ r.hand }}{% if r.draw %} {{ draw_badge(r, true) }}{% endif %}
{% if options.all_cards %}
All {{ r.drawn | length }} cards, in draw order: {{ chips(r.drawn) }}
{% endif %}
{%- endmacro %} {% macro standings_row(r, options) -%} -{{ r.place }}{{ r.plate }}{% if r.logo %}{% endif %}{{ r.entry }}{% if r.tie %} tie-break{% endif %}{% if r.draw %} draw {{ chip(r.draw) }}{% endif %}{{ r.laps }}{% if options.show_times %}{{ r.total }}{% endif %}{{ chips(r.cards) }}{{ r.hand }} +{{ r.place }}{{ r.plate }}{% if r.logo %}{% endif %}{{ r.entry }}{% if r.tie %} tie-break{% endif %}{% if r.draw %} {{ draw_badge(r) }}{% endif %}{{ r.laps }}{% if options.show_times %}{{ r.total }}{% endif %}{{ chips(r.cards) }}{{ r.hand }} {%- endmacro %} {% macro team_standings_row(r, options) -%} -{{ r.place }}{% if r.logo %}{% endif %}{{ r.entry }}{% if r.tie %} tie-break{% endif %}{% if r.draw %} draw {{ chip(r.draw) }}{% endif %}{{ r.laps }}{% if options.show_times %}{{ r.total }}{% endif %}{{ chips(r.cards) }}{{ r.hand }} +{{ r.place }}{% if r.logo %}{% endif %}{{ r.entry }}{% if r.tie %} tie-break{% endif %}{% if r.draw %} {{ draw_badge(r) }}{% endif %}{{ r.laps }}{% if options.show_times %}{{ r.total }}{% endif %}{{ chips(r.cards) }}{{ r.hand }} {%- endmacro %} {% macro laps_board(rows, options, title='Most laps', show_plate=true) -%} @@ -63,11 +74,11 @@ {%- endmacro %} {% macro field_row(r, options) -%} -{{ r.place }}{{ r.plate }}{% if r.logo %}{% endif %}{{ r.entry }}{% if r.sex %} · {{ r.sex }}{% endif %}{% if r.dnf %} dnf{% endif %}{% if r.draw %} draw {{ chip(r.draw) }}{% endif %}{{ r.type }}{{ r.laps }}{% if options.show_times %}{{ r.total }}{{ r.best_lap }}{% endif %}{{ chips(r.cards) }} {{ r.hand }} +{{ r.place }}{{ r.plate }}{% if r.logo %}{% endif %}{{ r.entry }}{% if r.sex %} · {{ r.sex }}{% endif %}{% if r.dnf %} dnf{% endif %}{% if r.draw %} {{ draw_badge(r) }}{% endif %}{{ r.type }}{{ r.laps }}{% if options.show_times %}{{ r.total }}{{ r.best_lap }}{% endif %}{{ chips(r.cards) }} {{ r.hand }} {%- endmacro %} {% macro team_field_row(r, options) -%} -{{ r.place }}{% if r.logo %}{% endif %}{{ r.entry }}{% if r.dnf %} dnf{% endif %}{{ r.laps }}{% if options.show_times %}{{ r.total }}{{ r.best_lap }}{% endif %}{{ chips(r.cards) }}{{ r.hand }} +{{ r.place }}{% if r.logo %}{% endif %}{{ r.entry }}{% if r.dnf %} dnf{% endif %}{% if r.draw %} {{ draw_badge(r) }}{% endif %}{{ r.laps }}{% if options.show_times %}{{ r.total }}{{ r.best_lap }}{% endif %}{{ chips(r.cards) }}{{ r.hand }} {%- endmacro %} {% macro drawn_row(r, span) -%} diff --git a/design/templates/poster.html.j2 b/design/templates/poster.html.j2 index c89e4349..78824f3e 100644 --- a/design/templates/poster.html.j2 +++ b/design/templates/poster.html.j2 @@ -5,8 +5,10 @@ Context (all required — StrictUndefined): event EventInfo: the ride's own meta, organizer, scorer, title and generated stamp - options ExportOptions: the export flags; only show_times - renders here (the poster shows no boards) + options ExportOptions: the export flags; show_times (the card's + trailing total) and all_cards (the whole-hand line) + render here, and nothing else (the poster shows no + boards) poster_sections (heading, rows) per section, in page order — the per-kind top three on a team event, the top five with no heading on a solo-only field. An empty row tuple @@ -15,14 +17,28 @@ fallback when the ride carries no logo, so the img never holds an empty src (D8) logo_alt organizer name for the logo + self_test_note str or None; the E6.4.3 caption rendered under the + header when the ride was finished over a failed + evaluator self-test (the results page's own note seam) compiled_css / fonts_css the vendored package assets, inlined — the |safe values on the page, like the results page's Cards are ["rank", suit] pairs with suit "j" for a joker; the chips macro takes the steel accent for hearts/diamonds/jokers (no red). + `r.draw` (R-14) is the entry's drawn tie-break card, a pair or None: + a card that drew one renders the shared `draw_badge` (the results + page's own, `on_card` tone) beside its hand prose. + `r.drawn` is the entry's whole hand in draw order. With + options.all_cards set, each card carries the full-field report's own + muted `drawn_row` line — "All N cards, in draw order" then the chips, + toned per card (`text-paper/70` on the dark first-place card, + `text-ink/55` on the plain ones). It reuses only classes the compiled + CSS already emits: gen_css.py scans base.html.j2/macros.html.j2/ + theme.css, never this file, so a poster-only class would render + unstyled with no gate failing. Regenerate the goldens deliberately only (Spec §8 tests). -#} {% import "macros.html.j2" as m with context -%} {% macro poster_card(r, options) -%} -
{{ r.place }}
{% if r.type != 'TEAM' %}#{{ r.plate }} {% endif %}{{ r.entry }}
{{ 'Team' if r.type == 'TEAM' else 'Solo' }}{% if r.sex %} ({{ r.sex }}){% endif %} — {{ r.entry }} · {{ r.laps }} laps{% if options.show_times %} · {{ r.total }}{% endif %}
{{ m.chips(r.cards, big=true) }}
{{ r.hand }}
+
{{ r.place }}
{% if r.type != 'TEAM' %}#{{ r.plate }} {% endif %}{{ r.entry }}
{{ 'Team' if r.type == 'TEAM' else 'Solo' }}{% if r.sex %} ({{ r.sex }}){% endif %} — {{ r.entry }} · {{ r.laps }} laps{% if options.show_times %} · {{ r.total }}{% endif %}
{{ m.chips(r.cards, big=true) }}
{{ r.hand }}{% if r.draw %} {{ m.draw_badge(r, true) }}{% endif %}
{% if options.all_cards %}
All {{ r.drawn | length }} cards, in draw order: {{ m.chips(r.drawn) }}
{% endif %}
{%- endmacro -%} @@ -50,7 +66,8 @@

{{ event.title }}

- + {% if self_test_note %} +

{{ self_test_note }}

{% endif %} {% for heading, rows in poster_sections %}{% if rows %}
{% if heading %}

{{ heading }}