Merge origin/main

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Chuck
2026-10-05 10:04:18 -04:00
23 changed files with 2003 additions and 404 deletions
+52
View File
@@ -67,6 +67,34 @@ to for one release are gone.
interface can no longer start or stop on-demand sessions or clear errors
on a running display (the mailbox used to carry them).
### Web UI: the Display tab is an ES-module page, with a page-visibility service (stage 4)
- New `static/v3/js/core/visibility.js`: each page module gets
`ctx.visibility` with `whileVisible(start, stop)`, `every(ms, fn)` and
`isVisible()`. Work registered there runs only while the page's tab is the
active tab and the browser tab is visible, and ends when the page is
swapped out, with no teardown code in the page. It reads the active tab
from `window.LEDVisibility`, so it agrees with the classic partials that
still use that directly. The page registry gained a `mountContext` option
for services bound to one mounted page.
- The Display tab's inline scripts are now `static/v3/js/pages/display.js`.
The partial has no inline script, `onclick` or `onchange` any more. The
multi-display sync status poll (every 5 s) runs through
`ctx.visibility.every`; the status and scroll-speed hint requests go
through `core/api.js` with the page's abort signal, and so does the Vegas
order widget's plugin-list request.
- Behaviour differences: with sync on, opening the tab asks for the status
once instead of twice, and a Display tab loaded while not on screen waits
until it is. A login redirect during a poll no longer flashes "Sync status
unavailable". The `window.syncStatusInterval` timer id is gone (nothing
read it). A pending scroll-hint request or widget retry is dropped when
the partial is swapped out.
- `window.updateSyncUI` keeps working as a deprecated alias through
`window.LEDMatrix` (one console warning).
- New suites `test/js/dom/test_visibility_service.js` and
`dom/test_display_page.js`; `unit/test_display_partial_ids.js` imports the
module instead of slicing the template.
### Plugins ask for the screen in-process: `request_on_demand()` / `end_on_demand()`
The in-process way in that stage 5 of the control socket needed
@@ -242,6 +270,20 @@ Internal; no behaviour change. Stage 3 of `docs/RUN_LOOP_REDESIGN.md`.
(`refresh_registry_in_background()`, backing off for a minute after an
offline failure), so a later load has them. The store, install and update
paths still fetch as before.
- A sports live manager's idle back-off now honours every pending kickoff,
not just the first. `_note_scheduled_start_candidate()` kept one kickoff
and, while it was inside its 15-minute grace, refused every later one; by
the time the grace ended the later one had passed and was refused again.
So of two favourites kicking off within 15 minutes of each other, the
second lost its own grace: if the first game was not live by then (a rain
delay, a postponement, ESPN slow to flip it) and ESPN had not flipped the
second either, the back-off went back to its ceiling and the second game
was noticed up to the ceiling (15 minutes by default) late. Later
kickoffs now wait in a short queue (`_later_scheduled_starts`, the
earliest 8) and each takes over with a grace of its own when the one
before it expires. A kickoff still
holds the live cadence for at most its own grace, so a postponed game
costs the same quarter of an hour as before.
### ESPN date-range fetches: fewer requests, fewer at once
@@ -716,6 +758,16 @@ policies are unchanged.
- `src/display_arbiter.py` -- the display loop's Arbiter (see Tooling).
Core-internal: plugins have no reason to import it, so it sets no
`ledmatrix_min_version` floor.
- `src/common/sports_game_over.py` -- `SportsGameOverMixin`, sports
consolidation family 5: `_is_game_really_over`, the scoreboards'
`SportsLive` check that drops a game ESPN still lists as live, once the
plugins made their five bodies one. Over on a final period text, or on a
0:00 clock from period `FINAL_PERIOD` on unless the score is level (a tie
at the end of regulation goes to overtime). `FINAL_PERIOD` is the per-sport
class attribute, `None` by default (the clock never ends a game); the
scoreboards declare 3 (hockey), 4 (basketball, football, lacrosse) or
`None`. List the mixin before `SportsLiveSharedMixin`. A plugin may import
it once it floors on the release that ships it, and deletes its copy then.
### Tooling
+36 -19
View File
@@ -91,6 +91,7 @@ more. Shared sports code lives in `src/common`:
| `sports_live_scroll.py` | next release | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place |
| `sports_display_rules.py` | next release | `SportsCardOptionsMixin`, `SportsGameRulesMixin` — scorebug date options, the no-favourites filter, non-favourite live dwell |
| `sports_font_path.py` | next release | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
| `sports_game_over.py` | next release | `SportsGameOverMixin` — `_is_game_really_over`, with the `FINAL_PERIOD` seam (family 5) |
Each is described in [src/common/README.md](../src/common/README.md).
@@ -134,8 +135,7 @@ constants rather than behavior:
| Attribute | Meaning | Default |
|---|---|---|
| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) |
| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) |
| `FINAL_PERIOD` | Period from which a 0:00 clock ends a game (`sports_game_over`) | `None`: the clock never ends a game (afl, nrl, soccer, baseball, ufc). Hockey sets `3`; basketball, football and lacrosse `4` |
| `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) |
### Why these are seams and not branches
@@ -146,11 +146,14 @@ so NRL matches favorites on team ID. Flattening every plugin to abbreviations
would silently select the wrong club for NRL users. The base declares the seam,
NRL fills it, and core never learns the string `"nrl"`.
`CLOCK_COUNTS_DOWN` exists for the same reason in the opposite direction: a
`FINAL_PERIOD` exists for the same reason in the opposite direction: a
soccer clock reading `0:00` means the match has not kicked off, so running the
clock-expiry branch there would evict live games.
clock-expiry rule there would evict live games. Those sports declare `None`,
and so do baseball (innings, not a clock) and ufc (a bout ends only on ESPN's
final status). One attribute covers both questions, whether the clock can end
a game and from which period, so no separate count-down flag was added.
`COALESCE_SCORING_SEQUENCE` is the third of the same kind. In football one
`COALESCE_SCORING_SEQUENCE` is another of the same kind. In football one
scoring play arrives as two score updates, so the follow-up must be folded into
the first celebration; in soccer two increments a few seconds apart are two real
goals, and folding them would swallow one. Neither default is "right" — which is
@@ -298,6 +301,20 @@ Left in the plugins, though identical:
renderers) is already core's, in `SportsHelpersMixin`; a renderer that
wants it can inherit that.
### Family 5: the game-over check (core done; adoption waits for a release)
The pilot of the method below. ledmatrix-plugins `scripts/test_game_over_check.py`
(#621) pinned 3,115 answers across the nine plugins first; the reconcile
(ledmatrix-plugins #625) made the five bodies one and
changed only the cells the owner's decisions under
[Product decisions](#product-decisions-each-family-needs) explain: ufc's
clock rule (65 cells), baseball's dormant one (53, every one a game with a
`period` baseball's games never carry), and a level score at 0:00 (five
cells in hockey, basketball, football and lacrosse). The harness renders
were pixel-identical. `src/common/sports_game_over.py` holds the body;
`test/test_sports_game_over_parity.py` compares it, and each plugin's
`FINAL_PERIOD`, with the plugin copies.
### Why the method changes
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
@@ -333,8 +350,8 @@ game-over check); the report measures each method in it. The procedure:
line in each plugin.
- *A per-sport fact* (hockey ends in period 3; a soccer clock counts up).
Make it a declared class constant or override point with a default, as
`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`, `COALESCE_SCORING_SEQUENCE` and
`_favorite_key` are, and add it to the tables above. Never a sport-name
`FINAL_PERIOD`, `COALESCE_SCORING_SEQUENCE` and `_favorite_key` are,
and add it to the tables above. Never a sport-name
branch: core must not learn sport names.
- *A product difference*: anything a user can see (which games show, a
colour, a date, a badge, how long a screen stays). The owner picks the
@@ -382,7 +399,7 @@ release.
| # | Family | Methods (variants) | Why here |
|---|---|---|---|
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Core side done; see [Stage 4](#stage-4-the-identical-sweep-core-done-adoption-waits-for-a-release) |
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; its seams (`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`) were designed in B1. The pilot for the procedure |
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; adoption waits for the release that ships it. See [Family 5](#family-5-the-game-over-check-core-done-adoption-waits-for-a-release) |
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam |
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc) | One outlier each; football carries two fixes the other eight lack |
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it |
@@ -416,17 +433,17 @@ family 9 prepares.
Owner calls to make before (or while) reconciling. Items marked *verify* are
suspected behaviour that needs a payload or a rig to confirm first.
- **5, game-over check.** Which rule each sport gets: the clock never ends a
game in afl, nrl and soccer (`CLOCK_COUNTS_DOWN = False`); hockey ends at
0:00 from period 3, basketball, football and lacrosse from period 4.
baseball and ufc share a copy that reads a missing clock as "0:00": dormant
in baseball (its games carry no `period`), and not triggered by ufc's round
breaks either. ESPN sends a break as `STATUS_END_OF_ROUND` with displayClock
`-`, not `0:00` (verified against recorded payloads; ledmatrix-plugins#580
pins it). Whatever rule ufc gets must not read `-` as `0:00`. Decide ufc's
rule: no clock rule (ESPN's `STATUS_FINAL` is the only end signal it needs;
this also closes a ~1 s window at the horn when the ticking clock reads
`0:00`), or its own final period.
- **5, game-over check. Decided 2026-10-05, done:** one seam,
`FINAL_PERIOD`: hockey 3; basketball, football and lacrosse 4; `None` (the
clock never ends a game) for afl, nrl and soccer (clocks that count up),
baseball (its games carry no `period`, so the old rule was dormant) and
ufc (a bout ends only on ESPN's final status, which also closes the ~1 s
window at the horn when the ticking clock reads `0:00`; ESPN's round-break
displayClock `-` was never a zero clock, ledmatrix-plugins#580). Only a
non-empty clock string counts (the baseball/ufc copy read a missing clock
as `0:00`). A score level at 0:00 is not over: the game stays live through
the break before overtime, and one that really ends tied ends on its final
status. Baseball keeps its postponed/suspended override in `BaseballLive`.
- **6, favourite matching.** NRL keeps matching favourites by team id
(abbreviations collide: NEW, CAN), through `_favorite_key` rather than its
own copies of the selection methods. Six plugins log the recent-games
+38 -4
View File
@@ -42,7 +42,8 @@ static/v3/js/
registry.js page lifecycle: init/destroy on htmx swaps
api.js fetch wrapper for /api/v3 (JSON envelope, login redirect)
facade.js window.LEDMatrix and deprecated aliases
(later) escape.js, notify.js, dialog.js, streams.js, visibility.js,
visibility.js ctx.visibility: a page's timers run only while it is on screen
(later) escape.js, notify.js, dialog.js, streams.js,
store.js (the one installed-plugin store), form/renderer.js
pages/ one module per tab partial
cache.js export init(root, ctx), destroy(root, ctx)
@@ -81,6 +82,12 @@ The conventions the converted pages share:
to the module's export of the same name and warns once.
- **Timers are cleared in `destroy()`**, the one thing `ctx.signal` cannot
undo by itself.
- **Polling goes through `ctx.visibility`.** A refresh that repeats
(`ctx.visibility.every(ms, fn)`) or work that should run only while the
page is on screen (`ctx.visibility.whileVisible(start, stop)`) is
registered there, never with a bare `setInterval`. It runs only while the
page's tab is the active tab and the browser tab is visible, and it ends
when the page is destroyed, with no code in `destroy()`.
- **A page reports its own htmx saves.** A form whose result a page module
shows (an `htmx:afterRequest` listener on the page root, in place of an
`hx-on` attribute naming a global) carries `data-reports-result`. `app.js`
@@ -111,6 +118,7 @@ Each mount gets a `ctx` object:
| `ctx.state` | A per-mount object for the page's own state |
| `ctx.api` | Shared service from `boot.js` |
| `ctx.notify` | Shared service from `boot.js` |
| `ctx.visibility` | This page's handle on `core/visibility.js` (below), made per mount by `boot.js` through the registry's `mountContext` option |
A page that passes `{ signal: ctx.signal }` to `addEventListener` and
`fetch` needs no teardown code. Its listeners and in-flight requests go
@@ -119,6 +127,30 @@ example: its delete buttons use one delegated listener, rows are built with
`textContent` rather than markup strings, and a newer load supersedes an
older one.
### Page visibility
`core/visibility.js` gives each mounted page `ctx.visibility`:
| Member | What it does |
|---|---|
| `whileVisible(start, stop)` | Runs `start()` when the page comes on screen (at once, if it mounts on screen) and `stop()` when it leaves. Returns a function that ends the registration, running `stop()` first if needed |
| `every(ms, fn)` | `fn()` at once, then every `ms` while on screen. The interval is cleared while hidden and restarted, with an immediate `fn()`, when the page is back. Returns the same kind of end function |
| `isVisible()` | True while the page is on screen |
| `tab` | The tab the page belongs to: its name, or `forPage(ctx, { tab })` |
"On screen" means the page's tab is the active tab and the browser tab is
visible. Everything a page registered ends when its `ctx.signal` aborts,
after `destroy()`, so a swapped-out partial leaves no interval behind.
The answer comes from `window.LEDVisibility` (`app-shell.js`), read at call
time, so the page modules and the classic partials that still call it
(Overview, Logs, Tools) agree on the active tab, and the SSE streams keep
pausing with them. Each registration takes its own `LEDVisibility` key, so
registrations never replace each other or a classic partial's. Without
`LEDVisibility` (a page outside `base.html`), the browser tab's visibility
alone decides. Moving the tracker itself into the module (the shell table
below) changes only `core/visibility.js`.
### One facade
`window.LEDMatrix` is the only global the module code adds:
@@ -249,7 +281,7 @@ are the inline script in each partial today.
| 5 | Backup & Restore | 232 lines, now 0 | **Done in stage 2.** Its 5 globals (`exportBackup`, `loadBackupList`, `validateRestoreFile`, `clearRestore`, `runRestore`) are deprecated aliases; the buttons are delegated `data-action`s. Uploads go through `ctx.api.request(..., { body: formData })` (`api.js` gained a raw `body` option) |
| 6 | Schedule | 193 lines, now 0 | **Done in stage 3.** Its 2 `hx-on` response handlers (`handleScheduleResponse`, `handleDimScheduleResponse`) are one `htmx:afterRequest` listener on the page root, and deprecated aliases. The forms are marked `data-reports-result` so `app.js` does not repeat the server's message. The saved schedules reach the module as JSON in `data-schedule-config` / `data-dim-schedule-config` instead of being templated into the script |
| 7 | General | 153 lines, now 0 | **Done in stage 3.** The Security section's three forms and two buttons are delegated `data-action`s (one submit and one click listener); `window.webLogin` is a deprecated alias of an object with its five methods. Login requests go through `ctx.api`, so the login redirect is quiet. The settings form keeps its `hx-on` call to the shared `showSaveResult`, as Rotation's does |
| 8 | Display | 231 | First page with `LEDVisibility` timers: those move to a `ctx.visibility` service that stops on destroy |
| 8 | Display | 292 lines (2 scripts), now 0 | **Done in stage 4.** The first page with a timer: the 5 s multi-display sync poll is `ctx.visibility.every(5000, ...)` (above), so it runs only while the tab is on screen and stops when the partial is swapped out. Its one global, `updateSyncUI` (the Role menu's `onchange`), is a deprecated alias; the Advanced section's `onclick` is a delegated `data-action="toggle-section"` that calls the shared `toggleSection`. The status poll and the scroll-speed hint go through `ctx.api` with `ctx.signal`, as does the Vegas order widget's plugin-list request. The settings form keeps its `hx-on` call to `showSaveResult` and its `onsubmit` call to `fixInvalidNumberInputs`, as Rotation's does |
| 9 | Overview | 410 (4 scripts) | First-run surface: Getting Started, update banner, live preview. Five globals |
| 10 | WiFi | 364 | `x-data="wifiSetup()"` is defined by its own script. Moves to `Alpine.data()` registered from the module. AP-mode first screen, so it needs the AP-mode test on a real device |
| 11 | Fonts | 681 | Large, but self-contained (6 globals) |
@@ -266,7 +298,7 @@ the order:
| `showNotification` | 4 versions | `core/notify.js` |
| The modal helper | `utils/dialog.js` | `core/dialog.js` |
| SSE streams | `app-shell.js` | `core/streams.js` |
| `LEDVisibility` | `app-shell.js` | `core/visibility.js` |
| `LEDVisibility` | `app-shell.js` | `core/visibility.js` (the page-facing `ctx.visibility` is there since step 8; it reads the tracker from `app-shell.js`) |
Each move leaves the old global as an alias. When the last inline script is
gone, the script re-execution in `htmx-config.js` and the "HTMX never
@@ -286,13 +318,15 @@ Unit suites need only node. They import the shipped modules directly:
| Suite | Kind | What it covers |
|---|---|---|
| `unit/test_page_registry.js` | Unit, minimal DOM shim | The lifecycle: one init per root, destroy on swap, a veto keeps the page, swaps elsewhere leave it alone, the sweep, lazy loading, a destroy while loading, error containment |
| `unit/test_page_registry.js` | Unit, minimal DOM shim | The lifecycle: one init per root, destroy on swap, a veto keeps the page, swaps elsewhere leave it alone, the sweep, lazy loading, a destroy while loading, error containment, `mountContext` fields per mount |
| `dom/test_visibility_service.js` | DOM: real `LEDVisibility` from `app-shell.js`, real registry, no server | `whileVisible` and `every` start and stop with the active tab and the browser tab's visibility; no interval runs while hidden or after a swap-out; one interval after five swaps; registrations never replace each other or a classic partial's; a destroyed page registers nothing; a throwing `start()` is contained; the no-`LEDVisibility` fallback |
| `unit/test_core_modules.js` | Unit | `api.js` (envelope, errors, abort, login redirect, path check) and `facade.js` (facade, aliases) |
| `dom/test_cache_page.js` | DOM: real partial, real API shape | No inline script; one request per swap and per Refresh after five swaps; a cancelled request draws nothing; hostile keys stay text; delete, empty, error, network and login states |
| `dom/test_durations_page.js` | DOM: real partial, real widget, real API shape | One plugin-list request per swap; Move down moves one place after five swaps; the swap cancels a request in flight; a late-loading widget is waited for, and a page swapped away while waiting starts nothing; hostile names stay text |
| `dom/test_operation_history_page.js` | DOM: real partial, real API shape | One history request per swap and per Refresh; the plugin filter filled once (from `PluginAPI`'s cache when loaded); paging, filters, debounced search, Clear (one DELETE), error/network/login states, cancel on swap; hostile ids, users and errors stay text |
| `dom/test_raw_json_page.js` | DOM: real partial, real config | One POST per Save after five swaps, to the right file; Format and Validate act once; invalid JSON never sent and its message stays text; a save survives a swap and is still reported; the old globals' entry points |
| `dom/test_schedule_page.js` | DOM: real partial, real widget | Both pickers drawn once per swap from the saved config; after five swaps each form's answer is one notification (message, fallback, refused, non-JSON, `null`), a request from outside the forms none; the brightness label; a late widget waited for, a page swapped away while waiting draws nothing; the old globals' entry points |
| `dom/test_display_page.js` | DOM: real partial, real widget, real `LEDVisibility`, real API shape | After five swaps one page, one sync interval, the Vegas order drawn once and each control acting once (brightness, resolution, the two show/hide toggles, the Advanced toggle, one debounced hint request); the sync poll only while on screen and never after a swap-out; sync states and hostile peer names as text, failure and login answers; a late widget waited for; `updateSyncUI`'s entry point |
| `dom/test_general_page.js` | DOM: real partial, real widget, real API shape | The timezone picker drawn once per swap with the saved zone; the settings form left to htmx; after five swaps each Security action makes one request (create, copy, revoke and its cancel, password and its mismatch); hostile token names stay text; refused, network and login answers; a create made before a swap is still reported and draws nothing; `webLogin`'s entry points |
| `dom/test_backup_restore_page.js` | DOM: real partial, real API shape | One request per Refresh, Delete, Export (busy button ignores a second click), Inspect and Restore after five swaps; the upload's fields and the six restore options; reads cancelled by a swap, writes not; hostile file and host names stay text; the old globals' entry points |
| `test/web_interface/test_es_modules.py` | pytest | MIME type; `no-cache` without `?v` and immutable with it; `boot.js` loads last; every import resolves inside `core/` and `pages/`; the converted pages are exactly the registered ones, each with its module, `init`, and one root in the rendered partial; a converted partial has no `<script>` and no `onclick`; every moved global is aliased in `boot.js` and exported by its module, and no template defines it any more |
+1
View File
@@ -38,6 +38,7 @@ src/common/sports_celebration.py
src/common/sports_display_rules.py
src/common/sports_fetch.py
src/common/sports_font_path.py
src/common/sports_game_over.py
src/common/sports_live_scroll.py
src/common/sports_plugin_host.py
src/common/sports_scroll.py
+11
View File
@@ -46,6 +46,7 @@ Rules for the package:
| [`sports_display_rules`](#sports_display_rules) | Which games a scoreboard shows, for how long, and its scorebug date line | Yes (scoreboards) | 3.8.0 |
| [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | Yes (scoreboards) | 3.7.0 |
| [`sports_font_path`](#sports_font_path) | Find a scoreboard's bundled font whatever the cwd | Yes (scoreboards) | 3.8.0 |
| [`sports_game_over`](#sports_game_over) | Whether a game ESPN still lists as live has ended | Yes (scoreboards) | next release |
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
@@ -294,6 +295,16 @@ path as given when it exists (relative to the cwd), else
`font_layout.resolve_asset_path(path)`. What the scoreboards'
`_resolve_font_path` copies return on a core that ships it.
### sports_game_over
[`sports_game_over.py`](sports_game_over.py). `SportsGameOverMixin`:
`_is_game_really_over(game)`, the `SportsLive` check that drops a game ESPN
still lists as live (`SportsLiveSharedMixin._detect_stale_games` calls it).
Over on a final period text, or on a 0:00 clock from period `FINAL_PERIOD`
on unless the score is level. `FINAL_PERIOD` is a class attribute the host
sets per sport; the default `None` means the clock never ends a game. List
it before `SportsLiveSharedMixin`.
### sports_game_renderer
[`sports_game_renderer.py`](sports_game_renderer.py).
+125
View File
@@ -0,0 +1,125 @@
"""Whether a game ESPN still lists as live has in fact ended (sports family 5).
``SportsGameOverMixin._is_game_really_over`` is the scoreboards'
``SportsLive._is_game_really_over``, reconciled in ledmatrix-plugins
#625 from five bodies into one and copied here under
its existing name. ``SportsLiveSharedMixin._detect_stale_games``
(``src.common.sports_shared``) calls it on every live game, and the plugins'
live-priority filters call it too, to drop a game ESPN still reports as
in progress.
A game is over when its period text says final. From period ``FINAL_PERIOD``
on, a clock reading 0:00 ends it too, unless the score is level: a tie at the
end of regulation goes to overtime (or a shootout), and a game that does end
tied says final. Only a clock *string* is read ("0:00" and ":00" are zero;
":40", "0.0" and ESPN's "-" between MMA rounds are not), and a missing or
unreadable score leaves the decision to the clock.
``FINAL_PERIOD`` is the one per-sport fact, a class attribute rather than a
sport-name branch. The scoreboards declare it on their ``SportsLive``:
- 3: hockey;
- 4: basketball, football, lacrosse;
- ``None`` (this default; the clock never ends a game): afl, nrl and soccer,
whose clocks count up; baseball, which has innings; ufc, whose bouts end
only on ESPN's final status.
A sport can still override the method and defer to it, as baseball's
``BaseballLive`` does to end postponed and suspended games first.
A new module rather than another method on ``sports_shared``, for the reason
``sports_helpers`` gives: a missing module fails at load, where the version
checks see it; a missing method fails mid-update.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_game_over.py`` fails if a read is added without
being listed here.
- ``logger`` -- a ``logging.Logger``; the method logs its verdict at DEBUG.
- ``FINAL_PERIOD`` -- defaulted here to ``None``; set it on the host class.
The method reads the game dict's ``away_abbr``, ``home_abbr``,
``period_text``, ``period``, ``clock``, ``away_score`` and ``home_score``
(``_extract_game_details_common``'s keys); any of them may be missing or
null.
BASE ORDER
----------
List the mixin before ``SportsLiveSharedMixin`` --
``class SportsLive(SportsGameOverMixin, SportsLiveSharedMixin, SportsCore)`` --
so the shared mixin's ``_detect_stale_games`` finds this method through the
MRO. Neither shared mixin defines it, so the order does not change which body
runs today; it keeps the method next to its caller should one ever be added
there. A method on the plugin's own class still wins, and its ``super()``
reaches this one. The mixin has no ``__init__`` and no state.
"""
import logging
from typing import Dict, Optional
class SportsGameOverMixin:
"""The live manager's "is this game really over?" check. See module docstring."""
# The host contract, declared for type checking only.
logger: logging.Logger
#: Period from which a 0:00 clock ends a game; None: the clock never does.
FINAL_PERIOD: Optional[int] = None
def _is_game_really_over(self, game: Dict) -> bool:
"""Whether a game ESPN still lists as live has in fact ended.
It has when its period text says final. From period ``FINAL_PERIOD``
on, a clock at 0:00 ends it too, unless the score is level: a tie at
the end of regulation goes to overtime, and a game that does end tied
says final. With ``FINAL_PERIOD = None`` the clock never ends a game.
"""
game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}"
# ESPN can send the key as null, and .get()'s default only covers a
# missing key, so a None here crashed the whole live update.
raw_period_text = game.get("period_text")
period_text = raw_period_text.lower() if isinstance(raw_period_text, str) else ""
if "final" in period_text:
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning True - 'final' in period_text='{period_text}'"
)
return True
# Same for a null or non-numeric period: treat it as period 0.
try:
period = int(game.get("period") or 0)
except (TypeError, ValueError, OverflowError):
period = 0
# Only a clock string is read: "0:00" and ":00" are zero; ":40" is not.
clock = game.get("clock")
clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00")
if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero:
try:
tied = int(game["away_score"]) == int(game["home_score"])
except (KeyError, TypeError, ValueError, OverflowError):
tied = False # a missing or unreadable score leaves it to the clock
if not tied:
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning True - clock at 0:00 (clock='{clock}', period={period})"
)
return True
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning False - tied at 0:00 (period={period}), overtime next"
)
return False
self.logger.debug(
f"_is_game_really_over({game_str}): returning False"
)
return False
__all__ = ["SportsGameOverMixin"]
+55 -7
View File
@@ -57,7 +57,9 @@ Methods that stay per-plugin, because they are not identical across the eight
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``,
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``,
``_is_game_really_over``, ``_is_ranked_game``, ``_passes_other_filters``.
``_is_ranked_game``, ``_passes_other_filters``. (``_is_game_really_over``,
which ``_detect_stale_games`` below calls, was here too until the plugins
reconciled it; it is now ``src.common.sports_game_over``.)
Of the fourteen shared class constants, thirteen are identical everywhere and
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
@@ -120,6 +122,32 @@ _DEFAULT_LIVE_IDLE_MAX_SECONDS = 900
_KICKOFF_GRACE_SECONDS = 900
#: Fallback cadence around a kickoff when the manager has no update_interval.
_KICKOFF_POLL_FLOOR = 30
#: How many kickoffs after the current one a live manager remembers. Only the
#: earliest few can matter before the next look refreshes the list, so this
#: bounds the memory without dropping a kickoff the board would wait for.
_KICKOFF_QUEUE_MAX = 8
def _current_scheduled_start(host: Any, now: float) -> Optional[float]:
"""The kickoff a live manager is honouring now, promoting the next queued one.
``_next_scheduled_start_ts`` is the kickoff being honoured: the earliest
one ahead of us, or one that has just passed and is inside its grace.
Kickoffs behind it wait in ``_later_scheduled_starts``. When the current
one's grace runs out, the earliest queued kickoff that is not itself past
its grace takes over -- including one that has already passed, so a
second kickoff inside the first one's grace still gets a grace of its own.
"""
current: Optional[float] = getattr(host, "_next_scheduled_start_ts", None)
if current and current > now - _KICKOFF_GRACE_SECONDS:
return current
queued: Optional[List[float]] = getattr(host, "_later_scheduled_starts", None)
if queued:
alive = sorted(s for s in queued if s > now - _KICKOFF_GRACE_SECONDS)
current = alive.pop(0) if alive else None
host._later_scheduled_starts = alive
host._next_scheduled_start_ts = current
return current if current and current > now - _KICKOFF_GRACE_SECONDS else None
def _resolve_font_path(path: str) -> str:
@@ -1294,11 +1322,11 @@ class SportsLiveSharedMixin:
otherwise look like another empty check and escalate the back-off
again, right when the game is actually starting.
"""
start = getattr(self, "_next_scheduled_start_ts", None)
now = time.time()
start = _current_scheduled_start(self, now)
if not start:
return interval
live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR
now = time.time()
if now < start:
return max(live, min(interval, int(start - now)))
if now - start <= _KICKOFF_GRACE_SECONDS:
@@ -1313,6 +1341,16 @@ class SportsLiveSharedMixin:
already has. Self-correcting: a stored start that has passed is
replaced by the next one offered, so a postponed game cannot pin the
cadence to a kickoff that never happens.
Every pending kickoff is honoured, not just the first. A kickoff that
arrives while an earlier one is inside its grace is queued in
``_later_scheduled_starts`` (the earliest _KICKOFF_QUEUE_MAX of them)
and takes over when that grace ends, with a grace of its own. Keeping
only the one kickoff dropped the second of two favourites starting
within the grace of each other: it was refused while the first held
the slot, and refused again once it had passed, so if ESPN had not
flipped it live by the end of the first grace the back-off went
straight back to its ceiling and the game was noticed up to that late.
"""
if not isinstance(details, dict):
return
@@ -1329,7 +1367,7 @@ class SportsLiveSharedMixin:
now = time.time()
if candidate <= now:
return
current = getattr(self, "_next_scheduled_start_ts", None)
current = _current_scheduled_start(self, now)
# A kickoff that has only just passed is *kept*, not replaced by the
# next one on the card. Replacing it immediately is what made the grace
# window in _clamp_to_scheduled_start dead code: the moment 13:00 came
@@ -1339,10 +1377,20 @@ class SportsLiveSharedMixin:
# polled at 13:00:45, found nothing live because ESPN had not flipped
# the status yet, and then went quiet for the next quarter of an hour,
# which is the behaviour this whole clamp exists to prevent.
if (current is None
or current <= now - _KICKOFF_GRACE_SECONDS
or candidate < current):
#
# Nor is it forgotten: whichever kickoff loses is queued behind the
# one honoured now, so it gets its own grace when that one's ends.
if current is None:
self._next_scheduled_start_ts = candidate
return
if candidate == current:
return
if candidate < current:
self._next_scheduled_start_ts, candidate = candidate, current
queued = getattr(self, "_later_scheduled_starts", None) or []
if candidate not in queued:
self._later_scheduled_starts = sorted(
[*queued, candidate])[:_KICKOFF_QUEUE_MAX]
#: How long a game that finished live is still reported by
#: finished_games_snapshot(): long enough for the recent-games list, which
+3 -1
View File
@@ -59,7 +59,7 @@ server has none.
| `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap |
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
| `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown |
| `unit/test_display_partial_ids.js` | no | `partials/display.html`: every literal `getElementById()` in its inline scripts names an id the partial renders, and moving the brightness slider (the shipped script, in a vm with a fake DOM) updates its label without throwing |
| `unit/test_display_partial_ids.js` | no | `js/pages/display.js` started on a fake root that answers only for the ids `partials/display.html` renders: every id it looks up (with every listener and timer it set fired) exists, and moving the brightness slider updates its label without throwing |
| `unit/test_general_web_login_token.js` | no | `createToken` from `js/pages/general.js`, imported with a fake DOM and fetch: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
@@ -70,6 +70,8 @@ server has none.
| `dom/test_operation_history_page.js` | yes | The Operation History tab (`js/pages/operation-history.js`): one request per swap and per Refresh, the plugin filter filled once, paging, filters, search, Clear, error/login states, hostile values stay text |
| `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points |
| `dom/test_schedule_page.js` | yes | The Schedule tab (`js/pages/schedule.js`) with the real `schedule-picker` widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points |
| `dom/test_visibility_service.js` | yes (no server) | `js/core/visibility.js` with the real `LEDVisibility` from `app-shell.js` and the real registry: start/stop with the active tab and the browser tab's visibility, no interval while hidden or after a swap-out, registrations independent, the no-`LEDVisibility` fallback |
| `dom/test_display_page.js` | yes | The Display tab (`js/pages/display.js`) with the real `plugin-order-list` widget and `LEDVisibility`: one page, one sync interval and one action per control after repeated swaps, the sync poll only while on screen and never after a swap-out, sync states as text, the debounced scroll-speed hint, `updateSyncUI`'s entry point |
| `dom/test_general_page.js` | yes | The General tab (`js/pages/general.js`) with the real `timezone-selector` widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, `webLogin`'s entry points |
| `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points |
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
+325
View File
@@ -0,0 +1,325 @@
// The Display tab as a page module (static/v3/js/pages/display.js), in a
// real DOM (jsdom) with the real server-rendered partial, the real
// plugin-order-list widget, the real window.LEDVisibility (app-shell.js)
// behind ctx.visibility, and the real API's answer shapes. Built like
// test_cache_page.js:
//
// * the partial ships no <script> and no inline handlers; its root is
// data-page="display"
// * after five swaps: one mounted page, the Vegas order drawn once, each
// control acting once (brightness, resolution, Vegas and double-sided
// toggles, the Advanced section toggle, one debounced scroll-speed hint
// request)
// * the sync status is polled only while the Display tab is on screen and
// the browser tab visible: no interval runs while hidden, and none after
// the partial is swapped out
// * sync states drawn as text; a failed poll says "unavailable", a login
// redirect draws nothing
// * a widget that loads late is waited for, and a page swapped away while
// waiting starts nothing
// * window.updateSyncUI's entry point still works
const http = require('http');
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const partial = await get('/partials/display');
const realSync = JSON.parse(await get('/api/v3/sync/status'));
const smooth = JSON.parse(await get('/api/v3/config/scroll-speed-advice?speed=50&min=1&max=200'));
const rough = JSON.parse(await get('/api/v3/config/scroll-speed-advice?speed=37&min=1&max=200'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const { createVisibility } = await load('core/visibility.js');
const displayPage = await load('pages/display.js');
console.log('\n── Display tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial has no inline handlers', !/\son(click|change|input)=/i.test(partial));
ok('the partial root is data-page="display"', /data-page="display"/.test(partial));
ok('the Advanced toggle names its action',
/data-action="toggle-section"\s+data-section="display-section-advanced-hardware"/.test(partial));
ok('the real sync status answers in the shape the page reads',
realSync.status === 'success' && realSync.data && typeof realSync.data.state === 'string', realSync);
ok('the real scroll-speed advice answers in the shape the page reads',
smooth.status === 'success' && smooth.data.applied && Array.isArray(rough.data.alternatives)
&& rough.data.alternatives.length > 0, rough);
const errs = [];
const logged = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => logged.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="display-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('display-content');
// The browser tab's visibility and the app's active tab, under test control.
let hidden = false;
Object.defineProperty(doc, 'hidden', { get: () => hidden, configurable: true });
const setHidden = v => { hidden = v; doc.dispatchEvent(new window.Event('visibilitychange')); };
const setTab = tab => doc.dispatchEvent(new window.CustomEvent('ledmatrix:tab-changed', { detail: { tab } }));
// Intervals, counted. Timeouts (the hint's debounce, the widget retry) are real.
const intervals = new Map();
let nextInterval = 1;
window.setInterval = (fn, ms) => { const id = nextInterval++; intervals.set(id, { fn, ms }); return id; };
window.clearInterval = id => { intervals.delete(id); };
const fireIntervals = () => [...intervals.values()].forEach(i => i.fn());
const HOSTILE = '<img src=x onerror="window.pwned=1">';
const plugins = [
{ id: 'clock', name: 'Clock', enabled: true },
{ id: 'weather', name: HOSTILE, enabled: true },
];
let syncAnswer = { status: 'success', data: { role: 'leader', state: 'no_peer' } };
let syncMode = 'ok';
let advice = smooth;
const requests = [];
function fakeFetch(url, init = {}) {
requests.push(url);
const respond = (status, body, headers) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: h => (headers || {})[h] || null },
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
});
if (url === '/api/v3/plugins/installed') return respond(200, { status: 'success', data: { plugins } });
if (url.startsWith('/api/v3/config/scroll-speed-advice?')) return respond(200, advice);
if (url === '/api/v3/sync/status') {
if (syncMode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (syncMode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
return respond(200, syncAnswer);
}
return respond(404, { status: 'error', message: 'unexpected ' + url });
}
window.fetch = fakeFetch;
// The shell: LEDVisibility (no Alpine here, so the active tab is the last
// ledmatrix:tab-changed; the SSE streams open stand-in EventSources), and
// the shared toggleSection the Advanced button calls.
window.getApp = () => null;
window.EventSource = class { addEventListener() {} removeEventListener() {} close() {} };
window.eval(fs.readFileSync(path.join(JS, 'app-shell.js'), 'utf8'));
const toggled = [];
window.toggleSection = id => toggled.push(id);
window.eval(fs.readFileSync(path.join(JS, 'widgets/plugin-order-list.js'), 'utf8'));
const widget = window.PluginOrderList;
ok('the widget script defines PluginOrderList', !!(widget && widget.init));
const visibility = createVisibility({ window });
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: () => {} },
mountContext: ctx => ({ visibility: visibility.forPage(ctx) }),
});
registry.register('display', displayPage);
const $ = id => doc.getElementById(id);
const root = () => doc.querySelector('[data-page="display"]');
const count = prefix => requests.filter(u => u.startsWith(prefix)).length;
const syncPolls = () => count('/api/v3/sync/status');
async function swap(html) {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = html === undefined ? partial : html;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(20);
}
function fire(el, type) { el.dispatchEvent(new window.Event(type, { bubbles: true })); }
function setRole(role) { $('sync_role').value = role; fire($('sync_role'), 'change'); }
setTab('display');
await registry.start();
await tick(200);
// ── first load ──────────────────────────────────────────────────────────
ok('one plugin-list request on start', count('/api/v3/plugins/installed') === 1, requests);
ok('one scroll-speed hint request on start (after the debounce)', count('/api/v3/config/scroll-speed-advice') === 1, requests);
ok('the saved role is standalone: no sync request, no interval work',
$('sync_role').value === 'standalone' && syncPolls() === 0, [$('sync_role').value, syncPolls()]);
ok('the sync poll interval runs while the tab is on screen', intervals.size === 1
&& [...intervals.values()][0].ms === 5000, intervals.size);
ok('the status bar is hidden for standalone', $('sync_status_bar').classList.contains('hidden'));
// ── five swaps ──────────────────────────────────────────────────────────
for (let i = 0; i < 5; i++) await swap();
await tick(200);
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
ok('one sync interval, not six', intervals.size === 1, intervals.size);
ok('one plugin-list request per swap', count('/api/v3/plugins/installed') === 6, count('/api/v3/plugins/installed'));
ok('the Vegas order drawn once, not stacked', doc.querySelectorAll('#vegas_plugin_order .plugin-order-item').length === 2,
doc.querySelectorAll('#vegas_plugin_order .plugin-order-item').length);
ok('a hostile plugin name is shown as text', $('vegas_plugin_order').textContent.includes(HOSTILE)
&& !doc.querySelector('#vegas_plugin_order img') && !window.pwned);
// ── the controls ────────────────────────────────────────────────────────
$('brightness').value = '42';
fire($('brightness'), 'input');
ok('the brightness value follows the slider', $('brightness-value').textContent === '42', $('brightness-value').textContent);
$('rows').value = '32'; $('cols').value = '64'; $('chain_length').value = '3'; $('parallel').value = '2';
fire($('parallel'), 'input');
ok('the resolution readout is cols x chain by rows x parallel',
$('display-resolution-value').textContent === '192 × 64 pixels', $('display-resolution-value').textContent);
$('orientation').value = '90';
fire($('orientation'), 'change');
ok('...swapped for a 90-degree orientation', $('display-resolution-value').textContent === '64 × 192 pixels',
$('display-resolution-value').textContent);
$('rows').value = '';
fire($('rows'), 'input');
ok('...and a dash while a field is empty', $('display-resolution-value').textContent === '—');
for (const [box, settings, shown] of [['vegas_scroll_enabled', 'vegas_scroll_settings', 'block'],
['double_sided_enabled', 'double_sided_settings', 'grid']]) {
$(box).checked = true; fire($(box), 'change');
const on = $(settings).style.display;
$(box).checked = false; fire($(box), 'change');
ok(`${box} shows and hides its settings`, on === shown && $(settings).style.display === 'none',
[on, $(settings).style.display]);
}
root().querySelector('[data-action="toggle-section"]').click();
ok('the Advanced button toggles its section once', toggled.join() === 'display-section-advanced-hardware', toggled);
// ── the scroll-speed hint ───────────────────────────────────────────────
const hints = count('/api/v3/config/scroll-speed-advice');
advice = rough;
for (const v of ['36', '37', '38']) { $('vegas_scroll_speed').value = v; fire($('vegas_scroll_speed'), 'input'); }
ok('the speed value follows the slider', $('vegas_scroll_speed_value').textContent === '38');
await tick(250);
ok('three quick moves make one hint request', count('/api/v3/config/scroll-speed-advice') === hints + 1,
count('/api/v3/config/scroll-speed-advice') - hints);
ok('...for the last speed', requests.filter(u => u.includes('advice')).pop().includes('speed=38'));
const buttons = $('vegas_scroll_speed_hint').querySelectorAll('button');
ok('a rough speed offers the smooth ones', buttons.length === rough.data.alternatives.length
&& /will run as/.test($('vegas_scroll_speed_hint').textContent), $('vegas_scroll_speed_hint').textContent);
buttons[0].click();
ok('picking one sets the slider', $('vegas_scroll_speed').value === String(Math.round(rough.data.alternatives[0].pixels_per_second))
&& $('vegas_scroll_speed_value').textContent === $('vegas_scroll_speed').value, $('vegas_scroll_speed').value);
advice = smooth;
await tick(250);
ok('...and asks again', count('/api/v3/config/scroll-speed-advice') === hints + 2);
ok('a smooth speed says so', /^Smooth on this panel/.test($('vegas_scroll_speed_hint').textContent),
$('vegas_scroll_speed_hint').textContent);
// ── sync: the role ──────────────────────────────────────────────────────
setRole('leader');
await tick(20);
ok('choosing Leader shows the status bar', !$('sync_status_bar').classList.contains('hidden'));
ok('...hides Position', $('setting-display-sync_follower_position').style.display === 'none');
ok('...and asks for the status once', syncPolls() === 1, syncPolls());
ok('...drawn as text', $('sync_status_content').textContent.includes('No follower detected'),
$('sync_status_content').textContent);
setRole('follower');
await tick(20);
ok('choosing Follower shows Position', $('setting-display-sync_follower_position').style.display === '');
// ── sync: the poll runs only while on screen ────────────────────────────
let polls = syncPolls();
fireIntervals();
await tick(20);
ok('each interval tick polls once', syncPolls() === polls + 1, syncPolls() - polls);
setTab('logs');
ok('switching to another tab clears the interval', intervals.size === 0, intervals.size);
polls = syncPolls();
setTab('display');
await tick(20);
ok('switching back polls at once', syncPolls() === polls + 1 && intervals.size === 1, [syncPolls() - polls, intervals.size]);
setHidden(true);
ok('hiding the browser tab clears the interval', intervals.size === 0, intervals.size);
polls = syncPolls();
setHidden(false);
await tick(20);
ok('showing it polls at once', syncPolls() === polls + 1 && intervals.size === 1, [syncPolls() - polls, intervals.size]);
// ── sync: states ────────────────────────────────────────────────────────
async function poll() { fireIntervals(); await tick(20); return $('sync_status_content').textContent; }
syncAnswer = { status: 'success', data: { role: 'leader', state: 'connected', peer_ip: HOSTILE, peer_chain: 2 } };
ok('a connected follower, its address as text', (await poll()).includes('Follower connected — ' + HOSTILE)
&& !$('sync_status_content').querySelector('img'), $('sync_status_content').textContent);
syncAnswer = { status: 'success', data: { role: 'leader', state: 'incompatible', error: 'rows differ ' + HOSTILE } };
ok('incompatible panels show the reason as text', (await poll()).includes('incompatible')
&& !$('sync_error_detail').classList.contains('hidden') && $('sync_error_text').textContent === 'rows differ ' + HOSTILE
&& !$('sync_error_detail').querySelector('img'));
syncAnswer = { status: 'success', data: realSync.data };
ok('the real server\'s answer is drawn', (await poll()).length > 0, $('sync_status_content').textContent);
syncMode = 'network';
ok('a failed poll says unavailable', (await poll()).includes('Sync status unavailable'));
syncAnswer = { status: 'success', data: { role: 'follower', state: 'follower', leader_ip: '10.0.0.2' } };
syncMode = 'ok';
ok('receiving from a leader', (await poll()).includes('Receiving from leader — 10.0.0.2'));
syncMode = 'login';
ok('a login redirect draws nothing', (await poll()).includes('Receiving from leader'));
syncMode = 'ok';
// ── standalone stops asking ─────────────────────────────────────────────
setRole('standalone');
polls = syncPolls();
fireIntervals();
await tick(20);
ok('standalone hides the bar and the poll asks nothing',
$('sync_status_bar').classList.contains('hidden') && syncPolls() === polls, syncPolls() - polls);
// ── window.updateSyncUI ─────────────────────────────────────────────────
$('sync_role').value = 'leader';
polls = syncPolls();
displayPage.updateSyncUI();
await tick(20);
ok('updateSyncUI() applies the role and asks once',
!$('sync_status_bar').classList.contains('hidden') && syncPolls() === polls + 1, syncPolls() - polls);
// ── swapped out ─────────────────────────────────────────────────────────
await swap('<p>another tab</p>');
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
ok('no interval left running', intervals.size === 0, intervals.size);
polls = syncPolls();
setTab('overview');
setTab('display');
setHidden(true);
setHidden(false);
await tick(20);
ok('a swapped-out page never polls again', syncPolls() === polls && intervals.size === 0, syncPolls() - polls);
const hintsGone = count('/api/v3/config/scroll-speed-advice');
await swap();
$('vegas_scroll_speed').value = '40';
fire($('vegas_scroll_speed'), 'input');
await swap('<p>another tab</p>');
await tick(250);
ok('a hint still waiting out its debounce at the swap is never asked for',
count('/api/v3/config/scroll-speed-advice') === hintsGone, count('/api/v3/config/scroll-speed-advice') - hintsGone);
// ── the widget loads late ───────────────────────────────────────────────
delete window.PluginOrderList;
const beforeLate = count('/api/v3/plugins/installed');
await swap();
ok('no list request while the widget is missing', count('/api/v3/plugins/installed') === beforeLate);
window.PluginOrderList = widget;
await tick(150);
ok('the list starts once the widget arrives', count('/api/v3/plugins/installed') === beforeLate + 1);
delete window.PluginOrderList;
await swap();
const beforeGone = count('/api/v3/plugins/installed');
await swap('<p>another tab</p>');
window.PluginOrderList = widget;
await tick(250);
ok('a page swapped away while waiting starts nothing', count('/api/v3/plugins/installed') === beforeGone);
ok('no console errors', logged.length === 0, logged);
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+211
View File
@@ -0,0 +1,211 @@
// The page-visibility service (static/v3/js/core/visibility.js), in a real
// DOM (jsdom) with the real window.LEDVisibility from app-shell.js and the
// real page registry, wired the way core/boot.js wires them (each mount gets
// ctx.visibility from mountContext):
//
// * whileVisible(start, stop) runs start() only while the page's tab is the
// active tab AND the browser tab is visible, stop() when either changes
// * every(ms, fn) calls fn at once and then on an interval while visible;
// no interval is left running while hidden
// * everything a page registered stops when the page is swapped out, and a
// page that registers after it was destroyed starts nothing
// * registrations never replace each other (two timers on one page, two
// pages, or a classic partial's own LEDVisibility key)
// * without LEDVisibility, the browser tab's visibility alone decides
//
// Needs jsdom but no server.
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const { createRegistry } = await load('core/registry.js');
const { createVisibility } = await load('core/visibility.js');
console.log('\n── Page visibility service (real DOM, real LEDVisibility) ──');
const errs = [];
const logged = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => logged.push(a.join(' ')));
const dom = new JSDOM('<!doctype html><html><body><div id="display-content"></div><div id="logs-content"></div></body></html>',
{ url: 'http://localhost/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
// The browser tab's visibility, under the test's control.
let hidden = false;
Object.defineProperty(doc, 'hidden', { get: () => hidden, configurable: true });
function setHidden(value) {
hidden = value;
doc.dispatchEvent(new window.Event('visibilitychange'));
}
function setTab(tab) {
doc.dispatchEvent(new window.CustomEvent('ledmatrix:tab-changed', { detail: { tab } }));
}
// Intervals, counted: the point is that none is left running.
const intervals = new Map();
let nextInterval = 1;
window.setInterval = (fn, ms) => { const id = nextInterval++; intervals.set(id, { fn, ms }); return id; };
window.clearInterval = id => { intervals.delete(id); };
const fireIntervals = () => [...intervals.values()].forEach(i => i.fn());
// The real LEDVisibility (app-shell.js). Alpine is absent, so the active
// tab is the last ledmatrix:tab-changed. The SSE streams are not under
// test: they open stand-in EventSources.
window.getApp = () => null;
window.EventSource = class { addEventListener() {} removeEventListener() {} close() {} };
window.eval(fs.readFileSync(path.join(JS, 'app-shell.js'), 'utf8'));
ok('app-shell.js defines LEDVisibility', !!(window.LEDVisibility && window.LEDVisibility.onActive));
const visibility = createVisibility({ window });
const registry = createRegistry({
document: doc,
context: {},
mountContext: ctx => ({ visibility: visibility.forPage(ctx) }),
});
const log = [];
let polls = 0;
const handles = [];
registry.register('display', {
init(root, ctx) {
handles.push(ctx.visibility);
ctx.visibility.whileVisible(() => log.push('start'), () => log.push('stop'));
ctx.visibility.every(5000, () => { polls++; });
},
});
let otherRuns = 0;
registry.register('logs', {
init(root, ctx) { ctx.visibility.every(1000, () => { otherRuns++; }); },
});
const panel = doc.getElementById('display-content');
async function swap(html) {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = html;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(10);
}
// A classic partial's own registration, keyed by its tab name: the
// service's registrations must not replace it, nor it them.
let classic = 0;
window.LEDVisibility.onActive('display', () => { classic++; }, () => {});
setTab('overview');
await registry.start();
await swap('<div data-page="display"></div>');
// ── mounted on another tab ──────────────────────────────────────────────
ok('mounted while another tab is active: nothing starts', log.length === 0 && polls === 0, [log, polls]);
ok('...and no interval runs', intervals.size === 0, intervals.size);
ok('isVisible() is false', handles[0].isVisible() === false);
ok('the page\'s tab is its name', handles[0].tab === 'display');
// ── its tab comes on screen ─────────────────────────────────────────────
setTab('display');
ok('switching to the tab runs start()', log.join() === 'start', log);
ok('every() calls fn at once', polls === 1, polls);
ok('...and sets one interval at the asked period', intervals.size === 1 && [...intervals.values()][0].ms === 5000,
[...intervals.values()].map(i => i.ms));
ok('isVisible() is true', handles[0].isVisible() === true);
ok('the classic registration still runs alongside', classic === 1, classic);
fireIntervals();
fireIntervals();
ok('fn runs on each interval', polls === 3, polls);
// ── the browser tab is hidden, then shown ───────────────────────────────
setHidden(true);
ok('hiding the browser tab runs stop()', log.join() === 'start,stop', log);
ok('...and clears the interval', intervals.size === 0, intervals.size);
ok('isVisible() is false while hidden', handles[0].isVisible() === false);
setHidden(false);
ok('showing it again runs start()', log.join() === 'start,stop,start', log);
ok('...and fn at once, with one interval again', polls === 4 && intervals.size === 1, [polls, intervals.size]);
// ── another tab ─────────────────────────────────────────────────────────
setTab('logs');
ok('switching away runs stop() and clears the interval', log.join() === 'start,stop,start,stop' && intervals.size === 0,
[log, intervals.size]);
setTab('display');
ok('switching back restarts it', log.length === 5 && polls === 5 && intervals.size === 1, [log, polls]);
// ── the partial is swapped out while on screen ──────────────────────────
await swap('<p>no page here</p>');
ok('a swap-out stops it', log[log.length - 1] === 'stop', log);
ok('...and leaves no interval running', intervals.size === 0, intervals.size);
const before = [log.length, polls];
setTab('overview');
setTab('display');
setHidden(true);
setHidden(false);
ok('a destroyed page never starts again', log.length === before[0] && polls === before[1], [log, polls]);
ok('the classic registration keeps running after the swap', classic === 5, classic);
// ── five swaps, then one page ───────────────────────────────────────────
for (let i = 0; i < 5; i++) await swap('<div data-page="display"></div>');
ok('after five swaps one interval runs, not five', intervals.size === 1, intervals.size);
const pollsBefore = polls;
fireIntervals();
ok('...and one poll per tick', polls === pollsBefore + 1, polls - pollsBefore);
// ── two timers on one page, and an end function ─────────────────────────
const h = handles[handles.length - 1];
let a = 0, b = 0;
const endA = h.every(100, () => { a++; });
h.every(200, () => { b++; });
ok('two more timers on one page both start', a === 1 && b === 1 && intervals.size === 3, [a, b, intervals.size]);
endA();
endA();
ok('an end function stops just its own timer (twice is harmless)', intervals.size === 2, intervals.size);
// ── a page that registers after it was destroyed ────────────────────────
const gone = handles[handles.length - 1];
await swap('<p>gone</p>');
ok('the swap-out clears every timer the page had', intervals.size === 0, intervals.size);
let late = 0;
const end = gone.every(1000, () => { late++; });
ok('registering after destroy starts nothing', late === 0 && intervals.size === 0 && typeof end === 'function');
// ── a start() that throws ───────────────────────────────────────────────
await swap('<div data-page="display"></div>');
const h2 = handles[handles.length - 1];
const loggedBefore = logged.length;
let afterThrow = 0;
h2.whileVisible(() => { throw new Error('boom'); }, () => {});
h2.whileVisible(() => { afterThrow++; }, () => {});
ok('a throwing start() is logged', logged.length === loggedBefore + 1 && /boom|start failed/.test(logged.join()),
logged.slice(loggedBefore));
ok('...and the next registration still starts', afterThrow === 1);
await swap('<p>gone</p>');
// ── without LEDVisibility (a page outside base.html) ────────────────────
const bare = createVisibility({ window, tracker: () => null }).forPage({ name: 'standalone', signal: new window.AbortController().signal });
const seen = [];
bare.whileVisible(() => seen.push('start'), () => seen.push('stop'));
ok('without LEDVisibility, a visible document starts at once', seen.join() === 'start', seen);
setHidden(true);
ok('...and hiding it stops', seen.join() === 'start,stop', seen);
ok('...isVisible() follows the document', bare.isVisible() === false);
setHidden(false);
ok('...and showing it starts again', seen.join() === 'start,stop,start', seen);
ok('registrations need functions', (() => { try { h2.whileVisible(null, null); return false; } catch { return true; } })());
ok('every() needs a positive period', (() => { try { h2.every(0, () => {}); return false; } catch { return true; } })());
ok('the logs page was never on screen: it never ran', otherRuns === 0, otherRuns);
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+2 -1
View File
@@ -37,7 +37,8 @@ const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js',
'dom/test_schedule_page.js', 'dom/test_general_page.js'];
'dom/test_schedule_page.js', 'dom/test_general_page.js',
'dom/test_visibility_service.js', 'dom/test_display_page.js'];
function reachable(url) {
return new Promise(res => {
+76 -68
View File
@@ -1,21 +1,22 @@
// The Display tab's inline script must only look up elements the partial
// The Display tab's page module must only look up elements the partial
// renders.
//
// Its brightness slider handler also wrote to #brightness-display, a "LED
// brightness: N%" line that #387 removed from partials/display.html. The
// Its brightness slider handler once also wrote to #brightness-display, a
// "LED brightness: N%" line that #387 removed from partials/display.html. The
// lookup returned null, so every movement of the slider threw a TypeError.
// This checks every literal getElementById() in the partial's inline scripts
// against the ids its markup renders, and runs the shipped script in a vm
// with a fake DOM (null for an id the markup lacks, as in a browser) to move
// the slider.
// This imports the shipped module (static/v3/js/pages/display.js), starts it
// on a fake root that answers only for the ids the partial's markup renders
// (null for any other, as in a browser), fires every listener it registered,
// and checks that every id it asked for exists. Then it moves the slider.
//
// No jsdom and no server needed.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { pathToFileURL } = require('url');
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/display.html');
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
@@ -23,86 +24,93 @@ const ok = (label, cond, extra) => cond
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
const html = fs.readFileSync(PARTIAL, 'utf8');
const blocks = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)];
const scripts = blocks.map(m => m[1]);
// The markup is what lies between the script blocks (sliced around them, not
// a replace(), which CodeQL reads as an incomplete HTML sanitizer).
let markup = '';
let from = 0;
for (const m of blocks) {
markup += html.slice(from, m.index);
from = m.index + m[0].length;
}
markup += html.slice(from);
const rendered = new Set([...markup.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
const rendered = new Set([...html.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
console.log('\n── Display partial: element lookups ──');
// 1. Static: every literal lookup names an id the partial renders.
const lookups = scripts.flatMap(s => [...s.matchAll(/getElementById\('([^']+)'\)/g)].map(m => m[1]));
const missing = [...new Set(lookups.filter(id => !rendered.has(id)))];
ok('the inline scripts look elements up', lookups.length > 0, lookups.length);
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
// 2. Behaviour: moving the brightness slider updates its label and throws nothing.
function fakeElement(id) {
const listeners = {};
const classes = new Set();
return {
id, value: '', textContent: '', min: '', max: '', checked: false,
id, value: '1', textContent: '', min: '', max: '', checked: false,
style: {}, dataset: {}, className: '',
classList: {
add: c => classes.add(c), remove: c => classes.delete(c),
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : (on ? classes.add(c) : classes.delete(c))),
contains: c => classes.has(c),
},
addEventListener: (type, fn) => { (listeners[type] ||= []).push(fn); },
dispatchEvent() { return true; },
appendChild() {},
getAttribute: () => null,
listeners,
};
}
const main = scripts.find(s => s.includes("getElementById('brightness')"));
ok('found the script that wires the brightness slider', !!main);
if (main) {
const elements = new Map();
const document = {
readyState: 'complete',
hidden: false,
getElementById: id => {
if (!rendered.has(id)) return null;
if (!elements.has(id)) elements.set(id, fakeElement(id));
return elements.get(id);
},
createElement: () => fakeElement(''),
createTextNode: () => ({}),
addEventListener() {},
};
const window = {
LEDEscape: { html: v => String(v), attr: v => String(v) },
LEDVisibility: { onActive() {} },
};
const context = {
window, document, console, URLSearchParams,
fetch: () => new Promise(() => {}),
setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {},
};
vm.createContext(context);
let loadError = null;
try { vm.runInContext(main, context); } catch (e) { loadError = e; }
ok('the script loads', !loadError, loadError && String(loadError));
(async () => {
console.log('\n── Display page module: element lookups ──');
const display = await import(pathToFileURL(path.join(JS, 'pages/display.js')).href);
const slider = elements.get('brightness');
const handlers = (slider && slider.listeners.input) || [];
ok('the slider has an input handler', handlers.length > 0);
const asked = new Set();
const elements = new Map();
const timers = [];
const win = {
setTimeout: fn => { timers.push(fn); return timers.length; },
clearTimeout() {},
URLSearchParams,
Event: class { constructor(type) { this.type = type; } },
PluginOrderList: { init() {} },
};
const doc = { defaultView: win, createElement: () => fakeElement(''), createTextNode: () => ({}) };
const rootListeners = {};
const root = {
ownerDocument: doc,
querySelector(sel) {
const m = /^#([\w-]+)$/.exec(sel);
if (!m) throw new Error('unexpected selector ' + sel);
asked.add(m[1]);
if (!rendered.has(m[1])) return null;
if (!elements.has(m[1])) elements.set(m[1], fakeElement(m[1]));
return elements.get(m[1]);
},
addEventListener: (type, fn) => { (rootListeners[type] ||= []).push(fn); },
contains: () => true,
};
const never = () => new Promise(() => {});
const polls = [];
const ctx = {
root, name: 'display', state: {}, signal: { aborted: false },
api: { get: never },
visibility: { every: (ms, fn) => { polls.push(ms); fn(); return () => {}; } },
};
let loadError = null;
try { display.init(root, ctx); } catch (e) { loadError = e; }
ok('init() runs', !loadError, loadError && String(loadError));
ok('the sync status is polled through ctx.visibility', polls.length === 1 && polls[0] === 5000, polls);
// Fire everything it wired, so every lookup it can make is made.
let thrown = null;
try {
for (const el of elements.values()) {
for (const fns of Object.values(el.listeners)) fns.forEach(fn => fn.call(el, { target: el }));
}
while (timers.length) timers.shift()();
} catch (e) { thrown = e; }
ok('its listeners and timers run without throwing', !thrown, thrown && String(thrown));
const missing = [...asked].filter(id => !rendered.has(id));
ok('it looks elements up', asked.size > 10, asked.size);
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
const slider = elements.get('brightness') || fakeElement('brightness');
const handlers = slider.listeners.input || [];
ok('the slider has an input handler', handlers.length > 0);
slider.value = '42';
thrown = null;
try { handlers.forEach(fn => fn.call(slider, { target: slider })); } catch (e) { thrown = e; }
ok('moving the slider throws nothing', !thrown, thrown && String(thrown));
ok('...and shows the new value', elements.get('brightness-value').textContent === '42',
elements.get('brightness-value').textContent);
}
const label = elements.get('brightness-value');
ok('...and shows the new value', !!label && label.textContent === '42', label && label.textContent);
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
display.destroy(root, ctx);
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+4 -5
View File
@@ -103,17 +103,16 @@ const ESCAPERS = [
'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false],
['slider.js (escapeAttr)',
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
['display.html (escapeAttr)',
'templates/v3/partials/display.html', 'function escapeAttr(text) {', 'escapeAttr', false],
['tools.html (escHtml)',
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
['tools.html (phEscape)',
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
['logs.html (escapeHtml)',
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
// cache.html, backup_restore.html and operation_history.html have no
// script any more: their js/pages/ modules draw server data with
// textContent, and each page's suite in test/js/dom/ checks a hostile value.
// cache.html, backup_restore.html, operation_history.html and display.html
// have no script any more (display.html's two escapers were never called):
// their js/pages/ modules draw server data with textContent, and each
// page's suite in test/js/dom/ checks a hostile value.
];
// The breakout payload: closes a double-quoted attribute and opens an event
+40
View File
@@ -242,6 +242,46 @@ function recorder(log) {
ok('has()', reg.has('dup') && !reg.has('nope'));
}
console.log('\n10. mountContext adds per-mount fields, after the shared ones');
{
const doc = new Doc();
const panel = doc.body.appendChild(new El('div', { id: 'panel' }));
panel.appendChild(new El('div', { id: 'a', [PAGE_ATTRIBUTE]: 'demo' }));
const seen = [];
const made = [];
const reg = createRegistry({
document: doc, context: { api: 'shared' }, logger: quiet,
mountContext(ctx) {
made.push([ctx.name, ctx.root.getAttribute('id'), !!ctx.signal, ctx.api]);
return { bound: { root: ctx.root, signal: ctx.signal } };
},
});
reg.register('demo', { init(root, ctx) { seen.push(ctx); } });
await reg.start();
await tick();
ok('mountContext sees the mount\'s name, root, signal and the shared services',
made.length === 1 && made[0].join() === 'demo,a,true,shared', made);
ok('...and its fields reach init()', seen.length === 1 && seen[0].bound && seen[0].bound.root === seen[0].root, seen.length);
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
panel.replaceChildren(new El('div', { id: 'b', [PAGE_ATTRIBUTE]: 'demo' }));
fire(panel, 'htmx:afterSwap', { target: panel });
await tick();
ok('called again for each new mount, with that mount\'s signal',
made.length === 2 && seen.length === 2 && !!seen[1].bound && seen[1].bound.signal === seen[1].signal
&& seen[0].signal.aborted, made);
const errors = [];
const doc2 = new Doc();
doc2.body.appendChild(new El('div', { id: 'c', [PAGE_ATTRIBUTE]: 'demo' }));
const reg2 = createRegistry({ document: doc2, logger: { error: (...a) => errors.push(a.join(' ')) },
mountContext() { throw new Error('boom'); } });
let started = 0;
reg2.register('demo', { init() { started++; } });
await reg2.start();
await tick();
ok('a throwing mountContext is logged and the page still starts', started === 1 && errors.length === 1, errors);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+275
View File
@@ -0,0 +1,275 @@
"""src.common.sports_game_over: behaviour, host contract and base order.
The matrix is ledmatrix-plugins' ``scripts/test_game_over_check.py`` (the
table the family 5 reconcile was checked against) folded to the three
``FINAL_PERIOD`` values the nine scoreboards declare: None (afl, baseball,
nrl, soccer, ufc), 4 (basketball, football, lacrosse) and 3 (hockey).
Baseball's postponed/suspended override stays in its plugin and is not here.
"""
import ast
import logging
import time
from pathlib import Path
import pytest
from src.common import sports_game_over
from src.common.sports_game_over import SportsGameOverMixin
from src.common.sports_shared import SportsLiveSharedMixin
LOG = logging.getLogger("test_sports_game_over")
def host(final_period):
"""A live manager stand-in declaring ``FINAL_PERIOD`` as a plugin does."""
cls = type("Live", (SportsGameOverMixin,), {"FINAL_PERIOD": final_period})
h = cls()
h.logger = LOG
return h
MISSING = object() # the key is absent from the game dict
def game(period_text="", period=MISSING, clock=MISSING, away="1", home="2"):
g = {"away_abbr": "AWY", "home_abbr": "HOM", "away_score": away,
"home_score": home, "period_text": period_text}
if period is not MISSING:
g["period"] = period
if clock is not MISSING:
g["clock"] = clock
return g
# ---------------------------------------------------------------------------
# The matrix: clock x period, for each FINAL_PERIOD. Scores 1-2.
# ---------------------------------------------------------------------------
FINAL_PERIODS = (None, 4, 3)
PERIODS = (MISSING, 1, 2, 3, 4, 5, 6)
CLOCKS = {"12:00": "12:00", "0:00": "0:00", ":00": ":00", "0.0": "0.0",
"-": "-", "''": "", "None": None, "missing": MISSING}
#: The period text each ESPN status carries. Only "Final" contains "final";
#: the method reads no status, so every other text answers the same row.
LIVE_TEXTS = {
"in progress": lambda p: "" if p is MISSING else f"P{p}",
"end of period": lambda p: "" if p is MISSING else f"End P{p}",
"halftime": lambda p: "Halftime",
"end of round": lambda p: "" if p is MISSING else f"End R{p}",
"postponed": lambda p: "Postponed",
}
#: clock -> one cell per period (missing, 1..6) for FINAL_PERIOD None, 4, 3.
EXPECTED_LIVE = {
"12:00": "....... ....... .......",
"0:00": "....... ....YYY ...YYYY",
":00": "....... ....YYY ...YYYY",
"0.0": "....... ....... .......",
"-": "....... ....... .......",
"''": "....... ....... .......",
"None": "....... ....... .......",
"missing": "....... ....... .......",
}
def row(text_for, clock):
return " ".join(
"".join("Y" if host(fp)._is_game_really_over(game(text_for(p), p, clock)) else "."
for p in PERIODS)
for fp in FINAL_PERIODS)
@pytest.mark.parametrize("status", sorted(LIVE_TEXTS))
@pytest.mark.parametrize("clock_label", sorted(CLOCKS))
def test_a_live_period_text(status, clock_label):
assert row(LIVE_TEXTS[status], CLOCKS[clock_label]) == EXPECTED_LIVE[clock_label]
@pytest.mark.parametrize("clock_label", sorted(CLOCKS))
def test_a_final_period_text_is_always_over(clock_label):
assert row(lambda p: "Final", CLOCKS[clock_label]) == "YYYYYYY YYYYYYY YYYYYYY"
#: label -> (game, one cell per FINAL_PERIOD None, 4, 3)
EDGES = {
"period_text None, P4 0:00": (game(None, 4, "0:00"), ".YY"),
"period None, 0:00": (game("", None, "0:00"), "..."),
"period 'OT', 0:00": (game("OT", "OT", "0:00"), "..."),
"period '4' (str), 0:00": (game("P4", "4", "0:00"), ".YY"),
"clock int 0, P4": (game("P4", 4, 0), "..."),
"clock float 0.0, P4": (game("P4", 4, 0.0), "..."),
"clock ' 0:00 ', P4": (game("P4", 4, " 0:00 "), ".YY"),
"clock '00:00', P4": (game("P4", 4, "00:00"), "..."),
"period_text 'Final/OT', P5 0:00": (game("Final/OT", 5, "0:00"), "YYY"),
"period_text 'FINAL', P1 12:00": (game("FINAL", 1, "12:00"), "YYY"),
}
@pytest.mark.parametrize("label", sorted(EDGES))
def test_edge_shapes(label):
g, want = EDGES[label]
got = "".join("Y" if host(fp)._is_game_really_over(dict(g)) else "." for fp in FINAL_PERIODS)
assert got == want
# ---------------------------------------------------------------------------
# The tie guard: level at 0:00 is overtime, not the end.
# ---------------------------------------------------------------------------
class TestTieGuard:
@pytest.mark.parametrize("fp,period", [(4, 4), (4, 5), (3, 3), (3, 4), (3, 5)])
def test_level_at_zero_is_not_over(self, fp, period):
assert host(fp)._is_game_really_over(game("", period, "0:00", "2", "2")) is False
def test_level_scores_compare_as_numbers(self):
assert host(4)._is_game_really_over(game("", 4, "0:00", 2, "2")) is False
assert host(4)._is_game_really_over(game("", 4, "0:00", " 2 ", "2")) is False
def test_a_game_that_ends_level_ends_on_final(self):
assert host(4)._is_game_really_over(game("Final/OT", 5, "0:00", "2", "2")) is True
def test_level_before_the_final_period_was_never_over(self):
assert host(4)._is_game_really_over(game("", 3, "0:00", "2", "2")) is False
@pytest.mark.parametrize("away,home", [
(MISSING, MISSING), (None, None), ("", ""), ("2", None),
("2.0", "2.0"), ({"value": 2}, {"value": 2}), ("inf", "inf"),
])
def test_an_unreadable_score_leaves_it_to_the_clock(self, away, home):
g = game("", 4, "0:00")
for key, value in (("away_score", away), ("home_score", home)):
if value is MISSING:
del g[key]
else:
g[key] = value
assert host(4)._is_game_really_over(g) is True
def test_float_infinity_does_not_raise(self):
assert host(4)._is_game_really_over(
game("", 4, "0:00", float("inf"), float("inf"))) is True
# ---------------------------------------------------------------------------
# ufc: ESPN MMA payloads, as ufc's _extract_game_details stores them
# (ledmatrix-plugins plugins/ufc-scoreboard/test/fixtures/espn_mma_round_states.json).
# ---------------------------------------------------------------------------
UFC_RECORDED = {
"in_round_3_of_3": ("R3", 3, "1:21"),
"break_after_round_1": ("R1", 1, "-"),
"end_of_round_after_stoppage": ("R2", 2, "0:51"),
"walkouts_five_rounder": ("", 0, "-"),
"final_five_round_decision": ("R5", 5, "5:00"),
"final_five_round_stoppage": ("R5", 5, "1:38"),
"final_three_round_decision": ("R3", 3, "5:00"),
"final_three_round_stoppage": ("R2", 2, "4:07"),
"break_after_round_4_of_5": ("R4", 4, "-"),
"end_of_round_5_awaiting_decision": ("R5", 5, "-"),
}
class TestUfc:
@pytest.mark.parametrize("name", sorted(UFC_RECORDED))
def test_no_recorded_state_is_over_here(self, name):
"""A finished bout leaves the live list on is_final, before this is asked."""
text, period, clock = UFC_RECORDED[name]
assert host(None)._is_game_really_over(game(text, period, clock, "0", "0")) is False
@pytest.mark.parametrize("fp", FINAL_PERIODS)
def test_a_round_break_dash_is_never_a_zero_clock(self, fp):
assert host(fp)._is_game_really_over(game("R4", 4, "-", "1", "2")) is False
@pytest.mark.parametrize("clock", ["0:00", None, MISSING])
def test_the_horn_does_not_end_a_bout(self, clock):
assert host(None)._is_game_really_over(game("R5", 5, clock, "1", "2")) is False
# ---------------------------------------------------------------------------
# Wiring: the default, overrides, and the live mixin's caller.
# ---------------------------------------------------------------------------
def test_the_default_is_no_clock_rule():
assert SportsGameOverMixin.FINAL_PERIOD is None
def test_an_override_defers_through_super():
"""baseball's BaseballLive: its own check first, then the shared one."""
class Baseballish(SportsGameOverMixin):
logger = LOG
def _is_game_really_over(self, game):
if game.get("status") == "status_postponed":
return True
return super()._is_game_really_over(game)
b = Baseballish()
assert b._is_game_really_over(dict(game("", 6, "0:00"), status="status_postponed")) is True
assert b._is_game_really_over(game("", 6, "0:00")) is False
assert b._is_game_really_over(game("Final", 9, None)) is True
class _Live(SportsGameOverMixin, SportsLiveSharedMixin):
"""A SportsLive stand-in in the documented base order."""
FINAL_PERIOD = 4
def __init__(self):
self.logger = LOG
self.stale_game_timeout = 600
self.game_update_timestamps = {}
class TestBaseOrder:
def test_the_documented_order_resolves_this_method(self):
assert _Live._is_game_really_over is SportsGameOverMixin._is_game_really_over
mro = _Live.__mro__
assert mro.index(SportsGameOverMixin) < mro.index(SportsLiveSharedMixin)
def test_neither_shared_mixin_defines_it(self):
"""So the base order cannot change which body runs."""
from src.common.sports_shared import SportsCoreSharedMixin
for mixin in (SportsLiveSharedMixin, SportsCoreSharedMixin):
assert "_is_game_really_over" not in vars(mixin)
def test_detect_stale_games_drops_an_over_game_through_it(self):
live = _Live()
live.game_update_timestamps = {"over": {"last_seen": time.time()},
"on": {"last_seen": time.time()}}
games = [dict(game("", 4, "0:00"), id="over"),
dict(game("", 4, "0:00", "2", "2"), id="on")]
live._detect_stale_games(games)
assert [g["id"] for g in games] == ["on"]
assert "over" not in live.game_update_timestamps
def test_the_class_value_wins_over_the_default(self):
assert _Live().FINAL_PERIOD == 4
assert _Live()._is_game_really_over(game("", 4, "0:00")) is True
# ---------------------------------------------------------------------------
# Host contract
# ---------------------------------------------------------------------------
def _self_reads():
tree = ast.parse(Path(sports_game_over.__file__).read_text(encoding="utf-8"))
cls = next(n for n in tree.body
if isinstance(n, ast.ClassDef) and n.name == "SportsGameOverMixin")
return {node.attr for node in ast.walk(cls)
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
and isinstance(node.value, ast.Name) and node.value.id == "self"}
class TestHostContract:
def test_every_host_read_is_documented(self):
undocumented = sorted(n for n in _self_reads()
if f"``{n}``" not in sports_game_over.__doc__)
assert undocumented == [], f"read but not in the host contract: {undocumented}"
def test_the_mixin_creates_no_state(self):
assert "__init__" not in vars(SportsGameOverMixin)
assert not hasattr(SportsGameOverMixin, "logger")
assert sorted(n for n in vars(SportsGameOverMixin) if not n.startswith("__")) == [
"FINAL_PERIOD", "_is_game_really_over"]
+123
View File
@@ -0,0 +1,123 @@
"""sports_game_over still matches every plugin copy, and each plugin's FINAL_PERIOD.
``SportsGameOverMixin._is_game_really_over`` was copied from the scoreboards'
``SportsLive._is_game_really_over`` once family 5 had made the nine copies one
body. The plugins delete their copies once they floor on the release that
ships this module. Until each has, a copy that changes on its own is a fix one
side has and the other lacks.
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and the method is
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
normalisation (the AST with docstrings and annotations dropped), plus the
decorators. A copy that is gone counts as adopted when the plugin's
``sports.py`` names the module. Each plugin's ``SportsLive.FINAL_PERIOD`` is
compared with the value the owner decided for its sport, which stays in the
plugin after adoption. Without the variable this skips: core CI has no plugins
checkout.
"""
import ast
import importlib.util
import os
from pathlib import Path
import pytest
from src.common import sports_game_over
REPO = Path(__file__).resolve().parents[1]
#: The owner's decision (docs/SPORTS_UNIFICATION.md, family 5): the period
#: from which a 0:00 clock ends a game, None where the clock never does.
FINAL_PERIOD = {
"afl": None, "baseball": None, "basketball": 4, "football": 4,
"hockey": 3, "lacrosse": 4, "nrl": None, "soccer": None, "ufc": None,
}
NAME = "_is_game_really_over"
def _drift_report():
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
spec = importlib.util.spec_from_file_location(
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
DRIFT = _drift_report()
def _plugins_root():
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
if root is None:
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
"compare this module against the plugin copies")
return root
def _class(tree, name):
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
def _method(cls):
return next((n for n in cls.body
if isinstance(n, ast.FunctionDef) and n.name == NAME), None)
def _fingerprint(node):
return (DRIFT._digest(node, DRIFT._Canonical()),
tuple(ast.unparse(d) for d in node.decorator_list))
def _final_period(cls):
for node in cls.body:
if (isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None):
target = node.targets[0] if isinstance(node, ast.Assign) else node.target
if isinstance(target, ast.Name) and target.id == "FINAL_PERIOD":
return ast.literal_eval(node.value)
raise AssertionError("SportsLive declares no FINAL_PERIOD")
def _ours():
tree = ast.parse(Path(sports_game_over.__file__).read_text(encoding="utf-8"))
return _class(tree, "SportsGameOverMixin")
def test_the_mixin_holds_one_method_and_the_default():
names = sorted(n.name if isinstance(n, ast.FunctionDef) else n.target.id
for n in _ours().body if isinstance(n, (ast.FunctionDef, ast.AnnAssign))
and (isinstance(n, ast.FunctionDef) or n.value is not None))
assert names == ["FINAL_PERIOD", NAME]
assert _final_period(_ours()) is None
@pytest.mark.parametrize("sport", sorted(FINAL_PERIOD))
def test_every_remaining_plugin_copy_matches(sport):
root = _plugins_root()
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
live = _class(ast.parse(source), "SportsLive")
copy = _method(live)
if copy is None:
assert sports_game_over.__name__ in source, (
f"{sport}: no {NAME} on SportsLive and no {sports_game_over.__name__} import")
else:
assert _fingerprint(copy) == _fingerprint(_method(_ours())), (
f"{NAME} in {sport} differs from sports_game_over. "
f"Port the change to both, or stop treating it as shared.")
@pytest.mark.parametrize("sport", sorted(FINAL_PERIOD))
def test_every_plugin_declares_its_final_period(sport):
root = _plugins_root()
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
assert _final_period(_class(ast.parse(source), "SportsLive")) == FINAL_PERIOD[sport]
def test_the_drift_report_still_calls_it_identical():
root = _plugins_root()
families = DRIFT.build(root, ("sports.py",))
rows = {(r["file"], r["family"]): r
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
row = rows.get(("sports.py", NAME))
assert row is None or row["worst_class_variants"] == 1
+130
View File
@@ -465,6 +465,136 @@ class TestLiveMixin:
# The safety property that makes it correct: 30s beats 600s.
assert h._idle_live_interval() == h.update_interval
# ---- every pending kickoff is honoured, not just the first ------------
#
# One stored kickoff held the slot through its grace and refused every
# later one; a later one that had passed by the time the grace ended was
# refused again as "already past". So of two favourites kicking off ten
# minutes apart, the second lost its grace: if ESPN had not flipped it live
# by the end of the FIRST game's grace, the back-off returned to its
# ceiling and the game was noticed up to that late. That bites whenever the
# first game is not live by then -- a rain delay, a postponement, ESPN slow
# to flip it -- since a live first game keeps the live cadence anyway.
@staticmethod
def _replay(monkeypatch, kickoffs, flips, until, poll=30, ceiling=900):
"""Drive a live manager's idle loop over a schedule on a fake clock.
``kickoffs`` are start offsets in seconds from t=0 (the first look),
``flips`` how long after its start ESPN reports each game live (None:
postponed, never live). Every
look offers each not-yet-live game, as the live loop does, then sleeps
for whatever the back-off returns. Returns, per game, how long after it
went live it was first seen live -- None if never.
"""
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = _LiveHost(no_data_interval=300)
h.live_idle_max_interval = ceiling
h.update_interval = poll
h._empty_live_streak = 30 # idle all morning: at the ceiling
t0 = clock[0]
seen = [None] * len(kickoffs)
while clock[0] - t0 < until:
now = clock[0] - t0
live = [f is not None and k + f <= now for k, f in zip(kickoffs, flips)]
for i, is_live in enumerate(live):
if is_live and seen[i] is None:
seen[i] = now - (kickoffs[i] + flips[i])
start = datetime.fromtimestamp(t0 + kickoffs[i], tz=timezone.utc)
h._note_scheduled_start_candidate(
{"is_live": is_live, "is_halftime": False,
"start_time_utc": start})
# A real board keeps polling at the live cadence while anything is
# live; a game here stays live for an hour after it flips.
on = any(f is not None and k + f <= now < k + f + 3600
for k, f in zip(kickoffs, flips))
h._note_live_fetch(on)
clock[0] += poll if on else h._idle_live_interval()
assert len(getattr(h, "_later_scheduled_starts", None) or ()) <= sports_shared._KICKOFF_QUEUE_MAX
return seen
@pytest.mark.parametrize("kickoffs,flips", [
# (start offsets, ESPN's flip delay per game), both in seconds.
pytest.param([1800], [120], id="one kickoff, flipped 2 min late"),
pytest.param([1800, 2400], [0, 840],
id="first live on time, the second flipped 14 min late"),
pytest.param([1800, 2400], [None, 840],
id="first postponed, the second 10 min later flipped 14 min late"),
pytest.param([1800, 2400], [1200, 840],
id="first in a 20 min delay, the second flipped 14 min late"),
pytest.param([1800, 2100, 2520], [None, None, 600],
id="three inside one grace, the last flipped 10 min late"),
pytest.param([1800, 1800, 2400], [None, None, 700],
id="two at the same time, then one 10 min later"),
pytest.param([1800, 2700], [None, 840],
id="second kickoff 15 min later, flipped 14 min late"),
pytest.param([1800 + 60 * i for i in range(20)],
[None] * 19 + [840],
id="twenty kickoffs a minute apart overflow the queue"),
])
def test_every_pending_kickoff_gets_its_grace(self, monkeypatch, kickoffs, flips):
seen = self._replay(monkeypatch, kickoffs, flips,
until=max(kickoffs) + 3 * 3600)
late = [s for s, f in zip(seen, flips)
if f is not None and (s is None or s > 30)]
assert not late, "games noticed late (s after going live): %r" % (seen,)
def test_a_kickoff_that_never_flips_costs_one_grace_then_backs_off(self, monkeypatch):
# A postponed game keeps the live cadence for its grace and no longer:
# remembering more kickoffs must not pin the poll to dead ones.
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = self._idle_host()
t0 = clock[0]
for offset in (600, 900):
h._note_scheduled_start_candidate(
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
clock[0] = t0 + 900 + sports_shared._KICKOFF_GRACE_SECONDS - 1
assert h._idle_live_interval() == h.update_interval
clock[0] = t0 + 900 + sports_shared._KICKOFF_GRACE_SECONDS + 1
assert h._idle_live_interval() == 900
assert not getattr(h, "_later_scheduled_starts", None)
def test_a_queued_kickoff_past_its_own_grace_is_skipped(self, monkeypatch):
# After a long sleep (or a run of looks that never woke the manager)
# several queued kickoffs may have gone stale at once. The one still
# inside its grace must win, not the first stale one in the queue.
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = self._idle_host()
t0 = clock[0]
for offset in (600, 700, 1500):
h._note_scheduled_start_candidate(
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
clock[0] = t0 + 1500 + 150 # 600 and 700 are past their grace
assert h._idle_live_interval() == h.update_interval
assert h._next_scheduled_start_ts == t0 + 1500
def test_the_queue_keeps_the_earliest_kickoffs(self, monkeypatch):
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = self._idle_host()
t0 = clock[0]
cap = sports_shared._KICKOFF_QUEUE_MAX
for offset in reversed(range(1, cap + 6)): # latest first
h._note_scheduled_start_candidate(
{"start_time_utc": datetime.fromtimestamp(t0 + 600 * offset, tz=timezone.utc)})
assert h._next_scheduled_start_ts == t0 + 600
assert h._later_scheduled_starts == [t0 + 600 * i for i in range(2, cap + 2)]
def test_a_kickoff_offered_twice_is_kept_once(self, monkeypatch):
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = self._idle_host()
t0 = clock[0]
for _ in range(3):
for offset in (600, 1200):
h._note_scheduled_start_candidate(
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
assert h._next_scheduled_start_ts == t0 + 600
assert h._later_scheduled_starts == [t0 + 1200]
def test_finding_a_live_game_resets_the_streak(self):
h = _LiveHost()
h._note_live_fetch(False)
+4 -1
View File
@@ -43,7 +43,8 @@ def _url(path):
def test_the_module_directories_hold_modules():
assert {p.name for p in MODULES} >= {"boot.js", "registry.js", "api.js", "facade.js", "cache.js",
"durations.js", "operation-history.js", "raw-json.js",
"backup-restore.js", "schedule.js", "general.js"}
"backup-restore.js", "schedule.js", "general.js",
"visibility.js", "display.js"}
for directory in MODULE_DIRS:
# node needs this to import them in the JS tests; browsers ignore it.
assert '"type": "module"' in (directory / "package.json").read_text(encoding="utf-8")
@@ -106,6 +107,7 @@ CONVERTED = {
"backup-restore": ("backup_restore.html", "/partials/backup-restore"),
"schedule": ("schedule.html", "/partials/schedule"),
"general": ("general.html", "/partials/general"),
"display": ("display.html", "/partials/display"),
}
# Old window.* names that moved into a page module. Each stays as a
@@ -118,6 +120,7 @@ ALIASES = {
"clearRestore", "runRestore"],
"schedule": ["handleScheduleResponse", "handleDimScheduleResponse"],
"general": ["webLogin"],
"display": ["updateSyncUI"],
}
+6
View File
@@ -11,13 +11,17 @@
import { createApi } from './api.js';
import { createFacade, installFacade } from './facade.js';
import { createRegistry } from './registry.js';
import { createVisibility } from './visibility.js';
const api = createApi();
const visibility = createVisibility({ window: window });
const registry = createRegistry({
context: {
api: api,
notify: function(message, type) { return window.LEDMatrix.notify(message, type); },
},
// Bound to one mounted page: its timers stop when it is destroyed.
mountContext: function(ctx) { return { visibility: visibility.forPage(ctx) }; },
});
const facade = installFacade(window, createFacade(window, api, registry));
@@ -45,6 +49,7 @@ const pages = {
'backup-restore': page(function() { return import('../pages/backup-restore.js'); }),
'schedule': page(function() { return import('../pages/schedule.js'); }),
'general': page(function() { return import('../pages/general.js'); }),
'display': page(function() { return import('../pages/display.js'); }),
};
Object.keys(pages).forEach(function(name) { registry.register(name, pages[name]); });
@@ -85,5 +90,6 @@ alias('cache', 'deleteCacheFile', "the Cache tab's Delete buttons");
});
aliasObject('general', 'webLogin', ['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'],
"the General tab's Security buttons");
alias('display', 'updateSyncUI', "the Display tab's Role menu");
registry.start();
+14 -1
View File
@@ -8,7 +8,9 @@
* destroy(root, ctx) optional; undo anything `ctx.signal` does not.
*
* ctx is a per-mount object holding the shared services passed to
* createRegistry({ context }) (boot.js passes `api` and `notify`) plus:
* createRegistry({ context }) (boot.js passes `api` and `notify`), the fields
* returned by createRegistry({ mountContext }) for this mount (boot.js adds
* `visibility`, core/visibility.js), plus:
* ctx.root the data-page element
* ctx.name the page name
* ctx.signal an AbortSignal aborted on destroy. Pass it to
@@ -42,12 +44,16 @@ export const PAGE_ATTRIBUTE = 'data-page';
* @param {object} [options]
* @param {Document} [options.document] the document to wire (default: globalThis.document)
* @param {object} [options.context] services copied onto every page's ctx
* @param {Function} [options.mountContext] (ctx) => fields added to that mount's ctx;
* for services bound to one page (they see
* ctx.root, ctx.name and ctx.signal)
* @param {{error: Function}} [options.logger]
*/
export function createRegistry(options = {}) {
const doc = options.document || globalThis.document;
const logger = options.logger || console;
const services = options.context || {};
const mountContext = options.mountContext || null;
// The document's own AbortController: an element only accepts a signal
// from its own realm (it matters for jsdom in the tests, not in a browser).
const Controller = (doc && doc.defaultView && doc.defaultView.AbortController) || globalThis.AbortController;
@@ -105,6 +111,13 @@ export function createRegistry(options = {}) {
const controller = new Controller();
const ctx = Object.assign({}, services,
{ root: root, name: name, signal: controller.signal, state: {} });
if (mountContext) {
try {
Object.assign(ctx, mountContext(ctx));
} catch (error) {
logger.error('[LEDMatrix.pages] ' + name + ': mountContext failed:', error);
}
}
const entry = { name: name, root: root, ctx: ctx, controller: controller,
module: null, initialised: false, destroyed: false, ready: null };
mounted.set(root, entry);
@@ -0,0 +1,144 @@
/*
* core/visibility.js -- run a page's timers and polling only while the page
* is on screen: its tab is the active tab AND the browser tab is visible.
*
* Each mounted page gets its own handle as ctx.visibility (the registry asks
* boot.js for it on every mount; see createRegistry's `mountContext`):
*
* ctx.visibility.whileVisible(start, stop)
* start() runs when the page comes on screen, stop() when it leaves.
* start() should refresh at once: it also runs when the page mounts
* already on screen. Returns a function that ends the registration
* (running stop() first if it is running).
* ctx.visibility.every(ms, fn)
* fn() at once and then every `ms` while on screen; the interval is
* cleared while hidden and restarted (with an immediate fn()) when the
* page is back. Returns the same kind of end function.
* ctx.visibility.isVisible()
* true while the page is on screen.
*
* Everything a page registered ends when the page is destroyed (its
* ctx.signal aborts, after destroy()), so a page needs no teardown for it:
* a swapped-out partial leaves no interval behind.
*
* Which tab: the page's name (ctx.name), or forPage(ctx, { tab }) when a
* page's tab is named differently.
*
* Where the answer comes from: window.LEDVisibility (app-shell.js), read at
* call time. It owns the one notion of "active tab" (Alpine's activeTab plus
* the ledmatrix:tab-changed event) and already pauses the SSE streams, so
* the classic partials that still call it and the page modules agree. Each
* registration here takes its own LEDVisibility key, so two pages (or two
* timers on one page) never replace each other. Without LEDVisibility (a
* page outside base.html) the browser tab's visibility alone decides.
*
* No DOM globals are read at import time, so node tests can import this file
* and hand createVisibility() a jsdom window.
*/
/**
* @param {object} [options]
* @param {Window} [options.window] default: globalThis
* @param {Function} [options.tracker] () => an LEDVisibility-shaped object or null
* (default: window.LEDVisibility at call time)
* @param {{error: Function}} [options.logger]
*/
export function createVisibility(options = {}) {
const win = options.window || globalThis;
const doc = win.document;
const logger = options.logger || win.console || console;
const tracker = options.tracker || function() { return win.LEDVisibility || null; };
let sequence = 0;
function call(fn, label) {
try {
fn();
} catch (error) {
logger.error('[LEDMatrix.visibility] ' + label + ' failed:', error);
}
}
/**
* Without a tracker: follow document.hidden only. Same contract as
* LEDVisibility.onActive: start/stop on change, returns an unregister fn.
*/
function onDocumentVisible(start, stop) {
let running = false;
function evaluate() {
const want = !doc.hidden;
if (want === running) return;
running = want;
call(want ? start : stop, want ? 'start' : 'stop');
}
doc.addEventListener('visibilitychange', evaluate);
evaluate();
return function() {
doc.removeEventListener('visibilitychange', evaluate);
if (running) { running = false; call(stop, 'stop'); }
};
}
/**
* The handle for one mounted page. Everything it registers ends when
* ctx.signal aborts.
*/
function forPage(ctx, pageOptions = {}) {
const tab = pageOptions.tab || ctx.name;
const signal = ctx.signal;
const live = new Set();
function whileVisible(start, stop) {
if (typeof start !== 'function' || typeof stop !== 'function') {
throw new TypeError('visibility.whileVisible(start, stop) needs two functions');
}
if (signal && signal.aborted) return function() {};
const t = tracker();
const key = 'page:' + ctx.name + ':' + (++sequence);
const unregister = t
? t.onActive(tab, start, stop, key)
: onDocumentVisible(start, stop);
let ended = false;
const end = function() {
if (ended) return;
ended = true;
live.delete(end);
if (typeof unregister === 'function') call(unregister, key + ' stop');
};
live.add(end);
return end;
}
function every(ms, fn) {
if (!(ms > 0) || typeof fn !== 'function') {
throw new TypeError('visibility.every(ms, fn) needs a positive interval and a function');
}
let timer = null;
function stop() {
if (timer !== null) {
win.clearInterval(timer);
timer = null;
}
}
return whileVisible(function() {
stop();
timer = win.setInterval(function() { call(fn, 'every(' + ms + ')'); }, ms);
fn();
}, stop);
}
function isVisible() {
const t = tracker();
return t ? t.isActive(tab) : !doc.hidden;
}
if (signal) {
signal.addEventListener('abort', function() {
Array.from(live).forEach(function(end) { end(); });
}, { once: true });
}
return Object.freeze({ tab: tab, whileVisible: whileVisible, every: every, isVisible: isVisible });
}
return { forPage: forPage };
}
+324
View File
@@ -0,0 +1,324 @@
/*
* pages/display.js -- the Display tab (templates/v3/partials/display.html):
* the panel hardware, Vegas scroll mode, double-sided and multi-display
* sync settings.
*
* The form itself stays plain htmx (hx-post with json-enc, and its hx-on
* call to the shared showSaveResult, as the Rotation and General forms
* keep). This module does what the partial's inline scripts did:
* - the brightness value next to its slider
* - the live "Your display: W x H pixels" readout
* - showing the Vegas and double-sided settings only while each is on
* - the scroll-speed hint (what the panel really does with a speed), asked
* of the server 150 ms after the slider stops; a newer answer wins
* - the Vegas plugin order (the shared plugin-order-list widget, as the
* Rotation tab uses; its plugin-list request carries ctx.signal)
* - the Advanced section's toggle button (data-action="toggle-section",
* calling the shared window.toggleSection the settings search also uses)
* - the multi-display sync role, and its status, polled every 5 s through
* ctx.visibility: only while the Display tab is on screen and the
* browser tab is visible, and never after the partial is swapped out
*
* The old global updateSyncUI is a deprecated alias made in core/boot.js; it
* forwards to the export of the same name at the bottom of this file.
*/
const SYNC_POLL_MS = 5000;
const HINT_DELAY_MS = 150;
const RETRY_MS = 100;
const MAX_TRIES = 50;
const RESOLUTION_INPUTS = ['rows', 'cols', 'chain_length', 'parallel'];
// The mounted page, for the deprecated alias (boot.js).
let active = null;
function quiet(error) {
return !!error && (error.name === 'AbortError' || error.loginRequired);
}
// ── the "Your display" readout ───────────────────────────────────────────────
// width = cols x chain_length, height = rows x parallel (the chain-length
// tooltip's math), swapped for a 90/270 orientation. A custom pixel mapper
// can change it further; src/display_geometry.py models those.
function showResolution(root) {
const out = root.querySelector('#display-resolution-value');
if (!out) return;
const read = function(id) {
const el = root.querySelector('#' + id);
return el ? parseInt(el.value, 10) : NaN;
};
const rows = read('rows');
const cols = read('cols');
const chain = read('chain_length');
const parallel = read('parallel');
if ([rows, cols, chain, parallel].some(function(n) { return !Number.isFinite(n) || n <= 0; })) {
out.textContent = '—';
return;
}
let w = cols * chain;
let h = rows * parallel;
const orientation = root.querySelector('#orientation');
if (orientation && (orientation.value === '90' || orientation.value === '270')) {
[w, h] = [h, w];
}
out.textContent = w + ' × ' + h + ' pixels';
}
// ── the scroll-speed hint ────────────────────────────────────────────────────
// Only speeds that advance a whole number of pixels per refresh look smooth,
// and which those are depends on the panel, so the server works it out.
function refreshScrollSpeedHint(root, ctx) {
const win = root.ownerDocument.defaultView;
const slider = root.querySelector('#vegas_scroll_speed');
win.clearTimeout(ctx.state.hintTimer);
ctx.state.hintTimer = win.setTimeout(function() {
ctx.state.hintTimer = null;
const hint = root.querySelector('#vegas_scroll_speed_hint');
if (!hint || !slider || ctx.signal.aborted) return;
ctx.state.hintSeq = (ctx.state.hintSeq || 0) + 1;
const seq = ctx.state.hintSeq;
const q = new win.URLSearchParams({ speed: slider.value, min: slider.min, max: slider.max });
ctx.api.get('/api/v3/config/scroll-speed-advice?' + q, { signal: ctx.signal })
.then(function(body) {
if (seq !== ctx.state.hintSeq || body.status !== 'success') return;
renderScrollSpeedHint(root, hint, slider, body.data);
})
.catch(function(error) {
// A refused request keeps the last hint, as before; a failed
// or unreadable one clears it.
if (quiet(error) || error.body) return;
hint.textContent = '';
});
}, HINT_DELAY_MS);
}
function renderScrollSpeedHint(root, hint, slider, a) {
const doc = root.ownerDocument;
const win = doc.defaultView;
const ap = a.applied;
const motion = ap.pixels_per_frame + ' px every ' + ap.frame_hold +
' refresh' + (ap.frame_hold === 1 ? '' : 'es');
hint.textContent = '';
hint.className = 'mt-1 text-xs ' + (a.smooth && a.exact ? 'text-green-700' : 'text-amber-700');
const line = doc.createElement('span');
if (a.smooth && a.exact) {
line.textContent = 'Smooth on this panel (' + motion + ', ' + a.refresh_hz + ' Hz).';
} else {
line.textContent = a.requested + ' px/s will run as ' + ap.pixels_per_second +
' px/s (' + motion + ', ' + ap.steppiness + ') on this ' + a.refresh_hz +
' Hz panel.' + (a.alternatives.length ? ' Smooth speeds: ' : '');
}
hint.appendChild(line);
a.alternatives.forEach(function(alt, i) {
if (i > 0) hint.appendChild(doc.createTextNode(' '));
const value = Math.round(alt.pixels_per_second);
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'underline font-medium';
btn.textContent = value + ' px/s';
btn.addEventListener('click', function() {
slider.value = value;
slider.dispatchEvent(new win.Event('input', { bubbles: true }));
});
hint.appendChild(btn);
});
}
// ── the Vegas plugin order ───────────────────────────────────────────────────
// The widget is a classic deferred script and normally loads before this
// module runs; the short retry covers a page that arrives first anyway.
function startPluginOrder(root, ctx) {
const container = root.querySelector('#vegas_plugin_order');
if (!container) return;
const win = root.ownerDocument.defaultView;
let tries = 0;
function attempt() {
ctx.state.orderTimer = null;
if (ctx.signal.aborted) return;
const widget = win.PluginOrderList;
if (!widget) {
if (++tries < MAX_TRIES) {
ctx.state.orderTimer = win.setTimeout(attempt, RETRY_MS);
} else {
container.textContent = 'Could not load the reorder widget — reload the page to try again.';
container.className = 'text-sm text-red-500';
}
return;
}
widget.init({
containerId: 'vegas_plugin_order',
orderInputId: 'vegas_plugin_order_value',
excludedInputId: 'vegas_excluded_plugins_value',
showVegasModeBadge: true,
signal: ctx.signal,
});
}
attempt();
}
// ── multi-display sync ───────────────────────────────────────────────────────
function syncRole(root) {
const select = root.querySelector('#sync_role');
return select ? select.value : null;
}
/** Show the status bar and the follower's Position field for the chosen role. */
function showSyncRole(root) {
const role = syncRole(root);
const bar = root.querySelector('#sync_status_bar');
const position = root.querySelector('#setting-display-sync_follower_position');
const errorDetail = root.querySelector('#sync_error_detail');
if (!bar || !position) return role;
if (role === 'standalone') {
bar.classList.add('hidden');
if (errorDetail) errorDetail.classList.add('hidden');
position.style.display = 'none';
} else {
bar.classList.remove('hidden');
position.style.display = role === 'follower' ? '' : 'none';
}
return role;
}
function pollSyncStatus(root, ctx) {
const role = syncRole(root);
if (!role || role === 'standalone' || ctx.signal.aborted) return;
ctx.api.get('/api/v3/sync/status', { signal: ctx.signal })
.then(function(body) { renderSyncStatus(root, body.data || {}); })
.catch(function(error) {
if (!quiet(error)) renderSyncStatus(root, { state: 'unknown' });
});
}
function renderSyncStatus(root, d) {
const doc = root.ownerDocument;
const content = root.querySelector('#sync_status_content');
const errorDetail = root.querySelector('#sync_error_detail');
const errorText = root.querySelector('#sync_error_text');
if (!content) return;
const state = d.state || 'unknown';
const role = d.role || 'unknown';
let icon, colorClass, text;
if (state === 'connected' || state === 'follower') {
icon = '●';
colorClass = 'bg-green-50 border-green-200 text-green-800';
const peer = d.peer_ip || d.leader_ip || 'peer';
text = role === 'leader'
? 'Follower connected — ' + peer + ' (chain ' + (d.peer_chain || '?') + ')'
: 'Receiving from leader — ' + peer;
errorDetail.classList.add('hidden');
} else if (state === 'incompatible') {
icon = '⚠';
colorClass = 'bg-yellow-50 border-yellow-200 text-yellow-800';
text = 'Follower connected but incompatible panels';
if (d.error) {
errorText.textContent = d.error;
errorDetail.classList.remove('hidden');
}
} else if (state === 'no_peer' || state === 'standalone') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-600';
text = role === 'leader' ? 'No follower detected' : 'Searching for leader…';
errorDetail.classList.add('hidden');
} else if (state === 'starting') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-500';
text = 'Display process starting…';
errorDetail.classList.add('hidden');
} else {
icon = '✕';
colorClass = 'bg-red-50 border-red-200 text-red-700';
text = 'Sync status unavailable';
errorDetail.classList.add('hidden');
}
content.className = 'flex items-start space-x-2 p-3 rounded-lg border text-sm ' + colorClass;
content.textContent = '';
const iconSpan = doc.createElement('span');
iconSpan.className = 'font-bold text-lg leading-none';
iconSpan.textContent = icon;
const textSpan = doc.createElement('span');
textSpan.textContent = text;
content.appendChild(iconSpan);
content.appendChild(textSpan);
}
/** The Role menu changed: show what goes with the new role, and ask for its status now. */
function changeSyncRole(root, ctx) {
if (showSyncRole(root) !== 'standalone') pollSyncStatus(root, ctx);
}
// ── lifecycle ────────────────────────────────────────────────────────────────
export function init(root, ctx) {
const win = root.ownerDocument.defaultView;
const on = { signal: ctx.signal };
const $ = function(id) { return root.querySelector('#' + id); };
const brightness = $('brightness');
const brightnessValue = $('brightness-value');
if (brightness && brightnessValue) {
brightness.addEventListener('input', function() { brightnessValue.textContent = brightness.value; }, on);
}
RESOLUTION_INPUTS.forEach(function(id) {
const el = $(id);
if (el) el.addEventListener('input', function() { showResolution(root); }, on);
});
const orientation = $('orientation');
if (orientation) orientation.addEventListener('change', function() { showResolution(root); }, on);
showResolution(root);
// Hidden rather than disabled, so the fields keep submitting and the
// server still sees an "off" state to persist.
[['vegas_scroll_enabled', 'vegas_scroll_settings', 'block'],
['double_sided_enabled', 'double_sided_settings', 'grid']].forEach(function(spec) {
const box = $(spec[0]);
const settings = $(spec[1]);
if (!box || !settings) return;
box.addEventListener('change', function() { settings.style.display = box.checked ? spec[2] : 'none'; }, on);
});
const speed = $('vegas_scroll_speed');
const speedValue = $('vegas_scroll_speed_value');
if (speed && speedValue) {
speed.addEventListener('input', function() {
speedValue.textContent = speed.value;
refreshScrollSpeedHint(root, ctx);
}, on);
refreshScrollSpeedHint(root, ctx);
}
root.addEventListener('click', function(event) {
const button = event.target && event.target.closest ? event.target.closest('[data-action="toggle-section"]') : null;
if (!button || !root.contains(button)) return;
if (typeof win.toggleSection === 'function') win.toggleSection(button.getAttribute('data-section'));
}, on);
const role = $('sync_role');
if (role) role.addEventListener('change', function() { changeSyncRole(root, ctx); }, on);
showSyncRole(root);
// The first request goes out as soon as the tab is on screen (at once,
// when it already is), then every 5 s while it stays there.
ctx.visibility.every(SYNC_POLL_MS, function() { pollSyncStatus(root, ctx); });
startPluginOrder(root, ctx);
active = ctx;
}
export function destroy(root, ctx) {
const win = root.ownerDocument.defaultView;
win.clearTimeout(ctx.state.hintTimer);
win.clearTimeout(ctx.state.orderTimer);
ctx.state.hintTimer = null;
ctx.state.orderTimer = null;
// The sync poll is ctx.visibility's: it stops when ctx.signal aborts.
if (active === ctx) active = null;
}
// ── the old global, kept as a deprecated alias (boot.js) ─────────────────────
/** window.updateSyncUI(): the Role menu's old onchange handler. */
export function updateSyncUI() {
if (active) changeSyncRole(active.root, active);
}
@@ -1,5 +1,5 @@
{% import 'v3/partials/_macros.html' as ui %}
<div class="bg-white rounded-lg shadow p-6">
<div class="bg-white rounded-lg shadow p-6" data-page="display">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Display Settings</h2>
<p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p>
@@ -168,7 +168,8 @@
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
aria-controls="display-section-advanced-hardware"
aria-expanded="false"
onclick="toggleSection('display-section-advanced-hardware')">
data-action="toggle-section"
data-section="display-section-advanced-hardware">
<div class="flex-1">
<h4 class="font-semibold text-gray-900">
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Hardware &amp; Display Options (15)
@@ -394,42 +395,6 @@
</div>
</div> <!-- /#display-section-advanced-hardware (nested-content) -->
</div> <!-- /advanced hardware nested-section -->
<script>
// Live "Your display: W x H" readout - width = cols x chain_length,
// height = rows x parallel (same math as the chain-length tooltip),
// swapped for a 90/270 orientation. A custom pixel mapper config can
// change it further; src/display_geometry.py models those.
(function () {
const ids = ['rows', 'cols', 'chain_length', 'parallel'];
const out = document.getElementById('display-resolution-value');
if (!out) return;
function recompute() {
const v = {};
for (const id of ids) {
const el = document.getElementById(id);
v[id] = el ? parseInt(el.value, 10) : NaN;
}
if (Object.values(v).some(n => !Number.isFinite(n) || n <= 0)) {
out.textContent = '—';
return;
}
let w = v.cols * v.chain_length, h = v.rows * v.parallel;
const orientation = document.getElementById('orientation');
if (orientation && (orientation.value === '90' || orientation.value === '270')) {
[w, h] = [h, w];
}
out.textContent = w + ' × ' + h + ' pixels';
}
for (const id of ids) {
const el = document.getElementById(id);
if (el) el.addEventListener('input', recompute);
}
const orientationSelect = document.getElementById('orientation');
if (orientationSelect) orientationSelect.addEventListener('change', recompute);
recompute();
})();
</script>
</div>
<!-- Vegas Scroll Mode Settings -->
@@ -830,7 +795,7 @@ With this off a live game takes over the whole display with the full-screen scor
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-sync_role" data-setting-key="sync.role">
<label for="sync_role" class="block text-sm font-medium text-gray-700">Role{{ ui.help_tip('This unit\'s part in a two-display setup.\nStandalone = sync off. Set one Pi to Leader (drives the scroll) and the other to Follower (receives frames). Restart required after changing.', 'Sync Role') }}</label>
<select id="sync_role" name="sync_role" class="form-control" onchange="updateSyncUI()">
<select id="sync_role" name="sync_role" class="form-control">
<option value="standalone" {% if main_config.get('sync', {}).get('role', 'standalone') == 'standalone' %}selected{% endif %}>Standalone (disabled)</option>
<option value="leader" {% if main_config.get('sync', {}).get('role', 'standalone') == 'leader' %}selected{% endif %}>Leader (drives scroll)</option>
<option value="follower" {% if main_config.get('sync', {}).get('role', 'standalone') == 'follower' %}selected{% endif %}>Follower (receives frames)</option>
@@ -879,261 +844,3 @@ With this off a live game takes over the whole display with the full-screen scor
</div>
</form>
</div>
<script>
// Update brightness display
document.getElementById('brightness').addEventListener('input', function() {
document.getElementById('brightness-value').textContent = this.value;
});
// Vegas Scroll Mode Settings
(function() {
function escapeHtml(text) { return window.LEDEscape.html(text); }
function escapeAttr(text) { return window.LEDEscape.attr(text); }
// Toggle settings visibility
const vegasEnabledCheckbox = document.getElementById('vegas_scroll_enabled');
const vegasSettings = document.getElementById('vegas_scroll_settings');
if (vegasEnabledCheckbox && vegasSettings) {
vegasEnabledCheckbox.addEventListener('change', function() {
vegasSettings.style.display = this.checked ? 'block' : 'none';
});
}
// Double-sided: copies/axis only mean anything while the feature is on.
// Hidden rather than disabled so the fields keep submitting and the server
// still sees an 'off' state to persist.
const doubleSidedCheckbox = document.getElementById('double_sided_enabled');
const doubleSidedSettings = document.getElementById('double_sided_settings');
if (doubleSidedCheckbox && doubleSidedSettings) {
doubleSidedCheckbox.addEventListener('change', function() {
doubleSidedSettings.style.display = this.checked ? 'grid' : 'none';
});
}
// Declared before first use: let is not hoisted usably.
let scrollHintTimer = null;
let scrollHintSeq = 0;
// Update scroll speed display
const scrollSpeedSlider = document.getElementById('vegas_scroll_speed');
const scrollSpeedValue = document.getElementById('vegas_scroll_speed_value');
if (scrollSpeedSlider && scrollSpeedValue) {
scrollSpeedSlider.addEventListener('input', function() {
scrollSpeedValue.textContent = this.value;
refreshScrollSpeedHint();
});
refreshScrollSpeedHint();
}
// Tell the user what the panel will really do with this speed. Only
// speeds that advance a whole number of pixels per refresh look smooth,
// and which those are depends on the panel, so the server works it out.
function refreshScrollSpeedHint() {
clearTimeout(scrollHintTimer);
scrollHintTimer = setTimeout(function() {
const hint = document.getElementById('vegas_scroll_speed_hint');
if (!hint) return;
const seq = ++scrollHintSeq;
const q = new URLSearchParams({
speed: scrollSpeedSlider.value,
min: scrollSpeedSlider.min,
max: scrollSpeedSlider.max
});
fetch('/api/v3/config/scroll-speed-advice?' + q)
.then(function(r) { return r.json(); })
.then(function(body) {
if (seq !== scrollHintSeq || body.status !== 'success') return;
renderScrollSpeedHint(hint, body.data);
})
.catch(function() { hint.textContent = ''; });
}, 150);
}
function renderScrollSpeedHint(hint, a) {
const ap = a.applied;
const motion = ap.pixels_per_frame + ' px every ' + ap.frame_hold +
' refresh' + (ap.frame_hold === 1 ? '' : 'es');
hint.textContent = '';
hint.className = 'mt-1 text-xs ' + (a.smooth && a.exact ? 'text-green-700' : 'text-amber-700');
const line = document.createElement('span');
if (a.smooth && a.exact) {
line.textContent = 'Smooth on this panel (' + motion + ', ' + a.refresh_hz + ' Hz).';
} else {
line.textContent = a.requested + ' px/s will run as ' + ap.pixels_per_second +
' px/s (' + motion + ', ' + ap.steppiness + ') on this ' + a.refresh_hz +
' Hz panel.' + (a.alternatives.length ? ' Smooth speeds: ' : '');
}
hint.appendChild(line);
a.alternatives.forEach(function(alt, i) {
if (i > 0) hint.appendChild(document.createTextNode(' '));
const value = Math.round(alt.pixels_per_second);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'underline font-medium';
btn.textContent = value + ' px/s';
btn.addEventListener('click', function() {
scrollSpeedSlider.value = value;
scrollSpeedSlider.dispatchEvent(new Event('input', {bubbles: true}));
});
hint.appendChild(btn);
});
}
// Initialize plugin order list via the shared drag-and-drop module
// (static/v3/js/widgets/plugin-order-list.js) — the same component the
// Durations tab uses for the primary rotation order.
function initPluginOrderList(attempt) {
const container = document.getElementById('vegas_plugin_order');
if (!container) return;
if (!window.PluginOrderList) {
// Widget script is deferred; retry briefly, then surface a real
// error instead of waiting forever.
if ((attempt || 0) < 50) {
setTimeout(function() { initPluginOrderList((attempt || 0) + 1); }, 100);
} else {
container.textContent = 'Could not load the reorder widget — reload the page to try again.';
container.className = 'text-sm text-red-500';
}
return;
}
window.PluginOrderList.init({
containerId: 'vegas_plugin_order',
orderInputId: 'vegas_plugin_order_value',
excludedInputId: 'vegas_excluded_plugins_value',
showVegasModeBadge: true
});
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPluginOrderList);
} else {
initPluginOrderList();
}
})();
// Multi-Display Sync UI
(function() {
function updateSyncUI() {
const role = document.getElementById('sync_role').value;
const bar = document.getElementById('sync_status_bar');
const posGroup = document.getElementById('setting-display-sync_follower_position');
if (role === 'standalone') {
bar.classList.add('hidden');
document.getElementById('sync_error_detail').classList.add('hidden');
posGroup.style.display = 'none';
} else {
bar.classList.remove('hidden');
posGroup.style.display = role === 'follower' ? '' : 'none';
pollSyncStatus();
}
}
window.updateSyncUI = updateSyncUI;
function pollSyncStatus() {
const role = document.getElementById('sync_role') && document.getElementById('sync_role').value;
if (!role || role === 'standalone') return;
fetch('/api/v3/sync/status')
.then(r => r.json())
.then(resp => {
const d = resp.data || {};
renderSyncStatus(d);
})
.catch(() => {
renderSyncStatus({state: 'unknown'});
});
}
function renderSyncStatus(d) {
const content = document.getElementById('sync_status_content');
const errorDetail = document.getElementById('sync_error_detail');
const errorText = document.getElementById('sync_error_text');
if (!content) return;
const state = d.state || 'unknown';
const role = d.role || 'unknown';
let icon, colorClass, text;
if (state === 'connected' || state === 'follower') {
icon = '●';
colorClass = 'bg-green-50 border-green-200 text-green-800';
const peer = d.peer_ip || d.leader_ip || 'peer';
text = role === 'leader'
? `Follower connected — ${peer} (chain ${d.peer_chain || '?'})`
: `Receiving from leader — ${peer}`;
errorDetail.classList.add('hidden');
} else if (state === 'incompatible') {
icon = '⚠';
colorClass = 'bg-yellow-50 border-yellow-200 text-yellow-800';
text = `Follower connected but incompatible panels`;
if (d.error) {
errorText.textContent = d.error;
errorDetail.classList.remove('hidden');
}
} else if (state === 'no_peer' || state === 'standalone') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-600';
text = role === 'leader' ? 'No follower detected' : 'Searching for leader…';
errorDetail.classList.add('hidden');
} else if (state === 'starting') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-500';
text = 'Display process starting…';
errorDetail.classList.add('hidden');
} else {
icon = '✕';
colorClass = 'bg-red-50 border-red-200 text-red-700';
text = 'Sync status unavailable';
errorDetail.classList.add('hidden');
}
content.className = `flex items-start space-x-2 p-3 rounded-lg border text-sm ${colorClass}`;
content.textContent = '';
const iconSpan = document.createElement('span');
iconSpan.className = 'font-bold text-lg leading-none';
iconSpan.textContent = icon;
const textSpan = document.createElement('span');
textSpan.textContent = text;
content.appendChild(iconSpan);
content.appendChild(textSpan);
}
// Initial UI state, then the 5s sync-status poll — only while the Display
// tab is active and the page is visible. LEDVisibility keys the
// registration, so a re-run of this partial replaces the previous timer.
function stopSyncTimer() {
if (window.syncStatusInterval) {
clearInterval(window.syncStatusInterval);
window.syncStatusInterval = null;
}
}
function startSyncTimer() {
stopSyncTimer();
pollSyncStatus();
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
function startSyncPolling() {
updateSyncUI();
window.LEDVisibility.onActive('display', startSyncTimer, stopSyncTimer, 'display-sync-status');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startSyncPolling);
} else {
startSyncPolling();
}
})();
</script>