diff --git a/deploy/compose.yaml b/deploy/compose.yaml index e034cbf..0a1a2c5 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -36,6 +36,20 @@ services: # No username or password: the broker allows anonymous connections, because a credential # this project invented is still a credential to store and keep out of a commit (ADR-0002). OPENF1_MQTT_NO_TLS: "true" + # A switch spelled as a credential, and the value is deliberately not one. + # + # Formula 1 has closed the legacy SignalR endpoint the recorder used to reach: a negotiate + # against `livetiming.formula1.com/signalr` now answers 401 with `WWW-Authenticate: Basic` + # and `Bearer`, so the live path receives nothing at all. The replacement, + # `livetiming.formula1.com/signalrcore`, still negotiates and subscribes anonymously. + # + # Upstream chooses between the two by whether `F1_TOKEN` is *set*, not by whether its value + # authenticates: unset omits `--auth` and takes the dead endpoint, set passes `--auth` and + # takes the live one (`real_time/recording.py`). It is then sent as a bearer, which Formula 1 + # is expected to ignore — this project holds no F1TV token and is not becoming a system that + # does (ADR-0002). Whether the free subset arrives on an unauthenticated connection is the + # measurement ADR-0002 leaves outstanding, and #42 settles it against a live Session. + F1_TOKEN: unauthenticated depends_on: - api diff --git a/test/timing-screen.test.sh b/test/timing-screen.test.sh index ef4591b..a24abf5 100755 --- a/test/timing-screen.test.sh +++ b/test/timing-screen.test.sh @@ -666,4 +666,53 @@ print(counter.columns) assert_equals "a row is twenty-five columns, the whole track list the design system lays out" \ "25" "$(cells_in_a_row <<<"$spark_screen")" +# --- The header names those columns (#70) ----------------------------------------------------- +# +# Twenty-five figures a viewer cannot read is twenty-five figures they cannot use. The header is +# the design system's (docs/adr/0010) and is laid on the same grid as the rows, so the assertion +# that matters is that it has a cell for every cell a row has: one short, and every label right of +# the gap names the wrong column while still looking perfectly plausible. + +cells_in_the_header() { + python3 -c ' +import re, sys +from html.parser import HTMLParser + +class Columns(HTMLParser): + def __init__(self): + super().__init__() + self.depth = 0 + self.columns = 0 + + def handle_starttag(self, tag, attrs): + if self.depth == 1: + self.columns += 1 + self.depth += 1 + + def handle_endtag(self, tag): + self.depth -= 1 + +header = re.search(r"
", sys.stdin.read(), re.S).group(0) +counter = Columns() +counter.feed(header) +print(counter.columns) +' +} + +assert_contains "the screen carries the header that names its columns" \ + 'class="driver-row-header"' "$spark_screen" + +assert_equals "the header has a cell for every cell a row has, so no label names the wrong column" \ + "$(cells_in_a_row <<<"$spark_screen")" "$(cells_in_the_header <<<"$spark_screen")" + +assert_contains "Gap and Interval are named apart, which is the pair the screen most confuses" \ + '>Gap<' "$spark_screen" +assert_contains "the Interval column is named" '>Int<' "$spark_screen" +assert_contains "the speed trap column is named" '>Trap<' "$spark_screen" + +# The header is not a Driver, so a click on it must not open one — the row handler reaches for +# [data-driver] and this is what keeps the header out of its way. +assert_equals "the header carries no data-driver, so clicking it opens nobody" \ + "0" "$(grep -o 'driver-row-header[^>]*data-driver' <<<"$spark_screen" | wc -l | tr -d ' ')" + finish diff --git a/web/timing-screen.ts b/web/timing-screen.ts index 934aa6f..23ae71c 100644 --- a/web/timing-screen.ts +++ b/web/timing-screen.ts @@ -29,13 +29,79 @@ import { tyreBadge } from './tyre.ts'; import { sparkline, type Plot } from './sparkline.ts'; /** - * The markup inside the timing table: one row per Driver, and no row that is not a Driver. `opened` - * is the Driver a viewer has opened (#18), which the rows carry only so the one behind the panel is - * marked — the twenty rows are rendered identically whether a Driver is open or not, which is what - * keeps them updating while one is. + * The twenty-five columns, in the order `driverRow` fills them: the label the design system gives + * each one, and the cell class the figures beneath it wear. The class is what aligns a label to its + * column — header and row are laid on the same grid track list, so a header cell wearing the class + * of the cell below it cannot drift from it (driver-row.css). + * + * Four columns are deliberately unlabelled: the position change and the team bar carry no heading in + * the design, and the Driver's own best beside each sector is headed `Best` three times because that + * is what it is — three separate columns, each naming the sector to its left. + * + * This list is the header's whole definition, and `test/timing-screen.test.sh` holds it to the same + * count as a rendered row, so a column added to `driverRow` without a heading here fails rather than + * silently shifting every label right of it. + */ +const COLUMNS: readonly { readonly label: string; readonly cell: string }[] = [ + { label: 'Pos', cell: 'cell--figure' }, + { label: '', cell: 'cell--figure' }, + { label: '', cell: 'cell' }, + { label: 'No', cell: 'cell--figure' }, + { label: 'Driver', cell: 'cell' }, + { label: 'State', cell: 'cell' }, + { label: 'Gap', cell: 'cell--figure' }, + { label: 'Gap trend', cell: 'cell' }, + { label: 'Int', cell: 'cell--figure' }, + { label: 'Last', cell: 'cell--figure' }, + { label: 'Lap trend', cell: 'cell' }, + { label: 'Best', cell: 'cell--figure' }, + { label: 'S1', cell: 'cell--figure' }, + { label: 'Best', cell: 'cell--figure' }, + { label: 'S2', cell: 'cell--figure' }, + { label: 'Best', cell: 'cell--figure' }, + { label: 'S3', cell: 'cell--figure' }, + { label: 'Best', cell: 'cell--figure' }, + { label: 'Trap', cell: 'cell--figure' }, + { label: 'Tyre', cell: 'cell--centred' }, + { label: 'Age', cell: 'cell--figure' }, + { label: 'St', cell: 'cell--figure' }, + { label: 'Pit', cell: 'cell--figure' }, + { label: 'Deg trend', cell: 'cell' }, + { label: 'Laps', cell: 'cell--figure' }, +]; + +/** + * The header that names the columns (#70). Twenty-five figures a viewer cannot read is twenty-five + * figures they cannot use — Gap from Interval, Last from Best, and which of the three `Best` columns + * belongs to which sector are all unanswerable without it. + * + * It is drawn here rather than written into `index.html` so that the labels sit beside the row they + * name, in one list, instead of twenty-five strings in a second file free to drift from the markup. + */ +export function timingHeader(): string { + return [ + '
', + ...COLUMNS.map(({ label, cell }) => `${label}`), + '
', + ].join(''); +} + +/** + * The markup inside the timing table: the header, then one row per Driver and no row that is not a + * Driver. `opened` is the Driver a viewer has opened (#18), which the rows carry only so the one + * behind the panel is marked — the twenty rows are rendered identically whether a Driver is open or + * not, which is what keeps them updating while one is. + * + * The header is the table's first child, as the design system lays it out, which is also what the + * row striping counts from (`driver-row:nth-child(even)`). A viewer's click reaches a Driver through + * `[data-driver]`, which the header does not carry, so it is inert by construction rather than by a + * guard in main.ts. */ export function timingScreen(state: SessionState, opened?: DriverNumber): string { - return state.drivers.map((driver) => driverRow(driver, driver.number === opened)).join('\n'); + return [ + timingHeader(), + ...state.drivers.map((driver) => driverRow(driver, driver.number === opened)), + ].join('\n'); } function driverRow(driver: Driver, isOpen: boolean): string {