mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-21 18:39:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3b3606414 |
@@ -72,4 +72,4 @@ jobs:
|
||||
--ignore=test/plugins \
|
||||
--cov=src --cov=web_interface \
|
||||
--cov-report=term \
|
||||
--cov-fail-under=52
|
||||
--cov-fail-under=45
|
||||
|
||||
@@ -600,14 +600,6 @@ These settings are typically only needed for non-standard panels or custom confi
|
||||
- Leave empty unless you need custom mapping
|
||||
- See rpi-rgb-led-matrix documentation for full options
|
||||
|
||||
- **`orientation`** (string, default: "normal")
|
||||
- Rotates the rendered image to match how the panel is physically mounted
|
||||
- Set to `"180"` (or use the "Upside Down" option in the web UI's Display
|
||||
settings) if the panel is mounted upside down — useful for optimizing
|
||||
where the Raspberry Pi and wiring sit relative to the mounting location
|
||||
- Applied independently of `pixel_mapper_config` (appended as a trailing
|
||||
`Rotate:180` mapper), so custom mapper configs keep working alongside it
|
||||
|
||||
- **`row_address_type`** (integer, default: 0)
|
||||
- How rows are addressed on the panel
|
||||
- Most panels use 0 (direct addressing)
|
||||
|
||||
@@ -112,7 +112,6 @@
|
||||
"led_rgb_sequence": "RGB",
|
||||
"limit_refresh_rate_hz": 100,
|
||||
"pixel_mapper_config": "",
|
||||
"orientation": "normal",
|
||||
"row_address_type": 0,
|
||||
"multiplexing": 0,
|
||||
"panel_type": ""
|
||||
@@ -130,9 +129,6 @@
|
||||
"plugin_rotation_order": [],
|
||||
"use_short_date_format": true,
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": false,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5,
|
||||
"enabled": false,
|
||||
"scroll_speed": 50,
|
||||
"separator_width": 32,
|
||||
@@ -153,7 +149,7 @@
|
||||
"min_plugin_width": 8,
|
||||
"lead_in_width": 0,
|
||||
"plugins_per_cycle": 6,
|
||||
"max_plugin_width_ratio": 0.0,
|
||||
"max_plugin_width_ratio": 3.0,
|
||||
"overflow_mode": "rotate",
|
||||
"dynamic_duration_enabled": true,
|
||||
"min_cycle_duration": 60,
|
||||
|
||||
@@ -64,98 +64,10 @@ JSON is optional.
|
||||
| `target_fps` | `125` | Target frame rate |
|
||||
| `buffer_ahead` | `2` | Number of plugins buffered ahead |
|
||||
|
||||
This table is a subset — `display.vegas_scroll` supports 30 keys in
|
||||
This table is a subset — `display.vegas_scroll` supports 26 keys in
|
||||
total. See the full list in
|
||||
[CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#displayvegas_scroll--continuous-scroll-mode).
|
||||
|
||||
### Live Content in the Ticker
|
||||
|
||||
By default, live content **preempts** Vegas mode: while any plugin reports
|
||||
live priority, the display controller refuses to run the ticker and shows
|
||||
that plugin's full-screen display instead. You get a big readable scoreboard,
|
||||
but the marquee stops entirely for the duration of the game.
|
||||
|
||||
Set `live_in_ticker` to keep the ticker running and let live content take
|
||||
**extra turns inside it** instead:
|
||||
|
||||
```json
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": true,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5
|
||||
}
|
||||
```
|
||||
|
||||
#### Why weights exist
|
||||
|
||||
The rotation is otherwise a strict round robin — every plugin appears exactly
|
||||
once per cycle. With a dozen plugins enabled, a live score comes round once a
|
||||
lap and can be minutes old by the time you see it. A weight of *N* gives a
|
||||
plugin *N* slots per cycle.
|
||||
|
||||
The slots are placed by **Smooth Weighted Round-Robin**, the same scheduler
|
||||
the sports plugins use internally to rotate their own games. The important
|
||||
property is that repeats are *spread through the cycle* rather than clumped:
|
||||
three appearances in a row followed by a long silence would be worse than not
|
||||
boosting at all.
|
||||
|
||||
Twelve plugins, with a favorite's baseball game and an ordinary live hockey
|
||||
game (`live_weight: 3`, `favorite_live_weight: 5`):
|
||||
|
||||
```
|
||||
baseball > hockey > weather > clock > baseball
|
||||
stocks > news > flights > baseball > hockey
|
||||
calendar > f1 > music > baseball > tides
|
||||
birds > hockey > baseball
|
||||
```
|
||||
|
||||
18 slots for 12 plugins. Baseball appears 5 times, hockey 3, everything else
|
||||
once, and no plugin ever appears twice in a row — **including across the seam**
|
||||
where the cycle loops back on itself. Smooth Weighted Round-Robin schedules the
|
||||
heaviest item first and usually last as well, so the strip would otherwise show
|
||||
it twice running at exactly the one join a within-cycle check cannot see. The
|
||||
trailing repeat is moved into the widest remaining gap. Where a double is
|
||||
unavoidable — a plugin holding most of the slots has to neighbour itself — the
|
||||
schedule is left as it is.
|
||||
|
||||
#### Where the weight comes from
|
||||
|
||||
For each plugin in the rotation, in order:
|
||||
|
||||
1. **The plugin's own answer.** If it implements
|
||||
`get_vegas_priority_weight()` and returns a number, that wins. This is the
|
||||
only route for favorite-team awareness — the core can see *that* a game is
|
||||
live, but not *whose*, so a scoreboard has to say so itself.
|
||||
2. **The core's default.** When the plugin returns `None` (the base-class
|
||||
default), a plugin where both `has_live_priority()` and `has_live_content()`
|
||||
are true gets `live_weight`.
|
||||
3. **Everything else** gets 1.
|
||||
|
||||
Because of step 2, **existing plugins need no changes** — any scoreboard with
|
||||
`live_priority` enabled already gets extra turns. Step 1 is opt-in, for
|
||||
plugins that want to distinguish a favorite's game from any other live game.
|
||||
|
||||
Weights are clamped to 1–10. A weight of 1 is no boost; a weight below 1 would
|
||||
drop the plugin from the rotation entirely, which is never what is meant.
|
||||
|
||||
#### Things worth knowing
|
||||
|
||||
- **Weights are per plugin, not per game.** A scoreboard showing four live
|
||||
games still occupies one slot at a time, rotating its own games within that
|
||||
slot using its own `favorite_live_boost`. This controls how often the
|
||||
*plugin* comes round.
|
||||
- **The ticker is zero-sum.** Giving baseball 5 slots does not make the cycle
|
||||
faster; it makes the cycle *longer* and everything else proportionally
|
||||
rarer. If you want live scores sooner in wall-clock terms, pair this with a
|
||||
smaller `plugins_per_cycle`.
|
||||
- **Frequency is not freshness.** Each appearance redraws from the plugin's
|
||||
current data (`refresh_updated_plugins()` drops cached content when a
|
||||
plugin's data changes), but how current that data is depends on the
|
||||
plugin's own `live_update_interval`. Showing a stale score five times a lap
|
||||
is no better than showing it once.
|
||||
- **Everything still appears.** A boost never starves another plugin out of
|
||||
the cycle; low-weight plugins keep their single slot.
|
||||
|
||||
### Per-Plugin Configuration
|
||||
|
||||
Override Vegas behavior for specific plugins:
|
||||
|
||||
@@ -18,7 +18,7 @@ tooling against it.
|
||||
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
||||
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
||||
| `target_fps` | int, `100` | Frame-rate ceiling for plugin rendering | `src/plugin_system/base_plugin.py`, `src/common/sports_scroll.py` |
|
||||
| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. | `SchemaManager.apply_device_location()`, then plugins via merged config |
|
||||
| `location` | object | `city` / `state` / `country`, offered to plugins that need a location (weather, etc.) | plugins via merged config |
|
||||
|
||||
## `schedule` — display on/off hours
|
||||
|
||||
@@ -66,7 +66,6 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
|
||||
| `led_rgb_sequence` | string, `"RGB"` |
|
||||
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
|
||||
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` |
|
||||
| `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); composed onto `pixel_mapper_config` as a trailing `Rotate:180` mapper, so it stays independent of any custom `pixel_mapper_config` value |
|
||||
| `row_address_type` | int, `0` — non-standard panel row addressing |
|
||||
| `multiplexing` | int, `0` — panel multiplexing scheme |
|
||||
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
|
||||
@@ -104,8 +103,7 @@ logical image to multiple chained physical panels.
|
||||
## `display.vegas_scroll` — continuous scroll mode
|
||||
|
||||
Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details, including
|
||||
[live content in the ticker](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details.
|
||||
|
||||
| Key | Type / default |
|
||||
|---|---|
|
||||
@@ -129,16 +127,13 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `min_plugin_width` | int, `8` |
|
||||
| `lead_in_width` | int, `0` |
|
||||
| `plugins_per_cycle` | int, `6` |
|
||||
| `max_plugin_width_ratio` | float, `0.0` |
|
||||
| `max_plugin_width_ratio` | float, `3.0` |
|
||||
| `overflow_mode` | string, `"rotate"` |
|
||||
| `dynamic_duration_enabled` | bool, `true` |
|
||||
| `min_cycle_duration` | int, `60` |
|
||||
| `max_cycle_duration` | int, `240` |
|
||||
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
||||
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
|
||||
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
|
||||
| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content |
|
||||
| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live |
|
||||
|
||||
## `sync` — multi-display synchronization
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
# Running on Low-Memory Boards
|
||||
|
||||
Applies to the Pi Zero 2 W (512 MB), Pi 3 / 3B+ (1 GB), and the 1 GB Pi 4.
|
||||
If your board has 2 GB or more you can skip this document.
|
||||
|
||||
## The failure this prevents
|
||||
|
||||
The display process is the largest thing on the board. On a 1 GB Pi 3B+ with
|
||||
around 20 plugins enabled it settles near **600 MB of 905 MB usable**, leaving
|
||||
under 200 MB of headroom for everything else.
|
||||
|
||||
When that headroom runs out, the board does not crash cleanly. `fork()` starts
|
||||
failing, and because a new process is needed to do almost anything, the
|
||||
symptoms look nothing like "out of memory":
|
||||
|
||||
| What you see | Why |
|
||||
|---|---|
|
||||
| SSH accepts the connection then closes it instantly, before any banner | `sshd` forks a session per connection; the fork fails |
|
||||
| The web UI still responds quickly | Already running, serves from existing threads, forks nothing |
|
||||
| Ping is perfect, 0% loss | Handled entirely in the kernel |
|
||||
| The panel is dark | The display process was killed and cannot be respawned |
|
||||
| The clock is wrong after the next boot | `fake-hwclock`'s periodic save is a scheduled job, and it cannot fork either |
|
||||
|
||||
The board looks healthy from the outside and cannot be logged into. Only a
|
||||
power cycle clears it. If you are here because SSH stopped working, also see
|
||||
[SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md), which
|
||||
covers the more common cause (AP mode).
|
||||
|
||||
## Check your headroom
|
||||
|
||||
```bash
|
||||
free -m
|
||||
ps -eo rss,comm --sort=-rss | head -5
|
||||
```
|
||||
|
||||
If `MemAvailable` is under ~150 MB while the display is running, you are close
|
||||
to the edge. To watch it over time:
|
||||
|
||||
```bash
|
||||
watch -n 30 'free -m | head -2'
|
||||
```
|
||||
|
||||
Available memory that falls steadily rather than holding flat means you will
|
||||
reach the wall; it is a question of when.
|
||||
|
||||
## What to do
|
||||
|
||||
**1. Enable the memory cgroup controller.** Without it, the `MemoryMax=85%` in
|
||||
`systemd/ledmatrix.service` is accepted by systemd and silently ignored, so the
|
||||
service has no ceiling and a runaway takes the whole board down instead of just
|
||||
restarting. Raspberry Pi firmware disables this controller by default.
|
||||
|
||||
`first_time_install.sh` does this for you. To check it took effect:
|
||||
|
||||
```bash
|
||||
grep memory /sys/fs/cgroup/cgroup.controllers
|
||||
```
|
||||
|
||||
If that prints nothing, add `cgroup_enable=memory cgroup_memory=1` to the
|
||||
kernel command line and reboot. Edit whichever file your image uses —
|
||||
`/boot/firmware/cmdline.txt` on current Raspberry Pi OS, `/boot/cmdline.txt` on
|
||||
older layouts (the installer checks the first and falls back to the second).
|
||||
Everything must stay on a single line.
|
||||
|
||||
This changes the failure mode from "the board becomes unreachable" to "the
|
||||
display service restarts". It is a safety net, not a fix.
|
||||
|
||||
**2. Run fewer plugins.** This is the actual remedy. Every enabled plugin costs
|
||||
memory permanently — its module, its parsed config, and its cached API
|
||||
responses. On a 512 MB or 1 GB board, keep the enabled set small and prefer
|
||||
plugins that poll infrequently.
|
||||
|
||||
**3. Lower the cache ceiling.** The in-memory cache is sized from total RAM
|
||||
(150 entries at 1 GB and below, up to 1500 at 8 GB). To go lower still:
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/ledmatrix.service.d/override.conf
|
||||
[Service]
|
||||
Environment=LEDMATRIX_CACHE_MAX_ENTRIES=75
|
||||
```
|
||||
|
||||
Writing the file does not change the running service. Reload systemd and
|
||||
restart it:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
Fewer entries means more API calls, so lower this only while you are actually
|
||||
short of memory.
|
||||
|
||||
**4. Consider `MemoryHigh`.** `MemoryMax` kills and restarts. `MemoryHigh`
|
||||
throttles and reclaims instead, which is gentler — but on a board where the
|
||||
process genuinely wants more than the limit, sustained reclaim can stall the
|
||||
render loop and show as visible stutter on the panel. Add it only if you prefer
|
||||
degraded output to a restart:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
MemoryHigh=70%
|
||||
```
|
||||
|
||||
## Keep your logs
|
||||
|
||||
These images default to volatile journald storage, so every reboot destroys the
|
||||
logs — including the ones explaining why the board rebooted. `first_time_install.sh`
|
||||
enables persistent storage capped at 64 MB. To confirm:
|
||||
|
||||
```bash
|
||||
journalctl --list-boots
|
||||
```
|
||||
|
||||
More than one boot listed means logs are surviving reboots. If only one is
|
||||
listed, journald is still writing to `/run` (tmpfs).
|
||||
@@ -170,47 +170,6 @@ Default returns `False`.
|
||||
List of display modes to show during a live takeover. Default returns the
|
||||
plugin's `display_modes` from its manifest.
|
||||
|
||||
#### `get_vegas_priority_weight() -> Optional[int]`
|
||||
|
||||
How many slots per Vegas cycle this plugin should get. Default returns
|
||||
`None`, which defers to the core.
|
||||
|
||||
The Vegas ticker is otherwise a strict round robin — every plugin appears
|
||||
exactly once per cycle — so with a dozen plugins enabled a live score can be
|
||||
minutes stale by the time it comes round. A weight of *N* gives the plugin
|
||||
*N* slots per cycle, spread evenly through it rather than clumped.
|
||||
|
||||
**You usually do not need this.** When the hook returns `None`, the core
|
||||
already gives a plugin `vegas_scroll.live_weight` whenever
|
||||
`has_live_priority()` and `has_live_content()` are both true. Live sports get
|
||||
extra turns with no code at all.
|
||||
|
||||
Implement it only when the plugin knows something the core cannot. The
|
||||
motivating case is favorite teams — the core can see *that* a game is live,
|
||||
but not *whose*:
|
||||
|
||||
```python
|
||||
def get_vegas_priority_weight(self):
|
||||
if not (self.has_live_priority() and self.has_live_content()):
|
||||
return None # let the core decide
|
||||
vegas = self.global_config.get('display', {}).get('vegas_scroll', {})
|
||||
if self._favorite_is_live():
|
||||
return vegas.get('favorite_live_weight', 5)
|
||||
return vegas.get('live_weight', 3)
|
||||
```
|
||||
|
||||
The weight is per *plugin*, not per game: a scoreboard showing four live games
|
||||
still occupies one slot at a time and rotates its own games within it. Values
|
||||
are clamped to 1–10 by the caller. An exception here is caught and logged, and
|
||||
the core then falls back to its own live-content check — so a plugin whose
|
||||
weight calculation is broken still gets `live_weight` for a game that really
|
||||
is live, rather than being demoted to 1.
|
||||
|
||||
Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the
|
||||
default (`false`) live content preempts Vegas entirely and there is no ticker
|
||||
to be weighted within. See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||
|
||||
### Vegas scroll hooks
|
||||
|
||||
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
||||
|
||||
@@ -14,7 +14,6 @@ the one-shot installer. The pages here go deeper.
|
||||
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes
|
||||
6. [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) — recovering SSH after install
|
||||
7. [CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md) — diagnosing config problems
|
||||
8. [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md) — Pi Zero 2 W / 3B+ / 1GB Pi 4 memory limits
|
||||
|
||||
## I want to write a plugin
|
||||
|
||||
|
||||
@@ -20,22 +20,7 @@ The installation script:
|
||||
- Installs and configures `dnsmasq` (DHCP server for AP mode)
|
||||
- These services can interfere with normal WiFi client mode
|
||||
|
||||
### 3. The Board Ran Out of Memory
|
||||
|
||||
On a 512MB or 1GB board, memory exhaustion stops `sshd` being able to fork a
|
||||
session process. The connection is accepted and then closed immediately, before
|
||||
any banner:
|
||||
|
||||
```text
|
||||
kex_exchange_identification: Connection closed by remote host
|
||||
```
|
||||
|
||||
The giveaway is that the board is otherwise healthy — ping is clean and the web
|
||||
UI still responds — but nothing that needs to start a new process works, and
|
||||
the panel is usually dark. Only a power cycle clears it. See
|
||||
[LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md).
|
||||
|
||||
### 4. Reboot After Installation
|
||||
### 3. Reboot After Installation
|
||||
|
||||
If the script reboots the Pi (which it recommends), network services may restart in a different state, potentially triggering AP mode.
|
||||
|
||||
@@ -205,23 +190,11 @@ The web interface allows you to:
|
||||
|
||||
## Summary
|
||||
|
||||
**SSH becomes unavailable because** — two unrelated causes, and they need
|
||||
different responses:
|
||||
|
||||
*AP mode (most common):*
|
||||
**SSH becomes unavailable because**:
|
||||
- WiFi monitor service enables AP mode when WiFi disconnects
|
||||
- AP mode switches WiFi from client to access point mode
|
||||
- Pi loses connection to your original network
|
||||
|
||||
*Memory exhaustion (low-memory boards):*
|
||||
- The board runs out of memory, so `sshd` cannot fork a session process
|
||||
- The connection is accepted and closed before any banner
|
||||
- Ping still answers and the web UI still responds, so it looks healthy
|
||||
- The panel is usually dark and the service cannot restart
|
||||
- **Only a power cycle clears this** — there is no remote recovery, because
|
||||
every remote route needs a new process
|
||||
- Prevention and tuning: [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md)
|
||||
|
||||
**To regain SSH**:
|
||||
1. Connect to **LEDMatrix-Setup** AP network (password: `ledmatrix123`)
|
||||
2. SSH to `192.168.4.1`
|
||||
|
||||
@@ -206,47 +206,6 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the
|
||||
|
||||
The widget will be automatically rendered when the plugin configuration form is loaded.
|
||||
|
||||
## Labelling Enum Options (`x-options.labels`)
|
||||
|
||||
A plain `enum` renders as a dropdown whose option text is the value with
|
||||
underscores replaced and title case applied — `day_first` becomes "Day First".
|
||||
That is fine for values that read as their own label, and wrong for values that
|
||||
do not: `vs` becomes "Vs", and `abbrev` says nothing about the `Sep 19` it
|
||||
actually produces.
|
||||
|
||||
Supply `x-options.labels` to set the visible text. This is the same convention
|
||||
the `checkbox-group` widget uses:
|
||||
|
||||
```json
|
||||
{
|
||||
"date_format": {
|
||||
"type": "string",
|
||||
"enum": ["abbrev", "numeric", "day_first"],
|
||||
"default": "abbrev",
|
||||
"x-options": {
|
||||
"labels": {
|
||||
"abbrev": "Sep 19",
|
||||
"numeric": "9/19",
|
||||
"day_first": "19 Sep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Labels are **display only** — the stored value is still the enum value, so
|
||||
adding them never changes a saved config. The map may be partial: any value
|
||||
without a label keeps the humanised fallback. Older cores that predate this
|
||||
support ignore `x-options` and render the fallback for every option, so a
|
||||
plugin can ship labels without requiring a core upgrade.
|
||||
|
||||
Array-table columns (`x-widget: array-table`) accept the same
|
||||
`x-options.labels` on a column definition, but their fallback is the **raw
|
||||
value** rather than the humanised one, because those columns hold values such
|
||||
as ticker symbols where `aapl` → "Aapl" would be wrong. Rows added in the
|
||||
browser use the labels too (`array-table.js`), so a column reads the same
|
||||
before and after a page reload.
|
||||
|
||||
## Marking Fields as Advanced (`x-advanced`)
|
||||
|
||||
Add `"x-advanced": true` to any top-level, non-object property to move it out
|
||||
|
||||
+3
-98
@@ -1419,16 +1419,9 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/
|
||||
EOF
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
||||
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
|
||||
# when its output is a terminal. From that pager (less) a "!sh" is a root
|
||||
# shell -- the standard journalctl escalation. The web interface always passes
|
||||
# --no-pager, so nothing here needs it, but the rule cannot require a flag that
|
||||
# sits in the middle of the command line. NOEXEC stops the command executing
|
||||
# another program at all, which closes the hole without depending on wildcard
|
||||
# matching subtleties.
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
|
||||
EOF
|
||||
fi
|
||||
|
||||
@@ -1695,94 +1688,6 @@ else
|
||||
echo "✗ $CMDLINE_FILE not found; skipping isolcpus optimization"
|
||||
fi
|
||||
|
||||
# Enable the memory cgroup controller (idempotent).
|
||||
# The Pi firmware boots with cgroup_disable=memory, so systemd's MemoryMax= is
|
||||
# accepted and silently ignored — the display service then has no ceiling, and
|
||||
# a runaway takes the whole board down (sshd can no longer fork, the panel goes
|
||||
# dark) rather than just restarting the one service.
|
||||
if [ "$SKIP_PERF" != "1" ] && [ -f "$CMDLINE_FILE" ]; then
|
||||
# Both parameters are required for the memory controller, and they can get
|
||||
# separated -- an image, another tool or a half-applied earlier run can
|
||||
# leave one without the other. Checking only cgroup_enable=memory would
|
||||
# report success while MemoryMax= silently does nothing, so each is checked
|
||||
# and appended independently.
|
||||
cgroup_missing=""
|
||||
for cgroup_param in cgroup_enable=memory cgroup_memory=1; do
|
||||
if ! grep -qw "$cgroup_param" "$CMDLINE_FILE"; then
|
||||
cgroup_missing="$cgroup_missing $cgroup_param"
|
||||
fi
|
||||
done
|
||||
if [ -z "$cgroup_missing" ]; then
|
||||
echo "cgroup memory parameters already present in $CMDLINE_FILE"
|
||||
else
|
||||
echo "Adding${cgroup_missing} to $CMDLINE_FILE..."
|
||||
cp "$CMDLINE_FILE" "$CMDLINE_FILE.bak" 2>/dev/null || true
|
||||
# The kernel command line must stay on one line.
|
||||
sed -i "1 s|\$|${cgroup_missing}|" "$CMDLINE_FILE"
|
||||
echo " Takes effect after reboot. Verify with:"
|
||||
echo " grep memory /sys/fs/cgroup/cgroup.controllers"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Persist the journal (idempotent).
|
||||
# These images default to volatile storage: journald keeps everything in /run
|
||||
# (tmpfs), so every reboot destroys the logs — including the ones that would
|
||||
# explain why the board rebooted. Capped so an SD card is not worn out by logs.
|
||||
# A non-empty /var/log/journal does not prove journald is configured the way
|
||||
# this needs: the directory survives a switch back to volatile storage, and it
|
||||
# says nothing about whether a size cap is set. Read the effective
|
||||
# configuration instead, and only write the keys the user has not set
|
||||
# themselves so an explicit local limit is preserved.
|
||||
journald_effective() {
|
||||
# systemd-analyze merges journald.conf with every drop-in; grep is the
|
||||
# fallback for images that ship without it.
|
||||
if command -v systemd-analyze >/dev/null 2>&1 &&
|
||||
systemd-analyze cat-config systemd/journald.conf >/dev/null 2>&1; then
|
||||
systemd-analyze cat-config systemd/journald.conf 2>/dev/null
|
||||
else
|
||||
cat /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf 2>/dev/null
|
||||
fi
|
||||
}
|
||||
journald_conf="$(journald_effective)"
|
||||
journald_storage="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')"
|
||||
journald_cap="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*SystemMaxUse=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')"
|
||||
|
||||
if [ "$journald_storage" = "persistent" ] && [ -n "$journald_cap" ]; then
|
||||
echo "Persistent journald storage already configured (SystemMaxUse=$journald_cap)"
|
||||
else
|
||||
echo "Enabling persistent journald storage..."
|
||||
mkdir -p /etc/systemd/journald.conf.d
|
||||
{
|
||||
echo "# Installed by LEDMatrix first_time_install.sh"
|
||||
echo "[Journal]"
|
||||
echo "Storage=persistent"
|
||||
if [ -n "$journald_cap" ]; then
|
||||
echo "# SystemMaxUse left to your existing setting ($journald_cap)"
|
||||
else
|
||||
# Capped so logs cannot wear out or fill an SD card.
|
||||
echo "SystemMaxUse=64M"
|
||||
fi
|
||||
} > /etc/systemd/journald.conf.d/ledmatrix-persistent.conf
|
||||
mkdir -p /var/log/journal
|
||||
systemd-tmpfiles --create --prefix /var/log/journal >/dev/null 2>&1 || true
|
||||
systemctl restart systemd-journald >/dev/null 2>&1 || true
|
||||
|
||||
# Drop-ins are applied in lexical order, so a locally added file that sorts
|
||||
# after ledmatrix-persistent.conf (zz-local.conf and friends) still wins.
|
||||
# Writing the file is not evidence it took effect -- re-read and say so
|
||||
# plainly rather than reporting success we cannot confirm.
|
||||
journald_now="$(journald_effective | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')"
|
||||
if [ "$journald_now" = "persistent" ]; then
|
||||
echo " Persistent journald storage active"
|
||||
else
|
||||
echo " WARNING: journald storage is still '${journald_now:-unset}' after"
|
||||
echo " writing /etc/systemd/journald.conf.d/ledmatrix-persistent.conf."
|
||||
echo " Another drop-in that sorts later is overriding it. Check:"
|
||||
echo " systemd-analyze cat-config systemd/journald.conf | grep -n Storage="
|
||||
echo " Logs will not survive a reboot until that is resolved."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure dtparam=audio=off in config.txt (idempotent)
|
||||
if [ "$SKIP_PERF" = "1" ]; then
|
||||
: # skipped
|
||||
|
||||
@@ -41,7 +41,6 @@ from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
)
|
||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||
check_empty_claimed,
|
||||
check_scale_up,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
@@ -116,11 +115,6 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
||||
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
||||
fill_strict = spec.get("fill_check") == "strict"
|
||||
# A mode that renders nothing without returning False is never skipped by
|
||||
# the display controller, so it holds a blank panel for its whole duration.
|
||||
# Warn-only by default: a scroll mode's first frame is legitimately its
|
||||
# blank scroll-in buffer.
|
||||
empty_strict = spec.get("empty_check") == "strict"
|
||||
|
||||
# Every run: the base config, plus one per harness.json "variant" —
|
||||
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
||||
@@ -148,7 +142,6 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
compare_to_goldens(results, golden_dir)
|
||||
|
||||
check_scale_up(results, design_size=design_size, strict=fill_strict)
|
||||
check_empty_claimed(results, strict=empty_strict)
|
||||
|
||||
# Tag variant runs so the report and PNG dumps stay distinguishable.
|
||||
if variant_name:
|
||||
@@ -185,9 +178,6 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
# warn-only underfill: big panel left mostly empty
|
||||
ex, ey = r.fill_extent
|
||||
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
|
||||
if r.empty_claimed and r.empty_ok is None:
|
||||
detail += (f" (empty warn: drew nothing but display() returned"
|
||||
f" {r.display_returned!r}, so the mode is not skipped)")
|
||||
else:
|
||||
everything_ok = False
|
||||
if r.error is not None:
|
||||
@@ -201,11 +191,6 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||
status = "FAIL"
|
||||
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
|
||||
elif r.empty_ok is False:
|
||||
status = "FAIL"
|
||||
detail = (f" drew nothing but display() returned"
|
||||
f" {r.display_returned!r}; return False so the"
|
||||
f" controller skips the mode")
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
|
||||
@@ -24,29 +24,9 @@ echo "========================================"
|
||||
# Auto-detect latest version if needed
|
||||
if [ "$PIXLET_VERSION" = "latest" ]; then
|
||||
echo "Detecting latest version..."
|
||||
# When this response arrives on a single line -- as it did on the device
|
||||
# where Starlark apps were failing -- `grep '"tag_name"'` matches the whole
|
||||
# document and a greedy `sed 's/.*"([^"]+)".*/\1/'` captures the LAST
|
||||
# quoted token in it rather than the tag. That resolved to
|
||||
# "mentions_count", which built a download URL for a release that does not
|
||||
# exist. (The API is pretty-printed by default, which is why the old
|
||||
# command looks correct when you try it by hand -- but the formatting is
|
||||
# not something to depend on.) Match the field itself and take the value
|
||||
# after it, which is right for either shape.
|
||||
PIXLET_VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
|
||||
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||||
| head -n1 \
|
||||
| sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')
|
||||
|
||||
# A wrong-but-non-empty value is what made the old bug silent, so check the
|
||||
# shape rather than just that something came back. Anchored at both ends: a
|
||||
# partial match would accept "v0.53garbage" or "0.53" and build a URL for a
|
||||
# release that cannot exist, which is the failure this check is here to
|
||||
# stop. Every tronbyt/pixlet release to date is vX.Y.Z; the optional suffix
|
||||
# leaves room for a future -rc.1 or +build tag.
|
||||
if ! printf '%s' "$PIXLET_VERSION" \
|
||||
| grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then
|
||||
echo "Could not detect the latest version (got: '${PIXLET_VERSION:-<empty>}'), using fallback"
|
||||
PIXLET_VERSION=$(curl -s "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if [ -z "$PIXLET_VERSION" ]; then
|
||||
echo "Failed to detect latest version, using fallback"
|
||||
PIXLET_VERSION="v0.50.2"
|
||||
fi
|
||||
fi
|
||||
@@ -87,26 +67,8 @@ download_binary() {
|
||||
temp_dir=$(mktemp -d -p "$PROJECT_ROOT" -t pixlet_download.XXXXXXXXXX)
|
||||
local temp_file="$temp_dir/$archive_name"
|
||||
|
||||
# -f so an HTTP error is a failure. Without it curl writes the 404 body
|
||||
# to the file and exits 0, and the first sign of trouble is tar saying
|
||||
# "not in gzip format" about what is actually a page of HTML.
|
||||
if ! curl -fL -o "$temp_file" "$url" 2>/dev/null; then
|
||||
echo "✗ Failed to download $arch from $url"
|
||||
rm -rf "$temp_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Belt and braces: a mirror or proxy can return 200 with an error page.
|
||||
if ! gzip -t "$temp_file" 2>/dev/null; then
|
||||
echo "✗ Downloaded file is not a gzip archive: $url"
|
||||
# These bytes come from whatever answered the request, so strip
|
||||
# everything non-printable before echoing them: an error page carrying
|
||||
# terminal escapes would otherwise be able to rewrite this output or
|
||||
# bury it in a CI log. Printable characters are kept rather than
|
||||
# hex-encoding the lot, because "<!DOCTYPE html>" is the diagnostic.
|
||||
local first_bytes
|
||||
first_bytes=$(head -c 60 "$temp_file" | tr -cd '[:print:]')
|
||||
printf ' (first bytes: %s)\n' "$first_bytes"
|
||||
if ! curl -L -o "$temp_file" "$url" 2>/dev/null; then
|
||||
echo "✗ Failed to download $arch"
|
||||
rm -rf "$temp_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
+12
-73
@@ -12,8 +12,6 @@ Follows LEDMatrix configuration management patterns:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
import json
|
||||
from typing import Dict, Any, Optional, List
|
||||
@@ -44,35 +42,10 @@ class BaseOddsManager:
|
||||
self.config_manager = config_manager
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.base_url = "https://sports.core.api.espn.com/v2/sports"
|
||||
|
||||
# This path used a bare requests.get, so it identified itself as
|
||||
# python-requests/x.y -- the one thing ESPN is known to reject. Around
|
||||
# 2026-08-04 it began 403ing browser strings and bare custom tokens
|
||||
# alike; what it accepts is a token with a URL that says who is
|
||||
# calling. Every other ESPN caller in the tree already sends this
|
||||
# (src/common/api_helper.py, src/base_classes/data_sources.py); the
|
||||
# odds path was simply missed, and it is the one whose failures cost
|
||||
# the caller its whole update budget.
|
||||
#
|
||||
# Deliberately no retry adapter, unlike api_helper: retries multiply
|
||||
# request_timeout, which is set to 5s precisely to stay inside that
|
||||
# budget. One try, then the cooldown below.
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
|
||||
'Accept': 'application/json',
|
||||
})
|
||||
|
||||
# Configuration with defaults
|
||||
self.update_interval = 3600 # 1 hour default
|
||||
# Well under the plugin executor's 30s operation budget. At 30s a
|
||||
# single stalled ESPN request consumed the entire budget and the whole
|
||||
# update() was killed -- and odds are fetched per live game, inside the
|
||||
# live update loop, with show_odds defaulting on. Losing one game's
|
||||
# odds beats losing the update that carries every game's score.
|
||||
self.request_timeout = 5
|
||||
# Set when a request fails; until then, skip the network entirely.
|
||||
self._skip_network_until = 0.0
|
||||
self.request_timeout = 30 # 30 seconds default
|
||||
self.cache_ttl = 1800 # 30 minutes default
|
||||
|
||||
# Load configuration if available
|
||||
@@ -100,14 +73,6 @@ class BaseOddsManager:
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}")
|
||||
|
||||
# After a network failure, stop trying for this long and serve cache only.
|
||||
# A short per-request timeout bounds one stall, but a full Sunday slate is
|
||||
# ~16 games fetched in a loop, so 16 consecutive timeouts still blow the
|
||||
# budget. When ESPN is unreachable it is unreachable for all of them, so
|
||||
# the first failure is enough to know: skip the rest of this pass and try
|
||||
# again shortly.
|
||||
_FAILURE_COOLDOWN = 60.0
|
||||
|
||||
def get_odds(self, sport: str | None, league: str | None, event_id: str,
|
||||
update_interval_seconds: int = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -136,18 +101,8 @@ class BaseOddsManager:
|
||||
self.logger.info(f"Using cached odds from ESPN for {cache_key}")
|
||||
return cached_data
|
||||
|
||||
if time.monotonic() < self._skip_network_until:
|
||||
# A recent request failed, so ESPN is very likely still unreachable.
|
||||
# Returning now keeps the caller's update inside its time budget
|
||||
# instead of paying the timeout again for every remaining game.
|
||||
self.logger.debug(
|
||||
"Skipping odds fetch for %s: a recent request failed, holding off "
|
||||
"for another %.0fs", cache_key,
|
||||
self._skip_network_until - time.monotonic())
|
||||
return None
|
||||
|
||||
self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
|
||||
|
||||
|
||||
try:
|
||||
# Map league names to ESPN API format
|
||||
league_mapping = {
|
||||
@@ -162,12 +117,10 @@ class BaseOddsManager:
|
||||
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
||||
self.logger.info(f"Requesting odds from URL: {url}")
|
||||
|
||||
response = self.session.get(url, timeout=self.request_timeout)
|
||||
response = requests.get(url, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
raw_data = response.json()
|
||||
|
||||
self._skip_network_until = 0.0 # reachable again
|
||||
|
||||
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
|
||||
|
||||
odds_data = self._extract_espn_data(raw_data)
|
||||
@@ -187,11 +140,7 @@ class BaseOddsManager:
|
||||
return odds_data
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
|
||||
self.logger.error(
|
||||
"Error fetching odds from ESPN API for %s: %s. Holding off on odds "
|
||||
"for %.0fs so a slate of games does not pay this timeout each.",
|
||||
cache_key, e, self._FAILURE_COOLDOWN)
|
||||
self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}")
|
||||
except json.JSONDecodeError:
|
||||
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
|
||||
|
||||
@@ -214,25 +163,19 @@ class BaseOddsManager:
|
||||
item = data["items"][0]
|
||||
self.logger.debug(f"First item keys: {list(item.keys())}")
|
||||
|
||||
# The ESPN API returns odds data directly in the item, not in a
|
||||
# providers array. ESPN sends explicit JSON nulls for absent
|
||||
# sides ("homeTeamOdds": null), so every level uses `or {}` —
|
||||
# .get's default only applies when the key is missing entirely.
|
||||
home = item.get("homeTeamOdds") or {}
|
||||
away = item.get("awayTeamOdds") or {}
|
||||
# The ESPN API returns odds data directly in the item, not in a providers array
|
||||
# Extract the odds data directly from the item
|
||||
extracted_data = {
|
||||
"details": item.get("details"),
|
||||
"over_under": item.get("overUnder"),
|
||||
"spread": item.get("spread"),
|
||||
"home_team_odds": {
|
||||
"money_line": home.get("moneyLine"),
|
||||
"spread_odds": ((home.get("current") or {})
|
||||
.get("pointSpread") or {}).get("value")
|
||||
"money_line": item.get("homeTeamOdds", {}).get("moneyLine"),
|
||||
"spread_odds": item.get("homeTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
|
||||
},
|
||||
"away_team_odds": {
|
||||
"money_line": away.get("moneyLine"),
|
||||
"spread_odds": ((away.get("current") or {})
|
||||
.get("pointSpread") or {}).get("value")
|
||||
"money_line": item.get("awayTeamOdds", {}).get("moneyLine"),
|
||||
"spread_odds": item.get("awayTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
|
||||
}
|
||||
}
|
||||
self.logger.debug(f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}")
|
||||
@@ -317,13 +260,9 @@ class BaseOddsManager:
|
||||
Returns:
|
||||
Formatted odds summary string
|
||||
"""
|
||||
# Gate only on truly-empty / negative-cached data. is_odds_available
|
||||
# deliberately ignores money lines (its callers decide whether to
|
||||
# RENDER an odds widget), but a summary of money-line-only odds is
|
||||
# still meaningful — the parts loop below handles them.
|
||||
if not odds_data or odds_data.get('no_odds'):
|
||||
if not self.is_odds_available(odds_data):
|
||||
return "No odds available"
|
||||
|
||||
|
||||
parts = []
|
||||
|
||||
# Add spread information
|
||||
|
||||
Vendored
+1
-80
@@ -14,13 +14,6 @@ import zlib
|
||||
from typing import Dict, Any, Optional, Protocol
|
||||
from datetime import datetime
|
||||
|
||||
# How old an abandoned write's temp file must be before the sweep removes it.
|
||||
# A real write holds its temp file for milliseconds, so an hour is far beyond
|
||||
# any in-flight write while still clearing the same day's debris. Deliberately
|
||||
# not tied to the retention policies: those describe how long data stays
|
||||
# useful, and a half-written file was never useful.
|
||||
_ORPHAN_TEMP_MAX_AGE_SECONDS = 3600
|
||||
|
||||
|
||||
|
||||
class CacheStrategyProtocol(Protocol):
|
||||
@@ -119,22 +112,6 @@ class DiskCache:
|
||||
record_ts = None
|
||||
|
||||
now = time.time()
|
||||
|
||||
# An explicit per-entry ttl wins over the caller's max_age. The
|
||||
# caller that wrote the record knows what its data is; max_age is
|
||||
# inferred from substrings in the key ("live", "odds", "stock") and
|
||||
# is only a fallback for records that never said. Until now the ttl
|
||||
# was stored and ignored, so `set(key, data, ttl=...)` did nothing
|
||||
# at all -- 48 plugin call sites and 4 in the core were writing a
|
||||
# number no read path consulted.
|
||||
effective_max_age = max_age
|
||||
if isinstance(record, dict):
|
||||
stored_ttl = record.get('ttl')
|
||||
if isinstance(stored_ttl, (int, float)) and not isinstance(stored_ttl, bool) \
|
||||
and stored_ttl >= 0:
|
||||
effective_max_age = stored_ttl
|
||||
max_age = effective_max_age
|
||||
|
||||
# max_age=None means "never expires" (mirrors MemoryCache and the
|
||||
# cache_manager docstring). Guard it explicitly — otherwise the
|
||||
# comparison below raises TypeError and the record is treated as a
|
||||
@@ -354,23 +331,6 @@ class DiskCache:
|
||||
"""Get the cache directory path."""
|
||||
return self.cache_dir
|
||||
|
||||
@staticmethod
|
||||
def _is_orphaned_temp(filename: str) -> bool:
|
||||
"""Whether a name is one of set()'s temp files rather than real data.
|
||||
|
||||
Matches only what this class creates: mkstemp with a prefix of
|
||||
".<cache filename>." , so ".weather.json.a1b2c3d4". The shape is
|
||||
checked rather than just the leading dot, because this predicate
|
||||
deletes things -- a stray dotfile someone left in the cache directory
|
||||
is not ours to remove, and a completed ".json" never is either.
|
||||
"""
|
||||
if not filename.startswith('.') or filename.endswith('.json'):
|
||||
return False
|
||||
head, sep, suffix = filename.rpartition('.json.')
|
||||
# head is the key (non-empty after the leading dot), suffix is
|
||||
# mkstemp's random component.
|
||||
return bool(sep) and len(head) > 1 and bool(suffix)
|
||||
|
||||
def cleanup_expired_files(self, cache_strategy: CacheStrategyProtocol, retention_policies: Dict[str, int]) -> Dict[str, Any]:
|
||||
"""
|
||||
Clean up expired cache files based on retention policies.
|
||||
@@ -405,50 +365,11 @@ class DiskCache:
|
||||
try:
|
||||
with self._lock:
|
||||
# Get snapshot of files while holding lock briefly
|
||||
entries = os.listdir(self.cache_dir)
|
||||
filenames = [f for f in os.listdir(self.cache_dir) if f.endswith('.json')]
|
||||
except OSError as list_error:
|
||||
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
|
||||
stats['errors'] += 1
|
||||
return stats
|
||||
|
||||
filenames = [f for f in entries if f.endswith('.json')]
|
||||
|
||||
# Sweep temp files abandoned by a write that never finished. set()
|
||||
# removes its own in a finally, so these are the ones where the
|
||||
# process died between mkstemp and os.replace -- a SIGKILL, a lost
|
||||
# restart race, a power cut. Nothing ever collected them: they are
|
||||
# named ".<key>.json.<random>", and the scan above only matches
|
||||
# names ending in .json, so they accumulated indefinitely. Measured
|
||||
# on a live rig: 76 files, 1,050 MB, 81% of the whole cache
|
||||
# directory, the oldest six months old.
|
||||
stats['orphan_temp_files_deleted'] = 0
|
||||
for filename in (f for f in entries if self._is_orphaned_temp(f)):
|
||||
# Counted as scanned like any other candidate, so files_deleted
|
||||
# can never exceed files_scanned and the summary line reads
|
||||
# honestly ("77/8864", not "77/0").
|
||||
stats['files_scanned'] += 1
|
||||
path = os.path.join(self.cache_dir, filename)
|
||||
try:
|
||||
# An in-flight write lives for milliseconds, so anything
|
||||
# this old is certainly abandoned rather than in progress.
|
||||
if (current_time - os.path.getmtime(path)) <= _ORPHAN_TEMP_MAX_AGE_SECONDS:
|
||||
continue
|
||||
with self._lock:
|
||||
size = os.path.getsize(path)
|
||||
os.remove(path)
|
||||
stats['files_deleted'] += 1
|
||||
stats['orphan_temp_files_deleted'] += 1
|
||||
stats['space_freed_bytes'] += size
|
||||
except FileNotFoundError:
|
||||
continue # another sweep got there first
|
||||
except OSError as e:
|
||||
stats['errors'] += 1
|
||||
self.logger.warning("Error deleting orphaned temp file %s: %s", filename, e)
|
||||
|
||||
if stats['orphan_temp_files_deleted']:
|
||||
self.logger.info(
|
||||
"Removed %d abandoned cache temp file(s)",
|
||||
stats['orphan_temp_files_deleted'])
|
||||
|
||||
# Process files outside the lock to avoid blocking get/set operations
|
||||
for filename in filenames:
|
||||
|
||||
Vendored
+16
-85
@@ -4,58 +4,11 @@ Memory Cache
|
||||
Handles in-memory caching with TTL support, size limits, and automatic cleanup.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
# Historical fixed ceiling, kept as the fallback when RAM cannot be read.
|
||||
DEFAULT_MAX_SIZE = 1000
|
||||
|
||||
|
||||
def _total_memory_mb() -> Optional[float]:
|
||||
"""Physical RAM in MB, or None where /proc/meminfo is unavailable."""
|
||||
try:
|
||||
with open('/proc/meminfo', 'r', encoding='utf-8') as fh:
|
||||
for line in fh:
|
||||
if line.startswith('MemTotal:'):
|
||||
return int(line.split()[1]) / 1024
|
||||
except (OSError, ValueError, IndexError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def default_max_size() -> int:
|
||||
"""Entry ceiling scaled to this machine's RAM.
|
||||
|
||||
One fixed ceiling cannot serve both a 512 MB Pi Zero 2 W and an 8 GB Pi 5.
|
||||
Entries here are parsed API payloads that routinely run tens of kilobytes
|
||||
each, so a thousand of them is a comfortable cache on a large board and a
|
||||
substantial fraction of total RAM on a small one — where the process
|
||||
competing for that RAM is also driving the panel. Set
|
||||
LEDMATRIX_CACHE_MAX_ENTRIES to override.
|
||||
"""
|
||||
override = os.environ.get('LEDMATRIX_CACHE_MAX_ENTRIES')
|
||||
if override:
|
||||
try:
|
||||
value = int(override)
|
||||
if value > 0:
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total_mb = _total_memory_mb()
|
||||
if total_mb is None:
|
||||
return DEFAULT_MAX_SIZE
|
||||
if total_mb < 1536: # 512 MB and 1 GB boards
|
||||
return 150
|
||||
if total_mb < 3072: # 2 GB
|
||||
return 400
|
||||
if total_mb < 6144: # 4 GB
|
||||
return 800
|
||||
return 1500 # 8 GB and up
|
||||
|
||||
|
||||
class MemoryCache:
|
||||
"""Manages in-memory cache with TTL and size limits."""
|
||||
@@ -104,16 +57,6 @@ class MemoryCache:
|
||||
if timestamp is None:
|
||||
return None
|
||||
|
||||
# An explicit per-entry ttl wins over the caller's max_age, matching
|
||||
# DiskCache. max_age is inferred from substrings in the key and is
|
||||
# only a fallback for records that did not say what they wanted.
|
||||
record = self._cache[key]
|
||||
if isinstance(record, dict):
|
||||
stored_ttl = record.get('ttl')
|
||||
if isinstance(stored_ttl, (int, float)) and not isinstance(stored_ttl, bool) \
|
||||
and stored_ttl >= 0:
|
||||
max_age = stored_ttl
|
||||
|
||||
# Check expiration
|
||||
if max_age is not None and (now - timestamp) > max_age:
|
||||
# Expired - remove it
|
||||
@@ -134,32 +77,6 @@ class MemoryCache:
|
||||
with self._lock:
|
||||
self._cache[key] = value
|
||||
self._timestamps[key] = time.time()
|
||||
# Enforce the ceiling here rather than leaving it to the periodic
|
||||
# cleanup, which only runs every cleanup_interval seconds (300 by
|
||||
# default). A burst of inserts between two sweeps could otherwise
|
||||
# take the cache far past _max_size, which is the memory growth this
|
||||
# limit exists to prevent -- and on a 1GB board that is the
|
||||
# difference between a bounded cache and an unreachable Pi.
|
||||
self._evict_over_limit_locked()
|
||||
|
||||
def _evict_over_limit_locked(self) -> int:
|
||||
"""Drop oldest entries until the cache is within _max_size.
|
||||
|
||||
Caller must hold self._lock. Returns the number of entries removed.
|
||||
"""
|
||||
excess = len(self._cache) - self._max_size
|
||||
if excess <= 0:
|
||||
return 0
|
||||
oldest = sorted(
|
||||
self._timestamps.items(),
|
||||
key=lambda item: float(item[1]) if isinstance(item[1], (int, float)) else 0.0
|
||||
)
|
||||
removed = 0
|
||||
for key, _ in oldest[:excess]:
|
||||
self._cache.pop(key, None)
|
||||
self._timestamps.pop(key, None)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
def clear(self, key: Optional[str] = None) -> None:
|
||||
"""
|
||||
@@ -216,8 +133,22 @@ class MemoryCache:
|
||||
self._timestamps.pop(key, None)
|
||||
removed_count += 1
|
||||
|
||||
# Same ceiling enforcement set() uses, so the two cannot drift.
|
||||
removed_count += self._evict_over_limit_locked()
|
||||
# Enforce size limit by removing oldest entries if cache is too large
|
||||
if len(self._cache) > self._max_size:
|
||||
# Sort by timestamp (oldest first)
|
||||
sorted_entries = sorted(
|
||||
self._timestamps.items(),
|
||||
key=lambda x: float(x[1]) if isinstance(x[1], (int, float)) else 0
|
||||
)
|
||||
|
||||
# Remove oldest entries until we're under the limit
|
||||
excess_count = len(self._cache) - self._max_size
|
||||
for i in range(excess_count):
|
||||
if i < len(sorted_entries):
|
||||
key = sorted_entries[i][0]
|
||||
self._cache.pop(key, None)
|
||||
self._timestamps.pop(key, None)
|
||||
removed_count += 1
|
||||
|
||||
self._last_cleanup = current_time
|
||||
|
||||
|
||||
+8
-51
@@ -33,7 +33,7 @@ import logging
|
||||
import threading
|
||||
import tempfile
|
||||
from src.exceptions import CacheError
|
||||
from src.cache.memory_cache import MemoryCache, default_max_size
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
from src.cache.disk_cache import DiskCache
|
||||
from src.cache.cache_strategy import CacheStrategy
|
||||
from src.cache.cache_metrics import CacheMetrics
|
||||
@@ -46,21 +46,7 @@ from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-e
|
||||
|
||||
class CacheManager:
|
||||
"""Manages caching of API responses to reduce API calls."""
|
||||
|
||||
# Which cache directories already have a cleanup thread in this process.
|
||||
#
|
||||
# The sweep is directory-scoped work -- it lists a directory and deletes
|
||||
# from it -- so one per directory is the right number no matter how many
|
||||
# managers exist. Nothing enforced that before: every instance started its
|
||||
# own, and because the loop closes over `self`, a discarded manager could
|
||||
# never be collected and its thread woke to re-scan the same directory
|
||||
# every 24 hours for the life of the process. Startup validation runs
|
||||
# twice and built a throwaway manager each time, so a display process
|
||||
# carried three threads for one cache.
|
||||
_cleanup_owners: Dict[str, 'CacheManager'] = {}
|
||||
_cleanup_owners_lock = threading.Lock()
|
||||
|
||||
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Initialize logger first
|
||||
self.logger: logging.Logger = get_logger(__name__)
|
||||
@@ -84,9 +70,7 @@ class CacheManager:
|
||||
self.logger.warning("ConfigManager not available, using default cache intervals")
|
||||
|
||||
# Initialize cache components using composition
|
||||
self._memory_cache_component = MemoryCache(
|
||||
max_size=default_max_size(), cleanup_interval=300.0
|
||||
)
|
||||
self._memory_cache_component = MemoryCache(max_size=1000, cleanup_interval=300.0)
|
||||
self._disk_cache_component = DiskCache(cache_dir=self.cache_dir, logger=self.logger)
|
||||
self._strategy_component = CacheStrategy(config_manager=self.config_manager, logger=self.logger)
|
||||
self._metrics_component = CacheMetrics(logger=self.logger)
|
||||
@@ -610,10 +594,8 @@ class CacheManager:
|
||||
Args:
|
||||
key: Cache key
|
||||
data: Data to cache
|
||||
ttl: Time-to-live in seconds for this entry. Takes precedence over
|
||||
the max_age a reader would otherwise apply, which is inferred
|
||||
from the key and is only a fallback for entries that did not
|
||||
say. Omit it to keep that inferred behaviour.
|
||||
ttl: Optional time-to-live in seconds (stored for compatibility but
|
||||
expiration is still controlled via max_age when reading)
|
||||
"""
|
||||
cache_data = {
|
||||
'data': data,
|
||||
@@ -734,29 +716,11 @@ class CacheManager:
|
||||
}
|
||||
|
||||
def start_cleanup_thread(self) -> None:
|
||||
"""Start background thread for periodic disk cache cleanup.
|
||||
|
||||
At most one thread per cache directory per process: the sweep is
|
||||
directory-scoped, so a second one only duplicates the scan.
|
||||
"""
|
||||
"""Start background thread for periodic disk cache cleanup."""
|
||||
if self._cleanup_thread and self._cleanup_thread.is_alive():
|
||||
self.logger.debug("Cleanup thread already running")
|
||||
return
|
||||
|
||||
with CacheManager._cleanup_owners_lock:
|
||||
owner = CacheManager._cleanup_owners.get(self.cache_dir)
|
||||
if owner is not None and owner is not self:
|
||||
thread = owner._cleanup_thread
|
||||
if thread is not None and thread.is_alive():
|
||||
self.logger.debug(
|
||||
"Cleanup thread for %s already owned by another cache "
|
||||
"manager in this process; not starting a second",
|
||||
self.cache_dir)
|
||||
return
|
||||
# The owner's thread died or was stopped -- take over.
|
||||
CacheManager._cleanup_owners[self.cache_dir] = self
|
||||
|
||||
|
||||
|
||||
def cleanup_loop():
|
||||
"""Background loop that runs cleanup periodically."""
|
||||
self.logger.info("Disk cache cleanup thread started (interval: %d hours)",
|
||||
@@ -804,17 +768,10 @@ class CacheManager:
|
||||
Signals the thread to stop and waits for it to finish (with timeout).
|
||||
This allows for clean shutdown during testing or application termination.
|
||||
"""
|
||||
# Release ownership first and unconditionally, so a manager that never
|
||||
# started a thread (or whose thread already exited) cannot keep the
|
||||
# directory claimed and block a live manager from sweeping it.
|
||||
with CacheManager._cleanup_owners_lock:
|
||||
if CacheManager._cleanup_owners.get(self.cache_dir) is self:
|
||||
del CacheManager._cleanup_owners[self.cache_dir]
|
||||
|
||||
if not self._cleanup_thread or not self._cleanup_thread.is_alive():
|
||||
self.logger.debug("Cleanup thread not running")
|
||||
return
|
||||
|
||||
|
||||
self.logger.info("Stopping disk cache cleanup thread...")
|
||||
self._cleanup_stop_event.set() # Signal thread to stop
|
||||
|
||||
|
||||
+12
-27
@@ -272,35 +272,20 @@ class APIHelper:
|
||||
def clear_cache(self, pattern: Optional[str] = None) -> None:
|
||||
"""
|
||||
Clear cache data.
|
||||
|
||||
Uses CacheManager's real surface (clear_cache / delete /
|
||||
list_cache_files); safely no-ops on managers without it. The old
|
||||
implementation guarded on a nonexistent ``clear`` method, so it
|
||||
silently never cleared anything.
|
||||
|
||||
|
||||
Args:
|
||||
pattern: Optional substring to match cache keys; only matching
|
||||
entries are deleted.
|
||||
pattern: Optional pattern to match cache keys
|
||||
"""
|
||||
if not self.cache_manager:
|
||||
return
|
||||
if pattern:
|
||||
if (hasattr(self.cache_manager, 'list_cache_files')
|
||||
and hasattr(self.cache_manager, 'delete')):
|
||||
for entry in self.cache_manager.list_cache_files():
|
||||
key = entry.get('key') if isinstance(entry, dict) else None
|
||||
if key and pattern in key:
|
||||
self.cache_manager.delete(key)
|
||||
else:
|
||||
self.logger.debug(
|
||||
"Cache manager lacks list_cache_files/delete; "
|
||||
"cannot clear by pattern")
|
||||
elif hasattr(self.cache_manager, 'clear_cache'):
|
||||
self.cache_manager.clear_cache()
|
||||
elif hasattr(self.cache_manager, 'clear'):
|
||||
self.cache_manager.clear()
|
||||
else:
|
||||
self.logger.debug("Cache manager exposes no clear method; no-op")
|
||||
if self.cache_manager:
|
||||
if hasattr(self.cache_manager, 'clear'):
|
||||
if pattern:
|
||||
# Clear only keys matching pattern
|
||||
keys = self.cache_manager.keys()
|
||||
for key in keys:
|
||||
if pattern in key:
|
||||
self.cache_manager.delete(key)
|
||||
else:
|
||||
self.cache_manager.clear()
|
||||
|
||||
def _get_from_cache(self, key: str) -> Optional[Any]:
|
||||
"""Get data from cache."""
|
||||
|
||||
@@ -5,7 +5,6 @@ Handles configuration management and validation for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -161,20 +160,17 @@ class ConfigHelper:
|
||||
override_config: Configuration to merge in (takes precedence)
|
||||
|
||||
Returns:
|
||||
Merged configuration dictionary (fully independent of both
|
||||
inputs — a shallow copy would alias un-overridden nested dicts,
|
||||
so mutating the result would mutate the caller's base config).
|
||||
Merged configuration dictionary
|
||||
"""
|
||||
merged = copy.deepcopy(base_config)
|
||||
|
||||
merged = base_config.copy()
|
||||
|
||||
for key, value in override_config.items():
|
||||
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
||||
# Recursively merge nested dictionaries
|
||||
merged[key] = self.merge_configs(merged[key], value)
|
||||
else:
|
||||
# Override with new value — deep-copied so mutating the
|
||||
# merged result can't reach back into override_config.
|
||||
merged[key] = copy.deepcopy(value)
|
||||
# Override with new value
|
||||
merged[key] = value
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
@@ -115,13 +115,17 @@ class DisplayHelper:
|
||||
if home_logo and away_logo:
|
||||
self._draw_logos(main_img, home_logo, away_logo)
|
||||
|
||||
# Draw one combined top line (period/status/clock all share y=1 —
|
||||
# drawing them separately overprinted each other).
|
||||
top_line = " ".join(p for p in [period_text, status_text, clock] if p)
|
||||
if top_line:
|
||||
self._draw_centered_text(draw, top_line,
|
||||
fonts.get('time', fonts.get('status')),
|
||||
y_position=1)
|
||||
# Draw status/period text (top center)
|
||||
if status_text or period_text:
|
||||
status_display = f"{period_text} {status_text}".strip()
|
||||
if status_display:
|
||||
self._draw_centered_text(draw, status_display,
|
||||
fonts.get('time', fonts.get('status')),
|
||||
y_position=1)
|
||||
|
||||
# Draw clock if available
|
||||
if clock:
|
||||
self._draw_centered_text(draw, clock, fonts.get('time'), y_position=1)
|
||||
|
||||
# Draw scores (center)
|
||||
score_text = f"{away_score}-{home_score}"
|
||||
@@ -149,28 +153,26 @@ class DisplayHelper:
|
||||
"""
|
||||
Draw a ticker/scrolling text layout.
|
||||
|
||||
Renders a single static frame with the text at the left edge; the
|
||||
caller advances the scroll by re-rendering or shifting. The
|
||||
scroll_speed parameter is accepted for API compatibility but does
|
||||
not affect this frame. (Previously the text was drawn starting at
|
||||
x=display_width — entirely off-canvas — so every frame was blank.)
|
||||
|
||||
Args:
|
||||
text: Text to display
|
||||
font: Font to use
|
||||
background_color: Background color
|
||||
text_color: Text color
|
||||
scroll_speed: Accepted for compatibility; unused per-frame
|
||||
|
||||
scroll_speed: Pixels to scroll per frame
|
||||
|
||||
Returns:
|
||||
PIL Image with ticker layout
|
||||
"""
|
||||
img = self.create_base_image(background_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
self._draw_text_with_outline(draw, text, (0, self.display_height // 2 - 6),
|
||||
|
||||
# Start text off-screen to the right
|
||||
x_position = self.display_width
|
||||
|
||||
# Draw text
|
||||
self._draw_text_with_outline(draw, text, (x_position, self.display_height // 2 - 6),
|
||||
font, fill=text_color)
|
||||
|
||||
|
||||
return img
|
||||
|
||||
def draw_centered_text(self, text: str, font: ImageFont.ImageFont,
|
||||
@@ -212,9 +214,15 @@ class DisplayHelper:
|
||||
Returns:
|
||||
PIL Image with error message
|
||||
"""
|
||||
# Dark red background, white text
|
||||
img = self.create_base_image((50, 0, 0)) # Dark red background
|
||||
|
||||
# Use default font
|
||||
font = ImageFont.load_default()
|
||||
return self.draw_centered_text(message, font, (50, 0, 0), (255, 255, 255))
|
||||
|
||||
# Draw centered error message
|
||||
self._draw_centered_text(message, font, (50, 0, 0), (255, 255, 255))
|
||||
|
||||
return img
|
||||
|
||||
def draw_no_data_message(self, message: str = "No Data") -> Image.Image:
|
||||
"""
|
||||
@@ -226,8 +234,11 @@ class DisplayHelper:
|
||||
Returns:
|
||||
PIL Image with no data message
|
||||
"""
|
||||
img = self.create_base_image((0, 0, 0))
|
||||
font = ImageFont.load_default()
|
||||
return self.draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
||||
self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
||||
|
||||
return img
|
||||
|
||||
def get_display_dimensions(self) -> Tuple[int, int]:
|
||||
"""
|
||||
|
||||
+12
-64
@@ -6,8 +6,6 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
@@ -21,10 +19,6 @@ from src.common.permission_utils import (
|
||||
)
|
||||
|
||||
|
||||
# Well above any real team logo; bounds what a remote URL can write to disk.
|
||||
MAX_LOGO_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
class LogoHelper:
|
||||
"""
|
||||
Helper class for logo loading, caching, and resizing.
|
||||
@@ -232,10 +226,7 @@ class LogoHelper:
|
||||
return {
|
||||
'cached_logos': len(self._logo_cache),
|
||||
'cache_size_limit': self.cache_size,
|
||||
'cache_usage_percent': (
|
||||
(len(self._logo_cache) / self.cache_size) * 100
|
||||
if self.cache_size else 0
|
||||
),
|
||||
'cache_usage_percent': (len(self._logo_cache) / self.cache_size) * 100
|
||||
}
|
||||
|
||||
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
|
||||
@@ -267,64 +258,21 @@ class LogoHelper:
|
||||
self._cache_order.append(cache_key)
|
||||
|
||||
def _download_logo(self, url: str, file_path: Path) -> None:
|
||||
"""Download logo from URL.
|
||||
|
||||
The response size is capped and the saved file is verified as a
|
||||
decodable image before it is left on disk: a logo URL is remote
|
||||
input, and without this an oversized or malformed response would
|
||||
be cached for every later load_logo() call to trip over.
|
||||
|
||||
The body is streamed and counted as it arrives rather than read
|
||||
through response.content, which buffers the whole thing first —
|
||||
a server that omits Content-Length and never stops sending would
|
||||
exhaust memory before any size check could run. Nothing lands at
|
||||
file_path until the download completes and decodes, so a failed
|
||||
download cannot leave a truncated logo behind either.
|
||||
"""
|
||||
"""Download logo from URL."""
|
||||
# Ensure directory exists with proper permissions
|
||||
ensure_directory_permissions(file_path.parent, get_assets_dir_mode())
|
||||
|
||||
# A unique temp name, not a fixed "<name>.part": two plugins can
|
||||
# ask for the same logo at once, and a shared name would let them
|
||||
# interleave writes into one file, publish the mixture, or delete
|
||||
# each other's partial. Same directory, so os.replace stays atomic.
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(file_path.parent), prefix=file_path.name + '.', suffix='.part')
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
# fdopen outermost so the descriptor mkstemp handed back is
|
||||
# always adopted and closed, including when the request itself
|
||||
# raises — load_logo_with_download swallows that, so a leak
|
||||
# here would accumulate quietly on a URL that keeps failing.
|
||||
with os.fdopen(fd, 'wb') as f:
|
||||
with self.session.get(url, timeout=30, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
downloaded = 0
|
||||
for chunk in response.iter_content(chunk_size=64 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
downloaded += len(chunk)
|
||||
if downloaded > MAX_LOGO_BYTES:
|
||||
raise ValueError(
|
||||
f"Logo at {url} exceeds the "
|
||||
f"{MAX_LOGO_BYTES}-byte limit; not saved")
|
||||
f.write(chunk)
|
||||
|
||||
# Verify it decodes before it becomes the cached logo. PIL
|
||||
# raises DecompressionBombError past its own pixel limit; a
|
||||
# partial or non-image response raises UnidentifiedImageError
|
||||
# (an OSError subclass).
|
||||
with Image.open(tmp_path) as probe:
|
||||
probe.load()
|
||||
|
||||
os.replace(tmp_path, file_path)
|
||||
except BaseException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
# Download with timeout
|
||||
response = self.session.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
# Save to file
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
# Set proper file permissions after saving
|
||||
ensure_file_permissions(file_path, get_assets_file_mode())
|
||||
|
||||
|
||||
self.logger.debug(f"Downloaded logo to {file_path}")
|
||||
|
||||
def _create_placeholder_logo(self, team_abbr: str,
|
||||
|
||||
+36
-94
@@ -19,7 +19,6 @@ Port default: 5765 (UDP). Open this port on both Pis if ufw is active:
|
||||
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
@@ -38,13 +37,6 @@ _RAW_MAGIC = b'SYNC_RAW'
|
||||
_RAW_HEADER = struct.Struct('<HH') # width, height (uint16 LE)
|
||||
|
||||
|
||||
# Upper bound on a decoded frame/scroll image. Generous for any real scroll
|
||||
# image (a leader's full cycle is long but only panel-height tall), and low
|
||||
# enough that a crafted image from any host on the LAN cannot force a large
|
||||
# allocation on the render thread. Applied on both receive paths — the TCP
|
||||
# image server and the follower's legacy-PNG UDP fallback.
|
||||
_MAX_FRAME_W, _MAX_FRAME_H = 100_000, 256
|
||||
|
||||
SYNC_PORT = 5765
|
||||
HELLO_INTERVAL = 5.0 # follower broadcasts hello every 5 s
|
||||
HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
|
||||
@@ -109,7 +101,6 @@ class DisplaySyncManager:
|
||||
self._peer_chain: int = 0
|
||||
self._last_heartbeat_time: float = 0.0
|
||||
self._leader_width: int = 0 # set by display_controller after init
|
||||
self._oversized_frame_warned: bool = False
|
||||
|
||||
# Follower state
|
||||
self._follower_state = FollowerState.STANDALONE
|
||||
@@ -183,10 +174,6 @@ class DisplaySyncManager:
|
||||
continue
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync leader recv error: %s", exc)
|
||||
# Brief backoff: a socket left in a bad state raises
|
||||
# immediately, which would otherwise spin this thread at
|
||||
# 100% CPU logging the same error.
|
||||
time.sleep(0.1)
|
||||
|
||||
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
||||
hw = self._hw_config
|
||||
@@ -286,10 +273,11 @@ class DisplaySyncManager:
|
||||
break
|
||||
data.extend(chunk)
|
||||
img = Image.open(io.BytesIO(data))
|
||||
if img.width > _MAX_FRAME_W or img.height > _MAX_FRAME_H:
|
||||
_MAX_W, _MAX_H = 100_000, 256 # generous for any real scroll image
|
||||
if img.width > _MAX_W or img.height > _MAX_H:
|
||||
self.logger.warning(
|
||||
"Sync: rejected oversized scroll image %dx%d (max %dx%d) from %s",
|
||||
img.width, img.height, _MAX_FRAME_W, _MAX_FRAME_H, addr,
|
||||
img.width, img.height, _MAX_W, _MAX_H, addr,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
@@ -408,7 +396,7 @@ class DisplaySyncManager:
|
||||
data = header + arr.tobytes()
|
||||
if len(data) <= 65000:
|
||||
self._send_sock.sendto(data, (self._peer_ip, self.port))
|
||||
elif not self._oversized_frame_warned:
|
||||
elif not getattr(self, '_oversized_frame_warned', False):
|
||||
self._oversized_frame_warned = True
|
||||
self.logger.warning(
|
||||
"Sync: frame too large for UDP (%d bytes, max 65000) — "
|
||||
@@ -463,76 +451,43 @@ class DisplaySyncManager:
|
||||
)
|
||||
self.write_status_file()
|
||||
|
||||
def _handle_received_frame(self, img: Image.Image, sender_ip: str) -> None:
|
||||
"""Record a decoded leader frame and enter follower mode if needed."""
|
||||
with self._frame_lock:
|
||||
self._latest_frame = img
|
||||
self._last_leader_frame_time = time.time()
|
||||
self._leader_ip = sender_ip
|
||||
|
||||
if self._follower_state == FollowerState.STANDALONE:
|
||||
self._follower_state = FollowerState.FOLLOWER
|
||||
self.logger.info(
|
||||
"Sync: leader active at %s — switching to follower mode",
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
|
||||
def _follower_recv_loop(self) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
data, addr = self._recv_sock.recvfrom(65535)
|
||||
sender_ip = addr[0]
|
||||
|
||||
if data[:8] == _RAW_MAGIC:
|
||||
# Magic-tagged raw RGB frame — self-describing, no guessing.
|
||||
if data[:8] == _RAW_MAGIC or len(data) > 512:
|
||||
# Frame data: prefer magic-tagged raw RGB; fall back to legacy PNG
|
||||
try:
|
||||
w, h = _RAW_HEADER.unpack(data[8:12])
|
||||
raw = data[12:]
|
||||
img = Image.frombuffer(
|
||||
"RGB", (w, h), raw, "raw", "RGB", 0, 1
|
||||
)
|
||||
self._handle_received_frame(img, sender_ip)
|
||||
if data[:8] == _RAW_MAGIC:
|
||||
w, h = _RAW_HEADER.unpack(data[8:12])
|
||||
raw = data[12:]
|
||||
img = Image.frombuffer(
|
||||
"RGB", (w, h), raw, "raw", "RGB", 0, 1
|
||||
)
|
||||
else:
|
||||
# Fallback: try legacy PNG
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img.load()
|
||||
with self._frame_lock:
|
||||
self._latest_frame = img
|
||||
self._last_leader_frame_time = time.time()
|
||||
self._leader_ip = sender_ip
|
||||
|
||||
if self._follower_state == FollowerState.STANDALONE:
|
||||
self._follower_state = FollowerState.FOLLOWER
|
||||
self.logger.info(
|
||||
"Sync: leader active at %s — switching to follower mode",
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: frame decode error: %s", exc)
|
||||
else:
|
||||
# No magic prefix. Whether the payload parses as JSON
|
||||
# decides between a control message and a legacy
|
||||
# (pre-magic) PNG frame — both wire formats are
|
||||
# self-describing, so no size heuristic is needed. A
|
||||
# >512-byte control message used to be misrouted into
|
||||
# image decode and silently dropped.
|
||||
# Control message
|
||||
try:
|
||||
msg = json.loads(data.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
# Not JSON — try a legacy PNG frame.
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
if img.width > _MAX_FRAME_W or img.height > _MAX_FRAME_H:
|
||||
# Same cap the TCP image path applies: decode
|
||||
# is deferred until load(), so check first.
|
||||
self.logger.debug(
|
||||
"Sync: rejected oversized legacy frame %dx%d from %s",
|
||||
img.width, img.height, sender_ip,
|
||||
)
|
||||
continue
|
||||
img.load()
|
||||
self._handle_received_frame(img, sender_ip)
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: frame decode error: %s", exc)
|
||||
continue
|
||||
|
||||
# It parsed, so it is a control message and never a
|
||||
# frame. Read and validate its fields under a guard —
|
||||
# a UDP payload is attacker-shaped, so a non-object
|
||||
# body makes .get() raise AttributeError and an "sx"
|
||||
# carrying a non-numeric x raises ValueError/TypeError
|
||||
# — but dispatch the callback *outside* it. Running
|
||||
# the callback in here would let a fault in someone
|
||||
# else's code read as a malformed packet and be
|
||||
# logged as one.
|
||||
fire_new_cycle = False
|
||||
try:
|
||||
t = msg.get("t")
|
||||
if t == "hello_ack":
|
||||
self._leader_ip = sender_ip
|
||||
@@ -546,17 +501,7 @@ class DisplaySyncManager:
|
||||
self.write_status_file()
|
||||
elif t == "sx":
|
||||
# Vegas scroll-position sync — tiny message, renders locally
|
||||
scroll_x = float(msg["x"])
|
||||
if not math.isfinite(scroll_x):
|
||||
# json.loads accepts the NaN/Infinity literals,
|
||||
# and float("nan") accepts the strings, so a
|
||||
# non-finite x reaches here intact. Left alone
|
||||
# it poisons every offset computed from it —
|
||||
# NaN comparisons are all false, so the
|
||||
# follower renders a frame it can never scroll
|
||||
# back from. Treat it as malformed.
|
||||
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
|
||||
self._latest_scroll_x = scroll_x
|
||||
self._latest_scroll_x = float(msg["x"])
|
||||
self._last_leader_frame_time = time.time()
|
||||
self._leader_ip = sender_ip
|
||||
if self._follower_state == FollowerState.STANDALONE:
|
||||
@@ -566,22 +511,19 @@ class DisplaySyncManager:
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
fire_new_cycle = True # build initial scroll image
|
||||
if self._on_new_cycle:
|
||||
self._on_new_cycle() # build initial scroll image
|
||||
elif t == "nc":
|
||||
# Leader started a new scroll cycle — rebuild local image
|
||||
fire_new_cycle = True
|
||||
except (KeyError, AttributeError, TypeError, ValueError) as exc:
|
||||
self.logger.debug("Sync: malformed control message: %s", exc)
|
||||
continue
|
||||
|
||||
if fire_new_cycle and self._on_new_cycle:
|
||||
self._on_new_cycle()
|
||||
if self._on_new_cycle:
|
||||
self._on_new_cycle()
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync follower recv error: %s", exc)
|
||||
time.sleep(0.1)
|
||||
|
||||
def _follower_announce_loop(self) -> None:
|
||||
hw = self._hw_config
|
||||
|
||||
+9
-64
@@ -269,47 +269,20 @@ class ConfigManager:
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||
|
||||
@staticmethod
|
||||
def _is_parallel_secrets_list(value: Any) -> bool:
|
||||
"""True for the parallel-placeholder list shape emitted by
|
||||
``secret_helpers.separate_secrets`` for array-item secrets: a
|
||||
non-empty list whose elements are ALL dicts (``{}`` marks an item
|
||||
with no secrets). Any other list-shaped secrets value is a
|
||||
whole-key secret (e.g. a list of secret scalars)."""
|
||||
return (isinstance(value, list) and bool(value)
|
||||
and all(isinstance(item, dict) for item in value))
|
||||
|
||||
def _strip_secrets_recursive(self, data_to_filter: Dict[str, Any], secrets: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Recursively remove secret keys from a dictionary."""
|
||||
result = {}
|
||||
for key, value in data_to_filter.items():
|
||||
if key not in secrets:
|
||||
if key in secrets:
|
||||
if isinstance(value, dict) and isinstance(secrets[key], dict):
|
||||
# This key is a shared group, recurse
|
||||
stripped_sub_dict = self._strip_secrets_recursive(value, secrets[key])
|
||||
if stripped_sub_dict: # Only add if there's non-secret data left
|
||||
result[key] = stripped_sub_dict
|
||||
# Else, it's a secret key at this level, so we skip it
|
||||
else:
|
||||
# This key is not in secrets, so we keep it
|
||||
result[key] = value
|
||||
continue
|
||||
sec = secrets[key]
|
||||
if isinstance(value, dict) and isinstance(sec, dict):
|
||||
# This key is a shared group, recurse
|
||||
stripped_sub_dict = self._strip_secrets_recursive(value, sec)
|
||||
if stripped_sub_dict: # Only add if there's non-secret data left
|
||||
result[key] = stripped_sub_dict
|
||||
elif isinstance(value, list) and self._is_parallel_secrets_list(sec):
|
||||
# Parallel-list shape from separate_secrets: sec[i] holds the
|
||||
# secret fields of value[i] ({} = item i has none). Strip each
|
||||
# item and ALWAYS keep the list — indices must survive so the
|
||||
# merge-on-load can realign secrets with their items. The
|
||||
# regular list's length is authoritative: extra secrets
|
||||
# entries are ignored.
|
||||
stripped_items = []
|
||||
for i, item in enumerate(value):
|
||||
s_item = sec[i] if i < len(sec) else {}
|
||||
if isinstance(item, dict) and s_item:
|
||||
stripped_items.append(self._strip_secrets_recursive(item, s_item))
|
||||
else:
|
||||
stripped_items.append(item)
|
||||
result[key] = stripped_items
|
||||
# Else: whole-key secret (scalar, list of secret scalars, or a
|
||||
# shape mismatch) -> drop the key entirely. Never leak.
|
||||
return result
|
||||
|
||||
def _load_secrets_for_save(self) -> Dict[str, Any]:
|
||||
@@ -385,39 +358,11 @@ class ConfigManager:
|
||||
return None
|
||||
|
||||
def _deep_merge(self, target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||
"""Deep merge source dict into target dict.
|
||||
|
||||
Sole call site: merging config_secrets.json into the loaded config.
|
||||
Understands the parallel-list shape separate_secrets emits for
|
||||
array-item secrets (see _is_parallel_secrets_list): each secrets
|
||||
list item is merged into the config list item at the same index
|
||||
({} placeholders skipped). The config list's length is
|
||||
authoritative — a user deleting an array item from config.json
|
||||
must not have it resurrected from a stale secrets entry."""
|
||||
"""Deep merge source dict into target dict."""
|
||||
for key, value in source.items():
|
||||
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
||||
self._deep_merge(target[key], value)
|
||||
elif (key in target and isinstance(target[key], list)
|
||||
and self._is_parallel_secrets_list(value)):
|
||||
tlist = target[key]
|
||||
for i, s_item in enumerate(value):
|
||||
if i >= len(tlist):
|
||||
# Interpolate only config-side data here — nothing
|
||||
# iterated out of the secrets dict (not even the key
|
||||
# name) may reach the log.
|
||||
self.logger.warning(
|
||||
"A secrets list is longer than the config list it "
|
||||
"parallels (config has %d item(s)); ignoring the "
|
||||
"extra entries", len(tlist))
|
||||
break
|
||||
if not s_item:
|
||||
continue # {} placeholder: item i has no secrets
|
||||
if isinstance(tlist[i], dict):
|
||||
self._deep_merge(tlist[i], s_item)
|
||||
else:
|
||||
tlist[i] = s_item # shape drift; the secret wins
|
||||
else:
|
||||
# Scalars AND whole-secret scalar arrays: replace (legacy).
|
||||
target[key] = value
|
||||
|
||||
def _create_config_from_template(self) -> None:
|
||||
|
||||
@@ -44,20 +44,6 @@ from src.common.sync_manager import DisplaySyncManager, SyncRole
|
||||
# Get logger with consistent configuration
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# How long startup will wait for plugins to fetch their first data before
|
||||
# showing anything. Each plugin's update blocks for up to the executor's 30s
|
||||
# timeout and they run one after another, so the uncapped total is the sum of
|
||||
# every slow plugin: 82 seconds on the worst boot measured, with a blank panel
|
||||
# throughout. Whatever does not finish in time is picked up by the scheduled
|
||||
# update tick moments later, with the display already running.
|
||||
_INITIAL_UPDATE_BUDGET_SECONDS = 20.0
|
||||
|
||||
# The least budget worth starting a plugin with. Below this the plugin is
|
||||
# deferred instead: granting it a floor would let the pass run past its
|
||||
# deadline, and granting it the true remainder would record a timeout for a
|
||||
# slot it never had a chance to use.
|
||||
_MIN_INITIAL_UPDATE_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
# Vegas mode import (lazy loaded to avoid circular imports)
|
||||
_vegas_mode_imported = False
|
||||
VegasModeCoordinator = None
|
||||
@@ -104,8 +90,7 @@ class DisplayController:
|
||||
# Validate startup configuration
|
||||
try:
|
||||
from src.startup_validator import StartupValidator
|
||||
validator = StartupValidator(self.config_manager,
|
||||
cache_manager=self.cache_manager)
|
||||
validator = StartupValidator(self.config_manager)
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
|
||||
if warnings:
|
||||
@@ -273,8 +258,7 @@ class DisplayController:
|
||||
# Validate plugins after plugin manager is created
|
||||
try:
|
||||
from src.startup_validator import StartupValidator
|
||||
validator = StartupValidator(self.config_manager, self.plugin_manager,
|
||||
cache_manager=self.cache_manager)
|
||||
validator = StartupValidator(self.config_manager, self.plugin_manager)
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
|
||||
if warnings:
|
||||
@@ -477,7 +461,7 @@ class DisplayController:
|
||||
# Initial data update for plugins (ensures data available on first display)
|
||||
logger.info("Performing initial plugin data update...")
|
||||
update_start = time.time()
|
||||
self._update_modules(deadline=update_start + _INITIAL_UPDATE_BUDGET_SECONDS)
|
||||
self._update_modules()
|
||||
logger.info("Initial plugin update completed in %.3f seconds", time.time() - update_start)
|
||||
|
||||
# Initialize Vegas mode coordinator
|
||||
@@ -833,42 +817,14 @@ class DisplayController:
|
||||
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
||||
return normal_brightness
|
||||
|
||||
def _update_modules(self, deadline: Optional[float] = None):
|
||||
"""Update all plugin modules.
|
||||
|
||||
Args:
|
||||
deadline: Wall-clock time after which remaining plugins are left
|
||||
for the scheduled update tick instead of being waited on. Each
|
||||
update blocks this thread for up to the executor's timeout, and
|
||||
they run one after another, so without a bound the total is the
|
||||
sum of every slow plugin on the system. Measured at startup on
|
||||
a live rig: 82 seconds, 55 and 26 on the two boots before -- all
|
||||
of it with nothing on the panel.
|
||||
"""
|
||||
def _update_modules(self):
|
||||
"""Update all plugin modules."""
|
||||
if not self.plugin_manager:
|
||||
return
|
||||
|
||||
|
||||
# Update all loaded plugins
|
||||
plugins_dict = getattr(self.plugin_manager, 'loaded_plugins', None) or getattr(self.plugin_manager, 'plugins', {})
|
||||
deferred = []
|
||||
for plugin_id, plugin_instance in plugins_dict.items():
|
||||
update_timeout = None
|
||||
if deadline is not None:
|
||||
update_timeout = deadline - time.time()
|
||||
if update_timeout < _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS:
|
||||
# Too little left to be worth starting. Deferring rather
|
||||
# than granting a floor keeps the budget a real ceiling --
|
||||
# clamping up to a minimum let a plugin that began with a
|
||||
# sliver left run on past the deadline -- and a plugin
|
||||
# handed a slot it cannot use would just be recorded as
|
||||
# having timed out.
|
||||
#
|
||||
# Nothing is lost either way: a plugin that has never
|
||||
# updated is immediately due, so run_scheduled_updates()
|
||||
# picks it up within seconds, with the display already
|
||||
# running.
|
||||
deferred.append(plugin_id)
|
||||
continue
|
||||
# Check circuit breaker before attempting update
|
||||
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||
if self.plugin_manager.health_tracker.should_skip_plugin(plugin_id):
|
||||
@@ -877,13 +833,7 @@ class DisplayController:
|
||||
|
||||
# Use PluginExecutor if available for safe execution
|
||||
if hasattr(self.plugin_manager, 'plugin_executor'):
|
||||
# The remaining budget is the timeout, so the pass cannot
|
||||
# run past its deadline. Bounding the loop alone did not do
|
||||
# it: the last plugin to start could still block for the
|
||||
# executor's full 30s, which turned a 20s budget into a 31.8s
|
||||
# pass on the rig.
|
||||
success = self.plugin_manager.plugin_executor.execute_update(
|
||||
plugin_instance, plugin_id, timeout=update_timeout)
|
||||
success = self.plugin_manager.plugin_executor.execute_update(plugin_instance, plugin_id)
|
||||
if success and hasattr(self.plugin_manager, 'plugin_last_update'):
|
||||
self.plugin_manager.plugin_last_update[plugin_id] = time.time()
|
||||
else:
|
||||
@@ -902,12 +852,6 @@ class DisplayController:
|
||||
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||
self.plugin_manager.health_tracker.record_failure(plugin_id, exc)
|
||||
|
||||
if deferred:
|
||||
logger.info(
|
||||
"Initial update budget spent; %d plugin(s) left to the update "
|
||||
"tick so the display can start: %s",
|
||||
len(deferred), ", ".join(deferred))
|
||||
|
||||
def _tick_plugin_updates_for_vegas(self) -> None:
|
||||
"""Run scheduled plugin updates and tell Vegas mode which plugins
|
||||
actually got fresh data, so it can hot-swap them into the scroll
|
||||
@@ -1694,12 +1638,6 @@ class DisplayController:
|
||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||
return live
|
||||
|
||||
def _vegas_keeps_live_in_ticker(self) -> bool:
|
||||
"""Whether live content should stay in the ticker instead of preempting it."""
|
||||
coordinator = getattr(self, 'vegas_coordinator', None)
|
||||
config = getattr(coordinator, 'vegas_config', None)
|
||||
return bool(getattr(config, 'live_in_ticker', False))
|
||||
|
||||
def _check_live_priority(self, advance=False):
|
||||
"""Return the live-priority mode to display, or None if nothing is live.
|
||||
|
||||
@@ -1913,24 +1851,14 @@ class DisplayController:
|
||||
# Check for live priority content and switch to it immediately.
|
||||
# advance=True so multiple simultaneously-live games take turns
|
||||
# (round-robin) instead of pinning to the first plugin.
|
||||
# Skipped when the ticker is keeping live content: switching
|
||||
# the rotation underneath Vegas would move current_mode_index
|
||||
# and stash a resume point for a takeover that never happens.
|
||||
if (not self.on_demand_active and not wifi_status_data
|
||||
and not (self._is_vegas_mode_active()
|
||||
and self._vegas_keeps_live_in_ticker())):
|
||||
if not self.on_demand_active and not wifi_status_data:
|
||||
live_priority_mode = self._check_live_priority(advance=True)
|
||||
self._apply_live_priority(live_priority_mode)
|
||||
|
||||
# Vegas scroll mode - continuous ticker across all plugins
|
||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||
if self._is_vegas_mode_active() and not wifi_status_data:
|
||||
# Live content normally preempts the ticker entirely. With
|
||||
# vegas_scroll.live_in_ticker the marquee keeps running and
|
||||
# the live plugin takes extra turns inside it instead --
|
||||
# see StreamManager._apply_priority_weights.
|
||||
live_mode = (None if self._vegas_keeps_live_in_ticker()
|
||||
else self._check_live_priority())
|
||||
live_mode = self._check_live_priority()
|
||||
if not live_mode:
|
||||
try:
|
||||
# Run Vegas mode iteration
|
||||
|
||||
+3
-112
@@ -25,7 +25,6 @@ the same object.
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
if os.getenv("EMULATOR", "false") == "true":
|
||||
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
|
||||
@@ -259,26 +258,6 @@ class DisplayManager:
|
||||
# Initialize managers
|
||||
# Calendar manager is now initialized by DisplayController
|
||||
|
||||
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix.
|
||||
# "normal" needs no suffix since 0 degrees is the identity transform.
|
||||
_ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270}
|
||||
|
||||
def _build_pixel_mapper_config(self, hardware_config: dict) -> str:
|
||||
"""Compose the raw pixel_mapper_config string with the orientation setting.
|
||||
|
||||
`pixel_mapper_config` stays available as a free-form advanced field (e.g.
|
||||
for "U-mapper" chain layouts); `orientation` is the user-facing dropdown
|
||||
for physical mounting (e.g. panels mounted upside down) and is appended as
|
||||
a "Rotate:<deg>" mapper rather than overwriting any existing config.
|
||||
"""
|
||||
base_mapper = (hardware_config.get('pixel_mapper_config') or '').strip()
|
||||
orientation = hardware_config.get('orientation', 'normal')
|
||||
degrees = self._ORIENTATION_ROTATE_DEGREES.get(orientation)
|
||||
if degrees is None:
|
||||
return base_mapper
|
||||
rotate_mapper = f'Rotate:{degrees}'
|
||||
return f'{base_mapper};{rotate_mapper}' if base_mapper else rotate_mapper
|
||||
|
||||
def _setup_matrix(self):
|
||||
"""Initialize the RGB matrix with configuration settings."""
|
||||
_init_error_str = None
|
||||
@@ -304,7 +283,7 @@ class DisplayManager:
|
||||
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
||||
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
||||
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
|
||||
options.pixel_mapper_config = self._build_pixel_mapper_config(hardware_config)
|
||||
options.pixel_mapper_config = hardware_config.get('pixel_mapper_config', '')
|
||||
options.row_address_type = hardware_config.get('row_address_type', 0)
|
||||
options.multiplexing = hardware_config.get('multiplexing', 0)
|
||||
options.panel_type = hardware_config.get('panel_type', '')
|
||||
@@ -518,91 +497,6 @@ class DisplayManager:
|
||||
logger.warning(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
|
||||
return -1
|
||||
|
||||
@staticmethod
|
||||
def _local_ip() -> Optional[str]:
|
||||
"""This device's address on the network it routes through, or None.
|
||||
|
||||
Deliberately not `hostname -I` or a systemctl probe for AP mode, which
|
||||
is how the web launcher does it: both spawn processes with multi-second
|
||||
timeouts, and this runs on the startup path the rest of this change
|
||||
exists to shorten. Connecting a UDP socket sends no packets -- it only
|
||||
asks the kernel which source address it would use -- so it costs
|
||||
microseconds and works with the network down, as long as a route
|
||||
exists.
|
||||
"""
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(0.2)
|
||||
sock.connect(("8.8.8.8", 80)) # nosec B104 - no traffic; selects a route
|
||||
ip = sock.getsockname()[0]
|
||||
return ip if ip and not ip.startswith("127.") else None
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _fitting_font(self, lines, width):
|
||||
"""The largest font from the usual ladder that fits every line."""
|
||||
candidates = [self.font,
|
||||
("assets/fonts/4x6-font.ttf", 6)]
|
||||
for candidate in candidates:
|
||||
try:
|
||||
font = candidate
|
||||
if isinstance(candidate, tuple):
|
||||
font = ImageFont.truetype(candidate[0], candidate[1])
|
||||
if all(self.draw.textlength(t, font=font) <= width for t in lines):
|
||||
return font
|
||||
except (OSError, ValueError, AttributeError):
|
||||
continue
|
||||
return self.font
|
||||
|
||||
def _draw_startup_banner(self, lines, width: int, height: int) -> None:
|
||||
"""Centre `lines` over whatever the test pattern already drew.
|
||||
|
||||
This screen stays on the panel for the whole initial plugin update, and
|
||||
on a headless Pi it is the only place the device's address appears
|
||||
without going looking for it -- so it has to be readable off a wall,
|
||||
not merely present.
|
||||
|
||||
The font is chosen to fit rather than fixed at 8px: "Initializing" is
|
||||
96px in PressStart2P, which ran off the side of a 64px panel even
|
||||
before an address was added. And the pattern is punched out behind the
|
||||
text, because the diagonal runs through the middle of the panel, which
|
||||
is exactly where this sits.
|
||||
|
||||
The text stays blue. It is not decoration: the pattern draws one pure
|
||||
channel per element -- red border, green diagonal, blue text -- so that
|
||||
a glance at the panel says whether led_rgb_sequence is right. Swap the
|
||||
wiring to BGR and the border comes up blue and this text red. Drawing
|
||||
it white would light all three channels and destroy the only blue
|
||||
reference on the screen, which is why it is worth a comment rather
|
||||
than a quiet preference.
|
||||
"""
|
||||
if not lines:
|
||||
return
|
||||
font = self._fitting_font(lines, width - 2)
|
||||
line_height = self.draw.textbbox((0, 0), "Ag", font=font)[3] + 1
|
||||
block_height = line_height * len(lines)
|
||||
block_top = max(1, (height - block_height) // 2)
|
||||
block_width = max(self.draw.textlength(t, font=font) for t in lines)
|
||||
block_left = max(0, (width - block_width) // 2)
|
||||
|
||||
self.draw.rectangle(
|
||||
[block_left - 2, block_top - 1,
|
||||
block_left + block_width + 1, block_top + block_height],
|
||||
fill=(0, 0, 0))
|
||||
|
||||
for row, line in enumerate(lines):
|
||||
line_width = self.draw.textlength(line, font=font)
|
||||
self.draw.text(
|
||||
(max(0, (width - line_width) // 2), block_top + row * line_height),
|
||||
line, font=font, fill=(0, 0, 255))
|
||||
|
||||
def _draw_test_pattern(self):
|
||||
"""Draw a test pattern to verify the display is working."""
|
||||
try:
|
||||
@@ -622,11 +516,8 @@ class DisplayManager:
|
||||
# Draw a diagonal line
|
||||
self.draw.line([0, 0, self.matrix.width-1, self.matrix.height-1], fill=(0, 255, 0))
|
||||
|
||||
lines = ["Initializing"]
|
||||
ip = self._local_ip()
|
||||
if ip:
|
||||
lines.append(ip)
|
||||
self._draw_startup_banner(lines, self.matrix.width, self.matrix.height)
|
||||
# Draw some text - changed from "TEST" to "Initializing" with smaller font
|
||||
self.draw.text((10, 10), "Initializing", font=self.font, fill=(0, 0, 255))
|
||||
|
||||
# Update the display once after everything is drawn
|
||||
self.update_display()
|
||||
|
||||
@@ -167,14 +167,10 @@ class DynamicTeamResolver:
|
||||
|
||||
# Sort by ranking (1, 2, 3, etc.)
|
||||
sorted_rankings = dict(sorted(rankings.items(), key=lambda x: x[1]))
|
||||
|
||||
# Cache the results ON THE CLASS. Assigning through self
|
||||
# would create instance attributes that shadow the shared
|
||||
# class-level cache, making it per-instance — and every
|
||||
# scoreboard constructs its own resolver, so the cache
|
||||
# would never actually be shared.
|
||||
DynamicTeamResolver._rankings_cache = sorted_rankings
|
||||
DynamicTeamResolver._cache_timestamp = current_time
|
||||
|
||||
# Cache the results
|
||||
self._rankings_cache = sorted_rankings
|
||||
self._cache_timestamp = current_time
|
||||
|
||||
self.logger.info(f"Fetched rankings for {len(sorted_rankings)} teams")
|
||||
return sorted_rankings
|
||||
@@ -220,11 +216,9 @@ class DynamicTeamResolver:
|
||||
return any(pattern in team_name.upper() for pattern in dynamic_patterns)
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the SHARED rankings cache to force fresh data on next
|
||||
request. Writes through the class — assigning via self would only
|
||||
shadow the shared cache for this instance."""
|
||||
DynamicTeamResolver._rankings_cache = {}
|
||||
DynamicTeamResolver._cache_timestamp = 0
|
||||
"""Clear the rankings cache to force fresh data on next request."""
|
||||
self._rankings_cache = {}
|
||||
self._cache_timestamp = 0
|
||||
self.logger.info("Cleared dynamic team rankings cache")
|
||||
|
||||
|
||||
|
||||
+9
-18
@@ -5,7 +5,6 @@ Provides consistent logging configuration across the LEDMatrix application.
|
||||
Supports structured logging with context information and appropriate log levels.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
@@ -66,29 +65,24 @@ class ContextualFormatter(logging.Formatter):
|
||||
self.include_context = include_context
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Format log record with context.
|
||||
|
||||
Works on a shallow copy of the record: a record is formatted once
|
||||
PER HANDLER, so mutating record.msg in place (the old behavior)
|
||||
prepended the context prefix again for every additional handler.
|
||||
"""
|
||||
"""Format log record with context."""
|
||||
# Add context to message if present
|
||||
if self.include_context:
|
||||
context_parts = []
|
||||
|
||||
|
||||
if hasattr(record, 'plugin_id'):
|
||||
context_parts.append(f"[Plugin: {record.plugin_id}]")
|
||||
|
||||
|
||||
if hasattr(record, 'operation_id'):
|
||||
context_parts.append(f"[Op: {record.operation_id}]")
|
||||
|
||||
|
||||
if hasattr(record, 'context') and isinstance(record.context, dict):
|
||||
for key, value in record.context.items():
|
||||
context_parts.append(f"[{key}: {value}]")
|
||||
|
||||
|
||||
if context_parts:
|
||||
record = copy.copy(record)
|
||||
record.msg = ' '.join(context_parts) + ' ' + str(record.msg)
|
||||
|
||||
|
||||
return super().format(record)
|
||||
|
||||
|
||||
@@ -230,11 +224,8 @@ def log_warning(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||
|
||||
|
||||
def log_error(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||
"""Log error message with context. Defaults exc_info=True; a caller
|
||||
passing exc_info explicitly wins (the old hardcoded keyword raised
|
||||
TypeError on that duplicate)."""
|
||||
kwargs.setdefault('exc_info', True)
|
||||
log_with_context(logger, logging.ERROR, message, **kwargs)
|
||||
"""Log error message with context."""
|
||||
log_with_context(logger, logging.ERROR, message, **kwargs, exc_info=True)
|
||||
|
||||
|
||||
def log_debug(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||
|
||||
@@ -364,10 +364,8 @@ class BasePlugin(ABC):
|
||||
# Handle None case
|
||||
if duration is None:
|
||||
pass # Fall through to config
|
||||
# Try to convert to float if it's a number or numeric string.
|
||||
# bool is excluded: it's an int subclass, and True would
|
||||
# otherwise read as a 1-second duration.
|
||||
elif isinstance(duration, (int, float)) and not isinstance(duration, bool):
|
||||
# Try to convert to float if it's a number or numeric string
|
||||
elif isinstance(duration, (int, float)):
|
||||
if duration > 0:
|
||||
return float(duration)
|
||||
else:
|
||||
@@ -405,9 +403,8 @@ class BasePlugin(ABC):
|
||||
# Fall back to config
|
||||
config_duration = self.config.get("display_duration", 15.0)
|
||||
try:
|
||||
# Ensure config value is also a valid float (bool excluded — an
|
||||
# int subclass that would otherwise read True as 1 second)
|
||||
if isinstance(config_duration, (int, float)) and not isinstance(config_duration, bool):
|
||||
# Ensure config value is also a valid float
|
||||
if isinstance(config_duration, (int, float)):
|
||||
if config_duration > 0:
|
||||
return float(config_duration)
|
||||
else:
|
||||
@@ -555,48 +552,6 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def get_vegas_priority_weight(self) -> Optional[int]:
|
||||
"""How many slots per Vegas cycle this plugin should get, or None.
|
||||
|
||||
The Vegas ticker is otherwise a strict round robin: every plugin
|
||||
appears exactly once per cycle. With a dozen plugins enabled that puts
|
||||
minutes between a live score and its next appearance. A weight of N
|
||||
gives the plugin N slots per cycle, spread evenly through it rather
|
||||
than clumped together.
|
||||
|
||||
Return ``None`` (the default) to let the core decide. It gives a
|
||||
plugin ``vegas_scroll.live_weight`` when ``has_live_priority()`` and
|
||||
``has_live_content()`` are both true, and 1 otherwise -- so live sports
|
||||
already get extra turns without implementing this at all.
|
||||
|
||||
Implement it only when the plugin knows something the core cannot. The
|
||||
motivating case is favorite teams: the core can see *that* a game is
|
||||
live but not *whose*, so a scoreboard that wants its favorite's game
|
||||
shown more often than other live games has to say so::
|
||||
|
||||
def get_vegas_priority_weight(self):
|
||||
if not (self.has_live_priority() and self.has_live_content()):
|
||||
return None # let the core decide
|
||||
cfg = self.global_config.get('display', {}).get('vegas_scroll', {})
|
||||
if self._favorite_is_live():
|
||||
return cfg.get('favorite_live_weight', 5)
|
||||
return cfg.get('live_weight', 3)
|
||||
|
||||
The weight is per *plugin*, not per game. A scoreboard showing four
|
||||
live games still occupies one slot at a time and rotates its own games
|
||||
within that slot; this controls how often the plugin itself comes
|
||||
round.
|
||||
|
||||
Raising is safe: the core logs it and falls back to its own
|
||||
live-content check, so a broken weight calculation costs the plugin
|
||||
the favorite distinction but not the live boost.
|
||||
|
||||
Returns:
|
||||
Slots per cycle (clamped to 1..10 by the caller), or None to
|
||||
defer to the core's own live-content weighting.
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_live_modes(self) -> List[str]:
|
||||
"""
|
||||
Get list of display modes that should be used during live priority takeover.
|
||||
@@ -839,12 +794,10 @@ class BasePlugin(ABC):
|
||||
self.logger.error("'enabled' must be a boolean")
|
||||
return False
|
||||
|
||||
# Check display_duration if present. bool is excluded explicitly:
|
||||
# it's an int subclass, and get_display_duration rejects it too.
|
||||
# Check display_duration if present
|
||||
if "display_duration" in self.config:
|
||||
duration = self.config["display_duration"]
|
||||
if (not isinstance(duration, (int, float))
|
||||
or isinstance(duration, bool) or duration <= 0):
|
||||
if not isinstance(duration, (int, float)) or duration <= 0:
|
||||
self.logger.error("'display_duration' must be a positive number")
|
||||
return False
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ and circuit breaker state. Provides automatic recovery mechanisms.
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Optional, Any, Tuple
|
||||
from typing import Dict, Optional, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
@@ -64,48 +64,10 @@ class PluginHealthTracker:
|
||||
cache_key, max_age=None, memory_ttl=0 if force_reload else None
|
||||
)
|
||||
|
||||
if isinstance(cached, dict) and cached:
|
||||
# Complete it rather than trusting it: a persisted record can be
|
||||
# missing fields the callers index directly (a partial write, a
|
||||
# restored backup, an older schema), and returning it verbatim makes
|
||||
# record_success / record_failure raise KeyError, which takes the
|
||||
# display down in a restart loop that survives reboots because the
|
||||
# bad entry is on disk.
|
||||
state, repaired = self._repair_health_state(cached)
|
||||
if repaired:
|
||||
self.logger.warning(
|
||||
f"Repaired health state for {plugin_id}: "
|
||||
f"{sorted(repaired)} missing or invalid, using defaults for those."
|
||||
)
|
||||
return state
|
||||
|
||||
# Not a dict at all: written by something other than
|
||||
# _save_health_state (a key collision, a corrupted entry). Nothing to
|
||||
# salvage.
|
||||
if cached is not None and not isinstance(cached, dict):
|
||||
self.logger.warning(
|
||||
f"Discarding malformed health state for {plugin_id}: expected "
|
||||
f"dict, got {type(cached).__name__}. Falling back to defaults."
|
||||
)
|
||||
|
||||
return self._default_health_state()
|
||||
|
||||
def _save_health_state(self, plugin_id: str, state: Dict[str, Any]) -> None:
|
||||
"""Save health state to cache."""
|
||||
cache_key = self._get_health_key(plugin_id)
|
||||
self.cache_manager.set(cache_key, state) # Persist indefinitely
|
||||
self._health_state[plugin_id] = state
|
||||
|
||||
# The fields callers index directly (state['circuit_state'] and friends).
|
||||
# A cached dict missing any of them raises KeyError deep in record_success /
|
||||
# record_failure, so the value is completed before it is handed out.
|
||||
_COUNTER_FIELDS = ('consecutive_failures', 'total_failures', 'total_successes')
|
||||
_TIMESTAMP_FIELDS = ('last_success_time', 'last_failure_time',
|
||||
'circuit_opened_time', 'half_open_start_time')
|
||||
|
||||
@staticmethod
|
||||
def _default_health_state() -> Dict[str, Any]:
|
||||
"""A fresh state with every field the callers expect."""
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Default state
|
||||
return {
|
||||
'consecutive_failures': 0,
|
||||
'total_failures': 0,
|
||||
@@ -115,56 +77,15 @@ class PluginHealthTracker:
|
||||
'circuit_state': CircuitState.CLOSED.value,
|
||||
'circuit_opened_time': None,
|
||||
'half_open_start_time': None,
|
||||
'last_error': None,
|
||||
'last_error': None
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _repair_health_state(cls, cached: Dict[str, Any]) -> Tuple[Dict[str, Any], list]:
|
||||
"""Return `cached` completed against the defaults, plus what was repaired.
|
||||
|
||||
Per-field rather than all-or-nothing: a record that has real failure
|
||||
counts but is missing `last_error` should keep the counts, not be reset
|
||||
to healthy. Only values that are absent or the wrong type fall back to
|
||||
the default, so a partial or older-schema record survives with whatever
|
||||
it does carry, while every field the callers index is guaranteed present
|
||||
and of a usable type.
|
||||
"""
|
||||
state = cls._default_health_state()
|
||||
repaired = []
|
||||
for field, default in state.items():
|
||||
if field not in cached:
|
||||
repaired.append(field)
|
||||
continue
|
||||
value = cached[field]
|
||||
if field in cls._COUNTER_FIELDS:
|
||||
ok = isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
||||
elif field in cls._TIMESTAMP_FIELDS:
|
||||
# bool is a subclass of int, so True would pass as a timestamp
|
||||
# and then compare as 1.0 -- expiring a cooldown the instant it
|
||||
# opens, or (False) making the elapsed check never fire.
|
||||
ok = value is None or (
|
||||
isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
)
|
||||
elif field == 'circuit_state':
|
||||
# Membership first requires the value to be hashable: a list or
|
||||
# dict here would raise TypeError out of the repair itself,
|
||||
# which is the crash this whole path exists to prevent.
|
||||
ok = isinstance(value, str) and value in {
|
||||
member.value for member in CircuitState
|
||||
}
|
||||
else: # last_error
|
||||
ok = value is None or isinstance(value, str)
|
||||
if ok:
|
||||
state[field] = value
|
||||
else:
|
||||
repaired.append(field)
|
||||
# Anything the schema has since grown (degraded, degraded_reason) is
|
||||
# read with .get() by its callers, so carry it through untouched.
|
||||
for field, value in cached.items():
|
||||
if field not in state:
|
||||
state[field] = value
|
||||
return state, repaired
|
||||
|
||||
|
||||
def _save_health_state(self, plugin_id: str, state: Dict[str, Any]) -> None:
|
||||
"""Save health state to cache."""
|
||||
cache_key = self._get_health_key(plugin_id)
|
||||
self.cache_manager.set(cache_key, state) # Persist indefinitely
|
||||
self._health_state[plugin_id] = state
|
||||
|
||||
def get_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||
"""Get current health state for a plugin.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import sys
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Tuple, Type
|
||||
from typing import Dict, Any, Optional, Tuple, Type
|
||||
import logging
|
||||
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
@@ -45,76 +45,6 @@ def requirements_has_real_deps(requirements_file: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _extra_dependencies(dist_name: str, extras) -> Optional[List[Requirement]]:
|
||||
"""Dependencies a distribution declares *only* behind the given extras.
|
||||
|
||||
Returns None when the installed metadata cannot be read or parsed, so the
|
||||
caller can fall back to running pip rather than assuming anything.
|
||||
"""
|
||||
try:
|
||||
meta = importlib.metadata.metadata(dist_name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
gated: List[Requirement] = []
|
||||
for raw in meta.get_all('Requires-Dist') or []:
|
||||
try:
|
||||
dep = Requirement(raw)
|
||||
except InvalidRequirement:
|
||||
return None
|
||||
if dep.marker is None:
|
||||
continue
|
||||
# Keep only what the distribution gates behind an extra we asked for:
|
||||
# satisfied when `extra` is that name, but not when no extra is
|
||||
# requested. A marker that holds either way (python_version, sys_platform)
|
||||
# belongs to the base install and is already covered by the version check.
|
||||
if dep.marker.evaluate({'extra': ''}):
|
||||
continue
|
||||
if any(dep.marker.evaluate({'extra': extra}) for extra in extras):
|
||||
gated.append(dep)
|
||||
return gated
|
||||
|
||||
|
||||
def _extras_are_satisfied(req: Requirement, _visited: Optional[set] = None) -> bool:
|
||||
"""Check the dependencies pulled in by req's extras are installed.
|
||||
|
||||
Follows extras through nested extras. A gated dependency can itself request
|
||||
one (`requests[socks]`), and checking only that `requests` is installed at
|
||||
an acceptable version says nothing about whether the socks extra's own
|
||||
dependency is there -- so the caller would skip pip and the plugin would
|
||||
fail at import instead. Plain dependencies are still checked one level
|
||||
deep, which is all that is needed to tell "the extra was installed" from
|
||||
"the extra was never installed".
|
||||
|
||||
`_visited` carries the (distribution, extras) pairs already seen, so a
|
||||
dependency cycle between extras terminates instead of recursing forever.
|
||||
Anything unreadable returns False, so the caller still falls through to pip.
|
||||
"""
|
||||
if _visited is None:
|
||||
_visited = set()
|
||||
marker = (req.name.lower(), frozenset(e.lower() for e in req.extras))
|
||||
if marker in _visited:
|
||||
# Already accounted for higher up the chain; treating a cycle as
|
||||
# satisfied here is safe because the outer frame still has to pass.
|
||||
return True
|
||||
_visited.add(marker)
|
||||
|
||||
gated = _extra_dependencies(req.name, req.extras)
|
||||
if gated is None:
|
||||
return False
|
||||
|
||||
for dep in gated:
|
||||
try:
|
||||
dep_version = importlib.metadata.version(dep.name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return False
|
||||
if dep.specifier and not dep.specifier.contains(dep_version, prereleases=True):
|
||||
return False
|
||||
if dep.extras and not _extras_are_satisfied(dep, _visited):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def requirements_are_satisfied(requirements_file: str) -> bool:
|
||||
"""
|
||||
Check whether every real requirement line in requirements.txt is already
|
||||
@@ -146,6 +76,9 @@ def requirements_are_satisfied(requirements_file: str) -> bool:
|
||||
except InvalidRequirement:
|
||||
return False
|
||||
|
||||
if req.extras:
|
||||
return False # verifying extras' sub-dependencies isn't worth it here
|
||||
|
||||
if req.marker is not None and not req.marker.evaluate():
|
||||
continue # not applicable on this platform/interpreter
|
||||
|
||||
@@ -157,9 +90,6 @@ def requirements_are_satisfied(requirements_file: str) -> bool:
|
||||
if req.specifier and not req.specifier.contains(installed_version, prereleases=True):
|
||||
return False
|
||||
|
||||
if req.extras and not _extras_are_satisfied(req):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -71,8 +71,7 @@ class PluginManager:
|
||||
self.plugin_loader = PluginLoader(logger=self.logger)
|
||||
self.plugin_executor = PluginExecutor(default_timeout=30.0, logger=self.logger)
|
||||
self.state_manager = PluginStateManager(logger=self.logger)
|
||||
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, logger=self.logger,
|
||||
config_manager=self.config_manager)
|
||||
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, logger=self.logger)
|
||||
|
||||
# Lock protecting plugin_manifests and plugin_directories from
|
||||
# concurrent mutation (background reconciliation) and reads (requests).
|
||||
@@ -126,14 +125,6 @@ class PluginManager:
|
||||
self._plugin_locks: Dict[str, threading.Lock] = {}
|
||||
self._plugin_locks_guard = threading.Lock()
|
||||
self._update_worker: Optional[threading.Thread] = None
|
||||
# Plugin ids whose update() has finished since the last time anyone
|
||||
# asked. Updates are dispatched to a worker thread, so a caller that
|
||||
# wants to know "whose data just changed" cannot learn it by diffing
|
||||
# plugin_last_update around run_scheduled_updates() -- that call only
|
||||
# enqueues, and the timestamp is stamped later, on the worker. See
|
||||
# run_scheduled_updates_with_changes().
|
||||
self._completed_updates: set = set()
|
||||
self._completed_updates_lock = threading.Lock()
|
||||
self._synchronous_updates = False
|
||||
if self.config_manager is not None:
|
||||
try:
|
||||
@@ -1034,7 +1025,6 @@ class PluginManager:
|
||||
if success:
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update[plugin_id] = scheduled_time
|
||||
self._note_update_completed(plugin_id)
|
||||
self.state_manager.record_update(plugin_id)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
if self.health_tracker:
|
||||
@@ -1099,41 +1089,28 @@ class PluginManager:
|
||||
|
||||
def run_scheduled_updates_with_changes(self, current_time: Optional[float] = None) -> List[str]:
|
||||
"""
|
||||
Like run_scheduled_updates(), but also reports which plugins have
|
||||
fresh data -- the ids whose update() has finished since the last
|
||||
call, not necessarily the ones enqueued by this one.
|
||||
Like run_scheduled_updates(), but also returns the plugin_ids whose
|
||||
plugin_last_update timestamp actually advanced during this call.
|
||||
|
||||
That distinction is the whole point. This used to snapshot
|
||||
plugin_last_update, call run_scheduled_updates(), and diff. But
|
||||
run_scheduled_updates() only *enqueues*: the work runs on the
|
||||
update worker and the timestamp is stamped there, after this method
|
||||
has already returned. The two snapshots were therefore always
|
||||
identical and the result was always empty, so Vegas never learned
|
||||
that any plugin's data had changed and kept scrolling whatever a
|
||||
segment was first built from -- last night's live game still drawn
|
||||
as live the next morning. The only path that ever worked was the
|
||||
synchronous kill-switch, where update() runs inline.
|
||||
|
||||
Reporting completions instead of enqueues costs a poll's worth of
|
||||
latency (the Vegas tick runs every ~4s) and is correct regardless of
|
||||
which side of the queue the work lands on.
|
||||
The before/after snapshots and the update pass itself are each
|
||||
individually lock-protected against concurrent plugin_last_update
|
||||
mutation (Vegas mode calls this from its own background
|
||||
update-tick thread, racing the main render loop's plugin updates),
|
||||
so callers get an atomic "who got fresh data" answer without
|
||||
reaching into plugin_last_update themselves. The lock is not held
|
||||
across the update pass so slow/blocking plugin update() calls don't
|
||||
serialize against other plugin_last_update readers.
|
||||
"""
|
||||
with self._plugin_last_update_lock:
|
||||
old_times = dict(self.plugin_last_update)
|
||||
|
||||
self.run_scheduled_updates(current_time)
|
||||
return self.drain_completed_updates()
|
||||
|
||||
def _note_update_completed(self, plugin_id: str) -> None:
|
||||
"""Record that a plugin's update() finished, for the next poll."""
|
||||
with self._completed_updates_lock:
|
||||
self._completed_updates.add(plugin_id)
|
||||
|
||||
def drain_completed_updates(self) -> List[str]:
|
||||
"""Return and clear the plugin ids whose update() has since finished."""
|
||||
with self._completed_updates_lock:
|
||||
if not self._completed_updates:
|
||||
return []
|
||||
done = sorted(self._completed_updates)
|
||||
self._completed_updates.clear()
|
||||
return done
|
||||
with self._plugin_last_update_lock:
|
||||
return [
|
||||
plugin_id for plugin_id, new_time in self.plugin_last_update.items()
|
||||
if new_time > old_times.get(plugin_id, 0.0)
|
||||
]
|
||||
|
||||
def update_all_plugins(self) -> None:
|
||||
"""
|
||||
@@ -1158,7 +1135,6 @@ class PluginManager:
|
||||
if success:
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update[plugin_id] = time.time()
|
||||
self._note_update_completed(plugin_id)
|
||||
self.state_manager.record_update(plugin_id)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
else:
|
||||
|
||||
@@ -6,7 +6,6 @@ Manages saved GitHub repository URLs for easy plugin discovery and installation.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
@@ -44,45 +43,20 @@ class SavedRepositoriesManager:
|
||||
return []
|
||||
|
||||
def _save_repositories(self) -> bool:
|
||||
"""Save repositories to file atomically.
|
||||
|
||||
Writes to a temp file in the same directory and os.replace()s it
|
||||
over the target, so a failed write can never truncate or
|
||||
half-overwrite an existing saved_repositories.json.
|
||||
"""
|
||||
tmp_path = self.config_path.with_suffix(self.config_path.suffix + '.tmp')
|
||||
"""Save repositories to file."""
|
||||
try:
|
||||
# Ensure directory exists
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(tmp_path, 'w') as f:
|
||||
|
||||
with open(self.config_path, 'w') as f:
|
||||
json.dump(self.repositories, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, self.config_path)
|
||||
|
||||
|
||||
self.logger.info(f"Saved {len(self.repositories)} repositories to {self.config_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving repositories: {e}")
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _clean_url(repo_url: str) -> str:
|
||||
"""Normalize a repo URL: strip whitespace, trailing slashes, and a
|
||||
trailing ``.git`` suffix ONLY. (The old ``.replace('.git', '')``
|
||||
was an unanchored substring replace that mangled URLs merely
|
||||
containing ``.git``, e.g. ``https://github.com/user/my.github.io``.)
|
||||
"""
|
||||
repo_url = repo_url.strip().rstrip('/')
|
||||
if repo_url.endswith('.git'):
|
||||
repo_url = repo_url[:-4]
|
||||
return repo_url
|
||||
|
||||
def get_all(self) -> List[Dict[str, str]]:
|
||||
"""Get all saved repositories."""
|
||||
return self.repositories.copy()
|
||||
@@ -98,14 +72,15 @@ class SavedRepositoriesManager:
|
||||
Returns:
|
||||
True if added successfully
|
||||
"""
|
||||
repo_url = self._clean_url(repo_url)
|
||||
|
||||
# Clean URL
|
||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
||||
|
||||
# Check if already exists
|
||||
for repo in self.repositories:
|
||||
if repo.get('url') == repo_url:
|
||||
self.logger.warning(f"Repository already exists: {repo_url}")
|
||||
return False
|
||||
|
||||
|
||||
# Extract name from URL if not provided
|
||||
if not name:
|
||||
parts = repo_url.split('/')
|
||||
@@ -113,20 +88,15 @@ class SavedRepositoriesManager:
|
||||
name = parts[-1]
|
||||
else:
|
||||
name = repo_url
|
||||
|
||||
|
||||
# Add repository
|
||||
self.repositories.append({
|
||||
'url': repo_url,
|
||||
'name': name,
|
||||
'type': 'registry' if 'plugins.json' in repo_url or 'ledmatrix-plugins' in repo_url.lower() else 'single'
|
||||
})
|
||||
|
||||
if not self._save_repositories():
|
||||
# Keep memory consistent with disk: a failed save must not leave
|
||||
# a phantom entry that only this process can see.
|
||||
self.repositories.pop()
|
||||
return False
|
||||
return True
|
||||
|
||||
return self._save_repositories()
|
||||
|
||||
def remove(self, repo_url: str) -> bool:
|
||||
"""
|
||||
@@ -138,25 +108,21 @@ class SavedRepositoriesManager:
|
||||
Returns:
|
||||
True if removed successfully
|
||||
"""
|
||||
repo_url = self._clean_url(repo_url)
|
||||
|
||||
previous = self.repositories
|
||||
remaining = [r for r in previous if r.get('url') != repo_url]
|
||||
|
||||
if len(remaining) < len(previous):
|
||||
self.repositories = remaining
|
||||
if not self._save_repositories():
|
||||
# Failed save: restore so memory matches disk.
|
||||
self.repositories = previous
|
||||
return False
|
||||
return True
|
||||
# Clean URL
|
||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
||||
|
||||
original_count = len(self.repositories)
|
||||
self.repositories = [r for r in self.repositories if r.get('url') != repo_url]
|
||||
|
||||
if len(self.repositories) < original_count:
|
||||
return self._save_repositories()
|
||||
else:
|
||||
self.logger.warning(f"Repository not found: {repo_url}")
|
||||
return False
|
||||
|
||||
def has(self, repo_url: str) -> bool:
|
||||
"""Check if a repository is already saved."""
|
||||
repo_url = self._clean_url(repo_url)
|
||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
||||
return any(r.get('url') == repo_url for r in self.repositories)
|
||||
|
||||
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
||||
|
||||
@@ -26,25 +26,7 @@ class SchemaManager:
|
||||
- Cache invalidation on plugin changes
|
||||
"""
|
||||
|
||||
# Plugin config keys that mean "where this device is". A plugin declaring
|
||||
# any of these in its schema gets the device-wide ``location`` block from
|
||||
# config.json as the *default* for that field, instead of whatever city the
|
||||
# plugin author happened to ship. A value the user set on the plugin itself
|
||||
# always wins -- this only ever replaces the schema default, so an explicit
|
||||
# per-plugin location is still honoured.
|
||||
#
|
||||
# Only these fully-namespaced keys are substituted. A bare ``state`` or
|
||||
# ``city`` key is deliberately left alone: plugins use those for unrelated
|
||||
# things (ledmatrix-elections' ``state`` is a two-letter code, not a place
|
||||
# name), and silently rewriting them would break those plugins.
|
||||
DEVICE_LOCATION_KEYS: Dict[str, str] = {
|
||||
'location_city': 'city',
|
||||
'location_state': 'state',
|
||||
'location_country': 'country',
|
||||
}
|
||||
|
||||
def __init__(self, plugins_dir: Optional[Path] = None, project_root: Optional[Path] = None,
|
||||
logger: Optional[logging.Logger] = None, config_manager: Optional[Any] = None):
|
||||
def __init__(self, plugins_dir: Optional[Path] = None, project_root: Optional[Path] = None, logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize the Schema Manager.
|
||||
|
||||
@@ -52,14 +34,10 @@ class SchemaManager:
|
||||
plugins_dir: Base plugins directory path
|
||||
project_root: Project root directory path
|
||||
logger: Optional logger instance
|
||||
config_manager: Optional config manager, used to resolve the
|
||||
device-wide ``location`` that seeds plugin location defaults.
|
||||
Omitting it simply leaves schema defaults untouched.
|
||||
"""
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.plugins_dir = plugins_dir
|
||||
self.project_root = project_root or Path.cwd()
|
||||
self.config_manager = config_manager
|
||||
|
||||
# Schema cache: plugin_id -> schema dict
|
||||
self._schema_cache: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -234,70 +212,10 @@ class SchemaManager:
|
||||
|
||||
return defaults
|
||||
|
||||
def get_device_location(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Return the device-wide ``location`` block from config.json, or None.
|
||||
|
||||
This is the City/State/Country the user sets once under General
|
||||
settings. Returns None when there is no config manager wired, the
|
||||
config can't be read, or no location has been configured.
|
||||
"""
|
||||
if self.config_manager is None:
|
||||
return None
|
||||
try:
|
||||
config = self.config_manager.load_config()
|
||||
except Exception as e:
|
||||
# A config that can't be read must never stop defaults being
|
||||
# generated -- the plugin's own schema defaults still apply.
|
||||
self.logger.debug(f"Could not read device location from config: {e}")
|
||||
return None
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
location = config.get('location')
|
||||
return location if isinstance(location, dict) else None
|
||||
|
||||
def apply_device_location(self, defaults: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Replace location-shaped schema defaults with the device's own location.
|
||||
|
||||
Without this, a plugin that ships ``"location_city": "Dallas"`` as its
|
||||
schema default silently reports Dallas weather (and centres its radar
|
||||
there) for every user who never opened that plugin's config form --
|
||||
even though they set their real city under General settings. The
|
||||
substituted value is still only a *default*: ``merge_with_defaults``
|
||||
lets any per-plugin value the user saved win over it.
|
||||
|
||||
Mutates and returns ``defaults`` for convenience.
|
||||
"""
|
||||
if not defaults:
|
||||
return defaults
|
||||
if not any(key in defaults for key in self.DEVICE_LOCATION_KEYS):
|
||||
return defaults
|
||||
|
||||
location = self.get_device_location()
|
||||
if not location:
|
||||
return defaults
|
||||
|
||||
for key, field in self.DEVICE_LOCATION_KEYS.items():
|
||||
if key not in defaults:
|
||||
continue
|
||||
value = location.get(field)
|
||||
# Only a non-empty string is a real answer; a blank or missing
|
||||
# field means "not configured", which leaves the schema default.
|
||||
if isinstance(value, str) and value.strip():
|
||||
defaults[key] = value.strip()
|
||||
|
||||
return defaults
|
||||
|
||||
def generate_default_config(self, plugin_id: str, use_cache: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate default configuration for a plugin from its schema.
|
||||
|
||||
Location fields (see ``DEVICE_LOCATION_KEYS``) default to the device's
|
||||
configured location rather than the plugin author's. That substitution
|
||||
is applied on the way out rather than being cached, so changing the
|
||||
device location takes effect without invalidating the defaults cache.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
use_cache: If True, return cached defaults if available
|
||||
@@ -307,7 +225,7 @@ class SchemaManager:
|
||||
"""
|
||||
# Check cache first
|
||||
if use_cache and plugin_id in self._defaults_cache:
|
||||
return self.apply_device_location(self._defaults_cache[plugin_id].copy())
|
||||
return self._defaults_cache[plugin_id].copy()
|
||||
|
||||
schema = self.load_schema(plugin_id, use_cache=use_cache)
|
||||
if not schema:
|
||||
@@ -331,11 +249,10 @@ class SchemaManager:
|
||||
if 'live_priority' not in defaults:
|
||||
defaults['live_priority'] = schema.get('properties', {}).get('live_priority', {}).get('default', False)
|
||||
|
||||
# Cache the defaults *before* the device location is layered on, so a
|
||||
# later change to the device location is picked up by the next call.
|
||||
# Cache the defaults
|
||||
self._defaults_cache[plugin_id] = defaults.copy()
|
||||
|
||||
return self.apply_device_location(defaults)
|
||||
return defaults
|
||||
|
||||
def validate_config_against_schema(self, config: Dict[str, Any], schema: Dict[str, Any],
|
||||
plugin_id: Optional[str] = None) -> Tuple[bool, List[str]]:
|
||||
|
||||
@@ -73,11 +73,6 @@ class RenderResult:
|
||||
golden_ok: Optional[bool] = None
|
||||
golden_diff_pixels: int = 0
|
||||
golden_max_delta: int = 0
|
||||
# what display() handed back; the controller skips a mode only on False
|
||||
display_returned: Any = None
|
||||
# empty-frame check: rendered nothing while not reporting "no content"
|
||||
empty_claimed: Optional[bool] = None # True when that happened
|
||||
empty_ok: Optional[bool] = None # False only in strict mode
|
||||
# fill / scale-up check (populated only for sizes >= 2x the design size)
|
||||
fill_checked: bool = False
|
||||
fill_ok: Optional[bool] = None # False only in strict mode
|
||||
@@ -97,8 +92,6 @@ class RenderResult:
|
||||
return False
|
||||
if self.fill_ok is False:
|
||||
return False
|
||||
if self.empty_ok is False:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -139,25 +132,21 @@ def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
||||
return plugin_instance
|
||||
|
||||
|
||||
def _render_mode(plugin_instance: Any, mode: str) -> Any:
|
||||
def _render_mode(plugin_instance: Any, mode: str) -> None:
|
||||
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
|
||||
drive the plugin's internal mode state machine (first display() call renders
|
||||
modes[current_mode_index] when current_display_mode is None).
|
||||
|
||||
Returns whatever display() returned. The display controller skips a mode
|
||||
whose display() returns False, so that value decides whether an empty mode
|
||||
is rotated past or sat on -- which makes it worth reporting rather than
|
||||
discarding."""
|
||||
modes[current_mode_index] when current_display_mode is None)."""
|
||||
sig = inspect.signature(plugin_instance.display)
|
||||
if "display_mode" in sig.parameters:
|
||||
return plugin_instance.display(force_clear=True, display_mode=mode)
|
||||
plugin_instance.display(force_clear=True, display_mode=mode)
|
||||
return
|
||||
|
||||
modes = getattr(plugin_instance, "modes", None)
|
||||
if modes and mode in modes:
|
||||
plugin_instance.current_mode_index = list(modes).index(mode)
|
||||
if hasattr(plugin_instance, "current_display_mode"):
|
||||
plugin_instance.current_display_mode = None
|
||||
return plugin_instance.display(force_clear=False)
|
||||
plugin_instance.display(force_clear=False)
|
||||
|
||||
|
||||
def _freeze(freeze_time: Optional[str]):
|
||||
@@ -245,7 +234,7 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
|
||||
plugin_id, mode, e)
|
||||
if result.error is None:
|
||||
result.display_returned = _render_mode(inst, mode)
|
||||
_render_mode(inst, mode)
|
||||
result.image = dm.get_image()
|
||||
result.overflow = dm.check_overflow()
|
||||
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||
@@ -352,44 +341,6 @@ def fill_metrics(image: Image.Image) -> Tuple[float, float, float]:
|
||||
return (extent_x, extent_y, ink)
|
||||
|
||||
|
||||
def check_empty_claimed(results: List[RenderResult],
|
||||
strict: bool = False) -> List[RenderResult]:
|
||||
"""Flag a mode that rendered nothing without reporting "no content".
|
||||
|
||||
The display controller skips a mode whose ``display()`` returns False, and
|
||||
treats anything else -- including None -- as "content was shown". A mode
|
||||
that draws nothing and does not return False therefore holds whatever is on
|
||||
the panel for its whole display duration. Since a mode switch clears first,
|
||||
that is a blank screen. Two sports plugins shipped exactly this: their
|
||||
``display()`` returned None on every path, so an out-of-season league sat
|
||||
blank for its full duration rather than being rotated past.
|
||||
|
||||
Warn-only by default, because a blank frame is not automatically wrong: a
|
||||
scroll mode whose first frame is its blank scroll-in buffer renders empty
|
||||
and is behaving correctly. ``strict=True`` sets ``empty_claimed`` such that
|
||||
``RenderResult.ok`` fails -- opt in per plugin via harness.json
|
||||
``{"empty_check": "strict"}`` once its modes are known to draw on the
|
||||
fixture data.
|
||||
|
||||
Note this can only catch what the fixtures actually render. A plugin whose
|
||||
harness fixture seeds content never exercises its empty path here; the
|
||||
source-level gate in the plugins repo covers that case.
|
||||
"""
|
||||
for r in results:
|
||||
if r.image is None or r.error is not None:
|
||||
continue
|
||||
# An explicit False is the plugin correctly saying "nothing to show".
|
||||
if r.display_returned is False:
|
||||
continue
|
||||
if r.image.convert("L").point(
|
||||
lambda p: 255 if p > _LIT_THRESHOLD else 0).getbbox() is not None:
|
||||
continue
|
||||
r.empty_claimed = True
|
||||
if strict:
|
||||
r.empty_ok = False
|
||||
return results
|
||||
|
||||
|
||||
def check_scale_up(results: List[RenderResult],
|
||||
design_size: Tuple[int, int] = (128, 32),
|
||||
min_extent: float = _MIN_FILL_EXTENT,
|
||||
|
||||
@@ -15,23 +15,16 @@ from src.logging_config import get_logger
|
||||
class StartupValidator:
|
||||
"""Validates system state on startup."""
|
||||
|
||||
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None,
|
||||
cache_manager: Optional[Any] = None) -> None:
|
||||
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None) -> None:
|
||||
"""
|
||||
Initialize the startup validator.
|
||||
|
||||
|
||||
Args:
|
||||
config_manager: ConfigManager instance
|
||||
plugin_manager: Optional PluginManager instance
|
||||
cache_manager: The CacheManager the application will actually use.
|
||||
Pass it. Without one this validator builds its own just to read
|
||||
a directory path, which reports on a cache the app does not
|
||||
use and leaves behind a cleanup thread that nothing stops --
|
||||
validation runs twice per startup, so that was two of them.
|
||||
"""
|
||||
self.config_manager = config_manager
|
||||
self.plugin_manager = plugin_manager
|
||||
self.cache_manager = cache_manager
|
||||
self.logger = get_logger(__name__)
|
||||
self.errors: List[str] = []
|
||||
self.warnings: List[str] = []
|
||||
@@ -44,12 +37,7 @@ class StartupValidator:
|
||||
Tuple of (is_valid, errors, warnings)
|
||||
"""
|
||||
self.logger.info("Starting startup validation...")
|
||||
|
||||
# Fresh lists each run — without this, calling validate_all() twice
|
||||
# duplicated every message.
|
||||
self.errors = []
|
||||
self.warnings = []
|
||||
|
||||
|
||||
# Validate configuration
|
||||
self._validate_config()
|
||||
|
||||
@@ -98,21 +86,9 @@ class StartupValidator:
|
||||
def _validate_cache_directory(self) -> None:
|
||||
"""Validate cache directory permissions."""
|
||||
try:
|
||||
cache_manager = self.cache_manager
|
||||
if cache_manager is None:
|
||||
# No caller supplied one (older embedders, direct use in a
|
||||
# script). Build one, but do not leave its cleanup thread
|
||||
# running behind us -- this instance is discarded on the next
|
||||
# line but the thread is a closure over it, so it would never
|
||||
# be collected.
|
||||
from src.cache_manager import CacheManager
|
||||
cache_manager = CacheManager()
|
||||
try:
|
||||
cache_dir = cache_manager.get_cache_dir()
|
||||
finally:
|
||||
cache_manager.stop_cleanup_thread()
|
||||
else:
|
||||
cache_dir = cache_manager.get_cache_dir()
|
||||
from src.cache_manager import CacheManager
|
||||
cache_manager = CacheManager()
|
||||
cache_dir = cache_manager.get_cache_dir()
|
||||
|
||||
if not cache_dir:
|
||||
self.warnings.append("Cache directory not available - caching will be disabled")
|
||||
|
||||
@@ -104,53 +104,15 @@ class VegasModeConfig:
|
||||
overflow_mode: str = "rotate"
|
||||
|
||||
# Cap on one plugin's share of a cycle, as a multiple of display width.
|
||||
# 0 (the default) disables the cap, so every plugin contributes all of its
|
||||
# content and is always entered at its beginning.
|
||||
#
|
||||
# Capping was the default until it proved to cost more than it bought.
|
||||
# Measured over a 17-plugin fleet on a 512px panel, only four plugins were
|
||||
# ever wide enough to hit a 3.0 cap; for those four it produced two visible
|
||||
# faults. Content resumed mid-item on each appearance (a news ticker entered
|
||||
# at column 6027 of its own strip), and the final window of a rotation was
|
||||
# whatever happened to be left — 348px of a 1840px stocks ticker, seven
|
||||
# seconds of panel time. Both read as the display being broken rather than
|
||||
# as deferral working.
|
||||
#
|
||||
# A wide plugin does hold the panel for a long time uncapped: set the cap
|
||||
# per plugin with vegas_max_width_screens where that matters, rather than
|
||||
# globally where it mostly hurts plugins that were never the problem.
|
||||
max_plugin_width_ratio: float = 0.0
|
||||
# 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)
|
||||
|
||||
# --- Live content in the ticker -------------------------------------
|
||||
#
|
||||
# By default a live game preempts Vegas entirely: the display controller
|
||||
# refuses to run the ticker while any plugin reports live priority, and you
|
||||
# get the full-screen scoreboard instead. Set live_in_ticker to keep the
|
||||
# marquee running and let live content take extra turns within it.
|
||||
#
|
||||
# The rotation is otherwise a strict round robin -- every plugin appears
|
||||
# exactly once per cycle -- so with a dozen plugins enabled a live score
|
||||
# comes round once a lap and can be minutes old on screen. Weighting lets a
|
||||
# plugin claim several slots per cycle instead.
|
||||
#
|
||||
# Weights are per plugin, not per game: a scoreboard showing four live
|
||||
# games still occupies one slot at a time, and rotates its own games within
|
||||
# that slot using its own favorite_live_boost.
|
||||
live_in_ticker: bool = False
|
||||
|
||||
# Slots per cycle for a plugin reporting live content. 1 disables the boost
|
||||
# and restores the plain round robin.
|
||||
live_weight: int = 3
|
||||
|
||||
# Slots per cycle for a plugin whose live content involves a favorite team.
|
||||
# Only plugins implementing get_vegas_priority_weight() can claim this --
|
||||
# the core cannot tell whose game is on, so the plugin reports it.
|
||||
favorite_live_weight: int = 5
|
||||
|
||||
# Performance settings
|
||||
target_fps: int = 125 # Target frame rate
|
||||
buffer_ahead: int = 2 # Number of plugins to buffer ahead
|
||||
@@ -197,16 +159,10 @@ class VegasModeConfig:
|
||||
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', 0.0)),
|
||||
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', [])),
|
||||
live_in_ticker=bool(vegas_config.get('live_in_ticker', False)),
|
||||
# Clamped: a weight below 1 would drop the plugin from the rotation
|
||||
# entirely, and a very large one starves everything else.
|
||||
live_weight=max(1, min(10, int(vegas_config.get('live_weight', 3)))),
|
||||
favorite_live_weight=max(
|
||||
1, min(10, int(vegas_config.get('favorite_live_weight', 5)))),
|
||||
target_fps=int(vegas_config.get('target_fps', 125)),
|
||||
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
|
||||
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
|
||||
@@ -236,9 +192,6 @@ class VegasModeConfig:
|
||||
'lead_in_width': self.lead_in_width,
|
||||
'plugins_per_cycle': self.plugins_per_cycle,
|
||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
||||
'live_in_ticker': self.live_in_ticker,
|
||||
'live_weight': self.live_weight,
|
||||
'favorite_live_weight': self.favorite_live_weight,
|
||||
'overflow_mode': self.overflow_mode,
|
||||
'plugin_order': self.plugin_order,
|
||||
'excluded_plugins': list(self.excluded_plugins),
|
||||
@@ -406,15 +359,6 @@ class VegasModeConfig:
|
||||
|
||||
if 'enabled' in vegas_config:
|
||||
self.enabled = vegas_config['enabled']
|
||||
if 'live_in_ticker' in vegas_config:
|
||||
self.live_in_ticker = bool(vegas_config['live_in_ticker'])
|
||||
# Clamped exactly as from_config does: a weight below 1 would drop the
|
||||
# plugin from the rotation, and a huge one starves everything else.
|
||||
if 'live_weight' in vegas_config:
|
||||
self.live_weight = max(1, min(10, int(vegas_config['live_weight'])))
|
||||
if 'favorite_live_weight' in vegas_config:
|
||||
self.favorite_live_weight = max(
|
||||
1, min(10, int(vegas_config['favorite_live_weight'])))
|
||||
if 'scroll_speed' in vegas_config:
|
||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||
if 'separator_width' in vegas_config:
|
||||
|
||||
@@ -12,7 +12,6 @@ Supports three display modes per plugin:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
import threading
|
||||
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
|
||||
@@ -31,21 +30,6 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _percentile(ordered: List[float], fraction: float) -> float:
|
||||
"""Nearest-rank percentile of an already-sorted list.
|
||||
|
||||
Index ceil(n * fraction) - 1, so 100 samples at 0.99 give the 99th-ranked
|
||||
value. The obvious int(n * fraction) is off by one and, at exactly 100
|
||||
samples, lands on the maximum -- which is the number already reported
|
||||
alongside this one as the worst frame, so the two columns would agree
|
||||
precisely when the sample was smallest.
|
||||
"""
|
||||
if not ordered:
|
||||
return 0.0
|
||||
index = math.ceil(len(ordered) * fraction) - 1
|
||||
return ordered[min(len(ordered) - 1, max(0, index))]
|
||||
|
||||
|
||||
class VegasModeCoordinator:
|
||||
"""
|
||||
Orchestrates Vegas scroll mode operation.
|
||||
@@ -398,12 +382,6 @@ class VegasModeCoordinator:
|
||||
fps_log_interval = 5.0 # Log FPS every 5 seconds
|
||||
last_fps_log_time = start_time
|
||||
fps_frame_count = 0
|
||||
# A mean hides stutter completely. At 120fps a five-second window is
|
||||
# ~600 frames, so a 200ms freeze -- plainly visible on a marquee --
|
||||
# moves the average from 120.0 to 115.4 and reads as healthy. What a
|
||||
# viewer actually notices is the worst frame, so track that too.
|
||||
frame_worst = 0.0
|
||||
frame_times: List[float] = []
|
||||
|
||||
logger.info("Starting Vegas iteration for %.1fs", duration)
|
||||
|
||||
@@ -439,11 +417,6 @@ class VegasModeCoordinator:
|
||||
frame_elapsed = time.time() - frame_started
|
||||
time.sleep(max(0.0, frame_interval - frame_elapsed))
|
||||
|
||||
# Measured before the sleep: time spent working, not pacing.
|
||||
if frame_elapsed > frame_worst:
|
||||
frame_worst = frame_elapsed
|
||||
frame_times.append(frame_elapsed)
|
||||
|
||||
# Increment frame count and check for interrupt periodically
|
||||
frame_count += 1
|
||||
fps_frame_count += 1
|
||||
@@ -452,16 +425,12 @@ class VegasModeCoordinator:
|
||||
current_time = time.time()
|
||||
if current_time - last_fps_log_time >= fps_log_interval:
|
||||
fps = fps_frame_count / (current_time - last_fps_log_time)
|
||||
p99 = _percentile(sorted(frame_times), 0.99)
|
||||
logger.info(
|
||||
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms",
|
||||
fps, self.vegas_config.target_fps, fps_frame_count,
|
||||
p99 * 1000.0, frame_worst * 1000.0
|
||||
"Vegas FPS: %.1f (target: %d, frames: %d)",
|
||||
fps, self.vegas_config.target_fps, fps_frame_count
|
||||
)
|
||||
last_fps_log_time = current_time
|
||||
fps_frame_count = 0
|
||||
frame_worst = 0.0
|
||||
frame_times.clear()
|
||||
|
||||
if (self._interrupt_check and
|
||||
frame_count % self._interrupt_check_interval == 0):
|
||||
@@ -528,12 +497,6 @@ class VegasModeCoordinator:
|
||||
if not self._live_priority_check:
|
||||
return False
|
||||
|
||||
if self.vegas_config.live_in_ticker:
|
||||
# The ticker keeps live content rather than yielding to it; the
|
||||
# extra turns are arranged in the rotation itself, so there is
|
||||
# nothing to pause for.
|
||||
return False
|
||||
|
||||
try:
|
||||
live_mode = self._live_priority_check()
|
||||
if live_mode:
|
||||
|
||||
@@ -68,21 +68,6 @@ class PluginAdapter:
|
||||
# always the same opening items.
|
||||
self._item_offsets: dict = {}
|
||||
|
||||
# What the matching entry in _item_offsets is an offset *into*, as
|
||||
# (kind, size). An offset only means anything against the content it
|
||||
# was derived from, and there are three incompatible kinds:
|
||||
#
|
||||
# ('rows', n) index into a list of n images
|
||||
# ('cuts', n) index into the n item boundaries of one image
|
||||
# ('cols', w) pixel column in a w-wide image with no item boundaries
|
||||
#
|
||||
# Without this the offsets were reused across kinds — a plugin that
|
||||
# returned one wide image on one fetch and several rows on the next had
|
||||
# a pixel column of 1400 read back as a row index — and across content
|
||||
# changes, where a column recorded against a 9,793px news strip pointed
|
||||
# into unrelated headlines once the strip refreshed to 9,505px.
|
||||
self._offset_shapes: dict = {}
|
||||
|
||||
logger.info(
|
||||
"PluginAdapter initialized: display=%dx%d",
|
||||
self.display_width, self.display_height
|
||||
@@ -413,88 +398,6 @@ class PluginAdapter:
|
||||
return 0
|
||||
return int(self.display_width * ratio)
|
||||
|
||||
def _resume_offset(self, plugin_id: str, shape: Tuple[str, int]) -> int:
|
||||
"""
|
||||
The plugin's stored rotation offset, if it still applies.
|
||||
|
||||
An offset is only meaningful against content shaped the way it was
|
||||
when the offset was recorded. When the shape has changed — a different
|
||||
number of rows, a re-rendered strip with different item boundaries —
|
||||
the stored value points somewhere arbitrary, so rotation restarts.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
shape: (kind, size) describing what an offset would index into now
|
||||
|
||||
Returns:
|
||||
The stored offset, or 0 when it no longer applies
|
||||
"""
|
||||
if self._offset_shapes.get(plugin_id) != shape:
|
||||
if plugin_id in self._item_offsets:
|
||||
logger.info(
|
||||
"[%s] Content is %s now, was %s — restarting the rotation "
|
||||
"rather than resuming at a position that no longer means "
|
||||
"anything", plugin_id, shape,
|
||||
self._offset_shapes.get(plugin_id))
|
||||
self._item_offsets.pop(plugin_id, None)
|
||||
self._offset_shapes[plugin_id] = shape
|
||||
return 0
|
||||
return self._item_offsets.get(plugin_id, 0)
|
||||
|
||||
def _record_offset(
|
||||
self, plugin_id: str, offset: int, shape: Tuple[str, int]
|
||||
) -> None:
|
||||
"""Store where the next window should resume, with what it indexes."""
|
||||
if offset:
|
||||
self._item_offsets[plugin_id] = offset
|
||||
self._offset_shapes[plugin_id] = shape
|
||||
else:
|
||||
# A wrapped-to-zero rotation is the same as no state at all, and
|
||||
# keeping the key would report a window as active when the next
|
||||
# pass starts from the top anyway.
|
||||
self._item_offsets.pop(plugin_id, None)
|
||||
self._offset_shapes.pop(plugin_id, None)
|
||||
|
||||
def _clear_offset(self, plugin_id: str) -> None:
|
||||
"""Forget any rotation state for a plugin."""
|
||||
self._item_offsets.pop(plugin_id, None)
|
||||
self._offset_shapes.pop(plugin_id, None)
|
||||
|
||||
def _merge_trailing_runt(self, end: int, width: int, budget: int) -> int:
|
||||
"""
|
||||
Extend a window to the end of the content when what would be left over
|
||||
is too small to be worth its own pass.
|
||||
|
||||
Windows were placed by walking forward from the last one, which makes
|
||||
the final window whatever happens to remain. Measured on a live panel
|
||||
that produced a 1,840px stocks ticker splitting 1,492 + 348 — the
|
||||
second pass showing seven seconds of content before cutting, which
|
||||
reads as the display failing rather than as a rotation.
|
||||
|
||||
Absorbing the remainder overruns the budget by less than one window
|
||||
floor, which is a better trade than a fragment: the budget is a guard
|
||||
against one plugin holding the panel for minutes, not a hard limit.
|
||||
|
||||
Args:
|
||||
end: Column the window would otherwise end at
|
||||
width: Full content width
|
||||
budget: Width budget being applied
|
||||
|
||||
Returns:
|
||||
``end``, or ``width`` when the remainder is below the floor
|
||||
"""
|
||||
remainder = width - end
|
||||
# Measured against the budget rather than the panel: snapping to item
|
||||
# boundaries means an ordinary window already lands short of the budget
|
||||
# (a 512px budget over 182px-pitch items yields 348px windows), so an
|
||||
# absolute floor would merge windows that were never fragments. Half a
|
||||
# budget separates "a short last pass" from "a sliver", and caps the
|
||||
# overrun this can cause at 1.5 budgets.
|
||||
floor = budget // 2
|
||||
if 0 < remainder < floor:
|
||||
return width
|
||||
return end
|
||||
|
||||
def _apply_width_budget(
|
||||
self, images: List[Image.Image], plugin_id: str,
|
||||
plugin: Optional['BasePlugin'] = None
|
||||
@@ -532,48 +435,32 @@ class PluginAdapter:
|
||||
|
||||
if not budget or total <= budget:
|
||||
# Fits, so reset rotation — the whole segment is being shown.
|
||||
self._clear_offset(plugin_id)
|
||||
self._item_offsets.pop(plugin_id, None)
|
||||
return images
|
||||
|
||||
if len(images) == 1:
|
||||
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
||||
|
||||
shape = ('rows', len(images))
|
||||
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._resume_offset(plugin_id, shape) % len(images)
|
||||
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.
|
||||
#
|
||||
# A window may overrun the budget while it is still shorter than the
|
||||
# runt floor, for the same reason _merge_trailing_runt exists on the
|
||||
# single-image path: a pass far shorter than its neighbours reads as
|
||||
# the display failing rather than as a rotation. Rows of 450, 450 and
|
||||
# 100 against a 512px budget used to give the 100 a pass of its own --
|
||||
# two seconds against nine. Wrapping does not prevent that, because it
|
||||
# only helps when the row wrapped to actually fits.
|
||||
floor = budget // 2
|
||||
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:
|
||||
# Keep the overrun bounded at the same 1.5 budgets the
|
||||
# single-image path allows. A next row too wide to absorb
|
||||
# leaves a short window standing -- better than a window of
|
||||
# 1.9 budgets, and the same trade the always-take-the-first
|
||||
# rule below already makes.
|
||||
if used >= floor or used + cost > budget + floor:
|
||||
break
|
||||
break
|
||||
selected.append(img)
|
||||
used += cost
|
||||
consumed += 1
|
||||
@@ -585,8 +472,7 @@ class PluginAdapter:
|
||||
plugin_id, budget, len(selected), len(images), used
|
||||
)
|
||||
else:
|
||||
self._record_offset(
|
||||
plugin_id, (start + consumed) % len(images), shape)
|
||||
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",
|
||||
@@ -604,13 +490,16 @@ class PluginAdapter:
|
||||
|
||||
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.
|
||||
|
||||
Rotation is tracked as an index into the strip's item boundaries rather
|
||||
than as a pixel column, because a ticker re-renders between fetches. A
|
||||
column recorded against one render points at unrelated content in the
|
||||
next as soon as anything ahead of it changes width — a digit in a
|
||||
price, a shorter headline. The Nth boundary stays the Nth boundary.
|
||||
"""
|
||||
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
|
||||
@@ -625,17 +514,9 @@ class PluginAdapter:
|
||||
# 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.
|
||||
#
|
||||
# With no items to index, the offset here has to stay a column, so
|
||||
# it is only reusable while the image keeps its width.
|
||||
shape = ('cols', img.width)
|
||||
offset = 0 if mode == 'truncate' else self._resume_offset(
|
||||
plugin_id, shape)
|
||||
end = self._merge_trailing_runt(
|
||||
min(offset + budget, img.width), img.width, budget)
|
||||
end = min(offset + budget, img.width)
|
||||
if mode != 'truncate':
|
||||
self._record_offset(
|
||||
plugin_id, 0 if end >= img.width else end, shape)
|
||||
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",
|
||||
@@ -647,15 +528,8 @@ class PluginAdapter:
|
||||
# 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})
|
||||
|
||||
shape = ('cuts', len(cuts))
|
||||
index = 0 if mode == 'truncate' else self._resume_offset(
|
||||
plugin_id, shape)
|
||||
# Clamped rather than wrapped: a stale index past the end means the
|
||||
# strip shrank, and restarting reads better than landing near the end.
|
||||
start_index = index if 0 <= index < len(cuts) - 1 else 0
|
||||
start = cuts[start_index]
|
||||
|
||||
later = cuts[start_index + 1:]
|
||||
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:
|
||||
@@ -663,22 +537,15 @@ class PluginAdapter:
|
||||
# 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)
|
||||
end = self._merge_trailing_runt(end, img.width, budget)
|
||||
# Every candidate for `end` came from `cuts` (which includes img.width),
|
||||
# so this always resolves; the fallback is defensive only.
|
||||
end_index = cuts.index(end) if end in cuts else len(cuts) - 1
|
||||
|
||||
if mode != 'truncate':
|
||||
# Next cycle resumes at the boundary this one stopped on; wrap when
|
||||
# the strip ends.
|
||||
self._record_offset(
|
||||
plugin_id, 0 if end >= img.width else end_index, shape)
|
||||
# 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 %d-%d of %d, %s",
|
||||
"(%dpx) at item boundaries, %s",
|
||||
plugin_id, budget, img.width, start, end, end - start,
|
||||
start_index, end_index, len(cuts) - 1,
|
||||
"showing the start only (overflow=truncate)"
|
||||
if mode == 'truncate' else "window advances next cycle"
|
||||
)
|
||||
|
||||
@@ -406,8 +406,6 @@ class StreamManager:
|
||||
)
|
||||
logger.info("Ordered plugins: %s", ordered_plugins)
|
||||
|
||||
ordered_plugins = self._apply_priority_weights(ordered_plugins)
|
||||
|
||||
# Atomically update shared state under lock to avoid races with prefetchers
|
||||
with self._buffer_lock:
|
||||
self._ordered_plugins = ordered_plugins
|
||||
@@ -419,143 +417,6 @@ class StreamManager:
|
||||
|
||||
logger.info("=" * 60)
|
||||
|
||||
def _plugin_weight(self, plugin_id: str) -> int:
|
||||
"""Slots per cycle for one plugin.
|
||||
|
||||
A plugin may answer for itself via get_vegas_priority_weight() -- the
|
||||
only way favorite-team awareness can reach here, since the core can see
|
||||
that a game is live but not whose. When it declines (returns None, the
|
||||
default), live content earns ``live_weight`` and everything else 1.
|
||||
"""
|
||||
plugin = None
|
||||
try:
|
||||
plugin = self.plugin_manager.plugins.get(plugin_id)
|
||||
except (AttributeError, TypeError):
|
||||
return 1
|
||||
if plugin is None:
|
||||
return 1
|
||||
|
||||
try:
|
||||
if hasattr(plugin, 'get_vegas_priority_weight'):
|
||||
declared = plugin.get_vegas_priority_weight()
|
||||
if declared is not None:
|
||||
return max(1, min(10, int(declared)))
|
||||
except Exception:
|
||||
# Deliberately falls through to the core's own live check rather
|
||||
# than demoting to 1. The plugin's weight calculation is broken,
|
||||
# but has_live_priority() and has_live_content() are separate
|
||||
# methods guarded separately below -- a plugin that genuinely has
|
||||
# a live game should still get live_weight for it.
|
||||
logger.exception("[%s] get_vegas_priority_weight() failed", plugin_id)
|
||||
|
||||
try:
|
||||
if (hasattr(plugin, 'has_live_priority')
|
||||
and hasattr(plugin, 'has_live_content')
|
||||
and plugin.has_live_priority()
|
||||
and plugin.has_live_content()):
|
||||
return self.config.live_weight
|
||||
except Exception:
|
||||
logger.exception("[%s] live-content check failed", plugin_id)
|
||||
return 1
|
||||
|
||||
def _apply_priority_weights(self, ordered: List[str]) -> List[str]:
|
||||
"""Expand the rotation so weighted plugins take several turns per cycle.
|
||||
|
||||
Smooth Weighted Round-Robin, the same scheduler the sports plugins use
|
||||
to rotate their own games: a plugin of weight N appears N times per
|
||||
cycle, and the repeats are spaced through the cycle rather than
|
||||
clumped, so a live score is never three-in-a-row followed by a long
|
||||
silence.
|
||||
|
||||
Returns the input unchanged when nothing is weighted, which is both the
|
||||
common case and the pre-existing behaviour.
|
||||
"""
|
||||
if not ordered or not self.config.live_in_ticker:
|
||||
return ordered
|
||||
|
||||
weights = {pid: self._plugin_weight(pid) for pid in ordered}
|
||||
total = sum(weights.values())
|
||||
if total <= len(ordered):
|
||||
return ordered # nothing boosted; plain round robin
|
||||
|
||||
current = {pid: 0 for pid in ordered}
|
||||
schedule: List[str] = []
|
||||
for _ in range(total):
|
||||
for pid in ordered:
|
||||
current[pid] += weights[pid]
|
||||
picked = max(current, key=lambda p: current[p])
|
||||
current[picked] -= total
|
||||
schedule.append(picked)
|
||||
|
||||
schedule = self._unclump_seam(schedule)
|
||||
|
||||
boosted = {p: w for p, w in weights.items() if w > 1}
|
||||
logger.info(
|
||||
"Vegas rotation weighted: %d slots for %d plugins (boosted: %s)",
|
||||
len(schedule), len(ordered), boosted)
|
||||
return schedule
|
||||
|
||||
@staticmethod
|
||||
def _unclump_seam(schedule: List[str]) -> List[str]:
|
||||
"""Stop the heaviest plugin sitting on both ends of the cycle.
|
||||
|
||||
Smooth Weighted Round-Robin spaces repeats well *within* a pass, but
|
||||
it schedules the heaviest item first and often last too. The strip
|
||||
loops, so those two are neighbours: the one place the marquee shows
|
||||
the same plugin twice running is the seam between cycles.
|
||||
|
||||
Rotating the list cannot fix this. Rotation preserves the cyclic order
|
||||
exactly, so it only moves where the seam is drawn, not the adjacency
|
||||
itself. The trailing entry has to be swapped with one from the middle
|
||||
whose neighbours differ from it, which breaks the pair without
|
||||
creating another.
|
||||
|
||||
Left alone when no such position exists -- a rotation short enough or
|
||||
lopsided enough to have none is one where the plugin is unavoidably
|
||||
adjacent to itself anyway.
|
||||
"""
|
||||
if len(schedule) < 3 or schedule[0] != schedule[-1]:
|
||||
return schedule
|
||||
|
||||
repeated = schedule[-1]
|
||||
size = len(schedule)
|
||||
|
||||
def cyclic_doubles(seq) -> int:
|
||||
return sum(1 for i in range(size) if seq[i] == seq[(i + 1) % size])
|
||||
|
||||
def clearance(seq, value) -> int:
|
||||
"""Smallest cyclic gap between appearances of `value`."""
|
||||
at = [i for i, v in enumerate(seq) if v == value]
|
||||
if len(at) < 2:
|
||||
return size
|
||||
return min(min((b - a) % size, (a - b) % size)
|
||||
for i, a in enumerate(at) for b in at[i + 1:])
|
||||
|
||||
# Try each swap and judge the result, rather than reasoning about which
|
||||
# neighbours the two moved elements will end up with. That reasoning is
|
||||
# where the first version went wrong: it guarded the slot `repeated`
|
||||
# moves into but not the one the displaced element lands in, so
|
||||
# ['a','b','c','d','x','y','x','a'] came back ending ['x','x'] -- the
|
||||
# seam duplicate traded for a fresh one.
|
||||
best = None
|
||||
best_clearance = -1
|
||||
for j in range(1, size - 1):
|
||||
candidate = list(schedule)
|
||||
candidate[j], candidate[-1] = candidate[-1], candidate[j]
|
||||
if cyclic_doubles(candidate):
|
||||
continue
|
||||
# Among the repairs that work, prefer the one that leaves the
|
||||
# boosted plugin most evenly spread; taking the first that merely
|
||||
# fits moved a repeat from a gap of 7 into a gap of 2.
|
||||
spread = clearance(candidate, repeated)
|
||||
if spread > best_clearance:
|
||||
best, best_clearance = candidate, spread
|
||||
|
||||
# None exists when the value is unavoidably adjacent to itself -- a
|
||||
# plugin holding most of the slots has to be. Schedule it as it is
|
||||
# rather than refuse.
|
||||
return best if best is not None else schedule
|
||||
|
||||
def _prefetch_content(self, count: int = 1) -> None:
|
||||
"""
|
||||
Prefetch content for upcoming plugins.
|
||||
|
||||
@@ -29,16 +29,18 @@ def success_response(
|
||||
Flask jsonify response
|
||||
"""
|
||||
response_data = create_success_response(data, message, metadata)
|
||||
|
||||
# Timing is merged into whatever the caller passed, without inventing a
|
||||
# metadata block for responses that have neither.
|
||||
enriched = dict(metadata) if metadata is not None else {}
|
||||
|
||||
# Add request metadata if available
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
|
||||
# Add timing if request start time is available
|
||||
if hasattr(request, 'start_time'):
|
||||
enriched['response_time_ms'] = int((time.time() - request.start_time) * 1000)
|
||||
|
||||
if metadata is not None or enriched:
|
||||
response_data['metadata'] = enriched
|
||||
|
||||
metadata['response_time_ms'] = int((time.time() - request.start_time) * 1000)
|
||||
|
||||
if metadata:
|
||||
response_data['metadata'] = metadata
|
||||
|
||||
return jsonify(response_data)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ Centralized error handling for web interface.
|
||||
Provides helpers for consistent error responses across API endpoints.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from flask import jsonify
|
||||
|
||||
@@ -17,97 +16,6 @@ from src.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Credentials that turn up inside exception text. A requests error quotes the
|
||||
# URL it failed on, and plugins that authenticate by query string put their key
|
||||
# there, so echoing an exception verbatim can hand out an API key. Redact the
|
||||
# value, keep the parameter name -- knowing *which* credential was involved is
|
||||
# part of the diagnosis.
|
||||
_REDACT_CREDENTIAL = re.compile(
|
||||
r'((?:api[_-]?key|access[_-]?token|auth|apikey|key|passwd|password|pwd|'
|
||||
r'secret|sig|signature|token)["\']?\s*[=:]\s*["\']?)([^\s&"\'<>,}]+)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# `Authorization: <scheme> <credential>`. The scheme name is kept because it
|
||||
# says which kind of credential failed; the credential goes. Any scheme
|
||||
# matches, not a fixed list: ApiKey, Negotiate, NTLM, AWS4-HMAC-SHA256 and
|
||||
# whatever a plugin's API invents next are all credentials, and a list would
|
||||
# silently leak the ones nobody thought of. Not covered by the generic pattern
|
||||
# above, whose value part stops at whitespace and so would keep the credential
|
||||
# once a space follows the scheme.
|
||||
_REDACT_AUTH_HEADER = re.compile(
|
||||
r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*'
|
||||
r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept
|
||||
r'([^\s,"\'<>}]+)', # the credential, redacted
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Credentials embedded in a URL: https://user:password@host. requests quotes
|
||||
# the full URL in its exceptions, so this is a realistic leak. The username is
|
||||
# kept -- it identifies which account failed without being the secret.
|
||||
_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
|
||||
re.IGNORECASE)
|
||||
|
||||
# Long enough for an errno string with a path, short enough not to dump a
|
||||
# parser's worth of context into a JSON field.
|
||||
_MAX_DETAIL_LENGTH = 400
|
||||
|
||||
|
||||
def describe_exception(exc: BaseException,
|
||||
max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
||||
"""
|
||||
One-line, safe-to-return description of an exception.
|
||||
|
||||
The generic "an error occurred; see logs for details" tells a user nothing
|
||||
and, when the failure is bad enough, the logs are unreachable too: a device
|
||||
whose storage was failing returned that message from every endpoint
|
||||
*including* the log viewer, because journalctl could not be executed. The
|
||||
underlying `[Errno 5] Input/output error` named the fault immediately.
|
||||
|
||||
Returns "TypeName: message", credentials redacted and length capped. The
|
||||
type alone is worth carrying -- a bare PermissionError says more than any
|
||||
generic sentence.
|
||||
|
||||
Args:
|
||||
exc: The exception to describe
|
||||
max_length: Truncate beyond this many characters
|
||||
|
||||
Returns:
|
||||
A single-line description, never empty
|
||||
"""
|
||||
message = str(exc).strip()
|
||||
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
||||
return redact_text(text, max_length)
|
||||
|
||||
|
||||
def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
||||
"""Make arbitrary text safe to hand back over HTTP.
|
||||
|
||||
Split out of describe_exception because exceptions are not the only thing
|
||||
worth returning: a subprocess's stderr, or a message a helper script
|
||||
printed, is just as useful to a user and just as capable of carrying a
|
||||
token or a password in it.
|
||||
|
||||
Args:
|
||||
text: The text to redact
|
||||
max_length: Truncate beyond this many characters
|
||||
|
||||
Returns:
|
||||
A single line, credentials replaced, length capped.
|
||||
"""
|
||||
text = text or ''
|
||||
# Order matters: the URL and header forms are more specific than the
|
||||
# generic key=value pattern, which would otherwise chew the scheme.
|
||||
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
|
||||
text = _REDACT_AUTH_HEADER.sub(r'\1<redacted>', text)
|
||||
text = _REDACT_CREDENTIAL.sub(r'\1<redacted>', text)
|
||||
# Collapse newlines/tabs so the detail stays one line in a JSON field.
|
||||
text = ' '.join(text.split())
|
||||
if len(text) > max_length:
|
||||
text = text[:max_length - 1].rstrip() + '…'
|
||||
return text
|
||||
|
||||
|
||||
def create_error_response(
|
||||
error_code: ErrorCode,
|
||||
message: str,
|
||||
@@ -161,17 +69,14 @@ def create_success_response(
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
# All three use `is not None` rather than truthiness: "" and {} are
|
||||
# values a caller chose to send, and dropping them silently would make
|
||||
# the response shape depend on the data.
|
||||
if data is not None:
|
||||
response["data"] = data
|
||||
|
||||
if message is not None:
|
||||
|
||||
if message:
|
||||
response["message"] = message
|
||||
|
||||
if metadata is not None:
|
||||
|
||||
if metadata:
|
||||
response["metadata"] = metadata
|
||||
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -89,11 +89,7 @@ class WebInterfaceError:
|
||||
self.category = category or self._infer_category(error_code)
|
||||
self.details = details
|
||||
self.context = context or {}
|
||||
# `is None`, not truthiness: an explicit [] means "this caller has
|
||||
# no suggestions to offer", which the default list would override.
|
||||
self.suggested_fixes = (
|
||||
suggested_fixes if suggested_fixes is not None
|
||||
else self._get_default_suggestions(error_code))
|
||||
self.suggested_fixes = suggested_fixes or self._get_default_suggestions(error_code)
|
||||
self.original_error = original_error
|
||||
|
||||
def _infer_category(self, error_code: ErrorCode) -> ErrorCategory:
|
||||
|
||||
@@ -43,15 +43,10 @@ def validate_image_url(url: str) -> Tuple[bool, Optional[str]]:
|
||||
if any(handler in url_lower for handler in ['onerror=', 'onload=', 'onclick=']):
|
||||
return False, "Event handlers not allowed in URLs"
|
||||
|
||||
# Reject directory traversal anywhere, not only in relative paths:
|
||||
# http://host/../secret is as much a traversal attempt as /../secret.
|
||||
if '..' in url:
|
||||
return False, "Invalid path: directory traversal not allowed"
|
||||
|
||||
# Allow relative paths starting with /
|
||||
if url.startswith('/'):
|
||||
# // would be a protocol-relative URL, not a local path
|
||||
if url.startswith('//'):
|
||||
# Validate it's a safe relative path (no directory traversal)
|
||||
if '..' in url or url.startswith('//'):
|
||||
return False, "Invalid relative path"
|
||||
return True, None
|
||||
|
||||
@@ -109,11 +104,10 @@ def validate_file_upload(filename: str, max_size_mb: int = 10,
|
||||
if '..' in filename or '/' in filename or '\\' in filename:
|
||||
return False, "Filename contains invalid characters"
|
||||
|
||||
# Check extension if specified. Both sides are lowercased: the caller's
|
||||
# list is as likely to hold '.TTF' as the filename is.
|
||||
# Check extension if specified
|
||||
if allowed_extensions:
|
||||
file_ext = Path(filename).suffix.lower()
|
||||
if file_ext not in [ext.lower() for ext in allowed_extensions]:
|
||||
if file_ext not in allowed_extensions:
|
||||
return False, f"File extension must be one of: {', '.join(allowed_extensions)}"
|
||||
|
||||
return True, None
|
||||
@@ -153,8 +147,7 @@ def validate_numeric_range(value: float, min_val: Optional[float] = None,
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
# bool is an int subclass, so True would otherwise validate as 1.
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||
if not isinstance(value, (int, float)):
|
||||
return False, "Value must be a number"
|
||||
|
||||
if min_val is not None and value < min_val:
|
||||
@@ -190,19 +183,11 @@ def validate_string_length(text: str, min_length: Optional[int] = None,
|
||||
|
||||
def sanitize_plugin_config(config: dict) -> dict:
|
||||
"""
|
||||
Restrict a plugin config to safe key names and value types.
|
||||
|
||||
Drops keys that are not plain identifiers and values that are not
|
||||
JSON-ish scalars, lists, or dicts, recursing into the latter two.
|
||||
|
||||
String values are returned **unescaped**: output escaping is the
|
||||
template layer's job, and escaping here would store the escaped form
|
||||
in config.json. Do not read this function as XSS protection for
|
||||
rendered output.
|
||||
|
||||
Sanitize plugin configuration input to prevent injection.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
|
||||
|
||||
Returns:
|
||||
Sanitized configuration dictionary
|
||||
"""
|
||||
|
||||
@@ -10,8 +10,8 @@ WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/scripts/utils/wifi_monitor_daemon.py --interval 30
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=ledmatrix-wifi-monitor
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -9,25 +9,8 @@ User=root
|
||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/run.py
|
||||
# Restart=always, not on-failure: run.py exiting 0 (a clean shutdown path taken
|
||||
# for a reason that no longer applies, e.g. a config reload) would otherwise leave
|
||||
# the service stopped and the panel dark indefinitely, with systemd considering
|
||||
# that a successful outcome and never bringing it back.
|
||||
Restart=always
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
# Memory ceiling as a share of physical RAM, so one unit file suits a 512 MB
|
||||
# Pi Zero 2 W and an 8 GB Pi 5 alike. This is a backstop, not a tuning knob: it
|
||||
# turns "the board runs out of memory, stops being able to fork, and takes sshd
|
||||
# and the panel down together until someone pulls the plug" into "this one
|
||||
# service restarts".
|
||||
#
|
||||
# NOTE: Raspberry Pi firmware boots the kernel with cgroup_disable=memory, and
|
||||
# systemd accepts this setting and then silently ignores it. Verify with:
|
||||
# grep memory /sys/fs/cgroup/cgroup.controllers
|
||||
# If that prints nothing, add "cgroup_enable=memory cgroup_memory=1" to
|
||||
# /boot/firmware/cmdline.txt (all on line 1) and reboot. first_time_install.sh
|
||||
# does this for you.
|
||||
MemoryMax=85%
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=ledmatrix
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
"""
|
||||
Shared scaffolding for api_v3 blueprint tests.
|
||||
|
||||
Not a test module (the leading underscore keeps pytest from collecting
|
||||
it). It is the pytest-fixture equivalent of ``_make_client()`` in
|
||||
test_uninstall_and_reconcile_endpoint.py, which is unittest-style and
|
||||
requires ``self.addCleanup``.
|
||||
|
||||
The api_v3 blueprint keeps its managers as attributes on a module-level
|
||||
singleton, not in Flask app state, so replacing them with mocks leaks
|
||||
into every later test that imports api_v3 unless the originals are put
|
||||
back. ``api_v3_client`` snapshots and restores them around each test.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
|
||||
# Every manager attribute the blueprint reads. Anything missing here keeps
|
||||
# whatever a previously-run test left on the singleton.
|
||||
API_V3_MANAGER_ATTRS = (
|
||||
'config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager',
|
||||
)
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
|
||||
def build_app(blueprint):
|
||||
app = Flask(__name__)
|
||||
app.config['TESTING'] = True
|
||||
app.config['SECRET_KEY'] = 'test'
|
||||
app.register_blueprint(blueprint, url_prefix='/api/v3')
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_v3_module():
|
||||
"""The api_v3 module with every manager replaced by a MagicMock.
|
||||
|
||||
Restores the original attributes afterwards. Tests point individual
|
||||
managers at real objects (a ConfigManager over tmp_path, say) or set
|
||||
them to None to exercise the not-initialized branches.
|
||||
"""
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
|
||||
originals = {
|
||||
name: getattr(module.api_v3, name, _SENTINEL)
|
||||
for name in API_V3_MANAGER_ATTRS
|
||||
}
|
||||
for name in API_V3_MANAGER_ATTRS:
|
||||
setattr(module.api_v3, name, MagicMock())
|
||||
# Default to the direct path; queue tests opt in explicitly.
|
||||
module.api_v3.operation_queue = None
|
||||
|
||||
yield module
|
||||
|
||||
for name, original in originals.items():
|
||||
if original is _SENTINEL:
|
||||
if hasattr(module.api_v3, name):
|
||||
try:
|
||||
delattr(module.api_v3, name)
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
setattr(module.api_v3, name, original)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_v3_client(api_v3_module):
|
||||
"""Flask test client wired to the mocked blueprint."""
|
||||
return build_app(api_v3_module.api_v3).test_client()
|
||||
@@ -1,275 +0,0 @@
|
||||
"""
|
||||
Tests for src/common/api_helper.py (APIHelper).
|
||||
|
||||
Covers rate limiting, cached GETs, ESPN URL/cache-key construction,
|
||||
session header defaults and per-call merging, the retry adapter, and the
|
||||
fixed clear_cache() behavior (real CacheManager surface: clear_cache /
|
||||
delete / list_cache_files, with safe no-ops elsewhere).
|
||||
|
||||
No real network: helper.session.get/post are always replaced with mocks.
|
||||
"""
|
||||
|
||||
import types
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from freezegun import freeze_time
|
||||
|
||||
import src.common.api_helper as api_helper_module
|
||||
from src.common.api_helper import APIHelper
|
||||
|
||||
|
||||
def _make_response(payload):
|
||||
response = MagicMock()
|
||||
response.json.return_value = payload
|
||||
response.raise_for_status.return_value = None
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache():
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
return cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def helper(cache):
|
||||
helper = APIHelper(cache_manager=cache)
|
||||
# Default min interval is 1.0s and would really sleep between requests.
|
||||
helper.set_rate_limit(0)
|
||||
return helper
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate limiting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRateLimiting:
|
||||
def test_sleeps_for_remaining_interval(self, helper, monkeypatch):
|
||||
fake_time = MagicMock()
|
||||
fake_time.time.side_effect = [102.0, 105.0]
|
||||
monkeypatch.setattr(api_helper_module, 'time', fake_time)
|
||||
|
||||
helper.set_rate_limit(5)
|
||||
helper._last_request_time = 100.0
|
||||
helper._enforce_rate_limit()
|
||||
|
||||
# 2s elapsed of a 5s interval -> sleep the remaining 3s.
|
||||
fake_time.sleep.assert_called_once()
|
||||
assert fake_time.sleep.call_args[0][0] == pytest.approx(3.0)
|
||||
assert helper._last_request_time == 105.0
|
||||
|
||||
def test_no_sleep_when_interval_elapsed(self, helper, monkeypatch):
|
||||
fake_time = MagicMock()
|
||||
fake_time.time.side_effect = [200.0, 201.0]
|
||||
monkeypatch.setattr(api_helper_module, 'time', fake_time)
|
||||
|
||||
helper.set_rate_limit(5)
|
||||
helper._last_request_time = 100.0
|
||||
helper._enforce_rate_limit()
|
||||
|
||||
fake_time.sleep.assert_not_called()
|
||||
assert helper._last_request_time == 201.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGet:
|
||||
def test_cache_hit_skips_request_and_rate_limit(self, helper, cache):
|
||||
cache.get.return_value = {'cached': True}
|
||||
helper.session.get = Mock()
|
||||
rate_spy = Mock()
|
||||
helper._enforce_rate_limit = rate_spy
|
||||
|
||||
result = helper.get('https://example.com/api', cache_key='k')
|
||||
|
||||
assert result == {'cached': True}
|
||||
helper.session.get.assert_not_called()
|
||||
rate_spy.assert_not_called()
|
||||
|
||||
def test_cache_miss_fetches_and_caches_without_ttl(self, helper, cache):
|
||||
cache.get.return_value = None
|
||||
helper.session.get = Mock(return_value=_make_response({'a': 1}))
|
||||
|
||||
result = helper.get('https://example.com/api', cache_key='k',
|
||||
cache_ttl=999)
|
||||
|
||||
assert result == {'a': 1}
|
||||
# Pin the ttl-dropped contract: CacheManager.set is called with
|
||||
# (key, data) only — the cache_ttl argument is discarded.
|
||||
cache.set.assert_called_once_with('k', {'a': 1})
|
||||
|
||||
def test_request_exception_returns_none_and_caches_nothing(
|
||||
self, helper, cache):
|
||||
helper.session.get = Mock(
|
||||
side_effect=requests.exceptions.RequestException('boom'))
|
||||
|
||||
result = helper.get('https://example.com/api', cache_key='k')
|
||||
|
||||
assert result is None
|
||||
cache.set.assert_not_called()
|
||||
|
||||
def test_timeout_zero_falls_back_to_default(self, helper):
|
||||
# Quirk pin: `timeout or self.default_timeout` treats an explicit
|
||||
# timeout=0 as falsy, so the default (30) is used instead.
|
||||
helper.session.get = Mock(return_value=_make_response({}))
|
||||
|
||||
helper.get('https://example.com/api', timeout=0)
|
||||
|
||||
assert helper.session.get.call_args.kwargs['timeout'] == 30
|
||||
|
||||
def test_per_call_headers_merge_over_session_headers(self, helper):
|
||||
helper.session.get = Mock(return_value=_make_response({}))
|
||||
|
||||
helper.get('https://example.com/api', headers={'X-Custom': 'yes'})
|
||||
|
||||
sent = helper.session.get.call_args.kwargs['headers']
|
||||
# Merged, not replaced: session defaults survive alongside the
|
||||
# per-call header.
|
||||
assert sent['X-Custom'] == 'yes'
|
||||
assert sent['User-Agent'] == (
|
||||
'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)')
|
||||
assert sent['Accept'] == 'application/json'
|
||||
# The session's own headers are not polluted by the per-call ones.
|
||||
assert 'X-Custom' not in helper.session.headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ESPN helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEspnHelpers:
|
||||
@freeze_time('2026-08-07')
|
||||
def test_fetch_espn_scoreboard_url_params_and_cache_key(self, helper):
|
||||
helper.get = Mock(return_value={'ok': 1})
|
||||
|
||||
result = helper.fetch_espn_scoreboard('football', 'nfl')
|
||||
|
||||
assert result == {'ok': 1}
|
||||
helper.get.assert_called_once_with(
|
||||
'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard',
|
||||
params={'dates': '20260807', 'limit': 1000},
|
||||
cache_key='espn_football_nfl_20260807',
|
||||
cache_ttl=300,
|
||||
)
|
||||
|
||||
def test_fetch_espn_scoreboard_explicit_date(self, helper):
|
||||
helper.get = Mock(return_value=None)
|
||||
|
||||
helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115')
|
||||
|
||||
kwargs = helper.get.call_args.kwargs
|
||||
assert kwargs['params'] == {'dates': '20250115', 'limit': 1000}
|
||||
assert kwargs['cache_key'] == 'espn_basketball_nba_20250115'
|
||||
|
||||
def test_fetch_espn_standings_url_and_cache_key(self, helper):
|
||||
helper.get = Mock(return_value={'ok': 1})
|
||||
|
||||
helper.fetch_espn_standings('football', 'nfl')
|
||||
|
||||
helper.get.assert_called_once_with(
|
||||
'https://site.api.espn.com/apis/site/v2/sports/football/nfl/standings',
|
||||
cache_key='espn_standings_football_nfl',
|
||||
cache_ttl=3600,
|
||||
)
|
||||
|
||||
def test_fetch_espn_rankings_url_and_cache_key(self, helper):
|
||||
helper.get = Mock(return_value={'ok': 1})
|
||||
|
||||
helper.fetch_espn_rankings('football', 'college-football')
|
||||
|
||||
helper.get.assert_called_once_with(
|
||||
'https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings',
|
||||
cache_key='espn_rankings_football_college-football',
|
||||
cache_ttl=3600,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSessionSetup:
|
||||
def test_user_agent_exact(self, helper):
|
||||
# Regression guard: ESPN began 403ing other user agents; this exact
|
||||
# string must be sent on every request.
|
||||
assert helper.session.headers['User-Agent'] == (
|
||||
'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)')
|
||||
|
||||
def test_retry_adapter_configuration(self):
|
||||
helper = APIHelper(cache_manager=None, max_retries=7)
|
||||
|
||||
retries = helper.session.get_adapter('https://x').max_retries
|
||||
assert retries.total == 7
|
||||
assert {429, 500, 502, 503, 504} <= set(retries.status_forcelist)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# clear_cache (fixed behavior: real CacheManager surface)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestClearCache:
|
||||
def test_no_pattern_uses_clear_cache_method(self):
|
||||
manager = types.SimpleNamespace(clear_cache=Mock())
|
||||
helper = APIHelper(cache_manager=manager)
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
helper.clear_cache()
|
||||
|
||||
manager.clear_cache.assert_called_once_with()
|
||||
|
||||
def test_no_pattern_falls_back_to_clear(self):
|
||||
manager = types.SimpleNamespace(clear=Mock())
|
||||
helper = APIHelper(cache_manager=manager)
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
helper.clear_cache()
|
||||
|
||||
manager.clear.assert_called_once_with()
|
||||
|
||||
def test_no_pattern_manager_without_any_clear_is_noop(self):
|
||||
helper = APIHelper(cache_manager=object())
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
helper.clear_cache() # must not raise
|
||||
|
||||
def test_pattern_deletes_only_matching_keys(self):
|
||||
manager = types.SimpleNamespace(
|
||||
list_cache_files=Mock(return_value=[
|
||||
{'key': 'espn_nfl_x'},
|
||||
{'key': 'other'},
|
||||
]),
|
||||
delete=Mock(),
|
||||
)
|
||||
helper = APIHelper(cache_manager=manager)
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
helper.clear_cache(pattern='espn')
|
||||
|
||||
manager.delete.assert_called_once_with('espn_nfl_x')
|
||||
|
||||
def test_pattern_manager_without_list_cache_files_is_noop(self):
|
||||
helper = APIHelper(cache_manager=object())
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
helper.clear_cache(pattern='espn') # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No cache manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNoCacheManager:
|
||||
def test_all_cache_operations_safe_without_manager(self):
|
||||
helper = APIHelper(cache_manager=None)
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
assert helper.get_cache('k') is None
|
||||
assert helper._get_from_cache('k') is None
|
||||
assert helper.set_cache('k', {'a': 1}) is None
|
||||
assert helper.clear_cache() is None
|
||||
assert helper.clear_cache(pattern='espn') is None
|
||||
@@ -1,226 +0,0 @@
|
||||
"""
|
||||
Endpoint tests for POST /plugins/calendar/upload-credentials.
|
||||
|
||||
The endpoint takes an uploaded Google OAuth credentials file, writes it
|
||||
into the calendar plugin's directory as credentials.json at mode 0600, and
|
||||
copies any previous file aside first. It had no tests.
|
||||
|
||||
Regression coverage for two fixed bugs:
|
||||
- The OAuth-shape check sat inside `except Exception: pass`, so a valid
|
||||
JSON document that is not an object — a bare `42`, a list, a string —
|
||||
raised TypeError on the membership test, was swallowed, and got saved
|
||||
as credentials.json anyway.
|
||||
- Each overwrite created a timestamped backup and nothing ever removed
|
||||
them, so every re-upload left another complete copy of the user's OAuth
|
||||
client credentials in the plugin directory, indefinitely.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
URL = "/api/v3/plugins/calendar/upload-credentials"
|
||||
|
||||
VALID_CREDENTIALS = {
|
||||
"installed": {
|
||||
"client_id": "abc.apps.googleusercontent.com",
|
||||
"client_secret": "shh",
|
||||
"redirect_uris": ["http://localhost"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_dir(tmp_path, api_v3_module):
|
||||
directory = tmp_path / "plugins" / "calendar"
|
||||
directory.mkdir(parents=True)
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory)
|
||||
return directory
|
||||
|
||||
|
||||
def upload(client, content, filename="credentials.json"):
|
||||
# bytes are sent verbatim (to exercise malformed input); anything else
|
||||
# is serialized, so None becomes the JSON literal null rather than an
|
||||
# empty body.
|
||||
payload = content if isinstance(content, bytes) else json.dumps(content).encode()
|
||||
return client.post(
|
||||
URL,
|
||||
data={"file": (io.BytesIO(payload), filename)},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
|
||||
def backups(plugin_dir):
|
||||
return sorted(plugin_dir.glob("credentials.json.backup.*"))
|
||||
|
||||
|
||||
class TestRequestValidation:
|
||||
def test_no_file_part_is_a_400(self, api_v3_client, plugin_dir):
|
||||
response = api_v3_client.post(URL, data={}, content_type="multipart/form-data")
|
||||
assert response.status_code == 400
|
||||
assert "No file provided" in response.get_json()["message"]
|
||||
|
||||
def test_empty_filename_is_a_400(self, api_v3_client, plugin_dir):
|
||||
response = upload(api_v3_client, VALID_CREDENTIALS, filename="")
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.parametrize("filename", ["creds.txt", "creds.pem", "creds"])
|
||||
def test_non_json_extension_is_a_400(self, api_v3_client, plugin_dir, filename):
|
||||
response = upload(api_v3_client, VALID_CREDENTIALS, filename=filename)
|
||||
assert response.status_code == 400
|
||||
assert "JSON file" in response.get_json()["message"]
|
||||
|
||||
def test_uppercase_json_extension_accepted(self, api_v3_client, plugin_dir):
|
||||
assert upload(api_v3_client, VALID_CREDENTIALS,
|
||||
filename="CREDENTIALS.JSON").status_code == 200
|
||||
|
||||
def test_oversized_file_is_a_400(self, api_v3_client, plugin_dir):
|
||||
response = upload(api_v3_client, b"x" * (1024 * 1024 + 1))
|
||||
assert response.status_code == 400
|
||||
assert "1MB" in response.get_json()["message"]
|
||||
assert not (plugin_dir / "credentials.json").exists()
|
||||
|
||||
def test_invalid_json_is_a_400(self, api_v3_client, plugin_dir):
|
||||
response = upload(api_v3_client, b"{not json")
|
||||
assert response.status_code == 400
|
||||
assert "not valid JSON" in response.get_json()["message"]
|
||||
assert not (plugin_dir / "credentials.json").exists()
|
||||
|
||||
def test_missing_plugin_directory_is_a_404(self, api_v3_client, api_v3_module, tmp_path):
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(
|
||||
tmp_path / "not-installed")
|
||||
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 404
|
||||
|
||||
|
||||
class TestOAuthShapeValidation:
|
||||
def test_installed_key_accepted(self, api_v3_client, plugin_dir):
|
||||
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 200
|
||||
|
||||
def test_web_key_accepted(self, api_v3_client, plugin_dir):
|
||||
assert upload(api_v3_client, {"web": {"client_id": "x"}}).status_code == 200
|
||||
|
||||
def test_object_without_oauth_keys_is_a_400(self, api_v3_client, plugin_dir):
|
||||
response = upload(api_v3_client, {"something": "else"})
|
||||
assert response.status_code == 400
|
||||
assert "valid Google OAuth" in response.get_json()["message"]
|
||||
assert not (plugin_dir / "credentials.json").exists()
|
||||
|
||||
@pytest.mark.parametrize("content", [42, "a string", [1, 2, 3], True, None])
|
||||
def test_valid_json_that_is_not_an_object_is_rejected(
|
||||
self, api_v3_client, plugin_dir, content):
|
||||
# Regression: `'installed' not in 42` raises TypeError, which the
|
||||
# bare `except Exception: pass` swallowed — the file was then saved
|
||||
# as credentials.json despite being unusable as credentials.
|
||||
response = upload(api_v3_client, content)
|
||||
assert response.status_code == 400
|
||||
assert "valid Google OAuth" in response.get_json()["message"]
|
||||
assert not (plugin_dir / "credentials.json").exists()
|
||||
|
||||
|
||||
class TestSaving:
|
||||
def test_file_written_with_contents_intact(self, api_v3_client, plugin_dir):
|
||||
response = upload(api_v3_client, VALID_CREDENTIALS)
|
||||
assert response.status_code == 200
|
||||
saved = json.loads((plugin_dir / "credentials.json").read_text())
|
||||
assert saved == VALID_CREDENTIALS
|
||||
|
||||
def test_response_reports_the_path(self, api_v3_client, plugin_dir):
|
||||
body = upload(api_v3_client, VALID_CREDENTIALS).get_json()
|
||||
assert body["path"].endswith("credentials.json")
|
||||
|
||||
def test_permissions_are_owner_only(self, api_v3_client, plugin_dir):
|
||||
upload(api_v3_client, VALID_CREDENTIALS)
|
||||
mode = stat.S_IMODE((plugin_dir / "credentials.json").stat().st_mode)
|
||||
assert mode == 0o600
|
||||
|
||||
def test_first_upload_creates_no_backup(self, api_v3_client, plugin_dir):
|
||||
upload(api_v3_client, VALID_CREDENTIALS)
|
||||
assert backups(plugin_dir) == []
|
||||
|
||||
def test_overwrite_backs_up_the_previous_file(self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"old": 1}}))
|
||||
upload(api_v3_client, VALID_CREDENTIALS)
|
||||
assert len(backups(plugin_dir)) == 1
|
||||
assert json.loads(backups(plugin_dir)[0].read_text()) == {"installed": {"old": 1}}
|
||||
assert json.loads((plugin_dir / "credentials.json").read_text()) == VALID_CREDENTIALS
|
||||
|
||||
|
||||
class TestBackupPruning:
|
||||
def _seed(self, plugin_dir, count):
|
||||
"""Create `count` backups with distinct, increasing mtimes."""
|
||||
now = int(time.time())
|
||||
for i in range(count):
|
||||
path = plugin_dir / f"credentials.json.backup.{now - (count - i) * 10}"
|
||||
path.write_text(json.dumps({"installed": {"gen": i}}))
|
||||
os.utime(path, (now - (count - i) * 10, now - (count - i) * 10))
|
||||
|
||||
def test_old_backups_are_pruned(self, api_v3_client, plugin_dir):
|
||||
# Regression: nothing ever removed these, so a plugin directory
|
||||
# accumulated one full copy of the user's OAuth credentials per
|
||||
# re-upload, forever.
|
||||
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
|
||||
self._seed(plugin_dir, 7)
|
||||
assert len(backups(plugin_dir)) == 7
|
||||
|
||||
upload(api_v3_client, VALID_CREDENTIALS)
|
||||
assert len(backups(plugin_dir)) == 5
|
||||
|
||||
def test_the_newest_backups_are_the_ones_kept(self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
|
||||
self._seed(plugin_dir, 7)
|
||||
|
||||
upload(api_v3_client, VALID_CREDENTIALS)
|
||||
remaining = backups(plugin_dir)
|
||||
# The just-created backup (of "cur") plus the four newest seeds.
|
||||
contents = [json.loads(p.read_text()) for p in remaining]
|
||||
assert {"installed": {"cur": 1}} in contents
|
||||
assert {"installed": {"gen": 0}} not in contents # oldest seed gone
|
||||
|
||||
def test_under_the_limit_nothing_is_removed(self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
|
||||
self._seed(plugin_dir, 2)
|
||||
upload(api_v3_client, VALID_CREDENTIALS)
|
||||
assert len(backups(plugin_dir)) == 3 # 2 seeded + 1 new
|
||||
|
||||
def test_repeated_uploads_stay_bounded(
|
||||
self, api_v3_client, plugin_dir, api_v3_module, monkeypatch):
|
||||
# The backup filename carries int(time.time()), so uploads inside
|
||||
# the same second all write the same name and overwrite each other.
|
||||
# Advance a fake clock a second per round — otherwise this never
|
||||
# reaches six backups and the bound holds for the wrong reason.
|
||||
clock = {"now": int(time.time())}
|
||||
monkeypatch.setattr(
|
||||
api_v3_module, "time", SimpleNamespace(time=lambda: clock["now"]))
|
||||
for i in range(10):
|
||||
clock["now"] += 1
|
||||
upload(api_v3_client, {"installed": {"round": i}})
|
||||
os.utime(plugin_dir / "credentials.json",
|
||||
(clock["now"], clock["now"]))
|
||||
remaining = backups(plugin_dir)
|
||||
assert len(remaining) == 5
|
||||
# And they are the five most recent rounds, not an arbitrary five.
|
||||
kept = sorted(int(p.name.rsplit(".", 1)[1]) for p in remaining)
|
||||
assert kept == [clock["now"] - 4 + i for i in range(5)]
|
||||
|
||||
def test_unremovable_backup_does_not_fail_the_upload(
|
||||
self, api_v3_client, plugin_dir, monkeypatch):
|
||||
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
|
||||
self._seed(plugin_dir, 7)
|
||||
|
||||
def refuse(self):
|
||||
raise OSError("read-only filesystem")
|
||||
monkeypatch.setattr(Path, "unlink", refuse)
|
||||
|
||||
# Pruning is housekeeping; failing it must not lose the upload.
|
||||
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 200
|
||||
@@ -1,302 +0,0 @@
|
||||
"""
|
||||
Endpoint tests for /plugins/authenticate/spotify and .../ytm.
|
||||
|
||||
The Spotify step-2 handler writes a Python wrapper script to a temp file
|
||||
with the user's redirect URL embedded in it, then runs that file through
|
||||
subprocess. That is the most dangerous shape in the blueprint and had no
|
||||
tests: the URL is user input reaching generated source code.
|
||||
|
||||
The two endpoints are NOT symmetrical, despite the matching names. Only
|
||||
Spotify has a two-step flow, a wrapper script, and a redirect_url; YTM
|
||||
just runs its script directly.
|
||||
|
||||
Regression coverage for one fixed bug: the wrapper file was unlinked in
|
||||
the success/failure branch and again in the TimeoutExpired handler, so
|
||||
any other failure from subprocess.run — the interpreter missing, a fork
|
||||
failure, an interrupted call — left a temp file containing the user's
|
||||
redirect URL behind.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_dir(tmp_path, api_v3_module):
|
||||
"""A plugin directory containing both auth scripts."""
|
||||
directory = tmp_path / "plugins" / "ledmatrix-music"
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "authenticate_spotify.py").write_text("print('spotify')\n")
|
||||
(directory / "authenticate_ytm.py").write_text("print('ytm')\n")
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory)
|
||||
return directory
|
||||
|
||||
|
||||
def completed(returncode=0, stdout="ok", stderr=""):
|
||||
return subprocess.CompletedProcess(
|
||||
args=["python3"], returncode=returncode, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
class TestSpotifyPreconditions:
|
||||
URL = "/api/v3/plugins/authenticate/spotify"
|
||||
|
||||
def test_missing_plugin_directory_is_404(self, api_v3_client, api_v3_module, tmp_path):
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(
|
||||
tmp_path / "not-installed")
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 404
|
||||
assert response.get_json()["message"] == "Plugin not found"
|
||||
|
||||
def test_none_plugin_directory_is_404(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = None
|
||||
assert api_v3_client.post(self.URL, json={}).status_code == 404
|
||||
|
||||
def test_missing_auth_script_is_404(self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "authenticate_spotify.py").unlink()
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 404
|
||||
assert "script not found" in response.get_json()["message"]
|
||||
|
||||
|
||||
class TestSpotifyStepTwo:
|
||||
"""redirect_url present — the wrapper-script path."""
|
||||
|
||||
URL = "/api/v3/plugins/authenticate/spotify"
|
||||
|
||||
def test_success(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed(0, "done")):
|
||||
response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["status"] == "success"
|
||||
assert body["output"] == "done"
|
||||
|
||||
def test_script_failure_is_a_400_with_combined_output(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed(1, "out", "err")):
|
||||
response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["output"] == "outerr"
|
||||
|
||||
def test_timeout_is_a_408(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run",
|
||||
side_effect=subprocess.TimeoutExpired("python3", 120)):
|
||||
response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
|
||||
assert response.status_code == 408
|
||||
assert "timed out" in response.get_json()["message"]
|
||||
|
||||
def test_runs_a_list_argv_never_a_shell(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed()) as run:
|
||||
api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
|
||||
args, kwargs = run.call_args
|
||||
assert isinstance(args[0], list)
|
||||
assert args[0][0] == "python3"
|
||||
assert kwargs.get("shell") in (None, False)
|
||||
|
||||
def test_timeout_is_bounded(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed()) as run:
|
||||
api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
|
||||
assert run.call_args.kwargs["timeout"] == 120
|
||||
|
||||
|
||||
class TestSpotifyWrapperCleanup:
|
||||
URL = "/api/v3/plugins/authenticate/spotify"
|
||||
|
||||
def _wrapper_paths_after(self, api_v3_client, run_mock):
|
||||
"""Run the endpoint and return the wrapper path subprocess saw."""
|
||||
seen = {}
|
||||
|
||||
def capture(args, **kwargs):
|
||||
seen["path"] = args[1]
|
||||
return run_mock(args, **kwargs)
|
||||
|
||||
with patch.object(subprocess, "run", side_effect=capture):
|
||||
api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"})
|
||||
return seen["path"]
|
||||
|
||||
def test_removed_after_success(self, api_v3_client, plugin_dir):
|
||||
path = self._wrapper_paths_after(api_v3_client, lambda *a, **kw: completed())
|
||||
assert not os.path.exists(path)
|
||||
|
||||
def test_removed_after_script_failure(self, api_v3_client, plugin_dir):
|
||||
path = self._wrapper_paths_after(
|
||||
api_v3_client, lambda *a, **kw: completed(1, "out", "err"))
|
||||
assert not os.path.exists(path)
|
||||
|
||||
def test_removed_after_timeout(self, api_v3_client, plugin_dir):
|
||||
def raise_timeout(*a, **kw):
|
||||
raise subprocess.TimeoutExpired("python3", 120)
|
||||
path = self._wrapper_paths_after(api_v3_client, raise_timeout)
|
||||
assert not os.path.exists(path)
|
||||
|
||||
def test_removed_when_subprocess_cannot_start(self, api_v3_client, plugin_dir):
|
||||
# Regression: cleanup lived in the success/failure branch and in the
|
||||
# TimeoutExpired handler only. An OSError from subprocess.run itself
|
||||
# — no interpreter, fork failure — skipped both and left the wrapper,
|
||||
# which contains the user's redirect URL, on disk.
|
||||
def raise_oserror(*a, **kw):
|
||||
raise OSError("[Errno 12] Cannot allocate memory")
|
||||
path = self._wrapper_paths_after(api_v3_client, raise_oserror)
|
||||
assert not os.path.exists(path)
|
||||
|
||||
|
||||
class TestSpotifyRedirectUrlIsNotInjectable:
|
||||
"""The wrapper embeds redirect_url into generated Python source."""
|
||||
|
||||
URL = "/api/v3/plugins/authenticate/spotify"
|
||||
|
||||
ADVERSARIAL = [
|
||||
'''http://cb/?code=x"''',
|
||||
"""http://cb/?code=x'""",
|
||||
'http://cb/?code=x\\',
|
||||
'http://cb/?code=x\nimport os; os.system("id")',
|
||||
'http://cb/?code=x"""\nimport os\n"""',
|
||||
"http://cb/?code=x'''",
|
||||
'http://cb/?code=x\\"\\n',
|
||||
'"; import os; os.system("id"); "',
|
||||
]
|
||||
|
||||
def _wrapper_source(self, api_v3_client, redirect_url):
|
||||
captured = {}
|
||||
|
||||
def capture(args, **kwargs):
|
||||
captured["source"] = Path(args[1]).read_text()
|
||||
return completed()
|
||||
|
||||
with patch.object(subprocess, "run", side_effect=capture):
|
||||
api_v3_client.post(self.URL, json={"redirect_url": redirect_url})
|
||||
return captured["source"]
|
||||
|
||||
@pytest.mark.parametrize("redirect_url", ADVERSARIAL)
|
||||
def test_wrapper_is_still_valid_python(self, api_v3_client, plugin_dir, redirect_url):
|
||||
# If escaping failed, the generated file would not parse at all.
|
||||
source = self._wrapper_source(api_v3_client, redirect_url)
|
||||
ast.parse(source)
|
||||
|
||||
@pytest.mark.parametrize("redirect_url", ADVERSARIAL)
|
||||
def test_url_survives_as_one_string_literal(
|
||||
self, api_v3_client, plugin_dir, redirect_url):
|
||||
# Stronger than "it parses": the URL must still be a single string
|
||||
# assigned to redirect_url, not code that escaped into statements.
|
||||
source = self._wrapper_source(api_v3_client, redirect_url)
|
||||
tree = ast.parse(source)
|
||||
assigned = [
|
||||
node.value.value for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Assign)
|
||||
and isinstance(node.value, ast.Constant)
|
||||
and any(getattr(t, "id", None) == "redirect_url" for t in node.targets)
|
||||
]
|
||||
assert assigned == [redirect_url.strip()]
|
||||
|
||||
def test_injected_call_does_not_become_a_statement(self, api_v3_client, plugin_dir):
|
||||
source = self._wrapper_source(
|
||||
api_v3_client, 'http://cb/\nimport os; os.system("id")')
|
||||
tree = ast.parse(source)
|
||||
imported = {
|
||||
alias.name for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Import) for alias in node.names
|
||||
}
|
||||
# The wrapper legitimately imports sys, subprocess and os; what it
|
||||
# must not gain is a *call* smuggled in through the URL.
|
||||
calls = [
|
||||
node for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "system"
|
||||
]
|
||||
assert calls == []
|
||||
|
||||
|
||||
class TestSpotifyStepOne:
|
||||
"""No redirect_url — the OAuth-URL path, which imports the script."""
|
||||
|
||||
URL = "/api/v3/plugins/authenticate/spotify"
|
||||
|
||||
def test_script_without_credentials_helper_is_an_error(
|
||||
self, api_v3_client, plugin_dir):
|
||||
# The stub script defines neither get_auth_url nor
|
||||
# load_spotify_credentials, so no URL can be produced.
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code in (400, 500)
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
def test_unusable_credentials_do_not_leak_into_the_response(
|
||||
self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "authenticate_spotify.py").write_text(
|
||||
"def load_spotify_credentials():\n"
|
||||
" return ('id-abc', 'super-secret-value', None)\n"
|
||||
)
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert "super-secret-value" not in response.get_data(as_text=True)
|
||||
|
||||
def test_script_raising_on_import_is_handled(self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "authenticate_spotify.py").write_text("raise RuntimeError('boom')\n")
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
def test_bodyless_post_reaches_step_one(self, api_v3_client, plugin_dir):
|
||||
# Covered by the silent=True fix: previously a 500 from body parsing.
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.status_code in (400, 500)
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
def test_whitespace_redirect_url_is_treated_as_absent(
|
||||
self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed()) as run:
|
||||
api_v3_client.post(self.URL, json={"redirect_url": " "})
|
||||
# Step 2 never runs, so no wrapper is executed.
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
class TestYouTubeMusic:
|
||||
"""No wrapper script and no redirect_url — deliberately not symmetric."""
|
||||
|
||||
URL = "/api/v3/plugins/authenticate/ytm"
|
||||
|
||||
def test_missing_plugin_directory_is_404(self, api_v3_client, api_v3_module, tmp_path):
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(
|
||||
tmp_path / "not-installed")
|
||||
assert api_v3_client.post(self.URL).status_code == 404
|
||||
|
||||
def test_missing_script_is_404(self, api_v3_client, plugin_dir):
|
||||
(plugin_dir / "authenticate_ytm.py").unlink()
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.status_code == 404
|
||||
assert "script not found" in response.get_json()["message"]
|
||||
|
||||
def test_success(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed(0, "authorized")):
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["output"] == "authorized"
|
||||
|
||||
def test_failure_is_a_400_with_combined_output(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed(1, "out", "err")):
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["output"] == "outerr"
|
||||
|
||||
def test_timeout_is_a_408(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run",
|
||||
side_effect=subprocess.TimeoutExpired("python3", 60)):
|
||||
assert api_v3_client.post(self.URL).status_code == 408
|
||||
|
||||
def test_runs_the_script_directly_without_a_shell(self, api_v3_client, plugin_dir):
|
||||
with patch.object(subprocess, "run", return_value=completed()) as run:
|
||||
api_v3_client.post(self.URL)
|
||||
args, kwargs = run.call_args
|
||||
assert args[0][0] == "python3"
|
||||
assert args[0][1].endswith("authenticate_ytm.py")
|
||||
assert kwargs.get("shell") in (None, False)
|
||||
assert kwargs["timeout"] == 60
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
Regression tests: POST endpoints whose body is optional must accept a
|
||||
request that has no body at all.
|
||||
|
||||
Six handlers in api_v3 read their body as ``request.get_json() or {}``.
|
||||
The ``or {}`` states the intent plainly — every field is optional, so a
|
||||
bodyless POST should fall back to defaults. But ``get_json()`` without
|
||||
``silent=True`` raises ``UnsupportedMediaType`` when the request carries
|
||||
no JSON Content-Type, and it raises *before* ``or {}`` is evaluated. Each
|
||||
handler's catch-all then turned that into a 500.
|
||||
|
||||
So the natural way to call these endpoints — a POST with no body, which
|
||||
is what curl, a fetch() without options, and most HTTP clients send by
|
||||
default — failed on every one of them. The shipped UI always sends a JSON
|
||||
object, which is why this went unnoticed.
|
||||
|
||||
This file covers the endpoints whose bodyless behaviour is not already
|
||||
tested in their own suite.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
|
||||
class TestOnDemandStart:
|
||||
URL = "/api/v3/display/on-demand/start"
|
||||
|
||||
def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module):
|
||||
response = api_v3_client.post(self.URL)
|
||||
# The endpoint may still reject the request on its own terms (no
|
||||
# plugin_id, nothing to display); what it must not do is fail with
|
||||
# a 500 raised out of body parsing.
|
||||
assert response.status_code != 500
|
||||
|
||||
def test_json_body_still_works(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(self.URL, json={}).status_code != 500
|
||||
|
||||
|
||||
class TestResetPluginConfig:
|
||||
URL = "/api/v3/plugins/config/reset"
|
||||
|
||||
def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(self.URL).status_code != 500
|
||||
|
||||
def test_json_body_still_works(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(self.URL, json={}).status_code != 500
|
||||
|
||||
|
||||
class TestDeleteOfTheDayJson:
|
||||
URL = "/api/v3/plugins/of-the-day/json/delete"
|
||||
|
||||
def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(self.URL).status_code != 500
|
||||
|
||||
def test_json_body_still_works(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(self.URL, json={}).status_code != 500
|
||||
|
||||
|
||||
class TestPluginLimits:
|
||||
URL = "/api/v3/plugins/clock/limits"
|
||||
|
||||
def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(self.URL).status_code != 500
|
||||
|
||||
|
||||
class TestMissingBodyGivesTheDeclaredError:
|
||||
"""Handlers that answer "No data provided" must actually be able to.
|
||||
|
||||
A second group of handlers reads `data = request.get_json()` and then
|
||||
guards with `if not data: return 400`. That guard is unreachable for a
|
||||
request with no JSON body, because get_json() raises first — so the
|
||||
caller got a 500 "an error occurred; see logs for details" instead of
|
||||
the 400 the handler plainly intends to send.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"/api/v3/plugins/install",
|
||||
"/api/v3/plugins/install-from-url",
|
||||
"/api/v3/plugins/registry-from-url",
|
||||
"/api/v3/config/raw/main",
|
||||
"/api/v3/config/raw/secrets",
|
||||
"/api/v3/cache/delete",
|
||||
])
|
||||
def test_bodyless_post_gets_a_400_not_a_500(self, api_v3_client, api_v3_module, url):
|
||||
response = api_v3_client.post(url)
|
||||
assert response.status_code == 400, (
|
||||
f"{url} answered {response.status_code}: "
|
||||
f"{response.get_data(as_text=True)[:200]}")
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"/api/v3/plugins/install",
|
||||
"/api/v3/config/raw/main",
|
||||
])
|
||||
def test_malformed_json_gets_a_400_not_a_500(self, api_v3_client, api_v3_module, url):
|
||||
response = api_v3_client.post(
|
||||
url, data="{not json", content_type="application/json")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestNoBodyReadContradictsItsOwnGuard:
|
||||
SOURCE = Path(__file__).parent.parent / "web_interface/blueprints/api_v3.py"
|
||||
|
||||
def test_no_or_default_read_is_unguarded(self):
|
||||
"""`get_json() or <default>` is a contradiction without silent=True.
|
||||
|
||||
Writing `or {}` declares the body optional; omitting silent=True
|
||||
means the call raises before the default can apply.
|
||||
"""
|
||||
offenders = [
|
||||
line.strip() for line in self.SOURCE.read_text().splitlines()
|
||||
if "request.get_json()" in line and " or " in line
|
||||
]
|
||||
assert offenders == [], (
|
||||
"these reads declare a default but raise before reaching it; "
|
||||
f"use get_json(silent=True): {offenders}")
|
||||
|
||||
def test_no_not_data_guard_is_unreachable(self):
|
||||
"""A `if not data:` guard needs a read that can actually return None."""
|
||||
lines = self.SOURCE.read_text().splitlines()
|
||||
offenders = []
|
||||
for i, line in enumerate(lines):
|
||||
if re.search(r"=\s*request\.get_json\(\)\s*$", line):
|
||||
window = "\n".join(lines[i + 1:i + 3])
|
||||
if re.search(r"if\s+(not\s+data\b|data\s+is\s+None)", window):
|
||||
offenders.append(f"line {i + 1}: {line.strip()}")
|
||||
assert offenders == [], (
|
||||
"these handlers guard on a missing body but raise before the "
|
||||
f"guard runs; use get_json(silent=True): {offenders}")
|
||||
@@ -1,302 +0,0 @@
|
||||
"""
|
||||
Endpoint tests for POST /plugins/install and POST /plugins/install-from-url.
|
||||
|
||||
Both were only ever tested at the PluginStoreManager layer, so the route
|
||||
logic — the queue-vs-direct branch, schema invalidation, plugin discovery,
|
||||
state and history recording — was unexercised.
|
||||
|
||||
/plugins/install carries the same install logic twice: once inside the
|
||||
operation-queue callback and once in the direct fallback. The paired
|
||||
tests below assert both branches produce the same side effects, so the
|
||||
duplication cannot quietly drift.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
INSTALL = "/api/v3/plugins/install"
|
||||
FROM_URL = "/api/v3/plugins/install-from-url"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queued(api_v3_module):
|
||||
"""Enable the operation queue and run its callback synchronously."""
|
||||
queue = MagicMock()
|
||||
|
||||
def enqueue(operation_type, plugin_id, operation_callback=None):
|
||||
queue.callback_result = operation_callback(MagicMock())
|
||||
return "op-123"
|
||||
|
||||
queue.enqueue_operation.side_effect = enqueue
|
||||
api_v3_module.api_v3.operation_queue = queue
|
||||
return queue
|
||||
|
||||
|
||||
def side_effects(module):
|
||||
"""The manager calls a successful install is expected to make."""
|
||||
api = module.api_v3
|
||||
return {
|
||||
"schema_invalidated": api.schema_manager.invalidate_cache.call_args_list,
|
||||
"discovered": api.plugin_manager.discover_plugins.call_count,
|
||||
"loaded": api.plugin_manager.load_plugin.call_args_list,
|
||||
"state_set": api.plugin_state_manager.set_plugin_installed.call_args_list,
|
||||
"history": api.operation_history.record_operation.call_args_list,
|
||||
}
|
||||
|
||||
|
||||
class TestInstallValidation:
|
||||
def test_uninitialized_store_manager_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager = None
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert response.status_code == 500
|
||||
assert "not initialized" in response.get_json()["message"]
|
||||
|
||||
def test_missing_plugin_id_is_a_400(self, api_v3_client, api_v3_module):
|
||||
response = api_v3_client.post(INSTALL, json={})
|
||||
assert response.status_code == 400
|
||||
assert "plugin_id required" in response.get_json()["message"]
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.assert_not_called()
|
||||
|
||||
def test_empty_body_is_a_400(self, api_v3_client, api_v3_module):
|
||||
assert api_v3_client.post(INSTALL, json=None).status_code == 400
|
||||
|
||||
|
||||
class TestInstallDirectPath:
|
||||
"""operation_queue is None — the fallback branch."""
|
||||
|
||||
def test_success(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["status"] == "success"
|
||||
|
||||
def test_success_side_effects(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
effects = side_effects(api_v3_module)
|
||||
assert effects["schema_invalidated"] == [(("clock",), {})]
|
||||
assert effects["discovered"] == 1
|
||||
assert effects["loaded"] == [(("clock",), {})]
|
||||
assert effects["state_set"] == [(("clock",), {})]
|
||||
assert effects["history"][0].kwargs["status"] == "success"
|
||||
|
||||
def test_branch_forwarded_to_the_manager(self, api_v3_client, api_v3_module):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.install_plugin.return_value = True
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
|
||||
manager.install_plugin.assert_called_once_with("clock", branch="dev")
|
||||
|
||||
def test_branch_named_in_the_message(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
|
||||
assert "(branch: dev)" in response.get_json()["message"]
|
||||
|
||||
def test_failure_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert response.status_code == 500
|
||||
assert "Failed to install" in response.get_json()["message"]
|
||||
|
||||
def test_failure_mentions_missing_registry_entry(self, api_v3_client, api_v3_module):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.install_plugin.return_value = False
|
||||
manager.get_plugin_info.return_value = None
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "ghost"})
|
||||
assert "not found in registry" in response.get_json()["message"]
|
||||
|
||||
def test_failure_omits_registry_note_when_plugin_is_known(
|
||||
self, api_v3_client, api_v3_module):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.install_plugin.return_value = False
|
||||
manager.get_plugin_info.return_value = {"id": "clock"}
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert "not found in registry" not in response.get_json()["message"]
|
||||
|
||||
def test_failure_recorded_in_history(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
record = api_v3_module.api_v3.operation_history.record_operation.call_args
|
||||
assert record.kwargs["status"] == "failed"
|
||||
|
||||
def test_no_side_effects_on_failure(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
effects = side_effects(api_v3_module)
|
||||
assert effects["schema_invalidated"] == []
|
||||
assert effects["loaded"] == []
|
||||
assert effects["state_set"] == []
|
||||
|
||||
|
||||
class TestInstallQueuedPath:
|
||||
"""operation_queue present — the callback branch."""
|
||||
|
||||
def test_returns_an_operation_id(self, api_v3_client, api_v3_module, queued):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"]["operation_id"] == "op-123"
|
||||
|
||||
def test_message_says_queued(self, api_v3_client, api_v3_module, queued):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert "queued" in response.get_json()["message"]
|
||||
|
||||
def test_callback_success_side_effects(self, api_v3_client, api_v3_module, queued):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
effects = side_effects(api_v3_module)
|
||||
assert effects["schema_invalidated"] == [(("clock",), {})]
|
||||
assert effects["discovered"] == 1
|
||||
assert effects["loaded"] == [(("clock",), {})]
|
||||
assert effects["state_set"] == [(("clock",), {})]
|
||||
assert effects["history"][0].kwargs["status"] == "success"
|
||||
|
||||
def test_callback_reports_success(self, api_v3_client, api_v3_module, queued):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert queued.callback_result["success"] is True
|
||||
|
||||
def test_callback_failure_raises_for_the_queue(self, api_v3_client, api_v3_module, queued):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
||||
# The callback signals failure by raising, so the queue can mark the
|
||||
# operation failed; the route's catch-all turns it into a 500.
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert response.status_code == 500
|
||||
|
||||
def test_callback_failure_recorded_in_history(self, api_v3_client, api_v3_module, queued):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
record = api_v3_module.api_v3.operation_history.record_operation.call_args
|
||||
assert record.kwargs["status"] == "failed"
|
||||
|
||||
def test_branch_forwarded_from_the_callback(self, api_v3_client, api_v3_module, queued):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.install_plugin.return_value = True
|
||||
api_v3_client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
|
||||
manager.install_plugin.assert_called_once_with("clock", branch="dev")
|
||||
|
||||
|
||||
class TestInstallPathsAgree:
|
||||
"""The queue callback and the direct fallback duplicate the same logic."""
|
||||
|
||||
def _run(self, client, module, install_ok, queue):
|
||||
module.api_v3.plugin_store_manager.install_plugin.return_value = install_ok
|
||||
client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
|
||||
return side_effects(module)
|
||||
|
||||
def test_success_side_effects_match(self, api_v3_client, api_v3_module):
|
||||
direct = self._run(api_v3_client, api_v3_module, True, None)
|
||||
|
||||
# Reset and re-run through the queue.
|
||||
for mock in (api_v3_module.api_v3.schema_manager,
|
||||
api_v3_module.api_v3.plugin_manager,
|
||||
api_v3_module.api_v3.plugin_state_manager,
|
||||
api_v3_module.api_v3.operation_history):
|
||||
mock.reset_mock()
|
||||
queue = MagicMock()
|
||||
queue.enqueue_operation.side_effect = (
|
||||
lambda t, p, operation_callback=None: operation_callback(MagicMock()) and "op")
|
||||
api_v3_module.api_v3.operation_queue = queue
|
||||
queued = self._run(api_v3_client, api_v3_module, True, queue)
|
||||
|
||||
assert direct["schema_invalidated"] == queued["schema_invalidated"]
|
||||
assert direct["discovered"] == queued["discovered"]
|
||||
assert direct["loaded"] == queued["loaded"]
|
||||
assert direct["state_set"] == queued["state_set"]
|
||||
assert (direct["history"][0].kwargs["status"]
|
||||
== queued["history"][0].kwargs["status"])
|
||||
assert (direct["history"][0].kwargs["details"]
|
||||
== queued["history"][0].kwargs["details"])
|
||||
|
||||
def test_only_the_message_wording_differs(self, api_v3_client, api_v3_module):
|
||||
# Characterized: the direct path says "Plugin installed
|
||||
# successfully" while the queue callback says "Plugin clock
|
||||
# installed successfully". Cosmetic, and the queue's text is
|
||||
# internal to the operation record rather than the HTTP response.
|
||||
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
direct = api_v3_client.post(INSTALL, json={"plugin_id": "clock"}).get_json()
|
||||
assert direct["message"] == "Plugin installed successfully"
|
||||
|
||||
|
||||
class TestInstallFromUrl:
|
||||
def test_uninitialized_store_manager_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager = None
|
||||
assert api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}).status_code == 500
|
||||
|
||||
def test_missing_repo_url_is_a_400(self, api_v3_client, api_v3_module):
|
||||
response = api_v3_client.post(FROM_URL, json={})
|
||||
assert response.status_code == 400
|
||||
assert "repo_url required" in response.get_json()["message"]
|
||||
|
||||
def test_success(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
||||
"success": True, "plugin_id": "clock", "name": "Clock"}
|
||||
response = api_v3_client.post(FROM_URL, json={"repo_url": "https://github.com/o/r"})
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["plugin_id"] == "clock"
|
||||
assert body["name"] == "Clock"
|
||||
|
||||
def test_all_optional_arguments_forwarded(self, api_v3_client, api_v3_module):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.install_from_url.return_value = {"success": True, "plugin_id": "clock"}
|
||||
api_v3_client.post(FROM_URL, json={
|
||||
"repo_url": " https://github.com/o/r ",
|
||||
"plugin_id": "clock",
|
||||
"plugin_path": "plugins/clock",
|
||||
"branch": "dev",
|
||||
})
|
||||
manager.install_from_url.assert_called_once_with(
|
||||
repo_url="https://github.com/o/r",
|
||||
plugin_id="clock",
|
||||
plugin_path="plugins/clock",
|
||||
branch="dev",
|
||||
)
|
||||
|
||||
def test_success_invalidates_schema_and_loads_plugin(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
||||
"success": True, "plugin_id": "clock"}
|
||||
api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
|
||||
api_v3_module.api_v3.schema_manager.invalidate_cache.assert_called_once_with("clock")
|
||||
api_v3_module.api_v3.plugin_manager.load_plugin.assert_called_once_with("clock")
|
||||
|
||||
def test_success_without_plugin_id_skips_discovery(self, api_v3_client, api_v3_module):
|
||||
# install_from_url can succeed without naming the plugin; there is
|
||||
# then nothing to invalidate or load.
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
||||
"success": True, "plugin_id": None}
|
||||
api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
|
||||
api_v3_module.api_v3.schema_manager.invalidate_cache.assert_not_called()
|
||||
api_v3_module.api_v3.plugin_manager.load_plugin.assert_not_called()
|
||||
|
||||
def test_branch_from_result_included(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
||||
"success": True, "plugin_id": "clock", "branch": "dev"}
|
||||
body = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}).get_json()
|
||||
assert body["branch"] == "dev"
|
||||
assert "(branch: dev)" in body["message"]
|
||||
|
||||
def test_failure_reports_the_managers_error(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
||||
"success": False, "error": "repo not found"}
|
||||
response = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()["message"] == "repo not found"
|
||||
|
||||
def test_failure_without_error_uses_fallback_text(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
||||
"success": False}
|
||||
response = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
|
||||
assert "Failed to install plugin from URL" in response.get_json()["message"]
|
||||
|
||||
def test_manager_exception_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.install_from_url.side_effect = (
|
||||
RuntimeError("boom"))
|
||||
assert api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}).status_code == 500
|
||||
@@ -1,179 +0,0 @@
|
||||
"""
|
||||
Endpoint tests for the plugin-registry routes in api_v3:
|
||||
POST /plugins/store/refresh and POST /plugins/registry-from-url.
|
||||
|
||||
Both reach out to the network through PluginStoreManager (mocked here) and
|
||||
had no endpoint-level coverage; registry-from-url in particular takes a
|
||||
user-supplied URL and hands it straight to the manager.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
|
||||
class TestRefreshPluginStore:
|
||||
URL = "/api/v3/plugins/store/refresh"
|
||||
|
||||
def test_uninitialized_manager_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager = None
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 500
|
||||
assert "not initialized" in response.get_json()["message"]
|
||||
|
||||
def test_success_reports_plugin_count(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {
|
||||
"plugins": [{"id": "a"}, {"id": "b"}, {"id": "c"}]}
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["plugin_count"] == 3
|
||||
|
||||
def test_forces_a_refresh_rather_than_using_cache(self, api_v3_client, api_v3_module):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.fetch_registry.return_value = {"plugins": []}
|
||||
api_v3_client.post(self.URL, json={})
|
||||
manager.fetch_registry.assert_called_once_with(force_refresh=True)
|
||||
|
||||
def test_empty_registry_reports_zero(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {}
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.get_json()["plugin_count"] == 0
|
||||
|
||||
def test_no_body_is_accepted(self, api_v3_client, api_v3_module):
|
||||
# Regression: `request.get_json() or {}` says a missing body is
|
||||
# fine, but get_json() raises UnsupportedMediaType before `or {}`
|
||||
# is reached, so a bodyless POST — the natural way to call a
|
||||
# refresh endpoint — came back 500.
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
||||
assert api_v3_client.post(self.URL).status_code == 200
|
||||
|
||||
def test_body_without_json_content_type_is_accepted(
|
||||
self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
||||
response = api_v3_client.post(self.URL, data="", content_type="text/plain")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_malformed_json_body_falls_back_to_defaults(
|
||||
self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
||||
response = api_v3_client.post(
|
||||
self.URL, data="{not json", content_type="application/json")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.parametrize("key", ["fetch_commit_info", "fetch_latest_versions"])
|
||||
def test_either_commit_info_key_extends_the_message(
|
||||
self, api_v3_client, api_v3_module, key):
|
||||
# fetch_latest_versions is the older spelling; both must work.
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
||||
response = api_v3_client.post(self.URL, json={key: True})
|
||||
assert "commit metadata" in response.get_json()["message"]
|
||||
|
||||
def test_message_stays_plain_without_the_flag(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.get_json()["message"] == "Plugin store refreshed"
|
||||
|
||||
def test_network_failure_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.side_effect = (
|
||||
ConnectionError("github unreachable"))
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()["message"] == "An error occurred; see logs for details"
|
||||
|
||||
def test_failure_body_carries_no_traceback_or_paths(
|
||||
self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.side_effect = (
|
||||
RuntimeError("failed at /home/user/LEDMatrix/src/secret.py line 42"))
|
||||
body = api_v3_client.post(self.URL, json={}).get_json()
|
||||
assert "Traceback" not in str(body)
|
||||
# `details` is describe_exception output: one line, type-named,
|
||||
# credential-redacted. It may quote the message, but never a stack.
|
||||
assert body["details"].startswith("RuntimeError:")
|
||||
assert "\n" not in body["details"]
|
||||
|
||||
|
||||
class TestRegistryFromUrl:
|
||||
URL = "/api/v3/plugins/registry-from-url"
|
||||
|
||||
def test_uninitialized_manager_is_a_500(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager = None
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": "http://x"})
|
||||
assert response.status_code == 500
|
||||
|
||||
def test_missing_repo_url_is_a_400(self, api_v3_client, api_v3_module):
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 400
|
||||
assert "repo_url required" in response.get_json()["message"]
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.assert_not_called()
|
||||
|
||||
def test_success_returns_the_plugin_list(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.return_value = {
|
||||
"plugins": [{"id": "clock"}]}
|
||||
response = api_v3_client.post(
|
||||
self.URL, json={"repo_url": "https://github.com/o/r"})
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["plugins"] == [{"id": "clock"}]
|
||||
assert body["registry_url"] == "https://github.com/o/r"
|
||||
|
||||
def test_url_is_trimmed_before_use(self, api_v3_client, api_v3_module):
|
||||
manager = api_v3_module.api_v3.plugin_store_manager
|
||||
manager.fetch_registry_from_url.return_value = {"plugins": []}
|
||||
api_v3_client.post(self.URL, json={"repo_url": " https://github.com/o/r "})
|
||||
manager.fetch_registry_from_url.assert_called_once_with("https://github.com/o/r")
|
||||
|
||||
def test_registry_without_plugins_key_returns_empty_list(
|
||||
self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.return_value = {
|
||||
"other": 1}
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": "http://x"})
|
||||
assert response.get_json()["plugins"] == []
|
||||
|
||||
def test_no_registry_found_is_a_400(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.return_value = None
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": "http://x/not-a-registry"})
|
||||
assert response.status_code == 400
|
||||
assert "Failed to fetch registry" in response.get_json()["message"]
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"not a url",
|
||||
"javascript:alert(1)",
|
||||
"file:///etc/passwd",
|
||||
"http://localhost:8080/admin",
|
||||
])
|
||||
def test_unusable_urls_fail_cleanly(self, api_v3_client, api_v3_module, url):
|
||||
# Characterization: the handler performs no URL validation of its
|
||||
# own — whatever the manager makes of the URL decides the outcome.
|
||||
# What is pinned here is that a rejected URL produces a clean 400
|
||||
# rather than a traceback or a 500.
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.return_value = None
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": url})
|
||||
assert response.status_code == 400
|
||||
assert "Traceback" not in str(response.get_json())
|
||||
|
||||
def test_fetch_exception_is_a_500_without_internals(
|
||||
self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.side_effect = (
|
||||
ValueError("parse failed in /srv/app/internal.py"))
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": "http://x"})
|
||||
assert response.status_code == 500
|
||||
body = response.get_json()
|
||||
assert body["message"] == "An error occurred; see logs for details"
|
||||
assert "Traceback" not in str(body)
|
||||
|
||||
def test_non_string_repo_url_is_rejected(self, api_v3_client, api_v3_module):
|
||||
# Regression: .strip() on a non-string raised, and the catch-all
|
||||
# reported the caller's own mistake as a server fault.
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": 12345})
|
||||
assert response.status_code == 400
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.assert_not_called()
|
||||
|
||||
def test_blank_repo_url_is_rejected(self, api_v3_client, api_v3_module):
|
||||
response = api_v3_client.post(self.URL, json={"repo_url": " "})
|
||||
assert response.status_code == 400
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry_from_url.assert_not_called()
|
||||
@@ -1,240 +0,0 @@
|
||||
"""
|
||||
Endpoint tests for the /wifi/* routes in api_v3.
|
||||
|
||||
These routes drive the host's actual networking — connecting, dropping a
|
||||
connection, switching the radio off — and had no endpoint-level tests at
|
||||
all. WiFiManager is mocked throughout; nothing here may touch real
|
||||
networking.
|
||||
|
||||
Each handler does `from src.wifi_manager import WiFiManager` inside the
|
||||
function body, so the patch target is the class at its definition site.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wifi_manager():
|
||||
"""Patch WiFiManager where it is defined; yield the instance mock."""
|
||||
with patch("src.wifi_manager.WiFiManager") as cls:
|
||||
instance = MagicMock()
|
||||
cls.return_value = instance
|
||||
yield instance
|
||||
|
||||
|
||||
class TestConnect:
|
||||
URL = "/api/v3/wifi/connect"
|
||||
|
||||
def test_success(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (True, "Connected to HomeNet")
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "pw"})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["message"] == "Connected to HomeNet"
|
||||
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "pw")
|
||||
|
||||
def test_missing_body_rejected(self, api_v3_client, wifi_manager):
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 400
|
||||
wifi_manager.connect_to_network.assert_not_called()
|
||||
|
||||
def test_missing_ssid_rejected(self, api_v3_client, wifi_manager):
|
||||
response = api_v3_client.post(self.URL, json={"password": "pw"})
|
||||
assert response.status_code == 400
|
||||
assert "SSID is required" in response.get_json()["message"]
|
||||
wifi_manager.connect_to_network.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("ssid", ["", " ", "\t"])
|
||||
def test_blank_ssid_rejected(self, api_v3_client, wifi_manager, ssid):
|
||||
response = api_v3_client.post(self.URL, json={"ssid": ssid})
|
||||
assert response.status_code == 400
|
||||
wifi_manager.connect_to_network.assert_not_called()
|
||||
|
||||
def test_ssid_is_trimmed(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (True, "ok")
|
||||
api_v3_client.post(self.URL, json={"ssid": " HomeNet "})
|
||||
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "")
|
||||
|
||||
def test_missing_password_becomes_empty_string(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (True, "ok")
|
||||
api_v3_client.post(self.URL, json={"ssid": "OpenNet"})
|
||||
wifi_manager.connect_to_network.assert_called_once_with("OpenNet", "")
|
||||
|
||||
def test_null_password_becomes_empty_string(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (True, "ok")
|
||||
api_v3_client.post(self.URL, json={"ssid": "OpenNet", "password": None})
|
||||
wifi_manager.connect_to_network.assert_called_once_with("OpenNet", "")
|
||||
|
||||
def test_failure_reports_the_managers_reason(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (False, "Bad password")
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "Bad password"
|
||||
|
||||
def test_failure_without_reason_uses_fallback_text(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.return_value = (False, None)
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "Failed to connect to network"
|
||||
|
||||
def test_manager_exception_is_a_500_without_leaking_internals(
|
||||
self, api_v3_client, wifi_manager):
|
||||
wifi_manager.connect_to_network.side_effect = RuntimeError(
|
||||
"/usr/lib/secret/path blew up")
|
||||
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
|
||||
assert response.status_code == 500
|
||||
body = response.get_json()
|
||||
assert body["message"] == "An error occurred; see logs for details"
|
||||
# `details` comes from describe_exception, which is deliberately
|
||||
# safe to return (redacted, capped) — it names the type.
|
||||
assert "RuntimeError" in body["details"]
|
||||
|
||||
|
||||
class TestDisconnect:
|
||||
URL = "/api/v3/wifi/disconnect"
|
||||
|
||||
def test_success(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.disconnect_from_network.return_value = (True, "Disconnected")
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["message"] == "Disconnected"
|
||||
|
||||
def test_failure(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.disconnect_from_network.return_value = (False, "Not connected")
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "Not connected"
|
||||
|
||||
def test_failure_without_reason_uses_fallback(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.disconnect_from_network.return_value = (False, "")
|
||||
response = api_v3_client.post(self.URL)
|
||||
assert response.get_json()["message"] == "Failed to disconnect from network"
|
||||
|
||||
def test_exception_is_a_500(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.disconnect_from_network.side_effect = OSError("nmcli missing")
|
||||
assert api_v3_client.post(self.URL).status_code == 500
|
||||
|
||||
|
||||
class TestApMode:
|
||||
ENABLE = "/api/v3/wifi/ap/enable"
|
||||
DISABLE = "/api/v3/wifi/ap/disable"
|
||||
|
||||
def test_enable_success(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.enable_ap_mode.return_value = (True, "AP enabled")
|
||||
response = api_v3_client.post(self.ENABLE, json={})
|
||||
assert response.status_code == 200
|
||||
wifi_manager.enable_ap_mode.assert_called_once_with(force=False)
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
(True, True), (False, False),
|
||||
("true", True), ("TRUE", True), ("1", True),
|
||||
("false", False), ("no", False), ("yes", False),
|
||||
(1, False), # only real True or the listed strings count
|
||||
])
|
||||
def test_force_coercion(self, api_v3_client, wifi_manager, raw, expected):
|
||||
wifi_manager.enable_ap_mode.return_value = (True, "ok")
|
||||
api_v3_client.post(self.ENABLE, json={"force": raw})
|
||||
wifi_manager.enable_ap_mode.assert_called_once_with(force=expected)
|
||||
|
||||
def test_enable_without_body(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.enable_ap_mode.return_value = (True, "ok")
|
||||
assert api_v3_client.post(self.ENABLE).status_code == 200
|
||||
|
||||
def test_enable_failure(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.enable_ap_mode.return_value = (False, "hostapd missing")
|
||||
response = api_v3_client.post(self.ENABLE, json={})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "hostapd missing"
|
||||
|
||||
def test_disable_success(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.disable_ap_mode.return_value = (True, "AP disabled")
|
||||
assert api_v3_client.post(self.DISABLE).status_code == 200
|
||||
|
||||
def test_disable_failure(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.disable_ap_mode.return_value = (False, "not running")
|
||||
assert api_v3_client.post(self.DISABLE).status_code == 400
|
||||
|
||||
def test_enable_exception_is_a_500(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.enable_ap_mode.side_effect = RuntimeError("boom")
|
||||
assert api_v3_client.post(self.ENABLE, json={}).status_code == 500
|
||||
|
||||
|
||||
class TestRadio:
|
||||
URL = "/api/v3/wifi/radio"
|
||||
|
||||
def test_get_state(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.get_wifi_radio_state.return_value = {
|
||||
"enabled": True, "ethernet_connected": False}
|
||||
response = api_v3_client.get(self.URL)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"]["enabled"] is True
|
||||
|
||||
def test_get_state_exception_is_a_500(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.get_wifi_radio_state.side_effect = OSError("rfkill missing")
|
||||
assert api_v3_client.get(self.URL).status_code == 500
|
||||
|
||||
def test_enabled_is_required(self, api_v3_client, wifi_manager):
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 400
|
||||
assert "enabled is required" in response.get_json()["message"]
|
||||
wifi_manager.set_wifi_radio.assert_not_called()
|
||||
|
||||
def test_enable_success(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.set_wifi_radio.return_value = (True, "Radio on", None)
|
||||
wifi_manager.get_wifi_radio_state.return_value = {"enabled": True}
|
||||
response = api_v3_client.post(self.URL, json={"enabled": True})
|
||||
assert response.status_code == 200
|
||||
wifi_manager.set_wifi_radio.assert_called_once_with(True, force=False)
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
(True, True), ("true", True), ("1", True), ("yes", True),
|
||||
(False, False), ("false", False), ("off", False), (0, False),
|
||||
])
|
||||
def test_enabled_coercion_is_string_aware(
|
||||
self, api_v3_client, wifi_manager, raw, expected):
|
||||
# bool("false") is True, so the endpoint parses strings explicitly
|
||||
# rather than trusting truthiness — it is a public contract, not
|
||||
# only the shipped UI which always sends real JSON booleans.
|
||||
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
|
||||
wifi_manager.get_wifi_radio_state.return_value = {}
|
||||
api_v3_client.post(self.URL, json={"enabled": raw})
|
||||
wifi_manager.set_wifi_radio.assert_called_once_with(expected, force=False)
|
||||
|
||||
def test_force_passed_through(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
|
||||
wifi_manager.get_wifi_radio_state.return_value = {}
|
||||
api_v3_client.post(self.URL, json={"enabled": False, "force": "true"})
|
||||
wifi_manager.set_wifi_radio.assert_called_once_with(False, force=True)
|
||||
|
||||
def test_refusal_reports_reason(self, api_v3_client, wifi_manager):
|
||||
# Disabling the radio without Ethernet would lock the user out of
|
||||
# this very interface, so the manager can refuse with a reason.
|
||||
wifi_manager.set_wifi_radio.return_value = (
|
||||
False, "Refusing: no wired fallback", "no_ethernet")
|
||||
response = api_v3_client.post(self.URL, json={"enabled": False})
|
||||
assert response.status_code == 400
|
||||
body = response.get_json()
|
||||
assert body["reason"] == "no_ethernet"
|
||||
assert "Refusing" in body["message"]
|
||||
|
||||
def test_exception_is_a_500(self, api_v3_client, wifi_manager):
|
||||
wifi_manager.set_wifi_radio.side_effect = RuntimeError("boom")
|
||||
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
|
||||
|
||||
|
||||
class TestNoRealNetworking:
|
||||
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
|
||||
# Guard against a future refactor moving the import to module level,
|
||||
# where the fixture's patch of the definition site would stop
|
||||
# applying and the tests would start driving real networking.
|
||||
with patch("src.wifi_manager.WiFiManager") as cls:
|
||||
cls.return_value.disconnect_from_network.return_value = (True, "ok")
|
||||
api_v3_client.post("/api/v3/wifi/disconnect")
|
||||
assert cls.called
|
||||
@@ -1,365 +0,0 @@
|
||||
"""
|
||||
Tests for src/base_odds_manager.py (BaseOddsManager).
|
||||
|
||||
Covers get_odds validation/caching/URL construction, the null-safe
|
||||
_extract_espn_data fix (ESPN sends explicit JSON nulls for absent sides),
|
||||
the no_odds sentinel, stale-cache fallback on request failure,
|
||||
is_odds_available's ML-blind truth table, the fixed format_odds_summary
|
||||
gate (money-line-only odds now format), get_odds_for_games, and
|
||||
configuration loading.
|
||||
|
||||
No real network: requests.Session.get is always patched. The odds path sends
|
||||
its requests through a session so it can identify itself to ESPN, so patching
|
||||
the module-level requests.get would no longer intercept anything.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from src.base_odds_manager import BaseOddsManager
|
||||
|
||||
|
||||
FULL_ITEM = {
|
||||
'details': 'DAL -3.5',
|
||||
'overUnder': 47.5,
|
||||
'spread': -3.5,
|
||||
'homeTeamOdds': {'moneyLine': -150, 'current': {'pointSpread': {'value': -3.5}}},
|
||||
'awayTeamOdds': {'moneyLine': 130, 'current': {'pointSpread': {'value': 3.5}}},
|
||||
}
|
||||
|
||||
FULL_EXTRACTED = {
|
||||
'details': 'DAL -3.5',
|
||||
'over_under': 47.5,
|
||||
'spread': -3.5,
|
||||
'home_team_odds': {'money_line': -150, 'spread_odds': -3.5},
|
||||
'away_team_odds': {'money_line': 130, 'spread_odds': 3.5},
|
||||
}
|
||||
|
||||
|
||||
def _make_response(payload):
|
||||
response = MagicMock()
|
||||
response.json.return_value = payload
|
||||
response.raise_for_status.return_value = None
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache_manager():
|
||||
cm = MagicMock()
|
||||
# A bare MagicMock returns truthy Mocks from every call, so every
|
||||
# get_odds() would look like a cache hit. Explicitly wire a miss.
|
||||
cm.get_with_auto_strategy.return_value = None
|
||||
return cm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(cache_manager):
|
||||
return BaseOddsManager(cache_manager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get():
|
||||
with patch('src.base_odds_manager.requests.Session.get') as m:
|
||||
m.return_value = _make_response({'items': [dict(FULL_ITEM)]})
|
||||
yield m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_odds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetOdds:
|
||||
def test_none_sport_raises(self, manager):
|
||||
with pytest.raises(ValueError):
|
||||
manager.get_odds(None, 'nfl', '1')
|
||||
|
||||
def test_none_league_raises(self, manager):
|
||||
with pytest.raises(ValueError):
|
||||
manager.get_odds('football', None, '1')
|
||||
|
||||
def test_cache_key_and_url(self, manager, cache_manager, mock_get):
|
||||
manager.get_odds('football', 'nfl', '401')
|
||||
|
||||
cache_manager.get_with_auto_strategy.assert_called_once_with(
|
||||
'odds_espn_football_nfl_401')
|
||||
url = mock_get.call_args[0][0]
|
||||
# Event id appears twice: /events/<id>/competitions/<id>/odds
|
||||
assert '/events/401/competitions/401/odds' in url
|
||||
assert url == ('https://sports.core.api.espn.com/v2/sports/football/'
|
||||
'leagues/nfl/events/401/competitions/401/odds')
|
||||
# The number matters less than the property: a single stalled request
|
||||
# must not be able to consume the plugin executor's 30s operation
|
||||
# budget, since odds are fetched per live game inside update().
|
||||
assert mock_get.call_args.kwargs['timeout'] == 5
|
||||
assert mock_get.call_args.kwargs['timeout'] < 30
|
||||
|
||||
def test_ncaa_fb_maps_to_college_football(self, manager, mock_get):
|
||||
manager.get_odds('football', 'ncaa_fb', '401')
|
||||
|
||||
url = mock_get.call_args[0][0]
|
||||
assert '/leagues/college-football/' in url
|
||||
|
||||
def test_unknown_league_passes_through(self, manager, mock_get):
|
||||
manager.get_odds('football', 'xfl', '401')
|
||||
|
||||
assert '/leagues/xfl/' in mock_get.call_args[0][0]
|
||||
|
||||
def test_cache_hit_skips_http(self, manager, cache_manager, mock_get):
|
||||
cache_manager.get_with_auto_strategy.return_value = {'spread': -3.0}
|
||||
|
||||
result = manager.get_odds('football', 'nfl', '401')
|
||||
|
||||
assert result == {'spread': -3.0}
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_cached_no_odds_sentinel_returned_verbatim(
|
||||
self, manager, cache_manager, mock_get):
|
||||
cache_manager.get_with_auto_strategy.return_value = {'no_odds': True}
|
||||
|
||||
result = manager.get_odds('football', 'nfl', '401')
|
||||
|
||||
assert result == {'no_odds': True}
|
||||
mock_get.assert_not_called()
|
||||
assert manager.is_odds_available(result) is False
|
||||
|
||||
def test_success_caches_extracted_data_with_interval_ttl(
|
||||
self, manager, cache_manager, mock_get):
|
||||
result = manager.get_odds('football', 'nfl', '401',
|
||||
update_interval_seconds=100)
|
||||
|
||||
assert result == FULL_EXTRACTED
|
||||
cache_manager.set.assert_called_once_with(
|
||||
'odds_espn_football_nfl_401', FULL_EXTRACTED, ttl=100)
|
||||
|
||||
def test_no_odds_caches_sentinel(self, manager, cache_manager, mock_get):
|
||||
mock_get.return_value = _make_response({'count': 0, 'items': []})
|
||||
|
||||
result = manager.get_odds('football', 'nfl', '401')
|
||||
|
||||
assert result is None
|
||||
cache_manager.set.assert_called_once_with(
|
||||
'odds_espn_football_nfl_401', {'no_odds': True}, ttl=3600)
|
||||
|
||||
def test_zero_interval_falls_back_to_default(
|
||||
self, manager, cache_manager, mock_get):
|
||||
# Quirk pin: `update_interval_seconds or self.update_interval`
|
||||
# treats an explicit 0 as falsy, so the 3600 default wins.
|
||||
manager.get_odds('football', 'nfl', '401', update_interval_seconds=0)
|
||||
|
||||
assert cache_manager.set.call_args.kwargs['ttl'] == 3600
|
||||
|
||||
def test_request_exception_falls_back_to_stale_cache(
|
||||
self, manager, cache_manager, mock_get):
|
||||
cache_manager.get_with_auto_strategy.side_effect = [
|
||||
None, {'stale': True}]
|
||||
mock_get.side_effect = requests.exceptions.RequestException('boom')
|
||||
|
||||
result = manager.get_odds('football', 'nfl', '401')
|
||||
|
||||
assert result == {'stale': True}
|
||||
assert cache_manager.get_with_auto_strategy.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_espn_data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractEspnData:
|
||||
def test_full_item_extracts_all_fields(self, manager):
|
||||
result = manager._extract_espn_data({'items': [dict(FULL_ITEM)]})
|
||||
assert result == FULL_EXTRACTED
|
||||
|
||||
def test_explicit_nulls_do_not_raise(self, manager):
|
||||
# Post-fix: ESPN sends explicit JSON nulls for absent sides
|
||||
# ("homeTeamOdds": null, "current": null); extraction must not
|
||||
# raise and yields None fields.
|
||||
payload = {'items': [{
|
||||
'homeTeamOdds': None,
|
||||
'awayTeamOdds': {'moneyLine': 150, 'current': None},
|
||||
}]}
|
||||
|
||||
result = manager._extract_espn_data(payload)
|
||||
|
||||
assert result is not None
|
||||
assert result['home_team_odds']['money_line'] is None
|
||||
assert result['home_team_odds']['spread_odds'] is None
|
||||
assert result['away_team_odds']['money_line'] == 150
|
||||
assert result['away_team_odds']['spread_odds'] is None
|
||||
|
||||
def test_valid_empty_response_returns_none(self, manager):
|
||||
assert manager._extract_espn_data({'count': 0, 'items': []}) is None
|
||||
|
||||
def test_unexpected_structure_returns_none(self, manager):
|
||||
assert manager._extract_espn_data({'unexpected': True}) is None
|
||||
|
||||
def test_item_without_odds_fields_cached_as_data_not_sentinel(
|
||||
self, manager, cache_manager, mock_get):
|
||||
# Characterization pin: an item with no odds fields still extracts
|
||||
# to a truthy dict of all-None values, so get_odds caches it as
|
||||
# real data (NOT the no_odds sentinel) — but is_odds_available
|
||||
# correctly reports False for it.
|
||||
mock_get.return_value = _make_response({'items': [{}]})
|
||||
|
||||
result = manager.get_odds('football', 'nfl', '401')
|
||||
|
||||
assert result == {
|
||||
'details': None,
|
||||
'over_under': None,
|
||||
'spread': None,
|
||||
'home_team_odds': {'money_line': None, 'spread_odds': None},
|
||||
'away_team_odds': {'money_line': None, 'spread_odds': None},
|
||||
}
|
||||
cache_manager.set.assert_called_once_with(
|
||||
'odds_espn_football_nfl_401', result, ttl=3600)
|
||||
assert manager.is_odds_available(result) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_odds_available
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsOddsAvailable:
|
||||
def test_none_is_false(self, manager):
|
||||
assert manager.is_odds_available(None) is False
|
||||
|
||||
def test_empty_dict_is_false(self, manager):
|
||||
assert manager.is_odds_available({}) is False
|
||||
|
||||
def test_no_odds_sentinel_is_false(self, manager):
|
||||
assert manager.is_odds_available({'no_odds': True}) is False
|
||||
|
||||
def test_spread_is_true(self, manager):
|
||||
assert manager.is_odds_available({'spread': -3.5}) is True
|
||||
|
||||
def test_over_under_is_true(self, manager):
|
||||
assert manager.is_odds_available({'over_under': 47.5}) is True
|
||||
|
||||
def test_nested_home_spread_odds_is_true(self, manager):
|
||||
assert manager.is_odds_available(
|
||||
{'home_team_odds': {'spread_odds': -3.5}}) is True
|
||||
|
||||
def test_nested_away_spread_odds_is_true(self, manager):
|
||||
assert manager.is_odds_available(
|
||||
{'away_team_odds': {'spread_odds': 3.5}}) is True
|
||||
|
||||
def test_moneyline_only_is_false(self, manager):
|
||||
# Pinned ML-blind contract: is_odds_available ignores money lines
|
||||
# (its callers decide whether to render an odds widget). Note that
|
||||
# format_odds_summary deliberately uses a DIFFERENT gate — it will
|
||||
# still format money-line-only odds (see TestFormatOddsSummary).
|
||||
ml_only = {
|
||||
'home_team_odds': {'money_line': -120},
|
||||
'away_team_odds': {'money_line': 100},
|
||||
}
|
||||
assert manager.is_odds_available(ml_only) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_odds_summary (fixed gate: empty / no_odds only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFormatOddsSummary:
|
||||
def test_moneyline_only_formats(self, manager):
|
||||
result = manager.format_odds_summary({
|
||||
'home_team_odds': {'money_line': -120},
|
||||
'away_team_odds': {'money_line': 100},
|
||||
})
|
||||
assert result == 'Home ML: -120 | Away ML: 100'
|
||||
|
||||
def test_full_data_formats_all_parts(self, manager):
|
||||
result = manager.format_odds_summary(FULL_EXTRACTED)
|
||||
assert result == 'Spread: -3.5 | O/U: 47.5 | Home ML: -150 | Away ML: 130'
|
||||
|
||||
def test_none_is_no_odds(self, manager):
|
||||
assert manager.format_odds_summary(None) == 'No odds available'
|
||||
|
||||
def test_empty_dict_is_no_odds(self, manager):
|
||||
assert manager.format_odds_summary({}) == 'No odds available'
|
||||
|
||||
def test_no_odds_sentinel_is_no_odds(self, manager):
|
||||
assert manager.format_odds_summary(
|
||||
{'no_odds': True}) == 'No odds available'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_odds_for_games
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetOddsForGames:
|
||||
def test_missing_fields_get_none_odds_without_http(self, manager, mock_get):
|
||||
games = [
|
||||
{'sport': 'football'},
|
||||
{'league': 'nfl'},
|
||||
{'id': '9'},
|
||||
{},
|
||||
]
|
||||
|
||||
result = manager.get_odds_for_games(games)
|
||||
|
||||
assert all(g['odds'] is None for g in result)
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_per_game_exception_continues_loop(self, manager, monkeypatch):
|
||||
def fake_get_odds(sport, league, event_id,
|
||||
update_interval_seconds=None):
|
||||
if event_id == 'bad':
|
||||
raise RuntimeError('boom')
|
||||
return {'spread': -1.0}
|
||||
|
||||
monkeypatch.setattr(manager, 'get_odds', fake_get_odds)
|
||||
games = [
|
||||
{'sport': 'football', 'league': 'nfl', 'id': 'bad'},
|
||||
{'sport': 'football', 'league': 'nfl', 'id': 'ok'},
|
||||
]
|
||||
|
||||
result = manager.get_odds_for_games(games)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]['odds'] is None
|
||||
assert result[1]['odds'] == {'spread': -1.0}
|
||||
|
||||
def test_input_dicts_mutated_in_place_and_returned(self, manager, mock_get):
|
||||
# Pin: get_odds_for_games mutates the caller's game dicts in place
|
||||
# and returns the same objects, not copies.
|
||||
game = {'sport': 'football', 'league': 'nfl', 'id': '401'}
|
||||
|
||||
result = manager.get_odds_for_games([game])
|
||||
|
||||
assert result[0] is game
|
||||
assert game['odds'] == FULL_EXTRACTED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _load_configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLoadConfiguration:
|
||||
def test_loads_values_from_config(self, cache_manager):
|
||||
config_manager = MagicMock()
|
||||
config_manager.get_config.return_value = {
|
||||
'base_odds_manager': {
|
||||
'update_interval': 100,
|
||||
'timeout': 5,
|
||||
'cache_ttl': 42,
|
||||
}
|
||||
}
|
||||
|
||||
manager = BaseOddsManager(cache_manager, config_manager=config_manager)
|
||||
|
||||
assert manager.update_interval == 100
|
||||
# Key/attr mismatch pin: the config key is 'timeout' but the
|
||||
# attribute is request_timeout.
|
||||
assert manager.request_timeout == 5
|
||||
assert manager.cache_ttl == 42
|
||||
|
||||
def test_get_config_raising_keeps_defaults(self, cache_manager):
|
||||
config_manager = MagicMock()
|
||||
config_manager.get_config.side_effect = RuntimeError('boom')
|
||||
|
||||
manager = BaseOddsManager(cache_manager, config_manager=config_manager)
|
||||
|
||||
assert manager.update_interval == 3600
|
||||
assert manager.request_timeout == 5
|
||||
assert manager.cache_ttl == 1800
|
||||
@@ -78,20 +78,10 @@ class TestInstanceVariable:
|
||||
instance_duration=[30])
|
||||
assert plugin.get_display_duration() == 20.0
|
||||
|
||||
def test_bool_true_falls_through_like_any_non_number(self):
|
||||
# bool is an int subclass, but a boolean is not a duration: True
|
||||
# must NOT read as 1 second — it falls through to config/default.
|
||||
assert make_plugin(instance_duration=True).get_display_duration() == 15.0
|
||||
|
||||
def test_bool_true_falls_through_to_config(self):
|
||||
plugin = make_plugin(config={"display_duration": 20},
|
||||
instance_duration=True)
|
||||
assert plugin.get_display_duration() == 20.0
|
||||
|
||||
def test_bool_false_still_falls_through(self):
|
||||
plugin = make_plugin(config={"display_duration": 20},
|
||||
instance_duration=False)
|
||||
assert plugin.get_display_duration() == 20.0
|
||||
def test_bool_true_is_one_second(self):
|
||||
# Characterized quirk: bool is an int subclass, so display_duration =
|
||||
# True passes the isinstance((int, float)) branch and returns 1.0.
|
||||
assert make_plugin(instance_duration=True).get_display_duration() == 1.0
|
||||
|
||||
|
||||
class TestConfigFallback:
|
||||
@@ -118,29 +108,3 @@ class TestConfigFallback:
|
||||
|
||||
def test_config_none_uses_default(self):
|
||||
assert make_plugin({"display_duration": None}).get_display_duration() == 15.0
|
||||
|
||||
def test_config_bool_uses_default(self):
|
||||
assert make_plugin({"display_duration": True}).get_display_duration() == 15.0
|
||||
assert make_plugin({"display_duration": False}).get_display_duration() == 15.0
|
||||
|
||||
|
||||
class TestValidateConfigDuration:
|
||||
# validate_config must agree with get_display_duration about what a
|
||||
# valid duration is — a config it accepts must not then be rejected
|
||||
# (or silently defaulted) when the duration is actually read.
|
||||
|
||||
def test_positive_number_valid(self):
|
||||
assert make_plugin({"display_duration": 20}).validate_config() is True
|
||||
|
||||
def test_zero_and_negative_invalid(self):
|
||||
assert make_plugin({"display_duration": 0}).validate_config() is False
|
||||
assert make_plugin({"display_duration": -5}).validate_config() is False
|
||||
|
||||
def test_bool_invalid(self):
|
||||
# bool is an int subclass; True would otherwise pass as "positive
|
||||
# number" here while get_display_duration rejects it.
|
||||
assert make_plugin({"display_duration": True}).validate_config() is False
|
||||
assert make_plugin({"display_duration": False}).validate_config() is False
|
||||
|
||||
def test_missing_duration_valid(self):
|
||||
assert make_plugin({}).validate_config() is True
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
"""Tests that one cache directory gets one cleanup thread per process.
|
||||
|
||||
The sweep lists a directory and deletes from it, so a second thread over the
|
||||
same directory only duplicates the scan. Nothing enforced that: every
|
||||
CacheManager started its own, and since the loop closes over `self`, a
|
||||
discarded manager could never be collected -- its thread stayed alive and
|
||||
re-scanned the same directory every 24 hours for the life of the process.
|
||||
|
||||
On the dev rig a display process carried three, for one cache directory:
|
||||
|
||||
14:22:59.954 display_controller (the real one)
|
||||
14:22:59.973 startup validation, run 1 (discarded)
|
||||
14:23:01.055 startup validation, run 2 (discarded)
|
||||
|
||||
Startup validation runs twice and built a throwaway manager each time, purely
|
||||
to read a directory path.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cache_manager import CacheManager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry():
|
||||
CacheManager._cleanup_owners.clear()
|
||||
yield
|
||||
for owner in list(CacheManager._cleanup_owners.values()):
|
||||
owner.stop_cleanup_thread()
|
||||
CacheManager._cleanup_owners.clear()
|
||||
|
||||
|
||||
def _live_cleanup_threads():
|
||||
return [t for t in threading.enumerate()
|
||||
if t.name == 'DiskCacheCleanup' and t.is_alive()]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(tmp_path, monkeypatch):
|
||||
"""A CacheManager pinned to a temp dir, so tests never touch the real one."""
|
||||
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir',
|
||||
lambda self: str(tmp_path))
|
||||
return CacheManager
|
||||
|
||||
|
||||
class TestOneThreadPerDirectory:
|
||||
def test_a_single_manager_starts_one(self, manager):
|
||||
before = len(_live_cleanup_threads())
|
||||
m = manager()
|
||||
try:
|
||||
assert len(_live_cleanup_threads()) == before + 1
|
||||
finally:
|
||||
m.stop_cleanup_thread()
|
||||
|
||||
def test_three_managers_still_start_one(self, manager):
|
||||
# Exactly the rig's shape: the real manager plus two throwaways.
|
||||
before = len(_live_cleanup_threads())
|
||||
managers = [manager() for _ in range(3)]
|
||||
try:
|
||||
assert len(_live_cleanup_threads()) == before + 1
|
||||
finally:
|
||||
for m in managers:
|
||||
m.stop_cleanup_thread()
|
||||
|
||||
def test_the_first_one_owns_it(self, manager):
|
||||
first, second = manager(), manager()
|
||||
try:
|
||||
assert CacheManager._cleanup_owners[first.cache_dir] is first
|
||||
assert second._cleanup_thread is None
|
||||
finally:
|
||||
first.stop_cleanup_thread()
|
||||
second.stop_cleanup_thread()
|
||||
|
||||
def test_the_survivor_can_take_over(self, manager):
|
||||
first = manager()
|
||||
first.stop_cleanup_thread()
|
||||
assert not _live_cleanup_threads()
|
||||
|
||||
second = manager()
|
||||
try:
|
||||
# Ownership was released, so the directory is swept again rather
|
||||
# than being left permanently unclaimed by a dead owner.
|
||||
assert len(_live_cleanup_threads()) == 1
|
||||
assert CacheManager._cleanup_owners[second.cache_dir] is second
|
||||
finally:
|
||||
second.stop_cleanup_thread()
|
||||
|
||||
def test_stopping_a_non_owner_does_not_unclaim_the_directory(self, manager):
|
||||
first, second = manager(), manager()
|
||||
try:
|
||||
second.stop_cleanup_thread() # never owned it
|
||||
assert CacheManager._cleanup_owners[first.cache_dir] is first
|
||||
assert len(_live_cleanup_threads()) == 1
|
||||
finally:
|
||||
first.stop_cleanup_thread()
|
||||
|
||||
def test_separate_directories_get_separate_threads(self, tmp_path, monkeypatch):
|
||||
a, b = tmp_path / 'a', tmp_path / 'b'
|
||||
a.mkdir()
|
||||
b.mkdir()
|
||||
dirs = iter([str(a), str(b)])
|
||||
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir',
|
||||
lambda self: next(dirs))
|
||||
first, second = CacheManager(), CacheManager()
|
||||
try:
|
||||
assert first.cache_dir != second.cache_dir
|
||||
assert len(_live_cleanup_threads()) == 2
|
||||
finally:
|
||||
first.stop_cleanup_thread()
|
||||
second.stop_cleanup_thread()
|
||||
|
||||
def test_no_thread_leaks_across_many_constructions(self, manager):
|
||||
before = len(_live_cleanup_threads())
|
||||
made = [manager() for _ in range(12)]
|
||||
try:
|
||||
assert len(_live_cleanup_threads()) == before + 1
|
||||
finally:
|
||||
for m in made:
|
||||
m.stop_cleanup_thread()
|
||||
assert len(_live_cleanup_threads()) == before
|
||||
|
||||
|
||||
class TestValidatorDoesNotBuildItsOwn:
|
||||
def test_it_uses_the_cache_manager_it_is_given(self, manager):
|
||||
from src.startup_validator import StartupValidator
|
||||
|
||||
shared = manager()
|
||||
try:
|
||||
before = len(_live_cleanup_threads())
|
||||
v = StartupValidator(config_manager=object(), cache_manager=shared)
|
||||
v._validate_cache_directory()
|
||||
assert len(_live_cleanup_threads()) == before, (
|
||||
"validation started another cleanup thread")
|
||||
finally:
|
||||
shared.stop_cleanup_thread()
|
||||
|
||||
def test_without_one_it_cleans_up_after_itself(self, manager):
|
||||
from src.startup_validator import StartupValidator
|
||||
|
||||
before = len(_live_cleanup_threads())
|
||||
v = StartupValidator(config_manager=object())
|
||||
v._validate_cache_directory()
|
||||
assert len(_live_cleanup_threads()) == before, (
|
||||
"the fallback manager left its cleanup thread running")
|
||||
@@ -458,26 +458,3 @@ class TestDiskCacheWriteEconomy:
|
||||
cache = DiskCache(cache_dir=str(tmp_path))
|
||||
cache.set("k", {"when": datetime(2026, 7, 12, 10, 30)})
|
||||
assert cache.get("k") == {"when": "2026-07-12T10:30:00"}
|
||||
|
||||
|
||||
# --- the ceiling has to hold between cleanup sweeps ---------------------------
|
||||
|
||||
def test_memory_cache_enforces_ceiling_on_every_write():
|
||||
"""_cleanup_memory_cache only runs every cleanup_interval seconds (300 by
|
||||
default). If set() accepted entries without bound in between, a burst could
|
||||
take the cache far past max_size -- which is the unbounded growth the limit
|
||||
exists to prevent, and on a 1GB board the difference between a bounded cache
|
||||
and a Pi that cannot fork.
|
||||
"""
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
|
||||
cache = MemoryCache(max_size=150, cleanup_interval=300.0)
|
||||
for i in range(1000):
|
||||
cache.set(f"k{i}", {"v": i})
|
||||
|
||||
assert len(cache._cache) <= 150
|
||||
# The timestamp map has to be evicted alongside the values, or it becomes
|
||||
# the leak instead.
|
||||
assert len(cache._timestamps) <= 150
|
||||
assert cache.get("k999") is not None, "the newest write must survive"
|
||||
assert cache.get("k0") is None, "the oldest must be the one evicted"
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
"""Tests that abandoned cache temp files get collected.
|
||||
|
||||
DiskCache.set() writes through tempfile.mkstemp and os.replace, removing its
|
||||
own temp file in a finally. That covers a failed write, but not a process that
|
||||
dies between the two -- a SIGKILL, a lost restart race, a power cut, all
|
||||
ordinary on a Pi. Nothing collected what was left behind: the temp names are
|
||||
".<key>.json.<random>", and the expiry sweep only listed names ending in
|
||||
.json, so they accumulated for as long as the card had been in service.
|
||||
|
||||
Measured on a live rig before this fix: 76 orphans totalling 1,050 MB -- 81%
|
||||
of the entire cache directory -- the oldest six months old.
|
||||
|
||||
The predicate that decides what to delete is tested harder than the sweep
|
||||
itself, because a false positive here destroys real data.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cache.disk_cache import DiskCache, _ORPHAN_TEMP_MAX_AGE_SECONDS
|
||||
|
||||
|
||||
class FakeStrategy:
|
||||
@staticmethod
|
||||
def get_data_type_from_key(key):
|
||||
return 'default'
|
||||
|
||||
|
||||
POLICIES = {'default': 30}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache(tmp_path):
|
||||
return DiskCache(str(tmp_path))
|
||||
|
||||
|
||||
def _age(path, seconds):
|
||||
old = time.time() - seconds
|
||||
os.utime(path, (old, old))
|
||||
|
||||
|
||||
def _write(tmp_path, name, body='{}'):
|
||||
p = tmp_path / name
|
||||
p.write_text(body, encoding='utf-8')
|
||||
return p
|
||||
|
||||
|
||||
class TestWhatCountsAsAnOrphan:
|
||||
@pytest.mark.parametrize('name', [
|
||||
'.weather.json.a1b2c3d4',
|
||||
'.odds_espn_football_nfl_401.json.xyz00000',
|
||||
'.a.json.b',
|
||||
])
|
||||
def test_our_temp_files_are_orphans(self, name):
|
||||
assert DiskCache._is_orphaned_temp(name)
|
||||
|
||||
@pytest.mark.parametrize('name', [
|
||||
'weather.json', # real data
|
||||
'.weather.json', # a dotted key that completed
|
||||
'.gitignore', # not ours
|
||||
'.hidden', # not ours
|
||||
'weather.json.bak', # no leading dot: someone else's
|
||||
'.json.abc', # no key between the dot and .json.
|
||||
'.weather.json.', # no random component
|
||||
'notes.txt',
|
||||
])
|
||||
def test_everything_else_is_left_alone(self, name):
|
||||
assert not DiskCache._is_orphaned_temp(name)
|
||||
|
||||
def test_the_names_set_actually_creates_are_matched(self, cache, tmp_path):
|
||||
"""Guard against the predicate and the writer drifting apart."""
|
||||
created = []
|
||||
real = os.replace
|
||||
|
||||
def capture(src, dst):
|
||||
created.append(os.path.basename(src))
|
||||
return real(src, dst)
|
||||
|
||||
import src.cache.disk_cache as mod
|
||||
mod.os.replace = capture
|
||||
try:
|
||||
cache.set('weather', {'v': 1})
|
||||
finally:
|
||||
mod.os.replace = real
|
||||
|
||||
assert created, "set() did not go through the temp-file path"
|
||||
assert all(DiskCache._is_orphaned_temp(n) for n in created), created
|
||||
|
||||
|
||||
class TestTheSweep:
|
||||
def test_an_old_orphan_is_removed(self, cache, tmp_path):
|
||||
p = _write(tmp_path, '.weather.json.a1b2c3d4', 'x' * 5000)
|
||||
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert not p.exists()
|
||||
assert stats['orphan_temp_files_deleted'] == 1
|
||||
assert stats['space_freed_bytes'] >= 5000
|
||||
|
||||
def test_an_in_flight_write_is_not_snatched_away(self, cache, tmp_path):
|
||||
# The whole risk of this sweep: deleting a temp file another thread is
|
||||
# about to os.replace into place.
|
||||
p = _write(tmp_path, '.weather.json.inflight')
|
||||
|
||||
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert p.exists()
|
||||
|
||||
def test_real_cache_files_survive(self, cache, tmp_path):
|
||||
fresh = _write(tmp_path, 'weather.json')
|
||||
dotted = _write(tmp_path, '.weather.json')
|
||||
_age(dotted, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert fresh.exists()
|
||||
assert dotted.exists(), "a completed .json was treated as a temp file"
|
||||
|
||||
def test_unrelated_dotfiles_survive(self, cache, tmp_path):
|
||||
keep = _write(tmp_path, '.gitignore')
|
||||
_age(keep, 400 * 86400)
|
||||
|
||||
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert keep.exists()
|
||||
|
||||
def test_expiry_still_works_alongside_it(self, cache, tmp_path):
|
||||
stale = _write(tmp_path, 'old.json')
|
||||
_age(stale, 40 * 86400) # past the 30-day default
|
||||
orphan = _write(tmp_path, '.old.json.zz999999')
|
||||
_age(orphan, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert not stale.exists()
|
||||
assert not orphan.exists()
|
||||
assert stats['files_deleted'] == 2
|
||||
assert stats['orphan_temp_files_deleted'] == 1
|
||||
|
||||
def test_the_rig_scenario(self, cache, tmp_path):
|
||||
"""76 orphans of assorted ages, none of them reachable before."""
|
||||
for i in range(76):
|
||||
p = _write(tmp_path, '.sched_%d.json.r%06d' % (i, i), 'x' * 1000)
|
||||
_age(p, (i + 2) * 86400)
|
||||
keep = _write(tmp_path, 'sched.json')
|
||||
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert stats['orphan_temp_files_deleted'] == 76
|
||||
assert keep.exists()
|
||||
assert not list(tmp_path.glob('.sched_*'))
|
||||
# The summary line is "<deleted>/<scanned>", so an orphan that is
|
||||
# deleted but never counted as scanned renders as "76/1".
|
||||
assert stats['files_scanned'] == 77
|
||||
assert stats['files_deleted'] <= stats['files_scanned']
|
||||
|
||||
def test_deleted_never_exceeds_scanned(self, cache, tmp_path):
|
||||
p = _write(tmp_path, '.only.json.a1b2c3d4')
|
||||
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
|
||||
assert stats['files_deleted'] == 1
|
||||
assert stats['files_scanned'] == 1
|
||||
|
||||
def test_a_missing_file_mid_sweep_is_not_an_error(self, cache, tmp_path):
|
||||
p = _write(tmp_path, '.weather.json.a1b2c3d4')
|
||||
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
|
||||
|
||||
import src.cache.disk_cache as mod
|
||||
real = mod.os.path.getsize
|
||||
|
||||
def vanish(path):
|
||||
if path.endswith('.a1b2c3d4'):
|
||||
os.remove(path)
|
||||
raise FileNotFoundError(path)
|
||||
return real(path)
|
||||
|
||||
mod.os.path.getsize = vanish
|
||||
try:
|
||||
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
|
||||
finally:
|
||||
mod.os.path.getsize = real
|
||||
|
||||
assert stats['errors'] == 0
|
||||
@@ -1,130 +0,0 @@
|
||||
"""Tests that a per-entry ttl actually controls expiry.
|
||||
|
||||
Regression under test: `CacheManager.set(key, data, ttl=...)` stored the value
|
||||
and no read path ever consulted it. Expiry came from a `max_age` inferred from
|
||||
substrings in the key ("live", "odds", "stock"), so every caller passing `ttl=`
|
||||
-- 48 sites across the plugins and 4 in the core -- was writing a number that
|
||||
did nothing. The old docstring admitted as much: "stored for compatibility but
|
||||
expiration is still controlled via max_age when reading".
|
||||
|
||||
Measured against a real device's cache (8,873 entries carrying a ttl), the
|
||||
inferred value and the intended one disagreed almost everywhere:
|
||||
|
||||
stocks max_age 600 vs ttl 1800 4903 entries
|
||||
news max_age 3600 vs ttl 600 1770 entries
|
||||
odds max_age 1800 vs ttl 3600 1301 entries
|
||||
images max_age 300 vs ttl 2592000 20 entries
|
||||
|
||||
No `sports_live` entry carries a ttl, so live scores keep their inferred
|
||||
30-second freshness either way.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
from src.cache.disk_cache import DiskCache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disk(tmp_path):
|
||||
return DiskCache(cache_dir=str(tmp_path))
|
||||
|
||||
|
||||
def _record(ttl=None, age=0.0):
|
||||
rec = {"data": {"v": 1}, "timestamp": time.time() - age}
|
||||
if ttl is not None:
|
||||
rec["ttl"] = ttl
|
||||
return rec
|
||||
|
||||
|
||||
class TestDiskCacheHonoursTtl:
|
||||
def test_ttl_longer_than_max_age_keeps_the_entry(self, disk):
|
||||
# The odds case: written wanting an hour, expired at 30 minutes.
|
||||
disk.set("odds_espn_football_nfl_401", _record(ttl=3600, age=1900))
|
||||
assert disk.get("odds_espn_football_nfl_401", max_age=1800) is not None
|
||||
|
||||
def test_ttl_shorter_than_max_age_expires_the_entry(self, disk):
|
||||
# The news case: written wanting 10 minutes, kept for an hour.
|
||||
disk.set("news_NHL_1", _record(ttl=600, age=900))
|
||||
assert disk.get("news_NHL_1", max_age=3600) is None
|
||||
|
||||
def test_without_a_ttl_max_age_still_applies(self, disk):
|
||||
disk.set("plain_key", _record(age=400))
|
||||
assert disk.get("plain_key", max_age=300) is None
|
||||
disk.set("plain_key2", _record(age=100))
|
||||
assert disk.get("plain_key2", max_age=300) is not None
|
||||
|
||||
def test_a_fresh_entry_within_its_ttl_survives(self, disk):
|
||||
disk.set("k", _record(ttl=600, age=10))
|
||||
assert disk.get("k", max_age=30) is not None
|
||||
|
||||
def test_ttl_zero_expires_immediately(self, disk):
|
||||
# 0 means zero seconds, not "forever" -- max_age=None is how a caller
|
||||
# asks for no expiry.
|
||||
disk.set("k", _record(ttl=0, age=1))
|
||||
assert disk.get("k", max_age=99999) is None
|
||||
|
||||
@pytest.mark.parametrize("bad", ["600", None, True, False, -5, {"a": 1}])
|
||||
def test_a_nonsense_ttl_falls_back_to_max_age(self, disk, bad):
|
||||
# Including bools: True is an int in Python and must not become a 1s ttl.
|
||||
rec = _record(age=400)
|
||||
rec["ttl"] = bad
|
||||
disk.set("k_%s" % type(bad).__name__, rec)
|
||||
assert disk.get("k_%s" % type(bad).__name__, max_age=300) is None
|
||||
|
||||
|
||||
class TestMemoryCacheHonoursTtl:
|
||||
def test_ttl_longer_than_max_age_keeps_the_entry(self):
|
||||
m = MemoryCache()
|
||||
m.set("k", _record(ttl=3600))
|
||||
m._timestamps["k"] = time.time() - 1900
|
||||
assert m.get("k", max_age=1800) is not None
|
||||
|
||||
def test_ttl_shorter_than_max_age_expires_the_entry(self):
|
||||
m = MemoryCache()
|
||||
m.set("k", _record(ttl=600))
|
||||
m._timestamps["k"] = time.time() - 900
|
||||
assert m.get("k", max_age=3600) is None
|
||||
|
||||
def test_without_a_ttl_max_age_still_applies(self):
|
||||
m = MemoryCache()
|
||||
m.set("k", _record())
|
||||
m._timestamps["k"] = time.time() - 400
|
||||
assert m.get("k", max_age=300) is None
|
||||
|
||||
def test_both_layers_agree(self, tmp_path):
|
||||
"""A record must not be live in one layer and expired in the other."""
|
||||
rec = _record(ttl=3600, age=1900)
|
||||
d = DiskCache(cache_dir=str(tmp_path))
|
||||
d.set("k", rec)
|
||||
m = MemoryCache()
|
||||
m.set("k", rec)
|
||||
m._timestamps["k"] = rec["timestamp"]
|
||||
assert (d.get("k", max_age=1800) is not None) == (m.get("k", max_age=1800) is not None)
|
||||
|
||||
|
||||
class TestEndToEnd:
|
||||
def test_set_then_get_respects_the_ttl(self, tmp_path, monkeypatch):
|
||||
"""The behaviour a caller of CacheManager.set(ttl=...) expects."""
|
||||
from src.cache_manager import CacheManager
|
||||
|
||||
cm = CacheManager()
|
||||
cm._disk_cache_component = DiskCache(cache_dir=str(tmp_path))
|
||||
cm._memory_cache_component = MemoryCache()
|
||||
|
||||
cm.set("odds_espn_football_nfl_401", {"spread": 6.5}, ttl=3600)
|
||||
|
||||
# Age the stored record past the inferred max_age for odds (1800s) but
|
||||
# within the ttl the caller asked for.
|
||||
path = cm._disk_cache_component.get_cache_path("odds_espn_football_nfl_401")
|
||||
import json
|
||||
rec = json.load(open(path))
|
||||
rec["timestamp"] = time.time() - 1900
|
||||
json.dump(rec, open(path, "w"))
|
||||
cm._memory_cache_component.clear() if hasattr(
|
||||
cm._memory_cache_component, "clear") else None
|
||||
|
||||
got = cm.get_with_auto_strategy("odds_espn_football_nfl_401")
|
||||
assert got is not None, "the ttl the caller asked for was ignored"
|
||||
@@ -1,113 +0,0 @@
|
||||
"""A checkbox group must not post back options it cannot show.
|
||||
|
||||
The enum that lets the widget draw checkboxes is also what validates the
|
||||
saved value. When a league retires a team code -- OAK for the Athletics, ARI
|
||||
for the Coyotes -- or a schema drops an option, a config that still holds the
|
||||
old value has nothing to render for it. The value stayed in the hidden
|
||||
``_data`` input regardless, because that input is seeded from the stored array
|
||||
and only rebuilt by ``updateCheckboxGroupData()`` on change. Editing any other
|
||||
field on that plugin therefore posted the stale value back, the schema
|
||||
rejected it, and the save endpoint returned 400
|
||||
``CONFIG_VALIDATION_FAILED`` -- so the whole plugin became uneditable until
|
||||
the user worked out which invisible entry was at fault.
|
||||
|
||||
Runtime was never affected: plugin loading treats schema violations as
|
||||
warn/degrade, and the stale code already matched no team. Only the web UI
|
||||
blocked.
|
||||
|
||||
These tests render the checkbox-group block lifted *out of the shipped
|
||||
template*, following test_enum_option_labels.py, so they exercise the
|
||||
production expression rather than a copy that could drift from it.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import DictLoader, Environment
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CONFIG_FORM = (PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'partials'
|
||||
/ 'plugin_config.html')
|
||||
|
||||
# The checkbox-group branch: from its `{% elif %}` guard through the sentinel
|
||||
# hidden input that closes it. Anchored on the guard so the match cannot run on
|
||||
# into a neighbouring widget branch.
|
||||
BLOCK_RE = re.compile(
|
||||
r"\{%\s*elif x_widget == 'checkbox-group'\s*%\}(.*?)"
|
||||
r"<input type=\"hidden\" name=\"\{\{ full_key \}\}\[\]\" value=\"\">",
|
||||
re.S,
|
||||
)
|
||||
|
||||
|
||||
def _shipped_block() -> str:
|
||||
"""Return the live checkbox-group block lifted from plugin_config.html."""
|
||||
source = CONFIG_FORM.read_text(encoding='utf-8')
|
||||
match = BLOCK_RE.search(source)
|
||||
assert match, (
|
||||
'could not find the checkbox-group block in plugin_config.html — the '
|
||||
'template changed shape and this guard needs updating'
|
||||
)
|
||||
block = match.group(1)
|
||||
assert 'data-option-value' in block, 'extracted the wrong branch'
|
||||
assert '{% elif' not in block, 'extraction ran past the checkbox-group branch'
|
||||
return block
|
||||
|
||||
|
||||
def _render(prop: dict, value=None) -> str:
|
||||
env = Environment(loader=DictLoader({'f': _shipped_block()}), autoescape=True)
|
||||
return env.get_template('f').render(
|
||||
prop=prop, value=value, field_id='fid', full_key='k'
|
||||
)
|
||||
|
||||
|
||||
def _submitted(html: str) -> list:
|
||||
"""The array the form will actually post: the hidden _data input."""
|
||||
match = re.search(r'id="fid_data"[^>]*\svalue=\'([^\']*)\'', html)
|
||||
assert match, f'hidden _data input not found in:\n{html}'
|
||||
return json.loads(match.group(1).replace(''', "'"))
|
||||
|
||||
|
||||
def _checked(html: str) -> list:
|
||||
return re.findall(r'data-option-value="([^"]+)"[^>]*checked', html)
|
||||
|
||||
|
||||
MLB = {'type': 'array', 'items': {'type': 'string', 'enum': ['NYY', 'BOS', 'ATH']},
|
||||
'x-widget': 'checkbox-group'}
|
||||
|
||||
|
||||
def test_a_retired_code_is_not_posted_back() -> None:
|
||||
"""The regression: OAK became ATH, and OAK used to ride along on save."""
|
||||
html = _render(MLB, ['NYY', 'OAK'])
|
||||
assert _submitted(html) == ['NYY'], 'stale value would still be submitted'
|
||||
|
||||
|
||||
def test_the_dropped_value_is_named_rather_than_vanishing() -> None:
|
||||
html = _render(MLB, ['NYY', 'OAK'])
|
||||
assert 'OAK' in html
|
||||
assert 'data-stale-options' in html
|
||||
|
||||
|
||||
def test_valid_values_are_untouched_and_still_checked() -> None:
|
||||
html = _render(MLB, ['NYY', 'ATH'])
|
||||
assert _submitted(html) == ['NYY', 'ATH']
|
||||
assert sorted(_checked(html)) == ['ATH', 'NYY']
|
||||
assert 'data-stale-options' not in html
|
||||
|
||||
|
||||
def test_an_all_stale_selection_clears_rather_than_blocking() -> None:
|
||||
html = _render(MLB, ['OAK', 'SD'])
|
||||
assert _submitted(html) == []
|
||||
|
||||
|
||||
def test_an_empty_enum_leaves_the_value_alone() -> None:
|
||||
"""No options means nothing to validate against — filtering would wipe it."""
|
||||
prop = {'type': 'array', 'items': {'type': 'string'}, 'x-widget': 'checkbox-group'}
|
||||
html = _render(prop, ['ANYTHING', 'GOES'])
|
||||
assert _submitted(html) == ['ANYTHING', 'GOES']
|
||||
|
||||
|
||||
def test_unset_value_falls_back_to_the_default() -> None:
|
||||
prop = dict(MLB, default=['BOS'])
|
||||
html = _render(prop, None)
|
||||
assert _submitted(html) == ['BOS']
|
||||
assert _checked(html) == ['BOS']
|
||||
@@ -1,253 +0,0 @@
|
||||
"""
|
||||
Tests for src/common/config_helper.py — pins the ConfigHelper contract.
|
||||
|
||||
Covers: load/save round trips (missing/malformed files return {} rather
|
||||
than raising, non-ASCII preserved via ensure_ascii=False, top-level JSON
|
||||
lists returned as-is), dot-notation get/set including the silent-failure
|
||||
contract when an intermediate key holds a non-dict, merge_configs deep
|
||||
semantics with NO aliasing of the base config (the fixed bug — the old
|
||||
shallow copy let mutations of the merged result leak into base's nested
|
||||
dicts), simplified schema validation including the caught-TypeError path
|
||||
when a schema 'type' is given as a string, plugin config key conventions
|
||||
('{plugin_id}_config', enabled defaults True), and required-key checks
|
||||
where a key present with value None counts as present.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common.config_helper import ConfigHelper
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def helper():
|
||||
return ConfigHelper()
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
def test_missing_file_returns_empty_dict(self, helper, tmp_path):
|
||||
assert helper.load_config(tmp_path / "nope.json") == {}
|
||||
|
||||
def test_malformed_json_returns_empty_dict(self, helper, tmp_path):
|
||||
path = tmp_path / "bad.json"
|
||||
path.write_text("{ this is not json", encoding="utf-8")
|
||||
assert helper.load_config(path) == {}
|
||||
|
||||
def test_top_level_list_returned_as_is(self, helper, tmp_path):
|
||||
# load_config does not enforce a dict shape: a JSON list comes
|
||||
# straight back. Pinned as a characterization of current behavior.
|
||||
path = tmp_path / "list.json"
|
||||
path.write_text("[1, 2, 3]", encoding="utf-8")
|
||||
assert helper.load_config(path) == [1, 2, 3]
|
||||
|
||||
|
||||
class TestSaveConfig:
|
||||
def test_round_trip(self, helper, tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
config = {'display': {'hardware': {'rows': 32}}, 'timezone': 'UTC'}
|
||||
assert helper.save_config(config, path) is True
|
||||
assert helper.load_config(path) == config
|
||||
|
||||
def test_creates_parent_directories(self, helper, tmp_path):
|
||||
path = tmp_path / "deep" / "nested" / "config.json"
|
||||
assert helper.save_config({'a': 1}, path) is True
|
||||
assert path.exists()
|
||||
assert helper.load_config(path) == {'a': 1}
|
||||
|
||||
def test_non_ascii_survives_round_trip(self, helper, tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
config = {'city': 'Zürich', 'note': 'météo ☀'}
|
||||
assert helper.save_config(config, path) is True
|
||||
assert helper.load_config(path) == config
|
||||
# ensure_ascii=False: characters are written raw, not \u-escaped
|
||||
assert 'Zürich' in path.read_text(encoding='utf-8')
|
||||
|
||||
def test_directory_path_returns_false_not_raise(self, helper, tmp_path):
|
||||
assert helper.save_config({'a': 1}, tmp_path) is False
|
||||
|
||||
|
||||
class TestGetConfigValue:
|
||||
def test_dot_notation_hit(self, helper):
|
||||
config = {'display': {'hardware': {'rows': 32}}}
|
||||
assert helper.get_config_value(config, 'display.hardware.rows') == 32
|
||||
|
||||
def test_missing_returns_default(self, helper):
|
||||
sentinel = object()
|
||||
assert helper.get_config_value({}, 'display.rows', default=sentinel) is sentinel
|
||||
|
||||
def test_intermediate_non_dict_returns_default(self, helper):
|
||||
config = {'display': 'not-a-dict'}
|
||||
assert helper.get_config_value(config, 'display.hardware.rows', default=64) == 64
|
||||
|
||||
def test_required_missing_raises_keyerror(self, helper):
|
||||
with pytest.raises(KeyError):
|
||||
helper.get_config_value({}, 'display.rows', required=True)
|
||||
|
||||
|
||||
class TestSetConfigValue:
|
||||
def test_sets_top_level(self, helper):
|
||||
config = {}
|
||||
helper.set_config_value(config, 'timezone', 'UTC')
|
||||
assert config == {'timezone': 'UTC'}
|
||||
|
||||
def test_auto_creates_intermediates(self, helper):
|
||||
config = {}
|
||||
helper.set_config_value(config, 'display.hardware.rows', 32)
|
||||
assert config == {'display': {'hardware': {'rows': 32}}}
|
||||
|
||||
def test_silent_failure_on_non_dict_intermediate(self, helper):
|
||||
# 'a' exists but holds an int; the assignment attempt raises
|
||||
# TypeError internally, which set_config_value swallows and logs.
|
||||
# The config is left unchanged — pinned silent-failure contract.
|
||||
config = {'a': 5}
|
||||
helper.set_config_value(config, 'a.b', 1)
|
||||
assert config == {'a': 5}
|
||||
|
||||
|
||||
class TestMergeConfigs:
|
||||
def test_nested_dicts_merge_recursively(self, helper):
|
||||
base = {'display': {'rows': 32, 'cols': 64}, 'timezone': 'UTC'}
|
||||
override = {'display': {'cols': 128, 'brightness': 90}}
|
||||
merged = helper.merge_configs(base, override)
|
||||
assert merged == {
|
||||
'display': {'rows': 32, 'cols': 128, 'brightness': 90},
|
||||
'timezone': 'UTC',
|
||||
}
|
||||
|
||||
def test_scalar_override_wins_over_dict(self, helper):
|
||||
merged = helper.merge_configs({'display': {'rows': 32}}, {'display': 7})
|
||||
assert merged['display'] == 7
|
||||
|
||||
def test_dict_override_wins_over_scalar(self, helper):
|
||||
merged = helper.merge_configs({'display': 7}, {'display': {'rows': 32}})
|
||||
assert merged['display'] == {'rows': 32}
|
||||
|
||||
def test_no_aliasing_of_base(self, helper):
|
||||
# Post-fix: merge deep-copies base, so mutating the result never
|
||||
# leaks back into the caller's base config.
|
||||
base = {'display': {'x': 1}}
|
||||
merged = helper.merge_configs(base, {})
|
||||
assert merged['display'] is not base['display']
|
||||
merged['display']['x'] = 99
|
||||
assert base['display']['x'] == 1
|
||||
|
||||
def test_inputs_unchanged(self, helper):
|
||||
base = {'a': {'b': 1}}
|
||||
override = {'a': {'c': 2}}
|
||||
helper.merge_configs(base, override)
|
||||
assert base == {'a': {'b': 1}}
|
||||
assert override == {'a': {'c': 2}}
|
||||
|
||||
def test_no_aliasing_of_override_values(self, helper):
|
||||
# The non-recursive branch must deep-copy the override value too:
|
||||
# mutating a merged-in list or dict must not reach back into
|
||||
# override_config.
|
||||
override = {'teams': ['A', 'B'], 'nested': {'x': [1]}}
|
||||
merged = helper.merge_configs({}, override)
|
||||
merged['teams'].append('C')
|
||||
merged['nested']['x'].append(2)
|
||||
assert override == {'teams': ['A', 'B'], 'nested': {'x': [1]}}
|
||||
|
||||
|
||||
class TestValidateConfig:
|
||||
def test_no_schema_dict_is_valid(self, helper):
|
||||
assert helper.validate_config({'a': 1}) is True
|
||||
|
||||
def test_no_schema_list_is_invalid(self, helper):
|
||||
assert helper.validate_config([1, 2]) is False
|
||||
|
||||
def test_required_key_missing_is_invalid(self, helper):
|
||||
schema = {'rows': {'required': True, 'type': int}}
|
||||
assert helper.validate_config({}, schema) is False
|
||||
|
||||
def test_optional_key_missing_is_valid(self, helper):
|
||||
schema = {'rows': {'required': False, 'type': int}}
|
||||
assert helper.validate_config({}, schema) is True
|
||||
|
||||
def test_wrong_type_is_invalid(self, helper):
|
||||
schema = {'rows': {'type': int}}
|
||||
assert helper.validate_config({'rows': 'thirty-two'}, schema) is False
|
||||
assert helper.validate_config({'rows': 32}, schema) is True
|
||||
|
||||
def test_allowed_values_violation_is_invalid(self, helper):
|
||||
schema = {'mode': {'allowed_values': ['clock', 'weather']}}
|
||||
assert helper.validate_config({'mode': 'stocks'}, schema) is False
|
||||
assert helper.validate_config({'mode': 'clock'}, schema) is True
|
||||
|
||||
def test_string_type_in_schema_is_invalid_via_typeerror(self, helper):
|
||||
# 'type' given as the STRING "int" makes isinstance() raise
|
||||
# TypeError; validate_config catches it and returns False rather
|
||||
# than raising. Pinned characterization.
|
||||
schema = {'rows': {'type': 'int'}}
|
||||
assert helper.validate_config({'rows': 32}, schema) is False
|
||||
|
||||
|
||||
class TestPluginConfigHelpers:
|
||||
def test_get_plugin_config_uses_suffixed_key(self, helper):
|
||||
plugin_cfg = {'enabled': True, 'display_duration': 30}
|
||||
assert helper.get_plugin_config({'clock_config': plugin_cfg}, 'clock') == plugin_cfg
|
||||
|
||||
def test_get_plugin_config_bare_id_key_not_found(self, helper):
|
||||
# Only '{plugin_id}_config' is consulted — a bare 'clock' section
|
||||
# is invisible to this helper. Pinned key contract.
|
||||
assert helper.get_plugin_config({'clock': {'enabled': True}}, 'clock') == {}
|
||||
|
||||
def test_create_default_config_wraps_in_suffixed_key(self, helper):
|
||||
defaults = {'enabled': True}
|
||||
assert helper.create_default_config('clock', defaults) == {'clock_config': defaults}
|
||||
|
||||
def test_is_plugin_enabled_defaults_true_for_unknown(self, helper):
|
||||
assert helper.is_plugin_enabled({}, 'clock') is True
|
||||
|
||||
def test_is_plugin_enabled_false_when_disabled(self, helper):
|
||||
config = {'clock_config': {'enabled': False}}
|
||||
assert helper.is_plugin_enabled(config, 'clock') is False
|
||||
|
||||
def test_is_plugin_enabled_ignores_bare_id_key(self, helper):
|
||||
# Disabled under the wrong key -> still reported enabled (default).
|
||||
config = {'clock': {'enabled': False}}
|
||||
assert helper.is_plugin_enabled(config, 'clock') is True
|
||||
|
||||
|
||||
class TestSportsAndDisplayHelpers:
|
||||
def test_get_display_config(self, helper):
|
||||
display = {'hardware': {'rows': 32}}
|
||||
assert helper.get_display_config({'display': display}) == display
|
||||
assert helper.get_display_config({}) == {}
|
||||
|
||||
def test_get_sports_config_uses_scoreboard_suffix(self, helper):
|
||||
sport_cfg = {'favorite_teams': ['TB']}
|
||||
config = {'football_scoreboard': sport_cfg}
|
||||
assert helper.get_sports_config(config, 'football') == sport_cfg
|
||||
assert helper.get_sports_config(config, 'hockey') == {}
|
||||
|
||||
def test_get_favorite_teams(self, helper):
|
||||
config = {'football_scoreboard': {'favorite_teams': ['TB', 'DAL']}}
|
||||
assert helper.get_favorite_teams(config, 'football') == ['TB', 'DAL']
|
||||
assert helper.get_favorite_teams({}, 'football') == []
|
||||
|
||||
def test_get_display_modes(self, helper):
|
||||
modes = {'live': True, 'recent': False}
|
||||
config = {'football_scoreboard': {'display_modes': modes}}
|
||||
assert helper.get_display_modes(config, 'football') == modes
|
||||
assert helper.get_display_modes({}, 'football') == {}
|
||||
|
||||
|
||||
class TestValidateRequiredKeys:
|
||||
def test_returns_missing_subset(self, helper):
|
||||
config = {'a': 1, 'c': {'d': 2}}
|
||||
missing = helper.validate_required_keys(config, ['a', 'b', 'c.d', 'c.e'])
|
||||
assert missing == ['b', 'c.e']
|
||||
|
||||
def test_dot_notation_present(self, helper):
|
||||
config = {'display': {'hardware': {'rows': 32}}}
|
||||
assert helper.validate_required_keys(config, ['display.hardware.rows']) == []
|
||||
|
||||
def test_empty_requirements(self, helper):
|
||||
assert helper.validate_required_keys({'a': 1}, []) == []
|
||||
|
||||
def test_present_with_none_counts_as_present(self, helper):
|
||||
# _has_key checks key membership, not truthiness — a key set to
|
||||
# None is NOT reported missing. Pinned semantics.
|
||||
assert helper.validate_required_keys({'a': None}, ['a']) == []
|
||||
@@ -177,159 +177,3 @@ class TestLoadFastPath:
|
||||
os.utime(config_file, ns=(1_000_000_000, 1_000_000_000))
|
||||
|
||||
assert manager.load_config()["timezone"] == "AAA" # stale, by design
|
||||
|
||||
|
||||
class TestArraySecretStripAndMerge:
|
||||
"""Array-item secrets round-trip (parallel-placeholder lists).
|
||||
|
||||
secret_helpers.separate_secrets emits array secrets as a list parallel
|
||||
to the regular list, with {} for items that carry no secrets. Strip
|
||||
must remove the secret fields from config.json while preserving item
|
||||
indices; load must merge them back into the right items. The regular
|
||||
list's length is authoritative in both directions.
|
||||
"""
|
||||
|
||||
def test_strip_removes_array_item_secrets_keeps_indices(self, tmp_path):
|
||||
manager = make_manager(tmp_path)
|
||||
data = {"plugin": {"accounts": [
|
||||
{"name": "a", "token": "ta"},
|
||||
{"name": "b"},
|
||||
]}}
|
||||
secrets = {"plugin": {"accounts": [{"token": "ta"}, {}]}}
|
||||
stripped = manager._strip_secrets_recursive(data, secrets)
|
||||
assert stripped == {"plugin": {"accounts": [{"name": "a"}, {"name": "b"}]}}
|
||||
|
||||
def test_strip_keeps_all_placeholder_items(self, tmp_path):
|
||||
# Even when every item strips to nothing extra, the list survives
|
||||
# with its indices — required for merge-on-load alignment.
|
||||
manager = make_manager(tmp_path)
|
||||
data = {"accounts": [{"token": "t1"}, {"token": "t2"}]}
|
||||
secrets = {"accounts": [{"token": "t1"}, {"token": "t2"}]}
|
||||
stripped = manager._strip_secrets_recursive(data, secrets)
|
||||
assert stripped == {"accounts": [{}, {}]}
|
||||
|
||||
def test_strip_whole_scalar_array_secret_drops_key(self, tmp_path):
|
||||
# A list of secret scalars is a whole-key secret, not the parallel
|
||||
# shape — the key must vanish from config.json entirely.
|
||||
manager = make_manager(tmp_path)
|
||||
data = {"recovery_codes": ["a", "b"], "city": "Austin"}
|
||||
secrets = {"recovery_codes": ["a", "b"]}
|
||||
stripped = manager._strip_secrets_recursive(data, secrets)
|
||||
assert stripped == {"city": "Austin"}
|
||||
|
||||
def test_strip_shape_mismatch_drops_key(self, tmp_path):
|
||||
# Conservative contract: if the shapes disagree, never leak.
|
||||
manager = make_manager(tmp_path)
|
||||
data = {"accounts": {"name": "not-a-list"}}
|
||||
secrets = {"accounts": [{"token": "t"}]}
|
||||
stripped = manager._strip_secrets_recursive(data, secrets)
|
||||
assert stripped == {}
|
||||
|
||||
def test_strip_ignores_extra_secrets_entries(self, tmp_path):
|
||||
# Regular list length is authoritative: a user deleted an item.
|
||||
manager = make_manager(tmp_path)
|
||||
data = {"accounts": [{"name": "a", "token": "ta"}]}
|
||||
secrets = {"accounts": [{"token": "ta"}, {"token": "tb"}]}
|
||||
stripped = manager._strip_secrets_recursive(data, secrets)
|
||||
assert stripped == {"accounts": [{"name": "a"}]}
|
||||
|
||||
def test_merge_restores_array_item_secrets(self, tmp_path):
|
||||
manager = make_manager(tmp_path)
|
||||
target = {"accounts": [{"name": "a"}, {"name": "b"}]}
|
||||
manager._deep_merge(target, {"accounts": [{"token": "ta"}, {}]})
|
||||
assert target == {"accounts": [
|
||||
{"name": "a", "token": "ta"},
|
||||
{"name": "b"},
|
||||
]}
|
||||
|
||||
def test_merge_ignores_extra_secrets_entries_with_warning(self, tmp_path, caplog):
|
||||
manager = make_manager(tmp_path)
|
||||
target = {"accounts": [{"name": "a"}]}
|
||||
with caplog.at_level("WARNING"):
|
||||
manager._deep_merge(
|
||||
target, {"accounts": [{"token": "ta"}, {"token": "ghost"}]})
|
||||
assert target == {"accounts": [{"name": "a", "token": "ta"}]}
|
||||
assert any("longer than the config list" in r.message for r in caplog.records)
|
||||
|
||||
def test_merge_non_dict_item_replaced_by_secret(self, tmp_path):
|
||||
# Shape drift inside the list: the secret wins for that index.
|
||||
manager = make_manager(tmp_path)
|
||||
target = {"accounts": ["oddball", {"name": "b"}]}
|
||||
manager._deep_merge(target, {"accounts": [{"token": "ta"}, {}]})
|
||||
assert target == {"accounts": [{"token": "ta"}, {"name": "b"}]}
|
||||
|
||||
def test_merge_whole_scalar_array_still_replaces(self, tmp_path):
|
||||
# Legacy behavior preserved: a non-parallel list replaces wholesale.
|
||||
manager = make_manager(tmp_path)
|
||||
target = {"recovery_codes": ["old"]}
|
||||
manager._deep_merge(target, {"recovery_codes": ["new1", "new2"]})
|
||||
assert target == {"recovery_codes": ["new1", "new2"]}
|
||||
|
||||
def test_full_save_load_round_trip(self, tmp_path):
|
||||
# End to end on real files: save strips array secrets out of
|
||||
# config.json; load merges them back into the right items.
|
||||
manager = make_manager(
|
||||
tmp_path,
|
||||
config={"plugin": {"accounts": [
|
||||
{"name": "a", "token": "s3cret-a"},
|
||||
{"name": "b", "token": "s3cret-b"},
|
||||
]}},
|
||||
secrets={"plugin": {"accounts": [
|
||||
{"token": "s3cret-a"}, {"token": "s3cret-b"},
|
||||
]}},
|
||||
)
|
||||
loaded = manager.load_config()
|
||||
assert loaded["plugin"]["accounts"][0]["token"] == "s3cret-a"
|
||||
|
||||
manager.save_config(loaded)
|
||||
|
||||
raw = (tmp_path / "config.json").read_text()
|
||||
assert "s3cret" not in raw
|
||||
on_disk = json.loads(raw)
|
||||
assert on_disk["plugin"]["accounts"] == [{"name": "a"}, {"name": "b"}]
|
||||
|
||||
# A fresh manager (constructed directly — make_manager would
|
||||
# overwrite the just-saved config.json) re-merges from the secrets
|
||||
# file on load.
|
||||
fresh = ConfigManager(config_path=str(tmp_path / "config.json"),
|
||||
secrets_path=str(tmp_path / "config_secrets.json"))
|
||||
fresh.template_path = str(tmp_path / "no-template.json")
|
||||
reloaded = fresh.load_config()
|
||||
assert reloaded["plugin"]["accounts"] == [
|
||||
{"name": "a", "token": "s3cret-a"},
|
||||
{"name": "b", "token": "s3cret-b"},
|
||||
]
|
||||
|
||||
def test_whole_item_secret_list_never_leaks_values(self, tmp_path):
|
||||
# When the ENTIRE array item is secret (schema marks both key[]
|
||||
# and key[].field), separate_secrets stores the full item dicts in
|
||||
# the secrets file. That shape also matches the parallel-list
|
||||
# discriminator — which is safe: strip drops every leaf key that
|
||||
# appears in the secret item, so only empty {} skeletons (item
|
||||
# count, no values) can reach config.json, and merge-on-load
|
||||
# restores the full items from those skeletons.
|
||||
from src.web_interface.secret_helpers import (
|
||||
find_secret_fields, separate_secrets)
|
||||
schema_props = {"accounts": {
|
||||
"type": "array",
|
||||
"items": {"type": "object", "x-secret": True, "properties": {
|
||||
"id": {"type": "string"},
|
||||
"token": {"type": "string", "x-secret": True},
|
||||
}},
|
||||
}}
|
||||
paths = find_secret_fields(schema_props)
|
||||
assert paths == {"accounts[]", "accounts[].token"}
|
||||
full = {"accounts": [{"id": "i1", "token": "s3cret-a"},
|
||||
{"id": "i2", "token": "s3cret-b"}]}
|
||||
_, secrets = separate_secrets(full, paths)
|
||||
assert secrets == full # whole items are secret
|
||||
|
||||
manager = make_manager(tmp_path)
|
||||
stripped = manager._strip_secrets_recursive(full, secrets)
|
||||
assert stripped == {"accounts": [{}, {}]}
|
||||
|
||||
raw = json.dumps(stripped)
|
||||
assert "s3cret" not in raw and "i1" not in raw
|
||||
|
||||
manager._deep_merge(stripped, secrets)
|
||||
assert stripped == full # round trip restores the items
|
||||
|
||||
@@ -338,48 +338,31 @@ class TestDisplayControllerSchedule:
|
||||
"""Test schedule management."""
|
||||
|
||||
def test_schedule_disabled(self, test_display_controller):
|
||||
"""schedule.enabled=False keeps the display active even outside the
|
||||
configured window. (This test used to patch config_service, which
|
||||
_check_schedule never reads — it asserted the init default.)"""
|
||||
"""Test when schedule is disabled."""
|
||||
controller = test_display_controller
|
||||
controller.config['schedule'] = {
|
||||
"enabled": False,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00",
|
||||
}
|
||||
controller._schedule_checked_minute = None
|
||||
controller._tz = None
|
||||
controller.is_display_active = False # prove the method flips it back
|
||||
|
||||
with patch('src.display_controller.datetime') as mock_datetime:
|
||||
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
|
||||
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
|
||||
mock_datetime.strptime = datetime.strptime
|
||||
|
||||
schedule_config = {"schedule": {"enabled": False}}
|
||||
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is True
|
||||
|
||||
def test_active_hours(self, test_display_controller):
|
||||
"""A time inside the window activates the display. (This test used
|
||||
to patch config_service, which _check_schedule never reads — it
|
||||
asserted the init default.)"""
|
||||
"""Test active hours check."""
|
||||
controller = test_display_controller
|
||||
controller.config['schedule'] = {
|
||||
"enabled": True,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00",
|
||||
}
|
||||
controller._schedule_checked_minute = None
|
||||
controller._tz = None
|
||||
controller.is_display_active = False # prove the method flips it on
|
||||
|
||||
with patch('src.display_controller.datetime') as mock_datetime:
|
||||
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
|
||||
mock_datetime.now.return_value.time.return_value = datetime.strptime("12:00", "%H:%M").time()
|
||||
mock_datetime.strptime = datetime.strptime
|
||||
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is True
|
||||
schedule_config = {
|
||||
"schedule": {
|
||||
"enabled": True,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00"
|
||||
}
|
||||
}
|
||||
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is True
|
||||
|
||||
def test_inactive_hours(self, test_display_controller):
|
||||
"""Test inactive hours check."""
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
"""
|
||||
Behavioral tests for DisplayController._check_schedule and
|
||||
_check_dim_schedule — the on/off window and night-dimming logic.
|
||||
|
||||
test_display_controller_optimizations.py::TestScheduleMinuteGate already
|
||||
covers the once-per-minute gating; this file covers what it doesn't:
|
||||
midnight-crossing windows, mode selection (global / per-day / legacy
|
||||
inference), per-day disabled days, invalid time strings, unknown
|
||||
timezones, boundary equality, and the transition-tracking flags.
|
||||
|
||||
Both methods read only self.config and a handful of instance attributes,
|
||||
so a bare stub via object.__new__ (the test_display_controller_vegas_tick
|
||||
pattern) is enough — no managers needed.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
from src.display_controller import DisplayController # noqa: E402
|
||||
|
||||
|
||||
def make_controller(config=None, *, normal_brightness=90):
|
||||
dc = object.__new__(DisplayController)
|
||||
dc.config = config or {}
|
||||
dc._tz = None
|
||||
dc._schedule_checked_minute = None
|
||||
dc.is_display_active = True
|
||||
dc._was_display_active = True
|
||||
dc._normal_brightness = normal_brightness
|
||||
dc._dim_checked_minute = None
|
||||
dc._cached_target_brightness = None
|
||||
dc.is_dimmed = False
|
||||
dc._was_dimmed = False
|
||||
return dc
|
||||
|
||||
|
||||
def at(time_str, day="monday"):
|
||||
"""Context manager patching the controller module's clock."""
|
||||
patcher = patch("src.display_controller.datetime")
|
||||
mock_dt = patcher.start()
|
||||
mock_dt.strptime = datetime.strptime
|
||||
mock_dt.now.return_value.time.return_value = (
|
||||
datetime.strptime(time_str, "%H:%M").time())
|
||||
mock_dt.now.return_value.strftime.return_value.lower.return_value = day
|
||||
mock_dt.now.return_value.hour = int(time_str.split(":")[0])
|
||||
mock_dt.now.return_value.minute = int(time_str.split(":")[1])
|
||||
return patcher
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock():
|
||||
patchers = []
|
||||
|
||||
def _at(time_str, day="monday"):
|
||||
patchers.append(p := at(time_str, day))
|
||||
return p
|
||||
|
||||
yield _at
|
||||
for p in patchers:
|
||||
p.stop()
|
||||
|
||||
|
||||
def check_at(dc, time_str, day="monday", clock=None):
|
||||
"""Run _check_schedule at a mocked wall time, resetting the minute gate."""
|
||||
dc._schedule_checked_minute = None
|
||||
p = at(time_str, day)
|
||||
try:
|
||||
dc._check_schedule()
|
||||
finally:
|
||||
p.stop()
|
||||
return dc.is_display_active
|
||||
|
||||
|
||||
def dim_at(dc, time_str, day="monday"):
|
||||
dc._dim_checked_minute = None
|
||||
p = at(time_str, day)
|
||||
try:
|
||||
return dc._check_dim_schedule()
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
|
||||
class TestScheduleWindows:
|
||||
def _config(self, start, end, **extra):
|
||||
return {"schedule": {"enabled": True, "start_time": start,
|
||||
"end_time": end, **extra},
|
||||
"timezone": "UTC"}
|
||||
|
||||
def test_same_day_window(self):
|
||||
dc = make_controller(self._config("09:00", "17:00"))
|
||||
assert check_at(dc, "12:00") is True
|
||||
assert check_at(dc, "20:00") is False
|
||||
assert check_at(dc, "08:59") is False
|
||||
|
||||
def test_boundaries_are_inclusive(self):
|
||||
dc = make_controller(self._config("09:00", "17:00"))
|
||||
assert check_at(dc, "09:00") is True # now == start
|
||||
assert check_at(dc, "17:00") is True # now == end
|
||||
|
||||
def test_midnight_crossing_window(self):
|
||||
# 21:00 -> 07:00: active late evening AND early morning, inactive
|
||||
# mid-day.
|
||||
dc = make_controller(self._config("21:00", "07:00"))
|
||||
assert check_at(dc, "23:00") is True
|
||||
assert check_at(dc, "03:00") is True
|
||||
assert check_at(dc, "12:00") is False
|
||||
assert check_at(dc, "21:00") is True # boundary
|
||||
assert check_at(dc, "07:00") is True # boundary
|
||||
|
||||
def test_no_schedule_config_is_always_active(self):
|
||||
dc = make_controller({"timezone": "UTC"})
|
||||
dc.is_display_active = False
|
||||
dc._check_schedule()
|
||||
assert dc.is_display_active is True
|
||||
|
||||
def test_invalid_time_string_falls_back_to_active(self):
|
||||
dc = make_controller(self._config("9 o'clock", "17:00"))
|
||||
dc.is_display_active = False
|
||||
assert check_at(dc, "03:00") is True # ValueError -> stay on
|
||||
|
||||
def test_unknown_timezone_falls_back_to_utc(self):
|
||||
dc = make_controller({"schedule": {"enabled": True,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00"},
|
||||
"timezone": "Mars/Olympus_Mons"})
|
||||
assert check_at(dc, "12:00") is True
|
||||
import pytz
|
||||
assert dc._tz is pytz.UTC
|
||||
|
||||
|
||||
class TestScheduleModes:
|
||||
DAYS = {
|
||||
"monday": {"enabled": True, "start_time": "10:00",
|
||||
"end_time": "18:00"},
|
||||
"tuesday": {"enabled": False},
|
||||
}
|
||||
|
||||
def test_global_mode_ignores_days(self):
|
||||
dc = make_controller({"schedule": {
|
||||
"enabled": True, "mode": "global",
|
||||
"start_time": "09:00", "end_time": "17:00",
|
||||
"days": self.DAYS}, "timezone": "UTC"})
|
||||
# 09:30 is inside the global window but outside monday's per-day one.
|
||||
assert check_at(dc, "09:30", day="monday") is True
|
||||
|
||||
def test_per_day_mode_uses_day_window(self):
|
||||
dc = make_controller({"schedule": {
|
||||
"enabled": True, "mode": "per-day",
|
||||
"start_time": "09:00", "end_time": "17:00",
|
||||
"days": self.DAYS}, "timezone": "UTC"})
|
||||
assert check_at(dc, "09:30", day="monday") is False # before 10:00
|
||||
assert check_at(dc, "12:00", day="monday") is True
|
||||
|
||||
def test_per_day_underscore_spelling_accepted(self):
|
||||
dc = make_controller({"schedule": {
|
||||
"enabled": True, "mode": "per_day",
|
||||
"days": self.DAYS}, "timezone": "UTC"})
|
||||
assert check_at(dc, "12:00", day="monday") is True
|
||||
|
||||
def test_legacy_no_mode_infers_per_day_from_days_config(self):
|
||||
dc = make_controller({"schedule": {
|
||||
"enabled": True,
|
||||
"start_time": "09:00", "end_time": "17:00",
|
||||
"days": self.DAYS}, "timezone": "UTC"})
|
||||
assert check_at(dc, "09:30", day="monday") is False # per-day won
|
||||
|
||||
def test_per_day_disabled_day_turns_display_off(self):
|
||||
dc = make_controller({"schedule": {
|
||||
"enabled": True, "mode": "per-day",
|
||||
"days": self.DAYS}, "timezone": "UTC"})
|
||||
assert check_at(dc, "12:00", day="tuesday") is False
|
||||
|
||||
def test_per_day_missing_day_falls_back_to_global(self):
|
||||
dc = make_controller({"schedule": {
|
||||
"enabled": True, "mode": "per-day",
|
||||
"start_time": "09:00", "end_time": "17:00",
|
||||
"days": self.DAYS}, "timezone": "UTC"})
|
||||
# Wednesday has no per-day entry -> global window applies.
|
||||
assert check_at(dc, "09:30", day="wednesday") is True
|
||||
|
||||
def test_missing_enabled_key_means_enabled(self):
|
||||
# Backward compat: schedules written before the enabled flag.
|
||||
dc = make_controller({"schedule": {
|
||||
"start_time": "09:00", "end_time": "17:00"}, "timezone": "UTC"})
|
||||
assert check_at(dc, "20:00") is False
|
||||
|
||||
|
||||
class TestScheduleTransitions:
|
||||
def test_was_display_active_tracks_state(self):
|
||||
dc = make_controller({"schedule": {"enabled": True,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00"},
|
||||
"timezone": "UTC"})
|
||||
check_at(dc, "12:00")
|
||||
assert dc._was_display_active is True
|
||||
check_at(dc, "20:00")
|
||||
assert dc._was_display_active is False
|
||||
check_at(dc, "12:05")
|
||||
assert dc._was_display_active is True
|
||||
|
||||
|
||||
class TestDimSchedule:
|
||||
def _config(self, start="20:00", end="07:00", **extra):
|
||||
return {"dim_schedule": {"enabled": True, "start_time": start,
|
||||
"end_time": end, "dim_brightness": 25,
|
||||
**extra},
|
||||
"timezone": "UTC"}
|
||||
|
||||
def test_disabled_by_default(self):
|
||||
dc = make_controller({"dim_schedule": {"start_time": "20:00",
|
||||
"end_time": "07:00"},
|
||||
"timezone": "UTC"})
|
||||
# Unlike the on/off schedule, dimming defaults to DISABLED when the
|
||||
# enabled key is missing.
|
||||
assert dim_at(dc, "23:00") == 90
|
||||
assert dc.is_dimmed is False
|
||||
|
||||
def test_overnight_dim_window(self):
|
||||
dc = make_controller(self._config())
|
||||
assert dim_at(dc, "23:00") == 25
|
||||
assert dc.is_dimmed is True
|
||||
assert dim_at(dc, "03:00") == 25
|
||||
assert dim_at(dc, "12:00") == 90
|
||||
assert dc.is_dimmed is False
|
||||
|
||||
def test_dim_brightness_defaults_to_30(self):
|
||||
dc = make_controller({"dim_schedule": {"enabled": True,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"},
|
||||
"timezone": "UTC"})
|
||||
assert dim_at(dc, "23:00") == 30
|
||||
|
||||
def test_inactive_display_short_circuits_undimmed(self):
|
||||
dc = make_controller(self._config())
|
||||
dc.is_display_active = False
|
||||
dc.is_dimmed = True
|
||||
assert dim_at(dc, "23:00") == 90
|
||||
assert dc.is_dimmed is False
|
||||
|
||||
def test_per_day_mode(self):
|
||||
dc = make_controller(self._config(mode="per-day", days={
|
||||
"monday": {"enabled": True, "start_time": "22:00",
|
||||
"end_time": "06:00"},
|
||||
"tuesday": {"enabled": False},
|
||||
}))
|
||||
assert dim_at(dc, "23:00", day="monday") == 25
|
||||
assert dim_at(dc, "21:00", day="monday") == 90 # before per-day start
|
||||
assert dim_at(dc, "23:00", day="tuesday") == 90 # day disabled
|
||||
assert dc.is_dimmed is False
|
||||
|
||||
def test_no_legacy_inference_for_dim(self):
|
||||
# Unlike _check_schedule, dim mode defaults to GLOBAL even when a
|
||||
# days config exists — no legacy inference.
|
||||
dc = make_controller(self._config(days={
|
||||
"monday": {"enabled": True, "start_time": "22:00",
|
||||
"end_time": "06:00"},
|
||||
}))
|
||||
# 21:00 is inside the global 20:00-07:00 window but outside monday's
|
||||
# per-day 22:00 start; global mode wins.
|
||||
assert dim_at(dc, "21:00", day="monday") == 25
|
||||
|
||||
def test_invalid_time_string_returns_normal(self):
|
||||
dc = make_controller(self._config(start="late"))
|
||||
assert dim_at(dc, "23:00") == 90
|
||||
|
||||
def test_was_dimmed_tracks_transitions(self):
|
||||
dc = make_controller(self._config())
|
||||
dim_at(dc, "23:00")
|
||||
assert dc._was_dimmed is True
|
||||
dim_at(dc, "12:00")
|
||||
assert dc._was_dimmed is False
|
||||
@@ -1,307 +0,0 @@
|
||||
"""Tests for src/common/display_helper.py (DisplayHelper).
|
||||
|
||||
Pure-PIL tests, no hardware or mocks required. Pixel assertions rely on
|
||||
getbbox()/getpixel() rather than exact text pixel counts, because the
|
||||
default-font metrics vary across Pillow versions.
|
||||
|
||||
These tests pin the FIXED behaviors on this branch:
|
||||
- draw_error_message / draw_no_data_message return a rendered image
|
||||
(they previously crashed with AttributeError),
|
||||
- draw_scorebug_layout draws period/status/clock as one combined top
|
||||
line (previously overprinted at the same y),
|
||||
- draw_ticker_layout draws at x=0 (previously started at
|
||||
x=display_width, i.e. entirely off-canvas -> blank frames).
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from src.common.display_helper import DisplayHelper
|
||||
|
||||
|
||||
def default_font():
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def make_helper(width=128, height=32):
|
||||
return DisplayHelper(width, height)
|
||||
|
||||
|
||||
class TestCreateBaseImage:
|
||||
def test_default_is_black_rgb_display_sized(self):
|
||||
helper = make_helper()
|
||||
img = helper.create_base_image()
|
||||
assert img.size == (128, 32)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.getpixel((0, 0)) == (0, 0, 0)
|
||||
assert img.getpixel((127, 31)) == (0, 0, 0)
|
||||
# Entirely black -> no bounding box in luminance
|
||||
assert img.convert('L').getbbox() is None
|
||||
|
||||
def test_custom_background_color(self):
|
||||
helper = make_helper()
|
||||
img = helper.create_base_image(background_color=(10, 20, 30))
|
||||
assert img.getpixel((0, 0)) == (10, 20, 30)
|
||||
assert img.getpixel((64, 16)) == (10, 20, 30)
|
||||
|
||||
def test_mode_rgba_is_honored(self):
|
||||
helper = make_helper()
|
||||
img = helper.create_base_image(mode='RGBA')
|
||||
assert img.mode == 'RGBA'
|
||||
assert img.size == (128, 32)
|
||||
|
||||
|
||||
class TestCreateOverlay:
|
||||
def test_overlay_is_transparent_rgba(self):
|
||||
helper = make_helper()
|
||||
overlay = helper.create_overlay()
|
||||
assert overlay.mode == 'RGBA'
|
||||
assert overlay.size == (128, 32)
|
||||
assert overlay.getpixel((0, 0)) == (0, 0, 0, 0)
|
||||
assert overlay.getpixel((127, 31)) == (0, 0, 0, 0)
|
||||
|
||||
|
||||
class TestCompositeImages:
|
||||
def test_rgb_inputs_are_upconverted_and_result_is_rgba(self):
|
||||
helper = make_helper()
|
||||
base = Image.new('RGB', (128, 32), (0, 0, 0))
|
||||
overlay = Image.new('RGB', (128, 32), (255, 0, 0))
|
||||
result = helper.composite_images(base, overlay)
|
||||
assert result.mode == 'RGBA'
|
||||
assert result.size == base.size
|
||||
# RGB->RGBA conversion yields a fully opaque overlay
|
||||
assert result.getpixel((0, 0)) == (255, 0, 0, 255)
|
||||
|
||||
def test_transparent_overlay_leaves_base_visible(self):
|
||||
helper = make_helper()
|
||||
base = Image.new('RGB', (128, 32), (5, 6, 7))
|
||||
overlay = helper.create_overlay()
|
||||
result = helper.composite_images(base, overlay)
|
||||
assert result.mode == 'RGBA'
|
||||
assert result.getpixel((64, 16)) == (5, 6, 7, 255)
|
||||
|
||||
|
||||
class TestScorebugLayout:
|
||||
def test_full_game_data_renders(self):
|
||||
helper = make_helper()
|
||||
font = default_font()
|
||||
fonts = {'time': font, 'status': font, 'score': font, 'team': font}
|
||||
game_data = {
|
||||
'home_score': 3, 'away_score': 2,
|
||||
'home_abbr': 'NYY', 'away_abbr': 'BOS',
|
||||
'status_text': 'LIVE', 'period_text': 'T9', 'clock': '2:30',
|
||||
}
|
||||
img = helper.draw_scorebug_layout(game_data, fonts)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.size == (128, 32)
|
||||
assert img.convert('L').getbbox() is not None
|
||||
|
||||
def test_empty_game_data_uses_defaults_without_raising(self):
|
||||
helper = make_helper()
|
||||
font = default_font()
|
||||
fonts = {'time': font, 'status': font, 'score': font, 'team': font}
|
||||
img = helper.draw_scorebug_layout({}, fonts)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.size == (128, 32)
|
||||
# Defaults '0'/'HOME'/'AWAY' actually render something
|
||||
assert img.convert('L').getbbox() is not None
|
||||
|
||||
def test_empty_fonts_dict_falls_back_to_default_font(self):
|
||||
# Pin: fonts={} must not raise — PIL falls back to the default
|
||||
# font when font=None is passed through.
|
||||
helper = make_helper()
|
||||
img = helper.draw_scorebug_layout(
|
||||
{'status_text': 'FINAL', 'period_text': 'Q4', 'clock': '0:00'}, {})
|
||||
assert img.size == (128, 32)
|
||||
assert img.convert('L').getbbox() is not None
|
||||
|
||||
def test_top_line_is_one_combined_centered_draw(self):
|
||||
# FIXED behavior: period/status/clock are joined into a single
|
||||
# top line drawn once at y=1 instead of three overprinted draws.
|
||||
helper = make_helper()
|
||||
calls = []
|
||||
original = helper._draw_centered_text
|
||||
|
||||
def spy(draw, text, font, y_position):
|
||||
calls.append({'text': text, 'y_position': y_position})
|
||||
original(draw, text, font, y_position)
|
||||
|
||||
helper._draw_centered_text = spy
|
||||
font = default_font()
|
||||
fonts = {'time': font, 'status': font, 'score': font, 'team': font}
|
||||
helper.draw_scorebug_layout(
|
||||
{'period_text': 'Q4', 'status_text': 'LIVE', 'clock': '2:30'},
|
||||
fonts)
|
||||
|
||||
top_calls = [c for c in calls if c['y_position'] == 1]
|
||||
assert len(top_calls) == 1
|
||||
text = top_calls[0]['text']
|
||||
assert 'Q4' in text
|
||||
assert 'LIVE' in text
|
||||
assert '2:30' in text
|
||||
|
||||
def test_no_top_line_when_all_parts_empty(self):
|
||||
helper = make_helper()
|
||||
calls = []
|
||||
original = helper._draw_centered_text
|
||||
|
||||
def spy(draw, text, font, y_position):
|
||||
calls.append(y_position)
|
||||
original(draw, text, font, y_position)
|
||||
|
||||
helper._draw_centered_text = spy
|
||||
font = default_font()
|
||||
helper.draw_scorebug_layout({}, {'score': font, 'team': font})
|
||||
assert 1 not in calls # no combined top line drawn
|
||||
|
||||
def test_logo_positions_bleed_off_edges(self):
|
||||
# Home logo pastes at x = width - logo.width + 10 (right edge,
|
||||
# bleeding off-screen right); away at x = -10 (bleeding left).
|
||||
helper = make_helper()
|
||||
home_logo = Image.new('RGBA', (20, 20), (0, 0, 255, 255)) # blue
|
||||
away_logo = Image.new('RGBA', (20, 20), (255, 0, 0, 255)) # red
|
||||
# Empty abbrs/status so text can't land on the probed pixels.
|
||||
game_data = {'home_abbr': '', 'away_abbr': ''}
|
||||
font = default_font()
|
||||
img = helper.draw_scorebug_layout(game_data, {'score': font},
|
||||
home_logo=home_logo,
|
||||
away_logo=away_logo)
|
||||
# center_y = 16; logos span y 6..25 -> probe y=16 at both edges.
|
||||
assert img.getpixel((0, 16)) == (255, 0, 0) # away (left edge)
|
||||
assert img.getpixel((127, 16)) == (0, 0, 255) # home (right edge)
|
||||
# And the off-screen parts are truly clipped: image is still 128 wide
|
||||
assert img.size == (128, 32)
|
||||
|
||||
|
||||
class TestTickerLayout:
|
||||
def test_frame_is_not_blank(self):
|
||||
# FIXED behavior: text now starts at x=0. Previously it was drawn
|
||||
# at x=display_width, entirely off-canvas, so frames were blank.
|
||||
helper = make_helper()
|
||||
img = helper.draw_ticker_layout('HELLO WORLD', default_font())
|
||||
assert img.size == (128, 32)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.convert('L').getbbox() is not None
|
||||
|
||||
def test_text_starts_at_left_edge(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_ticker_layout('HELLO', default_font())
|
||||
bbox = img.convert('L').getbbox()
|
||||
assert bbox is not None
|
||||
# Text is positioned at x=0 (outline extends 1px left, clipped),
|
||||
# so ink begins hugging the left edge. Allow a couple of pixels of
|
||||
# slack for font-dependent left-side bearing.
|
||||
assert bbox[0] <= 2
|
||||
|
||||
def test_scroll_speed_does_not_affect_frame(self):
|
||||
# Pin: scroll_speed is accepted for API compatibility only.
|
||||
helper = make_helper()
|
||||
font = default_font()
|
||||
img1 = helper.draw_ticker_layout('SCROLLING', font, scroll_speed=1)
|
||||
img5 = helper.draw_ticker_layout('SCROLLING', font, scroll_speed=5)
|
||||
assert img1.tobytes() == img5.tobytes()
|
||||
|
||||
def test_custom_colors(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_ticker_layout('X', default_font(),
|
||||
background_color=(0, 0, 40),
|
||||
text_color=(0, 255, 0))
|
||||
assert img.getpixel((127, 0)) == (0, 0, 40) # background corner
|
||||
colors = {img.getpixel((x, y))
|
||||
for x in range(img.width) for y in range(img.height)}
|
||||
# Text color appears somewhere (anti-aliasing may blend it, so
|
||||
# check for a green-dominant pixel rather than the exact color).
|
||||
assert any(g > 150 and r < 100 for (r, g, b) in colors)
|
||||
|
||||
|
||||
class TestCenteredText:
|
||||
def test_renders_centered_text_on_background(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_centered_text('HI', default_font(),
|
||||
background_color=(0, 0, 60),
|
||||
text_color=(255, 255, 0))
|
||||
assert img.size == (128, 32)
|
||||
assert img.convert('L').getbbox() is not None
|
||||
# Corners stay pure background
|
||||
assert img.getpixel((0, 0)) == (0, 0, 60)
|
||||
assert img.getpixel((127, 0)) == (0, 0, 60)
|
||||
assert img.getpixel((0, 31)) == (0, 0, 60)
|
||||
assert img.getpixel((127, 31)) == (0, 0, 60)
|
||||
|
||||
|
||||
class TestErrorAndNoDataMessages:
|
||||
def test_draw_error_message_returns_rendered_image(self):
|
||||
# FIXED behavior: used to crash with AttributeError; now returns
|
||||
# a rendered image on a dark red background.
|
||||
helper = make_helper()
|
||||
img = helper.draw_error_message('Boom')
|
||||
assert img.size == (128, 32)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.convert('L').getbbox() is not None
|
||||
assert img.getpixel((0, 0)) == (50, 0, 0) # dark red background
|
||||
|
||||
def test_draw_error_message_default_text(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_error_message()
|
||||
assert img.size == (128, 32)
|
||||
assert img.getpixel((127, 31)) == (50, 0, 0)
|
||||
|
||||
def test_draw_no_data_message_returns_rendered_image(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_no_data_message()
|
||||
assert img.size == (128, 32)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.convert('L').getbbox() is not None
|
||||
assert img.getpixel((0, 0)) == (0, 0, 0) # black background
|
||||
|
||||
|
||||
class TestDrawTextWithOutline:
|
||||
def test_fill_color_appears_in_output(self):
|
||||
helper = make_helper()
|
||||
img = Image.new('RGB', (40, 20), (0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
helper._draw_text_with_outline(draw, 'X', (5, 2), default_font(),
|
||||
fill=(255, 0, 0))
|
||||
pixels = {img.getpixel((x, y))
|
||||
for x in range(img.width) for y in range(img.height)}
|
||||
# Anti-aliased fonts blend edge pixels, so look for red-dominant
|
||||
# (fill) and near-black (outline) pixels rather than exact colors.
|
||||
assert any(r > 150 and g < 50 for (r, g, b) in pixels) # fill
|
||||
assert any(max(p) < 80 for p in pixels) # outline
|
||||
|
||||
def test_default_fill_is_white(self):
|
||||
helper = make_helper()
|
||||
img = Image.new('RGB', (40, 20), (0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
helper._draw_text_with_outline(draw, 'X', (5, 2), default_font())
|
||||
pixels = {img.getpixel((x, y))
|
||||
for x in range(img.width) for y in range(img.height)}
|
||||
# White-dominant pixel present (exact white may be anti-aliased)
|
||||
assert any(r > 200 and g > 200 for (r, g, b) in pixels)
|
||||
|
||||
|
||||
class TestOrientationAndDimensions:
|
||||
def test_landscape_display(self):
|
||||
helper = DisplayHelper(128, 32)
|
||||
assert helper.is_landscape() is True
|
||||
assert helper.is_portrait() is False
|
||||
|
||||
def test_portrait_display(self):
|
||||
helper = DisplayHelper(32, 128)
|
||||
assert helper.is_portrait() is True
|
||||
assert helper.is_landscape() is False
|
||||
|
||||
def test_square_display_is_neither(self):
|
||||
# Pin: a square display is neither portrait nor landscape.
|
||||
helper = DisplayHelper(64, 64)
|
||||
assert helper.is_portrait() is False
|
||||
assert helper.is_landscape() is False
|
||||
|
||||
def test_get_center_position(self):
|
||||
assert DisplayHelper(128, 32).get_center_position() == (64, 16)
|
||||
|
||||
def test_get_center_position_floors_odd_dimensions(self):
|
||||
assert DisplayHelper(65, 33).get_center_position() == (32, 16)
|
||||
|
||||
def test_get_display_dimensions(self):
|
||||
assert DisplayHelper(128, 32).get_display_dimensions() == (128, 32)
|
||||
assert DisplayHelper(64, 64).get_display_dimensions() == (64, 64)
|
||||
@@ -237,45 +237,3 @@ class TestDisplayManagerDoubleSided:
|
||||
suppress_test_pattern=True)
|
||||
assert dm.set_brightness(70) is True
|
||||
assert mock_rgb_matrix['matrix_instance'].brightness == 70
|
||||
|
||||
|
||||
class TestDisplayManagerOrientation:
|
||||
"""The orientation setting composes onto pixel_mapper_config for panels
|
||||
mounted upside down, without disturbing a custom pixel_mapper_config."""
|
||||
|
||||
def _config(self, **hardware_overrides):
|
||||
config = {
|
||||
'display': {
|
||||
'hardware': {
|
||||
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
|
||||
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90,
|
||||
},
|
||||
'runtime': {'gpio_slowdown': 2},
|
||||
},
|
||||
'timezone': 'UTC',
|
||||
'plugin_system': {'plugins_directory': 'plugins'},
|
||||
}
|
||||
config['display']['hardware'].update(hardware_overrides)
|
||||
return config
|
||||
|
||||
def test_default_orientation_leaves_pixel_mapper_config_untouched(self, mock_rgb_matrix):
|
||||
DisplayManager._instance = None
|
||||
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||
DisplayManager(self._config(), suppress_test_pattern=True)
|
||||
options = mock_rgb_matrix['options_class'].return_value
|
||||
assert options.pixel_mapper_config == ''
|
||||
|
||||
def test_orientation_180_appends_rotate_mapper(self, mock_rgb_matrix):
|
||||
DisplayManager._instance = None
|
||||
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||
DisplayManager(self._config(orientation='180'), suppress_test_pattern=True)
|
||||
options = mock_rgb_matrix['options_class'].return_value
|
||||
assert options.pixel_mapper_config == 'Rotate:180'
|
||||
|
||||
def test_orientation_180_composes_with_existing_pixel_mapper_config(self, mock_rgb_matrix):
|
||||
DisplayManager._instance = None
|
||||
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||
DisplayManager(self._config(orientation='180', pixel_mapper_config='U-mapper'),
|
||||
suppress_test_pattern=True)
|
||||
options = mock_rgb_matrix['options_class'].return_value
|
||||
assert options.pixel_mapper_config == 'U-mapper;Rotate:180'
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
"""
|
||||
Tests for src/dynamic_team_resolver.py (DynamicTeamResolver).
|
||||
|
||||
Covers dynamic team expansion (AP_TOP_5/10/25), order-preserving dedup,
|
||||
unknown dynamic-name dropping, rankings parsing, the fixed genuinely
|
||||
class-shared rankings cache (fetch and clear_cache write through
|
||||
DynamicTeamResolver._rankings_cache / _cache_timestamp), TTL expiry,
|
||||
network-failure resilience, and the resolve_dynamic_teams module function.
|
||||
|
||||
No real network: src.dynamic_team_resolver.requests.get is always patched.
|
||||
"""
|
||||
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
import src.dynamic_team_resolver as dtr_module
|
||||
from src.dynamic_team_resolver import DynamicTeamResolver, resolve_dynamic_teams
|
||||
|
||||
|
||||
TOP_TEAMS = ['UGA', 'MICH', 'OSU', 'TEX', 'ALA', 'ORE', 'PSU', 'ND', 'FSU', 'OU']
|
||||
|
||||
|
||||
def _rankings_payload(teams=None):
|
||||
teams = TOP_TEAMS if teams is None else teams
|
||||
return {
|
||||
'rankings': [{
|
||||
'name': 'AP Top 25',
|
||||
'ranks': [
|
||||
{'current': i + 1, 'team': {'abbreviation': abbr}}
|
||||
for i, abbr in enumerate(teams)
|
||||
],
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
def _make_response(payload):
|
||||
response = MagicMock()
|
||||
response.json.return_value = payload
|
||||
response.raise_for_status.return_value = None
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_class_cache():
|
||||
"""Reset the CLASS-level shared cache between tests."""
|
||||
DynamicTeamResolver._rankings_cache = {}
|
||||
DynamicTeamResolver._cache_timestamp = 0
|
||||
yield
|
||||
DynamicTeamResolver._rankings_cache = {}
|
||||
DynamicTeamResolver._cache_timestamp = 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get():
|
||||
with patch('src.dynamic_team_resolver.requests.get') as m:
|
||||
m.return_value = _make_response(_rankings_payload())
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resolver():
|
||||
return DynamicTeamResolver()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_teams basics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveTeamsBasics:
|
||||
def test_empty_list_returns_empty_no_http(self, resolver, mock_get):
|
||||
assert resolver.resolve_teams([]) == []
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_no_dynamic_names_passthrough_no_http(self, resolver, mock_get):
|
||||
assert resolver.resolve_teams(['UGA', 'AUB', 'LSU']) == [
|
||||
'UGA', 'AUB', 'LSU']
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_expansion_inserted_in_place_order_preserved(
|
||||
self, resolver, mock_get):
|
||||
result = resolver.resolve_teams(['UGA', 'AP_TOP_5', 'AUB'])
|
||||
|
||||
# UGA is also ranked #1, so dedup keeps its first occurrence; the
|
||||
# top-5 expansion lands where AP_TOP_5 appeared, AUB stays after.
|
||||
assert result == ['UGA', 'MICH', 'OSU', 'TEX', 'ALA', 'AUB']
|
||||
|
||||
def test_order_preserving_dedup(self, resolver, mock_get):
|
||||
result = resolver.resolve_teams(['UGA', 'AP_TOP_5', 'UGA'])
|
||||
|
||||
assert result == ['UGA', 'MICH', 'OSU', 'TEX', 'ALA']
|
||||
assert result.count('UGA') == 1
|
||||
assert result[0] == 'UGA'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AP_TOP_N slicing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSlicing:
|
||||
def test_top_n_counts_and_order(self, resolver, mock_get):
|
||||
teams_25 = [f'T{i:02d}' for i in range(1, 26)]
|
||||
mock_get.return_value = _make_response(_rankings_payload(teams_25))
|
||||
|
||||
top5 = resolver.resolve_teams(['AP_TOP_5'])
|
||||
top10 = resolver.resolve_teams(['AP_TOP_10'])
|
||||
top25 = resolver.resolve_teams(['AP_TOP_25'])
|
||||
|
||||
assert top5 == teams_25[:5]
|
||||
assert top10 == teams_25[:10]
|
||||
assert top25 == teams_25
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unknown dynamic-looking names
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUnknownDynamicNames:
|
||||
def test_unknown_dynamic_looking_names_dropped(self, resolver, mock_get):
|
||||
result = resolver.resolve_teams(
|
||||
['AP_TOP_100', 'TOP_10', 'RANKED_ALL', 'PLAYOFF_TEAMS'])
|
||||
|
||||
assert result == []
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_top_substring_hazard(self, resolver, mock_get):
|
||||
# Hazard pin: _is_potential_dynamic_team matches the substring
|
||||
# 'TOP_' anywhere in the (upper-cased) name, so a team literally
|
||||
# named 'TOP_GUN' is dropped as an unknown dynamic team too.
|
||||
assert resolver.resolve_teams(['TOP_GUN']) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rankings parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRankingsParsing:
|
||||
def test_drops_zero_rank_and_empty_abbreviation_sorts_ascending(
|
||||
self, resolver, mock_get):
|
||||
payload = {
|
||||
'rankings': [{
|
||||
'name': 'AP Top 25',
|
||||
'ranks': [
|
||||
{'current': 3, 'team': {'abbreviation': 'C3'}},
|
||||
{'current': 1, 'team': {'abbreviation': 'A1'}},
|
||||
{'current': 0, 'team': {'abbreviation': 'ZERO'}},
|
||||
{'current': 4, 'team': {'abbreviation': ''}},
|
||||
{'current': 2, 'team': {'abbreviation': 'B2'}},
|
||||
],
|
||||
}]
|
||||
}
|
||||
mock_get.return_value = _make_response(payload)
|
||||
|
||||
rankings = resolver._fetch_ncaa_fb_rankings()
|
||||
|
||||
assert list(rankings.keys()) == ['A1', 'B2', 'C3']
|
||||
assert list(rankings.values()) == [1, 2, 3]
|
||||
|
||||
def test_empty_rankings_returns_empty_and_caches_nothing(
|
||||
self, resolver, mock_get):
|
||||
mock_get.return_value = _make_response({'rankings': []})
|
||||
|
||||
assert resolver._fetch_ncaa_fb_rankings() == {}
|
||||
# Nothing was cached, so the next call hits HTTP again.
|
||||
assert resolver._fetch_ncaa_fb_rankings() == {}
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared class cache (fixed behavior)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSharedCache:
|
||||
def test_cache_shared_across_instances(self, mock_get):
|
||||
resolver1 = DynamicTeamResolver()
|
||||
resolver1.resolve_teams(['AP_TOP_5'])
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
resolver2 = DynamicTeamResolver()
|
||||
result = resolver2.resolve_teams(['AP_TOP_5'])
|
||||
|
||||
# Post-fix: the class-level cache serves the second instance with
|
||||
# ZERO additional HTTP calls.
|
||||
assert result == TOP_TEAMS[:5]
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
def test_ttl_expiry_refetches(self, resolver, mock_get, monkeypatch):
|
||||
resolver.resolve_teams(['AP_TOP_5'])
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
stamp = DynamicTeamResolver._cache_timestamp
|
||||
monkeypatch.setattr(
|
||||
dtr_module, 'time', types.SimpleNamespace(time=lambda: stamp + 3601))
|
||||
|
||||
resolver.resolve_teams(['AP_TOP_5'])
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
def test_clear_cache_through_one_instance_affects_all(self, mock_get):
|
||||
resolver1 = DynamicTeamResolver()
|
||||
resolver1.resolve_teams(['AP_TOP_5'])
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
resolver2 = DynamicTeamResolver()
|
||||
resolver2.clear_cache()
|
||||
|
||||
# Post-fix: clear_cache writes through the class, so resolver1
|
||||
# must refetch even though resolver2 did the clearing.
|
||||
resolver1.resolve_teams(['AP_TOP_5'])
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
def test_module_function_benefits_from_class_cache(self, mock_get):
|
||||
# resolve_dynamic_teams constructs a fresh resolver per call, but
|
||||
# the class-shared cache means only the first call hits HTTP.
|
||||
first = resolve_dynamic_teams(['AP_TOP_5'])
|
||||
second = resolve_dynamic_teams(['AP_TOP_5'])
|
||||
|
||||
assert first == second == TOP_TEAMS[:5]
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failure handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFailureHandling:
|
||||
def test_network_failure_drops_dynamic_keeps_static_caches_nothing(
|
||||
self, resolver, mock_get):
|
||||
mock_get.side_effect = [
|
||||
requests.exceptions.RequestException('boom'),
|
||||
_make_response(_rankings_payload()),
|
||||
]
|
||||
|
||||
result = resolver.resolve_teams(['UGA', 'AP_TOP_5'])
|
||||
|
||||
# Dynamic name silently dropped, static name kept, nothing raises.
|
||||
assert result == ['UGA']
|
||||
|
||||
# Nothing was cached on failure: a subsequent call refetches and
|
||||
# succeeds.
|
||||
result = resolver.resolve_teams(['UGA', 'AP_TOP_5'])
|
||||
assert result == ['UGA', 'MICH', 'OSU', 'TEX', 'ALA']
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sport argument
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSportArgument:
|
||||
def test_sport_arg_ignored_for_expansion(self, resolver, mock_get):
|
||||
# Pin: the sport argument is effectively ignored — each pattern
|
||||
# carries its own sport ('ncaa_fb'), so passing sport='nfl' still
|
||||
# expands from the college-football rankings.
|
||||
result = resolver.resolve_teams(['AP_TOP_5'], sport='nfl')
|
||||
|
||||
assert result == TOP_TEAMS[:5]
|
||||
assert mock_get.call_count == 1
|
||||
@@ -1,134 +0,0 @@
|
||||
"""Guard: enum dropdowns in the plugin config form honour x-options.labels.
|
||||
|
||||
The form derives an option's visible text from its value — underscores
|
||||
replaced, title case applied ("day_first" -> "Day First"). That cannot
|
||||
express every label a schema needs: "vs" reads as "Vs", and "abbrev" says
|
||||
nothing about the "Sep 19" it produces. Schemas can supply x-options.labels
|
||||
instead, the same convention the checkbox-group widget already uses.
|
||||
|
||||
These tests extract the enum <select> block *out of the shipped template*
|
||||
and render that, so they exercise the production expression rather than a
|
||||
copy of it. If the fallback or the lookup changes, these tests render the
|
||||
changed code and fail — a duplicated fragment here would silently keep
|
||||
passing.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from jinja2 import DictLoader, Environment
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CONFIG_FORM = (PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'partials'
|
||||
/ 'plugin_config.html')
|
||||
ARRAY_TABLE_JS = (PROJECT_ROOT / 'web_interface' / 'static' / 'v3' / 'js'
|
||||
/ 'widgets' / 'array-table.js')
|
||||
|
||||
# The enum branch: from the `{% set enum_labels %}` line through `</select>`.
|
||||
ENUM_BLOCK_RE = re.compile(
|
||||
r"(\{%\s*set enum_labels\s*=.*?</select>)", re.S
|
||||
)
|
||||
|
||||
|
||||
def _shipped_enum_block() -> str:
|
||||
"""Return the live enum <select> block lifted from plugin_config.html."""
|
||||
source = CONFIG_FORM.read_text(encoding='utf-8')
|
||||
match = ENUM_BLOCK_RE.search(source)
|
||||
assert match, (
|
||||
'could not find the enum <select> block in plugin_config.html — the '
|
||||
'template changed shape and this guard needs updating'
|
||||
)
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _render(prop: dict, value=None) -> str:
|
||||
"""Render the shipped enum block with a minimal fixture."""
|
||||
env = Environment(loader=DictLoader({'f': _shipped_enum_block()}),
|
||||
autoescape=True)
|
||||
return env.get_template('f').render(
|
||||
prop=prop, value=value, field_id='fid', full_key='k'
|
||||
)
|
||||
|
||||
|
||||
def _option_labels(html: str) -> dict:
|
||||
"""Map each rendered option's value to its visible text."""
|
||||
return {
|
||||
value: text.strip()
|
||||
for value, text in re.findall(
|
||||
r'<option value="([^"]*)"[^>]*>(.*?)</option>', html, re.S
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def test_labels_are_used_when_supplied() -> None:
|
||||
html = _render({
|
||||
'enum': ['vs', 'date_time'],
|
||||
'x-options': {'labels': {'vs': 'VS', 'date_time': 'Date and time'}},
|
||||
})
|
||||
assert _option_labels(html) == {'vs': 'VS', 'date_time': 'Date and time'}
|
||||
|
||||
|
||||
def test_unlabelled_values_keep_the_humanised_fallback() -> None:
|
||||
"""Schemas without labels must render exactly as they did before."""
|
||||
html = _render({'enum': ['day_first', 'weekday']})
|
||||
assert _option_labels(html) == {'day_first': 'Day First', 'weekday': 'Weekday'}
|
||||
|
||||
|
||||
def test_partial_labels_fall_back_per_value() -> None:
|
||||
"""A labels map covering some values leaves the rest humanised."""
|
||||
html = _render({'enum': ['vs', 'day_first'],
|
||||
'x-options': {'labels': {'vs': 'VS'}}})
|
||||
assert _option_labels(html) == {'vs': 'VS', 'day_first': 'Day First'}
|
||||
|
||||
|
||||
def test_option_values_are_unchanged_by_labelling() -> None:
|
||||
"""Labels are display-only: the submitted value stays the enum value."""
|
||||
html = _render({'enum': ['abbrev'],
|
||||
'x-options': {'labels': {'abbrev': 'Sep 19'}}})
|
||||
assert _option_labels(html) == {'abbrev': 'Sep 19'}
|
||||
|
||||
|
||||
def test_selected_option_still_tracks_the_current_value() -> None:
|
||||
"""Labelling must not disturb which option is marked selected."""
|
||||
html = _render({'enum': ['abbrev', 'numeric'],
|
||||
'x-options': {'labels': {'abbrev': 'Sep 19'}}},
|
||||
value='numeric')
|
||||
selected = re.search(r'<option value="([^"]+)"[^>]*selected', html)
|
||||
assert selected and selected.group(1) == 'numeric'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', ['x-options', 'x_options'])
|
||||
def test_both_option_key_spellings_work(key: str) -> None:
|
||||
"""The template accepts either spelling, as its other widgets do."""
|
||||
html = _render({'enum': ['vs'], key: {'labels': {'vs': 'VS'}}})
|
||||
assert _option_labels(html) == {'vs': 'VS'}
|
||||
|
||||
|
||||
def test_table_column_enum_falls_back_to_the_raw_value() -> None:
|
||||
"""Array-table columns must not title-case values that were never labelled.
|
||||
|
||||
Those columns hold values such as ticker symbols, where "aapl" -> "Aapl"
|
||||
would be wrong, so their fallback stays the raw value.
|
||||
"""
|
||||
source = CONFIG_FORM.read_text(encoding='utf-8')
|
||||
assert 'col_labels.get(opt, opt)' in source, (
|
||||
'array-table column options must fall back to the raw value, not the '
|
||||
'humanised one'
|
||||
)
|
||||
|
||||
|
||||
def test_dynamically_added_table_rows_use_the_same_labels() -> None:
|
||||
"""Rows added client-side must label options like the server-rendered ones.
|
||||
|
||||
array-table.js builds new rows in the browser; if it printed the raw value
|
||||
a column would read differently before and after a page reload.
|
||||
"""
|
||||
js = ARRAY_TABLE_JS.read_text(encoding='utf-8')
|
||||
assert 'function enumOptionLabel' in js, (
|
||||
'array-table.js lost its enum label helper'
|
||||
)
|
||||
raw_option_text = re.findall(r'o\.textContent\s*=\s*opt\s*;', js)
|
||||
assert not raw_option_text, (
|
||||
'array-table.js renders an enum option as its raw value; it must go '
|
||||
'through enumOptionLabel() so dynamic rows match server-rendered ones'
|
||||
)
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Tests for the harness empty-frame check (src/plugin_system/testing/harness.py).
|
||||
|
||||
The display controller skips a mode whose display() returns False and treats
|
||||
anything else -- including None -- as "content was shown". A mode that draws
|
||||
nothing without returning False is therefore never skipped, and since a mode
|
||||
switch clears the panel first, it sits on a blank screen for its whole display
|
||||
duration.
|
||||
|
||||
Two sports plugins shipped exactly that: their display() returned None on every
|
||||
path, so an out-of-season league held a blank panel instead of being rotated
|
||||
past. The harness rendered those modes and passed them, because it discarded
|
||||
the return value entirely.
|
||||
"""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from src.plugin_system.testing.harness import RenderResult, check_empty_claimed
|
||||
|
||||
|
||||
def _blank(w=64, h=32):
|
||||
return Image.new("RGB", (w, h), (0, 0, 0))
|
||||
|
||||
|
||||
def _drawn(w=64, h=32):
|
||||
img = _blank(w, h)
|
||||
img.paste(Image.new("RGB", (10, 10), (255, 255, 255)), (5, 5))
|
||||
return img
|
||||
|
||||
|
||||
def _result(image, returned=None, **kw):
|
||||
return RenderResult("p", 64, 32, "mode", image=image,
|
||||
display_returned=returned, **kw)
|
||||
|
||||
|
||||
class TestCheckEmptyClaimed:
|
||||
def test_blank_frame_returning_none_is_flagged(self):
|
||||
# The shape that shipped: nothing drawn, nothing reported.
|
||||
r = _result(_blank(), returned=None)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is True
|
||||
|
||||
def test_blank_frame_returning_true_is_flagged(self):
|
||||
# Just as broken, and more explicit about it.
|
||||
r = _result(_blank(), returned=True)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is True
|
||||
|
||||
def test_blank_frame_returning_false_is_fine(self):
|
||||
# The plugin correctly said "no content"; the controller will skip it.
|
||||
r = _result(_blank(), returned=False)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is None
|
||||
|
||||
def test_a_drawn_frame_is_fine_whatever_it_returns(self):
|
||||
for returned in (None, True, False):
|
||||
r = _result(_drawn(), returned=returned)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is None, returned
|
||||
|
||||
def test_near_black_still_counts_as_drawn(self):
|
||||
# Guard the threshold: content dim enough to look black to the eye is
|
||||
# still content, and flagging it would train people to ignore this.
|
||||
img = _blank()
|
||||
img.paste(Image.new("RGB", (4, 4), (60, 60, 60)), (2, 2))
|
||||
r = _result(img, returned=None)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is None
|
||||
|
||||
|
||||
class TestWarnVersusStrict:
|
||||
def test_warn_only_by_default(self):
|
||||
# A scroll mode's first frame is legitimately its blank scroll-in
|
||||
# buffer, so this must not fail a run unless opted in.
|
||||
r = _result(_blank(), returned=None)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_ok is None
|
||||
assert r.ok is True
|
||||
|
||||
def test_strict_fails_the_result(self):
|
||||
r = _result(_blank(), returned=None)
|
||||
check_empty_claimed([r], strict=True)
|
||||
assert r.empty_ok is False
|
||||
assert r.ok is False
|
||||
|
||||
def test_strict_still_allows_an_honest_false(self):
|
||||
r = _result(_blank(), returned=False)
|
||||
check_empty_claimed([r], strict=True)
|
||||
assert r.empty_ok is None
|
||||
assert r.ok is True
|
||||
|
||||
|
||||
class TestSkippedResults:
|
||||
def test_a_crashed_render_is_left_alone(self):
|
||||
# error already fails the result; adding a second reason just muddies
|
||||
# the report.
|
||||
r = _result(None, returned=None, error="boom")
|
||||
check_empty_claimed([r], strict=True)
|
||||
assert r.empty_claimed is None
|
||||
|
||||
def test_a_result_with_no_image_is_left_alone(self):
|
||||
r = _result(None, returned=None)
|
||||
check_empty_claimed([r], strict=True)
|
||||
assert r.empty_claimed is None
|
||||
@@ -1,224 +0,0 @@
|
||||
"""Tests that startup does not wait indefinitely for plugins to fetch data.
|
||||
|
||||
DisplayController.__init__ calls _update_modules() once, to populate plugin
|
||||
data before the first frame. It walks every loaded plugin in turn, and each
|
||||
update blocks the calling thread for up to the executor's 30s timeout, so the
|
||||
uncapped total is the sum of every slow plugin on the system.
|
||||
|
||||
Profiled on a live rig with py-spy, the main thread sat 9.34s in
|
||||
|
||||
display_controller._update_modules
|
||||
-> plugin_executor.execute_update
|
||||
-> execute_with_timeout -> threading.join
|
||||
|
||||
and the controller's own log put the full pass at 82 seconds on the worst
|
||||
boot measured (55 and 26 on the two before). The panel shows nothing for all
|
||||
of it.
|
||||
|
||||
Nothing is lost by stopping early: a plugin that has never updated is
|
||||
immediately due, so run_scheduled_updates() collects it seconds later with the
|
||||
display already running.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
# display_controller imports display_manager, which binds the hardware
|
||||
# rgbmatrix module unless EMULATOR=true is set before import (same convention
|
||||
# as test_display_controller_vegas_tick.py).
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
from src.display_controller import ( # noqa: E402
|
||||
DisplayController, _INITIAL_UPDATE_BUDGET_SECONDS,
|
||||
_MIN_INITIAL_UPDATE_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
class FakeExecutor:
|
||||
"""Records which plugins were updated, and can make some of them slow."""
|
||||
|
||||
def __init__(self, cost=0.0, slow=()):
|
||||
self.updated = []
|
||||
self.cost = cost
|
||||
self.slow = set(slow)
|
||||
|
||||
def execute_update(self, plugin, plugin_id, timeout=None):
|
||||
self.updated.append(plugin_id)
|
||||
if plugin_id in self.slow:
|
||||
time.sleep(self.cost)
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tiny_floor(monkeypatch):
|
||||
"""Shrink the "worth starting" floor so timing tests stay quick."""
|
||||
import src.display_controller as mod
|
||||
monkeypatch.setattr(mod, "_MIN_INITIAL_UPDATE_TIMEOUT_SECONDS", 0.01)
|
||||
|
||||
|
||||
def _controller(plugin_ids, executor):
|
||||
c = DisplayController.__new__(DisplayController)
|
||||
c.plugin_manager = Mock()
|
||||
# Both attributes, because _update_modules reads
|
||||
# `loaded_plugins or plugins` and an empty dict is falsy.
|
||||
c.plugin_manager.loaded_plugins = {pid: Mock() for pid in plugin_ids}
|
||||
c.plugin_manager.plugins = dict(c.plugin_manager.loaded_plugins)
|
||||
c.plugin_manager.plugin_executor = executor
|
||||
c.plugin_manager.plugin_last_update = {}
|
||||
c.plugin_manager.health_tracker = None
|
||||
return c
|
||||
|
||||
|
||||
class TestTheBudgetIsRespected:
|
||||
def test_without_a_deadline_every_plugin_is_updated(self):
|
||||
ex = FakeExecutor()
|
||||
_controller(['a', 'b', 'c'], ex)._update_modules()
|
||||
assert ex.updated == ['a', 'b', 'c']
|
||||
|
||||
def test_a_passed_deadline_stops_the_pass(self):
|
||||
ex = FakeExecutor()
|
||||
_controller(['a', 'b', 'c'], ex)._update_modules(deadline=time.time() - 1)
|
||||
assert ex.updated == [], "updated %r after the deadline" % ex.updated
|
||||
|
||||
def test_slow_plugins_do_not_drag_in_the_rest(self, tiny_floor):
|
||||
# One plugin burns the whole budget; the remainder must be left alone
|
||||
# rather than each adding its own wait.
|
||||
ex = FakeExecutor(cost=0.3, slow={'slow'})
|
||||
c = _controller(['slow'] + ['p%d' % i for i in range(20)], ex)
|
||||
started = time.time()
|
||||
c._update_modules(deadline=started + 0.2)
|
||||
elapsed = time.time() - started
|
||||
|
||||
assert ex.updated == ['slow'], "updated %r" % ex.updated
|
||||
# Bounded by the one in-flight update, not by twenty more.
|
||||
assert elapsed < 1.0, "%.2fs" % elapsed
|
||||
|
||||
def test_a_generous_deadline_still_gets_everything(self):
|
||||
ex = FakeExecutor()
|
||||
c = _controller(['a', 'b', 'c'], ex)
|
||||
c._update_modules(deadline=time.time() + 30)
|
||||
assert ex.updated == ['a', 'b', 'c']
|
||||
|
||||
def test_the_deadline_is_checked_before_each_plugin(self, tiny_floor):
|
||||
# Not just once up front: the budget can be spent partway through.
|
||||
ex = FakeExecutor(cost=0.15, slow={'a', 'b', 'c', 'd'})
|
||||
c = _controller(['a', 'b', 'c', 'd'], ex)
|
||||
c._update_modules(deadline=time.time() + 0.2)
|
||||
assert 0 < len(ex.updated) < 4, "updated %r" % ex.updated
|
||||
|
||||
|
||||
class TestThePassIsBoundedInPractice:
|
||||
def test_the_last_plugin_cannot_overrun_the_budget(self):
|
||||
# Checking the deadline before each plugin is not enough on its own:
|
||||
# one that starts with a moment left could still block for the
|
||||
# executor's full timeout. On the rig that turned a 20s budget into a
|
||||
# 31.8s pass, so the remaining budget is passed down as the timeout.
|
||||
seen = []
|
||||
|
||||
class Executor:
|
||||
def execute_update(self, plugin, plugin_id, timeout=None):
|
||||
seen.append(timeout)
|
||||
return True
|
||||
|
||||
c = _controller(['a', 'b', 'c'], Executor())
|
||||
deadline = time.time() + 5
|
||||
c._update_modules(deadline=deadline)
|
||||
|
||||
assert seen and all(t is not None for t in seen), seen
|
||||
assert all(t <= 5.01 for t in seen), seen
|
||||
# The exact remainder, never clamped up: clamping would let the pass
|
||||
# run past its deadline. Anything below the floor is deferred instead,
|
||||
# so what does start always has a usable slot.
|
||||
assert all(t >= _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS for t in seen), seen
|
||||
|
||||
def test_without_a_deadline_the_executor_default_is_left_alone(self):
|
||||
seen = []
|
||||
|
||||
class Executor:
|
||||
def execute_update(self, plugin, plugin_id, timeout=None):
|
||||
seen.append(timeout)
|
||||
return True
|
||||
|
||||
_controller(['a'], Executor())._update_modules()
|
||||
assert seen == [None], seen
|
||||
|
||||
|
||||
class TestTheBudgetItself:
|
||||
def test_it_is_short_enough_to_be_worth_having(self):
|
||||
# The measured uncapped worst case was 82s; a budget near that would
|
||||
# not bound anything.
|
||||
assert _INITIAL_UPDATE_BUDGET_SECONDS <= 30
|
||||
|
||||
def test_it_is_long_enough_for_a_quick_plugin_or_two(self):
|
||||
assert _INITIAL_UPDATE_BUDGET_SECONDS >= 5
|
||||
|
||||
|
||||
class TestNothingIsSilentlyDropped:
|
||||
def test_deferred_plugins_are_named_in_the_log(self, caplog):
|
||||
ex = FakeExecutor()
|
||||
c = _controller(['a', 'b'], ex)
|
||||
with caplog.at_level('INFO'):
|
||||
c._update_modules(deadline=time.time() - 1)
|
||||
text = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert 'a' in text and 'b' in text, text
|
||||
assert 'budget' in text.lower(), text
|
||||
|
||||
def test_nothing_is_logged_when_all_of_them_ran(self, caplog):
|
||||
ex = FakeExecutor()
|
||||
c = _controller(['a'], ex)
|
||||
with caplog.at_level('INFO'):
|
||||
c._update_modules(deadline=time.time() + 30)
|
||||
assert not any('budget' in r.getMessage().lower() for r in caplog.records)
|
||||
|
||||
|
||||
class TestItDoesNotBreakTheOrdinaryPaths:
|
||||
def test_no_plugin_manager_is_harmless(self):
|
||||
c = DisplayController.__new__(DisplayController)
|
||||
c.plugin_manager = None
|
||||
c._update_modules(deadline=time.time() - 1) # must not raise
|
||||
|
||||
def test_an_empty_plugin_set_is_harmless(self):
|
||||
ex = FakeExecutor()
|
||||
_controller([], ex)._update_modules(deadline=time.time() + 5)
|
||||
assert ex.updated == []
|
||||
|
||||
|
||||
class TestTooLittleBudgetDefersRatherThanClamps:
|
||||
def test_a_plugin_starting_below_the_floor_is_deferred(self):
|
||||
ex = FakeExecutor()
|
||||
c = _controller(['a'], ex)
|
||||
# Just under the floor: previously this was clamped up to the floor and
|
||||
# run anyway, which pushed the pass past its deadline.
|
||||
c._update_modules(
|
||||
deadline=time.time() + _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS - 0.05)
|
||||
assert ex.updated == [], "started a plugin it could not give a slot to"
|
||||
|
||||
def test_a_plugin_starting_above_the_floor_still_runs(self):
|
||||
ex = FakeExecutor()
|
||||
c = _controller(['a'], ex)
|
||||
c._update_modules(
|
||||
deadline=time.time() + _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS + 1)
|
||||
assert ex.updated == ['a']
|
||||
|
||||
def test_the_timeout_is_the_remainder_not_the_floor(self):
|
||||
seen = []
|
||||
|
||||
class Executor:
|
||||
def execute_update(self, plugin, plugin_id, timeout=None):
|
||||
seen.append(timeout)
|
||||
return True
|
||||
|
||||
c = _controller(['a'], Executor())
|
||||
c._update_modules(deadline=time.time() + 9)
|
||||
assert seen and 8.5 <= seen[0] <= 9.01, seen
|
||||
|
||||
def test_the_pass_cannot_outlast_its_deadline(self, tiny_floor):
|
||||
# Every plugin sleeps well past the budget; the deferral keeps the
|
||||
# whole pass inside it rather than overrunning by a floor's worth.
|
||||
ex = FakeExecutor(cost=0.4, slow={'a', 'b', 'c', 'd', 'e'})
|
||||
c = _controller(['a', 'b', 'c', 'd', 'e'], ex)
|
||||
started = time.time()
|
||||
c._update_modules(deadline=started + 0.5)
|
||||
assert time.time() - started < 1.2, "%.2fs" % (time.time() - started)
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Tests the startup screen that shows while plugins fetch their first data.
|
||||
|
||||
That screen is on the panel for the whole initial-update window, and on a
|
||||
headless Pi it is the only place the device's address appears without going
|
||||
looking for it -- so it now carries the address as well as "Initializing".
|
||||
|
||||
Two things have to hold. It must fit every supported panel: the old fixed
|
||||
8px PressStart2P drew "Initializing" 96px wide at x=10, which ran off the
|
||||
side of a 64px panel before an address was ever added. And the lookup must be
|
||||
cheap, because this runs on the startup path that the rest of this change
|
||||
exists to shorten.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
from src.display_manager import DisplayManager # noqa: E402
|
||||
|
||||
SIZES = [(64, 32), (128, 32), (128, 64), (256, 32), (512, 64)]
|
||||
|
||||
|
||||
class FakeMatrix:
|
||||
def __init__(self, width, height):
|
||||
self.width, self.height = width, height
|
||||
|
||||
|
||||
def _manager(width, height):
|
||||
dm = DisplayManager.__new__(DisplayManager)
|
||||
dm.image = Image.new('RGB', (width, height))
|
||||
dm.draw = ImageDraw.Draw(dm.image)
|
||||
dm.matrix = FakeMatrix(width, height)
|
||||
dm.font = ImageFont.truetype('assets/fonts/PressStart2P-Regular.ttf', 8)
|
||||
return dm
|
||||
|
||||
|
||||
def _layout(dm, lines):
|
||||
"""The geometry _draw_startup_banner uses."""
|
||||
font = dm._fitting_font(lines, dm.matrix.width - 2)
|
||||
line_height = dm.draw.textbbox((0, 0), "Ag", font=font)[3] + 1
|
||||
top = max(1, (dm.matrix.height - line_height * len(lines)) // 2)
|
||||
widths = [dm.draw.textlength(t, font=font) for t in lines]
|
||||
return font, widths, top, top + line_height * len(lines)
|
||||
|
||||
|
||||
def _render_over_pattern(width, height, lines):
|
||||
"""Draw the test pattern, then the banner over it, as startup does."""
|
||||
dm = _manager(width, height)
|
||||
dm.draw.rectangle([0, 0, width - 1, height - 1], outline=(255, 0, 0))
|
||||
dm.draw.line([0, 0, width - 1, height - 1], fill=(0, 255, 0))
|
||||
dm._draw_startup_banner(lines, width, height)
|
||||
return dm
|
||||
|
||||
|
||||
class TestTheAddressLookup:
|
||||
def test_it_never_reports_loopback(self):
|
||||
# A loopback address on the panel would be actively misleading -- it is
|
||||
# not something anyone can browse to.
|
||||
ip = DisplayManager._local_ip()
|
||||
assert ip is None or not ip.startswith("127."), ip
|
||||
|
||||
def test_it_looks_like_an_address_when_there_is_one(self):
|
||||
ip = DisplayManager._local_ip()
|
||||
if ip is None:
|
||||
pytest.skip("host has no routable address")
|
||||
parts = ip.split(".")
|
||||
assert len(parts) == 4 and all(p.isdigit() for p in parts), ip
|
||||
|
||||
def test_it_is_cheap_enough_for_the_startup_path(self):
|
||||
DisplayManager._local_ip() # warm anything cacheable
|
||||
started = time.perf_counter()
|
||||
for _ in range(20):
|
||||
DisplayManager._local_ip()
|
||||
per_call = (time.perf_counter() - started) / 20
|
||||
# `hostname -I` with its 2s timeout, which the web launcher uses, would
|
||||
# be thousands of times this.
|
||||
assert per_call < 0.05, "%.1f ms per call" % (per_call * 1000)
|
||||
|
||||
def test_it_returns_none_rather_than_raising(self, monkeypatch):
|
||||
import src.display_manager as mod
|
||||
|
||||
def no_network(*a, **k):
|
||||
raise OSError("network is unreachable")
|
||||
|
||||
monkeypatch.setattr(mod.socket, "socket", no_network)
|
||||
assert DisplayManager._local_ip() is None
|
||||
|
||||
|
||||
class TestItFitsEveryPanel:
|
||||
@pytest.mark.parametrize("width,height", SIZES)
|
||||
def test_both_lines_fit_with_an_address(self, width, height):
|
||||
dm = _manager(width, height)
|
||||
_font, widths, top, bottom = _layout(dm, ["Initializing", "255.255.255.255"])
|
||||
assert all(w <= width - 2 for w in widths), (width, widths)
|
||||
assert bottom <= height and top >= 0, (top, bottom, height)
|
||||
|
||||
@pytest.mark.parametrize("width,height", SIZES)
|
||||
def test_it_still_fits_with_no_address(self, width, height):
|
||||
dm = _manager(width, height)
|
||||
_font, widths, _top, bottom = _layout(dm, ["Initializing"])
|
||||
assert all(w <= width - 2 for w in widths), (width, widths)
|
||||
assert bottom <= height, (bottom, height)
|
||||
|
||||
def test_the_smallest_panel_drops_to_a_narrower_font(self):
|
||||
# The regression this guards: PressStart2P at 8px is 96px wide for
|
||||
# "Initializing", which does not fit 64px however it is positioned.
|
||||
dm = _manager(64, 32)
|
||||
font, widths, _t, _b = _layout(dm, ["Initializing", "10.0.20.104"])
|
||||
assert font is not dm.font, "kept a font that cannot fit"
|
||||
assert max(widths) <= 62, widths
|
||||
|
||||
def test_a_roomy_panel_keeps_the_larger_font(self):
|
||||
dm = _manager(256, 32)
|
||||
font, _w, _t, _b = _layout(dm, ["Initializing", "10.0.20.104"])
|
||||
assert font is dm.font, "needlessly shrank on a panel with room"
|
||||
|
||||
|
||||
class TestPlacement:
|
||||
@pytest.mark.parametrize("width,height", SIZES)
|
||||
def test_the_lines_are_centred(self, width, height):
|
||||
dm = _manager(width, height)
|
||||
lines = ["Initializing", "10.0.20.104"]
|
||||
_font, widths, _t, _b = _layout(dm, lines)
|
||||
for w in widths:
|
||||
left = max(0, (width - w) // 2)
|
||||
assert abs((left + (left + w)) - width) <= 2, (left, w, width)
|
||||
|
||||
def test_the_address_sits_under_the_word(self):
|
||||
dm = _manager(128, 64)
|
||||
font, _w, top, bottom = _layout(dm, ["Initializing", "10.0.20.104"])
|
||||
line_height = dm.draw.textbbox((0, 0), "Ag", font=font)[3] + 1
|
||||
assert bottom - top == line_height * 2
|
||||
|
||||
|
||||
class TestItIsActuallyReadable:
|
||||
"""The point of the address is that someone can read it off the wall."""
|
||||
|
||||
@pytest.mark.parametrize("width,height", SIZES)
|
||||
def test_the_diagonal_does_not_cross_the_text(self, width, height):
|
||||
lines = ["Initializing", "10.0.20.104"]
|
||||
dm = _render_over_pattern(width, height, lines)
|
||||
_font, widths, top, bottom = _layout(dm, lines)
|
||||
# textlength returns a float, so these must be floored before they
|
||||
# can index pixels.
|
||||
block_width = int(max(widths))
|
||||
left = int(max(0, (width - block_width) // 2))
|
||||
|
||||
px = dm.image.load()
|
||||
green = 0
|
||||
for y in range(int(top), min(int(bottom), height)):
|
||||
for x in range(left, min(left + block_width, width)):
|
||||
r, g, b = px[x, y]
|
||||
if g > 128 and r < 128 and b < 128:
|
||||
green += 1
|
||||
assert green == 0, "%d green pixels behind the text at %dx%d" % (
|
||||
green, width, height)
|
||||
|
||||
@pytest.mark.parametrize("width,height", SIZES)
|
||||
def test_the_text_stays_pure_blue(self, width, height):
|
||||
# Not a style choice. The pattern lights one channel per element --
|
||||
# red border, green diagonal, blue text -- so a glance says whether
|
||||
# led_rgb_sequence is right: wire it BGR and the border comes up blue
|
||||
# and this text red. White text would light all three and destroy the
|
||||
# only blue reference on the screen.
|
||||
dm = _render_over_pattern(width, height, ["Initializing", "10.0.20.104"])
|
||||
px = dm.image.load()
|
||||
blue = sum(1 for y in range(height) for x in range(width)
|
||||
if px[x, y] == (0, 0, 255))
|
||||
assert blue > 20, "only %d blue pixels at %dx%d" % (blue, width, height)
|
||||
white = sum(1 for y in range(height) for x in range(width)
|
||||
if px[x, y] == (255, 255, 255))
|
||||
assert white == 0, "%d white pixels would muddy the channel check" % white
|
||||
|
||||
def test_each_element_lights_one_channel(self):
|
||||
# The whole point of the pattern: three pure primaries on screen.
|
||||
dm = _render_over_pattern(128, 64, ["Initializing", "10.0.20.104"])
|
||||
seen = set(dm.image.getdata())
|
||||
assert (255, 0, 0) in seen, "no pure red border"
|
||||
assert (0, 255, 0) in seen, "no pure green diagonal"
|
||||
assert (0, 0, 255) in seen, "no pure blue text"
|
||||
|
||||
def test_nothing_is_drawn_for_no_lines(self):
|
||||
dm = _manager(128, 64)
|
||||
before = dm.image.tobytes()
|
||||
dm._draw_startup_banner([], 128, 64)
|
||||
assert dm.image.tobytes() == before
|
||||
@@ -1,274 +0,0 @@
|
||||
"""
|
||||
Tests for src/logging_config.py — the formatters, adapter, and setup used
|
||||
by every logger in the system (BasePlugin uses get_logger, not stdlib
|
||||
logging.getLogger).
|
||||
|
||||
Includes regression guards for two fixed bugs: ContextualFormatter used to
|
||||
mutate record.msg in place (double-prefixing with two handlers), and
|
||||
log_error hardcoded exc_info=True so passing it explicitly raised
|
||||
TypeError.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from src.logging_config import (
|
||||
ContextualFormatter,
|
||||
PluginLoggerAdapter,
|
||||
StructuredFormatter,
|
||||
get_logger,
|
||||
log_debug,
|
||||
log_error,
|
||||
log_info,
|
||||
log_warning,
|
||||
log_with_context,
|
||||
setup_logging,
|
||||
)
|
||||
|
||||
|
||||
def make_record(msg="hello", level=logging.INFO, **extra):
|
||||
record = logging.LogRecord(
|
||||
name="test.logger", level=level, pathname=__file__, lineno=42,
|
||||
msg=msg, args=(), exc_info=None)
|
||||
for key, value in extra.items():
|
||||
setattr(record, key, value)
|
||||
return record
|
||||
|
||||
|
||||
class TestStructuredFormatter:
|
||||
def test_emits_valid_json_with_base_keys(self):
|
||||
out = json.loads(StructuredFormatter().format(make_record()))
|
||||
assert set(out) == {
|
||||
"timestamp", "level", "logger", "message",
|
||||
"module", "function", "line",
|
||||
}
|
||||
assert out["level"] == "INFO"
|
||||
assert out["message"] == "hello"
|
||||
assert out["logger"] == "test.logger"
|
||||
|
||||
def test_optional_keys_only_when_present(self):
|
||||
record = make_record(context={"k": "v"}, plugin_id="clock",
|
||||
operation_id="op-1")
|
||||
out = json.loads(StructuredFormatter().format(record))
|
||||
assert out["context"] == {"k": "v"}
|
||||
assert out["plugin_id"] == "clock"
|
||||
assert out["operation_id"] == "op-1"
|
||||
|
||||
def test_exception_key_when_exc_info_present(self):
|
||||
try:
|
||||
raise ValueError("kaboom")
|
||||
except ValueError:
|
||||
record = logging.LogRecord(
|
||||
name="t", level=logging.ERROR, pathname=__file__, lineno=1,
|
||||
msg="failed", args=(), exc_info=sys.exc_info())
|
||||
out = json.loads(StructuredFormatter().format(record))
|
||||
assert "kaboom" in out["exception"]
|
||||
|
||||
def test_percent_args_formatted_into_message(self):
|
||||
record = logging.LogRecord(
|
||||
name="t", level=logging.INFO, pathname=__file__, lineno=1,
|
||||
msg="count=%d", args=(7,), exc_info=None)
|
||||
out = json.loads(StructuredFormatter().format(record))
|
||||
assert out["message"] == "count=7"
|
||||
|
||||
|
||||
class TestContextualFormatter:
|
||||
def test_context_prefix_prepended(self):
|
||||
record = make_record(plugin_id="clock", operation_id="op-1",
|
||||
context={"k": "v"})
|
||||
out = ContextualFormatter().format(record)
|
||||
assert "[Plugin: clock] [Op: op-1] [k: v] hello" in out
|
||||
|
||||
def test_include_context_false_leaves_message_bare(self):
|
||||
record = make_record(plugin_id="clock")
|
||||
out = ContextualFormatter(include_context=False).format(record)
|
||||
assert "[Plugin:" not in out
|
||||
assert "hello" in out
|
||||
|
||||
def test_location_toggle(self):
|
||||
record = make_record()
|
||||
with_loc = ContextualFormatter(include_location=True).format(record)
|
||||
without = ContextualFormatter(include_location=False).format(record)
|
||||
assert f":{record.lineno}" in with_loc
|
||||
assert f":{record.lineno}" not in without
|
||||
|
||||
def test_record_not_mutated_no_double_prefix(self):
|
||||
# Regression: a record is formatted once PER HANDLER. The formatter
|
||||
# must not mutate record.msg, or the second handler's format call
|
||||
# prepends the prefix again.
|
||||
record = make_record(plugin_id="clock")
|
||||
formatter = ContextualFormatter()
|
||||
first = formatter.format(record)
|
||||
second = formatter.format(record)
|
||||
assert record.msg == "hello" # untouched
|
||||
assert first.count("[Plugin: clock]") == 1
|
||||
assert second.count("[Plugin: clock]") == 1
|
||||
|
||||
def test_percent_args_still_format_after_copy(self):
|
||||
record = logging.LogRecord(
|
||||
name="t", level=logging.INFO, pathname=__file__, lineno=1,
|
||||
msg="count=%d", args=(7,), exc_info=None)
|
||||
record.plugin_id = "clock"
|
||||
out = ContextualFormatter().format(record)
|
||||
assert "[Plugin: clock] count=7" in out
|
||||
|
||||
def test_exception_renders_through_two_handlers(self):
|
||||
try:
|
||||
raise ValueError("kaboom")
|
||||
except ValueError:
|
||||
record = logging.LogRecord(
|
||||
name="t", level=logging.ERROR, pathname=__file__, lineno=1,
|
||||
msg="failed", args=(), exc_info=sys.exc_info())
|
||||
record.plugin_id = "clock"
|
||||
formatter = ContextualFormatter()
|
||||
assert "kaboom" in formatter.format(record)
|
||||
assert "kaboom" in formatter.format(record) # second handler's pass
|
||||
|
||||
|
||||
class TestPluginLoggerAdapter:
|
||||
def _capture(self, adapter):
|
||||
records = []
|
||||
handler = logging.Handler()
|
||||
handler.emit = records.append
|
||||
adapter.logger.addHandler(handler)
|
||||
adapter.logger.setLevel(logging.DEBUG)
|
||||
return records
|
||||
|
||||
def test_stamps_plugin_id_on_every_record(self):
|
||||
adapter = get_logger("test.adapter1", plugin_id="clock")
|
||||
records = self._capture(adapter)
|
||||
adapter.info("x")
|
||||
assert records[0].plugin_id == "clock"
|
||||
|
||||
def test_explicit_extra_plugin_id_wins(self):
|
||||
adapter = get_logger("test.adapter2", plugin_id="clock")
|
||||
records = self._capture(adapter)
|
||||
adapter.info("x", extra={"plugin_id": "other"})
|
||||
assert records[0].plugin_id == "other"
|
||||
|
||||
def test_unrelated_extra_keys_preserved(self):
|
||||
adapter = get_logger("test.adapter3", plugin_id="clock")
|
||||
records = self._capture(adapter)
|
||||
adapter.info("x", extra={"custom": 1})
|
||||
assert records[0].plugin_id == "clock"
|
||||
assert records[0].custom == 1
|
||||
|
||||
|
||||
class TestGetLogger:
|
||||
def test_plain_logger_without_plugin_id(self):
|
||||
logger = get_logger("test.plain")
|
||||
assert isinstance(logger, logging.Logger)
|
||||
assert logger.name == "test.plain"
|
||||
|
||||
def test_adapter_with_plugin_id(self):
|
||||
adapter = get_logger("test.wrapped", plugin_id="clock")
|
||||
assert isinstance(adapter, PluginLoggerAdapter)
|
||||
assert adapter.logger.name == "test.wrapped"
|
||||
|
||||
|
||||
class TestSetupLogging:
|
||||
# conftest's autouse reset_logging restores root handlers after each test.
|
||||
|
||||
def test_installs_single_stdout_handler(self):
|
||||
setup_logging()
|
||||
root = logging.getLogger()
|
||||
assert len(root.handlers) == 1
|
||||
assert isinstance(root.handlers[0], logging.StreamHandler)
|
||||
|
||||
def test_repeat_calls_do_not_accumulate_handlers(self):
|
||||
setup_logging()
|
||||
setup_logging()
|
||||
assert len(logging.getLogger().handlers) == 1
|
||||
|
||||
def test_json_format_selects_structured_formatter(self):
|
||||
setup_logging(format_type="json")
|
||||
assert isinstance(
|
||||
logging.getLogger().handlers[0].formatter, StructuredFormatter)
|
||||
|
||||
def test_readable_format_selects_contextual_formatter(self):
|
||||
setup_logging(format_type="readable")
|
||||
assert isinstance(
|
||||
logging.getLogger().handlers[0].formatter, ContextualFormatter)
|
||||
|
||||
def test_log_file_adds_file_handler(self, tmp_path):
|
||||
log_file = tmp_path / "test.log"
|
||||
setup_logging(log_file=str(log_file))
|
||||
root = logging.getLogger()
|
||||
file_handlers = [h for h in root.handlers
|
||||
if isinstance(h, logging.FileHandler)]
|
||||
assert len(file_handlers) == 1
|
||||
for h in file_handlers:
|
||||
h.close()
|
||||
|
||||
def test_unwritable_log_file_warns_and_keeps_console(self, tmp_path, capsys):
|
||||
bad_path = tmp_path / "no-such-dir" / "test.log"
|
||||
setup_logging(log_file=str(bad_path)) # must not raise
|
||||
assert len(logging.getLogger().handlers) == 1 # console only
|
||||
assert "Could not set up file logging" in capsys.readouterr().err
|
||||
|
||||
def test_debug_env_true_enables_debug(self, monkeypatch):
|
||||
monkeypatch.setenv("LEDMATRIX_DEBUG", "TRUE")
|
||||
setup_logging()
|
||||
assert logging.getLogger().level == logging.DEBUG
|
||||
|
||||
def test_debug_env_other_values_stay_info(self, monkeypatch):
|
||||
# Pinned: only the literal (case-insensitive) "true" enables debug;
|
||||
# "1" does not.
|
||||
monkeypatch.setenv("LEDMATRIX_DEBUG", "1")
|
||||
setup_logging()
|
||||
assert logging.getLogger().level == logging.INFO
|
||||
|
||||
def test_explicit_level_wins_over_env(self, monkeypatch):
|
||||
monkeypatch.setenv("LEDMATRIX_DEBUG", "true")
|
||||
setup_logging(level=logging.WARNING)
|
||||
assert logging.getLogger().level == logging.WARNING
|
||||
|
||||
|
||||
class TestLogWithContext:
|
||||
def _capture(self, name):
|
||||
logger = logging.getLogger(name)
|
||||
records = []
|
||||
handler = logging.Handler()
|
||||
handler.emit = records.append
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
return logger, records
|
||||
|
||||
def test_context_attrs_stamped(self):
|
||||
logger, records = self._capture("test.lwc1")
|
||||
log_with_context(logger, logging.INFO, "msg",
|
||||
context={"k": "v"}, plugin_id="clock",
|
||||
operation_id="op-1")
|
||||
record = records[0]
|
||||
assert record.context == {"k": "v"}
|
||||
assert record.plugin_id == "clock"
|
||||
assert record.operation_id == "op-1"
|
||||
|
||||
def test_wrappers_use_their_levels(self):
|
||||
logger, records = self._capture("test.lwc2")
|
||||
log_debug(logger, "d")
|
||||
log_info(logger, "i")
|
||||
log_warning(logger, "w")
|
||||
assert [r.levelno for r in records] == [
|
||||
logging.DEBUG, logging.INFO, logging.WARNING]
|
||||
|
||||
def test_log_error_defaults_exc_info_true(self):
|
||||
logger, records = self._capture("test.lwc3")
|
||||
try:
|
||||
raise ValueError("kaboom")
|
||||
except ValueError:
|
||||
log_error(logger, "failed")
|
||||
assert records[0].levelno == logging.ERROR
|
||||
assert records[0].exc_info is not None
|
||||
|
||||
def test_log_error_accepts_explicit_exc_info(self):
|
||||
# Regression: the old hardcoded exc_info=True raised
|
||||
# "got multiple values for keyword argument 'exc_info'".
|
||||
logger, records = self._capture("test.lwc4")
|
||||
log_error(logger, "failed", exc_info=False)
|
||||
# Falsy exc_info is stored verbatim on the record; the contract is
|
||||
# simply "no traceback attached".
|
||||
assert not records[0].exc_info
|
||||
@@ -1,423 +0,0 @@
|
||||
"""
|
||||
Tests for src/common/logo_helper.py — logo loading, LRU caching, resizing,
|
||||
and download-with-fallback. Previously untested: nothing in test/ referenced
|
||||
this module at all.
|
||||
|
||||
Real PIL images under tmp_path are used rather than mocked ones, since
|
||||
load_logo() does real Path.exists() and Image.open() calls; only the HTTP
|
||||
session and the permission helpers are patched.
|
||||
|
||||
Regression coverage for two fixed bugs:
|
||||
- _download_logo wrote response.content to disk with no size cap and no
|
||||
check that the bytes decoded as an image, so a hostile or broken URL
|
||||
could leave arbitrary/oversized content cached in the assets directory.
|
||||
- get_cache_stats() divided by self.cache_size unguarded, raising
|
||||
ZeroDivisionError for a helper constructed with cache_size=0.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from src.common.logo_helper import MAX_LOGO_BYTES, LogoHelper
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_chmod(monkeypatch):
|
||||
# Keep the permission helpers out of the way: their own env detection
|
||||
# is not what these tests are about.
|
||||
monkeypatch.setattr("src.common.logo_helper.ensure_directory_permissions", MagicMock())
|
||||
monkeypatch.setattr("src.common.logo_helper.ensure_file_permissions", MagicMock())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def helper():
|
||||
return LogoHelper(display_width=64, display_height=32,
|
||||
logger=logging.getLogger("test.logo_helper"))
|
||||
|
||||
|
||||
def write_logo(path: Path, size=(20, 20), color=(255, 0, 0), fmt="PNG") -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.new("RGB", size, color).save(path, format=fmt)
|
||||
return path
|
||||
|
||||
|
||||
def fake_response(content: bytes, chunk_size: int = 64 * 1024):
|
||||
"""Stand-in for a streamed requests.Response.
|
||||
|
||||
_download_logo opens `with session.get(..., stream=True)` and reads
|
||||
through iter_content(), so the fake has to be a context manager that
|
||||
yields the body in pieces rather than exposing it as .content.
|
||||
Chunking is the fake's own, not the caller's, so a test can dribble a
|
||||
body out in small pieces.
|
||||
"""
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.__exit__.return_value = False
|
||||
response.raise_for_status = MagicMock()
|
||||
|
||||
def _iter_content(*_args, **_kwargs):
|
||||
for i in range(0, len(content), chunk_size):
|
||||
yield content[i:i + chunk_size]
|
||||
|
||||
response.iter_content = _iter_content
|
||||
return response
|
||||
|
||||
|
||||
def endless_response(chunk: bytes = b"\x00" * 65536):
|
||||
"""A server that declares no length and never stops sending.
|
||||
|
||||
This is the case response.content could not survive: it buffers to
|
||||
completion, so the size check never got a chance to run.
|
||||
"""
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.__exit__.return_value = False
|
||||
response.raise_for_status = MagicMock()
|
||||
|
||||
def _iter_content(*_args, **_kwargs):
|
||||
while True:
|
||||
yield chunk
|
||||
|
||||
response.iter_content = _iter_content
|
||||
return response
|
||||
|
||||
|
||||
def png_bytes(size=(20, 20), color=(0, 128, 0)) -> bytes:
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class TestLoadLogo:
|
||||
def test_loads_and_converts_to_rgba(self, helper, tmp_path):
|
||||
path = write_logo(tmp_path / "PHI.png")
|
||||
logo = helper.load_logo("PHI", path)
|
||||
assert logo is not None
|
||||
assert logo.mode == "RGBA"
|
||||
|
||||
def test_missing_file_returns_none(self, helper, tmp_path, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert helper.load_logo("NOPE", tmp_path / "missing.png") is None
|
||||
assert "Logo not found" in caplog.text
|
||||
|
||||
def test_second_load_is_served_from_cache(self, helper, tmp_path):
|
||||
path = write_logo(tmp_path / "PHI.png")
|
||||
first = helper.load_logo("PHI", path)
|
||||
path.unlink() # cache hit must not touch the filesystem
|
||||
assert helper.load_logo("PHI", path) is first
|
||||
|
||||
def test_cache_key_includes_requested_size(self, helper, tmp_path):
|
||||
# A panel-size change must not hand back a logo sized for the old
|
||||
# dimensions, so the two sizes get separate cache entries.
|
||||
path = write_logo(tmp_path / "PHI.png", size=(100, 100))
|
||||
small = helper.load_logo("PHI", path, max_width=10, max_height=10)
|
||||
large = helper.load_logo("PHI", path, max_width=50, max_height=50)
|
||||
assert small is not large
|
||||
assert small.size != large.size
|
||||
assert len(helper._logo_cache) == 2
|
||||
|
||||
def test_default_size_is_one_and_a_half_display(self, helper, tmp_path):
|
||||
path = write_logo(tmp_path / "PHI.png", size=(500, 500))
|
||||
logo = helper.load_logo("PHI", path)
|
||||
assert logo.width <= int(64 * 1.5)
|
||||
assert logo.height <= int(32 * 1.5)
|
||||
|
||||
def test_smaller_image_is_not_upscaled(self, helper, tmp_path):
|
||||
path = write_logo(tmp_path / "PHI.png", size=(8, 8))
|
||||
assert helper.load_logo("PHI", path, max_width=64, max_height=64).size == (8, 8)
|
||||
|
||||
def test_larger_image_is_downscaled_preserving_aspect(self, helper, tmp_path):
|
||||
path = write_logo(tmp_path / "PHI.png", size=(200, 100))
|
||||
logo = helper.load_logo("PHI", path, max_width=50, max_height=50)
|
||||
assert logo.width <= 50 and logo.height <= 50
|
||||
assert logo.width == 50 and logo.height == 25 # 2:1 preserved
|
||||
|
||||
def test_string_path_accepted(self, helper, tmp_path):
|
||||
path = write_logo(tmp_path / "PHI.png")
|
||||
assert helper.load_logo("PHI", str(path)) is not None
|
||||
|
||||
def test_corrupt_file_returns_none(self, helper, tmp_path, caplog):
|
||||
bad = tmp_path / "bad.png"
|
||||
bad.write_bytes(b"not an image")
|
||||
with caplog.at_level(logging.ERROR):
|
||||
assert helper.load_logo("BAD", bad) is None
|
||||
assert "Error loading logo" in caplog.text
|
||||
|
||||
|
||||
class TestCacheManagement:
|
||||
def test_lru_evicts_oldest(self, tmp_path):
|
||||
helper = LogoHelper(64, 32, cache_size=2, logger=MagicMock())
|
||||
paths = [write_logo(tmp_path / f"T{i}.png") for i in range(3)]
|
||||
for i, path in enumerate(paths):
|
||||
helper.load_logo(f"T{i}", path)
|
||||
assert len(helper._logo_cache) == 2
|
||||
assert not any(k.startswith("T0_") for k in helper._logo_cache)
|
||||
|
||||
def test_cache_hit_refreshes_lru_position(self, tmp_path):
|
||||
helper = LogoHelper(64, 32, cache_size=2, logger=MagicMock())
|
||||
a, b, c = [write_logo(tmp_path / f"{n}.png") for n in ("A", "B", "C")]
|
||||
helper.load_logo("A", a)
|
||||
helper.load_logo("B", b)
|
||||
helper.load_logo("A", a) # A is now most-recently used
|
||||
helper.load_logo("C", c) # evicts B, not A
|
||||
assert any(k.startswith("A_") for k in helper._logo_cache)
|
||||
assert not any(k.startswith("B_") for k in helper._logo_cache)
|
||||
|
||||
def test_clear_cache_empties_both_structures(self, helper, tmp_path):
|
||||
helper.load_logo("PHI", write_logo(tmp_path / "PHI.png"))
|
||||
helper.clear_cache()
|
||||
assert helper._logo_cache == {}
|
||||
assert helper._cache_order == []
|
||||
|
||||
def test_cache_stats(self, tmp_path):
|
||||
helper = LogoHelper(64, 32, cache_size=4, logger=MagicMock())
|
||||
helper.load_logo("PHI", write_logo(tmp_path / "PHI.png"))
|
||||
stats = helper.get_cache_stats()
|
||||
assert stats["cached_logos"] == 1
|
||||
assert stats["cache_size_limit"] == 4
|
||||
assert stats["cache_usage_percent"] == 25
|
||||
|
||||
def test_zero_cache_size_does_not_divide_by_zero(self):
|
||||
# Regression: this raised ZeroDivisionError.
|
||||
stats = LogoHelper(64, 32, cache_size=0, logger=MagicMock()).get_cache_stats()
|
||||
assert stats["cache_usage_percent"] == 0
|
||||
assert stats["cache_size_limit"] == 0
|
||||
|
||||
|
||||
class TestLoadLogoWithDownload:
|
||||
def test_existing_file_skips_download(self, helper, tmp_path):
|
||||
path = write_logo(tmp_path / "PHI.png")
|
||||
helper.session.get = MagicMock()
|
||||
assert helper.load_logo_with_download("PHI", path, "http://x/logo.png") is not None
|
||||
helper.session.get.assert_not_called()
|
||||
|
||||
def test_downloads_then_loads(self, helper, tmp_path):
|
||||
path = tmp_path / "PHI.png"
|
||||
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
|
||||
logo = helper.load_logo_with_download("PHI", path, "http://x/logo.png")
|
||||
assert logo is not None
|
||||
assert path.exists()
|
||||
# stream=True is load-bearing: it is what lets the size cap apply
|
||||
# before the body is buffered.
|
||||
helper.session.get.assert_called_once_with(
|
||||
"http://x/logo.png", timeout=30, stream=True)
|
||||
|
||||
def test_download_failure_falls_back_to_placeholder(self, helper, tmp_path):
|
||||
helper.session.get = MagicMock(
|
||||
side_effect=requests.RequestException("connection reset"))
|
||||
logo = helper.load_logo_with_download(
|
||||
"PHI", tmp_path / "PHI.png", "http://x/logo.png",
|
||||
max_width=20, max_height=20)
|
||||
assert logo is not None and logo.size == (20, 20) # placeholder
|
||||
|
||||
def test_http_error_falls_back_to_placeholder(self, helper, tmp_path):
|
||||
response = fake_response(b"")
|
||||
response.raise_for_status.side_effect = requests.HTTPError("404")
|
||||
helper.session.get = MagicMock(return_value=response)
|
||||
logo = helper.load_logo_with_download(
|
||||
"PHI", tmp_path / "PHI.png", "http://x/logo.png",
|
||||
max_width=20, max_height=20)
|
||||
assert logo is not None and logo.size == (20, 20)
|
||||
|
||||
def test_no_url_and_no_file_gives_placeholder(self, helper, tmp_path):
|
||||
logo = helper.load_logo_with_download(
|
||||
"PHI", tmp_path / "missing.png", None, max_width=16, max_height=16)
|
||||
assert logo is not None and logo.size == (16, 16)
|
||||
|
||||
|
||||
class TestDownloadLogo:
|
||||
def test_writes_file_and_sets_permissions(self, helper, tmp_path):
|
||||
path = tmp_path / "assets" / "PHI.png"
|
||||
# Directory creation is ensure_directory_permissions' job, and the
|
||||
# autouse fixture stubs it out — so make the directory here.
|
||||
path.parent.mkdir()
|
||||
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
|
||||
with patch("src.common.logo_helper.ensure_directory_permissions") as dirs, \
|
||||
patch("src.common.logo_helper.ensure_file_permissions") as files:
|
||||
helper._download_logo("http://x/logo.png", path)
|
||||
assert path.exists()
|
||||
dirs.assert_called_once()
|
||||
files.assert_called_once()
|
||||
assert dirs.call_args[0][0] == path.parent
|
||||
|
||||
def test_oversized_response_is_rejected_without_writing(self, helper, tmp_path):
|
||||
# Regression: an unbounded response.content was written straight to
|
||||
# disk, so a hostile URL chose how many bytes landed in assets/.
|
||||
path = tmp_path / "huge.png"
|
||||
helper.session.get = MagicMock(
|
||||
return_value=fake_response(b"\x00" * (MAX_LOGO_BYTES + 1)))
|
||||
with pytest.raises(ValueError, match="exceeds the"):
|
||||
helper._download_logo("http://x/huge.png", path)
|
||||
assert not path.exists()
|
||||
|
||||
def test_unbounded_response_is_aborted_at_the_cap(self, helper, tmp_path):
|
||||
# Regression: the cap used to be checked against response.content,
|
||||
# which buffers the whole body first — so a server that omits
|
||||
# Content-Length and never stops sending exhausted memory before
|
||||
# the check could run. Streaming counts bytes as they arrive, so
|
||||
# this terminates instead of hanging.
|
||||
path = tmp_path / "endless.png"
|
||||
helper.session.get = MagicMock(return_value=endless_response())
|
||||
with pytest.raises(ValueError, match="exceeds the"):
|
||||
helper._download_logo("http://x/endless.png", path)
|
||||
assert not path.exists()
|
||||
|
||||
def test_no_partial_file_is_left_when_the_stream_dies(self, helper, tmp_path):
|
||||
# A transfer that fails midway must not leave a truncated logo
|
||||
# where the real one belongs — load_logo() would cache it.
|
||||
path = tmp_path / "cut.png"
|
||||
real = png_bytes()
|
||||
|
||||
def _dies_midway(*_args, **_kwargs):
|
||||
yield real[:20]
|
||||
raise OSError("connection reset")
|
||||
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.__exit__.return_value = False
|
||||
response.raise_for_status = MagicMock()
|
||||
response.iter_content = _dies_midway
|
||||
helper.session.get = MagicMock(return_value=response)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
helper._download_logo("http://x/cut.png", path)
|
||||
assert not path.exists()
|
||||
assert list(tmp_path.glob("*.part")) == []
|
||||
|
||||
def test_concurrent_downloads_do_not_share_a_temp_file(self, helper, tmp_path):
|
||||
# Two plugins can ask for the same logo at once. A fixed
|
||||
# "<name>.part" would let them interleave writes into one file and
|
||||
# publish the mixture; each download gets its own temp name.
|
||||
path = tmp_path / "PHI.png"
|
||||
seen = []
|
||||
real_mkstemp = tempfile.mkstemp
|
||||
|
||||
def record(*args, **kwargs):
|
||||
fd, name = real_mkstemp(*args, **kwargs)
|
||||
seen.append(name)
|
||||
return fd, name
|
||||
|
||||
with patch("src.common.logo_helper.tempfile.mkstemp", side_effect=record):
|
||||
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
|
||||
helper._download_logo("http://x/logo.png", path)
|
||||
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
|
||||
helper._download_logo("http://x/logo.png", path)
|
||||
|
||||
assert len(seen) == 2 and seen[0] != seen[1]
|
||||
assert path.exists()
|
||||
assert list(tmp_path.glob("*.part")) == [] # both cleaned up
|
||||
|
||||
def test_request_failure_leaves_no_temp_file(self, helper, tmp_path):
|
||||
# mkstemp creates the file up front, so an error before any bytes
|
||||
# arrive still has something to clean up.
|
||||
helper.session.get = MagicMock(
|
||||
side_effect=requests.RequestException("connection reset"))
|
||||
with pytest.raises(requests.RequestException):
|
||||
helper._download_logo("http://x/logo.png", tmp_path / "PHI.png")
|
||||
assert list(tmp_path.glob("*")) == []
|
||||
|
||||
def test_non_image_response_is_deleted_and_raises(self, helper, tmp_path):
|
||||
# Regression: undecodable bytes stayed on disk, so every later
|
||||
# load_logo() call hit the corrupt file instead of re-downloading.
|
||||
path = tmp_path / "bad.png"
|
||||
helper.session.get = MagicMock(return_value=fake_response(b"<html>404</html>"))
|
||||
# Specifically Pillow's identify failure, not any OSError: the
|
||||
# point is that the bytes did not decode, and OSError alone would
|
||||
# also admit unrelated filesystem faults.
|
||||
with pytest.raises(UnidentifiedImageError):
|
||||
helper._download_logo("http://x/bad.png", path)
|
||||
assert not path.exists()
|
||||
assert list(tmp_path.glob("*.part")) == []
|
||||
|
||||
def test_decompression_bomb_is_deleted_and_raises(self, helper, tmp_path, monkeypatch):
|
||||
path = tmp_path / "bomb.png"
|
||||
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
|
||||
|
||||
class Bomb:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def load(self):
|
||||
raise Image.DecompressionBombError("too many pixels")
|
||||
|
||||
monkeypatch.setattr("src.common.logo_helper.Image.open", lambda *a, **kw: Bomb())
|
||||
with pytest.raises(Image.DecompressionBombError):
|
||||
helper._download_logo("http://x/bomb.png", path)
|
||||
assert not path.exists()
|
||||
|
||||
def test_bad_download_surfaces_as_placeholder_not_crash(self, helper, tmp_path):
|
||||
# The new guards raise, and load_logo_with_download's existing
|
||||
# broad except turns that into the placeholder path.
|
||||
helper.session.get = MagicMock(return_value=fake_response(b"garbage"))
|
||||
logo = helper.load_logo_with_download(
|
||||
"PHI", tmp_path / "PHI.png", "http://x/bad.png",
|
||||
max_width=12, max_height=12)
|
||||
assert logo is not None and logo.size == (12, 12)
|
||||
|
||||
|
||||
class TestLogoVariations:
|
||||
def test_plain_abbreviation_returns_itself(self, helper):
|
||||
assert helper.get_logo_variations("PHI") == ["PHI"]
|
||||
|
||||
def test_ampersand_expanded(self, helper):
|
||||
assert "TAAND M" in helper.get_logo_variations("TA& M")
|
||||
|
||||
def test_and_contracted(self, helper):
|
||||
assert "T&M" in helper.get_logo_variations("TANDM")
|
||||
|
||||
def test_special_case_appends_known_aliases(self, helper):
|
||||
variations = helper.get_logo_variations("TA&M")
|
||||
assert "TAMU" in variations and "TEXASAM" in variations
|
||||
assert "TAANDM" in variations # the generic & rule still applies
|
||||
|
||||
|
||||
class TestNormalizeAbbreviation:
|
||||
def test_uppercases_and_strips(self, helper):
|
||||
assert helper.normalize_abbreviation(" phi ") == "PHI"
|
||||
|
||||
def test_ampersand_becomes_and(self, helper):
|
||||
assert helper.normalize_abbreviation("TA&M") == "TAANDM"
|
||||
|
||||
def test_internal_spaces_removed(self, helper):
|
||||
assert helper.normalize_abbreviation("New York") == "NEWYORK"
|
||||
|
||||
def test_deliberately_differs_from_logo_downloader(self, helper):
|
||||
# Pinned, not a bug: LogoDownloader.normalize_abbreviation replaces
|
||||
# filesystem-unsafe characters but keeps spaces, and plugins call
|
||||
# that one. Changing either changes which logo filenames resolve on
|
||||
# existing installs. Both docstrings say so explicitly.
|
||||
from src.logo_downloader import LogoDownloader
|
||||
assert helper.normalize_abbreviation("New York") == "NEWYORK"
|
||||
assert LogoDownloader.normalize_abbreviation("New York") == "NEW YORK"
|
||||
|
||||
|
||||
class TestPlaceholderLogo:
|
||||
def test_uses_requested_dimensions(self, helper):
|
||||
assert helper._create_placeholder_logo("PHI", 30, 20).size == (30, 20)
|
||||
|
||||
def test_defaults_to_one_and_a_half_display(self, helper):
|
||||
assert helper._create_placeholder_logo("PHI").size == (96, 48)
|
||||
|
||||
def test_is_rgba(self, helper):
|
||||
assert helper._create_placeholder_logo("PHI", 10, 10).mode == "RGBA"
|
||||
|
||||
def test_invalid_dimensions_return_none(self, helper, caplog):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
assert helper._create_placeholder_logo("PHI", -5, -5) is None
|
||||
assert "Error creating placeholder" in caplog.text
|
||||
|
||||
|
||||
class TestSessionConfiguration:
|
||||
def test_user_agent_and_accept_headers(self, helper):
|
||||
assert helper.session.headers["User-Agent"] == "LEDMatrix-Common/1.0"
|
||||
assert helper.session.headers["Accept"] == "image/*"
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Tests that a slow ESPN cannot take a whole plugin update with it.
|
||||
|
||||
Odds are fetched per live game from inside SportsLive.update(), with show_odds
|
||||
defaulting on, and the plugin executor kills an operation at 30s. The odds
|
||||
request timeout was also 30s, so one stalled request consumed the entire budget
|
||||
and the update carrying every game's score was killed:
|
||||
|
||||
00:43:43 ERROR plugin football-scoreboard operation timed out after 30.0s
|
||||
01:43:43 ERROR plugin football-scoreboard operation timed out after 30.0s
|
||||
|
||||
Invisible out of season -- preseason week 1 returns a single game -- and a
|
||||
Sunday slate is around sixteen.
|
||||
|
||||
The request now goes through a session that identifies the caller, so the
|
||||
tests patch `manager.session.get` rather than the module's `requests.get`.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import requests
|
||||
|
||||
from src.base_odds_manager import BaseOddsManager
|
||||
|
||||
PLUGIN_BUDGET = 30.0 # PluginExecutor(default_timeout=30.0)
|
||||
|
||||
|
||||
def _manager(cache=None):
|
||||
cache = cache or Mock()
|
||||
cache.get_with_auto_strategy.return_value = None
|
||||
return BaseOddsManager(cache_manager=cache, config_manager=None)
|
||||
|
||||
|
||||
def _timing_out(manager):
|
||||
"""Point the manager's session at a request that always times out."""
|
||||
manager.session.get = Mock(side_effect=requests.exceptions.Timeout("x"))
|
||||
return manager.session.get
|
||||
|
||||
|
||||
def _returning(manager, payload):
|
||||
resp = Mock()
|
||||
resp.json.return_value = payload
|
||||
resp.raise_for_status.return_value = None
|
||||
manager.session.get = Mock(return_value=resp)
|
||||
return manager.session.get
|
||||
|
||||
|
||||
class TestRequestTimeout:
|
||||
def test_leaves_room_in_the_operation_budget(self):
|
||||
assert _manager().request_timeout < PLUGIN_BUDGET / 2
|
||||
|
||||
def test_the_timeout_is_the_one_actually_used(self):
|
||||
m = _manager()
|
||||
get = _timing_out(m)
|
||||
m.get_odds("football", "nfl", "401")
|
||||
assert get.call_args.kwargs["timeout"] == m.request_timeout
|
||||
|
||||
|
||||
class TestIdentifiesItselfToEspn:
|
||||
"""ESPN 403s python-requests' default agent, and bare custom tokens.
|
||||
|
||||
What it accepts is a token carrying a URL that says who is calling. This
|
||||
path used a bare requests.get and so sent the default -- the one thing
|
||||
known to be rejected. Everything else in the tree that talks to ESPN
|
||||
already sends the header below.
|
||||
"""
|
||||
|
||||
def test_the_user_agent_names_the_project_and_links_to_it(self):
|
||||
ua = _manager().session.headers["User-Agent"]
|
||||
assert "python-requests" not in ua
|
||||
assert "LEDMatrix" in ua
|
||||
assert "github.com/ChuckBuilds/LEDMatrix" in ua
|
||||
|
||||
def test_it_is_the_same_agent_the_rest_of_the_tree_sends(self):
|
||||
# Compared against the live value rather than a copied literal, so the
|
||||
# two cannot drift apart the next time ESPN moves the goalposts.
|
||||
from src.common.api_helper import APIHelper
|
||||
assert (_manager().session.headers["User-Agent"]
|
||||
== APIHelper().session.headers["User-Agent"])
|
||||
|
||||
def test_the_header_reaches_the_request(self):
|
||||
m = _manager()
|
||||
get = _returning(m, {})
|
||||
m._extract_espn_data = Mock(return_value=None)
|
||||
m.get_odds("football", "nfl", "401")
|
||||
# Sent via the session, so it applies without being passed per-call.
|
||||
assert get.call_count == 1
|
||||
assert "User-Agent" in m.session.headers
|
||||
|
||||
def test_no_retry_adapter_multiplies_the_timeout(self):
|
||||
# api_helper mounts a retrying adapter; this path must not, or a 5s
|
||||
# timeout becomes 15s and the budget fix is undone.
|
||||
m = _manager()
|
||||
for adapter in m.session.adapters.values():
|
||||
retries = getattr(adapter, "max_retries", None)
|
||||
assert getattr(retries, "total", 0) in (0, None), (
|
||||
"odds session mounts a retrying adapter (total=%r); retries "
|
||||
"multiply request_timeout" % getattr(retries, "total", None))
|
||||
|
||||
|
||||
class TestSlowEspnCannotKillTheUpdate:
|
||||
def test_one_failure_stops_the_rest_of_the_slate_hitting_the_network(self):
|
||||
m = _manager()
|
||||
get = _timing_out(m)
|
||||
for i in range(16): # a full slate, one game at a time
|
||||
m.get_odds("football", "nfl", "4018730%02d" % i)
|
||||
|
||||
assert get.call_count == 1, (
|
||||
"%d games each paid the timeout; the breaker should have stopped "
|
||||
"after the first" % get.call_count)
|
||||
|
||||
def test_worst_case_slate_stays_inside_the_budget(self):
|
||||
m = _manager()
|
||||
assert m.request_timeout * 1 < PLUGIN_BUDGET
|
||||
|
||||
def test_recovery_is_automatic(self):
|
||||
m = _manager()
|
||||
import src.base_odds_manager as mod
|
||||
real_monotonic = mod.time.monotonic
|
||||
clock = {"t": 1000.0}
|
||||
try:
|
||||
mod.time.monotonic = lambda: clock["t"]
|
||||
get = _timing_out(m)
|
||||
m.get_odds("football", "nfl", "401")
|
||||
assert m._skip_network_until > clock["t"], "breaker did not open"
|
||||
|
||||
clock["t"] += 1
|
||||
before = get.call_count
|
||||
m.get_odds("football", "nfl", "402")
|
||||
assert get.call_count == before, "should not have retried"
|
||||
|
||||
clock["t"] += m._FAILURE_COOLDOWN
|
||||
m.get_odds("football", "nfl", "403")
|
||||
assert get.call_count > before, "never retried"
|
||||
finally:
|
||||
mod.time.monotonic = real_monotonic
|
||||
|
||||
def test_a_healthy_fetch_clears_the_breaker(self):
|
||||
m = _manager()
|
||||
m._skip_network_until = 0.0
|
||||
m._extract_espn_data = Mock(return_value=None)
|
||||
_returning(m, {})
|
||||
m.get_odds("football", "nfl", "401")
|
||||
assert m._skip_network_until == 0.0
|
||||
|
||||
def test_a_403_opens_the_breaker_rather_than_hammering(self):
|
||||
# raise_for_status raises HTTPError, a RequestException -- so a wrong
|
||||
# or missing agent backs off instead of 403ing once per game.
|
||||
m = _manager()
|
||||
resp = Mock()
|
||||
resp.raise_for_status.side_effect = requests.exceptions.HTTPError("403")
|
||||
m.session.get = Mock(return_value=resp)
|
||||
m.get_odds("football", "nfl", "401")
|
||||
assert m._skip_network_until > 0.0
|
||||
|
||||
def test_the_stale_cache_fallback_still_works(self):
|
||||
# The failing request must still hand back whatever was cached; only
|
||||
# the *subsequent* games skip the network.
|
||||
cache = Mock()
|
||||
cache.get_with_auto_strategy.side_effect = [None, {"details": "stale"}]
|
||||
m = BaseOddsManager(cache_manager=cache, config_manager=None)
|
||||
_timing_out(m)
|
||||
assert m.get_odds("football", "nfl", "401") == {"details": "stale"}
|
||||
@@ -1,241 +0,0 @@
|
||||
"""
|
||||
Getting Started checklist: what the server decides, and what it must not.
|
||||
|
||||
The timezone step used to tick server-side when the saved timezone differed
|
||||
from the shipped default, OR-ed with the saved city. That made the step
|
||||
unsatisfiable for anyone genuinely in the default zone (the card nagged
|
||||
forever), and let a saved city tick it off while the timezone was still wrong.
|
||||
The step is now verified in the browser against its own zone, so the server's
|
||||
only job is to hand over the configured value and stay out of the decision.
|
||||
|
||||
These tests pin that contract: the panel-size step still reflects config, the
|
||||
timezone step never pre-ticks, it carries the configured zone, and the city
|
||||
has no influence on it.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
BASE_CONFIG = {
|
||||
"timezone": "America/New_York",
|
||||
"location": {"city": "Tampa", "state": "Florida", "country": "US"},
|
||||
"display": {
|
||||
"hardware": {"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1},
|
||||
"runtime": {},
|
||||
"double_sided": {"enabled": False},
|
||||
"vegas_scroll": {"plugin_order": [], "excluded_plugins": []},
|
||||
"plugin_rotation_order": [],
|
||||
},
|
||||
"plugin_system": {},
|
||||
"schedule": {},
|
||||
"dim_schedule": {},
|
||||
"sync": {},
|
||||
}
|
||||
|
||||
|
||||
def render(config):
|
||||
"""Render the overview partial against one config, as app.py would."""
|
||||
base = PROJECT_ROOT / "web_interface"
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=str(base / "templates"),
|
||||
static_folder=str(base / "static"),
|
||||
)
|
||||
app.config["TESTING"] = True
|
||||
|
||||
from web_interface.blueprints import pages_v3 as pv
|
||||
|
||||
# pages_v3 is a module-level singleton shared across the test process;
|
||||
# restore whatever the previous test left on it.
|
||||
original_cm = getattr(pv.pages_v3, "config_manager", None)
|
||||
original_pm = getattr(pv.pages_v3, "plugin_manager", None)
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.load_config.return_value = config
|
||||
mock_cm.get_raw_file_content.return_value = config
|
||||
pv.pages_v3.config_manager = mock_cm
|
||||
|
||||
mock_pm = MagicMock()
|
||||
mock_pm.plugins = {}
|
||||
mock_pm.get_all_plugin_info.return_value = []
|
||||
mock_pm.get_plugin_display_modes.side_effect = lambda pid: []
|
||||
pv.pages_v3.plugin_manager = mock_pm
|
||||
|
||||
app.register_blueprint(pv.pages_v3, url_prefix="")
|
||||
try:
|
||||
resp = app.test_client().get("/partials/overview")
|
||||
assert resp.status_code == 200, resp.status_code
|
||||
return resp.get_data(as_text=True)
|
||||
finally:
|
||||
pv.pages_v3.config_manager = original_cm
|
||||
pv.pages_v3.plugin_manager = original_pm
|
||||
|
||||
|
||||
def timezone_step(body):
|
||||
"""The checklist <button> for the timezone step."""
|
||||
match = re.search(r"<button[^>]*data-check=\"timezone\"[^>]*>", body)
|
||||
assert match, "timezone step not found in the rendered checklist"
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def config_with(**overrides):
|
||||
config = copy.deepcopy(BASE_CONFIG)
|
||||
for key, value in overrides.items():
|
||||
config[key] = value
|
||||
return config
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"timezone",
|
||||
["America/New_York", "America/Los_Angeles", "Europe/Madrid", "Asia/Kolkata"],
|
||||
)
|
||||
def test_timezone_step_never_pre_ticks_server_side(timezone):
|
||||
"""The browser owns this decision; the server must not pre-empt it.
|
||||
|
||||
The default zone is in the list deliberately: that is the case the old
|
||||
default-comparison could never tick.
|
||||
"""
|
||||
step = timezone_step(render(config_with(timezone=timezone)))
|
||||
assert 'data-done="0"' in step, step
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"timezone",
|
||||
["America/New_York", "Europe/Madrid", "Pacific/Auckland"],
|
||||
)
|
||||
def test_timezone_step_carries_the_configured_zone(timezone):
|
||||
"""JS compares data-tz against the browser, so it has to be the real value."""
|
||||
assert f'data-tz="{timezone}"' in timezone_step(render(config_with(timezone=timezone)))
|
||||
|
||||
|
||||
def test_city_does_not_influence_the_timezone_step():
|
||||
"""The coupling this change removes: city said nothing about the timezone,
|
||||
and OR-ing it let a saved city tick the step off with the zone still wrong.
|
||||
|
||||
timezone_step() returns the opening tag only, so this compares the state
|
||||
the step is in -- data-done and data-tz -- and not the label, which does
|
||||
still show the configured city as context and so differs between the two.
|
||||
"""
|
||||
tampa = timezone_step(render(config_with(
|
||||
location={"city": "Tampa", "state": "Florida", "country": "US"})))
|
||||
seattle = timezone_step(render(config_with(
|
||||
location={"city": "Seattle", "state": "Washington", "country": "US"})))
|
||||
assert tampa == seattle
|
||||
|
||||
|
||||
def test_missing_timezone_leaves_the_step_open():
|
||||
"""Nothing saved means nothing to verify: the step stays unticked and the
|
||||
JS bails on the empty value rather than comparing against ''."""
|
||||
step = timezone_step(render(config_with(timezone="")))
|
||||
assert 'data-tz=""' in step
|
||||
assert 'data-done="0"' in step
|
||||
|
||||
|
||||
def test_zone_comparison_asks_for_the_time_of_day():
|
||||
"""Guard on the Intl options, which look like a stylistic choice.
|
||||
|
||||
dateStyle/timeStyle are late additions (Firefox shipped them in 91). An
|
||||
implementation that does not know them ignores them and formats the date
|
||||
alone -- which compares New York, Chicago and Madrid as equal and ticks
|
||||
the step for a timezone that is plainly wrong. Explicit numeric fields
|
||||
have been in Intl since ECMA-402 v1.
|
||||
"""
|
||||
template = (PROJECT_ROOT / "web_interface" / "templates" / "v3"
|
||||
/ "partials" / "overview.html").read_text()
|
||||
body = template[template.index("function sameZone"):]
|
||||
body = body[:body.index("}())")]
|
||||
# The comment above the options names dateStyle/timeStyle to explain why
|
||||
# they are not used, so match on code only.
|
||||
body = "\n".join(line for line in body.splitlines()
|
||||
if not line.lstrip().startswith("//"))
|
||||
assert "dateStyle" not in body and "timeStyle" not in body, (
|
||||
"zone comparison must not depend on dateStyle/timeStyle")
|
||||
for field in ("hour:", "minute:", "year:", "month:", "day:"):
|
||||
assert field in body, f"zone comparison dropped {field!r}"
|
||||
|
||||
|
||||
def test_zone_comparison_samples_both_sides_of_dst():
|
||||
"""One instant is not enough, and the shortfall is invisible for months.
|
||||
|
||||
America/New_York and America/Lima hold the same offset all winter, so a
|
||||
check against now alone ticks the step in January for a panel that runs an
|
||||
hour off from March. The comparison has to sample instants either side of
|
||||
DST -- mid-January and mid-July, which covers both hemispheres.
|
||||
"""
|
||||
template = (PROJECT_ROOT / "web_interface" / "templates" / "v3"
|
||||
/ "partials" / "overview.html").read_text()
|
||||
body = template[template.index("function sameZone"):]
|
||||
body = body[:body.index("}())")]
|
||||
code = "\n".join(line for line in body.splitlines()
|
||||
if not line.lstrip().startswith("//"))
|
||||
assert "Date.UTC" in code, (
|
||||
"zone comparison samples only the current instant, so zones that "
|
||||
"coincide seasonally would read as equal")
|
||||
assert code.count("Date.UTC") >= 2, "expected an instant either side of DST"
|
||||
|
||||
|
||||
def _stamp(zone, instant):
|
||||
"""The JS comparison's algorithm, for pinning what it must decide.
|
||||
|
||||
There is no JS runtime here (and the repo has no JS test infra), so this
|
||||
mirrors sameZone rather than executing it: same instants, same wall-clock
|
||||
equality. It records the verdicts the shipped code has to reach.
|
||||
"""
|
||||
from zoneinfo import ZoneInfo
|
||||
return instant.astimezone(ZoneInfo(zone)).strftime("%m/%d/%Y %H:%M")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"left,right,equivalent",
|
||||
[
|
||||
# Aliases: one zone under two names.
|
||||
("Asia/Calcutta", "Asia/Kolkata", True),
|
||||
("Europe/Kiev", "Europe/Kyiv", True),
|
||||
# Same rules year-round: either renders the same times, so a panel set
|
||||
# to one and browsed from the other is correctly configured.
|
||||
("America/New_York", "America/Toronto", True),
|
||||
# Coincide in winter only -- the case a single-instant check gets wrong.
|
||||
("America/New_York", "America/Lima", False),
|
||||
("America/Phoenix", "America/Los_Angeles", False),
|
||||
("Australia/Sydney", "Pacific/Guadalcanal", False),
|
||||
# Plainly different.
|
||||
("America/New_York", "America/Chicago", False),
|
||||
("America/New_York", "Europe/Madrid", False),
|
||||
],
|
||||
)
|
||||
def test_which_zone_pairs_must_count_as_the_same(left, right, equivalent):
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
year = 2026
|
||||
instants = [datetime(year, 1, 15, 12, tzinfo=ZoneInfo("UTC")),
|
||||
datetime(year, 7, 15, 12, tzinfo=ZoneInfo("UTC"))]
|
||||
matched = all(_stamp(left, at) == _stamp(right, at) for at in instants)
|
||||
assert matched is equivalent, (
|
||||
f"{left} vs {right}: sampling both seasons gave {matched}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hardware,expected",
|
||||
[
|
||||
({"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1}, "1"),
|
||||
({"rows": 0, "cols": 0, "chain_length": 0, "parallel": 1}, "0"),
|
||||
],
|
||||
)
|
||||
def test_panel_size_step_still_reflects_config(hardware, expected):
|
||||
"""Regression guard: the hardware step is still decided server-side."""
|
||||
config = config_with()
|
||||
config["display"]["hardware"] = hardware
|
||||
body = render(config)
|
||||
match = re.search(r"<button[^>]*data-tab=\"display\"[^>]*>", body)
|
||||
assert match, "panel-size step not found"
|
||||
assert f'data-done="{expected}"' in match.group(0), match.group(0)
|
||||
@@ -1,180 +0,0 @@
|
||||
"""
|
||||
Tests for scripts/download_pixlet.sh -- release-tag resolution and download guards.
|
||||
|
||||
Background: Starlark apps render through the pixlet binary, and the installer
|
||||
that fetches it failed silently. It resolved the release tag by grepping the
|
||||
GitHub API response for '"tag_name"' and taking the last quoted token on the
|
||||
match with a greedy sed. When the response arrives on one line that token is
|
||||
"mentions_count", not the tag, so the script built a URL for a release that
|
||||
cannot exist -- and `curl -L -o` without -f wrote the 404 body to the file and
|
||||
exited 0, so the first sign of trouble was tar reporting "not in gzip format"
|
||||
about a page of HTML.
|
||||
|
||||
The API is pretty-printed by default, which is exactly why this needs a test:
|
||||
by hand the old command looks correct, and the failure only appears when the
|
||||
formatting changes. These drive the real script with a stubbed curl on PATH, so
|
||||
both response shapes are covered without touching the network.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "download_pixlet.sh"
|
||||
|
||||
PRETTY = """{
|
||||
"url": "https://api.github.com/repos/tronbyt/pixlet/releases/12345",
|
||||
"id": 12345,
|
||||
"tag_name": "v0.53.1",
|
||||
"name": "v0.53.1",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"mentions_count": 3
|
||||
}
|
||||
"""
|
||||
|
||||
# The shape that broke it: one line, and the last quoted token is not the tag.
|
||||
MINIFIED = (
|
||||
'{"url":"https://api.github.com/repos/tronbyt/pixlet/releases/12345",'
|
||||
'"id":12345,"tag_name":"v0.53.1","name":"v0.53.1","draft":false,'
|
||||
'"prerelease":false,"mentions_count":3}'
|
||||
)
|
||||
|
||||
|
||||
def run_script(tmp_path, api_body, download=None):
|
||||
"""Run the real script against a stubbed curl.
|
||||
|
||||
Args:
|
||||
api_body: what the stub returns for the api.github.com request.
|
||||
download: bytes to write for a release-asset request, or None to make
|
||||
that request fail the way `curl -f` does on an HTTP error.
|
||||
"""
|
||||
root = tmp_path / "project"
|
||||
(root / "scripts").mkdir(parents=True)
|
||||
shutil.copy(SCRIPT, root / "scripts" / "download_pixlet.sh")
|
||||
|
||||
api_file = tmp_path / "api.json"
|
||||
api_file.write_text(api_body)
|
||||
|
||||
stub_dir = tmp_path / "stub"
|
||||
stub_dir.mkdir()
|
||||
asset_file = tmp_path / "asset.bin"
|
||||
if download is not None:
|
||||
asset_file.write_bytes(download)
|
||||
|
||||
# Stands in for curl, including the -f semantics the fix turns on: without
|
||||
# -f, real curl writes the error body to the output file and exits 0, which
|
||||
# is what let a 404 masquerade as a successful download. The stub has to
|
||||
# honour that or a test of the fix would pass against the old script too.
|
||||
(stub_dir / "curl").write_text(f"""#!/bin/bash
|
||||
out=""
|
||||
url=""
|
||||
fail_on_error=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o) out="$2"; shift 2 ;;
|
||||
-*f*) fail_on_error=1; shift ;;
|
||||
-*) shift ;;
|
||||
*) url="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
if [[ "$url" == *api.github.com* ]]; then
|
||||
cat {api_file}
|
||||
exit 0
|
||||
fi
|
||||
if [ -f "{asset_file}" ]; then
|
||||
cp "{asset_file}" "$out"
|
||||
exit 0
|
||||
fi
|
||||
# No asset: stand in for an HTTP 404.
|
||||
if [ "$fail_on_error" = "1" ]; then
|
||||
exit 22
|
||||
fi
|
||||
printf '<!DOCTYPE html><html>404 Not Found</html>' > "$out"
|
||||
exit 0
|
||||
""")
|
||||
(stub_dir / "curl").chmod(0o755)
|
||||
|
||||
return subprocess.run(
|
||||
["bash", str(root / "scripts" / "download_pixlet.sh")],
|
||||
capture_output=True, text=True,
|
||||
env={"PATH": f"{stub_dir}:/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
"PIXLET_VERSION": "latest"},
|
||||
)
|
||||
|
||||
|
||||
def resolved_version(result):
|
||||
match = re.search(r"^Version: (.+)$", result.stdout, re.M)
|
||||
assert match, f"no version line in output:\n{result.stdout}"
|
||||
return match.group(1).strip()
|
||||
|
||||
|
||||
def test_script_is_syntactically_valid():
|
||||
result = subprocess.run(["bash", "-n", str(SCRIPT)], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,label", [(PRETTY, "pretty"), (MINIFIED, "minified")])
|
||||
def test_tag_is_resolved_from_either_response_shape(tmp_path, body, label):
|
||||
"""The minified case is the regression: the last quoted token there is
|
||||
"mentions_count", which is what the old greedy sed captured."""
|
||||
result = run_script(tmp_path, body)
|
||||
assert resolved_version(result) == "v0.53.1", f"{label}: {result.stdout}"
|
||||
assert "mentions_count" not in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag",
|
||||
["mentions_count", "v0.53garbage", "0.53", "v0.5", "", "v0.53.1 ; echo pwned"],
|
||||
)
|
||||
def test_a_tag_that_is_not_a_release_falls_back(tmp_path, tag):
|
||||
"""A wrong-but-non-empty value is what made the original bug silent, so the
|
||||
check is on the shape. Partial matches must not pass: "v0.53garbage" and
|
||||
"0.53" would build a URL for a release that cannot exist."""
|
||||
result = run_script(tmp_path, '{"tag_name": "%s"}' % tag)
|
||||
assert resolved_version(result) == "v0.50.2", result.stdout
|
||||
assert "using fallback" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", ["v0.53.1", "v1.0.0", "v0.54.0-rc.1", "v1.2.3+build.4"])
|
||||
def test_real_release_tag_shapes_are_accepted(tmp_path, tag):
|
||||
assert resolved_version(run_script(tmp_path, '{"tag_name": "%s"}' % tag)) == tag
|
||||
|
||||
|
||||
def test_an_http_error_is_reported_as_a_failed_download(tmp_path):
|
||||
"""Without curl -f the 404 body lands in the file and curl exits 0, so the
|
||||
failure surfaced two steps later as tar complaining about gzip -- about
|
||||
what was really a page of HTML. It has to be reported where it happened.
|
||||
|
||||
Both versions end at 0/1, so asserting only on the count would pass against
|
||||
the old script; the discriminating part is which layer reports it.
|
||||
"""
|
||||
result = run_script(tmp_path, PRETTY, download=None)
|
||||
assert "Download complete: 0/1 succeeded" in result.stdout
|
||||
assert "✓ Downloaded" not in result.stdout
|
||||
assert "Failed to download" in result.stdout
|
||||
assert "Failed to extract" not in result.stdout, (
|
||||
"an HTTP error should not surface as an extraction failure")
|
||||
|
||||
|
||||
def test_a_non_archive_response_is_rejected_before_extraction(tmp_path):
|
||||
result = run_script(tmp_path, PRETTY, download=b"<!DOCTYPE html><html>502 Bad Gateway")
|
||||
assert "not a gzip archive" in result.stdout
|
||||
assert "Download complete: 0/1 succeeded" in result.stdout
|
||||
|
||||
|
||||
def test_the_diagnostic_cannot_smuggle_terminal_escapes(tmp_path):
|
||||
"""Those bytes come from whatever answered the request. An error page
|
||||
carrying escapes must not be able to rewrite the output or bury it."""
|
||||
hostile = b"<!DOCTYPE html>\x1b[2J\x1b[31mgone\x1b[0m\rHTTP 200 OK\x08\x08"
|
||||
result = run_script(tmp_path, PRETTY, download=hostile)
|
||||
assert "not a gzip archive" in result.stdout
|
||||
printed = re.search(r"^\s*\(first bytes: (.*)\)$", result.stdout, re.M)
|
||||
assert printed, f"no diagnostic line:\n{result.stdout}"
|
||||
assert "DOCTYPE" in printed.group(1), "the useful part of the page was dropped"
|
||||
for forbidden in ("\x1b", "\r", "\x08", "\x00"):
|
||||
assert forbidden not in printed.group(1), (
|
||||
f"control byte {forbidden!r} reached the terminal")
|
||||
@@ -91,101 +91,3 @@ def test_force_reload_refreshes_stale_in_memory_snapshot():
|
||||
|
||||
# and it asked the cache to bypass the in-memory tier (memory_ttl=0).
|
||||
assert any(c.kwargs.get("memory_ttl") == 0 for c in cache.get.call_args_list)
|
||||
|
||||
|
||||
# --- persisted state that does not match the current schema -------------------
|
||||
#
|
||||
# A record on disk can be missing fields the callers index directly: a partial
|
||||
# write, a restored backup, or a state written by an older schema. Returning it
|
||||
# verbatim raises KeyError inside record_success / record_failure, which takes
|
||||
# the display down in a restart loop that survives reboots, because the bad
|
||||
# entry is on disk and gets read again on the way back up. Observed in the wild
|
||||
# as `plugin clock-simple operation failed: 'circuit_state'`, repeating ~50x a
|
||||
# minute with the panel frozen.
|
||||
|
||||
_INDEXED_FIELDS = (
|
||||
"consecutive_failures", "total_failures", "total_successes",
|
||||
"last_success_time", "last_failure_time", "circuit_state",
|
||||
"circuit_opened_time", "half_open_start_time", "last_error",
|
||||
)
|
||||
|
||||
|
||||
def _tracker_reading(persisted):
|
||||
cache = _cache()
|
||||
cache.get.return_value = persisted
|
||||
return PluginHealthTracker(cache)
|
||||
|
||||
|
||||
def test_partial_state_is_completed_not_returned_raw():
|
||||
"""The shape seen in the wild: one field, everything else absent."""
|
||||
state = _tracker_reading({"circuit_state": "closed"}).get_health_state("p")
|
||||
for field in _INDEXED_FIELDS:
|
||||
assert field in state, f"{field} missing; callers index it directly"
|
||||
|
||||
|
||||
def test_repair_keeps_real_failure_history():
|
||||
"""A record with genuine counts must not be reset to healthy just because
|
||||
an optional field is absent -- that would clear a tripped breaker."""
|
||||
state = _tracker_reading({
|
||||
"consecutive_failures": 5,
|
||||
"total_failures": 5,
|
||||
"circuit_state": "open",
|
||||
}).get_health_state("p")
|
||||
assert state["consecutive_failures"] == 5
|
||||
assert state["total_failures"] == 5
|
||||
assert state["circuit_state"] == "open"
|
||||
|
||||
|
||||
def test_wrong_types_fall_back_per_field():
|
||||
"""A counter persisted as a string would pass a membership check and then
|
||||
fail on the first += 1; an unknown circuit_state would take a branch the
|
||||
breaker has no handling for."""
|
||||
state = _tracker_reading({
|
||||
"consecutive_failures": "3",
|
||||
"circuit_state": "melted",
|
||||
"total_failures": 7,
|
||||
}).get_health_state("p")
|
||||
assert state["consecutive_failures"] == 0
|
||||
assert state["circuit_state"] == CircuitState.CLOSED.value
|
||||
assert state["total_failures"] == 7, "valid neighbours must survive"
|
||||
|
||||
|
||||
def test_newer_fields_are_carried_through():
|
||||
"""degraded/degraded_reason are read with .get() and are not part of the
|
||||
indexed set; repairing must not drop them."""
|
||||
state = _tracker_reading({
|
||||
"circuit_state": "closed", "degraded": True, "degraded_reason": "x",
|
||||
}).get_health_state("p")
|
||||
assert state["degraded"] is True
|
||||
assert state["degraded_reason"] == "x"
|
||||
|
||||
|
||||
def test_recording_against_a_repaired_state_does_not_raise():
|
||||
"""The actual failure: record_failure indexing a field that was not there.
|
||||
|
||||
The seed deliberately omits circuit_state. Seeding a record that *has* it
|
||||
would pass against the old raw-return behaviour too -- the counters are
|
||||
read with .get(), so circuit_state is the only field whose absence used to
|
||||
raise.
|
||||
"""
|
||||
tracker = _tracker_reading({"total_failures": 2, "total_successes": 1})
|
||||
tracker.record_failure("p", Exception("boom"))
|
||||
tracker.record_success("p")
|
||||
|
||||
|
||||
def test_unhashable_or_boolean_values_are_repaired():
|
||||
"""Values that break the repair itself rather than a later caller.
|
||||
|
||||
An unhashable circuit_state raises TypeError inside a set membership test,
|
||||
and bool is a subclass of int, so True would pass as a timestamp and then
|
||||
compare as 1.0 -- expiring a cooldown the moment it opens.
|
||||
"""
|
||||
for bad_state in ({"circuit_state": []}, {"circuit_state": {}}):
|
||||
state = _tracker_reading(bad_state).get_health_state("p")
|
||||
assert state["circuit_state"] == CircuitState.CLOSED.value
|
||||
|
||||
state = _tracker_reading({
|
||||
"circuit_opened_time": True, "last_success_time": False,
|
||||
}).get_health_state("p")
|
||||
assert state["circuit_opened_time"] is None
|
||||
assert state["last_success_time"] is None
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
"""
|
||||
Tests for src/plugin_system/saved_repositories.py — pins the
|
||||
SavedRepositoriesManager contract.
|
||||
|
||||
Covers: the three accepted on-disk load shapes (bare list, wrapped
|
||||
{"repositories": [...]}, anything else -> []) and that saves always write
|
||||
the bare-list form; add/remove/has round trips through a fresh manager;
|
||||
URL normalization post-fix (_clean_url strips only a TRAILING '.git' after
|
||||
trailing slashes — the old unanchored .replace('.git', '') mangled URLs
|
||||
like my.github.io); name derivation and registry-vs-single type
|
||||
classification (the ledmatrix-plugins check is lowercased, the
|
||||
plugins.json check is case-sensitive); and the post-fix rollback of the
|
||||
in-memory list when _save_repositories() fails, so memory never diverges
|
||||
from disk.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from src.plugin_system.saved_repositories import SavedRepositoriesManager
|
||||
|
||||
|
||||
def make_manager(path):
|
||||
return SavedRepositoriesManager(config_path=str(path))
|
||||
|
||||
|
||||
class TestLoading:
|
||||
def test_missing_file_empty_and_not_created(self, tmp_path):
|
||||
path = tmp_path / "repos.json"
|
||||
manager = make_manager(path)
|
||||
assert manager.get_all() == []
|
||||
assert not path.exists()
|
||||
|
||||
def test_bare_list_shape(self, tmp_path):
|
||||
path = tmp_path / "repos.json"
|
||||
entries = [{'url': 'https://github.com/u/r', 'name': 'r', 'type': 'single'}]
|
||||
path.write_text(json.dumps(entries))
|
||||
assert make_manager(path).get_all() == entries
|
||||
|
||||
def test_wrapped_dict_shape(self, tmp_path):
|
||||
path = tmp_path / "repos.json"
|
||||
entries = [{'url': 'https://github.com/u/r', 'name': 'r', 'type': 'single'}]
|
||||
path.write_text(json.dumps({'repositories': entries}))
|
||||
assert make_manager(path).get_all() == entries
|
||||
|
||||
def test_other_shape_yields_empty(self, tmp_path):
|
||||
path = tmp_path / "repos.json"
|
||||
path.write_text(json.dumps({'x': 1}))
|
||||
assert make_manager(path).get_all() == []
|
||||
|
||||
def test_malformed_json_yields_empty_no_raise(self, tmp_path):
|
||||
path = tmp_path / "repos.json"
|
||||
path.write_text("not json {{")
|
||||
assert make_manager(path).get_all() == []
|
||||
|
||||
|
||||
class TestSaveFormat:
|
||||
def test_save_always_writes_bare_list(self, tmp_path):
|
||||
# Even when loaded from the wrapped {"repositories": [...]} form,
|
||||
# the next save normalizes the file to a bare JSON list.
|
||||
path = tmp_path / "repos.json"
|
||||
entries = [{'url': 'https://github.com/u/r', 'name': 'r', 'type': 'single'}]
|
||||
path.write_text(json.dumps({'repositories': entries}))
|
||||
manager = make_manager(path)
|
||||
assert manager.add("https://github.com/u/r2") is True
|
||||
on_disk = json.loads(path.read_text())
|
||||
assert isinstance(on_disk, list)
|
||||
assert len(on_disk) == 2
|
||||
|
||||
|
||||
class TestAdd:
|
||||
def test_round_trip_creates_parents_and_reloads(self, tmp_path):
|
||||
path = tmp_path / "sub" / "repos.json"
|
||||
manager = make_manager(path)
|
||||
assert manager.add("https://github.com/user/repo") is True
|
||||
assert path.exists()
|
||||
entry = manager.get_all()[0]
|
||||
assert entry['url'] == "https://github.com/user/repo"
|
||||
assert entry['name'] == "repo"
|
||||
assert entry['type'] == "single"
|
||||
# A fresh manager on the same path sees the persisted entry.
|
||||
fresh = make_manager(path)
|
||||
assert fresh.get_all() == [entry]
|
||||
|
||||
def test_duplicate_returns_false_file_unchanged(self, tmp_path):
|
||||
path = tmp_path / "repos.json"
|
||||
manager = make_manager(path)
|
||||
assert manager.add("https://github.com/user/repo") is True
|
||||
before = path.read_text()
|
||||
assert manager.add("https://github.com/user/repo") is False
|
||||
assert path.read_text() == before
|
||||
assert len(manager.get_all()) == 1
|
||||
|
||||
def test_trailing_git_and_slash_stripped(self, tmp_path):
|
||||
manager = make_manager(tmp_path / "repos.json")
|
||||
assert manager.add("https://github.com/user/repo.git/") is True
|
||||
assert manager.get_all()[0]['url'] == "https://github.com/user/repo"
|
||||
|
||||
def test_interior_dot_git_not_mangled(self, tmp_path):
|
||||
# Regression for the old unanchored .replace('.git', ''): a URL
|
||||
# merely CONTAINING '.git' must be stored verbatim.
|
||||
manager = make_manager(tmp_path / "repos.json")
|
||||
url = "https://github.com/user/my.github.io"
|
||||
assert manager.add(url) is True
|
||||
assert manager.get_all()[0]['url'] == url
|
||||
|
||||
|
||||
class TestNameExtraction:
|
||||
def test_name_derived_from_last_path_segment(self, tmp_path):
|
||||
manager = make_manager(tmp_path / "repos.json")
|
||||
manager.add("https://github.com/user/football-scoreboard")
|
||||
assert manager.get_all()[0]['name'] == "football-scoreboard"
|
||||
|
||||
def test_explicit_name_preserved(self, tmp_path):
|
||||
manager = make_manager(tmp_path / "repos.json")
|
||||
manager.add("https://github.com/user/repo", name="My Repo")
|
||||
assert manager.get_all()[0]['name'] == "My Repo"
|
||||
|
||||
def test_url_without_slash_uses_whole_url(self, tmp_path):
|
||||
manager = make_manager(tmp_path / "repos.json")
|
||||
manager.add("standalone")
|
||||
assert manager.get_all()[0]['name'] == "standalone"
|
||||
|
||||
|
||||
class TestTypeClassification:
|
||||
def _type_of(self, tmp_path, url):
|
||||
manager = make_manager(tmp_path / "repos.json")
|
||||
assert manager.add(url) is True
|
||||
return manager.get_all()[0]['type']
|
||||
|
||||
def test_plugins_json_url_is_registry(self, tmp_path):
|
||||
url = "https://raw.githubusercontent.com/x/main/plugins.json"
|
||||
assert self._type_of(tmp_path, url) == "registry"
|
||||
|
||||
def test_ledmatrix_plugins_check_is_case_insensitive(self, tmp_path):
|
||||
url = "https://github.com/ChuckBuilds/LEDMATRIX-PLUGINS"
|
||||
assert self._type_of(tmp_path, url) == "registry"
|
||||
|
||||
def test_plugins_json_check_is_case_sensitive(self, tmp_path):
|
||||
# Only the 'ledmatrix-plugins' check is lowercased; the
|
||||
# 'plugins.json' substring check is case-sensitive. Pinned.
|
||||
url = "https://example.com/PLUGINS.JSON"
|
||||
assert self._type_of(tmp_path, url) == "single"
|
||||
|
||||
def test_plain_repo_is_single(self, tmp_path):
|
||||
assert self._type_of(tmp_path, "https://github.com/user/repo") == "single"
|
||||
|
||||
def test_get_registry_repositories_filters(self, tmp_path):
|
||||
manager = make_manager(tmp_path / "repos.json")
|
||||
manager.add("https://github.com/user/repo")
|
||||
manager.add("https://raw.githubusercontent.com/x/main/plugins.json")
|
||||
registries = manager.get_registry_repositories()
|
||||
assert len(registries) == 1
|
||||
assert registries[0]['type'] == "registry"
|
||||
|
||||
|
||||
class TestRemove:
|
||||
def test_remove_present_persists(self, tmp_path):
|
||||
path = tmp_path / "repos.json"
|
||||
manager = make_manager(path)
|
||||
manager.add("https://github.com/user/repo")
|
||||
assert manager.remove("https://github.com/user/repo") is True
|
||||
assert manager.get_all() == []
|
||||
assert make_manager(path).get_all() == []
|
||||
|
||||
def test_remove_absent_false_no_write(self, tmp_path):
|
||||
path = tmp_path / "repos.json"
|
||||
manager = make_manager(path)
|
||||
manager.add("https://github.com/user/repo")
|
||||
before = path.read_text()
|
||||
assert manager.remove("https://github.com/user/other") is False
|
||||
assert path.read_text() == before
|
||||
|
||||
def test_remove_with_dirty_url_matches_clean_stored(self, tmp_path):
|
||||
manager = make_manager(tmp_path / "repos.json")
|
||||
manager.add("https://github.com/user/repo")
|
||||
assert manager.remove("https://github.com/user/repo.git/") is True
|
||||
assert manager.get_all() == []
|
||||
|
||||
|
||||
class TestHas:
|
||||
def test_has_applies_url_cleaning(self, tmp_path):
|
||||
manager = make_manager(tmp_path / "repos.json")
|
||||
manager.add("https://x/y")
|
||||
assert manager.has("https://x/y.git/") is True
|
||||
assert manager.has("https://x/z") is False
|
||||
|
||||
|
||||
class TestSaveFailureRollback:
|
||||
def test_add_rolls_back_on_save_failure(self, tmp_path, monkeypatch):
|
||||
# Post-fix: a failed save must not leave a phantom in-memory entry.
|
||||
path = tmp_path / "repos.json"
|
||||
manager = make_manager(path)
|
||||
monkeypatch.setattr(manager, "_save_repositories", lambda: False)
|
||||
assert manager.add("https://github.com/user/repo") is False
|
||||
assert manager.get_all() == []
|
||||
assert not path.exists()
|
||||
|
||||
def test_remove_rolls_back_on_save_failure(self, tmp_path, monkeypatch):
|
||||
path = tmp_path / "repos.json"
|
||||
manager = make_manager(path)
|
||||
manager.add("https://github.com/user/repo") # real save
|
||||
monkeypatch.setattr(manager, "_save_repositories", lambda: False)
|
||||
assert manager.remove("https://github.com/user/repo") is False
|
||||
assert manager.get_all() == [
|
||||
{'url': 'https://github.com/user/repo', 'name': 'repo', 'type': 'single'}
|
||||
]
|
||||
# Disk still has the entry too — memory and disk stay in sync.
|
||||
assert len(json.loads(path.read_text())) == 1
|
||||
|
||||
def test_failed_write_leaves_existing_file_intact(self, tmp_path, monkeypatch):
|
||||
# The save is atomic (temp file + os.replace): a write that dies
|
||||
# mid-serialization must neither truncate the existing file nor
|
||||
# leave a stray .tmp behind.
|
||||
path = tmp_path / "repos.json"
|
||||
manager = make_manager(path)
|
||||
manager.add("https://github.com/user/repo") # real save
|
||||
before = path.read_text()
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise OSError("disk full")
|
||||
monkeypatch.setattr(json, "dump", boom)
|
||||
assert manager.add("https://github.com/user/other") is False
|
||||
|
||||
assert path.read_text() == before
|
||||
assert list(tmp_path.glob("*.tmp")) == []
|
||||
|
||||
|
||||
class TestGetAllCopy:
|
||||
def test_get_all_is_shallow_copy(self, tmp_path):
|
||||
# Characterization: get_all() copies the LIST but not the entry
|
||||
# dicts, so mutating a returned entry mutates internal state.
|
||||
# Appending to the returned list, however, does not. Do not "fix"
|
||||
# without auditing callers that rely on list-copy semantics.
|
||||
manager = make_manager(tmp_path / "repos.json")
|
||||
manager.add("https://github.com/user/repo")
|
||||
returned = manager.get_all()
|
||||
returned.append({'url': 'x'})
|
||||
assert len(manager.get_all()) == 1 # list itself is copied
|
||||
manager.get_all()[0]['name'] = 'hacked'
|
||||
assert manager.get_all()[0]['name'] == 'hacked' # dicts are shared
|
||||
@@ -1,179 +0,0 @@
|
||||
"""
|
||||
Tests for the device-location default: a plugin that ships a location field in
|
||||
its schema must default to the device's configured City/State/Country, not to
|
||||
whatever place the plugin author hard-coded.
|
||||
|
||||
The bug this pins: ledmatrix-weather ships ``"location_city": "Dallas"`` as a
|
||||
schema default, so a user who set Kansas City under General settings but never
|
||||
opened the weather plugin's own config form got Dallas weather — and a radar
|
||||
centred on Dallas — with nothing in config.json to explain it.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
|
||||
|
||||
class FakeConfigManager:
|
||||
"""Minimal stand-in exposing the load_config() SchemaManager relies on."""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.load_count = 0
|
||||
|
||||
def load_config(self):
|
||||
self.load_count += 1
|
||||
return self.config
|
||||
|
||||
|
||||
class ExplodingConfigManager:
|
||||
def load_config(self):
|
||||
raise OSError("config.json is unreadable")
|
||||
|
||||
|
||||
WEATHER_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location_city": {"type": "string", "default": "Dallas"},
|
||||
"location_state": {"type": "string", "default": "Texas"},
|
||||
"location_country": {"type": "string", "default": "US"},
|
||||
"units": {"type": "string", "default": "imperial"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_plugin(plugins_dir, plugin_id, schema):
|
||||
plugin_dir = plugins_dir / plugin_id
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
(plugin_dir / "config_schema.json").write_text(json.dumps(schema))
|
||||
return plugin_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugins_dir(tmp_path):
|
||||
d = tmp_path / "plugin-repos"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
def make_sm(plugins_dir, tmp_path, location):
|
||||
config = {} if location is None else {"location": location}
|
||||
cm = FakeConfigManager(config)
|
||||
sm = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path,
|
||||
config_manager=cm)
|
||||
return sm, cm
|
||||
|
||||
|
||||
class TestDeviceLocationDefaults:
|
||||
def test_device_location_replaces_plugin_default(self, plugins_dir, tmp_path):
|
||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
||||
sm, _ = make_sm(plugins_dir, tmp_path,
|
||||
{"city": "Kansas City", "state": "Missouri", "country": "US"})
|
||||
|
||||
defaults = sm.generate_default_config("ledmatrix-weather")
|
||||
|
||||
assert defaults["location_city"] == "Kansas City"
|
||||
assert defaults["location_state"] == "Missouri"
|
||||
assert defaults["location_country"] == "US"
|
||||
# Non-location defaults are untouched.
|
||||
assert defaults["units"] == "imperial"
|
||||
|
||||
def test_user_set_plugin_value_still_wins(self, plugins_dir, tmp_path):
|
||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
||||
sm, _ = make_sm(plugins_dir, tmp_path,
|
||||
{"city": "Kansas City", "state": "Missouri", "country": "US"})
|
||||
|
||||
defaults = sm.generate_default_config("ledmatrix-weather")
|
||||
merged = sm.merge_with_defaults({"location_city": "Denver"}, defaults)
|
||||
|
||||
assert merged["location_city"] == "Denver"
|
||||
# Fields the user did not override still follow the device.
|
||||
assert merged["location_state"] == "Missouri"
|
||||
|
||||
def test_blank_and_missing_device_fields_leave_schema_default(self, plugins_dir, tmp_path):
|
||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
||||
sm, _ = make_sm(plugins_dir, tmp_path, {"city": "Kansas City", "state": " "})
|
||||
|
||||
defaults = sm.generate_default_config("ledmatrix-weather")
|
||||
|
||||
assert defaults["location_city"] == "Kansas City"
|
||||
assert defaults["location_state"] == "Texas" # blank -> not configured
|
||||
assert defaults["location_country"] == "US" # absent -> schema default
|
||||
|
||||
def test_no_device_location_configured_is_a_no_op(self, plugins_dir, tmp_path):
|
||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
||||
sm, _ = make_sm(plugins_dir, tmp_path, None)
|
||||
|
||||
defaults = sm.generate_default_config("ledmatrix-weather")
|
||||
|
||||
assert defaults["location_city"] == "Dallas"
|
||||
|
||||
def test_no_config_manager_is_a_no_op(self, plugins_dir, tmp_path):
|
||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
||||
sm = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
|
||||
|
||||
assert sm.generate_default_config("ledmatrix-weather")["location_city"] == "Dallas"
|
||||
|
||||
def test_unreadable_config_falls_back_to_schema_defaults(self, plugins_dir, tmp_path):
|
||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
||||
sm = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path,
|
||||
config_manager=ExplodingConfigManager())
|
||||
|
||||
assert sm.generate_default_config("ledmatrix-weather")["location_city"] == "Dallas"
|
||||
|
||||
|
||||
class TestScopedToNamespacedKeys:
|
||||
def test_bare_state_key_is_not_rewritten(self, plugins_dir, tmp_path):
|
||||
"""ledmatrix-elections' ``state`` is a two-letter code, not a place name."""
|
||||
write_plugin(plugins_dir, "ledmatrix-elections", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {"type": "string", "default": "CA"},
|
||||
"city": {"type": "string", "default": "Springfield"},
|
||||
},
|
||||
})
|
||||
sm, _ = make_sm(plugins_dir, tmp_path,
|
||||
{"city": "Kansas City", "state": "Missouri", "country": "US"})
|
||||
|
||||
defaults = sm.generate_default_config("ledmatrix-elections")
|
||||
|
||||
assert defaults["state"] == "CA"
|
||||
assert defaults["city"] == "Springfield"
|
||||
|
||||
def test_plugin_without_location_fields_never_reads_config(self, plugins_dir, tmp_path):
|
||||
write_plugin(plugins_dir, "clock-simple", {
|
||||
"type": "object",
|
||||
"properties": {"format": {"type": "string", "default": "12h"}},
|
||||
})
|
||||
sm, cm = make_sm(plugins_dir, tmp_path, {"city": "Kansas City"})
|
||||
|
||||
defaults = sm.generate_default_config("clock-simple")
|
||||
|
||||
assert defaults["format"] == "12h"
|
||||
assert cm.load_count == 0
|
||||
|
||||
|
||||
class TestCachingStaysFresh:
|
||||
def test_location_change_is_picked_up_through_the_defaults_cache(self, plugins_dir, tmp_path):
|
||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
||||
sm, cm = make_sm(plugins_dir, tmp_path, {"city": "Kansas City"})
|
||||
|
||||
assert sm.generate_default_config("ledmatrix-weather")["location_city"] == "Kansas City"
|
||||
|
||||
cm.config["location"]["city"] = "Omaha"
|
||||
|
||||
# Second call is served from the defaults cache, but must not serve a
|
||||
# stale location.
|
||||
assert sm.generate_default_config("ledmatrix-weather")["location_city"] == "Omaha"
|
||||
|
||||
def test_cached_defaults_are_not_mutated_by_the_overlay(self, plugins_dir, tmp_path):
|
||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
||||
sm, cm = make_sm(plugins_dir, tmp_path, {"city": "Kansas City"})
|
||||
|
||||
sm.generate_default_config("ledmatrix-weather")
|
||||
assert sm._defaults_cache["ledmatrix-weather"]["location_city"] == "Dallas"
|
||||
|
||||
cm.config.pop("location")
|
||||
assert sm.generate_default_config("ledmatrix-weather")["location_city"] == "Dallas"
|
||||
@@ -1,377 +0,0 @@
|
||||
"""Gap tests for src/skin_system/skin_runtime.py: the discovery cache,
|
||||
module namespacing internals, API gating edge cases, and targeting.
|
||||
|
||||
test/test_skin_system.py already covers discovery validation, load_skin
|
||||
basics, and build_context — nothing here duplicates those.
|
||||
|
||||
NOTE: every test uses a UNIQUE skin id. load_skin caches the entry
|
||||
module in sys.modules per skin id and never re-executes it, so reusing
|
||||
an id across tests would silently serve another test's module.
|
||||
"""
|
||||
|
||||
import builtins
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# skin_runtime -> skin_base can transitively reach hardware modules via
|
||||
# sports imports in sibling tests' processes; stub the matrix driver
|
||||
# before importing, matching test_skin_system.py.
|
||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
||||
|
||||
from src.skin_system import skin_runtime
|
||||
from src.skin_system.skin_base import SKIN_API_VERSION, ScoreboardSkin
|
||||
|
||||
|
||||
DEFAULT_BODY = (
|
||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||
"class {cls}(ScoreboardSkin):\n"
|
||||
" def render_live(self, ctx, game):\n"
|
||||
" return True\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_runtime_state():
|
||||
"""Clear the discovery cache and any skin modules this test creates."""
|
||||
skin_runtime._discovery_cache.clear()
|
||||
before = {k for k in sys.modules if k.startswith("_skin_")}
|
||||
yield
|
||||
skin_runtime._discovery_cache.clear()
|
||||
created = [k for k in sys.modules
|
||||
if k.startswith("_skin_") and k not in before]
|
||||
for k in created:
|
||||
sys.modules.pop(k, None)
|
||||
|
||||
|
||||
def make_skin(skins_dir: Path, skin_id: str, *,
|
||||
api_version: str = SKIN_API_VERSION,
|
||||
class_name: str = "TestSkin",
|
||||
body: str = None,
|
||||
extra_files: dict = None,
|
||||
entry_point: str = None,
|
||||
manifest_id: str = None,
|
||||
manifest_extra: dict = None,
|
||||
write_entry: bool = True) -> Path:
|
||||
"""Write a skin package directory and return its path."""
|
||||
skin_dir = skins_dir / skin_id
|
||||
skin_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest = {
|
||||
"id": manifest_id or skin_id,
|
||||
"name": skin_id,
|
||||
"version": "1.0.0",
|
||||
"skin_api_version": api_version,
|
||||
"class_name": class_name,
|
||||
}
|
||||
if entry_point:
|
||||
manifest["entry_point"] = entry_point
|
||||
manifest.update(manifest_extra or {})
|
||||
(skin_dir / "skin.json").write_text(json.dumps(manifest))
|
||||
if write_entry:
|
||||
entry_name = entry_point or "skin.py"
|
||||
(skin_dir / entry_name).write_text(
|
||||
body if body is not None else DEFAULT_BODY.format(cls=class_name))
|
||||
for name, content in (extra_files or {}).items():
|
||||
(skin_dir / name).write_text(content)
|
||||
return skin_dir
|
||||
|
||||
|
||||
def counting_read_manifest(monkeypatch):
|
||||
"""Wrap skin_runtime._read_manifest with a call counter."""
|
||||
original = skin_runtime._read_manifest
|
||||
counter = {"count": 0}
|
||||
|
||||
def wrapper(skin_dir):
|
||||
counter["count"] += 1
|
||||
return original(skin_dir)
|
||||
|
||||
monkeypatch.setattr(skin_runtime, "_read_manifest", wrapper)
|
||||
return counter
|
||||
|
||||
|
||||
def bump_mtime(path: Path, offset: float = 100.0):
|
||||
"""Set a distinct, strictly later mtime so the fingerprint changes."""
|
||||
t = time.time() + offset
|
||||
os.utime(path, (t, t))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A. Discovery cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDiscoveryCache:
|
||||
def test_second_call_serves_cache(self, tmp_path, monkeypatch):
|
||||
make_skin(tmp_path, "t01-cache-hit")
|
||||
counter = counting_read_manifest(monkeypatch)
|
||||
first = skin_runtime.discover_skins(tmp_path)
|
||||
count_after_first = counter["count"]
|
||||
assert count_after_first >= 1
|
||||
second = skin_runtime.discover_skins(tmp_path)
|
||||
assert counter["count"] == count_after_first # no re-read
|
||||
assert second == first
|
||||
assert "t01-cache-hit" in second
|
||||
|
||||
def test_manifest_edit_invalidates_without_force_refresh(self, tmp_path):
|
||||
skin_dir = make_skin(tmp_path, "t02-edit")
|
||||
skins = skin_runtime.discover_skins(tmp_path)
|
||||
assert skins["t02-edit"]["name"] == "t02-edit"
|
||||
|
||||
manifest_path = skin_dir / "skin.json"
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
manifest["name"] = "renamed"
|
||||
manifest_path.write_text(json.dumps(manifest))
|
||||
bump_mtime(manifest_path)
|
||||
|
||||
skins = skin_runtime.discover_skins(tmp_path) # no force_refresh
|
||||
assert skins["t02-edit"]["name"] == "renamed"
|
||||
|
||||
def test_new_skin_dir_invalidates(self, tmp_path):
|
||||
make_skin(tmp_path, "t03-first")
|
||||
assert set(skin_runtime.discover_skins(tmp_path)) == {"t03-first"}
|
||||
|
||||
new_dir = make_skin(tmp_path, "t03-second")
|
||||
bump_mtime(new_dir / "skin.json")
|
||||
bump_mtime(tmp_path)
|
||||
|
||||
skins = skin_runtime.discover_skins(tmp_path) # no force_refresh
|
||||
assert set(skins) == {"t03-first", "t03-second"}
|
||||
|
||||
def test_py_file_change_does_not_invalidate(self, tmp_path, monkeypatch):
|
||||
# PIN: the fingerprint only globs */skin.json — editing a skin's
|
||||
# .py file alone does NOT invalidate the cache; the cached
|
||||
# manifests are still served (a code change needs a restart).
|
||||
skin_dir = make_skin(tmp_path, "t04-pyedit")
|
||||
counter = counting_read_manifest(monkeypatch)
|
||||
skin_runtime.discover_skins(tmp_path)
|
||||
count_after_first = counter["count"]
|
||||
|
||||
(skin_dir / "skin.py").write_text("# rewritten\n" +
|
||||
DEFAULT_BODY.format(cls="TestSkin"))
|
||||
bump_mtime(skin_dir / "skin.py")
|
||||
|
||||
skins = skin_runtime.discover_skins(tmp_path)
|
||||
assert counter["count"] == count_after_first # cache still served
|
||||
assert "t04-pyedit" in skins
|
||||
|
||||
def test_force_refresh_rereads_with_unchanged_fingerprint(self, tmp_path,
|
||||
monkeypatch):
|
||||
make_skin(tmp_path, "t05-force")
|
||||
counter = counting_read_manifest(monkeypatch)
|
||||
skin_runtime.discover_skins(tmp_path)
|
||||
count_after_first = counter["count"]
|
||||
skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||
assert counter["count"] > count_after_first
|
||||
|
||||
def test_result_mapping_is_copy_but_manifests_shared(self, tmp_path):
|
||||
make_skin(tmp_path, "t06-copy")
|
||||
result = skin_runtime.discover_skins(tmp_path)
|
||||
|
||||
# Mutating the returned mapping does not poison the cache...
|
||||
del result["t06-copy"]
|
||||
again = skin_runtime.discover_skins(tmp_path) # cache hit
|
||||
assert "t06-copy" in again
|
||||
|
||||
# ...but the inner manifest dicts ARE shared with the cache (pin).
|
||||
again["t06-copy"]["name"] = "mutated-inner"
|
||||
third = skin_runtime.discover_skins(tmp_path) # cache hit
|
||||
assert third["t06-copy"]["name"] == "mutated-inner"
|
||||
|
||||
def test_missing_directory_returns_empty_and_caches_nothing(self, tmp_path):
|
||||
missing = tmp_path / "not-yet"
|
||||
assert skin_runtime.discover_skins(missing) == {}
|
||||
assert str(missing) not in skin_runtime._discovery_cache
|
||||
|
||||
# Creating the directory later is picked up without force_refresh.
|
||||
make_skin(missing, "t07-late")
|
||||
skins = skin_runtime.discover_skins(missing)
|
||||
assert "t07-late" in skins
|
||||
|
||||
def test_hidden_underscore_and_plain_file_entries_skipped(self, tmp_path):
|
||||
make_skin(tmp_path, ".hidden-skin")
|
||||
make_skin(tmp_path, "_private-skin")
|
||||
(tmp_path / "stray-file").write_text("not a directory")
|
||||
make_skin(tmp_path, "t08-good")
|
||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||
assert set(skins) == {"t08-good"}
|
||||
|
||||
def test_manifest_id_mismatch_keys_by_manifest_id(self, tmp_path):
|
||||
make_skin(tmp_path, "t09-dirname", manifest_id="t09-manifest-id")
|
||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||
assert "t09-manifest-id" in skins
|
||||
assert "t09-dirname" not in skins
|
||||
assert skins["t09-manifest-id"]["_skin_dir"].endswith("t09-dirname")
|
||||
|
||||
def test_falsy_required_field_drops_skin(self, tmp_path):
|
||||
make_skin(tmp_path, "t10-empty-class", class_name="")
|
||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||
assert skins == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B. Module namespacing (_load_skin_module via load_skin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BODY_WITH_HELPERS = (
|
||||
"import helpers\n"
|
||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||
"class TestSkin(ScoreboardSkin):\n"
|
||||
" pass\n"
|
||||
)
|
||||
|
||||
|
||||
class TestModuleNamespacing:
|
||||
def test_namespaced_sys_modules_keys(self, tmp_path):
|
||||
make_skin(tmp_path, "t11-ns", body=BODY_WITH_HELPERS,
|
||||
extra_files={"helpers.py": "VALUE = 11\n"})
|
||||
skin = skin_runtime.load_skin("t11-ns", skins_dir=tmp_path)
|
||||
assert skin is not None
|
||||
assert "_skin_t11-ns_skin" in sys.modules
|
||||
assert "_skin_t11-ns_helpers" in sys.modules
|
||||
|
||||
def test_preseeded_bare_name_restored(self, tmp_path, monkeypatch):
|
||||
sentinel = object()
|
||||
monkeypatch.setitem(sys.modules, "helpers", sentinel)
|
||||
make_skin(tmp_path, "t12a-restore", body=BODY_WITH_HELPERS,
|
||||
extra_files={"helpers.py": "VALUE = 'a'\n"})
|
||||
skin = skin_runtime.load_skin("t12a-restore", skins_dir=tmp_path)
|
||||
assert skin is not None
|
||||
assert sys.modules["helpers"] is sentinel
|
||||
|
||||
def test_absent_bare_name_stays_absent(self, tmp_path):
|
||||
saved = sys.modules.pop("helpers", None)
|
||||
try:
|
||||
assert "helpers" not in sys.modules
|
||||
make_skin(tmp_path, "t12b-absent", body=BODY_WITH_HELPERS,
|
||||
extra_files={"helpers.py": "VALUE = 'b'\n"})
|
||||
skin = skin_runtime.load_skin("t12b-absent", skins_dir=tmp_path)
|
||||
assert skin is not None
|
||||
assert "helpers" not in sys.modules
|
||||
finally:
|
||||
if saved is not None:
|
||||
sys.modules["helpers"] = saved
|
||||
|
||||
def test_stdlib_shadowing_sibling_leaves_real_module_intact(self, tmp_path):
|
||||
real_json = sys.modules["json"]
|
||||
make_skin(tmp_path, "t12c-json",
|
||||
extra_files={"json.py": "SKIN_LOCAL = True\n"})
|
||||
skin = skin_runtime.load_skin("t12c-json", skins_dir=tmp_path)
|
||||
assert skin is not None
|
||||
assert sys.modules["json"] is real_json
|
||||
assert not hasattr(sys.modules["json"], "SKIN_LOCAL")
|
||||
assert json.loads('{"ok": 1}') == {"ok": 1} # stdlib still works
|
||||
# The skin's copy lives only under its namespaced alias.
|
||||
assert getattr(sys.modules["_skin_t12c-json_json"], "SKIN_LOCAL") is True
|
||||
|
||||
def test_entry_module_executed_once_across_loads(self, tmp_path,
|
||||
monkeypatch):
|
||||
executions = []
|
||||
monkeypatch.setattr(builtins, "_t13_skin_executions", executions,
|
||||
raising=False)
|
||||
body = (
|
||||
"import builtins\n"
|
||||
"builtins._t13_skin_executions.append(1)\n"
|
||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||
"class TestSkin(ScoreboardSkin):\n"
|
||||
" pass\n"
|
||||
)
|
||||
make_skin(tmp_path, "t13-cached", body=body)
|
||||
for _ in range(3):
|
||||
skin = skin_runtime.load_skin("t13-cached", skins_dir=tmp_path)
|
||||
assert skin is not None
|
||||
assert len(executions) == 1 # module executed exactly once
|
||||
|
||||
def test_sibling_import_failure_returns_none_and_restores_bare(
|
||||
self, tmp_path, monkeypatch):
|
||||
sentinel = object()
|
||||
monkeypatch.setitem(sys.modules, "helpers", sentinel)
|
||||
make_skin(tmp_path, "t14-sibfail", body=BODY_WITH_HELPERS,
|
||||
extra_files={"helpers.py": "raise RuntimeError('sibling boom')\n"})
|
||||
assert skin_runtime.load_skin("t14-sibfail", skins_dir=tmp_path) is None
|
||||
assert sys.modules["helpers"] is sentinel
|
||||
|
||||
def test_missing_entry_point_file(self, tmp_path):
|
||||
make_skin(tmp_path, "t15-noentry", write_entry=False)
|
||||
assert skin_runtime.load_skin("t15-noentry", skins_dir=tmp_path) is None
|
||||
|
||||
def test_custom_entry_point(self, tmp_path):
|
||||
make_skin(tmp_path, "t16-custom", entry_point="render.py")
|
||||
skin = skin_runtime.load_skin("t16-custom", skins_dir=tmp_path)
|
||||
assert isinstance(skin, ScoreboardSkin)
|
||||
assert "_skin_t16-custom_render" in sys.modules
|
||||
assert "_skin_t16-custom_skin" not in sys.modules
|
||||
|
||||
def test_class_name_pointing_at_unrelated_class(self, tmp_path):
|
||||
body = "class NotASkin:\n pass\n"
|
||||
make_skin(tmp_path, "t17a-wrongclass", body=body,
|
||||
class_name="NotASkin")
|
||||
assert skin_runtime.load_skin("t17a-wrongclass",
|
||||
skins_dir=tmp_path) is None
|
||||
|
||||
def test_class_name_pointing_at_instance(self, tmp_path):
|
||||
body = (
|
||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||
"class MySkin(ScoreboardSkin):\n"
|
||||
" pass\n"
|
||||
"obj = MySkin({}, {})\n"
|
||||
)
|
||||
make_skin(tmp_path, "t17b-instance", body=body, class_name="obj")
|
||||
assert skin_runtime.load_skin("t17b-instance",
|
||||
skins_dir=tmp_path) is None
|
||||
|
||||
def test_constructor_raising_returns_none(self, tmp_path):
|
||||
body = (
|
||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||
"class TestSkin(ScoreboardSkin):\n"
|
||||
" def __init__(self, manifest, options):\n"
|
||||
" raise ValueError('ctor boom')\n"
|
||||
)
|
||||
make_skin(tmp_path, "t18-ctor", body=body)
|
||||
assert skin_runtime.load_skin("t18-ctor", skins_dir=tmp_path) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C. API gate + targeting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApiGateAndTargeting:
|
||||
def test_same_major_higher_minor_loads(self, tmp_path):
|
||||
make_skin(tmp_path, "t19-minor", api_version="1.9.0")
|
||||
skin = skin_runtime.load_skin("t19-minor", skins_dir=tmp_path)
|
||||
assert isinstance(skin, ScoreboardSkin)
|
||||
|
||||
def test_malformed_api_version_refused(self, tmp_path):
|
||||
make_skin(tmp_path, "t20-malformed", api_version="abc")
|
||||
assert skin_runtime.load_skin("t20-malformed",
|
||||
skins_dir=tmp_path) is None
|
||||
|
||||
@pytest.mark.parametrize("manifest,sport,sport_key,expected", [
|
||||
# No targets key at all -> matches everything
|
||||
({"id": "x"}, "baseball", "mlb", True),
|
||||
({"id": "x"}, None, None, True),
|
||||
# Empty targets dict -> matches everything
|
||||
({"id": "x", "targets": {}}, "hockey", None, True),
|
||||
# sports family match
|
||||
({"id": "x", "targets": {"sports": ["baseball"]}},
|
||||
"baseball", None, True),
|
||||
# sport_keys exact match
|
||||
({"id": "x", "targets": {"sport_keys": ["milb"]}},
|
||||
None, "milb", True),
|
||||
# OR semantics: sport_keys matches even though sports excludes it
|
||||
({"id": "x", "targets": {"sports": ["hockey"],
|
||||
"sport_keys": ["milb"]}},
|
||||
"baseball", "milb", True),
|
||||
# Neither matches
|
||||
({"id": "x", "targets": {"sports": ["hockey"]}},
|
||||
"baseball", None, False),
|
||||
({"id": "x", "targets": {"sports": ["hockey"],
|
||||
"sport_keys": ["nhl"]}},
|
||||
"baseball", "milb", False),
|
||||
])
|
||||
def test_skin_matches_target(self, manifest, sport, sport_key, expected):
|
||||
assert skin_runtime.skin_matches_target(
|
||||
manifest, sport, sport_key) is expected
|
||||
@@ -878,200 +878,3 @@ class TestCapabilityExports:
|
||||
def test_rotation_strategy_base_requires_a_schedule(self):
|
||||
with pytest.raises(NotImplementedError):
|
||||
RotationStrategy().schedule([game("a")])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Celebrations: rendering + previously untested edges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
||||
|
||||
|
||||
class _RenderableLive(_FakeLive):
|
||||
"""A _FakeLive that can actually execute _draw_celebration_layout:
|
||||
real fonts, a display manager holding a real PIL image, and the two
|
||||
SportsCore drawing seams the mixin calls."""
|
||||
|
||||
def __init__(self, mode_config=None, favorite_teams=None,
|
||||
width=128, height=32, with_matrix=True):
|
||||
super().__init__(mode_config=mode_config, favorite_teams=favorite_teams)
|
||||
font = ImageFont.load_default()
|
||||
self.fonts = {"time": font, "status": font, "score": font}
|
||||
self.display_width = width
|
||||
self.display_height = height
|
||||
dm = MagicMock()
|
||||
if with_matrix:
|
||||
dm.matrix.width = width
|
||||
dm.matrix.height = height
|
||||
else:
|
||||
dm.matrix = None
|
||||
dm.image = Image.new("RGB", (width, height))
|
||||
self.display_manager = dm
|
||||
self.logo_calls = []
|
||||
|
||||
def _load_and_resize_logo(self, team_id, abbr, path, url):
|
||||
self.logo_calls.append(abbr)
|
||||
logo = Image.new("RGBA", (10, 10), (0, 200, 0, 255))
|
||||
return logo
|
||||
|
||||
def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255)):
|
||||
draw.text(position, str(text), font=font, fill=fill)
|
||||
|
||||
|
||||
class _RenderableCelebrating(CelebrationMixin, _RenderableLive):
|
||||
pass
|
||||
|
||||
|
||||
def _armed(manager, *, kind="score", side="home", started_ago=0.0):
|
||||
manager._start_celebration(
|
||||
game("g1", home_score=7, away_score=3), kind,
|
||||
scored_side=side, team_abbr="HOM", away_score=3, home_score=7,
|
||||
points=7,
|
||||
)
|
||||
manager.active_celebration["started_at"] = time.time() - started_ago
|
||||
return manager.active_celebration
|
||||
|
||||
|
||||
class TestDrawCelebrationLayout:
|
||||
"""The takeover render path, executed for real (previously always
|
||||
mocked out)."""
|
||||
|
||||
def test_renders_and_hands_frame_to_display_manager(self):
|
||||
manager = _RenderableCelebrating()
|
||||
celebration = _armed(manager)
|
||||
manager._draw_celebration_layout(celebration)
|
||||
# The final frame was assigned and pushed.
|
||||
assert isinstance(manager.display_manager.image, Image.Image)
|
||||
assert manager.display_manager.image.mode == "RGB"
|
||||
assert manager.display_manager.image.size == (128, 32)
|
||||
manager.display_manager.update_display.assert_called_once()
|
||||
assert manager.display_manager.image.convert("L").getbbox() is not None
|
||||
|
||||
def test_force_clear_clears_display_first(self):
|
||||
manager = _RenderableCelebrating()
|
||||
celebration = _armed(manager)
|
||||
manager._draw_celebration_layout(celebration, force_clear=True)
|
||||
manager.display_manager.clear.assert_called_once()
|
||||
|
||||
def test_flash_background_within_first_window(self):
|
||||
# elapsed < 1.2 with int(elapsed/0.2) even -> flash color backdrop.
|
||||
manager = _RenderableCelebrating()
|
||||
celebration = _armed(manager, started_ago=0.05)
|
||||
manager._draw_celebration_layout(celebration)
|
||||
flash = manager.display_manager.image
|
||||
# After the flash window: plain black backdrop.
|
||||
celebration["started_at"] = time.time() - 5
|
||||
manager._draw_celebration_layout(celebration)
|
||||
steady = manager.display_manager.image
|
||||
# Corner pixels (away from logos/text) show the two backgrounds.
|
||||
assert flash.getpixel((64, 30)) != steady.getpixel((64, 30)) or \
|
||||
flash.getpixel((3, 0)) != steady.getpixel((3, 0))
|
||||
|
||||
def test_matrix_dims_fallback_to_display_attrs(self):
|
||||
manager = _RenderableCelebrating(width=96, height=48, with_matrix=False)
|
||||
celebration = _armed(manager)
|
||||
manager._draw_celebration_layout(celebration)
|
||||
assert manager.display_manager.image.size == (96, 48)
|
||||
|
||||
def test_highlight_color_alternates_with_elapsed(self):
|
||||
manager = _RenderableCelebrating()
|
||||
celebration = _armed(manager)
|
||||
# int(elapsed*4) % 2 == 0 -> yellow; == 1 -> orange. Force each phase
|
||||
# and diff the frames.
|
||||
celebration["started_at"] = time.time() - 2.0 # 8 -> even
|
||||
manager._draw_celebration_layout(celebration)
|
||||
even = manager.display_manager.image.tobytes()
|
||||
celebration["started_at"] = time.time() - 2.25 # 9 -> odd
|
||||
manager._draw_celebration_layout(celebration)
|
||||
odd = manager.display_manager.image.tobytes()
|
||||
assert even != odd
|
||||
|
||||
def test_logo_failure_still_renders_text(self):
|
||||
manager = _RenderableCelebrating()
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("disk gone")
|
||||
|
||||
manager._load_and_resize_logo = boom
|
||||
celebration = _armed(manager, started_ago=5) # steady background
|
||||
manager._draw_celebration_layout(celebration) # must not raise
|
||||
assert manager.display_manager.image.convert("L").getbbox() is not None
|
||||
manager.display_manager.update_display.assert_called_once()
|
||||
|
||||
|
||||
class TestCelebrationEdges:
|
||||
def test_should_celebrate_for_three_way_branch(self, celebrating):
|
||||
g = game("g1", home="FAV", away="OPP")
|
||||
favored = celebrating(favorites=["FAV"])
|
||||
assert favored._should_celebrate_for(g, "home") is True # favorite
|
||||
assert favored._should_celebrate_for(g, "away") is False # opponent
|
||||
favored.celebrate_opponent_scores = True
|
||||
assert favored._should_celebrate_for(g, "away") is True # opted in
|
||||
unconfigured = celebrating(favorites=[])
|
||||
assert unconfigured._should_celebrate_for(g, "away") is True # no favs
|
||||
|
||||
def test_active_celebration_boundary_is_strict(self, celebrating):
|
||||
manager = celebrating(mode_config={"celebration_duration": 3})
|
||||
manager.active_celebration = {"started_at": time.time() - 3.0}
|
||||
# elapsed == duration -> strictly-less-than comparison says done.
|
||||
assert manager.has_active_celebration() is False
|
||||
manager.active_celebration = None
|
||||
assert manager.has_active_celebration() is False
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
({"value": None}, None), # int(float(None)) TypeError -> caught
|
||||
({"value": "abc"}, None),
|
||||
({"other": 1}, 0), # neither key -> default 0
|
||||
([3], None), # list -> TypeError -> caught
|
||||
("-4", None), # regex fallback finds digits -> 4? No:
|
||||
])
|
||||
def test_score_to_int_edges(self, value, expected):
|
||||
result = CelebrationMixin._score_to_int(value)
|
||||
if value == "-4":
|
||||
# int(float("-4")) parses directly: -4.
|
||||
assert result == -4
|
||||
else:
|
||||
assert result == expected
|
||||
|
||||
def test_both_teams_scoring_prefers_away(self, celebrating):
|
||||
manager = celebrating(favorites=[])
|
||||
manager._check_for_score(game("g1", home_score=0, away_score=0))
|
||||
manager._check_for_score(game("g1", home_score=7, away_score=3))
|
||||
assert manager.active_celebration["scored_side"] == "away"
|
||||
|
||||
def test_away_not_celebratable_falls_through_to_home(self, celebrating):
|
||||
manager = celebrating(favorites=["HOM"]) # away is the opponent
|
||||
manager._check_for_score(game("g1", home_score=0, away_score=0))
|
||||
manager._check_for_score(game("g1", home_score=7, away_score=3))
|
||||
assert manager.active_celebration["scored_side"] == "home"
|
||||
|
||||
def test_coalesce_expired_celebration_fires_fresh(self, celebrating):
|
||||
manager = celebrating(cls=_Coalescing,
|
||||
mode_config={"celebration_duration": 1})
|
||||
manager._check_for_score(game("g1"))
|
||||
manager._check_for_score(game("g1", home_score=6))
|
||||
first = manager.active_celebration
|
||||
assert first is not None
|
||||
first["started_at"] = time.time() - 2 # expired
|
||||
manager._check_for_score(game("g1", home_score=7))
|
||||
# A new celebration replaced the expired one (coalescing only
|
||||
# suppresses while one is actively on screen).
|
||||
assert manager.active_celebration is not first
|
||||
assert manager.active_celebration["home_score"] == 7
|
||||
|
||||
def test_disabled_win_check_preserves_baseline(self, celebrating):
|
||||
manager = celebrating(favorites=["HOM"])
|
||||
manager._check_for_score(game("g1"))
|
||||
assert "g1" in manager._score_baselines
|
||||
manager.celebration_enabled = False
|
||||
manager._check_for_win(game("g1", home_score=7))
|
||||
# Early return BEFORE consuming the baseline: re-enabling later can
|
||||
# still fire for this game.
|
||||
assert "g1" in manager._score_baselines
|
||||
|
||||
def test_prune_drops_baselines_for_idless_live_games(self, celebrating):
|
||||
manager = celebrating()
|
||||
manager._score_baselines = {"g1": {"away": 0, "home": 0}}
|
||||
manager.prune_score_baselines([{"no_id_here": True}])
|
||||
# live ids collapse to {None}; g1 is not live -> dropped.
|
||||
assert manager._score_baselines == {}
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
"""
|
||||
Tests for src/startup_validator.py — pins the StartupValidator contract.
|
||||
|
||||
Covers: required-key/config error reporting (errors never propagate out of
|
||||
validate_all), the load_config/get_config accessor split, cache-directory
|
||||
error-vs-warning downgrade behavior, plugin discovery/manifest checks with
|
||||
reserved config keys skipped, idempotent validate_all (fresh error/warning
|
||||
lists each run — the pre-fix behavior duplicated messages), and the
|
||||
exception classification precedence in raise_on_errors (config > cache >
|
||||
plugin > fallback ConfigError).
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.exceptions import CacheError, ConfigError, PluginError
|
||||
from src.startup_validator import StartupValidator
|
||||
|
||||
GOOD_CONFIG = {
|
||||
'display': {'hardware': {'rows': 32, 'cols': 64}},
|
||||
'timezone': 'UTC',
|
||||
}
|
||||
|
||||
|
||||
def make_config_manager(config):
|
||||
"""Config manager whose load_config() and get_config() return `config`."""
|
||||
mgr = MagicMock()
|
||||
mgr.load_config.return_value = copy.deepcopy(config)
|
||||
mgr.get_config.return_value = copy.deepcopy(config)
|
||||
return mgr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def good_cache(monkeypatch, tmp_path):
|
||||
"""Patch CacheManager so cache validation sees an existing writable dir.
|
||||
|
||||
_validate_cache_directory does `from src.cache_manager import CacheManager`
|
||||
at call time, so patching the attribute on the module is picked up.
|
||||
"""
|
||||
mock_cls = MagicMock()
|
||||
mock_cls.return_value.get_cache_dir.return_value = str(tmp_path)
|
||||
monkeypatch.setattr("src.cache_manager.CacheManager", mock_cls)
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestValidateConfig:
|
||||
"""Configuration validation via load_config()."""
|
||||
|
||||
def test_happy_path(self, good_cache):
|
||||
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is True
|
||||
assert errors == []
|
||||
assert warnings == []
|
||||
|
||||
def test_missing_required_keys(self, good_cache):
|
||||
validator = StartupValidator(make_config_manager({}))
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is False
|
||||
assert "Missing required configuration key: display" in errors
|
||||
assert "Missing required configuration key: timezone" in errors
|
||||
|
||||
def test_config_error_does_not_propagate(self, good_cache):
|
||||
mgr = make_config_manager(GOOD_CONFIG)
|
||||
mgr.load_config.side_effect = ConfigError("bad json")
|
||||
validator = StartupValidator(mgr)
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is False
|
||||
config_errors = [e for e in errors if e.startswith("Configuration error:")]
|
||||
assert len(config_errors) == 1
|
||||
assert "bad json" in config_errors[0]
|
||||
|
||||
def test_unexpected_error_does_not_propagate(self, good_cache):
|
||||
mgr = make_config_manager(GOOD_CONFIG)
|
||||
mgr.load_config.side_effect = RuntimeError("kapow")
|
||||
validator = StartupValidator(mgr)
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is False
|
||||
unexpected = [e for e in errors
|
||||
if e.startswith("Unexpected error validating configuration:")]
|
||||
assert len(unexpected) == 1
|
||||
assert "kapow" in unexpected[0]
|
||||
|
||||
def test_accessor_split_get_config_failure_is_warning_only(self, good_cache):
|
||||
# _validate_config uses load_config(); _validate_display_config uses
|
||||
# get_config(). A broken get_config must degrade to a warning, not
|
||||
# crash or produce a config error.
|
||||
mgr = make_config_manager(GOOD_CONFIG)
|
||||
mgr.get_config.side_effect = RuntimeError("accessor broken")
|
||||
validator = StartupValidator(mgr)
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is True
|
||||
assert errors == []
|
||||
assert any(w.startswith("Could not validate display configuration:")
|
||||
for w in warnings)
|
||||
assert mgr.load_config.called
|
||||
assert mgr.get_config.called
|
||||
|
||||
|
||||
class TestDisplayConfig:
|
||||
"""Display hardware validation via get_config()."""
|
||||
|
||||
def test_missing_hardware_section_is_error(self, good_cache):
|
||||
config = {'display': {'runtime': {'gpio_slowdown': 2}}, 'timezone': 'UTC'}
|
||||
validator = StartupValidator(make_config_manager(config))
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is False
|
||||
assert "Display hardware configuration is missing" in errors
|
||||
|
||||
def test_missing_rows_cols_are_warnings_not_errors(self, good_cache):
|
||||
config = {'display': {'hardware': {'brightness': 90}}, 'timezone': 'UTC'}
|
||||
validator = StartupValidator(make_config_manager(config))
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is True
|
||||
assert errors == []
|
||||
assert "Display hardware setting 'rows' not specified, using default" in warnings
|
||||
assert "Display hardware setting 'cols' not specified, using default" in warnings
|
||||
|
||||
|
||||
class TestCacheDirectory:
|
||||
"""Cache directory validation error/warning split."""
|
||||
|
||||
def _patch_cache_dir(self, monkeypatch, cache_dir):
|
||||
mock_cls = MagicMock()
|
||||
mock_cls.return_value.get_cache_dir.return_value = cache_dir
|
||||
monkeypatch.setattr("src.cache_manager.CacheManager", mock_cls)
|
||||
|
||||
def test_nonexistent_cache_dir_is_error(self, monkeypatch, tmp_path):
|
||||
missing = str(tmp_path / "does_not_exist")
|
||||
self._patch_cache_dir(monkeypatch, missing)
|
||||
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is False
|
||||
assert any("does not exist" in e and missing in e for e in errors)
|
||||
|
||||
def test_writable_cache_dir_no_errors(self, monkeypatch, tmp_path):
|
||||
self._patch_cache_dir(monkeypatch, str(tmp_path))
|
||||
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is True
|
||||
assert not any('cache' in e.lower() for e in errors)
|
||||
|
||||
def test_unwritable_cache_dir_is_error(self, monkeypatch, tmp_path):
|
||||
# Root (common in CI) can write anywhere, so chmod tricks don't
|
||||
# work — force os.access to deny writes for the cache dir only.
|
||||
cache_dir = str(tmp_path)
|
||||
self._patch_cache_dir(monkeypatch, cache_dir)
|
||||
real_access = os.access
|
||||
|
||||
def fake_access(path, mode):
|
||||
if str(path) == cache_dir and mode == os.W_OK:
|
||||
return False
|
||||
return real_access(path, mode)
|
||||
|
||||
monkeypatch.setattr(os, "access", fake_access)
|
||||
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is False
|
||||
assert any("is not writable" in e for e in errors)
|
||||
|
||||
def test_none_cache_dir_is_warning_not_error(self, monkeypatch):
|
||||
self._patch_cache_dir(monkeypatch, None)
|
||||
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is True
|
||||
assert errors == []
|
||||
assert "Cache directory not available - caching will be disabled" in warnings
|
||||
|
||||
def test_cache_manager_constructor_failure_is_warning(self, monkeypatch):
|
||||
mock_cls = MagicMock(side_effect=RuntimeError("no disk"))
|
||||
monkeypatch.setattr("src.cache_manager.CacheManager", mock_cls)
|
||||
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is True
|
||||
assert errors == []
|
||||
assert any(w.startswith("Could not validate cache directory:") for w in warnings)
|
||||
|
||||
|
||||
class TestPlugins:
|
||||
"""Plugin validation with a plugin manager present."""
|
||||
|
||||
def _config_with_plugins(self):
|
||||
return {
|
||||
'display': {'hardware': {'rows': 32, 'cols': 64}},
|
||||
'schedule': {'enabled': True}, # reserved key that LOOKS enabled
|
||||
'timezone': 'UTC',
|
||||
'plugin_system': {},
|
||||
'known': {'enabled': True},
|
||||
'ghost': {'enabled': True},
|
||||
}
|
||||
|
||||
def test_ghost_plugin_warns_and_reserved_keys_skipped(self, good_cache, tmp_path):
|
||||
pm = MagicMock()
|
||||
pm.discover_plugins.return_value = ['known']
|
||||
known_dir = tmp_path / "known"
|
||||
known_dir.mkdir()
|
||||
(known_dir / "manifest.json").write_text("{}")
|
||||
pm.get_plugin_directory.return_value = str(known_dir)
|
||||
|
||||
validator = StartupValidator(make_config_manager(self._config_with_plugins()), pm)
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is True
|
||||
assert "Plugin 'ghost' is enabled but not found in plugins directory" in warnings
|
||||
# Reserved sections are never treated as plugins, even when they
|
||||
# contain an 'enabled' flag (schedule above).
|
||||
for reserved in ('display', 'schedule', 'timezone', 'plugin_system'):
|
||||
assert not any(f"'{reserved}'" in w for w in warnings)
|
||||
|
||||
def test_enabled_plugin_missing_manifest_is_error(self, good_cache, tmp_path):
|
||||
pm = MagicMock()
|
||||
pm.discover_plugins.return_value = ['known']
|
||||
plugin_dir = tmp_path / "known"
|
||||
plugin_dir.mkdir() # exists, but no manifest.json inside
|
||||
pm.get_plugin_directory.return_value = str(plugin_dir)
|
||||
|
||||
config = dict(GOOD_CONFIG, known={'enabled': True})
|
||||
validator = StartupValidator(make_config_manager(config), pm)
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is False
|
||||
assert "Plugin 'known' manifest.json not found" in errors
|
||||
|
||||
def test_disabled_plugin_not_checked_for_manifest(self, good_cache, tmp_path):
|
||||
pm = MagicMock()
|
||||
pm.discover_plugins.return_value = ['known']
|
||||
pm.get_plugin_directory.return_value = str(tmp_path / "nowhere")
|
||||
|
||||
config = dict(GOOD_CONFIG, known={'enabled': False})
|
||||
validator = StartupValidator(make_config_manager(config), pm)
|
||||
is_valid, errors, warnings = validator.validate_all()
|
||||
assert is_valid is True
|
||||
assert errors == []
|
||||
|
||||
|
||||
class TestIdempotence:
|
||||
"""validate_all() resets error/warning state each run (the fixed bug)."""
|
||||
|
||||
def test_repeated_runs_do_not_accumulate(self, good_cache):
|
||||
validator = StartupValidator(make_config_manager({}))
|
||||
first = validator.validate_all()
|
||||
second = validator.validate_all()
|
||||
assert first == second
|
||||
assert len(second[1]) == len(first[1])
|
||||
assert len(second[2]) == len(first[2])
|
||||
|
||||
|
||||
class TestRaiseOnErrors:
|
||||
"""Exception classification and precedence in raise_on_errors()."""
|
||||
|
||||
def _validator(self, errors):
|
||||
validator = StartupValidator(make_config_manager(GOOD_CONFIG))
|
||||
validator.errors = list(errors)
|
||||
return validator
|
||||
|
||||
def test_no_errors_returns_none(self):
|
||||
assert self._validator([]).raise_on_errors() is None
|
||||
|
||||
def test_config_error(self):
|
||||
msg = "Missing required configuration key: display"
|
||||
with pytest.raises(ConfigError) as excinfo:
|
||||
self._validator([msg]).raise_on_errors()
|
||||
assert excinfo.value.message == "Configuration validation failed"
|
||||
assert msg in excinfo.value.context['errors']
|
||||
|
||||
def test_cache_error(self):
|
||||
msg = "Cache directory does not exist: /nope"
|
||||
with pytest.raises(CacheError) as excinfo:
|
||||
self._validator([msg]).raise_on_errors()
|
||||
assert msg in excinfo.value.context['errors']
|
||||
|
||||
def test_plugin_error(self):
|
||||
msg = "Plugin 'known' manifest.json not found"
|
||||
with pytest.raises(PluginError) as excinfo:
|
||||
self._validator([msg]).raise_on_errors()
|
||||
assert msg in excinfo.value.context['errors']
|
||||
|
||||
def test_unclassified_error_falls_back_to_config_error(self):
|
||||
msg = "Something entirely else went wrong"
|
||||
with pytest.raises(ConfigError) as excinfo:
|
||||
self._validator([msg]).raise_on_errors()
|
||||
assert excinfo.value.message == "Startup validation failed"
|
||||
assert msg in excinfo.value.context['errors']
|
||||
|
||||
def test_precedence_config_beats_cache(self):
|
||||
# A message matching both 'config' and 'cache' substrings raises
|
||||
# ConfigError because config classification is checked first.
|
||||
msg = "config problem touching the cache layer"
|
||||
with pytest.raises(ConfigError) as excinfo:
|
||||
self._validator([msg]).raise_on_errors()
|
||||
assert excinfo.value.message == "Configuration validation failed"
|
||||
assert msg in excinfo.value.context['errors']
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Wildcard grants to commands that start a pager must carry NOEXEC.
|
||||
|
||||
`journalctl` runs a pager when its output is a terminal, and from `less` a
|
||||
`!sh` is a shell with the privileges journalctl was given. That is the standard
|
||||
journalctl privilege escalation, and the installer's rules end in a wildcard:
|
||||
|
||||
<user> ALL=(ALL) NOPASSWD: /usr/bin/journalctl -u ledmatrix *
|
||||
|
||||
The web interface always passes --no-pager -- both call sites do, in app.py and
|
||||
api_v3.py -- so nothing the project runs needs the pager. But a sudoers rule
|
||||
cannot require a flag that sits in the middle of the command line, and reasoning
|
||||
about what a trailing `*` does or does not admit is exactly the kind of
|
||||
subtlety that produces a hole.
|
||||
|
||||
sudo's NOEXEC tag stops the command executing another program at all, which
|
||||
closes it without depending on that reasoning. It works by LD_PRELOAD, so it
|
||||
applies to dynamically linked binaries; journalctl is one.
|
||||
|
||||
On a stock Raspberry Pi image none of this is reachable, because
|
||||
/etc/sudoers.d/010_pi-nopasswd already grants the default user
|
||||
`ALL=(ALL) NOPASSWD: ALL`. It matters on a hardened install, or where the
|
||||
service runs as a user without that blanket rule.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
INSTALLERS = (
|
||||
ROOT / "first_time_install.sh",
|
||||
ROOT / "scripts" / "install" / "configure_wifi_permissions.sh",
|
||||
)
|
||||
|
||||
#: Commands that will start another program of their own accord -- a pager, an
|
||||
#: editor, a shell -- and so must not be granted the ability to do so.
|
||||
SPAWNS_A_PROGRAM = ("journalctl", "systemctl", "less", "more", "man", "git")
|
||||
|
||||
|
||||
def _grant_lines():
|
||||
lines = []
|
||||
for installer in INSTALLERS:
|
||||
if not installer.is_file():
|
||||
continue
|
||||
for line in installer.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
stripped = line.strip()
|
||||
if "NOPASSWD" in stripped and not stripped.startswith("#"):
|
||||
lines.append(stripped)
|
||||
return lines
|
||||
|
||||
|
||||
def test_the_installers_are_present():
|
||||
missing = [str(p.relative_to(ROOT)) for p in INSTALLERS if not p.is_file()]
|
||||
assert not missing, f"installer(s) missing: {missing}"
|
||||
|
||||
|
||||
def test_wildcard_pager_grants_carry_noexec():
|
||||
offenders = []
|
||||
for rule in _grant_lines():
|
||||
command = rule.split("NOPASSWD", 1)[1]
|
||||
if not command.rstrip().endswith("*"):
|
||||
continue
|
||||
tool = command.replace("_PATH", "").replace("$", "").lower()
|
||||
for name in SPAWNS_A_PROGRAM:
|
||||
if re.search(rf"(^|/|\s){name}(\s|$)", tool):
|
||||
if "NOEXEC" not in rule:
|
||||
offenders.append(rule)
|
||||
break
|
||||
assert not offenders, (
|
||||
"wildcard grant to a command that can start a pager or shell, without "
|
||||
"NOEXEC:\n " + "\n ".join(offenders))
|
||||
|
||||
|
||||
def test_journalctl_is_granted_at_all():
|
||||
"""Guard against 'fixing' the above by deleting the rules."""
|
||||
text = "\n".join(_grant_lines())
|
||||
assert "JOURNALCTL_PATH" in text or "journalctl" in text, (
|
||||
"no journalctl grant remains; the web interface reads logs through it")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unit", ["ledmatrix.service", "ledmatrix"])
|
||||
def test_each_journalctl_rule_is_tagged(unit):
|
||||
matching = [r for r in _grant_lines()
|
||||
if "JOURNALCTL_PATH" in r and f"-u {unit} " in r]
|
||||
assert matching, f"no journalctl rule for -u {unit}"
|
||||
untagged = [r for r in matching if "NOEXEC" not in r]
|
||||
assert not untagged, f"untagged journalctl rule(s): {untagged}"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,182 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests that "which plugins have fresh data" survives the async update worker.
|
||||
|
||||
Regression under test: run_scheduled_updates_with_changes() snapshotted
|
||||
plugin_last_update, called run_scheduled_updates(), and diffed the two. But
|
||||
run_scheduled_updates() only *enqueues* -- the work runs on the update worker
|
||||
and stamps the timestamp there, after the method has already returned. The
|
||||
snapshots were therefore always identical and the result always empty.
|
||||
|
||||
Vegas depends on that result: it is what calls mark_plugin_updated(), which
|
||||
drops the cached content for a plugin whose data changed. With it always
|
||||
empty, a segment kept scrolling whatever it was first built from -- the
|
||||
"last night's live game still drawn as live the next morning" failure the
|
||||
coordinator comments describe. Observed on a live rig: zero update ticks in
|
||||
twenty minutes, with weather, stocks and news all updating.
|
||||
|
||||
Run: python -m pytest test/test_update_change_reporting.py -v
|
||||
"""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
|
||||
|
||||
|
||||
def _manager():
|
||||
"""A PluginManager with only the update-reporting state initialised."""
|
||||
manager = PluginManager.__new__(PluginManager)
|
||||
manager._completed_updates = set()
|
||||
manager._completed_updates_lock = threading.Lock()
|
||||
return manager
|
||||
|
||||
|
||||
class DrainCompletedUpdates(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.manager = _manager()
|
||||
|
||||
def test_nothing_completed_reports_nothing(self):
|
||||
self.assertEqual(self.manager.drain_completed_updates(), [])
|
||||
|
||||
def test_a_completed_update_is_reported(self):
|
||||
self.manager._note_update_completed("news")
|
||||
self.assertEqual(self.manager.drain_completed_updates(), ["news"])
|
||||
|
||||
def test_draining_clears_so_the_next_poll_is_empty(self):
|
||||
self.manager._note_update_completed("news")
|
||||
self.manager.drain_completed_updates()
|
||||
self.assertEqual(
|
||||
self.manager.drain_completed_updates(), [],
|
||||
"a plugin must be reported once per update, not on every poll, "
|
||||
"or Vegas would drop its cached content every few seconds")
|
||||
|
||||
def test_repeated_completions_between_polls_collapse(self):
|
||||
for _ in range(5):
|
||||
self.manager._note_update_completed("weather")
|
||||
self.assertEqual(self.manager.drain_completed_updates(), ["weather"])
|
||||
|
||||
def test_multiple_plugins_are_all_reported(self):
|
||||
for plugin_id in ("news", "weather", "ledmatrix-stocks"):
|
||||
self.manager._note_update_completed(plugin_id)
|
||||
self.assertEqual(self.manager.drain_completed_updates(),
|
||||
["ledmatrix-stocks", "news", "weather"])
|
||||
|
||||
|
||||
class CompletionReportingIsAsyncSafe(unittest.TestCase):
|
||||
"""The point of the change: completion may land after the call returns."""
|
||||
|
||||
def setUp(self):
|
||||
self.manager = _manager()
|
||||
|
||||
def test_an_update_completing_after_the_call_is_still_reported(self):
|
||||
"""The exact shape of the bug.
|
||||
|
||||
The enqueueing call sees nothing, because the worker has not run yet.
|
||||
The next poll must report it -- under the old diff it was lost, since
|
||||
the second snapshot was taken before the worker ever stamped.
|
||||
"""
|
||||
first = self.manager.drain_completed_updates()
|
||||
self.assertEqual(first, [], "nothing has finished yet")
|
||||
|
||||
# The worker finishes some time later, on its own thread.
|
||||
worker = threading.Thread(
|
||||
target=self.manager._note_update_completed, args=("news",))
|
||||
worker.start()
|
||||
worker.join()
|
||||
|
||||
self.assertEqual(
|
||||
self.manager.drain_completed_updates(), ["news"],
|
||||
"an update that finishes between polls must still be reported")
|
||||
|
||||
def test_concurrent_completions_are_not_lost(self):
|
||||
ids = ["plugin-%02d" % i for i in range(40)]
|
||||
threads = [threading.Thread(target=self.manager._note_update_completed,
|
||||
args=(pid,)) for pid in ids]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
self.assertEqual(self.manager.drain_completed_updates(), sorted(ids))
|
||||
|
||||
def test_a_completion_during_a_drain_is_not_swallowed(self):
|
||||
"""A drain must not clear an entry it did not report."""
|
||||
self.manager._note_update_completed("news")
|
||||
reported = self.manager.drain_completed_updates()
|
||||
# ...worker finishes another one immediately afterwards
|
||||
self.manager._note_update_completed("weather")
|
||||
self.assertEqual(reported, ["news"])
|
||||
self.assertEqual(self.manager.drain_completed_updates(), ["weather"])
|
||||
|
||||
|
||||
class EveryStampRecordsACompletion(unittest.TestCase):
|
||||
"""The ledger is only correct if the production paths actually fill it.
|
||||
|
||||
Asserting on the mechanics alone passes even when nothing calls
|
||||
_note_update_completed -- verified by deleting the call sites, which the
|
||||
behavioural tests above did not notice. This checks the invariant at the
|
||||
source: wherever a successful update stamps plugin_last_update, it must
|
||||
also record the completion, or Vegas silently stops being told.
|
||||
"""
|
||||
|
||||
def test_success_paths_record_the_completion(self):
|
||||
import src.plugin_system.plugin_manager as pm
|
||||
|
||||
tree = ast.parse(inspect.getsource(pm))
|
||||
stamps = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.With):
|
||||
continue
|
||||
# `with self._plugin_last_update_lock:` blocks that stamp a real
|
||||
# time on success. Two stamps are deliberately excluded: the 0.0
|
||||
# written at registration, and the failure path, which backs the
|
||||
# timestamp off to space out retries -- neither means fresh data.
|
||||
assigns_time = any(
|
||||
isinstance(stmt, ast.Assign)
|
||||
and any(isinstance(t, ast.Subscript)
|
||||
and getattr(t.value, "attr", None) == "plugin_last_update"
|
||||
for t in stmt.targets)
|
||||
and not (isinstance(stmt.value, ast.Constant)
|
||||
and stmt.value.value == 0.0)
|
||||
and "failure" not in ast.dump(stmt.value)
|
||||
for stmt in node.body
|
||||
)
|
||||
if assigns_time:
|
||||
stamps.append(node)
|
||||
|
||||
self.assertGreaterEqual(
|
||||
len(stamps), 2,
|
||||
"expected the worker and inline success paths to stamp the time; "
|
||||
"if this drops, the search below is looking at the wrong thing")
|
||||
|
||||
for stamp in stamps:
|
||||
enclosing = self._enclosing_function(tree, stamp)
|
||||
calls = [n for n in ast.walk(enclosing)
|
||||
if isinstance(n, ast.Call)
|
||||
and getattr(n.func, "attr", None) == "_note_update_completed"]
|
||||
self.assertTrue(
|
||||
calls,
|
||||
"%s stamps plugin_last_update on success but never calls "
|
||||
"_note_update_completed, so a plugin's fresh data would never "
|
||||
"be reported and Vegas would keep its stale cached content"
|
||||
% enclosing.name)
|
||||
|
||||
@staticmethod
|
||||
def _enclosing_function(tree, target):
|
||||
best = None
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
if node.lineno <= target.lineno <= (node.end_lineno or node.lineno):
|
||||
if best is None or node.lineno > best.lineno:
|
||||
best = node
|
||||
return best
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -781,20 +781,6 @@ class TestNewConfigKeys:
|
||||
assert cfg.render_width_pct == 100
|
||||
assert cfg.min_content_separation == 24
|
||||
|
||||
def test_width_cap_is_off_by_default(self):
|
||||
# Capping made wide plugins resume mid-content on every appearance and
|
||||
# emit runt final windows; it is now opt-in per plugin instead.
|
||||
assert VegasModeConfig().max_plugin_width_ratio == 0.0
|
||||
assert VegasModeConfig.from_config({}).max_plugin_width_ratio == 0.0
|
||||
|
||||
def test_width_cap_is_still_available_when_asked_for(self):
|
||||
# Defaulting the cap off must not remove it: a user who sets a ratio
|
||||
# still gets one, and 0 still means uncapped.
|
||||
cfg = VegasModeConfig.from_config(
|
||||
{'display': {'vegas_scroll': {'max_plugin_width_ratio': 3.0}}})
|
||||
assert cfg.max_plugin_width_ratio == 3.0
|
||||
assert cfg.validate() == []
|
||||
|
||||
@pytest.mark.parametrize('overrides,bad_key', [
|
||||
({'render_width_pct': 5}, 'render_width_pct'),
|
||||
({'render_width_pct': 101}, 'render_width_pct'),
|
||||
@@ -1657,228 +1643,3 @@ class TestPerPluginWidthBudget:
|
||||
strip = canvas([(0, 5000)], width=5000)
|
||||
adapter.get_content(NativePlugin([strip]), 'ticker')
|
||||
assert adapter._item_offsets.get('ticker', 0) > 0
|
||||
|
||||
|
||||
def ticker(item_widths, gap=32, height=DISPLAY_H):
|
||||
"""
|
||||
A strip of discrete items separated by real gaps, like a news or stocks
|
||||
ticker. Wide enough gaps that blank_runs() sees item boundaries, which is
|
||||
what puts _crop_to_budget on its item-aligned path rather than treating the
|
||||
strip as one continuous block.
|
||||
"""
|
||||
width = sum(item_widths) + gap * (len(item_widths) - 1)
|
||||
spans, x = [], 0
|
||||
for w in item_widths:
|
||||
spans.append((x, x + w))
|
||||
x += w + gap
|
||||
return canvas(spans, width=width, height=height)
|
||||
|
||||
|
||||
class TestTrailingRuntWindow:
|
||||
"""
|
||||
A rotation's last window used to be whatever happened to be left over.
|
||||
Measured on a live 512px panel, a 1,840px stocks ticker against a 1,536px
|
||||
budget split 1,492 + 348 — the second pass showed seven seconds and cut.
|
||||
"""
|
||||
|
||||
def test_a_barely_oversized_strip_is_shown_whole(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
# 1.2 budgets wide: splitting it can only ever produce a fragment.
|
||||
strip = ticker([180] * 12) # 2160 + 352 gaps = 2512px vs 512 budget
|
||||
assert strip.width > DISPLAY_W
|
||||
|
||||
adapter = adapter_with(content_padding=0,
|
||||
max_plugin_width_ratio=strip.width / DISPLAY_W * 0.9)
|
||||
shown = adapter.get_content(NativePlugin([strip]), 'stocks')[0]
|
||||
assert shown.width == strip.width, "should absorb the runt, not split"
|
||||
assert 'stocks' not in adapter._item_offsets
|
||||
|
||||
def test_no_window_in_a_rotation_is_a_fragment(self):
|
||||
# Walk a long ticker all the way round; every pass must be worth
|
||||
# showing rather than one of them being a leftover sliver.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
strip = ticker([150] * 40)
|
||||
plugin = NativePlugin([strip])
|
||||
|
||||
widths, seen_offsets = [], set()
|
||||
for _ in range(20):
|
||||
adapter.invalidate_cache('news')
|
||||
widths.append(adapter.get_content(plugin, 'news')[0].width)
|
||||
offset = adapter._item_offsets.get('news', 0)
|
||||
if offset in seen_offsets:
|
||||
break
|
||||
seen_offsets.add(offset)
|
||||
|
||||
assert len(widths) > 1, "a strip this long must take several passes"
|
||||
# Item snapping means an ordinary window lands short of the budget, so
|
||||
# the bar is "not a sliver" rather than "a full budget".
|
||||
assert min(widths) >= DISPLAY_W // 2, (
|
||||
"no window should be a fragment, got %r" % widths)
|
||||
assert max(widths) <= DISPLAY_W * 1.5, (
|
||||
"absorbing a runt must stay bounded, got %r" % widths)
|
||||
|
||||
def test_a_continuous_image_also_absorbs_its_runt(self):
|
||||
# The no-item-gaps path had the same leftover problem.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
solid = canvas([(0, 700)], width=700) # 512 budget -> 512 + 188 runt
|
||||
first = adapter.get_content(NativePlugin([solid]), 'chart')[0]
|
||||
assert first.width == 700, "188px tail is not worth its own pass"
|
||||
assert 'chart' not in adapter._item_offsets
|
||||
|
||||
def test_the_reported_stocks_case(self):
|
||||
# The exact numbers logged on a 512px panel: an 1,840px stocks ticker
|
||||
# against a 1,536px budget split 1,492 + 348, so every other appearance
|
||||
# showed seven seconds of stocks and cut. It should now come through in
|
||||
# one piece, 20% over budget being the better of the two outcomes.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
|
||||
# 10 items of 152px with 32px gaps = 1520 + 288 = 1808, near enough.
|
||||
strip = ticker([152] * 10)
|
||||
assert DISPLAY_W * 3 < strip.width < DISPLAY_W * 4
|
||||
|
||||
widths = []
|
||||
for _ in range(3):
|
||||
adapter.invalidate_cache('stocks')
|
||||
widths.append(adapter.get_content(
|
||||
NativePlugin([strip]), 'stocks')[0].width)
|
||||
|
||||
assert widths == [strip.width] * 3, (
|
||||
"a strip this close to the budget should be shown whole every "
|
||||
"time, not split into a big pass and a sliver; got %r" % widths)
|
||||
|
||||
def test_a_short_final_row_window_is_not_left_alone(self):
|
||||
# The multi-row path has the same fault as the single-image one, and
|
||||
# wrapping does not save it: rows of 450/450/100 against a 512px budget
|
||||
# gave the 100 a pass of its own, two seconds against nine, because the
|
||||
# row it wrapped to did not fit either.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
|
||||
intra_plugin_gap=0, min_content_separation=0)
|
||||
rows = [canvas([(0, 450)], width=450),
|
||||
canvas([(0, 450)], width=450),
|
||||
canvas([(0, 100)], width=100)]
|
||||
|
||||
widths = []
|
||||
for _ in range(6):
|
||||
adapter.invalidate_cache('rows')
|
||||
shown = adapter.get_content(NativePlugin(list(rows)), 'rows')
|
||||
widths.append(sum(img.width for img in shown))
|
||||
|
||||
assert min(widths) >= DISPLAY_W // 2, (
|
||||
"a row window should not be a sliver, got %r" % widths)
|
||||
assert max(widths) <= DISPLAY_W * 1.5, (
|
||||
"absorbing a short row must stay bounded, got %r" % widths)
|
||||
|
||||
def test_row_rotation_still_covers_every_row(self):
|
||||
# Absorbing a short tail must not drop rows from the rotation.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
|
||||
intra_plugin_gap=0, min_content_separation=0)
|
||||
rows = [canvas([(0, 450)], width=450),
|
||||
canvas([(0, 450)], width=450),
|
||||
canvas([(0, 100)], width=100)]
|
||||
|
||||
seen = set()
|
||||
for _ in range(8):
|
||||
adapter.invalidate_cache('rows')
|
||||
for img in adapter.get_content(NativePlugin(list(rows)), 'rows'):
|
||||
seen.add(img.width)
|
||||
assert seen == {450, 100}, "rotation never showed every row: %r" % seen
|
||||
|
||||
def test_a_row_too_wide_to_absorb_still_bounds_the_overrun(self):
|
||||
# When the next row cannot be taken without blowing past 1.5 budgets,
|
||||
# a short window is the lesser evil — the same trade the always-show-
|
||||
# the-first-row rule already makes.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
|
||||
intra_plugin_gap=0, min_content_separation=0)
|
||||
rows = [canvas([(0, 900)], width=900), canvas([(0, 100)], width=100)]
|
||||
for _ in range(4):
|
||||
adapter.invalidate_cache('wide')
|
||||
shown = adapter.get_content(NativePlugin(list(rows)), 'wide')
|
||||
assert sum(i.width for i in shown) <= 900, (
|
||||
"must not merge a row that overruns the cap")
|
||||
|
||||
def test_a_genuinely_long_strip_still_gets_capped(self):
|
||||
# Absorbing runts must not become "never cap anything".
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
strip = ticker([150] * 60)
|
||||
shown = adapter.get_content(NativePlugin([strip]), 'long')[0]
|
||||
assert shown.width < strip.width
|
||||
assert shown.width <= DISPLAY_W * 2
|
||||
|
||||
|
||||
class TestOffsetOutlivesItsContent:
|
||||
"""
|
||||
A rotation offset only means something against the content it was recorded
|
||||
against. news re-rendered 9,793px -> 9,505px mid-rotation while its stored
|
||||
column kept advancing, so the window pointed into unrelated headlines.
|
||||
"""
|
||||
|
||||
def test_rotation_survives_items_changing_width(self):
|
||||
# Same items, each a little wider — a price gaining a digit. The window
|
||||
# should resume at the same *item*, not at a now-meaningless column.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
adapter.get_content(NativePlugin([ticker([150] * 40)]), 'stocks')
|
||||
first = adapter._item_offsets.get('stocks')
|
||||
assert first, "the first pass should leave a resume point"
|
||||
|
||||
adapter.invalidate_cache('stocks')
|
||||
adapter.get_content(NativePlugin([ticker([158] * 40)]), 'stocks')
|
||||
assert adapter._item_offsets.get('stocks', 0) > first, (
|
||||
"same item count means the offset still applies and should advance")
|
||||
|
||||
def test_rotation_restarts_when_the_item_count_changes(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
adapter.get_content(NativePlugin([ticker([150] * 40)]), 'news')
|
||||
assert adapter._item_offsets.get('news', 0) > 0
|
||||
|
||||
# A fresh headline set with fewer entries: the old position is
|
||||
# meaningless, so the next pass starts at the top.
|
||||
adapter.invalidate_cache('news')
|
||||
shown = adapter.get_content(NativePlugin([ticker([150] * 25)]), 'news')[0]
|
||||
expected = adapter.get_content(
|
||||
NativePlugin([ticker([150] * 25)]), 'fresh')[0]
|
||||
assert shown.width == expected.width
|
||||
|
||||
def test_a_row_index_is_never_read_back_as_a_pixel_column(self):
|
||||
# The unit collision: _apply_width_budget stores an index into a list
|
||||
# of rows, _crop_to_budget a column in one image, under the same key.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
|
||||
intra_plugin_gap=0, min_content_separation=0)
|
||||
rows = [canvas([(0, 200)], width=200) for _ in range(8)]
|
||||
adapter.get_content(NativePlugin(rows), 'mixed')
|
||||
assert adapter._item_offsets.get('mixed', 0) > 0
|
||||
assert adapter._offset_shapes['mixed'][0] == 'rows'
|
||||
|
||||
# Now the same plugin returns one wide strip instead. The row index
|
||||
# must not be read as a column into it: the strip is entered at the
|
||||
# top, exactly as it would be for a plugin with no history at all.
|
||||
strip = ticker([150] * 40)
|
||||
adapter.invalidate_cache('mixed')
|
||||
carried = adapter.get_content(NativePlugin([strip]), 'mixed')[0]
|
||||
assert adapter._offset_shapes['mixed'][0] == 'cuts'
|
||||
|
||||
clean = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
|
||||
intra_plugin_gap=0, min_content_separation=0)
|
||||
assert carried.tobytes() == clean.get_content(
|
||||
NativePlugin([strip]), 'clean')[0].tobytes()
|
||||
|
||||
def test_a_stale_index_past_the_end_restarts(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
strip = ticker([150] * 40)
|
||||
adapter.get_content(NativePlugin([strip]), 'news')
|
||||
# Force an index far beyond anything the current strip has, keeping the
|
||||
# shape intact so the guard does not catch it first.
|
||||
shape = adapter._offset_shapes['news']
|
||||
adapter._item_offsets['news'] = 10_000
|
||||
adapter.invalidate_cache('news')
|
||||
shown = adapter.get_content(NativePlugin([strip]), 'news')[0]
|
||||
assert shown.width > 0
|
||||
assert adapter._offset_shapes['news'] == shape
|
||||
|
||||
def test_content_that_fits_clears_both_offset_and_shape(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
adapter.get_content(NativePlugin([ticker([150] * 40)]), 'shrink')
|
||||
assert 'shrink' in adapter._offset_shapes
|
||||
|
||||
adapter.invalidate_cache('shrink')
|
||||
adapter.get_content(NativePlugin([canvas([(0, 100)], width=100)]), 'shrink')
|
||||
assert 'shrink' not in adapter._item_offsets
|
||||
assert 'shrink' not in adapter._offset_shapes
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Tests the percentile used by the Vegas frame-time log line.
|
||||
|
||||
The FPS line reports p99 next to the worst frame, and the point of having both
|
||||
is that they say different things: p99 is the bad-but-ordinary frame, worst is
|
||||
the outlier. The obvious index, int(n * 0.99), is off by one and at exactly
|
||||
100 samples selects the maximum -- so the two columns would report the same
|
||||
number precisely when the sample was smallest.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from src.vegas_mode.coordinator import _percentile
|
||||
|
||||
|
||||
class TestNearestRank:
|
||||
def test_a_hundred_samples_do_not_return_the_maximum(self):
|
||||
ordered = [float(i) for i in range(100)] # 0..99
|
||||
assert _percentile(ordered, 0.99) == 98.0
|
||||
assert _percentile(ordered, 0.99) != max(ordered)
|
||||
|
||||
def test_it_matches_the_nearest_rank_definition(self):
|
||||
for n in (1, 2, 3, 10, 99, 100, 101, 600, 1000):
|
||||
ordered = [float(i) for i in range(n)]
|
||||
expected = ordered[min(n - 1, max(0, math.ceil(n * 0.99) - 1))]
|
||||
assert _percentile(ordered, 0.99) == expected, n
|
||||
|
||||
@pytest.mark.parametrize('fraction,expected', [
|
||||
(0.0, 0.0), # first
|
||||
(0.5, 49.0), # median, nearest-rank
|
||||
(1.0, 99.0), # last
|
||||
])
|
||||
def test_other_fractions(self, fraction, expected):
|
||||
assert _percentile([float(i) for i in range(100)], fraction) == expected
|
||||
|
||||
|
||||
class TestEdges:
|
||||
def test_empty_is_zero_not_an_error(self):
|
||||
# The loop calls this before any frame has been timed.
|
||||
assert _percentile([], 0.99) == 0.0
|
||||
|
||||
def test_a_single_sample_is_itself(self):
|
||||
assert _percentile([4.2], 0.99) == 4.2
|
||||
|
||||
def test_it_never_indexes_past_the_end(self):
|
||||
for n in range(1, 50):
|
||||
_percentile([float(i) for i in range(n)], 1.0) # must not raise
|
||||
|
||||
|
||||
class TestItSaysSomethingUsefulAboutFrames:
|
||||
def test_one_freeze_does_not_drag_p99_up(self):
|
||||
# 599 healthy frames and one 3.2s freeze: p99 should still describe
|
||||
# the healthy population, while the worst frame is reported separately.
|
||||
frames = [0.0083] * 599 + [3.2]
|
||||
p99 = _percentile(sorted(frames), 0.99)
|
||||
assert p99 == pytest.approx(0.0083), p99
|
||||
assert max(frames) == 3.2
|
||||
|
||||
def test_sustained_slowness_does_move_it(self):
|
||||
# Ten percent of frames slow is not an outlier, it is the shape of the
|
||||
# distribution, and p99 must reflect that.
|
||||
frames = [0.0083] * 540 + [0.05] * 60
|
||||
assert _percentile(sorted(frames), 0.99) == pytest.approx(0.05)
|
||||
@@ -1,300 +0,0 @@
|
||||
"""Tests that live content can take extra turns inside the Vegas ticker.
|
||||
|
||||
Vegas was a strict round robin -- every plugin exactly once per cycle -- and
|
||||
live content did not appear in it at all, because the display controller
|
||||
refused to run the ticker while anything was live. With a dozen plugins
|
||||
enabled that left a live score either absent or minutes stale.
|
||||
|
||||
Two things change, both off by default. `live_in_ticker` keeps the marquee
|
||||
running instead of yielding to a full-screen takeover, and the rotation is
|
||||
expanded by Smooth Weighted Round-Robin so a weighted plugin gets several
|
||||
slots per cycle, spaced through it rather than clumped.
|
||||
|
||||
Weights are per plugin, not per game: a scoreboard showing four live games
|
||||
still occupies one slot at a time and rotates its own games within it.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
"""A plugin that can fail in each place independently.
|
||||
|
||||
hook_raises and live_raises are separate because they mean different
|
||||
things: a broken weight calculation should still leave the core's own
|
||||
live-content check usable, while a plugin that cannot answer whether it is
|
||||
live at all has nothing left to fall back on.
|
||||
"""
|
||||
|
||||
def __init__(self, live=False, declared=None, raises=False,
|
||||
hook_raises=False, live_raises=False):
|
||||
self._live = live
|
||||
self._declared = declared
|
||||
self._hook_raises = hook_raises or raises
|
||||
self._live_raises = live_raises or raises
|
||||
self.enabled = True
|
||||
|
||||
def has_live_priority(self):
|
||||
if self._live_raises:
|
||||
raise RuntimeError("cannot say whether I am live")
|
||||
return self._live
|
||||
|
||||
def has_live_content(self):
|
||||
return self._live
|
||||
|
||||
def get_vegas_priority_weight(self):
|
||||
if self._hook_raises:
|
||||
raise RuntimeError("weight calculation blew up")
|
||||
return self._declared
|
||||
|
||||
|
||||
def _manager(plugins, **cfg):
|
||||
config = VegasModeConfig(live_in_ticker=cfg.pop('live_in_ticker', True), **cfg)
|
||||
pm = Mock()
|
||||
pm.plugins = plugins
|
||||
sm = StreamManager.__new__(StreamManager)
|
||||
sm.config = config
|
||||
sm.plugin_manager = pm
|
||||
return sm
|
||||
|
||||
|
||||
def _counts(schedule):
|
||||
return {p: schedule.count(p) for p in set(schedule)}
|
||||
|
||||
|
||||
def _max_gap(schedule, plugin_id):
|
||||
"""Largest gap between consecutive appearances, wrapping around."""
|
||||
at = [i for i, p in enumerate(schedule) if p == plugin_id]
|
||||
if len(at) < 2:
|
||||
return len(schedule)
|
||||
gaps = [b - a for a, b in zip(at, at[1:])]
|
||||
gaps.append(len(schedule) - at[-1] + at[0])
|
||||
return max(gaps)
|
||||
|
||||
|
||||
class TestWeightsComeFromTheRightPlace:
|
||||
def test_a_quiet_plugin_gets_one_slot(self):
|
||||
sm = _manager({'clock': FakePlugin()})
|
||||
assert sm._plugin_weight('clock') == 1
|
||||
|
||||
def test_live_content_earns_the_configured_weight(self):
|
||||
sm = _manager({'mlb': FakePlugin(live=True)}, live_weight=4)
|
||||
assert sm._plugin_weight('mlb') == 4
|
||||
|
||||
def test_a_plugin_may_answer_for_itself(self):
|
||||
# The only route for favorite-team awareness: the core can see that a
|
||||
# game is live, not whose.
|
||||
sm = _manager({'mlb': FakePlugin(live=True, declared=7)}, live_weight=3)
|
||||
assert sm._plugin_weight('mlb') == 7
|
||||
|
||||
def test_declaring_none_defers_to_the_core(self):
|
||||
sm = _manager({'mlb': FakePlugin(live=True, declared=None)}, live_weight=3)
|
||||
assert sm._plugin_weight('mlb') == 3
|
||||
|
||||
def test_a_declared_weight_is_clamped(self):
|
||||
sm = _manager({'a': FakePlugin(declared=99), 'b': FakePlugin(declared=0)})
|
||||
assert sm._plugin_weight('a') == 10
|
||||
assert sm._plugin_weight('b') == 1
|
||||
|
||||
def test_a_plugin_that_raises_everywhere_weighs_one(self):
|
||||
sm = _manager({'bad': FakePlugin(raises=True)})
|
||||
assert sm._plugin_weight('bad') == 1
|
||||
|
||||
def test_a_broken_hook_still_earns_the_live_boost(self):
|
||||
# The hook is only how a plugin asks for *more* than live_weight.
|
||||
# Losing it should cost the favorite distinction, not the live boost:
|
||||
# has_live_priority/has_live_content are separate and still work.
|
||||
sm = _manager({'mlb': FakePlugin(live=True, hook_raises=True)},
|
||||
live_weight=4)
|
||||
assert sm._plugin_weight('mlb') == 4
|
||||
|
||||
def test_a_broken_hook_on_a_quiet_plugin_weighs_one(self):
|
||||
sm = _manager({'clock': FakePlugin(live=False, hook_raises=True)},
|
||||
live_weight=4)
|
||||
assert sm._plugin_weight('clock') == 1
|
||||
|
||||
def test_a_plugin_that_cannot_say_whether_it_is_live_weighs_one(self):
|
||||
# Nothing left to fall back on, so no boost.
|
||||
sm = _manager({'mlb': FakePlugin(live=True, live_raises=True)},
|
||||
live_weight=4)
|
||||
assert sm._plugin_weight('mlb') == 1
|
||||
|
||||
def test_an_unknown_plugin_weighs_one(self):
|
||||
assert _manager({})._plugin_weight('ghost') == 1
|
||||
|
||||
|
||||
class TestTheSchedule:
|
||||
def test_nothing_weighted_leaves_the_order_untouched(self):
|
||||
order = ['weather', 'clock', 'news']
|
||||
sm = _manager({p: FakePlugin() for p in order})
|
||||
assert sm._apply_priority_weights(order) == order
|
||||
|
||||
def test_off_by_default_the_order_is_untouched(self):
|
||||
order = ['weather', 'mlb', 'news']
|
||||
sm = _manager({'weather': FakePlugin(), 'mlb': FakePlugin(live=True),
|
||||
'news': FakePlugin()}, live_in_ticker=False, live_weight=3)
|
||||
assert sm._apply_priority_weights(order) == order
|
||||
|
||||
def test_a_live_plugin_takes_its_share_of_slots(self):
|
||||
order = ['weather', 'mlb', 'news', 'clock']
|
||||
sm = _manager({'weather': FakePlugin(), 'mlb': FakePlugin(live=True),
|
||||
'news': FakePlugin(), 'clock': FakePlugin()},
|
||||
live_weight=3)
|
||||
schedule = sm._apply_priority_weights(order)
|
||||
counts = _counts(schedule)
|
||||
assert counts['mlb'] == 3, counts
|
||||
assert counts['weather'] == counts['news'] == counts['clock'] == 1, counts
|
||||
assert len(schedule) == 6
|
||||
|
||||
def test_every_plugin_still_appears(self):
|
||||
# A boost must not starve anything out of the cycle.
|
||||
order = ['a', 'b', 'c', 'd', 'e', 'f']
|
||||
plugins = {p: FakePlugin() for p in order}
|
||||
plugins['a'] = FakePlugin(live=True, declared=10)
|
||||
sm = _manager(plugins)
|
||||
schedule = sm._apply_priority_weights(order)
|
||||
assert set(schedule) == set(order), set(order) - set(schedule)
|
||||
|
||||
def test_nothing_doubles_across_the_cycle_seam(self):
|
||||
# The strip loops, so the last slot neighbours the first. Smooth
|
||||
# Weighted Round-Robin schedules the heaviest item first and often
|
||||
# last too, which put the one clump the algorithm exists to avoid at
|
||||
# the one place a within-cycle check cannot see.
|
||||
order = ['baseball', 'weather', 'geochron', 'flights', 'stocks',
|
||||
'oftheday', 'youtube', 'stocknews', 'leaderboard',
|
||||
'countdown', 'odds', 'f1', 'football', 'music']
|
||||
plugins = {p: FakePlugin() for p in order}
|
||||
plugins['baseball'] = FakePlugin(live=True, declared=5)
|
||||
plugins['football'] = FakePlugin(live=True, declared=3)
|
||||
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||
|
||||
n = len(schedule)
|
||||
doubles = [schedule[i] for i in range(n)
|
||||
if schedule[i] == schedule[(i + 1) % n]]
|
||||
assert not doubles, "%r repeats across the seam in %r" % (doubles, schedule)
|
||||
|
||||
def test_the_seam_repair_keeps_every_slot(self):
|
||||
order = ['a', 'b', 'c', 'd', 'e', 'f']
|
||||
plugins = {p: FakePlugin() for p in order}
|
||||
plugins['a'] = FakePlugin(live=True, declared=4)
|
||||
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||
assert _counts(schedule)['a'] == 4, _counts(schedule)
|
||||
assert sorted(schedule) == sorted(
|
||||
['a'] * 4 + ['b', 'c', 'd', 'e', 'f']), schedule
|
||||
|
||||
def test_the_repair_uses_the_widest_gap(self):
|
||||
# Moving the trailing repeat into the first slot that merely fits
|
||||
# undoes the spacing: on a 28-slot rotation that turned a gap of 7
|
||||
# into a gap of 2, which is more clumped than the seam ever was.
|
||||
order = ['a'] + ['p%d' % i for i in range(13)]
|
||||
plugins = {p: FakePlugin() for p in order}
|
||||
plugins['a'] = FakePlugin(live=True, declared=4)
|
||||
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||
at = [i for i, p in enumerate(schedule) if p == 'a']
|
||||
gaps = [b - a for a, b in zip(at, at[1:])]
|
||||
gaps.append(len(schedule) - at[-1] + at[0])
|
||||
ideal = len(schedule) / len(at)
|
||||
assert min(gaps) >= ideal / 2, "gaps %r for ideal %.1f" % (gaps, ideal)
|
||||
|
||||
def test_an_unavoidable_double_is_left_alone(self):
|
||||
# Five of seven slots are the same plugin, so it must neighbour
|
||||
# itself. Better to schedule it than to refuse or loop forever.
|
||||
order = ['a', 'b', 'c']
|
||||
plugins = {p: FakePlugin() for p in order}
|
||||
plugins['a'] = FakePlugin(live=True, declared=5)
|
||||
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||
assert _counts(schedule) == {'a': 5, 'b': 1, 'c': 1}, _counts(schedule)
|
||||
assert set(schedule) == {'a', 'b', 'c'}
|
||||
|
||||
def test_the_repair_never_creates_a_new_double(self):
|
||||
# The first version guarded the slot the repeated value moves *into*
|
||||
# but not the one the displaced element lands in, so this traded the
|
||||
# seam duplicate for a fresh one and came back ending ['x', 'x'].
|
||||
sm = _manager({})
|
||||
out = sm._unclump_seam(['a', 'b', 'c', 'd', 'x', 'y', 'x', 'a'])
|
||||
n = len(out)
|
||||
doubles = [out[i] for i in range(n) if out[i] == out[(i + 1) % n]]
|
||||
assert not doubles, "%r in %r" % (doubles, out)
|
||||
assert sorted(out) == sorted(['a', 'b', 'c', 'd', 'x', 'y', 'x', 'a'])
|
||||
|
||||
def test_the_last_two_slots_are_a_usable_swap(self):
|
||||
# Reasoning about indices said this candidate was unsafe because
|
||||
# schedule[j] is schedule[-2]; after the swap its neighbour is the
|
||||
# repeated value, not itself. Refusing it left the only repair this
|
||||
# schedule has on the table.
|
||||
assert _manager({})._unclump_seam(['a', 'b', 'c', 'a']) == ['a', 'b', 'a', 'c']
|
||||
|
||||
def test_no_seam_schedule_is_ever_made_worse(self):
|
||||
import random
|
||||
sm = _manager({})
|
||||
random.seed(11)
|
||||
checked = 0
|
||||
for size in range(3, 10):
|
||||
for _ in range(400):
|
||||
original = [random.choice('abcd') for _ in range(size)]
|
||||
if original[0] != original[-1]:
|
||||
continue
|
||||
checked += 1
|
||||
out = sm._unclump_seam(list(original))
|
||||
n = len(out)
|
||||
before = sum(1 for i in range(n)
|
||||
if original[i] == original[(i + 1) % n])
|
||||
after = sum(1 for i in range(n) if out[i] == out[(i + 1) % n])
|
||||
assert after <= before, (original, out)
|
||||
assert sorted(out) == sorted(original), (original, out)
|
||||
assert checked > 100, "the generator stopped producing seam cases"
|
||||
|
||||
def test_a_schedule_too_short_to_repair_is_returned_as_is(self):
|
||||
sm = _manager({})
|
||||
assert sm._unclump_seam(['a', 'a']) == ['a', 'a']
|
||||
assert sm._unclump_seam(['a']) == ['a']
|
||||
assert sm._unclump_seam([]) == []
|
||||
|
||||
def test_a_schedule_with_no_seam_clash_is_untouched(self):
|
||||
sm = _manager({})
|
||||
plain = ['a', 'b', 'c', 'a', 'd']
|
||||
assert sm._unclump_seam(plain) == plain
|
||||
|
||||
def test_repeats_are_spread_not_clumped(self):
|
||||
# The point of Smooth Weighted Round-Robin. Three-in-a-row followed by
|
||||
# a long silence would be worse than not boosting at all.
|
||||
order = ['weather', 'mlb', 'news', 'clock', 'stocks', 'f1']
|
||||
plugins = {p: FakePlugin() for p in order}
|
||||
plugins['mlb'] = FakePlugin(live=True)
|
||||
sm = _manager(plugins, live_weight=3)
|
||||
schedule = sm._apply_priority_weights(order)
|
||||
|
||||
assert _counts(schedule)['mlb'] == 3
|
||||
# Evenly spread over 8 slots means a gap of about 3, never 6.
|
||||
assert _max_gap(schedule, 'mlb') <= 4, schedule
|
||||
# And never twice running.
|
||||
assert not any(a == b == 'mlb' for a, b in zip(schedule, schedule[1:])), schedule
|
||||
|
||||
def test_a_favorite_outranks_another_live_game(self):
|
||||
order = ['weather', 'mlb', 'nhl']
|
||||
sm = _manager({'weather': FakePlugin(),
|
||||
'mlb': FakePlugin(live=True, declared=5),
|
||||
'nhl': FakePlugin(live=True)}, live_weight=2)
|
||||
counts = _counts(sm._apply_priority_weights(order))
|
||||
assert counts['mlb'] == 5 and counts['nhl'] == 2 and counts['weather'] == 1, counts
|
||||
|
||||
def test_an_empty_rotation_is_harmless(self):
|
||||
assert _manager({})._apply_priority_weights([]) == []
|
||||
|
||||
|
||||
class TestConfigParsing:
|
||||
def test_defaults_preserve_todays_behaviour(self):
|
||||
cfg = VegasModeConfig.from_config({})
|
||||
assert cfg.live_in_ticker is False
|
||||
assert cfg.live_weight == 3 and cfg.favorite_live_weight == 5
|
||||
|
||||
@pytest.mark.parametrize("given,expected", [(0, 1), (-4, 1), (99, 10), (4, 4)])
|
||||
def test_weights_are_clamped(self, given, expected):
|
||||
cfg = VegasModeConfig.from_config(
|
||||
{'display': {'vegas_scroll': {'live_weight': given}}})
|
||||
assert cfg.live_weight == expected
|
||||
@@ -1,248 +0,0 @@
|
||||
"""Tests for surfacing the underlying error in web responses.
|
||||
|
||||
Regression under test: every failing endpoint returned "An error occurred; see
|
||||
logs for details" and nothing else. On a device whose storage was failing that
|
||||
sentence came back from the restart action, from /system/status, and from
|
||||
/logs -- the log viewer itself -- because journalctl could not be executed. The
|
||||
exception underneath said `[Errno 5] Input/output error: 'systemctl'`, which
|
||||
names the fault outright, and nine handlers were discarding it entirely rather
|
||||
than even logging it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.web_interface.error_handler import describe_exception
|
||||
|
||||
|
||||
class TestDescribeException:
|
||||
def test_names_the_type_and_message(self):
|
||||
detail = describe_exception(OSError(5, "Input/output error", "systemctl"))
|
||||
assert detail == "OSError: [Errno 5] Input/output error: 'systemctl'"
|
||||
|
||||
def test_the_reported_failure_is_legible(self):
|
||||
# The whole point: this string is the diagnosis.
|
||||
assert "Input/output error" in describe_exception(
|
||||
OSError(5, "Input/output error", "systemctl"))
|
||||
|
||||
def test_a_bare_exception_still_names_its_type(self):
|
||||
# A PermissionError with no message still says more than "unknown".
|
||||
assert describe_exception(PermissionError()) == "PermissionError"
|
||||
assert describe_exception(Exception()) == "Exception"
|
||||
|
||||
def test_message_is_kept_when_present(self):
|
||||
assert describe_exception(ValueError("bad port")) == "ValueError: bad port"
|
||||
|
||||
|
||||
class TestCredentialRedaction:
|
||||
"""Exception text quotes URLs, and plugins authenticate by query string."""
|
||||
|
||||
@pytest.mark.parametrize("secret_text,leaked", [
|
||||
("failed: https://api.x.com/v1?api_key=SEC123&city=Tampa", "SEC123"),
|
||||
("token=abcdef123456 was rejected", "abcdef123456"),
|
||||
("connect failed password=hunter2", "hunter2"),
|
||||
("GET /?access_token=zzz999", "zzz999"),
|
||||
('{"secret": "topsecret"}', "topsecret"),
|
||||
# requests quotes the URL it failed on, and both of these forms turn
|
||||
# up in real client exceptions.
|
||||
("401 for https://user:hunter2@example.com/api", "hunter2"),
|
||||
("headers: {'Authorization': 'Bearer eyJ.SECRET.sig'}", "eyJ.SECRET.sig"),
|
||||
("Authorization: Basic dXNlcjpwYXNzd29yZA==", "dXNlcjpwYXNzd29yZA=="),
|
||||
("Proxy-Authorization: Bearer ptok999", "ptok999"),
|
||||
# Any scheme, not a fixed list -- a list silently leaks whatever it
|
||||
# does not name, and plugin APIs invent their own.
|
||||
("Authorization: ApiKey SECRET123", "SECRET123"),
|
||||
("Authorization: Negotiate YIIZnegotiateblob", "YIIZnegotiateblob"),
|
||||
("Authorization: NTLM TlRMTVNTUAAB", "TlRMTVNTUAAB"),
|
||||
("authorization: barecredential", "barecredential"),
|
||||
])
|
||||
def test_credentials_never_reach_the_response(self, secret_text, leaked):
|
||||
detail = describe_exception(RuntimeError(secret_text))
|
||||
assert leaked not in detail
|
||||
assert "<redacted>" in detail
|
||||
|
||||
def test_the_parameter_name_survives_redaction(self):
|
||||
# Knowing *which* credential was involved is part of the diagnosis.
|
||||
detail = describe_exception(RuntimeError("https://x/y?api_key=SEC123"))
|
||||
assert "api_key" in detail
|
||||
|
||||
def test_unknown_schemes_keep_their_name(self):
|
||||
for scheme in ("ApiKey", "Negotiate", "NTLM", "AWS4-HMAC-SHA256"):
|
||||
detail = describe_exception(
|
||||
RuntimeError("Authorization: %s SECRETVALUE" % scheme))
|
||||
assert scheme in detail, detail
|
||||
assert "SECRETVALUE" not in detail, detail
|
||||
|
||||
def test_auth_scheme_and_username_survive(self):
|
||||
# Which kind of credential, and whose, without the credential itself.
|
||||
assert "Bearer" in describe_exception(
|
||||
RuntimeError("Authorization: Bearer eyJ.SECRET.sig"))
|
||||
assert "user" in describe_exception(
|
||||
RuntimeError("https://user:hunter2@example.com"))
|
||||
|
||||
def test_non_secret_context_is_preserved(self):
|
||||
detail = describe_exception(RuntimeError("https://api.x.com/v1?city=Tampa"))
|
||||
assert "city=Tampa" in detail
|
||||
assert "<redacted>" not in detail
|
||||
|
||||
|
||||
class TestBounds:
|
||||
def test_long_messages_are_truncated(self):
|
||||
detail = describe_exception(ValueError("x" * 5000))
|
||||
assert len(detail) <= 400
|
||||
|
||||
def test_newlines_are_collapsed_to_one_line(self):
|
||||
detail = describe_exception(ValueError("line one\nline two\tthree"))
|
||||
assert "\n" not in detail and "\t" not in detail
|
||||
assert detail == "ValueError: line one line two three"
|
||||
|
||||
def test_custom_length_is_honoured(self):
|
||||
assert len(describe_exception(ValueError("y" * 500), max_length=50)) <= 50
|
||||
|
||||
|
||||
class TestHandlersCarryDetail:
|
||||
"""The response shape callers actually see."""
|
||||
|
||||
def test_no_api_v3_handler_discards_its_exception(self):
|
||||
"""Every generic-message handler must log a traceback and return detail.
|
||||
|
||||
Nine of them bound `e` and never used it, so the promised log entry was
|
||||
never written either. Checking merely that *something* was logged is
|
||||
too weak -- a `logger.info("failed")` would satisfy it while throwing
|
||||
the exception away just as completely, so this asserts the two things
|
||||
that actually make the failure diagnosable: an error-level record with
|
||||
the traceback, and the sanitized detail in the response.
|
||||
"""
|
||||
import ast
|
||||
|
||||
src = open("web_interface/blueprints/api_v3.py").read()
|
||||
tree = ast.parse(src)
|
||||
generic = "An error occurred; see logs for details"
|
||||
|
||||
def logs_a_traceback(handler):
|
||||
"""An error/exception-level log call carrying exc_info."""
|
||||
for call in [n for n in ast.walk(handler) if isinstance(n, ast.Call)]:
|
||||
func = call.func
|
||||
if not isinstance(func, ast.Attribute):
|
||||
continue
|
||||
if func.attr == "exception": # implies exc_info
|
||||
return True
|
||||
if func.attr not in ("error", "critical"):
|
||||
continue
|
||||
if any(kw.arg == "exc_info" and getattr(kw.value, "value", False) is True
|
||||
for kw in call.keywords):
|
||||
return True
|
||||
return False
|
||||
|
||||
def describes_this_exception(node, bound):
|
||||
"""A describe_exception(<bound>) call anywhere under `node`."""
|
||||
for call in [n for n in ast.walk(node) if isinstance(n, ast.Call)]:
|
||||
if not (isinstance(call.func, ast.Name)
|
||||
and call.func.id == "describe_exception"):
|
||||
continue
|
||||
if bound is None:
|
||||
return True # bare `except:` cannot name it; accept
|
||||
if any(isinstance(a, ast.Name) and a.id == bound
|
||||
for a in call.args):
|
||||
return True
|
||||
return False
|
||||
|
||||
def returns_the_detail(handler):
|
||||
"""The detail must be inside what the handler actually returns.
|
||||
|
||||
Looking anywhere in the handler is too weak: a handler could
|
||||
compute describe_exception(e), drop it on the floor, and return the
|
||||
generic message with no details field, while still passing. So the
|
||||
call has to appear within a `return` expression.
|
||||
"""
|
||||
returns = [n for n in ast.walk(handler) if isinstance(n, ast.Return)]
|
||||
if not returns:
|
||||
return False
|
||||
return all(describes_this_exception(r, handler.name) for r in returns)
|
||||
|
||||
offenders = []
|
||||
for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]:
|
||||
seg = ast.get_source_segment(src, h) or ""
|
||||
if generic not in seg:
|
||||
continue
|
||||
missing = []
|
||||
if not logs_a_traceback(h):
|
||||
missing.append("error-level log with exc_info")
|
||||
if not returns_the_detail(h):
|
||||
missing.append("describe_exception(e) in the response")
|
||||
if missing:
|
||||
offenders.append((h.lineno, missing))
|
||||
|
||||
assert not offenders, (
|
||||
"handlers returning the generic message without %s: %r"
|
||||
% ("both a traceback log and the detail", offenders))
|
||||
|
||||
def test_client_errors_keep_their_own_status(self):
|
||||
"""A 405 must not be reported as a server-side UNKNOWN_ERROR.
|
||||
|
||||
Werkzeug's HTTPExceptions subclass Exception, so the catch-all saw them
|
||||
too: a GET on a POST-only route came back 500 "an error occurred",
|
||||
which tells the caller nothing and blames the wrong side. Found while
|
||||
probing a device whose POST-only config endpoints answered every GET
|
||||
with UNKNOWN_ERROR.
|
||||
"""
|
||||
from flask import Flask, jsonify
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle(error):
|
||||
if isinstance(error, HTTPException):
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error_code": (error.name or "HTTP_ERROR").upper().replace(" ", "_"),
|
||||
"message": error.description,
|
||||
}), error.code or 500
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error_code": "UNKNOWN_ERROR",
|
||||
"message": "An error occurred; see logs for details",
|
||||
"details": describe_exception(error),
|
||||
}), 500
|
||||
|
||||
@app.route("/only-post", methods=["POST"])
|
||||
def only_post():
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@app.route("/boom")
|
||||
def boom():
|
||||
raise OSError(5, "Input/output error", "systemctl")
|
||||
|
||||
client = app.test_client()
|
||||
|
||||
resp = client.get("/only-post")
|
||||
assert resp.status_code == 405, "a wrong method must stay a 405"
|
||||
assert resp.get_json()["error_code"] == "METHOD_NOT_ALLOWED"
|
||||
|
||||
# A genuine server fault still reports as one, with its detail.
|
||||
resp = client.get("/boom")
|
||||
assert resp.status_code == 500
|
||||
assert "Input/output error" in resp.get_json()["details"]
|
||||
|
||||
def test_global_handler_reports_the_underlying_error(self):
|
||||
from flask import Flask, jsonify
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle(error):
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error_code": "UNKNOWN_ERROR",
|
||||
"message": "An error occurred; see logs for details",
|
||||
"details": describe_exception(error),
|
||||
}), 500
|
||||
|
||||
@app.route("/boom")
|
||||
def boom():
|
||||
raise OSError(5, "Input/output error", "systemctl")
|
||||
|
||||
client = app.test_client()
|
||||
body = client.get("/boom").get_json()
|
||||
assert body["error_code"] == "UNKNOWN_ERROR"
|
||||
assert "Input/output error" in body["details"]
|
||||
@@ -1,220 +0,0 @@
|
||||
"""
|
||||
Path-containment tests for the backup file routes:
|
||||
GET /backup/download/<filename>, DELETE /backup/<filename>, and the
|
||||
listing/validation routes alongside them.
|
||||
|
||||
Both filename routes take user input straight from the URL and turn it
|
||||
into a filesystem path, one to read and one to unlink. `_safe_backup_path`
|
||||
is what stops that from reaching outside the export directory, and it had
|
||||
no tests.
|
||||
|
||||
This is verification of existing containment, not a fix: no bypass was
|
||||
found. The tests exist so that a later "just let dots through" change has
|
||||
to argue with something.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from web_interface.blueprints import api_v3 as api_v3_module # noqa: E402
|
||||
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||
|
||||
_MANAGER_ATTRS = (
|
||||
'config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager',
|
||||
)
|
||||
_SENTINEL = object()
|
||||
|
||||
# Anything that tries to name a file outside the export directory, or that
|
||||
# is not a plain <name>.zip.
|
||||
TRAVERSAL_ATTEMPTS = [
|
||||
"../../etc/passwd",
|
||||
"../config.json",
|
||||
"..%2f..%2fetc%2fpasswd",
|
||||
"....//....//etc/passwd",
|
||||
"/etc/passwd",
|
||||
"..\\..\\config.json",
|
||||
"backup.zip/../../../etc/passwd",
|
||||
".hidden.zip",
|
||||
"backup.txt",
|
||||
"backup.zip.exe",
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path, monkeypatch):
|
||||
export_dir = tmp_path / "backups"
|
||||
export_dir.mkdir()
|
||||
monkeypatch.setattr(api_v3_module, "_BACKUP_EXPORT_DIR", export_dir)
|
||||
|
||||
# A file outside the export dir that a traversal would be reaching for.
|
||||
secret = tmp_path / "config.json"
|
||||
secret.write_text(json.dumps({"secret": "do not touch"}))
|
||||
|
||||
originals = {name: getattr(api_v3, name, _SENTINEL) for name in _MANAGER_ATTRS}
|
||||
for name in _MANAGER_ATTRS:
|
||||
setattr(api_v3, name, MagicMock())
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
|
||||
class Env:
|
||||
pass
|
||||
|
||||
e = Env()
|
||||
e.client = app.test_client()
|
||||
e.export_dir = export_dir
|
||||
e.secret = secret
|
||||
yield e
|
||||
|
||||
for name, original in originals.items():
|
||||
if original is _SENTINEL:
|
||||
if hasattr(api_v3, name):
|
||||
delattr(api_v3, name)
|
||||
else:
|
||||
setattr(api_v3, name, original)
|
||||
|
||||
|
||||
def make_backup(export_dir, name="backup-2026-01-01.zip"):
|
||||
path = export_dir / name
|
||||
path.write_bytes(b"PK\x03\x04fake zip")
|
||||
return path
|
||||
|
||||
|
||||
class TestSafeBackupPath:
|
||||
"""The containment helper itself."""
|
||||
|
||||
@pytest.mark.parametrize("filename", TRAVERSAL_ATTEMPTS)
|
||||
def test_rejects_unsafe_names(self, env, filename):
|
||||
assert api_v3_module._safe_backup_path(filename) is None
|
||||
|
||||
def test_rejects_none(self, env):
|
||||
assert api_v3_module._safe_backup_path(None) is None
|
||||
|
||||
@pytest.mark.parametrize("filename", [
|
||||
"backup.zip",
|
||||
"backup-2026-01-01.zip",
|
||||
"backup_2026.01.01-v2.zip",
|
||||
"a.zip",
|
||||
])
|
||||
def test_accepts_plain_zip_names(self, env, filename):
|
||||
resolved = api_v3_module._safe_backup_path(filename)
|
||||
assert resolved is not None
|
||||
assert resolved.parent == env.export_dir.resolve()
|
||||
|
||||
def test_result_is_always_inside_the_export_dir(self, env):
|
||||
resolved = api_v3_module._safe_backup_path("backup.zip")
|
||||
resolved.relative_to(env.export_dir.resolve()) # raises if outside
|
||||
|
||||
def test_overlong_name_rejected(self, env):
|
||||
assert api_v3_module._safe_backup_path("a" * 250 + ".zip") is None
|
||||
|
||||
|
||||
class TestDownload:
|
||||
def test_downloads_an_existing_backup(self, env):
|
||||
make_backup(env.export_dir)
|
||||
response = env.client.get("/api/v3/backup/download/backup-2026-01-01.zip")
|
||||
assert response.status_code == 200
|
||||
assert response.data == b"PK\x03\x04fake zip"
|
||||
|
||||
def test_missing_file_is_a_404(self, env):
|
||||
response = env.client.get("/api/v3/backup/download/never-made.zip")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.parametrize("filename", TRAVERSAL_ATTEMPTS)
|
||||
def test_traversal_attempts_are_refused(self, env, filename):
|
||||
response = env.client.get(f"/api/v3/backup/download/{filename}")
|
||||
# However the request is turned away — 404 from the containment
|
||||
# check, or 308/405 from routing never matching at all — what
|
||||
# matters is that no file outside the export directory is served.
|
||||
assert response.status_code != 200
|
||||
assert b"do not touch" not in response.data
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_deletes_an_existing_backup(self, env):
|
||||
path = make_backup(env.export_dir)
|
||||
response = env.client.delete("/api/v3/backup/backup-2026-01-01.zip")
|
||||
assert response.status_code == 200
|
||||
assert not path.exists()
|
||||
|
||||
def test_missing_file_is_a_404(self, env):
|
||||
response = env.client.delete("/api/v3/backup/never-made.zip")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.parametrize("filename", TRAVERSAL_ATTEMPTS)
|
||||
def test_traversal_attempts_delete_nothing(self, env, filename):
|
||||
response = env.client.delete(f"/api/v3/backup/{filename}")
|
||||
assert response.status_code != 200
|
||||
assert env.secret.exists() # the file a traversal was aiming at
|
||||
|
||||
def test_only_the_named_backup_is_removed(self, env):
|
||||
keep = make_backup(env.export_dir, "keep.zip")
|
||||
drop = make_backup(env.export_dir, "drop.zip")
|
||||
env.client.delete("/api/v3/backup/drop.zip")
|
||||
assert keep.exists()
|
||||
assert not drop.exists()
|
||||
|
||||
def test_directory_with_a_matching_name_is_not_removed(self, env):
|
||||
# The delete loop matches by name but requires a regular file.
|
||||
(env.export_dir / "sneaky.zip").mkdir()
|
||||
response = env.client.delete("/api/v3/backup/sneaky.zip")
|
||||
assert response.status_code == 404
|
||||
assert (env.export_dir / "sneaky.zip").is_dir()
|
||||
|
||||
|
||||
class TestList:
|
||||
def test_lists_only_zip_files(self, env):
|
||||
make_backup(env.export_dir, "one.zip")
|
||||
(env.export_dir / "notes.txt").write_text("ignore me")
|
||||
response = env.client.get("/api/v3/backup/list")
|
||||
assert response.status_code == 200
|
||||
names = [entry["filename"] for entry in response.get_json()["data"]]
|
||||
assert names == ["one.zip"]
|
||||
|
||||
def test_empty_directory_lists_nothing(self, env):
|
||||
response = env.client.get("/api/v3/backup/list")
|
||||
assert response.get_json()["data"] == []
|
||||
|
||||
def test_entries_carry_size_and_timestamp(self, env):
|
||||
make_backup(env.export_dir, "one.zip")
|
||||
entry = env.client.get("/api/v3/backup/list").get_json()["data"][0]
|
||||
assert entry["size"] == len(b"PK\x03\x04fake zip")
|
||||
assert entry["created_at"]
|
||||
|
||||
|
||||
class TestValidate:
|
||||
def test_missing_file_is_a_400(self, env):
|
||||
response = env.client.post("/api/v3/backup/validate", data={},
|
||||
content_type="multipart/form-data")
|
||||
assert response.status_code == 400
|
||||
assert "No backup_file" in response.get_json()["message"]
|
||||
|
||||
def test_invalid_archive_is_a_400(self, env):
|
||||
response = env.client.post(
|
||||
"/api/v3/backup/validate",
|
||||
data={"backup_file": (io.BytesIO(b"not a zip"), "bad.zip")},
|
||||
content_type="multipart/form-data")
|
||||
assert response.status_code == 400
|
||||
assert "Invalid or corrupted" in response.get_json()["message"]
|
||||
|
||||
def test_validation_does_not_leave_temp_files_in_the_export_dir(self, env):
|
||||
env.client.post(
|
||||
"/api/v3/backup/validate",
|
||||
data={"backup_file": (io.BytesIO(b"not a zip"), "bad.zip")},
|
||||
content_type="multipart/form-data")
|
||||
assert list(env.export_dir.iterdir()) == []
|
||||
@@ -1,262 +0,0 @@
|
||||
"""
|
||||
Endpoint tests for POST /backup/restore.
|
||||
|
||||
Restore is the most destructive operation the web interface exposes: it
|
||||
overwrites config, secrets, WiFi settings and fonts, and reinstalls
|
||||
plugins. It had no tests.
|
||||
|
||||
restore_backup itself is mocked — this file is about what the route does
|
||||
with the request and with the result, not about ZIP handling, which
|
||||
belongs to backup_manager's own tests.
|
||||
|
||||
Regression coverage for one fixed bug: a malformed `options` field fell
|
||||
back to {}, and since every RestoreOptions flag defaults to True, that
|
||||
turned a mis-serialized narrow restore into a full one — secrets
|
||||
included — with no indication anything had been ignored.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||
|
||||
URL = "/api/v3/backup/restore"
|
||||
|
||||
_MANAGER_ATTRS = (
|
||||
'config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager',
|
||||
)
|
||||
_SENTINEL = object()
|
||||
|
||||
|
||||
class FakeResult:
|
||||
"""Stand-in for backup_manager.RestoreResult."""
|
||||
|
||||
def __init__(self, success=True, restored=None, errors=None,
|
||||
plugins_to_install=None):
|
||||
self.success = success
|
||||
self.restored = restored if restored is not None else ["config"]
|
||||
self.errors = errors or []
|
||||
self.plugins_to_install = plugins_to_install or []
|
||||
self.plugins_installed = []
|
||||
self.plugins_failed = []
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"success": self.success,
|
||||
"restored": self.restored,
|
||||
"errors": self.errors,
|
||||
"plugins_installed": self.plugins_installed,
|
||||
"plugins_failed": self.plugins_failed,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
originals = {name: getattr(api_v3, name, _SENTINEL) for name in _MANAGER_ATTRS}
|
||||
for name in _MANAGER_ATTRS:
|
||||
setattr(api_v3, name, MagicMock())
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
yield app.test_client()
|
||||
|
||||
for name, original in originals.items():
|
||||
if original is _SENTINEL:
|
||||
if hasattr(api_v3, name):
|
||||
delattr(api_v3, name)
|
||||
else:
|
||||
setattr(api_v3, name, original)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restore():
|
||||
"""Patch backup_manager.restore_backup (imported inside the handler)."""
|
||||
with patch("src.backup_manager.restore_backup") as mock:
|
||||
mock.return_value = FakeResult()
|
||||
yield mock
|
||||
|
||||
|
||||
def post(client, options=None, filename="backup.zip", content=b"PK\x03\x04fake"):
|
||||
data = {"backup_file": (io.BytesIO(content), filename)}
|
||||
if options is not None:
|
||||
data["options"] = options
|
||||
return client.post(URL, data=data, content_type="multipart/form-data")
|
||||
|
||||
|
||||
class TestRequestValidation:
|
||||
def test_missing_file_is_a_400(self, client, restore):
|
||||
response = client.post(URL, data={}, content_type="multipart/form-data")
|
||||
assert response.status_code == 400
|
||||
assert "No backup_file" in response.get_json()["message"]
|
||||
restore.assert_not_called()
|
||||
|
||||
def test_absent_options_defaults_to_a_full_restore(self, client, restore):
|
||||
# Documented default, not the bug: omitting options entirely means
|
||||
# "restore everything".
|
||||
post(client)
|
||||
options = restore.call_args[0][2]
|
||||
assert options.restore_config is True
|
||||
assert options.restore_secrets is True
|
||||
assert options.reinstall_plugins is True
|
||||
|
||||
def test_partial_options_are_honoured(self, client, restore):
|
||||
post(client, options=json.dumps({
|
||||
"restore_secrets": False, "reinstall_plugins": False}))
|
||||
options = restore.call_args[0][2]
|
||||
assert options.restore_secrets is False
|
||||
assert options.reinstall_plugins is False
|
||||
assert options.restore_config is True # unspecified stays default
|
||||
|
||||
@pytest.mark.parametrize("raw", ["{not json", "", "{'single': 'quotes'}"])
|
||||
def test_malformed_options_are_refused(self, client, restore, raw):
|
||||
# Regression: this fell back to {}, and every flag defaults to
|
||||
# True, so a caller asking for a narrow restore and mis-serializing
|
||||
# it got a full one — secrets overwritten — and no warning.
|
||||
response = post(client, options=raw)
|
||||
assert response.status_code == 400
|
||||
assert "Invalid options" in response.get_json()["message"]
|
||||
restore.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("raw", ["[1,2,3]", '"a string"', "42", "true", "null"])
|
||||
def test_options_that_are_not_an_object_are_refused(self, client, restore, raw):
|
||||
response = post(client, options=raw)
|
||||
assert response.status_code == 400
|
||||
restore.assert_not_called()
|
||||
|
||||
def test_empty_object_is_accepted_as_all_defaults(self, client, restore):
|
||||
assert post(client, options="{}").status_code == 200
|
||||
assert restore.call_args[0][2].restore_config is True
|
||||
|
||||
|
||||
class TestSuccess:
|
||||
def test_success_returns_the_result(self, client, restore):
|
||||
restore.return_value = FakeResult(success=True, restored=["config", "secrets"])
|
||||
response = post(client)
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["status"] == "success"
|
||||
assert body["data"]["restored"] == ["config", "secrets"]
|
||||
|
||||
def test_temp_file_is_cleaned_up(self, client, restore):
|
||||
seen = {}
|
||||
|
||||
def capture(path, project_root, options):
|
||||
seen["path"] = Path(path)
|
||||
assert seen["path"].exists() # present while restoring
|
||||
return FakeResult()
|
||||
|
||||
restore.side_effect = capture
|
||||
post(client)
|
||||
assert not seen["path"].exists()
|
||||
|
||||
def test_temp_file_cleaned_up_even_when_restore_raises(self, client, restore):
|
||||
seen = {}
|
||||
|
||||
def blow_up(path, project_root, options):
|
||||
seen["path"] = Path(path)
|
||||
raise RuntimeError("corrupt archive")
|
||||
|
||||
restore.side_effect = blow_up
|
||||
response = post(client)
|
||||
assert response.status_code == 500
|
||||
assert not seen["path"].exists()
|
||||
|
||||
|
||||
class TestPluginReinstall:
|
||||
def test_plugins_are_reinstalled_when_requested(self, client, restore):
|
||||
restore.return_value = FakeResult(
|
||||
plugins_to_install=[{"plugin_id": "clock"}, {"plugin_id": "weather"}])
|
||||
api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
response = post(client)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"]["plugins_installed"] == ["clock", "weather"]
|
||||
|
||||
def test_reinstall_skipped_when_not_requested(self, client, restore):
|
||||
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
|
||||
post(client, options=json.dumps({"reinstall_plugins": False}))
|
||||
api_v3.plugin_store_manager.install_plugin.assert_not_called()
|
||||
|
||||
def test_entries_without_a_plugin_id_are_skipped(self, client, restore):
|
||||
restore.return_value = FakeResult(plugins_to_install=[{}, {"plugin_id": "clock"}])
|
||||
api_v3.plugin_store_manager.install_plugin.return_value = True
|
||||
post(client)
|
||||
assert api_v3.plugin_store_manager.install_plugin.call_count == 1
|
||||
|
||||
def test_failed_reinstall_turns_the_whole_restore_into_an_error(
|
||||
self, client, restore):
|
||||
# Pinned as intentional: file restoration succeeded and does not
|
||||
# touch result.errors, but a user whose plugins did not come back
|
||||
# should not be told the restore was a success.
|
||||
restore.return_value = FakeResult(
|
||||
success=True, plugins_to_install=[{"plugin_id": "clock"}])
|
||||
api_v3.plugin_store_manager.install_plugin.return_value = False
|
||||
response = post(client)
|
||||
assert response.status_code == 500
|
||||
body = response.get_json()
|
||||
assert body["status"] == "error"
|
||||
assert "clock" in body["message"]
|
||||
|
||||
def test_message_names_what_landed_and_what_did_not(self, client, restore):
|
||||
restore.return_value = FakeResult(
|
||||
success=True, restored=["config", "fonts"],
|
||||
plugins_to_install=[{"plugin_id": "clock"}])
|
||||
api_v3.plugin_store_manager.install_plugin.return_value = False
|
||||
message = post(client).get_json()["message"]
|
||||
assert "restored: config, fonts" in message
|
||||
assert "plugins not reinstalled: clock" in message
|
||||
|
||||
def test_install_exception_is_recorded_without_leaking_details(
|
||||
self, client, restore):
|
||||
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
|
||||
api_v3.plugin_store_manager.install_plugin.side_effect = RuntimeError(
|
||||
"/srv/internal/path exploded")
|
||||
body = post(client).get_json()
|
||||
failures = body["data"]["plugins_failed"]
|
||||
assert failures[0]["plugin_id"] == "clock"
|
||||
assert "/srv/internal/path" not in json.dumps(body)
|
||||
|
||||
def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
|
||||
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
|
||||
api_v3.plugin_store_manager = None
|
||||
with patch("web_interface.blueprints.api_v3.plugin_store_manager", None):
|
||||
body = post(client).get_json()
|
||||
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
|
||||
|
||||
|
||||
class TestFailureReporting:
|
||||
def test_restore_errors_produce_a_500(self, client, restore):
|
||||
restore.return_value = FakeResult(
|
||||
success=False, restored=[], errors=["config: permission denied"])
|
||||
response = post(client)
|
||||
assert response.status_code == 500
|
||||
assert "permission denied" in response.get_json()["message"]
|
||||
|
||||
def test_partial_restore_names_both_sides(self, client, restore):
|
||||
restore.return_value = FakeResult(
|
||||
success=False, restored=["config"], errors=["secrets: unwritable"])
|
||||
message = post(client).get_json()["message"]
|
||||
assert "restored: config" in message
|
||||
assert "failed: secrets: unwritable" in message
|
||||
|
||||
def test_failure_without_detail_still_says_something(self, client, restore):
|
||||
restore.return_value = FakeResult(success=False, restored=[], errors=[])
|
||||
message = post(client).get_json()["message"]
|
||||
assert "Restore incomplete" in message
|
||||
|
||||
def test_unexpected_exception_is_a_500(self, client, restore):
|
||||
restore.side_effect = RuntimeError("boom")
|
||||
response = post(client)
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()["status"] == "error"
|
||||
@@ -1,204 +0,0 @@
|
||||
"""
|
||||
Endpoint tests for POST /config/raw/main and POST /config/raw/secrets.
|
||||
|
||||
These write whatever JSON they are given straight to config.json and
|
||||
config_secrets.json, bypassing the secret-separation path that
|
||||
/config/main and the plugin-config endpoints go through. Given how much
|
||||
care the rest of the config surface takes to keep secrets out of
|
||||
config.json, an untested pair of endpoints that writes it verbatim is
|
||||
worth pinning precisely.
|
||||
|
||||
Like test_api_v3_secret_roundtrip.py, these run a REAL ConfigManager over
|
||||
tmp_path so the assertions are against files on disk rather than mock
|
||||
calls.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from src.config_manager import ConfigManager # noqa: E402
|
||||
from src.exceptions import ConfigError # noqa: E402
|
||||
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||
|
||||
MAIN = "/api/v3/config/raw/main"
|
||||
SECRETS = "/api/v3/config/raw/secrets"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({"timezone": "UTC"}))
|
||||
secrets_file = tmp_path / "config_secrets.json"
|
||||
|
||||
config_manager = ConfigManager(
|
||||
config_path=str(config_file), secrets_path=str(secrets_file))
|
||||
config_manager.template_path = str(tmp_path / "no-template.json")
|
||||
|
||||
_SENTINEL = object()
|
||||
attrs = ('config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager',
|
||||
'schema_manager', 'operation_queue', 'operation_history',
|
||||
'cache_manager')
|
||||
originals = {name: getattr(api_v3, name, _SENTINEL) for name in attrs}
|
||||
|
||||
for name in attrs:
|
||||
setattr(api_v3, name, MagicMock())
|
||||
api_v3.config_manager = config_manager
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
|
||||
class Env:
|
||||
pass
|
||||
|
||||
e = Env()
|
||||
e.client = app.test_client()
|
||||
e.config_manager = config_manager
|
||||
e.config_file = config_file
|
||||
e.secrets_file = secrets_file
|
||||
yield e
|
||||
|
||||
for name, original in originals.items():
|
||||
if original is _SENTINEL:
|
||||
if hasattr(api_v3, name):
|
||||
delattr(api_v3, name)
|
||||
else:
|
||||
setattr(api_v3, name, original)
|
||||
|
||||
|
||||
class TestSaveRawMain:
|
||||
def test_writes_the_body_to_config_json(self, env):
|
||||
response = env.client.post(MAIN, json={"timezone": "America/Chicago"})
|
||||
assert response.status_code == 200
|
||||
assert json.loads(env.config_file.read_text()) == {"timezone": "America/Chicago"}
|
||||
|
||||
def test_replaces_rather_than_merges(self, env):
|
||||
env.client.post(MAIN, json={"only": "this"})
|
||||
assert json.loads(env.config_file.read_text()) == {"only": "this"}
|
||||
|
||||
def test_does_not_touch_the_secrets_file(self, env):
|
||||
env.secrets_file.write_text(json.dumps({"weather": {"api_key": "k"}}))
|
||||
env.client.post(MAIN, json={"timezone": "UTC"})
|
||||
assert json.loads(env.secrets_file.read_text()) == {"weather": {"api_key": "k"}}
|
||||
|
||||
def test_uninitialized_manager_is_a_500(self, env):
|
||||
api_v3.config_manager = None
|
||||
response = env.client.post(MAIN, json={"timezone": "UTC"})
|
||||
assert response.status_code == 500
|
||||
assert "not initialized" in response.get_json()["message"]
|
||||
|
||||
def test_empty_object_is_a_400(self, env):
|
||||
response = env.client.post(MAIN, json={})
|
||||
assert response.status_code == 400
|
||||
assert "No data provided" in response.get_json()["message"]
|
||||
|
||||
def test_bodyless_post_is_a_400(self, env):
|
||||
response = env.client.post(MAIN)
|
||||
assert response.status_code == 400
|
||||
assert "No data provided" in response.get_json()["message"]
|
||||
|
||||
def test_malformed_json_is_a_400_in_the_app_shape(self, env):
|
||||
response = env.client.post(MAIN, data="{not json",
|
||||
content_type="application/json")
|
||||
assert response.status_code == 400
|
||||
body = response.get_json()
|
||||
assert body["status"] == "error"
|
||||
# A body that was sent but does not parse is a distinct mistake
|
||||
# from sending none, and says so. Previously the handler's own
|
||||
# json.JSONDecodeError arm was unreachable — Werkzeug raised
|
||||
# first — so this collapsed into "No data provided".
|
||||
assert "Invalid JSON in request body" in body["message"]
|
||||
|
||||
def test_config_error_is_a_500_with_context(self, env, monkeypatch):
|
||||
def refuse(kind, data):
|
||||
raise ConfigError("cannot write", config_path="/etc/x.json")
|
||||
monkeypatch.setattr(env.config_manager, "save_raw_file_content", refuse)
|
||||
response = env.client.post(MAIN, json={"timezone": "UTC"})
|
||||
assert response.status_code == 500
|
||||
assert "/etc/x.json" in json.dumps(response.get_json())
|
||||
|
||||
def test_unexpected_error_is_a_500(self, env, monkeypatch):
|
||||
def boom(kind, data):
|
||||
raise RuntimeError("disk on fire")
|
||||
monkeypatch.setattr(env.config_manager, "save_raw_file_content", boom)
|
||||
response = env.client.post(MAIN, json={"timezone": "UTC"})
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
|
||||
class TestSaveRawSecrets:
|
||||
def test_writes_only_to_the_secrets_file(self, env):
|
||||
response = env.client.post(SECRETS, json={"weather": {"api_key": "s3cret"}})
|
||||
assert response.status_code == 200
|
||||
assert json.loads(env.secrets_file.read_text()) == {"weather": {"api_key": "s3cret"}}
|
||||
|
||||
def test_secret_values_never_reach_config_json(self, env):
|
||||
env.client.post(SECRETS, json={"weather": {"api_key": "s3cret"}})
|
||||
assert "s3cret" not in env.config_file.read_text()
|
||||
|
||||
def test_existing_main_config_is_untouched(self, env):
|
||||
before = env.config_file.read_text()
|
||||
env.client.post(SECRETS, json={"weather": {"api_key": "k"}})
|
||||
assert env.config_file.read_text() == before
|
||||
|
||||
def test_github_token_is_reloaded_for_the_store_manager(self, env):
|
||||
store = MagicMock()
|
||||
store._load_github_token.return_value = "ghp_new"
|
||||
api_v3.plugin_store_manager = store
|
||||
env.client.post(SECRETS, json={"github": {"token": "ghp_new"}})
|
||||
store._load_github_token.assert_called_once()
|
||||
assert store.github_token == "ghp_new"
|
||||
|
||||
def test_absent_store_manager_is_fine(self, env):
|
||||
api_v3.plugin_store_manager = None
|
||||
assert env.client.post(SECRETS, json={"a": 1}).status_code == 200
|
||||
|
||||
def test_uninitialized_manager_is_a_500(self, env):
|
||||
api_v3.config_manager = None
|
||||
assert env.client.post(SECRETS, json={"a": 1}).status_code == 500
|
||||
|
||||
def test_empty_object_is_a_400(self, env):
|
||||
assert env.client.post(SECRETS, json={}).status_code == 400
|
||||
|
||||
def test_bodyless_post_is_a_400(self, env):
|
||||
assert env.client.post(SECRETS).status_code == 400
|
||||
|
||||
def test_error_is_a_500(self, env, monkeypatch):
|
||||
def boom(kind, data):
|
||||
raise RuntimeError("nope")
|
||||
monkeypatch.setattr(env.config_manager, "save_raw_file_content", boom)
|
||||
assert env.client.post(SECRETS, json={"a": 1}).status_code == 500
|
||||
|
||||
|
||||
class TestRawEndpointsBypassSecretSeparation:
|
||||
"""Pinned behaviour, deliberately not "fixed".
|
||||
|
||||
These endpoints are the escape hatch for editing the config files
|
||||
directly from the web UI's raw JSON editor. They write what they are
|
||||
given, so a secret typed into the main-config editor lands in
|
||||
config.json in plain text — unlike /config/main and the plugin-config
|
||||
endpoints, which route x-secret fields into config_secrets.json.
|
||||
|
||||
That is the point of a raw editor, but it is a sharp edge worth
|
||||
stating out loud: anyone adding a "convenience" that posts plugin
|
||||
config through this endpoint would silently lose secret separation.
|
||||
"""
|
||||
|
||||
def test_secret_shaped_keys_are_written_verbatim_to_main(self, env):
|
||||
env.client.post(MAIN, json={"weather": {"api_key": "PLAINTEXT-KEY"}})
|
||||
on_disk = json.loads(env.config_file.read_text())
|
||||
assert on_disk["weather"]["api_key"] == "PLAINTEXT-KEY"
|
||||
|
||||
def test_no_separation_happens_on_the_raw_path(self, env):
|
||||
env.client.post(MAIN, json={"weather": {"api_key": "PLAINTEXT-KEY"}})
|
||||
# Nothing was moved aside into the secrets file.
|
||||
assert not env.secrets_file.exists() or "PLAINTEXT-KEY" not in env.secrets_file.read_text()
|
||||
@@ -1,259 +0,0 @@
|
||||
"""
|
||||
End-to-end secret round-trips through the three api_v3 endpoints that
|
||||
separate secrets from regular config (main-config save, plugin-config save,
|
||||
plugin-config reset) — now backed by the canonical
|
||||
src/web_interface/secret_helpers implementations.
|
||||
|
||||
Unlike test_web_api.py (which mocks the config manager), these tests run a
|
||||
REAL ConfigManager and a REAL SchemaManager over tmp_path files, so they
|
||||
prove the whole chain: endpoint separation -> config_secrets.json write ->
|
||||
atomic config.json save (strip) -> load_config (merge back), including the
|
||||
array-item secret shape (accounts[].token) the inline copies never
|
||||
supported.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from src.config_manager import ConfigManager # noqa: E402
|
||||
from src.plugin_system.schema_manager import SchemaManager # noqa: E402
|
||||
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||
|
||||
|
||||
PLUGIN_ID = "testplugin"
|
||||
|
||||
SCHEMA = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"display_duration": {"type": "number", "default": 15},
|
||||
"api_key": {"type": "string", "x-secret": True, "default": ""},
|
||||
"city": {"type": "string", "default": "Austin"},
|
||||
"accounts": {
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"token": {"type": "string", "x-secret": True},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
"""Real ConfigManager + SchemaManager over tmp_path, wired onto the
|
||||
api_v3 blueprint with the remaining managers mocked."""
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("{}")
|
||||
|
||||
plugins_dir = tmp_path / "plugins"
|
||||
plugin_dir = plugins_dir / PLUGIN_ID
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "config_schema.json").write_text(json.dumps(SCHEMA))
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps({
|
||||
"id": PLUGIN_ID, "name": "Test Plugin", "version": "1.0.0",
|
||||
}))
|
||||
|
||||
config_manager = ConfigManager(
|
||||
config_path=str(config_file),
|
||||
secrets_path=str(tmp_path / "config_secrets.json"))
|
||||
config_manager.template_path = str(tmp_path / "no-template.json")
|
||||
|
||||
schema_manager = SchemaManager(plugins_dir=plugins_dir,
|
||||
project_root=tmp_path)
|
||||
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}}
|
||||
plugin_manager.plugins_dir = plugins_dir
|
||||
plugin_manager.get_plugin.return_value = None
|
||||
|
||||
api_v3.config_manager = config_manager
|
||||
api_v3.schema_manager = schema_manager
|
||||
api_v3.plugin_manager = plugin_manager
|
||||
api_v3.plugin_store_manager = MagicMock()
|
||||
api_v3.saved_repositories_manager = MagicMock()
|
||||
api_v3.operation_queue = MagicMock()
|
||||
api_v3.plugin_state_manager = MagicMock()
|
||||
api_v3.operation_history = MagicMock()
|
||||
api_v3.cache_manager = MagicMock()
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
|
||||
class Env:
|
||||
pass
|
||||
|
||||
e = Env()
|
||||
e.client = app.test_client()
|
||||
e.config_manager = config_manager
|
||||
e.config_file = config_file
|
||||
e.secrets_file = tmp_path / "config_secrets.json"
|
||||
e.tmp_path = tmp_path
|
||||
|
||||
def fresh_load():
|
||||
"""Load via a NEW ConfigManager, as the next request/process would.
|
||||
|
||||
The endpoint's manager serves its post-save in-memory config via the
|
||||
mtime fast path, and that copy predates the secrets it just
|
||||
separated out — a pre-existing quirk that applies to scalar secrets
|
||||
too. On-disk truth is what these tests care about.
|
||||
"""
|
||||
fresh = ConfigManager(config_path=str(config_file),
|
||||
secrets_path=str(e.secrets_file))
|
||||
fresh.template_path = str(tmp_path / "no-template.json")
|
||||
return fresh.load_config()
|
||||
|
||||
e.fresh_load = fresh_load
|
||||
return e
|
||||
|
||||
|
||||
def _on_disk(path):
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
class TestSaveMainConfig:
|
||||
"""Site A: POST /config/main with a plugin-id key."""
|
||||
|
||||
def test_array_and_scalar_secrets_routed_to_secrets_file(self, env):
|
||||
resp = env.client.post("/api/v3/config/main", json={
|
||||
PLUGIN_ID: {
|
||||
"city": "Dallas",
|
||||
"api_key": "s3cret-key",
|
||||
"accounts": [
|
||||
{"name": "a", "token": "s3cret-a"},
|
||||
{"name": "b"},
|
||||
],
|
||||
},
|
||||
})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
on_disk = _on_disk(env.config_file)
|
||||
assert on_disk[PLUGIN_ID]["city"] == "Dallas"
|
||||
assert "api_key" not in on_disk[PLUGIN_ID]
|
||||
assert on_disk[PLUGIN_ID]["accounts"] == [{"name": "a"}, {"name": "b"}]
|
||||
assert "s3cret" not in env.config_file.read_text()
|
||||
|
||||
secrets = _on_disk(env.secrets_file)
|
||||
assert secrets[PLUGIN_ID]["api_key"] == "s3cret-key"
|
||||
assert secrets[PLUGIN_ID]["accounts"] == [{"token": "s3cret-a"}, {}]
|
||||
|
||||
def test_load_config_merges_secrets_back(self, env):
|
||||
env.client.post("/api/v3/config/main", json={
|
||||
PLUGIN_ID: {"accounts": [{"name": "a", "token": "s3cret-a"}]},
|
||||
})
|
||||
merged = env.fresh_load()
|
||||
assert merged[PLUGIN_ID]["accounts"] == [
|
||||
{"name": "a", "token": "s3cret-a"}]
|
||||
|
||||
|
||||
class TestSavePluginConfig:
|
||||
"""Site B: POST /plugins/config (JSON body)."""
|
||||
|
||||
def _save(self, env, config):
|
||||
return env.client.post("/api/v3/plugins/config", json={
|
||||
"plugin_id": PLUGIN_ID, "config": config,
|
||||
})
|
||||
|
||||
def test_round_trip_with_array_secrets(self, env):
|
||||
resp = self._save(env, {
|
||||
"enabled": True,
|
||||
"city": "Houston",
|
||||
"api_key": "s3cret-key",
|
||||
"accounts": [
|
||||
{"name": "a", "token": "s3cret-a"},
|
||||
{"name": "b", "token": "s3cret-b"},
|
||||
],
|
||||
})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
assert "s3cret" not in env.config_file.read_text()
|
||||
on_disk = _on_disk(env.config_file)
|
||||
assert on_disk[PLUGIN_ID]["accounts"] == [{"name": "a"}, {"name": "b"}]
|
||||
|
||||
secrets = _on_disk(env.secrets_file)
|
||||
assert secrets[PLUGIN_ID]["accounts"] == [
|
||||
{"token": "s3cret-a"}, {"token": "s3cret-b"}]
|
||||
|
||||
merged = env.fresh_load()
|
||||
assert merged[PLUGIN_ID]["accounts"][1]["token"] == "s3cret-b"
|
||||
|
||||
def test_secret_count_message_counts_top_level_keys(self, env):
|
||||
# Pinned: the "(N secret field(s))" message counts TOP-LEVEL keys of
|
||||
# the separated secrets dict. Here that is 2: the posted accounts
|
||||
# array (all its item tokens count as ONE key) plus the schema's
|
||||
# api_key default ("") that merge_with_defaults adds before
|
||||
# separation.
|
||||
resp = self._save(env, {
|
||||
"accounts": [{"name": "a", "token": "t"}],
|
||||
})
|
||||
message = resp.get_json()["message"]
|
||||
assert "(2 secret field(s) saved to config_secrets.json)" in message
|
||||
|
||||
def test_resave_replaces_stored_secrets_list_wholesale(self, env):
|
||||
# Characterized: api_v3's deep_merge intentionally replaces lists,
|
||||
# so a re-save's parallel secrets list is authoritative.
|
||||
self._save(env, {"accounts": [
|
||||
{"name": "a", "token": "old-a"},
|
||||
{"name": "b", "token": "old-b"},
|
||||
]})
|
||||
self._save(env, {"accounts": [{"name": "only", "token": "new-only"}]})
|
||||
|
||||
secrets = _on_disk(env.secrets_file)
|
||||
assert secrets[PLUGIN_ID]["accounts"] == [{"token": "new-only"}]
|
||||
merged = env.fresh_load()
|
||||
assert merged[PLUGIN_ID]["accounts"] == [
|
||||
{"name": "only", "token": "new-only"}]
|
||||
|
||||
|
||||
class TestResetPluginConfig:
|
||||
"""Site C: POST /plugins/config/reset."""
|
||||
|
||||
def _seed(self, env):
|
||||
env.client.post("/api/v3/plugins/config", json={
|
||||
"plugin_id": PLUGIN_ID,
|
||||
"config": {"city": "Houston", "api_key": "s3cret-key",
|
||||
"accounts": [{"name": "a", "token": "s3cret-a"}]},
|
||||
})
|
||||
|
||||
def test_reset_preserving_secrets(self, env):
|
||||
self._seed(env)
|
||||
resp = env.client.post("/api/v3/plugins/config/reset", json={
|
||||
"plugin_id": PLUGIN_ID, "preserve_secrets": True,
|
||||
})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
on_disk = _on_disk(env.config_file)
|
||||
assert on_disk[PLUGIN_ID]["city"] == "Austin" # schema default
|
||||
assert on_disk[PLUGIN_ID]["accounts"] == [] # schema default
|
||||
|
||||
# Existing secrets survive (top-level-only preserve merge, pinned).
|
||||
secrets = _on_disk(env.secrets_file)
|
||||
assert secrets[PLUGIN_ID]["api_key"] == "s3cret-key"
|
||||
assert secrets[PLUGIN_ID]["accounts"] == [{"token": "s3cret-a"}]
|
||||
|
||||
def test_reset_without_preserving_secrets(self, env):
|
||||
self._seed(env)
|
||||
resp = env.client.post("/api/v3/plugins/config/reset", json={
|
||||
"plugin_id": PLUGIN_ID, "preserve_secrets": False,
|
||||
})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
secrets = _on_disk(env.secrets_file)
|
||||
# Replaced with schema-default secrets — the schema declares no
|
||||
# secret defaults, so the plugin's secrets are emptied.
|
||||
assert secrets[PLUGIN_ID] in ({}, {"api_key": ""})
|
||||
@@ -1,408 +0,0 @@
|
||||
"""Tests the calendar plugin's OAuth and calendar-listing endpoints.
|
||||
|
||||
The plugin's config UI advertised a three-step setup, but only step 1 existed
|
||||
on the server. Step 3's picker fetched /api/v3/plugins/calendar/list-calendars,
|
||||
which was never registered, so Flask fell through to the global 404 handler and
|
||||
the user saw "Resource not found" — with nothing to say which resource. Step 2
|
||||
had no endpoint either, and no field in the schema at all, even though the
|
||||
plugin ships calendar_registration.py written expressly for a web-driven
|
||||
two-step flow.
|
||||
|
||||
These cover the two new routes: that they exist, that they fail with something
|
||||
actionable rather than a bare 404, and that the shapes the widgets consume are
|
||||
what the server actually sends.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pickle
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from web_interface.blueprints import api_v3 as mod # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, tmp_path):
|
||||
"""A test client whose calendar plugin lives in tmp_path."""
|
||||
from flask import Flask
|
||||
|
||||
plugin_dir = tmp_path / 'calendar'
|
||||
plugin_dir.mkdir()
|
||||
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(mod.api_v3, url_prefix='/api/v3')
|
||||
app.config['TESTING'] = True
|
||||
monkeypatch.setattr(mod, '_calendar_plugin_dir', lambda: plugin_dir)
|
||||
with app.test_client() as c:
|
||||
c.plugin_dir = plugin_dir
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uninstalled(monkeypatch):
|
||||
from flask import Flask
|
||||
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(mod.api_v3, url_prefix='/api/v3')
|
||||
app.config['TESTING'] = True
|
||||
monkeypatch.setattr(mod, '_calendar_plugin_dir', lambda: None)
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestTheRoutesExistAtAll:
|
||||
"""The original bug: the URLs the widgets call were not registered."""
|
||||
|
||||
def test_list_calendars_is_routed(self, client):
|
||||
response = client.get('/api/v3/plugins/calendar/list-calendars')
|
||||
# Reaching the handler is the whole point; what it then says about
|
||||
# missing setup is TestItSaysWhatIsWrong's business.
|
||||
assert response.status_code != 404, "still unrouted"
|
||||
assert response.get_json()['message'] != 'Resource not found'
|
||||
|
||||
def test_authenticate_is_routed(self, client):
|
||||
response = client.post('/api/v3/plugins/calendar/authenticate', json={})
|
||||
assert response.status_code != 404, "still unrouted"
|
||||
assert response.get_json()['message'] != 'Resource not found'
|
||||
|
||||
def test_both_urls_match_what_the_widgets_request(self):
|
||||
# The widgets hardcode these; a rename on either side reintroduces the
|
||||
# original bug silently.
|
||||
picker = Path(project_root) / 'web_interface/static/v3/js/widgets/google-calendar-picker.js'
|
||||
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||
assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
|
||||
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
|
||||
source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8')
|
||||
assert "'/plugins/calendar/list-calendars'" in source
|
||||
assert "'/plugins/calendar/authenticate'" in source
|
||||
|
||||
def test_the_oauth_widget_is_dispatched_not_rendered_as_a_text_box(self):
|
||||
# The string branch of the config template dispatches on an allow-list
|
||||
# of widget names; anything missing from it silently falls through to a
|
||||
# plain <input type="text">. That produced two boxes on the calendar
|
||||
# page -- the widget's own, and a stray one for the same field -- and
|
||||
# no way to tell which to paste into.
|
||||
template = (Path(project_root)
|
||||
/ 'web_interface/templates/v3/partials/plugin_config.html'
|
||||
).read_text(encoding='utf-8')
|
||||
allow_list_line = [ln for ln in template.splitlines()
|
||||
if "str_widget in [" in ln]
|
||||
assert allow_list_line, "the string widget allow-list moved"
|
||||
assert "'google-oauth'" in allow_list_line[0], allow_list_line[0]
|
||||
|
||||
def test_the_widget_script_is_served(self):
|
||||
base = (Path(project_root) / 'web_interface/templates/v3/base.html'
|
||||
).read_text(encoding='utf-8')
|
||||
assert 'widgets/google-oauth.js' in base
|
||||
|
||||
def test_the_status_line_is_announced(self):
|
||||
# Every message the widget gives arrives after an async call, so a
|
||||
# screen reader hears nothing unless the element is a live region.
|
||||
widget = (Path(project_root)
|
||||
/ 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||
).read_text(encoding='utf-8')
|
||||
# Both attributes must be on the *status* element. Searching for them
|
||||
# separately would pass with each on a different node, which announces
|
||||
# nothing.
|
||||
assert "status.setAttribute('role', 'status')" in widget, widget[:0]
|
||||
assert "status.setAttribute('aria-live', 'polite')" in widget
|
||||
|
||||
def test_the_paste_box_has_an_accessible_name(self):
|
||||
# A visible label is not enough on its own: without the association the
|
||||
# input's only name is a placeholder, which vanishes on focus -- which
|
||||
# is exactly when the value is being pasted.
|
||||
widget = (Path(project_root)
|
||||
/ 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||
).read_text(encoding='utf-8')
|
||||
# The binding is what matters, not that both lines exist: a `for` and
|
||||
# an `id` that disagree leave the input just as anonymous. Both must
|
||||
# go through the same identifier.
|
||||
import re as _re
|
||||
for_target = _re.search(r"codeLabel\.setAttribute\('for',\s*(\w+)\)", widget)
|
||||
id_source = _re.search(r"codeInput\.id\s*=\s*(\w+)", widget)
|
||||
assert for_target and id_source, (for_target, id_source)
|
||||
assert for_target.group(1) == id_source.group(1), (
|
||||
"label points at %r but the input is %r"
|
||||
% (for_target.group(1), id_source.group(1)))
|
||||
|
||||
def test_the_failed_page_is_called_out_loudly(self):
|
||||
# The loopback redirect lands on a browser error page at exactly the
|
||||
# moment the user has to act. In small grey text it gets missed and the
|
||||
# flow reads as broken while it is working.
|
||||
widget = (Path(project_root)
|
||||
/ 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||
).read_text(encoding='utf-8')
|
||||
assert 'expected' in widget.lower()
|
||||
assert 'amber' in widget, "the warning is not visually distinguished"
|
||||
|
||||
|
||||
class TestItSaysWhatIsWrong:
|
||||
def test_listing_without_a_token_asks_for_step_2(self, client):
|
||||
response = client.get('/api/v3/plugins/calendar/list-calendars')
|
||||
assert response.status_code == 400
|
||||
body = response.get_json()
|
||||
assert body['status'] == 'error'
|
||||
assert 'step 2' in body['message'].lower(), body['message']
|
||||
|
||||
def test_authenticating_without_credentials_asks_for_step_1(self, client):
|
||||
response = client.post('/api/v3/plugins/calendar/authenticate', json={})
|
||||
assert response.status_code == 400
|
||||
assert 'step 1' in response.get_json()['message'].lower()
|
||||
|
||||
def test_an_uninstalled_plugin_says_so(self, uninstalled):
|
||||
for response in (
|
||||
uninstalled.get('/api/v3/plugins/calendar/list-calendars'),
|
||||
uninstalled.post('/api/v3/plugins/calendar/authenticate', json={}),
|
||||
):
|
||||
assert response.status_code == 404
|
||||
# A 404 here is honest -- but it must name the plugin, not read as
|
||||
# the generic "Resource not found" that started this.
|
||||
assert 'not installed' in response.get_json()['message'].lower()
|
||||
|
||||
|
||||
class TestTheScriptRunner:
|
||||
def test_it_returns_the_json_the_script_prints(self, tmp_path):
|
||||
script = tmp_path / 'calendar_registration.py'
|
||||
script.write_text(
|
||||
'print(\'{"status": "success", "auth_url": "https://x"}\')\n',
|
||||
encoding='utf-8')
|
||||
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||
assert error is None
|
||||
assert payload['auth_url'] == 'https://x'
|
||||
|
||||
def test_it_ignores_noise_before_the_json(self, tmp_path):
|
||||
# An import warning or a library writing to stdout would otherwise
|
||||
# make the last-line parse fail.
|
||||
script = tmp_path / 'calendar_registration.py'
|
||||
script.write_text(
|
||||
'print("some library warning")\n'
|
||||
'print(\'{"status": "success"}\')\n', encoding='utf-8')
|
||||
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||
assert error is None and payload['status'] == 'success'
|
||||
|
||||
def test_it_passes_stdin_through(self, tmp_path):
|
||||
script = tmp_path / 'calendar_registration.py'
|
||||
script.write_text(
|
||||
'import sys, json\n'
|
||||
'print(json.dumps({"status": "success", "got": sys.stdin.read().strip()}))\n',
|
||||
encoding='utf-8')
|
||||
payload, _ = mod._run_calendar_registration(tmp_path, 'http://127.0.0.1/?code=abc')
|
||||
assert payload['got'] == 'http://127.0.0.1/?code=abc'
|
||||
|
||||
def test_a_missing_script_is_reported(self, tmp_path):
|
||||
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||
assert payload is None
|
||||
assert 'script not found' in error.lower()
|
||||
|
||||
def test_output_that_is_not_json_is_reported_with_context(self, tmp_path):
|
||||
script = tmp_path / 'calendar_registration.py'
|
||||
script.write_text('import sys\nsys.stderr.write("boom\\n")\n', encoding='utf-8')
|
||||
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||
assert payload is None
|
||||
assert 'no result' in error.lower()
|
||||
assert 'boom' in error
|
||||
|
||||
|
||||
class TestListingShape:
|
||||
"""The picker reads cal.id, cal.summary and cal.primary."""
|
||||
|
||||
def _authenticate(self, client, monkeypatch, items):
|
||||
creds = type('C', (), {'expired': False, 'refresh_token': None, 'valid': True})()
|
||||
(client.plugin_dir / 'token.pickle').write_bytes(pickle.dumps({'x': 1}))
|
||||
monkeypatch.setattr(mod.pickle if hasattr(mod, 'pickle') else pickle,
|
||||
'loads', lambda *a, **k: creds, raising=False)
|
||||
|
||||
import types
|
||||
fake_pickle = types.SimpleNamespace(load=lambda f: creds, dump=lambda *a: None)
|
||||
# Callers pass a flat list of calendars; the API returns them wrapped
|
||||
# in a page. One page is all these cases need -- TestPagination builds
|
||||
# its own multi-page sequences.
|
||||
pages = [{'items': items}]
|
||||
|
||||
state = {'i': 0}
|
||||
|
||||
def fake_list(**kwargs):
|
||||
page = pages[min(state['i'], len(pages) - 1)]
|
||||
state['i'] += 1
|
||||
return types.SimpleNamespace(execute=lambda: page)
|
||||
|
||||
def fake_build(*args, **kwargs):
|
||||
return types.SimpleNamespace(
|
||||
calendarList=lambda: types.SimpleNamespace(list=fake_list))
|
||||
|
||||
real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
|
||||
else __builtins__.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == 'pickle':
|
||||
return fake_pickle
|
||||
if name == 'google.auth.transport.requests':
|
||||
return types.SimpleNamespace(Request=object)
|
||||
if name == 'googleapiclient.discovery':
|
||||
return types.SimpleNamespace(build=fake_build)
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr('builtins.__import__', fake_import)
|
||||
|
||||
def test_it_returns_id_summary_and_primary(self, client, monkeypatch):
|
||||
self._authenticate(client, monkeypatch, [
|
||||
{'id': 'b@x', 'summary': 'Work'},
|
||||
{'id': 'a@x', 'summary': 'Personal', 'primary': True},
|
||||
])
|
||||
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||
assert body['status'] == 'success'
|
||||
assert {c['id'] for c in body['calendars']} == {'a@x', 'b@x'}
|
||||
assert all(set(c) == {'id', 'summary', 'primary'} for c in body['calendars'])
|
||||
|
||||
def test_the_primary_calendar_comes_first(self, client, monkeypatch):
|
||||
# Short list, but the one the user wants is almost always their own.
|
||||
self._authenticate(client, monkeypatch, [
|
||||
{'id': 'z@x', 'summary': 'Aardvarks'},
|
||||
{'id': 'a@x', 'summary': 'Zebras', 'primary': True},
|
||||
])
|
||||
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||
assert body['calendars'][0]['id'] == 'a@x'
|
||||
assert body['calendars'][0]['primary'] is True
|
||||
|
||||
def test_a_calendar_without_a_name_still_lists(self, client, monkeypatch):
|
||||
self._authenticate(client, monkeypatch, [{'id': 'noname@x'}])
|
||||
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||
assert body['calendars'][0]['summary'] == 'noname@x'
|
||||
|
||||
def test_entries_without_an_id_are_dropped(self, client, monkeypatch):
|
||||
# Nothing could be selected by such a row, and the checkbox value
|
||||
# would be undefined.
|
||||
self._authenticate(client, monkeypatch, [{'summary': 'ghost'}, {'id': 'real@x'}])
|
||||
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||
assert [c['id'] for c in body['calendars']] == ['real@x']
|
||||
|
||||
|
||||
class TestPagination:
|
||||
"""calendarList.list pages at 250 and defaults to 100."""
|
||||
|
||||
def _paged(self, client, monkeypatch, pages):
|
||||
import types
|
||||
creds = type('C', (), {'expired': False, 'refresh_token': None, 'valid': True})()
|
||||
(client.plugin_dir / 'token.pickle').write_bytes(b'x')
|
||||
state = {'i': 0}
|
||||
seen = []
|
||||
|
||||
def fake_list(**kwargs):
|
||||
seen.append(kwargs)
|
||||
page = pages[min(state['i'], len(pages) - 1)]
|
||||
state['i'] += 1
|
||||
return types.SimpleNamespace(execute=lambda: page)
|
||||
|
||||
def fake_build(*args, **kwargs):
|
||||
return types.SimpleNamespace(
|
||||
calendarList=lambda: types.SimpleNamespace(list=fake_list))
|
||||
|
||||
real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
|
||||
else __builtins__.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == 'pickle':
|
||||
return types.SimpleNamespace(load=lambda f: creds, dump=lambda *a: None)
|
||||
if name == 'google.auth.transport.requests':
|
||||
return types.SimpleNamespace(Request=object)
|
||||
if name == 'googleapiclient.discovery':
|
||||
return types.SimpleNamespace(build=fake_build)
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr('builtins.__import__', fake_import)
|
||||
return seen
|
||||
|
||||
def test_every_page_is_collected(self, client, monkeypatch):
|
||||
# Taking only the first page would hide calendars from the picker with
|
||||
# nothing to say the list was cut short.
|
||||
self._paged(client, monkeypatch, [
|
||||
{'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 't1'},
|
||||
{'items': [{'id': 'b@x', 'summary': 'B'}], 'nextPageToken': 't2'},
|
||||
{'items': [{'id': 'c@x', 'summary': 'C'}]},
|
||||
])
|
||||
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||
assert [c['id'] for c in body['calendars']] == ['a@x', 'b@x', 'c@x']
|
||||
|
||||
def test_the_page_token_is_passed_back(self, client, monkeypatch):
|
||||
seen = self._paged(client, monkeypatch, [
|
||||
{'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 'tok'},
|
||||
{'items': [{'id': 'b@x', 'summary': 'B'}]},
|
||||
])
|
||||
client.get('/api/v3/plugins/calendar/list-calendars')
|
||||
assert seen[0]['pageToken'] is None
|
||||
assert seen[1]['pageToken'] == 'tok'
|
||||
assert all(k['maxResults'] == 250 for k in seen)
|
||||
|
||||
def test_a_looping_token_cannot_spin_forever(self, client, monkeypatch):
|
||||
# Every page claims another follows.
|
||||
self._paged(client, monkeypatch, [
|
||||
{'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 'same'},
|
||||
])
|
||||
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||
assert body['status'] == 'success'
|
||||
assert len(body['calendars']) <= mod._CALENDAR_LIST_MAX_PAGES
|
||||
|
||||
|
||||
class TestDiagnosticsAreRedacted:
|
||||
def test_script_stderr_is_redacted_on_the_way_out(self, tmp_path):
|
||||
script = tmp_path / 'calendar_registration.py'
|
||||
script.write_text(
|
||||
'import sys\n'
|
||||
'sys.stderr.write("boom client_secret=hunter2 more\\n")\n',
|
||||
encoding='utf-8')
|
||||
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||
assert payload is None
|
||||
assert 'hunter2' not in error, error
|
||||
assert '<redacted>' in error, error
|
||||
|
||||
def test_a_failing_script_payload_is_redacted(self, client):
|
||||
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
|
||||
(client.plugin_dir / 'calendar_registration.py').write_text(
|
||||
'import json\n'
|
||||
'print(json.dumps({"status": "error", '
|
||||
'"message": "Failed: client_secret=topsecret"}))\n',
|
||||
encoding='utf-8')
|
||||
body = client.post('/api/v3/plugins/calendar/authenticate',
|
||||
json={}).get_json()
|
||||
assert body['status'] == 'error'
|
||||
assert 'topsecret' not in json.dumps(body), body
|
||||
assert '<redacted>' in body['message'], body
|
||||
|
||||
def test_an_unrunnable_script_is_reported_without_raw_exception_text(self,
|
||||
tmp_path,
|
||||
monkeypatch):
|
||||
# OSError from the spawn carries the interpreter path and whatever the
|
||||
# OS chose to say; it reaches the client through the redactor like
|
||||
# everything else.
|
||||
script = tmp_path / 'calendar_registration.py'
|
||||
script.write_text('', encoding='utf-8')
|
||||
|
||||
def boom(*a, **k):
|
||||
raise OSError("Exec format error: token=abcd1234 /usr/bin/python3")
|
||||
|
||||
monkeypatch.setattr(mod.subprocess, 'run', boom)
|
||||
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||
assert payload is None
|
||||
assert 'abcd1234' not in error, error
|
||||
assert 'OSError' in error, error
|
||||
|
||||
def test_a_missing_google_library_is_reported_without_raw_exception_text(
|
||||
self, client, monkeypatch):
|
||||
(client.plugin_dir / 'token.pickle').write_bytes(b'x')
|
||||
real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
|
||||
else __builtins__.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name.startswith('google'):
|
||||
raise ImportError("No module named 'google' password=hunter2")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr('builtins.__import__', fake_import)
|
||||
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||
assert 'hunter2' not in json.dumps(body), body
|
||||
assert 'requirements.txt' in body['message']
|
||||
@@ -1,149 +0,0 @@
|
||||
"""
|
||||
Tests for the response builders in src/web_interface/error_handler.py and
|
||||
the success path in src/web_interface/api_helpers.py.
|
||||
|
||||
describe_exception() in the same module is already covered by
|
||||
test/test_web_error_detail.py and is not duplicated here.
|
||||
|
||||
Regression coverage for one fixed bug: create_success_response used
|
||||
truthiness for `message` and `metadata` while using `is not None` for
|
||||
`data`, so an explicitly-passed "" or {} was silently dropped —
|
||||
api_helpers.success_response() repeated the same gate, which is the path
|
||||
every api_v3 endpoint actually calls.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from src.web_interface.api_helpers import success_response
|
||||
from src.web_interface.error_handler import (
|
||||
create_error_response,
|
||||
create_success_response,
|
||||
)
|
||||
from src.web_interface.errors import ErrorCode, WebInterfaceError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
class TestCreateErrorResponse:
|
||||
def test_returns_response_and_status_tuple(self, app):
|
||||
with app.test_request_context():
|
||||
response, status = create_error_response(
|
||||
ErrorCode.CONFIG_SAVE_FAILED, "could not save")
|
||||
assert status == 500
|
||||
assert response.get_json()["message"] == "could not save"
|
||||
|
||||
def test_status_code_passthrough(self, app):
|
||||
with app.test_request_context():
|
||||
_, status = create_error_response(
|
||||
ErrorCode.INVALID_INPUT, "bad", status_code=400)
|
||||
assert status == 400
|
||||
|
||||
def test_body_matches_the_error_dataclass(self, app):
|
||||
with app.test_request_context():
|
||||
response, _ = create_error_response(
|
||||
ErrorCode.NETWORK_ERROR, "offline",
|
||||
details="connection refused", context={"url": "http://x"})
|
||||
expected = WebInterfaceError(
|
||||
error_code=ErrorCode.NETWORK_ERROR, message="offline",
|
||||
details="connection refused", context={"url": "http://x"}).to_dict()
|
||||
assert response.get_json() == expected
|
||||
|
||||
def test_none_context_produces_no_context_key(self, app):
|
||||
with app.test_request_context():
|
||||
response, _ = create_error_response(ErrorCode.SYSTEM_ERROR, "boom")
|
||||
assert "context" not in response.get_json()
|
||||
|
||||
def test_suggested_fixes_passed_through(self, app):
|
||||
with app.test_request_context():
|
||||
response, _ = create_error_response(
|
||||
ErrorCode.SYSTEM_ERROR, "boom", suggested_fixes=["Try again"])
|
||||
assert response.get_json()["suggested_fixes"] == ["Try again"]
|
||||
|
||||
|
||||
class TestCreateSuccessResponse:
|
||||
def test_bare_success(self):
|
||||
assert create_success_response() == {"status": "success"}
|
||||
|
||||
def test_data_included(self):
|
||||
assert create_success_response(data={"a": 1})["data"] == {"a": 1}
|
||||
|
||||
@pytest.mark.parametrize("falsy", [0, "", False, {}, []])
|
||||
def test_falsy_data_is_still_included(self, falsy):
|
||||
assert create_success_response(data=falsy)["data"] == falsy
|
||||
|
||||
def test_none_data_omitted(self):
|
||||
assert "data" not in create_success_response(data=None)
|
||||
|
||||
def test_message_included(self):
|
||||
assert create_success_response(message="done")["message"] == "done"
|
||||
|
||||
def test_empty_message_is_still_included(self):
|
||||
# Regression: `if message:` dropped an explicitly-passed "".
|
||||
assert create_success_response(message="")["message"] == ""
|
||||
|
||||
def test_none_message_omitted(self):
|
||||
assert "message" not in create_success_response(message=None)
|
||||
|
||||
def test_metadata_included(self):
|
||||
assert create_success_response(metadata={"v": 1})["metadata"] == {"v": 1}
|
||||
|
||||
def test_empty_metadata_is_still_included(self):
|
||||
# Regression: `if metadata:` dropped an explicitly-passed {}.
|
||||
assert create_success_response(metadata={})["metadata"] == {}
|
||||
|
||||
def test_none_metadata_omitted(self):
|
||||
assert "metadata" not in create_success_response(metadata=None)
|
||||
|
||||
|
||||
class TestSuccessResponseHelper:
|
||||
"""api_helpers.success_response — the wrapper every endpoint calls."""
|
||||
|
||||
def test_plain_response_has_no_metadata_block(self, app):
|
||||
with app.test_request_context():
|
||||
body = success_response(data={"a": 1}).get_json()
|
||||
assert body == {"status": "success", "data": {"a": 1}}
|
||||
|
||||
def test_explicit_empty_metadata_survives_the_wrapper(self, app):
|
||||
# Regression: the wrapper re-gated metadata on truthiness after
|
||||
# create_success_response had already included it, so {} was
|
||||
# dropped again on the way out.
|
||||
with app.test_request_context():
|
||||
body = success_response(data=None, metadata={}).get_json()
|
||||
assert body["metadata"] == {}
|
||||
|
||||
def test_caller_metadata_preserved(self, app):
|
||||
with app.test_request_context():
|
||||
body = success_response(metadata={"version": "1.2"}).get_json()
|
||||
assert body["metadata"]["version"] == "1.2"
|
||||
|
||||
def test_timing_added_when_request_has_start_time(self, app):
|
||||
with app.test_request_context() as ctx:
|
||||
ctx.request.start_time = 0.0
|
||||
body = success_response(data={"a": 1}).get_json()
|
||||
assert "response_time_ms" in body["metadata"]
|
||||
|
||||
def test_timing_merges_with_caller_metadata(self, app):
|
||||
with app.test_request_context() as ctx:
|
||||
ctx.request.start_time = 0.0
|
||||
body = success_response(metadata={"version": "1.2"}).get_json()
|
||||
assert body["metadata"]["version"] == "1.2"
|
||||
assert "response_time_ms" in body["metadata"]
|
||||
|
||||
def test_caller_metadata_dict_is_not_mutated(self, app):
|
||||
# The helper used to add response_time_ms straight into the dict the
|
||||
# caller passed, so a module-level or reused metadata dict would
|
||||
# accumulate timings from previous requests.
|
||||
caller_metadata = {"version": "1.2"}
|
||||
with app.test_request_context() as ctx:
|
||||
ctx.request.start_time = 0.0
|
||||
success_response(metadata=caller_metadata)
|
||||
assert caller_metadata == {"version": "1.2"}
|
||||
|
||||
def test_message_passed_through(self, app):
|
||||
with app.test_request_context():
|
||||
body = success_response(message="saved").get_json()
|
||||
assert body["message"] == "saved"
|
||||
@@ -1,208 +0,0 @@
|
||||
"""
|
||||
Tests for src/web_interface/errors.py — the structured error type behind
|
||||
every API error response (category inference, default suggestions, the
|
||||
JSON shape, and exception conversion).
|
||||
|
||||
Pure logic; no Flask context needed.
|
||||
|
||||
Regression coverage for one fixed bug: suggested_fixes used `or`, so a
|
||||
caller passing [] to mean "no suggestions" silently got the default list.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.web_interface.errors import ErrorCategory, ErrorCode, WebInterfaceError
|
||||
|
||||
|
||||
class TestCategoryInference:
|
||||
@pytest.mark.parametrize("code,expected", [
|
||||
(ErrorCode.CONFIG_SAVE_FAILED, ErrorCategory.CONFIGURATION),
|
||||
(ErrorCode.CONFIG_ROLLBACK_FAILED, ErrorCategory.CONFIGURATION),
|
||||
(ErrorCode.PLUGIN_NOT_FOUND, ErrorCategory.PLUGIN),
|
||||
(ErrorCode.PLUGIN_OPERATION_CONFLICT, ErrorCategory.PLUGIN),
|
||||
(ErrorCode.VALIDATION_ERROR, ErrorCategory.VALIDATION),
|
||||
(ErrorCode.SCHEMA_VALIDATION_FAILED, ErrorCategory.VALIDATION),
|
||||
(ErrorCode.INVALID_INPUT, ErrorCategory.VALIDATION),
|
||||
(ErrorCode.NETWORK_ERROR, ErrorCategory.NETWORK),
|
||||
(ErrorCode.API_ERROR, ErrorCategory.NETWORK),
|
||||
(ErrorCode.TIMEOUT, ErrorCategory.NETWORK),
|
||||
(ErrorCode.PERMISSION_DENIED, ErrorCategory.PERMISSION),
|
||||
(ErrorCode.FILE_PERMISSION_ERROR, ErrorCategory.PERMISSION),
|
||||
(ErrorCode.SYSTEM_ERROR, ErrorCategory.SYSTEM),
|
||||
(ErrorCode.SERVICE_UNAVAILABLE, ErrorCategory.SYSTEM),
|
||||
(ErrorCode.UNKNOWN_ERROR, ErrorCategory.UNKNOWN),
|
||||
])
|
||||
def test_every_code_prefix_maps_to_its_category(self, code, expected):
|
||||
assert WebInterfaceError(code, "msg").category is expected
|
||||
|
||||
def test_explicit_category_overrides_inference(self):
|
||||
error = WebInterfaceError(
|
||||
ErrorCode.CONFIG_SAVE_FAILED, "msg", category=ErrorCategory.SYSTEM)
|
||||
assert error.category is ErrorCategory.SYSTEM
|
||||
|
||||
def test_every_error_code_gets_a_category(self):
|
||||
# No code may fall through uncategorized as the enum grows.
|
||||
for code in ErrorCode:
|
||||
assert isinstance(WebInterfaceError(code, "msg").category, ErrorCategory)
|
||||
|
||||
|
||||
class TestDefaultSuggestions:
|
||||
def test_mapped_code_gets_specific_suggestions(self):
|
||||
fixes = WebInterfaceError(ErrorCode.CONFIG_SAVE_FAILED, "msg").suggested_fixes
|
||||
assert "Check available disk space" in fixes
|
||||
|
||||
def test_unmapped_code_gets_generic_fallback(self):
|
||||
# PLUGIN_UPDATE_FAILED has no entry in suggestions_map.
|
||||
fixes = WebInterfaceError(ErrorCode.PLUGIN_UPDATE_FAILED, "msg").suggested_fixes
|
||||
assert fixes == ["Review error details and try again"]
|
||||
|
||||
def test_explicit_suggestions_win(self):
|
||||
error = WebInterfaceError(
|
||||
ErrorCode.CONFIG_SAVE_FAILED, "msg", suggested_fixes=["Do the thing"])
|
||||
assert error.suggested_fixes == ["Do the thing"]
|
||||
|
||||
def test_explicit_empty_list_is_respected(self):
|
||||
# Regression: `suggested_fixes or default` treated [] as "unset",
|
||||
# so a caller could not express "I have no suggestions".
|
||||
error = WebInterfaceError(
|
||||
ErrorCode.CONFIG_SAVE_FAILED, "msg", suggested_fixes=[])
|
||||
assert error.suggested_fixes == []
|
||||
|
||||
def test_none_still_gets_defaults(self):
|
||||
error = WebInterfaceError(
|
||||
ErrorCode.CONFIG_SAVE_FAILED, "msg", suggested_fixes=None)
|
||||
assert len(error.suggested_fixes) > 0
|
||||
|
||||
|
||||
class TestToDict:
|
||||
def test_base_keys_always_present(self):
|
||||
result = WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom").to_dict()
|
||||
assert result["status"] == "error"
|
||||
assert result["error_code"] == "SYSTEM_ERROR"
|
||||
assert result["error_category"] == "system"
|
||||
assert result["message"] == "boom"
|
||||
|
||||
def test_details_included_when_set(self):
|
||||
result = WebInterfaceError(
|
||||
ErrorCode.SYSTEM_ERROR, "boom", details="disk full").to_dict()
|
||||
assert result["details"] == "disk full"
|
||||
|
||||
def test_details_omitted_when_absent(self):
|
||||
assert "details" not in WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom").to_dict()
|
||||
|
||||
def test_context_included_when_non_empty(self):
|
||||
result = WebInterfaceError(
|
||||
ErrorCode.SYSTEM_ERROR, "boom", context={"path": "/tmp/x"}).to_dict()
|
||||
assert result["context"] == {"path": "/tmp/x"}
|
||||
|
||||
def test_empty_context_is_omitted(self):
|
||||
# Pinned as intentional, not a bug: __init__ normalizes context to
|
||||
# {}, and an empty context carries no information, so it is left out
|
||||
# rather than padding every error body with "context": {}.
|
||||
result = WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom", context={}).to_dict()
|
||||
assert "context" not in result
|
||||
|
||||
def test_empty_suggestions_omitted(self):
|
||||
result = WebInterfaceError(
|
||||
ErrorCode.SYSTEM_ERROR, "boom", suggested_fixes=[]).to_dict()
|
||||
assert "suggested_fixes" not in result
|
||||
|
||||
def test_is_json_serializable(self):
|
||||
import json
|
||||
error = WebInterfaceError(
|
||||
ErrorCode.NETWORK_ERROR, "boom",
|
||||
details="timeout", context={"url": "http://x"})
|
||||
assert json.loads(json.dumps(error.to_dict()))["error_code"] == "NETWORK_ERROR"
|
||||
|
||||
|
||||
class TestFromException:
|
||||
@pytest.mark.parametrize("exc_name,expected", [
|
||||
("ConfigError", ErrorCode.CONFIG_LOAD_FAILED),
|
||||
("PluginError", ErrorCode.PLUGIN_LOAD_FAILED),
|
||||
("PermissionError", ErrorCode.PERMISSION_DENIED),
|
||||
("AccessDenied", ErrorCode.PERMISSION_DENIED),
|
||||
("ValidationError", ErrorCode.VALIDATION_ERROR),
|
||||
("SchemaError", ErrorCode.VALIDATION_ERROR),
|
||||
("NetworkError", ErrorCode.NETWORK_ERROR),
|
||||
("ConnectionError", ErrorCode.NETWORK_ERROR),
|
||||
("TimeoutError", ErrorCode.TIMEOUT),
|
||||
("SomethingElse", ErrorCode.UNKNOWN_ERROR),
|
||||
])
|
||||
def test_code_inferred_from_exception_class_name(self, exc_name, expected):
|
||||
exc = type(exc_name, (Exception,), {})("boom")
|
||||
assert WebInterfaceError.from_exception(exc).error_code is expected
|
||||
|
||||
def test_explicit_code_skips_inference(self):
|
||||
error = WebInterfaceError.from_exception(
|
||||
ValueError("boom"), error_code=ErrorCode.PLUGIN_NOT_FOUND)
|
||||
assert error.error_code is ErrorCode.PLUGIN_NOT_FOUND
|
||||
|
||||
def test_message_is_the_safe_one_not_the_exception_text(self):
|
||||
# The raw exception text is not echoed into `message`; that field is
|
||||
# a fixed, user-facing string per code.
|
||||
error = WebInterfaceError.from_exception(ValueError("secret-ish detail"))
|
||||
assert error.message == "An unexpected error occurred"
|
||||
assert "secret-ish" not in error.message
|
||||
|
||||
def test_exception_type_recorded_in_context(self):
|
||||
error = WebInterfaceError.from_exception(ValueError("boom"))
|
||||
assert error.context["exception_type"] == "ValueError"
|
||||
|
||||
def test_caller_context_is_preserved_alongside_type(self):
|
||||
error = WebInterfaceError.from_exception(
|
||||
ValueError("boom"), context={"plugin_id": "clock"})
|
||||
assert error.context["plugin_id"] == "clock"
|
||||
assert error.context["exception_type"] == "ValueError"
|
||||
|
||||
def test_caller_supplied_exception_type_is_overwritten(self):
|
||||
error = WebInterfaceError.from_exception(
|
||||
ValueError("boom"), context={"exception_type": "Fake"})
|
||||
assert error.context["exception_type"] == "ValueError"
|
||||
|
||||
def test_original_error_retained(self):
|
||||
exc = ValueError("boom")
|
||||
assert WebInterfaceError.from_exception(exc).original_error is exc
|
||||
|
||||
def test_every_code_has_a_safe_message(self):
|
||||
for code in ErrorCode:
|
||||
assert WebInterfaceError._safe_message(code)
|
||||
|
||||
|
||||
class TestExceptionDetails:
|
||||
def test_context_dict_is_flattened(self):
|
||||
exc = ValueError("boom")
|
||||
exc.context = {"config_path": "/etc/x.json", "line": 4}
|
||||
details = WebInterfaceError._get_exception_details(exc)
|
||||
assert "config_path: /etc/x.json" in details
|
||||
assert "line: 4" in details
|
||||
assert "; " in details
|
||||
|
||||
def test_exception_type_key_excluded(self):
|
||||
exc = ValueError("boom")
|
||||
exc.context = {"exception_type": "ValueError", "path": "/tmp/x"}
|
||||
details = WebInterfaceError._get_exception_details(exc)
|
||||
assert "exception_type" not in details
|
||||
assert details == "path: /tmp/x"
|
||||
|
||||
def test_context_with_only_exception_type_gives_none(self):
|
||||
exc = ValueError("boom")
|
||||
exc.context = {"exception_type": "ValueError"}
|
||||
assert WebInterfaceError._get_exception_details(exc) is None
|
||||
|
||||
def test_no_context_attribute_gives_none(self):
|
||||
assert WebInterfaceError._get_exception_details(ValueError("boom")) is None
|
||||
|
||||
def test_non_dict_context_gives_none(self):
|
||||
exc = ValueError("boom")
|
||||
exc.context = "not a dict"
|
||||
assert WebInterfaceError._get_exception_details(exc) is None
|
||||
|
||||
def test_empty_context_gives_none(self):
|
||||
exc = ValueError("boom")
|
||||
exc.context = {}
|
||||
assert WebInterfaceError._get_exception_details(exc) is None
|
||||
|
||||
def test_details_flow_into_from_exception(self):
|
||||
exc = ValueError("boom")
|
||||
exc.context = {"config_path": "/etc/x.json"}
|
||||
assert "config_path" in WebInterfaceError.from_exception(exc).details
|
||||
@@ -1,13 +1,18 @@
|
||||
"""
|
||||
Drift guard: api_v3 must use the canonical secret helpers.
|
||||
Drift guard for the duplicated secret-separation logic.
|
||||
|
||||
Historically web_interface/blueprints/api_v3.py carried THREE inline
|
||||
nested-function copies of ``find_secret_fields``/``separate_secrets`` (in the
|
||||
main-config save, plugin-config save, and plugin-config reset endpoints).
|
||||
They lacked the canonical module's array-item secret support and drifted from
|
||||
each other. They have been migrated onto
|
||||
``src/web_interface/secret_helpers`` — this file now guards against copies
|
||||
REAPPEARING, and keeps the canonical array-item behavior executable.
|
||||
src/web_interface/secret_helpers.py is the canonical implementation of
|
||||
find_secret_fields / separate_secrets, but web_interface/blueprints/api_v3.py
|
||||
still carries THREE inline nested-function copies of each (in the plugin
|
||||
config GET, POST, and reset endpoints). The copies lack the canonical
|
||||
module's array-item support (`accounts[].token`), so migrating an endpoint
|
||||
onto the module is a behavior change that must be made deliberately.
|
||||
|
||||
This file guards two things:
|
||||
1. The copy count can only go DOWN. A fourth copy appearing means someone
|
||||
re-implemented the logic again instead of importing secret_helpers.
|
||||
2. The known behavioral gap is documented as an executable fact, so whoever
|
||||
migrates the endpoints knows exactly what changes.
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -18,44 +23,77 @@ from src.web_interface.secret_helpers import find_secret_fields, separate_secret
|
||||
API_V3_PATH = (Path(__file__).resolve().parents[2]
|
||||
/ "web_interface" / "blueprints" / "api_v3.py")
|
||||
|
||||
# The migration is complete: any inline reimplementation is a regression.
|
||||
EXPECTED_INLINE_COPIES = 0
|
||||
# Update DOWNWARD as endpoints migrate onto src/web_interface/secret_helpers.
|
||||
EXPECTED_INLINE_COPIES = 3
|
||||
|
||||
|
||||
class TestNoInlineCopies:
|
||||
class TestInlineCopyCount:
|
||||
def _count(self, name: str) -> int:
|
||||
source = API_V3_PATH.read_text(encoding="utf-8")
|
||||
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
|
||||
|
||||
def test_no_inline_find_secret_fields(self):
|
||||
def test_find_secret_fields_copy_count(self):
|
||||
count = self._count("find_secret_fields")
|
||||
assert count == EXPECTED_INLINE_COPIES, (
|
||||
f"api_v3.py has {count} inline find_secret_fields definitions, "
|
||||
f"expected {EXPECTED_INLINE_COPIES}. Import it from "
|
||||
f"src/web_interface/secret_helpers instead of re-implementing it."
|
||||
f"expected {EXPECTED_INLINE_COPIES}. New code must import it from "
|
||||
f"src/web_interface/secret_helpers instead of re-implementing it; "
|
||||
f"if you migrated an endpoint, lower EXPECTED_INLINE_COPIES."
|
||||
)
|
||||
|
||||
def test_no_inline_separate_secrets(self):
|
||||
def test_separate_secrets_copy_count(self):
|
||||
count = self._count("separate_secrets")
|
||||
assert count == EXPECTED_INLINE_COPIES, (
|
||||
f"api_v3.py has {count} inline separate_secrets definitions, "
|
||||
f"expected {EXPECTED_INLINE_COPIES}. Import it from "
|
||||
f"src/web_interface/secret_helpers instead of re-implementing it."
|
||||
f"expected {EXPECTED_INLINE_COPIES}. New code must import it from "
|
||||
f"src/web_interface/secret_helpers instead of re-implementing it; "
|
||||
f"if you migrated an endpoint, lower EXPECTED_INLINE_COPIES."
|
||||
)
|
||||
|
||||
def test_canonical_import_present(self):
|
||||
# Tripwire: the endpoints still need the helpers, so removing the
|
||||
# import means either dead secret handling or a new local copy.
|
||||
source = API_V3_PATH.read_text(encoding="utf-8")
|
||||
assert re.search(
|
||||
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
|
||||
source,
|
||||
), "api_v3.py no longer imports the canonical secret helpers"
|
||||
def test_inline_copies_lack_array_item_support(self):
|
||||
"""The documented gap: no inline copy recurses into array `items`
|
||||
schemas, so array-item secrets (accounts[].token) are NOT routed to
|
||||
config_secrets.json by these endpoints. The canonical module handles
|
||||
them. When an endpoint migrates onto the module that behavior
|
||||
changes (a fix, but a deliberate one).
|
||||
|
||||
If this fails, an inline copy has grown array support — duplicating
|
||||
the canonical module even harder. Migrate the endpoint onto
|
||||
src/web_interface/secret_helpers instead.
|
||||
"""
|
||||
for body in self._inline_bodies("find_secret_fields"):
|
||||
# Array handling requires checking type == 'array'; no inline
|
||||
# copy does. (Can't grep bare "items" — properties.items() the
|
||||
# dict method appears legitimately.)
|
||||
assert "'array'" not in body and '"array"' not in body
|
||||
|
||||
@staticmethod
|
||||
def _inline_bodies(name: str):
|
||||
"""Extract each inline def's body from api_v3.py by indentation."""
|
||||
lines = API_V3_PATH.read_text(encoding="utf-8").splitlines()
|
||||
bodies = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
match = re.match(rf"^(\s+)def {name}\(", lines[i])
|
||||
if not match:
|
||||
i += 1
|
||||
continue
|
||||
indent = len(match.group(1))
|
||||
body = [lines[i]]
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if line.strip() and (len(line) - len(line.lstrip())) <= indent:
|
||||
break
|
||||
body.append(line)
|
||||
i += 1
|
||||
bodies.append("\n".join(body))
|
||||
assert bodies, f"no inline {name} definitions found"
|
||||
return bodies
|
||||
|
||||
|
||||
class TestCanonicalArrayItemBehavior:
|
||||
"""Executable documentation of the array-item secret contract the
|
||||
endpoints now inherit from the canonical module."""
|
||||
"""Executable documentation of what migrating endpoints will change."""
|
||||
|
||||
SCHEMA = {
|
||||
"accounts": {
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
"""
|
||||
Tests for src/web_interface/validators.py.
|
||||
|
||||
dedup_unique_arrays is already covered by test_dedup_unique_arrays.py and
|
||||
is not repeated here; this file covers the other eight functions, none of
|
||||
which had any tests.
|
||||
|
||||
Regression coverage for three fixed bugs:
|
||||
- validate_numeric_range accepted True/False, since bool subclasses int.
|
||||
- validate_file_upload lowercased the filename's extension but not the
|
||||
caller's allowed_extensions list, so ['.TTF'] rejected 'font.ttf'.
|
||||
- validate_image_url only checked for '..' inside the relative-path
|
||||
branch, so http://host/../secret passed validation untouched.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.web_interface.validators import (
|
||||
escape_html,
|
||||
sanitize_plugin_config,
|
||||
validate_file_upload,
|
||||
validate_font_awesome_class,
|
||||
validate_image_url,
|
||||
validate_mime_type,
|
||||
validate_numeric_range,
|
||||
validate_string_length,
|
||||
)
|
||||
|
||||
|
||||
class TestEscapeHtml:
|
||||
def test_escapes_all_five_entities(self):
|
||||
assert escape_html("""<a href="x">O'Neill & co</a>""") == (
|
||||
"<a href="x">O'Neill & co</a>")
|
||||
|
||||
def test_ampersand_is_escaped_first_so_nothing_double_escapes(self):
|
||||
# If '<' were replaced before '&', the '&' of '<' would be
|
||||
# escaped again into '&lt;'.
|
||||
assert escape_html("<") == "<"
|
||||
assert escape_html("&") == "&"
|
||||
assert escape_html("&<") == "&<"
|
||||
|
||||
def test_plain_text_unchanged(self):
|
||||
assert escape_html("hello world") == "hello world"
|
||||
|
||||
def test_non_string_is_coerced(self):
|
||||
assert escape_html(42) == "42"
|
||||
assert escape_html(None) == "None"
|
||||
|
||||
def test_script_tag_neutralized(self):
|
||||
assert "<script>" not in escape_html("<script>alert(1)</script>")
|
||||
|
||||
|
||||
class TestValidateImageUrl:
|
||||
@pytest.mark.parametrize("url", [
|
||||
"javascript:alert(1)",
|
||||
"JavaScript:alert(1)",
|
||||
"JAVASCRIPT:alert(1)",
|
||||
"data:text/html;base64,PHNjcmlwdD4=",
|
||||
"vbscript:msgbox(1)",
|
||||
"file:///etc/passwd",
|
||||
])
|
||||
def test_dangerous_protocols_rejected(self, url):
|
||||
valid, error = validate_image_url(url)
|
||||
assert valid is False and "protocol" in error.lower()
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://x/a.png?onerror=alert(1)",
|
||||
"http://x/a.png#onload=alert(1)",
|
||||
"http://x/onclick=alert(1).png",
|
||||
])
|
||||
def test_event_handlers_rejected(self, url):
|
||||
valid, error = validate_image_url(url)
|
||||
assert valid is False and "Event handlers" in error
|
||||
|
||||
@pytest.mark.parametrize("url", ["", None, 123, []])
|
||||
def test_empty_or_non_string_rejected(self, url):
|
||||
assert validate_image_url(url)[0] is False
|
||||
|
||||
def test_http_and_https_allowed(self):
|
||||
assert validate_image_url("http://example.com/logo.png") == (True, None)
|
||||
assert validate_image_url("https://example.com/logo.png") == (True, None)
|
||||
|
||||
def test_other_schemes_rejected(self):
|
||||
valid, error = validate_image_url("ftp://example.com/logo.png")
|
||||
assert valid is False and "http://" in error
|
||||
|
||||
def test_relative_path_allowed(self):
|
||||
assert validate_image_url("/static/logo.png") == (True, None)
|
||||
|
||||
def test_protocol_relative_url_rejected(self):
|
||||
assert validate_image_url("//evil.com/logo.png")[0] is False
|
||||
|
||||
def test_relative_traversal_rejected(self):
|
||||
assert validate_image_url("/static/../../etc/passwd")[0] is False
|
||||
|
||||
def test_absolute_url_traversal_rejected(self):
|
||||
# Regression: the '..' check used to sit inside the leading-slash
|
||||
# branch, so an absolute URL skipped it entirely.
|
||||
valid, error = validate_image_url("http://example.com/../secret")
|
||||
assert valid is False and "traversal" in error.lower()
|
||||
|
||||
def test_bare_traversal_rejected(self):
|
||||
assert validate_image_url("../../etc/passwd")[0] is False
|
||||
|
||||
|
||||
class TestValidateFontAwesomeClass:
|
||||
@pytest.mark.parametrize("cls", ["fa-star", "fas fa-star", "fa-solid fa-house"])
|
||||
def test_valid_classes_accepted(self, cls):
|
||||
assert validate_font_awesome_class(cls) == (True, None)
|
||||
|
||||
@pytest.mark.parametrize("cls", ["star", "glyphicon-star", ""])
|
||||
def test_classes_without_fa_prefix_rejected(self, cls):
|
||||
assert validate_font_awesome_class(cls)[0] is False
|
||||
|
||||
def test_injection_attempt_rejected(self):
|
||||
assert validate_font_awesome_class('fa-star" onload="alert(1)')[0] is False
|
||||
|
||||
def test_angle_brackets_rejected(self):
|
||||
assert validate_font_awesome_class("<script>fa-star</script>")[0] is False
|
||||
|
||||
def test_non_string_rejected(self):
|
||||
valid, error = validate_font_awesome_class(None)
|
||||
assert valid is False and "string" in error
|
||||
|
||||
def test_explicit_fa_check_is_unreachable_but_harmless(self):
|
||||
# Characterized, not fixed: the regex already requires 'fa-', so the
|
||||
# follow-up `if 'fa-' not in class_name` can never fire. Anything
|
||||
# lacking 'fa-' is rejected by the pattern first, with the pattern's
|
||||
# own message.
|
||||
valid, error = validate_font_awesome_class("star")
|
||||
assert valid is False
|
||||
assert error == "Invalid Font Awesome class name format"
|
||||
|
||||
|
||||
class TestValidateFileUpload:
|
||||
def test_plain_filename_accepted(self):
|
||||
assert validate_file_upload("logo.png") == (True, None)
|
||||
|
||||
@pytest.mark.parametrize("filename", [
|
||||
"../etc/passwd", "dir/file.png", "dir\\file.png", "..\\..\\secrets",
|
||||
])
|
||||
def test_traversal_characters_rejected(self, filename):
|
||||
valid, error = validate_file_upload(filename)
|
||||
assert valid is False and "invalid characters" in error
|
||||
|
||||
@pytest.mark.parametrize("filename", ["", None, 123])
|
||||
def test_empty_or_non_string_rejected(self, filename):
|
||||
assert validate_file_upload(filename)[0] is False
|
||||
|
||||
def test_allowed_extension_accepted(self):
|
||||
assert validate_file_upload("font.ttf", allowed_extensions=[".ttf", ".otf"]) == (True, None)
|
||||
|
||||
def test_disallowed_extension_rejected(self):
|
||||
valid, error = validate_file_upload("evil.exe", allowed_extensions=[".ttf"])
|
||||
assert valid is False and "extension" in error
|
||||
|
||||
def test_uppercase_filename_extension_matches(self):
|
||||
assert validate_file_upload("FONT.TTF", allowed_extensions=[".ttf"]) == (True, None)
|
||||
|
||||
def test_uppercase_allowed_list_matches(self):
|
||||
# Regression: only the filename side was lowercased, so a caller
|
||||
# passing ['.TTF'] rejected every valid .ttf upload.
|
||||
assert validate_file_upload("font.ttf", allowed_extensions=[".TTF"]) == (True, None)
|
||||
|
||||
def test_no_extension_list_skips_the_check(self):
|
||||
assert validate_file_upload("anything.xyz") == (True, None)
|
||||
|
||||
|
||||
class TestValidateMimeType:
|
||||
def test_known_type_accepted(self):
|
||||
assert validate_mime_type("logo.png", ["image/png"]) == (True, None)
|
||||
|
||||
def test_mismatched_type_rejected(self):
|
||||
valid, error = validate_mime_type("logo.png", ["image/jpeg"])
|
||||
assert valid is False and "not allowed" in error
|
||||
|
||||
def test_undeterminable_type_rejected(self):
|
||||
valid, error = validate_mime_type("mystery.zzz", ["image/png"])
|
||||
assert valid is False and "Could not determine" in error
|
||||
|
||||
def test_guess_type_failure_is_caught(self, monkeypatch):
|
||||
import mimetypes
|
||||
monkeypatch.setattr(mimetypes, "guess_type",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
valid, error = validate_mime_type("logo.png", ["image/png"])
|
||||
assert valid is False and "Error validating MIME type" in error
|
||||
|
||||
|
||||
class TestValidateNumericRange:
|
||||
def test_value_in_range(self):
|
||||
assert validate_numeric_range(5, min_val=0, max_val=10) == (True, None)
|
||||
|
||||
def test_boundaries_are_inclusive(self):
|
||||
assert validate_numeric_range(0, min_val=0, max_val=10) == (True, None)
|
||||
assert validate_numeric_range(10, min_val=0, max_val=10) == (True, None)
|
||||
|
||||
def test_below_minimum_rejected(self):
|
||||
valid, error = validate_numeric_range(-1, min_val=0)
|
||||
assert valid is False and "at least" in error
|
||||
|
||||
def test_above_maximum_rejected(self):
|
||||
valid, error = validate_numeric_range(11, max_val=10)
|
||||
assert valid is False and "at most" in error
|
||||
|
||||
def test_floats_accepted(self):
|
||||
assert validate_numeric_range(2.5, min_val=0, max_val=10) == (True, None)
|
||||
|
||||
def test_no_bounds_accepts_any_number(self):
|
||||
assert validate_numeric_range(-9999) == (True, None)
|
||||
|
||||
@pytest.mark.parametrize("value", ["5", None, [], {}])
|
||||
def test_non_numeric_rejected(self, value):
|
||||
valid, error = validate_numeric_range(value, min_val=0, max_val=10)
|
||||
assert valid is False and error == "Value must be a number"
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False])
|
||||
def test_booleans_rejected(self, value):
|
||||
# Regression: bool subclasses int, so True passed the isinstance
|
||||
# check and then compared as 1 against the range.
|
||||
valid, error = validate_numeric_range(value, min_val=0, max_val=10)
|
||||
assert valid is False and error == "Value must be a number"
|
||||
|
||||
|
||||
class TestValidateStringLength:
|
||||
def test_within_range(self):
|
||||
assert validate_string_length("hello", min_length=1, max_length=10) == (True, None)
|
||||
|
||||
def test_boundaries_are_inclusive(self):
|
||||
assert validate_string_length("abc", min_length=3, max_length=3) == (True, None)
|
||||
|
||||
def test_too_short_rejected(self):
|
||||
valid, error = validate_string_length("", min_length=1)
|
||||
assert valid is False and "at least" in error
|
||||
|
||||
def test_too_long_rejected(self):
|
||||
valid, error = validate_string_length("abcdef", max_length=3)
|
||||
assert valid is False and "at most" in error
|
||||
|
||||
def test_non_string_rejected(self):
|
||||
valid, error = validate_string_length(123, max_length=10)
|
||||
assert valid is False and "must be a string" in error
|
||||
|
||||
def test_no_bounds_accepts_anything(self):
|
||||
assert validate_string_length("") == (True, None)
|
||||
|
||||
|
||||
class TestSanitizePluginConfig:
|
||||
def test_valid_keys_and_scalars_kept(self):
|
||||
config = {"enabled": True, "count": 3, "ratio": 1.5, "name": "clock"}
|
||||
assert sanitize_plugin_config(config) == config
|
||||
|
||||
@pytest.mark.parametrize("key", ["has space", "has-dash", "has.dot", "has/slash", ""])
|
||||
def test_invalid_key_names_dropped(self, key):
|
||||
assert sanitize_plugin_config({key: "value", "good": 1}) == {"good": 1}
|
||||
|
||||
def test_non_string_keys_dropped(self):
|
||||
assert sanitize_plugin_config({1: "a", "good": 2}) == {"good": 2}
|
||||
|
||||
def test_nested_dicts_recursed(self):
|
||||
result = sanitize_plugin_config({"outer": {"inner": 1, "bad key": 2}})
|
||||
assert result == {"outer": {"inner": 1}}
|
||||
|
||||
def test_list_of_scalars_preserved(self):
|
||||
assert sanitize_plugin_config({"teams": ["PHI", "NYG"]})["teams"] == ["PHI", "NYG"]
|
||||
|
||||
def test_list_of_dicts_recursed(self):
|
||||
result = sanitize_plugin_config({"items": [{"ok": 1, "bad key": 2}]})
|
||||
assert result["items"] == [{"ok": 1}]
|
||||
|
||||
def test_unknown_value_types_dropped(self):
|
||||
assert sanitize_plugin_config({"weird": {1, 2, 3}, "good": 1}) == {"good": 1}
|
||||
|
||||
def test_none_values_dropped(self):
|
||||
assert sanitize_plugin_config({"nothing": None, "good": 1}) == {"good": 1}
|
||||
|
||||
def test_strings_are_not_html_escaped(self):
|
||||
# Pinned, not a bug: escaping here would persist the escaped form in
|
||||
# config.json. Output escaping belongs to the template layer, which
|
||||
# the function's docstring now says explicitly.
|
||||
payload = "<script>alert(1)</script>"
|
||||
assert sanitize_plugin_config({"title": payload})["title"] == payload
|
||||
|
||||
def test_empty_config(self):
|
||||
assert sanitize_plugin_config({}) == {}
|
||||
+4
-35
@@ -16,8 +16,6 @@ from datetime import datetime, timedelta
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.config_manager import ConfigManager
|
||||
from src.web_interface.error_handler import describe_exception
|
||||
from werkzeug.exceptions import HTTPException
|
||||
from src.exceptions import ConfigError
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
@@ -118,8 +116,7 @@ saved_repositories_manager = SavedRepositoriesManager()
|
||||
schema_manager = SchemaManager(
|
||||
plugins_dir=plugins_dir,
|
||||
project_root=project_root,
|
||||
logger=None,
|
||||
config_manager=config_manager
|
||||
logger=None
|
||||
)
|
||||
|
||||
# Initialize operation queue for plugin operations
|
||||
@@ -394,42 +391,15 @@ def internal_error(error):
|
||||
import logging
|
||||
logger = logging.getLogger('web_interface')
|
||||
logger.error("Internal server error", exc_info=True)
|
||||
payload = {
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_code': 'INTERNAL_ERROR',
|
||||
'message': 'An internal error occurred; see logs for details',
|
||||
}
|
||||
# Flask hands the original exception over as `error.original_exception`
|
||||
# when propagation is off; without it there is nothing to describe.
|
||||
original = getattr(error, 'original_exception', None) or (
|
||||
error if isinstance(error, BaseException) else None)
|
||||
if original is not None:
|
||||
payload['details'] = describe_exception(original)
|
||||
return jsonify(payload), 500
|
||||
}), 500
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_exception(error):
|
||||
"""Handle all unhandled exceptions.
|
||||
|
||||
Returning only "see logs for details" is fine until the logs are exactly
|
||||
what you cannot reach. A device with failing storage answered every
|
||||
endpoint with that sentence -- including the log viewer, because journalctl
|
||||
could not be executed -- while the exception underneath said
|
||||
`[Errno 5] Input/output error`. Naming the error costs nothing here and is
|
||||
frequently the whole diagnosis, so include it alongside the log pointer.
|
||||
"""
|
||||
# Werkzeug's HTTPExceptions subclass Exception, so this catch-all sees
|
||||
# them too and was reporting every 405, 400, 413 and 415 as a server-side
|
||||
# UNKNOWN_ERROR 500. A GET on a POST-only route came back as "an error
|
||||
# occurred" rather than "method not allowed", which tells the caller
|
||||
# nothing and blames the wrong side. Hand those back as themselves.
|
||||
if isinstance(error, HTTPException):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_code': (error.name or 'HTTP_ERROR').upper().replace(' ', '_'),
|
||||
'message': error.description,
|
||||
}), error.code or 500
|
||||
|
||||
"""Handle all unhandled exceptions."""
|
||||
import logging
|
||||
logger = logging.getLogger('web_interface')
|
||||
logger.error("Unhandled exception", exc_info=True)
|
||||
@@ -437,7 +407,6 @@ def handle_exception(error):
|
||||
'status': 'error',
|
||||
'error_code': 'UNKNOWN_ERROR',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(error),
|
||||
}), 500
|
||||
|
||||
# Captive portal redirect middleware
|
||||
|
||||
+214
-423
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user