mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 12:59:12 +00:00
Compare commits
12
Commits
eae063700f
..
v3.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32d637a446 | ||
|
|
bc2dbf3824 | ||
|
|
cb0545ecb3 | ||
|
|
300cdaa250 | ||
|
|
10da2f97fd | ||
|
|
92f9d06af9 | ||
|
|
6e361e05cc | ||
|
|
a686932c7e | ||
|
|
154525beb8 | ||
|
|
9b522d412c | ||
|
|
cbc540a679 | ||
|
|
4aeb0033e0 |
@@ -17,6 +17,81 @@ release that ships it.
|
|||||||
accepts both, but the store flags the old spelling as deprecated
|
accepts both, but the store flags the old spelling as deprecated
|
||||||
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
||||||
|
|
||||||
|
## 3.3.0
|
||||||
|
|
||||||
|
**The release the sports scoreboards floor on to delete their bundled copies.**
|
||||||
|
3.2.0 shipped the unified sports library and made `ledmatrix_min_version`
|
||||||
|
enforceable; this ships the last three shared modules and completes the store
|
||||||
|
gate, so a scoreboard can now floor here and carry no fallback at all.
|
||||||
|
|
||||||
|
New modules a plugin may import via `src.*` and floor on 3.3.0 for:
|
||||||
|
|
||||||
|
- `src/common/sports_card.py` — settings, colour, font and date helpers for a
|
||||||
|
scoreboard's `game_renderer.py`. Free functions taking `config`/`fonts`
|
||||||
|
explicitly, so nothing about the caller's class is assumed.
|
||||||
|
- `src/common/sports_game_renderer.py` — `SportsGameRendererMixin`: scroll/Vegas
|
||||||
|
card geometry (centre gap, logo slot and cache key, layout offsets, the
|
||||||
|
upcoming-card date and time layout). No `__init__` and no state, so adoption
|
||||||
|
is one line on the class statement.
|
||||||
|
- `src/common/sports_shared.py` — `SportsCoreSharedMixin`,
|
||||||
|
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` bodies
|
||||||
|
byte-identical in all eight lineage-sharing scoreboards.
|
||||||
|
|
||||||
|
All three sit under `src/common/` rather than `src/base_classes/sports/`,
|
||||||
|
deliberately: importing that package pulls `core.py` → `DisplayManager` →
|
||||||
|
`rgbmatrix`, and these are pure logic. Plugins importing them must not acquire a
|
||||||
|
hardware dependency.
|
||||||
|
|
||||||
|
Three notes for anyone adopting `sports_shared`:
|
||||||
|
|
||||||
|
- `SportsRecentSharedMixin` defines `__init__`. Its bare `super()` binds to the
|
||||||
|
mixin, so it reaches the host only when the mixin is listed **first** in the
|
||||||
|
bases. Reversing that order silently skips the host constructor.
|
||||||
|
- Methods that resolve the plugin's `config_schema.json` use `_plugin_dir()`,
|
||||||
|
which walks the MRO rather than reading `__file__` — `__file__` is now
|
||||||
|
`src/common/`. It walks because `SportsCore` is an ABC: a subclass built with
|
||||||
|
`type(name, bases, ns)` reports `__module__` as `"abc"`.
|
||||||
|
- `_get_timezone`, `_extract_game_details` and `_fetch_data` are byte-identical
|
||||||
|
across the eight but stay in the plugins. The first binds a per-plugin
|
||||||
|
timezone module whose contents differ; the other two are the abstract stubs
|
||||||
|
that define the sport.
|
||||||
|
|
||||||
|
**The store's compatibility gate is now on every registry-managed route.** 3.2.0
|
||||||
|
gated `install_plugin`. This release gates the git-pull update path and
|
||||||
|
`install_from_url`, so a plugin whose floor the core cannot meet is refused
|
||||||
|
after download with no partial directory left behind.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ESPN 403s.** `site.api` began rejecting the User-Agent strings this repo
|
||||||
|
sent on 2026-08-04; every shared-data-source scoreboard returned
|
||||||
|
`403 Forbidden`. Requests now send an identifying token with a project URL —
|
||||||
|
browser-style strings and bare custom tokens are both refused.
|
||||||
|
- **Low-memory boards becoming unreachable under load** while still answering
|
||||||
|
pings and serving the web UI. Fetched payloads are released after delivery
|
||||||
|
rather than pinned on the completed request for up to an hour, malloc arenas
|
||||||
|
are capped, and log volume and SD writes are reduced. Available memory is now
|
||||||
|
reported in Tools diagnostics.
|
||||||
|
- **A failed logo download pinning a team to a grey box**: the placeholder was
|
||||||
|
written under the real logo's filename, so later attempts found a file and
|
||||||
|
reported success without retrying.
|
||||||
|
- **A plugin enabled but never loaded is retried** rather than staying absent
|
||||||
|
with `error = null`.
|
||||||
|
- `ttl` now controls cache expiry; abandoned cache writes no longer leave temp
|
||||||
|
files; one cache-cleanup thread per directory rather than per manager.
|
||||||
|
- Odds are fetched for the games displayed, not the whole schedule window, and a
|
||||||
|
stalled ESPN no longer stalls the whole plugin update.
|
||||||
|
- Array-item secrets are no longer wiped or logged.
|
||||||
|
- A restored backup matches the device it was taken from.
|
||||||
|
- The web UI reports the real error instead of "unknown", rejects non-finite
|
||||||
|
JSON numbers, and stops checkbox groups posting back hidden options.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `display.hardware.orientation` for panels mounted upside down.
|
||||||
|
- Vegas keeps live content in the ticker rather than being preempted by it.
|
||||||
|
- Schemas can label enum dropdown options.
|
||||||
|
|
||||||
## 3.2.0
|
## 3.2.0
|
||||||
|
|
||||||
**The first release shipping the unified sports library.** This is the version
|
**The first release shipping the unified sports library.** This is the version
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 105 KiB |
+169
-37
@@ -27,7 +27,7 @@ These are independent concerns. Conflating them is what produces god classes.
|
|||||||
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
||||||
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
||||||
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
|
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
|
||||||
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. Nothing enforces that floor today, so the copy also waits for the B6 gate below. |
|
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. The store enforces that floor on every registry-managed install and on both supported update paths (sideloading via `install_from_url` is not gated), but a floor cannot reach a user who never updates, so the copy also waits for the B6 gate below. |
|
||||||
|
|
||||||
The core API is **additive-only**. A method the plugins call is never removed or
|
The core API is **additive-only**. A method the plugins call is never removed or
|
||||||
given a new required parameter; new behavior arrives as new methods with
|
given a new required parameter; new behavior arrives as new methods with
|
||||||
@@ -204,7 +204,7 @@ legacy compatibility rather than the mechanism.
|
|||||||
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
|
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
|
||||||
**rollout**, and it splits into three phases with very different risk profiles.
|
**rollout**, and it splits into three phases with very different risk profiles.
|
||||||
The original plan folded the last two together; they are separated here because
|
The original plan folded the last two together; they are separated here because
|
||||||
one of them is safe by construction and the other is not.
|
one of them cannot break a user on an old core and the other can.
|
||||||
|
|
||||||
| Phase | Scope | Status | Gate |
|
| Phase | Scope | Status | Gate |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -212,9 +212,9 @@ one of them is safe by construction and the other is not.
|
|||||||
| **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended |
|
| **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended |
|
||||||
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
|
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
|
||||||
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
|
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
|
||||||
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ⏳ **next** | Tag, release, and `src.__version__` agree; compatibility gate merged |
|
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ✅ | Released 2026-08-03; tag, release and `src.__version__` agree; compatibility gate merged (#428, #431, #433) |
|
||||||
| **B5** | Adoption — guarded core imports: three pilots, then the remaining six. **Bundled copies stay.** | after B4 | Per plugin: harness + goldens byte-identical, then a device soak |
|
| **B5** | Adoption — guarded core imports, all eight. **Bundled copies stay.** | ✅ | All eight adopted; harness byte-identical; see the B5 retrospective below — four shipped broken and were repaired in plugins #251 |
|
||||||
| **B6** | Sunset — delete the bundled copies | **blocked** | B4's gate shipped *and* in users' hands (see below) |
|
| **B6** | Sunset — delete the bundled copies | ✅ | Ran 2026-09-01, all eight. Floors at 3.2.0; the store refuses on all three routes in (#431/#433, #508, #510). See "B6 — what actually happened" |
|
||||||
|
|
||||||
### B4 — what "ship 3.2.0" actually requires
|
### B4 — what "ship 3.2.0" actually requires
|
||||||
|
|
||||||
@@ -234,6 +234,20 @@ a floor can be trusted against, and today it is not:
|
|||||||
compares the plugin's manifest version against the registry's
|
compares the plugin's manifest version against the registry's
|
||||||
`latest_version` and nothing else.
|
`latest_version` and nothing else.
|
||||||
|
|
||||||
|
*Fixed, in two parts.* `install_plugin` gained the gate in #431/#433, which
|
||||||
|
covers every registry-managed install and, through `_reinstall_with_rollback`,
|
||||||
|
the update path that re-downloads.
|
||||||
|
`update_plugin`'s git branch pulls in place and re-downloads nothing, so it
|
||||||
|
stayed ungated until `_gate_pulled_commit` closed it — checked after the pull
|
||||||
|
(the registry carries no floor field, so the incoming floor is unknowable
|
||||||
|
before it) and undone with `git reset --hard` to the pre-pull commit. That
|
||||||
|
route is rare in practice, since monorepo plugins install as archives; it was
|
||||||
|
closed because the sunset rule in the plugins repo's
|
||||||
|
`08-shared-sports-code.md` states as **condition 3** that the core enforces
|
||||||
|
the floor "at install/update time", and B6 rests on that being true rather
|
||||||
|
than merely written down. `install_from_url` — sideloading a plugin from a
|
||||||
|
URL — is still ungated.
|
||||||
|
|
||||||
So B4 is: tag and release 3.2.0; make the tag, the release, and `__version__`
|
So B4 is: tag and release 3.2.0; make the tag, the release, and `__version__`
|
||||||
agree, and keep them agreeing; reconsider the `< 2.0.0` skip; migrate manifests
|
agree, and keep them agreeing; reconsider the `< 2.0.0` skip; migrate manifests
|
||||||
from `ledmatrix_min` to `ledmatrix_min_version`; and add the install/update
|
from `ledmatrix_min` to `ledmatrix_min_version`; and add the install/update
|
||||||
@@ -262,13 +276,21 @@ default is the more restrictive.
|
|||||||
`compatible_versions`. No manifest still carries it, so there is nothing to
|
`compatible_versions`. No manifest still carries it, so there is nothing to
|
||||||
migrate there.)
|
migrate there.)
|
||||||
|
|
||||||
### B5 — adoption is safe by construction
|
### B5 — the *fallback* is safe by construction; the modern path is not
|
||||||
|
|
||||||
|
The heading matters, because the unqualified version of this claim is false and
|
||||||
|
this document proves it two sections down: four of the eight adopted plugins
|
||||||
|
shipped with scroll mode broken on a 3.2.0 core. What is safe by construction is
|
||||||
|
narrower than "adoption".
|
||||||
|
|
||||||
A plugin adopting core imports keeps its bundled copy and reaches it through the
|
A plugin adopting core imports keeps its bundled copy and reaches it through the
|
||||||
guarded import (see the Upgradability table above). On a core that ships the
|
guarded import (see the Upgradability table above). On a core that doesn't ship
|
||||||
module the plugin uses core code; on one that doesn't it falls back and behaves
|
the module the plugin falls back and behaves exactly as it does today. That
|
||||||
exactly as it does today. There is no version of this step that breaks a user,
|
fallback compatibility — and only that — is safe by construction. On a core that
|
||||||
which is why it does not wait for B6's gate.
|
*does* ship the module, correctness is not automatic — object-level and scroll-mode validation
|
||||||
|
(building both classes and comparing, per the retrospective below) is required
|
||||||
|
to prove full behavior. There is no version of this step that breaks a user *on
|
||||||
|
an old core*, which is why it does not wait for B6's gate.
|
||||||
|
|
||||||
The hockey scroll-display pilot is **already validated**: adopted against a core
|
The hockey scroll-display pilot is **already validated**: adopted against a core
|
||||||
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
|
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
|
||||||
@@ -297,7 +319,8 @@ been in users' hands long enough that the population running a core without it
|
|||||||
is small.** The bundled copies cost disk space; deleting them early costs
|
is small.** The bundled copies cost disk space; deleting them early costs
|
||||||
scoreboards, silently. That trade is not close.
|
scoreboards, silently. That trade is not close.
|
||||||
|
|
||||||
Before the first sunset, add a **compatibility regression test**. It has to
|
Before the first sunset, add a **compatibility regression test**. **Built:**
|
||||||
|
core `test/test_sports_sunset_matrix.py` (#505). It has to
|
||||||
cover four cases, not one — B5's safety claim and B6's failure mode are
|
cover four cases, not one — B5's safety claim and B6's failure mode are
|
||||||
different propositions and only the second is obvious:
|
different propositions and only the second is obvious:
|
||||||
|
|
||||||
@@ -321,35 +344,144 @@ gate rather than trusting the failure to be noticed.
|
|||||||
The same suite should exercise the install/update gate, since it is the other
|
The same suite should exercise the install/update gate, since it is the other
|
||||||
half of the guarantee.
|
half of the guarantee.
|
||||||
|
|
||||||
|
### B6 — what actually happened
|
||||||
|
|
||||||
|
**Ran 2026-09-01, across all eight scoreboards.** Held from 2026-08-05 to
|
||||||
|
2026-09-01 on the argument below, which is kept because the reasoning applies to
|
||||||
|
the next module, not because it is still in force.
|
||||||
|
|
||||||
|
**The hold, and why it lifted.** The stated gate was evidence of 3.2.0 uptake —
|
||||||
|
"a few months of it being the default download, or store-side install data".
|
||||||
|
That evidence never arrived and could not: the core updates by
|
||||||
|
`git pull --rebase`, so release-asset counts cannot measure uptake, and no
|
||||||
|
store-side telemetry exists. What changed instead is that the *risk* the gate
|
||||||
|
protected against was closed directly. The store now refuses a plugin whose
|
||||||
|
floor exceeds the running core on **all three** routes in:
|
||||||
|
|
||||||
|
| route | gated by |
|
||||||
|
|---|---|
|
||||||
|
| `install_plugin` — every path that re-downloads, `_reinstall_with_rollback` included | #431, #433 |
|
||||||
|
| `update_plugin`'s git branch — pulls in place, re-downloads nothing | #508 |
|
||||||
|
| `install_from_url` — sideloading | #510 |
|
||||||
|
|
||||||
|
With all three closed a pre-3.2.0 user cannot receive a sunset plugin at all;
|
||||||
|
they keep the version they already run. The population the hold existed to
|
||||||
|
protect is protected by refusal rather than by a bundled copy — which is what
|
||||||
|
the copy was standing in for.
|
||||||
|
|
||||||
|
**What shipped.** Eight plugins, ~5,800 lines of frozen fallback deleted. Each:
|
||||||
|
copy removed, guarded import collapsed to a plain one, floor raised to 3.2.0,
|
||||||
|
`test_core_fallback.py` rewritten as `test_core_scroll.py` asserting the sunset
|
||||||
|
rather than the fallback. `scripts/check_scroll_adoption.py` gained
|
||||||
|
`sunset_violations` and a `SUNSET_PLUGINS` set naming all eight, so a
|
||||||
|
resurrected copy or a returned guard fails CI.
|
||||||
|
|
||||||
|
Delivered as plugins #346 (hockey, later folded into #351), #349 (football),
|
||||||
|
#350 (baseball), #351 (the remaining six).
|
||||||
|
|
||||||
|
**Two things found by doing it, both worth carrying forward:**
|
||||||
|
|
||||||
|
- **Only one fallback held orchestration logic the core lacked.** baseball's
|
||||||
|
`_configure_scroll_helper` reinterpreted `scroll_speed` as pixels-per-*frame*
|
||||||
|
when `speed × delay` fell outside the 0.1–5.0 window — measured, 10–20×
|
||||||
|
faster than configured for a speed between 1.0 and 5.0. Standardised onto the
|
||||||
|
core's behaviour (honour the documented px/sec, clamp) rather than preserved.
|
||||||
|
Every other difference across the eight was a docstring, an unreachable
|
||||||
|
`scroll_helper is None` guard, or an equivalent diagnostic.
|
||||||
|
- **Two tests had been leaning on the guard without anyone knowing.**
|
||||||
|
`soccer/test_live_screens.py` stubbed `src` in a way that shadowed the core,
|
||||||
|
so its guarded import fell back and the test had been exercising the *frozen
|
||||||
|
copy* rather than the shipping class since B5. Before sunsetting anything else
|
||||||
|
that carries a guarded core import, grep for tests that stub `src`.
|
||||||
|
|
||||||
|
**The floor-raising traps still apply** to any future sunset: four plugins
|
||||||
|
declare their floor top-level, where editing `versions[0]` is a silent no-op,
|
||||||
|
and the floor has three live spellings (`min_ledmatrix_version`,
|
||||||
|
`requires.min_ledmatrix_version`, `versions[].ledmatrix_min_version`, plus
|
||||||
|
deprecated `ledmatrix_min`). See
|
||||||
|
`src/plugin_system/compatibility.py:declared_min_version` for the resolution
|
||||||
|
order any floor-raising tool must reproduce — and note the name is **inverted**
|
||||||
|
between the top level and `versions[]`.
|
||||||
|
|
||||||
|
**Still not adopted, deliberately:** `data_sources.py`, `game_renderer.py` and
|
||||||
|
`base_odds_manager.py`. The standing decision held them until B6 closed; it now
|
||||||
|
has, so they can be reconsidered — with B5's lesson applied, which is to build
|
||||||
|
the object and diff rendered output rather than trust a static check.
|
||||||
|
|
||||||
|
### B5 retrospective — what the adoption actually cost
|
||||||
|
|
||||||
|
Recorded because it is the evidence behind the two decisions above, and because
|
||||||
|
"the adoption went fine" is not what happened.
|
||||||
|
|
||||||
|
**Four of the eight shipped with scroll mode broken** on a 3.2.0 core, and were
|
||||||
|
repaired in plugins-repo #251. The restructure lifted the content methods
|
||||||
|
verbatim but left the state they read off `self` behind: separator-icon
|
||||||
|
constants (hockey, basketball, lacrosse) and the game-renderer cache (afl).
|
||||||
|
hockey/basketball/lacrosse could not construct the scroll display at all; afl
|
||||||
|
raised inside `prepare_scroll_content`, which the core base *catches*, so its
|
||||||
|
only symptom was scroll mode silently drawing nothing.
|
||||||
|
|
||||||
|
Three things are worth carrying forward:
|
||||||
|
|
||||||
|
- **The bundled fallback did not protect anyone from this.** The break was on
|
||||||
|
the modern path, which the fallback never touches. Carrying the second copy
|
||||||
|
bought nothing against the actual defect while creating the divergence that
|
||||||
|
produced it. That is an argument *for* B6, not against it.
|
||||||
|
- **Every gate was green.** The safety harness renders the scoreboard screens,
|
||||||
|
not scroll mode; `test_core_fallback.py` checked that methods existed and that
|
||||||
|
their *globals* resolved, and `self.NHL_SEPARATOR_ICON` is an attribute read,
|
||||||
|
invisible to an AST scan for `Name` loads. The fix was to stop reasoning about
|
||||||
|
source and **build the object**: construct both classes on both paths, compare
|
||||||
|
the separator icons they end up with, and assert the adopted class ends up
|
||||||
|
with every instance attribute the bundled one sets.
|
||||||
|
- **Test what the change touches, not what is convenient to render.** Scroll
|
||||||
|
mode had no coverage because the harness could not reach it. A comparison
|
||||||
|
harness that renders the same games through both paths and diffs the pixels
|
||||||
|
needs no per-sport knowledge of the right answer, only that adopting core code
|
||||||
|
did not change it.
|
||||||
|
|
||||||
|
**The ledger.** Before adoption, eight duplicated copies totalled 5,685 lines.
|
||||||
|
After adoption plus the frozen legacy copies it was 10,610; removing the dead
|
||||||
|
inline duplication (plugins #252) brought it to roughly 8,620. B6 would take it
|
||||||
|
to about 3,300 including the shared core module — some 2,400 fewer than before
|
||||||
|
this project started. **Until B6 runs, the adoption is net negative on disk**,
|
||||||
|
and its one delivered user-visible gain is that adopted plugins honour the
|
||||||
|
global `target_fps` instead of hardcoding ~100 FPS.
|
||||||
|
|
||||||
|
### Decision: stop adopting further modules until B6 closes
|
||||||
|
|
||||||
|
`data_sources.py` (9 copies), `game_renderer.py` (8) and `base_odds_manager.py`
|
||||||
|
are the obvious next candidates. **Do not adopt them yet.** Each adoption adds
|
||||||
|
carrying cost — a second copy to keep in step — against a payoff that is
|
||||||
|
contingent on B6, and B6 is gated on an installed base we cannot currently
|
||||||
|
measure. Consolidate what is already committed; revisit when B6 does.
|
||||||
|
|
||||||
## What's next
|
## What's next
|
||||||
|
|
||||||
In order. Each step is independently useful and independently revertible.
|
Steps 1–5 of the original plan are **done**: 3.2.0 is tagged and published with
|
||||||
|
a version number CI now asserts (#428), the compatibility gate is in
|
||||||
|
`install_plugin` and reads `compatible_versions` as well as the floor
|
||||||
|
(#431, #433), the newest manifest entry is required to use `ledmatrix_min_version`
|
||||||
|
(plugins #244), and all eight plugins have adopted the scroll orchestration
|
||||||
|
(plugins #245–#249, repaired in #251, tidied in #252).
|
||||||
|
|
||||||
1. **Tag and publish v3.2.0.** The code is already on `main` (`21825cbf`).
|
What actually remains, smallest first:
|
||||||
Nothing else blocks this, and it is what makes `ledmatrix_min_version:
|
|
||||||
"3.2.0"` refer to something real.
|
1. **Soak the adoptions on hardware.** football and hockey have been run on a
|
||||||
2. **Make the version number honest.** Have the release process assert that the
|
live rig through real games; baseball was watched through one earlier. The
|
||||||
tag, the GitHub release, and `src.__version__` agree — a check in CI is
|
rest are proven by harness, unit tests and pixel comparison. Out-of-season
|
||||||
cheaper than the confusion of the last two releases. Then revisit the
|
sports cannot be soaked until their season starts. When you do, **check the
|
||||||
`< 2.0.0` skip in `_warn_if_incompatible`, which currently silences the
|
rig's `*_display_mode` first** — a board in `switch` mode will happily load a
|
||||||
warning for the users who most need it.
|
sunset plugin and tell you nothing about the scroll code the sunset changed.
|
||||||
3. **Add the compatibility gate** to `StoreManager.install_plugin` and
|
2. **Cut 3.3.0.** Not required by B6 — its floors are 3.2.0, which is released —
|
||||||
`.update_plugin`: refuse a plugin whose declared floor exceeds
|
but `calendar` 1.2.3 floors at 3.3.0 for the device-authorization endpoints
|
||||||
`src.__version__`, and surface the reason in the store UI rather than only
|
that landed after 3.2.0, so it is un-installable until the release exists.
|
||||||
the log. This is the single change that turns the floor from documentation
|
3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`,
|
||||||
into a guarantee, and B6 depends on it.
|
`base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is
|
||||||
4. **Migrate the manifests** to `ledmatrix_min_version`, and reconcile them with
|
the largest single duplication left: ~11,500 lines across eight plugins, with
|
||||||
`compatible_versions` (see above — that field is the required, canonical one,
|
~36,500 more in the eight `sports.py`. Note that core already ships
|
||||||
and the gate does not read it yet). Currently 28 plugins spell the floor both
|
`src/base_classes/sports/` (~143KB, promoted in B1/B2) that **no plugin
|
||||||
ways across their `versions[]` entries, 12 use only the old spelling, and 2
|
imports** — check whether it has drifted before treating it as the target.
|
||||||
only the new. Scope the sweep to the nine sports plugins if a 42-plugin
|
|
||||||
version-bump wave isn't worth it — but the `compatible_versions` half has to
|
|
||||||
cover every manifest the gate can refuse, or define explicit legacy handling,
|
|
||||||
before the gate is allowed to block anything.
|
|
||||||
5. **Run B5 adoption** — hockey, soccer, football, then the remaining six.
|
|
||||||
Bundled copies stay. Byte-identical harness output per plugin, then a soak.
|
|
||||||
6. **Only then plan B6**, with the compatibility regression test described above
|
|
||||||
in CI first.
|
|
||||||
|
|
||||||
## How to keep this project healthy
|
## How to keep this project healthy
|
||||||
|
|
||||||
|
|||||||
@@ -1740,12 +1740,15 @@ journald_effective() {
|
|||||||
systemd-analyze cat-config systemd/journald.conf >/dev/null 2>&1; then
|
systemd-analyze cat-config systemd/journald.conf >/dev/null 2>&1; then
|
||||||
systemd-analyze cat-config systemd/journald.conf 2>/dev/null
|
systemd-analyze cat-config systemd/journald.conf 2>/dev/null
|
||||||
else
|
else
|
||||||
cat /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf 2>/dev/null
|
cat /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
journald_conf="$(journald_effective)"
|
journald_conf="$(journald_effective)"
|
||||||
journald_storage="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')"
|
# Both settings are optional, and stock images ship them commented out. grep
|
||||||
journald_cap="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*SystemMaxUse=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')"
|
# exits 1 on no match, which pipefail turns fatal under set -e — an absent
|
||||||
|
# setting must read as empty, not abort the install.
|
||||||
|
journald_storage="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||||
|
journald_cap="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*SystemMaxUse=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||||
|
|
||||||
if [ "$journald_storage" = "persistent" ] && [ -n "$journald_cap" ]; then
|
if [ "$journald_storage" = "persistent" ] && [ -n "$journald_cap" ]; then
|
||||||
echo "Persistent journald storage already configured (SystemMaxUse=$journald_cap)"
|
echo "Persistent journald storage already configured (SystemMaxUse=$journald_cap)"
|
||||||
@@ -1771,7 +1774,7 @@ else
|
|||||||
# after ledmatrix-persistent.conf (zz-local.conf and friends) still wins.
|
# after ledmatrix-persistent.conf (zz-local.conf and friends) still wins.
|
||||||
# Writing the file is not evidence it took effect -- re-read and say so
|
# Writing the file is not evidence it took effect -- re-read and say so
|
||||||
# plainly rather than reporting success we cannot confirm.
|
# plainly rather than reporting success we cannot confirm.
|
||||||
journald_now="$(journald_effective | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')"
|
journald_now="$(journald_effective | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||||
if [ "$journald_now" = "persistent" ]; then
|
if [ "$journald_now" = "persistent" ]; then
|
||||||
echo " Persistent journald storage active"
|
echo " Persistent journald storage active"
|
||||||
else
|
else
|
||||||
|
|||||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
|||||||
Core source package for the LED Matrix Display project.
|
Core source package for the LED Matrix Display project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.2.0"
|
__version__ = "3.3.0"
|
||||||
|
|
||||||
|
|||||||
@@ -478,8 +478,22 @@ class BackgroundDataService:
|
|||||||
cb(result)
|
cb(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in callback for request {request.id}: {e}")
|
logger.error(f"Error in callback for request {request.id}: {e}")
|
||||||
# Delivered. Drop both references -- they point at the same
|
|
||||||
# object, so one survivor keeps the whole payload resident.
|
# Released AFTER the loop, not inside it. Every callback here holds
|
||||||
|
# the same FetchResult, so releasing per-delivery handed the first
|
||||||
|
# one the data and every joiner `result.data is None` -- which is
|
||||||
|
# not a quiet degradation: they read `result.data.get('events')` and
|
||||||
|
# raise AttributeError, which this very loop catches and logs, so
|
||||||
|
# the symptom was one ERROR line and a manager that silently never
|
||||||
|
# got its schedule. Deduplication is the normal case, not a corner:
|
||||||
|
# a sport's recent, upcoming and live managers all ride one season
|
||||||
|
# fetch.
|
||||||
|
#
|
||||||
|
# Guarded on `callbacks`, because a request submitted without one
|
||||||
|
# has no other way to collect its payload than polling get_result().
|
||||||
|
# The old per-delivery release got that right by accident: an empty
|
||||||
|
# list never entered the loop body.
|
||||||
|
if callbacks:
|
||||||
self._release_payload(result)
|
self._release_payload(result)
|
||||||
request.result = None
|
request.result = None
|
||||||
|
|
||||||
@@ -489,8 +503,11 @@ class BackgroundDataService:
|
|||||||
def _release_payload(result: FetchResult) -> None:
|
def _release_payload(result: FetchResult) -> None:
|
||||||
"""Drop a delivered payload, keeping the result's status and timings.
|
"""Drop a delivered payload, keeping the result's status and timings.
|
||||||
|
|
||||||
Only called once a callback has been handed the data. Consumers read
|
Only called once EVERY callback has been handed the data -- callers
|
||||||
fetched data back from the cache under ``cache_key``; the copy carried
|
that joined an in-flight fetch share this object, so releasing between
|
||||||
|
deliveries strips the payload out from under the ones still queued.
|
||||||
|
Consumers read fetched data back from the cache under ``cache_key``;
|
||||||
|
the copy carried
|
||||||
here was pinning a parsed season schedule -- 946 games for NCAA
|
here was pinning a parsed season schedule -- 946 games for NCAA
|
||||||
football, roughly a tenth of total RAM on a 1GB Pi -- in memory until
|
football, roughly a tenth of total RAM on a 1GB Pi -- in memory until
|
||||||
the hourly sweep.
|
the hourly sweep.
|
||||||
|
|||||||
@@ -143,8 +143,10 @@ class LogoHelper:
|
|||||||
"""
|
"""
|
||||||
logo_path = Path(logo_path)
|
logo_path = Path(logo_path)
|
||||||
|
|
||||||
# Try to load existing logo first
|
# Try to load existing logo first. A placeholder written by a previous
|
||||||
if logo_path.exists():
|
# failed download does not count: it wears the real logo's filename, so
|
||||||
|
# trusting the file's existence is what left teams as grey boxes.
|
||||||
|
if logo_path.exists() and not self._is_stale_placeholder(logo_path):
|
||||||
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
||||||
|
|
||||||
# Download if URL provided and file doesn't exist
|
# Download if URL provided and file doesn't exist
|
||||||
@@ -152,13 +154,61 @@ class LogoHelper:
|
|||||||
try:
|
try:
|
||||||
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
||||||
self._download_logo(logo_url, logo_path)
|
self._download_logo(logo_url, logo_path)
|
||||||
|
# The file on disk just changed. Any cached image for it is the
|
||||||
|
# placeholder we came here to replace, and load_logo() answers
|
||||||
|
# from the cache before touching the disk -- so without this the
|
||||||
|
# real logo would not appear until the process restarted.
|
||||||
|
self._invalidate_cached_logo(team_abbr, logo_path)
|
||||||
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
||||||
|
# The retry failed, so restart the back-off. The stale
|
||||||
|
# placeholder is still on disk with its old timestamp, and
|
||||||
|
# leaving it there means the next call retries immediately --
|
||||||
|
# a download attempt per call, which is what the back-off
|
||||||
|
# exists to prevent.
|
||||||
|
self._refresh_stale_placeholder(logo_path)
|
||||||
|
|
||||||
# Create placeholder if all else fails
|
# Create placeholder if all else fails
|
||||||
return self._create_placeholder_logo(team_abbr, max_width, max_height)
|
return self._create_placeholder_logo(team_abbr, max_width, max_height)
|
||||||
|
|
||||||
|
def _invalidate_cached_logo(self, team_abbr: str, logo_path: Path) -> None:
|
||||||
|
"""Drop every cached size of one logo after its file changed on disk."""
|
||||||
|
prefix = f"{team_abbr}_{logo_path}_"
|
||||||
|
for key in [k for k in self._logo_cache if k.startswith(prefix)]:
|
||||||
|
self._logo_cache.pop(key, None)
|
||||||
|
if key in self._cache_order:
|
||||||
|
self._cache_order.remove(key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _refresh_stale_placeholder(logo_path: Path) -> None:
|
||||||
|
"""Restart the retry back-off after a failed download attempt."""
|
||||||
|
try:
|
||||||
|
from src.logo_downloader import refresh_placeholder_timestamp
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
refresh_placeholder_timestamp(logo_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_stale_placeholder(logo_path: Path) -> bool:
|
||||||
|
"""True if the file is a placeholder old enough to be worth retrying.
|
||||||
|
|
||||||
|
Imported lazily so this module keeps working against a core build whose
|
||||||
|
logo_downloader predates placeholder marking.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.logo_downloader import (
|
||||||
|
PLACEHOLDER_RETRY_SECONDS,
|
||||||
|
is_placeholder_logo,
|
||||||
|
placeholder_age_seconds,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
if not is_placeholder_logo(logo_path):
|
||||||
|
return False
|
||||||
|
age = placeholder_age_seconds(logo_path)
|
||||||
|
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
|
||||||
|
|
||||||
def get_logo_variations(self, team_abbr: str) -> List[str]:
|
def get_logo_variations(self, team_abbr: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get possible filename variations for a team abbreviation.
|
Get possible filename variations for a team abbreviation.
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
"""Card-drawing helpers shared by every sports scoreboard plugin.
|
||||||
|
|
||||||
|
The eight scoreboards each carried byte-identical copies of the functions
|
||||||
|
below: the colour pickers, the settings lookup, the date and time formatting,
|
||||||
|
the favourite-team rules and the font-size grid snapping. One fix had to be
|
||||||
|
made eight times, and a new scoreboard started by copying them a ninth.
|
||||||
|
|
||||||
|
Everything here is a **free function taking explicit arguments**, not a base
|
||||||
|
class. Adoption is therefore per-function and reversible: a plugin keeps its
|
||||||
|
method and delegates the body, so the call sites and the override points are
|
||||||
|
untouched. That is also why `config`, `logger` and `fonts` are parameters
|
||||||
|
rather than attributes -- the helper never reaches back into the caller.
|
||||||
|
|
||||||
|
The bodies are the plugins' own code, moved rather than rewritten. The one
|
||||||
|
deliberate difference is `crisp_size`, which takes the seven-plugin guard
|
||||||
|
(`not desired`) instead of football's: they agree on every real input, and
|
||||||
|
the extra guard only stops a None size raising TypeError.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
|
||||||
|
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
|
||||||
|
"scroll_card_option", "element_color", "font_color", "coerce_rgb",
|
||||||
|
"score_color_for", "recent_score_color", "favorite_teams_for",
|
||||||
|
"side_is_favorite", "side_score", "favorite_result",
|
||||||
|
"card_tzinfo", "weekday_for", "format_game_date", "format_game_time",
|
||||||
|
"vs_text", "upcoming_center_mode", "crisp_size", "schema_font_size",
|
||||||
|
"resolve_font_size", "unshare_element_fonts",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Which customization element owns each font key, for colour resolution.
|
||||||
|
ELEMENT_FOR_FONT: Dict[str, str] = {
|
||||||
|
"score": "score_text",
|
||||||
|
"time": "period_text",
|
||||||
|
"team": "team_name",
|
||||||
|
"status": "status_text",
|
||||||
|
"detail": "detail_text",
|
||||||
|
"rank": "rank_text",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Fallback colours when favourite_result_colors is on but a slot is unset.
|
||||||
|
FAVORITE_RESULT_COLOR_DEFAULTS: Dict[str, Tuple[int, int, int]] = {
|
||||||
|
"win": (0, 255, 0),
|
||||||
|
"loss": (255, 0, 0),
|
||||||
|
"tie": (255, 200, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Family aliases the web UI may write, mapped to the shipped filename.
|
||||||
|
FONT_NAME_ALIASES: Dict[str, str] = {
|
||||||
|
"press_start": "PressStart2P-Regular.ttf",
|
||||||
|
"four_by_six": "4x6-font.ttf",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Pixel grid each face renders crisply on. Off-grid sizes anti-alias, which
|
||||||
|
#: on an LED matrix is a dim lamp rather than a soft edge.
|
||||||
|
FONT_PIXEL_GRID: Dict[str, int] = {
|
||||||
|
"PressStart2P-Regular.ttf": 8,
|
||||||
|
"4x6-font.ttf": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings lookup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def scroll_card_option(config: Optional[Dict[str, Any]], key: str,
|
||||||
|
default: Any = None) -> Any:
|
||||||
|
"""Read one key from the scroll_card config block."""
|
||||||
|
block = (config or {}).get("scroll_card")
|
||||||
|
if isinstance(block, dict) and block.get(key) is not None:
|
||||||
|
return block.get(key)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def vs_text(config: Optional[Dict[str, Any]]) -> str:
|
||||||
|
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
|
||||||
|
return str(scroll_card_option(config, "vs_text", "VS"))
|
||||||
|
|
||||||
|
|
||||||
|
def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str:
|
||||||
|
"""Middle of an upcoming card: 'vs', 'date_time' or 'none'."""
|
||||||
|
mode = str(scroll_card_option(config, "upcoming_center", "vs") or "vs").lower()
|
||||||
|
return mode if mode in ("vs", "date_time", "none") else "vs"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Colour
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def element_color(config: Optional[Dict[str, Any]], element: str,
|
||||||
|
default: Tuple[int, int, int] = (255, 255, 255)):
|
||||||
|
"""Per-element text colour from customization.<element>.text_color."""
|
||||||
|
try:
|
||||||
|
cfg = (config or {}).get("customization", {}).get(element, {})
|
||||||
|
value = cfg.get("text_color")
|
||||||
|
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||||
|
return tuple(max(0, min(255, int(c))) for c in value)
|
||||||
|
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
|
||||||
|
return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
|
||||||
|
font, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||||
|
"""Colour for whichever element owns this face.
|
||||||
|
|
||||||
|
Matched on identity, and deliberately gives up when one object is
|
||||||
|
shared: the last-resort font path can hand the same face to several
|
||||||
|
keys, and there is no right answer for which element's colour that is.
|
||||||
|
White is what those draws used before, so ambiguity costs nothing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
fonts = fonts or {}
|
||||||
|
matches = [element for key, element in ELEMENT_FOR_FONT.items()
|
||||||
|
if fonts.get(key) is font]
|
||||||
|
if len(matches) == 1:
|
||||||
|
return element_color(config, matches[0], default)
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_rgb(value, fallback):
|
||||||
|
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
|
||||||
|
# Checked before unpacking: a 3-character string ("123") would otherwise
|
||||||
|
# iterate into three digits and yield a colour rather than the fallback.
|
||||||
|
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
r, g, b = (max(0, min(255, int(channel))) for channel in value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
return (r, g, b)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Favourite teams
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def favorite_teams_for(config: Dict[str, Any], game: Dict[str, Any]) -> list:
|
||||||
|
"""Favorite teams that apply to this game.
|
||||||
|
|
||||||
|
Both sources are used. Games carry the league manager's *resolved*
|
||||||
|
favorites, which is the only place dynamic groups such as AP_TOP_25
|
||||||
|
appear expanded; the config is read as well so an edit takes effect on
|
||||||
|
already-fetched games, and so hand-built game dicts (tests, other
|
||||||
|
callers) still work.
|
||||||
|
"""
|
||||||
|
favorites = list(game.get("favorite_teams") or [])
|
||||||
|
league_config = config.get(str(game.get("league", "") or ""))
|
||||||
|
if isinstance(league_config, dict):
|
||||||
|
favorites += list(league_config.get("favorite_teams") or [])
|
||||||
|
else:
|
||||||
|
favorites += list(config.get("favorite_teams") or [])
|
||||||
|
return favorites
|
||||||
|
|
||||||
|
|
||||||
|
def side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool:
|
||||||
|
"""Is the home/away side of this game a favorite team?
|
||||||
|
|
||||||
|
Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``)
|
||||||
|
payload shapes, and matches on the ESPN id too, because a couple of
|
||||||
|
leagues (NRL) key favorites by id where abbreviations collide.
|
||||||
|
"""
|
||||||
|
candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")]
|
||||||
|
team = game.get(f"{side}_team")
|
||||||
|
if isinstance(team, dict):
|
||||||
|
candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")]
|
||||||
|
for value in candidates:
|
||||||
|
if value is not None and str(value).strip().upper() in favorites:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def side_score(game: Dict[str, Any], side: str) -> Optional[int]:
|
||||||
|
"""Numeric score for one side, from either payload shape."""
|
||||||
|
raw = None
|
||||||
|
team = game.get(f"{side}_team")
|
||||||
|
if isinstance(team, dict) and team.get("score") is not None:
|
||||||
|
raw = team.get("score")
|
||||||
|
if raw is None:
|
||||||
|
raw = game.get(f"{side}_score")
|
||||||
|
try:
|
||||||
|
return int(float(str(raw).strip()))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def favorite_result(config: Dict[str, Any], game: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""Say how the favorite team did in a finished game.
|
||||||
|
|
||||||
|
Returns 'win', 'loss' or 'tie', or None when there is no single team
|
||||||
|
to root for: no favorites configured, neither side is a favorite, or
|
||||||
|
*both* are -- a favorite-vs-favorite game has no losing side worth
|
||||||
|
flagging in red. Also None when the scores are not usable numbers.
|
||||||
|
"""
|
||||||
|
favorites = {
|
||||||
|
str(team).strip().upper()
|
||||||
|
for team in favorite_teams_for(config, game)
|
||||||
|
if str(team).strip()
|
||||||
|
}
|
||||||
|
if not favorites:
|
||||||
|
return None
|
||||||
|
|
||||||
|
home_fav = side_is_favorite(game, "home", favorites)
|
||||||
|
away_fav = side_is_favorite(game, "away", favorites)
|
||||||
|
if home_fav == away_fav:
|
||||||
|
return None
|
||||||
|
|
||||||
|
home_score = side_score(game, "home")
|
||||||
|
away_score = side_score(game, "away")
|
||||||
|
if home_score is None or away_score is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if home_score == away_score:
|
||||||
|
return "tie"
|
||||||
|
favorite_score, other_score = (
|
||||||
|
(home_score, away_score) if home_fav else (away_score, home_score)
|
||||||
|
)
|
||||||
|
return "win" if favorite_score > other_score else "loss"
|
||||||
|
|
||||||
|
|
||||||
|
def recent_score_color(config: Dict[str, Any], logger, game: Dict[str, Any], default):
|
||||||
|
"""Fill color for a finished game's score, per favorite_result_colors."""
|
||||||
|
try:
|
||||||
|
settings = (config.get("customization") or {}).get(
|
||||||
|
"favorite_result_colors"
|
||||||
|
) or {}
|
||||||
|
if not settings.get("enabled", False):
|
||||||
|
return default
|
||||||
|
result = favorite_result(config, game)
|
||||||
|
if result is None:
|
||||||
|
return default
|
||||||
|
return coerce_rgb(
|
||||||
|
settings.get(f"{result}_color"),
|
||||||
|
FAVORITE_RESULT_COLOR_DEFAULTS[result],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not resolve favorite result color", exc_info=True)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def score_color_for(config: Dict[str, Any], logger, game: Dict[str, Any],
|
||||||
|
game_type: str, default=None):
|
||||||
|
"""Fill color for a game card's score. Only finished games are tinted.
|
||||||
|
|
||||||
|
The default is the configured score colour rather than a flat white,
|
||||||
|
so customization.score_text.text_color shows on games the favourite
|
||||||
|
tint does not apply to. The tint still wins where it applies.
|
||||||
|
"""
|
||||||
|
if default is None:
|
||||||
|
default = element_color(config, 'score_text')
|
||||||
|
if game_type != "recent":
|
||||||
|
return default
|
||||||
|
return recent_score_color(config, logger, game, default)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Date and time
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def card_tzinfo(config: Optional[Dict[str, Any]], logger):
|
||||||
|
"""Timezone for weekday/24h conversions; falls back to UTC."""
|
||||||
|
configured = (config or {}).get("timezone")
|
||||||
|
if configured:
|
||||||
|
try:
|
||||||
|
return ZoneInfo(configured)
|
||||||
|
except (KeyError, ValueError, TypeError, OSError) as exc:
|
||||||
|
# KeyError covers ZoneInfoNotFoundError. A bad zone name in
|
||||||
|
# config should fall back to UTC, not blank the card.
|
||||||
|
logger.debug("Unusable timezone %r: %s", configured, exc)
|
||||||
|
return timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def weekday_for(config: Optional[Dict[str, Any]], logger,
|
||||||
|
game: Optional[Dict]) -> str:
|
||||||
|
"""Weekday abbreviation from the game's start time, or ''."""
|
||||||
|
if not game:
|
||||||
|
return ""
|
||||||
|
raw = game.get("start_time_utc") or game.get("start_time")
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
|
||||||
|
str(raw).replace("Z", "+00:00"))
|
||||||
|
return WEEKDAY_ABBR[start.astimezone(card_tzinfo(config, logger)).weekday()]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
||||||
|
game: Optional[Dict] = None) -> str:
|
||||||
|
"""Format an upcoming card's date per scroll_card.date_format."""
|
||||||
|
raw = str(date_text or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
||||||
|
if fmt == "numeric":
|
||||||
|
return raw
|
||||||
|
parts = raw.replace("-", "/").split("/")
|
||||||
|
if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()):
|
||||||
|
return raw
|
||||||
|
month, day = int(parts[0]), int(parts[1])
|
||||||
|
if not 1 <= month <= 12:
|
||||||
|
return raw
|
||||||
|
name = MONTH_ABBR[month - 1]
|
||||||
|
if fmt == "numeric_day_first":
|
||||||
|
return f"{day}/{month}"
|
||||||
|
if fmt == "day_first":
|
||||||
|
return f"{day} {name}"
|
||||||
|
if fmt == "weekday":
|
||||||
|
weekday = weekday_for(config, logger, game)
|
||||||
|
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
|
||||||
|
return f"{name} {day}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
|
||||||
|
"""Return the time as-is (12h) or converted to 24h."""
|
||||||
|
raw = str(time_text or "").strip()
|
||||||
|
if not raw or str(scroll_card_option(config, "time_format", "12h")) != "24h":
|
||||||
|
return raw
|
||||||
|
cleaned = raw.upper().replace(" ", "")
|
||||||
|
meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else ""
|
||||||
|
if not meridiem:
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
hh, _, mm = cleaned[:-2].partition(":")
|
||||||
|
hour, minute = int(hh), int(mm or 0)
|
||||||
|
except ValueError:
|
||||||
|
return raw
|
||||||
|
if not (0 <= hour <= 12 and 0 <= minute <= 59):
|
||||||
|
return raw
|
||||||
|
hour = hour % 12 + (12 if meridiem == "PM" else 0)
|
||||||
|
return f"{hour:02d}:{minute:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Font sizing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Per-schema caches, keyed by the schema's absolute path. Keyed rather than
|
||||||
|
#: global because each plugin declares its own defaults; keyed rather than
|
||||||
|
#: per-class because the helper has no class to hang it on.
|
||||||
|
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def crisp_size(font_file, desired, aliases=None, grid_table=None):
|
||||||
|
"""Snap *desired* to the nearest size *font_file* renders crisply at.
|
||||||
|
|
||||||
|
A face with no known grid is returned unchanged, so a user-supplied
|
||||||
|
font is never second-guessed.
|
||||||
|
|
||||||
|
``aliases`` and ``grid_table`` default to the shared tables; a plugin
|
||||||
|
that ships an extra face can pass its own without forking this.
|
||||||
|
"""
|
||||||
|
aliases = FONT_NAME_ALIASES if aliases is None else aliases
|
||||||
|
grid_table = FONT_PIXEL_GRID if grid_table is None else grid_table
|
||||||
|
font_file = aliases.get(font_file, font_file)
|
||||||
|
grid = grid_table.get(font_file)
|
||||||
|
if not grid or not desired or desired <= 0:
|
||||||
|
return desired
|
||||||
|
return max(grid, int(round(float(desired) / grid)) * grid)
|
||||||
|
|
||||||
|
|
||||||
|
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
||||||
|
"""The font_size this plugin's config_schema.json declares, or None.
|
||||||
|
|
||||||
|
Cached per schema path. The plugins cached this on their own class; the
|
||||||
|
path is the same distinction expressed without one, so two plugins never
|
||||||
|
share an entry.
|
||||||
|
"""
|
||||||
|
if not element_key:
|
||||||
|
return None
|
||||||
|
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
|
||||||
|
if cache is None:
|
||||||
|
cache = {}
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
with open(schema_path) as fh:
|
||||||
|
schema = json.load(fh)
|
||||||
|
props = (schema.get('properties', {})
|
||||||
|
.get('customization', {})
|
||||||
|
.get('properties', {}))
|
||||||
|
for key, spec in props.items():
|
||||||
|
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
||||||
|
if size is not None:
|
||||||
|
cache[key] = int(size)
|
||||||
|
except Exception:
|
||||||
|
cache = {}
|
||||||
|
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
|
||||||
|
return cache.get(element_key)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_font_size(schema_path: str, element_config, element_key,
|
||||||
|
default_size, font_name, aliases=None, grid_table=None):
|
||||||
|
"""Size to render at: the user's choice, or a grid-snapped default.
|
||||||
|
|
||||||
|
A configured size counts as a real choice only when it differs from
|
||||||
|
the schema default. The web UI writes the whole schema default block
|
||||||
|
on every save, so "font_size == schema default" carries no intent and
|
||||||
|
would otherwise pin every install to an anti-aliased size forever.
|
||||||
|
"""
|
||||||
|
configured = (element_config or {}).get('font_size')
|
||||||
|
if configured is not None:
|
||||||
|
try:
|
||||||
|
configured = int(configured)
|
||||||
|
if configured != schema_font_size(schema_path, element_key):
|
||||||
|
return configured
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return crisp_size(font_name, default_size, aliases, grid_table)
|
||||||
|
|
||||||
|
|
||||||
|
def unshare_element_fonts(logger, fonts):
|
||||||
|
"""Give each colourable element its own face object.
|
||||||
|
|
||||||
|
The colour a draw gets is resolved from the face it was handed, and
|
||||||
|
several of these loaders legitimately hand one object to more than one
|
||||||
|
element -- a size resolver that lands two elements on the same face, a
|
||||||
|
fallback that fills every key from one default, football's narrowing
|
||||||
|
step that deliberately shrinks the clock along with the score. Sharing
|
||||||
|
the object makes the element ambiguous and the colour unresolvable.
|
||||||
|
|
||||||
|
Re-instantiating from the same path and size gives a distinct object
|
||||||
|
with identical metrics, so nothing about the rendering changes; only
|
||||||
|
the ability to tell two elements apart does. Faces that cannot be
|
||||||
|
rebuilt (a BDF loaded through freetype.Face, anything without a usable
|
||||||
|
path) are left shared, and their draws stay white as before.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import ImageFont as _IF
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
return fonts
|
||||||
|
seen = {}
|
||||||
|
for key in ELEMENT_FOR_FONT:
|
||||||
|
font = fonts.get(key)
|
||||||
|
if font is None:
|
||||||
|
continue
|
||||||
|
if id(font) not in seen:
|
||||||
|
seen[id(font)] = key
|
||||||
|
continue
|
||||||
|
path, size = getattr(font, "path", None), getattr(font, "size", None)
|
||||||
|
if not path or not size:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fonts[key] = _IF.truetype(path, size)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
logger.debug(
|
||||||
|
"Could not un-share the %s face; it keeps the default colour", key)
|
||||||
|
return fonts
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""The scroll/Vegas card geometry the sports scoreboards all share.
|
||||||
|
|
||||||
|
Eight scoreboards -- afl, baseball, basketball, football, hockey, lacrosse,
|
||||||
|
nrl and soccer -- each carried their own ``game_renderer.py``. After the card
|
||||||
|
helpers moved to ``src/common/sports_card.py`` the settings lookups were
|
||||||
|
shared, but the *geometry* was not: nine methods that decide how wide the
|
||||||
|
centre gap is, how much room a logo gets, and where an upcoming card's date
|
||||||
|
and time land were still eight separate copies. Five were byte-identical
|
||||||
|
across all eight plugins; the other four were identical in seven, with a
|
||||||
|
different single outlier each time.
|
||||||
|
|
||||||
|
That last detail is why this is a mixin rather than free functions. There is
|
||||||
|
no per-sport branching to write -- baseball needs its own
|
||||||
|
``_draw_upcoming_game_status`` and ``_logo_slot_width``, hockey its own
|
||||||
|
``_upcoming_date_and_time``, football its own ``_score_reserve_width``, and
|
||||||
|
every one of those is an ordinary override. The seven that agree inherit and
|
||||||
|
say nothing.
|
||||||
|
|
||||||
|
It is deliberately *only* a mixin: no ``__init__``, no state of its own. The
|
||||||
|
plugins' constructors differ in six ways and none of that difference is worth
|
||||||
|
unifying, so adoption is one line on the class statement plus deleting the
|
||||||
|
methods that now come from here.
|
||||||
|
|
||||||
|
What a host class must provide
|
||||||
|
------------------------------
|
||||||
|
Attributes: ``display_width``, ``display_height``, ``config``, ``fonts``,
|
||||||
|
``logger``, ``_team_rankings_cache``.
|
||||||
|
|
||||||
|
Methods: ``_draw_text_with_outline(draw, text, position, font, fill=None,
|
||||||
|
outline_color=(0, 0, 0))`` -- the one hook whose body genuinely varies -- plus
|
||||||
|
the ``sports_card`` delegations ``_scroll_card_option``,
|
||||||
|
``_upcoming_center_mode``, ``_vs_text``, ``_element_color``,
|
||||||
|
``_format_game_date`` and ``_format_game_time``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import ClassVar, Dict, Tuple
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
|
||||||
|
class SportsGameRendererMixin:
|
||||||
|
"""Shared card geometry for the sports scoreboards. See module docstring."""
|
||||||
|
|
||||||
|
#: Centre strip as a fraction of card width, before clamping.
|
||||||
|
CENTER_GAP_RATIO: ClassVar[float] = 0.28
|
||||||
|
#: Clamp floor for the derived centre gap.
|
||||||
|
CENTER_GAP_MIN_PX: ClassVar[int] = 22
|
||||||
|
#: Clamp ceiling for the derived centre gap.
|
||||||
|
CENTER_GAP_MAX_PX: ClassVar[int] = 40
|
||||||
|
#: Breathing room kept between the score and each logo.
|
||||||
|
_SCORE_LOGO_GUTTER_PX: ClassVar[int] = 4
|
||||||
|
#: Widest score the centre strip must fit. Leagues that can reach three
|
||||||
|
#: digits a side override this with "000-000".
|
||||||
|
_SCORE_PROBE: ClassVar[str] = "00-00"
|
||||||
|
|
||||||
|
# ---- geometry ------------------------------------------------------
|
||||||
|
|
||||||
|
# Non-finite settings are rejected before any int()/round(): "inf" reaches
|
||||||
|
# these from config as a float or a string, passes an `isinstance` plus
|
||||||
|
# `>= 0` check unharmed, and then raises OverflowError out of int() --
|
||||||
|
# which the old `except (TypeError, ValueError)` did not catch, so it
|
||||||
|
# aborted the whole card render. Present in all eight plugins before this
|
||||||
|
# moved to the core; fixing it here fixes it in all eight.
|
||||||
|
|
||||||
|
def _score_reserve_width(self) -> int:
|
||||||
|
"""Centre strip the score actually needs, measured rather than assumed.
|
||||||
|
|
||||||
|
The gap was derived from the card width alone (width x
|
||||||
|
CENTER_GAP_RATIO, clamped to CENTER_GAP_MAX_PX) while the score's size
|
||||||
|
comes from config and the element-style resolver. Nothing compared the
|
||||||
|
two, so any score wider than the clamp was drawn over the logos.
|
||||||
|
Measuring it keeps the strip wide enough for whatever font is in play.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
probe = ImageDraw.Draw(Image.new("RGB", (4, 4)))
|
||||||
|
width = probe.textlength(self._SCORE_PROBE, font=self.fonts['score'])
|
||||||
|
return int(width) + 2 * self._SCORE_LOGO_GUTTER_PX
|
||||||
|
except Exception:
|
||||||
|
self.logger.debug("Score reserve measurement failed", exc_info=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _center_gap_width(self) -> int:
|
||||||
|
"""Width of the middle strip kept clear of logos.
|
||||||
|
|
||||||
|
``scroll_card.center_gap`` pins it outright; otherwise it scales with
|
||||||
|
the card width between the configurable min and max. 0 restores
|
||||||
|
edge-to-edge logos.
|
||||||
|
"""
|
||||||
|
configured = self._scroll_card_option("center_gap")
|
||||||
|
if (isinstance(configured, (int, float))
|
||||||
|
and math.isfinite(configured) and configured >= 0):
|
||||||
|
return int(configured)
|
||||||
|
ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO)
|
||||||
|
low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX)
|
||||||
|
high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX)
|
||||||
|
try:
|
||||||
|
ratio, low, high = float(ratio), float(low), float(high)
|
||||||
|
if not (math.isfinite(ratio) and math.isfinite(low)
|
||||||
|
and math.isfinite(high)):
|
||||||
|
return self.CENTER_GAP_MIN_PX
|
||||||
|
scaled = round(self.display_width * ratio)
|
||||||
|
derived = int(max(int(low), min(int(high), scaled)))
|
||||||
|
# A strip narrower than the score is the bug, not a style choice.
|
||||||
|
# An explicit ``center_gap`` is still honoured above, including 0.
|
||||||
|
return max(derived, self._score_reserve_width())
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return self.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
def _logo_slot_width(self) -> int:
|
||||||
|
"""Per-side logo slot, leaving the center gap clear.
|
||||||
|
|
||||||
|
No longer capped at display_height: the card is sized as two
|
||||||
|
full-height logos plus the measured gap, so what is left after the gap
|
||||||
|
is exactly the logo's share. The cap was what froze the logos at 46px
|
||||||
|
on the old flat 128px card.
|
||||||
|
"""
|
||||||
|
available = (self.display_width - self._center_gap_width()) // 2
|
||||||
|
return max(8, available)
|
||||||
|
|
||||||
|
def _logo_cache_key(self, name: str) -> str:
|
||||||
|
"""Cache key scoped to the logo slot.
|
||||||
|
|
||||||
|
One cache dict is shared by renderers built for different card widths,
|
||||||
|
so a logo sized for a wide slot must not be handed to a narrow one.
|
||||||
|
"""
|
||||||
|
return f"{name}@{self._logo_slot_width()}x{self.display_height}"
|
||||||
|
|
||||||
|
def _layout_offset(self, element: str, axis: str, default: int = 0) -> int:
|
||||||
|
"""X/Y nudge for one element, from customization.layout.
|
||||||
|
|
||||||
|
Same block the full-screen scorebug reads (sports.py
|
||||||
|
_get_layout_offset), so a nudge configured in the web UI now moves
|
||||||
|
the element on the scroll/Vegas card too -- previously the schema
|
||||||
|
advertised these offsets but this renderer ignored them.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
layout = (self.config or {}).get("customization", {}).get("layout", {})
|
||||||
|
value = (layout.get(element) or {}).get(axis, default)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return default
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return int(value) if math.isfinite(value) else default
|
||||||
|
if isinstance(value, str):
|
||||||
|
parsed = float(value)
|
||||||
|
return int(parsed) if math.isfinite(parsed) else default
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
# ---- upcoming cards ------------------------------------------------
|
||||||
|
|
||||||
|
def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]:
|
||||||
|
"""(date, time) for an upcoming card, from the extractor's flat keys."""
|
||||||
|
return (
|
||||||
|
str(game.get("game_date", "") or ""),
|
||||||
|
str(game.get("game_time", "") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None:
|
||||||
|
"""Draw the middle of an upcoming card.
|
||||||
|
|
||||||
|
Never a score: an upcoming game has not started, so the extractor's
|
||||||
|
0-0 is noise. Either the VS text (default), the date and time stacked,
|
||||||
|
or nothing at all.
|
||||||
|
"""
|
||||||
|
mode = self._upcoming_center_mode()
|
||||||
|
if mode == "none":
|
||||||
|
return
|
||||||
|
|
||||||
|
if mode == "vs":
|
||||||
|
vs_text = self._vs_text()
|
||||||
|
if not vs_text:
|
||||||
|
return
|
||||||
|
vs_width = draw.textlength(vs_text, font=self.fonts['score'])
|
||||||
|
vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset')
|
||||||
|
vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset')
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, vs_text, (vs_x, vs_y), self.fonts['score'],
|
||||||
|
fill=self._element_color('score_text')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
date_text, time_text = self._upcoming_date_and_time(game)
|
||||||
|
lines = []
|
||||||
|
if self._scroll_card_option("show_date", True):
|
||||||
|
lines.append(self._format_game_date(date_text, game))
|
||||||
|
if self._scroll_card_option("show_time", True):
|
||||||
|
lines.append(self._format_game_time(time_text))
|
||||||
|
lines = [t for t in lines if t]
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
font = self.fonts.get('detail') or self.fonts['time']
|
||||||
|
line_h = 7
|
||||||
|
top = (self.display_height // 2) - (len(lines) * line_h) // 2
|
||||||
|
top += self._layout_offset('score', 'y_offset')
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
width = draw.textlength(line, font=font)
|
||||||
|
x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset')
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, line, (x, top + i * line_h), font,
|
||||||
|
fill=self._element_color('detail_text')
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_upcoming_game_status(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None:
|
||||||
|
"""Draw the date and time around an upcoming card.
|
||||||
|
|
||||||
|
Time top and date bottom by default; scroll_card.swap_date_time puts
|
||||||
|
the date on top instead. Skipped when the pair is stacked in the
|
||||||
|
middle, which would otherwise print them twice.
|
||||||
|
"""
|
||||||
|
if self._upcoming_center_mode() == "date_time":
|
||||||
|
return
|
||||||
|
|
||||||
|
date_raw, time_raw = self._upcoming_date_and_time(game)
|
||||||
|
date_text = (self._format_game_date(date_raw, game)
|
||||||
|
if self._scroll_card_option("show_date", True) else "")
|
||||||
|
time_text = (self._format_game_time(time_raw)
|
||||||
|
if self._scroll_card_option("show_time", True) else "")
|
||||||
|
|
||||||
|
if self._scroll_card_option("swap_date_time", False):
|
||||||
|
top_text, top_el, bottom_text, bottom_el = (
|
||||||
|
date_text, 'date', time_text, 'time')
|
||||||
|
top_font = self.fonts.get('detail') or self.fonts['time']
|
||||||
|
bottom_font = self.fonts['time']
|
||||||
|
top_color, bottom_color = 'detail_text', 'period_text'
|
||||||
|
else:
|
||||||
|
top_text, top_el, bottom_text, bottom_el = (
|
||||||
|
time_text, 'time', date_text, 'date')
|
||||||
|
top_font = self.fonts['time']
|
||||||
|
bottom_font = self.fonts.get('detail') or self.fonts['time']
|
||||||
|
top_color, bottom_color = 'period_text', 'detail_text'
|
||||||
|
|
||||||
|
if top_text:
|
||||||
|
top_width = draw.textlength(top_text, font=top_font)
|
||||||
|
top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset')
|
||||||
|
top_y = 1 + self._layout_offset(top_el, 'y_offset')
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, top_text, (top_x, top_y), top_font,
|
||||||
|
fill=self._element_color(top_color)
|
||||||
|
)
|
||||||
|
|
||||||
|
if bottom_text:
|
||||||
|
bottom_width = draw.textlength(bottom_text, font=bottom_font)
|
||||||
|
bottom_x = ((self.display_width - bottom_width) // 2
|
||||||
|
+ self._layout_offset(bottom_el, 'x_offset'))
|
||||||
|
# Measured, not a fixed -7: the detail font is 6px in most plugins
|
||||||
|
# but 10px in soccer and nrl, where "Sep 19" ran past the card.
|
||||||
|
ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3]
|
||||||
|
bottom_y = (max(0, self.display_height - ink_bottom - 1)
|
||||||
|
+ self._layout_offset(bottom_el, 'y_offset'))
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, bottom_text, (bottom_x, bottom_y), bottom_font,
|
||||||
|
fill=self._element_color(bottom_color)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- rankings ------------------------------------------------------
|
||||||
|
|
||||||
|
def set_rankings_cache(self, rankings: Dict[str, int]) -> None:
|
||||||
|
"""Set the team rankings cache for display."""
|
||||||
|
self._team_rankings_cache = rankings
|
||||||
File diff suppressed because it is too large
Load Diff
+116
-8
@@ -14,6 +14,7 @@ import json
|
|||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from PIL.PngImagePlugin import PngInfo
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
@@ -25,6 +26,96 @@ from src.common.permission_utils import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: PNG text key stamped into a generated placeholder so a later run can tell it
|
||||||
|
#: apart from a real logo that happens to be small.
|
||||||
|
PLACEHOLDER_MARKER = "ledmatrix_placeholder"
|
||||||
|
|
||||||
|
#: Geometry of a generated placeholder, used to recognise ones written before
|
||||||
|
#: the marker existed. Those are already on users' disks and would otherwise
|
||||||
|
#: never be retried.
|
||||||
|
PLACEHOLDER_SIZE = (64, 64)
|
||||||
|
PLACEHOLDER_BG = (100, 100, 100, 255)
|
||||||
|
|
||||||
|
#: How long a placeholder is trusted before the real logo is attempted again.
|
||||||
|
#: A placeholder means the download failed, and download failures are usually
|
||||||
|
#: transient (no network at boot, ESPN blipping). Retrying every frame would
|
||||||
|
#: hammer the API from a Pi that is also driving a panel; never retrying leaves
|
||||||
|
#: the team a grey box forever, which is the bug this exists to avoid.
|
||||||
|
PLACEHOLDER_RETRY_SECONDS = 6 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def is_placeholder_logo(filepath: Path) -> bool:
|
||||||
|
"""True if the file at ``filepath`` is a generated placeholder, not a logo.
|
||||||
|
|
||||||
|
Checks the marker first, then falls back to matching the placeholder's
|
||||||
|
exact geometry and background colour so files written before the marker was
|
||||||
|
introduced are still recognised.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Image.open(filepath) as img:
|
||||||
|
if img.info.get(PLACEHOLDER_MARKER):
|
||||||
|
return True
|
||||||
|
if img.size != PLACEHOLDER_SIZE:
|
||||||
|
return False
|
||||||
|
return img.convert("RGBA").getpixel((0, 0)) == PLACEHOLDER_BG
|
||||||
|
except Exception:
|
||||||
|
# Unreadable file: not provably a placeholder, and the caller's own
|
||||||
|
# error handling is better placed to deal with it.
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def should_attempt_download(filepath: Path, force_download: bool = False) -> bool:
|
||||||
|
"""Whether a real logo is worth (re)fetching for ``filepath``.
|
||||||
|
|
||||||
|
True when nothing is there, when the caller forced it, or when what is
|
||||||
|
there is a placeholder old enough to retry. A *fresh* placeholder says a
|
||||||
|
download just failed, so retrying it immediately would hammer the API for
|
||||||
|
a result that is very unlikely to have changed.
|
||||||
|
"""
|
||||||
|
if force_download or not filepath.exists():
|
||||||
|
return True
|
||||||
|
if not is_placeholder_logo(filepath):
|
||||||
|
return False
|
||||||
|
age = placeholder_age_seconds(filepath)
|
||||||
|
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_placeholder_timestamp(filepath: Path) -> bool:
|
||||||
|
"""Restamp a placeholder so a failed retry restarts the back-off clock.
|
||||||
|
|
||||||
|
Without this a stale placeholder stays stale: every later call sees an
|
||||||
|
expired timestamp, retries, fails, and leaves the timestamp untouched --
|
||||||
|
which is a download attempt per call, the opposite of what the back-off is
|
||||||
|
for.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not is_placeholder_logo(filepath):
|
||||||
|
return False
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
|
||||||
|
with Image.open(filepath) as img:
|
||||||
|
img.copy().save(filepath, "PNG", pnginfo=metadata)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not refresh placeholder timestamp for %s", filepath,
|
||||||
|
exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def placeholder_age_seconds(filepath: Path) -> Optional[float]:
|
||||||
|
"""Seconds since a placeholder was written, or None if unknown."""
|
||||||
|
try:
|
||||||
|
with Image.open(filepath) as img:
|
||||||
|
stamped = img.info.get(PLACEHOLDER_MARKER)
|
||||||
|
if stamped and stamped != "1":
|
||||||
|
return max(0.0, time.time() - float(stamped))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return max(0.0, time.time() - filepath.stat().st_mtime)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
class LogoDownloader:
|
class LogoDownloader:
|
||||||
"""Centralized logo downloader for team logos from ESPN API."""
|
"""Centralized logo downloader for team logos from ESPN API."""
|
||||||
|
|
||||||
@@ -499,8 +590,10 @@ class LogoDownloader:
|
|||||||
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
||||||
filepath = Path(logo_dir) / filename
|
filepath = Path(logo_dir) / filename
|
||||||
|
|
||||||
# Skip if already exists and not forcing download
|
# A placeholder does not count as existing -- it is a previous
|
||||||
if filepath.exists() and not force_download:
|
# failure, and a bulk pass is exactly where it should get another
|
||||||
|
# chance, subject to the same back-off as everywhere else.
|
||||||
|
if not should_attempt_download(filepath, force_download):
|
||||||
logger.debug(f"Skipping {display_name}: {filename} already exists")
|
logger.debug(f"Skipping {display_name}: {filename} already exists")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -559,8 +652,9 @@ class LogoDownloader:
|
|||||||
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
||||||
filepath = Path(logo_dir) / filename
|
filepath = Path(logo_dir) / filename
|
||||||
|
|
||||||
# Skip if already exists and not forcing download
|
# Same eligibility rule as every other download site: a stale
|
||||||
if filepath.exists() and not force_download:
|
# placeholder is a failed download, not a logo.
|
||||||
|
if not should_attempt_download(filepath, force_download):
|
||||||
logger.debug(f"Skipping {display_name} ({category}, {conference}): {filename} already exists")
|
logger.debug(f"Skipping {display_name} ({category}, {conference}): {filename} already exists")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -674,11 +768,16 @@ class LogoDownloader:
|
|||||||
# Fallback without font
|
# Fallback without font
|
||||||
draw.text((16, 24), text, fill=(255, 255, 255, 255))
|
draw.text((16, 24), text, fill=(255, 255, 255, 255))
|
||||||
|
|
||||||
logo.save(filepath)
|
# Stamp it so a later run can tell this apart from a real logo and
|
||||||
|
# retry the download, instead of treating the file's existence as
|
||||||
|
# proof the logo was fetched.
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
|
||||||
|
logo.save(filepath, "PNG", pnginfo=metadata)
|
||||||
|
|
||||||
# Set proper file permissions after saving
|
# Set proper file permissions after saving
|
||||||
ensure_file_permissions(filepath, get_assets_file_mode())
|
ensure_file_permissions(filepath, get_assets_file_mode())
|
||||||
|
|
||||||
logger.info(f"Created placeholder logo for {team_abbreviation} at {filepath}")
|
logger.info(f"Created placeholder logo for {team_abbreviation} at {filepath}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -770,9 +869,18 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
|
|||||||
# Use the exact filepath that was passed in (respects config settings)
|
# Use the exact filepath that was passed in (respects config settings)
|
||||||
filepath = logo_path
|
filepath = logo_path
|
||||||
|
|
||||||
if filepath.exists():
|
if filepath.exists() and not should_attempt_download(filepath):
|
||||||
|
# Either a real logo, or a placeholder too fresh to be worth retrying.
|
||||||
logger.debug(f"Logo already exists for {team_abbreviation} ({league})")
|
logger.debug(f"Logo already exists for {team_abbreviation} ({league})")
|
||||||
return True
|
return True
|
||||||
|
if filepath.exists():
|
||||||
|
# A placeholder is a *failed* download wearing the real logo's
|
||||||
|
# filename. Treating it as "already exists" is what pinned a team to a
|
||||||
|
# grey box permanently after one transient failure.
|
||||||
|
logger.info(
|
||||||
|
"Logo for %s (%s) is a placeholder from a failed download; "
|
||||||
|
"retrying the real logo", team_abbreviation, league,
|
||||||
|
)
|
||||||
|
|
||||||
# Try to download the real logo first
|
# Try to download the real logo first
|
||||||
logger.info(f"Attempting to download logo for {team_abbreviation} from {league}")
|
logger.info(f"Attempting to download logo for {team_abbreviation} from {league}")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
# Below this, the core's self-reported version is not evidence of anything.
|
# Below this, the core's self-reported version is not evidence of anything.
|
||||||
@@ -35,6 +36,45 @@ from typing import Any, Dict, Optional, Tuple
|
|||||||
TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0)
|
TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ``src/__init__.py`` relative to this file: src/plugin_system/ -> src/
|
||||||
|
_VERSION_FILE = Path(__file__).resolve().parent.parent / "__init__.py"
|
||||||
|
_VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.M)
|
||||||
|
|
||||||
|
|
||||||
|
def current_core_version() -> str:
|
||||||
|
"""The core version as it is on disk right now, not as it was at import.
|
||||||
|
|
||||||
|
``from src import __version__`` binds whatever the process loaded at start.
|
||||||
|
The web UI runs as its own long-lived service (``ledmatrix-web.service``),
|
||||||
|
and updating the core replaces files on disk without restarting it -- the
|
||||||
|
update route says so explicitly and asks the user to restart. Its prompt
|
||||||
|
names the *display* service, so a user who follows it leaves the web
|
||||||
|
process holding the old number.
|
||||||
|
|
||||||
|
The plugin store's gate lives in that web process. Stale by one release is
|
||||||
|
exactly the case that matters: every plugin flooring on the release you
|
||||||
|
just installed gets refused, with a message blaming a core version that is
|
||||||
|
already correct on disk. 3.3.0 is the first release where that hits a whole
|
||||||
|
plugin family at once -- all eight sports scoreboards floor there.
|
||||||
|
|
||||||
|
Reading the file costs one stat and a small read per call, and only on the
|
||||||
|
install/update path. Any failure falls back to the imported value, so this
|
||||||
|
can only ever be as wrong as before, never worse.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
text = _VERSION_FILE.read_text(encoding="utf-8")
|
||||||
|
match = _VERSION_RE.search(text)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from src import __version__ as imported
|
||||||
|
return imported
|
||||||
|
except Exception: # noqa: BLE001 - never let this raise
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
|
def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
|
||||||
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
|
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
|
||||||
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
|
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
|
||||||
|
|||||||
@@ -772,8 +772,8 @@ class PluginLoader:
|
|||||||
newer than the running core. Advisory only — never raises — so a
|
newer than the running core. Advisory only — never raises — so a
|
||||||
plugin that guards optional features with try/except keeps working.
|
plugin that guards optional features with try/except keeps working.
|
||||||
"""
|
"""
|
||||||
from src import __version__ as core_version
|
|
||||||
from src.plugin_system import compatibility
|
from src.plugin_system import compatibility
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
compatible, _reason = compatibility.check(manifest, core_version)
|
compatible, _reason = compatibility.check(manifest, core_version)
|
||||||
if compatible:
|
if compatible:
|
||||||
|
|||||||
@@ -1467,8 +1467,11 @@ class PluginStoreManager:
|
|||||||
# already had. Allowing it costs them a plugin that raises
|
# already had. Allowing it costs them a plugin that raises
|
||||||
# ModuleNotFoundError at load and is reported only as one line
|
# ModuleNotFoundError at load and is reported only as one line
|
||||||
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
|
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
|
||||||
from src import __version__ as core_version
|
|
||||||
from src.plugin_system import compatibility
|
from src.plugin_system import compatibility
|
||||||
|
# On disk, not as imported: this process may predate the core
|
||||||
|
# update that made the plugin compatible. See
|
||||||
|
# compatibility.current_core_version.
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
compatible, reason = compatibility.check(manifest, core_version)
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
if not compatible:
|
if not compatible:
|
||||||
@@ -1602,6 +1605,26 @@ class PluginStoreManager:
|
|||||||
'error': f'Manifest missing required fields: {", ".join(missing_fields)}'
|
'error': f'Manifest missing required fields: {", ".join(missing_fields)}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Refuse a plugin that needs a newer core than this one, exactly as
|
||||||
|
# _install_plugin_impl does after its download. Sideloading is an
|
||||||
|
# explicit act rather than an automatic store update, but the floor
|
||||||
|
# is not advice about intent -- it is a statement that the plugin
|
||||||
|
# cannot run here, and letting it through produces the same silent
|
||||||
|
# PluginState.ERROR at load. This was the last of the three routes
|
||||||
|
# in that skipped the check.
|
||||||
|
#
|
||||||
|
# Before the move, so the `finally` below removes the temp tree and
|
||||||
|
# nothing half-installed is left behind.
|
||||||
|
from src.plugin_system import compatibility
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
|
if not compatible:
|
||||||
|
self.logger.error(
|
||||||
|
"Refusing to install %s from %s: %s",
|
||||||
|
plugin_id, repo_url, reason)
|
||||||
|
return {'success': False, 'error': reason}
|
||||||
|
|
||||||
# Validate version fields consistency (warnings only, not required)
|
# Validate version fields consistency (warnings only, not required)
|
||||||
validation_errors = self._validate_manifest_version_fields(manifest)
|
validation_errors = self._validate_manifest_version_fields(manifest)
|
||||||
if validation_errors:
|
if validation_errors:
|
||||||
@@ -2583,6 +2606,85 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
|
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _gate_pulled_commit(self, plugin_id: str, plugin_path: Path,
|
||||||
|
previous_sha: Optional[str]) -> bool:
|
||||||
|
"""Apply the compatibility gate to a commit that arrived via git pull.
|
||||||
|
|
||||||
|
Every other route into an installed plugin goes through
|
||||||
|
``install_plugin``, which gates in ``_install_plugin_impl``. This one
|
||||||
|
did not: a ``git pull`` could deliver a manifest flooring above this
|
||||||
|
core and nothing would notice until the plugin failed to load, which
|
||||||
|
surfaces as one line in the journal and a scoreboard that silently
|
||||||
|
stopped appearing.
|
||||||
|
|
||||||
|
Checked after the pull rather than before it, for the same reason
|
||||||
|
``_install_plugin_impl`` checks after the download: the registry
|
||||||
|
carries no compatibility field, so the incoming floor is only knowable
|
||||||
|
once the new commit is on disk.
|
||||||
|
|
||||||
|
Undone with ``git reset --hard`` rather than by removing the directory.
|
||||||
|
This is a live checkout, the previous commit is still in the object
|
||||||
|
store, and the reset leaves the user on the exact version they were
|
||||||
|
already running -- the same promise ``_reinstall_with_rollback`` makes,
|
||||||
|
reached by the means this path actually has. It is also the gentler
|
||||||
|
option: no window in which the plugin directory does not exist, and no
|
||||||
|
``.standalone-backup-`` debris if the process dies mid-way.
|
||||||
|
|
||||||
|
A manifest that cannot be read is not evidence of incompatibility, so
|
||||||
|
it allows. ``compatibility.check`` refuses only on evidence for the
|
||||||
|
same reason: a wrong refusal breaks a working install, while a wrong
|
||||||
|
allowance degrades to exactly the behaviour this path had before the
|
||||||
|
gate existed.
|
||||||
|
"""
|
||||||
|
manifest_path = plugin_path / "manifest.json"
|
||||||
|
try:
|
||||||
|
with open(manifest_path, 'r', encoding='utf-8') as mf:
|
||||||
|
manifest = json.load(mf)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
self.logger.warning(
|
||||||
|
"Could not read %s after updating %s (%s); allowing the "
|
||||||
|
"update, as an unreadable manifest declares no floor",
|
||||||
|
manifest_path, plugin_id, e)
|
||||||
|
return True
|
||||||
|
|
||||||
|
from src.plugin_system import compatibility
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
|
if compatible:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.logger.error("Refusing the update to %s: %s", plugin_id, reason)
|
||||||
|
|
||||||
|
if not previous_sha:
|
||||||
|
self.logger.error(
|
||||||
|
"Cannot roll %s back: the commit it was on before the pull is "
|
||||||
|
"unknown. It is now on a version this core cannot run — "
|
||||||
|
"reinstall it from the plugin store.", plugin_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Safe by construction: update_plugin returns before pulling unless the
|
||||||
|
# tree was clean or successfully stashed, so there are no uncommitted
|
||||||
|
# tracked edits for --hard to discard. The stash is not popped on the
|
||||||
|
# success path either, so the reset leaves the working tree exactly
|
||||||
|
# where a successful pull would have. Say "commit", not "changes".
|
||||||
|
reset = subprocess.run(
|
||||||
|
['git', '-C', str(plugin_path), 'reset', '--hard', previous_sha],
|
||||||
|
capture_output=True, text=True, timeout=60, check=False)
|
||||||
|
if reset.returncode != 0:
|
||||||
|
self.logger.error(
|
||||||
|
"CRITICAL: could not roll %s back to commit %s: %s. It is left "
|
||||||
|
"on a version this core cannot run; "
|
||||||
|
"`git -C %s reset --hard %s` restores it.",
|
||||||
|
plugin_id, previous_sha[:7],
|
||||||
|
(reset.stderr or reset.stdout or '').strip(),
|
||||||
|
plugin_path, previous_sha)
|
||||||
|
else:
|
||||||
|
self.logger.info(
|
||||||
|
"Rolled %s back to commit %s; it stays on the version it was "
|
||||||
|
"already running.", plugin_id, previous_sha[:7])
|
||||||
|
return False
|
||||||
|
|
||||||
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
|
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
|
||||||
"""Replace an installed plugin with a fresh install, atomically.
|
"""Replace an installed plugin with a fresh install, atomically.
|
||||||
|
|
||||||
@@ -2860,6 +2962,8 @@ class PluginStoreManager:
|
|||||||
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
|
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
|
||||||
|
|
||||||
stash_info = ""
|
stash_info = ""
|
||||||
|
# Whether the pull can be undone without destroying work.
|
||||||
|
tree_is_recoverable = not has_changes
|
||||||
if has_changes:
|
if has_changes:
|
||||||
self.logger.info(f"Stashing local changes in {plugin_id} before update")
|
self.logger.info(f"Stashing local changes in {plugin_id} before update")
|
||||||
try:
|
try:
|
||||||
@@ -2873,12 +2977,37 @@ class PluginStoreManager:
|
|||||||
)
|
)
|
||||||
if stash_result.returncode == 0:
|
if stash_result.returncode == 0:
|
||||||
stash_info = " (local changes were stashed)"
|
stash_info = " (local changes were stashed)"
|
||||||
|
tree_is_recoverable = True
|
||||||
self.logger.info(f"Stashed local changes (including untracked files) for {plugin_id}")
|
self.logger.info(f"Stashed local changes (including untracked files) for {plugin_id}")
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Failed to stash local changes for {plugin_id}: {stash_result.stderr}")
|
self.logger.warning(f"Failed to stash local changes for {plugin_id}: {stash_result.stderr}")
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
self.logger.warning(f"Stash operation timed out for {plugin_id}, proceeding with pull")
|
self.logger.warning(f"Stash operation timed out for {plugin_id}, proceeding with pull")
|
||||||
|
|
||||||
|
# Do not pull what cannot be un-pulled.
|
||||||
|
#
|
||||||
|
# The compatibility gate below can refuse the commit this
|
||||||
|
# pull brings down, and its only way back is `git reset
|
||||||
|
# --hard`, which discards uncommitted tracked edits. Those
|
||||||
|
# edits are exactly what the stash above exists to protect,
|
||||||
|
# so a stash that failed or timed out leaves the rollback
|
||||||
|
# unable to run without destroying them.
|
||||||
|
#
|
||||||
|
# A pull does not necessarily refuse on a dirty tree -- git
|
||||||
|
# merges happily as long as the incoming commit touches
|
||||||
|
# different files -- so without this the update would
|
||||||
|
# succeed, the gate would refuse, and the reset would take
|
||||||
|
# the user's work with it. Refusing here costs an update in
|
||||||
|
# a case that already went wrong; the alternative costs
|
||||||
|
# data.
|
||||||
|
if not tree_is_recoverable:
|
||||||
|
self.logger.error(
|
||||||
|
"Refusing to update %s: it has local changes that could "
|
||||||
|
"not be stashed, and an incompatible update could then "
|
||||||
|
"only be rolled back by discarding them. Commit or stash "
|
||||||
|
"them by hand, then update.", plugin_id)
|
||||||
|
return False
|
||||||
|
|
||||||
# Pull from the determined remote branch
|
# Pull from the determined remote branch
|
||||||
self.logger.info(f"Pulling from origin/{remote_pull_branch} for {plugin_id}...")
|
self.logger.info(f"Pulling from origin/{remote_pull_branch} for {plugin_id}...")
|
||||||
pull_result = subprocess.run(
|
pull_result = subprocess.run(
|
||||||
@@ -2901,6 +3030,14 @@ class PluginStoreManager:
|
|||||||
elif updated_sha:
|
elif updated_sha:
|
||||||
self.logger.info(f"Plugin {plugin_id} updated to commit {updated_sha[:7]}{stash_info}")
|
self.logger.info(f"Plugin {plugin_id} updated to commit {updated_sha[:7]}{stash_info}")
|
||||||
|
|
||||||
|
# The install gate, at the only point on this path where
|
||||||
|
# it can be answered. Every other route in goes through
|
||||||
|
# install_plugin, which gates in _install_plugin_impl; this
|
||||||
|
# one did not, so a pull could deliver a manifest flooring
|
||||||
|
# above this core and nothing would notice.
|
||||||
|
if not self._gate_pulled_commit(plugin_id, plugin_path, local_sha):
|
||||||
|
return False
|
||||||
|
|
||||||
self._install_dependencies(plugin_path)
|
self._install_dependencies(plugin_path)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,14 @@ it only in passing before reading the cache back.
|
|||||||
Requests submitted *without* a callback keep their payload: polling
|
Requests submitted *without* a callback keep their payload: polling
|
||||||
get_result() is then the only way to collect it, so releasing would break that
|
get_result() is then the only way to collect it, so releasing would break that
|
||||||
contract.
|
contract.
|
||||||
|
|
||||||
|
And the release must happen after EVERY callback, not after each one. Callers
|
||||||
|
that joined an in-flight fetch share a single FetchResult, so releasing per
|
||||||
|
delivery strips the payload out from under everyone still queued -- see
|
||||||
|
TestJoinersAllGetTheData.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
@@ -185,3 +191,90 @@ class TestCacheHitPath:
|
|||||||
cache_key="nfl_2026",
|
cache_key="nfl_2026",
|
||||||
)
|
)
|
||||||
assert service.get_result(req_id).data == PAYLOAD
|
assert service.get_result(req_id).data == PAYLOAD
|
||||||
|
|
||||||
|
|
||||||
|
class TestJoinersAllGetTheData:
|
||||||
|
"""Deduplicated callers share one FetchResult; releasing between them
|
||||||
|
empties it for the rest.
|
||||||
|
|
||||||
|
Not a corner case. A sport's recent, upcoming and live managers all ask for
|
||||||
|
the same season schedule, so the second and third are joiners on almost
|
||||||
|
every cycle. Releasing inside the delivery loop handed the payload to
|
||||||
|
whichever ran first and gave the others `result.data is None`.
|
||||||
|
|
||||||
|
The consequence was worse than a quiet degradation, because consumers do
|
||||||
|
`result.data.get('events')`: they raised AttributeError, the delivery loop
|
||||||
|
caught it, and the whole failure surfaced as a single
|
||||||
|
"Error in callback for request ..." line while that manager silently never
|
||||||
|
received its schedule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class _BlockingSession:
|
||||||
|
"""Holds the fetch open so a second submit lands while in flight."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.release = threading.Event()
|
||||||
|
self.started = threading.Event()
|
||||||
|
|
||||||
|
def get(self, *a, **k):
|
||||||
|
self.started.set()
|
||||||
|
self.release.wait(timeout=5)
|
||||||
|
return _resp()
|
||||||
|
|
||||||
|
def test_every_joiner_is_handed_the_payload(self, service):
|
||||||
|
session = self._BlockingSession()
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def record(name):
|
||||||
|
# Read it the way the sport managers do. `result.data['events']`
|
||||||
|
# would raise TypeError on None; `.get` raises AttributeError,
|
||||||
|
# which is the error actually seen in the field.
|
||||||
|
def cb(result):
|
||||||
|
seen[name] = result.data.get('events') if result.data else None
|
||||||
|
return cb
|
||||||
|
|
||||||
|
with patch.object(service, "session", session):
|
||||||
|
first = service.submit_fetch_request(
|
||||||
|
sport="nhl", year=2026, url="https://x/s", cache_key="nhl_2026",
|
||||||
|
callback=record("first"), max_retries=0)
|
||||||
|
assert session.started.wait(timeout=5)
|
||||||
|
|
||||||
|
joined = service.submit_fetch_request(
|
||||||
|
sport="nhl", year=2026, url="https://x/s", cache_key="nhl_2026",
|
||||||
|
callback=record("second"), max_retries=0)
|
||||||
|
# Without coalescing these are two independent fetches that each
|
||||||
|
# own their result, and the test proves nothing about sharing.
|
||||||
|
assert joined == first, "the joiner should share the in-flight id"
|
||||||
|
|
||||||
|
session.release.set()
|
||||||
|
_wait(service, first)
|
||||||
|
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while len(seen) < 2 and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
assert set(seen) == {"first", "second"}, f"both must be called, got {seen}"
|
||||||
|
for name, events in seen.items():
|
||||||
|
assert events is not None, (
|
||||||
|
f"{name!r} was handed a released payload: the result was "
|
||||||
|
f"emptied before every callback had been delivered")
|
||||||
|
assert len(events) == 50, f"{name!r} got {events!r}"
|
||||||
|
|
||||||
|
def test_the_payload_is_still_released_once_they_have_all_had_it(self, service):
|
||||||
|
"""The memory fix must survive the ordering fix."""
|
||||||
|
session = self._BlockingSession()
|
||||||
|
|
||||||
|
with patch.object(service, "session", session):
|
||||||
|
first = service.submit_fetch_request(
|
||||||
|
sport="nhl", year=2026, url="https://x/s", cache_key="nhl_2026",
|
||||||
|
callback=lambda r: None, max_retries=0)
|
||||||
|
assert session.started.wait(timeout=5)
|
||||||
|
service.submit_fetch_request(
|
||||||
|
sport="nhl", year=2026, url="https://x/s", cache_key="nhl_2026",
|
||||||
|
callback=lambda r: None, max_retries=0)
|
||||||
|
session.release.set()
|
||||||
|
_wait_for_release(service, first)
|
||||||
|
|
||||||
|
stored = service.get_result(first)
|
||||||
|
assert stored is not None and stored.data is None, (
|
||||||
|
"the payload must still be dropped once every callback has run")
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""The gate must read the core version from disk, not from its own import.
|
||||||
|
|
||||||
|
`from src import __version__` binds whatever the process loaded at start. The
|
||||||
|
web UI is a long-lived service of its own (ledmatrix-web.service), and updating
|
||||||
|
the core replaces files on disk without restarting it -- the update route says
|
||||||
|
so and asks the user to restart, but its prompt named only the *display*
|
||||||
|
service, so a user who followed it left the web process holding the old number.
|
||||||
|
|
||||||
|
The plugin store's gate lives in that web process, so being stale by exactly
|
||||||
|
one release is the case that bites: every plugin flooring on the release you
|
||||||
|
just installed is refused, with a message blaming a core version that is
|
||||||
|
already correct on disk.
|
||||||
|
|
||||||
|
Observed on hardware: after updating a rig to 3.3.0 and restarting only the
|
||||||
|
display service, all eight sports scoreboards were refused with
|
||||||
|
"supports LEDMatrix >=3.3.0, but this system is running 3.2.0" while
|
||||||
|
src/__init__.py on that machine read 3.3.0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system import compatibility
|
||||||
|
|
||||||
|
|
||||||
|
class TestCurrentCoreVersion:
|
||||||
|
def test_it_reads_the_file_rather_than_the_imported_value(self, monkeypatch, tmp_path):
|
||||||
|
# Simulate a process whose import predates the update: the module
|
||||||
|
# object says 3.2.0 while the file on disk says 3.3.0.
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, "__version__", "3.2.0")
|
||||||
|
fake = tmp_path / "__init__.py"
|
||||||
|
fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8")
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||||
|
assert compatibility.current_core_version() == "3.3.0"
|
||||||
|
|
||||||
|
def test_it_matches_the_real_file_by_default(self):
|
||||||
|
import src
|
||||||
|
assert compatibility.current_core_version() == src.__version__
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body", [
|
||||||
|
"__version__ = '3.4.1'\n",
|
||||||
|
'__version__="3.4.1"\n',
|
||||||
|
'"""doc"""\n\n__version__ = "3.4.1" # trailing comment\n',
|
||||||
|
])
|
||||||
|
def test_it_tolerates_the_ways_that_line_gets_written(self, monkeypatch, tmp_path, body):
|
||||||
|
fake = tmp_path / "__init__.py"
|
||||||
|
fake.write_text(body, encoding="utf-8")
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||||
|
assert compatibility.current_core_version() == "3.4.1"
|
||||||
|
|
||||||
|
def test_a_missing_file_falls_back_to_the_import(self, monkeypatch, tmp_path):
|
||||||
|
# Never worse than before: an unreadable file returns what the old
|
||||||
|
# code would have returned.
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, "__version__", "3.2.0")
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", tmp_path / "gone.py")
|
||||||
|
assert compatibility.current_core_version() == "3.2.0"
|
||||||
|
|
||||||
|
def test_a_file_without_the_line_falls_back(self, monkeypatch, tmp_path):
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, "__version__", "3.2.0")
|
||||||
|
fake = tmp_path / "__init__.py"
|
||||||
|
fake.write_text("# no version here\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||||
|
assert compatibility.current_core_version() == "3.2.0"
|
||||||
|
|
||||||
|
def test_it_never_raises(self, monkeypatch, tmp_path):
|
||||||
|
# This runs on the install path; an exception here would surface as a
|
||||||
|
# failed update rather than a version mismatch.
|
||||||
|
bad = tmp_path / "__init__.py"
|
||||||
|
bad.write_bytes(b"\xff\xfe\x00 not utf-8 \xff")
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", bad)
|
||||||
|
assert isinstance(compatibility.current_core_version(), str)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheBugItFixes:
|
||||||
|
def test_a_stale_import_no_longer_refuses_a_compatible_plugin(self, monkeypatch, tmp_path):
|
||||||
|
"""The exact hardware failure, as a test."""
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, "__version__", "3.2.0") # what the process holds
|
||||||
|
fake = tmp_path / "__init__.py"
|
||||||
|
fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8") # what is on disk
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||||
|
|
||||||
|
manifest = {"name": "Hockey Scoreboard", "min_ledmatrix_version": "3.3.0"}
|
||||||
|
|
||||||
|
stale_ok, _ = compatibility.check(manifest, src.__version__)
|
||||||
|
assert stale_ok is False, "precondition: the stale value is what refused it"
|
||||||
|
|
||||||
|
fresh_ok, reason = compatibility.check(
|
||||||
|
manifest, compatibility.current_core_version())
|
||||||
|
assert fresh_ok is True, f"the disk version must allow it, got: {reason}"
|
||||||
|
|
||||||
|
def test_it_still_refuses_when_the_core_really_is_too_old(self, monkeypatch, tmp_path):
|
||||||
|
# The gate must not become permissive: a genuinely old core still says no.
|
||||||
|
fake = tmp_path / "__init__.py"
|
||||||
|
fake.write_text('__version__ = "3.2.0"\n', encoding="utf-8")
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||||
|
ok, reason = compatibility.check(
|
||||||
|
{"name": "Hockey", "min_ledmatrix_version": "3.3.0"},
|
||||||
|
compatibility.current_core_version())
|
||||||
|
assert ok is False
|
||||||
|
assert "3.2.0" in (reason or "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCallSites:
|
||||||
|
@pytest.mark.parametrize("module", [
|
||||||
|
"src.plugin_system.store_manager",
|
||||||
|
"src.plugin_system.plugin_loader",
|
||||||
|
])
|
||||||
|
def test_no_gate_binds_the_version_at_import(self, module):
|
||||||
|
"""Catch a future call site reintroducing the stale read."""
|
||||||
|
import inspect
|
||||||
|
mod = importlib.import_module(module)
|
||||||
|
source = inspect.getsource(mod)
|
||||||
|
assert "from src import __version__ as core_version" not in source, (
|
||||||
|
f"{module} binds __version__ at import; use "
|
||||||
|
f"compatibility.current_core_version() so a long-lived process "
|
||||||
|
f"sees a core update.")
|
||||||
@@ -8,11 +8,27 @@ ensure_logo_directory, and the download_missing_logo function path
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, Mock, MagicMock
|
from unittest.mock import patch, Mock, MagicMock
|
||||||
|
|
||||||
from src.logo_downloader import LogoDownloader
|
from PIL import Image
|
||||||
|
from PIL.PngImagePlugin import PngInfo
|
||||||
|
|
||||||
|
from src.logo_downloader import (
|
||||||
|
PLACEHOLDER_BG,
|
||||||
|
PLACEHOLDER_MARKER,
|
||||||
|
PLACEHOLDER_RETRY_SECONDS,
|
||||||
|
PLACEHOLDER_SIZE,
|
||||||
|
LogoDownloader,
|
||||||
|
download_missing_logo,
|
||||||
|
is_placeholder_logo,
|
||||||
|
placeholder_age_seconds,
|
||||||
|
refresh_placeholder_timestamp,
|
||||||
|
should_attempt_download,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -127,3 +143,211 @@ class TestEnsureLogoDirectory:
|
|||||||
with patch("builtins.open", side_effect=mock_open):
|
with patch("builtins.open", side_effect=mock_open):
|
||||||
result = downloader.ensure_logo_directory(test_dir)
|
result = downloader.ensure_logo_directory(test_dir)
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Placeholder detection and retry
|
||||||
|
#
|
||||||
|
# A failed download used to be cached as a placeholder wearing the real logo's
|
||||||
|
# filename, and download_missing_logo returned early on "the file exists". One
|
||||||
|
# transient failure therefore pinned a team to a grey box permanently.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPlaceholderLogos:
|
||||||
|
def _placeholder(self, tmp_path, abbrev="COLL"):
|
||||||
|
downloader = LogoDownloader()
|
||||||
|
assert downloader.create_placeholder_logo(abbrev, str(tmp_path)) is True
|
||||||
|
return tmp_path / f"{abbrev}.png"
|
||||||
|
|
||||||
|
def test_generated_placeholder_is_recognised(self, tmp_path):
|
||||||
|
assert is_placeholder_logo(self._placeholder(tmp_path)) is True
|
||||||
|
|
||||||
|
def test_real_logo_is_not_a_placeholder(self, tmp_path):
|
||||||
|
real = tmp_path / "REAL.png"
|
||||||
|
Image.new("RGBA", (500, 500), (12, 34, 56, 255)).save(real)
|
||||||
|
assert is_placeholder_logo(real) is False
|
||||||
|
|
||||||
|
def test_legacy_unmarked_placeholder_is_recognised(self, tmp_path):
|
||||||
|
"""Placeholders written before the marker existed must still be caught.
|
||||||
|
|
||||||
|
They are already sitting on users' disks; if they were not recognised
|
||||||
|
those teams would stay grey boxes forever even after this fix.
|
||||||
|
"""
|
||||||
|
legacy = tmp_path / "LEGACY.png"
|
||||||
|
Image.new("RGBA", PLACEHOLDER_SIZE, PLACEHOLDER_BG).save(legacy)
|
||||||
|
assert is_placeholder_logo(legacy) is True
|
||||||
|
|
||||||
|
def test_same_size_but_different_colour_is_not_a_placeholder(self, tmp_path):
|
||||||
|
real = tmp_path / "SMALL.png"
|
||||||
|
Image.new("RGBA", PLACEHOLDER_SIZE, (10, 200, 10, 255)).save(real)
|
||||||
|
assert is_placeholder_logo(real) is False
|
||||||
|
|
||||||
|
def test_missing_file_is_not_a_placeholder(self, tmp_path):
|
||||||
|
assert is_placeholder_logo(tmp_path / "nope.png") is False
|
||||||
|
|
||||||
|
def test_existing_real_logo_short_circuits_without_downloading(self, tmp_path):
|
||||||
|
real = tmp_path / "REAL.png"
|
||||||
|
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
|
||||||
|
with patch.object(LogoDownloader, "download_logo") as download:
|
||||||
|
assert download_missing_logo(
|
||||||
|
"afl", "1", "REAL", real, logo_url="http://example/x.png") is True
|
||||||
|
download.assert_not_called()
|
||||||
|
|
||||||
|
def _age_placeholder(self, path, seconds):
|
||||||
|
"""Rewrite a placeholder's marker so it reads as `seconds` old."""
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - seconds))
|
||||||
|
with Image.open(path) as img:
|
||||||
|
img.copy().save(path, "PNG", pnginfo=metadata)
|
||||||
|
|
||||||
|
def test_stale_placeholder_triggers_a_retry(self, tmp_path):
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
self._age_placeholder(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||||
|
assert placeholder_age_seconds(path) > PLACEHOLDER_RETRY_SECONDS
|
||||||
|
|
||||||
|
with patch.object(LogoDownloader, "download_logo", return_value=True) as download:
|
||||||
|
assert download_missing_logo(
|
||||||
|
"afl", "1", "COLL", path,
|
||||||
|
logo_url="http://example/coll.png") is True
|
||||||
|
download.assert_called_once()
|
||||||
|
|
||||||
|
def test_placeholder_age_survives_an_mtime_touch(self, tmp_path):
|
||||||
|
"""The age comes from the stamp, not the filesystem.
|
||||||
|
|
||||||
|
Anything that rewrites file times -- a backup restore, an rsync, a
|
||||||
|
permissions fix script -- would otherwise reset the retry clock.
|
||||||
|
"""
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
self._age_placeholder(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||||
|
now = time.time()
|
||||||
|
os.utime(path, (now, now))
|
||||||
|
assert placeholder_age_seconds(path) > PLACEHOLDER_RETRY_SECONDS
|
||||||
|
|
||||||
|
def test_fresh_placeholder_does_not_retry(self, tmp_path):
|
||||||
|
"""Rate limiting: a placeholder written seconds ago must not re-download.
|
||||||
|
|
||||||
|
Without this the fix would trade a permanent grey box for an ESPN
|
||||||
|
request on every frame.
|
||||||
|
"""
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
with patch.object(LogoDownloader, "download_logo") as download:
|
||||||
|
assert download_missing_logo(
|
||||||
|
"afl", "1", "COLL", path,
|
||||||
|
logo_url="http://example/coll.png") is True
|
||||||
|
download.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadEligibility:
|
||||||
|
"""One rule, shared by every download site.
|
||||||
|
|
||||||
|
The two bulk loops and the single-logo path each had their own idea of what
|
||||||
|
counted as "already have it", which is how one of them ended up retrying
|
||||||
|
fresh placeholders and the other skipping stale ones forever.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _placeholder(self, tmp_path, abbrev="COLL"):
|
||||||
|
assert LogoDownloader().create_placeholder_logo(abbrev, str(tmp_path))
|
||||||
|
return tmp_path / f"{abbrev}.png"
|
||||||
|
|
||||||
|
def _age(self, path, seconds):
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - seconds))
|
||||||
|
with Image.open(path) as img:
|
||||||
|
img.copy().save(path, "PNG", pnginfo=metadata)
|
||||||
|
|
||||||
|
def test_missing_file_is_eligible(self, tmp_path):
|
||||||
|
assert should_attempt_download(tmp_path / "nope.png") is True
|
||||||
|
|
||||||
|
def test_real_logo_is_not_eligible(self, tmp_path):
|
||||||
|
real = tmp_path / "REAL.png"
|
||||||
|
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
|
||||||
|
assert should_attempt_download(real) is False
|
||||||
|
|
||||||
|
def test_force_download_beats_a_real_logo(self, tmp_path):
|
||||||
|
real = tmp_path / "REAL.png"
|
||||||
|
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
|
||||||
|
assert should_attempt_download(real, force_download=True) is True
|
||||||
|
|
||||||
|
def test_fresh_placeholder_is_not_eligible(self, tmp_path):
|
||||||
|
assert should_attempt_download(self._placeholder(tmp_path)) is False
|
||||||
|
|
||||||
|
def test_stale_placeholder_is_eligible(self, tmp_path):
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
self._age(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||||
|
assert should_attempt_download(path) is True
|
||||||
|
|
||||||
|
def test_league_bulk_loop_skips_a_fresh_placeholder(self, tmp_path):
|
||||||
|
"""A bulk pass honours the same back-off as everything else."""
|
||||||
|
self._placeholder(tmp_path, "AAA")
|
||||||
|
downloader = LogoDownloader()
|
||||||
|
teams = [{"abbreviation": "AAA", "display_name": "A", "logo_url": "http://x/a.png"}]
|
||||||
|
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
|
||||||
|
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
|
||||||
|
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
|
||||||
|
with patch.object(LogoDownloader, "download_logo") as download:
|
||||||
|
downloader.download_missing_logos_for_league("nfl")
|
||||||
|
download.assert_not_called()
|
||||||
|
|
||||||
|
def test_league_bulk_loop_retries_a_stale_placeholder(self, tmp_path):
|
||||||
|
path = self._placeholder(tmp_path, "AAA")
|
||||||
|
self._age(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||||
|
downloader = LogoDownloader()
|
||||||
|
teams = [{"abbreviation": "AAA", "display_name": "A", "logo_url": "http://x/a.png"}]
|
||||||
|
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
|
||||||
|
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
|
||||||
|
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
|
||||||
|
with patch.object(LogoDownloader, "download_logo", return_value=True) as download:
|
||||||
|
downloader.download_missing_logos_for_league("nfl")
|
||||||
|
download.assert_called_once()
|
||||||
|
|
||||||
|
def test_ncaa_bulk_loop_retries_a_stale_placeholder(self, tmp_path):
|
||||||
|
"""This loop skipped placeholders forever; it now shares the rule."""
|
||||||
|
path = self._placeholder(tmp_path, "AAA")
|
||||||
|
self._age(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||||
|
downloader = LogoDownloader()
|
||||||
|
teams = [{"abbreviation": "AAA", "display_name": "A",
|
||||||
|
"logo_url": "http://x/a.png", "category": "FBS",
|
||||||
|
"conference": "SEC"}]
|
||||||
|
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
|
||||||
|
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
|
||||||
|
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
|
||||||
|
with patch.object(LogoDownloader, "download_logo", return_value=True) as download:
|
||||||
|
downloader.download_all_ncaa_football_logos()
|
||||||
|
download.assert_called_once()
|
||||||
|
|
||||||
|
def test_ncaa_bulk_loop_skips_a_fresh_placeholder(self, tmp_path):
|
||||||
|
self._placeholder(tmp_path, "AAA")
|
||||||
|
downloader = LogoDownloader()
|
||||||
|
teams = [{"abbreviation": "AAA", "display_name": "A",
|
||||||
|
"logo_url": "http://x/a.png", "category": "FBS",
|
||||||
|
"conference": "SEC"}]
|
||||||
|
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
|
||||||
|
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
|
||||||
|
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
|
||||||
|
with patch.object(LogoDownloader, "download_logo") as download:
|
||||||
|
downloader.download_all_ncaa_football_logos()
|
||||||
|
download.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefreshPlaceholderTimestamp:
|
||||||
|
def test_restarts_the_back_off(self, tmp_path):
|
||||||
|
assert LogoDownloader().create_placeholder_logo("COLL", str(tmp_path))
|
||||||
|
path = tmp_path / "COLL.png"
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - (PLACEHOLDER_RETRY_SECONDS + 60)))
|
||||||
|
with Image.open(path) as img:
|
||||||
|
img.copy().save(path, "PNG", pnginfo=metadata)
|
||||||
|
assert should_attempt_download(path) is True
|
||||||
|
|
||||||
|
assert refresh_placeholder_timestamp(path) is True
|
||||||
|
assert should_attempt_download(path) is False
|
||||||
|
|
||||||
|
def test_refuses_to_touch_a_real_logo(self, tmp_path):
|
||||||
|
real = tmp_path / "REAL.png"
|
||||||
|
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
|
||||||
|
before = real.read_bytes()
|
||||||
|
assert refresh_placeholder_timestamp(real) is False
|
||||||
|
assert real.read_bytes() == before
|
||||||
|
|
||||||
|
def test_missing_file_is_not_an_error(self, tmp_path):
|
||||||
|
assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False
|
||||||
|
|||||||
@@ -421,3 +421,76 @@ class TestSessionConfiguration:
|
|||||||
def test_user_agent_and_accept_headers(self, helper):
|
def test_user_agent_and_accept_headers(self, helper):
|
||||||
assert helper.session.headers["User-Agent"] == "LEDMatrix-Common/1.0"
|
assert helper.session.headers["User-Agent"] == "LEDMatrix-Common/1.0"
|
||||||
assert helper.session.headers["Accept"] == "image/*"
|
assert helper.session.headers["Accept"] == "image/*"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStalePlaceholderHandling:
|
||||||
|
"""load_logo_with_download must not be fooled by a cached placeholder.
|
||||||
|
|
||||||
|
A placeholder wears the real logo's filename, so both the file cache and
|
||||||
|
the in-memory cache can hold one and look like a hit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _placeholder(self, tmp_path, abbrev="COLL"):
|
||||||
|
from src.logo_downloader import LogoDownloader
|
||||||
|
assert LogoDownloader().create_placeholder_logo(abbrev, str(tmp_path))
|
||||||
|
return tmp_path / f"{abbrev}.png"
|
||||||
|
|
||||||
|
def _make_stale(self, path):
|
||||||
|
import time
|
||||||
|
from PIL.PngImagePlugin import PngInfo
|
||||||
|
from src.logo_downloader import PLACEHOLDER_MARKER, PLACEHOLDER_RETRY_SECONDS
|
||||||
|
metadata = PngInfo()
|
||||||
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - (PLACEHOLDER_RETRY_SECONDS + 60)))
|
||||||
|
with Image.open(path) as img:
|
||||||
|
img.copy().save(path, "PNG", pnginfo=metadata)
|
||||||
|
|
||||||
|
def test_fresh_placeholder_is_served_without_a_download(self, helper, tmp_path):
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
with patch.object(LogoHelper, "_download_logo") as download:
|
||||||
|
assert helper.load_logo_with_download("COLL", path, "http://x/c.png") is not None
|
||||||
|
download.assert_not_called()
|
||||||
|
|
||||||
|
def test_stale_placeholder_triggers_a_download(self, helper, tmp_path):
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
self._make_stale(path)
|
||||||
|
with patch.object(LogoHelper, "_download_logo") as download:
|
||||||
|
helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||||
|
download.assert_called_once()
|
||||||
|
|
||||||
|
def test_replacement_logo_is_not_masked_by_the_cached_placeholder(self, helper, tmp_path):
|
||||||
|
"""The bug this guards: load_logo answers from cache before the disk.
|
||||||
|
|
||||||
|
Without invalidation the freshly downloaded logo would not appear until
|
||||||
|
the process restarted.
|
||||||
|
"""
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
first = helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||||
|
assert first is not None
|
||||||
|
|
||||||
|
self._make_stale(path)
|
||||||
|
|
||||||
|
def fake_download(_self, _url, file_path):
|
||||||
|
Image.new("RGB", (500, 500), (7, 8, 9)).save(file_path, format="PNG")
|
||||||
|
|
||||||
|
with patch.object(LogoHelper, "_download_logo", fake_download):
|
||||||
|
second = helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||||
|
|
||||||
|
assert second is not None
|
||||||
|
from src.logo_downloader import is_placeholder_logo
|
||||||
|
assert is_placeholder_logo(path) is False
|
||||||
|
assert second.getpixel((0, 0))[:3] == (7, 8, 9)
|
||||||
|
|
||||||
|
def test_failed_retry_restarts_the_back_off(self, helper, tmp_path):
|
||||||
|
"""Otherwise a stale placeholder means a download attempt per call."""
|
||||||
|
from src.logo_downloader import should_attempt_download
|
||||||
|
path = self._placeholder(tmp_path)
|
||||||
|
self._make_stale(path)
|
||||||
|
assert should_attempt_download(path) is True
|
||||||
|
|
||||||
|
def boom(_self, _url, _file_path):
|
||||||
|
raise OSError("network down")
|
||||||
|
|
||||||
|
with patch.object(LogoHelper, "_download_logo", boom):
|
||||||
|
helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||||
|
|
||||||
|
assert should_attempt_download(path) is False
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ The rules being pinned here, in priority order:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
@@ -146,9 +147,18 @@ def store(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
class TestInstallGate:
|
class TestInstallGate:
|
||||||
"""`install_plugin` is the chokepoint: `_reinstall_with_rollback` calls it,
|
"""`install_plugin` is the chokepoint for every route that re-downloads:
|
||||||
so gating there covers updates too, and a refused update restores the
|
`_reinstall_with_rollback` calls it, so a refused update restores the
|
||||||
version the user already had."""
|
version the user already had.
|
||||||
|
|
||||||
|
It is not the *only* route in, and saying so here once is cheaper than
|
||||||
|
rediscovering it. `update_plugin` also has a git branch that pulls in
|
||||||
|
place and never re-downloads; that one is gated separately by
|
||||||
|
`_gate_pulled_commit` and pinned in `TestGitPullGate` below. A third
|
||||||
|
route, `install_from_url` (sideloading from a URL), is gated in that
|
||||||
|
function and pinned in `TestSideloadGate`. All three refuse on the same
|
||||||
|
rule.
|
||||||
|
"""
|
||||||
|
|
||||||
def _install_with_manifest(self, store, manifest, core_version, monkeypatch):
|
def _install_with_manifest(self, store, manifest, core_version, monkeypatch):
|
||||||
mgr, plugins_dir = store
|
mgr, plugins_dir = store
|
||||||
@@ -208,6 +218,291 @@ class TestInstallGate:
|
|||||||
assert (path / "manifest.json").exists()
|
assert (path / "manifest.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSideloadGate:
|
||||||
|
"""`install_from_url` never looked at the core version.
|
||||||
|
|
||||||
|
Sideloading is an explicit act rather than an automatic store update, so
|
||||||
|
the argument for gating it is different: not "the user did not choose
|
||||||
|
this", but that the floor states the plugin *cannot run here*. Letting it
|
||||||
|
through produces the same silent PluginState.ERROR at load that the store
|
||||||
|
gate exists to prevent, and the user who typed the URL is no better placed
|
||||||
|
to diagnose it than one who pressed Update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _sideload(self, store, manifest, core_version, monkeypatch):
|
||||||
|
mgr, plugins_dir = store
|
||||||
|
plugin_id = manifest["id"]
|
||||||
|
|
||||||
|
def fake_clone(repo_url, target_path, branches=None):
|
||||||
|
target_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
(target_path / "manifest.json").write_text(
|
||||||
|
json.dumps(manifest), encoding="utf-8")
|
||||||
|
(target_path / "manager.py").write_text(
|
||||||
|
"class P: pass\n", encoding="utf-8")
|
||||||
|
return "main"
|
||||||
|
|
||||||
|
monkeypatch.setattr(mgr, "_install_via_git", fake_clone)
|
||||||
|
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, "__version__", core_version)
|
||||||
|
return mgr.install_from_url("https://example.invalid/plugin"), \
|
||||||
|
plugins_dir / plugin_id
|
||||||
|
|
||||||
|
def test_refuses_a_plugin_that_needs_a_newer_core(self, store, monkeypatch):
|
||||||
|
manifest = {
|
||||||
|
"id": "sideload-newer", "name": "Sideload", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
||||||
|
}
|
||||||
|
result, path = self._sideload(store, manifest, "3.2.0", monkeypatch)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "9.9.9" in result["error"], result["error"]
|
||||||
|
assert not path.exists(), (
|
||||||
|
"a refused sideload must not leave the plugin installed")
|
||||||
|
|
||||||
|
def test_allows_a_compatible_plugin(self, store, monkeypatch):
|
||||||
|
"""The guard against over-refusing: a gate that blocks everything
|
||||||
|
passes the test above and breaks sideloading entirely."""
|
||||||
|
manifest = {
|
||||||
|
"id": "sideload-fine", "name": "Sideload", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "3.0.0",
|
||||||
|
}
|
||||||
|
result, path = self._sideload(store, manifest, "3.2.0", monkeypatch)
|
||||||
|
|
||||||
|
assert result["success"] is True, result.get("error")
|
||||||
|
assert (path / "manifest.json").exists()
|
||||||
|
|
||||||
|
def test_untrustworthy_core_does_not_block_a_2_0_0_floor(
|
||||||
|
self, store, monkeypatch):
|
||||||
|
"""Same rule as the other two routes: a v3.1.0 release reports 1.0.0,
|
||||||
|
and nearly every published manifest floors at 2.0.0."""
|
||||||
|
manifest = {
|
||||||
|
"id": "sideload-floored", "name": "Sideload", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "versions": [{"ledmatrix_min": "2.0.0"}],
|
||||||
|
}
|
||||||
|
result, _ = self._sideload(store, manifest, "1.0.0", monkeypatch)
|
||||||
|
|
||||||
|
assert result["success"] is True, result.get("error")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# The git-pull update path — the one route that does not re-download
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _git_available() -> bool:
|
||||||
|
# OSError, not just a non-zero exit: with no git on PATH subprocess raises
|
||||||
|
# FileNotFoundError, and this runs at import time -- before skipif can act,
|
||||||
|
# so the whole module would error out instead of skipping.
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
['git', '--version'], capture_output=True).returncode == 0
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_HAS_GIT = _git_available()
|
||||||
|
|
||||||
|
|
||||||
|
def _git(*args, cwd):
|
||||||
|
return subprocess.run(['git', *args], cwd=str(cwd),
|
||||||
|
capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_GIT, reason='git not available')
|
||||||
|
class TestGitPullGate:
|
||||||
|
"""A plugin installed as a git checkout updates by pulling in place, so it
|
||||||
|
never passes through `install_plugin` and was never gated.
|
||||||
|
|
||||||
|
Real git repositories rather than mocks, because the claim under test is
|
||||||
|
that `git reset --hard` puts the checkout back — a mock of git would only
|
||||||
|
prove the call was made, which is the easy half.
|
||||||
|
|
||||||
|
Only the handful of registry entries with no `plugin_path` reach this path
|
||||||
|
in practice; monorepo plugins install as archives and update through
|
||||||
|
`_reinstall_with_rollback`. It is gated anyway because the sunset rule in
|
||||||
|
`docs/plugin-development/08-shared-sports-code.md` states the core enforces
|
||||||
|
the floor "at install/update time", and a precondition that is documented
|
||||||
|
but not true is worse than one that is merely missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def checkout(self, tmp_path, monkeypatch):
|
||||||
|
"""An origin repo holding a plugin, and a clone of it installed as
|
||||||
|
`plugin-repos/gitplug`, with the store pointed at it."""
|
||||||
|
from src.plugin_system.store_manager import PluginStoreManager
|
||||||
|
|
||||||
|
origin = tmp_path / 'origin'
|
||||||
|
origin.mkdir()
|
||||||
|
_git('init', '--initial-branch=main', '--bare', cwd=origin)
|
||||||
|
|
||||||
|
seed = tmp_path / 'seed'
|
||||||
|
_git('clone', str(origin), str(seed), cwd=tmp_path)
|
||||||
|
_git('config', 'user.email', 'test@example.com', cwd=seed)
|
||||||
|
_git('config', 'user.name', 'Test', cwd=seed)
|
||||||
|
(seed / 'manifest.json').write_text(json.dumps({
|
||||||
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "2.0.0",
|
||||||
|
}), encoding='utf-8')
|
||||||
|
(seed / 'manager.py').write_text("class P: pass\n", encoding='utf-8')
|
||||||
|
_git('add', '-A', cwd=seed)
|
||||||
|
_git('commit', '-m', 'initial', cwd=seed)
|
||||||
|
_git('push', '-u', 'origin', 'main', cwd=seed)
|
||||||
|
|
||||||
|
plugins_dir = tmp_path / 'plugin-repos'
|
||||||
|
plugins_dir.mkdir()
|
||||||
|
work = plugins_dir / 'gitplug'
|
||||||
|
_git('clone', str(origin), str(work), cwd=tmp_path)
|
||||||
|
_git('config', 'user.email', 'test@example.com', cwd=work)
|
||||||
|
_git('config', 'user.name', 'Test', cwd=work)
|
||||||
|
|
||||||
|
mgr = PluginStoreManager(plugins_dir=str(plugins_dir))
|
||||||
|
mgr.logger = MagicMock()
|
||||||
|
# No registry entry, so update_plugin takes the plain-pull branch
|
||||||
|
# rather than the remote-mismatch or already-current shortcuts.
|
||||||
|
monkeypatch.setattr(mgr, 'fetch_registry', lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(mgr, 'get_plugin_info', lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(mgr, '_install_dependencies', lambda *a, **k: True)
|
||||||
|
return mgr, seed, work
|
||||||
|
|
||||||
|
def _push(self, seed, manifest_text):
|
||||||
|
(seed / 'manifest.json').write_text(manifest_text, encoding='utf-8')
|
||||||
|
_git('add', '-A', cwd=seed)
|
||||||
|
_git('commit', '-m', 'update', cwd=seed)
|
||||||
|
_git('push', 'origin', 'main', cwd=seed)
|
||||||
|
|
||||||
|
def _head(self, work):
|
||||||
|
return _git('rev-parse', 'HEAD', cwd=work).stdout.strip()
|
||||||
|
|
||||||
|
def test_refuses_a_pulled_commit_that_needs_a_newer_core(
|
||||||
|
self, checkout, monkeypatch):
|
||||||
|
mgr, seed, work = checkout
|
||||||
|
before = self._head(work)
|
||||||
|
self._push(seed, json.dumps({
|
||||||
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
||||||
|
}))
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
||||||
|
assert mgr.update_plugin('gitplug') is False
|
||||||
|
|
||||||
|
assert self._head(work) == before, (
|
||||||
|
"a refused update must leave the checkout on the commit it was "
|
||||||
|
"already running, not on the one it cannot load")
|
||||||
|
floor = json.loads((work / 'manifest.json').read_text())
|
||||||
|
assert floor['min_ledmatrix_version'] == '2.0.0', (
|
||||||
|
"the reset must restore the working tree, not just the ref")
|
||||||
|
|
||||||
|
def test_allows_a_pulled_commit_the_core_can_run(
|
||||||
|
self, checkout, monkeypatch):
|
||||||
|
"""The guard against over-refusing. A gate that refuses everything
|
||||||
|
passes the test above and breaks every update."""
|
||||||
|
mgr, seed, work = checkout
|
||||||
|
before = self._head(work)
|
||||||
|
self._push(seed, json.dumps({
|
||||||
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "3.0.0",
|
||||||
|
}))
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
||||||
|
assert mgr.update_plugin('gitplug') is True
|
||||||
|
assert self._head(work) != before
|
||||||
|
|
||||||
|
def test_an_unreadable_manifest_after_pull_is_not_a_refusal(
|
||||||
|
self, checkout, monkeypatch):
|
||||||
|
"""Rule 1 of this file — refuse only on evidence. A manifest that
|
||||||
|
will not parse declares no floor, so it is not evidence of anything."""
|
||||||
|
mgr, seed, work = checkout
|
||||||
|
self._push(seed, '{ this is not json')
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
||||||
|
assert mgr.update_plugin('gitplug') is True
|
||||||
|
|
||||||
|
def test_an_untrustworthy_core_does_not_block_a_2_0_0_floor_on_pull(
|
||||||
|
self, checkout, monkeypatch):
|
||||||
|
"""The same regression guard as
|
||||||
|
`test_untrustworthy_core_does_not_block_installs`, because this path
|
||||||
|
now shares that rule: a v3.1.0 release reports 1.0.0, and nearly every
|
||||||
|
published manifest floors at 2.0.0."""
|
||||||
|
mgr, seed, work = checkout
|
||||||
|
before = self._head(work)
|
||||||
|
self._push(seed, json.dumps({
|
||||||
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "2.0.0",
|
||||||
|
"description": "changed",
|
||||||
|
}))
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, '__version__', '1.0.0')
|
||||||
|
assert mgr.update_plugin('gitplug') is True
|
||||||
|
assert self._head(work) != before
|
||||||
|
|
||||||
|
def test_a_failed_stash_stops_the_update_before_pulling(
|
||||||
|
self, checkout, monkeypatch):
|
||||||
|
"""Uncommitted work is not collateral for the gate.
|
||||||
|
|
||||||
|
The gate's only rollback is `git reset --hard`, which discards
|
||||||
|
uncommitted tracked edits. update_plugin stashes them first -- but when
|
||||||
|
that stash fails it used to pull anyway, and a pull touching different
|
||||||
|
files succeeds on a dirty tree. Refuse, refuse the rollback's rollback,
|
||||||
|
and the user's edits go with it.
|
||||||
|
"""
|
||||||
|
mgr, seed, work = checkout
|
||||||
|
before = self._head(work)
|
||||||
|
(work / 'manager.py').write_text(
|
||||||
|
"class P:\n MINE = 'do not lose this'\n", encoding='utf-8')
|
||||||
|
self._push(seed, json.dumps({
|
||||||
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
||||||
|
}))
|
||||||
|
|
||||||
|
real_run = subprocess.run
|
||||||
|
|
||||||
|
def fail_stash(cmd, *a, **k):
|
||||||
|
if isinstance(cmd, list) and 'stash' in cmd:
|
||||||
|
return subprocess.CompletedProcess(cmd, 1, '', 'stash boom')
|
||||||
|
return real_run(cmd, *a, **k)
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
||||||
|
monkeypatch.setattr(
|
||||||
|
'src.plugin_system.store_manager.subprocess.run', fail_stash)
|
||||||
|
assert mgr.update_plugin('gitplug') is False
|
||||||
|
|
||||||
|
assert self._head(work) == before, "must not pull what it cannot undo"
|
||||||
|
assert 'do not lose this' in (work / 'manager.py').read_text(), (
|
||||||
|
"the local edit the stash failed to save must still be there")
|
||||||
|
|
||||||
|
def test_rollback_reports_the_recovery_command_when_git_fails(
|
||||||
|
self, checkout, monkeypatch):
|
||||||
|
"""If the reset itself fails the user is left on a version that cannot
|
||||||
|
load, so the log line has to carry the command that fixes it —
|
||||||
|
it is the only thing standing between them and a manual reinstall."""
|
||||||
|
mgr, seed, work = checkout
|
||||||
|
self._push(seed, json.dumps({
|
||||||
|
"id": "gitplug", "name": "Git Plug", "class_name": "P",
|
||||||
|
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
||||||
|
}))
|
||||||
|
|
||||||
|
real_run = subprocess.run
|
||||||
|
|
||||||
|
def fail_reset(cmd, *a, **k):
|
||||||
|
if isinstance(cmd, list) and 'reset' in cmd:
|
||||||
|
return subprocess.CompletedProcess(cmd, 1, '', 'reset boom')
|
||||||
|
return real_run(cmd, *a, **k)
|
||||||
|
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, '__version__', '3.2.0')
|
||||||
|
monkeypatch.setattr(
|
||||||
|
'src.plugin_system.store_manager.subprocess.run', fail_reset)
|
||||||
|
assert mgr.update_plugin('gitplug') is False
|
||||||
|
|
||||||
|
logged = ' '.join(str(c) for c in mgr.logger.error.call_args_list)
|
||||||
|
assert 'reset --hard' in logged, logged
|
||||||
|
|
||||||
|
|
||||||
class TestLoaderAndStoreAgree:
|
class TestLoaderAndStoreAgree:
|
||||||
"""Both read the same manifests; a disagreement means one of them is
|
"""Both read the same manifests; a disagreement means one of them is
|
||||||
lying to the user."""
|
lying to the user."""
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""The card helpers the eight scoreboards now share.
|
||||||
|
|
||||||
|
These bodies lived in eight byte-identical copies. Moving them here means one
|
||||||
|
fix reaches every scoreboard — and that a mistake does too, which is what this
|
||||||
|
file guards. Each case below is one the plugins' own code already handled; the
|
||||||
|
point is that it keeps handling it.
|
||||||
|
|
||||||
|
The functions take ``config``/``logger``/``fonts`` as arguments rather than
|
||||||
|
reading them off an instance, so a plugin keeps its method and delegates the
|
||||||
|
body. That is what let all eight adopt this with byte-identical renders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.common import sports_card as C
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def log():
|
||||||
|
return logging.getLogger("test_sports_card")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsLookup:
|
||||||
|
def test_reads_the_scroll_card_block(self):
|
||||||
|
cfg = {"scroll_card": {"vs_text": "@"}}
|
||||||
|
assert C.scroll_card_option(cfg, "vs_text", "VS") == "@"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cfg", [None, {}, {"scroll_card": None},
|
||||||
|
{"scroll_card": {"vs_text": None}}])
|
||||||
|
def test_missing_or_null_falls_back(self, cfg):
|
||||||
|
"""A null in config means "unset", not "empty string"."""
|
||||||
|
assert C.scroll_card_option(cfg, "vs_text", "VS") == "VS"
|
||||||
|
|
||||||
|
def test_upcoming_center_rejects_unknown_modes(self):
|
||||||
|
for bad in ("sideways", "", None, 7):
|
||||||
|
assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": bad}}) == "vs"
|
||||||
|
assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": "DATE_TIME"}}) == "date_time"
|
||||||
|
|
||||||
|
|
||||||
|
class TestColour:
|
||||||
|
def test_rgb_list_and_hex_both_work(self):
|
||||||
|
assert C.element_color({"customization": {"score_text": {"text_color": [1, 2, 3]}}},
|
||||||
|
"score_text") == (1, 2, 3)
|
||||||
|
assert C.element_color({"customization": {"score_text": {"text_color": "#ff8000"}}},
|
||||||
|
"score_text") == (255, 128, 0)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["nope", "#fff", [1, 2], None, ["a", "b", "c"]])
|
||||||
|
def test_unusable_colour_falls_back(self, value):
|
||||||
|
cfg = {"customization": {"score_text": {"text_color": value}}}
|
||||||
|
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (9, 9, 9)
|
||||||
|
|
||||||
|
def test_coerce_rgb_clamps_rather_than_rejecting(self):
|
||||||
|
assert C.coerce_rgb([300, -5, 20], (0, 0, 0)) == (255, 0, 20)
|
||||||
|
|
||||||
|
def test_coerce_rgb_refuses_a_three_character_string(self):
|
||||||
|
""""123" would otherwise iterate into three digits and yield a colour."""
|
||||||
|
assert C.coerce_rgb("123", (7, 7, 7)) == (7, 7, 7)
|
||||||
|
|
||||||
|
def test_font_colour_is_resolved_by_identity(self):
|
||||||
|
a, b = object(), object()
|
||||||
|
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
|
||||||
|
assert C.font_color(cfg, {"score": a, "team": b}, a) == (4, 5, 6)
|
||||||
|
|
||||||
|
def test_a_shared_face_gives_up_rather_than_guessing(self):
|
||||||
|
"""One object used for two elements has no single right colour."""
|
||||||
|
shared = object()
|
||||||
|
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
|
||||||
|
assert C.font_color(cfg, {"score": shared, "team": shared}, shared) == (255, 255, 255)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFavourites:
|
||||||
|
GAME = {"home_abbr": "TB", "away_abbr": "NO", "home_score": "21", "away_score": "17"}
|
||||||
|
|
||||||
|
def test_win_loss_and_tie(self):
|
||||||
|
cfg = {"favorite_teams": ["TB"]}
|
||||||
|
assert C.favorite_result(cfg, self.GAME) == "win"
|
||||||
|
assert C.favorite_result({"favorite_teams": ["NO"]}, self.GAME) == "loss"
|
||||||
|
tied = dict(self.GAME, home_score="3", away_score="3")
|
||||||
|
assert C.favorite_result(cfg, tied) == "tie"
|
||||||
|
|
||||||
|
def test_no_verdict_without_exactly_one_favourite_side(self):
|
||||||
|
assert C.favorite_result({}, self.GAME) is None
|
||||||
|
assert C.favorite_result({"favorite_teams": ["TB", "NO"]}, self.GAME) is None
|
||||||
|
assert C.favorite_result({"favorite_teams": ["SEA"]}, self.GAME) is None
|
||||||
|
|
||||||
|
def test_unusable_scores_give_no_verdict(self):
|
||||||
|
bad = dict(self.GAME, home_score="x")
|
||||||
|
assert C.favorite_result({"favorite_teams": ["TB"]}, bad) is None
|
||||||
|
|
||||||
|
def test_nested_payload_shape_is_read_too(self):
|
||||||
|
game = {"home_team": {"abbrev": "TB", "score": 9},
|
||||||
|
"away_team": {"abbrev": "NO", "score": 2}}
|
||||||
|
assert C.side_score(game, "home") == 9
|
||||||
|
assert C.side_is_favorite(game, "home", {"TB"}) is True
|
||||||
|
|
||||||
|
def test_matches_on_id_where_abbreviations_collide(self):
|
||||||
|
"""NRL keys favourites by ESPN id; abbreviations are not unique there."""
|
||||||
|
game = {"home_abbr": "SYD", "home_id": "4321"}
|
||||||
|
assert C.side_is_favorite(game, "home", {"4321"}) is True
|
||||||
|
|
||||||
|
def test_game_and_config_favourites_are_both_used(self):
|
||||||
|
"""Games carry resolved dynamic groups; config catches later edits."""
|
||||||
|
game = dict(self.GAME, favorite_teams=["NO"], league="nfl")
|
||||||
|
assert set(C.favorite_teams_for({"nfl": {"favorite_teams": ["TB"]}}, game)) == {"NO", "TB"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDateAndTime:
|
||||||
|
def test_date_formats(self, log):
|
||||||
|
for fmt, want in [("abbrev", "Sep 5"), ("numeric", "9/5"),
|
||||||
|
("day_first", "5 Sep"), ("numeric_day_first", "5/9")]:
|
||||||
|
cfg = {"scroll_card": {"date_format": fmt}}
|
||||||
|
assert C.format_game_date(cfg, log, "9/5") == want
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["", "garbage", "13/40", "no/slash/here"])
|
||||||
|
def test_unparseable_dates_pass_through(self, log, raw):
|
||||||
|
assert C.format_game_date({}, log, raw) == raw.strip()
|
||||||
|
|
||||||
|
def test_24h_conversion(self):
|
||||||
|
cfg = {"scroll_card": {"time_format": "24h"}}
|
||||||
|
assert C.format_game_time(cfg, "7:30 PM") == "19:30"
|
||||||
|
assert C.format_game_time(cfg, "12:00 AM") == "00:00"
|
||||||
|
assert C.format_game_time(cfg, "12:15 PM") == "12:15"
|
||||||
|
|
||||||
|
def test_12h_is_left_alone_and_junk_survives(self):
|
||||||
|
assert C.format_game_time({}, "7:30 PM") == "7:30 PM"
|
||||||
|
assert C.format_game_time({"scroll_card": {"time_format": "24h"}}, "soon") == "soon"
|
||||||
|
|
||||||
|
def test_a_bad_timezone_falls_back_to_utc(self, log):
|
||||||
|
"""A typo in config should not blank the card."""
|
||||||
|
from datetime import timezone
|
||||||
|
assert C.card_tzinfo({"timezone": "Not/AZone"}, log) is timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
class TestFontSizing:
|
||||||
|
def test_snaps_to_the_faces_pixel_grid(self):
|
||||||
|
assert C.crisp_size("4x6-font.ttf", 6) == 7 # 7px grid
|
||||||
|
assert C.crisp_size("PressStart2P-Regular.ttf", 10) == 8
|
||||||
|
assert C.crisp_size("PressStart2P-Regular.ttf", 13) == 16
|
||||||
|
|
||||||
|
def test_an_unknown_face_is_never_second_guessed(self):
|
||||||
|
assert C.crisp_size("SomeUserFont.ttf", 11) == 11
|
||||||
|
|
||||||
|
def test_aliases_resolve_before_the_grid_lookup(self):
|
||||||
|
assert C.crisp_size("four_by_six", 6) == C.crisp_size("4x6-font.ttf", 6)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("desired", [0, -3, None])
|
||||||
|
def test_unusable_sizes_pass_through_without_raising(self, desired):
|
||||||
|
"""None reached this in the field; football's variant raised TypeError."""
|
||||||
|
assert C.crisp_size("4x6-font.ttf", desired) == desired
|
||||||
|
|
||||||
|
def test_schema_cache_is_keyed_per_schema_not_globally(self, tmp_path):
|
||||||
|
"""Two plugins declaring different defaults must not share an answer."""
|
||||||
|
a, b = tmp_path / "a.json", tmp_path / "b.json"
|
||||||
|
for path, size in ((a, 11), (b, 22)):
|
||||||
|
path.write_text(json.dumps({"properties": {"customization": {"properties": {
|
||||||
|
"score_text": {"properties": {"font_size": {"default": size}}}}}}}))
|
||||||
|
assert C.schema_font_size(str(a), "score_text") == 11
|
||||||
|
assert C.schema_font_size(str(b), "score_text") == 22
|
||||||
|
|
||||||
|
def test_a_missing_schema_is_not_an_error(self, tmp_path):
|
||||||
|
assert C.schema_font_size(str(tmp_path / "nope.json"), "score_text") is None
|
||||||
|
|
||||||
|
def test_a_configured_size_matching_the_schema_default_is_not_a_choice(self, tmp_path):
|
||||||
|
"""The web UI writes the whole default block on every save, so
|
||||||
|
font_size == schema default carries no intent and must not pin the
|
||||||
|
install to an off-grid size forever."""
|
||||||
|
schema = tmp_path / "s.json"
|
||||||
|
schema.write_text(json.dumps({"properties": {"customization": {"properties": {
|
||||||
|
"score_text": {"properties": {"font_size": {"default": 10}}}}}}}))
|
||||||
|
got = C.resolve_font_size(str(schema), {"font_size": 10}, "score_text", 10,
|
||||||
|
"PressStart2P-Regular.ttf")
|
||||||
|
assert got == 8, "a default-valued size should snap to the grid"
|
||||||
|
|
||||||
|
def test_a_real_choice_wins(self, tmp_path):
|
||||||
|
schema = tmp_path / "s.json"
|
||||||
|
schema.write_text(json.dumps({"properties": {"customization": {"properties": {
|
||||||
|
"score_text": {"properties": {"font_size": {"default": 10}}}}}}}))
|
||||||
|
got = C.resolve_font_size(str(schema), {"font_size": 13}, "score_text", 10,
|
||||||
|
"PressStart2P-Regular.ttf")
|
||||||
|
assert got == 13, "an explicit size the user chose is not second-guessed"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTables:
|
||||||
|
def test_every_font_key_maps_to_an_element(self):
|
||||||
|
assert set(C.ELEMENT_FOR_FONT) == {"score", "time", "team", "status", "detail", "rank"}
|
||||||
|
|
||||||
|
def test_result_colours_cover_every_verdict(self):
|
||||||
|
assert set(C.FAVORITE_RESULT_COLOR_DEFAULTS) == {"win", "loss", "tie"}
|
||||||
|
|
||||||
|
def test_month_and_weekday_tables_are_complete(self):
|
||||||
|
assert len(C.MONTH_ABBR) == 12 and len(C.WEEKDAY_ABBR) == 7
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""The shared card geometry, exercised against a host that supplies only what
|
||||||
|
the mixin's contract names.
|
||||||
|
|
||||||
|
The point of these is the contract, not the arithmetic. The mixin reaches for
|
||||||
|
``display_width``, ``fonts``, ``config`` and six ``sports_card`` delegations
|
||||||
|
through ``self``, and the eight plugins are what actually provide them. A
|
||||||
|
stub host that provides exactly the documented surface and nothing else is
|
||||||
|
what catches the mixin quietly growing a dependency the plugins do not have.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from src.common.sports_game_renderer import SportsGameRendererMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Host(SportsGameRendererMixin):
|
||||||
|
"""The documented contract, and not one attribute more."""
|
||||||
|
|
||||||
|
def __init__(self, width=128, height=32, config=None, scroll=None):
|
||||||
|
self.display_width = width
|
||||||
|
self.display_height = height
|
||||||
|
self.config = config or {}
|
||||||
|
self.logger = _Logger()
|
||||||
|
self._team_rankings_cache = {}
|
||||||
|
self._scroll = scroll or {}
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
self.fonts = {'score': font, 'time': font, 'detail': font}
|
||||||
|
self.drawn = []
|
||||||
|
|
||||||
|
# -- the six sports_card delegations the mixin calls --
|
||||||
|
def _scroll_card_option(self, key, default=None):
|
||||||
|
return self._scroll.get(key, default)
|
||||||
|
|
||||||
|
def _upcoming_center_mode(self):
|
||||||
|
return self._scroll.get('upcoming_center', 'vs')
|
||||||
|
|
||||||
|
def _vs_text(self):
|
||||||
|
return self._scroll.get('vs_text', 'VS')
|
||||||
|
|
||||||
|
def _element_color(self, element):
|
||||||
|
return (255, 255, 255)
|
||||||
|
|
||||||
|
def _format_game_date(self, raw, game):
|
||||||
|
return raw
|
||||||
|
|
||||||
|
def _format_game_time(self, raw):
|
||||||
|
return raw
|
||||||
|
|
||||||
|
# -- the one hook whose body genuinely varies per plugin --
|
||||||
|
def _draw_text_with_outline(self, draw, text, position, font,
|
||||||
|
fill=None, outline_color=(0, 0, 0)):
|
||||||
|
self.drawn.append((text, position))
|
||||||
|
|
||||||
|
|
||||||
|
class _Logger:
|
||||||
|
def debug(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _draw():
|
||||||
|
return ImageDraw.Draw(Image.new("RGB", (256, 64)))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCenterGap:
|
||||||
|
def test_explicit_center_gap_wins_outright(self):
|
||||||
|
assert Host(scroll={'center_gap': 31})._center_gap_width() == 31
|
||||||
|
|
||||||
|
def test_explicit_zero_restores_edge_to_edge_logos(self):
|
||||||
|
# 0 is a real setting, not a falsy miss -- the guard is `>= 0`.
|
||||||
|
assert Host(scroll={'center_gap': 0})._center_gap_width() == 0
|
||||||
|
|
||||||
|
def test_otherwise_it_scales_with_card_width_within_the_clamp(self):
|
||||||
|
h = Host(width=512)
|
||||||
|
# 512 * 0.28 = 143, clamped to the 40px ceiling.
|
||||||
|
assert h._center_gap_width() >= h.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
def test_the_gap_never_ends_up_narrower_than_the_score(self):
|
||||||
|
# This is the bug the measurement exists to prevent: a derived gap
|
||||||
|
# smaller than the rendered score drew the score over the logos.
|
||||||
|
h = Host(width=64)
|
||||||
|
assert h._center_gap_width() >= h._score_reserve_width()
|
||||||
|
|
||||||
|
def test_a_junk_ratio_falls_back_to_the_floor(self):
|
||||||
|
h = Host(scroll={'center_gap_ratio': 'wide'})
|
||||||
|
assert h._center_gap_width() == h.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
|
||||||
|
class TestNonFiniteSettings:
|
||||||
|
"""inf reaches int() and raises OverflowError, which the old
|
||||||
|
`except (TypeError, ValueError)` did not catch -- so one bad config value
|
||||||
|
aborted the entire card render rather than falling back."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [float("inf"), float("-inf")])
|
||||||
|
def test_a_non_finite_center_gap_falls_back(self, bad):
|
||||||
|
h = Host(scroll={'center_gap': bad})
|
||||||
|
assert h._center_gap_width() >= h.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [float("inf"), float("-inf"), float("nan")])
|
||||||
|
def test_a_non_finite_ratio_falls_back_to_the_floor(self, bad):
|
||||||
|
h = Host(scroll={'center_gap_ratio': bad})
|
||||||
|
assert h._center_gap_width() == h.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [float("inf"), float("-inf")])
|
||||||
|
def test_non_finite_clamp_bounds_fall_back(self, bad):
|
||||||
|
h = Host(scroll={'center_gap_min': bad, 'center_gap_max': bad})
|
||||||
|
assert h._center_gap_width() == h.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [float("inf"), float("-inf"), "inf", "-inf", "nan"])
|
||||||
|
def test_a_non_finite_layout_offset_gives_the_default(self, bad):
|
||||||
|
cfg = {'customization': {'layout': {'score': {'x_offset': bad}}}}
|
||||||
|
assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == 7
|
||||||
|
|
||||||
|
def test_a_finite_value_is_still_honoured(self):
|
||||||
|
# The guard must not swallow ordinary settings.
|
||||||
|
assert Host(scroll={'center_gap': 31})._center_gap_width() == 31
|
||||||
|
cfg = {'customization': {'layout': {'score': {'x_offset': -3}}}}
|
||||||
|
assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == -3
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoreReserve:
|
||||||
|
def test_it_measures_the_probe_plus_both_gutters(self):
|
||||||
|
h = Host()
|
||||||
|
assert h._score_reserve_width() > 2 * h._SCORE_LOGO_GUTTER_PX
|
||||||
|
|
||||||
|
def test_a_wider_probe_reserves_more(self):
|
||||||
|
class Wide(Host):
|
||||||
|
_SCORE_PROBE = "000-000"
|
||||||
|
assert Wide()._score_reserve_width() > Host()._score_reserve_width()
|
||||||
|
|
||||||
|
def test_an_unmeasurable_font_reserves_nothing_rather_than_raising(self):
|
||||||
|
h = Host()
|
||||||
|
h.fonts = {'score': object()}
|
||||||
|
assert h._score_reserve_width() == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogoSlot:
|
||||||
|
def test_the_slot_is_what_is_left_after_the_gap(self):
|
||||||
|
h = Host(width=128, scroll={'center_gap': 40})
|
||||||
|
assert h._logo_slot_width() == 44
|
||||||
|
|
||||||
|
def test_it_is_not_capped_at_the_card_height(self):
|
||||||
|
# The height cap is what froze logos at 46px on a 128px card.
|
||||||
|
h = Host(width=512, height=32, scroll={'center_gap': 40})
|
||||||
|
assert h._logo_slot_width() > h.display_height
|
||||||
|
|
||||||
|
def test_a_gap_wider_than_the_card_still_leaves_a_usable_slot(self):
|
||||||
|
assert Host(width=64, scroll={'center_gap': 200})._logo_slot_width() == 8
|
||||||
|
|
||||||
|
def test_the_cache_key_is_scoped_to_the_slot_not_just_the_name(self):
|
||||||
|
# One cache dict is shared by renderers of different card widths.
|
||||||
|
narrow = Host(width=64, scroll={'center_gap': 20})._logo_cache_key("NYY")
|
||||||
|
wide = Host(width=256, scroll={'center_gap': 20})._logo_cache_key("NYY")
|
||||||
|
assert narrow != wide
|
||||||
|
|
||||||
|
|
||||||
|
class TestLayoutOffset:
|
||||||
|
def _host(self, value):
|
||||||
|
return Host(config={'customization': {'layout': {'score': {'x_offset': value}}}})
|
||||||
|
|
||||||
|
def test_it_reads_the_same_block_as_the_full_screen_scorebug(self):
|
||||||
|
assert self._host(5)._layout_offset('score', 'x_offset') == 5
|
||||||
|
|
||||||
|
def test_a_string_offset_from_the_web_ui_is_coerced(self):
|
||||||
|
assert self._host("-3")._layout_offset('score', 'x_offset') == -3
|
||||||
|
|
||||||
|
def test_a_bool_is_not_silently_an_offset_of_one(self):
|
||||||
|
assert self._host(True)._layout_offset('score', 'x_offset', 9) == 9
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cfg", [{}, {'customization': {}},
|
||||||
|
{'customization': {'layout': {}}}])
|
||||||
|
def test_a_missing_block_gives_the_default(self, cfg):
|
||||||
|
assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == 7
|
||||||
|
|
||||||
|
def test_an_unparseable_offset_gives_the_default(self):
|
||||||
|
assert self._host("left")._layout_offset('score', 'x_offset', 4) == 4
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpcomingCenter:
|
||||||
|
def test_none_draws_nothing(self):
|
||||||
|
h = Host(scroll={'upcoming_center': 'none'})
|
||||||
|
h._draw_upcoming_center(_draw(), {})
|
||||||
|
assert h.drawn == []
|
||||||
|
|
||||||
|
def test_vs_is_the_default_and_never_a_score(self):
|
||||||
|
# An upcoming game has not started; the extractor's 0-0 is noise.
|
||||||
|
h = Host()
|
||||||
|
h._draw_upcoming_center(_draw(), {'home_score': 0, 'away_score': 0})
|
||||||
|
assert [t for t, _ in h.drawn] == ['VS']
|
||||||
|
|
||||||
|
def test_an_empty_vs_string_draws_nothing(self):
|
||||||
|
h = Host(scroll={'vs_text': ''})
|
||||||
|
h._draw_upcoming_center(_draw(), {})
|
||||||
|
assert h.drawn == []
|
||||||
|
|
||||||
|
def test_date_time_stacks_both_lines(self):
|
||||||
|
h = Host(scroll={'upcoming_center': 'date_time'})
|
||||||
|
h._draw_upcoming_center(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||||
|
assert [t for t, _ in h.drawn] == ['Sep 19', '7:00 PM']
|
||||||
|
|
||||||
|
def test_hiding_both_lines_draws_nothing(self):
|
||||||
|
h = Host(scroll={'upcoming_center': 'date_time',
|
||||||
|
'show_date': False, 'show_time': False})
|
||||||
|
h._draw_upcoming_center(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||||
|
assert h.drawn == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpcomingStatus:
|
||||||
|
def test_time_on_top_and_date_below_by_default(self):
|
||||||
|
h = Host()
|
||||||
|
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||||
|
assert [t for t, _ in h.drawn] == ['7:00 PM', 'Sep 19']
|
||||||
|
|
||||||
|
def test_swap_date_time_reverses_them(self):
|
||||||
|
h = Host(scroll={'swap_date_time': True})
|
||||||
|
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||||
|
assert [t for t, _ in h.drawn] == ['Sep 19', '7:00 PM']
|
||||||
|
|
||||||
|
def test_it_stays_out_of_the_way_when_the_centre_already_has_them(self):
|
||||||
|
# Otherwise the date and time print twice on the same card.
|
||||||
|
h = Host(scroll={'upcoming_center': 'date_time'})
|
||||||
|
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||||
|
assert h.drawn == []
|
||||||
|
|
||||||
|
def test_the_bottom_line_is_measured_not_a_fixed_offset(self):
|
||||||
|
# A fixed -7 ran "Sep 19" past the card wherever the detail font is
|
||||||
|
# 10px rather than 6px.
|
||||||
|
h = Host(height=64)
|
||||||
|
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||||
|
bottom_y = h.drawn[1][1][1]
|
||||||
|
assert 0 <= bottom_y < h.display_height
|
||||||
|
|
||||||
|
|
||||||
|
class TestRankings:
|
||||||
|
def test_set_rankings_cache_replaces_the_cache(self):
|
||||||
|
h = Host()
|
||||||
|
h.set_rankings_cache({'UGA': 1})
|
||||||
|
assert h._team_rankings_cache == {'UGA': 1}
|
||||||
|
|
||||||
|
|
||||||
|
class TestContract:
|
||||||
|
def test_the_mixin_carries_no_state_of_its_own(self):
|
||||||
|
# Adoption must be one line on the class statement; a mixin with an
|
||||||
|
# __init__ would force eight constructors to cooperate.
|
||||||
|
assert '__init__' not in SportsGameRendererMixin.__dict__
|
||||||
|
|
||||||
|
def test_a_host_providing_the_documented_surface_needs_nothing_more(self):
|
||||||
|
# Host defines exactly what the module docstring names. If the mixin
|
||||||
|
# grows a new self.* dependency, this is what fails.
|
||||||
|
h = Host()
|
||||||
|
h._center_gap_width()
|
||||||
|
h._logo_slot_width()
|
||||||
|
h._logo_cache_key("X")
|
||||||
|
h._layout_offset('score', 'x_offset')
|
||||||
|
h._upcoming_date_and_time({})
|
||||||
|
h._draw_upcoming_center(_draw(), {})
|
||||||
|
h._draw_upcoming_game_status(_draw(), {})
|
||||||
|
h.set_rankings_cache({})
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""The shared sports.py mixins: their host contract, and _plugin_dir.
|
||||||
|
|
||||||
|
Two things are worth testing here and the rest is not. The 45 method bodies
|
||||||
|
moved verbatim from the plugins, so they are covered by the plugins' own tests
|
||||||
|
and by 176 byte-identical safety-harness renders. What is genuinely new is:
|
||||||
|
|
||||||
|
1. The contract. Every ``self.<CONSTANT>`` a mixin reads must be defined on the
|
||||||
|
mixin, or a host that does not happen to declare it raises AttributeError at
|
||||||
|
runtime. Two were missed on the first pass (_QUALITY_CHOICES and
|
||||||
|
_RANKING_COVERAGE_SECONDS); the eight plugins all declare them, so nothing
|
||||||
|
failed -- it would only have bitten a ninth. The test derives the list rather
|
||||||
|
than restating it, so the next omission fails here instead of in the field.
|
||||||
|
|
||||||
|
2. ``_plugin_dir``. This is the only line of genuinely new logic in the move. In
|
||||||
|
sports.py these methods found config_schema.json with ``__file__``; here that
|
||||||
|
is src/common/, so the plugin directory has to be recovered from the
|
||||||
|
instance -- and getting it wrong is silent, costing grid-snapped font sizes
|
||||||
|
(measured at 81% anti-aliased edges) rather than raising.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from abc import ABC
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.common import sports_shared
|
||||||
|
from src.common.sports_shared import (
|
||||||
|
SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
|
||||||
|
|
||||||
|
MIXINS = (SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
|
||||||
|
|
||||||
|
|
||||||
|
def _constants_read_by_mixins():
|
||||||
|
"""Every ALL-CAPS ``self.X`` the mixin bodies read, found by parsing them."""
|
||||||
|
tree = ast.parse(open(sports_shared.__file__).read())
|
||||||
|
names = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if (isinstance(node, ast.Attribute)
|
||||||
|
and isinstance(node.value, ast.Name)
|
||||||
|
and node.value.id == "self"
|
||||||
|
and node.attr.upper() == node.attr):
|
||||||
|
names.add(node.attr)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostContract:
|
||||||
|
def test_every_constant_read_is_also_defined(self):
|
||||||
|
# Otherwise a host that does not declare it raises AttributeError the
|
||||||
|
# first time the code path runs -- which for these is mid-render.
|
||||||
|
missing = sorted(
|
||||||
|
name for name in _constants_read_by_mixins()
|
||||||
|
if not any(hasattr(m, name) for m in MIXINS))
|
||||||
|
assert missing == [], (
|
||||||
|
f"read but never defined on a mixin: {missing}. Give each a default "
|
||||||
|
f"on SportsCoreSharedMixin and document it in the module docstring.")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,expected", [
|
||||||
|
("_QUALITY_CHOICES", frozenset({"any", "ranked"})),
|
||||||
|
("_RANKING_COVERAGE_SECONDS", 3600),
|
||||||
|
("_SCORE_PROBE_TEXT", "00-00"),
|
||||||
|
("_FONT_DESIGN_HEIGHT", 32),
|
||||||
|
])
|
||||||
|
def test_defaults_match_what_the_plugins_ship(self, name, expected):
|
||||||
|
# The eight plugins declare their own copies, which shadow these. The
|
||||||
|
# values must still agree, or a ninth plugin inheriting the default
|
||||||
|
# behaves differently from the eight.
|
||||||
|
assert getattr(SportsCoreSharedMixin, name) == expected
|
||||||
|
|
||||||
|
def test_only_the_recent_mixin_carries_a_constructor(self):
|
||||||
|
# SportsCore and SportsLive keep their own __init__ -- those differ per
|
||||||
|
# plugin. SportsRecent.__init__ was one of the 48 byte-identical bodies,
|
||||||
|
# so it moved with the rest; that is deliberate, not an oversight.
|
||||||
|
assert "__init__" not in SportsCoreSharedMixin.__dict__
|
||||||
|
assert "__init__" not in SportsLiveSharedMixin.__dict__
|
||||||
|
assert "__init__" in SportsRecentSharedMixin.__dict__
|
||||||
|
|
||||||
|
def test_the_recent_constructor_still_chains_to_the_host(self):
|
||||||
|
"""Its zero-arg super() binds to where it is DEFINED, not where it is used.
|
||||||
|
|
||||||
|
Moving a body containing bare ``super()`` is the one move that can
|
||||||
|
change meaning: the compiler closes over __class__ = the defining class,
|
||||||
|
so after the move that is SportsRecentSharedMixin rather than the
|
||||||
|
plugin's SportsRecent. It still works only because the mixin is listed
|
||||||
|
first, leaving the host class next in the MRO -- adopt it in the other
|
||||||
|
order and the chain silently skips the host's __init__.
|
||||||
|
"""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Host:
|
||||||
|
def __init__(self, config, display_manager, cache_manager, logger, sport_key):
|
||||||
|
calls.append(sport_key)
|
||||||
|
self.mode_config = {}
|
||||||
|
|
||||||
|
class Recent(SportsRecentSharedMixin, Host):
|
||||||
|
pass
|
||||||
|
|
||||||
|
inst = Recent({}, None, None, None, "nhl")
|
||||||
|
assert calls == ["nhl"], "the host constructor must still run"
|
||||||
|
assert inst.current_game_index == 0
|
||||||
|
assert inst.update_interval == 3600
|
||||||
|
assert inst._zero_clock_timestamps == {}
|
||||||
|
|
||||||
|
def test_adopting_the_recent_mixin_second_would_skip_the_host(self):
|
||||||
|
# The failure mode the ordering above prevents, pinned so nobody
|
||||||
|
# "tidies" the base list.
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Host:
|
||||||
|
def __init__(self, *a):
|
||||||
|
calls.append(a)
|
||||||
|
self.mode_config = {}
|
||||||
|
|
||||||
|
class Wrong(Host, SportsRecentSharedMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
Wrong({}, None, None, None, "nhl")
|
||||||
|
# Host.__init__ wins and the mixin's setup never runs at all.
|
||||||
|
assert not hasattr(Wrong({}, None, None, None, "nhl"), "current_game_index")
|
||||||
|
|
||||||
|
|
||||||
|
class _Host(SportsCoreSharedMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _write_plugin(tmp_path, name="fakeplug", schema=True):
|
||||||
|
"""A throwaway package on sys.path, with or without a config_schema.json."""
|
||||||
|
d = tmp_path / name
|
||||||
|
d.mkdir()
|
||||||
|
(d / "__init__.py").write_text("")
|
||||||
|
(d / "mod.py").write_text("class Leaf:\n pass\n")
|
||||||
|
if schema:
|
||||||
|
(d / "config_schema.json").write_text(
|
||||||
|
'{"properties": {"customization": {"properties": '
|
||||||
|
'{"score": {"properties": {"font_size": {"default": 16}}}}}}}')
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginDir:
|
||||||
|
def test_it_finds_the_directory_holding_config_schema_json(self, tmp_path, monkeypatch):
|
||||||
|
d = _write_plugin(tmp_path)
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("fakeplug.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
assert host._plugin_dir() == str(d)
|
||||||
|
|
||||||
|
def test_a_class_built_by_type_still_resolves(self, tmp_path, monkeypatch):
|
||||||
|
# SportsCore is an ABC, so type(name, bases, ns) reports __module__ as
|
||||||
|
# "abc" rather than the plugin -- which is exactly what the plugins'
|
||||||
|
# own tests build. Walking the MRO is what steps past it.
|
||||||
|
d = _write_plugin(tmp_path, "abcplug")
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("abcplug.mod", fromlist=["Leaf"])
|
||||||
|
|
||||||
|
class Base(SportsCoreSharedMixin, mod.Leaf, ABC):
|
||||||
|
pass
|
||||||
|
|
||||||
|
synthetic = type("Probe", (Base,), {})
|
||||||
|
assert synthetic.__module__ == "abc", "precondition: the trap this guards"
|
||||||
|
assert synthetic.__new__(synthetic)._plugin_dir() == str(d)
|
||||||
|
|
||||||
|
def test_it_returns_none_when_no_schema_is_anywhere_on_the_mro(self, tmp_path, monkeypatch):
|
||||||
|
d = _write_plugin(tmp_path, "noschema", schema=False)
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("noschema.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
# None rather than a wrong guess: _schema_font_size then caches empty
|
||||||
|
# and every element keeps its own default.
|
||||||
|
assert host._plugin_dir() is None
|
||||||
|
|
||||||
|
def test_it_never_returns_the_core_module_directory(self):
|
||||||
|
# The bug this replaced: __file__ pointed at src/common/, so the schema
|
||||||
|
# was never found and font sizes silently stopped snapping to the grid.
|
||||||
|
host = _Host()
|
||||||
|
core_common = os.path.dirname(os.path.abspath(sports_shared.__file__))
|
||||||
|
assert host._plugin_dir() != core_common
|
||||||
|
|
||||||
|
def test_a_module_with_no_file_is_skipped_not_crashed_on(self, monkeypatch):
|
||||||
|
# Namespace packages and some frozen/dynamic modules have no __file__.
|
||||||
|
ghost = types.ModuleType("ghost_no_file")
|
||||||
|
if hasattr(ghost, "__file__"):
|
||||||
|
del ghost.__file__
|
||||||
|
monkeypatch.setitem(sys.modules, "ghost_no_file", ghost)
|
||||||
|
cls = type("H", (SportsCoreSharedMixin,), {"__module__": "ghost_no_file"})
|
||||||
|
assert cls.__new__(cls)._plugin_dir() is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaFontSize:
|
||||||
|
def test_it_reads_the_plugin_schema_not_the_cores(self, tmp_path, monkeypatch):
|
||||||
|
d = _write_plugin(tmp_path, "sizeplug")
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("sizeplug.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
assert host._schema_font_size("score") == 16
|
||||||
|
|
||||||
|
def test_an_unknown_element_is_none(self, tmp_path, monkeypatch):
|
||||||
|
_write_plugin(tmp_path, "unkplug")
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("unkplug.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
assert host._schema_font_size("nonesuch") is None
|
||||||
|
|
||||||
|
def test_an_empty_key_is_none_without_touching_the_disk(self):
|
||||||
|
assert _Host()._schema_font_size("") is None
|
||||||
|
|
||||||
|
def test_a_missing_schema_degrades_to_none_rather_than_raising(self, tmp_path, monkeypatch):
|
||||||
|
_write_plugin(tmp_path, "bareplug", schema=False)
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("bareplug.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
assert host._schema_font_size("score") is None
|
||||||
|
|
||||||
|
|
||||||
|
class _LiveHost(SportsLiveSharedMixin):
|
||||||
|
"""The documented contract for the live mixin, and nothing else."""
|
||||||
|
|
||||||
|
def __init__(self, no_data_interval=300, stale_game_timeout=600, over=()):
|
||||||
|
self.no_data_interval = no_data_interval
|
||||||
|
self.stale_game_timeout = stale_game_timeout
|
||||||
|
self.game_update_timestamps = {}
|
||||||
|
self._over = set(over)
|
||||||
|
|
||||||
|
class _L:
|
||||||
|
def __getattr__(self, _n):
|
||||||
|
return lambda *a, **k: None
|
||||||
|
self.logger = _L()
|
||||||
|
|
||||||
|
def _is_game_really_over(self, game):
|
||||||
|
return game.get("id") in self._over
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveMixin:
|
||||||
|
"""These three moved to the core, so they are tested here.
|
||||||
|
|
||||||
|
They were already covered by hockey's and lacrosse's own tests, but those
|
||||||
|
two plugins disable live mode in their safety-harness fixtures, so the 176
|
||||||
|
renders never exercise this path. Testing the mixin directly means the
|
||||||
|
coverage no longer depends on which plugin happens to have a unit test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_a_stale_game_is_dropped_and_forgotten(self):
|
||||||
|
h = _LiveHost(stale_game_timeout=600)
|
||||||
|
import time as _t
|
||||||
|
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5000}
|
||||||
|
games = [{"id": "g1", "home_abbr": "H", "away_abbr": "A"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert games == []
|
||||||
|
assert "g1" not in h.game_update_timestamps, "its timestamp must go too"
|
||||||
|
|
||||||
|
def test_a_fresh_game_survives(self):
|
||||||
|
h = _LiveHost(stale_game_timeout=600)
|
||||||
|
import time as _t
|
||||||
|
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5}
|
||||||
|
games = [{"id": "g1"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert len(games) == 1
|
||||||
|
|
||||||
|
def test_a_game_never_seen_is_not_treated_as_stale(self):
|
||||||
|
# last_seen 0 means "no reading", not "seen at the epoch".
|
||||||
|
h = _LiveHost()
|
||||||
|
games = [{"id": "g1"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert len(games) == 1
|
||||||
|
|
||||||
|
def test_a_game_with_no_id_is_left_alone(self):
|
||||||
|
h = _LiveHost()
|
||||||
|
games = [{"home_abbr": "H"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert len(games) == 1
|
||||||
|
|
||||||
|
def test_a_finished_game_is_dropped_even_when_fresh(self):
|
||||||
|
h = _LiveHost(over=("g2",))
|
||||||
|
games = [{"id": "g1"}, {"id": "g2"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert [g["id"] for g in games] == ["g1"]
|
||||||
|
|
||||||
|
def test_removing_several_does_not_skip_any(self):
|
||||||
|
# It iterates a copy for exactly this reason; mutating the live list
|
||||||
|
# while looping would step over the element after each removal.
|
||||||
|
h = _LiveHost(over=("g1", "g2", "g3"))
|
||||||
|
games = [{"id": "g1"}, {"id": "g2"}, {"id": "g3"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert games == []
|
||||||
|
|
||||||
|
def test_the_idle_interval_escalates_with_the_empty_streak(self):
|
||||||
|
h = _LiveHost(no_data_interval=60)
|
||||||
|
h.live_idle_max_interval = 100000
|
||||||
|
base = h._idle_live_interval()
|
||||||
|
h._empty_live_streak = 6
|
||||||
|
short = h._idle_live_interval()
|
||||||
|
h._empty_live_streak = 24
|
||||||
|
long = h._idle_live_interval()
|
||||||
|
assert base < short < long
|
||||||
|
|
||||||
|
def test_the_ceiling_bounds_even_the_unescalated_interval(self):
|
||||||
|
# base > ceiling is a reachable config: the two settings are
|
||||||
|
# independent integers with no cross-validation. Returning base
|
||||||
|
# unclamped made the wait SHRINK as the streak grew.
|
||||||
|
h = _LiveHost(no_data_interval=3600)
|
||||||
|
h.live_idle_max_interval = 900
|
||||||
|
h._empty_live_streak = 0
|
||||||
|
assert h._idle_live_interval() == 900
|
||||||
|
h._empty_live_streak = 24
|
||||||
|
assert h._idle_live_interval() == 900
|
||||||
|
|
||||||
|
def test_finding_a_live_game_resets_the_streak(self):
|
||||||
|
h = _LiveHost()
|
||||||
|
h._note_live_fetch(False)
|
||||||
|
h._note_live_fetch(False)
|
||||||
|
assert h._empty_live_streak == 2
|
||||||
|
h._note_live_fetch(True)
|
||||||
|
assert h._empty_live_streak == 0
|
||||||
|
|
||||||
|
def test_the_streak_starts_from_absent_state(self):
|
||||||
|
# The host is not required to pre-declare _empty_live_streak.
|
||||||
|
h = _LiveHost()
|
||||||
|
assert not hasattr(h, "_empty_live_streak")
|
||||||
|
h._note_live_fetch(False)
|
||||||
|
assert h._empty_live_streak == 1
|
||||||
@@ -1164,9 +1164,16 @@
|
|||||||
// The pull replaced files on disk; the running services still
|
// The pull replaced files on disk; the running services still
|
||||||
// hold the code they loaded at boot. Ask for the restart that
|
// hold the code they loaded at boot. Ask for the restart that
|
||||||
// makes the update actually take effect.
|
// makes the update actually take effect.
|
||||||
|
//
|
||||||
|
// Both services, not just the display. The plugin store's
|
||||||
|
// compatibility gate runs in the web process, so a web service
|
||||||
|
// still holding the previous version refuses every plugin that
|
||||||
|
// floors on the release just installed -- blaming a core
|
||||||
|
// version that is already correct on disk.
|
||||||
if (data.restart_required && typeof window.showRestartPending === 'function') {
|
if (data.restart_required && typeof window.showRestartPending === 'function') {
|
||||||
window.showRestartPending(
|
window.showRestartPending(
|
||||||
'Update installed \u2014 restart the display to run the new code');
|
'Update installed \u2014 restart the display and web ' +
|
||||||
|
'services to run the new code');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
|
|||||||
Reference in New Issue
Block a user