From dba477deacf77790a68a0e7f46a590adfe144801 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Tue, 22 Sep 2026 23:03:11 -0400 Subject: [PATCH 1/2] =?UTF-8?q?test(results):=20DNS=20rows,=20Show=20DNS?= =?UTF-8?q?=20Riders=20toggle=20and=20unique-riders=20header=20=E2=80=94?= =?UTF-8?q?=20red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the failing tests first: ZeroLapMode DNS/HIDE/RANK partitioning in standings.rank_by_kind; the FINISHED-gated Show DNS Riders toggle through the data source, presenter and results window; blank-Place / "DNS"-laps and blank-time rendering across HTML, PDF and CSV; the sparse race-data dns flag; the DNS-excluded unique-riders header count; the poster filter and the golden-fixture census. --- .../epic-2026-results-no-times.html | 4 +- .../htmlexport/epic-2026-results-solo.html | 4 +- .../htmlexport/epic-2026-results.html | 4 +- .../fixtures/pdfexport/epic-2026-results.pdf | Bin 535443 -> 535758 bytes tests/unit/htmlexport_fixtures.py | 29 +- tests/unit/presenters/test_console.py | 79 +++- tests/unit/presenters/test_data_source.py | 103 +++++ tests/unit/presenters/test_protocols.py | 4 +- tests/unit/presenters/test_results.py | 27 +- tests/unit/presenters/test_settings.py | 38 ++ tests/unit/test_csvio.py | 70 ++++ tests/unit/test_gen_htmlexport_goldens.py | 59 +++ tests/unit/test_htmlexport.py | 265 ++++++++++++- tests/unit/test_payload.py | 69 ++++ tests/unit/test_pdfexport.py | 230 +++++++++++- tests/unit/test_standings.py | 205 ++++++++++ tests/unit/test_xrc_structure.py | 30 +- tests/unit/ui/test_app_exports.py | 240 +++++++++++- tests/unit/ui/test_app_publish_menu.py | 10 +- tests/unit/ui/test_commands.py | 13 +- tests/unit/ui/test_menu_state.py | 1 + tests/unit/ui/test_results_win.py | 354 +++++++++++++++++- tests/unit/ui/test_settings_contract.py | 17 + 23 files changed, 1806 insertions(+), 49 deletions(-) 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 763ddf0e..f7b47b70 100644 --- a/tests/unit/fixtures/htmlexport/epic-2026-results-no-times.html +++ b/tests/unit/fixtures/htmlexport/epic-2026-results-no-times.html @@ -56,8 +56,8 @@

GORBA EPIC

Sunday September 20, 2026 · Guelph Lake MTB Trails · 8 km closed loop · riding 10:00–16:00

diff --git a/tests/unit/fixtures/htmlexport/epic-2026-results-solo.html b/tests/unit/fixtures/htmlexport/epic-2026-results-solo.html index 7b30af12..29c8e811 100644 --- a/tests/unit/fixtures/htmlexport/epic-2026-results-solo.html +++ b/tests/unit/fixtures/htmlexport/epic-2026-results-solo.html @@ -56,8 +56,8 @@

GORBA EPIC

Sunday September 20, 2026 · Guelph Lake MTB Trails · 8 km closed loop · riding 10:00–16:00

diff --git a/tests/unit/fixtures/htmlexport/epic-2026-results.html b/tests/unit/fixtures/htmlexport/epic-2026-results.html index f1a74418..7d50eaac 100644 --- a/tests/unit/fixtures/htmlexport/epic-2026-results.html +++ b/tests/unit/fixtures/htmlexport/epic-2026-results.html @@ -56,8 +56,8 @@

GORBA EPIC

Sunday September 20, 2026 · Guelph Lake MTB Trails · 8 km closed loop · riding 10:00–16:00

