mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 17:28:05 +00:00
Compare commits
4
Commits
d69dfbbaee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21825cbfbc | ||
|
|
82a65ad2a2 | ||
|
|
83f20b64fe | ||
|
|
5b45f35888 |
@@ -5,6 +5,10 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Both jobs only check out the repo and run pytest.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
plugin-safety:
|
||||
name: Plugin safety harness + unit tests
|
||||
@@ -31,3 +35,45 @@ jobs:
|
||||
test/plugins/test_harness.py \
|
||||
test/plugins/test_visual_rendering.py \
|
||||
test/plugins/test_plugin_matrix.py
|
||||
|
||||
unit-tests:
|
||||
name: Core unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pip install RGBMatrixEmulator
|
||||
|
||||
# Safety net for the shared sports/scroll/style infrastructure. These
|
||||
# suites existed but were not enrolled in CI, so a refactor of
|
||||
# src/base_classes or src/common could regress them silently. Enrolled
|
||||
# explicitly (not `pytest test/`) so known hardware-only suites don't
|
||||
# break CI; grow this list as more suites are made headless.
|
||||
- name: Run core unit suites
|
||||
run: |
|
||||
pytest --no-cov \
|
||||
test/test_skin_system.py \
|
||||
test/test_font_manager.py \
|
||||
test/test_data_sources.py \
|
||||
test/test_api_extractors.py \
|
||||
test/test_scroll_helper.py \
|
||||
test/test_scroll_helper_continuous.py \
|
||||
test/test_adaptive_layout.py \
|
||||
test/test_loader_compat_warning.py \
|
||||
test/test_sports_base_characterization.py \
|
||||
test/test_element_style.py \
|
||||
test/test_sports_core_promotions.py \
|
||||
test/test_sports_modes_promotions.py \
|
||||
test/test_sports_capabilities.py \
|
||||
test/test_sports_scroll.py
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
# Changelog
|
||||
|
||||
Notable changes to the LEDMatrix core. The version below is the value of
|
||||
`src.__version__`, which the plugin loader reports to compatibility checks and
|
||||
which plugin manifests reference via `ledmatrix_min_version`.
|
||||
|
||||
**Why this file exists:** the plugin monorepo bundles fallback copies of several
|
||||
core modules (see `docs/plugin-development/08-shared-sports-code.md` in
|
||||
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)). A plugin
|
||||
may delete its bundled copy only when its manifest floors on the first core
|
||||
release that ships the module — which requires module additions to be recorded
|
||||
here, against a version number. When you add a module plugins will import via
|
||||
`src.*`, note it in the Unreleased section and bump `src/__init__.py` in the
|
||||
release that ships it.
|
||||
|
||||
**Use `ledmatrix_min_version` in manifests, not `ledmatrix_min`.** The loader
|
||||
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`.
|
||||
|
||||
## 3.2.0
|
||||
|
||||
**The first release shipping the unified sports library.** This is the version
|
||||
a sports plugin floors `ledmatrix_min_version` at before deleting its bundled
|
||||
copy of `sports.py`, `scroll_display.py`, `data_sources.py` or
|
||||
`base_odds_manager.py` — the sunset rule in
|
||||
`docs/plugin-development/08-shared-sports-code.md` keys on exactly this number.
|
||||
|
||||
Adoption is deliberately staged: the modules below ship here, plugins adopt them
|
||||
behind guarded imports, and only then do the bundled copies go away. Nothing in
|
||||
this release changes what an existing plugin loads.
|
||||
|
||||
### Added
|
||||
- `src/element_style.py` — per-element style resolver backing the
|
||||
`x-style-elements` config-schema extension. Already consumed (behind guarded
|
||||
imports with classic fallbacks) by the `of-the-day`, `ledmatrix-music`, and
|
||||
`football-scoreboard` plugins.
|
||||
- Core unit-test CI job enrolling the previously unenrolled suites (skin
|
||||
system, data sources, API extractors, scroll helper, adaptive layout, loader
|
||||
compatibility warning) plus new characterization tests for
|
||||
`src/base_classes/sports.py` ahead of the shared sports-code unification.
|
||||
- `src/base_classes/sports/` — `sports.py` is now a package (`core.py` +
|
||||
`modes.py`). The import path is unchanged: `from src.base_classes.sports
|
||||
import SportsCore` still works.
|
||||
- Nine methods promoted onto the sports base classes from the plugins'
|
||||
bundled copies, plus the override points `_favorite_key`,
|
||||
`_config_schema_path` and `_font_root` and the class attributes
|
||||
`FINAL_PERIOD` / `CLOCK_COUNTS_DOWN`. See `docs/SPORTS_UNIFICATION.md`.
|
||||
A plugin may start calling these once its manifest floors
|
||||
`ledmatrix_min_version` at the release that ships them.
|
||||
|
||||
- `src/base_classes/sports/capabilities/` — opt-in capabilities for the sports
|
||||
scoreboards, composed by inheritance rather than gated by config branches
|
||||
inside the base classes:
|
||||
- `CelebrationMixin` — the score/win takeover, merging the goal and score
|
||||
dialects behind the `score_phrase()` / `win_phrase()` hooks, the
|
||||
`COALESCE_SCORING_SEQUENCE` class attribute and the `_favorite_key` seam.
|
||||
Reads both the `celebrate_opponent_goals` and `celebrate_opponent_scores`
|
||||
config spellings. Sports that do not mix it in have none of this code in
|
||||
their MRO.
|
||||
- `RotationStrategy` + a name registry (`swrr`, `weighted`, `simple`,
|
||||
plus `register_rotation_strategy` for plugin-supplied orderings). Each
|
||||
built-in is verified against a verbatim transcription of the plugin
|
||||
implementation it replaces. An unknown name degrades to `simple`.
|
||||
|
||||
- `src/common/sports_scroll.py` — `SportsScrollDisplay` and
|
||||
`SportsScrollDisplayManager`, the shared scroll **orchestration** layer for
|
||||
the sports scoreboards, plus native support for
|
||||
`global_config['target_fps']` (the bundled plugin copies hardcode ~100 FPS
|
||||
via `scroll_delay` and never consult the global target). Content building
|
||||
(`prepare_scroll_content`, `_load_separator_icons`) is per-sport and stays an
|
||||
override point — see `docs/SPORTS_UNIFICATION.md` for where the line falls
|
||||
and why.
|
||||
|
||||
### Changed
|
||||
- `src/__init__.py` bumped to **3.2.0** — the number the sunset rule keys on.
|
||||
- **Live games are no longer dropped when the feed omits a game clock.**
|
||||
`SportsLive._is_game_really_over` previously (in the baseball and UFC
|
||||
plugin lineages) coerced a missing or non-string clock to the literal
|
||||
`"0:00"` and then treated the game as finished once `period >= 4`. Baseball
|
||||
has no game clock and `period` is the inning, so live MLB games disappeared
|
||||
from the scoreboard from the 5th inning onward; UFC was affected the same
|
||||
way. The clock check is now skipped when the clock is unusable, and the
|
||||
period threshold is the per-sport `FINAL_PERIOD` (hockey ends in P3).
|
||||
Sports whose clocks count up — soccer, AFL, NRL — set
|
||||
`CLOCK_COUNTS_DOWN = False` and never run the check at all, since `0:00`
|
||||
there means kickoff rather than expiry.
|
||||
|
||||
### Fixed
|
||||
- `FontManager` resolves `assets/fonts` against the core install root instead
|
||||
of the process working directory, so font loading works when the process
|
||||
starts elsewhere (e.g. the plugin safety harness on CI).
|
||||
- Hockey events whose competitors carry no `statistics` array are no longer
|
||||
discarded. The extractor read `competitor["statistics"]` unguarded, so a
|
||||
`KeyError` inside the generator dropped the entire event despite valid
|
||||
scores and status; shot counts now fall back to `0`.
|
||||
- Live baseball events that populate status only at the competition level are
|
||||
no longer discarded. The extractor read the event top-level
|
||||
`game_event["status"]` for the inning; real ESPN events duplicate it, but
|
||||
MiLB events synthesized from the MLB Stats API do not, so the lookup raised
|
||||
a bare `KeyError`. It now reads the already-validated competition-level
|
||||
status.
|
||||
- `SportsLive._is_game_really_over` no longer crashes the live-update pass when
|
||||
a feed sends an explicit null `period`. `None >= FINAL_PERIOD` raised
|
||||
`TypeError`, and the only caller (`_detect_stale_games`) has no `try/except`
|
||||
— the same failure shape as the already-fixed null `period_text`.
|
||||
- An expired clock spelled `"00:00"` now ends the game. The check compared the
|
||||
colon-stripped clock against a hand-listed set of literals, which `"0000"` is
|
||||
not a member of, so a finished game with a two-digit-minute clock stayed on
|
||||
the scoreboard indefinitely. The comparison is now numeric.
|
||||
- `SportsCore._load_fonts` resolves `assets/fonts` through the `_font_root()`
|
||||
seam instead of the process working directory. Started outside the install
|
||||
root, every scoreboard font silently degraded to PIL's default bitmap face.
|
||||
- `SportsCore._should_log` no longer raises `AttributeError` on the first
|
||||
warning of a run; `_last_warning_time` is initialized in `__init__` rather
|
||||
than lazily by an unrelated method.
|
||||
- `SportsCore._resolve_project_path` resolved relative logo directories
|
||||
against `<root>/src` instead of the repo root after `sports.py` became a
|
||||
package — the class bodies moved byte-identically but `__file__` gained a
|
||||
directory. Both it and `_font_root` now derive from one `_INSTALL_ROOT`
|
||||
constant.
|
||||
|
||||
## 3.1.0
|
||||
|
||||
Baseline for this changelog. Highlights already shipped at this version:
|
||||
skin system for sports scoreboards (#419), Vegas continuous-scroll overhaul
|
||||
(#423), plugin update surfacing (#421).
|
||||
@@ -88,6 +88,7 @@
|
||||
}
|
||||
},
|
||||
"timezone": "America/New_York",
|
||||
"target_fps": 100,
|
||||
"location": {
|
||||
"city": "Tampa",
|
||||
"state": "Florida",
|
||||
@@ -130,7 +131,25 @@
|
||||
"plugin_order": [],
|
||||
"excluded_plugins": [],
|
||||
"target_fps": 125,
|
||||
"buffer_ahead": 2
|
||||
"buffer_ahead": 2,
|
||||
"intra_plugin_gap": 8,
|
||||
"render_width_pct": 100,
|
||||
"min_content_separation": 24,
|
||||
"min_cut_gap": 6,
|
||||
"continuous_scroll": true,
|
||||
"smooth_scroll": true,
|
||||
"extend_threshold_screens": 2.0,
|
||||
"auto_trim": true,
|
||||
"trim_threshold": 10,
|
||||
"content_padding": 8,
|
||||
"min_plugin_width": 8,
|
||||
"lead_in_width": 0,
|
||||
"plugins_per_cycle": 6,
|
||||
"max_plugin_width_ratio": 3.0,
|
||||
"overflow_mode": "rotate",
|
||||
"dynamic_duration_enabled": true,
|
||||
"min_cycle_duration": 60,
|
||||
"max_cycle_duration": 240
|
||||
}
|
||||
},
|
||||
"sync": {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# Sports Code Unification — Architecture
|
||||
|
||||
How the nine sports scoreboard plugins converge onto shared core code **without**
|
||||
becoming nine clients of a god class.
|
||||
|
||||
## The problem
|
||||
|
||||
Nine plugins (`afl`, `baseball`, `basketball`, `football`, `hockey`, `lacrosse`,
|
||||
`nrl`, `soccer`, `ufc`) each ship a ~3,000-line `sports.py` descended from this
|
||||
repo's `src/base_classes/sports.py`. They have drifted into three lineages, and
|
||||
only 28 of the 66 methods appearing across them are present in all nine. One
|
||||
logical fix (the UTC start-time bug) cost 75 files.
|
||||
|
||||
Merging everything into one base class would fix the duplication and create a
|
||||
worse problem: a single 2,500-line class that all nine plugins inherit, where any
|
||||
change has a nine-plugin blast radius and per-sport behavior survives only as
|
||||
`if self.sport == "hockey"` branches.
|
||||
|
||||
## Three properties, three mechanisms
|
||||
|
||||
These are independent concerns. Conflating them is what produces god classes.
|
||||
|
||||
### Upgradability — a plugin keeps working across core versions
|
||||
|
||||
| Rule | Mechanism |
|
||||
|---|---|
|
||||
| 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. |
|
||||
| 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**: only when its manifest floors `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`). |
|
||||
|
||||
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
|
||||
defaults, or as capabilities they opt into.
|
||||
|
||||
### Reusability — write once, nine plugins benefit
|
||||
|
||||
Only code that is **identical in intent across all nine** moves into the base
|
||||
class. That set is small and knowable — it is exactly the methods present in every
|
||||
copy today (phase B1 below). Everything else stays where it is until it earns
|
||||
promotion.
|
||||
|
||||
### Modularity — a change to one feature cannot reach a plugin that doesn't use it
|
||||
|
||||
This is the property the naive merge destroys, and it is enforced structurally:
|
||||
|
||||
1. **Capabilities are separate modules composed by inheritance, not config
|
||||
branches inside the base class.** Hockey has no celebrations, so
|
||||
`HockeyLive` does not inherit `CelebrationMixin` — the celebration code is not
|
||||
merely disabled for hockey, it is *not in hockey's MRO at all*. No shared
|
||||
state, no dead branches, no risk. Contrast with
|
||||
`if self.celebrations_enabled:` inside `SportsLive`, where a bug in
|
||||
celebration code can still crash a plugin that never wanted the feature.
|
||||
|
||||
2. **Variant behavior is a strategy object chosen by name, not a branch.**
|
||||
Live rotation exists in three dialects across the lineages; core ships all
|
||||
three behind `rotation_strategy: "swrr" | "weighted" | "simple"` and a plugin
|
||||
may register its own. Core never learns sport names.
|
||||
|
||||
3. **Sport-specific behavior is a documented override point.** The base class
|
||||
declares the seam; the plugin fills it. Basketball's tournament-round parsing
|
||||
and baseball's BDF sizing stay in their plugins forever — they are not
|
||||
candidates for promotion, and core must never grow a branch for them.
|
||||
|
||||
4. **Files bound the blast radius.** Capabilities live in their own modules so a
|
||||
diff shows at a glance which plugins a change can reach.
|
||||
|
||||
## Layering
|
||||
|
||||
```
|
||||
src/base_classes/sports/
|
||||
__init__.py re-exports the public API (import path unchanged)
|
||||
core.py SportsCore — fetch, cache, config, logos, fonts, odds,
|
||||
view-model extraction, the skin seam
|
||||
modes.py SportsUpcoming / SportsRecent / SportsLive
|
||||
capabilities/
|
||||
celebrations.py CelebrationMixin (opt-in: 4 of 9 plugins)
|
||||
rotation.py RotationStrategy + registry
|
||||
|
||||
src/common/
|
||||
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration
|
||||
(content building stays in the plugins)
|
||||
```
|
||||
|
||||
`from src.base_classes.sports import SportsCore` keeps working — the package
|
||||
`__init__` re-exports, so the conversion is invisible to every existing importer.
|
||||
|
||||
## Override points (the plugin-facing seam)
|
||||
|
||||
The base class calls these; plugins implement or override them. This table is the
|
||||
contract — additions require a default implementation, removals require a
|
||||
deprecation cycle.
|
||||
|
||||
| Hook | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `_fetch_data()` | Sport's schedule source | abstract |
|
||||
| `_extract_game_details(event)` | Sport-specific view-model fields on top of the common ones | delegates to `_extract_game_details_common` |
|
||||
| `_draw_scorebug_layout(game, force_clear)` | Sport's card rendering | base layout |
|
||||
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
|
||||
| `render_skin_card(game, size)` | Skin-system entry point | built-in fallback |
|
||||
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
|
||||
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
|
||||
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["<side>_abbr"]` |
|
||||
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
|
||||
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
|
||||
|
||||
Two class attributes serve the same purpose for values that are per-sport
|
||||
constants rather than behavior:
|
||||
|
||||
| Attribute | Meaning | Default |
|
||||
|---|---|---|
|
||||
| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) |
|
||||
| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) |
|
||||
| `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) |
|
||||
|
||||
### Why these are seams and not branches
|
||||
|
||||
`_favorite_key` exists because NRL abbreviations are **not unique** — "NEW" is both
|
||||
Newcastle Knights and New Zealand Warriors, "CAN" both Canberra and Canterbury —
|
||||
so NRL matches favorites on team ID. Flattening every plugin to abbreviations
|
||||
would silently select the wrong club for NRL users. The base declares the seam,
|
||||
NRL fills it, and core never learns the string `"nrl"`.
|
||||
|
||||
`CLOCK_COUNTS_DOWN` exists for the same reason in the opposite direction: a
|
||||
soccer clock reading `0:00` means the match has not kicked off, so running the
|
||||
clock-expiry branch there would evict live games.
|
||||
|
||||
`COALESCE_SCORING_SEQUENCE` is the third of the same kind. In football one
|
||||
scoring play arrives as two score updates, so the follow-up must be folded into
|
||||
the first celebration; in soccer two increments a few seconds apart are two real
|
||||
goals, and folding them would swallow one. Neither default is "right" — which is
|
||||
precisely why it is a declared per-sport constant rather than a hidden
|
||||
assumption baked into the shared body.
|
||||
|
||||
## Capabilities
|
||||
|
||||
```
|
||||
capabilities/
|
||||
celebrations.py CelebrationMixin opt-in: afl, nrl, soccer, football
|
||||
rotation.py RotationStrategy + registry
|
||||
```
|
||||
|
||||
**`CelebrationMixin`** merges the two dialects the lineages grew
|
||||
(`_check_for_goal`/`celebrate_opponent_goals` vs
|
||||
`_check_for_score`/`celebrate_opponent_scores`). Their bodies were identical
|
||||
apart from three things, each now a seam: wording (`score_phrase`), follow-up
|
||||
suppression (`COALESCE_SCORING_SEQUENCE`), and team identity (`_favorite_key`,
|
||||
so NRL matches on id). Both config spellings are read, so a plugin adopting the
|
||||
mixin keeps working with the keys already in its published schema.
|
||||
|
||||
Mix it in **before** the mode class — `class SoccerLive(CelebrationMixin,
|
||||
SportsLive)` — so the celebration `display()` runs first and falls through to
|
||||
the scorebug via `super()`.
|
||||
|
||||
**Rotation strategies.** The three "dialects" turned out to be one algorithm
|
||||
(Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state
|
||||
across calls (afl/nrl/soccer) and a precomputed per-cycle list
|
||||
(football/baseball/basketball, and hockey with a different loop shape). They
|
||||
agree within a cycle and differ only at the boundary — the incremental form has
|
||||
no restart seam — so core ships both rather than declaring a winner:
|
||||
|
||||
```python
|
||||
self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight)
|
||||
```
|
||||
|
||||
`weight_for` is supplied by the host, so the *favorites* policy stays with the
|
||||
plugin and `rotation.py` never learns what a favorite is. An unknown strategy
|
||||
name degrades to `simple` rather than raising: the name comes from user config,
|
||||
and a typo should cost the boost, not the scoreboard. When a plugin needs an
|
||||
ordering that core does not ship, it calls `register_rotation_strategy` to add
|
||||
its own — rather than core growing a branch for it.
|
||||
|
||||
`test_sports_capabilities.py` checks each strategy against a **verbatim
|
||||
transcription** of the plugin code it replaces, over every live-game shape up to
|
||||
four games. That differential is what B5 deletes the bundled copies on the
|
||||
strength of.
|
||||
|
||||
## Scroll display — where the promotion line falls
|
||||
|
||||
`src/common/sports_scroll.py` is deliberately *not* a superset of the ten
|
||||
`scroll_display.py` copies. A method-level comparison of the eight that share a
|
||||
shape (f1 and ufc are genuine forks) found a sharp split:
|
||||
|
||||
| Layer | Evidence | Outcome |
|
||||
|---|---|---|
|
||||
| Orchestration — `get_all_vegas_content_items`, `clear_all`, `get_scroll_info`, `get_dynamic_duration`, `is_complete`, `display_frame` | identical to 96–100% similar across all eight | **promoted** |
|
||||
| Settings — `_get_scroll_settings` | one algorithm; the copies differ *only* in which league keys they walk | **promoted**, with the ladder as data (`SCROLL_LEAGUE_KEYS`) |
|
||||
| Content — `prepare_scroll_content`, `_load_separator_icons` | 8 distinct bodies across 8 plugins (145 lines, 53% similar at worst); icons 6% | **override point, permanently** |
|
||||
|
||||
Same name, different job: `prepare_scroll_content` draws *this sport's* game
|
||||
card. Merging the eight bodies would be the exact mistake the promotion rule
|
||||
exists to prevent, so the base class raises `NotImplementedError` rather than
|
||||
rendering something plausible — a base that rendered *something* would let a
|
||||
plugin ship a silently blank scroll.
|
||||
|
||||
The one behavior the upstreamed version adds is native
|
||||
`global_config['target_fps']` support. The bundled copies hardcode ~100 FPS via
|
||||
`scroll_delay = 0.01` and never consult the global smooth-scrolling target;
|
||||
Part A threaded it through each copy by hand, and this makes that threading
|
||||
legacy compatibility rather than the mechanism.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Scope | Risk control |
|
||||
|---|---|---|
|
||||
| **B0** ✅ | Characterization tests, CI unit job, `element_style`, font cwd fix, CHANGELOG discipline | — |
|
||||
| **B1** ✅ | Promote the nine universal methods; convert `sports.py` → package | Characterization suite must stay green; no behavior change intended |
|
||||
| **B2** ✅ | `CelebrationMixin` + rotation strategies as opt-in capabilities | Plugins that don't opt in 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 | Plugin copies remain until sunset; content building stays per-sport |
|
||||
| **B4** | Bump to 3.2.0, record modules in CHANGELOG, migrate `ledmatrix_min` → `ledmatrix_min_version` | Gives plugins a version to floor on |
|
||||
| **B5** ⏳ | Pilot one plugin per lineage (hockey, soccer, football) on core imports; then the remaining six; then delete bundled copies | Pilot soaks before rollout; harness + golden suites gate each |
|
||||
|
||||
**B5 is blocked on this PR merging and 3.2.0 shipping** — a plugin cannot floor
|
||||
`ledmatrix_min_version` at a release that does not exist, and an unguarded
|
||||
`src.common.sports_scroll` import would break every user on 3.1.0.
|
||||
|
||||
The hockey scroll-display pilot has been **validated ahead of that gate**:
|
||||
adopted against a core carrying 3.2.0, `scroll_display.py` went from 691 to 289
|
||||
lines and all 16 harness renders (8 sizes × 2 screens) came out byte-for-byte
|
||||
identical to the pre-adoption run. The adoption recipe and the two gotchas it
|
||||
surfaced are written up in the plugins repo's
|
||||
`docs/plugin-development/08-shared-sports-code.md`.
|
||||
|
||||
## Rules for contributors
|
||||
|
||||
- **Promote on evidence, not intuition.** A method moves to core when every copy
|
||||
has it and they agree on intent. Otherwise it stays in the plugins.
|
||||
- **Never add a sport name to core.** If core needs to know which sport it is,
|
||||
the design is wrong — add an override point instead.
|
||||
- **A capability that is not opted into must not execute.** If you find yourself
|
||||
writing `if self.<capability>_enabled` inside a base class, it belongs in a
|
||||
mixin.
|
||||
- **Touch the view-model keys only additively.** Published skins depend on them.
|
||||
- **Every promotion lands with the characterization suite green**, and every
|
||||
pilot adoption lands with that plugin's harness and golden suites green.
|
||||
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Vegas Mode Density Audit
|
||||
|
||||
Reports how much of the Vegas ticker is actually showing something. Loads the
|
||||
real enabled plugins, pulls each one's content through the real
|
||||
``PluginAdapter``, composes the strip through the real ``ScrollHelper``, then
|
||||
measures the result.
|
||||
|
||||
The headline number is the **dead-frame ratio**: the fraction of viewport
|
||||
positions across a full cycle that are effectively blank. Because the panel
|
||||
only ever shows ``display_width`` columns at a time, a blank stretch wider than
|
||||
the viewport is a stretch where the display looks switched off — so this ratio
|
||||
tracks perceived dead time rather than just counting unlit pixels.
|
||||
|
||||
Runs entirely off-hardware, so it is safe to run alongside a live display.
|
||||
|
||||
Usage:
|
||||
# Audit every enabled plugin at the display size from config.json
|
||||
python scripts/dev/vegas_audit.py
|
||||
|
||||
# Specific plugins, dump each segment as a PNG for eyeballing
|
||||
python scripts/dev/vegas_audit.py -p of-the-day,youtube-stats --dump-dir /tmp/vg
|
||||
|
||||
# Machine-readable, for before/after comparison
|
||||
python scripts/dev/vegas_audit.py --json > after.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# Must precede any src import that may reach for hardware.
|
||||
os.environ.setdefault('EMULATOR', 'true')
|
||||
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper # noqa: E402
|
||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
build_full_config,
|
||||
find_plugin_dir,
|
||||
load_manifest,
|
||||
)
|
||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||
from src.vegas_mode.geometry import ( # noqa: E402
|
||||
DEFAULT_INK_THRESHOLD,
|
||||
column_has_ink,
|
||||
content_bounds,
|
||||
dead_window_stats,
|
||||
window_coverage_stats,
|
||||
)
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402
|
||||
|
||||
# Sampling stride for the dead-window scan. A full cycle can be 30,000px wide;
|
||||
# 4px granularity keeps the scan instant while staying well under the ~10px a
|
||||
# single scroll step ever covers, so no dead stretch is missed.
|
||||
DEAD_SCAN_STEP = 4
|
||||
|
||||
|
||||
def load_main_config(path: Path) -> Dict[str, Any]:
|
||||
with open(path, 'r') as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def display_size_from_config(config: Dict[str, Any]) -> tuple:
|
||||
"""Derive the logical ticker size the way DisplayManager does."""
|
||||
hw = config.get('display', {}).get('hardware', {})
|
||||
cols = int(hw.get('cols', 64))
|
||||
chain = int(hw.get('chain_length', 1))
|
||||
rows = int(hw.get('rows', 32))
|
||||
parallel = int(hw.get('parallel', 1))
|
||||
return cols * chain, rows * parallel
|
||||
|
||||
|
||||
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
|
||||
"""Plugin IDs that are enabled in config, excluding non-plugin sections."""
|
||||
ids = []
|
||||
for key, value in config.items():
|
||||
if isinstance(value, dict) and value.get('enabled') is True:
|
||||
ids.append(key)
|
||||
return ids
|
||||
|
||||
|
||||
def instantiate(plugin_id: str, display_manager, cache_manager, plugin_manager):
|
||||
"""Load one plugin offline. Returns the instance or None."""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
search_dirs = [
|
||||
str(PROJECT_ROOT / 'plugin-repos'),
|
||||
str(PROJECT_ROOT / 'plugins'),
|
||||
]
|
||||
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
|
||||
if not plugin_dir:
|
||||
return None
|
||||
|
||||
try:
|
||||
manifest = load_manifest(Path(plugin_dir))
|
||||
cfg = build_full_config(Path(plugin_dir))
|
||||
instance, _ = PluginLoader().load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=Path(plugin_dir),
|
||||
config=cfg,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
plugin_manager=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
return instance
|
||||
except Exception as exc: # noqa: BLE001 - audit tool must survive any plugin
|
||||
print(f" ! {plugin_id}: load failed ({type(exc).__name__}: {exc})",
|
||||
file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def join_rows(images: List[Image.Image], gap: int) -> Image.Image:
|
||||
"""Concatenate one plugin's rows, matching RenderPipeline._join_plugin_rows."""
|
||||
if len(images) == 1:
|
||||
return images[0]
|
||||
gap = max(0, gap)
|
||||
width = sum(img.width for img in images) + gap * (len(images) - 1)
|
||||
height = max(img.height for img in images)
|
||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
x = 0
|
||||
for img in images:
|
||||
block.paste(img, (x, 0))
|
||||
x += img.width + gap
|
||||
return block
|
||||
|
||||
|
||||
def measure_segment(images: List[Image.Image], display_width: int,
|
||||
scroll_speed: float, threshold: int) -> Dict[str, Any]:
|
||||
"""Geometry of one plugin's contribution to the ticker."""
|
||||
total_width = sum(img.width for img in images)
|
||||
combined = Image.new('RGB', (max(1, total_width), images[0].height))
|
||||
x = 0
|
||||
for img in images:
|
||||
combined.paste(img, (x, 0))
|
||||
x += img.width
|
||||
|
||||
ink = column_has_ink(combined, threshold)
|
||||
bounds = content_bounds(combined, threshold)
|
||||
ink_cols = int(ink.sum())
|
||||
|
||||
return {
|
||||
'images': len(images),
|
||||
'width_px': total_width,
|
||||
'ink_cols': ink_cols,
|
||||
'ink_pct': round(100.0 * ink_cols / total_width, 1) if total_width else 0.0,
|
||||
'lead_black_px': bounds[0] if bounds else total_width,
|
||||
'trail_black_px': (total_width - 1 - bounds[1]) if bounds else 0,
|
||||
'seconds_on_screen': round(total_width / scroll_speed, 1) if scroll_speed else 0.0,
|
||||
'widths': [img.width for img in images],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Audit Vegas mode content density')
|
||||
parser.add_argument('--config', default=str(PROJECT_ROOT / 'config' / 'config.json'),
|
||||
help='Path to main config.json')
|
||||
parser.add_argument('-p', '--plugins', default=None,
|
||||
help='Comma-separated plugin IDs (default: all enabled)')
|
||||
parser.add_argument('--width', type=int, default=None,
|
||||
help='Override display width (default: from config hardware)')
|
||||
parser.add_argument('--height', type=int, default=None,
|
||||
help='Override display height (default: from config hardware)')
|
||||
parser.add_argument('--dump-dir', default=None,
|
||||
help='Write each segment and the composed strip as PNGs here')
|
||||
parser.add_argument('--threshold', type=int, default=DEFAULT_INK_THRESHOLD,
|
||||
help=f'Ink threshold (default: {DEFAULT_INK_THRESHOLD})')
|
||||
parser.add_argument('--per-cycle', type=int, default=None,
|
||||
help='Plugins composed per cycle '
|
||||
'(default: buffer_ahead + 1, matching production)')
|
||||
parser.add_argument('--json', action='store_true',
|
||||
help='Emit JSON instead of a text report')
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_main_config(Path(args.config))
|
||||
vegas = VegasModeConfig.from_config(config)
|
||||
|
||||
cfg_w, cfg_h = display_size_from_config(config)
|
||||
width = args.width or cfg_w
|
||||
height = args.height or cfg_h
|
||||
speed = vegas.scroll_speed
|
||||
|
||||
if args.plugins:
|
||||
plugin_ids = [p.strip() for p in args.plugins.split(',') if p.strip()]
|
||||
else:
|
||||
plugin_ids = vegas.get_ordered_plugins(enabled_plugin_ids(config))
|
||||
|
||||
dump_dir = Path(args.dump_dir) if args.dump_dir else None
|
||||
if dump_dir:
|
||||
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from src.plugin_system.testing import (
|
||||
MockCacheManager, MockPluginManager, VisualTestDisplayManager,
|
||||
)
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
# Pass the loaded config, exactly as VegasModeCoordinator does. Omitting it
|
||||
# makes PluginAdapter fall back to VegasModeConfig() defaults, so the audit
|
||||
# would silently report trimming and width-budget behaviour that differs
|
||||
# from the user's config.json — the same drift the lead_gap and grouping
|
||||
# arguments below exist to avoid.
|
||||
adapter = PluginAdapter(display_manager, vegas)
|
||||
|
||||
if not args.json:
|
||||
print(f"Vegas audit — display {width}x{height}, scroll {speed:g}px/s, "
|
||||
f"separator {vegas.separator_width}px")
|
||||
print(f"One display width = {width / speed:.1f}s of screen time\n")
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
segments: List[Image.Image] = []
|
||||
|
||||
for plugin_id in plugin_ids:
|
||||
started = time.time()
|
||||
instance = instantiate(plugin_id, display_manager, cache_manager, plugin_manager)
|
||||
if instance is None:
|
||||
results.append({'plugin': plugin_id, 'status': 'load_failed'})
|
||||
continue
|
||||
|
||||
plugin_manager.plugins[plugin_id] = instance
|
||||
adapter.invalidate_cache(plugin_id)
|
||||
|
||||
try:
|
||||
images = adapter.get_content(instance, plugin_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
results.append({'plugin': plugin_id, 'status': 'fetch_error',
|
||||
'error': f'{type(exc).__name__}: {exc}'})
|
||||
continue
|
||||
|
||||
fetch_ms = round((time.time() - started) * 1000)
|
||||
|
||||
if not images:
|
||||
results.append({'plugin': plugin_id, 'status': 'no_content',
|
||||
'fetch_ms': fetch_ms})
|
||||
if not args.json:
|
||||
print(f" {plugin_id:28s} NO CONTENT ({fetch_ms}ms)")
|
||||
continue
|
||||
|
||||
entry = {'plugin': plugin_id, 'status': 'ok', 'fetch_ms': fetch_ms}
|
||||
entry.update(measure_segment(images, width, speed, args.threshold))
|
||||
results.append(entry)
|
||||
segments.extend(images)
|
||||
|
||||
if dump_dir:
|
||||
for idx, img in enumerate(images):
|
||||
img.save(dump_dir / f"{plugin_id}__{idx:02d}.png")
|
||||
|
||||
if not args.json:
|
||||
print(f" {plugin_id:28s} {entry['width_px']:>6d}px "
|
||||
f"{entry['images']:>2d} img ink {entry['ink_pct']:>5.1f}% "
|
||||
f"lead {entry['lead_black_px']:>4d} tail {entry['trail_black_px']:>4d} "
|
||||
f"{entry['seconds_on_screen']:>6.1f}s ({fetch_ms}ms)")
|
||||
|
||||
summary: Dict[str, Any] = {
|
||||
'display_width': width,
|
||||
'display_height': height,
|
||||
'scroll_speed': speed,
|
||||
'separator_width': vegas.separator_width,
|
||||
'plugins_audited': len(plugin_ids),
|
||||
'plugins_with_content': sum(1 for r in results if r.get('status') == 'ok'),
|
||||
}
|
||||
|
||||
# Production composes only the plugins sitting in the active buffer, so
|
||||
# measuring one giant strip of every plugin would hide the per-cycle costs
|
||||
# (most importantly the leading gap, which is charged once per cycle).
|
||||
# Group the segments the way the running service does.
|
||||
per_cycle = max(1, args.per_cycle or vegas.plugins_per_cycle)
|
||||
|
||||
cycles: List[Dict[str, Any]] = []
|
||||
with_content = [r for r in results if r.get('status') == 'ok']
|
||||
|
||||
if segments:
|
||||
logger = logging.getLogger('vegas_audit')
|
||||
seg_index = 0
|
||||
for start in range(0, len(with_content), per_cycle):
|
||||
group = with_content[start:start + per_cycle]
|
||||
|
||||
# Mirror RenderPipeline: each plugin's rows are joined by
|
||||
# intra_plugin_gap into one block, and separator_width is applied
|
||||
# only between blocks. Measuring a flat list here would report gaps
|
||||
# the service does not emit.
|
||||
blocks: List[Image.Image] = []
|
||||
for entry in group:
|
||||
count = entry['images']
|
||||
rows = segments[seg_index:seg_index + count]
|
||||
seg_index += count
|
||||
if rows:
|
||||
blocks.append(join_rows(rows, vegas.intra_plugin_gap))
|
||||
if not blocks:
|
||||
continue
|
||||
|
||||
# ScrollHelper logs unconditionally, so it needs a real logger.
|
||||
helper = ScrollHelper(width, height, logger)
|
||||
helper.create_scrolling_image(
|
||||
content_items=blocks,
|
||||
item_gap=vegas.separator_width,
|
||||
element_gap=0,
|
||||
# Must match RenderPipeline. Omitting this made the audit
|
||||
# measure a full-display-width leading gap the service no
|
||||
# longer emits, overstating dead space by 512px per cycle.
|
||||
lead_gap=vegas.lead_in_width,
|
||||
)
|
||||
composed = helper.cached_image
|
||||
if composed is None:
|
||||
continue
|
||||
|
||||
dead = dead_window_stats(composed, width, args.threshold, step=DEAD_SCAN_STEP)
|
||||
cover = window_coverage_stats(
|
||||
composed, width, args.threshold, step=DEAD_SCAN_STEP)
|
||||
|
||||
if dump_dir:
|
||||
composed.save(dump_dir / f"_cycle{len(cycles):02d}.png")
|
||||
|
||||
cycles.append({
|
||||
'plugins': [e['plugin'] for e in group],
|
||||
'width_px': composed.width,
|
||||
'seconds': round(composed.width / speed, 1) if speed else 0.0,
|
||||
'dead_pct': round(100 * dead.dead_ratio, 1),
|
||||
'longest_dead_seconds': round(
|
||||
dead.longest_dead_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
|
||||
'mean_ink_pct': round(100 * cover.mean_ink_ratio, 1),
|
||||
'sparse_pct': round(100 * cover.sparse_ratio, 1),
|
||||
'longest_sparse_seconds': round(
|
||||
cover.longest_sparse_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
|
||||
})
|
||||
|
||||
if cycles:
|
||||
total_px = sum(c['width_px'] for c in cycles)
|
||||
# Weight each cycle by its width so a long cycle counts proportionally.
|
||||
summary.update({
|
||||
'cycles': len(cycles),
|
||||
'total_px': total_px,
|
||||
'full_rotation_seconds': round(total_px / speed, 1) if speed else 0.0,
|
||||
'dead_pct': round(
|
||||
sum(c['dead_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
||||
'mean_ink_pct': round(
|
||||
sum(c['mean_ink_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
||||
'sparse_pct': round(
|
||||
sum(c['sparse_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
||||
'worst_dead_seconds': max(c['longest_dead_seconds'] for c in cycles),
|
||||
'worst_sparse_seconds': max(c['longest_sparse_seconds'] for c in cycles),
|
||||
})
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({'summary': summary, 'cycles': cycles, 'plugins': results},
|
||||
indent=2))
|
||||
else:
|
||||
print(f"\n Cycles ({per_cycle} plugins each, as production composes them):")
|
||||
for idx, cyc in enumerate(cycles):
|
||||
print(f" [{idx}] {cyc['width_px']:>6d}px {cyc['seconds']:>6.1f}s "
|
||||
f"ink {cyc['mean_ink_pct']:>5.1f}% blank {cyc['dead_pct']:>5.1f}% "
|
||||
f"worst blank {cyc['longest_dead_seconds']:>5.1f}s "
|
||||
f"| {', '.join(cyc['plugins'])}")
|
||||
|
||||
print(f"\n {'-' * 66}")
|
||||
print(f" full rotation {summary.get('full_rotation_seconds', 0):>7.1f}s "
|
||||
f"over {summary.get('cycles', 0)} cycles")
|
||||
print(f" mean ink coverage {summary.get('mean_ink_pct', 0):>7.1f}% "
|
||||
f"(higher is better; target >25%)")
|
||||
print(f" fully blank {summary.get('dead_pct', 0):>7.1f}% (target <2%)")
|
||||
print(f" reads as empty {summary.get('sparse_pct', 0):>7.1f}% (target <15%)")
|
||||
print(f" worst blank stretch {summary.get('worst_dead_seconds', 0):>7.1f}s "
|
||||
f"(target <1.5s)")
|
||||
print(f" plugins w/ content {summary.get('plugins_with_content', 0):>7d}"
|
||||
f" of {summary['plugins_audited']}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "3.1.0"
|
||||
__version__ = "3.2.0"
|
||||
|
||||
|
||||
@@ -151,7 +151,12 @@ class Baseball(SportsCore):
|
||||
|
||||
# Only log detailed information for favorite teams
|
||||
if is_favorite_game:
|
||||
self.logger.debug(f"Full status data: {game_event['status']}")
|
||||
# Use the validated competition-level `status` here too. MiLB
|
||||
# events carry no event-level one, so this debug line raised a
|
||||
# KeyError and dropped the very games it was meant to help
|
||||
# diagnose -- and only for favourites, which is the worst way
|
||||
# for it to fail.
|
||||
self.logger.debug(f"Full status data: {status}")
|
||||
self.logger.debug(f"Status type: {game_status}, State: {status_state}")
|
||||
self.logger.debug(f"Status detail: {status['type'].get('detail', '')}")
|
||||
self.logger.debug(
|
||||
@@ -164,7 +169,13 @@ class Baseball(SportsCore):
|
||||
# Get game state information
|
||||
if status_state == "in":
|
||||
# For live games, get detailed state
|
||||
inning = game_event["status"].get(
|
||||
# Use the competition-level `status` already validated by
|
||||
# _extract_game_details_common. Real ESPN events duplicate
|
||||
# status at the event top level, but MiLB events (synthesized
|
||||
# from the MLB Stats API into an ESPN-like shape) populate
|
||||
# only the competition-level one, so the top-level lookup
|
||||
# raised a bare KeyError and dropped the event.
|
||||
inning = status.get(
|
||||
"period", 1
|
||||
) # Get inning from status period
|
||||
|
||||
@@ -187,7 +198,7 @@ class Baseball(SportsCore):
|
||||
if "end" in status_detail or "end" in status_short:
|
||||
inning_half = "top"
|
||||
inning = (
|
||||
game_event["status"].get("period", 1) + 1
|
||||
status.get("period", 1) + 1
|
||||
) # Use period and increment for next inning
|
||||
if is_favorite_game:
|
||||
self.logger.debug(
|
||||
|
||||
@@ -38,10 +38,17 @@ class Hockey(SportsCore):
|
||||
status = competition["status"]
|
||||
powerplay = False
|
||||
penalties = ""
|
||||
# A competitor may legitimately arrive without a "statistics"
|
||||
# array (pre-game feeds, and some in-progress ones). Reading it
|
||||
# unguarded raised KeyError inside the generator and dropped the
|
||||
# WHOLE event, discarding valid scores and status. Default to an
|
||||
# empty list so the saves/shots figures fall back to 0 instead.
|
||||
home_stats = home_team.get("statistics", [])
|
||||
away_stats = away_team.get("statistics", [])
|
||||
home_team_saves = next(
|
||||
(
|
||||
int(c["displayValue"])
|
||||
for c in home_team["statistics"]
|
||||
for c in home_stats
|
||||
if c.get("name") == "saves"
|
||||
),
|
||||
0,
|
||||
@@ -49,7 +56,7 @@ class Hockey(SportsCore):
|
||||
home_team_saves_per = next(
|
||||
(
|
||||
float(c["displayValue"])
|
||||
for c in home_team["statistics"]
|
||||
for c in home_stats
|
||||
if c.get("name") == "savePct"
|
||||
),
|
||||
0.0,
|
||||
@@ -57,7 +64,7 @@ class Hockey(SportsCore):
|
||||
away_team_saves = next(
|
||||
(
|
||||
int(c["displayValue"])
|
||||
for c in away_team["statistics"]
|
||||
for c in away_stats
|
||||
if c.get("name") == "saves"
|
||||
),
|
||||
0,
|
||||
@@ -65,7 +72,7 @@ class Hockey(SportsCore):
|
||||
away_team_saves_per = next(
|
||||
(
|
||||
float(c["displayValue"])
|
||||
for c in away_team["statistics"]
|
||||
for c in away_stats
|
||||
if c.get("name") == "savePct"
|
||||
),
|
||||
0.0,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Sports scoreboard base classes.
|
||||
|
||||
Formerly the single module ``src/base_classes/sports.py``; now a package so
|
||||
capabilities can be composed instead of accumulating in one class. See
|
||||
docs/SPORTS_UNIFICATION.md for the architecture. The import path is
|
||||
unchanged: ``from src.base_classes.sports import SportsCore`` still works.
|
||||
"""
|
||||
|
||||
from .core import SportsCore
|
||||
from .modes import SportsLive, SportsRecent, SportsUpcoming
|
||||
|
||||
__all__ = [
|
||||
"SportsCore",
|
||||
"SportsUpcoming",
|
||||
"SportsRecent",
|
||||
"SportsLive",
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Opt-in capabilities for the sports scoreboards.
|
||||
|
||||
Each module here is a feature that only *some* sports want. They are composed
|
||||
by inheritance (mixins) or selected by name (strategies) — never enabled by an
|
||||
``if self.<feature>_enabled:`` branch inside the base classes.
|
||||
|
||||
The distinction matters: hockey has no celebrations, so ``HockeyLive`` does not
|
||||
inherit :class:`~.celebrations.CelebrationMixin` and the celebration code is not
|
||||
in hockey's MRO at all. A bug in it cannot reach a plugin that never opted in.
|
||||
|
||||
See ``docs/SPORTS_UNIFICATION.md`` for the full rationale.
|
||||
"""
|
||||
|
||||
from .celebrations import CelebrationMixin
|
||||
from .rotation import (
|
||||
RotationStrategy,
|
||||
SimpleRotation,
|
||||
SmoothWeightedRotation,
|
||||
WeightedCycleRotation,
|
||||
get_rotation_strategy,
|
||||
register_rotation_strategy,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CelebrationMixin",
|
||||
"RotationStrategy",
|
||||
"SimpleRotation",
|
||||
"SmoothWeightedRotation",
|
||||
"WeightedCycleRotation",
|
||||
"get_rotation_strategy",
|
||||
"register_rotation_strategy",
|
||||
]
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Score / win celebration takeover — an opt-in capability.
|
||||
|
||||
Four of the nine scoreboards celebrate (afl, nrl, soccer, football); the other
|
||||
five do not. This is a **mixin** rather than a flag inside ``SportsLive`` so the
|
||||
five that do not opt in have none of this code in their MRO: a bug here cannot
|
||||
reach hockey, and hockey's config never grows keys it ignores.
|
||||
|
||||
Usage — mix in *before* the mode class so its ``display`` runs first::
|
||||
|
||||
class SoccerLive(CelebrationMixin, SportsLive):
|
||||
def score_phrase(self, points, team_abbr):
|
||||
return secrets.choice(("GOOOOAAALLL!", f"{team_abbr} SCORES!"))
|
||||
|
||||
The two lineages spelled this differently (``_check_for_goal`` /
|
||||
``celebrate_opponent_goals`` in the soccer lineage, ``_check_for_score`` /
|
||||
``celebrate_opponent_scores`` in football) but the bodies were identical apart
|
||||
from three things, each of which is a seam here rather than a branch:
|
||||
|
||||
* **wording** — :meth:`score_phrase`, the hook football uses to say "TOUCHDOWN"
|
||||
from the points delta and soccer uses to say "GOOOOAAALLL";
|
||||
* **follow-up suppression** — :attr:`COALESCE_SCORING_SEQUENCE`, on for football
|
||||
where a touchdown lands as +6 then +1 a few seconds later, off elsewhere where
|
||||
two quick goals are two real events;
|
||||
* **team identity** — matching goes through ``_favorite_key``, so nrl can match
|
||||
on team id (its abbreviations are ambiguous) without core knowing why.
|
||||
|
||||
The config keys are read under both spellings, so a plugin adopting the mixin
|
||||
keeps working with the ``*_goals`` keys already in its published schema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
class CelebrationMixin:
|
||||
"""Full-screen takeover when a tracked team scores or wins."""
|
||||
|
||||
#: Collapse increments that land while a celebration is already on screen
|
||||
#: into that one celebration. True for sports where a single scoring play
|
||||
#: arrives as more than one score update (football: touchdown +6, then the
|
||||
#: extra point +1). False where consecutive increments are distinct events —
|
||||
#: suppressing there would swallow a real goal.
|
||||
COALESCE_SCORING_SEQUENCE = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
mode_config = getattr(self, "mode_config", {}) or {}
|
||||
self.celebration_enabled = mode_config.get("celebration_enabled", True)
|
||||
# Coerced and floored at init: this value is compared numerically on the
|
||||
# display path, where a string from a hand-edited config would raise
|
||||
# TypeError outside any try block, and a zero or negative value would
|
||||
# arm a celebration that can never render.
|
||||
raw_duration = mode_config.get("celebration_duration", 8)
|
||||
try:
|
||||
self.celebration_duration = max(1.0, float(raw_duration))
|
||||
except (TypeError, ValueError):
|
||||
self.logger.warning(
|
||||
"[Celebrations] Unusable celebration_duration %r; using 8s. "
|
||||
"Set a positive number of seconds.",
|
||||
raw_duration,
|
||||
)
|
||||
self.celebration_duration = 8.0
|
||||
# Both spellings: the soccer lineage ships `celebrate_opponent_goals`,
|
||||
# football ships `celebrate_opponent_scores`. Whichever the plugin's
|
||||
# schema declares is the one its users have set.
|
||||
self.celebrate_opponent_scores = mode_config.get(
|
||||
"celebrate_opponent_scores",
|
||||
mode_config.get("celebrate_opponent_goals", False),
|
||||
)
|
||||
# Per-game score baselines: {game_id: {"away": int, "home": int}}
|
||||
self._score_baselines: Dict[str, Dict[str, int]] = {}
|
||||
# The active celebration (a game *snapshot*, so a win survives the game
|
||||
# leaving live_games) or None. See _start_celebration for the shape.
|
||||
self.active_celebration: Optional[Dict[str, Any]] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Override points
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def score_phrase(self, points: int, team_abbr: str) -> str:
|
||||
"""The wording for a score celebration.
|
||||
|
||||
``points`` is the score delta that triggered it, which sports with
|
||||
variable-value scores use to name the play. The default is deliberately
|
||||
sport-neutral; every celebrating plugin overrides it.
|
||||
"""
|
||||
return f"{team_abbr} SCORES!"
|
||||
|
||||
def win_phrase(self, team_abbr: str) -> str:
|
||||
"""The wording for a win celebration."""
|
||||
return f"{team_abbr} WINS!"
|
||||
|
||||
def _is_favorite(self, key: Optional[str]) -> bool:
|
||||
"""Whether ``key`` (whatever ``_favorite_key`` returns) is a favorite."""
|
||||
return bool(self.favorite_teams) and key in self.favorite_teams
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _score_to_int(score) -> Optional[int]:
|
||||
"""Coerce an ESPN score value (str / int / dict) to an int, or None."""
|
||||
try:
|
||||
if score is None:
|
||||
return None
|
||||
if isinstance(score, str):
|
||||
s = score.strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return int(float(s))
|
||||
except ValueError:
|
||||
numbers = re.findall(r"\d+", s)
|
||||
return int(numbers[0]) if numbers else None
|
||||
if isinstance(score, dict):
|
||||
return int(float(score.get("value", score.get("displayValue", 0))))
|
||||
return int(float(score))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _should_celebrate_for(self, game: Dict, side: str) -> bool:
|
||||
"""Whether a score by ``side`` in ``game`` should trigger a celebration."""
|
||||
if self._is_favorite(self._favorite_key(game, side)):
|
||||
return True
|
||||
if not self.favorite_teams:
|
||||
# No favorites configured: the user opted to show this game, so
|
||||
# celebrate any score in it.
|
||||
return True
|
||||
# Favorites exist but this team isn't one -> it's the opponent.
|
||||
return self.celebrate_opponent_scores
|
||||
|
||||
def prune_score_baselines(self, live_games: List[Dict]) -> None:
|
||||
"""Drop baselines for games no longer live.
|
||||
|
||||
Only :meth:`_check_for_win` removes entries, and it only fires for games
|
||||
seen to go final. A game that vanishes from the live list any other way
|
||||
— postponed, dropped by the feed, or simply still live when the board
|
||||
restarts — leaves its baseline behind forever, so on a board that runs
|
||||
all season the dict grows without bound.
|
||||
|
||||
Call this from ``update()`` with the current live set, alongside the
|
||||
equivalent pruning in :meth:`SmoothWeightedRotation.next_game`.
|
||||
"""
|
||||
live_ids = {g.get("id") for g in live_games}
|
||||
self._score_baselines = {
|
||||
gid: baseline
|
||||
for gid, baseline in self._score_baselines.items()
|
||||
if gid in live_ids
|
||||
}
|
||||
|
||||
def has_active_celebration(self) -> bool:
|
||||
"""True while a celebration is within its display window."""
|
||||
celebration = self.active_celebration
|
||||
return bool(celebration) and (
|
||||
time.time() - celebration["started_at"] < self.celebration_duration
|
||||
)
|
||||
|
||||
def _check_for_score(self, game: Dict) -> None:
|
||||
"""Compare a live game's score against its baseline and arm a
|
||||
celebration when a celebratable team's score increases."""
|
||||
if not self.celebration_enabled:
|
||||
return
|
||||
game_id = game.get("id")
|
||||
if not game_id:
|
||||
return
|
||||
away = self._score_to_int(game.get("away_score"))
|
||||
home = self._score_to_int(game.get("home_score"))
|
||||
if away is None or home is None:
|
||||
return
|
||||
|
||||
baseline = self._score_baselines.get(game_id)
|
||||
# Always refresh the baseline: a first sighting must never celebrate (a
|
||||
# game already in progress at boot would false-fire), and a decrement
|
||||
# (VAR, a correction) just re-bases silently.
|
||||
self._score_baselines[game_id] = {"away": away, "home": home}
|
||||
if baseline is None:
|
||||
return
|
||||
|
||||
away_delta = away - baseline["away"]
|
||||
home_delta = home - baseline["home"]
|
||||
if away_delta <= 0 and home_delta <= 0:
|
||||
return
|
||||
|
||||
# One takeover per scoring sequence, where the sport has such a thing.
|
||||
# The baseline is already advanced above, so nothing re-fires later.
|
||||
if self.COALESCE_SCORING_SEQUENCE and self.has_active_celebration():
|
||||
return
|
||||
|
||||
scored_side = None
|
||||
points = 0
|
||||
if away_delta > 0 and self._should_celebrate_for(game, "away"):
|
||||
scored_side, points = "away", away_delta
|
||||
if scored_side is None and home_delta > 0 and self._should_celebrate_for(
|
||||
game, "home"
|
||||
):
|
||||
scored_side, points = "home", home_delta
|
||||
if scored_side is None:
|
||||
return
|
||||
|
||||
self._start_celebration(
|
||||
game,
|
||||
"score",
|
||||
scored_side=scored_side,
|
||||
team_abbr=game.get(f"{scored_side}_abbr", ""),
|
||||
away_score=away,
|
||||
home_score=home,
|
||||
points=points,
|
||||
)
|
||||
|
||||
def _check_for_win(self, game: Dict) -> None:
|
||||
"""When a game we were tracking live goes final, arm a win celebration
|
||||
if a favorite won. Fires at most once per game."""
|
||||
if not self.celebration_enabled:
|
||||
return
|
||||
game_id = game.get("id")
|
||||
if not game_id:
|
||||
return
|
||||
# Only celebrate wins for games we actually watched go live: one seen
|
||||
# for the first time already-final (the board started after full time)
|
||||
# has no baseline and must not fire.
|
||||
if game_id not in self._score_baselines:
|
||||
return
|
||||
# Consume the baseline so this can only fire once.
|
||||
self._score_baselines.pop(game_id, None)
|
||||
|
||||
away = self._score_to_int(game.get("away_score"))
|
||||
home = self._score_to_int(game.get("home_score"))
|
||||
if away is None or home is None:
|
||||
return
|
||||
|
||||
if away > home:
|
||||
winner_side = "away"
|
||||
elif home > away:
|
||||
winner_side = "home"
|
||||
else:
|
||||
return # draw -> no win celebration
|
||||
|
||||
# Wins are gated strictly on favorites: every game ends, so the
|
||||
# "no favorites -> celebrate all" score fallback would be far too noisy.
|
||||
if not self._is_favorite(self._favorite_key(game, winner_side)):
|
||||
return
|
||||
|
||||
self._start_celebration(
|
||||
game,
|
||||
"win",
|
||||
scored_side=winner_side,
|
||||
team_abbr=game.get(f"{winner_side}_abbr", ""),
|
||||
away_score=away,
|
||||
home_score=home,
|
||||
)
|
||||
|
||||
def _start_celebration(
|
||||
self,
|
||||
game: Dict,
|
||||
kind: str,
|
||||
scored_side: str,
|
||||
team_abbr: str,
|
||||
away_score: int,
|
||||
home_score: int,
|
||||
points: int = 0,
|
||||
) -> None:
|
||||
"""Arm a celebration. ``scored_side`` ('away'/'home') is the side whose
|
||||
score digit gets highlighted."""
|
||||
phrase = (
|
||||
self.win_phrase(team_abbr)
|
||||
if kind == "win"
|
||||
else self.score_phrase(points, team_abbr)
|
||||
)
|
||||
|
||||
self.active_celebration = {
|
||||
"kind": kind,
|
||||
"game": dict(game), # snapshot: survives the game leaving live_games
|
||||
"scored_side": scored_side,
|
||||
"team_abbr": team_abbr,
|
||||
"away_score": away_score,
|
||||
"home_score": home_score,
|
||||
"started_at": time.time(),
|
||||
"phrase": phrase,
|
||||
}
|
||||
# Pin focus to the involved game so the post-celebration scorebug
|
||||
# resumes on it.
|
||||
self.current_game = dict(game)
|
||||
self.logger.info(
|
||||
f"[Celebrations] {kind} armed: {phrase} "
|
||||
f"[{game.get('away_abbr')} {away_score}-{home_score} {game.get('home_abbr')}]"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fit_font(self, draw, text: str, max_width: int, fonts: List):
|
||||
"""The first font whose rendered ``text`` fits ``max_width``, falling
|
||||
back to the last (smallest) font."""
|
||||
for font in fonts:
|
||||
if draw.textlength(text, font=font) <= max_width - 2:
|
||||
return font
|
||||
return fonts[-1]
|
||||
|
||||
def _draw_celebration_layout(
|
||||
self, celebration: Dict, force_clear: bool = False
|
||||
) -> None:
|
||||
"""Render the full-screen score/win takeover."""
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
display_width = (
|
||||
self.display_manager.matrix.width
|
||||
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
|
||||
else self.display_width
|
||||
)
|
||||
display_height = (
|
||||
self.display_manager.matrix.height
|
||||
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
|
||||
else self.display_height
|
||||
)
|
||||
|
||||
elapsed = time.time() - celebration["started_at"]
|
||||
game = celebration["game"]
|
||||
|
||||
# Background: a brief color flash for the first ~1.2s, then black.
|
||||
bg = (0, 0, 0, 255)
|
||||
if elapsed < 1.2 and int(elapsed / 0.2) % 2 == 0:
|
||||
bg = (12, 12, 48, 255)
|
||||
main_img = Image.new("RGBA", (display_width, display_height), bg)
|
||||
overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
|
||||
# Logos at the edges (best-effort: a logo failure must not blank the
|
||||
# celebration).
|
||||
try:
|
||||
center_y = display_height // 2
|
||||
home_logo = self._load_and_resize_logo(
|
||||
game.get("home_id"), game.get("home_abbr"),
|
||||
game.get("home_logo_path"), game.get("home_logo_url"),
|
||||
)
|
||||
away_logo = self._load_and_resize_logo(
|
||||
game.get("away_id"), game.get("away_abbr"),
|
||||
game.get("away_logo_path"), game.get("away_logo_url"),
|
||||
)
|
||||
if home_logo:
|
||||
main_img.paste(
|
||||
home_logo,
|
||||
(display_width - home_logo.width + 2, center_y - home_logo.height // 2),
|
||||
home_logo,
|
||||
)
|
||||
if away_logo:
|
||||
main_img.paste(
|
||||
away_logo, (-2, center_y - away_logo.height // 2), away_logo
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"[Celebrations] Logo load failed: {e}")
|
||||
|
||||
# Phrase across the top, shrunk to fit the panel width.
|
||||
phrase = celebration["phrase"]
|
||||
phrase_font = self._fit_font(
|
||||
draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]]
|
||||
)
|
||||
phrase_width = draw.textlength(phrase, font=phrase_font)
|
||||
self._draw_text_with_outline(
|
||||
draw, phrase, ((display_width - phrase_width) // 2, 1), phrase_font
|
||||
)
|
||||
|
||||
# Score centered low, with the scoring/winning side's digit pulsing in a
|
||||
# highlight color so the change reads at a glance.
|
||||
away_text = str(celebration["away_score"])
|
||||
home_text = str(celebration["home_score"])
|
||||
score_font = self.fonts["score"]
|
||||
segments = [
|
||||
(away_text, celebration["scored_side"] == "away"),
|
||||
("-", False),
|
||||
(home_text, celebration["scored_side"] == "home"),
|
||||
]
|
||||
total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments)
|
||||
highlight = (255, 255, 0) if int(elapsed * 4) % 2 == 0 else (255, 170, 0)
|
||||
x = (display_width - total_width) // 2
|
||||
y = display_height - 14
|
||||
for seg, is_highlight in segments:
|
||||
color = highlight if is_highlight else (255, 255, 255)
|
||||
self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color)
|
||||
x += draw.textlength(seg, font=score_font)
|
||||
|
||||
main_img = Image.alpha_composite(main_img, overlay).convert("RGB")
|
||||
self.display_manager.image = main_img
|
||||
self.display_manager.update_display()
|
||||
|
||||
def display(self, force_clear: bool = False) -> bool:
|
||||
"""Render an active celebration as a full-screen takeover; otherwise
|
||||
defer to the normal live scorebug."""
|
||||
if not self.is_enabled:
|
||||
return False
|
||||
celebration = self.active_celebration
|
||||
if celebration:
|
||||
if self.has_active_celebration():
|
||||
try:
|
||||
self._draw_celebration_layout(celebration, force_clear)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"[Celebrations] Error drawing celebration: {e}", exc_info=True
|
||||
)
|
||||
# Disarm rather than retry: the same render would fail on
|
||||
# every frame for the rest of the window, logging a
|
||||
# traceback each time and leaving the scorebug off screen.
|
||||
self.active_celebration = None
|
||||
self.last_game_switch = time.time()
|
||||
else:
|
||||
self.active_celebration = None
|
||||
# Reset the dwell so the scorebug resumes on the scoring/winning
|
||||
# game for a full duration before rotation can move on.
|
||||
self.last_game_switch = time.time()
|
||||
return super().display(force_clear)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Live-rotation strategies — which live game to show next.
|
||||
|
||||
The nine plugin copies grew three spellings of this, and the survey behind
|
||||
``docs/SPORTS_UNIFICATION.md`` found they are all the *same* Smooth Weighted
|
||||
Round-Robin algorithm in two shapes:
|
||||
|
||||
* an **incremental picker** that holds weight state across calls and answers
|
||||
"what next?" one game at a time (afl / nrl / soccer's ``_swrr_advance``), and
|
||||
* a **precomputed cycle** that returns a full list of game ids up front
|
||||
(football / baseball / basketball's ``_build_weighted_schedule`` and hockey's
|
||||
``_build_rotation_schedule``, which differ only in loop shape).
|
||||
|
||||
They agree *within* a cycle — SWRR is deterministic — and differ only at cycle
|
||||
boundaries, where the incremental form has no seam and the precomputed form
|
||||
restarts. That is a real behavioral difference, so core ships both rather than
|
||||
declaring a winner, and a plugin picks one by name:
|
||||
|
||||
self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight)
|
||||
|
||||
Core never learns which sport is asking. A plugin with a genuinely novel
|
||||
ordering registers its own strategy instead of core growing a branch::
|
||||
|
||||
register_rotation_strategy("my-order", MyRotation)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict, List, Optional, Type
|
||||
|
||||
|
||||
def _game_id(game: Dict) -> Optional[str]:
|
||||
"""The rotation key for a game, or None if it has no usable id."""
|
||||
return game.get("id")
|
||||
|
||||
|
||||
class RotationStrategy:
|
||||
"""Base class for live-rotation ordering.
|
||||
|
||||
Subclasses implement :meth:`schedule`; :meth:`next_game` has a working
|
||||
default derived from it. Strategies whose natural shape is incremental
|
||||
override :meth:`next_game` instead and derive :meth:`schedule`.
|
||||
|
||||
:param weight_for: callable mapping a game dict to a positive integer
|
||||
weight — how many turns it gets per turn of a weight-1 game. Supplied by
|
||||
the host so the *favorites* policy stays with the plugin and this module
|
||||
stays free of any notion of what a favorite is. Defaults to equal
|
||||
weights, which makes every strategy a plain round robin.
|
||||
"""
|
||||
|
||||
#: Name this strategy is registered under. Set by :func:`register_rotation_strategy`.
|
||||
name: str = ""
|
||||
|
||||
#: Ceiling on a per-game weight. A cycle is ``sum(weights)`` long and each
|
||||
#: step scans every game, so an unbounded weight — a misread config field,
|
||||
#: say — would spin the display thread for an unbounded time. On a Pi that
|
||||
#: stalls rendering outright, so the bound is clamped like the floor is.
|
||||
MAX_WEIGHT = 16
|
||||
|
||||
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
|
||||
self._weight_for = weight_for or (lambda game: 1)
|
||||
|
||||
def weights(self, games: List[Dict]) -> Dict[str, int]:
|
||||
"""``{game_id: weight}`` for games that have an id, in ``games`` order.
|
||||
|
||||
A weight below 1 is clamped up: a zero or negative weight would starve
|
||||
a game out of the rotation entirely, which no caller means to express
|
||||
and which would make ``total_weight`` collapse. It is clamped down at
|
||||
:attr:`MAX_WEIGHT` for the reason documented there.
|
||||
"""
|
||||
weights: Dict[str, int] = {}
|
||||
for game in games:
|
||||
gid = _game_id(game)
|
||||
if gid is None:
|
||||
continue
|
||||
try:
|
||||
weight = int(self._weight_for(game))
|
||||
except (TypeError, ValueError):
|
||||
weight = 1
|
||||
weights[gid] = min(self.MAX_WEIGHT, max(1, weight))
|
||||
return weights
|
||||
|
||||
def schedule(self, games: List[Dict]) -> List[str]:
|
||||
"""Game ids in display order for one cycle. Ids may repeat."""
|
||||
raise NotImplementedError
|
||||
|
||||
def next_game(self, games: List[Dict]) -> Optional[Dict]:
|
||||
"""The next game to display, or None when there is nothing to show."""
|
||||
order = self.schedule(games)
|
||||
if not order:
|
||||
return None
|
||||
by_id = {gid: g for g in games if (gid := _game_id(g)) is not None}
|
||||
return by_id.get(order[0])
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop any accumulated state. Stateless strategies need do nothing."""
|
||||
|
||||
|
||||
class SimpleRotation(RotationStrategy):
|
||||
"""Plain round robin: every live game once per cycle, weights ignored.
|
||||
|
||||
The fallback for a plugin that wants strictly even rotation regardless of
|
||||
favorites.
|
||||
"""
|
||||
|
||||
def schedule(self, games: List[Dict]) -> List[str]:
|
||||
return [gid for g in games if (gid := _game_id(g)) is not None]
|
||||
|
||||
|
||||
class WeightedCycleRotation(RotationStrategy):
|
||||
"""Precomputed SWRR cycle — the football / baseball / basketball / hockey shape.
|
||||
|
||||
Returns a full cycle of ``sum(weights)`` ids with repeats spaced evenly
|
||||
rather than clumped, highest weight scheduled first. When no game carries a
|
||||
boost the cycle degenerates to a single pass in ``games`` order, which is
|
||||
exactly the plain round robin it replaced.
|
||||
"""
|
||||
|
||||
def schedule(self, games: List[Dict]) -> List[str]:
|
||||
weights = self.weights(games)
|
||||
if not weights:
|
||||
return []
|
||||
total_weight = sum(weights.values())
|
||||
if total_weight <= len(weights):
|
||||
# No boost in effect — plain order, one pass. (Also the guard that
|
||||
# keeps the loop below from being O(total_weight) for nothing.)
|
||||
return list(weights)
|
||||
|
||||
current = {gid: 0 for gid in weights}
|
||||
order: List[str] = []
|
||||
for _ in range(total_weight):
|
||||
for gid, weight in weights.items():
|
||||
current[gid] += weight
|
||||
picked = max(current, key=lambda gid: current[gid])
|
||||
current[picked] -= total_weight
|
||||
order.append(picked)
|
||||
return order
|
||||
|
||||
|
||||
class SmoothWeightedRotation(RotationStrategy):
|
||||
"""Incremental SWRR — the afl / nrl / soccer shape.
|
||||
|
||||
Weight state persists across calls, so there is no fixed-length cycle and
|
||||
therefore no clustering seam at a cycle boundary. A game seen for the first
|
||||
time starts at weight 0 and receives its full weight on the next call, so a
|
||||
favorite's game that has just gone live naturally wins the first pick after
|
||||
it appears — "queued first on refresh" without a special-cased branch.
|
||||
|
||||
State for games no longer live is dropped on each call, so a long-running
|
||||
board does not accumulate entries for finished games.
|
||||
"""
|
||||
|
||||
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
|
||||
super().__init__(weight_for)
|
||||
self._current: Dict[str, int] = {}
|
||||
|
||||
def reset(self) -> None:
|
||||
self._current = {}
|
||||
|
||||
def next_game(self, games: List[Dict]) -> Optional[Dict]:
|
||||
if not games:
|
||||
return None
|
||||
weights = self.weights(games)
|
||||
if not weights:
|
||||
return None
|
||||
|
||||
# Keep state only for games still live.
|
||||
self._current = {
|
||||
gid: value for gid, value in self._current.items() if gid in weights
|
||||
}
|
||||
for gid, weight in weights.items():
|
||||
self._current[gid] = self._current.get(gid, 0) + weight
|
||||
|
||||
total_weight = sum(weights.values())
|
||||
# Iterate in `games` order so ties break toward the feed's ordering,
|
||||
# which is what the plugin copies did and what makes the no-boost case
|
||||
# identical to a plain round robin.
|
||||
ids_in_order = [gid for g in games if (gid := _game_id(g)) in weights]
|
||||
best = max(ids_in_order, key=lambda gid: self._current[gid])
|
||||
self._current[best] -= total_weight
|
||||
return next(g for g in games if _game_id(g) == best)
|
||||
|
||||
def schedule(self, games: List[Dict]) -> List[str]:
|
||||
"""One cycle's worth of picks, without disturbing live state.
|
||||
|
||||
Derived by running the picker forward on a copy, so the returned order
|
||||
is exactly what repeated :meth:`next_game` calls would produce from the
|
||||
current state — callers can use it to preview or log the rotation
|
||||
without perturbing it.
|
||||
"""
|
||||
weights = self.weights(games)
|
||||
if not weights:
|
||||
return []
|
||||
# type(self), not this class: a subclass that overrides next_game must
|
||||
# be previewed through its own ordering, or the returned order is not
|
||||
# the one repeated next_game calls would produce — which is exactly
|
||||
# what this method promises.
|
||||
preview = type(self)(self._weight_for)
|
||||
preview._current = dict(self._current)
|
||||
order: List[str] = []
|
||||
for _ in range(sum(weights.values())):
|
||||
picked = preview.next_game(games)
|
||||
if picked is None:
|
||||
break
|
||||
order.append(_game_id(picked))
|
||||
return order
|
||||
|
||||
|
||||
_REGISTRY: Dict[str, Type[RotationStrategy]] = {}
|
||||
|
||||
|
||||
def register_rotation_strategy(name: str, factory: Type[RotationStrategy]) -> None:
|
||||
"""Register a rotation strategy under ``name``.
|
||||
|
||||
When a plugin needs an ordering that core does not ship, it registers its
|
||||
own here instead of core growing a sport-specific branch. Re-registering a
|
||||
name replaces it, so a plugin may also override a built-in for itself.
|
||||
"""
|
||||
if not name:
|
||||
raise ValueError("rotation strategy name must be a non-empty string")
|
||||
# Fail at registration, not at the first schedule() call several frames
|
||||
# later, where the cause is no longer on the stack.
|
||||
if not (isinstance(factory, type) and issubclass(factory, RotationStrategy)):
|
||||
raise TypeError(
|
||||
f"rotation strategy {name!r} must be a RotationStrategy subclass, "
|
||||
f"got {factory!r}"
|
||||
)
|
||||
factory.name = name
|
||||
_REGISTRY[name] = factory
|
||||
|
||||
|
||||
def get_rotation_strategy(
|
||||
name: str, weight_for: Optional[Callable[[Dict], int]] = None
|
||||
) -> RotationStrategy:
|
||||
"""Build the strategy registered under ``name``.
|
||||
|
||||
Falls back to ``"simple"`` for an unknown name rather than raising: the name
|
||||
arrives from user config, and a typo should cost the boost, not the
|
||||
scoreboard.
|
||||
"""
|
||||
factory = _REGISTRY.get(name) or _REGISTRY["simple"]
|
||||
return factory(weight_for=weight_for)
|
||||
|
||||
|
||||
register_rotation_strategy("simple", SimpleRotation)
|
||||
register_rotation_strategy("weighted", WeightedCycleRotation)
|
||||
register_rotation_strategy("swrr", SmoothWeightedRotation)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+203
-12
@@ -110,20 +110,30 @@ class ScrollHelper:
|
||||
self.is_scrolling = False
|
||||
self.scroll_complete = False
|
||||
|
||||
def create_scrolling_image(self, content_items: list,
|
||||
def create_scrolling_image(self, content_items: list,
|
||||
item_gap: int = 32,
|
||||
element_gap: int = 16) -> Image.Image:
|
||||
element_gap: int = 16,
|
||||
lead_gap: Optional[int] = None) -> Image.Image:
|
||||
"""
|
||||
Create a wide image containing all content items for scrolling.
|
||||
|
||||
|
||||
Args:
|
||||
content_items: List of PIL Images to include in scroll
|
||||
item_gap: Gap between different items
|
||||
element_gap: Gap between elements within an item
|
||||
|
||||
lead_gap: Blank columns before the first item. Defaults to a full
|
||||
display width, which makes a standalone ticker scroll in from
|
||||
off-screen. Callers that loop many plugins back-to-back (Vegas
|
||||
mode) pass a smaller value, since a full display width of black
|
||||
reads as the panel being switched off at the start of every
|
||||
cycle.
|
||||
|
||||
Returns:
|
||||
PIL Image containing all content arranged horizontally
|
||||
"""
|
||||
if lead_gap is None:
|
||||
lead_gap = self.display_width
|
||||
lead_gap = max(0, int(lead_gap))
|
||||
if not content_items:
|
||||
# Create empty image if no content
|
||||
# Still set total_scroll_width to 0 to indicate no scrollable content
|
||||
@@ -144,13 +154,13 @@ class ScrollHelper:
|
||||
total_width += element_gap * len(content_items)
|
||||
|
||||
# Add initial gap before first item
|
||||
total_width += self.display_width
|
||||
|
||||
total_width += lead_gap
|
||||
|
||||
# Create the full scrolling image
|
||||
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
|
||||
|
||||
|
||||
# Position items
|
||||
current_x = self.display_width # Start with initial gap
|
||||
current_x = lead_gap # Start with initial gap
|
||||
|
||||
for i, img in enumerate(content_items):
|
||||
# Paste the item image
|
||||
@@ -338,13 +348,72 @@ class ScrollHelper:
|
||||
"""
|
||||
if not self.cached_image or self.cached_array is None:
|
||||
return None
|
||||
|
||||
# Use integer pixel positioning for high FPS scrolling (like stock ticker)
|
||||
|
||||
start_x_int = int(self.scroll_position)
|
||||
end_x_int = start_x_int + self.display_width
|
||||
|
||||
# Fast integer pixel path (no interpolation - high frame rate provides smoothness)
|
||||
|
||||
# Integer positioning quantises motion to whole pixels, so the number of
|
||||
# distinct frames per second equals the scroll speed in px/s, no matter
|
||||
# how fast the loop renders. At 50px/s and 78fps that made 36% of frames
|
||||
# identical: the extra frames cost work and bought nothing. Blending
|
||||
# between the two neighbouring positions gives motion at the frame rate
|
||||
# instead of the step rate.
|
||||
if self.sub_pixel_scrolling:
|
||||
fractional = self.scroll_position - start_x_int
|
||||
if fractional > 0.0:
|
||||
return self._blend_visible_portion(start_x_int, fractional)
|
||||
|
||||
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
||||
|
||||
def _blend_visible_portion(self, start_x: int, fractional: float) -> Image.Image:
|
||||
"""
|
||||
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
|
||||
|
||||
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
|
||||
installed on the target devices (HAS_SCIPY is False there), which is why
|
||||
the pre-existing sub-pixel path was dead code — get_visible_portion never
|
||||
consulted the flag, and the scipy fallback would not have interpolated
|
||||
anyway.
|
||||
|
||||
Args:
|
||||
start_x: Left column of the earlier of the two frames
|
||||
fractional: How far between the two, in [0, 1)
|
||||
|
||||
Returns:
|
||||
The blended frame
|
||||
"""
|
||||
width = self.display_width
|
||||
strip_width = self.cached_array.shape[1]
|
||||
|
||||
if start_x + width + 1 <= strip_width:
|
||||
# Slice the backing array directly. Going via
|
||||
# _get_visible_portion_integer would build two PIL images only for
|
||||
# them to be converted straight back to arrays, which measured 15x
|
||||
# the cost of the integer path.
|
||||
near = self.cached_array[:, start_x:start_x + width]
|
||||
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
|
||||
else:
|
||||
# Close enough to the end that one of the slices wraps; let the
|
||||
# integer path handle that and pay the conversion. Continuous mode
|
||||
# extends the strip before reaching here, so this is the rare case.
|
||||
near = np.asarray(
|
||||
self._get_visible_portion_integer(start_x, start_x + width))
|
||||
far = np.asarray(
|
||||
self._get_visible_portion_integer(start_x + 1, start_x + 1 + width))
|
||||
|
||||
# Fixed-point rather than float32: integer multiply-add on uint16 is
|
||||
# markedly faster than float maths on the Pi's ARM cores, and 8 bits of
|
||||
# weight is finer than the panel can show.
|
||||
weight = int(fractional * 256.0)
|
||||
blended = (
|
||||
(near.astype(np.uint16) * (256 - weight)
|
||||
+ far.astype(np.uint16) * weight) >> 8
|
||||
).astype(np.uint8)
|
||||
|
||||
return Image.frombytes(
|
||||
'RGB', (width, self.display_height),
|
||||
np.ascontiguousarray(blended).tobytes()
|
||||
)
|
||||
|
||||
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
||||
"""Fast integer pixel extraction (no interpolation).
|
||||
@@ -638,6 +707,128 @@ class ScrollHelper:
|
||||
"""
|
||||
return self.scroll_complete
|
||||
|
||||
def append_content(self, content_items: list,
|
||||
item_gap: int = 32,
|
||||
element_gap: int = 0) -> bool:
|
||||
"""
|
||||
Append items to the right of the existing strip, preserving scroll state.
|
||||
|
||||
Lets a caller keep one continuous strip instead of replacing it. Vegas
|
||||
mode uses this so the next group of plugins scrolls in from the right
|
||||
rather than the strip being swapped out underneath the viewer — a swap
|
||||
shows as a flash and a hard cut to already-full-screen content.
|
||||
|
||||
``scroll_position`` and ``total_distance_scrolled`` are untouched, so
|
||||
motion continues uninterrupted; only the strip gets longer. Because
|
||||
completion is measured against ``total_scroll_width``, extending the
|
||||
strip also defers completion, which is the intent.
|
||||
|
||||
Args:
|
||||
content_items: Images to append, in order
|
||||
item_gap: Gap between appended items, and between the existing
|
||||
content and the first appended item
|
||||
element_gap: Extra gap after each item, mirroring
|
||||
create_scrolling_image
|
||||
|
||||
Returns:
|
||||
True if content was appended
|
||||
"""
|
||||
if not content_items:
|
||||
return False
|
||||
|
||||
if self.cached_image is None or self.cached_array is None:
|
||||
# Nothing to extend yet — this is just the first build.
|
||||
self.create_scrolling_image(
|
||||
content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0)
|
||||
return True
|
||||
|
||||
gap = max(0, item_gap)
|
||||
addition_width = (
|
||||
sum(img.width for img in content_items)
|
||||
+ gap * len(content_items) # one leading gap per item
|
||||
+ element_gap * len(content_items)
|
||||
)
|
||||
|
||||
addition = Image.new('RGB', (addition_width, self.display_height), (0, 0, 0))
|
||||
x = 0
|
||||
for img in content_items:
|
||||
x += gap # separate from whatever precedes
|
||||
addition.paste(img, (x, 0))
|
||||
x += img.width + element_gap
|
||||
|
||||
# numpy concatenate then one conversion back, rather than allocating a
|
||||
# full-width PIL image and pasting twice: the strip can be tens of
|
||||
# thousands of columns wide and this runs on the render path.
|
||||
self.cached_array = np.concatenate(
|
||||
(self.cached_array, np.array(addition)), axis=1)
|
||||
self.cached_image = Image.fromarray(self.cached_array)
|
||||
self.total_scroll_width = self.cached_image.width
|
||||
self.scroll_complete = False
|
||||
|
||||
self.logger.info(
|
||||
"Appended %d item(s) (%dpx) to scroll strip: now %dpx, position %.0f",
|
||||
len(content_items), addition_width, self.total_scroll_width,
|
||||
self.scroll_position
|
||||
)
|
||||
return True
|
||||
|
||||
def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
|
||||
"""
|
||||
Discard columns that have already scrolled past, to bound memory.
|
||||
|
||||
A continuously extended strip would otherwise grow without limit. All
|
||||
the positional state is shifted by the amount removed so the visible
|
||||
frame and the completion arithmetic are unchanged:
|
||||
``total_distance_scrolled`` and ``total_scroll_width`` both shrink by the
|
||||
same amount, preserving their difference.
|
||||
|
||||
Args:
|
||||
keep_before: Columns to retain behind the current position, as a
|
||||
safety margin against a caller reading slightly behind it
|
||||
|
||||
Returns:
|
||||
Number of columns actually removed
|
||||
"""
|
||||
if self.cached_image is None or self.cached_array is None:
|
||||
return 0
|
||||
|
||||
# While the viewport wraps, get_visible_portion fills its right-hand side
|
||||
# from the *head* of the strip, so trimming the head would change what
|
||||
# is on screen. Continuous mode extends before ever reaching that state;
|
||||
# refusing here keeps "trimming is invisible" true unconditionally.
|
||||
if self.scroll_position + self.display_width > self.cached_image.width:
|
||||
return 0
|
||||
|
||||
cut = int(self.scroll_position) - max(0, keep_before)
|
||||
if cut <= 0:
|
||||
return 0
|
||||
# Never trim so far that the remaining strip is narrower than the
|
||||
# viewport, or get_visible_portion has nothing to slice.
|
||||
cut = min(cut, max(0, self.cached_image.width - self.display_width))
|
||||
if cut <= 0:
|
||||
return 0
|
||||
|
||||
# .copy() so the original buffer is released rather than kept alive by
|
||||
# a numpy view.
|
||||
self.cached_array = self.cached_array[:, cut:].copy()
|
||||
self.cached_image = Image.fromarray(self.cached_array)
|
||||
self.total_scroll_width = self.cached_image.width
|
||||
self.scroll_position -= cut
|
||||
self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut)
|
||||
|
||||
self.logger.debug(
|
||||
"Dropped %dpx of scrolled strip: now %dpx, position %.0f",
|
||||
cut, self.total_scroll_width, self.scroll_position
|
||||
)
|
||||
return cut
|
||||
|
||||
def remaining_unscrolled(self) -> int:
|
||||
"""Columns of strip still to the right of the viewport."""
|
||||
if self.cached_image is None:
|
||||
return 0
|
||||
return max(0, self.total_scroll_width - int(self.scroll_position)
|
||||
- self.display_width)
|
||||
|
||||
def reset_scroll(self) -> None:
|
||||
"""
|
||||
Reset scroll position to beginning.
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Shared scroll-display scaffolding for the sports scoreboards.
|
||||
|
||||
Ten plugins ship a `scroll_display.py`. A method-level comparison of the eight
|
||||
that share a shape (f1 and ufc are genuine forks) found a sharp split, and this
|
||||
module is drawn along it rather than around all of it:
|
||||
|
||||
* The **orchestration layer is converged** — ``get_all_vegas_content_items`` is
|
||||
byte-identical in all eight, and ``clear_all``, ``get_scroll_info``,
|
||||
``get_dynamic_duration``, ``is_complete`` and ``display_frame`` are 96-100%
|
||||
similar. That is what lives here.
|
||||
* The **content layer has genuinely diverged** — ``prepare_scroll_content`` has
|
||||
eight distinct bodies across eight plugins (145 lines, 53% similarity at
|
||||
worst) and ``_load_separator_icons`` seven (6% at worst). Those build each
|
||||
sport's game cards and icon strip; they are *not* drift to be merged but
|
||||
per-sport rendering. They stay override points here, permanently.
|
||||
|
||||
Promoting the content layer would be exactly the mistake
|
||||
``docs/SPORTS_UNIFICATION.md`` warns against — merging on the intuition that
|
||||
same-named methods are the same method. Same name, different job.
|
||||
|
||||
The one behavior this module adds over the plugin copies is native support for
|
||||
``global_config['target_fps']``: the bundled copies hardcode ~100 FPS via
|
||||
``scroll_delay=0.01`` and never consult the global smooth-scrolling target. A
|
||||
plugin inheriting from here gets it for free.
|
||||
|
||||
Usage::
|
||||
|
||||
class HockeyScrollDisplay(SportsScrollDisplay):
|
||||
SCROLL_LEAGUE_KEYS = ("nhl", "ncaa_mens", "ncaam_hockey")
|
||||
|
||||
def prepare_scroll_content(self, games, game_type, leagues, rankings=None):
|
||||
... # build this sport's cards
|
||||
|
||||
class HockeyScrollDisplayManager(SportsScrollDisplayManager):
|
||||
display_class = HockeyScrollDisplay
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Defaults every copy agreed on. A subclass overrides
|
||||
#: :meth:`SportsScrollDisplay.scroll_settings_defaults` to change them —
|
||||
#: the soccer lineage uses a 24px gap and min/max duration keys instead.
|
||||
DEFAULT_SCROLL_SETTINGS: Dict[str, Any] = {
|
||||
"scroll_speed": 50.0,
|
||||
"scroll_delay": 0.01,
|
||||
"gap_between_games": 48,
|
||||
"show_league_separators": True,
|
||||
"dynamic_duration": True,
|
||||
}
|
||||
|
||||
#: Bounds on the px/second -> px/frame conversion, applied before the helper
|
||||
#: sees the value. FPS is *not* clamped here — ScrollHelper.set_target_fps
|
||||
#: already does that, and a second copy of the range would drift from it.
|
||||
MIN_PIXELS_PER_FRAME = 0.1
|
||||
MAX_PIXELS_PER_FRAME = 5.0
|
||||
|
||||
#: Pacing to assume when scroll_delay is 0, i.e. the plugin has not set one.
|
||||
ASSUMED_FPS_WHEN_UNPACED = 100.0
|
||||
|
||||
|
||||
class SportsScrollDisplay:
|
||||
"""One scrolling strip of game cards.
|
||||
|
||||
Subclasses supply the content (:meth:`prepare_scroll_content`) and,
|
||||
optionally, the per-sport league ladder and separator icons. Everything
|
||||
else — helper configuration, frame pumping, completion, state — is here.
|
||||
"""
|
||||
|
||||
#: Config keys to walk when looking for per-league ``scroll_settings``,
|
||||
#: most-preferred first. A sport's own league names, which is the *only*
|
||||
#: reason the eight copies of ``_get_scroll_settings`` differ. Empty means
|
||||
#: the plugin has no per-league scroll settings.
|
||||
SCROLL_LEAGUE_KEYS: tuple = ()
|
||||
|
||||
#: Config block holding scroll settings when the plugin keeps them in one
|
||||
#: place rather than per league (the afl/nrl/soccer shape).
|
||||
SCROLL_CONFIG_KEY: Optional[str] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
display_manager,
|
||||
config: Dict[str, Any],
|
||||
custom_logger: Optional[logging.Logger] = None,
|
||||
global_config: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
:param display_manager: the core display manager
|
||||
:param config: the plugin's configuration
|
||||
:param custom_logger: the plugin's logger, so scroll lines are attributed
|
||||
:param global_config: the LEDMatrix global config — the source of
|
||||
``target_fps``. Optional so an older caller that does not pass it
|
||||
keeps working at the config-derived pacing.
|
||||
"""
|
||||
self.display_manager = display_manager
|
||||
self.config = config
|
||||
self.logger = custom_logger or logger
|
||||
self.global_config = global_config or {}
|
||||
|
||||
if getattr(display_manager, "matrix", None) is not None:
|
||||
self.display_width = display_manager.matrix.width
|
||||
self.display_height = display_manager.matrix.height
|
||||
else:
|
||||
self.display_width = getattr(display_manager, "width", 128)
|
||||
self.display_height = getattr(display_manager, "height", 32)
|
||||
|
||||
self.scroll_helper = ScrollHelper(
|
||||
self.display_width, self.display_height, self.logger
|
||||
)
|
||||
self._configure_scroll_helper()
|
||||
|
||||
self._logo_cache: Dict[str, Image.Image] = {}
|
||||
self._separator_icons: Dict[str, Image.Image] = {}
|
||||
self._load_separator_icons()
|
||||
|
||||
self._current_games: List[Dict] = []
|
||||
self._current_game_type: str = ""
|
||||
self._current_leagues: List[str] = []
|
||||
self._vegas_content_items: List[Image.Image] = []
|
||||
self._is_scrolling = False
|
||||
self._scroll_start_time: Optional[float] = None
|
||||
self._last_log_time: float = 0
|
||||
self._log_interval: float = 5.0
|
||||
self._frame_count: int = 0
|
||||
self._fps_sample_start: float = time.time()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Override points
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def prepare_scroll_content(
|
||||
self,
|
||||
games: List[Dict],
|
||||
game_type: str,
|
||||
leagues: List[str],
|
||||
rankings_cache: Optional[Dict[str, int]] = None,
|
||||
) -> bool:
|
||||
"""Render ``games`` into one wide image and hand it to the scroll helper.
|
||||
|
||||
**Per-sport by nature, not by drift** — the eight plugin copies have
|
||||
eight different bodies because each draws its own card. Implementations
|
||||
build the strip, hand it over with
|
||||
``self.scroll_helper.set_scrolling_image(...)`` (or
|
||||
``create_scrolling_image(...)`` from a list of cards), and record
|
||||
``self._current_games`` / ``_current_game_type`` / ``_current_leagues``.
|
||||
|
||||
:returns: True when there is content to scroll.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} must implement prepare_scroll_content(); "
|
||||
"it builds this sport's game cards and is not shared code."
|
||||
)
|
||||
|
||||
def _load_separator_icons(self) -> None:
|
||||
"""Populate ``self._separator_icons``. Per-sport; no-op by default."""
|
||||
|
||||
def scroll_settings_defaults(self) -> Dict[str, Any]:
|
||||
"""The baseline scroll settings before any config is applied."""
|
||||
defaults = dict(DEFAULT_SCROLL_SETTINGS)
|
||||
defaults["game_card_width"] = self.display_width
|
||||
return defaults
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Settings
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_scroll_settings(self, league: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Resolve scroll settings: defaults, then the most specific override.
|
||||
|
||||
Precedence: the named ``league``, then each entry of
|
||||
:attr:`SCROLL_LEAGUE_KEYS` in order, then :attr:`SCROLL_CONFIG_KEY`.
|
||||
The eight plugin copies implement exactly this and differ only in which
|
||||
league names they walk — which is why the ladder is data here rather
|
||||
than a body per sport.
|
||||
"""
|
||||
settings = self.scroll_settings_defaults()
|
||||
|
||||
candidates: List[str] = []
|
||||
if league:
|
||||
candidates.append(league)
|
||||
candidates.extend(self.SCROLL_LEAGUE_KEYS)
|
||||
for key in candidates:
|
||||
override = (self.config.get(key) or {}).get("scroll_settings")
|
||||
if override:
|
||||
return {**settings, **override}
|
||||
|
||||
if self.SCROLL_CONFIG_KEY:
|
||||
override = self.config.get(self.SCROLL_CONFIG_KEY) or {}
|
||||
if override:
|
||||
return {**settings, **override}
|
||||
return settings
|
||||
|
||||
def _resolve_target_fps(self) -> Optional[float]:
|
||||
"""The global smooth-scrolling FPS target, or None to keep config pacing.
|
||||
|
||||
Coerced before use: a malformed value in the global config must degrade
|
||||
to the existing ``scroll_delay`` pacing, never raise on a display path.
|
||||
"""
|
||||
raw = self.global_config.get("target_fps") or self.global_config.get(
|
||||
"scroll_target_fps"
|
||||
)
|
||||
try:
|
||||
return float(raw) if raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
self.logger.debug("Ignoring unusable target_fps: %r", raw)
|
||||
return None
|
||||
|
||||
def _coerce_float(self, value: Any, default: float) -> float:
|
||||
"""A usable float from config, or ``default``.
|
||||
|
||||
``dict.get(key, default)`` only helps when the key is *absent*; a key
|
||||
present with ``null`` or a string returns that value verbatim and blows
|
||||
up in the arithmetic below — inside ``__init__``, so the whole display
|
||||
fails to construct.
|
||||
"""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
self.logger.warning(
|
||||
"Ignoring unusable scroll setting %r; using %s", value, default
|
||||
)
|
||||
return default
|
||||
|
||||
def _configure_scroll_helper(self) -> None:
|
||||
"""Apply config to the scroll helper. Safe to call again after a change."""
|
||||
settings = self._get_scroll_settings()
|
||||
|
||||
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0)
|
||||
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01)
|
||||
dynamic_duration = bool(settings.get("dynamic_duration", True))
|
||||
|
||||
self.scroll_helper.set_scroll_delay(scroll_delay)
|
||||
self.scroll_helper.set_dynamic_duration_settings(
|
||||
enabled=dynamic_duration,
|
||||
min_duration=settings.get("min_duration", 30),
|
||||
max_duration=settings.get("max_duration", 600),
|
||||
buffer=0.2, # ensure the strip clears the panel completely
|
||||
)
|
||||
# Frame-based scrolling: motion advances per rendered frame rather than
|
||||
# per wall-clock second, which is what makes the pacing stable.
|
||||
self.scroll_helper.set_frame_based_scrolling(True)
|
||||
|
||||
# Config states speed in px/second; frame-based mode wants px/frame.
|
||||
if scroll_delay > 0:
|
||||
pixels_per_frame = scroll_speed * scroll_delay
|
||||
else:
|
||||
pixels_per_frame = scroll_speed / ASSUMED_FPS_WHEN_UNPACED
|
||||
pixels_per_frame = max(
|
||||
MIN_PIXELS_PER_FRAME, min(MAX_PIXELS_PER_FRAME, pixels_per_frame)
|
||||
)
|
||||
self.scroll_helper.set_scroll_speed(pixels_per_frame)
|
||||
|
||||
effective_pps = (
|
||||
pixels_per_frame / scroll_delay
|
||||
if scroll_delay > 0
|
||||
else pixels_per_frame * ASSUMED_FPS_WHEN_UNPACED
|
||||
)
|
||||
self.logger.info(
|
||||
f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, "
|
||||
f"delay={scroll_delay}s (effective {effective_pps:.1f} px/s from "
|
||||
f"{scroll_speed} px/s config), dynamic_duration={dynamic_duration}"
|
||||
)
|
||||
|
||||
# The reason this module exists upstream: the bundled copies hardcode
|
||||
# ~100 FPS via scroll_delay and never consult the global target.
|
||||
# No hasattr guard here, unlike the plugin copies: they probe because
|
||||
# they may run against an older core, whereas this module ships in the
|
||||
# same release as the ScrollHelper it calls. The helper clamps.
|
||||
target_fps = self._resolve_target_fps()
|
||||
if target_fps:
|
||||
self.scroll_helper.set_target_fps(target_fps)
|
||||
self.logger.info(f"Target FPS set to {target_fps}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Frame pumping
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def display_scroll_frame(self) -> bool:
|
||||
"""Advance and render one frame.
|
||||
|
||||
:returns: True if a frame was drawn; False when there is no content or
|
||||
the frame could not be rendered.
|
||||
"""
|
||||
if not self.scroll_helper.cached_image:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Inside the try, not before it: advancing the position and cropping
|
||||
# the visible slice are as capable of raising as the display push,
|
||||
# and the promise below is that no frame failure reaches the
|
||||
# plugin's loop.
|
||||
self.scroll_helper.update_scroll_position()
|
||||
visible = self.scroll_helper.get_visible_portion()
|
||||
if not visible:
|
||||
return False
|
||||
|
||||
self.display_manager.image = visible
|
||||
self.display_manager.update_display()
|
||||
self._frame_count += 1
|
||||
self.scroll_helper.log_frame_rate()
|
||||
self._log_scroll_progress()
|
||||
except Exception:
|
||||
# A display failure must not propagate into the plugin's loop.
|
||||
self.logger.exception("Error displaying scroll frame")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _log_scroll_progress(self) -> None:
|
||||
"""Emit a throttled progress line."""
|
||||
now = time.time()
|
||||
if now - self._last_log_time < self._log_interval:
|
||||
return
|
||||
self._last_log_time = now
|
||||
elapsed = now - self._fps_sample_start
|
||||
fps = self._frame_count / elapsed if elapsed > 0 else 0.0
|
||||
self.logger.debug(
|
||||
f"Scrolling {len(self._current_games)} {self._current_game_type} "
|
||||
f"game(s) at {fps:.1f} FPS"
|
||||
)
|
||||
|
||||
def is_scroll_complete(self) -> bool:
|
||||
"""True when the strip has scrolled fully past the panel."""
|
||||
return self.scroll_helper.is_scroll_complete()
|
||||
|
||||
def reset_scroll(self) -> None:
|
||||
"""Return the strip to its starting position, keeping the content."""
|
||||
self.scroll_helper.reset_scroll()
|
||||
self._frame_count = 0
|
||||
self._fps_sample_start = time.time()
|
||||
self.logger.debug("Scroll position reset")
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Drop cached content and reset tracking state."""
|
||||
self.scroll_helper.clear_cache()
|
||||
self._current_games = []
|
||||
self._current_game_type = ""
|
||||
self._current_leagues = []
|
||||
self._vegas_content_items = []
|
||||
self._is_scrolling = False
|
||||
self._scroll_start_time = None
|
||||
self.logger.debug("Scroll display cleared")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Introspection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_dynamic_duration(self) -> int:
|
||||
"""How long this content needs to scroll fully, in seconds."""
|
||||
return self.scroll_helper.get_dynamic_duration()
|
||||
|
||||
def has_cached_content(self) -> bool:
|
||||
"""Whether content is prepared and ready to scroll."""
|
||||
return bool(self.scroll_helper.cached_image)
|
||||
|
||||
def get_current_game_count(self) -> int:
|
||||
return len(self._current_games)
|
||||
|
||||
def get_current_leagues(self) -> List[str]:
|
||||
return list(self._current_leagues)
|
||||
|
||||
def get_scroll_info(self) -> Dict[str, Any]:
|
||||
"""Helper state plus this display's tracking state, for logging/debug."""
|
||||
info = self.scroll_helper.get_scroll_info()
|
||||
info.update(
|
||||
{
|
||||
"game_count": len(self._current_games),
|
||||
"game_type": self._current_game_type,
|
||||
"leagues": self._current_leagues,
|
||||
"is_scrolling": self._is_scrolling,
|
||||
}
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
class SportsScrollDisplayManager:
|
||||
"""One :class:`SportsScrollDisplay` per game type ('live'/'recent'/'upcoming').
|
||||
|
||||
Subclasses set :attr:`display_class`; everything else was near-identical
|
||||
across the eight plugin copies.
|
||||
"""
|
||||
|
||||
#: The SportsScrollDisplay subclass to instantiate per game type.
|
||||
display_class = SportsScrollDisplay
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
display_manager,
|
||||
config: Dict[str, Any],
|
||||
custom_logger: Optional[logging.Logger] = None,
|
||||
global_config: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
self.display_manager = display_manager
|
||||
self.config = config
|
||||
self.logger = custom_logger or logger
|
||||
self.global_config = global_config or {}
|
||||
self._scroll_displays: Dict[str, SportsScrollDisplay] = {}
|
||||
# "" rather than None, matching SportsScrollDisplay's own empty value —
|
||||
# both are falsy, so `game_type or self._current_game_type` behaved
|
||||
# either way, but two spellings of "nothing active" across two classes
|
||||
# is a trap for anyone comparing state between them.
|
||||
self._current_game_type: str = ""
|
||||
|
||||
def get_scroll_display(self, game_type: str) -> SportsScrollDisplay:
|
||||
"""The display for ``game_type``, created on first use."""
|
||||
if game_type not in self._scroll_displays:
|
||||
self._scroll_displays[game_type] = self.display_class(
|
||||
self.display_manager,
|
||||
self.config,
|
||||
self.logger,
|
||||
global_config=self.global_config,
|
||||
)
|
||||
return self._scroll_displays[game_type]
|
||||
|
||||
def prepare_and_display(
|
||||
self,
|
||||
games: List[Dict],
|
||||
game_type: str,
|
||||
leagues: List[str],
|
||||
rankings_cache: Optional[Dict[str, int]] = None,
|
||||
) -> bool:
|
||||
"""Build content for ``game_type`` and make it the active strip."""
|
||||
scroll_display = self.get_scroll_display(game_type)
|
||||
try:
|
||||
success = scroll_display.prepare_scroll_content(
|
||||
games, game_type, leagues, rankings_cache
|
||||
)
|
||||
except Exception:
|
||||
# prepare_scroll_content is subclass-implemented and builds cards
|
||||
# straight from feed data, which is exactly where this PR's other
|
||||
# crashes came from. One sport's bad payload must not take down the
|
||||
# shared orchestration for the others.
|
||||
self.logger.exception(
|
||||
"Error preparing scroll content for game_type=%s", game_type
|
||||
)
|
||||
return False
|
||||
if success:
|
||||
self._current_game_type = game_type
|
||||
return success
|
||||
|
||||
def display_frame(self, game_type: Optional[str] = None) -> bool:
|
||||
"""Advance the active strip (or a named one) by one frame."""
|
||||
game_type = game_type or self._current_game_type
|
||||
if not game_type:
|
||||
return False
|
||||
scroll_display = self._scroll_displays.get(game_type)
|
||||
if scroll_display is None:
|
||||
return False
|
||||
return scroll_display.display_scroll_frame()
|
||||
|
||||
def is_complete(self, game_type: Optional[str] = None) -> bool:
|
||||
"""True when the strip has finished — including when there isn't one,
|
||||
so a caller waiting on completion is never wedged."""
|
||||
game_type = game_type or self._current_game_type
|
||||
if not game_type:
|
||||
return True
|
||||
scroll_display = self._scroll_displays.get(game_type)
|
||||
if scroll_display is None:
|
||||
return True
|
||||
return scroll_display.is_scroll_complete()
|
||||
|
||||
def clear_all(self) -> None:
|
||||
"""Clear every display and forget which one was active."""
|
||||
for scroll_display in self._scroll_displays.values():
|
||||
scroll_display.clear()
|
||||
self._current_game_type = ""
|
||||
|
||||
def get_all_vegas_content_items(self) -> List[Image.Image]:
|
||||
"""Every display's Vegas items, for splicing into the marquee."""
|
||||
items: List[Image.Image] = []
|
||||
for scroll_display in self._scroll_displays.values():
|
||||
vegas_items = getattr(scroll_display, "_vegas_content_items", None)
|
||||
if vegas_items:
|
||||
items.extend(vegas_items)
|
||||
return items
|
||||
+70
-2
@@ -186,8 +186,14 @@ class DisplayManager:
|
||||
self.config = config or {}
|
||||
self._force_fallback = force_fallback
|
||||
self._suppress_test_pattern = suppress_test_pattern
|
||||
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
||||
self._capture_mode_active = False
|
||||
# Per-thread capture state. update_display() and clear() skip hardware
|
||||
# writes while the *calling* thread is capturing content off-screen.
|
||||
#
|
||||
# Thread-local rather than a plain flag because Vegas mode prepares
|
||||
# upcoming content on a background thread: a shared flag set there would
|
||||
# suppress the render loop's own frame pushes for the duration, freezing
|
||||
# the panel exactly when the point was to avoid a freeze.
|
||||
self._capture_state = threading.local()
|
||||
# Double-sided mode state (resolved in _setup_matrix). When disabled,
|
||||
# the logical image is blitted to the matrix unchanged.
|
||||
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
||||
@@ -520,6 +526,15 @@ class DisplayManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
|
||||
|
||||
@property
|
||||
def _capture_mode_active(self) -> bool:
|
||||
"""True while the calling thread is capturing content off-screen."""
|
||||
return getattr(self._capture_state, 'active', False)
|
||||
|
||||
@_capture_mode_active.setter
|
||||
def _capture_mode_active(self, value: bool) -> None:
|
||||
self._capture_state.active = bool(value)
|
||||
|
||||
@contextmanager
|
||||
def capture_mode(self):
|
||||
"""Suppress hardware output during off-screen content capture.
|
||||
@@ -536,6 +551,59 @@ class DisplayManager:
|
||||
finally:
|
||||
self._capture_mode_active = False
|
||||
|
||||
@contextmanager
|
||||
def render_size(self, width: int, height: Optional[int] = None):
|
||||
"""Temporarily present a smaller logical canvas to plugins.
|
||||
|
||||
Plugins lay out against ``display_manager.matrix.width`` (and the
|
||||
``width``/``height`` properties, which defer to it), so the only way to
|
||||
get a *narrower layout* rather than a cropped one is to tell the plugin
|
||||
the screen is narrower while it renders. Trimming after the fact cannot
|
||||
fix a forecast spread across five columns or a progress bar drawn at
|
||||
100% width — those need the plugin to make different layout decisions.
|
||||
|
||||
Vegas mode uses this so a plugin can occupy a fraction of a wide panel
|
||||
and still look deliberately composed. Reuses the same _LogicalMatrix
|
||||
indirection that double-sided mode relies on, so plugins see a
|
||||
consistent size from every accessor.
|
||||
|
||||
Only meaningful inside :meth:`capture_mode` — this swaps the shared
|
||||
image buffer, so the render loop must not be writing to it concurrently.
|
||||
|
||||
Args:
|
||||
width: Logical width to report, clamped to at least 1 and to the
|
||||
real panel width (a larger canvas would overflow the hardware).
|
||||
height: Logical height, defaulting to the current height.
|
||||
"""
|
||||
real_matrix = self.matrix
|
||||
prev_image = getattr(self, 'image', None)
|
||||
prev_draw = getattr(self, 'draw', None)
|
||||
|
||||
current_w = self.width
|
||||
current_h = self.height
|
||||
target_w = max(1, min(int(width), current_w))
|
||||
target_h = max(1, min(int(height) if height else current_h, current_h))
|
||||
|
||||
if target_w == current_w and target_h == current_h:
|
||||
# Nothing to do; avoid pointless wrapping and buffer churn.
|
||||
yield
|
||||
return
|
||||
|
||||
try:
|
||||
if real_matrix is not None:
|
||||
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
|
||||
# With no hardware, the width/height properties fall through to
|
||||
# self.image, so swapping the buffer below is enough on its own.
|
||||
self.image = Image.new('RGB', (target_w, target_h))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
yield
|
||||
finally:
|
||||
self.matrix = real_matrix
|
||||
if prev_image is not None:
|
||||
self.image = prev_image
|
||||
if prev_draw is not None:
|
||||
self.draw = prev_draw
|
||||
|
||||
def _composite_double_sided(self):
|
||||
"""Tile the logical screen across the full physical chain.
|
||||
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
"""
|
||||
Shared per-element style resolution for plugins (the x-style-elements system).
|
||||
|
||||
Plugins expose user-customizable text styling — font, size, color, and x/y
|
||||
pixel offsets per named element — through their ``config_schema.json``. Two
|
||||
declaration forms exist in the plugin ecosystem:
|
||||
|
||||
- The compact ``x-style-elements`` map on the ``customization`` object
|
||||
(of-the-day is the reference). ``expand_style_elements()`` turns it into
|
||||
the full per-element property blocks the web-UI config form renders.
|
||||
- The manual ``customization`` block: hand-written per-element objects with
|
||||
``font`` / ``font_size`` / ``text_color`` defaults (the scoreboards,
|
||||
ledmatrix-music). No expansion needed — the defaults are read as-is.
|
||||
|
||||
At render time a plugin builds an ``ElementStyleResolver`` from its config
|
||||
and the schema-file defaults, then asks for each element's resolved style::
|
||||
|
||||
from src.element_style import ElementStyleResolver, defaults_from_schema_file
|
||||
|
||||
resolver = ElementStyleResolver(config, defaults_from_schema_file(schema_path))
|
||||
title = resolver.style('title_text', classic_font='PressStart2P-Regular.ttf',
|
||||
classic_size=8, classic_color=(255, 255, 255))
|
||||
# title.font (PIL font / freetype.Face), title.color (RGB tuple),
|
||||
# title.offset ((dx, dy)), title.user_forced, title.user_forced_color
|
||||
|
||||
The central subtlety is what "the user set it" means. The web UI's save flow
|
||||
(``schema_manager.merge_with_defaults``) writes the FULL schema-default
|
||||
object into ``config.json`` on every save, whether or not the user touched
|
||||
the styling section — so a value merely being *present* in config is not an
|
||||
override. A value only counts as user-forced when it genuinely differs from
|
||||
the schema default for that element. When nothing is forced, ``style()``
|
||||
returns exactly the ``classic_*`` values the caller passes (the plugin's
|
||||
pre-customization styling), so an untouched config renders byte-identically
|
||||
to the classic code path. Note the classic values and the schema defaults
|
||||
may legitimately differ (e.g. football's status_text: schema declares 4x6,
|
||||
the classic loader fell back to PressStart) — the schema default is the
|
||||
override *reference*, the classic values are the *fallback*.
|
||||
|
||||
``style()`` never raises: any malformed config value degrades to the classic
|
||||
style with a logged warning. Font faces are cached module-wide by
|
||||
(resolved path, size), and font files resolve independently of the caller's
|
||||
cwd (cwd ``assets/fonts/`` first for compatibility, then the core install
|
||||
root derived from this module's own location).
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
from PIL import ImageFont
|
||||
|
||||
try:
|
||||
import freetype
|
||||
except ImportError: # pragma: no cover - freetype ships with the core
|
||||
freetype = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Core install root (the directory that contains src/ and assets/fonts/),
|
||||
# derived from this file so fonts resolve regardless of the caller's cwd.
|
||||
_CORE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
_FONTS_SUBDIR = os.path.join('assets', 'fonts')
|
||||
|
||||
# Last-resort font when a requested file can't be found or loaded.
|
||||
_FALLBACK_FONT_NAME = 'PressStart2P-Regular.ttf'
|
||||
|
||||
# (resolved absolute path, size) -> loaded font face. BDF faces are stateful
|
||||
# in principle, but the core's own FontManager shares faces the same way.
|
||||
_font_cache: Dict[Tuple[str, int], Any] = {}
|
||||
|
||||
# Config keys a style element block carries, in schema/UI order.
|
||||
_STYLE_KEYS = ('font', 'font_size', 'text_color')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ElementStyle:
|
||||
"""A fully resolved style for one named element."""
|
||||
|
||||
font: Any # PIL ImageFont or freetype.Face
|
||||
color: Tuple[int, int, int] # resolved RGB
|
||||
offset: Tuple[int, int] # user layout (x, y) offset, default (0, 0)
|
||||
font_name: str # resolved font filename
|
||||
font_size: int # resolved pixel size
|
||||
user_forced: bool # font or size genuinely overridden
|
||||
user_forced_color: bool # color genuinely overridden
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Font loading (cwd-independent, cached)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_font_path(font_name: str) -> Optional[str]:
|
||||
"""Locate a font file by name, independent of the caller's cwd.
|
||||
|
||||
Tries, in order: an absolute path as given; ``assets/fonts/<name>``
|
||||
relative to the cwd (the classic loaders' behavior, kept first so a
|
||||
process running from a different checkout keeps its own fonts); then
|
||||
``assets/fonts/<name>`` under the core install root. Returns an
|
||||
absolute path, or None when the file doesn't exist anywhere.
|
||||
"""
|
||||
if not font_name or not isinstance(font_name, str):
|
||||
return None
|
||||
if os.path.isabs(font_name):
|
||||
return font_name if os.path.isfile(font_name) else None
|
||||
# A relative name must be a bare filename. font_name comes from plugin
|
||||
# config, which the web UI writes; a value like "../../config/config.json"
|
||||
# would otherwise escape assets/fonts/ once joined and let a config probe
|
||||
# arbitrary paths for existence. os.path.basename collapses any such value
|
||||
# to its last component, so a name that isn't already bare is rejected.
|
||||
if os.path.basename(font_name) != font_name:
|
||||
return None
|
||||
candidates = (
|
||||
os.path.join(os.getcwd(), _FONTS_SUBDIR, font_name),
|
||||
os.path.join(_CORE_ROOT, _FONTS_SUBDIR, font_name),
|
||||
)
|
||||
for candidate in candidates:
|
||||
if os.path.isfile(candidate):
|
||||
return os.path.abspath(candidate)
|
||||
return None
|
||||
|
||||
|
||||
def load_font(font_name: str, size: int) -> Any:
|
||||
"""Load a font by filename at a pixel size, with caching and fallback.
|
||||
|
||||
``.bdf`` files load as ``freetype.Face`` (matching FontManager), other
|
||||
files through ``PIL.ImageFont.truetype``. A missing or unloadable font
|
||||
degrades to ``PressStart2P-Regular.ttf`` at the requested size, then to
|
||||
PIL's built-in default — this function never raises.
|
||||
"""
|
||||
try:
|
||||
size = max(1, int(size))
|
||||
except (TypeError, ValueError):
|
||||
size = 8
|
||||
|
||||
path = resolve_font_path(font_name)
|
||||
if path is None:
|
||||
logger.warning("Font file not found: %s, using fallback", font_name)
|
||||
return _load_fallback_font(size)
|
||||
|
||||
cache_key = (path, size)
|
||||
cached = _font_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
if path.lower().endswith('.bdf'):
|
||||
if freetype is None:
|
||||
raise RuntimeError("freetype not available for BDF fonts")
|
||||
face = freetype.Face(path)
|
||||
# Character size in 1/64th points at 72dpi == pixel size.
|
||||
face.set_char_size(size * 64, size * 64, 72, 72)
|
||||
font: Any = face
|
||||
else:
|
||||
font = ImageFont.truetype(path, size)
|
||||
except Exception as e:
|
||||
logger.warning("Error loading font %s at %spx: %s, using fallback",
|
||||
path, size, e)
|
||||
return _load_fallback_font(size)
|
||||
|
||||
_font_cache[cache_key] = font
|
||||
return font
|
||||
|
||||
|
||||
def _load_fallback_font(size: int) -> Any:
|
||||
"""PressStart2P at the requested size, else PIL's built-in default."""
|
||||
path = resolve_font_path(_FALLBACK_FONT_NAME)
|
||||
if path is not None:
|
||||
cache_key = (path, size)
|
||||
cached = _font_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
font = ImageFont.truetype(path, size)
|
||||
_font_cache[cache_key] = font
|
||||
return font
|
||||
except Exception as e:
|
||||
logger.error("Error loading fallback font: %s", e)
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Expand a ``customization.x-style-elements`` declaration into the full
|
||||
per-element property blocks the web-UI config form renders.
|
||||
|
||||
Each declared element becomes an object with ``font`` / ``font_size`` /
|
||||
``text_color`` properties (only the sub-fields the declaration carries),
|
||||
tagged ``x-style-managed: true``; elements declaring ``offsets: true``
|
||||
additionally get an entry under ``customization.layout`` with
|
||||
``x_offset`` / ``y_offset`` integers defaulting to 0. Hand-written
|
||||
element blocks with the same key are left untouched.
|
||||
|
||||
Returns the schema unchanged (same object) when there is nothing to
|
||||
expand; otherwise returns an expanded deep copy. Never raises.
|
||||
"""
|
||||
try:
|
||||
customization = schema.get('properties', {}).get('customization')
|
||||
if not isinstance(customization, dict):
|
||||
return schema
|
||||
declaration = customization.get('x-style-elements')
|
||||
if not isinstance(declaration, dict) or not declaration:
|
||||
return schema
|
||||
|
||||
expanded = copy.deepcopy(schema)
|
||||
customization = expanded['properties']['customization']
|
||||
customization.setdefault('type', 'object')
|
||||
props = customization.setdefault('properties', {})
|
||||
layout_props: Dict[str, Any] = {}
|
||||
|
||||
for element_key, spec in declaration.items():
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
if element_key not in props:
|
||||
props[element_key] = _element_block_from_spec(element_key, spec)
|
||||
if spec.get('offsets'):
|
||||
layout_props[element_key] = _offset_block_from_spec(
|
||||
element_key, spec)
|
||||
|
||||
if layout_props:
|
||||
layout = props.setdefault('layout', {
|
||||
'type': 'object',
|
||||
'title': 'Layout Offsets',
|
||||
'description': 'Pixel offsets applied to each element '
|
||||
'(positive x moves right, positive y moves down)',
|
||||
'x-advanced': True,
|
||||
'properties': {},
|
||||
'additionalProperties': False,
|
||||
})
|
||||
layout.setdefault('properties', {})
|
||||
for element_key, block in layout_props.items():
|
||||
layout['properties'].setdefault(element_key, block)
|
||||
|
||||
return expanded
|
||||
except Exception as e:
|
||||
logger.warning("Error expanding x-style-elements: %s", e)
|
||||
return schema
|
||||
|
||||
|
||||
def _element_block_from_spec(element_key: str,
|
||||
spec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build one expanded per-element schema block from its declaration."""
|
||||
properties: Dict[str, Any] = {}
|
||||
order = []
|
||||
|
||||
font_spec = spec.get('font')
|
||||
if isinstance(font_spec, dict):
|
||||
font_prop: Dict[str, Any] = {
|
||||
'type': 'string',
|
||||
'title': 'Font Family',
|
||||
'x-advanced': True,
|
||||
}
|
||||
if 'default' in font_spec:
|
||||
font_prop['default'] = font_spec['default']
|
||||
if isinstance(font_spec.get('enum'), list):
|
||||
font_prop['enum'] = list(font_spec['enum'])
|
||||
properties['font'] = font_prop
|
||||
order.append('font')
|
||||
|
||||
size_spec = spec.get('size')
|
||||
if isinstance(size_spec, dict):
|
||||
size_prop: Dict[str, Any] = {
|
||||
'type': 'integer',
|
||||
'title': 'Font Size',
|
||||
'description': 'Font size in pixels',
|
||||
'x-advanced': True,
|
||||
}
|
||||
if 'default' in size_spec:
|
||||
size_prop['default'] = size_spec['default']
|
||||
if 'min' in size_spec:
|
||||
size_prop['minimum'] = size_spec['min']
|
||||
if 'max' in size_spec:
|
||||
size_prop['maximum'] = size_spec['max']
|
||||
properties['font_size'] = size_prop
|
||||
order.append('font_size')
|
||||
|
||||
color_spec = spec.get('color')
|
||||
if isinstance(color_spec, dict):
|
||||
color_prop: Dict[str, Any] = {
|
||||
'type': 'array',
|
||||
'title': 'Text Color',
|
||||
'items': {'type': 'integer', 'minimum': 0, 'maximum': 255},
|
||||
'minItems': 3,
|
||||
'maxItems': 3,
|
||||
'x-widget': 'color-picker',
|
||||
}
|
||||
if 'default' in color_spec:
|
||||
color_prop['default'] = list(color_spec['default'])
|
||||
properties['text_color'] = color_prop
|
||||
order.append('text_color')
|
||||
|
||||
return {
|
||||
'type': 'object',
|
||||
'title': spec.get('title', element_key),
|
||||
'x-style-managed': True,
|
||||
'x-propertyOrder': order,
|
||||
'additionalProperties': False,
|
||||
'properties': properties,
|
||||
}
|
||||
|
||||
|
||||
def _offset_block_from_spec(element_key: str,
|
||||
spec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build one layout.<element> offset block (x/y, default 0)."""
|
||||
axis = {
|
||||
'type': 'integer',
|
||||
'default': 0,
|
||||
'x-advanced': True,
|
||||
}
|
||||
return {
|
||||
'type': 'object',
|
||||
'title': spec.get('title', element_key),
|
||||
'x-style-managed': True,
|
||||
'additionalProperties': False,
|
||||
'properties': {
|
||||
'x_offset': dict(axis, title='X Offset'),
|
||||
'y_offset': dict(axis, title='Y Offset'),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract per-element style defaults from a config schema dict.
|
||||
|
||||
Understands both declaration forms: the compact ``x-style-elements``
|
||||
map, and hand-written per-element blocks under
|
||||
``customization.properties`` (their ``font`` / ``font_size`` /
|
||||
``text_color`` property defaults). Returns a config-shaped dict::
|
||||
|
||||
{"customization": {"<element>": {"font": ..., "font_size": ...,
|
||||
"text_color": [...]}, ...}}
|
||||
|
||||
Elements with no declared defaults are omitted. Never raises.
|
||||
"""
|
||||
elements: Dict[str, Dict[str, Any]] = {}
|
||||
try:
|
||||
customization = schema.get('properties', {}).get('customization')
|
||||
if not isinstance(customization, dict):
|
||||
return {'customization': elements}
|
||||
|
||||
declaration = customization.get('x-style-elements')
|
||||
if isinstance(declaration, dict):
|
||||
for element_key, spec in declaration.items():
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
defaults: Dict[str, Any] = {}
|
||||
font_spec = spec.get('font')
|
||||
if isinstance(font_spec, dict) and 'default' in font_spec:
|
||||
defaults['font'] = font_spec['default']
|
||||
size_spec = spec.get('size')
|
||||
if isinstance(size_spec, dict) and 'default' in size_spec:
|
||||
defaults['font_size'] = size_spec['default']
|
||||
color_spec = spec.get('color')
|
||||
if isinstance(color_spec, dict) and 'default' in color_spec:
|
||||
defaults['text_color'] = list(color_spec['default'])
|
||||
if defaults:
|
||||
elements[element_key] = defaults
|
||||
|
||||
properties = customization.get('properties')
|
||||
if isinstance(properties, dict):
|
||||
for element_key, block in properties.items():
|
||||
if element_key == 'layout' or element_key in elements:
|
||||
continue
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_props = block.get('properties')
|
||||
if not isinstance(block_props, dict):
|
||||
continue
|
||||
defaults = {}
|
||||
for style_key in _STYLE_KEYS:
|
||||
prop = block_props.get(style_key)
|
||||
if isinstance(prop, dict) and 'default' in prop:
|
||||
defaults[style_key] = prop['default']
|
||||
if defaults:
|
||||
elements[element_key] = defaults
|
||||
except Exception as e:
|
||||
logger.warning("Error extracting style defaults from schema: %s", e)
|
||||
return {'customization': elements}
|
||||
|
||||
|
||||
def defaults_from_schema_file(schema_path: Union[str, os.PathLike]) -> Dict[str, Any]:
|
||||
"""``defaults_from_schema`` for a schema file on disk. A missing or
|
||||
malformed file yields empty defaults (with a logged warning) — every
|
||||
configured value then counts as a user override, which is the safe
|
||||
degradation. Never raises."""
|
||||
try:
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
if not isinstance(schema, dict):
|
||||
raise ValueError("schema is not a JSON object")
|
||||
except Exception as e:
|
||||
logger.warning("Could not read style defaults from %s: %s",
|
||||
schema_path, e)
|
||||
return {'customization': {}}
|
||||
return defaults_from_schema(schema)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
|
||||
"""An (r, g, b) tuple of ints in 0..255, or None for anything else."""
|
||||
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||
try:
|
||||
rgb = tuple(int(c) for c in value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if all(0 <= c <= 255 for c in rgb):
|
||||
return rgb # type: ignore[return-value]
|
||||
return None
|
||||
|
||||
|
||||
class ElementStyleResolver:
|
||||
"""Resolves per-element user styling against schema defaults.
|
||||
|
||||
Built from a plugin's live config dict and the defaults extracted from
|
||||
its own ``config_schema.json`` (``defaults_from_schema_file``). The
|
||||
config dict is held by reference as ``_config`` — consumers compare
|
||||
identity (``resolver._config is not self.config``) to decide when a
|
||||
resolver must be rebuilt after ``on_config_change`` swaps the dict.
|
||||
|
||||
A configured font/size/color counts as user-forced only when it differs
|
||||
from the schema default (see module docstring); otherwise ``style()``
|
||||
returns the caller's classic values verbatim, keeping untouched configs
|
||||
byte-identical to pre-customization rendering.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]],
|
||||
defaults: Optional[Dict[str, Any]] = None):
|
||||
# Keep the exact object for identity-based invalidation, even if the
|
||||
# caller hands us something odd; reads are guarded.
|
||||
self._config = config
|
||||
if isinstance(defaults, dict):
|
||||
element_defaults = defaults.get('customization', {})
|
||||
else:
|
||||
element_defaults = {}
|
||||
self._defaults: Dict[str, Any] = (
|
||||
element_defaults if isinstance(element_defaults, dict) else {})
|
||||
self._memo: Dict[Any, ElementStyle] = {}
|
||||
|
||||
# -- internal accessors -------------------------------------------------
|
||||
|
||||
def _customization(self) -> Dict[str, Any]:
|
||||
config = self._config if isinstance(self._config, dict) else {}
|
||||
customization = config.get('customization', {})
|
||||
return customization if isinstance(customization, dict) else {}
|
||||
|
||||
def _element_config(self, element_key: str) -> Dict[str, Any]:
|
||||
element = self._customization().get(element_key, {})
|
||||
return element if isinstance(element, dict) else {}
|
||||
|
||||
def _element_defaults(self, element_key: str) -> Dict[str, Any]:
|
||||
defaults = self._defaults.get(element_key, {})
|
||||
return defaults if isinstance(defaults, dict) else {}
|
||||
|
||||
# -- public API ---------------------------------------------------------
|
||||
|
||||
def style(self, element_key: str,
|
||||
classic_font: str = _FALLBACK_FONT_NAME,
|
||||
classic_size: int = 8,
|
||||
classic_color: Optional[Tuple[int, int, int]] = None) -> ElementStyle:
|
||||
"""Resolve one element's style. Never raises.
|
||||
|
||||
Args:
|
||||
element_key: Key under ``config['customization']`` (e.g.
|
||||
``'title_text'``).
|
||||
classic_font: Font filename the plugin's classic (pre-
|
||||
customization) code used for this element.
|
||||
classic_size: Classic pixel size.
|
||||
classic_color: Classic RGB color, or None when the caller only
|
||||
cares about the font (``.color`` then falls back to the
|
||||
schema default color, else white).
|
||||
|
||||
Returns:
|
||||
ElementStyle with the loaded font face, RGB color, (x, y)
|
||||
offset, and the ``user_forced`` / ``user_forced_color`` flags.
|
||||
"""
|
||||
try:
|
||||
memo_key = (element_key, classic_font, classic_size,
|
||||
_normalize_color(classic_color) or classic_color)
|
||||
memoized = self._memo.get(memo_key)
|
||||
if memoized is not None:
|
||||
return memoized
|
||||
except Exception:
|
||||
memo_key = None
|
||||
|
||||
try:
|
||||
resolved = self._resolve(element_key, classic_font,
|
||||
classic_size, classic_color)
|
||||
except Exception as e:
|
||||
logger.warning("Error resolving style for element '%s': %s — "
|
||||
"using classic style", element_key, e)
|
||||
resolved = self._classic_style(classic_font, classic_size,
|
||||
classic_color)
|
||||
if memo_key is not None:
|
||||
self._memo[memo_key] = resolved
|
||||
return resolved
|
||||
|
||||
def offset(self, element_key: str) -> Tuple[int, int]:
|
||||
"""The user's ``customization.layout.<element>`` (x, y) pixel
|
||||
offset, defaulting to (0, 0). Never raises."""
|
||||
return (self.offset_value(element_key, 'x_offset', 0),
|
||||
self.offset_value(element_key, 'y_offset', 0))
|
||||
|
||||
def offset_value(self, element_key: str, axis: str, default: int = 0) -> int:
|
||||
"""One ``customization.layout.<element>.<axis>`` value as an int.
|
||||
|
||||
``axis`` is usually ``'x_offset'`` / ``'y_offset'`` but any key is
|
||||
honored (e.g. the scoreboards' ``'away_x_offset'``). Numeric
|
||||
strings are coerced; anything else degrades to ``default``. Never
|
||||
raises.
|
||||
"""
|
||||
try:
|
||||
layout = self._customization().get('layout', {})
|
||||
if not isinstance(layout, dict):
|
||||
return int(default)
|
||||
element = layout.get(element_key, {})
|
||||
if not isinstance(element, dict):
|
||||
return int(default)
|
||||
value = element.get(axis, default)
|
||||
if isinstance(value, bool):
|
||||
return int(default)
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Invalid layout offset for %s.%s: %r, using %s",
|
||||
element_key, axis, value, default)
|
||||
return int(default)
|
||||
return int(default)
|
||||
except Exception as e:
|
||||
logger.warning("Error reading layout offset %s.%s: %s",
|
||||
element_key, axis, e)
|
||||
try:
|
||||
return int(default)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
# -- resolution internals -----------------------------------------------
|
||||
|
||||
def _resolve(self, element_key: str, classic_font: str,
|
||||
classic_size: int,
|
||||
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
|
||||
element_config = self._element_config(element_key)
|
||||
element_defaults = self._element_defaults(element_key)
|
||||
|
||||
# Font family: forced only when it differs from the schema default
|
||||
# (falling back to the classic font as the reference when the
|
||||
# schema declares none).
|
||||
default_font = element_defaults.get('font', classic_font)
|
||||
configured_font = element_config.get('font')
|
||||
font_forced = (isinstance(configured_font, str) and configured_font
|
||||
and configured_font != default_font)
|
||||
|
||||
# Font size: same rule, with defensive int coercion.
|
||||
default_size = self._coerce_size(
|
||||
element_defaults.get('font_size'), None)
|
||||
if default_size is None:
|
||||
default_size = self._coerce_size(classic_size, 8)
|
||||
configured_size = self._coerce_size(element_config.get('font_size'),
|
||||
None)
|
||||
size_forced = (configured_size is not None
|
||||
and configured_size != default_size)
|
||||
|
||||
font_name = configured_font if font_forced else classic_font
|
||||
font_size = configured_size if size_forced else self._coerce_size(
|
||||
classic_size, 8)
|
||||
user_forced = bool(font_forced or size_forced)
|
||||
|
||||
# Color: forced only when it differs from the schema default (or,
|
||||
# absent one, from the classic color).
|
||||
default_color = _normalize_color(element_defaults.get('text_color'))
|
||||
configured_color = _normalize_color(element_config.get('text_color'))
|
||||
reference_color = (default_color if default_color is not None
|
||||
else _normalize_color(classic_color))
|
||||
color_forced = (configured_color is not None
|
||||
and configured_color != reference_color)
|
||||
if color_forced:
|
||||
color = configured_color
|
||||
else:
|
||||
color = (_normalize_color(classic_color) or default_color
|
||||
or (255, 255, 255))
|
||||
|
||||
return ElementStyle(
|
||||
font=load_font(font_name, font_size),
|
||||
color=color,
|
||||
offset=self.offset(element_key),
|
||||
font_name=font_name,
|
||||
font_size=font_size,
|
||||
user_forced=user_forced,
|
||||
user_forced_color=bool(color_forced),
|
||||
)
|
||||
|
||||
def _classic_style(self, classic_font: str, classic_size: int,
|
||||
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
|
||||
"""The untouched fallback style — used when resolution itself
|
||||
fails, so ``style()`` can keep its never-raises promise."""
|
||||
size = self._coerce_size(classic_size, 8)
|
||||
return ElementStyle(
|
||||
font=load_font(classic_font, size),
|
||||
color=_normalize_color(classic_color) or (255, 255, 255),
|
||||
offset=(0, 0),
|
||||
font_name=classic_font,
|
||||
font_size=size,
|
||||
user_forced=False,
|
||||
user_forced_color=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_size(value: Any, default: Optional[int]) -> Optional[int]:
|
||||
"""An int pixel size, or ``default`` for None/garbage."""
|
||||
if value is None or isinstance(value, bool):
|
||||
return default
|
||||
try:
|
||||
size = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return size if size > 0 else default
|
||||
+21
-1
@@ -659,6 +659,25 @@ class FontManager:
|
||||
|
||||
# ==================== Font Discovery ====================
|
||||
|
||||
@staticmethod
|
||||
def _resolve_asset_path(relative_path: str) -> str:
|
||||
"""Resolve a repo-relative asset path independently of the process cwd.
|
||||
|
||||
Prefers the working directory (preserving behavior when the process
|
||||
runs from the install root), then falls back to the install root
|
||||
derived from this module's own location. Without the fallback, any
|
||||
process started outside the install root (e.g. the plugin safety
|
||||
harness on CI) silently loses every font and degrades to PIL's
|
||||
default face.
|
||||
"""
|
||||
if os.path.exists(relative_path):
|
||||
return relative_path
|
||||
install_root = Path(__file__).resolve().parent.parent
|
||||
candidate = install_root / relative_path
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
return relative_path
|
||||
|
||||
def _initialize_fonts(self):
|
||||
"""Initialize font catalog and validate configuration."""
|
||||
self._scan_fonts_directory()
|
||||
@@ -667,7 +686,7 @@ class FontManager:
|
||||
|
||||
def _scan_fonts_directory(self):
|
||||
"""Scan assets/fonts directory for available fonts."""
|
||||
fonts_dir = "assets/fonts"
|
||||
fonts_dir = self._resolve_asset_path("assets/fonts")
|
||||
if not os.path.exists(fonts_dir):
|
||||
logger.warning(f"Fonts directory not found: {fonts_dir}")
|
||||
return
|
||||
@@ -683,6 +702,7 @@ class FontManager:
|
||||
def _register_common_fonts(self):
|
||||
"""Register common font aliases from common_fonts dictionary."""
|
||||
for family_name, font_path in self.common_fonts.items():
|
||||
font_path = self._resolve_asset_path(font_path)
|
||||
# Check if font file exists
|
||||
if os.path.exists(font_path):
|
||||
# Register the common font name (overrides auto-generated name if exists)
|
||||
|
||||
@@ -145,6 +145,77 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
raise NotImplementedError("Plugins must implement display()")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Global (whole-device) configuration
|
||||
# -------------------------------------------------------------------------
|
||||
@property
|
||||
def global_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
The full LEDMatrix configuration, for reading device-wide settings.
|
||||
|
||||
``self.config`` is only this plugin's own slice, so cross-cutting
|
||||
settings — ``target_fps``, ``timezone``, ``location`` — were previously
|
||||
unreachable from a plugin without reaching into a manager by hand.
|
||||
|
||||
Resolution order mirrors the timezone helpers the sports plugins
|
||||
already ship: ``plugin_manager.config_manager`` first (the cores that
|
||||
hang it there), then ``cache_manager.config_manager``. Returns ``{}``
|
||||
when neither is available, so callers can use plain ``.get()`` without
|
||||
guarding, and a plugin on a core that predates this property still
|
||||
loads — ``getattr(self, 'global_config', {})`` simply yields the
|
||||
default.
|
||||
|
||||
Treat as read-only: the returned dict is the live config the core is
|
||||
using, so mutating it edits every other consumer's view and can be
|
||||
persisted back to disk.
|
||||
|
||||
Assignment is still allowed and wins over the resolved value. Several
|
||||
shipped plugins (news, stock-news, ledmatrix-stocks, ledmatrix-
|
||||
elections, ledmatrix-leaderboard, nfl-draft) set
|
||||
``self.global_config`` to their own ``config['global']`` sub-dict; a
|
||||
property without a setter would raise AttributeError and stop those
|
||||
plugins loading.
|
||||
|
||||
Example:
|
||||
fps = self.global_config.get('target_fps')
|
||||
"""
|
||||
override = getattr(self, '_global_config_override', None)
|
||||
if override is not None:
|
||||
return override
|
||||
for owner in (self.plugin_manager, self.cache_manager):
|
||||
config_manager = getattr(owner, 'config_manager', None)
|
||||
if config_manager is None:
|
||||
continue
|
||||
try:
|
||||
config = config_manager.get_config()
|
||||
except Exception:
|
||||
# A broken or unreadable config must never stop a plugin from
|
||||
# loading; fall through to the next source, then to {}.
|
||||
self.logger.debug(
|
||||
"Could not read global config from %s",
|
||||
type(owner).__name__, exc_info=True,
|
||||
)
|
||||
continue
|
||||
# Only a real mapping is usable: callers do .get() on this and feed
|
||||
# the result to numeric code, so handing back whatever a stub or a
|
||||
# half-built manager returned would fail later and further away.
|
||||
#
|
||||
# An empty dict is treated as "nothing here yet" rather than a
|
||||
# valid answer, so resolution continues to the next source. Both
|
||||
# managers default to the same config/config.json, so falling
|
||||
# through cannot pick up a different file's settings -- but it does
|
||||
# rescue the case where the first manager simply hasn't loaded yet,
|
||||
# which would otherwise return {} and silently disable every
|
||||
# setting read through this property.
|
||||
if isinstance(config, dict) and config:
|
||||
return config
|
||||
return {}
|
||||
|
||||
@global_config.setter
|
||||
def global_config(self, value: Dict[str, Any]) -> None:
|
||||
"""Let a plugin substitute its own view (see the getter's docstring)."""
|
||||
self._global_config_override = value
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Adaptive layout support (opt-in)
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -505,6 +576,40 @@ class BasePlugin(ABC):
|
||||
# -------------------------------------------------------------------------
|
||||
# Vegas scroll mode support
|
||||
# -------------------------------------------------------------------------
|
||||
def get_vegas_render_width(self) -> int:
|
||||
"""
|
||||
Width the Vegas ticker wants this plugin's content to occupy.
|
||||
|
||||
On a wide panel a layout built to fill the screen reads as sparse in a
|
||||
ticker — a forecast spread over five columns, a progress bar drawn at
|
||||
100% width, a stat block with the panel's whole width between its
|
||||
elements. Vegas asks for a narrower render so the plugin can choose a
|
||||
tighter arrangement instead of being cropped afterwards.
|
||||
|
||||
Vegas also narrows ``display_manager`` for the duration of the call, so
|
||||
a plugin that already sizes itself from ``matrix.width`` needs no
|
||||
changes. Read this only when you size content some other way.
|
||||
|
||||
Controlled by the plugin's own ``vegas_width_pct`` config value, else
|
||||
the global ``display.vegas_scroll.render_width_pct``.
|
||||
|
||||
Returns:
|
||||
Target width in pixels. Outside a Vegas content request, the full
|
||||
display width.
|
||||
"""
|
||||
requested = getattr(self, '_vegas_render_width', None)
|
||||
if isinstance(requested, int) and requested > 0:
|
||||
return requested
|
||||
|
||||
display_manager = getattr(self, 'display_manager', None)
|
||||
matrix = getattr(display_manager, 'matrix', None)
|
||||
if matrix is not None and getattr(matrix, 'width', None):
|
||||
return int(matrix.width)
|
||||
width = getattr(display_manager, 'width', None)
|
||||
if callable(width):
|
||||
width = width()
|
||||
return int(width) if width else 128
|
||||
|
||||
def get_vegas_content(self) -> Optional[Any]:
|
||||
"""
|
||||
Get content for Vegas-style continuous scroll mode.
|
||||
|
||||
@@ -115,7 +115,17 @@ class SchemaManager:
|
||||
if not isinstance(schema, dict):
|
||||
self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary")
|
||||
return None
|
||||
|
||||
|
||||
# Expand any customization.x-style-elements declaration into the
|
||||
# full per-element style blocks (font/size/color + layout
|
||||
# offsets) the web-UI config form renders. No-op for schemas
|
||||
# without the declaration; never raises.
|
||||
try:
|
||||
from src.element_style import expand_style_elements
|
||||
schema = expand_style_elements(schema)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Cache the schema
|
||||
self._schema_cache[plugin_id] = schema
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ PIL Image canvas and draws text using the actual project fonts.
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
@@ -62,6 +63,9 @@ class VisualTestDisplayManager:
|
||||
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
||||
self.matrix = _MatrixProxy(width, height)
|
||||
|
||||
# Set while inside capture_mode(); mirrors DisplayManager's flag.
|
||||
self._capture_mode_active = False
|
||||
|
||||
# Scrolling state (interface compat, no-op)
|
||||
self._scrolling_state = {
|
||||
'is_scrolling': False,
|
||||
@@ -174,6 +178,50 @@ class VisualTestDisplayManager:
|
||||
"""No-op for hardware; marks that display was updated."""
|
||||
self.update_called = True
|
||||
|
||||
@contextmanager
|
||||
def render_size(self, width: int, height: Optional[int] = None):
|
||||
"""
|
||||
Interface parity with DisplayManager.render_size().
|
||||
|
||||
Vegas mode narrows the canvas so plugins lay out compactly instead of
|
||||
being cropped. The harness must offer the same context or that path
|
||||
cannot be exercised offline — and because the adapter catches broadly,
|
||||
a missing method shows up as "no content" rather than an error.
|
||||
"""
|
||||
prev_image = self.image
|
||||
prev_draw = self.draw
|
||||
prev_w, prev_h = self._width, self._height
|
||||
|
||||
target_w = max(1, min(int(width), prev_w))
|
||||
target_h = max(1, min(int(height) if height else prev_h, prev_h))
|
||||
|
||||
try:
|
||||
self._width, self._height = target_w, target_h
|
||||
self.matrix = _MatrixProxy(target_w, target_h)
|
||||
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
yield
|
||||
finally:
|
||||
self._width, self._height = prev_w, prev_h
|
||||
self.matrix = _MatrixProxy(prev_w, prev_h)
|
||||
self.image = prev_image
|
||||
self.draw = prev_draw
|
||||
|
||||
@contextmanager
|
||||
def capture_mode(self):
|
||||
"""
|
||||
Interface parity with DisplayManager.capture_mode().
|
||||
|
||||
There is no hardware to suppress here, but Vegas mode's PluginAdapter
|
||||
wraps every off-screen content fetch in this context, so the harness
|
||||
must provide it for that code path to be exercisable in tests.
|
||||
"""
|
||||
self._capture_mode_active = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._capture_mode_active = False
|
||||
|
||||
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
|
||||
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
|
||||
font: Optional[Any] = None, centered: bool = False) -> None:
|
||||
|
||||
@@ -21,6 +21,94 @@ class VegasModeConfig:
|
||||
scroll_speed: float = 50.0 # Pixels per second
|
||||
separator_width: int = 32 # Gap between plugins (pixels)
|
||||
|
||||
# Fraction of the panel width a plugin is told it has while rendering for
|
||||
# the ticker, as a percentage. Trimming can only remove blank margins; it
|
||||
# cannot compact a layout that genuinely spans the display — a five-column
|
||||
# forecast, a full-width progress bar, a centred stat block with the panel's
|
||||
# whole width between its elements. Rendering at a narrower size makes the
|
||||
# plugin choose a tighter layout instead. 100 disables it.
|
||||
render_width_pct: int = 100
|
||||
|
||||
# Minimum blank columns guaranteed between adjacent content, measured from
|
||||
# actual ink rather than added blindly. A flat additive gap leaves
|
||||
# card-style content nearly touching when the cards are drawn flush to their
|
||||
# own edges, while padding out content that already has wide margins.
|
||||
min_content_separation: int = 24
|
||||
|
||||
# Gap between rows contributed by the *same* plugin. separator_width marks
|
||||
# the handoff from one plugin to the next; applying it between every image
|
||||
# forced a 32px chasm between each row of a per-row ticker (the F1
|
||||
# scoreboard renders its own rows 4px apart), which both looked wrong and
|
||||
# silently inflated the width that plugin occupied.
|
||||
intra_plugin_gap: int = 8
|
||||
|
||||
# Content density
|
||||
#
|
||||
# Plugins that render onto a full-display canvas contribute that whole
|
||||
# canvas to the ticker, blank margins included. On a wide panel that is the
|
||||
# dominant source of dead air: a plugin drawing 35px of text on a 512px
|
||||
# canvas otherwise buys 9.5s of black at 50px/s. Trimming reclaims it.
|
||||
auto_trim: bool = True
|
||||
trim_threshold: int = 10 # Per-channel value a pixel must exceed to be "ink"
|
||||
content_padding: int = 8 # Blank columns kept either side of trimmed content
|
||||
min_plugin_width: int = 8 # Segments narrower than this after trim are dropped
|
||||
|
||||
# Columns of blank lead-in before the first item of a cycle. ScrollHelper
|
||||
# defaults this to a full display width, which reads as the display being
|
||||
# switched off at the start of every cycle.
|
||||
lead_in_width: int = 0
|
||||
|
||||
# Blend between neighbouring pixel positions so motion happens at the frame
|
||||
# rate rather than the scroll speed. With integer positioning the number of
|
||||
# distinct frames per second equals scroll_speed, so at 50px/s the motion is
|
||||
# 50 discrete 1px steps however fast the loop runs. The trade is a slight
|
||||
# horizontal softening of text, since each frame is a blend of two positions.
|
||||
smooth_scroll: bool = True
|
||||
|
||||
# Keep one continuous strip, extending it with the next group of plugins as
|
||||
# the scroll approaches the end, instead of composing a fresh strip and
|
||||
# swapping it in. A swap stops the motion, substitutes every pixel at once
|
||||
# and restarts with the viewport already full — read as a freeze, a flash
|
||||
# and a jump. Extending means the next group simply scrolls in from the
|
||||
# right. Set false to restore the swap behaviour.
|
||||
continuous_scroll: bool = True
|
||||
|
||||
# Extend once the unscrolled remainder falls below this many screen widths.
|
||||
# Needs to be more than one so the join is prepared before it is on screen.
|
||||
extend_threshold_screens: float = 2.0
|
||||
|
||||
# How many plugins are composed into one scroll cycle. Kept separate from
|
||||
# buffer_ahead (which is only a prefetch low-water mark) because the two
|
||||
# were previously the same number: a buffer_ahead of 2 meant just 3 plugins
|
||||
# per cycle, so a 20-plugin install took seven cycles to come around.
|
||||
plugins_per_cycle: int = 6
|
||||
|
||||
# Minimum run of blank columns that counts as a boundary between items when
|
||||
# an oversized segment has to be narrowed. Measured on rendered text, the
|
||||
# gaps between characters are a single column while gaps between items are
|
||||
# 8px and up, so anything above 1 stops a cut landing inside a word. Cutting
|
||||
# mid-word orphaned the tail into the next cycle, which showed up as a lone
|
||||
# letter floating between two unrelated plugins.
|
||||
min_cut_gap: int = 6
|
||||
|
||||
# What to do when a plugin's content exceeds its width budget.
|
||||
#
|
||||
# "rotate" — advance a window each cycle so everything is seen eventually.
|
||||
# Right for interchangeable items: news headlines, odds, stocks.
|
||||
# "truncate" — always show the start. Right for ordered content, where a
|
||||
# window into the middle is meaningless: a league table that
|
||||
# shows ranks 1-6 then resumes at 7 two rotations later reads
|
||||
# as out of order and out of context.
|
||||
#
|
||||
# Override per plugin with vegas_overflow.
|
||||
overflow_mode: str = "rotate"
|
||||
|
||||
# Cap on one plugin's share of a cycle, as a multiple of display width.
|
||||
# A single ticker returning 7,000px would otherwise hold the panel for over
|
||||
# two minutes. Overflow is deferred to later cycles rather than discarded.
|
||||
# 0 disables the cap.
|
||||
max_plugin_width_ratio: float = 3.0
|
||||
|
||||
# Plugin management
|
||||
plugin_order: List[str] = field(default_factory=list)
|
||||
excluded_plugins: Set[str] = field(default_factory=set)
|
||||
@@ -55,6 +143,24 @@ class VegasModeConfig:
|
||||
enabled=vegas_config.get('enabled', False),
|
||||
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
|
||||
separator_width=int(vegas_config.get('separator_width', 32)),
|
||||
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
|
||||
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
|
||||
min_content_separation=int(
|
||||
vegas_config.get('min_content_separation', 24)),
|
||||
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
|
||||
smooth_scroll=vegas_config.get('smooth_scroll', True),
|
||||
continuous_scroll=vegas_config.get('continuous_scroll', True),
|
||||
extend_threshold_screens=float(
|
||||
vegas_config.get('extend_threshold_screens', 2.0)),
|
||||
auto_trim=vegas_config.get('auto_trim', True),
|
||||
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
|
||||
content_padding=int(vegas_config.get('content_padding', 8)),
|
||||
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)),
|
||||
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
|
||||
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
|
||||
max_plugin_width_ratio=float(
|
||||
vegas_config.get('max_plugin_width_ratio', 3.0)),
|
||||
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
||||
target_fps=int(vegas_config.get('target_fps', 125)),
|
||||
@@ -72,6 +178,21 @@ class VegasModeConfig:
|
||||
'enabled': self.enabled,
|
||||
'scroll_speed': self.scroll_speed,
|
||||
'separator_width': self.separator_width,
|
||||
'intra_plugin_gap': self.intra_plugin_gap,
|
||||
'render_width_pct': self.render_width_pct,
|
||||
'min_content_separation': self.min_content_separation,
|
||||
'min_cut_gap': self.min_cut_gap,
|
||||
'smooth_scroll': self.smooth_scroll,
|
||||
'continuous_scroll': self.continuous_scroll,
|
||||
'extend_threshold_screens': self.extend_threshold_screens,
|
||||
'auto_trim': self.auto_trim,
|
||||
'trim_threshold': self.trim_threshold,
|
||||
'content_padding': self.content_padding,
|
||||
'min_plugin_width': self.min_plugin_width,
|
||||
'lead_in_width': self.lead_in_width,
|
||||
'plugins_per_cycle': self.plugins_per_cycle,
|
||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
||||
'overflow_mode': self.overflow_mode,
|
||||
'plugin_order': self.plugin_order,
|
||||
'excluded_plugins': list(self.excluded_plugins),
|
||||
'target_fps': self.target_fps,
|
||||
@@ -157,6 +278,74 @@ class VegasModeConfig:
|
||||
if self.buffer_ahead > 5:
|
||||
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
|
||||
|
||||
if not 10 <= self.render_width_pct <= 100:
|
||||
errors.append(
|
||||
"render_width_pct must be between 10 and 100, "
|
||||
f"got {self.render_width_pct}")
|
||||
|
||||
if not 0 <= self.min_content_separation <= 256:
|
||||
errors.append(
|
||||
"min_content_separation must be between 0 and 256, "
|
||||
f"got {self.min_content_separation}")
|
||||
|
||||
if not 1.0 <= self.extend_threshold_screens <= 10.0:
|
||||
errors.append(
|
||||
"extend_threshold_screens must be between 1.0 and 10.0, "
|
||||
f"got {self.extend_threshold_screens}")
|
||||
|
||||
if not 1 <= self.min_cut_gap <= 128:
|
||||
errors.append(
|
||||
"min_cut_gap must be between 1 and 128, "
|
||||
f"got {self.min_cut_gap}")
|
||||
|
||||
if self.intra_plugin_gap < 0:
|
||||
errors.append(
|
||||
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
|
||||
if self.intra_plugin_gap > 128:
|
||||
errors.append(
|
||||
f"intra_plugin_gap must be <= 128, got {self.intra_plugin_gap}")
|
||||
|
||||
if not 0 <= self.trim_threshold <= 254:
|
||||
errors.append(
|
||||
f"trim_threshold must be between 0 and 254, got {self.trim_threshold}")
|
||||
|
||||
if self.content_padding < 0:
|
||||
errors.append(
|
||||
f"content_padding must be >= 0, got {self.content_padding}")
|
||||
if self.content_padding > 128:
|
||||
errors.append(
|
||||
f"content_padding must be <= 128, got {self.content_padding}")
|
||||
|
||||
if self.min_plugin_width < 0:
|
||||
errors.append(
|
||||
f"min_plugin_width must be >= 0, got {self.min_plugin_width}")
|
||||
# Bounded because every segment narrower than this is dropped — an
|
||||
# unbounded value would discard every plugin and leave a blank ticker.
|
||||
if self.min_plugin_width > 512:
|
||||
errors.append(
|
||||
f"min_plugin_width must be <= 512, got {self.min_plugin_width}")
|
||||
|
||||
if self.lead_in_width < 0:
|
||||
errors.append(
|
||||
f"lead_in_width must be >= 0, got {self.lead_in_width}")
|
||||
|
||||
if self.plugins_per_cycle < 1:
|
||||
errors.append(
|
||||
f"plugins_per_cycle must be >= 1, got {self.plugins_per_cycle}")
|
||||
if self.plugins_per_cycle > 50:
|
||||
errors.append(
|
||||
f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}")
|
||||
|
||||
if self.overflow_mode not in ('rotate', 'truncate'):
|
||||
errors.append(
|
||||
"overflow_mode must be 'rotate' or 'truncate', "
|
||||
f"got {self.overflow_mode!r}")
|
||||
|
||||
if self.max_plugin_width_ratio < 0:
|
||||
errors.append(
|
||||
"max_plugin_width_ratio must be >= 0 "
|
||||
f"(0 disables the cap), got {self.max_plugin_width_ratio}")
|
||||
|
||||
return errors
|
||||
|
||||
def update(self, new_config: Dict[str, Any]) -> None:
|
||||
@@ -174,6 +363,39 @@ class VegasModeConfig:
|
||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||
if 'separator_width' in vegas_config:
|
||||
self.separator_width = int(vegas_config['separator_width'])
|
||||
if 'intra_plugin_gap' in vegas_config:
|
||||
self.intra_plugin_gap = int(vegas_config['intra_plugin_gap'])
|
||||
if 'render_width_pct' in vegas_config:
|
||||
self.render_width_pct = int(vegas_config['render_width_pct'])
|
||||
if 'min_content_separation' in vegas_config:
|
||||
self.min_content_separation = int(
|
||||
vegas_config['min_content_separation'])
|
||||
if 'min_cut_gap' in vegas_config:
|
||||
self.min_cut_gap = int(vegas_config['min_cut_gap'])
|
||||
if 'smooth_scroll' in vegas_config:
|
||||
self.smooth_scroll = vegas_config['smooth_scroll']
|
||||
if 'continuous_scroll' in vegas_config:
|
||||
self.continuous_scroll = vegas_config['continuous_scroll']
|
||||
if 'extend_threshold_screens' in vegas_config:
|
||||
self.extend_threshold_screens = float(
|
||||
vegas_config['extend_threshold_screens'])
|
||||
if 'auto_trim' in vegas_config:
|
||||
self.auto_trim = vegas_config['auto_trim']
|
||||
if 'trim_threshold' in vegas_config:
|
||||
self.trim_threshold = int(vegas_config['trim_threshold'])
|
||||
if 'content_padding' in vegas_config:
|
||||
self.content_padding = int(vegas_config['content_padding'])
|
||||
if 'min_plugin_width' in vegas_config:
|
||||
self.min_plugin_width = int(vegas_config['min_plugin_width'])
|
||||
if 'lead_in_width' in vegas_config:
|
||||
self.lead_in_width = int(vegas_config['lead_in_width'])
|
||||
if 'plugins_per_cycle' in vegas_config:
|
||||
self.plugins_per_cycle = int(vegas_config['plugins_per_cycle'])
|
||||
if 'max_plugin_width_ratio' in vegas_config:
|
||||
self.max_plugin_width_ratio = float(
|
||||
vegas_config['max_plugin_width_ratio'])
|
||||
if 'overflow_mode' in vegas_config:
|
||||
self.overflow_mode = str(vegas_config['overflow_mode'])
|
||||
if 'plugin_order' in vegas_config:
|
||||
self.plugin_order = list(vegas_config['plugin_order'])
|
||||
if 'excluded_plugins' in vegas_config:
|
||||
|
||||
@@ -64,7 +64,7 @@ class VegasModeCoordinator:
|
||||
self.plugin_manager = plugin_manager
|
||||
|
||||
# Initialize components
|
||||
self.plugin_adapter = PluginAdapter(display_manager)
|
||||
self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
|
||||
self.stream_manager = StreamManager(
|
||||
self.vegas_config,
|
||||
plugin_manager,
|
||||
@@ -233,6 +233,11 @@ class VegasModeCoordinator:
|
||||
self._should_stop = False
|
||||
self._start_time = time.time()
|
||||
|
||||
# Line up the next group immediately, so the first extension is already
|
||||
# warm rather than stalling the scroll to fetch it.
|
||||
if self.vegas_config.continuous_scroll:
|
||||
self.render_pipeline.start_prefetch()
|
||||
|
||||
logger.info("Vegas mode started")
|
||||
return True
|
||||
|
||||
@@ -301,16 +306,43 @@ class VegasModeCoordinator:
|
||||
if has_pending_update:
|
||||
self._apply_pending_config()
|
||||
|
||||
# Check if we need to start a new cycle
|
||||
if self.render_pipeline.is_cycle_complete():
|
||||
if not self.render_pipeline.start_new_cycle():
|
||||
logger.warning("Failed to start new Vegas cycle")
|
||||
return False
|
||||
self.stats['cycles_completed'] += 1
|
||||
if self.vegas_config.continuous_scroll:
|
||||
# Drop cached content for plugins whose data just changed, so the
|
||||
# next time each comes round it is composed from current data. The
|
||||
# swap path's hot_swap_content() does this via process_updates(),
|
||||
# but it also rebuilds and repositions the whole strip, which is
|
||||
# the freeze-and-jump this mode exists to avoid. Without this the
|
||||
# pending-update flags are never consumed and a segment keeps
|
||||
# rendering whatever it was first built from — last night's live
|
||||
# game still shown as live the next morning.
|
||||
self.render_pipeline.refresh_updated_plugins()
|
||||
|
||||
# Check for hot-swap opportunities
|
||||
if self.render_pipeline.should_recompose():
|
||||
self.render_pipeline.hot_swap_content()
|
||||
# Extend the strip before the scroll can reach its end, so the next
|
||||
# group arrives from the right and motion never stops. No cycle
|
||||
# boundary, so no freeze, no substitution and no restart with the
|
||||
# viewport already full.
|
||||
# Trickle in the plugins that can only be fetched here, one per
|
||||
# frame, before considering a further extension.
|
||||
if self.render_pipeline.has_deferred():
|
||||
self.render_pipeline.drain_deferred()
|
||||
elif self.render_pipeline.needs_extension():
|
||||
if self.render_pipeline.extend_scroll_content():
|
||||
self.stats['cycles_completed'] += 1
|
||||
elif self.render_pipeline.is_cycle_complete():
|
||||
# Extension failed and the strip has run out: fall back to
|
||||
# the swap rather than sitting on a dead frame.
|
||||
self.render_pipeline.start_new_cycle()
|
||||
else:
|
||||
# Check if we need to start a new cycle
|
||||
if self.render_pipeline.is_cycle_complete():
|
||||
if not self.render_pipeline.start_new_cycle():
|
||||
logger.warning("Failed to start new Vegas cycle")
|
||||
return False
|
||||
self.stats['cycles_completed'] += 1
|
||||
|
||||
# Check for hot-swap opportunities
|
||||
if self.render_pipeline.should_recompose():
|
||||
self.render_pipeline.hot_swap_content()
|
||||
|
||||
# Render frame
|
||||
return self.render_pipeline.render_frame()
|
||||
@@ -337,7 +369,14 @@ class VegasModeCoordinator:
|
||||
self._update_static_mode_plugins()
|
||||
|
||||
frame_interval = self.vegas_config.get_frame_interval()
|
||||
duration = self.render_pipeline.get_dynamic_duration()
|
||||
if self.vegas_config.continuous_scroll:
|
||||
# The strip is continuously extended and trimmed, so its width says
|
||||
# nothing about how long to run. This is only how often control
|
||||
# returns to the display controller; interrupts are still checked
|
||||
# every few frames, so it costs nothing to make it a fixed period.
|
||||
duration = float(self.vegas_config.max_cycle_duration)
|
||||
else:
|
||||
duration = self.render_pipeline.get_dynamic_duration()
|
||||
start_time = time.time()
|
||||
frame_count = 0
|
||||
fps_log_interval = 5.0 # Log FPS every 5 seconds
|
||||
@@ -347,6 +386,8 @@ class VegasModeCoordinator:
|
||||
logger.info("Starting Vegas iteration for %.1fs", duration)
|
||||
|
||||
while True:
|
||||
frame_started = time.time()
|
||||
|
||||
# Check for STATIC mode plugin that should pause scroll
|
||||
static_plugin = self._check_static_plugin_trigger()
|
||||
if static_plugin:
|
||||
@@ -367,8 +408,14 @@ class VegasModeCoordinator:
|
||||
# Paused for live priority - let caller handle
|
||||
return False
|
||||
|
||||
# Sleep for frame interval
|
||||
time.sleep(frame_interval)
|
||||
# Sleep only the remainder of the frame budget. This used to sleep
|
||||
# the whole interval on top of however long the frame took, so at a
|
||||
# measured 31.6ms per frame a fixed 8ms of that was pure idle — a
|
||||
# quarter of the budget spent not rendering. Subtracting the work
|
||||
# already done keeps the pacing target while reclaiming that time,
|
||||
# and yields the GIL either way so other threads still run.
|
||||
frame_elapsed = time.time() - frame_started
|
||||
time.sleep(max(0.0, frame_interval - frame_elapsed))
|
||||
|
||||
# Increment frame count and check for interrupt periodically
|
||||
frame_count += 1
|
||||
@@ -505,6 +552,10 @@ class VegasModeCoordinator:
|
||||
# Update components
|
||||
self.render_pipeline.update_config(new_vegas_config)
|
||||
self.stream_manager.config = new_vegas_config
|
||||
self.plugin_adapter.config = new_vegas_config
|
||||
# Cached segments were trimmed under the old settings, so drop them
|
||||
# or a changed trim/padding value would not visibly take effect.
|
||||
self.plugin_adapter.invalidate_cache()
|
||||
|
||||
# Force refresh of stream manager to pick up plugin_order/buffer changes
|
||||
self.stream_manager._last_refresh = 0
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
Geometry primitives for Vegas Mode.
|
||||
|
||||
Pure, side-effect-free measurements over PIL images. Two consumers:
|
||||
|
||||
- ``PluginAdapter`` trims the blank margins plugins bake into their content
|
||||
before it enters the ticker (see ``trim_to_content``).
|
||||
- ``scripts/dev/vegas_audit.py`` reports how much of the composed ticker is
|
||||
dead space (see ``dead_window_stats``).
|
||||
|
||||
Keeping both on the same primitives means the number the audit reports is the
|
||||
number the trimmer acted on.
|
||||
|
||||
All column scans go through numpy: a Python-level per-column loop over a
|
||||
17,000px-wide ticker image takes seconds, which is far too slow for the render
|
||||
path.
|
||||
"""
|
||||
|
||||
from typing import List, NamedTuple, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# A pixel counts as "ink" when any channel exceeds this. Chosen to ignore the
|
||||
# 1-2/255 noise that JPEG-sourced logos and alpha compositing leave behind in
|
||||
# nominally black areas, while still treating any deliberately drawn dark grey
|
||||
# as real content.
|
||||
DEFAULT_INK_THRESHOLD = 10
|
||||
|
||||
# A window counts as "dead" when this fraction of its columns carry no ink.
|
||||
DEFAULT_DEAD_WINDOW_RATIO = 0.95
|
||||
|
||||
|
||||
def column_has_ink(img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD) -> np.ndarray:
|
||||
"""
|
||||
Return a boolean array, one entry per image column, True where the column
|
||||
contains at least one pixel brighter than ``threshold`` in any channel.
|
||||
|
||||
Args:
|
||||
img: Image to scan (converted to RGB internally)
|
||||
threshold: Per-channel value a pixel must exceed to count as ink
|
||||
|
||||
Returns:
|
||||
Bool array of shape (width,)
|
||||
"""
|
||||
arr = np.asarray(img if img.mode == 'RGB' else img.convert('RGB'))
|
||||
if arr.ndim != 3:
|
||||
# Degenerate/empty image — treat every column as blank.
|
||||
return np.zeros(img.width, dtype=bool)
|
||||
# Collapse rows and channels: a column is ink if any pixel in it is bright.
|
||||
return arr.max(axis=(0, 2)) > threshold
|
||||
|
||||
|
||||
def content_bounds(
|
||||
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""
|
||||
Find the first and last columns containing ink.
|
||||
|
||||
Args:
|
||||
img: Image to measure
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
(first_col, last_col) inclusive, or None if the image is entirely blank
|
||||
"""
|
||||
ink = column_has_ink(img, threshold)
|
||||
if not ink.any():
|
||||
return None
|
||||
first = int(ink.argmax())
|
||||
last = len(ink) - 1 - int(ink[::-1].argmax())
|
||||
return first, last
|
||||
|
||||
|
||||
class TrimResult(NamedTuple):
|
||||
"""Outcome of a ``trim_to_content`` call."""
|
||||
|
||||
image: Optional[Image.Image] # None when the source was entirely blank
|
||||
original_width: int
|
||||
trimmed_left: int
|
||||
trimmed_right: int
|
||||
|
||||
@property
|
||||
def is_blank(self) -> bool:
|
||||
"""True when the source image carried no ink at all."""
|
||||
return self.image is None
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
"""Width after trimming (0 for a blank source)."""
|
||||
return 0 if self.image is None else self.image.width
|
||||
|
||||
@property
|
||||
def removed(self) -> int:
|
||||
"""Total columns removed."""
|
||||
return self.trimmed_left + self.trimmed_right
|
||||
|
||||
|
||||
def trim_to_content(
|
||||
img: Image.Image,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
padding: int = 0,
|
||||
) -> TrimResult:
|
||||
"""
|
||||
Crop blank columns off the left and right edges of an image.
|
||||
|
||||
Only the outer edges are considered. Blank columns *between* two pieces of
|
||||
content are deliberately preserved — those are the plugin's own layout
|
||||
(e.g. a logo on the left and a score on the right), and closing them up
|
||||
would corrupt the design rather than reclaim dead space.
|
||||
|
||||
A plugin drawing on a non-black background is unaffected: every column of a
|
||||
filled background carries ink, so there is nothing to trim.
|
||||
|
||||
Args:
|
||||
img: Image to trim
|
||||
threshold: Ink threshold
|
||||
padding: Columns of the original blank margin to keep on each side, as
|
||||
breathing room. Capped at what the margin actually contains, so
|
||||
this never widens the image beyond its original bounds.
|
||||
|
||||
Returns:
|
||||
TrimResult. When the image is entirely blank, ``image`` is None and the
|
||||
caller decides whether to skip the plugin.
|
||||
"""
|
||||
bounds = content_bounds(img, threshold)
|
||||
if bounds is None:
|
||||
return TrimResult(None, img.width, 0, 0)
|
||||
|
||||
first, last = bounds
|
||||
pad = max(0, padding)
|
||||
left = max(0, first - pad)
|
||||
right = min(img.width, last + 1 + pad)
|
||||
|
||||
if left == 0 and right == img.width:
|
||||
return TrimResult(img, img.width, 0, 0)
|
||||
|
||||
cropped = img.crop((left, 0, right, img.height))
|
||||
return TrimResult(cropped, img.width, left, img.width - right)
|
||||
|
||||
|
||||
def edge_blank(
|
||||
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
Blank column counts at the left and right edges of an image.
|
||||
|
||||
Used to space items by *measured* separation rather than a flat added gap.
|
||||
A fixed gap gets this wrong in both directions at once: card-style content
|
||||
drawn flush to its own edges ends up nearly touching its neighbour, while
|
||||
content that already carries wide margins gets pushed even further apart.
|
||||
|
||||
Args:
|
||||
img: Image to measure
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
(left_blank, right_blank). For an entirely blank image both are the
|
||||
full width, since there is no ink to be close to.
|
||||
"""
|
||||
bounds = content_bounds(img, threshold)
|
||||
if bounds is None:
|
||||
return img.width, img.width
|
||||
first, last = bounds
|
||||
return first, img.width - 1 - last
|
||||
|
||||
|
||||
def separation_gap(
|
||||
left_img: Image.Image,
|
||||
right_img: Image.Image,
|
||||
target: int,
|
||||
minimum: int = 0,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> int:
|
||||
"""
|
||||
Columns to insert between two images so their ink is ``target`` apart.
|
||||
|
||||
Only the shortfall is added: if the two images already carry enough blank
|
||||
at the facing edges, nothing (beyond ``minimum``) is inserted.
|
||||
|
||||
Args:
|
||||
left_img: Image on the left
|
||||
right_img: Image on the right
|
||||
target: Desired blank columns between the two pieces of ink
|
||||
minimum: Floor applied regardless of what the images already have
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
Number of columns to insert, never negative
|
||||
"""
|
||||
existing = edge_blank(left_img, threshold)[1] + edge_blank(right_img, threshold)[0]
|
||||
return max(minimum, target - existing, 0)
|
||||
|
||||
|
||||
def blank_runs(
|
||||
img: Image.Image,
|
||||
min_run: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> List[Tuple[int, int]]:
|
||||
"""
|
||||
Find maximal runs of blank columns at least ``min_run`` wide.
|
||||
|
||||
Distinguishes item boundaries from letter spacing. Measured on real
|
||||
rendered text, the gaps *between characters* are a single column, while the
|
||||
gaps a plugin puts *between items* are 8px and up (the stocks ticker uses
|
||||
32px, baseball 48px). Treating any blank column as a cut point therefore
|
||||
slices words in half; requiring a run excludes letter spacing.
|
||||
|
||||
Args:
|
||||
img: Image to scan
|
||||
min_run: Minimum consecutive blank columns to qualify
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
List of (start, end) half-open column ranges, in left-to-right order
|
||||
"""
|
||||
blank = ~column_has_ink(img, threshold)
|
||||
if not blank.any():
|
||||
return []
|
||||
|
||||
# Vectorised run detection: pad with False so runs touching either edge get
|
||||
# a boundary, then read starts and ends off the first difference. A Python
|
||||
# loop here would be far too slow on a 17,000px ticker strip.
|
||||
padded = np.concatenate(([False], blank, [False]))
|
||||
diff = np.diff(padded.astype(np.int8))
|
||||
starts = np.flatnonzero(diff == 1)
|
||||
ends = np.flatnonzero(diff == -1)
|
||||
|
||||
long_enough = (ends - starts) >= max(1, min_run)
|
||||
return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist()))
|
||||
|
||||
|
||||
def find_item_boundary(
|
||||
img: Image.Image,
|
||||
target: int,
|
||||
min_run: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Find the column nearest ``target`` that sits inside a gap between items.
|
||||
|
||||
Used to narrow an oversized segment without cutting through a word. Only
|
||||
runs of at least ``min_run`` blank columns are considered, so the
|
||||
single-column gaps between characters are never chosen — cutting there
|
||||
orphaned the tail of a word into the following cycle, which is how a lone
|
||||
"y" from "Wednesday" ended up floating between two unrelated plugins.
|
||||
|
||||
Args:
|
||||
img: Image to cut
|
||||
target: Preferred cut column
|
||||
min_run: Minimum blank-run width that counts as an item boundary
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
A column inside a qualifying gap, or None when the image has no such
|
||||
gap at all — in which case the caller must not cut it.
|
||||
"""
|
||||
runs = blank_runs(img, min_run, threshold)
|
||||
if not runs:
|
||||
return None
|
||||
|
||||
# Nearest point of the nearest run. For a run left of target that is its
|
||||
# end (content resumes just after), for a run right of target its start
|
||||
# (content stopped just before) — the right choice in both directions.
|
||||
def clamp_to_run(run: Tuple[int, int]) -> int:
|
||||
start, end = run
|
||||
return max(start, min(target, end - 1))
|
||||
|
||||
return min((clamp_to_run(r) for r in runs), key=lambda c: abs(c - target))
|
||||
|
||||
|
||||
def find_blank_cut(
|
||||
img: Image.Image,
|
||||
target: int,
|
||||
search_radius: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> int:
|
||||
"""
|
||||
Find a column near ``target`` that carries no ink, so an image can be cut
|
||||
there without slicing through a glyph or logo.
|
||||
|
||||
Used when a single oversized segment has to be narrowed to fit a width
|
||||
budget. Cutting at an arbitrary column would leave half a character
|
||||
hanging at the panel edge; snapping to the nearest gap hides the cut.
|
||||
|
||||
Args:
|
||||
img: Image to cut
|
||||
target: Preferred cut column
|
||||
search_radius: How far either side of ``target`` to look
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
A blank column within the search window, or ``target`` clamped to the
|
||||
image bounds when the window contains no blank column at all.
|
||||
"""
|
||||
width = img.width
|
||||
target = max(0, min(target, width))
|
||||
if search_radius <= 0 or width == 0:
|
||||
return target
|
||||
|
||||
ink = column_has_ink(img, threshold)
|
||||
|
||||
# target may legitimately equal width (a cut after the last column), but
|
||||
# there is no column to inspect there, so both bounds stop at width - 1.
|
||||
lo = max(0, min(target - search_radius, width - 1))
|
||||
hi = max(0, min(target + search_radius, width - 1))
|
||||
|
||||
# Walk outwards from target so the nearest gap wins.
|
||||
for offset in range(0, search_radius + 1):
|
||||
right = target + offset
|
||||
if lo <= right <= hi and not ink[right]:
|
||||
return right
|
||||
left = target - offset
|
||||
if lo <= left <= hi and not ink[left]:
|
||||
return left
|
||||
|
||||
return target
|
||||
|
||||
|
||||
class DeadWindowStats(NamedTuple):
|
||||
"""How much of a composed ticker reads as blank to a viewer."""
|
||||
|
||||
total_windows: int
|
||||
dead_windows: int
|
||||
longest_dead_run: int # consecutive dead windows (i.e. scroll steps)
|
||||
|
||||
@property
|
||||
def dead_ratio(self) -> float:
|
||||
"""Fraction of viewport positions that are effectively blank."""
|
||||
if self.total_windows <= 0:
|
||||
return 0.0
|
||||
return self.dead_windows / self.total_windows
|
||||
|
||||
|
||||
def dead_window_stats(
|
||||
img: Image.Image,
|
||||
viewport_width: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
dead_ratio: float = DEFAULT_DEAD_WINDOW_RATIO,
|
||||
step: int = 1,
|
||||
) -> DeadWindowStats:
|
||||
"""
|
||||
Slide a viewport across a composed ticker image and count how many
|
||||
positions are effectively blank.
|
||||
|
||||
This models what the viewer actually experiences: the ticker is only ever
|
||||
seen ``viewport_width`` columns at a time, so a stretch of blank wider than
|
||||
the viewport becomes a period where the panel looks switched off. Measuring
|
||||
per-window rather than per-column is what makes the result correspond to
|
||||
perceived dead time.
|
||||
|
||||
Args:
|
||||
img: Composed ticker image
|
||||
viewport_width: Display width in pixels
|
||||
threshold: Ink threshold
|
||||
dead_ratio: Fraction of blank columns for a window to count as dead
|
||||
step: Column stride between sampled windows. 1 is exact; larger values
|
||||
trade precision for speed on very wide images.
|
||||
|
||||
Returns:
|
||||
DeadWindowStats. ``longest_dead_run`` is in units of ``step`` columns,
|
||||
so multiply by ``step`` for pixels.
|
||||
"""
|
||||
if viewport_width <= 0 or img.width <= 0:
|
||||
return DeadWindowStats(0, 0, 0)
|
||||
|
||||
ink = column_has_ink(img, threshold)
|
||||
step = max(1, step)
|
||||
|
||||
# Prefix sum of ink counts lets each window be evaluated in constant time,
|
||||
# instead of re-summing viewport_width columns per position.
|
||||
prefix = np.concatenate(([0], np.cumsum(ink)))
|
||||
|
||||
# Only whole windows are sampled; a partial tail window would report
|
||||
# artificially dead because it has fewer columns to draw ink from.
|
||||
last_start = img.width - viewport_width
|
||||
if last_start < 0:
|
||||
# Image narrower than the viewport — evaluate it as a single window.
|
||||
blank_cols = len(ink) - int(prefix[-1])
|
||||
is_dead = blank_cols >= dead_ratio * len(ink)
|
||||
return DeadWindowStats(1, 1 if is_dead else 0, 1 if is_dead else 0)
|
||||
|
||||
starts = np.arange(0, last_start + 1, step)
|
||||
ink_counts = prefix[starts + viewport_width] - prefix[starts]
|
||||
blank_counts = viewport_width - ink_counts
|
||||
dead = blank_counts >= dead_ratio * viewport_width
|
||||
|
||||
longest = _longest_true_run(dead)
|
||||
return DeadWindowStats(len(starts), int(dead.sum()), longest)
|
||||
|
||||
|
||||
class CoverageStats(NamedTuple):
|
||||
"""How well-filled the viewport stays as the ticker scrolls past."""
|
||||
|
||||
total_windows: int
|
||||
mean_ink_ratio: float # average fraction of the viewport carrying ink
|
||||
min_ink_ratio: float # worst viewport position in the cycle
|
||||
sparse_windows: int # positions below the "looks empty" threshold
|
||||
longest_sparse_run: int # consecutive sparse positions, in steps
|
||||
|
||||
@property
|
||||
def sparse_ratio(self) -> float:
|
||||
"""Fraction of viewport positions that read as near-empty."""
|
||||
if self.total_windows <= 0:
|
||||
return 0.0
|
||||
return self.sparse_windows / self.total_windows
|
||||
|
||||
|
||||
def window_coverage_stats(
|
||||
img: Image.Image,
|
||||
viewport_width: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
sparse_ink_ratio: float = 0.10,
|
||||
step: int = 1,
|
||||
) -> CoverageStats:
|
||||
"""
|
||||
Measure how full the viewport stays across a whole scroll cycle.
|
||||
|
||||
``dead_window_stats`` only catches viewport positions that are *entirely*
|
||||
blank. That misses the more common complaint: a position holding one narrow
|
||||
sliver of content at the very edge, with the other 90% black. Such a
|
||||
position is not "dead" by that definition but still looks switched off.
|
||||
This function grades every position by how much ink it carries, so
|
||||
"there is always something to see" becomes measurable.
|
||||
|
||||
Args:
|
||||
img: Composed ticker image
|
||||
viewport_width: Display width in pixels
|
||||
threshold: Ink threshold
|
||||
sparse_ink_ratio: A position with less than this fraction of inked
|
||||
columns counts as reading near-empty
|
||||
step: Column stride between sampled positions
|
||||
|
||||
Returns:
|
||||
CoverageStats
|
||||
"""
|
||||
if viewport_width <= 0 or img.width <= 0:
|
||||
return CoverageStats(0, 0.0, 0.0, 0, 0)
|
||||
|
||||
ink = column_has_ink(img, threshold)
|
||||
step = max(1, step)
|
||||
prefix = np.concatenate(([0], np.cumsum(ink)))
|
||||
|
||||
last_start = img.width - viewport_width
|
||||
if last_start < 0:
|
||||
ratio = float(prefix[-1]) / viewport_width
|
||||
sparse = ratio < sparse_ink_ratio
|
||||
return CoverageStats(1, ratio, ratio, 1 if sparse else 0, 1 if sparse else 0)
|
||||
|
||||
starts = np.arange(0, last_start + 1, step)
|
||||
ratios = (prefix[starts + viewport_width] - prefix[starts]) / viewport_width
|
||||
sparse_flags = ratios < sparse_ink_ratio
|
||||
|
||||
return CoverageStats(
|
||||
total_windows=len(starts),
|
||||
mean_ink_ratio=float(ratios.mean()),
|
||||
min_ink_ratio=float(ratios.min()),
|
||||
sparse_windows=int(sparse_flags.sum()),
|
||||
longest_sparse_run=_longest_true_run(sparse_flags),
|
||||
)
|
||||
|
||||
|
||||
def _longest_true_run(flags: np.ndarray) -> int:
|
||||
"""Length of the longest consecutive run of True in a boolean array."""
|
||||
if flags.size == 0 or not flags.any():
|
||||
return 0
|
||||
# Reset a running counter at every False by subtracting the cumulative max
|
||||
# of the counter's value at the preceding False positions.
|
||||
idx = np.arange(len(flags))
|
||||
not_flag = ~flags
|
||||
# For each position, the index of the most recent False at or before it.
|
||||
last_false = np.maximum.accumulate(np.where(not_flag, idx, -1))
|
||||
run_lengths = idx - last_false
|
||||
return int(run_lengths[flags].max())
|
||||
@@ -8,9 +8,16 @@ implement get_vegas_content() and fallback capture of display() output.
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from contextlib import nullcontext
|
||||
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.geometry import (
|
||||
blank_runs,
|
||||
separation_gap,
|
||||
trim_to_content,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
@@ -26,14 +33,21 @@ class PluginAdapter:
|
||||
2. Fallback: Capture display_manager.image after calling plugin.display()
|
||||
"""
|
||||
|
||||
def __init__(self, display_manager: Any):
|
||||
def __init__(self, display_manager: Any, config: Optional[Any] = None):
|
||||
"""
|
||||
Initialize the plugin adapter.
|
||||
|
||||
Args:
|
||||
display_manager: DisplayManager instance for fallback capture
|
||||
config: VegasModeConfig controlling trim behaviour. When omitted,
|
||||
trimming runs with the dataclass defaults, so existing callers
|
||||
and tests keep working unchanged.
|
||||
"""
|
||||
self.display_manager = display_manager
|
||||
if config is None:
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
config = VegasModeConfig()
|
||||
self.config = config
|
||||
# Handle both property and method access patterns
|
||||
self.display_width = (
|
||||
display_manager.width() if callable(display_manager.width)
|
||||
@@ -49,12 +63,18 @@ class PluginAdapter:
|
||||
self._cache_lock = threading.Lock()
|
||||
self._cache_ttl = 5.0 # Cache for 5 seconds
|
||||
|
||||
# Per-plugin rotation offset, so a plugin whose content exceeds its
|
||||
# width budget shows a different slice on each cycle rather than
|
||||
# always the same opening items.
|
||||
self._item_offsets: dict = {}
|
||||
|
||||
logger.info(
|
||||
"PluginAdapter initialized: display=%dx%d",
|
||||
self.display_width, self.display_height
|
||||
)
|
||||
|
||||
def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Image.Image]]:
|
||||
def get_content(self, plugin: 'BasePlugin', plugin_id: str,
|
||||
offscreen_only: bool = False) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Get scrollable content from a plugin.
|
||||
|
||||
@@ -63,6 +83,13 @@ class PluginAdapter:
|
||||
Args:
|
||||
plugin: Plugin instance to get content from
|
||||
plugin_id: Plugin identifier for logging
|
||||
offscreen_only: Skip every path that touches the shared display
|
||||
canvas, for callers running off the render thread. The canvas
|
||||
and the matrix proxy are process-wide mutable state, so
|
||||
narrowing or capturing through them from another thread would
|
||||
corrupt the frame the render loop is pushing. Returns None when
|
||||
the plugin can only be served that way, leaving the caller to
|
||||
fetch it on the render thread.
|
||||
|
||||
Returns:
|
||||
List of PIL Images representing plugin content, or None if no content
|
||||
@@ -86,32 +113,38 @@ class PluginAdapter:
|
||||
has_native = hasattr(plugin, 'get_vegas_content')
|
||||
logger.info("[%s] Has get_vegas_content: %s", plugin_id, has_native)
|
||||
if has_native:
|
||||
content = self._get_native_content(plugin, plugin_id)
|
||||
content = self._get_native_content(plugin, plugin_id, offscreen_only)
|
||||
if content:
|
||||
total_width = sum(img.width for img in content)
|
||||
logger.info(
|
||||
"[%s] Native content SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
return self._finalize(content, plugin_id, 'native', plugin)
|
||||
logger.info("[%s] Native content returned None", plugin_id)
|
||||
|
||||
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
|
||||
has_scroll_helper = hasattr(plugin, 'scroll_helper')
|
||||
logger.info("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper)
|
||||
content = self._get_scroll_helper_content(plugin, plugin_id)
|
||||
content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only)
|
||||
if content:
|
||||
total_width = sum(img.width for img in content)
|
||||
logger.info(
|
||||
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
return self._finalize(content, plugin_id, 'scroll_helper', plugin)
|
||||
if has_scroll_helper:
|
||||
logger.info("[%s] ScrollHelper content returned None", plugin_id)
|
||||
|
||||
if offscreen_only:
|
||||
# Display capture needs the shared canvas; leave it to the caller.
|
||||
logger.info(
|
||||
"[%s] Needs display capture, deferring to the render thread",
|
||||
plugin_id
|
||||
)
|
||||
return None
|
||||
|
||||
# Fall back to display capture
|
||||
logger.info("[%s] Trying fallback display capture...", plugin_id)
|
||||
content = self._capture_display_content(plugin, plugin_id)
|
||||
@@ -121,8 +154,7 @@ class PluginAdapter:
|
||||
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
return self._finalize(content, plugin_id, 'fallback', plugin)
|
||||
|
||||
logger.warning(
|
||||
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
|
||||
@@ -130,8 +162,397 @@ class PluginAdapter:
|
||||
)
|
||||
return None
|
||||
|
||||
def _finalize(
|
||||
self, images: List[Image.Image], plugin_id: str, source: str,
|
||||
plugin: Optional['BasePlugin'] = None
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Trim dead space off a segment, then cache it.
|
||||
|
||||
Every content path funnels through here so trimming is applied
|
||||
uniformly. Previously only the scroll_helper path had its margins
|
||||
stripped, which left plugins that render onto a full-display canvas
|
||||
contributing their entire blank canvas to the ticker.
|
||||
|
||||
Each image is trimmed independently because compose_scroll_content()
|
||||
treats every image as its own item and inserts separator_width between
|
||||
them — so a per-image trim is what makes that separator the real gap.
|
||||
|
||||
Args:
|
||||
images: Raw content from one of the fetch paths
|
||||
plugin_id: Plugin identifier for logging
|
||||
source: Which path produced the content, for logging
|
||||
|
||||
Returns:
|
||||
Trimmed image list, or None if nothing worth showing remains
|
||||
"""
|
||||
if not self.config.auto_trim:
|
||||
# Trimming is off, but the width budget is a separate concern —
|
||||
# turning off margin cropping should not let one plugin hold the
|
||||
# panel for minutes. Skipping it here previously let a 14,848px
|
||||
# segment through untouched.
|
||||
kept = self._apply_width_budget(list(images), plugin_id, plugin)
|
||||
self._cache_content(plugin_id, kept)
|
||||
return kept
|
||||
|
||||
original_width = sum(img.width for img in images)
|
||||
kept: List[Image.Image] = []
|
||||
dropped_blank = 0
|
||||
|
||||
for img in images:
|
||||
result = trim_to_content(
|
||||
img,
|
||||
threshold=self.config.trim_threshold,
|
||||
padding=self.config.content_padding,
|
||||
)
|
||||
if result.is_blank:
|
||||
dropped_blank += 1
|
||||
continue
|
||||
kept.append(result.image)
|
||||
|
||||
if not kept:
|
||||
logger.info(
|
||||
"[%s] All %d image(s) from %s were blank — contributing nothing",
|
||||
plugin_id, len(images), source
|
||||
)
|
||||
return None
|
||||
|
||||
trimmed_width = sum(img.width for img in kept)
|
||||
|
||||
if trimmed_width < self.config.min_plugin_width:
|
||||
logger.info(
|
||||
"[%s] Trimmed content %dpx is below min_plugin_width %dpx — skipping",
|
||||
plugin_id, trimmed_width, self.config.min_plugin_width
|
||||
)
|
||||
return None
|
||||
|
||||
if trimmed_width != original_width or dropped_blank:
|
||||
logger.info(
|
||||
"[%s] Trimmed %s content: %dpx -> %dpx (%.0f%% reclaimed), "
|
||||
"%d image(s) kept, %d blank dropped",
|
||||
plugin_id, source, original_width, trimmed_width,
|
||||
100.0 * (original_width - trimmed_width) / original_width
|
||||
if original_width else 0.0,
|
||||
len(kept), dropped_blank
|
||||
)
|
||||
|
||||
kept = self._apply_width_budget(kept, plugin_id, plugin)
|
||||
|
||||
self._cache_content(plugin_id, kept)
|
||||
return kept
|
||||
|
||||
def _capture(self):
|
||||
"""
|
||||
Context manager suppressing hardware writes while plugin render code runs.
|
||||
|
||||
Degrades to a no-op when the display manager predates capture_mode. As
|
||||
with _render_at, losing the suppression risks a visible flash, whereas
|
||||
raising would be swallowed by the broad handlers upstream and drop the
|
||||
plugin's content entirely — much worse.
|
||||
"""
|
||||
capture_mode = getattr(self.display_manager, 'capture_mode', None)
|
||||
if capture_mode is None:
|
||||
logger.debug(
|
||||
"display_manager has no capture_mode(); plugin writes during "
|
||||
"content capture may reach the panel"
|
||||
)
|
||||
return nullcontext()
|
||||
return capture_mode()
|
||||
|
||||
def _render_at(self, width: int):
|
||||
"""
|
||||
Context manager narrowing the plugin-facing canvas to ``width``.
|
||||
|
||||
Degrades to a no-op when the display manager predates render_size (a
|
||||
third-party or older test harness). Losing the narrowing is a cosmetic
|
||||
regression; raising here would be caught by the broad handlers upstream
|
||||
and silently drop the plugin's content entirely.
|
||||
"""
|
||||
render_size = getattr(self.display_manager, 'render_size', None)
|
||||
if render_size is None:
|
||||
logger.debug(
|
||||
"display_manager has no render_size(); Vegas width requests "
|
||||
"will be ignored"
|
||||
)
|
||||
return nullcontext()
|
||||
return render_size(width)
|
||||
|
||||
def resolve_render_width(self, plugin: 'BasePlugin', plugin_id: str) -> int:
|
||||
"""
|
||||
Width to tell a plugin it has while it renders for the ticker.
|
||||
|
||||
Resolution order, most specific first:
|
||||
1. the plugin's own ``vegas_width_pct`` config value
|
||||
2. the global ``vegas_scroll.render_width_pct``
|
||||
3. the full panel width
|
||||
|
||||
A percentage rather than an absolute width so one setting travels
|
||||
across panel sizes.
|
||||
|
||||
Args:
|
||||
plugin: Plugin instance, consulted for a per-plugin override
|
||||
plugin_id: Plugin identifier for logging
|
||||
|
||||
Returns:
|
||||
Target width in pixels, never wider than the panel
|
||||
"""
|
||||
pct = self.config.render_width_pct
|
||||
|
||||
plugin_cfg = getattr(plugin, 'config', None)
|
||||
if isinstance(plugin_cfg, dict):
|
||||
raw = plugin_cfg.get('vegas_width_pct')
|
||||
if raw not in (None, ''):
|
||||
try:
|
||||
candidate = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"[%s] Invalid vegas_width_pct %r, ignoring", plugin_id, raw)
|
||||
else:
|
||||
if 10 <= candidate <= 100:
|
||||
pct = candidate
|
||||
else:
|
||||
logger.warning(
|
||||
"[%s] vegas_width_pct %d out of range 10-100, ignoring",
|
||||
plugin_id, candidate)
|
||||
|
||||
if pct >= 100:
|
||||
return self.display_width
|
||||
return max(1, int(self.display_width * pct / 100))
|
||||
|
||||
def _row_gap(self, left: Image.Image, right: Image.Image) -> int:
|
||||
"""
|
||||
Gap the compositor will insert between two of a plugin's rows.
|
||||
|
||||
Mirrors RenderPipeline._join_plugin_rows so the width budget measures
|
||||
what will actually be rendered.
|
||||
"""
|
||||
return separation_gap(
|
||||
left, right,
|
||||
target=max(0, self.config.min_content_separation),
|
||||
minimum=max(0, self.config.intra_plugin_gap),
|
||||
threshold=self.config.trim_threshold,
|
||||
)
|
||||
|
||||
def _plugin_setting(self, plugin: 'BasePlugin', key: str):
|
||||
"""Read a per-plugin config override, or None if absent."""
|
||||
plugin_cfg = getattr(plugin, 'config', None)
|
||||
if not isinstance(plugin_cfg, dict):
|
||||
return None
|
||||
value = plugin_cfg.get(key)
|
||||
return None if value in (None, '') else value
|
||||
|
||||
def resolve_overflow_mode(self, plugin: 'BasePlugin', plugin_id: str) -> str:
|
||||
"""
|
||||
How to handle content that exceeds this plugin's width budget.
|
||||
|
||||
'rotate' advances a window each cycle so everything is seen eventually,
|
||||
which suits interchangeable items. 'truncate' always shows the start,
|
||||
which suits ordered content — a league table that shows ranks 1-6 and
|
||||
then resumes at 7 two rotations later reads as out of order, and nobody
|
||||
needs rank 23 in a ticker anyway.
|
||||
|
||||
Per-plugin ``vegas_overflow`` wins over the global ``overflow_mode``.
|
||||
"""
|
||||
raw = self._plugin_setting(plugin, 'vegas_overflow')
|
||||
if raw is not None:
|
||||
candidate = str(raw).strip().lower()
|
||||
if candidate in ('rotate', 'truncate'):
|
||||
return candidate
|
||||
logger.warning(
|
||||
"[%s] Invalid vegas_overflow %r, expected 'rotate' or 'truncate'",
|
||||
plugin_id, raw
|
||||
)
|
||||
return self.config.overflow_mode
|
||||
|
||||
def _width_budget(self, plugin: Optional['BasePlugin'] = None,
|
||||
plugin_id: str = '') -> int:
|
||||
"""
|
||||
Maximum columns one plugin may occupy in a cycle. 0 means unlimited.
|
||||
|
||||
A per-plugin ``vegas_max_width_screens`` overrides the global ratio, so
|
||||
content that has to stay whole can be given room (or uncapped with 0)
|
||||
without lifting the cap on every ticker.
|
||||
"""
|
||||
ratio = self.config.max_plugin_width_ratio
|
||||
|
||||
if plugin is not None:
|
||||
raw = self._plugin_setting(plugin, 'vegas_max_width_screens')
|
||||
if raw is not None:
|
||||
try:
|
||||
candidate = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"[%s] Invalid vegas_max_width_screens %r, ignoring",
|
||||
plugin_id, raw
|
||||
)
|
||||
else:
|
||||
if candidate >= 0:
|
||||
ratio = candidate
|
||||
else:
|
||||
logger.warning(
|
||||
"[%s] vegas_max_width_screens must be >= 0, got %s",
|
||||
plugin_id, candidate
|
||||
)
|
||||
|
||||
if ratio <= 0:
|
||||
return 0
|
||||
return int(self.display_width * ratio)
|
||||
|
||||
def _apply_width_budget(
|
||||
self, images: List[Image.Image], plugin_id: str,
|
||||
plugin: Optional['BasePlugin'] = None
|
||||
) -> List[Image.Image]:
|
||||
"""
|
||||
Hold one plugin to its share of a cycle.
|
||||
|
||||
A ticker returning 7,000px would otherwise own the panel for over two
|
||||
minutes, which defeats the point of a rotation. Overflow is deferred
|
||||
rather than discarded: the starting offset advances each time this
|
||||
plugin is fetched, so later items appear on subsequent cycles instead
|
||||
of never being seen.
|
||||
|
||||
Args:
|
||||
images: Trimmed images for this plugin
|
||||
plugin_id: Plugin identifier, used to track its rotation offset
|
||||
|
||||
Returns:
|
||||
Images that fit the budget, starting from the plugin's current
|
||||
rotation offset.
|
||||
"""
|
||||
budget = self._width_budget(plugin, plugin_id)
|
||||
mode = (self.resolve_overflow_mode(plugin, plugin_id)
|
||||
if plugin is not None else self.config.overflow_mode)
|
||||
|
||||
# Count the gaps the compositor will actually insert, not just the
|
||||
# pixels of the rows — otherwise a plugin with many rows quietly
|
||||
# occupies far more of the panel than its budget allows. These must use
|
||||
# the same measured rule as RenderPipeline._join_plugin_rows; assuming
|
||||
# the flat intra_plugin_gap here under-counted by up to
|
||||
# (min_content_separation - intra_plugin_gap) per row.
|
||||
total = sum(img.width for img in images) + sum(
|
||||
self._row_gap(images[i], images[i + 1]) for i in range(len(images) - 1)
|
||||
)
|
||||
|
||||
if not budget or total <= budget:
|
||||
# Fits, so reset rotation — the whole segment is being shown.
|
||||
self._item_offsets.pop(plugin_id, None)
|
||||
return images
|
||||
|
||||
if len(images) == 1:
|
||||
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
||||
|
||||
if mode == 'truncate':
|
||||
# Ordered content: always show from the top. Deliberately does not
|
||||
# advance the offset, so the same opening items appear every time
|
||||
# rather than the viewer being shown the middle of a ranked list.
|
||||
start = 0
|
||||
else:
|
||||
start = self._item_offsets.get(plugin_id, 0) % len(images)
|
||||
selected: List[Image.Image] = []
|
||||
used = 0
|
||||
consumed = 0
|
||||
|
||||
# Walk forward from the rotation offset, taking whole items only, so a
|
||||
# cut never lands in the middle of one.
|
||||
for step in range(len(images)):
|
||||
img = images[(start + step) % len(images)]
|
||||
cost = img.width
|
||||
if selected:
|
||||
cost += self._row_gap(selected[-1], img)
|
||||
if selected and used + cost > budget:
|
||||
break
|
||||
selected.append(img)
|
||||
used += cost
|
||||
consumed += 1
|
||||
|
||||
if mode == 'truncate':
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: showing the first %d of %d row(s) "
|
||||
"(%dpx incl. gaps); the rest are not shown (overflow=truncate)",
|
||||
plugin_id, budget, len(selected), len(images), used
|
||||
)
|
||||
else:
|
||||
self._item_offsets[plugin_id] = (start + consumed) % len(images)
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
||||
"from offset %d; remainder deferred to a later cycle",
|
||||
plugin_id, budget, len(selected), len(images), used, start
|
||||
)
|
||||
return selected
|
||||
|
||||
def _crop_to_budget(
|
||||
self, img: Image.Image, budget: int, plugin_id: str,
|
||||
mode: str = 'rotate'
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Narrow a single oversized image to the budget, advancing a window
|
||||
through it across cycles.
|
||||
|
||||
The cut is snapped to the nearest blank column so it does not slice
|
||||
through a glyph or logo and leave half a character at the panel edge.
|
||||
"""
|
||||
if mode == 'truncate':
|
||||
# Always the start of the strip, so a ranked table is never entered
|
||||
# from the middle.
|
||||
offset = 0
|
||||
else:
|
||||
offset = self._item_offsets.get(plugin_id, 0)
|
||||
if offset >= img.width:
|
||||
offset = 0
|
||||
|
||||
# Cut only where the plugin left a real gap between items. Snapping to
|
||||
# any blank column used to pick the single-column gaps between
|
||||
# characters, splitting a word and orphaning its tail into the next
|
||||
# cycle — a lone "y" from "Wednesday" floating between two unrelated
|
||||
# plugins. Overshooting the budget is the lesser evil.
|
||||
min_run = max(2, self.config.min_cut_gap)
|
||||
gaps = blank_runs(img, min_run, self.config.trim_threshold)
|
||||
|
||||
if not gaps:
|
||||
# No internal gaps means continuous content — a map, a chart, a
|
||||
# photo — where any column is as good as any other, so cut to the
|
||||
# budget exactly. The gap rule exists to protect discrete items
|
||||
# (words, ticker entries); it would be wrong to let a solid image
|
||||
# escape the cap in its name.
|
||||
end = min(offset + budget, img.width)
|
||||
if mode != 'truncate':
|
||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: cropped continuous %dpx image to "
|
||||
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
|
||||
plugin_id, budget, img.width, offset, end, min_run,
|
||||
"" if mode != 'truncate' else "; showing the start only"
|
||||
)
|
||||
return img.crop((offset, 0, end, img.height))
|
||||
|
||||
# Cut mid-gap so the content either side keeps some breathing room.
|
||||
cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps})
|
||||
|
||||
start = max((c for c in cuts if c <= offset), default=0)
|
||||
later = [c for c in cuts if c > start]
|
||||
if not later:
|
||||
end = img.width
|
||||
else:
|
||||
within = [c for c in later if c <= start + budget]
|
||||
# No boundary inside the budget: take the next one and overrun,
|
||||
# because the alternative is cutting through an item.
|
||||
end = max(within) if within else min(later)
|
||||
|
||||
if mode != 'truncate':
|
||||
# Next cycle resumes where this one stopped; wrap when the strip ends.
|
||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
|
||||
"(%dpx) at item boundaries, %s",
|
||||
plugin_id, budget, img.width, start, end, end - start,
|
||||
"showing the start only (overflow=truncate)"
|
||||
if mode == 'truncate' else "window advances next cycle"
|
||||
)
|
||||
return img.crop((start, 0, end, img.height))
|
||||
|
||||
def _get_native_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Get content via plugin's native get_vegas_content() method.
|
||||
@@ -145,7 +566,40 @@ class PluginAdapter:
|
||||
"""
|
||||
try:
|
||||
logger.info("[%s] Native: calling get_vegas_content()", plugin_id)
|
||||
result = plugin.get_vegas_content()
|
||||
|
||||
# Tell the plugin how much width the ticker wants it to use, and
|
||||
# narrow the canvas for the duration of the call. A plugin that
|
||||
# sizes its own images from display_manager.matrix.width picks up
|
||||
# the narrower value with no changes of its own; one that wants to
|
||||
# be explicit can read get_vegas_render_width().
|
||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||
if render_width != self.display_width:
|
||||
logger.info(
|
||||
"[%s] Native: requesting %dpx instead of %dpx",
|
||||
plugin_id, render_width, self.display_width
|
||||
)
|
||||
|
||||
plugin._vegas_render_width = render_width
|
||||
try:
|
||||
# capture_mode unconditionally, even at full width. Building
|
||||
# Vegas content is an off-screen operation, but a plugin is free
|
||||
# to call update_display() while doing it — and outside
|
||||
# capture_mode that write lands on the hardware, flashing the
|
||||
# panel mid-scroll. The narrowing context is separate because it
|
||||
# is a no-op at full width.
|
||||
if offscreen_only:
|
||||
# _render_at swaps the shared canvas, so it is unsafe here.
|
||||
# _vegas_render_width is set regardless: a plugin reading
|
||||
# get_vegas_render_width() still gets its narrow size, and
|
||||
# one that only reads matrix.width renders full width and is
|
||||
# trimmed instead.
|
||||
with self._capture():
|
||||
result = plugin.get_vegas_content()
|
||||
else:
|
||||
with self._capture(), self._render_at(render_width):
|
||||
result = plugin.get_vegas_content()
|
||||
finally:
|
||||
plugin._vegas_render_width = None
|
||||
|
||||
if result is None:
|
||||
logger.info("[%s] Native: get_vegas_content() returned None", plugin_id)
|
||||
@@ -223,7 +677,7 @@ class PluginAdapter:
|
||||
return None
|
||||
|
||||
def _get_scroll_helper_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Get content from plugin's scroll_helper if available.
|
||||
@@ -257,6 +711,13 @@ class PluginAdapter:
|
||||
"[%s] scroll_helper.cached_image is None, triggering content generation",
|
||||
plugin_id
|
||||
)
|
||||
if offscreen_only:
|
||||
# Generating it calls display(), which needs the canvas.
|
||||
logger.info(
|
||||
"[%s] scroll_helper cache empty; deferring generation "
|
||||
"to the render thread", plugin_id
|
||||
)
|
||||
return None
|
||||
# Try to trigger scroll content generation
|
||||
cached_image = self._trigger_scroll_content_generation(
|
||||
plugin, plugin_id, scroll_helper
|
||||
@@ -405,7 +866,7 @@ class PluginAdapter:
|
||||
# Save display state to restore after
|
||||
original_image = self.display_manager.image.copy()
|
||||
|
||||
with self.display_manager.capture_mode():
|
||||
with self._capture():
|
||||
# Method 1: Try _create_scrolling_display (stocks pattern)
|
||||
if hasattr(plugin, '_create_scrolling_display'):
|
||||
logger.info(
|
||||
@@ -497,7 +958,18 @@ class PluginAdapter:
|
||||
|
||||
# Clear and call plugin display — use capture_mode to suppress hardware writes
|
||||
# that plugins may trigger internally via update_display().
|
||||
with self.display_manager.capture_mode():
|
||||
#
|
||||
# render_size narrows the canvas the plugin lays out against, so a
|
||||
# plugin that spreads across the whole panel produces a compact
|
||||
# arrangement rather than one that has to be cropped afterwards.
|
||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||
if render_width != self.display_width:
|
||||
logger.info(
|
||||
"[%s] Fallback: rendering at %dpx instead of %dpx",
|
||||
plugin_id, render_width, self.display_width
|
||||
)
|
||||
|
||||
with self._capture(), self._render_at(render_width):
|
||||
self.display_manager.clear()
|
||||
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
|
||||
|
||||
@@ -531,7 +1003,7 @@ class PluginAdapter:
|
||||
plugin_id
|
||||
)
|
||||
# Try once more with force_clear=True
|
||||
with self.display_manager.capture_mode():
|
||||
with self._capture(), self._render_at(render_width):
|
||||
self.display_manager.clear()
|
||||
plugin.display(force_clear=True)
|
||||
captured = self.display_manager.image.copy()
|
||||
@@ -663,6 +1135,53 @@ class PluginAdapter:
|
||||
else:
|
||||
self._content_cache.clear()
|
||||
|
||||
def invalidate_plugin_scroll_cache(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
) -> bool:
|
||||
"""
|
||||
Drop a plugin's own cached scroll image so its visual is rebuilt.
|
||||
|
||||
Invalidating only this adapter's cache is not enough. A plugin that
|
||||
composes a scroll strip hands back the *same* image every time until its
|
||||
own cache is cleared — the sports plugins' ``get_vegas_content()``
|
||||
regenerates only "if the cache is empty" — so without this a segment
|
||||
keeps rendering whatever data it was first built from. That is how a
|
||||
game that was live last night can still be displayed as live the next
|
||||
morning.
|
||||
|
||||
Two layouts to cover: a helper directly on the plugin (stocks, news,
|
||||
odds-ticker) and one owned by a scroll-display manager (the sports
|
||||
scoreboards). ``cached_image`` and ``cached_array`` must be cleared
|
||||
together, since the array is the image's numpy mirror and code paths
|
||||
read whichever is convenient.
|
||||
|
||||
Returns:
|
||||
True if a cache was found and cleared.
|
||||
"""
|
||||
cleared = False
|
||||
for owner in (plugin, getattr(plugin, '_scroll_manager', None),
|
||||
getattr(plugin, 'scroll_manager', None)):
|
||||
if owner is None:
|
||||
continue
|
||||
helper = getattr(owner, 'scroll_helper', None)
|
||||
if helper is None:
|
||||
continue
|
||||
try:
|
||||
if getattr(helper, 'cached_image', None) is not None:
|
||||
helper.cached_image = None
|
||||
cleared = True
|
||||
if getattr(helper, 'cached_array', None) is not None:
|
||||
helper.cached_array = None
|
||||
cleared = True
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"[%s] Could not clear scroll cache on %s",
|
||||
plugin_id, type(owner).__name__
|
||||
)
|
||||
if cleared:
|
||||
logger.debug("[%s] Cleared plugin scroll cache", plugin_id)
|
||||
return cleared
|
||||
|
||||
def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
|
||||
"""
|
||||
Get the type of content a plugin provides.
|
||||
|
||||
@@ -6,6 +6,7 @@ Uses the existing ScrollHelper for numpy-optimized scroll operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from collections import deque
|
||||
@@ -14,6 +15,7 @@ from PIL import Image
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.geometry import separation_gap
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -34,6 +36,10 @@ class RenderPipeline:
|
||||
- Track scroll cycle completion
|
||||
"""
|
||||
|
||||
# Minimum gap between fetches of canvas-bound plugins, so their individual
|
||||
# stalls land in separate moments rather than one run of hitches.
|
||||
DEFERRED_DRAIN_INTERVAL = 2.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: VegasModeConfig,
|
||||
@@ -66,10 +72,6 @@ class RenderPipeline:
|
||||
else display_manager.height
|
||||
)
|
||||
|
||||
# Reusable blank frame for cycle-end pushes (allocated lazily,
|
||||
# re-blacked before each reuse)
|
||||
self._blank_frame = None
|
||||
|
||||
# ScrollHelper for optimized scrolling
|
||||
self.scroll_helper = ScrollHelper(
|
||||
self.display_width,
|
||||
@@ -85,6 +87,14 @@ class RenderPipeline:
|
||||
self._staging_scroll_image: Optional[Image.Image] = None
|
||||
self._buffer_lock = threading.Lock()
|
||||
|
||||
# Group prepared off the render thread, waiting to be appended.
|
||||
self._prepared_group = None
|
||||
# Plugins that need the shared canvas, appended one at a time.
|
||||
self._deferred_queue: List[str] = []
|
||||
self._last_drain_time = 0.0
|
||||
self._prefetch_thread: Optional[threading.Thread] = None
|
||||
self._prefetch_lock = threading.Lock()
|
||||
|
||||
# Render state
|
||||
self._is_rendering = False
|
||||
self._cycle_complete = False
|
||||
@@ -114,6 +124,7 @@ class RenderPipeline:
|
||||
"""Configure ScrollHelper with current settings."""
|
||||
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
|
||||
self.scroll_helper.set_scroll_delay(self.config.scroll_delay)
|
||||
self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll)
|
||||
|
||||
# Config scroll_speed is always pixels per second, but ScrollHelper
|
||||
# interprets it differently based on frame_based_scrolling mode:
|
||||
@@ -141,23 +152,37 @@ class RenderPipeline:
|
||||
True if composition successful
|
||||
"""
|
||||
try:
|
||||
# Get all buffered content
|
||||
images = self.stream_manager.get_all_content_for_composition()
|
||||
# Content grouped by plugin, so a separator can be placed at the
|
||||
# plugin boundaries only.
|
||||
grouped = self.stream_manager.get_grouped_content_for_composition()
|
||||
|
||||
if not images:
|
||||
if not grouped:
|
||||
logger.warning("No content available for composition")
|
||||
return False
|
||||
|
||||
# Add separator gaps between images
|
||||
content_with_gaps = []
|
||||
for i, img in enumerate(images):
|
||||
content_with_gaps.append(img)
|
||||
# Collapse each plugin's rows into a single block, joined by
|
||||
# intra_plugin_gap. ScrollHelper applies one uniform gap between the
|
||||
# items it is given, so handing it one item per plugin is what makes
|
||||
# separator_width mean "between plugins" instead of "between every
|
||||
# row". Without this, a per-row ticker such as the F1 scoreboard got
|
||||
# the full separator between each of its ~116 rows.
|
||||
blocks = []
|
||||
total_rows = 0
|
||||
for plugin_id, images in grouped:
|
||||
total_rows += len(images)
|
||||
blocks.append(self._join_plugin_rows(images))
|
||||
|
||||
# Create scrolling image via ScrollHelper
|
||||
# Create scrolling image via ScrollHelper.
|
||||
#
|
||||
# lead_gap is explicit because ScrollHelper otherwise prepends a
|
||||
# full display width of black — appropriate for a standalone ticker
|
||||
# scrolling in from off-screen, but in Vegas mode it is charged
|
||||
# once per cycle and reads as the panel switching off.
|
||||
self.scroll_helper.create_scrolling_image(
|
||||
content_items=content_with_gaps,
|
||||
content_items=blocks,
|
||||
item_gap=self.config.separator_width,
|
||||
element_gap=0
|
||||
element_gap=0,
|
||||
lead_gap=self.config.lead_in_width
|
||||
)
|
||||
|
||||
# Verify scroll image was created successfully
|
||||
@@ -177,11 +202,16 @@ class RenderPipeline:
|
||||
self._cycle_complete = False
|
||||
|
||||
logger.info(
|
||||
"Composed scroll image: %dx%d, %d plugins, %d items",
|
||||
"Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
|
||||
"separator=%dpx between plugins, rows spaced to %dpx of ink "
|
||||
"(min added %dpx)",
|
||||
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
|
||||
self.display_height,
|
||||
len(self._segments_in_scroll),
|
||||
len(images)
|
||||
len(blocks),
|
||||
total_rows,
|
||||
self.config.separator_width,
|
||||
self.config.min_content_separation,
|
||||
self.config.intra_plugin_gap,
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -191,6 +221,264 @@ class RenderPipeline:
|
||||
logger.exception("Error composing scroll content")
|
||||
return False
|
||||
|
||||
def needs_extension(self) -> bool:
|
||||
"""
|
||||
Whether the strip should be extended with the next group of plugins.
|
||||
|
||||
Cheap enough to call every frame: it is arithmetic over cached state.
|
||||
"""
|
||||
if not self.config.continuous_scroll or not self.scroll_helper.cached_image:
|
||||
return False
|
||||
threshold = int(self.display_width * self.config.extend_threshold_screens)
|
||||
return self.scroll_helper.remaining_unscrolled() <= threshold
|
||||
|
||||
def start_prefetch(self) -> None:
|
||||
"""
|
||||
Begin preparing the next group in the background, if not already doing so.
|
||||
|
||||
This is what makes the join seamless rather than merely continuous:
|
||||
fetching a group costs 0.5-4.8s (rendering leaderboard and baseball cards
|
||||
dominates), and doing it on the render thread stalls the scroll for that
|
||||
long. Off the render thread there is a whole group's scroll time to work
|
||||
in, so by the time the strip needs extending the content is already sat
|
||||
waiting.
|
||||
|
||||
Only paths that avoid the shared display canvas run here; anything
|
||||
needing it is marked and picked up on the render thread, where it is
|
||||
safe. Those are the cheap ones — display capture measured 12-14ms
|
||||
against seconds for the native renders.
|
||||
"""
|
||||
if not self.config.continuous_scroll:
|
||||
return
|
||||
|
||||
with self._prefetch_lock:
|
||||
if self._prefetch_thread is not None and self._prefetch_thread.is_alive():
|
||||
return
|
||||
if self._prepared_group is not None:
|
||||
return # already have one waiting
|
||||
|
||||
def _work():
|
||||
# Deprioritise against the render loop. Linux applies nice
|
||||
# per-thread, and the heavy lifting here is PIL and numpy work
|
||||
# that releases the GIL, so the scheduler can actually act on
|
||||
# it — without this the prefetch competes for the same cores and
|
||||
# costs frames.
|
||||
try:
|
||||
os.nice(10)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
try:
|
||||
group = self.stream_manager.take_next_group(offscreen_only=True)
|
||||
except Exception:
|
||||
logger.exception("Background prefetch failed")
|
||||
group = []
|
||||
with self._prefetch_lock:
|
||||
self._prepared_group = group
|
||||
|
||||
self._prefetch_thread = threading.Thread(
|
||||
target=_work, daemon=True, name="vegas-strip-prefetch")
|
||||
self._prefetch_thread.start()
|
||||
|
||||
def drain_deferred(self) -> bool:
|
||||
"""
|
||||
Fetch one queued canvas-bound plugin and append it to the strip.
|
||||
|
||||
Called once per frame. These plugins cannot be prepared off the render
|
||||
thread — display capture and scroll-content generation both need the
|
||||
shared canvas — so each costs roughly 290ms here. Doing one at a time
|
||||
spreads that out instead of stalling for the whole group at once, and the
|
||||
strip's lookahead means nothing runs dry while they arrive.
|
||||
|
||||
The cost is that a deferred plugin appears slightly after the group it
|
||||
came with, which is a fair trade for a smooth scroll.
|
||||
|
||||
Returns:
|
||||
True if a plugin was appended
|
||||
"""
|
||||
if not self._deferred_queue:
|
||||
return False
|
||||
|
||||
# Space the drains out. Each costs 40-600ms, and taking them back to
|
||||
# back turns one long stall into a train of short ones — barely better.
|
||||
# With a healthy lookahead there is no hurry, so wait a beat between
|
||||
# them; when the strip is actually running short, fetch immediately.
|
||||
threshold = int(self.display_width * self.config.extend_threshold_screens)
|
||||
urgent = self.scroll_helper.remaining_unscrolled() <= threshold
|
||||
if not urgent:
|
||||
now = time.time()
|
||||
if now - self._last_drain_time < self.DEFERRED_DRAIN_INTERVAL:
|
||||
return False
|
||||
self._last_drain_time = now
|
||||
else:
|
||||
self._last_drain_time = time.time()
|
||||
|
||||
plugin_id = self._deferred_queue.pop(0)
|
||||
plugins = getattr(self.stream_manager.plugin_manager, 'plugins', {})
|
||||
plugin = plugins.get(plugin_id)
|
||||
if plugin is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
images = self.stream_manager.plugin_adapter.get_content(plugin, plugin_id)
|
||||
except Exception:
|
||||
logger.exception("[%s] Error fetching deferred content", plugin_id)
|
||||
return False
|
||||
|
||||
if not images:
|
||||
return False
|
||||
|
||||
appended = self.scroll_helper.append_content(
|
||||
content_items=[self._join_plugin_rows(images)],
|
||||
item_gap=self.config.separator_width,
|
||||
element_gap=0,
|
||||
)
|
||||
if appended:
|
||||
with self._buffer_lock:
|
||||
self._active_scroll_image = self.scroll_helper.cached_image
|
||||
logger.info(
|
||||
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
|
||||
plugin_id, self.scroll_helper.total_scroll_width,
|
||||
self.scroll_helper.remaining_unscrolled()
|
||||
)
|
||||
return appended
|
||||
|
||||
def has_deferred(self) -> bool:
|
||||
"""Whether any canvas-bound plugins are still queued."""
|
||||
return bool(self._deferred_queue)
|
||||
|
||||
def _claim_prepared_group(self):
|
||||
"""Take the prefetched group, if one is ready."""
|
||||
with self._prefetch_lock:
|
||||
group = self._prepared_group
|
||||
self._prepared_group = None
|
||||
return group
|
||||
|
||||
def extend_scroll_content(self) -> bool:
|
||||
"""
|
||||
Append the next group of plugins to the strip, without interrupting motion.
|
||||
|
||||
This is what replaces the swap. Scroll position is untouched, so the new
|
||||
content simply arrives from the right; there is no substitution to see
|
||||
and no restart with the viewport already full.
|
||||
|
||||
Consumed columns behind the viewport are then released, keeping the strip
|
||||
bounded however long Vegas runs.
|
||||
|
||||
Returns:
|
||||
True if the strip was extended
|
||||
"""
|
||||
try:
|
||||
grouped = self._claim_prepared_group()
|
||||
if grouped is None:
|
||||
# Nothing prepared (first extension, or prefetch still running).
|
||||
# Fetch inline; the scroll hitches, but content keeps flowing.
|
||||
logger.info("No prepared group ready; fetching inline")
|
||||
grouped = self.stream_manager.take_next_group()
|
||||
|
||||
if not grouped:
|
||||
logger.warning("No content available to extend the scroll strip")
|
||||
return False
|
||||
|
||||
# Plugins the background thread had to defer need the shared canvas,
|
||||
# so they can only be fetched here. Queue them rather than doing all
|
||||
# of them now: measured, six in one go held the render thread for
|
||||
# 1.75s. They are trickled in one per frame by drain_deferred(),
|
||||
# which the strip's lookahead comfortably absorbs.
|
||||
deferred = [pid for pid, images in grouped if images is None]
|
||||
if deferred:
|
||||
self._deferred_queue.extend(deferred)
|
||||
logger.info(
|
||||
"Queued %d plugin(s) needing the render thread: %s",
|
||||
len(deferred), ', '.join(deferred)
|
||||
)
|
||||
|
||||
grouped = [(pid, imgs) for pid, imgs in grouped if imgs]
|
||||
|
||||
if not grouped:
|
||||
# Everything in this group is queued; the queue will extend the
|
||||
# strip as it drains, so this is not a failure.
|
||||
logger.info("Whole group deferred; strip will extend as it drains")
|
||||
self.start_prefetch()
|
||||
return bool(deferred)
|
||||
|
||||
blocks = []
|
||||
total_rows = 0
|
||||
for _plugin_id, images in grouped:
|
||||
total_rows += len(images)
|
||||
blocks.append(self._join_plugin_rows(images))
|
||||
|
||||
appended = self.scroll_helper.append_content(
|
||||
content_items=blocks,
|
||||
item_gap=self.config.separator_width,
|
||||
element_gap=0,
|
||||
)
|
||||
if not appended:
|
||||
return False
|
||||
|
||||
# Keep a screen's worth behind the viewport as a safety margin.
|
||||
self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
|
||||
|
||||
with self._buffer_lock:
|
||||
self._active_scroll_image = self.scroll_helper.cached_image
|
||||
|
||||
self._segments_in_scroll = [pid for pid, _ in grouped]
|
||||
self.stats['composition_count'] += 1
|
||||
self.stats['extensions'] = self.stats.get('extensions', 0) + 1
|
||||
|
||||
logger.info(
|
||||
"Extended scroll strip with %d plugin block(s), %d rows: "
|
||||
"strip now %dpx, %dpx still ahead of the viewport",
|
||||
len(blocks), total_rows, self.scroll_helper.total_scroll_width,
|
||||
self.scroll_helper.remaining_unscrolled()
|
||||
)
|
||||
|
||||
# Line up the group after this one straight away, so it is ready
|
||||
# well before the strip runs short again.
|
||||
self.start_prefetch()
|
||||
return True
|
||||
|
||||
except (ValueError, TypeError, OSError, RuntimeError):
|
||||
logger.exception("Error extending scroll content")
|
||||
return False
|
||||
|
||||
def _join_plugin_rows(self, images: List[Image.Image]) -> Image.Image:
|
||||
"""
|
||||
Concatenate one plugin's images into a single block.
|
||||
|
||||
Args:
|
||||
images: That plugin's content, in order
|
||||
|
||||
Returns:
|
||||
A single image with the rows laid out left to right, separated by
|
||||
``intra_plugin_gap``. Returned unchanged when there is only one row,
|
||||
which is the common case and avoids a pointless copy.
|
||||
"""
|
||||
if len(images) == 1:
|
||||
return images[0]
|
||||
|
||||
floor = max(0, self.config.intra_plugin_gap)
|
||||
target = max(0, self.config.min_content_separation)
|
||||
threshold = self.config.trim_threshold
|
||||
|
||||
# Space by measured separation, not a flat gap. Rows drawn flush to
|
||||
# their own edges (sports score cards) would otherwise end up nearly
|
||||
# touching, while rows that already carry wide margins would be pushed
|
||||
# needlessly further apart.
|
||||
gaps = [
|
||||
separation_gap(images[i], images[i + 1], target, floor, threshold)
|
||||
for i in range(len(images) - 1)
|
||||
]
|
||||
|
||||
width = sum(img.width for img in images) + sum(gaps)
|
||||
height = max(img.height for img in images)
|
||||
|
||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
x = 0
|
||||
for i, img in enumerate(images):
|
||||
block.paste(img, (x, 0))
|
||||
x += img.width + (gaps[i] if i < len(gaps) else 0)
|
||||
return block
|
||||
|
||||
def render_frame(self) -> bool:
|
||||
"""
|
||||
Render a single frame to the display.
|
||||
@@ -211,21 +499,33 @@ class RenderPipeline:
|
||||
|
||||
# Determine if the cycle is done.
|
||||
#
|
||||
# scroll_helper considers a cycle complete only after
|
||||
# total_distance_scrolled >= total_scroll_width + display_width.
|
||||
# That extra display_width of travel causes a "wrap-around" phase
|
||||
# where scroll_position resets to ~0 and the first plugin's content
|
||||
# re-enters from the right — the user sees this 2-3 s of re-entry
|
||||
# as "a plugin partially displaying before the next one starts."
|
||||
# get_visible_portion wraps: once scroll_position + display_width
|
||||
# passes the end of the strip it fills the right-hand side of the
|
||||
# frame from the *head* of the same strip. So the last
|
||||
# display_width of travel shows the cycle's first plugin re-entering
|
||||
# on the right while its last plugin exits on the left, and the
|
||||
# recompose that follows then replaces both at once. That reads as
|
||||
# the ticker "switching mid-scroll".
|
||||
#
|
||||
# We end the cycle as soon as total_distance_scrolled reaches
|
||||
# total_scroll_width (the wrap-around point), before any second-pass
|
||||
# content becomes visible. The scroll_helper's own is_scroll_complete()
|
||||
# check is kept as a fallback for any edge-cases where that threshold
|
||||
# is never hit.
|
||||
# This used to be hidden because the strip began with a full
|
||||
# display_width of blank, so the wrapped-in region was black.
|
||||
# lead_in_width now defaults to 0 (that blank was 10s of dead panel
|
||||
# at 50px/s), which exposed the wrap — so the cycle has to end
|
||||
# before it, one display width earlier.
|
||||
#
|
||||
# A strip no wider than the display never wraps, and subtracting
|
||||
# would make the cycle complete instantly, so clamp in that case.
|
||||
# In continuous mode there is no cycle to complete: the strip is
|
||||
# extended before the scroll can reach its end, so the wrap is never
|
||||
# entered and motion never stops. The completion path below stays for
|
||||
# the swap behaviour and as a backstop if an extension fails.
|
||||
wrap_point = self.scroll_helper.total_scroll_width
|
||||
if wrap_point > self.display_width:
|
||||
wrap_point -= self.display_width
|
||||
|
||||
at_wrap_point = (
|
||||
not self._cycle_complete and
|
||||
self.scroll_helper.total_distance_scrolled >= self.scroll_helper.total_scroll_width
|
||||
self.scroll_helper.total_distance_scrolled >= wrap_point
|
||||
)
|
||||
|
||||
if at_wrap_point or self.scroll_helper.is_scroll_complete():
|
||||
@@ -236,24 +536,17 @@ class RenderPipeline:
|
||||
"Scroll cycle complete after %.1fs",
|
||||
time.time() - self._cycle_start_time
|
||||
)
|
||||
# Push blank immediately so the hardware never shows any
|
||||
# post-wrap content while the coordinator recomposes the
|
||||
# next cycle (~100 ms). The blank is allocated once and
|
||||
# reused across cycle wraps (fresh paste each time in case
|
||||
# a consumer drew on the previous one).
|
||||
try:
|
||||
if self._blank_frame is None or self._blank_frame.size != (
|
||||
self.display_width, self.display_height):
|
||||
self._blank_frame = Image.new(
|
||||
'RGB', (self.display_width, self.display_height))
|
||||
else:
|
||||
self._blank_frame.paste(
|
||||
(0, 0, 0),
|
||||
(0, 0, self.display_width, self.display_height))
|
||||
self.display_manager.image = self._blank_frame
|
||||
self.display_manager.update_display()
|
||||
except Exception:
|
||||
logger.exception("Failed to write blank frame to display at cycle end")
|
||||
# Deliberately leave the last rendered frame on the panel.
|
||||
#
|
||||
# This used to push a blank frame so no post-wrap content
|
||||
# could be seen while the next cycle was composed. But
|
||||
# recomposing is synchronous and fetches plugin content:
|
||||
# measured 84ms at best and 4.8s at worst on a 512px panel,
|
||||
# and every millisecond of it was black. Holding the last
|
||||
# frame instead turns that into a brief freeze, which reads
|
||||
# as far less broken than the display switching off. The
|
||||
# frame is already past the end of the content, so there is
|
||||
# no second-pass content to leak.
|
||||
return True # Cycle done; coordinator starts new cycle next frame
|
||||
|
||||
# Get visible portion
|
||||
@@ -336,6 +629,25 @@ class RenderPipeline:
|
||||
|
||||
return False
|
||||
|
||||
def refresh_updated_plugins(self) -> bool:
|
||||
"""
|
||||
Let changed plugin data reach the strip without interrupting motion.
|
||||
|
||||
Used instead of :meth:`hot_swap_content` when scrolling continuously.
|
||||
The swap rebuilds the whole image and repositions the scroll, which is
|
||||
visible as a freeze and a jump; the strip is extended here rather than
|
||||
replaced, so it is enough to drop the stale caches and let the plugin
|
||||
recompose when it next comes round.
|
||||
|
||||
Returns:
|
||||
True if any plugin's cached content was dropped.
|
||||
"""
|
||||
try:
|
||||
return bool(self.stream_manager.invalidate_pending_updates())
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Failed to refresh updated plugins")
|
||||
return False
|
||||
|
||||
def hot_swap_content(self) -> bool:
|
||||
"""
|
||||
Hot-swap to new composed content.
|
||||
@@ -415,11 +727,12 @@ class RenderPipeline:
|
||||
result = self.compose_scroll_content()
|
||||
|
||||
if result and self.sync_manager:
|
||||
# When sync is active, start the leader at display_width instead of 0.
|
||||
# This skips the initial black gap so the leader immediately shows content.
|
||||
# The follower starts at position 0 (the gap) which looks like a clean
|
||||
# blank transition rather than near-end content wrapping around.
|
||||
self.scroll_helper.scroll_position = float(self.display_width)
|
||||
# When sync is active, start the leader past the lead-in gap so it
|
||||
# immediately shows content, leaving the follower on the blank gap
|
||||
# for a clean transition rather than near-end content wrapping
|
||||
# around. This tracks lead_in_width rather than assuming a full
|
||||
# display width of gap, which is no longer the default.
|
||||
self.scroll_helper.scroll_position = float(self.config.lead_in_width)
|
||||
|
||||
if result and self.sync_manager:
|
||||
# Signal follower that a new cycle started (triggers its own rebuild)
|
||||
|
||||
@@ -14,7 +14,7 @@ Supports three display modes:
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any, Deque, TYPE_CHECKING
|
||||
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from PIL import Image
|
||||
@@ -116,8 +116,11 @@ class StreamManager:
|
||||
logger.warning("No plugins available for Vegas scroll")
|
||||
return False
|
||||
|
||||
# Prefetch initial content
|
||||
self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins)))
|
||||
# Fill the buffer to a whole cycle's worth of plugins. This used to be
|
||||
# buffer_ahead + 1, which conflated prefetch depth with cycle size and
|
||||
# meant a 20-plugin install only showed 3 plugins before recomposing.
|
||||
self._prefetch_content(
|
||||
count=min(self.config.plugins_per_cycle, len(self._ordered_plugins)))
|
||||
|
||||
logger.info(
|
||||
"StreamManager initialized with %d plugins, %d segments buffered",
|
||||
@@ -198,6 +201,47 @@ class StreamManager:
|
||||
|
||||
logger.debug("Plugin %s marked for update", plugin_id)
|
||||
|
||||
def invalidate_pending_updates(self) -> List[str]:
|
||||
"""
|
||||
Drop cached content for plugins whose data changed, without refetching.
|
||||
|
||||
The continuous-scroll counterpart to :meth:`process_updates`. That method
|
||||
belongs to the swap path: it refetches immediately and merges into the
|
||||
active buffer, which continuous mode bypasses entirely, and doing that
|
||||
work on the render thread would hitch the scroll.
|
||||
|
||||
Here it is enough to clear the caches and let the plugin come round in
|
||||
the rotation, which recomposes it from current data a moment later. Left
|
||||
uncalled, ``_pending_updates`` simply accumulates and no visual ever
|
||||
refreshes — a game that was live last night keeps being drawn as live.
|
||||
|
||||
Returns:
|
||||
The plugin ids whose caches were dropped.
|
||||
"""
|
||||
with self._buffer_lock:
|
||||
if not self._pending_updates:
|
||||
return []
|
||||
updated = list(self._pending_updates.keys())
|
||||
self._pending_updates.clear()
|
||||
|
||||
plugins = getattr(self.plugin_manager, 'plugins', {})
|
||||
for plugin_id in updated:
|
||||
try:
|
||||
self.plugin_adapter.invalidate_cache(plugin_id)
|
||||
plugin = plugins.get(plugin_id)
|
||||
if plugin is not None:
|
||||
self.plugin_adapter.invalidate_plugin_scroll_cache(
|
||||
plugin, plugin_id)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"[%s] Could not invalidate cached content", plugin_id)
|
||||
|
||||
logger.info(
|
||||
"Vegas: dropped cached content for %d updated plugin(s): %s",
|
||||
len(updated), ', '.join(updated)
|
||||
)
|
||||
return updated
|
||||
|
||||
def has_pending_updates(self) -> bool:
|
||||
"""Check if any plugins have pending updates awaiting processing."""
|
||||
with self._buffer_lock:
|
||||
@@ -385,7 +429,7 @@ class StreamManager:
|
||||
return
|
||||
|
||||
for _ in range(count):
|
||||
if len(self._active_buffer) >= self.config.buffer_ahead + 1:
|
||||
if len(self._active_buffer) >= self.config.plugins_per_cycle:
|
||||
break
|
||||
|
||||
# Ensure index is valid (guard against empty list)
|
||||
@@ -521,28 +565,117 @@ class StreamManager:
|
||||
logger.debug("Refreshed content for %s in staging buffer", plugin_id)
|
||||
|
||||
def _ensure_buffer_filled(self) -> None:
|
||||
"""Ensure buffer has enough content prefetched."""
|
||||
if len(self._active_buffer) < self.config.buffer_ahead:
|
||||
needed = self.config.buffer_ahead - len(self._active_buffer)
|
||||
self._prefetch_content(count=needed)
|
||||
"""
|
||||
Top the buffer back up after segments have been served.
|
||||
|
||||
buffer_ahead is the low-water mark only; plugins_per_cycle is the
|
||||
ceiling and is enforced inside _prefetch_content.
|
||||
"""
|
||||
low_water = min(self.config.buffer_ahead, self.config.plugins_per_cycle)
|
||||
if len(self._active_buffer) < low_water:
|
||||
self._prefetch_content(count=low_water - len(self._active_buffer))
|
||||
|
||||
def get_all_content_for_composition(self) -> List[Image.Image]:
|
||||
"""
|
||||
Get all buffered content as a flat list of images.
|
||||
|
||||
Used when composing the full scroll image.
|
||||
Skips STATIC segments as they don't have images to compose.
|
||||
|
||||
Prefer get_grouped_content_for_composition(): flattening loses the
|
||||
plugin boundaries, which is what tells the compositor where a
|
||||
separator belongs and where it does not.
|
||||
|
||||
Returns:
|
||||
List of all images in buffer order
|
||||
"""
|
||||
all_images = []
|
||||
for _plugin_id, images in self.get_grouped_content_for_composition():
|
||||
all_images.extend(images)
|
||||
return all_images
|
||||
|
||||
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
|
||||
"""
|
||||
Get buffered content grouped by the plugin that produced it.
|
||||
|
||||
The grouping matters: separator_width is meant to mark the handoff from
|
||||
one plugin to the next, not to sit between every row a single plugin
|
||||
contributes. A per-row ticker like the F1 scoreboard returns over a
|
||||
hundred images that it renders 4px apart internally, so flattening them
|
||||
into one list and applying a uniform gap forced 32px between each of
|
||||
its rows — both inconsistent with how the plugin looks standalone, and
|
||||
a large hidden addition to the width it occupies.
|
||||
|
||||
Skips STATIC segments, which trigger a pause rather than contributing
|
||||
scroll content, and segments left with no images.
|
||||
|
||||
Returns:
|
||||
List of (plugin_id, images) in buffer order
|
||||
"""
|
||||
grouped: List[Tuple[str, List[Image.Image]]] = []
|
||||
with self._buffer_lock:
|
||||
for segment in self._active_buffer:
|
||||
# Skip STATIC segments - they trigger pauses, not scroll content
|
||||
if segment.display_mode != VegasDisplayMode.STATIC:
|
||||
all_images.extend(segment.images)
|
||||
return all_images
|
||||
if segment.display_mode == VegasDisplayMode.STATIC:
|
||||
continue
|
||||
if not segment.images:
|
||||
continue
|
||||
grouped.append((segment.plugin_id, list(segment.images)))
|
||||
return grouped
|
||||
|
||||
def take_next_group(
|
||||
self, count: Optional[int] = None, offscreen_only: bool = False
|
||||
) -> List[Tuple[str, Optional[List[Image.Image]]]]:
|
||||
"""
|
||||
Fetch and hand over the next slice of the rotation.
|
||||
|
||||
For continuous scrolling, where the strip is extended rather than
|
||||
replaced. Advances the rotation index so plugins come round in order
|
||||
across an unbroken strip, and bypasses the active buffer entirely — that
|
||||
buffer exists to stage a *replacement* cycle, which continuous mode has
|
||||
no use for.
|
||||
|
||||
Args:
|
||||
count: Number of plugins to gather, defaulting to plugins_per_cycle
|
||||
offscreen_only: Only use content paths that avoid the shared display
|
||||
canvas, for use off the render thread
|
||||
|
||||
Returns:
|
||||
Ordered list of (plugin_id, images). ``images`` is None when the
|
||||
plugin could not be served under ``offscreen_only``, so the caller
|
||||
can fetch just those on the render thread while keeping the order.
|
||||
"""
|
||||
if count is None:
|
||||
count = self.config.plugins_per_cycle
|
||||
|
||||
self.refresh()
|
||||
|
||||
with self._buffer_lock:
|
||||
if not self._ordered_plugins:
|
||||
return []
|
||||
total = len(self._ordered_plugins)
|
||||
ids = []
|
||||
for _ in range(min(max(1, count), total)):
|
||||
ids.append(self._ordered_plugins[self._prefetch_index])
|
||||
self._prefetch_index = (self._prefetch_index + 1) % total
|
||||
|
||||
plugins = getattr(self.plugin_manager, 'plugins', {})
|
||||
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
|
||||
|
||||
for plugin_id in ids:
|
||||
plugin = plugins.get(plugin_id)
|
||||
if not plugin:
|
||||
continue
|
||||
try:
|
||||
images = self.plugin_adapter.get_content(
|
||||
plugin, plugin_id, offscreen_only=offscreen_only)
|
||||
except Exception:
|
||||
logger.exception("[%s] ERROR fetching content", plugin_id)
|
||||
self.stats['fetch_errors'] += 1
|
||||
continue
|
||||
if images:
|
||||
self.stats['segments_fetched'] += 1
|
||||
group.append((plugin_id, images if images else None))
|
||||
|
||||
return group
|
||||
|
||||
def advance_cycle(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""
|
||||
Tests for src.element_style — the shared per-element style resolver behind
|
||||
the x-style-elements system.
|
||||
|
||||
The contract under test (defined by the plugin consumers: of-the-day,
|
||||
ledmatrix-music, football-scoreboard):
|
||||
|
||||
- defaults_from_schema_file parses BOTH declaration forms — the compact
|
||||
x-style-elements map and hand-written customization blocks.
|
||||
- expand_style_elements turns an x-style-elements declaration into the full
|
||||
per-element blocks (plus layout offsets) the web-UI form renders.
|
||||
- A config value counts as user-forced only when it genuinely differs from
|
||||
the schema default; untouched (or schema-default-populated) configs
|
||||
resolve to EXACTLY the classic font/size/color, keeping rendering
|
||||
byte-identical.
|
||||
- style() never raises; malformed input degrades to the classic style.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from PIL import ImageFont
|
||||
|
||||
from src.element_style import (
|
||||
ElementStyleResolver,
|
||||
defaults_from_schema,
|
||||
defaults_from_schema_file,
|
||||
expand_style_elements,
|
||||
load_font,
|
||||
resolve_font_path,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Compact declaration form (of-the-day's shape).
|
||||
STYLE_ELEMENTS_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": False},
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"x-style-elements": {
|
||||
"title_text": {
|
||||
"title": "Title",
|
||||
"font": {"default": "PressStart2P-Regular.ttf"},
|
||||
"size": {"default": 8, "min": 4, "max": 16},
|
||||
"color": {"default": [255, 255, 255]},
|
||||
"offsets": True,
|
||||
},
|
||||
"body_text": {
|
||||
"title": "Body Text",
|
||||
"font": {"default": "4x6-font.ttf"},
|
||||
"size": {"default": 6, "min": 4, "max": 12},
|
||||
"color": {"default": [200, 200, 200]},
|
||||
"offsets": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Manual declaration form (the scoreboards' / music's shape).
|
||||
MANUAL_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status_text": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"font": {"type": "string",
|
||||
"default": "4x6-font.ttf"},
|
||||
"font_size": {"type": "integer", "default": 6},
|
||||
},
|
||||
},
|
||||
"score_text": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"font": {"type": "string",
|
||||
"default": "PressStart2P-Regular.ttf"},
|
||||
"font_size": {"type": "integer", "default": 10},
|
||||
"text_color": {"type": "array",
|
||||
"default": [255, 255, 0]},
|
||||
},
|
||||
},
|
||||
"layout": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def style_schema_path(tmp_path):
|
||||
path = tmp_path / "config_schema.json"
|
||||
path.write_text(json.dumps(STYLE_ELEMENTS_SCHEMA))
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manual_schema_path(tmp_path):
|
||||
path = tmp_path / "config_schema.json"
|
||||
path.write_text(json.dumps(MANUAL_SCHEMA))
|
||||
return str(path)
|
||||
|
||||
|
||||
def _resolver(config, schema_path):
|
||||
return ElementStyleResolver(config, defaults_from_schema_file(schema_path))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDefaultsFromSchema:
|
||||
def test_x_style_elements_defaults(self, style_schema_path):
|
||||
defaults = defaults_from_schema_file(style_schema_path)
|
||||
cust = defaults["customization"]
|
||||
assert cust["title_text"] == {"font": "PressStart2P-Regular.ttf",
|
||||
"font_size": 8,
|
||||
"text_color": [255, 255, 255]}
|
||||
assert cust["body_text"]["font_size"] == 6
|
||||
assert cust["body_text"]["text_color"] == [200, 200, 200]
|
||||
|
||||
def test_manual_block_defaults(self, manual_schema_path):
|
||||
defaults = defaults_from_schema_file(manual_schema_path)
|
||||
cust = defaults["customization"]
|
||||
assert cust["status_text"] == {"font": "4x6-font.ttf", "font_size": 6}
|
||||
assert cust["score_text"]["text_color"] == [255, 255, 0]
|
||||
assert "layout" not in cust
|
||||
|
||||
def test_missing_file_degrades_to_empty(self, tmp_path):
|
||||
defaults = defaults_from_schema_file(str(tmp_path / "nope.json"))
|
||||
assert defaults == {"customization": {}}
|
||||
|
||||
def test_malformed_file_degrades_to_empty(self, tmp_path):
|
||||
path = tmp_path / "bad.json"
|
||||
path.write_text("{not json")
|
||||
assert defaults_from_schema_file(str(path)) == {"customization": {}}
|
||||
|
||||
def test_schema_without_customization(self):
|
||||
assert defaults_from_schema({"properties": {}}) == {"customization": {}}
|
||||
|
||||
|
||||
class TestExpandStyleElements:
|
||||
def test_expansion_generates_blocks(self):
|
||||
expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA)
|
||||
cust = expanded["properties"]["customization"]["properties"]
|
||||
title = cust["title_text"]
|
||||
assert title["x-style-managed"] is True
|
||||
assert title["properties"]["font"]["default"] == \
|
||||
"PressStart2P-Regular.ttf"
|
||||
assert title["properties"]["font_size"]["default"] == 8
|
||||
assert title["properties"]["font_size"]["minimum"] == 4
|
||||
assert title["properties"]["font_size"]["maximum"] == 16
|
||||
assert cust["body_text"]["properties"]["text_color"]["default"] == \
|
||||
[200, 200, 200]
|
||||
|
||||
def test_expansion_generates_layout_offsets(self):
|
||||
expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA)
|
||||
layout = expanded["properties"]["customization"]["properties"]["layout"]
|
||||
assert "title_text" in layout["properties"]
|
||||
offsets = layout["properties"]["body_text"]["properties"]
|
||||
assert offsets["x_offset"]["default"] == 0
|
||||
assert offsets["y_offset"]["default"] == 0
|
||||
|
||||
def test_input_schema_not_mutated(self):
|
||||
before = json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True)
|
||||
expand_style_elements(STYLE_ELEMENTS_SCHEMA)
|
||||
assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before
|
||||
|
||||
def test_no_declaration_returns_same_object(self):
|
||||
assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA
|
||||
empty = {"properties": {}}
|
||||
assert expand_style_elements(empty) is empty
|
||||
|
||||
def test_garbage_input_never_raises(self):
|
||||
bad = {"properties": {"customization": {"x-style-elements": "nope"}}}
|
||||
assert expand_style_elements(bad) is bad
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Classic identity: untouched configs resolve to the classic style
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestClassicIdentity:
|
||||
def test_bare_config_resolves_classic(self, style_schema_path):
|
||||
r = _resolver({}, style_schema_path)
|
||||
style = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=8, classic_color=(255, 255, 255))
|
||||
assert style.font_name == "PressStart2P-Regular.ttf"
|
||||
assert style.font_size == 8
|
||||
assert style.color == (255, 255, 255)
|
||||
assert style.offset == (0, 0)
|
||||
assert not style.user_forced
|
||||
assert not style.user_forced_color
|
||||
assert isinstance(style.font, ImageFont.FreeTypeFont)
|
||||
assert style.font.size == 8
|
||||
|
||||
def test_schema_populated_config_is_not_an_override(self, style_schema_path):
|
||||
# The web UI's save flow writes the full schema defaults into config
|
||||
# on every save — that must not count as a user override.
|
||||
config = {"customization": {
|
||||
"title_text": {"font": "PressStart2P-Regular.ttf", "font_size": 8,
|
||||
"text_color": [255, 255, 255]},
|
||||
"layout": {"title_text": {"x_offset": 0, "y_offset": 0}},
|
||||
}}
|
||||
style = _resolver(config, style_schema_path).style(
|
||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=8, classic_color=(255, 255, 255))
|
||||
assert not style.user_forced
|
||||
assert not style.user_forced_color
|
||||
assert style.font_size == 8
|
||||
assert style.color == (255, 255, 255)
|
||||
assert style.offset == (0, 0)
|
||||
|
||||
def test_schema_default_falls_back_to_classic_not_schema_font(
|
||||
self, manual_schema_path):
|
||||
# Classic values and schema defaults can legitimately differ
|
||||
# (football's status_text: schema says 4x6, classic loader used
|
||||
# PressStart). A schema-default config value must yield the CLASSIC
|
||||
# font, byte-identical to the old loader.
|
||||
config = {"customization": {"status_text": {"font": "4x6-font.ttf",
|
||||
"font_size": 6}}}
|
||||
style = _resolver(config, manual_schema_path).style(
|
||||
"status_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=6)
|
||||
assert not style.user_forced
|
||||
assert style.font_name == "PressStart2P-Regular.ttf"
|
||||
assert style.font_size == 6
|
||||
|
||||
def test_same_font_object_from_cache(self, style_schema_path):
|
||||
r = _resolver({}, style_schema_path)
|
||||
s1 = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=8)
|
||||
s2 = ElementStyleResolver({}, {}).style(
|
||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=8)
|
||||
assert s1.font is s2.font
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User overrides engage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUserOverrides:
|
||||
def test_font_override(self, style_schema_path):
|
||||
config = {"customization": {"title_text": {"font": "4x6-font.ttf"}}}
|
||||
style = _resolver(config, style_schema_path).style(
|
||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=8)
|
||||
assert style.user_forced
|
||||
assert style.font_name == "4x6-font.ttf"
|
||||
assert style.font_size == 8 # size untouched -> classic
|
||||
|
||||
def test_size_override(self, style_schema_path):
|
||||
config = {"customization": {"title_text": {
|
||||
"font": "PressStart2P-Regular.ttf", "font_size": 16}}}
|
||||
style = _resolver(config, style_schema_path).style(
|
||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=8)
|
||||
assert style.user_forced
|
||||
assert style.font_name == "PressStart2P-Regular.ttf"
|
||||
assert style.font_size == 16
|
||||
assert style.font.size == 16
|
||||
|
||||
def test_size_override_detected_vs_schema_default(self, manual_schema_path):
|
||||
# font_size 8 differs from the schema default 6 -> forced.
|
||||
config = {"customization": {"status_text": {"font": "4x6-font.ttf",
|
||||
"font_size": 8}}}
|
||||
style = _resolver(config, manual_schema_path).style(
|
||||
"status_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=6)
|
||||
assert style.user_forced
|
||||
assert style.font_size == 8
|
||||
|
||||
def test_color_override(self, style_schema_path):
|
||||
config = {"customization": {"title_text": {"text_color": [255, 0, 0]}}}
|
||||
style = _resolver(config, style_schema_path).style(
|
||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=8, classic_color=(255, 255, 255))
|
||||
assert style.user_forced_color
|
||||
assert not style.user_forced
|
||||
assert style.color == (255, 0, 0)
|
||||
|
||||
def test_offsets(self, style_schema_path):
|
||||
config = {"customization": {"layout": {
|
||||
"title_text": {"x_offset": 4, "y_offset": -2}}}}
|
||||
r = _resolver(config, style_schema_path)
|
||||
assert r.offset("title_text") == (4, -2)
|
||||
assert r.offset("body_text") == (0, 0)
|
||||
style = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=8)
|
||||
assert style.offset == (4, -2)
|
||||
|
||||
def test_offset_value_arbitrary_axis_and_strings(self, style_schema_path):
|
||||
# The scoreboards read non-standard axes (away_x_offset) and configs
|
||||
# can carry numeric strings/floats.
|
||||
config = {"customization": {"layout": {"records": {
|
||||
"away_x_offset": "3", "home_x_offset": 2.7}}}}
|
||||
r = _resolver(config, style_schema_path)
|
||||
assert r.offset_value("records", "away_x_offset", 0) == 3
|
||||
assert r.offset_value("records", "home_x_offset", 0) == 2
|
||||
assert r.offset_value("records", "missing_axis", 5) == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defensive degradation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDegradation:
|
||||
@pytest.mark.parametrize("config", [
|
||||
None,
|
||||
{"customization": "not a dict"},
|
||||
{"customization": {"title_text": "not a dict"}},
|
||||
{"customization": {"title_text": {"font": 42, "font_size": "huge",
|
||||
"text_color": "red"}}},
|
||||
{"customization": {"layout": {"title_text": {"x_offset": "junk"}}}},
|
||||
])
|
||||
def test_bad_config_degrades_to_classic(self, config, style_schema_path):
|
||||
style = _resolver(config, style_schema_path).style(
|
||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=8, classic_color=(10, 20, 30))
|
||||
assert not style.user_forced
|
||||
assert not style.user_forced_color
|
||||
assert style.font_name == "PressStart2P-Regular.ttf"
|
||||
assert style.font_size == 8
|
||||
assert style.color == (10, 20, 30)
|
||||
assert style.offset == (0, 0)
|
||||
|
||||
def test_unknown_font_falls_back(self, style_schema_path):
|
||||
config = {"customization": {"title_text": {"font": "no-such.ttf"}}}
|
||||
style = _resolver(config, style_schema_path).style(
|
||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=8)
|
||||
# The override IS honored as forced, but the face degrades safely.
|
||||
assert style.user_forced
|
||||
assert style.font is not None
|
||||
|
||||
def test_empty_defaults_treats_config_as_reference_to_classic(self):
|
||||
# No schema defaults at all: a config value equal to the classic
|
||||
# value is not forced; a different one is.
|
||||
r = ElementStyleResolver(
|
||||
{"customization": {"e": {"font": "4x6-font.ttf"}}}, {})
|
||||
assert not r.style("e", classic_font="4x6-font.ttf",
|
||||
classic_size=6).user_forced
|
||||
assert r.style("e", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=6).user_forced
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolver plumbing the consumers rely on
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolverPlumbing:
|
||||
def test_config_identity_exposed(self, style_schema_path):
|
||||
# Consumers rebuild the resolver when the config dict is swapped:
|
||||
# `resolver._config is not self.config`.
|
||||
config = {"customization": {}}
|
||||
r = _resolver(config, style_schema_path)
|
||||
assert r._config is config
|
||||
|
||||
def test_font_path_resolution_is_cwd_independent(self, tmp_path,
|
||||
monkeypatch):
|
||||
monkeypatch.chdir(tmp_path) # no assets/fonts under cwd
|
||||
path = resolve_font_path("PressStart2P-Regular.ttf")
|
||||
assert path is not None and os.path.isfile(path)
|
||||
font = load_font("PressStart2P-Regular.ttf", 8)
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
|
||||
def test_bdf_font_loads_as_freetype_face(self):
|
||||
import freetype
|
||||
font = load_font("5x7.bdf", 7)
|
||||
assert isinstance(font, freetype.Face)
|
||||
|
||||
@pytest.mark.parametrize("hostile", [
|
||||
"../../config/config.json",
|
||||
"../secrets.txt",
|
||||
"sub/dir/font.ttf",
|
||||
"..",
|
||||
])
|
||||
def test_relative_font_name_with_path_components_is_rejected(self, hostile):
|
||||
# font_name comes from plugin config (web-UI writable); a relative name
|
||||
# carrying path separators would escape assets/fonts/ after os.path.join
|
||||
# and let a config probe arbitrary paths. Only bare filenames resolve.
|
||||
assert resolve_font_path(hostile) is None
|
||||
|
||||
def test_bare_filename_still_resolves(self):
|
||||
# The guard must not reject legitimate bare names.
|
||||
assert resolve_font_path("PressStart2P-Regular.ttf") is not None
|
||||
|
||||
def test_schema_manager_expands_on_load(self, tmp_path):
|
||||
# The web-UI form path: SchemaManager.load_schema serves the
|
||||
# expanded schema so the style blocks actually appear in the UI.
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
plugin_dir = tmp_path / "plugins" / "styled"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "config_schema.json").write_text(
|
||||
json.dumps(STYLE_ELEMENTS_SCHEMA))
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps({
|
||||
"id": "styled", "config_schema": "config_schema.json"}))
|
||||
manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
schema = manager.load_schema("styled")
|
||||
assert schema is not None
|
||||
cust = schema["properties"]["customization"]["properties"]
|
||||
assert cust["title_text"]["x-style-managed"] is True
|
||||
assert "title_text" in cust["layout"]["properties"]
|
||||
+121
-1
@@ -250,5 +250,125 @@ class TestBasePlugin:
|
||||
|
||||
config = {"enabled": True, "live_priority": True}
|
||||
plugin = ConcretePlugin("test", config, mock_display_manager, mock_cache_manager, None)
|
||||
|
||||
|
||||
assert plugin.has_live_priority() is True
|
||||
|
||||
|
||||
class TestBasePluginGlobalConfig:
|
||||
"""global_config exposes device-wide settings that self.config cannot.
|
||||
|
||||
The sports scoreboards read `getattr(self, 'global_config', {})` to find
|
||||
the shared target_fps; before this property existed nothing ever set that
|
||||
attribute, so the lookup silently returned {} and the setting could never
|
||||
take effect on any core.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _plugin(display_manager, cache_manager, plugin_manager=None):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
class ConcretePlugin(BasePlugin):
|
||||
def update(self): pass
|
||||
def display(self, force_clear=False): pass
|
||||
|
||||
return ConcretePlugin(
|
||||
"test", {"enabled": True}, display_manager, cache_manager, plugin_manager
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _manager_with(config):
|
||||
"""A stand-in manager exposing config_manager.get_config()."""
|
||||
manager = MagicMock()
|
||||
manager.config_manager.get_config.return_value = config
|
||||
return manager
|
||||
|
||||
def test_reads_config_from_plugin_manager(self, mock_display_manager, mock_cache_manager):
|
||||
plugin = self._plugin(
|
||||
mock_display_manager, mock_cache_manager,
|
||||
self._manager_with({"target_fps": 100}),
|
||||
)
|
||||
assert plugin.global_config["target_fps"] == 100
|
||||
|
||||
def test_falls_back_to_cache_manager(self, mock_display_manager):
|
||||
# The core that hangs config_manager off the cache manager instead.
|
||||
cache_manager = self._manager_with({"target_fps": 75})
|
||||
plugin = self._plugin(mock_display_manager, cache_manager, plugin_manager=None)
|
||||
assert plugin.global_config["target_fps"] == 75
|
||||
|
||||
def test_plugin_manager_wins_over_cache_manager(self, mock_display_manager):
|
||||
plugin = self._plugin(
|
||||
mock_display_manager,
|
||||
self._manager_with({"target_fps": 75}),
|
||||
self._manager_with({"target_fps": 100}),
|
||||
)
|
||||
assert plugin.global_config["target_fps"] == 100
|
||||
|
||||
def test_empty_plugin_manager_config_falls_through(self, mock_display_manager):
|
||||
"""An empty first source means "not loaded yet", not "the answer".
|
||||
|
||||
Both managers default to the same config/config.json, so falling
|
||||
through cannot pick up a different file. Returning {} here instead
|
||||
would silently disable every setting read through this property --
|
||||
the exact failure this property exists to fix.
|
||||
"""
|
||||
plugin = self._plugin(
|
||||
mock_display_manager,
|
||||
self._manager_with({"target_fps": 100}), # cache_manager
|
||||
self._manager_with({}), # plugin_manager: empty
|
||||
)
|
||||
assert plugin.global_config["target_fps"] == 100
|
||||
|
||||
def test_returns_empty_dict_when_no_config_manager(self, mock_display_manager):
|
||||
# Plain objects: no config_manager attribute at all.
|
||||
plugin = self._plugin(mock_display_manager, object(), object())
|
||||
assert plugin.global_config == {}
|
||||
|
||||
def test_unreadable_config_does_not_raise(self, mock_display_manager):
|
||||
# A plugin must still load when the config on disk is broken.
|
||||
broken = MagicMock()
|
||||
broken.config_manager.get_config.side_effect = OSError("unreadable")
|
||||
plugin = self._plugin(mock_display_manager, broken, broken)
|
||||
assert plugin.global_config == {}
|
||||
|
||||
def test_non_dict_config_is_rejected(self, mock_display_manager):
|
||||
# A stub or half-built manager can return a non-mapping; handing that
|
||||
# back would blow up later in numeric code, far from the cause.
|
||||
plugin = self._plugin(
|
||||
mock_display_manager, object(), self._manager_with("not-a-dict")
|
||||
)
|
||||
assert plugin.global_config == {}
|
||||
|
||||
def test_missing_property_degrades_to_default(self, mock_display_manager, mock_cache_manager):
|
||||
# How plugins actually call it, so a plugin written against this core
|
||||
# still loads on one that predates the property.
|
||||
plugin = self._plugin(mock_display_manager, mock_cache_manager, object())
|
||||
assert getattr(plugin, "global_config", {}).get("target_fps") is None
|
||||
|
||||
def test_plugin_may_still_assign_global_config(self, mock_display_manager, mock_cache_manager):
|
||||
# news, stock-news, ledmatrix-stocks, ledmatrix-elections,
|
||||
# ledmatrix-leaderboard and nfl-draft all do exactly this. Without a
|
||||
# setter the property raises "has no setter" and those plugins stop
|
||||
# loading entirely.
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
class AssigningPlugin(BasePlugin):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.global_config = self.config.get("global", {})
|
||||
|
||||
def update(self): pass
|
||||
def display(self, force_clear=False): pass
|
||||
|
||||
plugin = AssigningPlugin(
|
||||
"news", {"enabled": True, "global": {"scroll_speed": 2}},
|
||||
mock_display_manager, mock_cache_manager, self._manager_with({"target_fps": 100}),
|
||||
)
|
||||
# The plugin's own value wins over the resolved config.
|
||||
assert plugin.global_config == {"scroll_speed": 2}
|
||||
|
||||
def test_template_ships_a_global_target_fps(self):
|
||||
# The plumbing is useless if the setting isn't in the shipped config.
|
||||
import json
|
||||
with open("config/config.template.json") as fh:
|
||||
template = json.load(fh)
|
||||
assert template.get("target_fps") == 100
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
Tests for ScrollHelper's continuous-strip primitives.
|
||||
|
||||
append_content extends the strip to the right without disturbing motion, and
|
||||
drop_scrolled_prefix reclaims what has already gone past. Together they let a
|
||||
caller keep one endless strip instead of swapping a new one in, which is what
|
||||
shows as a flash and a hard cut to already-full-screen content.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.vegas_mode.geometry import column_has_ink
|
||||
|
||||
W, H = 128, 32
|
||||
|
||||
|
||||
def helper():
|
||||
return ScrollHelper(W, H)
|
||||
|
||||
|
||||
def block(width, colour=(255, 255, 255), height=H):
|
||||
return Image.new('RGB', (width, height), colour)
|
||||
|
||||
|
||||
class TestAppendContent:
|
||||
def test_first_append_builds_the_strip(self):
|
||||
sh = helper()
|
||||
assert sh.append_content([block(100)], item_gap=0)
|
||||
assert sh.cached_image is not None
|
||||
assert sh.total_scroll_width == sh.cached_image.width
|
||||
|
||||
def test_strip_grows_by_content_plus_gaps(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
assert sh.cached_image.width == 100
|
||||
|
||||
sh.append_content([block(50)], item_gap=10, element_gap=0)
|
||||
# one leading gap of 10 then the 50px block
|
||||
assert sh.cached_image.width == 160
|
||||
assert sh.total_scroll_width == 160
|
||||
|
||||
def test_scroll_position_is_preserved(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(400)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 137.0
|
||||
sh.total_distance_scrolled = 137.0
|
||||
|
||||
sh.append_content([block(200)], item_gap=16)
|
||||
assert sh.scroll_position == 137.0
|
||||
assert sh.total_distance_scrolled == 137.0
|
||||
|
||||
def test_appending_defers_completion(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_complete = True
|
||||
|
||||
sh.append_content([block(200)], item_gap=0)
|
||||
assert not sh.scroll_complete
|
||||
assert sh.total_distance_scrolled < sh.total_scroll_width
|
||||
|
||||
def test_existing_pixels_are_untouched(self):
|
||||
sh = helper()
|
||||
original = block(80, (10, 200, 10))
|
||||
sh.create_scrolling_image([original], item_gap=0, element_gap=0, lead_gap=0)
|
||||
before = sh.cached_image.crop((0, 0, 80, H)).tobytes()
|
||||
|
||||
sh.append_content([block(40, (200, 10, 10))], item_gap=8)
|
||||
assert sh.cached_image.crop((0, 0, 80, H)).tobytes() == before
|
||||
|
||||
def test_appended_content_sits_after_the_gap(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(50)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.append_content([block(30)], item_gap=12)
|
||||
|
||||
ink = column_has_ink(sh.cached_image)
|
||||
assert ink[:50].all()
|
||||
assert not ink[50:62].any() # the 12px gap
|
||||
assert ink[62:92].all()
|
||||
|
||||
def test_array_and_image_stay_consistent(self):
|
||||
# get_visible_portion slices cached_array but bounds-checks against
|
||||
# cached_image.width, so a mismatch corrupts frames.
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.append_content([block(100)], item_gap=8)
|
||||
assert sh.cached_array.shape[1] == sh.cached_image.width
|
||||
assert sh.cached_array.shape[0] == sh.cached_image.height
|
||||
|
||||
def test_visible_portion_still_renders_after_append(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(300)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.append_content([block(300)], item_gap=8)
|
||||
sh.scroll_position = 250.0
|
||||
frame = sh.get_visible_portion()
|
||||
assert frame is not None and frame.size == (W, H)
|
||||
|
||||
def test_empty_append_is_a_no_op(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
assert sh.append_content([]) is False
|
||||
assert sh.cached_image.width == 100
|
||||
|
||||
def test_repeated_appends_accumulate(self):
|
||||
sh = helper()
|
||||
sh.append_content([block(100)], item_gap=0)
|
||||
for _ in range(5):
|
||||
sh.append_content([block(100)], item_gap=0)
|
||||
assert sh.cached_image.width == 600
|
||||
|
||||
|
||||
class TestDropScrolledPrefix:
|
||||
def test_removes_consumed_columns(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 500.0
|
||||
sh.total_distance_scrolled = 500.0
|
||||
|
||||
removed = sh.drop_scrolled_prefix(keep_before=0)
|
||||
assert removed == 500
|
||||
assert sh.cached_image.width == 500
|
||||
assert sh.scroll_position == 0.0
|
||||
|
||||
def test_keeps_the_requested_margin(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 500.0
|
||||
sh.drop_scrolled_prefix(keep_before=100)
|
||||
assert sh.scroll_position == 100.0
|
||||
assert sh.cached_image.width == 600
|
||||
|
||||
def test_completion_difference_is_preserved(self):
|
||||
# total_distance_scrolled and total_scroll_width must shift together, or
|
||||
# trimming would spuriously complete or un-complete the cycle.
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 600.0
|
||||
sh.total_distance_scrolled = 600.0
|
||||
before = sh.total_scroll_width - sh.total_distance_scrolled
|
||||
|
||||
sh.drop_scrolled_prefix(keep_before=0)
|
||||
assert sh.total_scroll_width - sh.total_distance_scrolled == before
|
||||
|
||||
def test_never_trims_below_the_viewport(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 190.0
|
||||
sh.drop_scrolled_prefix(keep_before=0)
|
||||
assert sh.cached_image.width >= W
|
||||
|
||||
def test_no_op_before_anything_has_scrolled(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
assert sh.drop_scrolled_prefix(keep_before=0) == 0
|
||||
assert sh.cached_image.width == 500
|
||||
|
||||
def test_no_op_with_no_strip(self):
|
||||
assert helper().drop_scrolled_prefix() == 0
|
||||
|
||||
def test_visible_frame_is_unchanged_by_trimming(self):
|
||||
# The whole point: trimming is invisible. Same pixels on screen before
|
||||
# and after. Position chosen so the viewport is well clear of the end,
|
||||
# i.e. not wrapping.
|
||||
sh = helper()
|
||||
items = [block(200, (255, 0, 0)), block(200, (0, 255, 0)),
|
||||
block(200, (0, 0, 255))]
|
||||
sh.create_scrolling_image(items, item_gap=20, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 300.0
|
||||
before = sh.get_visible_portion().tobytes()
|
||||
|
||||
assert sh.drop_scrolled_prefix(keep_before=0) > 0, "trim should have run"
|
||||
after = sh.get_visible_portion().tobytes()
|
||||
assert after == before
|
||||
|
||||
def test_refuses_to_trim_while_the_viewport_wraps(self):
|
||||
# Wrapping reads the head of the strip into the right of the frame, so
|
||||
# trimming the head there would visibly change the picture.
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 150.0 # 150 + 128 > 200, so wrapping
|
||||
before = sh.get_visible_portion().tobytes()
|
||||
assert sh.drop_scrolled_prefix(keep_before=0) == 0
|
||||
assert sh.get_visible_portion().tobytes() == before
|
||||
|
||||
def test_array_and_image_stay_consistent_after_trim(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(900)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 400.0
|
||||
sh.drop_scrolled_prefix(keep_before=0)
|
||||
assert sh.cached_array.shape[1] == sh.cached_image.width
|
||||
|
||||
|
||||
class TestRemainingUnscrolled:
|
||||
def test_counts_content_right_of_the_viewport(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
assert sh.remaining_unscrolled() == 500 - W
|
||||
|
||||
def test_shrinks_as_the_strip_scrolls(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 200.0
|
||||
assert sh.remaining_unscrolled() == 500 - 200 - W
|
||||
|
||||
def test_never_negative(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 500.0
|
||||
assert sh.remaining_unscrolled() == 0
|
||||
|
||||
def test_zero_with_no_strip(self):
|
||||
assert helper().remaining_unscrolled() == 0
|
||||
|
||||
def test_grows_when_content_is_appended(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 100.0
|
||||
before = sh.remaining_unscrolled()
|
||||
assert before > 0, "fixture should leave content ahead of the viewport"
|
||||
sh.append_content([block(400)], item_gap=0)
|
||||
assert sh.remaining_unscrolled() == before + 400
|
||||
|
||||
|
||||
class TestContinuousScrollingEndToEnd:
|
||||
def test_strip_can_be_extended_indefinitely_at_bounded_size(self):
|
||||
"""The invariant that makes this viable: extend + trim keeps the strip
|
||||
bounded while motion never stops."""
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
|
||||
widths = []
|
||||
for _ in range(20):
|
||||
sh.scroll_position += 200
|
||||
sh.total_distance_scrolled += 200
|
||||
if sh.remaining_unscrolled() < 2 * W:
|
||||
sh.append_content([block(600)], item_gap=16)
|
||||
sh.drop_scrolled_prefix(keep_before=W)
|
||||
widths.append(sh.cached_image.width)
|
||||
# A frame must always be renderable.
|
||||
assert sh.get_visible_portion() is not None
|
||||
|
||||
assert max(widths) < 3000, f"strip grew unbounded: max {max(widths)}"
|
||||
assert not sh.scroll_complete, "continuous strip should never complete"
|
||||
|
||||
|
||||
class TestSubPixelBlending:
|
||||
"""
|
||||
Integer positioning quantises motion to whole pixels, so distinct frames per
|
||||
second equals scroll speed regardless of frame rate — at 50px/s and 78fps,
|
||||
36% of frames were identical. Blending between neighbouring positions gives
|
||||
motion at the frame rate instead.
|
||||
"""
|
||||
|
||||
def _strip(self, width=2000):
|
||||
rng = np.random.default_rng(0)
|
||||
arr = (rng.random((H, width, 3)) * 255).astype(np.uint8)
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([Image.fromarray(arr)],
|
||||
item_gap=0, element_gap=0, lead_gap=0)
|
||||
return sh
|
||||
|
||||
def _frame(self, sh, pos, subpixel):
|
||||
sh.sub_pixel_scrolling = subpixel
|
||||
sh.scroll_position = pos
|
||||
return np.asarray(sh.get_visible_portion()).astype(int)
|
||||
|
||||
def test_integer_mode_ignores_the_fraction(self):
|
||||
sh = self._strip()
|
||||
a = self._frame(sh, 500.0, False)
|
||||
b = self._frame(sh, 500.9, False)
|
||||
assert np.array_equal(a, b), "integer positioning should not move sub-pixel"
|
||||
|
||||
def test_blending_moves_within_a_pixel(self):
|
||||
sh = self._strip()
|
||||
a = self._frame(sh, 500.0, True)
|
||||
b = self._frame(sh, 500.5, True)
|
||||
assert not np.array_equal(a, b)
|
||||
|
||||
def test_zero_fraction_matches_the_integer_frame(self):
|
||||
# No interpolation to do, so it must be pixel-identical and take the
|
||||
# cheap path.
|
||||
sh = self._strip()
|
||||
assert np.array_equal(self._frame(sh, 700.0, True),
|
||||
self._frame(sh, 700.0, False))
|
||||
|
||||
def test_blend_is_monotonic_between_neighbours(self):
|
||||
# Marching the fraction from 0 to 1 should approach the next integer
|
||||
# frame, not wander.
|
||||
sh = self._strip()
|
||||
target = self._frame(sh, 501.0, False)
|
||||
dists = []
|
||||
for frac in (0.0, 0.25, 0.5, 0.75):
|
||||
f = self._frame(sh, 500.0 + frac, True)
|
||||
dists.append(np.abs(f - target).mean())
|
||||
assert dists == sorted(dists, reverse=True), f"not converging: {dists}"
|
||||
|
||||
def test_blend_endpoints_bracket_the_two_frames(self):
|
||||
sh = self._strip()
|
||||
near = self._frame(sh, 500.0, False)
|
||||
far = self._frame(sh, 501.0, False)
|
||||
mid = self._frame(sh, 500.5, True)
|
||||
# Every blended pixel must lie between its two sources.
|
||||
lo = np.minimum(near, far)
|
||||
hi = np.maximum(near, far)
|
||||
assert (mid >= lo - 1).all() and (mid <= hi + 1).all()
|
||||
|
||||
def test_output_size_and_mode_are_unchanged(self):
|
||||
sh = self._strip()
|
||||
sh.sub_pixel_scrolling = True
|
||||
sh.scroll_position = 300.4
|
||||
frame = sh.get_visible_portion()
|
||||
assert frame.size == (W, H)
|
||||
assert frame.mode == 'RGB'
|
||||
|
||||
def test_works_near_the_end_of_the_strip(self):
|
||||
# One of the two slices wraps here; must not raise or missize.
|
||||
sh = self._strip(width=600)
|
||||
sh.sub_pixel_scrolling = True
|
||||
sh.scroll_position = float(600 - W // 2) + 0.5
|
||||
frame = sh.get_visible_portion()
|
||||
assert frame is not None and frame.size == (W, H)
|
||||
|
||||
def test_works_at_the_very_last_column(self):
|
||||
sh = self._strip(width=600)
|
||||
sh.sub_pixel_scrolling = True
|
||||
sh.scroll_position = 599.5
|
||||
assert sh.get_visible_portion().size == (W, H)
|
||||
|
||||
@pytest.mark.parametrize("frac", [0.01, 0.1, 0.33, 0.5, 0.67, 0.9, 0.99])
|
||||
def test_never_raises_across_the_fraction_range(self, frac):
|
||||
sh = self._strip()
|
||||
sh.sub_pixel_scrolling = True
|
||||
sh.scroll_position = 400.0 + frac
|
||||
assert sh.get_visible_portion().size == (W, H)
|
||||
@@ -0,0 +1,660 @@
|
||||
"""Characterization tests for src/base_classes/sports.py.
|
||||
|
||||
These tests PIN the current behavior of SportsCore / SportsUpcoming /
|
||||
SportsRecent / SportsLive ahead of the sports-unification merge (features
|
||||
from nine drifted plugin copies are about to be folded in). They assert
|
||||
what the code DOES today, not what it should do — a few pinned behaviors
|
||||
look like bugs and are flagged inline with "PINNED AS-IS".
|
||||
|
||||
Coverage:
|
||||
- `_extract_game_details_common` + the four sport extractors
|
||||
(football/hockey/baseball/basketball) against realistic ESPN scoreboard
|
||||
events (adapted from the ledmatrix-plugins monorepo test fixtures).
|
||||
The output must remain a superset of the frozen skin view-model
|
||||
contract (GUARANTEED_KEYS, imported from test_skin_system).
|
||||
- update() flow for concrete SportsUpcoming/SportsRecent/SportsLive
|
||||
subclasses: population, favorite-team filtering, empty/failed-fetch
|
||||
tolerance. All offline: `_fetch_data` reads a pre-seeded mocked cache
|
||||
and every instance's requests session raises ConnectionError.
|
||||
- Rendering smoke: one `display()` per mode class at 128x32 draws
|
||||
non-zero ink onto a real PIL image.
|
||||
- Guard rails: the skin-system seam methods on SportsCore must survive
|
||||
the merge.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import pytz
|
||||
import requests
|
||||
from freezegun import freeze_time
|
||||
from PIL import Image
|
||||
|
||||
# src.base_classes.sports transitively imports the hardware matrix driver;
|
||||
# stub it so these tests can import the sports base classes off-device.
|
||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
||||
|
||||
from src.base_classes.baseball import Baseball
|
||||
from src.base_classes.basketball import Basketball
|
||||
from src.base_classes.football import Football
|
||||
from src.base_classes.hockey import Hockey, HockeyLive
|
||||
from src.base_classes.sports import (
|
||||
SportsCore,
|
||||
SportsLive,
|
||||
SportsRecent,
|
||||
SportsUpcoming,
|
||||
)
|
||||
|
||||
# Reuse the frozen v1.0 skin view-model contract rather than redeclaring it.
|
||||
from test.test_skin_system import GUARANTEED_KEYS
|
||||
|
||||
SPORT_CLASSES = [Football, Hockey, Baseball, Basketball]
|
||||
SPORT_IDS = ["football", "hockey", "baseball", "basketball"]
|
||||
|
||||
# All update()-flow tests run at this frozen instant so the 21-day
|
||||
# SportsRecent window and time.time() interval gates are deterministic.
|
||||
FROZEN_NOW = "2026-01-20 12:00:00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ESPN scoreboard event builders (shape adapted from the monorepo fixtures,
|
||||
# e.g. ledmatrix-plugins/plugins/hockey-scoreboard/test/fixtures/mock.json:
|
||||
# team-shaped competitors with status/score/records).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _competitor(abbr, team_id, score, home_away, record="30-10-5"):
|
||||
return {
|
||||
"homeAway": home_away,
|
||||
"id": team_id,
|
||||
"score": score,
|
||||
"team": {
|
||||
"id": team_id,
|
||||
"abbreviation": abbr,
|
||||
"name": abbr.title(),
|
||||
"displayName": abbr.title(),
|
||||
"logo": None,
|
||||
},
|
||||
"records": [{"summary": record}],
|
||||
# The hockey extractor reads competitor["statistics"] for shot counts;
|
||||
# it now defaults to an empty list when the key is absent rather than
|
||||
# dropping the whole event (see
|
||||
# test_hockey_event_without_statistics_still_extracts).
|
||||
"statistics": [],
|
||||
}
|
||||
|
||||
|
||||
def make_event(event_id, state, date, home=("TB", "20", "3"),
|
||||
away=("DAL", "9", "2"), period=2, clock="12:45",
|
||||
name=None, short_detail=None, situation=None,
|
||||
home_record="30-10-5", away_record="25-14-6"):
|
||||
"""Build a realistic ESPN scoreboard event in the given state
|
||||
('in' / 'post' / 'pre')."""
|
||||
defaults = {
|
||||
"in": ("STATUS_IN_PROGRESS", f"P{period} {clock}"),
|
||||
"post": ("STATUS_FINAL", "Final"),
|
||||
"pre": ("STATUS_SCHEDULED", "1/15 - 6:30 PM"),
|
||||
}
|
||||
default_name, default_detail = defaults[state]
|
||||
status = {
|
||||
"clock": 0.0,
|
||||
"displayClock": clock,
|
||||
"period": period,
|
||||
"type": {
|
||||
"id": "2",
|
||||
"name": name or default_name,
|
||||
"state": state,
|
||||
"completed": state == "post",
|
||||
"description": short_detail or default_detail,
|
||||
"detail": short_detail or default_detail,
|
||||
"shortDetail": short_detail or default_detail,
|
||||
},
|
||||
}
|
||||
competition = {
|
||||
"id": event_id,
|
||||
"date": date,
|
||||
"status": status,
|
||||
"competitors": [
|
||||
_competitor(home[0], home[1], home[2], "home", home_record),
|
||||
_competitor(away[0], away[1], away[2], "away", away_record),
|
||||
],
|
||||
}
|
||||
if situation is not None:
|
||||
competition["situation"] = situation
|
||||
return {
|
||||
"id": event_id,
|
||||
"date": date,
|
||||
"name": f"{away[0]} at {home[0]}",
|
||||
"shortName": f"{away[0]} @ {home[0]}",
|
||||
"competitions": [competition],
|
||||
# Real ESPN payloads duplicate status at the event top level; the
|
||||
# baseball extractor reads it there for live innings.
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def make_probe(favorites=None):
|
||||
"""Bare-bones SportsCore stand-in for exercising the real extractors
|
||||
unbound (same pattern as TestViewModelContract in test_skin_system)."""
|
||||
probe = MagicMock()
|
||||
probe.logger = logging.getLogger("test_sports_base_characterization")
|
||||
probe.favorite_teams = list(favorites or [])
|
||||
probe.config = {}
|
||||
probe.logo_dir = Path("assets/logos")
|
||||
probe._get_timezone.return_value = pytz.utc
|
||||
probe.display_manager.format_date_with_ordinal.return_value = "Jan 15th"
|
||||
# The sport extractors call self._extract_game_details_common — route
|
||||
# it to the real implementation instead of a MagicMock.
|
||||
probe._extract_game_details_common = (
|
||||
lambda event: SportsCore._extract_game_details_common(probe, event))
|
||||
return probe
|
||||
|
||||
|
||||
def extract(sport_cls, event, favorites=None):
|
||||
return sport_cls._extract_game_details(make_probe(favorites), event)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. _extract_game_details_common contract, per wired sport
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractGameDetailsContract:
|
||||
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
|
||||
def test_live_event_guaranteed_keys_and_values(self, sport_cls):
|
||||
event = make_event("401", "in", "2026-01-15T18:30:00Z")
|
||||
details = extract(sport_cls, event)
|
||||
assert details is not None
|
||||
missing = [k for k in GUARANTEED_KEYS if k not in details]
|
||||
assert not missing, (
|
||||
f"{sport_cls.__name__} extractor no longer emits {missing} — "
|
||||
"these keys are the frozen skin view-model contract.")
|
||||
assert details["id"] == "401"
|
||||
assert details["home_abbr"] == "TB"
|
||||
assert details["away_abbr"] == "DAL"
|
||||
assert details["home_id"] == "20"
|
||||
assert details["away_id"] == "9"
|
||||
assert details["home_score"] == "3"
|
||||
assert details["away_score"] == "2"
|
||||
assert details["home_record"] == "30-10-5"
|
||||
assert details["away_record"] == "25-14-6"
|
||||
assert details["is_live"] is True
|
||||
assert details["is_final"] is False
|
||||
assert details["is_upcoming"] is False
|
||||
assert details["status_text"] == "P2 12:45"
|
||||
assert details["start_time_utc"] == datetime(
|
||||
2026, 1, 15, 18, 30, tzinfo=timezone.utc)
|
||||
# Sport-specific formatting of the same event:
|
||||
if sport_cls in (Football, Basketball):
|
||||
assert details["period_text"] == "Q2"
|
||||
assert details["clock"] == "12:45"
|
||||
elif sport_cls is Hockey:
|
||||
assert details["period_text"] == "P2"
|
||||
assert details["clock"] == "12:45"
|
||||
else: # Baseball keys inning/status instead of period_text
|
||||
assert details["inning"] == 2
|
||||
assert details["status_state"] == "in"
|
||||
|
||||
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
|
||||
def test_final_event_classification(self, sport_cls):
|
||||
event = make_event("402", "post", "2026-01-14T00:00:00Z",
|
||||
home=("BOS", "1", "4"), away=("TOR", "21", "2"),
|
||||
period=3, clock="0:00")
|
||||
details = extract(sport_cls, event)
|
||||
assert details is not None
|
||||
assert details["is_final"] is True
|
||||
assert details["is_live"] is False
|
||||
assert details["is_upcoming"] is False
|
||||
assert details["home_score"] == "4"
|
||||
assert details["away_score"] == "2"
|
||||
if sport_cls in (Football, Hockey, Basketball):
|
||||
assert details["period_text"] == "Final"
|
||||
|
||||
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
|
||||
def test_upcoming_event_classification(self, sport_cls):
|
||||
event = make_event("403", "pre", "2026-01-15T18:30:00Z",
|
||||
home=("NYR", "13", "0"), away=("PIT", "16", "0"),
|
||||
period=0, clock="0:00")
|
||||
details = extract(sport_cls, event)
|
||||
assert details is not None
|
||||
assert details["is_upcoming"] is True
|
||||
assert details["is_live"] is False
|
||||
assert details["is_final"] is False
|
||||
# Local time formatting (probe timezone is UTC): 18:30Z -> 6:30PM,
|
||||
# date rendered through display_manager.format_date_with_ordinal.
|
||||
assert details["game_time"] == "6:30PM"
|
||||
assert details["game_date"] == "Jan 15th"
|
||||
|
||||
def test_halftime_state_flags(self):
|
||||
# is_halftime keys off name STATUS_HALFTIME (or state "halftime")
|
||||
# while state "in" still counts as live.
|
||||
event = make_event("404", "in", "2026-01-15T18:30:00Z",
|
||||
name="STATUS_HALFTIME", short_detail="Halftime")
|
||||
details, *_ = SportsCore._extract_game_details_common(
|
||||
make_probe(), event)
|
||||
assert details["is_live"] is True
|
||||
assert details["is_halftime"] is True
|
||||
|
||||
def test_state_name_conflict_is_both_final_and_upcoming(self):
|
||||
# PINNED AS-IS (looks like a bug): is_upcoming also matches on
|
||||
# status.type.name ('scheduled'/'pre-game'/'status_scheduled'), so
|
||||
# an event with state="post" but name="Scheduled" reports BOTH
|
||||
# is_final and is_upcoming True.
|
||||
event = make_event("405", "post", "2026-01-14T00:00:00Z",
|
||||
name="Scheduled")
|
||||
details, *_ = SportsCore._extract_game_details_common(
|
||||
make_probe(), event)
|
||||
assert details["is_final"] is True
|
||||
assert details["is_upcoming"] is True
|
||||
|
||||
def test_zero_zero_record_blanked(self):
|
||||
event = make_event("406", "pre", "2026-01-15T18:30:00Z",
|
||||
home_record="0-0", away_record="0-0-0")
|
||||
details, *_ = SportsCore._extract_game_details_common(
|
||||
make_probe(), event)
|
||||
assert details["home_record"] == ""
|
||||
assert details["away_record"] == ""
|
||||
|
||||
def test_missing_abbreviation_uses_name_prefix(self):
|
||||
event = make_event("407", "pre", "2026-01-15T18:30:00Z")
|
||||
for comp in event["competitions"][0]["competitors"]:
|
||||
del comp["team"]["abbreviation"]
|
||||
comp["team"]["name"] = "Sharks" if comp["homeAway"] == "home" \
|
||||
else "Penguins"
|
||||
details, *_ = SportsCore._extract_game_details_common(
|
||||
make_probe(), event)
|
||||
assert details["home_abbr"] == "Sha"
|
||||
assert details["away_abbr"] == "Pen"
|
||||
|
||||
def test_empty_or_malformed_event_returns_none_tuple(self):
|
||||
probe = make_probe()
|
||||
assert SportsCore._extract_game_details_common(probe, {}) == \
|
||||
(None, None, None, None, None)
|
||||
assert SportsCore._extract_game_details_common(probe, None) == \
|
||||
(None, None, None, None, None)
|
||||
# Malformed event (no competitions) is swallowed, not raised.
|
||||
assert SportsCore._extract_game_details_common(
|
||||
probe, {"id": "999"}) == (None, None, None, None, None)
|
||||
|
||||
def test_football_live_situation_fields(self):
|
||||
event = make_event(
|
||||
"408", "in", "2026-01-15T18:30:00Z",
|
||||
situation={
|
||||
"shortDownDistanceText": "3rd & 4",
|
||||
"downDistanceText": "3rd & 4 at TB 30",
|
||||
"isRedZone": False,
|
||||
"possession": "20",
|
||||
"homeTimeouts": 2,
|
||||
"awayTimeouts": 3,
|
||||
})
|
||||
details = extract(Football, event)
|
||||
assert details["down_distance_text"] == "3rd & 4"
|
||||
assert details["down_distance_text_long"] == "3rd & 4 at TB 30"
|
||||
assert details["possession"] == "20"
|
||||
assert details["possession_indicator"] == "home" # matches home id
|
||||
assert details["home_timeouts"] == 2
|
||||
assert details["away_timeouts"] == 3
|
||||
|
||||
def test_hockey_live_power_play_and_default_shots(self):
|
||||
event = make_event("409", "in", "2026-01-15T18:30:00Z",
|
||||
situation={"isPowerPlay": True, "penalties": ""})
|
||||
details = extract(Hockey, event)
|
||||
assert details["power_play"] is True
|
||||
# Empty statistics arrays -> save-percentage math yields 0 shots.
|
||||
assert details["home_shots"] == 0
|
||||
assert details["away_shots"] == 0
|
||||
|
||||
def test_hockey_event_without_statistics_still_extracts(self):
|
||||
# FIXED (was pinned as returning None): the hockey extractor used to
|
||||
# iterate competitor["statistics"] unguarded, so a competitor without
|
||||
# the key raised KeyError internally and the WHOLE event was dropped
|
||||
# despite valid scores and status. It now defaults to an empty list,
|
||||
# matching the behaviour already shipped in the hockey plugin, so the
|
||||
# event survives with zeroed shot counts -- the same values
|
||||
# test_hockey_live_power_play_and_default_shots already expects for an
|
||||
# EMPTY statistics array.
|
||||
event = make_event("410", "in", "2026-01-15T18:30:00Z")
|
||||
for comp in event["competitions"][0]["competitors"]:
|
||||
del comp["statistics"]
|
||||
details = extract(Hockey, event)
|
||||
assert details is not None
|
||||
assert details["home_abbr"] == "TB"
|
||||
assert details["away_abbr"] == "DAL"
|
||||
assert details["home_score"] == "3"
|
||||
assert details["home_shots"] == 0
|
||||
assert details["away_shots"] == 0
|
||||
|
||||
def test_baseball_live_inning_and_count(self):
|
||||
event = make_event(
|
||||
"411", "in", "2026-07-16T23:05:00Z",
|
||||
home=("LAD", "19", "5"), away=("SF", "26", "3"),
|
||||
period=7, short_detail="Bot 7th",
|
||||
situation={
|
||||
"count": {"balls": 2, "strikes": 1},
|
||||
"outs": 2,
|
||||
"onFirst": True,
|
||||
"onSecond": False,
|
||||
"onThird": True,
|
||||
})
|
||||
details = extract(Baseball, event)
|
||||
assert details["inning"] == 7 # from top-level status period
|
||||
assert details["inning_half"] == "bottom"
|
||||
assert details["balls"] == 2
|
||||
assert details["strikes"] == 1
|
||||
assert details["outs"] == 2
|
||||
assert details["bases_occupied"] == [True, False, True]
|
||||
assert details["status"] == "status_in_progress"
|
||||
assert details["series_summary"] == ""
|
||||
|
||||
def test_baseball_live_without_top_level_status_still_extracts(self):
|
||||
# FIXED (was pinned as returning None): the baseball extractor read
|
||||
# game_event["status"] -- the event TOP-LEVEL status -- for the
|
||||
# inning, so an otherwise-valid live event lacking that duplicate key
|
||||
# was dropped entirely. Real ESPN events carry status in both places,
|
||||
# but MiLB events (synthesized from the MLB Stats API into an
|
||||
# ESPN-like shape) populate only the competition-level one. It now
|
||||
# reads the competition-level `status` that
|
||||
# _extract_game_details_common has already validated, so it can never
|
||||
# be missing at that point.
|
||||
event = make_event("412", "in", "2026-07-16T23:05:00Z", period=7)
|
||||
del event["status"]
|
||||
details = extract(Baseball, event)
|
||||
assert details is not None
|
||||
assert details["inning"] == 7
|
||||
|
||||
def test_baseball_live_without_top_level_status_extracts_for_favorites(self):
|
||||
# The favourite-team branch logs the status payload for diagnostics and
|
||||
# read the same event top-level key the test above proves can be
|
||||
# absent. So the identical MiLB event that extracts fine for a
|
||||
# non-favourite raised KeyError and was dropped once the team WAS a
|
||||
# favourite -- the worst shape for the bug, since it only hit the games
|
||||
# the user cared most about, and only on the diagnostic path that was
|
||||
# supposed to help debug them.
|
||||
event = make_event("413", "in", "2026-07-16T23:05:00Z", period=7)
|
||||
del event["status"]
|
||||
details = extract(Baseball, event, favorites=["TB"])
|
||||
assert details is not None
|
||||
assert details["inning"] == 7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. update() flow on concrete subclasses (offline, cache-fed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _UpcomingHarness(Hockey, SportsUpcoming):
|
||||
"""Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data."""
|
||||
|
||||
def _fetch_data(self):
|
||||
return self.cache_manager.get(f"{self.sport_key}_schedule")
|
||||
|
||||
|
||||
class _RecentHarness(Hockey, SportsRecent):
|
||||
def _fetch_data(self):
|
||||
return self.cache_manager.get(f"{self.sport_key}_schedule")
|
||||
|
||||
|
||||
class _LiveHarness(HockeyLive):
|
||||
def _fetch_data(self):
|
||||
return self.cache_manager.get(f"{self.sport_key}_schedule")
|
||||
|
||||
|
||||
def make_schedule():
|
||||
"""A mixed schedule around the frozen 'now' of 2026-01-20."""
|
||||
return {"events": [
|
||||
# Final 6 days ago — inside the recent 21-day window.
|
||||
make_event("9001", "post", "2026-01-14T00:00:00Z",
|
||||
home=("BOS", "1", "4"), away=("TOR", "21", "2"),
|
||||
period=3, clock="0:00"),
|
||||
# Live game.
|
||||
make_event("9002", "in", "2026-01-15T00:30:00Z",
|
||||
home=("TB", "20", "3"), away=("DAL", "9", "2")),
|
||||
# Two scheduled games.
|
||||
make_event("9003", "pre", "2026-01-16T00:00:00Z",
|
||||
home=("NYR", "13", "0"), away=("PIT", "16", "0"),
|
||||
period=0),
|
||||
make_event("9004", "pre", "2026-01-17T00:00:00Z",
|
||||
home=("BOS", "1", "0"), away=("MTL", "10", "0"),
|
||||
period=0),
|
||||
# Final from November — outside the recent 21-day window.
|
||||
make_event("9005", "post", "2025-11-01T00:00:00Z",
|
||||
home=("SEA", "124292", "1"), away=("VAN", "22", "5"),
|
||||
period=3, clock="0:00"),
|
||||
]}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def build_manager(monkeypatch, tmp_path):
|
||||
"""Factory for concrete sports managers: mocked display/cache managers,
|
||||
logo dir redirected to tmp, background service stubbed, and the
|
||||
requests session rigged to prove nothing hits the network."""
|
||||
monkeypatch.setattr(
|
||||
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"src.base_classes.sports.core.get_background_service",
|
||||
lambda *args, **kwargs: MagicMock())
|
||||
|
||||
# Rig requests.Session BEFORE any manager is built. Construction creates
|
||||
# both SportsCore.session and the ESPNDataSource.session; replacing only
|
||||
# manager.session after the fact (below) leaves data_source.session real,
|
||||
# so an accidental fetch during or right after construction could reach
|
||||
# the network. Patching the class makes every session created here raise.
|
||||
def _offline_get(*args, **kwargs):
|
||||
raise requests.exceptions.ConnectionError(
|
||||
"characterization tests are offline")
|
||||
|
||||
monkeypatch.setattr(requests.Session, "get", _offline_get)
|
||||
|
||||
def build(cls, schedule, **mode_cfg):
|
||||
config = {
|
||||
"timezone": "UTC",
|
||||
"display": {},
|
||||
"nhl_scoreboard": {"enabled": True, **mode_cfg},
|
||||
}
|
||||
display_manager = MagicMock()
|
||||
display_manager.matrix.width = 128
|
||||
display_manager.matrix.height = 32
|
||||
display_manager.width = 128
|
||||
display_manager.height = 32
|
||||
display_manager.image = Image.new("RGB", (128, 32))
|
||||
display_manager.format_date_with_ordinal.side_effect = (
|
||||
lambda dt: dt.strftime("%b %d"))
|
||||
cache_manager = MagicMock()
|
||||
cache_manager.get.return_value = schedule
|
||||
cache_manager.cache_dir = str(tmp_path)
|
||||
manager = cls(config, display_manager, cache_manager,
|
||||
logging.getLogger("test_sports_base_characterization"),
|
||||
"nhl")
|
||||
# Safety net: any accidental network fetch must fail loudly.
|
||||
manager.session = MagicMock()
|
||||
manager.session.get.side_effect = requests.exceptions.ConnectionError(
|
||||
"characterization tests are offline")
|
||||
return manager
|
||||
|
||||
return build
|
||||
|
||||
|
||||
def _ids(games):
|
||||
return [g["id"] for g in games]
|
||||
|
||||
|
||||
@freeze_time(FROZEN_NOW)
|
||||
class TestUpcomingUpdateFlow:
|
||||
def test_populates_games_list_sorted_by_start_time(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness, make_schedule())
|
||||
manager.update()
|
||||
# PINNED AS-IS: SportsUpcoming filters purely on is_upcoming
|
||||
# (state 'pre') — there is NO date filter, so 'pre' games whose
|
||||
# start time is already in the past (9003/9004 vs frozen 1/20)
|
||||
# are still shown.
|
||||
assert _ids(manager.games_list) == ["9003", "9004"]
|
||||
assert manager.current_game["id"] == "9003"
|
||||
|
||||
def test_filters_by_favorite_teams(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness, make_schedule(),
|
||||
show_favorite_teams_only=True,
|
||||
favorite_teams=["BOS"])
|
||||
manager.update()
|
||||
assert _ids(manager.games_list) == ["9004"]
|
||||
assert manager.current_game["id"] == "9004"
|
||||
|
||||
def test_favorites_only_with_no_favorites_shows_nothing(
|
||||
self, build_manager):
|
||||
# PINNED AS-IS: show_favorite_teams_only=True with an empty
|
||||
# favorite_teams list drops every game rather than falling back
|
||||
# to showing all games.
|
||||
manager = build_manager(_UpcomingHarness, make_schedule(),
|
||||
show_favorite_teams_only=True,
|
||||
favorite_teams=[])
|
||||
manager.update()
|
||||
assert manager.games_list == []
|
||||
assert manager.current_game is None
|
||||
|
||||
def test_caps_at_upcoming_games_to_show(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness, make_schedule(),
|
||||
upcoming_games_to_show=1)
|
||||
manager.update()
|
||||
assert _ids(manager.games_list) == ["9003"]
|
||||
|
||||
def test_tolerates_empty_events_list(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness, {"events": []})
|
||||
manager.update() # must not raise
|
||||
assert manager.games_list == []
|
||||
assert manager.current_game is None
|
||||
|
||||
def test_tolerates_fetch_returning_none(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness, None)
|
||||
manager.update() # must not raise
|
||||
assert manager.games_list == []
|
||||
assert manager.current_game is None
|
||||
|
||||
def test_disabled_manager_update_is_noop(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness, make_schedule(),
|
||||
enabled=False)
|
||||
manager.update()
|
||||
assert manager.games_list == []
|
||||
manager.cache_manager.get.assert_not_called()
|
||||
|
||||
|
||||
@freeze_time(FROZEN_NOW)
|
||||
class TestRecentUpdateFlow:
|
||||
def test_populates_only_finals_within_21_day_window(self, build_manager):
|
||||
manager = build_manager(_RecentHarness, make_schedule())
|
||||
manager.update()
|
||||
# 9001 (final, 6 days old) kept; 9005 (final, ~80 days old)
|
||||
# excluded by the 21-day cutoff; live/pre games excluded.
|
||||
assert _ids(manager.games_list) == ["9001"]
|
||||
assert manager.current_game["id"] == "9001"
|
||||
assert manager.current_game["is_final"] is True
|
||||
|
||||
def test_filters_by_favorite_teams(self, build_manager):
|
||||
manager = build_manager(_RecentHarness, make_schedule(),
|
||||
show_favorite_teams_only=True,
|
||||
favorite_teams=["TOR"])
|
||||
manager.update()
|
||||
assert _ids(manager.games_list) == ["9001"]
|
||||
|
||||
stranger = build_manager(_RecentHarness, make_schedule(),
|
||||
show_favorite_teams_only=True,
|
||||
favorite_teams=["XXX"])
|
||||
stranger.update()
|
||||
assert stranger.games_list == []
|
||||
assert stranger.current_game is None
|
||||
|
||||
def test_tolerates_empty_events_list(self, build_manager):
|
||||
manager = build_manager(_RecentHarness, {"events": []})
|
||||
manager.update() # must not raise
|
||||
assert manager.games_list == []
|
||||
assert manager.current_game is None
|
||||
|
||||
|
||||
@freeze_time(FROZEN_NOW)
|
||||
class TestLiveUpdateFlow:
|
||||
def test_selects_only_live_games(self, build_manager):
|
||||
manager = build_manager(_LiveHarness, make_schedule())
|
||||
manager.update()
|
||||
assert _ids(manager.live_games) == ["9002"]
|
||||
assert manager.current_game["id"] == "9002"
|
||||
assert manager.current_game["is_live"] is True
|
||||
|
||||
def test_no_live_games_clears_current_game(self, build_manager):
|
||||
schedule = {"events": [
|
||||
make_event("9001", "post", "2026-01-14T00:00:00Z"),
|
||||
make_event("9003", "pre", "2026-01-16T00:00:00Z", period=0),
|
||||
]}
|
||||
manager = build_manager(_LiveHarness, schedule)
|
||||
manager.update()
|
||||
assert manager.live_games == []
|
||||
assert manager.current_game is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Rendering smoke — one display() per mode class at 128x32
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fake_logo(*args, **kwargs):
|
||||
return Image.new("RGBA", (24, 24), (180, 30, 30, 255))
|
||||
|
||||
|
||||
@freeze_time(FROZEN_NOW)
|
||||
class TestRenderingSmoke:
|
||||
def _assert_rendered(self, manager):
|
||||
manager.display_manager.update_display.assert_called()
|
||||
assert manager.display_manager.image.convert("L").getbbox() is not None
|
||||
|
||||
def test_upcoming_display_draws_ink(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness, make_schedule())
|
||||
manager.update()
|
||||
manager._load_and_resize_logo = _fake_logo
|
||||
assert manager.display(force_clear=True) is True
|
||||
self._assert_rendered(manager)
|
||||
|
||||
def test_recent_display_draws_ink(self, build_manager):
|
||||
manager = build_manager(_RecentHarness, make_schedule())
|
||||
manager.update()
|
||||
manager._load_and_resize_logo = _fake_logo
|
||||
assert manager.display(force_clear=True) is True
|
||||
self._assert_rendered(manager)
|
||||
|
||||
def test_live_display_draws_ink(self, build_manager):
|
||||
manager = build_manager(_LiveHarness, make_schedule())
|
||||
manager.update()
|
||||
manager._load_and_resize_logo = _fake_logo
|
||||
assert manager.display(force_clear=True) is True
|
||||
self._assert_rendered(manager)
|
||||
|
||||
def test_draw_scorebug_layout_direct_call_does_not_raise(
|
||||
self, build_manager):
|
||||
# The base-class placeholder renderer must also stay callable.
|
||||
manager = build_manager(_UpcomingHarness, make_schedule())
|
||||
game = manager._extract_game_details(make_schedule()["events"][2])
|
||||
manager._load_and_resize_logo = _fake_logo
|
||||
SportsCore._draw_scorebug_layout(manager, game)
|
||||
assert manager.display_manager.image.convert("L").getbbox() is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Guard rails — seams the merge must not silently drop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGuardRails:
|
||||
def test_skin_seam_methods_survive(self):
|
||||
for name in ("_resolve_skin_id", "_get_skin", "_render_game",
|
||||
"render_skin_card"):
|
||||
assert callable(getattr(SportsCore, name, None)), (
|
||||
f"SportsCore.{name} is part of the skin-system seam "
|
||||
"(src/skin_system) — the sports-unification merge must "
|
||||
"keep it.")
|
||||
|
||||
def test_skin_mode_per_class(self):
|
||||
assert SportsCore.SKIN_MODE == "live"
|
||||
assert SportsUpcoming.SKIN_MODE == "upcoming"
|
||||
assert SportsRecent.SKIN_MODE == "recent"
|
||||
assert SportsLive.SKIN_MODE == "live" # inherits the default
|
||||
|
||||
def test_core_display_and_extractor_seams_survive(self):
|
||||
for name in ("display", "_draw_scorebug_layout",
|
||||
"_extract_game_details_common", "update"):
|
||||
owner = SportsCore if name != "update" else SportsUpcoming
|
||||
assert callable(getattr(owner, name, None)), name
|
||||
@@ -0,0 +1,880 @@
|
||||
"""Tests for the opt-in sports capabilities (phase B2).
|
||||
|
||||
Two properties matter beyond "the code works":
|
||||
|
||||
1. **Opting out is structural.** A mode class that does not mix in
|
||||
``CelebrationMixin`` must have none of its attributes or methods — not
|
||||
merely a disabled flag. ``TestOptOutIsStructural`` asserts that directly,
|
||||
because it is the property the whole mixin design exists to buy.
|
||||
|
||||
2. **The promoted behavior matches the plugin copies.** These bodies came from
|
||||
afl/soccer/nrl (goal dialect) and football (score dialect); the tests pin
|
||||
the reconciled behavior of both, including the three seams where the
|
||||
lineages genuinely disagreed.
|
||||
|
||||
See docs/SPORTS_UNIFICATION.md.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
||||
|
||||
from src.base_classes.sports.capabilities import ( # noqa: E402
|
||||
CelebrationMixin,
|
||||
RotationStrategy,
|
||||
SimpleRotation,
|
||||
SmoothWeightedRotation,
|
||||
WeightedCycleRotation,
|
||||
get_rotation_strategy,
|
||||
register_rotation_strategy,
|
||||
)
|
||||
|
||||
|
||||
def game(gid, home="HOM", away="AWY", home_score=0, away_score=0, **extra):
|
||||
g = {
|
||||
"id": gid,
|
||||
"home_abbr": home,
|
||||
"away_abbr": away,
|
||||
"home_id": f"{gid}-h",
|
||||
"away_id": f"{gid}-a",
|
||||
"home_score": home_score,
|
||||
"away_score": away_score,
|
||||
}
|
||||
g.update(extra)
|
||||
return g
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rotation strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def boost(favorites, factor=3):
|
||||
"""A weight_for callable of the shape the plugins supply."""
|
||||
return lambda g: factor if g.get("home_abbr") in favorites else 1
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
@pytest.mark.parametrize("name,cls", [
|
||||
("simple", SimpleRotation),
|
||||
("weighted", WeightedCycleRotation),
|
||||
("swrr", SmoothWeightedRotation),
|
||||
])
|
||||
def test_builtin_names_resolve(self, name, cls):
|
||||
assert isinstance(get_rotation_strategy(name), cls)
|
||||
|
||||
def test_unknown_name_falls_back_to_simple(self):
|
||||
"""The name comes from user config; a typo should cost the boost, not
|
||||
the scoreboard."""
|
||||
assert isinstance(get_rotation_strategy("typo"), SimpleRotation)
|
||||
|
||||
def test_a_plugin_can_register_its_own(self):
|
||||
class MyRotation(SimpleRotation):
|
||||
pass
|
||||
|
||||
register_rotation_strategy("test-only", MyRotation)
|
||||
try:
|
||||
assert isinstance(get_rotation_strategy("test-only"), MyRotation)
|
||||
assert MyRotation.name == "test-only"
|
||||
finally:
|
||||
from src.base_classes.sports.capabilities import rotation
|
||||
rotation._REGISTRY.pop("test-only", None)
|
||||
|
||||
def test_empty_name_is_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
register_rotation_strategy("", SimpleRotation)
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
SimpleRotation(), # an instance, not the class
|
||||
str, # unrelated class
|
||||
lambda **kw: None, # a factory function
|
||||
])
|
||||
def test_a_non_strategy_factory_is_rejected(self, bad):
|
||||
"""Fail at registration, not several frames later inside schedule(),
|
||||
where the cause is no longer on the stack."""
|
||||
with pytest.raises(TypeError):
|
||||
register_rotation_strategy("bad-factory", bad)
|
||||
|
||||
def test_weight_for_is_optional(self):
|
||||
"""Default weights are equal, so every strategy degenerates to a plain
|
||||
round robin — the pre-boost behavior."""
|
||||
games = [game("a"), game("b"), game("c")]
|
||||
for name in ("simple", "weighted", "swrr"):
|
||||
assert get_rotation_strategy(name).schedule(games) == ["a", "b", "c"]
|
||||
|
||||
|
||||
class TestWeights:
|
||||
def test_games_without_an_id_are_skipped(self):
|
||||
strategy = get_rotation_strategy("weighted")
|
||||
assert strategy.weights([game("a"), {"home_abbr": "X"}]) == {"a": 1}
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -5])
|
||||
def test_non_positive_weights_are_clamped_to_one(self, bad):
|
||||
"""A zero weight would starve the game out of the rotation entirely and
|
||||
collapse total_weight — no caller means that."""
|
||||
strategy = get_rotation_strategy("weighted", weight_for=lambda g: bad)
|
||||
assert strategy.weights([game("a")]) == {"a": 1}
|
||||
|
||||
@pytest.mark.parametrize("bad", [None, "three", object()])
|
||||
def test_unusable_weights_fall_back_to_one(self, bad):
|
||||
strategy = get_rotation_strategy("weighted", weight_for=lambda g: bad)
|
||||
assert strategy.weights([game("a")]) == {"a": 1}
|
||||
|
||||
def test_huge_weights_are_clamped(self):
|
||||
"""A cycle is sum(weights) long and each step scans every game, so an
|
||||
unbounded weight from a misread config spins the display thread."""
|
||||
strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000)
|
||||
assert strategy.weights([game("a")]) == {"a": RotationStrategy.MAX_WEIGHT}
|
||||
|
||||
def test_a_clamped_cycle_stays_bounded(self):
|
||||
strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000)
|
||||
order = strategy.schedule([game("a"), game("b")])
|
||||
assert len(order) == 2 * RotationStrategy.MAX_WEIGHT
|
||||
|
||||
|
||||
class TestSimpleRotation:
|
||||
def test_one_pass_in_feed_order(self):
|
||||
games = [game("a"), game("b"), game("c")]
|
||||
assert SimpleRotation().schedule(games) == ["a", "b", "c"]
|
||||
|
||||
def test_weights_are_ignored(self):
|
||||
games = [game("a", home="FAV"), game("b")]
|
||||
strategy = SimpleRotation(weight_for=boost({"FAV"}, 5))
|
||||
assert strategy.schedule(games) == ["a", "b"]
|
||||
|
||||
def test_empty(self):
|
||||
assert SimpleRotation().schedule([]) == []
|
||||
assert SimpleRotation().next_game([]) is None
|
||||
|
||||
|
||||
class TestWeightedCycleRotation:
|
||||
def test_no_boost_is_a_single_pass(self):
|
||||
games = [game("a"), game("b"), game("c")]
|
||||
strategy = WeightedCycleRotation(weight_for=boost({"NONE"}))
|
||||
assert strategy.schedule(games) == ["a", "b", "c"]
|
||||
|
||||
def test_favorite_gets_boost_many_slots(self):
|
||||
games = [game("a", home="FAV"), game("b")]
|
||||
order = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)).schedule(games)
|
||||
assert len(order) == 4
|
||||
assert order.count("a") == 3
|
||||
assert order.count("b") == 1
|
||||
|
||||
def test_repeats_are_spaced_not_clumped(self):
|
||||
"""The point of SWRR over naive repetition: 'aaab' is what we must NOT
|
||||
produce."""
|
||||
games = [game("a", home="FAV"), game("b")]
|
||||
order = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)).schedule(games)
|
||||
assert order != ["a", "a", "a", "b"]
|
||||
assert order[0] == "a", "highest weight is scheduled first"
|
||||
|
||||
def test_is_stateless_across_calls(self):
|
||||
games = [game("a", home="FAV"), game("b")]
|
||||
strategy = WeightedCycleRotation(weight_for=boost({"FAV"}, 3))
|
||||
assert strategy.schedule(games) == strategy.schedule(games)
|
||||
|
||||
def test_next_game_returns_the_first_of_the_cycle(self):
|
||||
games = [game("a"), game("b", home="FAV")]
|
||||
strategy = WeightedCycleRotation(weight_for=boost({"FAV"}, 4))
|
||||
assert strategy.next_game(games)["id"] == "b"
|
||||
|
||||
def test_empty(self):
|
||||
assert WeightedCycleRotation().schedule([]) == []
|
||||
|
||||
|
||||
class TestSmoothWeightedRotation:
|
||||
def test_no_boost_is_plain_round_robin(self):
|
||||
games = [game("a"), game("b"), game("c")]
|
||||
strategy = SmoothWeightedRotation()
|
||||
assert [strategy.next_game(games)["id"] for _ in range(6)] == [
|
||||
"a", "b", "c", "a", "b", "c"]
|
||||
|
||||
def test_favorite_wins_the_share_over_a_long_run(self):
|
||||
games = [game("a", home="FAV"), game("b")]
|
||||
strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3))
|
||||
picks = [strategy.next_game(games)["id"] for _ in range(40)]
|
||||
assert picks.count("a") == 30
|
||||
assert picks.count("b") == 10
|
||||
|
||||
def test_no_clustering_seam_across_cycle_boundaries(self):
|
||||
"""The property that motivates keeping this strategy separate from the
|
||||
precomputed one: state persists, so there is no restart every N picks
|
||||
and therefore no place where repeats bunch up."""
|
||||
games = [game("a", home="FAV"), game("b")]
|
||||
strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3))
|
||||
picks = [strategy.next_game(games)["id"] for _ in range(40)]
|
||||
assert "aaaa" not in "".join(picks)
|
||||
|
||||
def test_a_new_favorite_is_queued_first(self):
|
||||
"""A favorite's game that has just gone live starts at weight 0, gets
|
||||
its full weight on the next call, and so wins the first pick after it
|
||||
appears — without a special-cased branch."""
|
||||
games = [game("a"), game("b")]
|
||||
strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 5))
|
||||
for _ in range(3):
|
||||
strategy.next_game(games)
|
||||
games.append(game("c", home="FAV"))
|
||||
assert strategy.next_game(games)["id"] == "c"
|
||||
|
||||
def test_state_for_games_no_longer_live_is_dropped(self):
|
||||
games = [game("a"), game("b")]
|
||||
strategy = SmoothWeightedRotation()
|
||||
strategy.next_game(games)
|
||||
strategy.next_game([game("a")])
|
||||
assert set(strategy._current) == {"a"}
|
||||
|
||||
def test_reset_clears_state(self):
|
||||
games = [game("a"), game("b")]
|
||||
strategy = SmoothWeightedRotation()
|
||||
strategy.next_game(games)
|
||||
strategy.reset()
|
||||
assert strategy._current == {}
|
||||
assert strategy.next_game(games)["id"] == "a"
|
||||
|
||||
def test_schedule_previews_without_perturbing_state(self):
|
||||
games = [game("a", home="FAV"), game("b")]
|
||||
strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3))
|
||||
preview = strategy.schedule(games)
|
||||
actual = [strategy.next_game(games)["id"] for _ in range(len(preview))]
|
||||
assert preview == actual
|
||||
|
||||
def test_preview_uses_the_subclass_ordering(self):
|
||||
"""schedule() promises the order repeated next_game calls produce. Built
|
||||
from the base class, a subclass that overrides next_game gets a preview
|
||||
of the wrong algorithm."""
|
||||
class Reversed(SmoothWeightedRotation):
|
||||
def next_game(self, games):
|
||||
return super().next_game(list(reversed(games)))
|
||||
|
||||
games = [game("a"), game("b"), game("c")]
|
||||
strategy = Reversed()
|
||||
preview = strategy.schedule(games)
|
||||
actual = [strategy.next_game(games)["id"] for _ in range(len(preview))]
|
||||
assert preview == actual
|
||||
|
||||
def test_empty(self):
|
||||
assert SmoothWeightedRotation().next_game([]) is None
|
||||
assert SmoothWeightedRotation().schedule([]) == []
|
||||
|
||||
def test_games_without_ids_are_ignored(self):
|
||||
assert SmoothWeightedRotation().next_game([{"home_abbr": "X"}]) is None
|
||||
|
||||
|
||||
class TestStrategiesAgreeWithinACycle:
|
||||
"""The survey's core finding: the 'three dialects' are one algorithm. They
|
||||
must produce the same order within a cycle; they differ only at the
|
||||
boundary, which is why both shapes survive."""
|
||||
|
||||
@pytest.mark.parametrize("factor", [2, 3, 5])
|
||||
def test_first_cycle_matches(self, factor):
|
||||
games = [game("a", home="FAV"), game("b"), game("c")]
|
||||
weight_for = boost({"FAV"}, factor)
|
||||
assert (SmoothWeightedRotation(weight_for=weight_for).schedule(games)
|
||||
== WeightedCycleRotation(weight_for=weight_for).schedule(games))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Differential: core strategies vs. the plugin implementations they replace
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BOOST = 3
|
||||
|
||||
|
||||
def _is_fav(g):
|
||||
return g.get("home_abbr") == "FAV"
|
||||
|
||||
|
||||
def _weight_for(g):
|
||||
return BOOST if _is_fav(g) else 1
|
||||
|
||||
|
||||
class _PluginSwrr:
|
||||
"""afl / nrl / soccer ``_swrr_advance``, transcribed verbatim."""
|
||||
|
||||
favorite_live_boost = BOOST
|
||||
|
||||
def _is_favorite_game(self, g):
|
||||
return _is_fav(g)
|
||||
|
||||
def advance(self, games):
|
||||
if not games:
|
||||
return None
|
||||
weights = {}
|
||||
for g in games:
|
||||
gid = g.get("id")
|
||||
if gid is None:
|
||||
continue
|
||||
weights[gid] = self.favorite_live_boost if self._is_favorite_game(g) else 1
|
||||
if not weights:
|
||||
return None
|
||||
if not hasattr(self, "_swrr_weights"):
|
||||
self._swrr_weights = {}
|
||||
self._swrr_weights = {
|
||||
gid: w for gid, w in self._swrr_weights.items() if gid in weights}
|
||||
for gid, w in weights.items():
|
||||
self._swrr_weights[gid] = self._swrr_weights.get(gid, 0) + w
|
||||
total_weight = sum(weights.values())
|
||||
ids_in_order = [g.get("id") for g in games if g.get("id") in weights]
|
||||
best_gid = max(ids_in_order, key=lambda gid: self._swrr_weights[gid])
|
||||
self._swrr_weights[best_gid] -= total_weight
|
||||
return next(g for g in games if g.get("id") == best_gid)
|
||||
|
||||
|
||||
def _plugin_weighted_schedule(games):
|
||||
"""football / baseball / basketball ``_build_weighted_schedule``, verbatim."""
|
||||
if not games:
|
||||
return []
|
||||
weights = {g["id"]: (BOOST if _is_fav(g) else 1) for g in games}
|
||||
total_weight = sum(weights.values())
|
||||
if total_weight <= len(games):
|
||||
return [g["id"] for g in games]
|
||||
current_weight = {gid: 0 for gid in weights}
|
||||
schedule = []
|
||||
for _ in range(total_weight):
|
||||
for gid in weights:
|
||||
current_weight[gid] += weights[gid]
|
||||
picked = max(current_weight, key=lambda gid: current_weight[gid])
|
||||
current_weight[picked] -= total_weight
|
||||
schedule.append(picked)
|
||||
return schedule
|
||||
|
||||
|
||||
def _plugin_rotation_schedule(games):
|
||||
"""hockey ``_build_rotation_schedule``, transcribed verbatim."""
|
||||
weights = [(g["id"], BOOST if _is_fav(g) else 1) for g in games]
|
||||
total_weight = sum(w for _, w in weights)
|
||||
if not weights or total_weight <= 0:
|
||||
return [g["id"] for g in games]
|
||||
current_weights = {gid: 0 for gid, _ in weights}
|
||||
schedule = []
|
||||
for _ in range(total_weight):
|
||||
best_id, best_current = None, None
|
||||
for gid, w in weights:
|
||||
current_weights[gid] += w
|
||||
if best_current is None or current_weights[gid] > best_current:
|
||||
best_id, best_current = gid, current_weights[gid]
|
||||
current_weights[best_id] -= total_weight
|
||||
schedule.append(best_id)
|
||||
return schedule
|
||||
|
||||
|
||||
def _cases():
|
||||
"""Every live-game shape up to 4 games: each either a favorite or not.
|
||||
|
||||
Exhaustive rather than random so the gate is deterministic — a rotation
|
||||
regression must fail the same way on every run.
|
||||
"""
|
||||
import itertools
|
||||
for size in range(1, 5):
|
||||
for flags in itertools.product(("FAV", "OTH"), repeat=size):
|
||||
yield [game(f"g{i}", home=abbr) for i, abbr in enumerate(flags)]
|
||||
|
||||
|
||||
class TestMatchesThePluginImplementations:
|
||||
"""The promotion is only safe if these reproduce the plugin copies exactly.
|
||||
|
||||
B5 deletes the bundled copies on the strength of this: each core strategy is
|
||||
checked against the verbatim source it replaces, over every live-game shape
|
||||
up to four games.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("games", list(_cases()))
|
||||
def test_swrr_matches_the_incremental_plugin_picker(self, games):
|
||||
plugin = _PluginSwrr()
|
||||
core = SmoothWeightedRotation(weight_for=_weight_for)
|
||||
# 60 picks: long enough to cross many cycle boundaries, where a
|
||||
# state-handling divergence would show up.
|
||||
assert ([plugin.advance(games)["id"] for _ in range(60)]
|
||||
== [core.next_game(games)["id"] for _ in range(60)])
|
||||
|
||||
@pytest.mark.parametrize("games", list(_cases()))
|
||||
def test_weighted_matches_the_football_lineage(self, games):
|
||||
assert (_plugin_weighted_schedule(games)
|
||||
== WeightedCycleRotation(weight_for=_weight_for).schedule(games))
|
||||
|
||||
@pytest.mark.parametrize("games", list(_cases()))
|
||||
def test_weighted_matches_hockeys_loop_shape(self, games):
|
||||
assert (_plugin_rotation_schedule(games)
|
||||
== WeightedCycleRotation(weight_for=_weight_for).schedule(games))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Celebrations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeLive:
|
||||
"""Stand-in for SportsLive: just the surface the mixin touches."""
|
||||
|
||||
def __init__(self, mode_config=None, favorite_teams=None):
|
||||
self.mode_config = mode_config or {}
|
||||
self.favorite_teams = favorite_teams or []
|
||||
self.logger = MagicMock()
|
||||
self.display_manager = MagicMock()
|
||||
self.is_enabled = True
|
||||
self.current_game = None
|
||||
self.last_game_switch = 0
|
||||
self.display_calls = []
|
||||
|
||||
def _favorite_key(self, game, side):
|
||||
return game.get(f"{side}_abbr")
|
||||
|
||||
def display(self, force_clear=False):
|
||||
self.display_calls.append(force_clear)
|
||||
return True
|
||||
|
||||
|
||||
class _Celebrating(CelebrationMixin, _FakeLive):
|
||||
pass
|
||||
|
||||
|
||||
class _Coalescing(CelebrationMixin, _FakeLive):
|
||||
COALESCE_SCORING_SEQUENCE = True
|
||||
|
||||
def score_phrase(self, points, team_abbr):
|
||||
return "TOUCHDOWN!" if points >= 6 else f"{team_abbr} FIELD GOAL!"
|
||||
|
||||
|
||||
class _ById(CelebrationMixin, _FakeLive):
|
||||
"""The nrl shape: ambiguous abbreviations, so favorites match on team id."""
|
||||
|
||||
def _favorite_key(self, game, side):
|
||||
return game.get(f"{side}_id")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def celebrating():
|
||||
def _build(cls=_Celebrating, mode_config=None, favorites=None):
|
||||
return cls(mode_config=mode_config, favorite_teams=favorites)
|
||||
return _build
|
||||
|
||||
|
||||
class TestOptOutIsStructural:
|
||||
"""The property the mixin design exists to buy: a class that does not opt in
|
||||
has none of this code — not a disabled flag, not an unused attribute."""
|
||||
|
||||
def test_a_non_celebrating_class_has_no_celebration_surface(self):
|
||||
plain = _FakeLive()
|
||||
for attribute in ("active_celebration", "_score_baselines",
|
||||
"celebration_enabled", "celebration_duration",
|
||||
"_check_for_score", "_check_for_win",
|
||||
"has_active_celebration", "_draw_celebration_layout"):
|
||||
assert not hasattr(plain, attribute), (
|
||||
f"{attribute} leaked onto a class that never opted in")
|
||||
|
||||
def test_the_mixin_is_absent_from_a_non_celebrating_mro(self):
|
||||
assert CelebrationMixin not in _FakeLive.__mro__
|
||||
assert CelebrationMixin in _Celebrating.__mro__
|
||||
|
||||
def test_mixin_does_not_require_the_base_to_know_about_it(self):
|
||||
"""SportsLive must carry no celebration hooks — that would be the
|
||||
god-class shape the mixin replaces."""
|
||||
from src.base_classes.sports import SportsLive
|
||||
source = __import__("inspect").getsource(SportsLive)
|
||||
assert "celebration" not in source.lower()
|
||||
|
||||
|
||||
class TestCelebrationConfig:
|
||||
def test_defaults(self, celebrating):
|
||||
manager = celebrating()
|
||||
assert manager.celebration_enabled is True
|
||||
assert manager.celebration_duration == 8
|
||||
assert manager.celebrate_opponent_scores is False
|
||||
assert manager.active_celebration is None
|
||||
|
||||
def test_reads_the_goal_spelling_of_the_opponent_key(self, celebrating):
|
||||
"""The soccer lineage's published schema says `celebrate_opponent_goals`;
|
||||
adopting the mixin must not silently reset users' setting."""
|
||||
manager = celebrating(mode_config={"celebrate_opponent_goals": True})
|
||||
assert manager.celebrate_opponent_scores is True
|
||||
|
||||
def test_reads_the_score_spelling_of_the_opponent_key(self, celebrating):
|
||||
manager = celebrating(mode_config={"celebrate_opponent_scores": True})
|
||||
assert manager.celebrate_opponent_scores is True
|
||||
|
||||
def test_score_spelling_wins_when_both_are_present(self, celebrating):
|
||||
manager = celebrating(mode_config={"celebrate_opponent_scores": False,
|
||||
"celebrate_opponent_goals": True})
|
||||
assert manager.celebrate_opponent_scores is False
|
||||
|
||||
@pytest.mark.parametrize("bad", ["eight", None, {}, []])
|
||||
def test_unusable_duration_falls_back(self, celebrating, bad):
|
||||
"""The duration is compared numerically on the display path, outside
|
||||
any try block — a string from a hand-edited config would propagate a
|
||||
TypeError straight out of display()."""
|
||||
manager = celebrating(mode_config={"celebration_duration": bad})
|
||||
assert manager.celebration_duration == 8.0
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -5])
|
||||
def test_non_positive_duration_is_floored(self, celebrating, bad):
|
||||
"""Zero or negative would arm a celebration that can never render."""
|
||||
manager = celebrating(mode_config={"celebration_duration": bad})
|
||||
assert manager.celebration_duration == 1.0
|
||||
|
||||
def test_numeric_string_duration_is_accepted(self, celebrating):
|
||||
assert celebrating(
|
||||
mode_config={"celebration_duration": "12"}).celebration_duration == 12.0
|
||||
|
||||
|
||||
class TestScoreDetection:
|
||||
def test_first_sighting_never_celebrates(self, celebrating):
|
||||
"""A game already in progress at boot must not false-fire."""
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=3, away_score=1))
|
||||
assert manager.active_celebration is None
|
||||
assert manager._score_baselines["g1"] == {"away": 1, "home": 3}
|
||||
|
||||
def test_increment_arms_a_celebration(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=0, away_score=0))
|
||||
manager._check_for_score(game("g1", home_score=1, away_score=0))
|
||||
assert manager.active_celebration["kind"] == "score"
|
||||
assert manager.active_celebration["scored_side"] == "home"
|
||||
|
||||
def test_no_change_does_not_fire(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=2))
|
||||
manager._check_for_score(game("g1", home_score=2))
|
||||
assert manager.active_celebration is None
|
||||
|
||||
def test_decrement_rebases_silently(self, celebrating):
|
||||
"""A disallowed goal / correction must not celebrate, and must not leave
|
||||
a stale baseline that fires on the way back up."""
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=2))
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
assert manager.active_celebration is None
|
||||
assert manager._score_baselines["g1"]["home"] == 1
|
||||
|
||||
def test_disabled_never_fires(self, celebrating):
|
||||
manager = celebrating(mode_config={"celebration_enabled": False})
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
assert manager.active_celebration is None
|
||||
|
||||
def test_game_without_an_id_is_ignored(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._check_for_score({"home_score": 1, "away_score": 0})
|
||||
assert manager.active_celebration is None
|
||||
|
||||
@pytest.mark.parametrize("score", [None, "", "not-a-number-at-all"])
|
||||
def test_unusable_scores_are_ignored(self, celebrating, score):
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=score))
|
||||
assert manager._score_baselines == {}
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("7", 7), (7, 7), (7.0, 7), (" 7 ", 7), ("7 (SO)", 7),
|
||||
({"value": 7}, 7), ({"displayValue": "7"}, 7),
|
||||
])
|
||||
def test_score_coercion(self, raw, expected):
|
||||
assert CelebrationMixin._score_to_int(raw) == expected
|
||||
|
||||
def test_away_side_is_detected(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", away_score=0))
|
||||
manager._check_for_score(game("g1", away_score=1))
|
||||
assert manager.active_celebration["scored_side"] == "away"
|
||||
|
||||
|
||||
class TestWhoGetsCelebrated:
|
||||
def test_no_favorites_celebrates_everyone(self, celebrating):
|
||||
"""The user opted to show this game at all, so any score in it counts."""
|
||||
manager = celebrating(favorites=[])
|
||||
manager._check_for_score(game("g1", home="XXX", home_score=0))
|
||||
manager._check_for_score(game("g1", home="XXX", home_score=1))
|
||||
assert manager.active_celebration is not None
|
||||
|
||||
def test_favorite_scores(self, celebrating):
|
||||
manager = celebrating(favorites=["FAV"])
|
||||
manager._check_for_score(game("g1", home="FAV", home_score=0))
|
||||
manager._check_for_score(game("g1", home="FAV", home_score=1))
|
||||
assert manager.active_celebration is not None
|
||||
|
||||
def test_opponent_suppressed_by_default(self, celebrating):
|
||||
manager = celebrating(favorites=["FAV"])
|
||||
manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=0))
|
||||
manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=1))
|
||||
assert manager.active_celebration is None
|
||||
|
||||
def test_opponent_celebrated_when_opted_in(self, celebrating):
|
||||
manager = celebrating(mode_config={"celebrate_opponent_scores": True},
|
||||
favorites=["FAV"])
|
||||
manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=0))
|
||||
manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=1))
|
||||
assert manager.active_celebration is not None
|
||||
|
||||
def test_matching_goes_through_the_favorite_key_seam(self, celebrating):
|
||||
"""nrl matches on team id because its abbreviations are ambiguous
|
||||
('NEW' is both Newcastle and New Zealand). Core must not care why."""
|
||||
manager = celebrating(_ById, favorites=["g1-h"])
|
||||
manager._check_for_score(game("g1", home="NEW", home_score=0))
|
||||
manager._check_for_score(game("g1", home="NEW", home_score=1))
|
||||
assert manager.active_celebration is not None
|
||||
|
||||
def test_favorite_key_seam_also_excludes(self, celebrating):
|
||||
manager = celebrating(_ById, favorites=["someone-else"])
|
||||
manager._check_for_score(game("g1", home="NEW", home_score=0))
|
||||
manager._check_for_score(game("g1", home="NEW", home_score=1))
|
||||
assert manager.active_celebration is None
|
||||
|
||||
|
||||
class TestPhrasing:
|
||||
def test_default_phrase_is_sport_neutral(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home="HOM", home_score=0))
|
||||
manager._check_for_score(game("g1", home="HOM", home_score=1))
|
||||
assert manager.active_celebration["phrase"] == "HOM SCORES!"
|
||||
|
||||
def test_score_phrase_hook_sees_the_points_delta(self, celebrating):
|
||||
manager = celebrating(_Coalescing)
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=6))
|
||||
assert manager.active_celebration["phrase"] == "TOUCHDOWN!"
|
||||
|
||||
def test_score_phrase_hook_distinguishes_smaller_plays(self, celebrating):
|
||||
manager = celebrating(_Coalescing)
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=3))
|
||||
assert manager.active_celebration["phrase"] == "HOM FIELD GOAL!"
|
||||
|
||||
def test_win_phrase(self, celebrating):
|
||||
manager = celebrating(favorites=["HOM"])
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager._check_for_win(game("g1", home_score=2, away_score=1))
|
||||
assert manager.active_celebration["phrase"] == "HOM WINS!"
|
||||
|
||||
|
||||
class TestCoalescing:
|
||||
def test_off_by_default_two_goals_are_two_celebrations(self, celebrating):
|
||||
"""Soccer/afl/nrl: consecutive increments are distinct events, so
|
||||
suppressing the second would swallow a real goal."""
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
first = manager.active_celebration["started_at"]
|
||||
manager._check_for_score(game("g1", home_score=2))
|
||||
assert manager.active_celebration["started_at"] != first
|
||||
assert manager.active_celebration["home_score"] == 2
|
||||
|
||||
def test_on_suppresses_the_extra_point_follow_up(self, celebrating):
|
||||
"""Football: a touchdown lands as +6, then +1 seconds later. One
|
||||
takeover per scoring sequence."""
|
||||
manager = celebrating(_Coalescing)
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=6))
|
||||
armed = manager.active_celebration
|
||||
manager._check_for_score(game("g1", home_score=7))
|
||||
assert manager.active_celebration is armed
|
||||
|
||||
def test_suppression_still_advances_the_baseline(self, celebrating):
|
||||
"""Nothing may re-fire once the window closes."""
|
||||
manager = celebrating(_Coalescing)
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=6))
|
||||
manager._check_for_score(game("g1", home_score=7))
|
||||
assert manager._score_baselines["g1"]["home"] == 7
|
||||
|
||||
|
||||
class TestWinDetection:
|
||||
def test_win_requires_a_baseline(self, celebrating):
|
||||
"""A game seen for the first time already-final (board started after
|
||||
full time) must not fire."""
|
||||
manager = celebrating(favorites=["HOM"])
|
||||
manager._check_for_win(game("g1", home_score=3, away_score=1))
|
||||
assert manager.active_celebration is None
|
||||
|
||||
def test_win_fires_once_only(self, celebrating):
|
||||
manager = celebrating(favorites=["HOM"])
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager._check_for_win(game("g1", home_score=3, away_score=1))
|
||||
manager.active_celebration = None
|
||||
manager._check_for_win(game("g1", home_score=3, away_score=1))
|
||||
assert manager.active_celebration is None
|
||||
|
||||
def test_draw_does_not_celebrate(self, celebrating):
|
||||
manager = celebrating(favorites=["HOM"])
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager._check_for_win(game("g1", home_score=2, away_score=2))
|
||||
assert manager.active_celebration is None
|
||||
|
||||
def test_win_is_gated_strictly_on_favorites(self, celebrating):
|
||||
"""Unlike scores, a win with no favorites configured does NOT celebrate:
|
||||
every game ends, so the fallback would be constant noise."""
|
||||
manager = celebrating(favorites=[])
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager._check_for_win(game("g1", home_score=3, away_score=1))
|
||||
assert manager.active_celebration is None
|
||||
|
||||
def test_losing_favorite_does_not_celebrate(self, celebrating):
|
||||
manager = celebrating(favorites=["HOM"])
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager._check_for_win(game("g1", home_score=1, away_score=4))
|
||||
assert manager.active_celebration is None
|
||||
|
||||
def test_away_favorite_wins(self, celebrating):
|
||||
manager = celebrating(favorites=["AWY"])
|
||||
manager._check_for_score(game("g1", away_score=1))
|
||||
manager._check_for_win(game("g1", home_score=1, away_score=4))
|
||||
assert manager.active_celebration["scored_side"] == "away"
|
||||
|
||||
|
||||
class TestCelebrationSnapshot:
|
||||
def test_the_game_is_snapshotted_not_referenced(self, celebrating):
|
||||
"""A win must survive the game leaving live_games."""
|
||||
manager = celebrating()
|
||||
live = game("g1", home_score=0)
|
||||
manager._check_for_score(live)
|
||||
live = game("g1", home_score=1)
|
||||
manager._check_for_score(live)
|
||||
live["home_abbr"] = "MUTATED"
|
||||
assert manager.active_celebration["game"]["home_abbr"] == "HOM"
|
||||
|
||||
def test_focus_is_pinned_to_the_involved_game(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
assert manager.current_game["id"] == "g1"
|
||||
|
||||
|
||||
class TestDisplayTakeover:
|
||||
def test_no_celebration_defers_to_the_scorebug(self, celebrating):
|
||||
manager = celebrating()
|
||||
assert manager.display(force_clear=True) is True
|
||||
assert manager.display_calls == [True]
|
||||
|
||||
def test_active_celebration_takes_over(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager._draw_celebration_layout = MagicMock()
|
||||
assert manager.display() is True
|
||||
assert manager.display_calls == [], "the scorebug must not also render"
|
||||
manager._draw_celebration_layout.assert_called_once()
|
||||
|
||||
def test_expired_celebration_clears_and_defers(self, celebrating):
|
||||
# A short-but-valid duration: celebration_duration is clamped to a 1.0s
|
||||
# floor, so a config of 0 does NOT expire on the next frame. Backdate
|
||||
# started_at past the window to exercise the real expiry branch —
|
||||
# otherwise the celebration is still active and this only passes
|
||||
# because _draw_celebration_layout happens to raise in the harness
|
||||
# (that path is covered by test_a_render_failure_falls_through).
|
||||
manager = celebrating(mode_config={"celebration_duration": 1})
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window
|
||||
# Mock the layout so a render can't raise: otherwise the exception
|
||||
# branch clears the celebration too, and this test would pass whether
|
||||
# or not expiry actually fired. An expired celebration must NOT render.
|
||||
manager._draw_celebration_layout = MagicMock()
|
||||
assert manager.display() is True
|
||||
manager._draw_celebration_layout.assert_not_called()
|
||||
assert manager.active_celebration is None
|
||||
assert manager.display_calls == [False]
|
||||
|
||||
def test_expiry_resets_the_dwell_clock(self, celebrating):
|
||||
"""So the scorebug resumes on the scoring game for a full duration
|
||||
before rotation can move on."""
|
||||
manager = celebrating(mode_config={"celebration_duration": 1})
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window
|
||||
manager._draw_celebration_layout = MagicMock() # expiry, not a render failure
|
||||
manager.display()
|
||||
manager._draw_celebration_layout.assert_not_called()
|
||||
assert manager.last_game_switch > 0
|
||||
|
||||
def test_a_render_failure_falls_through_to_the_scorebug(self, celebrating):
|
||||
"""A broken celebration must never blank the display."""
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager._draw_celebration_layout = MagicMock(side_effect=RuntimeError("boom"))
|
||||
assert manager.display() is True
|
||||
assert manager.display_calls == [False]
|
||||
|
||||
def test_a_render_failure_disarms_rather_than_retrying(self, celebrating):
|
||||
"""Left armed, the same render fails on every frame for the rest of the
|
||||
window — a traceback per frame, and no scorebug."""
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=0))
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager._draw_celebration_layout = MagicMock(side_effect=RuntimeError("boom"))
|
||||
manager.display()
|
||||
assert manager.active_celebration is None
|
||||
manager.display()
|
||||
assert manager._draw_celebration_layout.call_count == 1
|
||||
|
||||
|
||||
class TestBaselinePruning:
|
||||
"""`_score_baselines` gains an entry per game and only _check_for_win ever
|
||||
removed one, so a board running all season grows the dict without bound."""
|
||||
|
||||
def test_prunes_games_no_longer_live(self, celebrating):
|
||||
manager = celebrating()
|
||||
for gid in ("g1", "g2", "g3"):
|
||||
manager._check_for_score(game(gid, home_score=1))
|
||||
manager.prune_score_baselines([game("g2")])
|
||||
assert set(manager._score_baselines) == {"g2"}
|
||||
|
||||
def test_keeps_every_still_live_game(self, celebrating):
|
||||
manager = celebrating()
|
||||
for gid in ("g1", "g2"):
|
||||
manager._check_for_score(game(gid, home_score=1))
|
||||
manager.prune_score_baselines([game("g1"), game("g2")])
|
||||
assert set(manager._score_baselines) == {"g1", "g2"}
|
||||
|
||||
def test_empty_live_set_clears_everything(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=1))
|
||||
manager.prune_score_baselines([])
|
||||
assert manager._score_baselines == {}
|
||||
|
||||
def test_pruning_does_not_disturb_a_surviving_baseline(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._check_for_score(game("g1", home_score=2))
|
||||
manager.prune_score_baselines([game("g1")])
|
||||
manager._check_for_score(game("g1", home_score=3))
|
||||
assert manager.active_celebration is not None, (
|
||||
"pruning must not drop a live game's baseline and re-trigger the "
|
||||
"first-sighting suppression")
|
||||
|
||||
def test_disabled_manager_renders_nothing(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager.is_enabled = False
|
||||
assert manager.display() is False
|
||||
|
||||
|
||||
class TestFitFont:
|
||||
def test_returns_the_first_font_that_fits(self, celebrating):
|
||||
manager = celebrating()
|
||||
draw = MagicMock()
|
||||
draw.textlength.side_effect = [100, 20]
|
||||
big, small = MagicMock(), MagicMock()
|
||||
assert manager._fit_font(draw, "GOAL", 50, [big, small]) is small
|
||||
|
||||
def test_falls_back_to_the_smallest(self, celebrating):
|
||||
manager = celebrating()
|
||||
draw = MagicMock()
|
||||
draw.textlength.return_value = 999
|
||||
big, small = MagicMock(), MagicMock()
|
||||
assert manager._fit_font(draw, "GOAL", 50, [big, small]) is small
|
||||
|
||||
|
||||
class TestCapabilityExports:
|
||||
@pytest.mark.parametrize("name", [
|
||||
"CelebrationMixin", "RotationStrategy", "SimpleRotation",
|
||||
"SmoothWeightedRotation", "WeightedCycleRotation",
|
||||
"get_rotation_strategy", "register_rotation_strategy",
|
||||
])
|
||||
def test_public_name_is_importable(self, name):
|
||||
"""Plugins import these behind a guarded fallback; the names are the
|
||||
contract."""
|
||||
import src.base_classes.sports.capabilities as capabilities
|
||||
assert hasattr(capabilities, name)
|
||||
|
||||
def test_rotation_strategy_base_requires_a_schedule(self):
|
||||
with pytest.raises(NotImplementedError):
|
||||
RotationStrategy().schedule([game("a")])
|
||||
@@ -0,0 +1,613 @@
|
||||
"""Tests for the methods promoted onto SportsCore from the nine bundled
|
||||
plugin copies of ``sports.py`` (phase B1 of docs/SPORTS_UNIFICATION.md).
|
||||
|
||||
Three methods and their seams land here:
|
||||
|
||||
- ``cleanup()`` — byte-identical in all nine copies. The tests pin the
|
||||
ordering (session close, then caches, then the completion log) and the
|
||||
deliberate *omission*: the process-wide background service must never be
|
||||
shut down by one unloading plugin.
|
||||
- ``_get_layout_offset()`` — football's resolver-backed variant, with the
|
||||
classic inline config read as the fallback used by every plugin that
|
||||
doesn't hand core a ``_config_schema_path()``.
|
||||
- ``_load_custom_font_from_element_config()`` — baseball's body (the only
|
||||
copy that handles BDF strikes correctly) under hockey's wider signature,
|
||||
resolving font files through the ``_font_root()`` seam instead of the
|
||||
process cwd.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PIL import Image, ImageFont
|
||||
|
||||
# src.base_classes.sports transitively imports the hardware matrix driver;
|
||||
# stub it so these tests can import the sports base classes off-device.
|
||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
||||
|
||||
from src.base_classes.sports import SportsCore
|
||||
|
||||
LOGGER = logging.getLogger("test_sports_core_promotions")
|
||||
|
||||
CORE_ROOT = Path(__file__).resolve().parents[1]
|
||||
FONTS_DIR = CORE_ROOT / "assets" / "fonts"
|
||||
TTF_NAME = "PressStart2P-Regular.ttf"
|
||||
BDF_NAME = "5x7.bdf" # a BDF whose only valid strike is 7px
|
||||
BDF_NATIVE_SIZE = 7
|
||||
|
||||
|
||||
class _StubSports(SportsCore):
|
||||
"""Minimal concrete SportsCore — the abstract methods are never called
|
||||
by anything under test here."""
|
||||
|
||||
def _fetch_data(self):
|
||||
return None
|
||||
|
||||
def _extract_game_details(self, game_event):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def build(monkeypatch, tmp_path):
|
||||
"""Factory for real SportsCore instances: logo dir redirected to tmp and
|
||||
the process-wide background service replaced with a MagicMock so the
|
||||
tests can assert nothing ever calls it."""
|
||||
monkeypatch.setattr(
|
||||
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"src.base_classes.sports.core.get_background_service",
|
||||
lambda *args, **kwargs: MagicMock())
|
||||
|
||||
def _build(config=None, cls=_StubSports):
|
||||
display_manager = MagicMock()
|
||||
display_manager.matrix.width = 128
|
||||
display_manager.matrix.height = 32
|
||||
display_manager.width = 128
|
||||
display_manager.height = 32
|
||||
display_manager.image = Image.new("RGB", (128, 32))
|
||||
cache_manager = MagicMock()
|
||||
cache_manager.cache_dir = str(tmp_path)
|
||||
return cls(config if config is not None else {"timezone": "UTC"},
|
||||
display_manager, cache_manager, LOGGER, "nhl")
|
||||
|
||||
return _build
|
||||
|
||||
|
||||
def probe(config=None):
|
||||
"""Unbound-call stand-in for hosts we don't need a full instance for
|
||||
(same pattern as make_probe in test_sports_base_characterization)."""
|
||||
host = MagicMock()
|
||||
host.logger = LOGGER
|
||||
host.config = config if config is not None else {}
|
||||
host._font_cache = {}
|
||||
host._bdf_native_size_cache = {}
|
||||
host._config_schema_path.return_value = None
|
||||
host._font_root.side_effect = lambda: SportsCore._font_root(host)
|
||||
host._resolve_font_path.side_effect = (
|
||||
lambda name: SportsCore._resolve_font_path(host, name))
|
||||
return host
|
||||
|
||||
|
||||
def offset(host, element, axis, default=0):
|
||||
return SportsCore._get_layout_offset(host, element, axis, default)
|
||||
|
||||
|
||||
def load_font(host, *args, **kwargs):
|
||||
return SportsCore._load_custom_font_from_element_config(host, *args, **kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. cleanup()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCleanup:
|
||||
def test_closes_session_and_clears_all_caches(self, build):
|
||||
manager = build()
|
||||
session = MagicMock()
|
||||
manager.session = session
|
||||
manager._logo_cache["TB"] = object()
|
||||
manager._font_cache[("PressStart2P-Regular.ttf", 8)] = object()
|
||||
manager._bdf_native_size_cache["assets/fonts/5x7.bdf"] = 7
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
session.close.assert_called_once_with()
|
||||
assert manager._logo_cache == {}
|
||||
# Promoted alongside the font loader: these hold PIL faces and are
|
||||
# an unbounded leak across enable/disable cycles if never released.
|
||||
assert manager._font_cache == {}
|
||||
assert manager._bdf_native_size_cache == {}
|
||||
|
||||
def test_second_cleanup_is_a_noop(self, build):
|
||||
manager = build()
|
||||
manager.session = MagicMock()
|
||||
manager._logo_cache["TB"] = object()
|
||||
manager._font_cache[("x", 8)] = object()
|
||||
|
||||
manager.cleanup()
|
||||
manager.cleanup() # must not raise on already-released state
|
||||
|
||||
assert manager._logo_cache == {}
|
||||
assert manager._font_cache == {}
|
||||
assert manager._bdf_native_size_cache == {}
|
||||
assert manager.session.close.call_count == 2
|
||||
|
||||
def test_does_not_shut_down_the_shared_background_service(self, build):
|
||||
# get_background_service() hands out a PROCESS-WIDE singleton shared
|
||||
# by every scoreboard. One plugin unloading must not stop background
|
||||
# fetching for the other eight — cleanup() touches it not at all.
|
||||
manager = build()
|
||||
service = manager.background_service
|
||||
manager.session = MagicMock()
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
assert service.shutdown.called is False
|
||||
assert service.stop.called is False
|
||||
assert service.method_calls == [], (
|
||||
"cleanup() called into the shared background service: "
|
||||
f"{service.method_calls}")
|
||||
|
||||
def test_completion_is_logged_even_when_session_close_raises(self, build, caplog):
|
||||
manager = build()
|
||||
manager.session = MagicMock()
|
||||
manager.session.close.side_effect = RuntimeError("socket already gone")
|
||||
manager._logo_cache["TB"] = object()
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger=LOGGER.name):
|
||||
manager.cleanup()
|
||||
|
||||
messages = [r.message for r in caplog.records]
|
||||
assert any("Error closing session" in m for m in messages)
|
||||
# Ordering is load-bearing: the caches still get cleared and the
|
||||
# completion log still fires after a failed close.
|
||||
assert manager._logo_cache == {}
|
||||
assert any("cleanup completed" in m for m in messages)
|
||||
|
||||
def test_tolerates_missing_attributes(self):
|
||||
# The hasattr guards exist so a partially constructed instance (an
|
||||
# __init__ that raised) can still be cleaned up.
|
||||
host = MagicMock(spec=["logger"])
|
||||
host.logger = LOGGER
|
||||
SportsCore.cleanup(host)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. _get_layout_offset() + the _config_schema_path() seam
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def layout_config(element, axis, value):
|
||||
return {"customization": {"layout": {element: {axis: value}}}}
|
||||
|
||||
|
||||
class TestLayoutOffsetClassicPath:
|
||||
"""The default path: _config_schema_path() returns None, so offsets come
|
||||
from the inline customization.layout read every plugin ships today."""
|
||||
|
||||
def test_config_schema_path_defaults_to_none(self, build):
|
||||
manager = build()
|
||||
assert manager._config_schema_path() is None
|
||||
|
||||
def test_reads_configured_int(self):
|
||||
host = probe(layout_config("home_logo", "x_offset", 5))
|
||||
assert offset(host, "home_logo", "x_offset") == 5
|
||||
|
||||
def test_float_is_truncated_to_int(self):
|
||||
host = probe(layout_config("score", "y_offset", 2.9))
|
||||
result = offset(host, "score", "y_offset")
|
||||
assert result == 2 and isinstance(result, int)
|
||||
|
||||
def test_numeric_string_is_coerced(self):
|
||||
host = probe(layout_config("score", "x_offset", "-3.5"))
|
||||
assert offset(host, "score", "x_offset") == -3
|
||||
|
||||
def test_unconfigured_element_and_axis_use_default(self):
|
||||
host = probe(layout_config("score", "x_offset", 5))
|
||||
assert offset(host, "status_text", "x_offset", 7) == 7
|
||||
assert offset(host, "score", "y_offset", -1) == -1
|
||||
assert offset(probe(), "score", "x_offset", 4) == 4
|
||||
|
||||
def test_non_numeric_string_degrades_to_default(self):
|
||||
host = probe(layout_config("score", "x_offset", "left"))
|
||||
assert offset(host, "score", "x_offset", 3) == 3
|
||||
|
||||
def test_unsupported_type_degrades_to_default(self):
|
||||
host = probe(layout_config("score", "x_offset", {"nested": 1}))
|
||||
assert offset(host, "score", "x_offset", 2) == 2
|
||||
host = probe(layout_config("score", "x_offset", None))
|
||||
assert offset(host, "score", "x_offset", 2) == 2
|
||||
|
||||
def test_broken_config_object_degrades_to_default(self):
|
||||
host = probe()
|
||||
host.config = "not a dict"
|
||||
assert offset(host, "score", "x_offset", 6) == 6
|
||||
|
||||
def test_boolean_counts_as_one(self):
|
||||
# PINNED AS-IS: the classic read predates the resolver and treats a
|
||||
# bool as its int value (True -> 1). See the resolver test below for
|
||||
# the stricter, more correct handling.
|
||||
host = probe(layout_config("score", "x_offset", True))
|
||||
assert offset(host, "score", "x_offset", 4) == 1
|
||||
|
||||
|
||||
class TestLayoutOffsetResolverPath:
|
||||
"""When a plugin supplies its config_schema.json, offsets resolve through
|
||||
src.element_style instead."""
|
||||
|
||||
@pytest.fixture
|
||||
def schema_path(self, tmp_path):
|
||||
path = tmp_path / "config_schema.json"
|
||||
path.write_text(json.dumps({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"layout": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
return str(path)
|
||||
|
||||
def host(self, schema_path, config):
|
||||
host = probe(config)
|
||||
host._config_schema_path.return_value = schema_path
|
||||
del host._style_resolver_cached # MagicMock auto-attrs otherwise
|
||||
host._style_resolver_cached = None
|
||||
return host
|
||||
|
||||
def test_reads_configured_offsets(self, schema_path):
|
||||
host = self.host(schema_path, layout_config("home_logo", "x_offset", 5))
|
||||
assert offset(host, "home_logo", "x_offset") == 5
|
||||
|
||||
def test_numeric_string_is_coerced(self, schema_path):
|
||||
host = self.host(schema_path, layout_config("score", "x_offset", "-3.5"))
|
||||
assert offset(host, "score", "x_offset") == -3
|
||||
|
||||
def test_missing_value_uses_default(self, schema_path):
|
||||
host = self.host(schema_path, layout_config("score", "x_offset", 5))
|
||||
assert offset(host, "score", "y_offset", 9) == 9
|
||||
|
||||
def test_bad_input_degrades_to_default(self, schema_path):
|
||||
host = self.host(schema_path, layout_config("score", "x_offset", "left"))
|
||||
assert offset(host, "score", "x_offset", 3) == 3
|
||||
host = self.host(schema_path, {"customization": {"layout": "nope"}})
|
||||
assert offset(host, "score", "x_offset", 3) == 3
|
||||
|
||||
def test_boolean_is_rejected_unlike_the_classic_path(self, schema_path):
|
||||
# The intended behavior difference: a bool is not a pixel offset, so
|
||||
# the resolver returns the default where the classic read returns 1.
|
||||
host = self.host(schema_path, layout_config("score", "x_offset", True))
|
||||
assert offset(host, "score", "x_offset", 4) == 4
|
||||
|
||||
def test_resolver_is_cached_and_rebuilt_when_config_is_swapped(self, schema_path):
|
||||
host = self.host(schema_path, layout_config("score", "x_offset", 5))
|
||||
assert offset(host, "score", "x_offset") == 5
|
||||
first = host._style_resolver_cached
|
||||
assert offset(host, "score", "x_offset") == 5
|
||||
assert host._style_resolver_cached is first
|
||||
|
||||
# on_config_change swaps the dict object; the resolver must follow.
|
||||
host.config = layout_config("score", "x_offset", 11)
|
||||
assert offset(host, "score", "x_offset") == 11
|
||||
assert host._style_resolver_cached is not first
|
||||
|
||||
def test_missing_schema_file_still_resolves_offsets(self, tmp_path):
|
||||
# Offsets don't depend on schema defaults, so an unreadable schema
|
||||
# must not cost the plugin its layout customization.
|
||||
host = self.host(str(tmp_path / "absent.json"),
|
||||
layout_config("score", "x_offset", 5))
|
||||
assert offset(host, "score", "x_offset") == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. _load_custom_font_from_element_config() + the _font_root() seam
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFontRootSeam:
|
||||
def test_default_font_root_is_the_core_install_root(self, build):
|
||||
manager = build()
|
||||
assert Path(manager._font_root()) == CORE_ROOT
|
||||
assert (Path(manager._font_root()) / "assets" / "fonts").is_dir()
|
||||
|
||||
def test_resolve_font_path_honors_an_overridden_root(self, tmp_path):
|
||||
fonts = tmp_path / "assets" / "fonts"
|
||||
fonts.mkdir(parents=True)
|
||||
(fonts / "Bundled.ttf").write_bytes(b"not really a font")
|
||||
host = probe()
|
||||
host._font_root.side_effect = lambda: str(tmp_path)
|
||||
assert SportsCore._resolve_font_path(host, "Bundled.ttf") == str(
|
||||
fonts / "Bundled.ttf")
|
||||
|
||||
def test_unknown_font_returns_the_familiar_relative_path(self):
|
||||
host = probe()
|
||||
assert SportsCore._resolve_font_path(host, "Nope.ttf") == os.path.join(
|
||||
"assets", "fonts", "Nope.ttf")
|
||||
|
||||
|
||||
class TestFontLoaderSignature:
|
||||
"""Hockey's signature is the only safe superset: basketball's positional
|
||||
``default_font: str`` blows up on an explicit None."""
|
||||
|
||||
def test_two_arg_call(self):
|
||||
font = load_font(probe(), {"font": TTF_NAME, "font_size": 10})
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert font.size == 10
|
||||
|
||||
def test_default_size_is_used_when_config_omits_it(self):
|
||||
assert load_font(probe(), {}, 12).size == 12
|
||||
|
||||
def test_three_positional_args(self):
|
||||
font = load_font(probe(), {}, 6, "4x6-font.ttf")
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert font.size == 6
|
||||
assert font.path.endswith("4x6-font.ttf")
|
||||
|
||||
def test_explicit_default_font_none(self):
|
||||
# The regression this signature guards: os.path.join(..., None).
|
||||
font = load_font(probe(), {"font_size": 9}, default_font=None)
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert font.path.endswith(TTF_NAME)
|
||||
|
||||
def test_config_font_wins_over_default_font(self):
|
||||
font = load_font(probe(), {"font": TTF_NAME}, 8, "4x6-font.ttf")
|
||||
assert font.path.endswith(TTF_NAME)
|
||||
|
||||
def test_string_font_size_is_coerced(self):
|
||||
assert load_font(probe(), {"font": TTF_NAME, "font_size": "11"}).size == 11
|
||||
|
||||
|
||||
class TestFontLoaderBehavior:
|
||||
def test_family_alias_resolves_through_the_font_manager_catalog(self):
|
||||
# "press_start" is a FontManager catalog family, not a filename; the
|
||||
# promoted loader must not carry its own duplicate alias table.
|
||||
host = probe()
|
||||
font = load_font(host, {"font": "press_start", "font_size": 8})
|
||||
assert font.path.endswith(TTF_NAME)
|
||||
assert ("PressStart2P-Regular.ttf", 8) in host._font_cache
|
||||
|
||||
def test_memo_cache_returns_the_same_face(self):
|
||||
host = probe()
|
||||
first = load_font(host, {"font": TTF_NAME, "font_size": 8})
|
||||
second = load_font(host, {"font": TTF_NAME, "font_size": 8})
|
||||
assert first is second
|
||||
assert len(host._font_cache) == 1
|
||||
# A different size is a different face.
|
||||
assert load_font(host, {"font": TTF_NAME, "font_size": 9}) is not first
|
||||
assert len(host._font_cache) == 2
|
||||
|
||||
def test_bdf_loads_at_its_native_strike_when_the_request_misses(self):
|
||||
# BDF is a fixed-size bitmap format: FreeType raises "invalid pixel
|
||||
# size" for anything but the file's own strike. Baseball's retry is
|
||||
# the only copy that gets this right.
|
||||
host = probe()
|
||||
font = load_font(host, {"font": BDF_NAME, "font_size": 8})
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert font.size == BDF_NATIVE_SIZE
|
||||
assert font.path.endswith(BDF_NAME)
|
||||
assert set(host._bdf_native_size_cache.values()) == {BDF_NATIVE_SIZE}
|
||||
# The retried face is memoized under the REQUESTED size.
|
||||
assert host._font_cache[(BDF_NAME, 8)] is font
|
||||
|
||||
def test_bdf_at_its_native_size_needs_no_retry(self):
|
||||
host = probe()
|
||||
font = load_font(host, {"font": BDF_NAME, "font_size": BDF_NATIVE_SIZE})
|
||||
assert font.size == BDF_NATIVE_SIZE
|
||||
assert host._bdf_native_size_cache == {}
|
||||
|
||||
def test_bdf_strike_lookup_is_memoized(self, monkeypatch):
|
||||
calls = []
|
||||
real = SportsCore.__module__
|
||||
|
||||
def counting(path):
|
||||
calls.append(path)
|
||||
from src.font_manager import FontManager
|
||||
return FontManager._read_bdf_native_size(path)
|
||||
|
||||
monkeypatch.setattr(f"{real}._read_bdf_native_size", counting)
|
||||
host = probe()
|
||||
load_font(host, {"font": BDF_NAME, "font_size": 8})
|
||||
host._font_cache.clear() # force the load path again
|
||||
load_font(host, {"font": BDF_NAME, "font_size": 8})
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_missing_font_falls_back_and_caches_the_fallback(self, caplog):
|
||||
host = probe()
|
||||
with caplog.at_level(logging.WARNING, logger=LOGGER.name):
|
||||
font = load_font(host, {"font": "DoesNotExist.ttf", "font_size": 8})
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert font.path.endswith(TTF_NAME)
|
||||
assert any("Font file not found" in r.message for r in caplog.records)
|
||||
# Cached under the requested name so a misconfiguration costs one
|
||||
# disk probe, not one per frame.
|
||||
assert host._font_cache[("DoesNotExist.ttf", 8)] is font
|
||||
|
||||
def test_unknown_extension_falls_back(self):
|
||||
host = probe()
|
||||
font = load_font(host, {"font": "AUTHORS", "font_size": 8})
|
||||
assert font.path.endswith(TTF_NAME)
|
||||
|
||||
def test_fallback_honors_the_supplied_default_font(self):
|
||||
host = probe()
|
||||
font = load_font(host, {"font": "DoesNotExist.ttf"}, 6, "4x6-font.ttf")
|
||||
assert font.path.endswith("4x6-font.ttf")
|
||||
|
||||
|
||||
class TestFontLoaderCwdIndependence:
|
||||
"""The bug the _font_root() seam exists to prevent: every plugin copy
|
||||
joins 'assets/fonts' onto the process cwd, so a process started anywhere
|
||||
else silently degrades to PIL's default bitmap face (the same defect
|
||||
already fixed in FontManager — see CHANGELOG Unreleased/Fixed)."""
|
||||
|
||||
@pytest.mark.parametrize("font_name,expected_size",
|
||||
[(TTF_NAME, 8), (BDF_NAME, BDF_NATIVE_SIZE)])
|
||||
def test_fonts_load_from_an_unrelated_cwd(self, monkeypatch, font_name,
|
||||
expected_size):
|
||||
monkeypatch.chdir("/")
|
||||
host = probe()
|
||||
font = load_font(host, {"font": font_name, "font_size": 8})
|
||||
assert isinstance(font, ImageFont.FreeTypeFont), (
|
||||
f"{font_name} degraded to PIL's default face when the process "
|
||||
"runs outside the install root")
|
||||
assert font.size == expected_size
|
||||
assert Path(font.path) == FONTS_DIR / font_name
|
||||
|
||||
def test_fallback_font_also_survives_an_unrelated_cwd(self, monkeypatch):
|
||||
monkeypatch.chdir("/")
|
||||
font = load_font(probe(), {"font": "DoesNotExist.ttf", "font_size": 8})
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert Path(font.path) == FONTS_DIR / TTF_NAME
|
||||
|
||||
@pytest.mark.parametrize("key", ["score", "time", "team", "status",
|
||||
"detail", "rank"])
|
||||
def test_load_fonts_survives_an_unrelated_cwd(self, monkeypatch, key):
|
||||
"""`_load_fonts` had the same cwd-relative literals the seam exists to
|
||||
remove; every scoreboard font silently became PIL's default bitmap face
|
||||
when the process started outside the install root."""
|
||||
monkeypatch.chdir("/")
|
||||
fonts = SportsCore._load_fonts(probe())
|
||||
assert isinstance(fonts[key], ImageFont.FreeTypeFont), (
|
||||
f"fonts['{key}'] degraded to PIL's default face outside the "
|
||||
"install root")
|
||||
|
||||
|
||||
class TestShouldLogCooldown:
|
||||
"""`_should_log` reads `self._last_warning_time` unguarded, so it must be
|
||||
initialized in __init__ — otherwise the first warning of a run raises
|
||||
AttributeError instead of logging."""
|
||||
|
||||
def test_cooldown_clock_is_initialized(self, build):
|
||||
assert build()._last_warning_time == 0
|
||||
|
||||
def test_first_call_logs_then_cools_down(self, build):
|
||||
manager = build()
|
||||
assert manager._should_log("api", cooldown=60) is True
|
||||
assert manager._should_log("api", cooldown=60) is False
|
||||
|
||||
def test_cooldown_expires(self, build):
|
||||
manager = build()
|
||||
assert manager._should_log("api", cooldown=60) is True
|
||||
manager._warning_cooldowns["api"] -= 61
|
||||
assert manager._should_log("api", cooldown=60) is True
|
||||
|
||||
def test_cooldowns_are_tracked_per_warning_type(self, build):
|
||||
"""The parameter was accepted and ignored: one shared timestamp meant an
|
||||
API warning silenced an unrelated cache warning for the next minute."""
|
||||
manager = build()
|
||||
assert manager._should_log("api", cooldown=60) is True
|
||||
assert manager._should_log("cache", cooldown=60) is True
|
||||
assert manager._should_log("api", cooldown=60) is False
|
||||
assert manager._should_log("cache", cooldown=60) is False
|
||||
|
||||
def test_one_type_expiring_does_not_free_another(self, build):
|
||||
manager = build()
|
||||
manager._should_log("api")
|
||||
manager._should_log("cache")
|
||||
manager._warning_cooldowns["api"] -= 61
|
||||
assert manager._should_log("api") is True
|
||||
assert manager._should_log("cache") is False
|
||||
|
||||
def test_legacy_single_clock_field_is_kept_in_step(self, build):
|
||||
"""Subclasses in the plugin copies read _last_warning_time directly."""
|
||||
manager = build()
|
||||
manager._should_log("api")
|
||||
assert manager._last_warning_time == manager._warning_cooldowns["api"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Seam guard rails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPromotedSeamsExist:
|
||||
@pytest.mark.parametrize("name", [
|
||||
"cleanup", "_get_layout_offset", "_load_custom_font_from_element_config",
|
||||
"_config_schema_path", "_font_root", "_resolve_font_path",
|
||||
])
|
||||
def test_method_is_callable_on_the_base_class(self, name):
|
||||
assert callable(getattr(SportsCore, name, None)), (
|
||||
f"SportsCore.{name} is part of the promoted plugin-facing seam "
|
||||
"(docs/SPORTS_UNIFICATION.md) — plugins probe for it with "
|
||||
"hasattr before delegating.")
|
||||
|
||||
def test_no_sport_names_leaked_into_core(self):
|
||||
"""core.py must never branch on which sport it is (prose and skin-id
|
||||
examples in docstrings are fine — executable code is not)."""
|
||||
tree = ast.parse((CORE_ROOT / "src" / "base_classes" / "sports"
|
||||
/ "core.py").read_text())
|
||||
docstrings = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef,
|
||||
ast.AsyncFunctionDef)):
|
||||
first = node.body[0] if node.body else None
|
||||
if (isinstance(first, ast.Expr)
|
||||
and isinstance(first.value, ast.Constant)
|
||||
and isinstance(first.value.value, str)):
|
||||
docstrings.add(id(first.value))
|
||||
|
||||
tokens = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
if id(node) not in docstrings:
|
||||
tokens.append(node.value)
|
||||
elif isinstance(node, ast.Name):
|
||||
tokens.append(node.id)
|
||||
elif isinstance(node, ast.Attribute):
|
||||
tokens.append(node.attr)
|
||||
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
|
||||
ast.ClassDef)):
|
||||
tokens.append(node.name)
|
||||
|
||||
haystack = " ".join(tokens).lower()
|
||||
for sport in ("afl", "nrl", "hockey", "baseball", "basketball",
|
||||
"football", "lacrosse", "soccer", "ufc"):
|
||||
assert sport not in haystack, (
|
||||
f"core.py code mentions '{sport}' — core must never learn "
|
||||
"sport names; add an override point instead.")
|
||||
|
||||
|
||||
class TestInstallRootResolution:
|
||||
"""Guards the depth bug the sports.py -> package move introduced.
|
||||
|
||||
The move was byte-identical in every class body, but `__file__` gained a
|
||||
directory, so `Path(__file__).resolve().parents[2]` silently changed from
|
||||
the repo root to `<root>/src`. Textual identity is not semantic identity
|
||||
when code measures its own location: these tests assert the resolved
|
||||
values, not the index.
|
||||
"""
|
||||
|
||||
def test_install_root_is_the_repo_root(self):
|
||||
from src.base_classes.sports.core import _INSTALL_ROOT
|
||||
|
||||
# The repo root is the directory that actually holds src/ and assets/.
|
||||
assert (_INSTALL_ROOT / "src").is_dir()
|
||||
assert (_INSTALL_ROOT / "src" / "base_classes" / "sports").is_dir()
|
||||
assert _INSTALL_ROOT.name != "src", (
|
||||
"_INSTALL_ROOT resolved to src/ — the parents[] depth is off by "
|
||||
"one, which is exactly the regression the package move caused.")
|
||||
|
||||
def test_resolve_project_path_roots_at_repo_not_src(self):
|
||||
from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT
|
||||
|
||||
resolved = SportsCore._resolve_project_path(None, Path("assets/fonts"))
|
||||
assert resolved == _INSTALL_ROOT / "assets" / "fonts"
|
||||
assert "src" not in resolved.relative_to(_INSTALL_ROOT).parts
|
||||
|
||||
def test_absolute_paths_pass_through_unchanged(self):
|
||||
from src.base_classes.sports.core import SportsCore
|
||||
|
||||
absolute = Path("/tmp/some/logo/dir")
|
||||
assert SportsCore._resolve_project_path(None, absolute) == absolute
|
||||
|
||||
def test_font_root_and_project_path_share_one_anchor(self):
|
||||
"""Both consumers must derive from the same constant, so a future
|
||||
move needs exactly one line changed rather than two."""
|
||||
from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT
|
||||
|
||||
assert SportsCore._font_root(None) == str(_INSTALL_ROOT)
|
||||
@@ -0,0 +1,585 @@
|
||||
"""Tests for the methods promoted onto SportsUpcoming / SportsRecent /
|
||||
SportsLive from the nine plugin copies (phase B1 of the sports unification;
|
||||
see docs/SPORTS_UNIFICATION.md).
|
||||
|
||||
Covered promotions:
|
||||
- SportsRecent: `_get_zero_clock_duration` / `_clear_zero_clock_tracking`
|
||||
(+ the `_zero_clock_timestamps` initializer).
|
||||
- SportsLive: `_is_game_really_over` / `_detect_stale_games`
|
||||
(+ `game_update_timestamps` / `stale_game_timeout`, and the
|
||||
`FINAL_PERIOD` / `CLOCK_COUNTS_DOWN` class attributes).
|
||||
- SportsUpcoming: `_select_games_for_display`.
|
||||
- SportsRecent: `_select_recent_games_for_display`.
|
||||
|
||||
The live pair is the risk centre: `_detect_stale_games` is the only caller
|
||||
that *removes* games, so `_is_game_really_over` returning a false positive
|
||||
silently drops a live game from the display. The canonical form deliberately
|
||||
declines to treat a missing clock as 0:00 — the plugin variant that did so
|
||||
dropped clockless sports (baseball) from the FINAL_PERIOD-th period onward.
|
||||
That regression is pinned by
|
||||
`test_missing_clock_at_late_period_is_not_over`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from freezegun import freeze_time
|
||||
from PIL import Image
|
||||
|
||||
# src.base_classes.sports transitively imports the hardware matrix driver;
|
||||
# stub it so these tests can import the sports base classes off-device.
|
||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
||||
|
||||
from src.base_classes.hockey import Hockey, HockeyLive
|
||||
from src.base_classes.sports import (
|
||||
SportsCore,
|
||||
SportsLive,
|
||||
SportsRecent,
|
||||
SportsUpcoming,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Harnesses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _UpcomingHarness(Hockey, SportsUpcoming):
|
||||
"""Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data.
|
||||
|
||||
`_favorite_key` is inherited from SportsCore — these harnesses
|
||||
deliberately do NOT define it, so the selection tests exercise the real
|
||||
seam rather than a local stand-in.
|
||||
"""
|
||||
|
||||
def _fetch_data(self):
|
||||
return None
|
||||
|
||||
|
||||
class _RecentHarness(Hockey, SportsRecent):
|
||||
def _fetch_data(self):
|
||||
return None
|
||||
|
||||
|
||||
class _LiveHarness(HockeyLive):
|
||||
def _fetch_data(self):
|
||||
return None
|
||||
|
||||
|
||||
class _ThreePeriodLiveHarness(_LiveHarness):
|
||||
"""Hockey-shaped: regulation ends after period 3."""
|
||||
|
||||
FINAL_PERIOD = 3
|
||||
|
||||
|
||||
class _CountUpLiveHarness(_LiveHarness):
|
||||
"""Soccer/AFL/NRL-shaped: the clock counts up, so 0:00 is kickoff."""
|
||||
|
||||
CLOCK_COUNTS_DOWN = False
|
||||
|
||||
|
||||
class _IdFavoriteUpcomingHarness(_UpcomingHarness):
|
||||
"""NRL-shaped: abbreviations are ambiguous, so favorites match on team id."""
|
||||
|
||||
def _favorite_key(self, game, side):
|
||||
team_id = game.get(f"{side}_id")
|
||||
return str(team_id) if team_id is not None else None
|
||||
|
||||
|
||||
class _IdFavoriteRecentHarness(_RecentHarness):
|
||||
def _favorite_key(self, game, side):
|
||||
team_id = game.get(f"{side}_id")
|
||||
return str(team_id) if team_id is not None else None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def build_manager(monkeypatch, tmp_path):
|
||||
"""Factory for concrete sports managers: mocked display/cache managers,
|
||||
logo dir redirected to tmp, background service stubbed, and the requests
|
||||
session rigged to prove nothing hits the network."""
|
||||
monkeypatch.setattr(
|
||||
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"src.base_classes.sports.core.get_background_service",
|
||||
lambda *args, **kwargs: MagicMock())
|
||||
|
||||
def build(cls, **mode_cfg):
|
||||
config = {
|
||||
"timezone": "UTC",
|
||||
"display": {},
|
||||
"nhl_scoreboard": {"enabled": True, **mode_cfg},
|
||||
}
|
||||
display_manager = MagicMock()
|
||||
display_manager.matrix.width = 128
|
||||
display_manager.matrix.height = 32
|
||||
display_manager.width = 128
|
||||
display_manager.height = 32
|
||||
display_manager.image = Image.new("RGB", (128, 32))
|
||||
cache_manager = MagicMock()
|
||||
cache_manager.get.return_value = None
|
||||
cache_manager.cache_dir = str(tmp_path)
|
||||
manager = cls(config, display_manager, cache_manager,
|
||||
logging.getLogger("test_sports_modes_promotions"),
|
||||
"nhl")
|
||||
manager.session = MagicMock()
|
||||
manager.session.get.side_effect = requests.exceptions.ConnectionError(
|
||||
"promotion tests are offline")
|
||||
return manager
|
||||
|
||||
return build
|
||||
|
||||
|
||||
def game(game_id="1", home="BOS", away="TOR", start=None, home_id=None,
|
||||
away_id=None, **extra):
|
||||
g = {
|
||||
"id": game_id,
|
||||
"home_abbr": home,
|
||||
"away_abbr": away,
|
||||
"home_id": home_id,
|
||||
"away_id": away_id,
|
||||
"start_time_utc": start,
|
||||
}
|
||||
g.update(extra)
|
||||
return g
|
||||
|
||||
|
||||
def at(day, hour=12):
|
||||
return datetime(2026, 1, day, hour, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _ids(games):
|
||||
return [g["id"] for g in games]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 1 — zero-clock tracking (SportsRecent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestZeroClockTracking:
|
||||
def test_initializer_present_and_empty(self, build_manager):
|
||||
manager = build_manager(_RecentHarness)
|
||||
assert manager._zero_clock_timestamps == {}
|
||||
|
||||
def test_first_call_returns_zero_and_starts_tracking(self, build_manager):
|
||||
manager = build_manager(_RecentHarness)
|
||||
assert manager._get_zero_clock_duration("g1") == 0.0
|
||||
assert "g1" in manager._zero_clock_timestamps
|
||||
|
||||
def test_subsequent_call_returns_elapsed_seconds(self, build_manager):
|
||||
manager = build_manager(_RecentHarness)
|
||||
with freeze_time("2026-01-20 12:00:00") as frozen:
|
||||
assert manager._get_zero_clock_duration("g1") == 0.0
|
||||
frozen.tick(45)
|
||||
assert manager._get_zero_clock_duration("g1") == pytest.approx(45.0)
|
||||
frozen.tick(15)
|
||||
assert manager._get_zero_clock_duration("g1") == pytest.approx(60.0)
|
||||
|
||||
def test_tracking_is_per_game(self, build_manager):
|
||||
manager = build_manager(_RecentHarness)
|
||||
with freeze_time("2026-01-20 12:00:00") as frozen:
|
||||
manager._get_zero_clock_duration("g1")
|
||||
frozen.tick(30)
|
||||
assert manager._get_zero_clock_duration("g2") == 0.0
|
||||
assert manager._get_zero_clock_duration("g1") == pytest.approx(30.0)
|
||||
|
||||
def test_clear_resets_tracking(self, build_manager):
|
||||
manager = build_manager(_RecentHarness)
|
||||
with freeze_time("2026-01-20 12:00:00") as frozen:
|
||||
manager._get_zero_clock_duration("g1")
|
||||
frozen.tick(30)
|
||||
manager._clear_zero_clock_tracking("g1")
|
||||
assert "g1" not in manager._zero_clock_timestamps
|
||||
# Restarts from zero after clearing.
|
||||
assert manager._get_zero_clock_duration("g1") == 0.0
|
||||
|
||||
def test_clear_unknown_game_is_a_noop(self, build_manager):
|
||||
manager = build_manager(_RecentHarness)
|
||||
manager._clear_zero_clock_tracking("never-seen") # must not raise
|
||||
assert manager._zero_clock_timestamps == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 2a — _is_game_really_over (SportsLive)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsGameReallyOver:
|
||||
def test_missing_clock_at_late_period_is_not_over(self, build_manager):
|
||||
"""THE baseball regression: no `clock` key at all, period 7.
|
||||
|
||||
The rejected variant coerced a missing clock to the literal "0:00" and
|
||||
declared the game over — dropping every MLB game from the 5th inning
|
||||
onward, since baseball has no game clock and `period` is the inning.
|
||||
A missing clock must fail safe.
|
||||
"""
|
||||
manager = build_manager(_LiveHarness)
|
||||
g = game(period=7, period_text="Top 7th")
|
||||
assert "clock" not in g
|
||||
assert manager._is_game_really_over(g) is False
|
||||
|
||||
def test_none_clock_at_late_period_is_not_over(self, build_manager):
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager._is_game_really_over(
|
||||
game(clock=None, period=7, period_text="Top 7th")) is False
|
||||
|
||||
def test_non_string_clock_at_late_period_is_not_over(self, build_manager):
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager._is_game_really_over(
|
||||
game(clock=0, period=9, period_text="Bot 9th")) is False
|
||||
|
||||
def test_blank_clock_at_late_period_is_not_over(self, build_manager):
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager._is_game_really_over(
|
||||
game(clock=" ", period=5, period_text="5th")) is False
|
||||
|
||||
@pytest.mark.parametrize("period_text", ["Final", "final", "Final/OT",
|
||||
"FINAL", "Final - SO"])
|
||||
def test_final_period_text_is_over(self, build_manager, period_text):
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager._is_game_really_over(
|
||||
game(clock="12:00", period=2, period_text=period_text)) is True
|
||||
|
||||
def test_none_period_text_does_not_raise(self, build_manager):
|
||||
"""All nine plugin copies called `.lower()` on `game.get("period_text", "")`,
|
||||
which is None when the key is present-but-None; `_detect_stale_games`
|
||||
has no try/except around the call."""
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager._is_game_really_over(
|
||||
game(clock="12:00", period=2, period_text=None)) is False
|
||||
|
||||
def test_missing_period_text_does_not_raise(self, build_manager):
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager._is_game_really_over(game(clock="12:00", period=2)) is False
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"clock", ["0:00", ":00", "00", "000", " 0:00 ", "00:00", "0", "0000"])
|
||||
def test_expired_clock_at_final_period_is_over(self, build_manager, clock):
|
||||
"""Every spelling of a zeroed clock counts, not a hand-listed few.
|
||||
|
||||
"00:00" is the one that motivated comparing numerically: it normalizes
|
||||
to "0000", which matched none of the literals the plugin copies listed,
|
||||
so a two-digit-minute expired clock kept the game on screen forever.
|
||||
"""
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager._is_game_really_over(
|
||||
game(clock=clock, period=4, period_text="Q4")) is True
|
||||
|
||||
def test_none_period_at_expired_clock_does_not_raise(self, build_manager):
|
||||
"""`period` present-but-None: `None >= FINAL_PERIOD` is a TypeError, and
|
||||
`_detect_stale_games` has no try/except — the same failure shape as the
|
||||
`period_text` case above."""
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager._is_game_really_over(
|
||||
game(clock="0:00", period=None, period_text="Q4")) is False
|
||||
|
||||
def test_none_period_with_running_clock_does_not_raise(self, build_manager):
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager._is_game_really_over(
|
||||
game(clock="8:12", period=None, period_text="Q2")) is False
|
||||
|
||||
def test_expired_clock_after_final_period_is_over(self, build_manager):
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager._is_game_really_over(
|
||||
game(clock="0:00", period=5, period_text="OT")) is True
|
||||
|
||||
def test_expired_clock_before_final_period_is_not_over(self, build_manager):
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager._is_game_really_over(
|
||||
game(clock="0:00", period=3, period_text="Q3")) is False
|
||||
|
||||
@pytest.mark.parametrize("clock", [":40", "0:40", "1:00"])
|
||||
def test_running_clock_is_not_over(self, build_manager, clock):
|
||||
"""Sub-minute clocks like ':40' are legitimate, not expired."""
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager._is_game_really_over(
|
||||
game(clock=clock, period=4, period_text="Q4")) is False
|
||||
|
||||
def test_defaults_are_four_period_countdown(self):
|
||||
assert SportsLive.FINAL_PERIOD == 4
|
||||
assert SportsLive.CLOCK_COUNTS_DOWN is True
|
||||
|
||||
def test_final_period_override_three(self, build_manager):
|
||||
"""Hockey-shaped subclass: regulation ends after period 3."""
|
||||
manager = build_manager(_ThreePeriodLiveHarness)
|
||||
assert manager.FINAL_PERIOD == 3
|
||||
assert manager._is_game_really_over(
|
||||
game(clock="0:00", period=3, period_text="P3")) is True
|
||||
assert manager._is_game_really_over(
|
||||
game(clock="0:00", period=2, period_text="P2")) is False
|
||||
# And the unmodified default still requires period 4.
|
||||
assert build_manager(_LiveHarness)._is_game_really_over(
|
||||
game(clock="0:00", period=3, period_text="P3")) is False
|
||||
|
||||
def test_count_up_clock_never_expires(self, build_manager):
|
||||
"""Soccer/AFL/NRL: 0:00 means kickoff, so the clock branch must not run."""
|
||||
manager = build_manager(_CountUpLiveHarness)
|
||||
assert manager.CLOCK_COUNTS_DOWN is False
|
||||
for period in (1, 2, 4, 9):
|
||||
assert manager._is_game_really_over(
|
||||
game(clock="0:00", period=period, period_text="1st Half")) is False
|
||||
|
||||
def test_count_up_clock_still_honors_final_text(self, build_manager):
|
||||
manager = build_manager(_CountUpLiveHarness)
|
||||
assert manager._is_game_really_over(
|
||||
game(clock="0:00", period=2, period_text="Final")) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 2b — _detect_stale_games (SportsLive)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDetectStaleGames:
|
||||
def test_initializer_defaults(self, build_manager):
|
||||
manager = build_manager(_LiveHarness)
|
||||
assert manager.game_update_timestamps == {}
|
||||
assert manager.stale_game_timeout == 300
|
||||
|
||||
def test_stale_timeout_is_configurable(self, build_manager):
|
||||
manager = build_manager(_LiveHarness, stale_game_timeout=42)
|
||||
assert manager.stale_game_timeout == 42
|
||||
|
||||
def test_mutates_caller_list_in_place_and_returns_none(self, build_manager):
|
||||
manager = build_manager(_LiveHarness)
|
||||
fresh = game("1", period_text="P2", clock="10:00", period=2)
|
||||
over = game("2", period_text="Final", clock="0:00", period=3)
|
||||
games = [fresh, over]
|
||||
original = games
|
||||
|
||||
result = manager._detect_stale_games(games)
|
||||
|
||||
assert result is None
|
||||
assert games is original # same object, mutated in place
|
||||
assert _ids(games) == ["1"]
|
||||
|
||||
def test_evicts_only_past_timeout_games(self, build_manager):
|
||||
manager = build_manager(_LiveHarness)
|
||||
with freeze_time("2026-01-20 12:00:00"):
|
||||
now = time.time()
|
||||
manager.game_update_timestamps = {
|
||||
"1": {"last_seen": now - 10}, # fresh
|
||||
"2": {"last_seen": now - 299}, # just inside the timeout
|
||||
"3": {"last_seen": now - 301}, # past the timeout
|
||||
}
|
||||
games = [game("1", period_text="P1", clock="10:00", period=1),
|
||||
game("2", period_text="P1", clock="10:00", period=1),
|
||||
game("3", period_text="P1", clock="10:00", period=1)]
|
||||
manager._detect_stale_games(games)
|
||||
|
||||
assert _ids(games) == ["1", "2"]
|
||||
assert "3" not in manager.game_update_timestamps
|
||||
assert set(manager.game_update_timestamps) == {"1", "2"}
|
||||
|
||||
def test_unknown_last_seen_is_never_stale(self, build_manager):
|
||||
"""last_seen == 0 (or no entry) means 'never recorded', not 'ancient'."""
|
||||
manager = build_manager(_LiveHarness)
|
||||
games = [game("1", period_text="P1", clock="10:00", period=1),
|
||||
game("2", period_text="P1", clock="10:00", period=1)]
|
||||
manager.game_update_timestamps = {"1": {"last_seen": 0}}
|
||||
manager._detect_stale_games(games)
|
||||
assert _ids(games) == ["1", "2"]
|
||||
|
||||
def test_removes_games_that_are_really_over(self, build_manager):
|
||||
manager = build_manager(_LiveHarness)
|
||||
manager.game_update_timestamps = {"2": {"last_seen": 0}}
|
||||
games = [game("1", period_text="Q2", clock="5:00", period=2),
|
||||
game("2", period_text="Final", clock="0:00", period=4)]
|
||||
manager._detect_stale_games(games)
|
||||
assert _ids(games) == ["1"]
|
||||
assert "2" not in manager.game_update_timestamps
|
||||
|
||||
def test_keeps_clockless_late_game(self, build_manager):
|
||||
"""The end-to-end form of the baseball regression: a clockless game in
|
||||
the 7th must survive the removal path."""
|
||||
manager = build_manager(_LiveHarness)
|
||||
games = [game("mlb-1", period=7, period_text="Top 7th")]
|
||||
manager._detect_stale_games(games)
|
||||
assert _ids(games) == ["mlb-1"]
|
||||
|
||||
def test_games_without_id_are_skipped(self, build_manager):
|
||||
manager = build_manager(_LiveHarness)
|
||||
no_id = {"home_abbr": "BOS", "away_abbr": "TOR",
|
||||
"period_text": "Final", "clock": "0:00", "period": 4}
|
||||
games = [no_id]
|
||||
manager._detect_stale_games(games)
|
||||
# `continue` fires before the "really over" check, so it stays.
|
||||
assert games == [no_id]
|
||||
|
||||
def test_empty_list_is_tolerated(self, build_manager):
|
||||
manager = build_manager(_LiveHarness)
|
||||
games = []
|
||||
assert manager._detect_stale_games(games) is None
|
||||
assert games == []
|
||||
|
||||
def test_removal_is_by_value_not_identity(self, build_manager):
|
||||
"""Sharp edge worth pinning: `list.remove` compares with `dict.__eq__`,
|
||||
so two structurally-equal dicts drop the FIRST occurrence."""
|
||||
manager = build_manager(_LiveHarness)
|
||||
first = game("1", period_text="Final", clock="0:00", period=4)
|
||||
twin = dict(first)
|
||||
games = [first, twin]
|
||||
manager._detect_stale_games(games)
|
||||
# Both entries are removed here (two iterations, two removals), but the
|
||||
# first removal deletes `first`, not the dict being iterated.
|
||||
assert games == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 3a — _select_games_for_display (SportsUpcoming)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSelectGamesForDisplay:
|
||||
def test_no_favorites_returns_all_sorted_ascending(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness)
|
||||
games = [game("late", start=at(20)), game("early", start=at(10)),
|
||||
game("mid", start=at(15))]
|
||||
assert _ids(manager._select_games_for_display(games, [])) == [
|
||||
"early", "mid", "late"]
|
||||
|
||||
def test_missing_start_time_sorts_last(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness)
|
||||
games = [game("none", start=None), game("early", start=at(10))]
|
||||
assert _ids(manager._select_games_for_display(games, [])) == [
|
||||
"early", "none"]
|
||||
|
||||
def test_filters_to_favorite_teams(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness)
|
||||
games = [game("1", home="BOS", away="TOR", start=at(10)),
|
||||
game("2", home="NYR", away="PIT", start=at(11)),
|
||||
game("3", home="MTL", away="BOS", start=at(12))]
|
||||
assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["1", "3"]
|
||||
|
||||
def test_respects_upcoming_games_to_show_per_team(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=2)
|
||||
games = [game(str(i), home="BOS", away="TOR", start=at(10 + i))
|
||||
for i in range(5)]
|
||||
assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["0", "1"]
|
||||
|
||||
def test_game_between_two_favorites_counts_for_both(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=1)
|
||||
games = [game("shared", home="BOS", away="TOR", start=at(10)),
|
||||
game("bos2", home="BOS", away="NYR", start=at(11)),
|
||||
game("tor2", home="TOR", away="PIT", start=at(12))]
|
||||
# "shared" fills both BOS's and TOR's single slot, so nothing else fits.
|
||||
assert _ids(manager._select_games_for_display(
|
||||
games, ["BOS", "TOR"])) == ["shared"]
|
||||
|
||||
def test_deduplicates_by_game_id(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=5)
|
||||
g = game("dupe", home="BOS", away="TOR", start=at(10))
|
||||
assert _ids(manager._select_games_for_display(
|
||||
[g, dict(g)], ["BOS"])) == ["dupe"]
|
||||
|
||||
def test_non_favorite_games_excluded(self, build_manager):
|
||||
manager = build_manager(_UpcomingHarness)
|
||||
games = [game("1", home="NYR", away="PIT", start=at(10))]
|
||||
assert manager._select_games_for_display(games, ["BOS"]) == []
|
||||
|
||||
def test_favorite_key_seam_supports_id_matching(self, build_manager):
|
||||
"""The NRL case: two clubs share the abbreviation 'NEW', so favorites
|
||||
must be matched on team id. Only the seam changes — the promoted
|
||||
method is identical."""
|
||||
games = [
|
||||
game("knights", home="NEW", away="SYD", home_id=1, away_id=2,
|
||||
start=at(10)),
|
||||
game("warriors", home="NEW", away="SYD", home_id=99, away_id=2,
|
||||
start=at(11)),
|
||||
]
|
||||
abbr_manager = build_manager(_UpcomingHarness)
|
||||
# Abbreviation matching cannot tell the two "NEW" clubs apart.
|
||||
assert _ids(abbr_manager._select_games_for_display(games, ["NEW"])) == [
|
||||
"knights", "warriors"]
|
||||
|
||||
id_manager = build_manager(_IdFavoriteUpcomingHarness)
|
||||
assert _ids(id_manager._select_games_for_display(games, ["99"])) == [
|
||||
"warriors"]
|
||||
|
||||
def test_favorite_key_none_never_matches(self, build_manager):
|
||||
"""`_favorite_key` returning None (missing id) must not match, even
|
||||
against a favorites list holding the string 'None'."""
|
||||
manager = build_manager(_IdFavoriteUpcomingHarness)
|
||||
games = [game("1", home="BOS", away="TOR", start=at(10))] # no ids
|
||||
assert manager._select_games_for_display(games, ["None"]) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 3b — _select_recent_games_for_display (SportsRecent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSelectRecentGamesForDisplay:
|
||||
def test_no_favorites_returns_all_sorted_descending(self, build_manager):
|
||||
manager = build_manager(_RecentHarness)
|
||||
games = [game("early", start=at(10)), game("late", start=at(20)),
|
||||
game("mid", start=at(15))]
|
||||
assert _ids(manager._select_recent_games_for_display(games, [])) == [
|
||||
"late", "mid", "early"]
|
||||
|
||||
def test_missing_start_time_sorts_last(self, build_manager):
|
||||
manager = build_manager(_RecentHarness)
|
||||
games = [game("none", start=None), game("late", start=at(20))]
|
||||
assert _ids(manager._select_recent_games_for_display(games, [])) == [
|
||||
"late", "none"]
|
||||
|
||||
def test_respects_recent_games_to_show_per_team(self, build_manager):
|
||||
manager = build_manager(_RecentHarness, recent_games_to_show=2)
|
||||
games = [game(str(i), home="BOS", away="TOR", start=at(10 + i))
|
||||
for i in range(5)]
|
||||
# Most recent first.
|
||||
assert _ids(manager._select_recent_games_for_display(
|
||||
games, ["BOS"])) == ["4", "3"]
|
||||
|
||||
def test_game_between_two_favorites_counts_for_both(self, build_manager):
|
||||
manager = build_manager(_RecentHarness, recent_games_to_show=1)
|
||||
games = [game("shared", home="BOS", away="TOR", start=at(20)),
|
||||
game("bos2", home="BOS", away="NYR", start=at(19)),
|
||||
game("tor2", home="TOR", away="PIT", start=at(18))]
|
||||
assert _ids(manager._select_recent_games_for_display(
|
||||
games, ["BOS", "TOR"])) == ["shared"]
|
||||
|
||||
def test_deduplicates_by_game_id(self, build_manager):
|
||||
manager = build_manager(_RecentHarness, recent_games_to_show=5)
|
||||
g = game("dupe", home="BOS", away="TOR", start=at(10))
|
||||
assert _ids(manager._select_recent_games_for_display(
|
||||
[g, dict(g)], ["BOS"])) == ["dupe"]
|
||||
|
||||
def test_non_favorite_games_excluded(self, build_manager):
|
||||
manager = build_manager(_RecentHarness)
|
||||
games = [game("1", home="NYR", away="PIT", start=at(10))]
|
||||
assert manager._select_recent_games_for_display(games, ["BOS"]) == []
|
||||
|
||||
def test_favorite_key_seam_supports_id_matching(self, build_manager):
|
||||
games = [
|
||||
game("knights", home="NEW", away="SYD", home_id=1, away_id=2,
|
||||
start=at(10)),
|
||||
game("warriors", home="NEW", away="SYD", home_id=99, away_id=2,
|
||||
start=at(11)),
|
||||
]
|
||||
id_manager = build_manager(_IdFavoriteRecentHarness)
|
||||
assert _ids(id_manager._select_recent_games_for_display(
|
||||
games, ["99"])) == ["warriors"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seam / inertness guards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPromotionShape:
|
||||
def test_promoted_methods_live_on_the_right_classes(self):
|
||||
assert hasattr(SportsRecent, "_get_zero_clock_duration")
|
||||
assert hasattr(SportsRecent, "_clear_zero_clock_tracking")
|
||||
assert hasattr(SportsRecent, "_select_recent_games_for_display")
|
||||
assert hasattr(SportsUpcoming, "_select_games_for_display")
|
||||
assert hasattr(SportsLive, "_is_game_really_over")
|
||||
assert hasattr(SportsLive, "_detect_stale_games")
|
||||
|
||||
def test_modes_does_not_define_the_favorite_key_seam(self):
|
||||
"""`_favorite_key` is a SportsCore override point. modes.py must call
|
||||
it, never define it — this test fails if the seam is added in the
|
||||
wrong file."""
|
||||
import src.base_classes.sports.modes as modes
|
||||
|
||||
for cls in (SportsUpcoming, SportsRecent, SportsLive):
|
||||
assert "_favorite_key" not in vars(cls)
|
||||
assert "_favorite_key" not in modes.__dict__
|
||||
@@ -0,0 +1,585 @@
|
||||
"""Tests for src/common/sports_scroll.py (phase B3).
|
||||
|
||||
The module is drawn along the split the survey found: the orchestration layer
|
||||
is shared, the content layer is not. Two things are worth asserting beyond
|
||||
"it works":
|
||||
|
||||
* ``prepare_scroll_content`` must stay an override point — a base class that
|
||||
quietly rendered *something* would let a plugin ship a blank scroll.
|
||||
* ``target_fps`` must actually reach the helper. That is the whole reason this
|
||||
module exists upstream; the bundled plugin copies hardcode ~100 FPS.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
||||
|
||||
from src.common.sports_scroll import ( # noqa: E402
|
||||
DEFAULT_SCROLL_SETTINGS,
|
||||
MAX_PIXELS_PER_FRAME,
|
||||
MIN_PIXELS_PER_FRAME,
|
||||
SportsScrollDisplay,
|
||||
SportsScrollDisplayManager,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger("test.sports_scroll")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def display_manager():
|
||||
manager = MagicMock()
|
||||
manager.matrix.width = 128
|
||||
manager.matrix.height = 32
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_scroll_helper(monkeypatch):
|
||||
"""Replace ScrollHelper with a recording double.
|
||||
|
||||
The real helper is covered by test_scroll_helper.py; here what matters is
|
||||
*which* calls this module makes and with what values.
|
||||
"""
|
||||
created = []
|
||||
|
||||
def _factory(width, height, logger):
|
||||
helper = MagicMock()
|
||||
helper.width, helper.height = width, height
|
||||
helper.cached_image = None
|
||||
helper.is_scroll_complete.return_value = False
|
||||
helper.get_dynamic_duration.return_value = 42
|
||||
helper.get_scroll_info.return_value = {"position": 0}
|
||||
created.append(helper)
|
||||
return helper
|
||||
|
||||
monkeypatch.setattr("src.common.sports_scroll.ScrollHelper", _factory)
|
||||
return created
|
||||
|
||||
|
||||
class _Display(SportsScrollDisplay):
|
||||
"""A minimal concrete subclass, as a plugin would write it."""
|
||||
|
||||
SCROLL_LEAGUE_KEYS = ("nhl", "ncaa_mens")
|
||||
|
||||
def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None):
|
||||
self._current_games = list(games)
|
||||
self._current_game_type = game_type
|
||||
self._current_leagues = list(leagues)
|
||||
self.scroll_helper.cached_image = Image.new("RGB", (400, 32))
|
||||
return bool(games)
|
||||
|
||||
|
||||
class _Manager(SportsScrollDisplayManager):
|
||||
display_class = _Display
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def build(display_manager):
|
||||
def _build(config=None, global_config=None, cls=_Display):
|
||||
return cls(display_manager, config or {}, LOGGER, global_config=global_config)
|
||||
return _build
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConstruction:
|
||||
def test_dimensions_come_from_the_matrix(self, build):
|
||||
display = build()
|
||||
assert (display.display_width, display.display_height) == (128, 32)
|
||||
|
||||
def test_dimensions_fall_back_when_there_is_no_matrix(self, display_manager):
|
||||
display_manager.matrix = None
|
||||
display_manager.width, display_manager.height = 256, 64
|
||||
display = _Display(display_manager, {}, LOGGER)
|
||||
assert (display.display_width, display.display_height) == (256, 64)
|
||||
|
||||
def test_final_fallback_dimensions(self):
|
||||
"""A display manager exposing neither must not crash construction."""
|
||||
bare = MagicMock(spec=[])
|
||||
display = _Display(bare, {}, LOGGER)
|
||||
assert (display.display_width, display.display_height) == (128, 32)
|
||||
|
||||
def test_global_config_is_optional(self, build):
|
||||
"""An older caller that doesn't pass it keeps working."""
|
||||
assert build().global_config == {}
|
||||
|
||||
def test_state_starts_empty(self, build):
|
||||
display = build()
|
||||
assert display._current_games == []
|
||||
assert display._vegas_content_items == []
|
||||
assert display.get_current_game_count() == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings resolution — the league ladder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScrollSettings:
|
||||
def test_defaults_when_nothing_is_configured(self, build):
|
||||
settings = build()._get_scroll_settings()
|
||||
for key, value in DEFAULT_SCROLL_SETTINGS.items():
|
||||
assert settings[key] == value
|
||||
|
||||
def test_card_width_defaults_to_the_panel_width(self, build):
|
||||
assert build()._get_scroll_settings()["game_card_width"] == 128
|
||||
|
||||
def test_named_league_wins(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {"scroll_speed": 10}},
|
||||
"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}}})
|
||||
assert display._get_scroll_settings("ncaa_mens")["scroll_speed"] == 20
|
||||
|
||||
def test_ladder_is_walked_in_order(self, build):
|
||||
"""The only reason the eight plugin copies differed: which league keys
|
||||
to try, and in what order."""
|
||||
display = build({"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}},
|
||||
"nhl": {"scroll_settings": {"scroll_speed": 10}}})
|
||||
assert display._get_scroll_settings()["scroll_speed"] == 10
|
||||
|
||||
def test_ladder_falls_through_to_the_next_key(self, build):
|
||||
display = build({"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}}})
|
||||
assert display._get_scroll_settings()["scroll_speed"] == 20
|
||||
|
||||
def test_overrides_merge_onto_defaults(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {"scroll_speed": 10}}})
|
||||
settings = display._get_scroll_settings()
|
||||
assert settings["scroll_speed"] == 10
|
||||
assert settings["gap_between_games"] == DEFAULT_SCROLL_SETTINGS[
|
||||
"gap_between_games"]
|
||||
|
||||
def test_empty_override_does_not_shadow_the_next_candidate(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {}},
|
||||
"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}}})
|
||||
assert display._get_scroll_settings()["scroll_speed"] == 20
|
||||
|
||||
def test_single_block_config_shape(self, build):
|
||||
"""The afl/nrl/soccer shape: one scroll_mode block, no league concept."""
|
||||
class _Single(_Display):
|
||||
SCROLL_LEAGUE_KEYS = ()
|
||||
SCROLL_CONFIG_KEY = "scroll_mode"
|
||||
|
||||
display = build({"scroll_mode": {"scroll_speed": 33}}, cls=_Single)
|
||||
assert display._get_scroll_settings()["scroll_speed"] == 33
|
||||
|
||||
def test_defaults_are_overridable_by_a_subclass(self, build):
|
||||
class _Wide(_Display):
|
||||
def scroll_settings_defaults(self):
|
||||
return {**super().scroll_settings_defaults(),
|
||||
"gap_between_games": 24}
|
||||
|
||||
assert build(cls=_Wide)._get_scroll_settings()["gap_between_games"] == 24
|
||||
|
||||
def test_a_null_league_block_is_tolerated(self, build):
|
||||
"""`config['nhl'] = None` appears in hand-edited configs."""
|
||||
display = build({"nhl": None})
|
||||
assert (display._get_scroll_settings()["scroll_speed"]
|
||||
== DEFAULT_SCROLL_SETTINGS["scroll_speed"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper configuration — including the reason this module exists upstream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConfigureScrollHelper:
|
||||
def test_speed_is_converted_to_pixels_per_frame(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {
|
||||
"scroll_speed": 100.0, "scroll_delay": 0.02}}})
|
||||
# 100 px/s * 0.02 s/frame = 2 px/frame
|
||||
display.scroll_helper.set_scroll_speed.assert_called_with(2.0)
|
||||
|
||||
def test_conversion_is_clamped_low(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {
|
||||
"scroll_speed": 0.001, "scroll_delay": 0.001}}})
|
||||
display.scroll_helper.set_scroll_speed.assert_called_with(MIN_PIXELS_PER_FRAME)
|
||||
|
||||
def test_conversion_is_clamped_high(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {
|
||||
"scroll_speed": 5000.0, "scroll_delay": 0.5}}})
|
||||
display.scroll_helper.set_scroll_speed.assert_called_with(MAX_PIXELS_PER_FRAME)
|
||||
|
||||
def test_zero_delay_assumes_a_pacing_instead_of_dividing_by_zero(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {
|
||||
"scroll_speed": 100.0, "scroll_delay": 0}}})
|
||||
display.scroll_helper.set_scroll_speed.assert_called_with(1.0)
|
||||
|
||||
def test_frame_based_scrolling_is_enabled(self, build):
|
||||
build().scroll_helper.set_frame_based_scrolling.assert_called_once_with(True)
|
||||
|
||||
def test_dynamic_duration_settings_are_applied(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {
|
||||
"dynamic_duration": False, "min_duration": 5, "max_duration": 50}}})
|
||||
_, kwargs = display.scroll_helper.set_dynamic_duration_settings.call_args
|
||||
assert kwargs["enabled"] is False
|
||||
assert kwargs["min_duration"] == 5
|
||||
assert kwargs["max_duration"] == 50
|
||||
|
||||
def test_target_fps_reaches_the_helper(self, build):
|
||||
"""The whole point of upstreaming: the bundled copies hardcode ~100 FPS
|
||||
via scroll_delay and never consult the global target."""
|
||||
display = build(global_config={"target_fps": 120})
|
||||
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
|
||||
|
||||
def test_legacy_key_is_honored(self, build):
|
||||
display = build(global_config={"scroll_target_fps": 90})
|
||||
display.scroll_helper.set_target_fps.assert_called_once_with(90.0)
|
||||
|
||||
def test_modern_key_wins_over_legacy(self, build):
|
||||
display = build(global_config={"target_fps": 120, "scroll_target_fps": 90})
|
||||
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
|
||||
|
||||
def test_absent_target_fps_leaves_config_pacing_alone(self, build):
|
||||
build().scroll_helper.set_target_fps.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("bad", ["fast", None, {}, [], "", 0])
|
||||
def test_unusable_target_fps_degrades_instead_of_raising(self, build, bad):
|
||||
"""A malformed global config must cost the FPS target, not the display."""
|
||||
display = build(global_config={"target_fps": bad})
|
||||
display.scroll_helper.set_target_fps.assert_not_called()
|
||||
|
||||
def test_string_digits_are_accepted(self, build):
|
||||
display = build(global_config={"target_fps": "120"})
|
||||
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
|
||||
|
||||
@pytest.mark.parametrize("bad", [None, "fast", {}, []])
|
||||
def test_unusable_scroll_speed_degrades_instead_of_crashing(self, build, bad):
|
||||
"""`.get(key, default)` only helps when the key is *absent*. A key
|
||||
present with null reaches the arithmetic and raises inside __init__,
|
||||
taking the whole display down before it renders anything."""
|
||||
display = build({"nhl": {"scroll_settings": {"scroll_speed": bad}}})
|
||||
# 50.0 px/s * 0.01 s/frame == 0.5 px/frame, i.e. the default speed.
|
||||
display.scroll_helper.set_scroll_speed.assert_called_with(0.5)
|
||||
|
||||
@pytest.mark.parametrize("bad", [None, "slow", {}])
|
||||
def test_unusable_scroll_delay_degrades_instead_of_crashing(self, build, bad):
|
||||
display = build({"nhl": {"scroll_settings": {"scroll_delay": bad}}})
|
||||
display.scroll_helper.set_scroll_delay.assert_called_with(0.01)
|
||||
|
||||
def test_numeric_strings_are_accepted(self, build):
|
||||
display = build({"nhl": {"scroll_settings": {
|
||||
"scroll_speed": "100", "scroll_delay": "0.02"}}})
|
||||
display.scroll_helper.set_scroll_speed.assert_called_with(2.0)
|
||||
|
||||
def test_fps_clamping_is_left_to_the_helper(self, build):
|
||||
"""Deliberately not clamped here — a second copy of the range would
|
||||
drift from ScrollHelper.set_target_fps."""
|
||||
display = build(global_config={"target_fps": 5000})
|
||||
display.scroll_helper.set_target_fps.assert_called_once_with(5000.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The content seam
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestContentIsAnOverridePoint:
|
||||
def test_base_refuses_to_render(self, display_manager):
|
||||
"""Eight plugins have eight different bodies for this; a base class that
|
||||
rendered *something* would let a plugin ship a silently blank scroll."""
|
||||
display = SportsScrollDisplay(display_manager, {}, LOGGER)
|
||||
with pytest.raises(NotImplementedError, match="prepare_scroll_content"):
|
||||
display.prepare_scroll_content([], "live", [])
|
||||
|
||||
def test_the_error_names_the_offending_class(self, display_manager):
|
||||
class Incomplete(SportsScrollDisplay):
|
||||
pass
|
||||
|
||||
with pytest.raises(NotImplementedError, match="Incomplete"):
|
||||
Incomplete(display_manager, {}, LOGGER).prepare_scroll_content(
|
||||
[], "live", [])
|
||||
|
||||
def test_separator_icons_default_to_a_no_op(self, build):
|
||||
assert build()._separator_icons == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frame pumping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFramePumping:
|
||||
def test_no_content_means_no_frame(self, build):
|
||||
assert build().display_scroll_frame() is False
|
||||
|
||||
def test_a_frame_is_drawn_and_pushed(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display.scroll_helper.get_visible_portion.return_value = Image.new(
|
||||
"RGB", (128, 32))
|
||||
assert display.display_scroll_frame() is True
|
||||
display.scroll_helper.update_scroll_position.assert_called_once()
|
||||
display.display_manager.update_display.assert_called_once()
|
||||
|
||||
def test_no_visible_portion_means_no_frame(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display.scroll_helper.get_visible_portion.return_value = None
|
||||
assert display.display_scroll_frame() is False
|
||||
|
||||
def test_a_display_failure_is_contained(self, build):
|
||||
"""A display error must not propagate into the plugin's render loop."""
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display.scroll_helper.get_visible_portion.return_value = Image.new(
|
||||
"RGB", (128, 32))
|
||||
display.display_manager.update_display.side_effect = RuntimeError("boom")
|
||||
assert display.display_scroll_frame() is False
|
||||
|
||||
@pytest.mark.parametrize("failing", ["update_scroll_position",
|
||||
"get_visible_portion"])
|
||||
def test_a_scroll_helper_failure_is_contained_too(self, build, failing):
|
||||
"""These ran outside the try, so a raise there reached the caller's
|
||||
frame loop despite the stated promise that none can."""
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
getattr(display.scroll_helper, failing).side_effect = RuntimeError("boom")
|
||||
assert display.display_scroll_frame() is False
|
||||
|
||||
def test_frames_are_counted(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display.scroll_helper.get_visible_portion.return_value = Image.new(
|
||||
"RGB", (128, 32))
|
||||
for _ in range(3):
|
||||
display.display_scroll_frame()
|
||||
assert display._frame_count == 3
|
||||
|
||||
def test_progress_logging_is_throttled(self, build):
|
||||
display = build()
|
||||
display._log_interval = 10_000
|
||||
display._last_log_time = 0
|
||||
display._log_scroll_progress()
|
||||
first = display._last_log_time
|
||||
display._log_scroll_progress()
|
||||
assert display._last_log_time == first
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
def test_reset_keeps_content_but_rewinds(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display._frame_count = 9
|
||||
display.reset_scroll()
|
||||
display.scroll_helper.reset_scroll.assert_called_once()
|
||||
assert display._frame_count == 0
|
||||
assert display._current_games, "reset must not drop content"
|
||||
|
||||
def test_clear_drops_everything(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display._vegas_content_items = [Image.new("RGB", (8, 8))]
|
||||
display.clear()
|
||||
display.scroll_helper.clear_cache.assert_called_once()
|
||||
assert display._current_games == []
|
||||
assert display._current_game_type == ""
|
||||
assert display._vegas_content_items == []
|
||||
|
||||
def test_completion_delegates_to_the_helper(self, build):
|
||||
display = build()
|
||||
display.scroll_helper.is_scroll_complete.return_value = True
|
||||
assert display.is_scroll_complete() is True
|
||||
|
||||
def test_dynamic_duration_delegates(self, build):
|
||||
assert build().get_dynamic_duration() == 42
|
||||
|
||||
def test_has_cached_content(self, build):
|
||||
display = build()
|
||||
assert display.has_cached_content() is False
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
assert display.has_cached_content() is True
|
||||
|
||||
def test_scroll_info_merges_helper_and_local_state(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
info = display.get_scroll_info()
|
||||
assert info["position"] == 0 # from the helper
|
||||
assert info["game_count"] == 1 # from this display
|
||||
assert info["leagues"] == ["nhl"]
|
||||
|
||||
def test_leagues_are_returned_as_a_copy(self, build):
|
||||
display = build()
|
||||
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
|
||||
display.get_current_leagues().append("mutated")
|
||||
assert display.get_current_leagues() == ["nhl"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestManager:
|
||||
@pytest.fixture
|
||||
def manager(self, display_manager):
|
||||
return _Manager(display_manager, {}, LOGGER, global_config={"target_fps": 120})
|
||||
|
||||
def test_displays_are_created_lazily_and_reused(self, manager):
|
||||
first = manager.get_scroll_display("live")
|
||||
assert manager.get_scroll_display("live") is first
|
||||
assert isinstance(first, _Display)
|
||||
|
||||
def test_each_game_type_gets_its_own(self, manager):
|
||||
assert manager.get_scroll_display("live") is not manager.get_scroll_display(
|
||||
"recent")
|
||||
|
||||
def test_global_config_is_threaded_to_children(self, manager):
|
||||
"""A missed hand-off here is exactly how the plugin copies ended up
|
||||
never honoring target_fps."""
|
||||
child = manager.get_scroll_display("live")
|
||||
assert child.global_config == {"target_fps": 120}
|
||||
child.scroll_helper.set_target_fps.assert_called_once_with(120.0)
|
||||
|
||||
def test_prepare_sets_the_active_type(self, manager):
|
||||
assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is True
|
||||
assert manager._current_game_type == "live"
|
||||
|
||||
def test_failed_prepare_does_not_become_active(self, manager):
|
||||
assert manager.prepare_and_display([], "live", ["nhl"]) is False
|
||||
assert not manager._current_game_type
|
||||
|
||||
def test_a_raising_subclass_does_not_escape_the_orchestration(self, manager):
|
||||
"""prepare_scroll_content is subclass code building cards from feed
|
||||
data. One sport's bad payload must not take down the others."""
|
||||
display = manager.get_scroll_display("live")
|
||||
display.prepare_scroll_content = MagicMock(side_effect=KeyError("status"))
|
||||
assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is False
|
||||
assert not manager._current_game_type
|
||||
|
||||
def test_empty_game_type_sentinel_matches_the_display(self, manager):
|
||||
"""Both classes must spell 'nothing active' the same way; two spellings
|
||||
across two classes is a trap for anyone comparing their state."""
|
||||
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
|
||||
manager.clear_all()
|
||||
assert (manager._current_game_type
|
||||
== manager.get_scroll_display("live")._current_game_type == "")
|
||||
|
||||
def test_display_frame_uses_the_active_type(self, manager):
|
||||
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
|
||||
display = manager.get_scroll_display("live")
|
||||
display.scroll_helper.get_visible_portion.return_value = Image.new(
|
||||
"RGB", (128, 32))
|
||||
assert manager.display_frame() is True
|
||||
|
||||
def test_display_frame_with_no_active_type(self, manager):
|
||||
assert manager.display_frame() is False
|
||||
|
||||
def test_display_frame_for_an_unknown_type(self, manager):
|
||||
assert manager.display_frame("never-prepared") is False
|
||||
|
||||
def test_completion_is_true_when_there_is_nothing_to_scroll(self, manager):
|
||||
"""A caller waiting on completion must never be wedged by absence."""
|
||||
assert manager.is_complete() is True
|
||||
assert manager.is_complete("never-prepared") is True
|
||||
|
||||
def test_completion_delegates_to_the_active_display(self, manager):
|
||||
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
|
||||
manager.get_scroll_display("live").scroll_helper \
|
||||
.is_scroll_complete.return_value = True
|
||||
assert manager.is_complete() is True
|
||||
|
||||
def test_clear_all_clears_every_display(self, manager):
|
||||
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
|
||||
manager.prepare_and_display([{"id": "g2"}], "recent", ["nhl"])
|
||||
manager.clear_all()
|
||||
assert not manager._current_game_type
|
||||
for game_type in ("live", "recent"):
|
||||
assert manager.get_scroll_display(game_type)._current_games == []
|
||||
|
||||
def test_vegas_items_are_collected_across_displays(self, manager):
|
||||
for game_type in ("live", "recent"):
|
||||
manager.get_scroll_display(game_type)._vegas_content_items = [
|
||||
Image.new("RGB", (8, 8))]
|
||||
assert len(manager.get_all_vegas_content_items()) == 2
|
||||
|
||||
def test_vegas_collection_tolerates_empty_displays(self, manager):
|
||||
manager.get_scroll_display("live")
|
||||
assert manager.get_all_vegas_content_items() == []
|
||||
|
||||
def test_display_class_is_the_subclass_seam(self, display_manager):
|
||||
class Other(_Display):
|
||||
pass
|
||||
|
||||
class OtherManager(SportsScrollDisplayManager):
|
||||
display_class = Other
|
||||
|
||||
manager = OtherManager(display_manager, {}, LOGGER)
|
||||
assert isinstance(manager.get_scroll_display("live"), Other)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration — against the real ScrollHelper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _RealDisplay(SportsScrollDisplay):
|
||||
"""Builds a strip the way a plugin would, via the real helper API."""
|
||||
|
||||
SCROLL_LEAGUE_KEYS = ("nhl",)
|
||||
|
||||
def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None):
|
||||
settings = self._get_scroll_settings()
|
||||
self.scroll_helper.create_scrolling_image(
|
||||
[Image.new("RGB", (100, 32), (20, 20, 20)) for _ in games],
|
||||
item_gap=settings["gap_between_games"],
|
||||
)
|
||||
self._current_games = list(games)
|
||||
self._current_game_type = game_type
|
||||
self._current_leagues = list(leagues)
|
||||
return bool(games)
|
||||
|
||||
|
||||
class _RealManager(SportsScrollDisplayManager):
|
||||
display_class = _RealDisplay
|
||||
|
||||
|
||||
class TestAgainstTheRealScrollHelper:
|
||||
"""The mocked tests above pin *which* calls this module makes; these pin
|
||||
that those calls exist and mean what we think. Without this, a rename in
|
||||
ScrollHelper would sail past a suite built entirely on MagicMock."""
|
||||
|
||||
@pytest.fixture
|
||||
def real(self, display_manager, monkeypatch):
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
monkeypatch.setattr("src.common.sports_scroll.ScrollHelper", ScrollHelper)
|
||||
return _RealManager(
|
||||
display_manager,
|
||||
{"nhl": {"scroll_settings": {"scroll_speed": 500.0,
|
||||
"scroll_delay": 0.001}}},
|
||||
LOGGER,
|
||||
global_config={"target_fps": 120},
|
||||
)
|
||||
|
||||
def test_configuration_lands_on_the_real_helper(self, real):
|
||||
helper = real.get_scroll_display("live").scroll_helper
|
||||
assert helper.target_fps == 120.0
|
||||
assert helper.frame_based_scrolling is True
|
||||
assert helper.scroll_speed == pytest.approx(0.5) # 500 px/s * 0.001 s
|
||||
|
||||
def test_a_strip_is_built_and_scrolls_to_completion(self, real):
|
||||
import time
|
||||
|
||||
assert real.prepare_and_display(
|
||||
[{"id": "a"}, {"id": "b"}, {"id": "c"}], "live", ["nhl"]) is True
|
||||
display = real.get_scroll_display("live")
|
||||
# 3 cards of 100px + gaps, so the strip is wider than the 128px panel.
|
||||
assert display.scroll_helper.cached_image.width > 128
|
||||
|
||||
# Frame-based scrolling is wall-clock gated on scroll_delay, so the
|
||||
# loop has to actually pass time rather than spin.
|
||||
deadline = time.time() + 20
|
||||
while not real.is_complete() and time.time() < deadline:
|
||||
real.display_frame()
|
||||
time.sleep(0.0012)
|
||||
|
||||
# Asserted separately so a host too slow to sustain the frame rate
|
||||
# reports a timeout rather than looking like a scrolling defect.
|
||||
assert time.time() < deadline, (
|
||||
"scroll did not finish within 20s — the host may be too slow to "
|
||||
"sustain the configured frame rate")
|
||||
assert real.is_complete() is True
|
||||
assert display.scroll_helper.scroll_position > 128
|
||||
|
||||
def test_dynamic_duration_is_a_real_number(self, real):
|
||||
real.prepare_and_display([{"id": "a"}], "live", ["nhl"])
|
||||
assert real.get_scroll_display("live").get_dynamic_duration() > 0
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Regression tests: changed plugin data must reach the strip in continuous mode.
|
||||
|
||||
Two faults combined to freeze Vegas content indefinitely.
|
||||
|
||||
PR #291 added a call to ``plugin_adapter.invalidate_plugin_scroll_cache()`` so a
|
||||
plugin's *own* cached scroll image would be rebuilt from fresh data. The method
|
||||
was never implemented, and ``hot_swap_content()`` wraps the call in a broad
|
||||
except, so every hot swap raised AttributeError and was silently swallowed.
|
||||
|
||||
Continuous scrolling then removed the only path that reached it at all:
|
||||
``should_recompose()``/``hot_swap_content()`` are called from the non-continuous
|
||||
branch, while ``continuous_scroll`` defaults to True.
|
||||
|
||||
Together, a plugin composed its scroll image once and handed back the same
|
||||
picture forever, because the sports plugins' ``get_vegas_content()`` regenerates
|
||||
only when its cache is empty. Symptom: a game that was live last night is still
|
||||
drawn as live the following morning.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
|
||||
|
||||
class FakeDisplayManager:
|
||||
width = 64
|
||||
height = 32
|
||||
|
||||
|
||||
def _helper():
|
||||
"""A stand-in ScrollHelper holding both halves of its cache."""
|
||||
image = Image.new('RGB', (128, 32), (10, 20, 30))
|
||||
return SimpleNamespace(cached_image=image, cached_array=np.array(image))
|
||||
|
||||
|
||||
class TestInvalidatePluginScrollCache:
|
||||
"""The method PR #291 called but never defined."""
|
||||
|
||||
def test_method_exists(self):
|
||||
# It was called for months without existing; the broad except in
|
||||
# hot_swap_content() meant nothing ever surfaced.
|
||||
assert hasattr(PluginAdapter, 'invalidate_plugin_scroll_cache')
|
||||
|
||||
def test_clears_helper_attached_to_the_plugin(self):
|
||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
||||
helper = _helper()
|
||||
plugin = SimpleNamespace(scroll_helper=helper)
|
||||
|
||||
assert adapter.invalidate_plugin_scroll_cache(plugin, 'stocks') is True
|
||||
assert helper.cached_image is None
|
||||
assert helper.cached_array is None
|
||||
|
||||
def test_clears_helper_owned_by_a_scroll_manager(self):
|
||||
# The sports scoreboards keep theirs on _scroll_manager, which is the
|
||||
# layout that produced the reported stale-scores bug.
|
||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
||||
helper = _helper()
|
||||
plugin = SimpleNamespace(_scroll_manager=SimpleNamespace(scroll_helper=helper))
|
||||
|
||||
assert adapter.invalidate_plugin_scroll_cache(plugin, 'baseball') is True
|
||||
assert helper.cached_image is None
|
||||
assert helper.cached_array is None
|
||||
|
||||
def test_clears_both_halves_together(self):
|
||||
# cached_array is the image's numpy mirror; leaving one behind lets a
|
||||
# reader pick up content the other no longer has.
|
||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
||||
helper = _helper()
|
||||
adapter.invalidate_plugin_scroll_cache(
|
||||
SimpleNamespace(scroll_helper=helper), 'news')
|
||||
assert (helper.cached_image, helper.cached_array) == (None, None)
|
||||
|
||||
def test_plugin_without_a_helper_is_not_an_error(self):
|
||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
||||
assert adapter.invalidate_plugin_scroll_cache(SimpleNamespace(), 'clock') is False
|
||||
|
||||
|
||||
class TestInvalidatePendingUpdates:
|
||||
def _manager(self, plugins):
|
||||
stream = StreamManager(
|
||||
VegasModeConfig(),
|
||||
SimpleNamespace(plugins=plugins),
|
||||
MagicMock(),
|
||||
)
|
||||
stream.plugin_adapter = MagicMock()
|
||||
return stream
|
||||
|
||||
def test_drops_caches_for_updated_plugins(self):
|
||||
helper = _helper()
|
||||
plugin = SimpleNamespace(scroll_helper=helper)
|
||||
stream = self._manager({'baseball': plugin})
|
||||
stream.mark_plugin_updated('baseball')
|
||||
|
||||
assert stream.invalidate_pending_updates() == ['baseball']
|
||||
stream.plugin_adapter.invalidate_cache.assert_called_once_with('baseball')
|
||||
stream.plugin_adapter.invalidate_plugin_scroll_cache.assert_called_once_with(
|
||||
plugin, 'baseball')
|
||||
|
||||
def test_pending_flags_are_consumed(self):
|
||||
# Left unconsumed they accumulate forever and nothing ever refreshes.
|
||||
stream = self._manager({'baseball': SimpleNamespace()})
|
||||
stream.mark_plugin_updated('baseball')
|
||||
assert stream.has_pending_updates() is True
|
||||
|
||||
stream.invalidate_pending_updates()
|
||||
assert stream.has_pending_updates() is False
|
||||
assert stream.invalidate_pending_updates() == []
|
||||
|
||||
def test_no_pending_updates_does_no_work(self):
|
||||
stream = self._manager({})
|
||||
assert stream.invalidate_pending_updates() == []
|
||||
stream.plugin_adapter.invalidate_cache.assert_not_called()
|
||||
|
||||
def test_a_failing_plugin_does_not_stop_the_others(self):
|
||||
stream = self._manager({'a': SimpleNamespace(), 'b': SimpleNamespace()})
|
||||
stream.mark_plugin_updated('a')
|
||||
stream.mark_plugin_updated('b')
|
||||
stream.plugin_adapter.invalidate_cache.side_effect = [
|
||||
RuntimeError('boom'), None]
|
||||
|
||||
assert sorted(stream.invalidate_pending_updates()) == ['a', 'b']
|
||||
assert stream.plugin_adapter.invalidate_cache.call_count == 2
|
||||
|
||||
|
||||
class TestContinuousModeReachesTheRefresh:
|
||||
def _pipeline(self):
|
||||
stream = MagicMock()
|
||||
stream.get_buffer_status.return_value = {'staging_count': 0}
|
||||
return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream
|
||||
|
||||
def test_refresh_delegates_to_the_stream_manager(self):
|
||||
pipeline, stream = self._pipeline()
|
||||
stream.invalidate_pending_updates.return_value = ['baseball']
|
||||
assert pipeline.refresh_updated_plugins() is True
|
||||
|
||||
def test_refresh_reports_false_when_nothing_changed(self):
|
||||
pipeline, stream = self._pipeline()
|
||||
stream.invalidate_pending_updates.return_value = []
|
||||
assert pipeline.refresh_updated_plugins() is False
|
||||
|
||||
def test_refresh_never_raises_into_the_render_loop(self):
|
||||
pipeline, stream = self._pipeline()
|
||||
stream.invalidate_pending_updates.side_effect = RuntimeError('boom')
|
||||
assert pipeline.refresh_updated_plugins() is False
|
||||
|
||||
def test_refresh_does_not_reposition_the_scroll(self):
|
||||
# The whole point of preferring this over hot_swap_content(): that path
|
||||
# rebuilds and repositions, which reads as a freeze then a jump.
|
||||
pipeline, stream = self._pipeline()
|
||||
stream.invalidate_pending_updates.return_value = ['baseball']
|
||||
pipeline.scroll_helper.scroll_position = 1234
|
||||
|
||||
pipeline.refresh_updated_plugins()
|
||||
|
||||
assert pipeline.scroll_helper.scroll_position == 1234
|
||||
stream.swap_buffers.assert_not_called()
|
||||
stream.process_updates.assert_not_called()
|
||||
|
||||
|
||||
class TestCoordinatorWiring:
|
||||
"""
|
||||
The regression itself: continuous mode has to *call* the refresh.
|
||||
|
||||
should_recompose()/hot_swap_content() sit in the non-continuous branch, and
|
||||
continuous_scroll defaults to True, so before this fix the refresh was
|
||||
simply never reached on a default install.
|
||||
"""
|
||||
|
||||
def _coordinator(self, continuous):
|
||||
import threading
|
||||
|
||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
||||
|
||||
config = VegasModeConfig()
|
||||
config.continuous_scroll = continuous
|
||||
# Built without __init__ so the test exercises run_frame's branching
|
||||
# without standing up a display, stream and render stack.
|
||||
coordinator = VegasModeCoordinator.__new__(VegasModeCoordinator)
|
||||
coordinator.vegas_config = config
|
||||
coordinator.render_pipeline = MagicMock()
|
||||
coordinator.render_pipeline.has_deferred.return_value = False
|
||||
coordinator.render_pipeline.needs_extension.return_value = False
|
||||
coordinator.render_pipeline.is_cycle_complete.return_value = False
|
||||
coordinator.render_pipeline.should_recompose.return_value = False
|
||||
coordinator.stream_manager = MagicMock()
|
||||
coordinator.stats = {'cycles_completed': 0}
|
||||
coordinator._state_lock = threading.Lock()
|
||||
coordinator._is_active = True
|
||||
coordinator._is_paused = False
|
||||
coordinator._should_stop = False
|
||||
coordinator._pending_config_update = False
|
||||
coordinator._live_priority_check = None
|
||||
coordinator._interrupt_check = None
|
||||
coordinator.sync_manager = None
|
||||
return coordinator
|
||||
|
||||
def test_continuous_mode_refreshes_updated_plugins_every_frame(self):
|
||||
coordinator = self._coordinator(continuous=True)
|
||||
coordinator.run_frame()
|
||||
coordinator.render_pipeline.refresh_updated_plugins.assert_called_once()
|
||||
|
||||
def test_continuous_mode_does_not_use_the_disruptive_swap(self):
|
||||
coordinator = self._coordinator(continuous=True)
|
||||
coordinator.run_frame()
|
||||
coordinator.render_pipeline.hot_swap_content.assert_not_called()
|
||||
|
||||
def test_swap_mode_still_uses_hot_swap(self):
|
||||
# The non-continuous path must keep its original behaviour.
|
||||
coordinator = self._coordinator(continuous=False)
|
||||
coordinator.render_pipeline.should_recompose.return_value = True
|
||||
coordinator.run_frame()
|
||||
coordinator.render_pipeline.hot_swap_content.assert_called_once()
|
||||
coordinator.render_pipeline.refresh_updated_plugins.assert_not_called()
|
||||
|
||||
def test_a_frame_is_still_rendered_either_way(self):
|
||||
for continuous in (True, False):
|
||||
coordinator = self._coordinator(continuous=continuous)
|
||||
coordinator.run_frame()
|
||||
coordinator.render_pipeline.render_frame.assert_called_once()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
||||
"""Tests for Vegas mode geometry primitives."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.geometry import (
|
||||
DEFAULT_INK_THRESHOLD,
|
||||
column_has_ink,
|
||||
content_bounds,
|
||||
dead_window_stats,
|
||||
edge_blank,
|
||||
find_blank_cut,
|
||||
separation_gap,
|
||||
trim_to_content,
|
||||
window_coverage_stats,
|
||||
)
|
||||
|
||||
|
||||
def make_img(width, height=8, fill=(0, 0, 0)):
|
||||
return Image.new('RGB', (width, height), fill)
|
||||
|
||||
|
||||
def paint(img, x0, x1, color=(255, 255, 255)):
|
||||
"""Fill columns [x0, x1) with a colour."""
|
||||
block = Image.new('RGB', (x1 - x0, img.height), color)
|
||||
img.paste(block, (x0, 0))
|
||||
return img
|
||||
|
||||
|
||||
class TestColumnHasInk:
|
||||
def test_all_black_has_no_ink(self):
|
||||
assert not column_has_ink(make_img(16)).any()
|
||||
|
||||
def test_marks_only_painted_columns(self):
|
||||
img = paint(make_img(16), 4, 8)
|
||||
ink = column_has_ink(img)
|
||||
assert ink.tolist() == [False] * 4 + [True] * 4 + [False] * 8
|
||||
|
||||
def test_threshold_is_exclusive(self):
|
||||
# A pixel exactly at the threshold is not ink; one above it is.
|
||||
at = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD,) * 3)
|
||||
above = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD + 1,) * 3)
|
||||
assert not column_has_ink(at).any()
|
||||
assert column_has_ink(above).all()
|
||||
|
||||
def test_single_bright_channel_counts(self):
|
||||
img = paint(make_img(4), 1, 2, (0, 0, 200))
|
||||
assert column_has_ink(img).tolist() == [False, True, False, False]
|
||||
|
||||
def test_one_lit_pixel_lights_the_column(self):
|
||||
img = make_img(4, height=8)
|
||||
img.putpixel((2, 5), (255, 255, 255))
|
||||
assert column_has_ink(img).tolist() == [False, False, True, False]
|
||||
|
||||
|
||||
class TestContentBounds:
|
||||
def test_blank_returns_none(self):
|
||||
assert content_bounds(make_img(16)) is None
|
||||
|
||||
def test_finds_inclusive_bounds(self):
|
||||
assert content_bounds(paint(make_img(20), 5, 12)) == (5, 11)
|
||||
|
||||
def test_full_width_content(self):
|
||||
assert content_bounds(paint(make_img(10), 0, 10)) == (0, 9)
|
||||
|
||||
def test_spans_interior_gap(self):
|
||||
img = paint(make_img(30), 2, 5)
|
||||
paint(img, 20, 25)
|
||||
assert content_bounds(img) == (2, 24)
|
||||
|
||||
|
||||
class TestTrimToContent:
|
||||
def test_blank_image_reports_blank(self):
|
||||
result = trim_to_content(make_img(512))
|
||||
assert result.is_blank
|
||||
assert result.image is None
|
||||
assert result.width == 0
|
||||
assert result.original_width == 512
|
||||
|
||||
def test_trims_both_edges(self):
|
||||
result = trim_to_content(paint(make_img(512), 100, 150))
|
||||
assert not result.is_blank
|
||||
assert result.width == 50
|
||||
assert result.trimmed_left == 100
|
||||
assert result.trimmed_right == 362
|
||||
assert result.removed == 462
|
||||
|
||||
def test_preserves_interior_gap(self):
|
||||
# Two content blocks with a wide blank between them: the gap is the
|
||||
# plugin's layout and must survive trimming.
|
||||
img = paint(make_img(400), 50, 80)
|
||||
paint(img, 300, 330)
|
||||
result = trim_to_content(img)
|
||||
assert result.width == 280 # 50..329 inclusive
|
||||
assert column_has_ink(result.image).sum() == 60
|
||||
|
||||
def test_full_width_content_is_returned_unchanged(self):
|
||||
img = paint(make_img(128), 0, 128)
|
||||
result = trim_to_content(img)
|
||||
assert result.image is img
|
||||
assert result.removed == 0
|
||||
|
||||
def test_non_black_background_is_never_trimmed(self):
|
||||
# A plugin drawing on a dark-but-not-black background fills every
|
||||
# column with ink, so there is nothing to reclaim.
|
||||
result = trim_to_content(make_img(256, fill=(0, 0, 40)))
|
||||
assert result.removed == 0
|
||||
assert result.width == 256
|
||||
|
||||
def test_padding_keeps_margin_up_to_what_exists(self):
|
||||
result = trim_to_content(paint(make_img(512), 100, 150), padding=8)
|
||||
assert result.trimmed_left == 92
|
||||
assert result.width == 66 # 50 content + 8 each side
|
||||
|
||||
def test_padding_cannot_widen_beyond_original(self):
|
||||
# Content starts 2px in; padding of 8 can only reclaim the 2 available.
|
||||
result = trim_to_content(paint(make_img(64), 2, 60), padding=8)
|
||||
assert result.trimmed_left == 0
|
||||
assert result.trimmed_right == 0
|
||||
assert result.width == 64
|
||||
|
||||
def test_height_is_preserved(self):
|
||||
result = trim_to_content(paint(make_img(200, height=64), 10, 20))
|
||||
assert result.image.height == 64
|
||||
|
||||
def test_real_world_of_the_day_case(self):
|
||||
# Measured on devpi: "No Data" occupying 35px of a 512px canvas.
|
||||
result = trim_to_content(paint(make_img(512, height=64), 4, 39))
|
||||
assert result.width == 35
|
||||
assert result.removed == 477
|
||||
|
||||
|
||||
class TestDeadWindowStats:
|
||||
def test_fully_inked_ticker_has_no_dead_windows(self):
|
||||
stats = dead_window_stats(paint(make_img(400), 0, 400), viewport_width=100)
|
||||
assert stats.dead_windows == 0
|
||||
assert stats.dead_ratio == 0.0
|
||||
assert stats.longest_dead_run == 0
|
||||
|
||||
def test_fully_blank_ticker_is_all_dead(self):
|
||||
stats = dead_window_stats(make_img(400), viewport_width=100)
|
||||
assert stats.total_windows == 301
|
||||
assert stats.dead_windows == 301
|
||||
assert stats.dead_ratio == 1.0
|
||||
assert stats.longest_dead_run == 301
|
||||
|
||||
def test_leading_blank_run_is_measured(self):
|
||||
# 512px of black then solid content: windows fully inside the black
|
||||
# stretch are dead. With a 100px viewport, starts 0..412 exist and a
|
||||
# window is dead while it holds >=95 blank columns.
|
||||
img = paint(make_img(1024), 512, 1024)
|
||||
stats = dead_window_stats(img, viewport_width=100)
|
||||
assert stats.dead_windows == 418 # starts 0..417 keep >=95 blank cols
|
||||
assert stats.longest_dead_run == 418
|
||||
|
||||
def test_narrow_content_island_still_leaves_dead_windows(self):
|
||||
# 35px of content in a 512px field, viewed 100px at a time: no window
|
||||
# can be 95% blank once it overlaps 35 lit columns, but the windows
|
||||
# clear of it are dead.
|
||||
img = paint(make_img(512), 100, 135)
|
||||
stats = dead_window_stats(img, viewport_width=100)
|
||||
assert stats.dead_windows > 0
|
||||
assert stats.dead_ratio == pytest.approx(
|
||||
stats.dead_windows / stats.total_windows
|
||||
)
|
||||
|
||||
def test_step_reduces_sampling(self):
|
||||
img = paint(make_img(1000), 500, 1000)
|
||||
exact = dead_window_stats(img, viewport_width=100, step=1)
|
||||
strided = dead_window_stats(img, viewport_width=100, step=10)
|
||||
assert strided.total_windows < exact.total_windows
|
||||
# Same underlying shape, so the ratios should stay close.
|
||||
assert strided.dead_ratio == pytest.approx(exact.dead_ratio, abs=0.02)
|
||||
|
||||
def test_image_narrower_than_viewport_is_one_window(self):
|
||||
stats = dead_window_stats(make_img(50), viewport_width=100)
|
||||
assert stats.total_windows == 1
|
||||
assert stats.dead_windows == 1
|
||||
|
||||
def test_zero_viewport_is_handled(self):
|
||||
stats = dead_window_stats(make_img(50), viewport_width=0)
|
||||
assert stats.total_windows == 0
|
||||
assert stats.dead_ratio == 0.0
|
||||
|
||||
def test_longest_run_picks_the_larger_of_two_gaps(self):
|
||||
# Short blank gap, content, then a long blank gap.
|
||||
img = make_img(1000)
|
||||
paint(img, 150, 400)
|
||||
paint(img, 500, 520)
|
||||
stats = dead_window_stats(img, viewport_width=100)
|
||||
# The 400..500 gap is only 100 wide; the tail from 520 is 480 wide.
|
||||
assert stats.longest_dead_run >= 380
|
||||
|
||||
|
||||
class TestWindowCoverageStats:
|
||||
def test_solid_content_is_fully_covered(self):
|
||||
stats = window_coverage_stats(paint(make_img(600), 0, 600), viewport_width=100)
|
||||
assert stats.mean_ink_ratio == 1.0
|
||||
assert stats.min_ink_ratio == 1.0
|
||||
assert stats.sparse_windows == 0
|
||||
|
||||
def test_blank_strip_is_entirely_sparse(self):
|
||||
stats = window_coverage_stats(make_img(600), viewport_width=100)
|
||||
assert stats.mean_ink_ratio == 0.0
|
||||
assert stats.sparse_ratio == 1.0
|
||||
|
||||
def test_catches_sliver_windows_that_dead_ratio_misses(self):
|
||||
# Narrow content islands separated by more than the viewport. A window
|
||||
# holding one whole 40px island carries 472 blank columns — under the
|
||||
# 486 needed to count as "dead" — yet only 7.8% ink, so it still reads
|
||||
# as an empty panel. Coverage must flag strictly more positions than
|
||||
# the dead-window scan does.
|
||||
img = paint(make_img(2000), 0, 40)
|
||||
paint(img, 1000, 1040)
|
||||
dead = dead_window_stats(img, viewport_width=512)
|
||||
cover = window_coverage_stats(img, viewport_width=512, sparse_ink_ratio=0.10)
|
||||
assert cover.sparse_windows > dead.dead_windows
|
||||
assert cover.min_ink_ratio == 0.0
|
||||
|
||||
def test_adjacent_full_width_segments_stay_partially_covered(self):
|
||||
# Documents why the dead-window scan alone understated the problem:
|
||||
# two 512px segments with mid-canvas content never fully blank the
|
||||
# viewport, they just hold it at a thin ~28%.
|
||||
img = paint(make_img(1024), 185, 330)
|
||||
paint(img, 697, 842)
|
||||
dead = dead_window_stats(img, viewport_width=512)
|
||||
cover = window_coverage_stats(img, viewport_width=512)
|
||||
assert dead.dead_windows == 0
|
||||
assert cover.mean_ink_ratio == pytest.approx(0.283, abs=0.01)
|
||||
|
||||
def test_min_ink_ratio_finds_the_worst_position(self):
|
||||
# A wide blank tail guarantees at least one totally empty viewport.
|
||||
img = paint(make_img(1200), 0, 200)
|
||||
stats = window_coverage_stats(img, viewport_width=200)
|
||||
assert stats.min_ink_ratio == 0.0
|
||||
assert stats.mean_ink_ratio > 0.0
|
||||
|
||||
def test_sparse_threshold_is_respected(self):
|
||||
# 40 inked columns in a 200px viewport = 20% coverage everywhere the
|
||||
# island is fully inside the window.
|
||||
img = paint(make_img(400), 100, 140)
|
||||
lenient = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.05)
|
||||
strict = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.50)
|
||||
assert strict.sparse_windows > lenient.sparse_windows
|
||||
|
||||
def test_step_approximates_exact_scan(self):
|
||||
img = paint(make_img(2000), 300, 500)
|
||||
paint(img, 1200, 1400)
|
||||
exact = window_coverage_stats(img, viewport_width=512, step=1)
|
||||
strided = window_coverage_stats(img, viewport_width=512, step=4)
|
||||
assert strided.mean_ink_ratio == pytest.approx(exact.mean_ink_ratio, abs=0.01)
|
||||
|
||||
def test_zero_viewport_is_handled(self):
|
||||
stats = window_coverage_stats(make_img(50), viewport_width=0)
|
||||
assert stats.total_windows == 0
|
||||
assert stats.sparse_ratio == 0.0
|
||||
|
||||
def test_image_narrower_than_viewport(self):
|
||||
stats = window_coverage_stats(paint(make_img(50), 0, 50), viewport_width=100)
|
||||
assert stats.total_windows == 1
|
||||
assert stats.mean_ink_ratio == pytest.approx(0.5)
|
||||
|
||||
|
||||
class TestLongestRunHelper:
|
||||
@pytest.mark.parametrize("flags,expected", [
|
||||
([], 0),
|
||||
([False, False], 0),
|
||||
([True], 1),
|
||||
([True, True, False, True], 2),
|
||||
([False, True, True, True, False, True], 3),
|
||||
([True, True, True], 3),
|
||||
])
|
||||
def test_run_lengths(self, flags, expected):
|
||||
from src.vegas_mode.geometry import _longest_true_run
|
||||
assert _longest_true_run(np.array(flags, dtype=bool)) == expected
|
||||
|
||||
|
||||
class TestEdgeBlank:
|
||||
def test_measures_both_edges(self):
|
||||
assert edge_blank(paint(make_img(100), 20, 60)) == (20, 40)
|
||||
|
||||
def test_flush_content_has_no_blank(self):
|
||||
assert edge_blank(paint(make_img(50), 0, 50)) == (0, 0)
|
||||
|
||||
def test_blank_image_reports_full_width_both_sides(self):
|
||||
# No ink means nothing to be close to.
|
||||
assert edge_blank(make_img(64)) == (64, 64)
|
||||
|
||||
|
||||
class TestSeparationGap:
|
||||
def test_flush_edges_get_the_full_target(self):
|
||||
a = paint(make_img(50), 0, 50)
|
||||
b = paint(make_img(50), 0, 50)
|
||||
assert separation_gap(a, b, target=24) == 24
|
||||
|
||||
def test_existing_margins_reduce_the_added_gap(self):
|
||||
# 8px blank on each facing edge already covers 16 of the 24 target.
|
||||
a = paint(make_img(50), 0, 42)
|
||||
b = paint(make_img(50), 8, 50)
|
||||
assert separation_gap(a, b, target=24) == 8
|
||||
|
||||
def test_ample_existing_margin_adds_nothing(self):
|
||||
a = paint(make_img(100), 0, 60)
|
||||
b = paint(make_img(100), 40, 100)
|
||||
assert separation_gap(a, b, target=24) == 0
|
||||
|
||||
def test_minimum_is_a_floor(self):
|
||||
a = paint(make_img(100), 0, 60)
|
||||
b = paint(make_img(100), 40, 100)
|
||||
assert separation_gap(a, b, target=24, minimum=4) == 4
|
||||
|
||||
def test_never_negative(self):
|
||||
a = paint(make_img(200), 0, 10)
|
||||
b = paint(make_img(200), 190, 200)
|
||||
assert separation_gap(a, b, target=8) == 0
|
||||
|
||||
def test_sports_card_case_gets_real_separation(self):
|
||||
# The reported problem: cards drawn edge to edge sat 8px apart under a
|
||||
# flat gap; measured separation lifts them to the 24px target.
|
||||
card = paint(make_img(150), 0, 150)
|
||||
assert separation_gap(card, card, target=24, minimum=8) == 24
|
||||
|
||||
|
||||
class TestFindBlankCut:
|
||||
def test_snaps_to_the_nearest_gap(self):
|
||||
img = paint(make_img(200), 0, 90)
|
||||
paint(img, 110, 200)
|
||||
# 100 is inside the 90..110 gap already.
|
||||
assert find_blank_cut(img, 100, 20) == 100
|
||||
|
||||
def test_walks_outwards_to_find_a_gap(self):
|
||||
img = paint(make_img(200), 0, 95)
|
||||
paint(img, 105, 200)
|
||||
cut = find_blank_cut(img, 90, 20)
|
||||
assert 95 <= cut < 105
|
||||
|
||||
def test_solid_ink_returns_the_target(self):
|
||||
assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100
|
||||
|
||||
def test_target_at_image_width_does_not_index_past_the_end(self):
|
||||
# A cut after the last column is legal. Indexing ink[width] raised
|
||||
# IndexError in the field, losing that plugin's content for the cycle.
|
||||
# Reached once the rotation offset advances so start + budget lands
|
||||
# exactly on the image width.
|
||||
img = paint(make_img(1840), 0, 1840)
|
||||
assert find_blank_cut(img, 1840, 32) == 1840
|
||||
|
||||
def test_target_past_image_width_is_clamped(self):
|
||||
img = paint(make_img(100), 0, 100)
|
||||
assert find_blank_cut(img, 500, 32) == 100
|
||||
|
||||
def test_target_at_width_with_a_trailing_gap_snaps_back(self):
|
||||
# Content 0..179, blank 180..199. The nearest blank column to 200 is
|
||||
# 199, not the start of the gap — nearest is what keeps the cut as
|
||||
# close as possible to the requested budget.
|
||||
img = paint(make_img(200), 0, 180)
|
||||
assert find_blank_cut(img, 200, 32) == 199
|
||||
|
||||
def test_zero_radius_returns_the_target(self):
|
||||
assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50
|
||||
|
||||
def test_negative_target_is_clamped_to_zero(self):
|
||||
assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0
|
||||
|
||||
@pytest.mark.parametrize("target", [0, 1, 50, 99, 100])
|
||||
def test_never_raises_across_the_range(self, target):
|
||||
img = paint(make_img(100), 0, 100)
|
||||
cut = find_blank_cut(img, target, 16)
|
||||
assert 0 <= cut <= 100
|
||||
@@ -167,6 +167,78 @@ class TestConfigAPI:
|
||||
'enabled': True, 'copies': 2, 'axis': 'vertical',
|
||||
}
|
||||
|
||||
def test_save_target_fps(self, client, mock_config_manager):
|
||||
"""The device-wide scroll frame rate persists as a top-level int."""
|
||||
response = client.post(
|
||||
'/api/v3/config/main',
|
||||
data={'target_fps': '90'},
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||
# Must be the coerced int, not the raw form string -- the generic
|
||||
# remaining-keys loop would otherwise write '90' back over it.
|
||||
assert saved['target_fps'] == 90
|
||||
|
||||
def test_save_target_fps_alone_does_not_reset_other_general_settings(
|
||||
self, client, mock_config_manager):
|
||||
"""A target_fps-only POST must not be treated as a full General-tab save.
|
||||
|
||||
The general branch reads web_display_autostart as an unchecked-checkbox
|
||||
(absent means False), so counting target_fps as a general update would
|
||||
silently switch autostart off for anyone setting only the frame rate.
|
||||
"""
|
||||
mock_config_manager.load_config.return_value['web_display_autostart'] = True
|
||||
|
||||
response = client.post(
|
||||
'/api/v3/config/main',
|
||||
data={'target_fps': '90'},
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||
assert saved['web_display_autostart'] is True
|
||||
|
||||
@pytest.mark.parametrize('value', [90.5, 90.0, True])
|
||||
def test_save_target_fps_rejects_non_integer_json(self, client, mock_config_manager, value):
|
||||
"""int() would truncate silently: 90.5 -> 90, True -> 1.
|
||||
|
||||
Only JSON can carry these; a form post sends '90.5', which int()
|
||||
already rejects.
|
||||
"""
|
||||
response = client.post(
|
||||
'/api/v3/config/main',
|
||||
data=json.dumps({'target_fps': value}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.parametrize('value', ['20', '250', 'fast'])
|
||||
def test_save_target_fps_rejects_out_of_range(self, client, mock_config_manager, value):
|
||||
"""Values ScrollHelper would silently clamp are reported instead."""
|
||||
response = client.post(
|
||||
'/api/v3/config/main',
|
||||
data={'target_fps': value},
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_save_target_fps_accepts_bounds(self, client, mock_config_manager):
|
||||
"""Both endpoints of the documented range are valid."""
|
||||
for value in ('30', '200'):
|
||||
response = client.post(
|
||||
'/api/v3/config/main',
|
||||
data={'target_fps': value},
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
)
|
||||
assert response.status_code == 200, f"{value} should be accepted"
|
||||
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||
assert saved['target_fps'] == int(value)
|
||||
|
||||
def test_save_double_sided_unchecked_disables(self, client, mock_config_manager):
|
||||
"""An omitted 'enabled' checkbox is saved as disabled, not left stale."""
|
||||
response = client.post(
|
||||
|
||||
@@ -747,6 +747,36 @@ def save_main_config():
|
||||
if 'timezone' in data:
|
||||
current_config['timezone'] = data['timezone']
|
||||
|
||||
# Device-wide scroll frame rate, read by plugins via
|
||||
# BasePlugin.global_config. Bounds match ScrollHelper.set_target_fps,
|
||||
# which clamps silently -- rejecting here instead means a value that
|
||||
# would have been quietly altered is reported rather than appearing to
|
||||
# save and then behaving differently.
|
||||
if 'target_fps' in data and data['target_fps'] not in ('', None):
|
||||
raw_target_fps = data['target_fps']
|
||||
# A JSON body can carry real floats and bools, where int() would
|
||||
# silently truncate: 90.5 would save as 90, and true as 1. Reject
|
||||
# them rather than storing a value the user did not ask for. Form
|
||||
# posts arrive as strings, so '90.5' still fails in int() below.
|
||||
if isinstance(raw_target_fps, (bool, float)):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for target_fps: must be an integer"
|
||||
}), 400
|
||||
try:
|
||||
target_fps = int(raw_target_fps)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for target_fps: must be an integer"
|
||||
}), 400
|
||||
if not (30 <= target_fps <= 200):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for target_fps: must be between 30 and 200"
|
||||
}), 400
|
||||
current_config['target_fps'] = target_fps
|
||||
|
||||
# Handle location settings
|
||||
if 'city' in data or 'state' in data or 'country' in data:
|
||||
if 'location' not in current_config:
|
||||
@@ -918,7 +948,15 @@ def save_main_config():
|
||||
|
||||
# Handle Vegas scroll mode settings
|
||||
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins']
|
||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins',
|
||||
'vegas_auto_trim', 'vegas_trim_threshold', 'vegas_content_padding',
|
||||
'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle',
|
||||
'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled',
|
||||
'vegas_min_cycle_duration', 'vegas_max_cycle_duration',
|
||||
'vegas_intra_plugin_gap', 'vegas_render_width_pct',
|
||||
'vegas_min_content_separation', 'vegas_min_cut_gap',
|
||||
'vegas_continuous_scroll', 'vegas_extend_threshold_screens',
|
||||
'vegas_smooth_scroll', 'vegas_overflow_mode']
|
||||
|
||||
if any(k in data for k in vegas_fields):
|
||||
if 'display' not in current_config:
|
||||
@@ -933,13 +971,85 @@ def save_main_config():
|
||||
# was submitted (any vegas field present) but enabled key is missing,
|
||||
# the checkbox was unchecked and we should set enabled=False
|
||||
vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled'))
|
||||
vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim'))
|
||||
vegas_config['dynamic_duration_enabled'] = _coerce_to_bool(
|
||||
data.get('vegas_dynamic_duration_enabled'))
|
||||
vegas_config['continuous_scroll'] = _coerce_to_bool(
|
||||
data.get('vegas_continuous_scroll'))
|
||||
vegas_config['smooth_scroll'] = _coerce_to_bool(
|
||||
data.get('vegas_smooth_scroll'))
|
||||
|
||||
# Handle numeric settings with validation
|
||||
# max_plugin_width_ratio is the one fractional setting, so it is
|
||||
# handled outside the integer loop below.
|
||||
if data.get('vegas_overflow_mode') not in ('', None):
|
||||
mode = str(data['vegas_overflow_mode']).strip().lower()
|
||||
if mode not in ('rotate', 'truncate'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for vegas_overflow_mode: "
|
||||
"must be 'rotate' or 'truncate'"
|
||||
}), 400
|
||||
vegas_config['overflow_mode'] = mode
|
||||
|
||||
if data.get('vegas_extend_threshold_screens') not in ('', None):
|
||||
try:
|
||||
screens = float(data['vegas_extend_threshold_screens'])
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for vegas_extend_threshold_screens: "
|
||||
"must be a number"
|
||||
}), 400
|
||||
if not (1.0 <= screens <= 10.0):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for vegas_extend_threshold_screens: "
|
||||
"must be between 1.0 and 10.0"
|
||||
}), 400
|
||||
vegas_config['extend_threshold_screens'] = screens
|
||||
|
||||
if data.get('vegas_max_plugin_width_ratio') not in ('', None):
|
||||
try:
|
||||
ratio = float(data['vegas_max_plugin_width_ratio'])
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for vegas_max_plugin_width_ratio: "
|
||||
"must be a number"
|
||||
}), 400
|
||||
if not (0 <= ratio <= 20):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for vegas_max_plugin_width_ratio: "
|
||||
"must be between 0 and 20 (0 disables the cap)"
|
||||
}), 400
|
||||
vegas_config['max_plugin_width_ratio'] = ratio
|
||||
|
||||
# Handle numeric settings with validation.
|
||||
#
|
||||
# These bounds must match VegasModeConfig.validate(), which is what
|
||||
# actually gates Vegas starting. Where they were looser, a value
|
||||
# saved with a 200 and then made VegasModeCoordinator.start() bail
|
||||
# out with only a log line, so the ticker silently never ran.
|
||||
# Where they were tighter (scroll_speed capped at 100 against a
|
||||
# slider that goes to 200), a legitimate value was rejected with a
|
||||
# 400. See test_vegas_api_bounds_match_validate.
|
||||
numeric_fields = {
|
||||
'vegas_scroll_speed': ('scroll_speed', 1, 100),
|
||||
'vegas_separator_width': ('separator_width', 0, 500),
|
||||
'vegas_target_fps': ('target_fps', 1, 200),
|
||||
'vegas_buffer_ahead': ('buffer_ahead', 1, 20),
|
||||
'vegas_scroll_speed': ('scroll_speed', 1, 200),
|
||||
'vegas_separator_width': ('separator_width', 0, 128),
|
||||
'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
|
||||
'vegas_render_width_pct': ('render_width_pct', 10, 100),
|
||||
'vegas_min_content_separation': ('min_content_separation', 0, 256),
|
||||
'vegas_min_cut_gap': ('min_cut_gap', 1, 128),
|
||||
'vegas_target_fps': ('target_fps', 30, 200),
|
||||
'vegas_buffer_ahead': ('buffer_ahead', 1, 5),
|
||||
'vegas_trim_threshold': ('trim_threshold', 0, 254),
|
||||
'vegas_content_padding': ('content_padding', 0, 128),
|
||||
'vegas_min_plugin_width': ('min_plugin_width', 0, 512),
|
||||
'vegas_lead_in_width': ('lead_in_width', 0, 2048),
|
||||
'vegas_plugins_per_cycle': ('plugins_per_cycle', 1, 50),
|
||||
'vegas_min_cycle_duration': ('min_cycle_duration', 5, 3600),
|
||||
'vegas_max_cycle_duration': ('max_cycle_duration', 10, 3600),
|
||||
}
|
||||
for field_name, (config_key, min_val, max_val) in numeric_fields.items():
|
||||
if field_name in data:
|
||||
@@ -1202,7 +1312,7 @@ def save_main_config():
|
||||
if key in ['timezone', 'city', 'state', 'country',
|
||||
'web_display_autostart', 'auto_discover',
|
||||
'auto_load_enabled', 'development_mode',
|
||||
'plugins_directory']:
|
||||
'plugins_directory', 'target_fps']:
|
||||
continue
|
||||
# Skip fields that are already handled above in their own named sections.
|
||||
# Without this, every form field name lands as a top-level config key too.
|
||||
|
||||
@@ -425,7 +425,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
|
||||
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap inserted between each plugin block in the ticker (0–128 px).\nDefault: 32. Larger values make the boundary between plugins clearer.', 'Separator Width') }}</label>
|
||||
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap where one plugin hands off to the next (0–128 px).\nDefault: 32. Larger values make the boundary between plugins clearer. This does not apply between rows of the same plugin — see Row Gap for that.', 'Separator Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_separator_width"
|
||||
name="vegas_separator_width"
|
||||
@@ -436,6 +436,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_intra_plugin_gap" data-setting-key="display.vegas_scroll.intra_plugin_gap">
|
||||
<label for="vegas_intra_plugin_gap" class="block text-sm font-medium text-gray-700">Row Gap (pixels){{ ui.help_tip('Extra gap always added between rows contributed by the same plugin (0–128 px).\nDefault: 8. This is a floor on top of Row Separation below, which does most of the work. Set both to 0 to butt rows directly together.', 'Row Gap') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_intra_plugin_gap"
|
||||
name="vegas_intra_plugin_gap"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('intra_plugin_gap', 8) }}"
|
||||
min="0"
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_min_content_separation" data-setting-key="display.vegas_scroll.min_content_separation">
|
||||
<label for="vegas_min_content_separation" class="block text-sm font-medium text-gray-700">Row Separation (pixels){{ ui.help_tip('Blank space guaranteed between rows of the same plugin, measured from the actual content rather than added blindly (0–256 px).\nDefault: 24. Rows already carrying wide margins get nothing added; rows drawn right up to their own edges — sports score cards, for instance — get the full amount, so they no longer look like they are touching. Raise it if items still feel cramped.', 'Row Separation') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_content_separation"
|
||||
name="vegas_min_content_separation"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_content_separation', 24) }}"
|
||||
min="0"
|
||||
max="256"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_render_width_pct" data-setting-key="display.vegas_scroll.render_width_pct">
|
||||
<label for="vegas_render_width_pct" class="block text-sm font-medium text-gray-700">Plugin Render Width (%){{ ui.help_tip('How much of the screen width each plugin is told it has while drawing for the ticker (10–100%).\nDefault: 100 (unchanged). Lowering it makes plugins choose a tighter layout rather than being cropped — a weather forecast becomes narrow cards instead of five columns spread across the panel. Useful on wide displays. Override per plugin with the vegas_width_pct setting in that plugin\'s own configuration.', 'Plugin Render Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_render_width_pct"
|
||||
name="vegas_render_width_pct"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('render_width_pct', 100) }}"
|
||||
min="10"
|
||||
max="100"
|
||||
step="5"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps">
|
||||
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label>
|
||||
@@ -456,6 +494,194 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cycle Pacing -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Cycle Pacing</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">How long one pass through the ticker lasts, and how many plugins it covers.</p>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-vegas_continuous_scroll" data-setting-key="display.vegas_scroll.continuous_scroll">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_continuous_scroll"
|
||||
name="vegas_continuous_scroll"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('continuous_scroll', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Scroll continuously between groups{{ ui.help_tip('Keep one endless strip, extending it with the next group of plugins as the scroll approaches the end, so they simply arrive from the right. Default: on.\nWith this off the ticker builds a fresh strip and swaps it in, which stops the motion, replaces everything at once and restarts with the screen already full — a freeze, a flash and a jump.', 'Continuous Scroll') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-vegas_smooth_scroll" data-setting-key="display.vegas_scroll.smooth_scroll">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_smooth_scroll"
|
||||
name="vegas_smooth_scroll"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('smooth_scroll', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Smooth sub-pixel motion{{ ui.help_tip('Blend between neighbouring pixel positions so the ticker moves once per rendered frame instead of once per pixel. Default: on.\nWithout it, motion happens only as often as the scroll speed in pixels per second — at 50 px/s that is 50 steps a second however fast the display renders, which reads as a slight judder. The trade is that text softens very slightly horizontally, since each frame blends two positions. Turn it off if you prefer maximum crispness.', 'Smooth Scrolling') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-group" id="setting-display-vegas_extend_threshold_screens" data-setting-key="display.vegas_scroll.extend_threshold_screens">
|
||||
<label for="vegas_extend_threshold_screens" class="block text-sm font-medium text-gray-700">Extend When (screens left){{ ui.help_tip('How much unscrolled content triggers loading the next group, measured in screen widths (1.0–10.0).\nDefault: 2. Higher loads earlier and leaves more slack, at the cost of holding more content in memory. Only applies when Continuous Scroll is on.', 'Extend Threshold') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_extend_threshold_screens"
|
||||
name="vegas_extend_threshold_screens"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('extend_threshold_screens', 2.0) }}"
|
||||
min="1"
|
||||
max="10"
|
||||
step="0.5"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_plugins_per_cycle" data-setting-key="display.vegas_scroll.plugins_per_cycle">
|
||||
<label for="vegas_plugins_per_cycle" class="block text-sm font-medium text-gray-700">Plugins Per Cycle{{ ui.help_tip('How many plugins are composed into one pass of the ticker (1–50).\nDefault: 6. Higher means more variety before the ticker restarts, and fewer recompose pauses. Lower means each plugin comes around sooner.', 'Plugins Per Cycle') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_plugins_per_cycle"
|
||||
name="vegas_plugins_per_cycle"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('plugins_per_cycle', 6) }}"
|
||||
min="1"
|
||||
max="50"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_overflow_mode" data-setting-key="display.vegas_scroll.overflow_mode">
|
||||
<label for="vegas_overflow_mode" class="block text-sm font-medium text-gray-700">When A Plugin Is Too Wide{{ ui.help_tip('What to do when a plugin has more content than its width allowance.\nRotate through it: show a different slice each time round, so everything is seen eventually. Right for interchangeable items like news headlines, odds or stock prices.\nShow the start only: always display from the beginning and drop the rest. Right for ordered content — a league table that shows ranks 1-6 and then resumes at 7 two rotations later reads as out of order.\nDefault: rotate. Override for one plugin with vegas_overflow in its own settings.', 'Overflow Handling') }}</label>
|
||||
<select id="vegas_overflow_mode" name="vegas_overflow_mode" class="form-control">
|
||||
<option value="rotate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'rotate' %}selected{% endif %}>Rotate through it (default)</option>
|
||||
<option value="truncate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'truncate' %}selected{% endif %}>Show the start only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
|
||||
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (0–20).\nDefault: 3. A long ticker such as a news feed or leaderboard is trimmed to this and the remainder shown on later cycles, so one plugin cannot hold the display for minutes. Set 0 for no limit.', 'Max Plugin Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_max_plugin_width_ratio"
|
||||
name="vegas_max_plugin_width_ratio"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 3.0) }}"
|
||||
min="0"
|
||||
max="20"
|
||||
step="0.5"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-4" id="setting-display-vegas_dynamic_duration_enabled" data-setting-key="display.vegas_scroll.dynamic_duration_enabled">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_dynamic_duration_enabled"
|
||||
name="vegas_dynamic_duration_enabled"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('dynamic_duration_enabled', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Size cycle time to the content{{ ui.help_tip('When on, each cycle runs just long enough to scroll all its content past, clamped to the min and max below.\nWhen off, the max is always used. Default: on.', 'Dynamic Cycle Duration') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
||||
<div class="form-group" id="setting-display-vegas_min_cycle_duration" data-setting-key="display.vegas_scroll.min_cycle_duration">
|
||||
<label for="vegas_min_cycle_duration" class="block text-sm font-medium text-gray-700">Min Cycle Time (seconds){{ ui.help_tip('Shortest a single ticker pass may last (5–3600 s).\nDefault: 60.', 'Min Cycle Time') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_cycle_duration"
|
||||
name="vegas_min_cycle_duration"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cycle_duration', 60) }}"
|
||||
min="5"
|
||||
max="3600"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_max_cycle_duration" data-setting-key="display.vegas_scroll.max_cycle_duration">
|
||||
<label for="vegas_max_cycle_duration" class="block text-sm font-medium text-gray-700">Max Cycle Time (seconds){{ ui.help_tip('Longest a single ticker pass may last before it restarts with fresh content (10–3600 s).\nThis is the setting that caps total Vegas scroll time. Default: 240. Lower it if the ticker feels like it takes too long to come back around.', 'Max Cycle Time') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_max_cycle_duration"
|
||||
name="vegas_max_cycle_duration"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('max_cycle_duration', 240) }}"
|
||||
min="10"
|
||||
max="3600"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dead Space -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Dead Space</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">Plugins that draw onto a full-screen canvas contribute all the empty space around their content. Trimming reclaims it so the ticker stays full.</p>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-vegas_auto_trim" data-setting-key="display.vegas_scroll.auto_trim">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_auto_trim"
|
||||
name="vegas_auto_trim"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('auto_trim', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Trim empty edges from plugin content{{ ui.help_tip('Crops blank columns from the left and right of each plugin block before it enters the ticker. Space between two pieces of content inside a block is left alone, so layouts are not altered. Default: on.', 'Auto Trim') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_content_padding" data-setting-key="display.vegas_scroll.content_padding">
|
||||
<label for="vegas_content_padding" class="block text-sm font-medium text-gray-700">Content Padding (pixels){{ ui.help_tip('Blank columns kept either side of trimmed content, so it does not butt against the separator (0–128 px).\nDefault: 8.', 'Content Padding') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_content_padding"
|
||||
name="vegas_content_padding"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('content_padding', 8) }}"
|
||||
min="0"
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_lead_in_width" data-setting-key="display.vegas_scroll.lead_in_width">
|
||||
<label for="vegas_lead_in_width" class="block text-sm font-medium text-gray-700">Lead-In Gap (pixels){{ ui.help_tip('Blank space before the first plugin of each cycle (0–2048 px).\nDefault: 0. Anything approaching your screen width reads as the display switching off at the start of every cycle.', 'Lead-In Gap') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_lead_in_width"
|
||||
name="vegas_lead_in_width"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('lead_in_width', 0) }}"
|
||||
min="0"
|
||||
max="2048"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div class="form-group" id="setting-display-vegas_trim_threshold" data-setting-key="display.vegas_scroll.trim_threshold">
|
||||
<label for="vegas_trim_threshold" class="block text-sm font-medium text-gray-700">Trim Threshold{{ ui.help_tip('How bright a pixel must be to count as content rather than empty space (0–254).\nDefault: 10, which ignores the near-black noise left by image compression. Raise it if very dark artwork is being kept; lower it if dark detail is being cropped.', 'Trim Threshold') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_trim_threshold"
|
||||
name="vegas_trim_threshold"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('trim_threshold', 10) }}"
|
||||
min="0"
|
||||
max="254"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_min_plugin_width" data-setting-key="display.vegas_scroll.min_plugin_width">
|
||||
<label for="vegas_min_plugin_width" class="block text-sm font-medium text-gray-700">Min Plugin Width (pixels){{ ui.help_tip('Plugin blocks narrower than this after trimming are skipped for that cycle (0–512 px).\nDefault: 8. Raise it to hide plugins showing only a tiny placeholder such as "No Data" until they have real content.', 'Min Plugin Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_plugin_width"
|
||||
name="vegas_min_plugin_width"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_plugin_width', 8) }}"
|
||||
min="0"
|
||||
max="512"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div class="form-group" id="setting-display-vegas_min_cut_gap" data-setting-key="display.vegas_scroll.min_cut_gap">
|
||||
<label for="vegas_min_cut_gap" class="block text-sm font-medium text-gray-700">Min Cut Gap (pixels){{ ui.help_tip('When a plugin is too wide for its share of a cycle and has to be narrowed, the cut is only made where there is at least this much blank space (1–128 px).\nDefault: 6. The gaps between letters are about 1px wide, so a smaller value lets a cut land inside a word and orphan its last letter into the next cycle. Raise it if cuts still land awkwardly. Continuous images such as maps have no gaps and are cut to fit regardless.', 'Min Cut Gap') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_cut_gap"
|
||||
name="vegas_min_cut_gap"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cut_gap', 6) }}"
|
||||
min="1"
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugin Order Section -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Plugin Order</h4>
|
||||
|
||||
@@ -49,6 +49,18 @@
|
||||
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
|
||||
<div id="timezone_container" class="mt-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Scroll frame rate (device-wide) -->
|
||||
<div class="form-group" id="setting-general-target-fps" data-setting-key="target_fps">
|
||||
<label for="target_fps" class="block text-sm font-medium text-gray-700">Scroll Frame Rate{{ ui.help_tip('Frames per second for scrolling content, applied across plugins that scroll.\nHigher is smoother but uses more CPU; lower frees CPU but looks steppier.\nRange 30-200. Default: 100.', 'Scroll Frame Rate') }}</label>
|
||||
<input type="number"
|
||||
id="target_fps"
|
||||
name="target_fps"
|
||||
value="{{ main_config.target_fps or 100 }}"
|
||||
min="30"
|
||||
max="200"
|
||||
class="form-control">
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
// Track if already initialized to prevent re-render
|
||||
|
||||
Reference in New Issue
Block a user