mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-14 15:18:04 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf4d25c47 | ||
|
|
a653368250 | ||
|
|
e87797e997 | ||
|
|
461de4ce90 | ||
|
|
f18b61aa8a | ||
|
|
bbe2a63127 | ||
|
|
320ee797d7 | ||
|
|
f57f864ae9 | ||
|
|
7cb42848fd | ||
|
|
799733fb1d | ||
|
|
13dad4570a | ||
|
|
54d1e314e4 | ||
|
|
b6bab63614 | ||
|
|
4fae11d7d1 | ||
|
|
062bdf691f | ||
|
|
9cf30bbbef | ||
|
|
a51fb7ce11 | ||
|
|
fce1fdac57 | ||
|
|
7171e6c022 | ||
|
|
9fbdd71941 | ||
|
|
2add759f40 | ||
|
|
bb1a1671ec | ||
|
|
8159afca43 | ||
|
|
44f59ede07 | ||
|
|
ca26c1b83b | ||
|
|
f887063434 | ||
|
|
6287acd591 | ||
|
|
ee59caa577 |
@@ -72,4 +72,4 @@ jobs:
|
|||||||
--ignore=test/plugins \
|
--ignore=test/plugins \
|
||||||
--cov=src --cov=web_interface \
|
--cov=src --cov=web_interface \
|
||||||
--cov-report=term \
|
--cov-report=term \
|
||||||
--cov-fail-under=45
|
--cov-fail-under=52
|
||||||
|
|||||||
@@ -600,6 +600,14 @@ These settings are typically only needed for non-standard panels or custom confi
|
|||||||
- Leave empty unless you need custom mapping
|
- Leave empty unless you need custom mapping
|
||||||
- See rpi-rgb-led-matrix documentation for full options
|
- 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)
|
- **`row_address_type`** (integer, default: 0)
|
||||||
- How rows are addressed on the panel
|
- How rows are addressed on the panel
|
||||||
- Most panels use 0 (direct addressing)
|
- Most panels use 0 (direct addressing)
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
"led_rgb_sequence": "RGB",
|
"led_rgb_sequence": "RGB",
|
||||||
"limit_refresh_rate_hz": 100,
|
"limit_refresh_rate_hz": 100,
|
||||||
"pixel_mapper_config": "",
|
"pixel_mapper_config": "",
|
||||||
|
"orientation": "normal",
|
||||||
"row_address_type": 0,
|
"row_address_type": 0,
|
||||||
"multiplexing": 0,
|
"multiplexing": 0,
|
||||||
"panel_type": ""
|
"panel_type": ""
|
||||||
@@ -149,7 +150,7 @@
|
|||||||
"min_plugin_width": 8,
|
"min_plugin_width": 8,
|
||||||
"lead_in_width": 0,
|
"lead_in_width": 0,
|
||||||
"plugins_per_cycle": 6,
|
"plugins_per_cycle": 6,
|
||||||
"max_plugin_width_ratio": 3.0,
|
"max_plugin_width_ratio": 0.0,
|
||||||
"overflow_mode": "rotate",
|
"overflow_mode": "rotate",
|
||||||
"dynamic_duration_enabled": true,
|
"dynamic_duration_enabled": true,
|
||||||
"min_cycle_duration": 60,
|
"min_cycle_duration": 60,
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
|
|||||||
| `led_rgb_sequence` | string, `"RGB"` |
|
| `led_rgb_sequence` | string, `"RGB"` |
|
||||||
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
|
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
|
||||||
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate: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 |
|
| `row_address_type` | int, `0` — non-standard panel row addressing |
|
||||||
| `multiplexing` | int, `0` — panel multiplexing scheme |
|
| `multiplexing` | int, `0` — panel multiplexing scheme |
|
||||||
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
|
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
|
||||||
@@ -127,7 +128,7 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
|||||||
| `min_plugin_width` | int, `8` |
|
| `min_plugin_width` | int, `8` |
|
||||||
| `lead_in_width` | int, `0` |
|
| `lead_in_width` | int, `0` |
|
||||||
| `plugins_per_cycle` | int, `6` |
|
| `plugins_per_cycle` | int, `6` |
|
||||||
| `max_plugin_width_ratio` | float, `3.0` |
|
| `max_plugin_width_ratio` | float, `0.0` |
|
||||||
| `overflow_mode` | string, `"rotate"` |
|
| `overflow_mode` | string, `"rotate"` |
|
||||||
| `dynamic_duration_enabled` | bool, `true` |
|
| `dynamic_duration_enabled` | bool, `true` |
|
||||||
| `min_cycle_duration` | int, `60` |
|
| `min_cycle_duration` | int, `60` |
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from src.plugin_system.testing.loading import ( # noqa: E402
|
|||||||
)
|
)
|
||||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||||
|
check_empty_claimed,
|
||||||
check_scale_up,
|
check_scale_up,
|
||||||
)
|
)
|
||||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||||
@@ -115,6 +116,11 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
|||||||
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
||||||
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
||||||
fill_strict = spec.get("fill_check") == "strict"
|
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" —
|
# Every run: the base config, plus one per harness.json "variant" —
|
||||||
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
||||||
@@ -142,6 +148,7 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
|||||||
compare_to_goldens(results, golden_dir)
|
compare_to_goldens(results, golden_dir)
|
||||||
|
|
||||||
check_scale_up(results, design_size=design_size, strict=fill_strict)
|
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.
|
# Tag variant runs so the report and PNG dumps stay distinguishable.
|
||||||
if variant_name:
|
if variant_name:
|
||||||
@@ -178,6 +185,9 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
|||||||
# warn-only underfill: big panel left mostly empty
|
# warn-only underfill: big panel left mostly empty
|
||||||
ex, ey = r.fill_extent
|
ex, ey = r.fill_extent
|
||||||
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
|
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:
|
else:
|
||||||
everything_ok = False
|
everything_ok = False
|
||||||
if r.error is not None:
|
if r.error is not None:
|
||||||
@@ -191,6 +201,11 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
|||||||
ex, ey = r.fill_extent or (0.0, 0.0)
|
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||||
status = "FAIL"
|
status = "FAIL"
|
||||||
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
|
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:
|
else:
|
||||||
status, detail = "FAIL", ""
|
status, detail = "FAIL", ""
|
||||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||||
|
|||||||
+71
-10
@@ -12,6 +12,8 @@ Follows LEDMatrix configuration management patterns:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
@@ -43,9 +45,34 @@ class BaseOddsManager:
|
|||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
self.base_url = "https://sports.core.api.espn.com/v2/sports"
|
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
|
# Configuration with defaults
|
||||||
self.update_interval = 3600 # 1 hour default
|
self.update_interval = 3600 # 1 hour default
|
||||||
self.request_timeout = 30 # 30 seconds 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.cache_ttl = 1800 # 30 minutes default
|
self.cache_ttl = 1800 # 30 minutes default
|
||||||
|
|
||||||
# Load configuration if available
|
# Load configuration if available
|
||||||
@@ -73,6 +100,14 @@ class BaseOddsManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Failed to load BaseOddsManager configuration: {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,
|
def get_odds(self, sport: str | None, league: str | None, event_id: str,
|
||||||
update_interval_seconds: int = None) -> Optional[Dict[str, Any]]:
|
update_interval_seconds: int = None) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -101,6 +136,16 @@ class BaseOddsManager:
|
|||||||
self.logger.info(f"Using cached odds from ESPN for {cache_key}")
|
self.logger.info(f"Using cached odds from ESPN for {cache_key}")
|
||||||
return cached_data
|
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}")
|
self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -117,10 +162,12 @@ class BaseOddsManager:
|
|||||||
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
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}")
|
self.logger.info(f"Requesting odds from URL: {url}")
|
||||||
|
|
||||||
response = requests.get(url, timeout=self.request_timeout)
|
response = self.session.get(url, timeout=self.request_timeout)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
raw_data = response.json()
|
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)}")
|
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
|
||||||
|
|
||||||
odds_data = self._extract_espn_data(raw_data)
|
odds_data = self._extract_espn_data(raw_data)
|
||||||
@@ -140,7 +187,11 @@ class BaseOddsManager:
|
|||||||
return odds_data
|
return odds_data
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {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)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
|
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
|
||||||
|
|
||||||
@@ -163,19 +214,25 @@ class BaseOddsManager:
|
|||||||
item = data["items"][0]
|
item = data["items"][0]
|
||||||
self.logger.debug(f"First item keys: {list(item.keys())}")
|
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
|
# The ESPN API returns odds data directly in the item, not in a
|
||||||
# Extract the odds data directly from the item
|
# 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 {}
|
||||||
extracted_data = {
|
extracted_data = {
|
||||||
"details": item.get("details"),
|
"details": item.get("details"),
|
||||||
"over_under": item.get("overUnder"),
|
"over_under": item.get("overUnder"),
|
||||||
"spread": item.get("spread"),
|
"spread": item.get("spread"),
|
||||||
"home_team_odds": {
|
"home_team_odds": {
|
||||||
"money_line": item.get("homeTeamOdds", {}).get("moneyLine"),
|
"money_line": home.get("moneyLine"),
|
||||||
"spread_odds": item.get("homeTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
|
"spread_odds": ((home.get("current") or {})
|
||||||
|
.get("pointSpread") or {}).get("value")
|
||||||
},
|
},
|
||||||
"away_team_odds": {
|
"away_team_odds": {
|
||||||
"money_line": item.get("awayTeamOdds", {}).get("moneyLine"),
|
"money_line": away.get("moneyLine"),
|
||||||
"spread_odds": item.get("awayTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
|
"spread_odds": ((away.get("current") or {})
|
||||||
|
.get("pointSpread") or {}).get("value")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.logger.debug(f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}")
|
self.logger.debug(f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}")
|
||||||
@@ -260,7 +317,11 @@ class BaseOddsManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Formatted odds summary string
|
Formatted odds summary string
|
||||||
"""
|
"""
|
||||||
if not self.is_odds_available(odds_data):
|
# 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'):
|
||||||
return "No odds available"
|
return "No odds available"
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
|
|||||||
Vendored
+80
-1
@@ -14,6 +14,13 @@ import zlib
|
|||||||
from typing import Dict, Any, Optional, Protocol
|
from typing import Dict, Any, Optional, Protocol
|
||||||
from datetime import datetime
|
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):
|
class CacheStrategyProtocol(Protocol):
|
||||||
@@ -112,6 +119,22 @@ class DiskCache:
|
|||||||
record_ts = None
|
record_ts = None
|
||||||
|
|
||||||
now = time.time()
|
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
|
# max_age=None means "never expires" (mirrors MemoryCache and the
|
||||||
# cache_manager docstring). Guard it explicitly — otherwise the
|
# cache_manager docstring). Guard it explicitly — otherwise the
|
||||||
# comparison below raises TypeError and the record is treated as a
|
# comparison below raises TypeError and the record is treated as a
|
||||||
@@ -331,6 +354,23 @@ class DiskCache:
|
|||||||
"""Get the cache directory path."""
|
"""Get the cache directory path."""
|
||||||
return self.cache_dir
|
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]:
|
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.
|
Clean up expired cache files based on retention policies.
|
||||||
@@ -365,12 +405,51 @@ class DiskCache:
|
|||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Get snapshot of files while holding lock briefly
|
# Get snapshot of files while holding lock briefly
|
||||||
filenames = [f for f in os.listdir(self.cache_dir) if f.endswith('.json')]
|
entries = os.listdir(self.cache_dir)
|
||||||
except OSError as list_error:
|
except OSError as list_error:
|
||||||
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
|
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
|
||||||
stats['errors'] += 1
|
stats['errors'] += 1
|
||||||
return stats
|
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
|
# Process files outside the lock to avoid blocking get/set operations
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
stats['files_scanned'] += 1
|
stats['files_scanned'] += 1
|
||||||
|
|||||||
Vendored
+10
@@ -57,6 +57,16 @@ class MemoryCache:
|
|||||||
if timestamp is None:
|
if timestamp is None:
|
||||||
return 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
|
# Check expiration
|
||||||
if max_age is not None and (now - timestamp) > max_age:
|
if max_age is not None and (now - timestamp) > max_age:
|
||||||
# Expired - remove it
|
# Expired - remove it
|
||||||
|
|||||||
+44
-3
@@ -47,6 +47,20 @@ from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-e
|
|||||||
class CacheManager:
|
class CacheManager:
|
||||||
"""Manages caching of API responses to reduce API calls."""
|
"""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:
|
def __init__(self) -> None:
|
||||||
# Initialize logger first
|
# Initialize logger first
|
||||||
self.logger: logging.Logger = get_logger(__name__)
|
self.logger: logging.Logger = get_logger(__name__)
|
||||||
@@ -594,8 +608,10 @@ class CacheManager:
|
|||||||
Args:
|
Args:
|
||||||
key: Cache key
|
key: Cache key
|
||||||
data: Data to cache
|
data: Data to cache
|
||||||
ttl: Optional time-to-live in seconds (stored for compatibility but
|
ttl: Time-to-live in seconds for this entry. Takes precedence over
|
||||||
expiration is still controlled via max_age when reading)
|
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.
|
||||||
"""
|
"""
|
||||||
cache_data = {
|
cache_data = {
|
||||||
'data': data,
|
'data': data,
|
||||||
@@ -716,11 +732,29 @@ class CacheManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def start_cleanup_thread(self) -> None:
|
def start_cleanup_thread(self) -> None:
|
||||||
"""Start background thread for periodic disk cache cleanup."""
|
"""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.
|
||||||
|
"""
|
||||||
if self._cleanup_thread and self._cleanup_thread.is_alive():
|
if self._cleanup_thread and self._cleanup_thread.is_alive():
|
||||||
self.logger.debug("Cleanup thread already running")
|
self.logger.debug("Cleanup thread already running")
|
||||||
return
|
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():
|
def cleanup_loop():
|
||||||
"""Background loop that runs cleanup periodically."""
|
"""Background loop that runs cleanup periodically."""
|
||||||
self.logger.info("Disk cache cleanup thread started (interval: %d hours)",
|
self.logger.info("Disk cache cleanup thread started (interval: %d hours)",
|
||||||
@@ -768,6 +802,13 @@ class CacheManager:
|
|||||||
Signals the thread to stop and waits for it to finish (with timeout).
|
Signals the thread to stop and waits for it to finish (with timeout).
|
||||||
This allows for clean shutdown during testing or application termination.
|
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():
|
if not self._cleanup_thread or not self._cleanup_thread.is_alive():
|
||||||
self.logger.debug("Cleanup thread not running")
|
self.logger.debug("Cleanup thread not running")
|
||||||
return
|
return
|
||||||
|
|||||||
+26
-11
@@ -273,19 +273,34 @@ class APIHelper:
|
|||||||
"""
|
"""
|
||||||
Clear cache data.
|
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:
|
Args:
|
||||||
pattern: Optional pattern to match cache keys
|
pattern: Optional substring to match cache keys; only matching
|
||||||
|
entries are deleted.
|
||||||
"""
|
"""
|
||||||
if self.cache_manager:
|
if not self.cache_manager:
|
||||||
if hasattr(self.cache_manager, 'clear'):
|
return
|
||||||
if pattern:
|
if pattern:
|
||||||
# Clear only keys matching pattern
|
if (hasattr(self.cache_manager, 'list_cache_files')
|
||||||
keys = self.cache_manager.keys()
|
and hasattr(self.cache_manager, 'delete')):
|
||||||
for key in keys:
|
for entry in self.cache_manager.list_cache_files():
|
||||||
if pattern in key:
|
key = entry.get('key') if isinstance(entry, dict) else None
|
||||||
self.cache_manager.delete(key)
|
if key and pattern in key:
|
||||||
else:
|
self.cache_manager.delete(key)
|
||||||
self.cache_manager.clear()
|
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")
|
||||||
|
|
||||||
def _get_from_cache(self, key: str) -> Optional[Any]:
|
def _get_from_cache(self, key: str) -> Optional[Any]:
|
||||||
"""Get data from cache."""
|
"""Get data from cache."""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Handles configuration management and validation for LED matrix plugins.
|
|||||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -160,17 +161,20 @@ class ConfigHelper:
|
|||||||
override_config: Configuration to merge in (takes precedence)
|
override_config: Configuration to merge in (takes precedence)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Merged configuration dictionary
|
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 = base_config.copy()
|
merged = copy.deepcopy(base_config)
|
||||||
|
|
||||||
for key, value in override_config.items():
|
for key, value in override_config.items():
|
||||||
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
||||||
# Recursively merge nested dictionaries
|
# Recursively merge nested dictionaries
|
||||||
merged[key] = self.merge_configs(merged[key], value)
|
merged[key] = self.merge_configs(merged[key], value)
|
||||||
else:
|
else:
|
||||||
# Override with new value
|
# Override with new value — deep-copied so mutating the
|
||||||
merged[key] = value
|
# merged result can't reach back into override_config.
|
||||||
|
merged[key] = copy.deepcopy(value)
|
||||||
|
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|||||||
@@ -115,17 +115,13 @@ class DisplayHelper:
|
|||||||
if home_logo and away_logo:
|
if home_logo and away_logo:
|
||||||
self._draw_logos(main_img, home_logo, away_logo)
|
self._draw_logos(main_img, home_logo, away_logo)
|
||||||
|
|
||||||
# Draw status/period text (top center)
|
# Draw one combined top line (period/status/clock all share y=1 —
|
||||||
if status_text or period_text:
|
# drawing them separately overprinted each other).
|
||||||
status_display = f"{period_text} {status_text}".strip()
|
top_line = " ".join(p for p in [period_text, status_text, clock] if p)
|
||||||
if status_display:
|
if top_line:
|
||||||
self._draw_centered_text(draw, status_display,
|
self._draw_centered_text(draw, top_line,
|
||||||
fonts.get('time', fonts.get('status')),
|
fonts.get('time', fonts.get('status')),
|
||||||
y_position=1)
|
y_position=1)
|
||||||
|
|
||||||
# Draw clock if available
|
|
||||||
if clock:
|
|
||||||
self._draw_centered_text(draw, clock, fonts.get('time'), y_position=1)
|
|
||||||
|
|
||||||
# Draw scores (center)
|
# Draw scores (center)
|
||||||
score_text = f"{away_score}-{home_score}"
|
score_text = f"{away_score}-{home_score}"
|
||||||
@@ -153,12 +149,18 @@ class DisplayHelper:
|
|||||||
"""
|
"""
|
||||||
Draw a ticker/scrolling text layout.
|
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:
|
Args:
|
||||||
text: Text to display
|
text: Text to display
|
||||||
font: Font to use
|
font: Font to use
|
||||||
background_color: Background color
|
background_color: Background color
|
||||||
text_color: Text color
|
text_color: Text color
|
||||||
scroll_speed: Pixels to scroll per frame
|
scroll_speed: Accepted for compatibility; unused per-frame
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL Image with ticker layout
|
PIL Image with ticker layout
|
||||||
@@ -166,11 +168,7 @@ class DisplayHelper:
|
|||||||
img = self.create_base_image(background_color)
|
img = self.create_base_image(background_color)
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
# Start text off-screen to the right
|
self._draw_text_with_outline(draw, text, (0, self.display_height // 2 - 6),
|
||||||
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)
|
font, fill=text_color)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
@@ -214,15 +212,9 @@ class DisplayHelper:
|
|||||||
Returns:
|
Returns:
|
||||||
PIL Image with error message
|
PIL Image with error message
|
||||||
"""
|
"""
|
||||||
img = self.create_base_image((50, 0, 0)) # Dark red background
|
# Dark red background, white text
|
||||||
|
|
||||||
# Use default font
|
|
||||||
font = ImageFont.load_default()
|
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:
|
def draw_no_data_message(self, message: str = "No Data") -> Image.Image:
|
||||||
"""
|
"""
|
||||||
@@ -234,11 +226,8 @@ class DisplayHelper:
|
|||||||
Returns:
|
Returns:
|
||||||
PIL Image with no data message
|
PIL Image with no data message
|
||||||
"""
|
"""
|
||||||
img = self.create_base_image((0, 0, 0))
|
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
return self.draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
||||||
|
|
||||||
return img
|
|
||||||
|
|
||||||
def get_display_dimensions(self) -> Tuple[int, int]:
|
def get_display_dimensions(self) -> Tuple[int, int]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Union
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
@@ -19,6 +21,10 @@ 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:
|
class LogoHelper:
|
||||||
"""
|
"""
|
||||||
Helper class for logo loading, caching, and resizing.
|
Helper class for logo loading, caching, and resizing.
|
||||||
@@ -226,7 +232,10 @@ class LogoHelper:
|
|||||||
return {
|
return {
|
||||||
'cached_logos': len(self._logo_cache),
|
'cached_logos': len(self._logo_cache),
|
||||||
'cache_size_limit': self.cache_size,
|
'cache_size_limit': self.cache_size,
|
||||||
'cache_usage_percent': (len(self._logo_cache) / self.cache_size) * 100
|
'cache_usage_percent': (
|
||||||
|
(len(self._logo_cache) / self.cache_size) * 100
|
||||||
|
if self.cache_size else 0
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
|
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
|
||||||
@@ -258,17 +267,60 @@ class LogoHelper:
|
|||||||
self._cache_order.append(cache_key)
|
self._cache_order.append(cache_key)
|
||||||
|
|
||||||
def _download_logo(self, url: str, file_path: Path) -> None:
|
def _download_logo(self, url: str, file_path: Path) -> None:
|
||||||
"""Download logo from URL."""
|
"""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.
|
||||||
|
"""
|
||||||
# Ensure directory exists with proper permissions
|
# Ensure directory exists with proper permissions
|
||||||
ensure_directory_permissions(file_path.parent, get_assets_dir_mode())
|
ensure_directory_permissions(file_path.parent, get_assets_dir_mode())
|
||||||
|
|
||||||
# Download with timeout
|
# A unique temp name, not a fixed "<name>.part": two plugins can
|
||||||
response = self.session.get(url, timeout=30)
|
# ask for the same logo at once, and a shared name would let them
|
||||||
response.raise_for_status()
|
# 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)
|
||||||
|
|
||||||
# Save to file
|
# Verify it decodes before it becomes the cached logo. PIL
|
||||||
with open(file_path, 'wb') as f:
|
# raises DecompressionBombError past its own pixel limit; a
|
||||||
f.write(response.content)
|
# 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
|
||||||
|
|
||||||
# Set proper file permissions after saving
|
# Set proper file permissions after saving
|
||||||
ensure_file_permissions(file_path, get_assets_file_mode())
|
ensure_file_permissions(file_path, get_assets_file_mode())
|
||||||
|
|||||||
+82
-35
@@ -37,6 +37,13 @@ _RAW_MAGIC = b'SYNC_RAW'
|
|||||||
_RAW_HEADER = struct.Struct('<HH') # width, height (uint16 LE)
|
_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
|
SYNC_PORT = 5765
|
||||||
HELLO_INTERVAL = 5.0 # follower broadcasts hello every 5 s
|
HELLO_INTERVAL = 5.0 # follower broadcasts hello every 5 s
|
||||||
HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
|
HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
|
||||||
@@ -101,6 +108,7 @@ class DisplaySyncManager:
|
|||||||
self._peer_chain: int = 0
|
self._peer_chain: int = 0
|
||||||
self._last_heartbeat_time: float = 0.0
|
self._last_heartbeat_time: float = 0.0
|
||||||
self._leader_width: int = 0 # set by display_controller after init
|
self._leader_width: int = 0 # set by display_controller after init
|
||||||
|
self._oversized_frame_warned: bool = False
|
||||||
|
|
||||||
# Follower state
|
# Follower state
|
||||||
self._follower_state = FollowerState.STANDALONE
|
self._follower_state = FollowerState.STANDALONE
|
||||||
@@ -174,6 +182,10 @@ class DisplaySyncManager:
|
|||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.debug("Sync leader recv error: %s", 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:
|
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
||||||
hw = self._hw_config
|
hw = self._hw_config
|
||||||
@@ -273,11 +285,10 @@ class DisplaySyncManager:
|
|||||||
break
|
break
|
||||||
data.extend(chunk)
|
data.extend(chunk)
|
||||||
img = Image.open(io.BytesIO(data))
|
img = Image.open(io.BytesIO(data))
|
||||||
_MAX_W, _MAX_H = 100_000, 256 # generous for any real scroll image
|
if img.width > _MAX_FRAME_W or img.height > _MAX_FRAME_H:
|
||||||
if img.width > _MAX_W or img.height > _MAX_H:
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Sync: rejected oversized scroll image %dx%d (max %dx%d) from %s",
|
"Sync: rejected oversized scroll image %dx%d (max %dx%d) from %s",
|
||||||
img.width, img.height, _MAX_W, _MAX_H, addr,
|
img.width, img.height, _MAX_FRAME_W, _MAX_FRAME_H, addr,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -396,7 +407,7 @@ class DisplaySyncManager:
|
|||||||
data = header + arr.tobytes()
|
data = header + arr.tobytes()
|
||||||
if len(data) <= 65000:
|
if len(data) <= 65000:
|
||||||
self._send_sock.sendto(data, (self._peer_ip, self.port))
|
self._send_sock.sendto(data, (self._peer_ip, self.port))
|
||||||
elif not getattr(self, '_oversized_frame_warned', False):
|
elif not self._oversized_frame_warned:
|
||||||
self._oversized_frame_warned = True
|
self._oversized_frame_warned = True
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Sync: frame too large for UDP (%d bytes, max 65000) — "
|
"Sync: frame too large for UDP (%d bytes, max 65000) — "
|
||||||
@@ -451,43 +462,76 @@ class DisplaySyncManager:
|
|||||||
)
|
)
|
||||||
self.write_status_file()
|
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:
|
def _follower_recv_loop(self) -> None:
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
data, addr = self._recv_sock.recvfrom(65535)
|
data, addr = self._recv_sock.recvfrom(65535)
|
||||||
sender_ip = addr[0]
|
sender_ip = addr[0]
|
||||||
|
|
||||||
if data[:8] == _RAW_MAGIC or len(data) > 512:
|
if data[:8] == _RAW_MAGIC:
|
||||||
# Frame data: prefer magic-tagged raw RGB; fall back to legacy PNG
|
# Magic-tagged raw RGB frame — self-describing, no guessing.
|
||||||
try:
|
try:
|
||||||
if data[:8] == _RAW_MAGIC:
|
w, h = _RAW_HEADER.unpack(data[8:12])
|
||||||
w, h = _RAW_HEADER.unpack(data[8:12])
|
raw = data[12:]
|
||||||
raw = data[12:]
|
img = Image.frombuffer(
|
||||||
img = Image.frombuffer(
|
"RGB", (w, h), raw, "raw", "RGB", 0, 1
|
||||||
"RGB", (w, h), raw, "raw", "RGB", 0, 1
|
)
|
||||||
)
|
self._handle_received_frame(img, sender_ip)
|
||||||
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:
|
except Exception as exc:
|
||||||
self.logger.debug("Sync: frame decode error: %s", exc)
|
self.logger.debug("Sync: frame decode error: %s", exc)
|
||||||
else:
|
else:
|
||||||
# Control message
|
# 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.
|
||||||
try:
|
try:
|
||||||
msg = json.loads(data.decode("utf-8"))
|
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")
|
t = msg.get("t")
|
||||||
if t == "hello_ack":
|
if t == "hello_ack":
|
||||||
self._leader_ip = sender_ip
|
self._leader_ip = sender_ip
|
||||||
@@ -511,19 +555,22 @@ class DisplaySyncManager:
|
|||||||
sender_ip,
|
sender_ip,
|
||||||
)
|
)
|
||||||
self.write_status_file()
|
self.write_status_file()
|
||||||
if self._on_new_cycle:
|
fire_new_cycle = True # build initial scroll image
|
||||||
self._on_new_cycle() # build initial scroll image
|
|
||||||
elif t == "nc":
|
elif t == "nc":
|
||||||
# Leader started a new scroll cycle — rebuild local image
|
# Leader started a new scroll cycle — rebuild local image
|
||||||
if self._on_new_cycle:
|
fire_new_cycle = True
|
||||||
self._on_new_cycle()
|
except (KeyError, AttributeError, TypeError, ValueError) as exc:
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError, KeyError):
|
self.logger.debug("Sync: malformed control message: %s", exc)
|
||||||
pass
|
continue
|
||||||
|
|
||||||
|
if fire_new_cycle and self._on_new_cycle:
|
||||||
|
self._on_new_cycle()
|
||||||
|
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.debug("Sync follower recv error: %s", exc)
|
self.logger.debug("Sync follower recv error: %s", exc)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
def _follower_announce_loop(self) -> None:
|
def _follower_announce_loop(self) -> None:
|
||||||
hw = self._hw_config
|
hw = self._hw_config
|
||||||
|
|||||||
+64
-9
@@ -269,20 +269,47 @@ class ConfigManager:
|
|||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=True)
|
||||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
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]:
|
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."""
|
"""Recursively remove secret keys from a dictionary."""
|
||||||
result = {}
|
result = {}
|
||||||
for key, value in data_to_filter.items():
|
for key, value in data_to_filter.items():
|
||||||
if key in secrets:
|
if key not 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
|
# This key is not in secrets, so we keep it
|
||||||
result[key] = value
|
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
|
return result
|
||||||
|
|
||||||
def _load_secrets_for_save(self) -> Dict[str, Any]:
|
def _load_secrets_for_save(self) -> Dict[str, Any]:
|
||||||
@@ -358,11 +385,39 @@ class ConfigManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _deep_merge(self, target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
def _deep_merge(self, target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||||
"""Deep merge source dict into target dict."""
|
"""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."""
|
||||||
for key, value in source.items():
|
for key, value in source.items():
|
||||||
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
||||||
self._deep_merge(target[key], value)
|
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:
|
else:
|
||||||
|
# Scalars AND whole-secret scalar arrays: replace (legacy).
|
||||||
target[key] = value
|
target[key] = value
|
||||||
|
|
||||||
def _create_config_from_template(self) -> None:
|
def _create_config_from_template(self) -> None:
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ from src.common.sync_manager import DisplaySyncManager, SyncRole
|
|||||||
# Get logger with consistent configuration
|
# Get logger with consistent configuration
|
||||||
logger = get_logger(__name__)
|
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 import (lazy loaded to avoid circular imports)
|
||||||
_vegas_mode_imported = False
|
_vegas_mode_imported = False
|
||||||
VegasModeCoordinator = None
|
VegasModeCoordinator = None
|
||||||
@@ -90,7 +104,8 @@ class DisplayController:
|
|||||||
# Validate startup configuration
|
# Validate startup configuration
|
||||||
try:
|
try:
|
||||||
from src.startup_validator import StartupValidator
|
from src.startup_validator import StartupValidator
|
||||||
validator = StartupValidator(self.config_manager)
|
validator = StartupValidator(self.config_manager,
|
||||||
|
cache_manager=self.cache_manager)
|
||||||
is_valid, errors, warnings = validator.validate_all()
|
is_valid, errors, warnings = validator.validate_all()
|
||||||
|
|
||||||
if warnings:
|
if warnings:
|
||||||
@@ -258,7 +273,8 @@ class DisplayController:
|
|||||||
# Validate plugins after plugin manager is created
|
# Validate plugins after plugin manager is created
|
||||||
try:
|
try:
|
||||||
from src.startup_validator import StartupValidator
|
from src.startup_validator import StartupValidator
|
||||||
validator = StartupValidator(self.config_manager, self.plugin_manager)
|
validator = StartupValidator(self.config_manager, self.plugin_manager,
|
||||||
|
cache_manager=self.cache_manager)
|
||||||
is_valid, errors, warnings = validator.validate_all()
|
is_valid, errors, warnings = validator.validate_all()
|
||||||
|
|
||||||
if warnings:
|
if warnings:
|
||||||
@@ -461,7 +477,7 @@ class DisplayController:
|
|||||||
# Initial data update for plugins (ensures data available on first display)
|
# Initial data update for plugins (ensures data available on first display)
|
||||||
logger.info("Performing initial plugin data update...")
|
logger.info("Performing initial plugin data update...")
|
||||||
update_start = time.time()
|
update_start = time.time()
|
||||||
self._update_modules()
|
self._update_modules(deadline=update_start + _INITIAL_UPDATE_BUDGET_SECONDS)
|
||||||
logger.info("Initial plugin update completed in %.3f seconds", time.time() - update_start)
|
logger.info("Initial plugin update completed in %.3f seconds", time.time() - update_start)
|
||||||
|
|
||||||
# Initialize Vegas mode coordinator
|
# Initialize Vegas mode coordinator
|
||||||
@@ -817,14 +833,42 @@ class DisplayController:
|
|||||||
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
||||||
return normal_brightness
|
return normal_brightness
|
||||||
|
|
||||||
def _update_modules(self):
|
def _update_modules(self, deadline: Optional[float] = None):
|
||||||
"""Update all plugin modules."""
|
"""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.
|
||||||
|
"""
|
||||||
if not self.plugin_manager:
|
if not self.plugin_manager:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update all loaded plugins
|
# Update all loaded plugins
|
||||||
plugins_dict = getattr(self.plugin_manager, 'loaded_plugins', None) or getattr(self.plugin_manager, '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():
|
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
|
# Check circuit breaker before attempting update
|
||||||
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||||
if self.plugin_manager.health_tracker.should_skip_plugin(plugin_id):
|
if self.plugin_manager.health_tracker.should_skip_plugin(plugin_id):
|
||||||
@@ -833,7 +877,13 @@ class DisplayController:
|
|||||||
|
|
||||||
# Use PluginExecutor if available for safe execution
|
# Use PluginExecutor if available for safe execution
|
||||||
if hasattr(self.plugin_manager, 'plugin_executor'):
|
if hasattr(self.plugin_manager, 'plugin_executor'):
|
||||||
success = self.plugin_manager.plugin_executor.execute_update(plugin_instance, plugin_id)
|
# 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)
|
||||||
if success and hasattr(self.plugin_manager, 'plugin_last_update'):
|
if success and hasattr(self.plugin_manager, 'plugin_last_update'):
|
||||||
self.plugin_manager.plugin_last_update[plugin_id] = time.time()
|
self.plugin_manager.plugin_last_update[plugin_id] = time.time()
|
||||||
else:
|
else:
|
||||||
@@ -852,6 +902,12 @@ class DisplayController:
|
|||||||
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||||
self.plugin_manager.health_tracker.record_failure(plugin_id, exc)
|
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:
|
def _tick_plugin_updates_for_vegas(self) -> None:
|
||||||
"""Run scheduled plugin updates and tell Vegas mode which plugins
|
"""Run scheduled plugin updates and tell Vegas mode which plugins
|
||||||
actually got fresh data, so it can hot-swap them into the scroll
|
actually got fresh data, so it can hot-swap them into the scroll
|
||||||
|
|||||||
+112
-3
@@ -25,6 +25,7 @@ the same object.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import tempfile
|
import tempfile
|
||||||
if os.getenv("EMULATOR", "false") == "true":
|
if os.getenv("EMULATOR", "false") == "true":
|
||||||
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
|
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
|
||||||
@@ -258,6 +259,26 @@ class DisplayManager:
|
|||||||
# Initialize managers
|
# Initialize managers
|
||||||
# Calendar manager is now initialized by DisplayController
|
# 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):
|
def _setup_matrix(self):
|
||||||
"""Initialize the RGB matrix with configuration settings."""
|
"""Initialize the RGB matrix with configuration settings."""
|
||||||
_init_error_str = None
|
_init_error_str = None
|
||||||
@@ -283,7 +304,7 @@ class DisplayManager:
|
|||||||
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
||||||
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
||||||
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
|
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
|
||||||
options.pixel_mapper_config = hardware_config.get('pixel_mapper_config', '')
|
options.pixel_mapper_config = self._build_pixel_mapper_config(hardware_config)
|
||||||
options.row_address_type = hardware_config.get('row_address_type', 0)
|
options.row_address_type = hardware_config.get('row_address_type', 0)
|
||||||
options.multiplexing = hardware_config.get('multiplexing', 0)
|
options.multiplexing = hardware_config.get('multiplexing', 0)
|
||||||
options.panel_type = hardware_config.get('panel_type', '')
|
options.panel_type = hardware_config.get('panel_type', '')
|
||||||
@@ -497,6 +518,91 @@ class DisplayManager:
|
|||||||
logger.warning(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
|
logger.warning(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
|
||||||
return -1
|
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):
|
def _draw_test_pattern(self):
|
||||||
"""Draw a test pattern to verify the display is working."""
|
"""Draw a test pattern to verify the display is working."""
|
||||||
try:
|
try:
|
||||||
@@ -516,8 +622,11 @@ class DisplayManager:
|
|||||||
# Draw a diagonal line
|
# Draw a diagonal line
|
||||||
self.draw.line([0, 0, self.matrix.width-1, self.matrix.height-1], fill=(0, 255, 0))
|
self.draw.line([0, 0, self.matrix.width-1, self.matrix.height-1], fill=(0, 255, 0))
|
||||||
|
|
||||||
# Draw some text - changed from "TEST" to "Initializing" with smaller font
|
lines = ["Initializing"]
|
||||||
self.draw.text((10, 10), "Initializing", font=self.font, fill=(0, 0, 255))
|
ip = self._local_ip()
|
||||||
|
if ip:
|
||||||
|
lines.append(ip)
|
||||||
|
self._draw_startup_banner(lines, self.matrix.width, self.matrix.height)
|
||||||
|
|
||||||
# Update the display once after everything is drawn
|
# Update the display once after everything is drawn
|
||||||
self.update_display()
|
self.update_display()
|
||||||
|
|||||||
@@ -168,9 +168,13 @@ class DynamicTeamResolver:
|
|||||||
# Sort by ranking (1, 2, 3, etc.)
|
# Sort by ranking (1, 2, 3, etc.)
|
||||||
sorted_rankings = dict(sorted(rankings.items(), key=lambda x: x[1]))
|
sorted_rankings = dict(sorted(rankings.items(), key=lambda x: x[1]))
|
||||||
|
|
||||||
# Cache the results
|
# Cache the results ON THE CLASS. Assigning through self
|
||||||
self._rankings_cache = sorted_rankings
|
# would create instance attributes that shadow the shared
|
||||||
self._cache_timestamp = current_time
|
# 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
|
||||||
|
|
||||||
self.logger.info(f"Fetched rankings for {len(sorted_rankings)} teams")
|
self.logger.info(f"Fetched rankings for {len(sorted_rankings)} teams")
|
||||||
return sorted_rankings
|
return sorted_rankings
|
||||||
@@ -216,9 +220,11 @@ class DynamicTeamResolver:
|
|||||||
return any(pattern in team_name.upper() for pattern in dynamic_patterns)
|
return any(pattern in team_name.upper() for pattern in dynamic_patterns)
|
||||||
|
|
||||||
def clear_cache(self):
|
def clear_cache(self):
|
||||||
"""Clear the rankings cache to force fresh data on next request."""
|
"""Clear the SHARED rankings cache to force fresh data on next
|
||||||
self._rankings_cache = {}
|
request. Writes through the class — assigning via self would only
|
||||||
self._cache_timestamp = 0
|
shadow the shared cache for this instance."""
|
||||||
|
DynamicTeamResolver._rankings_cache = {}
|
||||||
|
DynamicTeamResolver._cache_timestamp = 0
|
||||||
self.logger.info("Cleared dynamic team rankings cache")
|
self.logger.info("Cleared dynamic team rankings cache")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -5,6 +5,7 @@ Provides consistent logging configuration across the LEDMatrix application.
|
|||||||
Supports structured logging with context information and appropriate log levels.
|
Supports structured logging with context information and appropriate log levels.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
@@ -65,8 +66,12 @@ class ContextualFormatter(logging.Formatter):
|
|||||||
self.include_context = include_context
|
self.include_context = include_context
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
"""Format log record with context."""
|
"""Format log record with context.
|
||||||
# Add context to message if present
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
if self.include_context:
|
if self.include_context:
|
||||||
context_parts = []
|
context_parts = []
|
||||||
|
|
||||||
@@ -81,6 +86,7 @@ class ContextualFormatter(logging.Formatter):
|
|||||||
context_parts.append(f"[{key}: {value}]")
|
context_parts.append(f"[{key}: {value}]")
|
||||||
|
|
||||||
if context_parts:
|
if context_parts:
|
||||||
|
record = copy.copy(record)
|
||||||
record.msg = ' '.join(context_parts) + ' ' + str(record.msg)
|
record.msg = ' '.join(context_parts) + ' ' + str(record.msg)
|
||||||
|
|
||||||
return super().format(record)
|
return super().format(record)
|
||||||
@@ -224,8 +230,11 @@ def log_warning(logger: logging.Logger, message: str, **kwargs) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def log_error(logger: logging.Logger, message: str, **kwargs) -> None:
|
def log_error(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||||
"""Log error message with context."""
|
"""Log error message with context. Defaults exc_info=True; a caller
|
||||||
log_with_context(logger, logging.ERROR, message, **kwargs, exc_info=True)
|
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)
|
||||||
|
|
||||||
|
|
||||||
def log_debug(logger: logging.Logger, message: str, **kwargs) -> None:
|
def log_debug(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||||
|
|||||||
@@ -364,8 +364,10 @@ class BasePlugin(ABC):
|
|||||||
# Handle None case
|
# Handle None case
|
||||||
if duration is None:
|
if duration is None:
|
||||||
pass # Fall through to config
|
pass # Fall through to config
|
||||||
# Try to convert to float if it's a number or numeric string
|
# Try to convert to float if it's a number or numeric string.
|
||||||
elif isinstance(duration, (int, float)):
|
# 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):
|
||||||
if duration > 0:
|
if duration > 0:
|
||||||
return float(duration)
|
return float(duration)
|
||||||
else:
|
else:
|
||||||
@@ -403,8 +405,9 @@ class BasePlugin(ABC):
|
|||||||
# Fall back to config
|
# Fall back to config
|
||||||
config_duration = self.config.get("display_duration", 15.0)
|
config_duration = self.config.get("display_duration", 15.0)
|
||||||
try:
|
try:
|
||||||
# Ensure config value is also a valid float
|
# Ensure config value is also a valid float (bool excluded — an
|
||||||
if isinstance(config_duration, (int, float)):
|
# int subclass that would otherwise read True as 1 second)
|
||||||
|
if isinstance(config_duration, (int, float)) and not isinstance(config_duration, bool):
|
||||||
if config_duration > 0:
|
if config_duration > 0:
|
||||||
return float(config_duration)
|
return float(config_duration)
|
||||||
else:
|
else:
|
||||||
@@ -794,10 +797,12 @@ class BasePlugin(ABC):
|
|||||||
self.logger.error("'enabled' must be a boolean")
|
self.logger.error("'enabled' must be a boolean")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check display_duration if present
|
# Check display_duration if present. bool is excluded explicitly:
|
||||||
|
# it's an int subclass, and get_display_duration rejects it too.
|
||||||
if "display_duration" in self.config:
|
if "display_duration" in self.config:
|
||||||
duration = self.config["display_duration"]
|
duration = self.config["display_duration"]
|
||||||
if not isinstance(duration, (int, float)) or duration <= 0:
|
if (not isinstance(duration, (int, float))
|
||||||
|
or isinstance(duration, bool) or duration <= 0):
|
||||||
self.logger.error("'display_duration' must be a positive number")
|
self.logger.error("'display_duration' must be a positive number")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Manages saved GitHub repository URLs for easy plugin discovery and installation.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
@@ -43,20 +44,45 @@ class SavedRepositoriesManager:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def _save_repositories(self) -> bool:
|
def _save_repositories(self) -> bool:
|
||||||
"""Save repositories to file."""
|
"""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')
|
||||||
try:
|
try:
|
||||||
# Ensure directory exists
|
# Ensure directory exists
|
||||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
with open(self.config_path, 'w') as f:
|
with open(tmp_path, 'w') as f:
|
||||||
json.dump(self.repositories, f, indent=2)
|
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}")
|
self.logger.info(f"Saved {len(self.repositories)} repositories to {self.config_path}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error saving repositories: {e}")
|
self.logger.error(f"Error saving repositories: {e}")
|
||||||
|
try:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
return False
|
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]]:
|
def get_all(self) -> List[Dict[str, str]]:
|
||||||
"""Get all saved repositories."""
|
"""Get all saved repositories."""
|
||||||
return self.repositories.copy()
|
return self.repositories.copy()
|
||||||
@@ -72,8 +98,7 @@ class SavedRepositoriesManager:
|
|||||||
Returns:
|
Returns:
|
||||||
True if added successfully
|
True if added successfully
|
||||||
"""
|
"""
|
||||||
# Clean URL
|
repo_url = self._clean_url(repo_url)
|
||||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
|
||||||
|
|
||||||
# Check if already exists
|
# Check if already exists
|
||||||
for repo in self.repositories:
|
for repo in self.repositories:
|
||||||
@@ -96,7 +121,12 @@ class SavedRepositoriesManager:
|
|||||||
'type': 'registry' if 'plugins.json' in repo_url or 'ledmatrix-plugins' in repo_url.lower() else 'single'
|
'type': 'registry' if 'plugins.json' in repo_url or 'ledmatrix-plugins' in repo_url.lower() else 'single'
|
||||||
})
|
})
|
||||||
|
|
||||||
return self._save_repositories()
|
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
|
||||||
|
|
||||||
def remove(self, repo_url: str) -> bool:
|
def remove(self, repo_url: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -108,21 +138,25 @@ class SavedRepositoriesManager:
|
|||||||
Returns:
|
Returns:
|
||||||
True if removed successfully
|
True if removed successfully
|
||||||
"""
|
"""
|
||||||
# Clean URL
|
repo_url = self._clean_url(repo_url)
|
||||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
|
||||||
|
|
||||||
original_count = len(self.repositories)
|
previous = self.repositories
|
||||||
self.repositories = [r for r in self.repositories if r.get('url') != repo_url]
|
remaining = [r for r in previous if r.get('url') != repo_url]
|
||||||
|
|
||||||
if len(self.repositories) < original_count:
|
if len(remaining) < len(previous):
|
||||||
return self._save_repositories()
|
self.repositories = remaining
|
||||||
|
if not self._save_repositories():
|
||||||
|
# Failed save: restore so memory matches disk.
|
||||||
|
self.repositories = previous
|
||||||
|
return False
|
||||||
|
return True
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Repository not found: {repo_url}")
|
self.logger.warning(f"Repository not found: {repo_url}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def has(self, repo_url: str) -> bool:
|
def has(self, repo_url: str) -> bool:
|
||||||
"""Check if a repository is already saved."""
|
"""Check if a repository is already saved."""
|
||||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
repo_url = self._clean_url(repo_url)
|
||||||
return any(r.get('url') == repo_url for r in self.repositories)
|
return any(r.get('url') == repo_url for r in self.repositories)
|
||||||
|
|
||||||
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ class RenderResult:
|
|||||||
golden_ok: Optional[bool] = None
|
golden_ok: Optional[bool] = None
|
||||||
golden_diff_pixels: int = 0
|
golden_diff_pixels: int = 0
|
||||||
golden_max_delta: 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 / scale-up check (populated only for sizes >= 2x the design size)
|
||||||
fill_checked: bool = False
|
fill_checked: bool = False
|
||||||
fill_ok: Optional[bool] = None # False only in strict mode
|
fill_ok: Optional[bool] = None # False only in strict mode
|
||||||
@@ -92,6 +97,8 @@ class RenderResult:
|
|||||||
return False
|
return False
|
||||||
if self.fill_ok is False:
|
if self.fill_ok is False:
|
||||||
return False
|
return False
|
||||||
|
if self.empty_ok is False:
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -132,21 +139,25 @@ def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
|||||||
return plugin_instance
|
return plugin_instance
|
||||||
|
|
||||||
|
|
||||||
def _render_mode(plugin_instance: Any, mode: str) -> None:
|
def _render_mode(plugin_instance: Any, mode: str) -> Any:
|
||||||
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
|
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
|
||||||
drive the plugin's internal mode state machine (first display() call renders
|
drive the plugin's internal mode state machine (first display() call renders
|
||||||
modes[current_mode_index] when current_display_mode is None)."""
|
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."""
|
||||||
sig = inspect.signature(plugin_instance.display)
|
sig = inspect.signature(plugin_instance.display)
|
||||||
if "display_mode" in sig.parameters:
|
if "display_mode" in sig.parameters:
|
||||||
plugin_instance.display(force_clear=True, display_mode=mode)
|
return plugin_instance.display(force_clear=True, display_mode=mode)
|
||||||
return
|
|
||||||
|
|
||||||
modes = getattr(plugin_instance, "modes", None)
|
modes = getattr(plugin_instance, "modes", None)
|
||||||
if modes and mode in modes:
|
if modes and mode in modes:
|
||||||
plugin_instance.current_mode_index = list(modes).index(mode)
|
plugin_instance.current_mode_index = list(modes).index(mode)
|
||||||
if hasattr(plugin_instance, "current_display_mode"):
|
if hasattr(plugin_instance, "current_display_mode"):
|
||||||
plugin_instance.current_display_mode = None
|
plugin_instance.current_display_mode = None
|
||||||
plugin_instance.display(force_clear=False)
|
return plugin_instance.display(force_clear=False)
|
||||||
|
|
||||||
|
|
||||||
def _freeze(freeze_time: Optional[str]):
|
def _freeze(freeze_time: Optional[str]):
|
||||||
@@ -234,7 +245,7 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
|||||||
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
|
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
|
||||||
plugin_id, mode, e)
|
plugin_id, mode, e)
|
||||||
if result.error is None:
|
if result.error is None:
|
||||||
_render_mode(inst, mode)
|
result.display_returned = _render_mode(inst, mode)
|
||||||
result.image = dm.get_image()
|
result.image = dm.get_image()
|
||||||
result.overflow = dm.check_overflow()
|
result.overflow = dm.check_overflow()
|
||||||
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||||
@@ -341,6 +352,44 @@ def fill_metrics(image: Image.Image) -> Tuple[float, float, float]:
|
|||||||
return (extent_x, extent_y, ink)
|
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],
|
def check_scale_up(results: List[RenderResult],
|
||||||
design_size: Tuple[int, int] = (128, 32),
|
design_size: Tuple[int, int] = (128, 32),
|
||||||
min_extent: float = _MIN_FILL_EXTENT,
|
min_extent: float = _MIN_FILL_EXTENT,
|
||||||
|
|||||||
@@ -15,16 +15,23 @@ from src.logging_config import get_logger
|
|||||||
class StartupValidator:
|
class StartupValidator:
|
||||||
"""Validates system state on startup."""
|
"""Validates system state on startup."""
|
||||||
|
|
||||||
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None) -> None:
|
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None,
|
||||||
|
cache_manager: Optional[Any] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize the startup validator.
|
Initialize the startup validator.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_manager: ConfigManager instance
|
config_manager: ConfigManager instance
|
||||||
plugin_manager: Optional PluginManager 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.config_manager = config_manager
|
||||||
self.plugin_manager = plugin_manager
|
self.plugin_manager = plugin_manager
|
||||||
|
self.cache_manager = cache_manager
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
self.errors: List[str] = []
|
self.errors: List[str] = []
|
||||||
self.warnings: List[str] = []
|
self.warnings: List[str] = []
|
||||||
@@ -38,6 +45,11 @@ class StartupValidator:
|
|||||||
"""
|
"""
|
||||||
self.logger.info("Starting startup validation...")
|
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
|
# Validate configuration
|
||||||
self._validate_config()
|
self._validate_config()
|
||||||
|
|
||||||
@@ -86,9 +98,21 @@ class StartupValidator:
|
|||||||
def _validate_cache_directory(self) -> None:
|
def _validate_cache_directory(self) -> None:
|
||||||
"""Validate cache directory permissions."""
|
"""Validate cache directory permissions."""
|
||||||
try:
|
try:
|
||||||
from src.cache_manager import CacheManager
|
cache_manager = self.cache_manager
|
||||||
cache_manager = CacheManager()
|
if cache_manager is None:
|
||||||
cache_dir = cache_manager.get_cache_dir()
|
# 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()
|
||||||
|
|
||||||
if not cache_dir:
|
if not cache_dir:
|
||||||
self.warnings.append("Cache directory not available - caching will be disabled")
|
self.warnings.append("Cache directory not available - caching will be disabled")
|
||||||
|
|||||||
@@ -104,10 +104,22 @@ class VegasModeConfig:
|
|||||||
overflow_mode: str = "rotate"
|
overflow_mode: str = "rotate"
|
||||||
|
|
||||||
# Cap on one plugin's share of a cycle, as a multiple of display width.
|
# Cap on one plugin's share of a cycle, as a multiple of display width.
|
||||||
# A single ticker returning 7,000px would otherwise hold the panel for over
|
# 0 (the default) disables the cap, so every plugin contributes all of its
|
||||||
# two minutes. Overflow is deferred to later cycles rather than discarded.
|
# content and is always entered at its beginning.
|
||||||
# 0 disables the cap.
|
#
|
||||||
max_plugin_width_ratio: float = 3.0
|
# 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
|
||||||
|
|
||||||
# Plugin management
|
# Plugin management
|
||||||
plugin_order: List[str] = field(default_factory=list)
|
plugin_order: List[str] = field(default_factory=list)
|
||||||
@@ -159,7 +171,7 @@ class VegasModeConfig:
|
|||||||
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
|
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
|
||||||
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
|
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
|
||||||
max_plugin_width_ratio=float(
|
max_plugin_width_ratio=float(
|
||||||
vegas_config.get('max_plugin_width_ratio', 3.0)),
|
vegas_config.get('max_plugin_width_ratio', 0.0)),
|
||||||
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
||||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Supports three display modes per plugin:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
|
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
|
||||||
@@ -30,6 +31,21 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(__name__)
|
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:
|
class VegasModeCoordinator:
|
||||||
"""
|
"""
|
||||||
Orchestrates Vegas scroll mode operation.
|
Orchestrates Vegas scroll mode operation.
|
||||||
@@ -382,6 +398,12 @@ class VegasModeCoordinator:
|
|||||||
fps_log_interval = 5.0 # Log FPS every 5 seconds
|
fps_log_interval = 5.0 # Log FPS every 5 seconds
|
||||||
last_fps_log_time = start_time
|
last_fps_log_time = start_time
|
||||||
fps_frame_count = 0
|
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)
|
logger.info("Starting Vegas iteration for %.1fs", duration)
|
||||||
|
|
||||||
@@ -417,6 +439,11 @@ class VegasModeCoordinator:
|
|||||||
frame_elapsed = time.time() - frame_started
|
frame_elapsed = time.time() - frame_started
|
||||||
time.sleep(max(0.0, frame_interval - frame_elapsed))
|
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
|
# Increment frame count and check for interrupt periodically
|
||||||
frame_count += 1
|
frame_count += 1
|
||||||
fps_frame_count += 1
|
fps_frame_count += 1
|
||||||
@@ -425,12 +452,16 @@ class VegasModeCoordinator:
|
|||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if current_time - last_fps_log_time >= fps_log_interval:
|
if current_time - last_fps_log_time >= fps_log_interval:
|
||||||
fps = fps_frame_count / (current_time - last_fps_log_time)
|
fps = fps_frame_count / (current_time - last_fps_log_time)
|
||||||
|
p99 = _percentile(sorted(frame_times), 0.99)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Vegas FPS: %.1f (target: %d, frames: %d)",
|
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms",
|
||||||
fps, self.vegas_config.target_fps, fps_frame_count
|
fps, self.vegas_config.target_fps, fps_frame_count,
|
||||||
|
p99 * 1000.0, frame_worst * 1000.0
|
||||||
)
|
)
|
||||||
last_fps_log_time = current_time
|
last_fps_log_time = current_time
|
||||||
fps_frame_count = 0
|
fps_frame_count = 0
|
||||||
|
frame_worst = 0.0
|
||||||
|
frame_times.clear()
|
||||||
|
|
||||||
if (self._interrupt_check and
|
if (self._interrupt_check and
|
||||||
frame_count % self._interrupt_check_interval == 0):
|
frame_count % self._interrupt_check_interval == 0):
|
||||||
|
|||||||
@@ -68,6 +68,21 @@ class PluginAdapter:
|
|||||||
# always the same opening items.
|
# always the same opening items.
|
||||||
self._item_offsets: dict = {}
|
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(
|
logger.info(
|
||||||
"PluginAdapter initialized: display=%dx%d",
|
"PluginAdapter initialized: display=%dx%d",
|
||||||
self.display_width, self.display_height
|
self.display_width, self.display_height
|
||||||
@@ -398,6 +413,88 @@ class PluginAdapter:
|
|||||||
return 0
|
return 0
|
||||||
return int(self.display_width * ratio)
|
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(
|
def _apply_width_budget(
|
||||||
self, images: List[Image.Image], plugin_id: str,
|
self, images: List[Image.Image], plugin_id: str,
|
||||||
plugin: Optional['BasePlugin'] = None
|
plugin: Optional['BasePlugin'] = None
|
||||||
@@ -435,32 +532,48 @@ class PluginAdapter:
|
|||||||
|
|
||||||
if not budget or total <= budget:
|
if not budget or total <= budget:
|
||||||
# Fits, so reset rotation — the whole segment is being shown.
|
# Fits, so reset rotation — the whole segment is being shown.
|
||||||
self._item_offsets.pop(plugin_id, None)
|
self._clear_offset(plugin_id)
|
||||||
return images
|
return images
|
||||||
|
|
||||||
if len(images) == 1:
|
if len(images) == 1:
|
||||||
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
||||||
|
|
||||||
|
shape = ('rows', len(images))
|
||||||
if mode == 'truncate':
|
if mode == 'truncate':
|
||||||
# Ordered content: always show from the top. Deliberately does not
|
# Ordered content: always show from the top. Deliberately does not
|
||||||
# advance the offset, so the same opening items appear every time
|
# advance the offset, so the same opening items appear every time
|
||||||
# rather than the viewer being shown the middle of a ranked list.
|
# rather than the viewer being shown the middle of a ranked list.
|
||||||
start = 0
|
start = 0
|
||||||
else:
|
else:
|
||||||
start = self._item_offsets.get(plugin_id, 0) % len(images)
|
start = self._resume_offset(plugin_id, shape) % len(images)
|
||||||
selected: List[Image.Image] = []
|
selected: List[Image.Image] = []
|
||||||
used = 0
|
used = 0
|
||||||
consumed = 0
|
consumed = 0
|
||||||
|
|
||||||
# Walk forward from the rotation offset, taking whole items only, so a
|
# Walk forward from the rotation offset, taking whole items only, so a
|
||||||
# cut never lands in the middle of one.
|
# 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)):
|
for step in range(len(images)):
|
||||||
img = images[(start + step) % len(images)]
|
img = images[(start + step) % len(images)]
|
||||||
cost = img.width
|
cost = img.width
|
||||||
if selected:
|
if selected:
|
||||||
cost += self._row_gap(selected[-1], img)
|
cost += self._row_gap(selected[-1], img)
|
||||||
if selected and used + cost > budget:
|
if selected and used + cost > budget:
|
||||||
break
|
# 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
|
||||||
selected.append(img)
|
selected.append(img)
|
||||||
used += cost
|
used += cost
|
||||||
consumed += 1
|
consumed += 1
|
||||||
@@ -472,7 +585,8 @@ class PluginAdapter:
|
|||||||
plugin_id, budget, len(selected), len(images), used
|
plugin_id, budget, len(selected), len(images), used
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._item_offsets[plugin_id] = (start + consumed) % len(images)
|
self._record_offset(
|
||||||
|
plugin_id, (start + consumed) % len(images), shape)
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
||||||
"from offset %d; remainder deferred to a later cycle",
|
"from offset %d; remainder deferred to a later cycle",
|
||||||
@@ -490,16 +604,13 @@ class PluginAdapter:
|
|||||||
|
|
||||||
The cut is snapped to the nearest blank column so it does not slice
|
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.
|
through a glyph or logo and leave half a character at the panel edge.
|
||||||
"""
|
|
||||||
if mode == 'truncate':
|
|
||||||
# Always the start of the strip, so a ranked table is never entered
|
|
||||||
# from the middle.
|
|
||||||
offset = 0
|
|
||||||
else:
|
|
||||||
offset = self._item_offsets.get(plugin_id, 0)
|
|
||||||
if offset >= img.width:
|
|
||||||
offset = 0
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
# Cut only where the plugin left a real gap between items. Snapping to
|
# Cut only where the plugin left a real gap between items. Snapping to
|
||||||
# any blank column used to pick the single-column gaps between
|
# any blank column used to pick the single-column gaps between
|
||||||
# characters, splitting a word and orphaning its tail into the next
|
# characters, splitting a word and orphaning its tail into the next
|
||||||
@@ -514,9 +625,17 @@ class PluginAdapter:
|
|||||||
# budget exactly. The gap rule exists to protect discrete items
|
# budget exactly. The gap rule exists to protect discrete items
|
||||||
# (words, ticker entries); it would be wrong to let a solid image
|
# (words, ticker entries); it would be wrong to let a solid image
|
||||||
# escape the cap in its name.
|
# escape the cap in its name.
|
||||||
end = min(offset + budget, img.width)
|
#
|
||||||
|
# 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)
|
||||||
if mode != 'truncate':
|
if mode != 'truncate':
|
||||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
self._record_offset(
|
||||||
|
plugin_id, 0 if end >= img.width else end, shape)
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] Width budget %dpx: cropped continuous %dpx image to "
|
"[%s] Width budget %dpx: cropped continuous %dpx image to "
|
||||||
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
|
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
|
||||||
@@ -528,8 +647,15 @@ class PluginAdapter:
|
|||||||
# Cut mid-gap so the content either side keeps some breathing room.
|
# 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})
|
cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps})
|
||||||
|
|
||||||
start = max((c for c in cuts if c <= offset), default=0)
|
shape = ('cuts', len(cuts))
|
||||||
later = [c for c in cuts if c > start]
|
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:]
|
||||||
if not later:
|
if not later:
|
||||||
end = img.width
|
end = img.width
|
||||||
else:
|
else:
|
||||||
@@ -537,15 +663,22 @@ class PluginAdapter:
|
|||||||
# No boundary inside the budget: take the next one and overrun,
|
# No boundary inside the budget: take the next one and overrun,
|
||||||
# because the alternative is cutting through an item.
|
# because the alternative is cutting through an item.
|
||||||
end = max(within) if within else min(later)
|
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':
|
if mode != 'truncate':
|
||||||
# Next cycle resumes where this one stopped; wrap when the strip ends.
|
# Next cycle resumes at the boundary this one stopped on; wrap when
|
||||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
# the strip ends.
|
||||||
|
self._record_offset(
|
||||||
|
plugin_id, 0 if end >= img.width else end_index, shape)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
|
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
|
||||||
"(%dpx) at item boundaries, %s",
|
"(%dpx) at item boundaries %d-%d of %d, %s",
|
||||||
plugin_id, budget, img.width, start, end, end - start,
|
plugin_id, budget, img.width, start, end, end - start,
|
||||||
|
start_index, end_index, len(cuts) - 1,
|
||||||
"showing the start only (overflow=truncate)"
|
"showing the start only (overflow=truncate)"
|
||||||
if mode == 'truncate' else "window advances next cycle"
|
if mode == 'truncate' else "window advances next cycle"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,16 +30,14 @@ def success_response(
|
|||||||
"""
|
"""
|
||||||
response_data = create_success_response(data, message, metadata)
|
response_data = create_success_response(data, message, metadata)
|
||||||
|
|
||||||
# Add request metadata if available
|
# Timing is merged into whatever the caller passed, without inventing a
|
||||||
if metadata is None:
|
# metadata block for responses that have neither.
|
||||||
metadata = {}
|
enriched = dict(metadata) if metadata is not None else {}
|
||||||
|
|
||||||
# Add timing if request start time is available
|
|
||||||
if hasattr(request, 'start_time'):
|
if hasattr(request, 'start_time'):
|
||||||
metadata['response_time_ms'] = int((time.time() - request.start_time) * 1000)
|
enriched['response_time_ms'] = int((time.time() - request.start_time) * 1000)
|
||||||
|
|
||||||
if metadata:
|
if metadata is not None or enriched:
|
||||||
response_data['metadata'] = metadata
|
response_data['metadata'] = enriched
|
||||||
|
|
||||||
return jsonify(response_data)
|
return jsonify(response_data)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Centralized error handling for web interface.
|
|||||||
Provides helpers for consistent error responses across API endpoints.
|
Provides helpers for consistent error responses across API endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
|
|
||||||
@@ -16,6 +17,78 @@ from src.logging_config import get_logger
|
|||||||
logger = get_logger(__name__)
|
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__
|
||||||
|
# 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(
|
def create_error_response(
|
||||||
error_code: ErrorCode,
|
error_code: ErrorCode,
|
||||||
message: str,
|
message: str,
|
||||||
@@ -69,13 +142,16 @@ def create_success_response(
|
|||||||
"status": "success"
|
"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:
|
if data is not None:
|
||||||
response["data"] = data
|
response["data"] = data
|
||||||
|
|
||||||
if message:
|
if message is not None:
|
||||||
response["message"] = message
|
response["message"] = message
|
||||||
|
|
||||||
if metadata:
|
if metadata is not None:
|
||||||
response["metadata"] = metadata
|
response["metadata"] = metadata
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ class WebInterfaceError:
|
|||||||
self.category = category or self._infer_category(error_code)
|
self.category = category or self._infer_category(error_code)
|
||||||
self.details = details
|
self.details = details
|
||||||
self.context = context or {}
|
self.context = context or {}
|
||||||
self.suggested_fixes = suggested_fixes or self._get_default_suggestions(error_code)
|
# `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.original_error = original_error
|
self.original_error = original_error
|
||||||
|
|
||||||
def _infer_category(self, error_code: ErrorCode) -> ErrorCategory:
|
def _infer_category(self, error_code: ErrorCode) -> ErrorCategory:
|
||||||
|
|||||||
@@ -43,10 +43,15 @@ def validate_image_url(url: str) -> Tuple[bool, Optional[str]]:
|
|||||||
if any(handler in url_lower for handler in ['onerror=', 'onload=', 'onclick=']):
|
if any(handler in url_lower for handler in ['onerror=', 'onload=', 'onclick=']):
|
||||||
return False, "Event handlers not allowed in URLs"
|
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 /
|
# Allow relative paths starting with /
|
||||||
if url.startswith('/'):
|
if url.startswith('/'):
|
||||||
# Validate it's a safe relative path (no directory traversal)
|
# // would be a protocol-relative URL, not a local path
|
||||||
if '..' in url or url.startswith('//'):
|
if url.startswith('//'):
|
||||||
return False, "Invalid relative path"
|
return False, "Invalid relative path"
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
@@ -104,10 +109,11 @@ def validate_file_upload(filename: str, max_size_mb: int = 10,
|
|||||||
if '..' in filename or '/' in filename or '\\' in filename:
|
if '..' in filename or '/' in filename or '\\' in filename:
|
||||||
return False, "Filename contains invalid characters"
|
return False, "Filename contains invalid characters"
|
||||||
|
|
||||||
# Check extension if specified
|
# Check extension if specified. Both sides are lowercased: the caller's
|
||||||
|
# list is as likely to hold '.TTF' as the filename is.
|
||||||
if allowed_extensions:
|
if allowed_extensions:
|
||||||
file_ext = Path(filename).suffix.lower()
|
file_ext = Path(filename).suffix.lower()
|
||||||
if file_ext not in allowed_extensions:
|
if file_ext not in [ext.lower() for ext in allowed_extensions]:
|
||||||
return False, f"File extension must be one of: {', '.join(allowed_extensions)}"
|
return False, f"File extension must be one of: {', '.join(allowed_extensions)}"
|
||||||
|
|
||||||
return True, None
|
return True, None
|
||||||
@@ -147,7 +153,8 @@ def validate_numeric_range(value: float, min_val: Optional[float] = None,
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (is_valid, error_message)
|
Tuple of (is_valid, error_message)
|
||||||
"""
|
"""
|
||||||
if not isinstance(value, (int, float)):
|
# bool is an int subclass, so True would otherwise validate as 1.
|
||||||
|
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||||
return False, "Value must be a number"
|
return False, "Value must be a number"
|
||||||
|
|
||||||
if min_val is not None and value < min_val:
|
if min_val is not None and value < min_val:
|
||||||
@@ -183,7 +190,15 @@ def validate_string_length(text: str, min_length: Optional[int] = None,
|
|||||||
|
|
||||||
def sanitize_plugin_config(config: dict) -> dict:
|
def sanitize_plugin_config(config: dict) -> dict:
|
||||||
"""
|
"""
|
||||||
Sanitize plugin configuration input to prevent injection.
|
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.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Configuration dictionary
|
config: Configuration dictionary
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
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}")
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"""
|
||||||
|
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,10 +78,20 @@ class TestInstanceVariable:
|
|||||||
instance_duration=[30])
|
instance_duration=[30])
|
||||||
assert plugin.get_display_duration() == 20.0
|
assert plugin.get_display_duration() == 20.0
|
||||||
|
|
||||||
def test_bool_true_is_one_second(self):
|
def test_bool_true_falls_through_like_any_non_number(self):
|
||||||
# Characterized quirk: bool is an int subclass, so display_duration =
|
# bool is an int subclass, but a boolean is not a duration: True
|
||||||
# True passes the isinstance((int, float)) branch and returns 1.0.
|
# must NOT read as 1 second — it falls through to config/default.
|
||||||
assert make_plugin(instance_duration=True).get_display_duration() == 1.0
|
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
|
||||||
|
|
||||||
|
|
||||||
class TestConfigFallback:
|
class TestConfigFallback:
|
||||||
@@ -108,3 +118,29 @@ class TestConfigFallback:
|
|||||||
|
|
||||||
def test_config_none_uses_default(self):
|
def test_config_none_uses_default(self):
|
||||||
assert make_plugin({"display_duration": None}).get_display_duration() == 15.0
|
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
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""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")
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""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"
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""
|
||||||
|
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,3 +177,159 @@ class TestLoadFastPath:
|
|||||||
os.utime(config_file, ns=(1_000_000_000, 1_000_000_000))
|
os.utime(config_file, ns=(1_000_000_000, 1_000_000_000))
|
||||||
|
|
||||||
assert manager.load_config()["timezone"] == "AAA" # stale, by design
|
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,31 +338,48 @@ class TestDisplayControllerSchedule:
|
|||||||
"""Test schedule management."""
|
"""Test schedule management."""
|
||||||
|
|
||||||
def test_schedule_disabled(self, test_display_controller):
|
def test_schedule_disabled(self, test_display_controller):
|
||||||
"""Test when schedule is disabled."""
|
"""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.)"""
|
||||||
controller = test_display_controller
|
controller = test_display_controller
|
||||||
schedule_config = {"schedule": {"enabled": False}}
|
controller.config['schedule'] = {
|
||||||
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
|
"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
|
||||||
|
|
||||||
controller._check_schedule()
|
controller._check_schedule()
|
||||||
assert controller.is_display_active is True
|
assert controller.is_display_active is True
|
||||||
|
|
||||||
def test_active_hours(self, test_display_controller):
|
def test_active_hours(self, test_display_controller):
|
||||||
"""Test active hours check."""
|
"""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.)"""
|
||||||
controller = test_display_controller
|
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:
|
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.strftime.return_value.lower.return_value = "monday"
|
||||||
mock_datetime.now.return_value.time.return_value = datetime.strptime("12:00", "%H:%M").time()
|
mock_datetime.now.return_value.time.return_value = datetime.strptime("12:00", "%H:%M").time()
|
||||||
mock_datetime.strptime = datetime.strptime
|
mock_datetime.strptime = datetime.strptime
|
||||||
|
|
||||||
schedule_config = {
|
controller._check_schedule()
|
||||||
"schedule": {
|
assert controller.is_display_active is True
|
||||||
"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):
|
def test_inactive_hours(self, test_display_controller):
|
||||||
"""Test inactive hours check."""
|
"""Test inactive hours check."""
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""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,3 +237,45 @@ class TestDisplayManagerDoubleSided:
|
|||||||
suppress_test_pattern=True)
|
suppress_test_pattern=True)
|
||||||
assert dm.set_brightness(70) is True
|
assert dm.set_brightness(70) is True
|
||||||
assert mock_rgb_matrix['matrix_instance'].brightness == 70
|
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'
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
"""
|
||||||
|
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/*"
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""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"}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"""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,3 +878,200 @@ class TestCapabilityExports:
|
|||||||
def test_rotation_strategy_base_requires_a_schedule(self):
|
def test_rotation_strategy_base_requires_a_schedule(self):
|
||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(NotImplementedError):
|
||||||
RotationStrategy().schedule([game("a")])
|
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 == {}
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
"""
|
||||||
|
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']
|
||||||
@@ -0,0 +1,969 @@
|
|||||||
|
"""
|
||||||
|
Tests for src/common/sync_manager.py — the UDP leader/follower protocol
|
||||||
|
that synchronizes scrolling content across two LED matrix displays.
|
||||||
|
|
||||||
|
This module had zero coverage: it only ever appeared in the suite as a
|
||||||
|
MagicMock() stand-in (test_vegas_continuous_refresh.py,
|
||||||
|
test_display_controller_vegas_tick.py), so none of its real framing,
|
||||||
|
handshake, or socket logic was exercised.
|
||||||
|
|
||||||
|
Most tests build the manager via object.__new__() + manual attribute
|
||||||
|
assignment (the test_display_controller_vegas_tick.py bare-stub pattern)
|
||||||
|
so no real sockets open and no background threads start. Receive loops are
|
||||||
|
driven synchronously by once_then_stop(): the mocked socket call returns
|
||||||
|
one crafted packet, then flips _running False and raises socket.timeout,
|
||||||
|
so `while self._running:` exits after exactly one real iteration.
|
||||||
|
|
||||||
|
Regression coverage for three fixed bugs:
|
||||||
|
- Both recv loops' generic `except Exception` retried with no delay, so a
|
||||||
|
socket stuck raising a non-timeout error spun the thread at 100% CPU.
|
||||||
|
- _follower_recv_loop dispatched on `data[:8] == _RAW_MAGIC or
|
||||||
|
len(data) > 512`, which routed any control message over 512 bytes into
|
||||||
|
the image decoder (dropping it) and any raw frame under 512 bytes into
|
||||||
|
the JSON parser.
|
||||||
|
- _oversized_frame_warned was read via getattr(self, ..., False) instead of
|
||||||
|
being initialized in __init__.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.common import sync_manager
|
||||||
|
from src.common.sync_manager import (
|
||||||
|
DisplaySyncManager,
|
||||||
|
FollowerState,
|
||||||
|
LeaderState,
|
||||||
|
SyncRole,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolated_status_file(tmp_path, monkeypatch):
|
||||||
|
# STATUS_FILE is a module-level fixed path under tempfile.gettempdir() —
|
||||||
|
# genuinely shared state between tests and even between processes.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sync_manager, "STATUS_FILE", str(tmp_path / "led_matrix_sync_status.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def make_manager(role=SyncRole.STANDALONE, hw_config=None):
|
||||||
|
"""Bare stub bypassing __init__'s socket/thread setup."""
|
||||||
|
mgr = object.__new__(DisplaySyncManager)
|
||||||
|
mgr.role = role
|
||||||
|
mgr.logger = MagicMock()
|
||||||
|
mgr.port = sync_manager.SYNC_PORT
|
||||||
|
mgr._hw_config = hw_config or {"rows": 32, "cols": 64, "chain_length": 1}
|
||||||
|
|
||||||
|
mgr._leader_state = LeaderState.NO_PEER
|
||||||
|
mgr._peer_ip = None
|
||||||
|
mgr._peer_compatible = False
|
||||||
|
mgr._peer_chain = 0
|
||||||
|
mgr._last_heartbeat_time = 0.0
|
||||||
|
mgr._leader_width = 0
|
||||||
|
mgr._oversized_frame_warned = False
|
||||||
|
|
||||||
|
mgr._follower_state = FollowerState.STANDALONE
|
||||||
|
mgr._latest_frame = None
|
||||||
|
mgr._latest_scroll_x = None
|
||||||
|
mgr._last_leader_frame_time = 0.0
|
||||||
|
mgr._frame_lock = threading.Lock()
|
||||||
|
mgr._leader_ip = None
|
||||||
|
mgr._on_new_cycle = None
|
||||||
|
mgr._on_scroll_image = None
|
||||||
|
mgr._pending_scroll_image = None
|
||||||
|
mgr._scroll_image_lock = threading.Lock()
|
||||||
|
mgr._img_server_sock = None
|
||||||
|
|
||||||
|
mgr._on_follower_connected = None
|
||||||
|
mgr._error_message = None
|
||||||
|
mgr._running = False
|
||||||
|
mgr._recv_sock = None
|
||||||
|
mgr._send_sock = None
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
|
||||||
|
def once_then_stop(mgr, value):
|
||||||
|
"""side_effect returning `value` once, then stopping the enclosing loop."""
|
||||||
|
state = {"served": False}
|
||||||
|
|
||||||
|
def _side_effect(*args, **kwargs):
|
||||||
|
if not state["served"]:
|
||||||
|
state["served"] = True
|
||||||
|
return value
|
||||||
|
mgr._running = False
|
||||||
|
raise socket.timeout()
|
||||||
|
|
||||||
|
return _side_effect
|
||||||
|
|
||||||
|
|
||||||
|
def raise_n_then_stop(mgr, exc, count):
|
||||||
|
"""side_effect raising `exc` `count` times, then stopping the loop."""
|
||||||
|
state = {"n": 0}
|
||||||
|
|
||||||
|
def _side_effect(*args, **kwargs):
|
||||||
|
state["n"] += 1
|
||||||
|
if state["n"] <= count:
|
||||||
|
raise exc
|
||||||
|
mgr._running = False
|
||||||
|
raise socket.timeout()
|
||||||
|
|
||||||
|
return _side_effect
|
||||||
|
|
||||||
|
|
||||||
|
def fake_clock(monkeypatch, *, time_fn=None, sleep_fn=None):
|
||||||
|
"""Swap sync_manager's own `time` reference for a private stand-in.
|
||||||
|
|
||||||
|
sync_manager.time IS the stdlib module, so patching attributes on it
|
||||||
|
would freeze the clock and no-op sleep for the whole process —
|
||||||
|
including the daemon threads earlier tests left running, which is a
|
||||||
|
hard-to-trace source of cross-test flakiness. Rebinding the module's
|
||||||
|
reference keeps the patch scoped to the code under test. Anything not
|
||||||
|
overridden falls through to the real functions.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(sync_manager, "time", SimpleNamespace(
|
||||||
|
time=time_fn or time.time,
|
||||||
|
sleep=sleep_fn or time.sleep,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def run_watchdog_once(monkeypatch, mgr, watchdog, now):
|
||||||
|
"""Run exactly one watchdog iteration at a frozen wall-clock time."""
|
||||||
|
fake_clock(monkeypatch,
|
||||||
|
time_fn=lambda: now,
|
||||||
|
sleep_fn=lambda _: setattr(mgr, "_running", False))
|
||||||
|
mgr._running = True
|
||||||
|
watchdog()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
"""Minimal TCP connection stand-in whose recv() drains a byte buffer."""
|
||||||
|
|
||||||
|
def __init__(self, payload: bytes):
|
||||||
|
self._buf = payload
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def settimeout(self, _):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def recv(self, n):
|
||||||
|
chunk, self._buf = self._buf[:n], self._buf[n:]
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def png_bytes(size=(10, 10), color=(1, 2, 3)) -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", size, color).save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def raw_frame_packet(width, height, color=(10, 20, 30)) -> bytes:
|
||||||
|
arr = np.asarray(Image.new("RGB", (width, height), color), dtype=np.uint8)
|
||||||
|
return _magic_header(width, height) + arr.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _magic_header(width, height) -> bytes:
|
||||||
|
return sync_manager._RAW_MAGIC + sync_manager._RAW_HEADER.pack(width, height)
|
||||||
|
|
||||||
|
|
||||||
|
def length_prefixed(payload: bytes) -> bytes:
|
||||||
|
return len(payload).to_bytes(4, "big") + payload
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoleParsing:
|
||||||
|
def test_leader_role(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(DisplaySyncManager, "_start_leader", lambda self: None)
|
||||||
|
assert DisplaySyncManager("leader", {}, {}, MagicMock()).role is SyncRole.LEADER
|
||||||
|
|
||||||
|
def test_follower_role(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(DisplaySyncManager, "_start_follower", lambda self: None)
|
||||||
|
assert DisplaySyncManager("follower", {}, {}, MagicMock()).role is SyncRole.FOLLOWER
|
||||||
|
|
||||||
|
def test_standalone_starts_nothing(self):
|
||||||
|
mgr = DisplaySyncManager("standalone", {}, {}, MagicMock())
|
||||||
|
assert mgr.role is SyncRole.STANDALONE
|
||||||
|
assert mgr._running is False
|
||||||
|
assert mgr._recv_sock is None
|
||||||
|
|
||||||
|
def test_invalid_role_warns_and_falls_back(self):
|
||||||
|
logger = MagicMock()
|
||||||
|
assert DisplaySyncManager("bogus", {}, {}, logger).role is SyncRole.STANDALONE
|
||||||
|
assert logger.warning.called
|
||||||
|
|
||||||
|
def test_role_matching_is_case_sensitive(self):
|
||||||
|
# Pinned: SyncRole's values are lowercase, so "LEADER" is not
|
||||||
|
# normalized — it is simply invalid and falls back to standalone.
|
||||||
|
logger = MagicMock()
|
||||||
|
assert DisplaySyncManager("LEADER", {}, {}, logger).role is SyncRole.STANDALONE
|
||||||
|
assert logger.warning.called
|
||||||
|
|
||||||
|
def test_port_defaults_to_module_constant(self):
|
||||||
|
assert DisplaySyncManager("standalone", {}, {}, MagicMock()).port == sync_manager.SYNC_PORT
|
||||||
|
|
||||||
|
def test_port_read_from_config(self):
|
||||||
|
assert DisplaySyncManager("standalone", {"port": 9999}, {}, MagicMock()).port == 9999
|
||||||
|
|
||||||
|
def test_oversized_frame_warned_initialized_in_init(self, monkeypatch):
|
||||||
|
# Regression: this attribute was only ever created on first use via
|
||||||
|
# getattr(self, '_oversized_frame_warned', False).
|
||||||
|
monkeypatch.setattr(DisplaySyncManager, "_start_leader", lambda self: None)
|
||||||
|
mgr = DisplaySyncManager("leader", {}, {}, MagicMock())
|
||||||
|
assert mgr._oversized_frame_warned is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleHello:
|
||||||
|
def test_matching_panels_connect(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._send_sock = MagicMock()
|
||||||
|
mgr._handle_hello({"t": "hello", "rows": 32, "cols": 64, "chain": 3}, "10.0.0.5")
|
||||||
|
assert mgr._leader_state is LeaderState.CONNECTED
|
||||||
|
assert mgr._peer_ip == "10.0.0.5"
|
||||||
|
assert mgr._peer_compatible is True
|
||||||
|
assert mgr._peer_chain == 3
|
||||||
|
assert mgr._error_message is None
|
||||||
|
|
||||||
|
def test_ack_reports_compatibility(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._send_sock = MagicMock()
|
||||||
|
mgr._leader_width = 128
|
||||||
|
mgr._handle_hello({"t": "hello", "rows": 32, "cols": 64, "chain": 1}, "10.0.0.5")
|
||||||
|
payload, dest = mgr._send_sock.sendto.call_args[0]
|
||||||
|
ack = json.loads(payload.decode("utf-8"))
|
||||||
|
assert ack["compatible"] is True
|
||||||
|
assert ack["leader_width"] == 128
|
||||||
|
assert dest == ("10.0.0.5", mgr.port)
|
||||||
|
|
||||||
|
def test_mismatched_panels_are_incompatible(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._send_sock = MagicMock()
|
||||||
|
mgr._handle_hello({"t": "hello", "rows": 16, "cols": 32, "chain": 1}, "10.0.0.5")
|
||||||
|
assert mgr._leader_state is LeaderState.INCOMPATIBLE
|
||||||
|
assert "Incompatible panels" in mgr._error_message
|
||||||
|
ack = json.loads(mgr._send_sock.sendto.call_args[0][0].decode("utf-8"))
|
||||||
|
assert ack["compatible"] is False
|
||||||
|
assert ack["error"] == mgr._error_message
|
||||||
|
|
||||||
|
def test_chain_length_may_differ(self):
|
||||||
|
# Documented rule: rows/cols must match, chain_length need not.
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER, hw_config={"rows": 32, "cols": 64, "chain_length": 1})
|
||||||
|
mgr._send_sock = MagicMock()
|
||||||
|
mgr._handle_hello({"t": "hello", "rows": 32, "cols": 64, "chain": 4}, "10.0.0.5")
|
||||||
|
assert mgr._leader_state is LeaderState.CONNECTED
|
||||||
|
|
||||||
|
def test_connect_callback_fires_only_on_first_transition(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._send_sock = MagicMock()
|
||||||
|
fired = threading.Event()
|
||||||
|
calls = []
|
||||||
|
mgr._on_follower_connected = lambda: (calls.append(1), fired.set())
|
||||||
|
|
||||||
|
hello = {"t": "hello", "rows": 32, "cols": 64, "chain": 1}
|
||||||
|
mgr._handle_hello(hello, "10.0.0.5")
|
||||||
|
assert fired.wait(timeout=1)
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
fired.clear()
|
||||||
|
mgr._handle_hello(hello, "10.0.0.5") # already CONNECTED
|
||||||
|
assert not fired.wait(timeout=0.2)
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
def test_ack_send_failure_is_swallowed(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._send_sock = MagicMock()
|
||||||
|
mgr._send_sock.sendto.side_effect = OSError("network unreachable")
|
||||||
|
mgr._handle_hello({"t": "hello", "rows": 32, "cols": 64, "chain": 1}, "10.0.0.5")
|
||||||
|
assert mgr._leader_state is LeaderState.CONNECTED # state still updated
|
||||||
|
assert mgr.logger.debug.called
|
||||||
|
|
||||||
|
|
||||||
|
class TestWatchdogs:
|
||||||
|
def test_leader_drops_peer_after_heartbeat_timeout(self, monkeypatch):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._leader_state = LeaderState.CONNECTED
|
||||||
|
mgr._peer_ip = "10.0.0.1"
|
||||||
|
mgr._peer_compatible = True
|
||||||
|
mgr._last_heartbeat_time = 0.0
|
||||||
|
run_watchdog_once(monkeypatch, mgr, mgr._leader_watchdog,
|
||||||
|
now=sync_manager.PEER_TIMEOUT + 1)
|
||||||
|
assert mgr._leader_state is LeaderState.NO_PEER
|
||||||
|
assert mgr._peer_ip is None
|
||||||
|
assert mgr._peer_compatible is False
|
||||||
|
|
||||||
|
def test_leader_keeps_peer_within_timeout(self, monkeypatch):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._leader_state = LeaderState.CONNECTED
|
||||||
|
mgr._peer_ip = "10.0.0.1"
|
||||||
|
mgr._last_heartbeat_time = 100.0
|
||||||
|
run_watchdog_once(monkeypatch, mgr, mgr._leader_watchdog, now=101.0)
|
||||||
|
assert mgr._leader_state is LeaderState.CONNECTED
|
||||||
|
assert mgr._peer_ip == "10.0.0.1"
|
||||||
|
|
||||||
|
def test_leader_watchdog_ignores_disconnected_state(self, monkeypatch):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._leader_state = LeaderState.INCOMPATIBLE
|
||||||
|
mgr._last_heartbeat_time = 0.0
|
||||||
|
run_watchdog_once(monkeypatch, mgr, mgr._leader_watchdog, now=10_000)
|
||||||
|
assert mgr._leader_state is LeaderState.INCOMPATIBLE
|
||||||
|
|
||||||
|
def test_follower_returns_to_standalone_after_frame_timeout(self, monkeypatch):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._follower_state = FollowerState.FOLLOWER
|
||||||
|
mgr._last_leader_frame_time = 0.0
|
||||||
|
mgr._latest_frame = Image.new("RGB", (2, 2))
|
||||||
|
run_watchdog_once(monkeypatch, mgr, mgr._follower_watchdog,
|
||||||
|
now=sync_manager.LEADER_TIMEOUT + 1)
|
||||||
|
assert mgr._follower_state is FollowerState.STANDALONE
|
||||||
|
assert mgr.get_latest_frame() is None
|
||||||
|
|
||||||
|
def test_follower_keeps_frames_within_timeout(self, monkeypatch):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._follower_state = FollowerState.FOLLOWER
|
||||||
|
mgr._last_leader_frame_time = 100.0
|
||||||
|
mgr._latest_frame = Image.new("RGB", (2, 2))
|
||||||
|
run_watchdog_once(monkeypatch, mgr, mgr._follower_watchdog, now=101.0)
|
||||||
|
assert mgr._follower_state is FollowerState.FOLLOWER
|
||||||
|
assert mgr.get_latest_frame() is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestLeaderRecvLoop:
|
||||||
|
def _drive(self, mgr, payload, sender="10.0.0.8"):
|
||||||
|
mgr._recv_sock = MagicMock()
|
||||||
|
mgr._recv_sock.recvfrom.side_effect = once_then_stop(mgr, (payload, (sender, 1)))
|
||||||
|
mgr._running = True
|
||||||
|
mgr._leader_recv_loop()
|
||||||
|
|
||||||
|
def test_hello_is_dispatched(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._send_sock = MagicMock()
|
||||||
|
self._drive(mgr, json.dumps(
|
||||||
|
{"t": "hello", "rows": 32, "cols": 64, "chain": 1}).encode())
|
||||||
|
assert mgr._leader_state is LeaderState.CONNECTED
|
||||||
|
assert mgr._peer_ip == "10.0.0.8"
|
||||||
|
|
||||||
|
def test_heartbeat_from_known_peer_refreshes_timer(self, monkeypatch):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._peer_ip = "10.0.0.8"
|
||||||
|
fake_clock(monkeypatch, time_fn=lambda: 12345.0)
|
||||||
|
self._drive(mgr, json.dumps({"t": "hb"}).encode())
|
||||||
|
assert mgr._last_heartbeat_time == 12345.0
|
||||||
|
|
||||||
|
def test_heartbeat_from_stranger_is_ignored(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._peer_ip = "10.0.0.8"
|
||||||
|
mgr._last_heartbeat_time = 5.0
|
||||||
|
self._drive(mgr, json.dumps({"t": "hb"}).encode(), sender="10.0.0.99")
|
||||||
|
assert mgr._last_heartbeat_time == 5.0
|
||||||
|
|
||||||
|
def test_unknown_message_type_ignored(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
self._drive(mgr, json.dumps({"t": "who-knows"}).encode())
|
||||||
|
assert mgr._leader_state is LeaderState.NO_PEER
|
||||||
|
|
||||||
|
def test_malformed_json_is_swallowed(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
self._drive(mgr, b"{not json")
|
||||||
|
assert mgr._leader_state is LeaderState.NO_PEER
|
||||||
|
|
||||||
|
def test_undecodable_bytes_are_swallowed(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
self._drive(mgr, b"\xff\xfe\x00bad")
|
||||||
|
assert mgr._leader_state is LeaderState.NO_PEER
|
||||||
|
|
||||||
|
def test_backs_off_between_repeated_errors(self, monkeypatch):
|
||||||
|
# Regression: without a sleep this loop spun at 100% CPU whenever
|
||||||
|
# the socket raised a non-timeout error on every call.
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._recv_sock = MagicMock()
|
||||||
|
mgr._recv_sock.recvfrom.side_effect = raise_n_then_stop(mgr, OSError("boom"), 3)
|
||||||
|
sleeps = MagicMock()
|
||||||
|
fake_clock(monkeypatch, sleep_fn=sleeps)
|
||||||
|
mgr._running = True
|
||||||
|
mgr._leader_recv_loop()
|
||||||
|
assert sleeps.call_count == 3
|
||||||
|
sleeps.assert_called_with(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFollowerRecvLoop:
|
||||||
|
def _drive(self, mgr, payload, sender="10.0.0.2"):
|
||||||
|
mgr._recv_sock = MagicMock()
|
||||||
|
mgr._recv_sock.recvfrom.side_effect = once_then_stop(mgr, (payload, (sender, 1)))
|
||||||
|
mgr._running = True
|
||||||
|
mgr._follower_recv_loop()
|
||||||
|
|
||||||
|
def test_small_raw_frame_is_decoded(self):
|
||||||
|
# Regression: a raw frame under the old 512-byte threshold was sent
|
||||||
|
# to the JSON parser and dropped.
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
packet = raw_frame_packet(4, 3)
|
||||||
|
assert len(packet) <= 512
|
||||||
|
self._drive(mgr, packet)
|
||||||
|
frame = mgr.get_latest_frame()
|
||||||
|
assert frame is not None and frame.size == (4, 3)
|
||||||
|
assert mgr._follower_state is FollowerState.FOLLOWER
|
||||||
|
|
||||||
|
def test_large_raw_frame_is_decoded(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
packet = raw_frame_packet(64, 32)
|
||||||
|
assert len(packet) > 512
|
||||||
|
self._drive(mgr, packet)
|
||||||
|
assert mgr.get_latest_frame().size == (64, 32)
|
||||||
|
|
||||||
|
def test_large_control_message_is_not_routed_to_image_decode(self):
|
||||||
|
# Regression: the old `len(data) > 512` branch treated any large
|
||||||
|
# control message as frame data and silently discarded it.
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
long_error = "x" * 600
|
||||||
|
payload = json.dumps(
|
||||||
|
{"t": "hello_ack", "compatible": False, "error": long_error}).encode()
|
||||||
|
assert len(payload) > 512
|
||||||
|
self._drive(mgr, payload, sender="10.0.0.9")
|
||||||
|
assert mgr._leader_ip == "10.0.0.9"
|
||||||
|
assert mgr._peer_compatible is False
|
||||||
|
assert mgr._error_message == long_error
|
||||||
|
assert mgr.get_latest_frame() is None
|
||||||
|
assert mgr.logger.error.called
|
||||||
|
|
||||||
|
def test_legacy_png_frame_without_magic_is_decoded(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
self._drive(mgr, png_bytes(size=(5, 5)))
|
||||||
|
frame = mgr.get_latest_frame()
|
||||||
|
assert frame is not None and frame.size == (5, 5)
|
||||||
|
assert mgr._follower_state is FollowerState.FOLLOWER
|
||||||
|
|
||||||
|
def test_truncated_raw_frame_is_swallowed(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
self._drive(mgr, _magic_header(64, 32) + b"\x00" * 10) # far too short
|
||||||
|
assert mgr.get_latest_frame() is None
|
||||||
|
assert mgr.logger.debug.called
|
||||||
|
|
||||||
|
def test_garbage_payload_is_swallowed(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
self._drive(mgr, b"neither json nor a png, just bytes 1234567890")
|
||||||
|
assert mgr.get_latest_frame() is None
|
||||||
|
|
||||||
|
def test_hello_ack_updates_peer_state(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
self._drive(mgr, json.dumps(
|
||||||
|
{"t": "hello_ack", "compatible": True, "error": None}).encode(),
|
||||||
|
sender="10.0.0.6")
|
||||||
|
assert mgr._leader_ip == "10.0.0.6"
|
||||||
|
assert mgr._peer_compatible is True
|
||||||
|
assert mgr.logger.error.called is False
|
||||||
|
|
||||||
|
def test_scroll_x_switches_to_follower_and_builds_cycle(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
calls = []
|
||||||
|
mgr._on_new_cycle = lambda: calls.append(1)
|
||||||
|
self._drive(mgr, json.dumps({"t": "sx", "x": 12.34}).encode())
|
||||||
|
assert mgr._follower_state is FollowerState.FOLLOWER
|
||||||
|
assert mgr.get_latest_scroll_x() == 12.34
|
||||||
|
assert calls == [1]
|
||||||
|
|
||||||
|
def test_scroll_x_while_already_following_does_not_rebuild(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._follower_state = FollowerState.FOLLOWER
|
||||||
|
calls = []
|
||||||
|
mgr._on_new_cycle = lambda: calls.append(1)
|
||||||
|
self._drive(mgr, json.dumps({"t": "sx", "x": 5.0}).encode())
|
||||||
|
assert mgr.get_latest_scroll_x() == 5.0
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
def test_new_cycle_message_triggers_callback(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._follower_state = FollowerState.FOLLOWER
|
||||||
|
calls = []
|
||||||
|
mgr._on_new_cycle = lambda: calls.append(1)
|
||||||
|
self._drive(mgr, json.dumps({"t": "nc"}).encode())
|
||||||
|
assert calls == [1]
|
||||||
|
|
||||||
|
def test_non_object_json_does_not_reach_the_outer_handler(self):
|
||||||
|
# A bare JSON scalar parses, then msg.get() raises AttributeError.
|
||||||
|
# That has to be caught here so the payload still gets its shot at
|
||||||
|
# the legacy-PNG fallback; escaping to the outer handler would also
|
||||||
|
# charge one malformed packet the 0.1s error backoff.
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
sleeps = MagicMock()
|
||||||
|
with patch.object(sync_manager, "time",
|
||||||
|
SimpleNamespace(time=time.time, sleep=sleeps)):
|
||||||
|
self._drive(mgr, b"12345")
|
||||||
|
assert mgr.get_latest_frame() is None
|
||||||
|
sleeps.assert_not_called()
|
||||||
|
|
||||||
|
def test_non_numeric_scroll_x_does_not_reach_the_outer_handler(self):
|
||||||
|
# float("a") raises ValueError; {"x": null} raises TypeError.
|
||||||
|
for payload in ({"t": "sx", "x": "a"}, {"t": "sx", "x": None}):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
sleeps = MagicMock()
|
||||||
|
with patch.object(sync_manager, "time",
|
||||||
|
SimpleNamespace(time=time.time, sleep=sleeps)):
|
||||||
|
self._drive(mgr, json.dumps(payload).encode())
|
||||||
|
assert mgr.get_latest_scroll_x() is None
|
||||||
|
sleeps.assert_not_called()
|
||||||
|
|
||||||
|
def test_callback_failure_is_not_mistaken_for_a_malformed_packet(self, monkeypatch):
|
||||||
|
# A payload that parses is a control message, full stop. If the
|
||||||
|
# callback it triggers raises one of the types the field guard
|
||||||
|
# catches, that fault belongs to the callback: it must not send
|
||||||
|
# the packet to the image decoder, which would report it as a
|
||||||
|
# decode error and bury the real cause. The loop still survives
|
||||||
|
# it — the outer handler catches it like any other fault.
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._follower_state = FollowerState.FOLLOWER
|
||||||
|
|
||||||
|
def boom():
|
||||||
|
raise ValueError("callback is broken")
|
||||||
|
|
||||||
|
mgr._on_new_cycle = boom
|
||||||
|
fake_clock(monkeypatch, sleep_fn=MagicMock())
|
||||||
|
self._drive(mgr, json.dumps({"t": "nc"}).encode())
|
||||||
|
|
||||||
|
logged = " | ".join(str(c) for c in mgr.logger.debug.call_args_list)
|
||||||
|
assert "callback is broken" in logged
|
||||||
|
assert "frame decode error" not in logged
|
||||||
|
assert "malformed control message" not in logged
|
||||||
|
|
||||||
|
def test_oversized_legacy_frame_is_rejected_before_decode(self, monkeypatch):
|
||||||
|
# The UDP path is reachable by any host on the LAN, so it caps
|
||||||
|
# dimensions before load() just as the TCP image server does.
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
|
||||||
|
class Huge:
|
||||||
|
width, height = 10, sync_manager._MAX_FRAME_H + 1
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
raise AssertionError("load() must not run past the cap")
|
||||||
|
|
||||||
|
# Rebind the module's reference rather than mutating PIL.Image
|
||||||
|
# itself, which would hand Huge() to every caller in the process
|
||||||
|
# — including daemon threads earlier tests left running. Same
|
||||||
|
# reasoning as fake_clock above. The other names the receive loop
|
||||||
|
# reads off this reference pass through to the real module.
|
||||||
|
monkeypatch.setattr(sync_manager, "Image", SimpleNamespace(
|
||||||
|
open=lambda *a, **kw: Huge(),
|
||||||
|
frombuffer=Image.frombuffer,
|
||||||
|
DecompressionBombError=Image.DecompressionBombError,
|
||||||
|
))
|
||||||
|
self._drive(mgr, b"\x89PNG not really but not JSON either")
|
||||||
|
assert mgr.get_latest_frame() is None
|
||||||
|
|
||||||
|
def test_scroll_x_missing_key_is_swallowed(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
self._drive(mgr, json.dumps({"t": "sx"}).encode()) # no "x"
|
||||||
|
assert mgr.get_latest_scroll_x() is None
|
||||||
|
|
||||||
|
def test_backs_off_between_repeated_errors(self, monkeypatch):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._recv_sock = MagicMock()
|
||||||
|
mgr._recv_sock.recvfrom.side_effect = raise_n_then_stop(mgr, OSError("boom"), 3)
|
||||||
|
sleeps = MagicMock()
|
||||||
|
fake_clock(monkeypatch, sleep_fn=sleeps)
|
||||||
|
mgr._running = True
|
||||||
|
mgr._follower_recv_loop()
|
||||||
|
assert sleeps.call_count == 3
|
||||||
|
sleeps.assert_called_with(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendFrame:
|
||||||
|
def _connected_leader(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._leader_state = LeaderState.CONNECTED
|
||||||
|
mgr._peer_ip = "10.0.0.1"
|
||||||
|
mgr._send_sock = MagicMock()
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
def test_frame_sent_with_magic_header(self):
|
||||||
|
mgr = self._connected_leader()
|
||||||
|
mgr.send_frame(Image.new("RGB", (8, 8)))
|
||||||
|
packet = mgr._send_sock.sendto.call_args[0][0]
|
||||||
|
assert packet[:8] == sync_manager._RAW_MAGIC
|
||||||
|
assert sync_manager._RAW_HEADER.unpack(packet[8:12]) == (8, 8)
|
||||||
|
|
||||||
|
def test_oversized_frame_warns_once_and_is_dropped(self):
|
||||||
|
mgr = self._connected_leader()
|
||||||
|
big = Image.new("RGB", (300, 300)) # 270000 bytes > 65000 UDP cap
|
||||||
|
|
||||||
|
mgr.send_frame(big)
|
||||||
|
assert mgr._oversized_frame_warned is True
|
||||||
|
assert mgr.logger.warning.call_count == 1
|
||||||
|
assert not mgr._send_sock.sendto.called
|
||||||
|
|
||||||
|
mgr.send_frame(big)
|
||||||
|
assert mgr.logger.warning.call_count == 1 # still warned only once
|
||||||
|
|
||||||
|
def test_not_sent_when_no_peer(self):
|
||||||
|
mgr = self._connected_leader()
|
||||||
|
mgr._leader_state = LeaderState.NO_PEER
|
||||||
|
mgr.send_frame(Image.new("RGB", (8, 8)))
|
||||||
|
assert not mgr._send_sock.sendto.called
|
||||||
|
|
||||||
|
def test_follower_never_sends(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._send_sock = MagicMock()
|
||||||
|
mgr.send_frame(Image.new("RGB", (8, 8)))
|
||||||
|
assert not mgr._send_sock.sendto.called
|
||||||
|
|
||||||
|
def test_send_error_is_swallowed(self):
|
||||||
|
mgr = self._connected_leader()
|
||||||
|
mgr._send_sock.sendto.side_effect = OSError("no route")
|
||||||
|
mgr.send_frame(Image.new("RGB", (8, 8))) # must not raise
|
||||||
|
assert mgr.logger.debug.called
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendControlMessages:
|
||||||
|
def _connected_leader(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._leader_state = LeaderState.CONNECTED
|
||||||
|
mgr._peer_ip = "10.0.0.1"
|
||||||
|
mgr._send_sock = MagicMock()
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
def test_send_scroll_x_rounds_to_two_places(self):
|
||||||
|
mgr = self._connected_leader()
|
||||||
|
mgr.send_scroll_x(3.14159)
|
||||||
|
msg = json.loads(mgr._send_sock.sendto.call_args[0][0].decode())
|
||||||
|
assert msg == {"t": "sx", "x": 3.14}
|
||||||
|
|
||||||
|
def test_send_new_cycle(self):
|
||||||
|
mgr = self._connected_leader()
|
||||||
|
mgr.send_new_cycle()
|
||||||
|
msg = json.loads(mgr._send_sock.sendto.call_args[0][0].decode())
|
||||||
|
assert msg == {"t": "nc"}
|
||||||
|
|
||||||
|
def test_control_messages_noop_when_disconnected(self):
|
||||||
|
mgr = self._connected_leader()
|
||||||
|
mgr._leader_state = LeaderState.NO_PEER
|
||||||
|
mgr.send_scroll_x(1.0)
|
||||||
|
mgr.send_new_cycle()
|
||||||
|
assert not mgr._send_sock.sendto.called
|
||||||
|
|
||||||
|
def test_set_leader_width(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr.set_leader_width(256)
|
||||||
|
assert mgr._leader_width == 256
|
||||||
|
|
||||||
|
|
||||||
|
class TestImageServerLoop:
|
||||||
|
def _drive(self, mgr, conn):
|
||||||
|
mgr._img_server_sock = MagicMock()
|
||||||
|
mgr._img_server_sock.accept.side_effect = once_then_stop(
|
||||||
|
mgr, (conn, ("10.0.0.1", 1)))
|
||||||
|
mgr._running = True
|
||||||
|
mgr._image_server_loop()
|
||||||
|
|
||||||
|
def test_rejects_non_positive_length(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._on_scroll_image = MagicMock()
|
||||||
|
self._drive(mgr, FakeConn((0).to_bytes(4, "big")))
|
||||||
|
assert mgr.logger.warning.called
|
||||||
|
mgr._on_scroll_image.assert_not_called()
|
||||||
|
|
||||||
|
def test_rejects_oversized_length(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._on_scroll_image = MagicMock()
|
||||||
|
self._drive(mgr, FakeConn((11 * 1024 * 1024).to_bytes(4, "big")))
|
||||||
|
assert mgr.logger.warning.called
|
||||||
|
mgr._on_scroll_image.assert_not_called()
|
||||||
|
|
||||||
|
def test_rejects_oversized_dimensions(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._on_scroll_image = MagicMock()
|
||||||
|
self._drive(mgr, FakeConn(length_prefixed(png_bytes(size=(300, 300)))))
|
||||||
|
assert mgr.logger.warning.called
|
||||||
|
mgr._on_scroll_image.assert_not_called()
|
||||||
|
|
||||||
|
def test_rejects_decompression_bomb(self, monkeypatch):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._on_scroll_image = MagicMock()
|
||||||
|
|
||||||
|
class BombImage:
|
||||||
|
width = height = 10
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
raise Image.DecompressionBombError("too many pixels")
|
||||||
|
|
||||||
|
monkeypatch.setattr(sync_manager.Image, "open", lambda *a, **kw: BombImage())
|
||||||
|
self._drive(mgr, FakeConn(length_prefixed(png_bytes())))
|
||||||
|
assert mgr.logger.warning.called
|
||||||
|
mgr._on_scroll_image.assert_not_called()
|
||||||
|
|
||||||
|
def test_valid_image_invokes_callback(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
received = []
|
||||||
|
mgr._on_scroll_image = received.append
|
||||||
|
self._drive(mgr, FakeConn(length_prefixed(png_bytes(size=(10, 10)))))
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0].size == (10, 10)
|
||||||
|
|
||||||
|
def test_image_cached_when_callback_not_yet_registered(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._on_scroll_image = None
|
||||||
|
self._drive(mgr, FakeConn(length_prefixed(png_bytes(size=(6, 6)))))
|
||||||
|
assert mgr._pending_scroll_image is not None
|
||||||
|
assert mgr._pending_scroll_image.size == (6, 6)
|
||||||
|
|
||||||
|
def test_short_header_is_skipped(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._on_scroll_image = MagicMock()
|
||||||
|
self._drive(mgr, FakeConn(b"\x00\x01")) # under the 4-byte prefix
|
||||||
|
mgr._on_scroll_image.assert_not_called()
|
||||||
|
|
||||||
|
def test_connection_always_closed(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
conn = FakeConn(length_prefixed(png_bytes()))
|
||||||
|
self._drive(mgr, conn)
|
||||||
|
assert conn.closed is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestScrollImageCallback:
|
||||||
|
def test_pending_image_delivered_on_late_registration(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
img = Image.new("RGB", (3, 3))
|
||||||
|
mgr._pending_scroll_image = img
|
||||||
|
received = []
|
||||||
|
mgr.set_on_scroll_image(received.append)
|
||||||
|
assert received == [img]
|
||||||
|
assert mgr._pending_scroll_image is None
|
||||||
|
|
||||||
|
def test_no_pending_image_means_no_immediate_call(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
received = []
|
||||||
|
mgr.set_on_scroll_image(received.append)
|
||||||
|
assert received == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestFollowerConnectedCallback:
|
||||||
|
def test_fires_immediately_when_already_connected(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._leader_state = LeaderState.CONNECTED
|
||||||
|
fired = threading.Event()
|
||||||
|
mgr.set_on_follower_connected(fired.set)
|
||||||
|
assert fired.wait(timeout=1)
|
||||||
|
|
||||||
|
def test_does_not_fire_when_no_peer(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
fired = threading.Event()
|
||||||
|
mgr.set_on_follower_connected(fired.set)
|
||||||
|
assert not fired.wait(timeout=0.2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendScrollImage:
|
||||||
|
def test_noop_when_not_connected(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._leader_state = LeaderState.NO_PEER
|
||||||
|
with patch.object(sync_manager.socket, "socket") as sock:
|
||||||
|
mgr.send_scroll_image(Image.new("RGB", (4, 4)))
|
||||||
|
sock.assert_not_called()
|
||||||
|
|
||||||
|
def test_noop_for_follower_role(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
with patch.object(sync_manager.socket, "socket") as sock:
|
||||||
|
mgr.send_scroll_image(Image.new("RGB", (4, 4)))
|
||||||
|
sock.assert_not_called()
|
||||||
|
|
||||||
|
def test_sends_length_prefixed_png(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._leader_state = LeaderState.CONNECTED
|
||||||
|
mgr._peer_ip = "10.0.0.1"
|
||||||
|
fake_sock = MagicMock()
|
||||||
|
fake_sock.__enter__ = lambda s: s
|
||||||
|
fake_sock.__exit__ = lambda s, *a: False
|
||||||
|
with patch.object(sync_manager.socket, "socket", return_value=fake_sock):
|
||||||
|
mgr.send_scroll_image(Image.new("RGB", (4, 4)))
|
||||||
|
payload = fake_sock.sendall.call_args[0][0]
|
||||||
|
assert int.from_bytes(payload[:4], "big") == len(payload) - 4
|
||||||
|
assert payload[4:8] == b"\x89PNG"
|
||||||
|
|
||||||
|
def test_connection_error_is_swallowed(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._leader_state = LeaderState.CONNECTED
|
||||||
|
mgr._peer_ip = "10.0.0.1"
|
||||||
|
with patch.object(sync_manager.socket, "socket", side_effect=OSError("refused")):
|
||||||
|
mgr.send_scroll_image(Image.new("RGB", (4, 4))) # must not raise
|
||||||
|
assert mgr.logger.debug.called
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetStatus:
|
||||||
|
def test_standalone_shape(self):
|
||||||
|
status = make_manager(role=SyncRole.STANDALONE).get_status()
|
||||||
|
assert status["role"] == "standalone"
|
||||||
|
assert status["state"] == "standalone"
|
||||||
|
assert status["local_rows"] == 32 and status["local_cols"] == 64
|
||||||
|
|
||||||
|
def test_leader_shape(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._leader_state = LeaderState.CONNECTED
|
||||||
|
mgr._peer_ip = "10.0.0.1"
|
||||||
|
mgr._peer_compatible = True
|
||||||
|
mgr._peer_chain = 2
|
||||||
|
mgr._leader_width = 128
|
||||||
|
status = mgr.get_status()
|
||||||
|
assert status["role"] == "leader"
|
||||||
|
assert status["state"] == "connected"
|
||||||
|
assert status["peer_ip"] == "10.0.0.1"
|
||||||
|
assert status["peer_chain"] == 2
|
||||||
|
assert status["leader_width"] == 128
|
||||||
|
|
||||||
|
def test_follower_shape(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
mgr._follower_state = FollowerState.FOLLOWER
|
||||||
|
mgr._leader_ip = "10.0.0.2"
|
||||||
|
status = mgr.get_status()
|
||||||
|
assert status["role"] == "follower"
|
||||||
|
assert status["state"] == "follower"
|
||||||
|
assert status["leader_ip"] == "10.0.0.2"
|
||||||
|
assert "peer_chain" not in status
|
||||||
|
|
||||||
|
def test_is_follower_active(self):
|
||||||
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
|
assert mgr.is_follower_active() is False
|
||||||
|
mgr._follower_state = FollowerState.FOLLOWER
|
||||||
|
assert mgr.is_follower_active() is True
|
||||||
|
|
||||||
|
def test_leader_is_never_follower_active(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._follower_state = FollowerState.FOLLOWER
|
||||||
|
assert mgr.is_follower_active() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestWriteStatusFile:
|
||||||
|
def test_writes_status_and_cleans_up_temp(self):
|
||||||
|
mgr = make_manager(role=SyncRole.STANDALONE)
|
||||||
|
mgr.write_status_file()
|
||||||
|
data = json.loads(Path(sync_manager.STATUS_FILE).read_text())
|
||||||
|
assert data["role"] == "standalone"
|
||||||
|
assert "ts" in data
|
||||||
|
assert not Path(sync_manager.STATUS_FILE + ".tmp").exists()
|
||||||
|
|
||||||
|
def test_write_failure_is_swallowed(self, monkeypatch):
|
||||||
|
mgr = make_manager(role=SyncRole.STANDALONE)
|
||||||
|
monkeypatch.setattr("builtins.open", MagicMock(side_effect=OSError("disk full")))
|
||||||
|
mgr.write_status_file() # must not raise
|
||||||
|
assert mgr.logger.debug.called
|
||||||
|
|
||||||
|
|
||||||
|
class TestStop:
|
||||||
|
def _stub_with_sockets(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._recv_sock = MagicMock()
|
||||||
|
mgr._send_sock = MagicMock()
|
||||||
|
mgr._img_server_sock = MagicMock()
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
def test_closes_every_socket(self):
|
||||||
|
mgr = self._stub_with_sockets()
|
||||||
|
mgr.stop()
|
||||||
|
assert mgr._running is False
|
||||||
|
mgr._recv_sock.close.assert_called_once()
|
||||||
|
mgr._send_sock.close.assert_called_once()
|
||||||
|
mgr._img_server_sock.close.assert_called_once()
|
||||||
|
|
||||||
|
def test_is_idempotent(self):
|
||||||
|
mgr = self._stub_with_sockets()
|
||||||
|
mgr.stop()
|
||||||
|
mgr.stop() # must not raise
|
||||||
|
|
||||||
|
def test_close_failure_is_swallowed(self):
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr._recv_sock = MagicMock()
|
||||||
|
mgr._recv_sock.close.side_effect = OSError("already closed")
|
||||||
|
mgr.stop() # must not raise
|
||||||
|
assert mgr.logger.debug.called
|
||||||
|
|
||||||
|
def test_handles_unset_sockets(self):
|
||||||
|
make_manager(role=SyncRole.STANDALONE).stop() # all sockets None
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast_available(port):
|
||||||
|
"""True when a UDP broadcast can be sent at all in this environment.
|
||||||
|
|
||||||
|
The handshake below depends on broadcast: the follower announces
|
||||||
|
itself to ("<broadcast>", port), and sync_manager swallows any sendto
|
||||||
|
error. Without this probe, a sandbox or CI network that refuses
|
||||||
|
broadcast would make the test wait out its whole deadline and then
|
||||||
|
fail for a reason that has nothing to do with the code.
|
||||||
|
|
||||||
|
Sending is enough to detect the case that actually occurs — a
|
||||||
|
refusing environment raises here. Confirming *delivery* would mean
|
||||||
|
binding INADDR_ANY to receive, which is a listening socket this suite
|
||||||
|
has no reason to open; a network that accepts the send and silently
|
||||||
|
drops it still reaches the assertion, exactly as before.
|
||||||
|
"""
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
|
sock.sendto(b"probe", ("<broadcast>", port))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealSocketHandshake:
|
||||||
|
def test_leader_and_follower_negotiate_over_real_sockets(self, monkeypatch):
|
||||||
|
# One end-to-end check that the wire format actually round-trips:
|
||||||
|
# every other test drives the loops with mocked sockets.
|
||||||
|
#
|
||||||
|
# Not loopback-only, despite the free-port probe below: the manager
|
||||||
|
# binds UDP and TCP on all interfaces and the follower announces by
|
||||||
|
# broadcast. That is the behaviour under test, so the environment
|
||||||
|
# has to support it.
|
||||||
|
monkeypatch.setattr(sync_manager, "HELLO_INTERVAL", 0.02)
|
||||||
|
monkeypatch.setattr(sync_manager, "HEARTBEAT_INTERVAL", 0.02)
|
||||||
|
|
||||||
|
hw = {"rows": 32, "cols": 64, "chain_length": 1}
|
||||||
|
leader = follower = None
|
||||||
|
# The free-port probe is inherently racy — the port can be taken
|
||||||
|
# between release and rebind — so retry rather than fail on it.
|
||||||
|
for _attempt in range(5):
|
||||||
|
# Probed on loopback: this only needs a port number, and the
|
||||||
|
# manager's own bind is what has to succeed. If the port turns
|
||||||
|
# out to be taken on another interface, the retry below covers
|
||||||
|
# it — same as for the race.
|
||||||
|
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
probe.bind(("127.0.0.1", 0))
|
||||||
|
port = probe.getsockname()[1]
|
||||||
|
probe.close()
|
||||||
|
|
||||||
|
if not _broadcast_available(port):
|
||||||
|
pytest.skip("environment refuses UDP broadcast")
|
||||||
|
|
||||||
|
try:
|
||||||
|
leader = DisplaySyncManager("leader", {"port": port}, hw, MagicMock())
|
||||||
|
follower = DisplaySyncManager("follower", {"port": port}, hw, MagicMock())
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
# Port taken between probe and bind, or the TCP image
|
||||||
|
# server could not bind port+1. Tear down whichever end
|
||||||
|
# came up before retrying with a fresh port.
|
||||||
|
for mgr in (leader, follower):
|
||||||
|
if mgr is not None:
|
||||||
|
mgr.stop()
|
||||||
|
leader = follower = None
|
||||||
|
else:
|
||||||
|
pytest.skip("could not obtain a free port pair for the handshake")
|
||||||
|
|
||||||
|
try:
|
||||||
|
deadline = time.time() + 5.0
|
||||||
|
while time.time() < deadline:
|
||||||
|
if (leader._leader_state is LeaderState.CONNECTED
|
||||||
|
and follower._peer_compatible):
|
||||||
|
break
|
||||||
|
time.sleep(0.02)
|
||||||
|
assert leader._leader_state is LeaderState.CONNECTED
|
||||||
|
assert follower._peer_compatible is True
|
||||||
|
assert follower._leader_ip is not None
|
||||||
|
finally:
|
||||||
|
leader.stop()
|
||||||
|
follower.stop()
|
||||||
@@ -781,6 +781,20 @@ class TestNewConfigKeys:
|
|||||||
assert cfg.render_width_pct == 100
|
assert cfg.render_width_pct == 100
|
||||||
assert cfg.min_content_separation == 24
|
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', [
|
@pytest.mark.parametrize('overrides,bad_key', [
|
||||||
({'render_width_pct': 5}, 'render_width_pct'),
|
({'render_width_pct': 5}, 'render_width_pct'),
|
||||||
({'render_width_pct': 101}, 'render_width_pct'),
|
({'render_width_pct': 101}, 'render_width_pct'),
|
||||||
@@ -1643,3 +1657,228 @@ class TestPerPluginWidthBudget:
|
|||||||
strip = canvas([(0, 5000)], width=5000)
|
strip = canvas([(0, 5000)], width=5000)
|
||||||
adapter.get_content(NativePlugin([strip]), 'ticker')
|
adapter.get_content(NativePlugin([strip]), 'ticker')
|
||||||
assert adapter._item_offsets.get('ticker', 0) > 0
|
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
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""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"]
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""
|
||||||
|
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()) == []
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""
|
||||||
|
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"
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""
|
||||||
|
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": ""})
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
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"
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""
|
||||||
|
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,18 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Drift guard for the duplicated secret-separation logic.
|
Drift guard: api_v3 must use the canonical secret helpers.
|
||||||
|
|
||||||
src/web_interface/secret_helpers.py is the canonical implementation of
|
Historically web_interface/blueprints/api_v3.py carried THREE inline
|
||||||
find_secret_fields / separate_secrets, but web_interface/blueprints/api_v3.py
|
nested-function copies of ``find_secret_fields``/``separate_secrets`` (in the
|
||||||
still carries THREE inline nested-function copies of each (in the plugin
|
main-config save, plugin-config save, and plugin-config reset endpoints).
|
||||||
config GET, POST, and reset endpoints). The copies lack the canonical
|
They lacked the canonical module's array-item secret support and drifted from
|
||||||
module's array-item support (`accounts[].token`), so migrating an endpoint
|
each other. They have been migrated onto
|
||||||
onto the module is a behavior change that must be made deliberately.
|
``src/web_interface/secret_helpers`` — this file now guards against copies
|
||||||
|
REAPPEARING, and keeps the canonical array-item behavior executable.
|
||||||
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
|
import re
|
||||||
@@ -23,77 +18,44 @@ from src.web_interface.secret_helpers import find_secret_fields, separate_secret
|
|||||||
API_V3_PATH = (Path(__file__).resolve().parents[2]
|
API_V3_PATH = (Path(__file__).resolve().parents[2]
|
||||||
/ "web_interface" / "blueprints" / "api_v3.py")
|
/ "web_interface" / "blueprints" / "api_v3.py")
|
||||||
|
|
||||||
# Update DOWNWARD as endpoints migrate onto src/web_interface/secret_helpers.
|
# The migration is complete: any inline reimplementation is a regression.
|
||||||
EXPECTED_INLINE_COPIES = 3
|
EXPECTED_INLINE_COPIES = 0
|
||||||
|
|
||||||
|
|
||||||
class TestInlineCopyCount:
|
class TestNoInlineCopies:
|
||||||
def _count(self, name: str) -> int:
|
def _count(self, name: str) -> int:
|
||||||
source = API_V3_PATH.read_text(encoding="utf-8")
|
source = API_V3_PATH.read_text(encoding="utf-8")
|
||||||
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
|
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
|
||||||
|
|
||||||
def test_find_secret_fields_copy_count(self):
|
def test_no_inline_find_secret_fields(self):
|
||||||
count = self._count("find_secret_fields")
|
count = self._count("find_secret_fields")
|
||||||
assert count == EXPECTED_INLINE_COPIES, (
|
assert count == EXPECTED_INLINE_COPIES, (
|
||||||
f"api_v3.py has {count} inline find_secret_fields definitions, "
|
f"api_v3.py has {count} inline find_secret_fields definitions, "
|
||||||
f"expected {EXPECTED_INLINE_COPIES}. New code must import it from "
|
f"expected {EXPECTED_INLINE_COPIES}. Import it from "
|
||||||
f"src/web_interface/secret_helpers instead of re-implementing it; "
|
f"src/web_interface/secret_helpers instead of re-implementing it."
|
||||||
f"if you migrated an endpoint, lower EXPECTED_INLINE_COPIES."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_separate_secrets_copy_count(self):
|
def test_no_inline_separate_secrets(self):
|
||||||
count = self._count("separate_secrets")
|
count = self._count("separate_secrets")
|
||||||
assert count == EXPECTED_INLINE_COPIES, (
|
assert count == EXPECTED_INLINE_COPIES, (
|
||||||
f"api_v3.py has {count} inline separate_secrets definitions, "
|
f"api_v3.py has {count} inline separate_secrets definitions, "
|
||||||
f"expected {EXPECTED_INLINE_COPIES}. New code must import it from "
|
f"expected {EXPECTED_INLINE_COPIES}. Import it from "
|
||||||
f"src/web_interface/secret_helpers instead of re-implementing it; "
|
f"src/web_interface/secret_helpers instead of re-implementing it."
|
||||||
f"if you migrated an endpoint, lower EXPECTED_INLINE_COPIES."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_inline_copies_lack_array_item_support(self):
|
def test_canonical_import_present(self):
|
||||||
"""The documented gap: no inline copy recurses into array `items`
|
# Tripwire: the endpoints still need the helpers, so removing the
|
||||||
schemas, so array-item secrets (accounts[].token) are NOT routed to
|
# import means either dead secret handling or a new local copy.
|
||||||
config_secrets.json by these endpoints. The canonical module handles
|
source = API_V3_PATH.read_text(encoding="utf-8")
|
||||||
them. When an endpoint migrates onto the module that behavior
|
assert re.search(
|
||||||
changes (a fix, but a deliberate one).
|
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
|
||||||
|
source,
|
||||||
If this fails, an inline copy has grown array support — duplicating
|
), "api_v3.py no longer imports the canonical secret helpers"
|
||||||
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:
|
class TestCanonicalArrayItemBehavior:
|
||||||
"""Executable documentation of what migrating endpoints will change."""
|
"""Executable documentation of the array-item secret contract the
|
||||||
|
endpoints now inherit from the canonical module."""
|
||||||
|
|
||||||
SCHEMA = {
|
SCHEMA = {
|
||||||
"accounts": {
|
"accounts": {
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""
|
||||||
|
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({}) == {}
|
||||||
+33
-3
@@ -16,6 +16,8 @@ from datetime import datetime, timedelta
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from src.config_manager import ConfigManager
|
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.exceptions import ConfigError
|
||||||
from src.plugin_system.plugin_manager import PluginManager
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
from src.plugin_system.store_manager import PluginStoreManager
|
from src.plugin_system.store_manager import PluginStoreManager
|
||||||
@@ -391,15 +393,42 @@ def internal_error(error):
|
|||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('web_interface')
|
logger = logging.getLogger('web_interface')
|
||||||
logger.error("Internal server error", exc_info=True)
|
logger.error("Internal server error", exc_info=True)
|
||||||
return jsonify({
|
payload = {
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'error_code': 'INTERNAL_ERROR',
|
'error_code': 'INTERNAL_ERROR',
|
||||||
'message': 'An internal error occurred; see logs for details',
|
'message': 'An internal error occurred; see logs for details',
|
||||||
}), 500
|
}
|
||||||
|
# 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
|
||||||
|
|
||||||
@app.errorhandler(Exception)
|
@app.errorhandler(Exception)
|
||||||
def handle_exception(error):
|
def handle_exception(error):
|
||||||
"""Handle all unhandled exceptions."""
|
"""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
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('web_interface')
|
logger = logging.getLogger('web_interface')
|
||||||
logger.error("Unhandled exception", exc_info=True)
|
logger.error("Unhandled exception", exc_info=True)
|
||||||
@@ -407,6 +436,7 @@ def handle_exception(error):
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'error_code': 'UNKNOWN_ERROR',
|
'error_code': 'UNKNOWN_ERROR',
|
||||||
'message': 'An error occurred; see logs for details',
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(error),
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
# Captive portal redirect middleware
|
# Captive portal redirect middleware
|
||||||
|
|||||||
+202
-214
File diff suppressed because it is too large
Load Diff
@@ -117,6 +117,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="setting-display-orientation" data-setting-key="display.hardware.orientation">
|
||||||
|
<label for="orientation" class="block text-sm font-medium text-gray-700">Panel Orientation{{ ui.help_tip('Rotates the rendered image to match how the panel is physically mounted.\nUse "Upside Down" if you flipped the panel 180° to move the Raspberry Pi / wiring to a more convenient side.', 'Panel Orientation') }}</label>
|
||||||
|
<select id="orientation" name="orientation" class="form-control">
|
||||||
|
<option value="normal" {% if main_config.display.hardware.get('orientation', 'normal') == "normal" %}selected{% endif %}>Normal</option>
|
||||||
|
<option value="180" {% if main_config.display.hardware.get('orientation', 'normal') == "180" %}selected{% endif %}>Upside Down (180°)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence">
|
<div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence">
|
||||||
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped. Default: RGB.', 'LED RGB Sequence') }}</label>
|
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped. Default: RGB.', 'LED RGB Sequence') }}</label>
|
||||||
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
|
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
|
||||||
@@ -556,11 +564,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
|
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
|
||||||
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (0–20).\nDefault: 3. A long ticker such as a news feed or leaderboard is trimmed to this and the remainder shown on later cycles, so one plugin cannot hold the display for minutes. Set 0 for no limit.', 'Max Plugin Width') }}</label>
|
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (0–20).\nDefault: 0 (no limit) — every plugin shows all of its content and always starts at the beginning.\nSet a limit to stop one long ticker holding the display for minutes: it is cut to this width and the remainder shown on later cycles. The trade-off is that such a plugin then resumes mid-content on each appearance instead of starting fresh.', 'Max Plugin Width') }}</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="vegas_max_plugin_width_ratio"
|
id="vegas_max_plugin_width_ratio"
|
||||||
name="vegas_max_plugin_width_ratio"
|
name="vegas_max_plugin_width_ratio"
|
||||||
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 3.0) }}"
|
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 0.0) }}"
|
||||||
min="0"
|
min="0"
|
||||||
max="20"
|
max="20"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
|
|||||||
Reference in New Issue
Block a user