diff --git a/tests/unit/fixtures/pdfexport/epic-2026-results.pdf b/tests/unit/fixtures/pdfexport/epic-2026-results.pdf index d00331f799bc5dae17ab6a28439bdff4138e6b2d..b15ecba998ca139b28d25ff7eddb6a22ea4bf09c 100644 GIT binary patch delta 7088 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 NIASc8k>f_i{2$8Hrey#C diff --git a/tests/unit/htmlexport_fixtures.py b/tests/unit/htmlexport_fixtures.py index beab71f3..69eb07e7 100644 --- a/tests/unit/htmlexport_fixtures.py +++ b/tests/unit/htmlexport_fixtures.py @@ -23,6 +23,7 @@ import json import re +from dataclasses import replace from pathlib import Path from rivercrossing.htmlexport import RacePayload, _payload_from_record @@ -44,6 +45,22 @@ GOLDEN_NO_TIMES = FIXTURES_DIR / "epic-2026-results-no-times.html" GOLDEN_SOLO = FIXTURES_DIR / "epic-2026-results-solo.html" +# The header's unique-rider census per fixture file, the figure the +# golden pages were frozen with. It cannot be read from the fixture +# JSON: each sample's record carries a 14-row (7-row on the solo page) +# SUBSET of the ride's 180 entries, so no row-derived count can be +# right. The values mirror ``tools/gen_htmlexport_goldens.py``'s +# ``GOLDEN_SPECS`` (the tool cannot import test code), and +# ``test_gen_htmlexport_goldens.py`` pins the two tables equal: 262 +# riders for 180 entries is the ride's own documented census +# (``ui-designs-retired.md``), and a solo-only field of 180 entries has +# 180 riders. +GOLDEN_RIDERS: dict[str, int] = { + "payload-times.json": 262, + "payload-no-times.json": 262, + "payload-solo.json": 180, +} + _RACE_DATA_RE = re.compile( r'', re.DOTALL ) @@ -69,5 +86,13 @@ def parse_race_data(path: Path) -> dict[str, object]: def load_race_payload(path: Path) -> RacePayload: - """Read one committed ``payload-*.json`` fixture as a payload.""" - return _payload_from_record(json.loads(path.read_text(encoding="utf-8"))) + """Read one committed ``payload-*.json`` fixture as a payload. + + The payload carries its fixture's :data:`GOLDEN_RIDERS` census: the + golden pages were frozen with that count in their header, and the + count is render-only, so the fixture files themselves (the samples' + records, key for key) cannot hold it. Without this the byte-for-byte + golden comparisons would render ``· 0`` against the frozen header. + """ + payload = _payload_from_record(json.loads(path.read_text(encoding="utf-8"))) + return replace(payload, event=replace(payload.event, riders=GOLDEN_RIDERS.get(path.name, 0))) diff --git a/tests/unit/presenters/test_console.py b/tests/unit/presenters/test_console.py index a0a9fae7..8eecabdc 100644 --- a/tests/unit/presenters/test_console.py +++ b/tests/unit/presenters/test_console.py @@ -642,7 +642,12 @@ def test_engine_data_source_standings_maps_the_ranked_snapshot() -> None: def test_engine_data_source_standings_splits_teams_from_solo() -> None: - """A mixed roster ranks each kind in its own section from 1.""" + """A mixed roster ranks each kind in its own section from 1. + + Phase 7: each section numbers its own placed rows from 1 and keeps + its own DNS tail, so the 0-lap team and the 0-lap solo rider each + land unplaced (``place = 0``) at the bottom of their own list. + """ roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.RIDER_POOLED) roster.create_solo_entry(first_name="Rider", last_name="12", plate="12") roster.create_solo_entry(first_name="Rider", last_name="34", plate="34") @@ -659,12 +664,11 @@ def test_engine_data_source_standings_splits_teams_from_solo() -> None: engine.finish() source = EngineDataSource(engine, roster) - teams, solo = source.standings() + teams, solo = source.standings(show_dns_riders=True) - assert [row.plate for row in teams] == ["77"] - assert [row.place for row in teams] == [1] + assert [(row.plate, row.place, row.dns) for row in teams] == [("77", 0, True)] assert {row.plate for row in solo} == {"12", "34"} - assert [row.place for row in solo] == [1, 2] + assert [(row.place, row.dns) for row in solo] == [(1, False), (0, True)] def test_engine_data_source_standings_omitted_order_uses_the_default_constant() -> None: @@ -708,6 +712,23 @@ def _engine_with_a_hand_tie() -> RideEngine: return engine +def _one_lap_and_a_tied_hand_each( + engine: RideEngine, clock: _FakeDatetimeClock, plates: list[str] +) -> None: + """Give every plate in *plates* one lap and the same five cards. + + The wide-tie arrange the draw tests need: each entry starts (so + none is a Phase 7 DNS row) yet every credited hand is the same + pair-plus-kickers, which is what makes one tie group larger than + the 52-card draw deck. The loop lives here, in a helper, so the + test bodies stay single-Act (T-8). + """ + tied = [Card.parse(code) for code in ("5H", "5D", "2C", "3C", "4C")] + for plate in plates: + _record(engine, clock, plate, lap_time_s=100) + _seed_credited_hand(engine, plate, tied) + + def test_engine_data_source_standings_live_ride_ignores_the_stored_order() -> None: """A RUNNING ride auto-ranks by laps then time, not stored order. @@ -780,6 +801,41 @@ def test_engine_data_source_standings_rows_carry_the_drawn_card_codes() -> None: assert {row.plate: row.tiebreak_card for row in rows} == {"12": "5H", "34": "AH"} +def test_engine_data_source_standings_finished_dns_rows_carry_no_drawn_card() -> None: + """Phase 7: a DNS row shows no draw, though the finish made one. + + Two 0-lap entries tie on their empty hands, so ``finish`` records a + card against each of them; their rows are DNS (unplaced, never + started), so the Draw cell stays blank while the lapped pair's own + resolved draw (R-14) is untouched. + """ + roster = _roster_with_entries("12", "34", "56", "78") + engine, clock = _make_engine(roster=roster) + engine.start() + _record(engine, clock, "12", lap_time_s=100) + _record(engine, clock, "34", lap_time_s=60) + _record(engine, clock, "12", lap_time_s=100) + tied = [Card.parse(code) for code in ("5H", "5D", "2C", "3C", "4C")] + _seed_credited_hand(engine, "12", tied) + _seed_credited_hand(engine, "34", tied) + engine.finish() + source = EngineDataSource(engine, engine._roster) + drawn = { + result.plate: result.tiebreak_card.code() + for result in engine.snapshot() + if result.tiebreak_card is not None + } + + _teams, rows = source.standings() + + assert {row.plate: row.tiebreak_card for row in rows} == { + "12": drawn["12"], + "34": drawn["34"], + "56": "", + "78": "", + } + + def test_engine_data_source_standings_given_an_undrawn_pair_leaves_the_draw_blank() -> None: """T-4 nullable: a live ride's tied pair has drawn nothing yet.""" engine = _engine_with_a_hand_tie() @@ -857,7 +913,7 @@ def test_engine_data_source_standings_given_a_zero_card_entry_renders_a_blank_ha engine.finish() source = EngineDataSource(engine, engine._roster) - teams, rows = source.standings() + teams, rows = source.standings(show_dns_riders=True) by_plate = {row.plate: row for row in rows} assert by_plate["12"].hand != "" # one credited card has a real prose hand @@ -874,16 +930,17 @@ def test_engine_data_source_standings_given_more_ties_than_the_deck_keeps_the_dr partly drawn, which keeps the configured order's high-card barrier exactly where it was: every row keeps the ⚠ flag and its note -- while the 52 that did draw still carry their card, which is what - the Draw column shows. 53 entries with no crossings all tie on the - empty hand. + the Draw column shows. 53 entries with the same five cards tie on + hand (Phase 7: each rides a lap, so none is a DNS row). """ roster = _roster_with_entries(*[str(plate) for plate in range(1, 54)]) - engine, _ = _make_engine(roster=roster) + engine, clock = _make_engine(roster=roster) engine.start() + _one_lap_and_a_tied_hand_each(engine, clock, [str(plate) for plate in range(1, 54)]) engine.finish() source = EngineDataSource(engine, engine._roster) - _teams, rows = source.standings() + _teams, rows = source.standings(show_dns_riders=True) assert len(rows) == 53 assert {row.place for row in rows} == {1} @@ -3288,7 +3345,7 @@ def test_standings_given_reopened_zero_card_entry_renders_blank_hand_again() -> engine.finish() source = EngineDataSource(engine, engine._roster) - _teams, rows = source.standings() + _teams, rows = source.standings(show_dns_riders=True) by_plate = {row.plate: row for row in rows} assert by_plate["12"].hand != "" diff --git a/tests/unit/presenters/test_data_source.py b/tests/unit/presenters/test_data_source.py index 00ca31c7..c1f35993 100644 --- a/tests/unit/presenters/test_data_source.py +++ b/tests/unit/presenters/test_data_source.py @@ -1330,3 +1330,106 @@ def test_feed_rows_given_a_move_that_restores_a_short_lap_adds_the_review_item() [("7", ""), ("9", "")], [("45", "Team overlap"), ("7", ""), ("9", "")], ) + + +# ------------------------------------------- the DNS results display +# The Results menu's "Show DNS Riders" reaches this source as +# ``show_dns_riders`` (checked by default). It only bites on a FINISHED +# ride: a live board (DRAFT/RUNNING/REOPENED) ranks everyone normally, +# so the operator never watches rows vanish mid-ride. + + +def _finished_two_entry_ride() -> tuple[EngineDataSource, RideEngine]: + """Return a FINISHED two-team ride source and its engine. + + One team crossed twice, the other never: the 0-lap ACTIVE entry + the toggle decides about. + """ + roster = _two_team_pooled_roster() + engine = _running_engine(roster) + engine.record_crossing("45", at=_dt(10, 0, 5)) + engine.record_crossing("9", at=_dt(10, 0, 9)) + engine.finish() + return EngineDataSource(engine, roster), engine + + +def test_standings_given_a_finished_ride_and_dns_riders_on_appends_the_dns_team() -> None: + """Checked: the 0-lap team renders last, unplaced and marked DNS.""" + source, _engine = _finished_two_entry_ride() + + teams, _solo = source.standings(show_dns_riders=True) + + assert [(row.entry, row.laps, row.place, row.dns) for row in teams] == [ + ("Dirt Dynamos", 2, 1, False), + ("Trail Blazers", 0, 0, True), + ] + + +def test_standings_given_a_finished_ride_and_dns_riders_omitted_shows_them() -> None: + """Omitting the flag is the checked menu item's own reading.""" + source, _engine = _finished_two_entry_ride() + + teams, _solo = source.standings() + + assert [(row.entry, row.dns) for row in teams] == [ + ("Dirt Dynamos", False), + ("Trail Blazers", True), + ] + + +def test_standings_given_a_finished_ride_and_dns_riders_off_hides_the_dns_team() -> None: + """Unchecked drops the 0-lap team and renumbers the rest from 1.""" + source, _engine = _finished_two_entry_ride() + + teams, _solo = source.standings(show_dns_riders=False) + + assert [(row.entry, row.laps, row.place, row.dns) for row in teams] == [ + ("Dirt Dynamos", 2, 1, False) + ] + + +def test_standings_given_a_finished_solo_ride_and_dns_riders_on_marks_the_dns_row() -> None: + """The solo section keeps its own DNS tail, unplaced and last.""" + roster = _roster_with_entries("12", "34") + engine = _running_engine(roster) + engine.record_crossing("12", at=_dt(10, 0, 5)) + engine.finish() + source = EngineDataSource(engine, roster) + + _teams, solo = source.standings(show_dns_riders=True) + + assert [(row.plate, row.laps, row.place, row.dns) for row in solo] == [ + ("12", 1, 1, False), + ("34", 0, 0, True), + ] + + +def test_standings_given_a_live_ride_ranks_the_zero_lap_team_whatever_the_toggle() -> None: + """A RUNNING board is never filtered: rows never vanish.""" + roster = _two_team_pooled_roster() + engine = _running_engine(roster) + engine.record_crossing("45", at=_dt(10, 0, 5)) + source = EngineDataSource(engine, roster) + + teams, _solo = source.standings(show_dns_riders=False) + + assert [(row.entry, row.laps, row.place, row.dns) for row in teams] == [ + ("Dirt Dynamos", 1, 1, False), + ("Trail Blazers", 0, 2, False), + ] + + +def test_standings_given_a_stopped_ride_keeps_the_zero_lap_team_placed() -> None: + """Stop is not Finish: the board stays live until closed.""" + roster = _two_team_pooled_roster() + engine = _running_engine(roster) + engine.record_crossing("45", at=_dt(10, 0, 5)) + engine.stop() + source = EngineDataSource(engine, roster) + + teams, _solo = source.standings(show_dns_riders=False) + + assert [(row.entry, row.laps, row.place, row.dns) for row in teams] == [ + ("Dirt Dynamos", 1, 1, False), + ("Trail Blazers", 0, 2, False), + ] diff --git a/tests/unit/presenters/test_protocols.py b/tests/unit/presenters/test_protocols.py index 0b4db9c4..65bb7fa9 100644 --- a/tests/unit/presenters/test_protocols.py +++ b/tests/unit/presenters/test_protocols.py @@ -358,8 +358,10 @@ def riders(self) -> list[RiderRow]: def standings( self, - # DataSource's signature; the fake ignores order + # DataSource's signature; the fake ignores order and the toggle order: tuple[TieBreak, ...] = DEFAULT_TIEBREAK_ORDER, # noqa: ARG002 + *, + show_dns_riders: bool = True, # noqa: ARG002 -- DataSource's signature ) -> tuple[list[StandingsRow], list[StandingsRow]]: """Return one fixed team standings row for any order.""" return [ diff --git a/tests/unit/presenters/test_results.py b/tests/unit/presenters/test_results.py index 885760fe..eb299fed 100644 --- a/tests/unit/presenters/test_results.py +++ b/tests/unit/presenters/test_results.py @@ -95,20 +95,25 @@ def __init__(self) -> None: """Start with no pre-loaded rows and no recorded orders.""" super().__init__() self.standings_orders: list[tuple[TieBreak, ...]] = [] + self.standings_dns_riders: list[bool] = [] self.teams_by_order: dict[tuple[TieBreak, ...], list[StandingsRow]] = {} self.rows_by_order: dict[tuple[TieBreak, ...], list[StandingsRow]] = {} self.stale_result: bool = False self.stale_queries: list[int | None] = [] def standings( - self, order: tuple[TieBreak, ...] = DEFAULT_TIEBREAK_ORDER + self, + order: tuple[TieBreak, ...] = DEFAULT_TIEBREAK_ORDER, + *, + show_dns_riders: bool = True, ) -> tuple[list[StandingsRow], list[StandingsRow]]: - """Record *order*, then return the sections pre-loaded for it. + """Record *order* and *show_dns_riders*, then the sections. ``rows_by_order`` holds the solo section; ``teams_by_order`` the (usually empty) teams section -- Phase 3's two-list shape. """ self.standings_orders.append(order) + self.standings_dns_riders.append(show_dns_riders) return list(self.teams_by_order.get(order, [])), list(self.rows_by_order.get(order, [])) def results_stale(self, export_watermark: int | None) -> bool: @@ -214,6 +219,24 @@ def test_results_presenter_holds_the_view_and_data_source_it_was_given() -> None assert presenter.data_source is source +def test_results_presenter_init_defaults_show_dns_riders_to_on() -> None: + """Omitted, the toggle reaches the source checked.""" + source = RecordingResultsSource() + + ResultsPresenter(RecordingResultsView(), source) + + assert source.standings_dns_riders == [True] + + +def test_results_presenter_init_given_show_dns_riders_forwards_it_to_the_source() -> None: + """The Results menu's toggle reaches the data source's display.""" + source = RecordingResultsSource() + + ResultsPresenter(RecordingResultsView(), source, show_dns_riders=False) + + assert source.standings_dns_riders == [False] + + # --------------------------------------- E7.3.2 stale-export flag diff --git a/tests/unit/presenters/test_settings.py b/tests/unit/presenters/test_settings.py index f92e4b10..9c4b9550 100644 --- a/tests/unit/presenters/test_settings.py +++ b/tests/unit/presenters/test_settings.py @@ -59,6 +59,9 @@ "publish_time_board", "publish_full_field", "publish_all_cards", + # The Results-menu row's sixth toggle: also drives the Standings + # window, so it is not a ``publish_*`` name. + "show_dns_riders", } # G6: the defaults the results dialog's five checkboxes used to declare @@ -126,6 +129,39 @@ def test_default_settings_publish_flags_are_the_retired_xrc_checkbox_defaults() ) == _PUBLISH_DEFAULTS +def test_default_settings_show_dns_riders_is_on() -> None: + """The Results menu's "Show DNS Riders" ships checked.""" + assert default_settings().show_dns_riders is True + + +@pytest.mark.parametrize("stored", [True, False]) +def test_save_then_load_round_trips_the_dns_riders_toggle( + tmp_path: Path, + stored: bool, # noqa: FBT001 -- a parametrize row's value, not a call-site bool +) -> None: + """The Results menu row's sixth toggle survives a round trip.""" + path = tmp_path / "settings.json" + original = replace(default_settings(), show_dns_riders=stored) + + save_settings(original, path) + loaded = load_settings(path) + + assert loaded.show_dns_riders is stored + + +@pytest.mark.parametrize("stored", ["yes", 1, None, [], {}]) +def test_load_settings_non_bool_dns_riders_value_uses_the_default( + tmp_path: Path, stored: object +) -> None: + """T-4: a non-bool DNS-toggle value is corrupt for its field.""" + path = tmp_path / "settings.json" + path.write_text(json.dumps({"show_dns_riders": stored}), encoding="utf-8") + + loaded = load_settings(path) + + assert loaded.show_dns_riders is True + + def test_save_then_load_round_trips_the_publish_flags(tmp_path: Path) -> None: """G6: every publish flag survives a save/load round trip.""" path = tmp_path / "settings.json" @@ -164,6 +200,7 @@ def test_load_settings_missing_the_publish_keys_uses_the_defaults(tmp_path: Path loaded.publish_full_field, loaded.publish_all_cards, ) == _PUBLISH_DEFAULTS + assert loaded.show_dns_riders is True @pytest.mark.parametrize("stored", ["yes", 1, None, [], {}]) @@ -785,6 +822,7 @@ def test_appearance_for_radio_given_each_radio_state_returns_its_spelling( publish_time_board=st.booleans(), publish_full_field=st.booleans(), publish_all_cards=st.booleans(), + show_dns_riders=st.booleans(), ) diff --git a/tests/unit/test_csvio.py b/tests/unit/test_csvio.py index ba628020..a1092c57 100644 --- a/tests/unit/test_csvio.py +++ b/tests/unit/test_csvio.py @@ -3038,6 +3038,76 @@ def test_export_standings_zero_card_hand_writes_a_blank_hand() -> None: assert _read_lines(path)[1] == "1,9,Rider,solo,,0,," +def test_export_standings_dns_row_writes_a_blank_place_and_dns_laps() -> None: + """Phase 7: a DNS row's place is empty and its laps read "DNS".""" + placed = [ + _placed("88", "9S 9D 9C 9H 2C", laps=11), + replace(_placed("9", "", laps=0), place=0, dns=True), + ] + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / "standings.csv" + export_standings(placed, path) + lines = _read_lines(path) + + assert lines[2] == ",9,Rider,solo,,DNS,," + + +def test_export_standings_dns_row_blanks_the_total_time_cell() -> None: + """Phase 7: a DNS entry never started, so its time cell is blank. + + A non-zero stored reading (1200.0) is used deliberately: the cell + goes empty for the row's own reason, not because the number was + already zero. + """ + placed = [replace(_placed("9", "", laps=0, total_time=1200.0), place=0, dns=True)] + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / "standings.csv" + export_standings(placed, path, show_times=True) + row = _read_lines(path)[1] + + assert row == ",9,Rider,solo,,DNS,,," + + +def test_export_standings_placed_row_keeps_its_total_time_cell() -> None: + """T-3: an ordinary row still writes its numeric seconds.""" + placed = [_placed("88", "9S 9D 9C 9H 2C", laps=11, total_time=1200.0)] + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / "standings.csv" + export_standings(placed, path, show_times=True) + row = _read_lines(path)[1] + + assert row == "1,88,Rider,solo,,11,Four of a Kind — Nines,,1200.0" + + +def test_export_standings_placed_row_is_untouched_by_the_dns_rule() -> None: + """T-3: an ordinary row still writes its place and lap count.""" + placed = [_placed("88", "9S 9D 9C 9H 2C", laps=11)] + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / "standings.csv" + export_standings(placed, path) + row = _read_lines(path)[1] + + assert row == "1,88,Rider,solo,,11,Four of a Kind — Nines," + + +def test_export_standings_dns_row_writes_no_draw_code() -> None: + """Phase 7: a DNS entry never started, so it drew no card. + + A 0-lap entry's empty hand ties the finish's own draw, so its + snapshot carries one; the DNS row must leave the column blank. + """ + dns = replace(_placed("9", "", laps=0), place=0, dns=True) + placed = [ + replace(dns, result=replace(dns.result, tiebreak_card=Card.parse("7D"))), + ] + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / "standings.csv" + export_standings(placed, path) + row = _read_lines(path)[1] + + assert row == ",9,Rider,solo,,DNS,," + + # ========================================================= T-7 property diff --git a/tests/unit/test_gen_htmlexport_goldens.py b/tests/unit/test_gen_htmlexport_goldens.py index 2621196d..6ab3e47d 100644 --- a/tests/unit/test_gen_htmlexport_goldens.py +++ b/tests/unit/test_gen_htmlexport_goldens.py @@ -22,6 +22,7 @@ from pathlib import Path from types import ModuleType # noqa: TC003 -- used at runtime as a return type here +import htmlexport_fixtures import pytest from htmlexport_fixtures import ( GOLDEN_NO_TIMES, @@ -113,6 +114,64 @@ def test_generated_fixture_json_parses_to_the_sample_record() -> None: assert record == parse_race_data(TIMES_SAMPLE) +# --------------------------------------- the census rider count +# +# ``race-data`` carries no rider count (its nine event keys are frozen) +# and its ``results`` rows are a SUBSET of the field (14 of the ride's +# 180 entries), so no row-derived count can be right: the goldens show +# the ride's own documented census instead -- 262 riders for 180 +# entries (``ui-designs-retired.md``: "180 entries · 262 riders"), and +# 180 for the solo-only sample, where entries and riders are the same +# people. The count reaches the rendered page only -- the embedded +# record stays exactly the sample's own. + + +@pytest.mark.parametrize( + ("sample", "expected_riders"), + [ + (TIMES_SAMPLE, 262), + (NO_TIMES_SAMPLE, 262), + (SOLO_SAMPLE, 180), + ], + ids=["times", "no-times", "solo"], +) +def test_payload_and_record_carries_the_documented_census( + sample: Path, expected_riders: int +) -> None: + """Each sample's payload carries its documented census.""" + payload, record = gen_goldens.payload_and_record(gen_goldens.DEFAULT_SAMPLES_DIR, sample.name) + + assert payload.event.riders == expected_riders + assert "riders" not in record["event"] + + +def test_written_golden_renders_the_census_rider_count(tmp_path: Path) -> None: + """The frozen page shows the census, never the placeholder 0.""" + gen_goldens.write_goldens(gen_goldens.DEFAULT_SAMPLES_DIR, tmp_path) + + html = (tmp_path / "epic-2026-results.html").read_text(encoding="utf-8") + + assert "180 · 1124 · 1092 · 262" in html + assert "unique riders" in html + + +def test_census_table_matches_the_generators_spec() -> None: + """T-3: the fixture loader's census is the generator's own. + + ``load_race_payload`` applies the same per-sample value the goldens + were frozen with; the two tables are separate constants (the tool + cannot import test code), so this pins them equal -- drift would + otherwise show up only as a byte-for-byte golden failure. + """ + mismatches = { + fixture_name: spec_riders + for _sample, fixture_name, _golden, spec_riders in gen_goldens.GOLDEN_SPECS + if htmlexport_fixtures.GOLDEN_RIDERS[fixture_name] != spec_riders + } + + assert mismatches == {} + + # ----------------------------------------------------------- CLI modes diff --git a/tests/unit/test_htmlexport.py b/tests/unit/test_htmlexport.py index 066facad..46bcdb25 100644 --- a/tests/unit/test_htmlexport.py +++ b/tests/unit/test_htmlexport.py @@ -506,12 +506,28 @@ def test_render_public_builds_valid_page_from_minimal_fake_ride() -> None: assert "Saturday June 6, 2026 · Test Venue · 8 km loop" in html assert "Organizer: Test Org" in html assert "Scorer: T. Ester" in html - assert "2 · 21 · 10" in html + assert "2 · 21 · 10 · 0" in html + assert "entries · laps · cards dealt · unique riders" in html assert "#88 Moss Ridge Riders" in html record = json.loads(race_data_block(html)) assert record["event"]["entries"] == 2 assert record["results"][0]["plate"] == 88 assert record["results"][0]["total"] == "5:32:00" + assert "riders" not in record["event"] + + +def test_render_public_given_a_rider_count_renders_it_in_the_header() -> None: + """``riders`` is the header's fourth counter.""" + html = render(_StubRide(), _placed_pair(), ExportOptions(), riders=207) + + assert "2 · 21 · 10 · 207" in html + + +def test_render_public_omitted_rider_count_renders_zero_in_the_header() -> None: + """A caller that threads no count renders 0, never a blank cell.""" + html = render(_StubRide(), _placed_pair(), ExportOptions()) + + assert "2 · 21 · 10 · 0" in html def test_render_public_defaults_generated_to_samples_style() -> None: @@ -662,6 +678,20 @@ def _drawn_placed(*, code: str = "AH", kind: str = "solo", place: int = 1) -> tu return (Placed(place=place, result=result, tie_note=None, draw_required=False),) +def _drawn_dns_placed( # noqa: PLR0913 -- the row's own (plate, name, code, kind) + plate: str, name: str, code: str, *, kind: str = "solo" +) -> Placed: + """Build a DNS row whose result still carries a finish-time draw. + + Every 0-lap entry's empty hand ties every other empty hand, so + ``RideEngine.finish`` records a tiebreak draw for each and the card + survives on the snapshot's :attr:`EntryResult.tiebreak_card`. A DNS + row (never placed, never started) must render none of it. + """ + dns = _dns_placed(plate, name, kind=kind) + return replace(dns, result=replace(dns.result, tiebreak_card=Card.parse(code))) + + # The podium card's own badge form: with no text class of its own it # inherits the card's tone (light on the dark first-place card), where # the table rows' badge states the steel-700 body tone. @@ -1632,6 +1662,195 @@ def test_sections_never_exceeds_the_per_kind_caps(team_count: int, solo_count: i assert len(plan.laps_solo) <= (5 if team_count else 10) +# ------------------------------------------------- DNS rows (Phase 7) +# A DNS row is a 0-lap ACTIVE entry on a FINISHED ride: it keeps its +# row at the bottom of the Full field (blank Place, ``DNS`` Laps) and +# carries ``laps: 0`` plus a sparse ``"dns": true`` in the record -- but +# it was never placed, so the podiums, the top lists and the boards +# leave it off. + + +def _dns_placed(plate: str, name: str, *, kind: str = "solo") -> Placed: + """Build one DNS row (Phase 7): unplaced, 0 laps, no tie note.""" + return Placed( + place=0, + result=_sample_entry(plate, name, 0, kind=kind), + tie_note=None, + draw_required=False, + dns=True, + ) + + +def test_sections_given_a_dns_row_keeps_it_in_the_full_field_only() -> None: + """The DNS rows stay in the full field; no podium or top list.""" + placed = ( + *_field(teams=2, solo=2), + _dns_placed("31", "Rita Slow"), + _dns_placed("77", "Late Starters", kind="team"), + ) + payload = build_payload(_StubRide(), placed, ExportOptions(), _FIXTURE_GENERATED) + + plan = sections(payload, placed) + + assert [row.entry for row in plan.solo] == ["Solo 1", "Solo 2", "Rita Slow"] + assert [row.entry for row in plan.podium_solo] == ["Solo 1", "Solo 2"] + assert [row.entry for row in plan.top_solo] == ["Solo 1", "Solo 2"] + assert [row.entry for row in plan.podium_teams] == ["Team 1", "Team 2"] + assert [row.entry for row in plan.top_teams] == ["Team 1", "Team 2"] + + +def test_sections_given_a_dns_row_leaves_it_off_the_laps_boards() -> None: + """The most-laps boards rank started entries only.""" + placed = (*_field(teams=2, solo=2), _dns_placed("31", "Rita Slow")) + payload = build_payload( + _StubRide(), placed, ExportOptions(laps_board=True), _FIXTURE_GENERATED + ) + + plan = sections(payload, placed) + + assert [row.entry for row in plan.laps_teams] == ["Team 1", "Team 2"] + assert [row.entry for row in plan.laps_solo] == ["Solo 1", "Solo 2"] + + +def test_build_payload_given_a_dns_row_keeps_it_off_the_record_boards() -> None: + """The record's own boards carry no DNS row either.""" + placed = (*_field(teams=2, solo=2), _dns_placed("31", "Rita Slow")) + opts = ExportOptions(show_times=True, time_board=True, laps_board=True) + + payload = build_payload(_StubRide(), placed, opts, _FIXTURE_GENERATED) + + assert [row.entry for row in payload.laps_board] == [ + "Team 1", + "Team 2", + "Solo 1", + "Solo 2", + ] + assert [row.entry for row in payload.time_board] == [ + "Team 1", + "Team 2", + "Solo 1", + "Solo 2", + ] + + +def test_build_payload_given_a_dns_row_records_it_last_with_laps_zero() -> None: + """The record keeps the DNS row, after every placed row.""" + placed = (*_field(teams=2, solo=2), _dns_placed("31", "Rita Slow")) + + payload = build_payload(_StubRide(), placed, ExportOptions(), _FIXTURE_GENERATED) + + assert [(row.entry, row.laps, row.dns) for row in payload.results] == [ + ("Team 1", 30, False), + ("Team 2", 29, False), + ("Solo 1", 20, False), + ("Solo 2", 19, False), + ("Rita Slow", 0, True), + ] + + +def test_sections_given_only_dns_teams_keeps_the_mixed_solo_cap() -> None: + """Phase 7: a DNS team still makes the field a team event. + + The solo top list and its laps board must share one cap: sizing + the list off the *ranked* teams (empty when every team is DNS) + would silently widen it from five to ten. + """ + placed = (_dns_placed("77", "Late Starters", kind="team"), *_field(teams=0, solo=7)) + payload = build_payload( + _StubRide(), placed, ExportOptions(laps_board=True), _FIXTURE_GENERATED + ) + + plan = sections(payload, placed) + + assert len(plan.top_solo) == 5 + assert len(plan.laps_solo) == 5 + + +def test_render_public_given_a_dns_row_blanks_its_place_and_renders_dns_laps() -> None: + """The full field prints no place and the word "DNS".""" + placed = (*_placed_pair(), _dns_placed("31", "Rita Slow")) + + html = render(_StubRide(), placed, ExportOptions()) + + assert '31' in html + assert ">DNS" in html + + +def test_render_public_given_a_dns_row_records_laps_zero_and_the_dns_flag() -> None: + """The embedded record keeps the laps and the sparse flag.""" + placed = (*_placed_pair(), _dns_placed("31", "Rita Slow")) + + html = render(_StubRide(), placed, ExportOptions()) + + record = json.loads(race_data_block(html)) + row = next(result for result in record["results"] if result["plate"] == 31) + assert (row["laps"], row["place"], row["dns"]) == (0, 0, True) + + +def test_build_payload_given_a_drawn_dns_row_records_no_drawn_card() -> None: + """Phase 7: a DNS entry never started, so it drew nothing. + + A 0-lap entry's empty hand still ties the finish's draw, so its + snapshot carries a card -- the row must not hand it on as ``draw``. + """ + placed = (_drawn_dns_placed("31", "Rita Slow", "7D"),) + + payload = build_payload(_StubRide(), placed, ExportOptions(), _FIXTURE_GENERATED) + + assert payload.results[0].draw is None + + +def test_render_public_given_a_drawn_dns_row_renders_no_draw_badge() -> None: + """Phase 7: the DNS row's finish-time draw never renders.""" + placed = (*_drawn_placed(), _drawn_dns_placed("31", "Rita Slow", "7D")) + + html = render(_StubRide(), placed, ExportOptions()) + + assert 'draw A ♥' in html + assert 'draw 7 ♦' not in html + + +def test_render_public_given_a_dns_row_blanks_its_time_cells() -> None: + """Phase 7: a DNS entry never started, so its time cells are blank. + + The columns stay (the table's shape never moves); only the two + ``t-col`` cells go empty, where a placed row still prints its total + and best lap. The DNS row's own would-be readings are distinctive + here (20:00 / 15:00), so their absence is the cells going empty + rather than the whole row vanishing -- and the embedded record's + own null times agree. + """ + dns = _dns_placed("31", "Rita Slow") + placed = ( + *_placed_pair(), + replace(dns, result=replace(dns.result, total_time=1_200.0, best_lap=900.0)), + ) + + html = render(_StubRide(), placed, ExportOptions(show_times=True)) + + assert '' in html + assert "5:32:00" in html # the placed pair's own total is untouched + assert "20:00" not in html # the DNS row's would-be total + assert "15:00" not in html # ... and best lap + + +def test_build_payload_given_a_dns_row_records_null_times() -> None: + """The record carries ``null`` times for a DNS row (times shown). + + Null, never 0: the machine record distinguishes "never started" + from a real zero-second reading, exactly as the human surfaces show + a blank cell. + """ + placed = (*_placed_pair(), _dns_placed("31", "Rita Slow")) + + payload = build_payload( + _StubRide(), placed, ExportOptions(show_times=True), _FIXTURE_GENERATED + ) + + assert (payload.results[2].total, payload.results[2].best_lap) == (None, None) + assert payload.results[0].total == "5:32:00" + + # ================================================== template (the plan) # The page renders the planned sections: per-kind titles, per-kind # tables (teams never show a plate), per-kind drawn-row colspans, and @@ -2316,6 +2535,50 @@ def test_render_poster_given_no_draw_renders_no_draw_badge() -> None: assert "draw None: + """Phase 7: the solo-only poster's five carry no DNS row.""" + placed = ( + Placed( + place=1, + result=_sample_entry("88", "Moss Ridge Riders", 11), + tie_note=None, + draw_required=False, + ), + _dns_placed("31", "Rita Slow"), + ) + + page = _poster_page(placed) + + assert "Moss Ridge Riders" in page + assert "Rita Slow" not in page + + +def test_render_poster_given_a_dns_team_row_omits_it_from_the_team_three() -> None: + """Phase 7: a team event's three carry no DNS team either.""" + placed = ( + Placed( + place=1, + result=_sample_entry("88", "Moss Ridge Riders", 11, kind="team"), + tie_note=None, + draw_required=False, + ), + _dns_placed("77", "Late Starters", kind="team"), + ) + + page = _poster_page(placed) + + assert "Moss Ridge Riders" in page + assert "Late Starters" not in page + assert "#77" not in page + + # ------------------- the poster card's whole hand (drawn_row's design) diff --git a/tests/unit/test_payload.py b/tests/unit/test_payload.py index 606564b1..3f585cee 100644 --- a/tests/unit/test_payload.py +++ b/tests/unit/test_payload.py @@ -403,6 +403,75 @@ def test_payload_from_record_round_trips_the_draw_and_the_self_test_note() -> No assert _payload_from_record(record).to_record() == record +# --- the render-only unique-rider count --- + + +def test_event_info_riders_defaults_to_zero() -> None: + """A payload built without a count renders 0, never a blank.""" + assert _sample_event().riders == 0 + + +def test_event_info_to_record_omits_the_render_only_riders_count() -> None: + """``riders`` is render-only, exactly like ``ExportOptions.lap_km``. + + The golden ``race-data`` blocks carry nine event keys, and the + golden generator's ``record -> payload -> record`` parity check + rebuilds ``EventInfo`` from those nine -- so a tenth key here would + break both. + """ + event = replace(_sample_event(), riders=207) + + assert "riders" not in event.to_record() + + +def test_event_info_riders_reaches_the_page_but_not_the_record() -> None: + """T-3: the count is on the dataclass, absent from its record.""" + event = replace(_sample_event(), riders=207) + + assert (event.riders, len(event.to_record())) == (207, 9) + + +# --- the sparse DNS flag (Phase 7) --- + + +def _dns_row() -> ResultRow: + """Build one 0-lap DNS results row (Phase 7).""" + return ResultRow( + place=0, + plate=31, + entry="Rita Slow", + entry_type="SOLO", + laps=0, + hand="", + dns=True, + ) + + +def test_result_row_dns_defaults_to_false() -> None: + """An ordinary results row is not a DNS row.""" + assert _sample_results()[0].dns is False + + +def test_result_row_to_record_given_a_dns_row_keeps_laps_numeric_and_flags_dns() -> None: + """The machine record keeps ``laps: 0`` and adds the flag.""" + record = _dns_row().to_record(show_times=False) + + assert (record["laps"], record["place"], record["dns"]) == (0, 0, True) + + +def test_result_row_to_record_given_a_placed_row_omits_the_dns_key() -> None: + """T-3: the flag is sparse -- a placed row carries no key at all.""" + assert "dns" not in _sample_results()[0].to_record(show_times=False) + + +def test_payload_from_record_round_trips_a_dns_row() -> None: + """The sparse flag survives record -> payload -> record.""" + payload = replace(_times_hidden_payload(), results=(_dns_row(),)) + record = payload.to_record() + + assert _payload_from_record(record).to_record() == record + + # --- camelCase mapping --- diff --git a/tests/unit/test_pdfexport.py b/tests/unit/test_pdfexport.py index 5c1b58ab..b4d7dd8b 100644 --- a/tests/unit/test_pdfexport.py +++ b/tests/unit/test_pdfexport.py @@ -803,7 +803,7 @@ def test_render_empty_field_renders(tmp_path: Path) -> None: out = _render(tmp_path, (), ExportOptions(full_field=True, laps_board=True, time_board=True)) text = _text(out) - assert "0 · 0 · 0" in text + assert "0 · 0 · 0 · 0" in text assert "Full field" in text @@ -813,8 +813,164 @@ def test_render_cover_block_shows_kicker_title_and_counters(tmp_path: Path) -> N assert "Official results · poker run" in text assert "GORBA EPIC & MTB Festival 2026" in text - assert "50 · 347 · 349" in text - assert "entries · laps · cards dealt" in text + assert "50 · 347 · 349 · 0" in text + assert "entries · laps · cards dealt · unique riders" in text + + +def test_render_given_a_rider_count_puts_it_on_the_cover_counter_row(tmp_path: Path) -> None: + """``riders`` is the cover's fourth counter, thousands-separated.""" + out = tmp_path / "results.pdf" + + pdfexport.render( + build_ride(), + build_placed(), + golden_opts(), + out, + created_at=FIXED_CREATED, + riders=1207, + ) + + assert "50 · 347 · 349 · 1,207" in _text(out) + + +# --------------------------------------------- DNS rows (Phase 7) +# A DNS row is a 0-lap ACTIVE entry on a FINISHED ride. It keeps its +# row at the bottom of the Full field, with a blank Place cell and the +# word "DNS" where its lap count would be; the podiums, the top lists +# and the boards it never reached are untouched. + + +def _dns_placed(plate: str, name: str, *, kind: str = "solo") -> Placed: + """Build one DNS row (Phase 7): unplaced, 0 laps, no tie note.""" + return Placed( + place=0, + result=_entry(plate, name, 0, kind=kind), + tie_note=None, + draw_required=False, + dns=True, + ) + + +def _dns_field() -> tuple[Placed, ...]: + """Build a one-solo field plus its DNS tail (one section).""" + return ( + Placed( + place=1, + result=_entry("88", "Moss Ridge Riders", 11), + tie_note=None, + draw_required=False, + ), + _dns_placed("31", "Rita Slow"), + ) + + +def test_render_given_a_dns_row_prints_dns_where_the_laps_would_be(tmp_path: Path) -> None: + """The full field's DNS row renders the word "DNS".""" + opts = ExportOptions(full_field=True, laps_board=False, time_board=False) + + text = _text(_render(tmp_path, _dns_field(), opts)) + + assert "DNS" in text + + +def test_render_given_a_dns_row_prints_no_place_before_its_plate(tmp_path: Path) -> None: + """T-3: the DNS row's Place cell is blank, not its placeholder 0. + + The extracted row therefore reads plate-name-laps with no leading + digit, which is what ``"0 31"`` would be if the placeholder leaked + through the Place cell. + """ + opts = ExportOptions(full_field=True, laps_board=False, time_board=False) + + text = _text(_render(tmp_path, _dns_field(), opts)) + + assert "31 Rita Slow" in text + assert "0 31" not in text + + +def test_render_given_a_dns_row_leaves_it_off_the_podium(tmp_path: Path) -> None: + """T-3: the DNS rider is not a podium card (it was never placed).""" + text = _text(_render(tmp_path, _dns_field(), ExportOptions(full_field=True))) + + podium = _section(text, "Best hands — top 3", "Top ten") + assert "#88 Moss Ridge Riders" in podium + assert "Rita Slow" not in podium + + +def test_render_given_a_placed_row_keeps_its_place_and_lap_count(tmp_path: Path) -> None: + """T-3: an ordinary row is untouched by the DNS rule.""" + opts = ExportOptions(full_field=True, laps_board=False, time_board=False) + + text = _text(_render(tmp_path, _dns_field(), opts)) + + assert "1 88 Moss Ridge Riders" in text + + +def test_render_given_a_dns_row_blanks_its_time_cells(tmp_path: Path) -> None: + """Phase 7: a DNS entry's Total time and Best lap cells are blank. + + The DNS row's own would-be readings are distinctive here + (20:00 / 15:00), so their absence is the cells going empty rather + than the whole row vanishing. + """ + opts = ExportOptions(show_times=True, full_field=True, laps_board=False, time_board=False) + dns = _dns_placed("31", "Rita Slow") + placed = ( + Placed( + place=1, + result=_entry("88", "Moss Ridge Riders", 11), + tie_note=None, + draw_required=False, + ), + replace(dns, result=replace(dns.result, total_time=1_200.0, best_lap=900.0)), + ) + + text = _text(_render(tmp_path, placed, opts)) + + assert "5:32:00" in text # the placed row keeps its times + assert "20:00" not in text + assert "15:00" not in text + + +def test_draw_marker_given_a_dns_row_returns_no_marker() -> None: + """Phase 7: a DNS row renders no draw marker, whatever it carries. + + The record path can hand a DNS row a stray draw (a parsed + ``race-data`` row may hold both ``dns`` and ``draw``), so the + marker's own rule refuses it rather than trusting the caller. + """ + row = ResultRow( + place=0, + plate=31, + entry="Rita Slow", + entry_type="SOLO", + laps=0, + hand="", + dns=True, + draw=("7", "d"), + ) + + assert pdfexport._draw_marker(row) == "" + + +def test_render_given_a_drawn_dns_row_prints_no_draw_marker(tmp_path: Path) -> None: + """Phase 7: a DNS row's finish-time draw never reaches the page.""" + opts = ExportOptions(full_field=True, laps_board=False, time_board=False) + dns = _dns_placed("31", "Rita Slow") + placed = ( + Placed( + place=1, + result=replace(_entry("88", "Moss Ridge Riders", 11), tiebreak_card=Card.parse("AH")), + tie_note=None, + draw_required=False, + ), + replace(dns, result=replace(dns.result, tiebreak_card=Card.parse("7D"))), + ) + + text = _text(_render(tmp_path, placed, opts)) + + assert "DRAW A♥" in text + assert "DRAW 7♦" not in text def test_render_meta_line_formats_ride_fields(tmp_path: Path) -> None: @@ -1046,6 +1202,74 @@ def test_podium_poster_team_only_field_omits_the_solo_riders_section( assert "Solo riders" not in text +# --------------------------------------------- DNS rows (Phase 7) +# The PDF sibling of the HTML poster's own rule: the cards come from +# the ranked rows only, so a DNS row (never placed) is not a card -- +# and never borrows the enumerated place `_cards` hands out. + + +def test_podium_poster_given_a_dns_row_omits_it_from_the_solo_five(tmp_path: Path) -> None: + """Phase 7: a solo field's poster carries no DNS card.""" + placed = ( + Placed( + place=1, + result=_entry("88", "Moss Ridge Riders", 11), + tie_note=None, + draw_required=False, + ), + _dns_placed("31", "Rita Slow"), + ) + + text = _text(_poster(tmp_path, placed)) + + assert "Moss Ridge Riders" in text + assert "Rita Slow" not in text + + +def test_podium_poster_given_a_dns_team_row_omits_it_from_the_team_three( + tmp_path: Path, +) -> None: + """Phase 7: a team event's poster carries no DNS team card.""" + placed = ( + Placed( + place=1, + result=_entry("88", "Moss Ridge Riders", 11, kind="team"), + tie_note=None, + draw_required=False, + ), + _dns_placed("77", "Late Starters", kind="team"), + ) + + text = _text(_poster(tmp_path, placed)) + + assert "Moss Ridge Riders" in text + assert "Late Starters" not in text + + +def test_podium_poster_given_only_dns_teams_skips_the_teams_section(tmp_path: Path) -> None: + """Phase 7: an all-DNS team partition draws no Teams heading. + + The field is still a team event (the layout rule reads the whole + partition), so the solo section keeps the compact card sizing; the + Teams section, having no cards, draws neither heading nor card. + """ + placed = ( + _dns_placed("77", "Late Starters", kind="team"), + Placed( + place=1, + result=_entry("88", "Moss Ridge Riders", 11), + tie_note=None, + draw_required=False, + ), + ) + + text = _text(_poster(tmp_path, placed)) + + assert "Late Starters" not in text + assert "Teams" not in text + assert "Solo riders" in text + + def test_podium_poster_team_event_content_fits_one_letter_page() -> None: """Six compact cards and two headings end above the footer. diff --git a/tests/unit/test_standings.py b/tests/unit/test_standings.py index c4f94436..a5b286c9 100644 --- a/tests/unit/test_standings.py +++ b/tests/unit/test_standings.py @@ -38,6 +38,7 @@ LIVE_TIEBREAK_ORDER, EntryResult, TieBreak, + ZeroLapMode, hand_name, laps_leaderboard, rank, @@ -688,6 +689,210 @@ def test_rank_by_kind_unknown_tiebreak_member_raises_type_error() -> None: ) +# ------------------------------------------------- 0-lap entries (DNS) +# +# A rider who never crossed a line -- 0 recorded laps -- is still an +# ACTIVE entry: the ride closed with them on the course. On a FINISHED +# ride such an entry is a DNS ("Did Not Start") row: it is partitioned +# out BEFORE the sort, the rest renumber from 1 with contiguous places, +# and the DNS rows are appended afterwards, unplaced and unflagged. +# ``rank`` itself stays pure and never sees the mode -- the caller picks +# one with :class:`ZeroLapMode` at the split entry point. + + +def test_zero_lap_mode_members_are_rank_dns_and_hide() -> None: + """The three modes, and the readable spellings they report.""" + assert [(member.name, member.value) for member in ZeroLapMode] == [ + ("RANK", "rank"), + ("DNS", "dns"), + ("HIDE", "hide"), + ] + + +def test_rank_places_a_zero_lap_entry_normally() -> None: + """Rank stays pure: a 0-lap ACTIVE entry still places.""" + zero = _result("1", "AS KS QS JS 10S", laps=0) + + placed = rank([zero]) + + assert [(p.place, p.dns) for p in placed] == [(1, False)] + + +def test_rank_given_the_superseded_include_zero_laps_flag_raises_type_error() -> None: + """The filter this design replaced is gone from rank's signature.""" + with pytest.raises(TypeError, match=re.escape("include_zero_laps")): + rank([_result("1", "AS KS QS JS 10S")], include_zero_laps=False) + + +def test_rank_by_kind_default_mode_ranks_a_zero_lap_entry_in_place() -> None: + """Omitting the mode is RANK: the whole ACTIVE field places.""" + zero = _result("1", "9H 8C 7D 6S 5H", laps=0) + one_lap = _result("2", "AS KS QS JS 10S", laps=1) + + teams, solo = rank_by_kind([zero, one_lap]) + + assert teams == [] + assert [(p.place, p.dns, p.result.entry_id) for p in solo] == [ + (1, False, "2"), + (2, False, "1"), + ] + + +def test_rank_by_kind_given_rank_places_a_zero_lap_entry_in_place() -> None: + """An explicit RANK matches the omitted-mode behaviour exactly.""" + results = [ + _result("1", "9H 8C 7D 6S 5H", kind="team", laps=0), + _result("2", "AS KS QS JS 10S", kind="solo", laps=1), + ] + + assert rank_by_kind(results, zero_laps=ZeroLapMode.RANK) == rank_by_kind(results) + + +def test_rank_by_kind_given_dns_appends_the_zero_lap_entry_unplaced_last() -> None: + """A DNS row leaves the ranking and lands after the placed rows.""" + zero = _result("1", "AS KS QS JS 10S", laps=0) + one_lap = _result("2", "9H 8C 7D 6S 5H", laps=1) + + _teams, solo = rank_by_kind([zero, one_lap], zero_laps=ZeroLapMode.DNS) + + assert [(p.place, p.dns, p.result.entry_id) for p in solo] == [ + (1, False, "2"), + (0, True, "1"), + ] + + +def test_rank_by_kind_given_dns_marks_the_row_with_no_note_or_draw_flag() -> None: + """A DNS Placed carries place 0, no tie note and no draw flag.""" + zero = _result("1", "AS KS QS JS 10S", laps=0) + + _teams, solo = rank_by_kind([zero], zero_laps=ZeroLapMode.DNS) + + assert [(p.place, p.tie_note, p.draw_required, p.dns) for p in solo] == [ + (0, None, False, True) + ] + + +def test_rank_by_kind_given_dns_a_tied_zero_lap_pair_is_never_flagged() -> None: + """Two 0-lap tied hands are DNS rows, never a draw.""" + first = _result("1", "9H 8C 7D 6S 5H", laps=0) + second = _result("2", "9C 8D 7H 6C 5D", laps=0) + + _teams, solo = rank_by_kind([first, second], zero_laps=ZeroLapMode.DNS) + + assert [(p.place, p.draw_required, p.dns) for p in solo] == [ + (0, False, True), + (0, False, True), + ] + + +def test_rank_by_kind_given_dns_keeps_the_placed_places_contiguous() -> None: + """Partitioning before the sort leaves no place gaps (1..3).""" + results = [ + _result("1", "AS KS QS JS 10S", laps=0), + _result("2", "AH KH QH JH 10H", laps=4), + _result("3", "9H 8C 7D 6S 5H", laps=0), + _result("4", "JH JC JD 4H 4C", laps=3), + _result("5", "KH KC 5H 5D AS", laps=3), + ] + + _teams, solo = rank_by_kind(results, zero_laps=ZeroLapMode.DNS) + + assert [p.place for p in solo] == [1, 2, 3, 0, 0] + assert [p.result.entry_id for p in solo] == ["2", "4", "5", "1", "3"] + + +def test_rank_by_kind_given_dns_orders_the_dns_rows_by_input_order() -> None: + """The DNS tail keeps the caller's order, never the hand order.""" + later_input = _result("9", "9H 8C 7D 6S 5H", laps=0) + earlier_input = _result("1", "AS KS QS JS 10S", laps=0) + + _teams, solo = rank_by_kind([later_input, earlier_input], zero_laps=ZeroLapMode.DNS) + + assert [p.result.entry_id for p in solo] == ["9", "1"] + + +def test_rank_by_kind_given_dns_drops_a_zero_lap_dnf_entry() -> None: + """A 0-lap DNF is neither placed nor a DNS row: DNF wins.""" + zero_lap_dnf = _result("1", "AS KS QS JS 10S", laps=0, dnf=True) + one_lap = _result("2", "9H 8C 7D 6S 5H", laps=1) + + _teams, solo = rank_by_kind([zero_lap_dnf, one_lap], zero_laps=ZeroLapMode.DNS) + + assert [(p.place, p.dns, p.result.entry_id) for p in solo] == [(1, False, "2")] + + +def test_rank_by_kind_given_dns_drops_a_started_dnf_entry() -> None: + """A DNF entry never reaches the ranked side either.""" + zero_lap = _result("1", "AS KS QS JS 10S", laps=0) + started_dnf = _result("2", "9H 8C 7D 6S 5H", laps=3, dnf=True) + + _teams, solo = rank_by_kind([zero_lap, started_dnf], zero_laps=ZeroLapMode.DNS) + + assert [(p.place, p.dns, p.result.entry_id) for p in solo] == [(0, True, "1")] + + +def test_rank_by_kind_given_hide_drops_the_zero_lap_entry() -> None: + """HIDE is the toggle-off case: the 0-lap entry is gone entirely.""" + zero = _result("1", "AS KS QS JS 10S", laps=0) + one_lap = _result("2", "9H 8C 7D 6S 5H", laps=1) + + _teams, solo = rank_by_kind([zero, one_lap], zero_laps=ZeroLapMode.HIDE) + + assert [(p.place, p.dns, p.result.entry_id) for p in solo] == [(1, False, "2")] + + +@pytest.mark.parametrize("laps", [1, 2, 100]) +def test_rank_by_kind_given_dns_never_partitions_a_started_entry(laps: int) -> None: + """T-4 boundary: the partition is exactly ``laps == 0``.""" + started = _result("1", "AS KS QS JS 10S", laps=laps) + + _teams, solo = rank_by_kind([started], zero_laps=ZeroLapMode.DNS) + + assert [(p.place, p.dns) for p in solo] == [(1, False)] + + +def test_rank_by_kind_given_dns_partitions_teams_and_solos_independently() -> None: + """Teams and solos each keep their own DNS tail.""" + team_zero = _result("1", "AS KS QS JS 10S", kind="team", laps=0) + team_lapped = _result("2", "AH KH QH JH 10H", kind="team", laps=5) + solo_zero = _result("3", "9H 8C 7D 6S 5H", kind="solo", laps=0) + solo_lapped = _result("4", "JH JC JD 4H 4C", kind="solo", laps=3) + + teams, solo = rank_by_kind( + [team_zero, solo_zero, team_lapped, solo_lapped], zero_laps=ZeroLapMode.DNS + ) + + assert [(p.place, p.dns, p.result.entry_id) for p in teams] == [ + (1, False, "2"), + (0, True, "1"), + ] + assert [(p.place, p.dns, p.result.entry_id) for p in solo] == [ + (1, False, "4"), + (0, True, "3"), + ] + + +def test_rank_by_kind_given_dns_an_all_zero_lap_section_is_all_dns() -> None: + """A section where nobody started is entirely DNS rows.""" + first = _result("1", "AS KS QS JS 10S", laps=0) + second = _result("2", "9H 8C 7D 6S 5H", laps=0) + + _teams, solo = rank_by_kind([first, second], zero_laps=ZeroLapMode.DNS) + + assert [(p.place, p.dns) for p in solo] == [(0, True), (0, True)] + + +def test_rank_by_kind_given_dns_an_empty_field_is_two_empty_sections() -> None: + """The partitioned path turns an empty field into empty sections.""" + assert rank_by_kind([], zero_laps=ZeroLapMode.DNS) == ([], []) + + +def test_rank_by_kind_zero_laps_is_keyword_only() -> None: + """The mode is keyword-only, never read as the order.""" + with pytest.raises(TypeError, match=re.escape("positional")): + rank_by_kind([_result("1", "AS KS QS JS 10S")], DEFAULT_TIEBREAK_ORDER, ZeroLapMode.DNS) + + # ------------------------------------------------------ empty input diff --git a/tests/unit/test_xrc_structure.py b/tests/unit/test_xrc_structure.py index 3eace25a..33b945c1 100644 --- a/tests/unit/test_xrc_structure.py +++ b/tests/unit/test_xrc_structure.py @@ -126,7 +126,10 @@ # new item gates on its own export existing (HTML / PDF), and the # poster page gained its own pair (mi_export_poster_html and # mi_preview_poster_html_browser). G6: the five checkable publish -# options follow a separator, after the Preview rows. +# options follow a separator, after the Preview rows. The Results menu +# owns a sixth toggle: mi_show_dns_riders, the "Show DNS Riders" check +# item that hides a Finished ride's DNS rows from the window and every +# export (checked by default, from AppSettings.show_dns_riders). RESULTS_MENU_ITEMS = ( "mi_standings", "mi_export_html", @@ -142,6 +145,7 @@ "mi_time_board", "mi_full_field", "mi_all_cards", + "mi_show_dns_riders", ) ZOOM_MENU_ITEMS = ( "mi_zoom_90", @@ -229,7 +233,7 @@ # mi_void_card, G7 retired mi_edit_crossing, and the removals batch # retired mi_review_held and authored the two Podium-Poster-HTML # rows): File 8 (mi_simulation added), Ride 9, Riders 4, Cards 3, -# Results 14, View 1, Help 4. The single +# Results 15, View 1, Help 4. The single # View row expands into the 9 items section 15b names for it (W13: the # two time-column check items + the seven zoom radios; the theme trio # left the View menu). @@ -238,7 +242,7 @@ ("&Ride", 9), ("Ri&ders", 4), ("&Cards", 3), - ("Re&sults", 14), + ("Re&sults", 15), ("&View", 9), ("&Help", 4), ) @@ -525,13 +529,13 @@ def test_menu_declares_the_expected_item_count(menu_label: str, expected_items: assert len(items) == expected_items -def test_main_menubar_declares_forty_eight_menu_item_names() -> None: - """The removals batch leaves 48 ``mi_*`` names in main.xrc.""" +def test_main_menubar_declares_forty_nine_menu_item_names() -> None: + """The Results menu's zero-lap toggle leaves 49 ``mi_*`` names.""" names = _control_names_in(_window("main_menubar")) menu_item_names = [name for name in names if name.startswith("mi_")] - assert len(menu_item_names) == 48 + assert len(menu_item_names) == 49 def test_main_menubar_item_names_are_exactly_the_routed_item_set() -> None: @@ -568,7 +572,7 @@ def test_ride_menu_declares_the_spec_15_row_order_after_d1() -> None: def test_results_menu_declares_the_publish_rows_after_the_previews() -> None: - """G6: the five publish items close the Results menu.""" + """G6: the five publish items and the DNS toggle close it.""" results_menu = _menus()[4] names = [item.attrib["name"] for item in _menu_items(results_menu)] @@ -609,10 +613,11 @@ def test_results_menu_given_the_publish_group_separates_it_from_the_previews() - ("mi_time_board", "Fastest-time leaderboard"), ("mi_full_field", "Full field"), ("mi_all_cards", "All cards drawn"), + ("mi_show_dns_riders", "Show DNS Riders"), ], ) def test_results_menu_row_declares_its_label(item_name: str, label: str) -> None: - """G6: the renamed export row, plus the five publish options.""" + """G6: the renamed export row, plus the six Results-menu options.""" item = _objects_by_name(_window("main_menubar"))[item_name] assert _param(item, "label") == label @@ -620,7 +625,14 @@ def test_results_menu_row_declares_its_label(item_name: str, label: str) -> None @pytest.mark.parametrize( "item_name", - ["mi_show_times", "mi_laps_board", "mi_time_board", "mi_full_field", "mi_all_cards"], + [ + "mi_show_times", + "mi_laps_board", + "mi_time_board", + "mi_full_field", + "mi_all_cards", + "mi_show_dns_riders", + ], ) def test_results_publish_row_declares_a_checkable_item(item_name: str) -> None: """G6: each publish option is checkable (no radio group).""" diff --git a/tests/unit/ui/test_app_exports.py b/tests/unit/ui/test_app_exports.py index e4ea3e10..920f1404 100644 --- a/tests/unit/ui/test_app_exports.py +++ b/tests/unit/ui/test_app_exports.py @@ -25,7 +25,7 @@ from rivercrossing.hands import best_hand from rivercrossing.htmlexport import ExportOptions from rivercrossing.ride import RideStatus -from rivercrossing.roster import EntryMode, PlateModel, Roster +from rivercrossing.roster import EntryMode, PlateModel, Rider, Roster from rivercrossing.standings import EntryResult, Placed from rivercrossing.ui import app as app_module from rivercrossing.ui import std_dialogs @@ -150,11 +150,16 @@ def _unpack_groups(groups: object) -> tuple[object, object]: def _context(*, engine: _StubEngine | None, menubar: object = None) -> app_module._RouteContext: - """Build a route context with an optional engine and menubar.""" + """Build a route context with an optional engine and menubar. + + The roster is empty rather than None: the export handler's Phase 7 + rider tally walks it (``_unique_rider_count``), and a test that + cares about the count assigns its own roster. + """ return app_module._RouteContext( frame=_StubFrame(menubar), resource=None, - roster=None, # type: ignore[arg-type] + roster=Roster(), app=None, theme_controller=None, # type: ignore[arg-type] presenter=None if engine is None else _presenter(engine), @@ -531,6 +536,7 @@ def sync_offloop( # noqa: PLR0913 -- mirrors _run_export_offloop's inputs solo: object, opts: object, watermark: int, + riders: int = 0, team_logos: object = None, self_test_unverified: bool = False, ) -> None: @@ -541,6 +547,7 @@ def sync_offloop( # noqa: PLR0913 -- mirrors _run_export_offloop's inputs opts, target, path, + riders, team_logos=team_logos, self_test_unverified=self_test_unverified, ) @@ -581,6 +588,7 @@ def sync_offloop( # noqa: PLR0913 -- mirrors _run_export_offloop's inputs solo: object, # noqa: ARG001 -- mirrors the frozen signature opts: object, # noqa: ARG001 -- mirrors the frozen signature watermark: int, # noqa: ARG001 -- mirrors the frozen signature + riders: int = 0, # noqa: ARG001 -- mirrors the frozen signature team_logos: object = None, # noqa: ARG001 -- mirrors the frozen signature self_test_unverified: bool = False, ) -> None: @@ -619,6 +627,7 @@ def sync_offloop( # noqa: PLR0913 -- mirrors _run_export_offloop's inputs solo: object, opts: object, watermark: int, + riders: int = 0, team_logos: object = None, self_test_unverified: bool = False, ) -> None: @@ -629,6 +638,7 @@ def sync_offloop( # noqa: PLR0913 -- mirrors _run_export_offloop's inputs opts, target, path, + riders, team_logos=team_logos, self_test_unverified=self_test_unverified, ) @@ -689,6 +699,230 @@ def test_handle_export_command_given_the_csv_target_records_no_preview_path( assert context.export_watermark == 0 +# --- the unique-rider count and the DNS display on the export path --- + + +def _roster_of(*plates: str) -> Roster: + """Build a MIXED solo roster keyed by each entry's own plate. + + The stub snapshot names every result by its plate (``_result``'s + ``entry_id``), so the roster's stable keys are aligned with it -- + exactly what the real engine does when it files a crossing under + the entry's key. + """ + roster = Roster(entry_mode=EntryMode.SOLO, plate_model=PlateModel.RIDER_POOLED) + for plate in plates: + entry = roster.create_solo_entry(first_name="Rider", last_name=plate, plate=plate) + entry.key = plate + return roster + + +def _team_roster(*, key: str) -> Roster: + """Build one two-rider team whose key is the snapshot's own.""" + roster = Roster(entry_mode=EntryMode.MIXED, plate_model=PlateModel.RIDER_POOLED) + entry = roster.create_team_entry( + display_name="Trail Blazers", + riders=[Rider(first_name="A.", plate="77"), Rider(first_name="K.", plate="78")], + ) + entry.key = key + return roster + + +def _finished_engine_with_a_zero_lap_entry() -> _StubEngine: + """Build a FINISHED stub engine: a 4-lap entry and a 0-lap one.""" + return _StubEngine( + ( + _result("88", "9S 9D 9C 9H 2C", laps=4, total_time=1_000.0), + _result("7", "AS KS QS JS 10S", laps=0, total_time=1_200.0), + ) + ) + + +def test_handle_export_command_counts_only_the_riders_who_started( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The header's unique-rider count excludes the DNS entries' riders. + + ``riders`` counts individual people, so it comes from the roster -- + never from the placed rows, whose entry count is a count of entries + (a two-rider team is one entry and two riders) -- and only the + riders on an entry with at least one recorded lap. + """ + context = _context(engine=_StubEngine(_snapshot())) + context.roster = _roster_of("88", "7", "12") + monkeypatch.setattr(app_module, "_pick_export_path", lambda _name: tmp_path / "results.html") + captured: list[int] = [] + _capture_offloop(monkeypatch, captured) + + app_module._handle_export_command(context, "export_html") + + assert captured == [2] + + +def test_handle_export_command_counts_every_member_of_a_started_team( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A team member counts individually: riders, not entries.""" + context = _context(engine=_StubEngine(_snapshot())) + context.roster = _team_roster(key="88") + monkeypatch.setattr(app_module, "_pick_export_path", lambda _name: tmp_path / "results.html") + captured: list[int] = [] + _capture_offloop(monkeypatch, captured) + + app_module._handle_export_command(context, "export_html") + + assert captured == [2] + + +def test_handle_export_command_given_only_dns_entries_counts_no_riders( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """T-4 boundary: an all-DNS field contributes zero unique riders.""" + context = _context( + engine=_StubEngine((_result("12", "9S 9D 9C 9H 2C", laps=0, total_time=0.0),)) + ) + context.roster = _roster_of("12") + monkeypatch.setattr(app_module, "_pick_export_path", lambda _name: tmp_path / "results.html") + captured: list[int] = [] + _capture_offloop(monkeypatch, captured) + + app_module._handle_export_command(context, "export_html") + + assert captured == [0] + + +def _capture_offloop(monkeypatch: pytest.MonkeyPatch, captured: list[int]) -> None: + """Patch ``_run_export_offloop`` with a rider-count recorder.""" + + def record( # noqa: PLR0913 -- mirrors _run_export_offloop's signature + context: object, # noqa: ARG001 -- mirrors the frozen signature + target: object, # noqa: ARG001 -- mirrors the frozen signature + path: object, # noqa: ARG001 -- mirrors the frozen signature + *, + config: object, # noqa: ARG001 -- mirrors the frozen signature + teams: object, # noqa: ARG001 -- mirrors the frozen signature + solo: object, # noqa: ARG001 -- mirrors the frozen signature + opts: object, # noqa: ARG001 -- mirrors the frozen signature + watermark: object, # noqa: ARG001 -- mirrors the frozen signature + riders: int, + team_logos: object = None, # noqa: ARG001 -- mirrors the frozen signature + self_test_unverified: bool = False, # noqa: ARG001 -- mirrors it + ) -> None: + captured.append(riders) + + monkeypatch.setattr(app_module, "_run_export_offloop", record) + + +def test_write_export_given_a_rider_count_puts_it_on_the_html_header( + tmp_path: Path, +) -> None: + """The count threads through to the HTML writer.""" + teams, solo = _unpack_groups(app_module._placed_for_export(_context(engine=None))) + + app_module._write_export( + _StubConfig(), + teams, + solo, + ExportOptions(), + "export_html", + tmp_path / "results.html", + riders=207, + ) + + assert "· 207" in (tmp_path / "results.html").read_text(encoding="utf-8") + + +def test_placed_for_export_given_dns_riders_on_keeps_the_dns_entry() -> None: + """Checked: the 0-lap entry stays, unplaced and marked DNS.""" + context = _context(engine=_finished_engine_with_a_zero_lap_entry()) + + teams, solo = app_module._placed_for_export(context) + + assert [(p.result.plate, p.place, p.dns) for p in (*teams, *solo)] == [ + ("88", 1, False), + ("7", 0, True), + ] + + +def test_placed_for_export_given_dns_riders_off_drops_the_dns_entry() -> None: + """Unchecked: the same export hides the 0-lap entry entirely.""" + context = _context(engine=_finished_engine_with_a_zero_lap_entry()) + context.settings = replace(default_settings(), show_dns_riders=False) + + teams, solo = app_module._placed_for_export(context) + + assert [(p.result.plate, p.dns) for p in (*teams, *solo)] == [("88", False)] + + +def test_write_export_given_a_dns_entry_renders_the_dns_cells( + tmp_path: Path, +) -> None: + """The Results-menu toggle's own reading reaches the page. + + Window and exports read the same ``AppSettings.show_dns_riders`` + through ``_placed_for_export``, so the two can never disagree: the + checked default renders the DNS row's Laps cell as the word "DNS". + """ + context = _context(engine=_finished_engine_with_a_zero_lap_entry()) + + teams, solo = _unpack_groups(app_module._placed_for_export(context)) + app_module._write_export( + _StubConfig(), teams, solo, ExportOptions(), "export_html", tmp_path / "results.html" + ) + + assert ">DNS" in (tmp_path / "results.html").read_text(encoding="utf-8") + + +def test_write_export_given_dns_riders_off_omits_the_dns_entry( + tmp_path: Path, +) -> None: + """Unchecked: the dropped 0-lap entry never reaches the page. + + The embedded record is read rather than a raw plate search: the + page's inlined CSS carries ``#7...`` colour literals, so the plate + as a bare substring would match those. + """ + context = _context(engine=_finished_engine_with_a_zero_lap_entry()) + context.settings = replace(default_settings(), show_dns_riders=False) + + teams, solo = _unpack_groups(app_module._placed_for_export(context)) + app_module._write_export( + _StubConfig(), teams, solo, ExportOptions(), "export_html", tmp_path / "results.html" + ) + + html = (tmp_path / "results.html").read_text(encoding="utf-8") + assert '"plate": 88,' in html + assert '"plate": 7,' not in html + assert ">DNS" not in html + + +@pytest.mark.parametrize( + "state", + [RideStatus.DRAFT, RideStatus.RUNNING, RideStatus.REOPENED], + ids=lambda state: state.value, +) +def test_placed_for_export_given_a_live_engine_keeps_zero_lap_entries_ranked( + state: RideStatus, +) -> None: + """Phase 7: the DNS filter is FINISHED-only. + + The Results menu only offers its exports once the ride is FINISHED, + but this helper is the ranking seam itself: a route-level or future + direct caller must never turn a live ride's 0-lap entry into an + unplaced DNS row (``EngineDataSource.standings`` gates the same + way, so window and export can never disagree). + """ + context = _context(engine=_finished_engine_with_a_zero_lap_entry()) + context.presenter.engine.state = state + + teams, solo = app_module._placed_for_export(context) + + assert [(p.result.plate, p.place, p.dns) for p in (*teams, *solo)] == [ + ("7", 1, False), + ("88", 2, False), + ] + + def test_handle_preview_html_browser_opens_the_html_export( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/ui/test_app_publish_menu.py b/tests/unit/ui/test_app_publish_menu.py index fa9b246d..bd3a0dd9 100644 --- a/tests/unit/ui/test_app_publish_menu.py +++ b/tests/unit/ui/test_app_publish_menu.py @@ -39,19 +39,23 @@ from pathlib import Path # G6: each setting and the Results-menu item that mirrors it, in the -# menu's own order -- transcribed independently of app.py's map. +# menu's own order -- transcribed independently of app.py's map. The +# DNS toggle is the row's sixth entry: the same flip/persist/startup +# machinery, no R-63 gating, and it ships checked (the setting's +# default is True). PUBLISH_ITEMS = ( ("publish_show_times", ids.MI_SHOW_TIMES), ("publish_laps_board", ids.MI_LAPS_BOARD), ("publish_time_board", ids.MI_TIME_BOARD), ("publish_full_field", ids.MI_FULL_FIELD), ("publish_all_cards", ids.MI_ALL_CARDS), + ("show_dns_riders", ids.MI_SHOW_DNS_RIDERS), ) PUBLISH_ITEM_IDS = [item_id for _key, item_id in PUBLISH_ITEMS] PUBLISH_KEYS = [key for key, _item_id in PUBLISH_ITEMS] -# The five settings-surface names, plus the Fastest-time board item the -# R-63 gate acts on (one of the five) and nothing else. +# The six settings-surface names, plus the Fastest-time board item the +# R-63 gate acts on (one of the six) and nothing else. MENU_ITEM_NAMES = (*PUBLISH_ITEM_IDS, ids.MI_TIME_BOARD) diff --git a/tests/unit/ui/test_commands.py b/tests/unit/ui/test_commands.py index a46008ba..23d4fb21 100644 --- a/tests/unit/ui/test_commands.py +++ b/tests/unit/ui/test_commands.py @@ -127,12 +127,12 @@ def test_route_table_menu_breakdown_matches_spec_15(menu: str, expected_rows: in assert len(rows) == expected_rows -def test_route_table_covers_all_fifty_one_real_menu_item_ids_once_each() -> None: - """48 mi_* + 3 stock ids (main.xrc's own header), none repeated.""" +def test_route_table_covers_all_fifty_two_real_menu_item_ids_once_each() -> None: + """49 mi_* + 3 stock ids (main.xrc's own header), none repeated.""" flat_ids = [item_id for route in commands.ROUTE_TABLE for item_id in route.ids] - assert len(flat_ids) == 51 - assert len(set(flat_ids)) == 51 + assert len(flat_ids) == 52 + assert len(set(flat_ids)) == 52 @pytest.mark.parametrize(("route", "expected_kind"), KIND_CASES, ids=KIND_CASE_IDS) @@ -271,8 +271,8 @@ def test_standings_route_is_always_enabled_after_part_d() -> None: assert route.enabled_when == commands.Enablement() -def test_results_publish_route_given_the_five_ids_is_one_always_on_command() -> None: - """G6: five publish items, one always-on COMMAND row.""" +def test_results_publish_route_given_the_six_ids_is_one_always_on_command() -> None: + """G6: six Results-menu options, one always-on COMMAND row.""" route = commands.route_for_id("mi_show_times") assert (route.menu, route.kind, route.target) == ( @@ -286,6 +286,7 @@ def test_results_publish_route_given_the_five_ids_is_one_always_on_command() -> "mi_time_board", "mi_full_field", "mi_all_cards", + "mi_show_dns_riders", ) assert route.enabled_when == commands.Enablement() diff --git a/tests/unit/ui/test_menu_state.py b/tests/unit/ui/test_menu_state.py index 9ad0a17e..5d9851d8 100644 --- a/tests/unit/ui/test_menu_state.py +++ b/tests/unit/ui/test_menu_state.py @@ -148,6 +148,7 @@ def test_enablement_table_undo_crossing_needs_a_crossing(status: RideStatus) -> "mi_time_board", "mi_full_field", "mi_all_cards", + "mi_show_dns_riders", ) diff --git a/tests/unit/ui/test_results_win.py b/tests/unit/ui/test_results_win.py index ece3b284..74ce4ef1 100644 --- a/tests/unit/ui/test_results_win.py +++ b/tests/unit/ui/test_results_win.py @@ -41,6 +41,7 @@ from __future__ import annotations +from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING @@ -50,9 +51,12 @@ from hypothesis import given from hypothesis import strategies as st -from rivercrossing.roster import EntryMode, PlateModel +from conftest import _roster_with_entries, gorba_config +from rivercrossing.cards import Shoe +from rivercrossing.ride import RideEngine +from rivercrossing.roster import EntryMode, PlateModel, Roster from rivercrossing.ui import accelerators, std_dialogs -from rivercrossing.ui.presenters.data_source import StandingsRow +from rivercrossing.ui.presenters.data_source import EngineDataSource, StandingsRow from rivercrossing.ui.views import results_win from rivercrossing.ui.views.results_win import ( COL_BEST5, @@ -102,6 +106,7 @@ def _row( # noqa: PLR0913 -- a fixture builder mirroring StandingsRow's fields draw_required: bool = False, tie_note: str | None = None, tiebreak_card: str = "", + dns: bool = False, ) -> StandingsRow: """Build one standings row varying only what a test needs.""" return StandingsRow( @@ -118,6 +123,7 @@ def _row( # noqa: PLR0913 -- a fixture builder mirroring StandingsRow's fields best_lap=best_lap, best_lap_seconds=best_lap_seconds, tiebreak_card=tiebreak_card, + dns=dns, ) @@ -377,6 +383,30 @@ def test_get_column_count_given_a_rendered_row_reads_every_cell() -> None: assert model.GetValueByRow(0, COL_DRAW) == "10♦" +def test_get_value_by_row_given_a_dns_row_blanks_the_place_cell() -> None: + """Phase 7: the DNS row's Place cell is blank, not its place 0.""" + model = StandingsListModel([_row(place=0, laps=0, dns=True)]) + + assert model.GetValueByRow(0, COL_PLACE) == "" + + +def test_get_value_by_row_given_a_dns_row_renders_the_dns_laps_cell() -> None: + """Phase 7: the Laps cell reads the word "DNS", never the number.""" + model = StandingsListModel([_row(place=0, laps=0, dns=True)]) + + assert model.GetValueByRow(0, COL_LAPS) == "DNS" + + +def test_format_place_given_a_dns_row_returns_an_empty_cell() -> None: + """The Place formatter gates on dns before it reads the place.""" + assert results_win.format_place(_row(place=0, dns=True)) == "" + + +def test_format_place_given_a_drawn_tie_still_badges_its_place() -> None: + """T-3: a placed row keeps the ⚠ badge -- dns is the only blank.""" + assert results_win.format_place(_row(place=2, draw_required=True)) == "⚠ 2" + + def test_build_columns_given_rider_pooled_hides_the_plate_column_on_the_team_list() -> None: """Part 2: a pooled team's plate repeats a member's -- it goes.""" shell = _ColumnsShell(PlateModel.RIDER_POOLED) @@ -578,6 +608,34 @@ def test_standings_compare_given_the_place_column_orders_numerically_not_as_text assert result == 1 +def test_standings_compare_given_a_dns_row_orders_it_after_a_placed_row() -> None: + """Phase 7: the Place key sorts a DNS row last.""" + dns = _row(place=0, dns=True) + placed = _row(place=9) + shell = _CompareShell([dns, placed]) + + # wx's positional bool + result = StandingsListModel.Compare(shell, 0, 1, COL_PLACE, True) # noqa: FBT003 + + assert result == 1 + + +def test_standings_compare_given_a_dns_row_orders_it_last_on_the_laps_column() -> None: + """Phase 7: the Laps key sorts a DNS row last too. + + The row's ``laps`` is 0, so a bare numeric key would list every DNS + row first -- the exact opposite of the Place column's own rule. + """ + dns = _row(laps=0, dns=True) + placed = _row(laps=9) + shell = _CompareShell([dns, placed]) + + # wx's positional bool + result = StandingsListModel.Compare(shell, 0, 1, COL_LAPS, True) # noqa: FBT003 + + assert result == 1 + + # The Draw column's own key: ``cards.draw_key``'s rank-major, # suit-minor order (clubs < diamonds < hearts < spades), parsed out of # the stored code -- never the code's text. The pairs are @@ -1244,3 +1302,295 @@ def test_apply_min_size_given_the_dialog_keeps_the_measured_width_floor() -> Non results_win.MIN_SIZE[0], -1, ) + + +# ---------------------------------------------- the DNS results display +# +# The Results menu's "Show DNS Riders" arrives at the window as +# ``show_dns_riders`` (checked by default) and reaches the standings +# through the one presenter. The whole window is built headless here -- +# a dialog double, control doubles resolved through the one ``_find`` +# seam, and a fake ``wx.InfoBar`` (the measured exception: a real +# ``wx.InfoBar`` cannot be built without a desktop) -- so the assertion +# runs on the real ``ResultsPresenter`` and the real source over a real +# finished ride, never on a mock of either. + + +class _FakeInfoBar: + """A ``wx.InfoBar`` double: the banner is code-side only.""" + + def __init__(self, parent: object) -> None: + """Record the parent the SUT passed.""" + self.parent = parent + + def SetName(self, name: str) -> None: # noqa: N802 -- wx API name the SUT calls + """Record the frozen name the SUT applies.""" + + def SetShowHideEffects(self, show: int, hide: int) -> None: # noqa: N802 -- wx API + """Accept the two disabled effects.""" + + def ShowMessage(self, message: str, flags: int) -> None: # noqa: N802 -- wx API + """Record the note the banner would show.""" + + def Dismiss(self) -> None: # noqa: N802 -- wx API name the SUT calls + """Accept the dismissal.""" + + +class _FakeSizer: + """The dialog sizer double ``_build_infobar`` inserts into.""" + + def Insert( # noqa: N802, PLR0913, PLR0917 -- wx API name and shape + self, index: int, window: object, flags: int, border: int + ) -> None: + """Accept the insert; the banner is not under test.""" + + +class _WindowControl: + """A standings-list / notebook double for the whole window.""" + + def __init__(self) -> None: + """Start with no associated model and no columns.""" + self.model: object | None = None + self.columns: list[tuple[str, int, int, int, _Column]] = [] + + def AppendTextColumn( # noqa: N802, PLR0913 -- wx API name and shape + self, label: str, col: int, *, width: int, flags: int + ) -> _Column: + """Record the column and return its double.""" + column = _Column() + self.columns.append((label, col, width, flags, column)) + return column + + def Bind(self, event: object, handler: object) -> None: # noqa: N802 -- wx API + """Accept one binding.""" + + def AssociateModel(self, model: object) -> None: # noqa: N802 -- wx API name + """Record the model the view associates.""" + self.model = model + + def Refresh(self) -> None: # noqa: N802 -- wx API name the SUT calls + """Accept the repaint request.""" + + def Update(self) -> None: # noqa: N802 -- wx API name the SUT calls + """Accept the update request.""" + + def SetMinSize(self, size: object) -> None: # noqa: N802 -- wx API name + """Accept the row floor.""" + + def Hide(self) -> None: # noqa: N802 -- wx API name the SUT calls + """Accept the hide.""" + + def Show(self) -> None: # noqa: N802 -- wx API name the SUT calls + """Accept the show.""" + + +class _WindowDialog: + """The wx.Dialog double a whole ``ResultsWindow`` decorates.""" + + def __init__(self) -> None: + """Start with no presenter attached.""" + self.presenter: object | None = None + + def GetSizer(self) -> _FakeSizer: # noqa: N802 -- wx API name the SUT calls + """Return the sizer double.""" + return _FakeSizer() + + def Layout(self) -> None: # noqa: N802 -- wx API name the SUT calls + """Accept the layout request.""" + + def SetMinSize(self, size: object) -> None: # noqa: N802 -- wx API name + """Accept the window floor.""" + + def Fit(self) -> None: # noqa: N802 -- wx API name the SUT calls + """Accept the fit.""" + + +def _build_results_window( + monkeypatch: pytest.MonkeyPatch, + *, + data_source: object, + show_dns_riders: bool, +) -> tuple[ResultsWindow, _WindowControl]: + """Build a whole live ``ResultsWindow`` headless, in SOLO mode. + + Returns the window and the standalone ``standings_list`` double -- + its associated model is the rendered result, the assertion surface. + """ + dialog = _WindowDialog() + controls = { + "standings_list": _WindowControl(), + "teams_standings_list": _WindowControl(), + "solo_standings_list": _WindowControl(), + "results_notebook": _WindowControl(), + } + monkeypatch.setattr(ResultsWindow, "_find", lambda _self, name, _kind=None: controls[name]) + monkeypatch.setattr(results_win.wx, "InfoBar", _FakeInfoBar) + window = ResultsWindow(dialog, data_source=data_source, show_dns_riders=show_dns_riders) + return window, controls["standings_list"] + + +def _build_ride(roster: Roster) -> RideEngine: + """Build and start a RUNNING engine over *roster* (1 s laps).""" + config = gorba_config(min_lap_s=1) + shoe = Shoe(decks=config.deck_count, jokers_per_deck=config.jokers_per_deck, seed=20260920) + engine = RideEngine(config=config, shoe=shoe, clock=_frozen_clock, roster=roster) + engine.start() + return engine + + +def _frozen_clock() -> datetime: + """Return the event start; the tests stamp crossings explicitly.""" + return datetime(2026, 9, 20, 10, 0) # noqa: DTZ001 -- naive by design + + +def _lapped_solo_roster() -> Roster: + """Return a solo roster whose plate 12 rode a lap, 34 none.""" + roster = _roster_with_entries("12", "34") + engine = _build_ride(roster) + engine.record_crossing("12", at=datetime(2026, 9, 20, 10, 0, 5)) # noqa: DTZ001 -- by design + engine.finish() + return roster, engine + + +def _finished_ride_source() -> EngineDataSource: + """Return an ``EngineDataSource`` over a FINISHED two-solo ride. + + Plate 12 rode a lap; plate 34 never crossed, so its entry is ACTIVE + with 0 recorded laps -- the row the toggle decides about. + """ + roster, engine = _lapped_solo_roster() + return EngineDataSource(engine, roster) + + +def _live_ride_source() -> EngineDataSource: + """Return a source over the same ride, still RUNNING.""" + roster = _roster_with_entries("12", "34") + engine = _build_ride(roster) + engine.record_crossing("12", at=datetime(2026, 9, 20, 10, 0, 5)) # noqa: DTZ001 -- by design + return EngineDataSource(engine, roster) + + +def _zero_lap_ride_source() -> EngineDataSource: + """Return a source over a FINISHED ride neither entry started. + + Two 0-lap entries are two DNS rows, and their tied empty hands make + ``finish`` record a tie-break draw for each -- the card the Draw + column must never show for a row that never started. + """ + roster = _roster_with_entries("12", "34") + engine = _build_ride(roster) + engine.finish() + return EngineDataSource(engine, roster) + + +def _rendered_cells(list_control: _WindowControl, col: int) -> list[str]: + """Return the *col* cell of every row on *list_control*'s model.""" + model = list_control.model + assert isinstance(model, StandingsListModel) + return [model.GetValueByRow(row, col) for row in range(model.GetCount())] + + +def _rendered_entries(list_control: _WindowControl) -> list[str]: + """Return the Entry cell of every row on *list_control*'s model.""" + return _rendered_cells(list_control, COL_ENTRY) + + +def test_results_window_given_a_finished_ride_and_dns_riders_off_hides_the_dns_rider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unchecked: the DNS rider is absent from the standings.""" + _window, list_control = _build_results_window( + monkeypatch, data_source=_finished_ride_source(), show_dns_riders=False + ) + + assert _rendered_entries(list_control) == ["Rider 12"] + + +def test_results_window_given_a_finished_ride_and_dns_riders_on_lists_the_dns_rider_last( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Checked: the DNS rider renders, after every placed row.""" + _window, list_control = _build_results_window( + monkeypatch, data_source=_finished_ride_source(), show_dns_riders=True + ) + + assert _rendered_entries(list_control) == ["Rider 12", "Rider 34"] + + +def test_results_window_given_a_dns_row_blanks_the_place_cell( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The DNS row's Place cell is blank, never the placeholder 0.""" + _window, list_control = _build_results_window( + monkeypatch, data_source=_finished_ride_source(), show_dns_riders=True + ) + + assert _rendered_cells(list_control, COL_PLACE) == ["1", ""] + + +def test_results_window_given_a_dns_row_renders_the_dns_laps_cell( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The DNS row's Laps cell reads "DNS", the word not the 0.""" + _window, list_control = _build_results_window( + monkeypatch, data_source=_finished_ride_source(), show_dns_riders=True + ) + + assert _rendered_cells(list_control, COL_LAPS) == ["1", "DNS"] + + +def test_results_window_given_a_live_ride_lists_every_rider_whatever_the_toggle( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A RUNNING board shows everyone: rows must not vanish mid-ride.""" + _window, list_control = _build_results_window( + monkeypatch, data_source=_live_ride_source(), show_dns_riders=False + ) + + assert _rendered_entries(list_control) == ["Rider 12", "Rider 34"] + + +def test_results_window_given_a_live_zero_lap_rider_renders_a_zero_laps_cell( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """T-3: a live 0-lap row is not DNS -- its Laps cell is 0.""" + _window, list_control = _build_results_window( + monkeypatch, data_source=_live_ride_source(), show_dns_riders=False + ) + + assert _rendered_cells(list_control, COL_LAPS) == ["1", "0"] + + +def test_results_window_given_dns_rows_blanks_their_draw_cells( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 7: the Draw column shows no card for a DNS row. + + The finish drew a tie-break card for both 0-lap entries (their + empty hands tie), so an ungated row would render "5H"/"AH" beside a + row holding no place at all. + """ + _window, list_control = _build_results_window( + monkeypatch, data_source=_zero_lap_ride_source(), show_dns_riders=True + ) + + assert _rendered_cells(list_control, COL_DRAW) == ["", ""] + + +def test_results_window_defaults_show_dns_riders_to_on( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Omitting the seam is the checked menu item's own reading.""" + dialog = _WindowDialog() + controls = { + "standings_list": _WindowControl(), + "teams_standings_list": _WindowControl(), + "solo_standings_list": _WindowControl(), + "results_notebook": _WindowControl(), + } + monkeypatch.setattr(ResultsWindow, "_find", lambda _self, name, _kind=None: controls[name]) + monkeypatch.setattr(results_win.wx, "InfoBar", _FakeInfoBar) + + ResultsWindow(dialog, data_source=_finished_ride_source()) + + assert _rendered_cells(controls["standings_list"], COL_LAPS) == ["1", "DNS"] diff --git a/tests/unit/ui/test_settings_contract.py b/tests/unit/ui/test_settings_contract.py index 3a8a158a..9e5890e3 100644 --- a/tests/unit/ui/test_settings_contract.py +++ b/tests/unit/ui/test_settings_contract.py @@ -200,6 +200,23 @@ def test_settings_dialog_collect_settings_carries_the_publish_options_through( ) == (True, False, True, False, False) +def test_default_settings_show_dns_riders_defaults_to_true() -> None: + """The Results menu's "Show DNS Riders" ships checked.""" + assert default_settings().show_dns_riders is True + + +def test_settings_dialog_collect_settings_carries_the_dns_riders_toggle_through( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The DNS toggle has no dialog control, so OK carries it over.""" + settings = replace(default_settings(), show_dns_riders=False) + view, _controls, _dialog = _build_view(monkeypatch, settings=settings) + + collected = view.collect_settings() + + assert collected.show_dns_riders is False + + # ---------------------------------------- the appearance radio render From e35ba0e13e8f3f9b9e7c85bb993ad6cc63dfa271 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Tue, 22 Sep 2026 23:03:11 -0400 Subject: [PATCH 2/2] feat(results): DNS rows for zero-lap entries and a unique-riders header count On a FINISHED ride, any rider or team with 0 laps/crossings is a DNS entry: it still appears in the results, appended unplaced at the bottom of its section, with a blank Place cell, Laps rendered as "DNS", blank times and no tie-break draw. The Results menu's new "Show DNS Riders" check item (on by default, FINISHED-gated) hides those rows when off. The full-results header (HTML page and PDF cover) gains a fourth counter, "unique riders", counting individual riders and excluding DNS entries. The embedded race-data JSON keeps numeric laps plus a sparse dns flag; the standings CSV writes DNS for laps with a blank place and time. Write the change back into the contract: design spec sections 5, 6, 15 and 15b, xrc-windows section D, the user guide (and its generated HTML) and the sample results pages. --- design/docs-md/spec.md | 11 +- design/docs-md/xrc-windows.md | 2 +- .../exports/epic-2026-results-no-times.html | 4 +- design/exports/epic-2026-results-solo.html | 4 +- design/exports/epic-2026-results.html | 4 +- design/templates/macros.html.j2 | 12 +- docs/user-guide.html | 6 +- docs/user-guide.md | 6 +- src/rivercrossing/csvio.py | 23 +++- src/rivercrossing/htmlexport/__init__.py | 119 ++++++++++++++---- .../htmlexport/templates/macros.html.j2 | 12 +- src/rivercrossing/pdfexport.py | 106 ++++++++++++---- src/rivercrossing/standings.py | 108 +++++++++++++++- src/rivercrossing/ui/app.py | 100 ++++++++++++--- src/rivercrossing/ui/commands.py | 5 +- src/rivercrossing/ui/ids.py | 1 + .../ui/presenters/data_source.py | 70 ++++++++--- src/rivercrossing/ui/presenters/results.py | 12 +- src/rivercrossing/ui/presenters/settings.py | 18 ++- src/rivercrossing/ui/views/results_win.py | 36 +++++- src/rivercrossing/ui/views/settings.py | 1 + src/rivercrossing/ui/xrc/main.xrc | 28 ++++- tools/gen_htmlexport_goldens.py | 35 +++++- 23 files changed, 582 insertions(+), 141 deletions(-) diff --git a/design/docs-md/spec.md b/design/docs-md/spec.md index dfae3077..e1677901 100644 --- a/design/docs-md/spec.md +++ b/design/docs-md/spec.md @@ -156,7 +156,7 @@ Within `eval5` (exactly 5 cards) and a partial hand (fewer than 5), a joker alwa **Card cap X** (optional per ride): only the first X dealt cards score; later laps still count for laps/time. Entries holding fewer than 5 cards still rank: their cards form the best partial hand, and a missing kicker always ranks below any present one (a 4-card ace-high sits under every 5-card ace-high). A multi-deck shoe can deal one entry two physically identical cards, and within that entry's own hand they rank exactly as the physical cards they are — a pair, three, or four of a kind, or a flush whose kickers happen to repeat a rank, never a dealing error — with five of a kind only via a wild card; five natural identical cards are four of a kind. Hands this produces outside the 7,462-entry natural table order the same way every hand does: by class, then by the standard kicker comparison. **Ties** between identical hand ranks — same class, same kickers *and* the same joker count — resolve by the ride's ordered rules, applied to a FINISHED ride's results: the stored order defaults to ① high-card draw ② most laps ③ shortest total time. The venue's draw is real, not deferred: `RideEngine.finish()` draws one card per tied entry from one fresh 52-card deck (`cards.high_card_draw`, no jokers) seeded from the ride's stored `rng_seed` salted with a fixed constant (`ride._TIEBREAK_DRAW_SEED_XOR`, so the draw replays from the one stored number yet can never coincide with the shoe's own shuffle), records the cards as an audited `tiebreak_draw` event, and `snapshot()` hands each entry its own card as `EntryResult.tiebreak_card`. `standings` orders a tie group whose every entry holds its drawn card by `cards.draw_key` — rank major, suit minor, spades highest (clubs < diamonds < hearts < spades), 52 distinct keys — so the highest card wins and is the decider, and the drawn card renders in the Standings window's Draw column and in all three exports. A group with even one undrawn entry keeps the barrier, and a pair the sort still cannot separate (no drawn cards, or more tied entries than the 52-card deck holds) is flagged “draw required” at the venue. `reopen()` and a continue discard the draws, so a corrected ride redraws afresh at its next finish; a replayed `tiebreak_draw` restores them from its payload. A ride that is not yet finished ignores the stored order — its standings auto-rank ① most laps ② shortest total time, never a live venue draw. The order is editable after the finish; standings re-run instantly. Hand names render in one style everywhere — title-case em-dash ("Four of a Kind — Nines", "Full House — Aces over Fours"), produced by `rivercrossing.standings.hand_name` (E6.1.1; the frozen results-window sample row and the golden exports agree on it). -**Mixed rides rank two sections, Teams and Solo (Phase 3).** A team's pooled cards would dominate most solo hands, so teams rank against teams and solos against solos — never one combined field. `standings.rank_by_kind` runs the ranking once per kind (teams, then solos), each section numbered from 1 with DNF entrants excluded (R-65). The results window and both exports render the split — the HTML/PDF full fields carry a "Teams" section and a "Solo" section (a kind absent from the ride has no section) — and the §15 standings CSV's `type` column labels each row (`place, plate, entry, type, sex, laps, hand, draw[, total_time]` — `draw` being the card the entry drew for the venue's tie-break, blank for every entry that drew none). +**Mixed rides rank two sections, Teams and Solo (Phase 3).** A team's pooled cards would dominate most solo hands, so teams rank against teams and solos against solos — never one combined field. `standings.rank_by_kind` runs the ranking once per kind (teams, then solos), each section numbered from 1 with DNF entrants excluded (R-65). The results window and both exports render the split — the HTML/PDF full fields carry a "Teams" section and a "Solo" section (a kind absent from the ride has no section) — and the §15 standings CSV's `type` column labels each row (`place, plate, entry, type, sex, laps, hand, draw[, total_time]` — `draw` being the card the entry drew for the venue's tie-break, blank for every entry that drew none). A 0-lap ACTIVE entry on a FINISHED ride is a **DNS** row: `standings.rank_by_kind(..., zero_laps=ZeroLapMode.DNS)` partitions it out *before* the sort, so it takes no place, wins no leaderboard or podium slot and collects no tie-break draw badge, and is appended unplaced at the bottom of its section with Laps rendered as the literal `DNS`; `ZeroLapMode.HIDE` drops those rows instead, and `ZeroLapMode.RANK` — the live board's own ordering — places them like any other entry. ### 6 · Timing rules @@ -164,7 +164,7 @@ Within `eval5` (exactly 5 cards) and a partial hand (fewer than 5), a joker alwa - Crossings under `min_lap_s` (default 18:00 for an 8 km loop) flag *short*: usually a double-entry or mistyped plate. The crossing always records; whether its card is held is the ride's per-ride short-lap card policy (`RideConfig.hold_short_laps`, W4): the default Hold short laps keeps the card *held* for confirm / void / reassign (R-34) and plays the flagged cue, while the operator's explicit Always deal choice credits the card to the hand and never flags. -- Total time = last crossing − actual_start. Leaderboards: laps DESC, then total ASC. Times shown to 1 s. +- Total time = last crossing − actual_start. Leaderboards: laps DESC, then total ASC. Times shown to 1 s. A 0-lap ACTIVE entry on a FINISHED ride is a DNS row: it never reaches either leaderboard, and its Laps cell renders the literal `DNS` in the results (see §5). - Corrections: undo last (compensating write) · void/edit any crossing · reassign a crossing's plate · DNF is per rider: a pooled team member's own number marks that rider alone (their cards forfeit from the team hand) while a team drops only when every rider is out, and the mark keeps every lap and card in the record while excluding its subject from the results. @@ -352,11 +352,12 @@ One menu tree on both platforms (wx relocates About / Settings / Quit into the m | **Results ▸ Preview HTML in Browser** | Opens the last HTML export in the default browser | — | FINISHED, an HTML export exists this session | | **Results ▸ Preview Podium Poster HTML in Browser** | Opens the last podium-poster page export in the default browser | — | FINISHED, a poster page export exists this session | | **Results ▸ Preview PDF in Browser** | Opens the last PDF export — the results PDF or the podium poster — in the default browser | — | FINISHED, a PDF export exists this session | -| **Results ▸ Show lap & total times** | Checkable publish option (the five sit after a separator) — times in the published results, and the enabler for the Fastest-time leaderboard (R-63); persisted as `AppSettings.publish_show_times` (off by default) | — | always | +| **Results ▸ Show lap & total times** | Checkable publish option (the six sit after a separator) — times in the published results, and the enabler for the Fastest-time leaderboard (R-63); persisted as `AppSettings.publish_show_times` (off by default) | — | always | | **Results ▸ Laps leaderboard** | Checkable publish option — the laps board in the exports; `AppSettings.publish_laps_board` (on by default) | — | always | | **Results ▸ Fastest-time leaderboard** | Checkable publish option — the time board in the exports; `AppSettings.publish_time_board` (off by default), unchecked and disabled while Show lap & total times is off (R-63) | — | always | | **Results ▸ Full field** | Checkable publish option — the full field in the exports; `AppSettings.publish_full_field` (on by default) | — | always | | **Results ▸ All cards drawn** | Checkable publish option — every drawn card in the exports; `AppSettings.publish_all_cards` (on by default) | — | always | +| **Results ▸ Show DNS Riders** | Checkable — show/hide the DNS (0-lap) rows on a finished ride's results, listed at the bottom of their section with a blank Place and Laps "DNS"; `AppSettings.show_dns_riders` (on by default, no effect while the ride is live) | — | always | | **View ▸ Times · Zoom** | Direct commands — the two time-column check items (Show Total Times / Show Lap Time on Crossings Panel, each mirroring its own Settings checkbox) and the seven zoom radios; the Settings window's appearance radios are the single theme surface (W13 removed the View-menu theme trio and the Settings text-zoom choice — the duplicate surfaces testing notes #14/#12 removed; R-37's time columns keep both surfaces) | 1a · 3a | always | | **Help ▸ User Guide** | Opens bundled docs/user-guide.html in browser — 11 chapters + 2 appendices, section anchors deep-linked from dialog Help buttons; screenshots regenerated by the functional-test harness each release | 6a | always | | **Help ▸ Keyboard Shortcuts** | Shortcuts dialog | 3f | always | @@ -369,7 +370,7 @@ A CSV export enables none of the three Preview rows — the standings CSV is not Every window and dialog is defined in **wxWidgets XRC** (sizer-based, resizable, native controls, no absolute positioning, no restyling). The native window designs live in [XRC Windows](xrc-windows.md) — control names annotated there are the XRC `name` attributes and are **frozen**: tests find widgets by them (wxWindow.FindWindowByName), so a rename is a breaking change. The Industry-styled hi-fi mockups are retired as implementation reference. Baseline: **wxPython 4.3.1 / wxWidgets 3.3.3** — cp314 wheels, and the release that supplies wxApp.SetAppearance, so the dark theme needs no capability check (XRC colour syntax available but unused — native colours only). Measured on this baseline: macOS applies appearance changes at runtime to existing windows, while MSW returns `CannotChange` once any top-level window exists — a Windows theme change therefore takes effect at next launch, and the app says so in the status bar. -- **Naming:** snake_case; suffix by role — `_frame _dlg _panel _lbl _input _btn _chk _radio _choice _list _picker _spin _infobar _value`; menu items `mi_`; standard buttons use stock IDs (wxID_OK, wxID_CANCEL, wxID_CLOSE, wxID_DELETE, wxID_OPEN, wxID_NEW, wxID_EXIT, wxID_ABOUT). Names unique within their top-level window — so a name may repeat across windows, and several do: `plate_input`, `reason_input`, `continue_btn`, `message_lbl`. `ui/ids.py` mirrors these names 1:1 (generated from the .xrc files in CI — drift fails the build); the deltas below are the changes each batch made to that generated set. (This batch re-keyed `results_frame` as `results_dlg`, added the results notebook (`results_notebook`, `teams_standings_list`, `solo_standings_list`) and the Rider Simulator (`simulation_dlg` + `sim_running_dlg` with their controls), gaining `mi_simulation` and losing `reopen_btn` and `mi_tiebreak_order`; the prior batch added `assign_plate_btn` and `renumber_btn` to `rider_issues_dlg` and `convert_teams_of_one_chk` to `csv_preview_dlg`; Phase 4 added `team_editor_dlg` + `mi_team_editor`; Phase 8 added `exit_confirm_dlg` and `record_btn`; E5.4.1 added `duplicate_ride_dlg` and `reopen_ride_dlg`; R-78 added `rider_issues_dlg` + `mi_check_rider_issues`; ux-polish added the console gauge slots and review notebook — `elapsed_clock_panel`/`remaining_clock_panel`/`ride_status_panel`, `review_notebook`, `console_riders_list` — the Teams Editor's `logo_bmp` (renamed from `logo_preview`), the About box's `author_lbl`/`copyright_lbl`/`license_lbl`, and `no_ride_dlg` with `create_ride_btn`/`open_library_btn`, retiring `continue_or_new_dlg`/`archive_new_btn`; W7 added `add_rider_dlg` (its first/last-name fields reuse the editor's `first_name_input`/`last_name_input`) and the Rider Editor's `rider_search`; W8 added `add_team_dlg`; W11 added `plate_choice` to `entry_detail_dlg`; W13 removed the three View theme radios and the settings dialog's `zoom_choice`, the duplicate surfaces testing notes #14/#12 removed — W14 removed `mi_ride_setup`, the duplicate Ride ▸ Ride Setup… route to the setup window — W15 retired `no_ride_dlg` with its `create_ride_btn`/`open_library_btn`, a no-candidate launch showing nothing at all since the alert that replaced it was itself removed (R-80, retired 2026-09-13); H2 retired `finish_confirm_dlg`, `exit_confirm_dlg`, `duplicate_ride_dlg` and `reopen_ride_dlg` for native confirms (four windows, and their `message_lbl` registration, gone), C2 removed `arm_stop_chk`, D4 retired `mi_add_entry`, and Phase 3 removed `save_btn` (one constant, shared by both editors), `image_btn` and `remove_logo_btn` — while D1/D2 added `mi_edit_ride` and `mi_clear_ride`, C1 added the header's `ride_logo_bmp`/`ride_details_lbl`, F1 added `verbose_log_chk`, and Phase 3's `edit_btn` joined both editors; J2 added `crossing_detail_dlg` with its nine read-only value entry boxes — `crossing_rider_lbl`, `crossing_team_lbl`, `crossing_plate_lbl`, `crossing_lap_lbl`, `crossing_time_lbl`, `crossing_lap_time_lbl`, `crossing_total_lbl`, `crossing_card_lbl` and `crossing_held_lbl` — and reads the existing `edit_btn`/`delete_btn` constants the two editors already declare (J2's `edit_plate_input` was retired in the 1.0.17 follow-up, leaving those nine `crossing_*_lbl` entry boxes the window's value fields; the 1.0.17 follow-up also rebuilt them as one "Details" group of four columns and made the Team box read `solo` for a solo entry)); the results refactor swapped the one `mi_preview_browser` for the two per-format preview items — `mi_preview_html_browser` + `mi_preview_pdf_browser` (the net +1); the ride-lifecycle batch added the console header's six read-only ride read-outs — `ride_name_value`, `ride_date_value`, `ride_venue_value`, `ride_organizer_value`, `ride_scorer_value`, `ride_lap_km_value` — and the setup dialog's logo trio — `logo_preview_bmp`, `logo_status_lbl`, `logo_browse_btn` — retiring C1's `ride_name_lbl`/`ride_details_lbl` pair and `logo_picker`. The ride-sim/corrections batch added `crossing_number_dlg` (+`number_input`), the settings dialog's `avg_speed_spin`, the rider-issues dialog's `card_check_lbl`, and removed the simulator's `gen_teams_btn` (one Generate Riders button); the console header re-layout split the single Ride group into two labelled group boxes (no new frozen names — group labels are `