diff --git a/design/docs-md/module-skeletons.md b/design/docs-md/module-skeletons.md index d2fd8d91..d2b42f43 100644 --- a/design/docs-md/module-skeletons.md +++ b/design/docs-md/module-skeletons.md @@ -46,8 +46,11 @@ 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; 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) @@ -196,7 +199,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 @@ -228,8 +235,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 @@ -240,6 +248,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 @@ -271,8 +281,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 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/requirements.md b/design/docs-md/requirements.md index 0b84db3f..29339593 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 @@ -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 }}

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/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/ride.py b/src/rivercrossing/ride.py index 7a99d2f1..d2417c4b 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", @@ -713,7 +720,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 +728,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 +762,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 +896,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). @@ -1112,6 +1130,73 @@ 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. + - **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 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 + 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) @@ -1303,7 +1388,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 +1681,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 +1716,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 +1764,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 +1784,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 +2049,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 +2124,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 +2237,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 +2262,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 +2302,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 +2361,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 +2419,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 +2457,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 +2523,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 +2548,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,22 +2594,304 @@ 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}, + ) + ) + + # --------------------------------- 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). @@ -2630,6 +3086,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 +3131,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 +3153,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 +3199,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 +3217,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 +3280,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. @@ -3055,11 +3536,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. @@ -3095,6 +3576,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 +3594,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 +3608,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 +3622,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,45 +3641,74 @@ 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"]), ) + 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/src/rivercrossing/roster.py b/src/rivercrossing/roster.py index 2c41d902..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 @@ -61,6 +69,7 @@ """ import random +import uuid from dataclasses import dataclass, field from enum import StrEnum from typing import TYPE_CHECKING, cast @@ -244,6 +253,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 +275,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: @@ -498,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 = ( @@ -572,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.""" @@ -608,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*. @@ -701,6 +756,26 @@ 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. 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, *self._retired): + 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. @@ -1127,10 +1202,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 @@ -1146,7 +1225,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) @@ -1157,8 +1236,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" @@ -1244,15 +1323,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. """ @@ -1260,8 +1343,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" @@ -1438,16 +1524,26 @@ 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``. + + 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) - self._log( - "dissolve_team_entry", {"plate": entry.plate, "display_name": entry.display_name} - ) + 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}) def _require_plate_free_for_change(self, new_plate: str, *, exclude: str) -> None: """Raise unless *new_plate* is free, or equal to *exclude*. @@ -1471,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 9485506f..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 @@ -191,6 +206,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 +521,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 +535,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 +550,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") @@ -695,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 @@ -898,11 +922,21 @@ 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. + 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*. """ @@ -921,62 +955,94 @@ 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, 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. 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). + 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. @@ -994,38 +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, display_name, type, team_size, status, dnf_at, notes," - " logo_card)" - " VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)", - ( - ride_id, - entry.plate, - 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. @@ -1089,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`` @@ -1097,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 [ @@ -1155,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. @@ -1451,10 +1537,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 +1562,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 +1574,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 ""), ) ) @@ -1502,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. @@ -1564,8 +1652,15 @@ 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). + 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. @@ -1573,23 +1668,27 @@ 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, display_name, type, team_size, status, dnf_at, notes," - " logo_card)" - " VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)", + " (ride_id, plate, key, display_name, type, team_size, status, dnf_at, notes," + " logo_card, retired)" + " 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"], 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..d60d8907 --- /dev/null +++ b/src/rivercrossing/store/migrations.py @@ -0,0 +1,295 @@ +# 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 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 +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 json +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 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, + 2: _migrate_v2_to_v3, +} + + +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 b411e1d5..79b2fe72 100644 --- a/src/rivercrossing/store/schema.py +++ b/src/rivercrossing/store/schema.py @@ -11,16 +11,43 @@ 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 four changes back into the CREATE: +**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, +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 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: + +- ``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 @@ -61,6 +88,8 @@ import sqlite3 +from rivercrossing.store.migrations import run_migrations + __all__ = [ "PRAGMA_STATEMENTS", "SCHEMA_STATEMENTS", @@ -72,10 +101,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 = 3 class StoreError(RuntimeError): @@ -159,6 +189,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, @@ -166,9 +197,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, @@ -252,20 +290,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 @@ -274,18 +318,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 a57807e7..abe249f5 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 @@ -2337,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 @@ -3228,6 +3236,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 +3262,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 +3409,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 +3438,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) @@ -3453,6 +3479,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 +3502,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 +3535,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 +3570,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 +3586,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 +3599,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 +3626,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 +3656,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 +3897,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 @@ -5380,9 +5430,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/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/presenters/riders.py b/src/rivercrossing/ui/presenters/riders.py index 144d14ff..4eb452cc 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). @@ -854,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 @@ -877,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/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 *, 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/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/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/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 ca63a8ec..888a6440 100644 Binary files a/tests/unit/fixtures/pdfexport/epic-2026-podium.pdf and b/tests/unit/fixtures/pdfexport/epic-2026-podium.pdf differ diff --git a/tests/unit/fixtures/pdfexport/epic-2026-results.pdf b/tests/unit/fixtures/pdfexport/epic-2026-results.pdf index 999c01af..d00331f7 100644 Binary files a/tests/unit/fixtures/pdfexport/epic-2026-results.pdf and b/tests/unit/fixtures/pdfexport/epic-2026-results.pdf differ 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..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 @@ -312,8 +321,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 +333,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 +1221,112 @@ 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) + + +# --------------------------------- 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_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_riders.py b/tests/unit/presenters/test_riders.py index 60972914..8268605a 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 @@ -1577,9 +1656,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 +1669,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 @@ -2507,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 @@ -2523,3 +2726,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/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_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) 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" 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 9ddeb68d..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 ( @@ -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, @@ -116,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() @@ -141,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", @@ -164,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 @@ -179,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 @@ -195,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( @@ -213,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 @@ -241,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 @@ -258,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 @@ -276,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 @@ -288,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( @@ -306,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 @@ -339,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", }, @@ -447,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", }, @@ -467,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)), @@ -492,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 @@ -606,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", @@ -641,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", @@ -668,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", @@ -728,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 @@ -851,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", @@ -861,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 @@ -874,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 @@ -892,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", }, @@ -900,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 @@ -917,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", }, @@ -926,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)), @@ -941,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) @@ -964,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", @@ -992,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) @@ -1006,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) @@ -1032,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) @@ -1048,3 +1099,63 @@ 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_stays_equivalent_across_a_pooled_move() -> None: + """A RUNNING pooled move leaves the live engine like a replay. + + 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: 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() + 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 (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.key) for entry in replayed._roster.entries), + ) == ( + live.crossings, + live.held_crossings(), + 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_ride_move.py b/tests/unit/test_ride_move.py new file mode 100644 index 00000000..94a69431 --- /dev/null +++ b/tests/unit/test_ride_move.py @@ -0,0 +1,918 @@ +# 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 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 + +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, + RideEngine, + UnknownPlateError, +) +from rivercrossing.roster import EntryMode, PlateModel, Rider, Roster +from rivercrossing.store import Store + +# ------------------------------------------------------------- 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 + + +# =========================== 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 96dced37..51e1ba79 100644 --- a/tests/unit/test_roster.py +++ b/tests/unit/test_roster.py @@ -27,6 +27,17 @@ 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. 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 @@ -896,6 +907,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 +984,76 @@ 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( + 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 [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( @@ -962,13 +1063,15 @@ def test_move_rider_out_of_a_solo_entry_raises_invalid_move_error() -> None: Rider(first_name="Do", last_name="", plate="4"), ], ) + roster.mark_has_data(solo) - 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"] 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 +1079,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 +2314,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) @@ -2509,6 +2710,246 @@ 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 + + +# --------------------------------------- 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 89de931c..9442621b 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 @@ -36,6 +46,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, @@ -58,6 +69,7 @@ StoreError, backup, ) +from rivercrossing.store.migrations import MIGRATIONS, run_migrations from rivercrossing.store.schema import ( SCHEMA_STATEMENTS, SCHEMA_VERSION, @@ -125,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" @@ -157,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 @@ -182,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() @@ -192,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 == 3 assert (version, rows) == (SCHEMA_VERSION, 1) @@ -209,8 +222,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() @@ -219,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")) @@ -249,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() @@ -273,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", [4, 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)) @@ -362,41 +375,1076 @@ def flaky_apply(conn: sqlite3.Connection) -> None: raise sqlite3.OperationalError("disk I/O error") real_apply(conn) - monkeypatch.setattr(store_module, "apply_pragmas", flaky_apply) - db_path = tmp_path / "store.db" + monkeypatch.setattr(store_module, "apply_pragmas", flaky_apply) + db_path = tmp_path / "store.db" + + store = store_module.Store.open(db_path) + store.close() + + assert calls["n"] >= 2 + + +def test_store_open_does_not_retry_persistent_errors( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A persistent failure surfaces immediately, no futile retries. + + The retry is bounded to transient conditions; a schema collision + ("already exists") must raise on the first attempt exactly as + before (the mid-create rollback test). + """ + calls = {"n": 0} + + def failing_apply(_conn: sqlite3.Connection) -> None: + calls["n"] += 1 + raise sqlite3.OperationalError("table ride already exists") + + monkeypatch.setattr(store_module, "apply_pragmas", failing_apply) + + with pytest.raises(sqlite3.OperationalError, match=re.escape("already exists")): + store_module.Store.open(tmp_path / "store.db") + + assert calls["n"] == 1 + + +def test_store_schema_version_mismatch_error_is_a_store_error() -> None: + """The refusal surfaces as a StoreError subclass.""" + 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 +# 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, + 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"}') + """, + """ + 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) + +# 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, 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, 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: + """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") == [(3,)] + + +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,)], [(3,)]) + + +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 <> ''"), + ) == ([(3,)], [(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"), + ) == ([(3,)], 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)) + + +# ------------------------------------------------ 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) + - store = store_module.Store.open(db_path) - store.close() +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. - assert calls["n"] >= 2 + 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() -def test_store_open_does_not_retry_persistent_errors( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A persistent failure surfaces immediately, no futile retries. + 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,)], + ) - The retry is bounded to transient conditions; a schema collision - ("already exists") must raise on the first attempt exactly as - before (the mid-create rollback test). + +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. """ - calls = {"n": 0} + db_path = tmp_path / "v2.db" + _write_v2_file( + db_path, + entries=(_V2_RETIRED_ALICE_ENTRY, _V2_DYNAMOS_ENTRY), + audit=(_V2_REASSIGN_EVENT,), + ) - def failing_apply(_conn: sqlite3.Connection) -> None: - calls["n"] += 1 - raise sqlite3.OperationalError("table ride already exists") + Store.open(db_path).close() - monkeypatch.setattr(store_module, "apply_pragmas", failing_apply) + payload = _audit_payload(db_path, 2) - with pytest.raises(sqlite3.OperationalError, match=re.escape("already exists")): - store_module.Store.open(tmp_path / "store.db") + assert (payload["old_entry_id"], payload["new_entry_id"]) == ("12", _V2_DYNAMOS_KEY) - assert calls["n"] == 1 +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. -def test_store_schema_version_mismatch_error_is_a_store_error() -> None: - """The refusal surfaces as a StoreError subclass.""" - assert issubclass(SchemaVersionMismatchError, StoreError) + 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 @@ -547,7 +1595,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 ( @@ -765,6 +1813,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 +2041,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 +2051,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 +2059,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 +2115,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 +2139,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 +2206,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() @@ -1375,7 +2438,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 ( @@ -2061,8 +3124,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 +3526,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 +3627,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 +3644,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 +3655,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 +3667,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 +3691,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 +3702,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 +3713,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 +3747,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 +3848,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 +3858,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 +3900,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" @@ -3687,3 +4819,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)} + ) 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_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 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",