mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 09:06:36 +00:00
Merge remote-tracking branch 'origin/main' into claude/hdpi-scroll-performance-antialiasing-4ae609
This commit is contained in:
+42
-13
@@ -92,7 +92,7 @@ class TestGet:
|
||||
helper.session.get.assert_not_called()
|
||||
rate_spy.assert_not_called()
|
||||
|
||||
def test_cache_miss_fetches_and_caches_without_ttl(self, helper, cache):
|
||||
def test_cache_miss_fetches_and_caches_with_ttl(self, helper, cache):
|
||||
cache.get.return_value = None
|
||||
helper.session.get = Mock(return_value=_make_response({'a': 1}))
|
||||
|
||||
@@ -100,9 +100,47 @@ class TestGet:
|
||||
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})
|
||||
cache.set.assert_called_once_with('k', {'a': 1}, ttl=999)
|
||||
|
||||
def test_set_cache_passes_ttl(self, helper, cache):
|
||||
helper.set_cache('k', {'a': 1}, ttl=42)
|
||||
cache.set.assert_called_once_with('k', {'a': 1}, ttl=42)
|
||||
|
||||
|
||||
class TestCacheLifetimeWithRealCacheManager:
|
||||
"""cache_ttl decides how long a response is reused, in both directions:
|
||||
past CacheManager's 300-second default read age, and not beyond it."""
|
||||
|
||||
@pytest.fixture
|
||||
def real_cache(self, tmp_path):
|
||||
from unittest.mock import patch
|
||||
from src.cache_manager import CacheManager
|
||||
with patch('src.cache_manager.CacheManager._get_writable_cache_dir',
|
||||
return_value=str(tmp_path)):
|
||||
cache = CacheManager()
|
||||
yield cache
|
||||
# Releases the class-wide cleanup-thread claim on this directory,
|
||||
# which would otherwise leak into test_cache_cleanup_thread_ownership.
|
||||
cache.stop_cleanup_thread()
|
||||
|
||||
def _fetch_twice(self, real_cache, monkeypatch, ttl, elapsed):
|
||||
helper = APIHelper(cache_manager=real_cache)
|
||||
helper.set_rate_limit(0)
|
||||
helper.session.get = Mock(side_effect=[_make_response({'n': 1}),
|
||||
_make_response({'n': 2})])
|
||||
now = [1_000_000.0]
|
||||
monkeypatch.setattr('src.cache.memory_cache.time.time', lambda: now[0])
|
||||
monkeypatch.setattr('src.cache.disk_cache.time.time', lambda: now[0])
|
||||
monkeypatch.setattr('src.cache_manager.time.time', lambda: now[0])
|
||||
helper.get('https://example.com/api', cache_key='lifetime_test', cache_ttl=ttl)
|
||||
now[0] += elapsed
|
||||
return helper.get('https://example.com/api', cache_key='lifetime_test', cache_ttl=ttl)
|
||||
|
||||
def test_long_ttl_outlives_the_default_read_age(self, real_cache, monkeypatch):
|
||||
assert self._fetch_twice(real_cache, monkeypatch, ttl=3600, elapsed=1000) == {'n': 1}
|
||||
|
||||
def test_short_ttl_expires(self, real_cache, monkeypatch):
|
||||
assert self._fetch_twice(real_cache, monkeypatch, ttl=60, elapsed=120) == {'n': 2}
|
||||
|
||||
def test_request_exception_returns_none_and_caches_nothing(
|
||||
self, helper, cache):
|
||||
@@ -223,15 +261,6 @@ class TestClearCache:
|
||||
|
||||
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)
|
||||
|
||||
@@ -408,6 +408,14 @@ class TestAutoEnableApMode:
|
||||
assert response.status_code == 400
|
||||
assert "auto_enable_ap_mode" not in wifi_manager.config
|
||||
|
||||
def test_a_failed_save_is_reported(self, api_v3_client, wifi_manager):
|
||||
# wifi_config.json left owned by root is the usual cause.
|
||||
wifi_manager.config = {}
|
||||
wifi_manager._save_config.return_value = False
|
||||
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": False})
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
|
||||
class TestRadioEnabledAndForceAcceptIntegers:
|
||||
"""`{"enabled": 1}` / `{"enabled": 0}` used to be mishandled: the old
|
||||
|
||||
@@ -362,6 +362,3 @@ class TestPriorityIsAcceptedAndIgnored:
|
||||
rid = service.submit_fetch_request(
|
||||
"nfl", 2026, "http://example.invalid/x", cache_key="k", priority=5)
|
||||
assert service.get_result(rid).cached is True
|
||||
|
||||
def test_statistics_still_report_an_empty_queue(self, service):
|
||||
assert service.get_statistics()["queue_size"] == 0
|
||||
|
||||
@@ -180,6 +180,31 @@ def test_create_backup_manifest(project: Path, tmp_path: Path) -> None:
|
||||
assert set(manifest["contents"]) >= {"config", "secrets", "wifi", "fonts", "plugin_uploads", "plugins"}
|
||||
|
||||
|
||||
def test_manifest_version_is_the_core_release(project: Path, tmp_path: Path) -> None:
|
||||
"""Not a git sha or a truncated "ref: refs/he..." read from .git/HEAD."""
|
||||
from src import __version__
|
||||
git = project / ".git"
|
||||
git.mkdir()
|
||||
(git / "HEAD").write_text("ref: refs/heads/some-branch-that-is-not-there\n", encoding="utf-8")
|
||||
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
manifest = json.loads(zf.read("manifest.json"))
|
||||
assert manifest["ledmatrix_version"] == __version__
|
||||
|
||||
|
||||
def test_installed_plugins_come_from_the_configured_directory(tmp_path: Path) -> None:
|
||||
root = tmp_path / "proj"
|
||||
(root / "config").mkdir(parents=True)
|
||||
(root / "config" / "config.json").write_text(
|
||||
json.dumps({"plugin_system": {"plugins_directory": "plugins"}}), encoding="utf-8")
|
||||
plugin_dir = root / "plugins" / "dev-plugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "manifest.json").write_text(
|
||||
json.dumps({"id": "dev-plugin", "version": "0.3.0"}), encoding="utf-8")
|
||||
|
||||
assert [p["plugin_id"] for p in list_installed_plugins(root)] == ["dev-plugin"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -342,7 +342,6 @@ class TestLoadConfiguration:
|
||||
'base_odds_manager': {
|
||||
'update_interval': 100,
|
||||
'timeout': 5,
|
||||
'cache_ttl': 42,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +351,6 @@ class TestLoadConfiguration:
|
||||
# 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()
|
||||
@@ -362,4 +360,3 @@ class TestLoadConfiguration:
|
||||
|
||||
assert manager.update_interval == 3600
|
||||
assert manager.request_timeout == 5
|
||||
assert manager.cache_ttl == 1800
|
||||
|
||||
@@ -299,3 +299,48 @@ class TestDefaultMerging:
|
||||
|
||||
assert merged["enabled"] is False
|
||||
assert merged["display_duration"] == 60
|
||||
|
||||
|
||||
class TestMissingRequiredFields:
|
||||
"""One message per missing field, naming that field.
|
||||
|
||||
A manual ``required`` loop used to run after Draft7Validator, which already
|
||||
reports ``required``, so every missing top-level field was listed twice --
|
||||
and the validator's copy printed the schema's whole ``required`` list as
|
||||
if it were the field name.
|
||||
"""
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api_key": {"type": "string"},
|
||||
"city": {"type": "string"},
|
||||
"units": {"type": "string"},
|
||||
},
|
||||
"required": ["api_key", "city", "units"],
|
||||
}
|
||||
|
||||
def test_each_missing_field_is_reported_once_by_name(self):
|
||||
ok, errors = SchemaManager().validate_config_against_schema(
|
||||
{"units": "metric"}, self.SCHEMA, "test-plugin")
|
||||
|
||||
assert not ok
|
||||
assert errors == [
|
||||
"Field root: Missing required property 'api_key'",
|
||||
"Field root: Missing required property 'city'",
|
||||
]
|
||||
|
||||
def test_nested_missing_field_names_the_field_and_its_parent(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"nfl": {
|
||||
"type": "object",
|
||||
"properties": {"api_key": {"type": "string"}},
|
||||
"required": ["api_key"],
|
||||
}},
|
||||
}
|
||||
ok, errors = SchemaManager().validate_config_against_schema(
|
||||
{"nfl": {}}, schema, "test-plugin")
|
||||
|
||||
assert not ok
|
||||
assert errors == ["Field 'nfl': Missing required property 'api_key'"]
|
||||
|
||||
@@ -185,3 +185,35 @@ class TestLogoScale:
|
||||
def test_an_unusable_scale_is_ignored(self, logo, bad):
|
||||
helper = LogoHelper(display_width=64, display_height=32)
|
||||
assert helper.load_logo("AAA", logo, 32, 32, scale=bad).size == (32, 32)
|
||||
|
||||
def test_a_scale_the_schema_allows_is_applied(self, logo):
|
||||
"""The Scale field's maximum is honoured, not reset to 1.0."""
|
||||
from src.element_style import MAX_ELEMENT_SCALE
|
||||
helper = LogoHelper(display_width=64, display_height=32)
|
||||
big = helper.load_logo("AAA", logo, 4, 4, scale=MAX_ELEMENT_SCALE)
|
||||
assert big.size == (40, 40)
|
||||
|
||||
def test_a_scale_beyond_the_range_is_clamped(self, logo):
|
||||
from src.element_style import MAX_ELEMENT_SCALE, MIN_ELEMENT_SCALE
|
||||
helper = LogoHelper(display_width=64, display_height=32)
|
||||
assert helper.load_logo("AAA", logo, 4, 4, scale=MAX_ELEMENT_SCALE * 3).size == (40, 40)
|
||||
assert helper.load_logo("AAA", logo, 40, 40, scale=MIN_ELEMENT_SCALE / 2).size == (4, 4)
|
||||
|
||||
|
||||
class TestScaleCoercion:
|
||||
"""One range for the schema, element_scale and LogoHelper."""
|
||||
|
||||
def test_schema_bounds_are_the_clamp_bounds(self):
|
||||
from src.element_style import (MAX_ELEMENT_SCALE, MIN_ELEMENT_SCALE,
|
||||
_offset_block_from_spec)
|
||||
prop = _offset_block_from_spec("home_logo", {"scale": True})["properties"]["scale"]
|
||||
assert (prop["minimum"], prop["maximum"]) == (MIN_ELEMENT_SCALE, MAX_ELEMENT_SCALE)
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
(0.5, 0.5), (25, 10.0), (0.01, 0.1),
|
||||
(0, 1.0), (-2, 1.0), ("x", 1.0), (True, 1.0),
|
||||
(float("nan"), 1.0), (float("inf"), 1.0),
|
||||
])
|
||||
def test_element_scale_clamps_and_rejects(self, raw, expected):
|
||||
cfg = {"customization": {"layout": {"home_logo": {"scale": raw}}}}
|
||||
assert element_scale(cfg, "home_logo") == expected
|
||||
|
||||
@@ -124,6 +124,45 @@ class TestErrorRecording:
|
||||
assert aggregator._plugin_error_counts["plugin-a"]["ValueError"] == 2
|
||||
assert aggregator._plugin_error_counts["plugin-b"]["ValueError"] == 1
|
||||
|
||||
def test_stack_trace_recorded_outside_except_block(self):
|
||||
"""The trace comes from the exception, not from the handler in progress.
|
||||
|
||||
plugin_executor records exceptions caught on a worker thread after
|
||||
its except block has ended, where format_exc() only says
|
||||
"NoneType: None".
|
||||
"""
|
||||
def failing_plugin_update():
|
||||
raise ValueError("boom")
|
||||
|
||||
caught = []
|
||||
|
||||
def worker():
|
||||
try:
|
||||
failing_plugin_update()
|
||||
except ValueError as e:
|
||||
caught.append(e)
|
||||
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
thread.join()
|
||||
|
||||
record = ErrorAggregator().record_error(caught[0], plugin_id="p")
|
||||
|
||||
assert "NoneType: None" not in record.stack_trace
|
||||
assert "failing_plugin_update" in record.stack_trace
|
||||
assert "ValueError: boom" in record.stack_trace
|
||||
|
||||
def test_record_error_leaves_caller_context_unchanged(self):
|
||||
"""LEDMatrixError context is merged into a copy of the caller's dict."""
|
||||
context = {"caller": "value"}
|
||||
error = PluginError("failed", plugin_id="p", context={"extra": 1})
|
||||
|
||||
record = ErrorAggregator().record_error(error, context=context)
|
||||
|
||||
assert context == {"caller": "value"}
|
||||
assert record.context["caller"] == "value"
|
||||
assert record.context["extra"] == 1
|
||||
|
||||
|
||||
class TestPatternDetection:
|
||||
"""Test error pattern detection."""
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""scripts/fix_perms/fix_web_permissions.sh must not undo the installer's hardening.
|
||||
|
||||
The script chowns the whole project to the web user. That used to include the
|
||||
two helpers /etc/sudoers.d/ledmatrix_web lets the web user run as root
|
||||
(safe_plugin_rm.sh, safe_pip_install.sh) -- a helper the web user owns is a
|
||||
root shell for anyone who can edit it -- and config_secrets.json, which lost
|
||||
the ledmatrix group first_time_install.sh gives it. After the chown the script
|
||||
now puts both back the way the installer's Steps 11 and 11.1 leave them.
|
||||
|
||||
The behavioural test runs the real script against a scratch copy of the
|
||||
project with `sudo`, `getent` and `journalctl` stubbed, and checks the order
|
||||
of what it asked sudo to do.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = ROOT / "scripts" / "fix_perms" / "fix_web_permissions.sh"
|
||||
LIB = ROOT / "scripts" / "install" / "lib_sudoers.sh"
|
||||
|
||||
|
||||
def _text(path):
|
||||
return path.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
|
||||
|
||||
|
||||
def _granted_helpers():
|
||||
helpers = set(re.findall(r"scripts/fix_perms/([\w.-]+\.sh) \*", _text(LIB)))
|
||||
assert helpers, "no fix_perms helper grant found in lib_sudoers.sh"
|
||||
return helpers
|
||||
|
||||
|
||||
def test_every_granted_helper_is_rehardened_after_the_chown():
|
||||
text = _text(SCRIPT)
|
||||
chown = text.index('sudo chown -R "$WEB_USER:$WEB_USER" "$PROJECT_DIR"')
|
||||
loop = re.search(r"for helper in ([^;]+); do\n(.*?)\ndone", text, re.S)
|
||||
assert loop, "no helper-hardening loop in fix_web_permissions.sh"
|
||||
assert "sudo chown root:root" in loop.group(2) and "sudo chmod 755" in loop.group(2)
|
||||
assert loop.start() > chown, "helpers are hardened before the chown that undoes it"
|
||||
assert _granted_helpers() <= set(loop.group(1).split())
|
||||
|
||||
|
||||
def test_no_longer_claims_to_configure_sudoers():
|
||||
text = _text(SCRIPT)
|
||||
assert "Configure sudoers for passwordless access" not in text
|
||||
assert "./configure_web_sudo.sh" not in text.replace("scripts/install/configure_web_sudo.sh", "")
|
||||
|
||||
|
||||
_STUB_SUDO = """#!/bin/bash
|
||||
printf '%s\\n' "$*" >> "$SUDO_LOG"
|
||||
# `sudo -n ...` probes and `sudo -u ...` tests: report failure, run nothing.
|
||||
case "$1" in -n|-u) exit 1 ;; esac
|
||||
exit 0
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32" or shutil.which("bash") is None,
|
||||
reason="needs a POSIX bash")
|
||||
def test_script_rehardens_helpers_and_secrets(tmp_path):
|
||||
project = tmp_path / "LED Matrix"
|
||||
(project / "scripts" / "fix_perms").mkdir(parents=True)
|
||||
(project / "config").mkdir()
|
||||
script = project / "scripts" / "fix_perms" / "fix_web_permissions.sh"
|
||||
script.write_text(_text(SCRIPT), encoding="utf-8")
|
||||
for helper in ("safe_plugin_rm.sh", "safe_pip_install.sh"):
|
||||
(project / "scripts" / "fix_perms" / helper).write_text("#!/bin/bash\n")
|
||||
(project / "config" / "config_secrets.json").write_text("{}\n")
|
||||
|
||||
stubs = tmp_path / "stubs"
|
||||
stubs.mkdir()
|
||||
for name, body in (("sudo", _STUB_SUDO),
|
||||
("getent", "#!/bin/sh\nexit 0\n"),
|
||||
("journalctl", "#!/bin/sh\nexit 1\n")):
|
||||
(stubs / name).write_text(body)
|
||||
(stubs / name).chmod(0o755)
|
||||
log = tmp_path / "sudo.log"
|
||||
env = dict(os.environ, SUDO_LOG=str(log),
|
||||
PATH=os.pathsep.join([str(stubs), os.environ.get("PATH", "")]))
|
||||
|
||||
result = subprocess.run(["bash", str(script)], input="y", env=env,
|
||||
capture_output=True, text=True)
|
||||
if os.geteuid() == 0:
|
||||
# The script refuses to run as root; that refusal is the whole test.
|
||||
assert result.returncode == 1 and "should not be run as root" in result.stdout
|
||||
return
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
calls = log.read_text().splitlines()
|
||||
user = subprocess.run(["whoami"], capture_output=True, text=True).stdout.strip()
|
||||
chown_all = calls.index(f"chown -R {user}:{user} {project}")
|
||||
for helper in ("safe_plugin_rm.sh", "safe_pip_install.sh"):
|
||||
path = project / "scripts" / "fix_perms" / helper
|
||||
assert calls.index(f"chown root:root {path}") > chown_all, calls
|
||||
assert calls.index(f"chmod 755 {path}") > chown_all, calls
|
||||
secrets = project / "config" / "config_secrets.json"
|
||||
# The owner is the installed web unit's User= when there is one.
|
||||
owner = user
|
||||
unit = Path("/etc/systemd/system/ledmatrix-web.service")
|
||||
if unit.is_file():
|
||||
m = re.search(r"^User=(.*)$", unit.read_text(), re.M)
|
||||
if m and m.group(1):
|
||||
owner = m.group(1)
|
||||
assert calls.index(f"chown {owner}:ledmatrix {secrets}") > chown_all, calls
|
||||
assert calls.index(f"chmod 640 {secrets}") > chown_all, calls
|
||||
@@ -8,10 +8,14 @@ test here asserts observable behavior: returned font types, cache identity,
|
||||
fallback selection, and BDF native-size reading.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
|
||||
import freetype
|
||||
import pytest
|
||||
from PIL import ImageFont
|
||||
|
||||
from src.common.font_layout import resolve_asset_path
|
||||
from src.font_manager import FontManager
|
||||
|
||||
|
||||
@@ -132,3 +136,37 @@ class TestCacheLifecycle:
|
||||
fm.reload_config({})
|
||||
assert fm.cache_generation == gen_before + 1
|
||||
assert not fm.font_cache
|
||||
|
||||
|
||||
class TestPluginFonts:
|
||||
"""plugin:// sources resolve against the plugin's own directory, which
|
||||
by default lives under plugin-repos/, not a cwd-relative plugins/."""
|
||||
|
||||
MANIFEST = {"fonts": [{"family": "bundled", "source": "plugin://fonts/Bundled.ttf"}]}
|
||||
|
||||
@staticmethod
|
||||
def _plugin_with_font(root, name="my-plugin"):
|
||||
plugin_dir = root / name
|
||||
(plugin_dir / "fonts").mkdir(parents=True)
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps({"id": "my-plugin"}))
|
||||
shutil.copy(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"),
|
||||
plugin_dir / "fonts" / "Bundled.ttf")
|
||||
return plugin_dir
|
||||
|
||||
def test_font_resolves_under_the_given_plugin_dir(self, fm, tmp_path):
|
||||
plugin_dir = self._plugin_with_font(tmp_path / "plugin-repos")
|
||||
|
||||
assert fm.register_plugin_fonts("my-plugin", self.MANIFEST, plugin_dir=plugin_dir)
|
||||
|
||||
assert fm.font_catalog["my-plugin::bundled"] == str(plugin_dir / "fonts" / "Bundled.ttf")
|
||||
font = fm.resolve_font("x.y", "bundled", 8, plugin_id="my-plugin")
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
|
||||
def test_without_a_plugin_dir_the_configured_directory_is_searched(self, tmp_path):
|
||||
plugins_root = tmp_path / "installed"
|
||||
plugin_dir = self._plugin_with_font(plugins_root, name="ledmatrix-my-plugin")
|
||||
fm = FontManager({"plugin_system": {"plugins_directory": str(plugins_root)}})
|
||||
|
||||
assert fm.register_plugin_fonts("my-plugin", self.MANIFEST)
|
||||
|
||||
assert fm.font_catalog["my-plugin::bundled"] == str(plugin_dir / "fonts" / "Bundled.ttf")
|
||||
|
||||
@@ -133,6 +133,26 @@ class TestAssetPathsIgnoreTheWorkingDirectory:
|
||||
rel = f"assets/fonts/{FOUR_BY_SIX}"
|
||||
assert FontManager._resolve_asset_path(rel) == resolve_asset_path(rel)
|
||||
|
||||
def test_font_overrides_file_lives_in_the_install_config(self, tmp_path, monkeypatch):
|
||||
from src.font_manager import FontManager
|
||||
monkeypatch.chdir(tmp_path)
|
||||
fm = FontManager({})
|
||||
assert fm.font_overrides_file == str(PROJECT_ROOT / "config" / "font_overrides.json")
|
||||
|
||||
def test_logo_placeholder_draws_with_the_bundled_font(self, tmp_path, monkeypatch):
|
||||
import src.logo_downloader as logo_downloader
|
||||
from src.logo_downloader import LogoDownloader
|
||||
loaded = []
|
||||
|
||||
def spy(font, size, **kwargs):
|
||||
loaded.append(font)
|
||||
return load_truetype(font, size, **kwargs)
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(logo_downloader, "load_truetype", spy)
|
||||
assert LogoDownloader().create_placeholder_logo("AB", str(tmp_path))
|
||||
assert loaded == [str(PROJECT_ROOT / "assets" / "fonts" / PRESS_START)]
|
||||
|
||||
|
||||
class TestTheHarnessForkAgreesWithTheCore:
|
||||
"""The divergence that let the wrong rendering be blessed as golden.
|
||||
|
||||
@@ -78,3 +78,25 @@ class TestBackgroundDataServiceHeaders:
|
||||
assert 'yourusername' not in str(headers)
|
||||
finally:
|
||||
service.shutdown(wait=False)
|
||||
|
||||
|
||||
class TestResolverHeaders:
|
||||
def test_dynamic_team_resolver_sends_the_user_agent(self):
|
||||
from unittest.mock import patch
|
||||
from src.dynamic_team_resolver import DynamicTeamResolver
|
||||
DynamicTeamResolver._rankings_cache = {}
|
||||
DynamicTeamResolver._cache_timestamp = 0
|
||||
try:
|
||||
with patch('src.dynamic_team_resolver.requests.get',
|
||||
side_effect=RuntimeError("stop")) as get:
|
||||
DynamicTeamResolver().resolve_teams(["AP_TOP_5"])
|
||||
assert get.call_args.kwargs['headers']['User-Agent'] == USER_AGENT
|
||||
finally:
|
||||
DynamicTeamResolver._rankings_cache = {}
|
||||
DynamicTeamResolver._cache_timestamp = 0
|
||||
|
||||
def test_odds_manager_uses_the_shared_headers(self):
|
||||
from src.base_odds_manager import BaseOddsManager
|
||||
headers = BaseOddsManager(MagicMock()).session.headers
|
||||
for name, value in DEFAULT_HTTP_HEADERS.items():
|
||||
assert headers[name] == value
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""first_time_install.sh prints its completion summary before it reboots.
|
||||
|
||||
With -y (and so with the one-shot `curl | bash` installer, which always
|
||||
passes -y) the reboot used to be issued ~180 lines before the "Installation
|
||||
Complete / Web UI Access" summary. `reboot` returns at once and the script
|
||||
carried on printing while the system went down, so the SSH session usually
|
||||
dropped before the user saw the web UI address.
|
||||
|
||||
first_time_install.sh exits on anything but Raspberry Pi OS Trixie before it
|
||||
parses its arguments, so the behavioural test runs only the tail of the
|
||||
script -- from the summary to the end -- with systemctl, nmcli, hostname, ip
|
||||
and reboot stubbed.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
FIRST_TIME = ROOT / "first_time_install.sh"
|
||||
SUMMARY_START = 'echo "Installation Complete!"'
|
||||
|
||||
|
||||
def _text():
|
||||
return FIRST_TIME.read_text(encoding="utf-8").replace("\r\n", "\n")
|
||||
|
||||
|
||||
def test_every_reboot_comes_after_the_summary():
|
||||
text = _text()
|
||||
summary = text.index(SUMMARY_START)
|
||||
lines = text.splitlines()
|
||||
reboots = [i for i, line in enumerate(lines) if line.strip() == "reboot"]
|
||||
assert reboots, "no reboot call found"
|
||||
summary_line = text[:summary].count("\n")
|
||||
enjoy_line = text[:text.index('echo "Enjoy your LED Matrix display!"')].count("\n")
|
||||
assert all(i > enjoy_line > summary_line for i in reboots), (
|
||||
f"reboot at line(s) {[i + 1 for i in reboots]} runs before the summary "
|
||||
f"(line {summary_line + 1}) has finished printing")
|
||||
|
||||
|
||||
def _tail():
|
||||
"""The script from the summary header to the end, header rule included."""
|
||||
text = _text()
|
||||
start = text.rindex('echo "=========================================="', 0,
|
||||
text.index(SUMMARY_START))
|
||||
return text[start:]
|
||||
|
||||
|
||||
_POSIX = pytest.mark.skipif(sys.platform == "win32" or shutil.which("bash") is None,
|
||||
reason="needs a POSIX bash")
|
||||
|
||||
|
||||
def _run(tmp_path, env_extra, nmcli_active_line=True, stdin="", hostapd_active=False):
|
||||
stubs = tmp_path / "stubs"
|
||||
stubs.mkdir()
|
||||
log = tmp_path / "calls.log"
|
||||
active = 'echo "yes:HomeNet"' if nmcli_active_line else ":"
|
||||
hostapd = 'case "$*" in *"is-active --quiet hostapd"*) exit 0 ;; esac\n' if hostapd_active else ""
|
||||
bodies = {
|
||||
"reboot": f'#!/bin/sh\necho REBOOT-CALLED\necho reboot >> "{log}"\n',
|
||||
"systemctl": f"#!/bin/sh\n{hostapd}exit 3\n",
|
||||
"hostname": '#!/bin/sh\necho "192.168.1.50 fe80::1"\n',
|
||||
"ip": "#!/bin/sh\nexit 1\n",
|
||||
# device status -> one connected wifi device; device wifi -> active line
|
||||
"nmcli": ('#!/bin/sh\ncase "$*" in\n'
|
||||
' *"device status"*) echo "wlan0:wifi:connected" ;;\n'
|
||||
f' *"device wifi"*) {active} ;;\n'
|
||||
"esac\n"),
|
||||
}
|
||||
for name, body in bodies.items():
|
||||
(stubs / name).write_text(body)
|
||||
(stubs / name).chmod(0o755)
|
||||
script = "\n".join([
|
||||
"set -Eeuo pipefail",
|
||||
"on_error() { echo \"ERR-TRAP line $1\" >&2; exit 1; }",
|
||||
"trap 'on_error $LINENO' ERR",
|
||||
"PROJECT_ROOT_DIR=/home/pi/LEDMatrix",
|
||||
"ASSUME_YES=${ASSUME_YES:-0}",
|
||||
"SKIP_REBOOT_PROMPT=${SKIP_REBOOT_PROMPT:-0}",
|
||||
_tail(),
|
||||
])
|
||||
env = dict(os.environ, PATH=os.pathsep.join([str(stubs), "/usr/bin", "/bin"]), **env_extra)
|
||||
result = subprocess.run(["bash", "-c", script], env=env, input=stdin,
|
||||
capture_output=True, text=True)
|
||||
calls = log.read_text().splitlines() if log.exists() else []
|
||||
return result, calls
|
||||
|
||||
|
||||
@_POSIX
|
||||
@pytest.mark.parametrize("nmcli_active_line", [True, False], ids=["ssid", "no-ssid"])
|
||||
def test_assume_yes_prints_the_summary_then_reboots(tmp_path, nmcli_active_line):
|
||||
result, calls = _run(tmp_path, {"ASSUME_YES": "1"}, nmcli_active_line)
|
||||
out = result.stdout
|
||||
assert result.returncode == 0, out + result.stderr
|
||||
assert calls == ["reboot"]
|
||||
for text in ("Installation Complete!", "Web UI Access:", "http://192.168.1.50:5000",
|
||||
"Enjoy your LED Matrix display!"):
|
||||
assert out.index(text) < out.index("REBOOT-CALLED"), text
|
||||
assert "Password: ledmatrix123" not in out
|
||||
|
||||
|
||||
@_POSIX
|
||||
def test_setup_access_point_is_described_as_open(tmp_path):
|
||||
"""wifi_manager creates the setup AP with no security ("No password" on
|
||||
the panel); the summary used to print a password it does not have."""
|
||||
result, _ = _run(tmp_path, {"ASSUME_YES": "1"}, hostapd_active=True)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "AP Mode is ACTIVE" in result.stdout
|
||||
assert "Open network, no password" in result.stdout
|
||||
assert "Password:" not in result.stdout
|
||||
|
||||
|
||||
@_POSIX
|
||||
def test_no_reboot_prompt_prints_the_summary_and_does_not_reboot(tmp_path):
|
||||
result, calls = _run(tmp_path, {"ASSUME_YES": "1", "SKIP_REBOOT_PROMPT": "1"})
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert calls == []
|
||||
assert "Enjoy your LED Matrix display!" in result.stdout
|
||||
assert "Skipping reboot prompt" in result.stdout
|
||||
|
||||
|
||||
@_POSIX
|
||||
@pytest.mark.parametrize("answer,expected", [("y", ["reboot"]), ("n", [])])
|
||||
def test_interactive_prompt_comes_after_the_summary(tmp_path, answer, expected):
|
||||
result, calls = _run(tmp_path, {}, stdin=answer)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert calls == expected
|
||||
out = result.stdout
|
||||
assert "Enjoy your LED Matrix display!" in out
|
||||
if expected:
|
||||
assert out.index("Enjoy your LED Matrix display!") < out.index("REBOOT-CALLED")
|
||||
@@ -357,6 +357,33 @@ class TestRefreshPlaceholderTimestamp:
|
||||
assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False
|
||||
|
||||
|
||||
class TestFailurePaths:
|
||||
def test_a_team_without_logos_is_a_failed_download(self, tmp_path):
|
||||
downloader = LogoDownloader()
|
||||
with patch.object(downloader, "fetch_single_team",
|
||||
return_value={"team": {"logos": []}}):
|
||||
assert downloader.download_missing_logo_for_team(
|
||||
"nfl", "1", "XYZ", tmp_path / "XYZ.png") is False
|
||||
|
||||
def test_placeholder_is_written_where_the_caller_looks(self, tmp_path):
|
||||
"""A path that is not <normalized abbreviation>.png (the plugin's own
|
||||
file naming, or an abbreviation normalize_abbreviation rewrites)
|
||||
still ends up holding the placeholder, so True means it exists."""
|
||||
logo_path = tmp_path / "TA&M.png"
|
||||
with patch.object(LogoDownloader, "download_logo", return_value=False):
|
||||
assert download_missing_logo(
|
||||
"ncaa_fb", "245", "TA&M", logo_path,
|
||||
logo_url="http://example/tamu.png") is True
|
||||
assert is_placeholder_logo(logo_path)
|
||||
assert not (tmp_path / "TAANDM.png").exists()
|
||||
|
||||
def test_placeholder_uses_the_placeholder_geometry(self, tmp_path):
|
||||
assert LogoDownloader().create_placeholder_logo("AB", str(tmp_path))
|
||||
with Image.open(tmp_path / "AB.png") as img:
|
||||
assert img.size == PLACEHOLDER_SIZE
|
||||
assert img.convert("RGBA").getpixel((0, 0)) == PLACEHOLDER_BG
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# download_logo: the download the scoreboard plugins actually use
|
||||
#
|
||||
|
||||
@@ -116,10 +116,14 @@ def test_values_of_the_wrong_type_fall_back_to_usable_defaults(bad):
|
||||
assert isinstance(getattr(metrics, field_name), (int, float)), \
|
||||
f"{field_name} came back as {getattr(metrics, field_name)!r}"
|
||||
|
||||
# The real proof: arithmetic on the loaded metrics must not explode.
|
||||
# The real proof: the arithmetic monitor_call and get_metrics_summary do
|
||||
# on the loaded metrics must not explode.
|
||||
metrics.call_count += 1
|
||||
metrics.total_execution_time += 0.5
|
||||
metrics.update_average_execution_time()
|
||||
metrics.max_execution_time = max(metrics.max_execution_time, 0.5)
|
||||
metrics.min_execution_time = min(metrics.min_execution_time, 0.5)
|
||||
metrics.memory_mb = max(metrics.memory_mb, 1.0)
|
||||
assert metrics.total_execution_time / metrics.call_count >= 0
|
||||
|
||||
|
||||
def test_a_numeric_string_is_accepted_rather_than_discarded():
|
||||
|
||||
@@ -294,6 +294,31 @@ class TestValidateConfigFailure:
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestPluginFontRegistration:
|
||||
"""A manifest's fonts block is registered against the directory the
|
||||
plugin was loaded from, which plugin:// font sources are relative to."""
|
||||
|
||||
def test_plugin_dir_is_passed_to_the_font_manager(self, temp_plugin_dir, mock_managers):
|
||||
plugin_dir = temp_plugin_dir / "test-plugin"
|
||||
plugin_dir.mkdir()
|
||||
fonts = {"fonts": [{"family": "f", "source": "plugin://f.ttf"}]}
|
||||
manifest = {"id": "test-plugin", "name": "Test Plugin",
|
||||
"entry_point": "manager.py", "class_name": "TestPlugin",
|
||||
"fonts": fonts}
|
||||
|
||||
with patch('src.common.permission_utils.ensure_directory_permissions'):
|
||||
manager = PluginManager(plugins_dir=str(temp_plugin_dir), **mock_managers)
|
||||
manager.plugin_manifests["test-plugin"] = manifest
|
||||
with patch.object(manager.plugin_loader, 'load_plugin',
|
||||
return_value=(MagicMock(), MagicMock())):
|
||||
with patch.object(manager.plugin_loader, 'find_plugin_directory',
|
||||
return_value=plugin_dir):
|
||||
manager.load_plugin("test-plugin")
|
||||
|
||||
mock_managers["font_manager"].register_plugin_fonts.assert_called_once_with(
|
||||
"test-plugin", fonts, plugin_dir=plugin_dir)
|
||||
|
||||
|
||||
class TestPluginStateOnFailure:
|
||||
"""Test that plugin state is correctly set on various failures."""
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""reload_plugin re-reads the manifest from the plugin's actual directory.
|
||||
|
||||
It read ``plugins_dir / plugin_id / manifest.json``, but a plugin directory's
|
||||
name need not be the id its manifest declares -- discovery maps ids to
|
||||
directories for exactly that reason. For such a plugin the path did not exist,
|
||||
the re-read was skipped silently, and the reload kept the stale manifest.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pm_with_renamed_dir(tmp_path):
|
||||
plugins_dir = tmp_path / "plugins"
|
||||
plugin_dir = plugins_dir / "stock-ticker-v2"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
manifest_path = plugin_dir / "manifest.json"
|
||||
manifest_path.write_text(json.dumps({"id": "stocks", "version": "1.0.0"}))
|
||||
pm = PluginManager(plugins_dir=str(plugins_dir))
|
||||
assert pm.discover_plugins() == ["stocks"]
|
||||
return pm, manifest_path
|
||||
|
||||
|
||||
def test_reload_picks_up_an_edited_manifest(pm_with_renamed_dir, monkeypatch):
|
||||
pm, manifest_path = pm_with_renamed_dir
|
||||
manifest_path.write_text(json.dumps({"id": "stocks", "version": "2.0.0"}))
|
||||
loaded = []
|
||||
monkeypatch.setattr(pm, "load_plugin", lambda pid: loaded.append(pid) or True)
|
||||
|
||||
assert pm.reload_plugin("stocks") is True
|
||||
assert pm.plugin_manifests["stocks"]["version"] == "2.0.0"
|
||||
assert loaded == ["stocks"]
|
||||
@@ -115,5 +115,22 @@ def test_get_state_info_is_a_consistent_snapshot():
|
||||
assert not inconsistent, f"observed a torn snapshot: {inconsistent[:1]}"
|
||||
|
||||
|
||||
def test_state_info_reports_only_what_something_records():
|
||||
"""No field that is always null.
|
||||
|
||||
``last_display`` was reported here, but nothing ever recorded a display()
|
||||
call, so it was null for every plugin. Its only reader is the web process,
|
||||
whose PluginManager never calls display(), so recording it in the display
|
||||
process could not have filled it either.
|
||||
"""
|
||||
manager = PluginStateManager()
|
||||
manager.set_state("clock", PluginState.ENABLED)
|
||||
manager.record_update("clock")
|
||||
|
||||
info = manager.get_state_info("clock")
|
||||
assert "last_display" not in info
|
||||
assert info["last_update"] is not None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Repository URL handling shared by the plugin store and saved repositories.
|
||||
|
||||
The store cleaned URLs with ``url.rstrip('/').replace('.git', '')`` in two
|
||||
places, which removes ``.git`` anywhere in the URL:
|
||||
``https://github.com/user/my.github.io`` became ``.../myhub.io``, so installing
|
||||
or browsing such a repository asked GitHub for one that does not exist.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.repo_urls import (
|
||||
github_api_headers, github_owner_repo, normalize_repo_url, same_repo,
|
||||
)
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
PAGES_REPO = "https://github.com/user/my.github.io"
|
||||
|
||||
|
||||
class TestNormalizeRepoUrl:
|
||||
@pytest.mark.parametrize("raw, expected", [
|
||||
(PAGES_REPO, PAGES_REPO),
|
||||
(PAGES_REPO + ".git", PAGES_REPO),
|
||||
("https://github.com/user/repo.git/", "https://github.com/user/repo"),
|
||||
(" https://github.com/user/repo/ ", "https://github.com/user/repo"),
|
||||
])
|
||||
def test_only_a_trailing_dot_git_is_removed(self, raw, expected):
|
||||
assert normalize_repo_url(raw) == expected
|
||||
|
||||
def test_same_repo_ignores_case_and_suffix(self):
|
||||
assert same_repo("https://github.com/Owner/Repo.git",
|
||||
"https://github.com/owner/repo/")
|
||||
assert not same_repo("https://github.com/owner/repo",
|
||||
"https://github.com/owner/other")
|
||||
|
||||
|
||||
class TestGithubOwnerRepo:
|
||||
@pytest.mark.parametrize("url, expected", [
|
||||
(PAGES_REPO + ".git", ("user", "my.github.io")),
|
||||
("https://www.github.com/owner/repo", ("owner", "repo")),
|
||||
("https://github.com/owner/repo/tree/main/plugins/x", ("owner", "repo")),
|
||||
])
|
||||
def test_github_urls(self, url, expected):
|
||||
assert github_owner_repo(url) == expected
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://github.com.example.org/owner/repo",
|
||||
"https://gitlab.com/owner/repo",
|
||||
"https://github.com/owner",
|
||||
"github.com/owner/repo",
|
||||
])
|
||||
def test_anything_else_is_not_a_github_repo(self, url):
|
||||
assert github_owner_repo(url) is None
|
||||
|
||||
def test_headers_carry_the_token_only_when_given(self):
|
||||
assert "Authorization" not in github_api_headers(None)
|
||||
assert github_api_headers("abc")["Authorization"] == "token abc"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
return PluginStoreManager(
|
||||
plugins_dir=str(tmp_path / "plugins"),
|
||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||
|
||||
|
||||
def test_install_from_url_keeps_an_interior_dot_git(store, monkeypatch):
|
||||
cloned_from = []
|
||||
monkeypatch.setattr(store, "_install_via_git",
|
||||
lambda url, *a, **k: cloned_from.append(url))
|
||||
downloaded = []
|
||||
monkeypatch.setattr(store, "_install_via_download",
|
||||
lambda url, *a, **k: downloaded.append(url) or False)
|
||||
|
||||
result = store.install_from_url(PAGES_REPO + ".git")
|
||||
|
||||
assert result["success"] is False
|
||||
assert cloned_from == [PAGES_REPO]
|
||||
assert all(url.startswith(PAGES_REPO + "/archive/") for url in downloaded)
|
||||
|
||||
|
||||
def test_fetch_registry_from_url_asks_for_the_named_repository(store, monkeypatch):
|
||||
requested = []
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
requested.append(url)
|
||||
return MagicMock(status_code=404)
|
||||
|
||||
monkeypatch.setattr(store, "_http_get_with_retries", fake_get)
|
||||
|
||||
assert store.fetch_registry_from_url(PAGES_REPO) is None
|
||||
assert requested
|
||||
assert all(url.startswith("https://raw.githubusercontent.com/user/my.github.io/")
|
||||
for url in requested)
|
||||
@@ -93,6 +93,27 @@ class TestResourceLimits:
|
||||
with pytest.raises(ResourceLimitExceeded):
|
||||
mon.monitor_call("p", lambda: time.sleep(0.02))
|
||||
|
||||
def test_memory_limit_judges_each_call_on_its_own_growth(self):
|
||||
"""One expensive call must not fail every call after it.
|
||||
|
||||
The check used to compare the stored high-water mark, which never
|
||||
decreases, so after one call grew memory past the limit every later
|
||||
call raised too and the plugin never updated again.
|
||||
"""
|
||||
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
|
||||
mon.enable_monitoring = True # measure without needing psutil
|
||||
readings = iter([100.0, 200.0, # first call grows RSS by 100 MB
|
||||
200.0, 201.0]) # second call grows it by 1 MB
|
||||
mon._get_process_memory_mb = lambda: next(readings)
|
||||
mon._get_process_cpu_percent = lambda: 0.0
|
||||
mon.set_limits("p", ResourceLimits(max_memory_mb=50))
|
||||
|
||||
with pytest.raises(ResourceLimitExceeded):
|
||||
mon.monitor_call("p", lambda: None)
|
||||
assert mon.monitor_call("p", lambda: "ok") == "ok"
|
||||
# The high-water mark is still reported.
|
||||
assert mon.get_metrics("p").memory_mb == 100.0
|
||||
|
||||
def test_reset_metrics_clears_counts(self):
|
||||
cache = _cache()
|
||||
mon = PluginResourceMonitor(cache, enable_monitoring=False)
|
||||
|
||||
@@ -5,7 +5,7 @@ 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
|
||||
URL normalization post-fix (normalize_repo_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
|
||||
|
||||
@@ -63,6 +63,15 @@ class TestValidateConfig:
|
||||
assert "Missing required configuration key: display" in errors
|
||||
assert "Missing required configuration key: timezone" in errors
|
||||
|
||||
@pytest.mark.parametrize("config,expected", [
|
||||
({'timezone': 'UTC'}, "Missing required configuration key: display"),
|
||||
({'display': {}, 'timezone': 'UTC'}, "Display configuration is empty"),
|
||||
])
|
||||
def test_a_missing_display_section_is_reported_once(self, good_cache, config, expected):
|
||||
validator = StartupValidator(make_config_manager(config))
|
||||
_, errors, _ = validator.validate_all()
|
||||
assert errors == [expected]
|
||||
|
||||
def test_config_error_does_not_propagate(self, good_cache):
|
||||
mgr = make_config_manager(GOOD_CONFIG)
|
||||
mgr.load_config.side_effect = ConfigError("bad json")
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""A repository whose only branch is neither main nor master still installs.
|
||||
|
||||
_install_via_git tries the candidate branches, then the repository's default
|
||||
branch -- but it returned None both for "every clone failed" and for "the
|
||||
default-branch clone succeeded". install_from_url took the None as failure,
|
||||
fell through to the archive download of main/master (which does not exist),
|
||||
and reported "Failed to clone or download repository" for a repository it had
|
||||
just cloned.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
|
||||
|
||||
MANIFEST = {
|
||||
"id": "develop-only", "name": "Develop Only", "class_name": "P",
|
||||
"display_modes": ["develop_only"], "version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
def _git(*args, cwd):
|
||||
subprocess.run(
|
||||
["git", "-c", "user.name=t", "-c", "user.email=t@example.invalid", *args],
|
||||
cwd=cwd, check=True, capture_output=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def develop_only_repo(tmp_path):
|
||||
repo = tmp_path / "upstream"
|
||||
repo.mkdir()
|
||||
_git("init", "-q", "-b", "develop", cwd=repo)
|
||||
(repo / "manifest.json").write_text(json.dumps(MANIFEST))
|
||||
(repo / "manager.py").write_text("class P: pass\n")
|
||||
_git("add", ".", cwd=repo)
|
||||
_git("commit", "-q", "-m", "init", cwd=repo)
|
||||
return repo.as_uri()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
mgr = PluginStoreManager(
|
||||
plugins_dir=str(tmp_path / "plugins"),
|
||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
|
||||
downloads = []
|
||||
monkeypatch.setattr(mgr, "_install_via_download",
|
||||
lambda url, *a, **k: downloads.append(url) or False)
|
||||
mgr.downloads = downloads
|
||||
return mgr
|
||||
|
||||
|
||||
def test_a_default_branch_clone_reports_its_branch(store, develop_only_repo, tmp_path):
|
||||
target = tmp_path / "clone"
|
||||
assert store._install_via_git(develop_only_repo, target, ["main", "master"]) == "develop"
|
||||
assert (target / "manifest.json").exists()
|
||||
|
||||
|
||||
def test_a_failed_clone_reports_none(store, tmp_path):
|
||||
missing = (tmp_path / "no-such-repo").as_uri()
|
||||
target = tmp_path / "clone"
|
||||
assert store._install_via_git(missing, target, ["main"]) is None
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_install_from_url_installs_a_develop_only_repository(store, develop_only_repo):
|
||||
result = store.install_from_url(develop_only_repo)
|
||||
|
||||
assert result == {"success": True, "plugin_id": "develop-only",
|
||||
"name": "Develop Only", "branch": "develop"}
|
||||
assert (store.plugins_dir / "develop-only" / "manifest.json").exists()
|
||||
assert store.downloads == []
|
||||
@@ -0,0 +1,62 @@
|
||||
"""update_plugin must not borrow the enclosing LEDMatrix checkout's remote.
|
||||
|
||||
Plugins live in ``plugin-repos/`` inside the LEDMatrix git checkout. For a
|
||||
plugin installed from a ZIP (no ``.git`` of its own), ``git -C <plugin>``
|
||||
walks up to the LEDMatrix repository, and ``git config --local --get
|
||||
remote.origin.url`` answers with LEDMatrix's own URL. update_plugin then tried
|
||||
to "reinstall" the plugin from the LEDMatrix repository.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
|
||||
|
||||
PLUGIN_ID = "zip-installed"
|
||||
PARENT_REMOTE = "https://github.com/example/LEDMatrix"
|
||||
|
||||
|
||||
def _git(*args, cwd):
|
||||
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_inside_checkout(tmp_path):
|
||||
checkout = tmp_path / "LEDMatrix"
|
||||
checkout.mkdir()
|
||||
_git("init", "-q", cwd=checkout)
|
||||
_git("remote", "add", "origin", PARENT_REMOTE, cwd=checkout)
|
||||
|
||||
plugins_dir = checkout / "plugin-repos"
|
||||
plugin_dir = plugins_dir / PLUGIN_ID
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps(
|
||||
{"id": PLUGIN_ID, "name": "Zip", "version": "1.0.0"}))
|
||||
|
||||
store = PluginStoreManager(
|
||||
plugins_dir=str(plugins_dir),
|
||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||
return store, plugin_dir
|
||||
|
||||
|
||||
def test_a_plugin_without_its_own_git_has_no_remote(store_inside_checkout, monkeypatch):
|
||||
store, plugin_dir = store_inside_checkout
|
||||
# The premise: git itself does report the parent's remote here.
|
||||
parent_view = subprocess.run(
|
||||
["git", "-C", str(plugin_dir), "config", "--local", "--get", "remote.origin.url"],
|
||||
capture_output=True, text=True)
|
||||
assert parent_view.stdout.strip() == PARENT_REMOTE
|
||||
|
||||
monkeypatch.setattr(store, "fetch_registry", lambda *a, **k: {"plugins": []})
|
||||
monkeypatch.setattr(store, "get_plugin_info", lambda *a, **k: None)
|
||||
install_calls = []
|
||||
monkeypatch.setattr(store, "install_from_url",
|
||||
lambda *a, **k: install_calls.append((a, k)) or {"success": True})
|
||||
|
||||
assert store.update_plugin(PLUGIN_ID) is False
|
||||
assert install_calls == []
|
||||
@@ -11,12 +11,16 @@ Four such calls were ungranted, all of them captive-portal teardown/setup:
|
||||
rfkill unblock wifi wifi_manager.py:1811
|
||||
mkdir -p .../dnsmasq-shared.d wifi_manager.py:922
|
||||
|
||||
The drop-in written into that directory was missing too: the literal
|
||||
`cp /tmp/ledmatrix-nm-dnsmasq.conf .../dnsmasq-shared.d/ledmatrix-captive.conf`
|
||||
and `rm -f` of the same file, so the directory was granted but not the file.
|
||||
|
||||
It goes unnoticed because a stock Raspberry Pi image ships
|
||||
/etc/sudoers.d/010_pi-nopasswd granting the default user
|
||||
`ALL=(ALL) NOPASSWD: ALL`, which satisfies every gap in both files. It only
|
||||
bites once that blanket rule is removed or the service runs as another user.
|
||||
|
||||
Scope, deliberately narrow: this pins the four commands above, each of which
|
||||
Scope, deliberately narrow: this pins the commands above, each of which
|
||||
can be written out literally. The portal makes further sudo calls whose
|
||||
arguments are built at runtime -- iptables and nft rules carrying an interface
|
||||
name and a port, `ip addr`, `ip link` -- and those cannot be granted safely
|
||||
@@ -50,6 +54,11 @@ REQUIRED = (
|
||||
("nft", "delete", "table", "ip", "ledmatrix"),
|
||||
("rfkill", "unblock", "wifi"),
|
||||
("mkdir", "-p", "/etc/NetworkManager/dnsmasq-shared.d"),
|
||||
# The drop-in that directory exists for, written and removed by
|
||||
# _write_nm_dnsmasq_captive_conf / _remove_nm_dnsmasq_captive_conf.
|
||||
("cp", "/tmp/ledmatrix-nm-dnsmasq.conf",
|
||||
"/etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf"),
|
||||
("rm", "-f", "/etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf"),
|
||||
)
|
||||
|
||||
#: Tools with an option that executes a program of the caller's choosing.
|
||||
|
||||
@@ -19,6 +19,7 @@ import pytest
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FIRST_TIME = os.path.join(REPO_ROOT, "first_time_install.sh")
|
||||
CONFIGURE = os.path.join(REPO_ROOT, "scripts", "install", "configure_web_sudo.sh")
|
||||
WIFI = os.path.join(REPO_ROOT, "scripts", "install", "configure_wifi_permissions.sh")
|
||||
|
||||
VISUDO = shutil.which("visudo") or (
|
||||
"/usr/sbin/visudo" if os.path.exists("/usr/sbin/visudo") else None
|
||||
@@ -62,6 +63,54 @@ def test_configure_web_sudo_validates_before_installing():
|
||||
assert validate < install, "the rules must be checked before they are installed"
|
||||
|
||||
|
||||
def test_configure_web_sudo_does_not_use_a_predictable_temp_file():
|
||||
body = _read(CONFIGURE)
|
||||
assert 'TEMP_SUDOERS=$(mktemp' in body
|
||||
assert "/tmp/ledmatrix_web_sudoers_$$" not in body
|
||||
assert "trap 'rm -f \"$TEMP_SUDOERS\"' EXIT" in body
|
||||
|
||||
|
||||
def test_configure_web_sudo_installs_mode_440():
|
||||
body = _read(CONFIGURE)
|
||||
install = body.index('cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web')
|
||||
assert body.index("chmod 440 /etc/sudoers.d/ledmatrix_web") > install
|
||||
|
||||
|
||||
def test_configure_wifi_permissions_validates_before_installing():
|
||||
"""The third sudoers writer. It installed its rules unchecked."""
|
||||
body = _read(WIFI)
|
||||
# The check itself, as a condition -- not merely the command appearing in
|
||||
# the error report that follows it.
|
||||
validate = body.index('if ! visudo -c -f "$TEMP_SUDOERS"')
|
||||
install = body.index('sudo cp "$TEMP_SUDOERS" "$SUDOERS_FILE"')
|
||||
assert validate < install, "the rules must be checked before they are installed"
|
||||
# ...and a failed check stops the script before the copy.
|
||||
assert "exit 1" in body[validate:install]
|
||||
assert "TEMP_SUDOERS=$(mktemp" in body
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="visudo is POSIX only")
|
||||
@pytest.mark.skipif(VISUDO is None, reason="visudo not installed")
|
||||
def test_the_wifi_rules_actually_parse(tmp_path):
|
||||
"""Render configure_wifi_permissions.sh's heredoc with realistic paths."""
|
||||
body = _read(WIFI)
|
||||
opener = 'cat > "$TEMP_SUDOERS" << EOF\n'
|
||||
start = body.index(opener) + len(opener)
|
||||
end = body.index("\nEOF\n", start)
|
||||
out = tmp_path / "wifi"
|
||||
script = "\n".join([
|
||||
"WEB_USER=ledmatrix", "NMCLI_PATH=/usr/bin/nmcli",
|
||||
"SYSTEMCTL_PATH=/usr/bin/systemctl", "SYSCTL_PATH=/usr/sbin/sysctl",
|
||||
"NFT_PATH=/usr/sbin/nft", "RFKILL_PATH=/usr/sbin/rfkill",
|
||||
"MKDIR_PATH=/usr/bin/mkdir",
|
||||
f"cat > '{out}' << EOF", body[start:end], "EOF",
|
||||
])
|
||||
subprocess.run(["bash", "-c", script], check=True)
|
||||
os.chmod(out, 0o440)
|
||||
result = subprocess.run([VISUDO, "-c", "-f", str(out)], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_a_missing_rules_library_installs_nothing():
|
||||
"""If lib_sudoers.sh is missing, nothing is generated -- and an empty file
|
||||
would pass `visudo -c` -- so that branch must set the flag the install is
|
||||
|
||||
@@ -38,6 +38,7 @@ import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
|
||||
from src.common import sync_manager
|
||||
from src.common.sync_manager import (
|
||||
DisplaySyncManager,
|
||||
@@ -878,6 +879,14 @@ class TestWriteStatusFile:
|
||||
mgr.write_status_file() # must not raise
|
||||
assert mgr.logger.debug.called
|
||||
|
||||
def test_web_status_endpoint_reads_the_file_that_was_written(self, api_v3_client):
|
||||
"""GET /sync/status reads STATUS_FILE, which lives under
|
||||
tempfile.gettempdir() -- not always /tmp."""
|
||||
mgr = make_manager(role=SyncRole.LEADER)
|
||||
mgr.write_status_file()
|
||||
response = api_v3_client.get("/api/v3/sync/status")
|
||||
assert response.get_json()["data"]["role"] == "leader"
|
||||
|
||||
|
||||
class TestStop:
|
||||
def _stub_with_sockets(self):
|
||||
|
||||
@@ -17,6 +17,7 @@ editing files under /etc and restarting services is the installer's job, not
|
||||
something a display process should do to a machine while it boots.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -253,6 +254,24 @@ def test_sed_escape_replacement_preserves_special_characters():
|
||||
"a sed-special character in the replacement was not preserved literally")
|
||||
|
||||
|
||||
def test_every_unit_renderer_escapes_its_replacement():
|
||||
"""Each `sed s|__PLACEHOLDER__|$VALUE|` in an install script uses an escaped value.
|
||||
|
||||
install_dns_fix.sh and install_mqtt_bridge.sh interpolated the raw project
|
||||
path while the other three renderers went through sed_escape_replacement,
|
||||
so a checkout under a path containing `&` rendered a broken unit from
|
||||
those two only.
|
||||
"""
|
||||
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||
offenders = []
|
||||
for script in sorted((project_root / "scripts" / "install").glob("*.sh")):
|
||||
text = script.read_text(encoding="utf-8")
|
||||
for m in re.finditer(r"s\|__[A-Z_]+__\|\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?\|", text):
|
||||
if not m.group(1).startswith("ESCAPED_") and m.group(1) != "root":
|
||||
offenders.append(f"{script.name}: ${m.group(1)}")
|
||||
assert not offenders, "unescaped sed replacement(s): " + ", ".join(offenders)
|
||||
|
||||
|
||||
def test_no_installer_carries_its_own_copy_of_a_unit():
|
||||
"""The regression guard.
|
||||
|
||||
|
||||
@@ -24,10 +24,13 @@ class TestTextHelper:
|
||||
assert th.font_dir == tmp_path
|
||||
assert th._font_cache == {}
|
||||
|
||||
def test_init_default_font_dir(self):
|
||||
"""Test TextHelper initialization with default font directory."""
|
||||
def test_init_default_font_dir(self, tmp_path, monkeypatch):
|
||||
"""The default is the install's assets/fonts, not a cwd-relative path."""
|
||||
from pathlib import Path
|
||||
monkeypatch.chdir(tmp_path)
|
||||
th = TextHelper()
|
||||
assert th.font_dir == pytest.importorskip("pathlib").Path("assets/fonts")
|
||||
assert th.font_dir == Path(__file__).resolve().parents[1] / "assets" / "fonts"
|
||||
assert isinstance(th.load_fonts()["score"], ImageFont.FreeTypeFont)
|
||||
|
||||
@patch('PIL.ImageFont.truetype')
|
||||
@patch('PIL.ImageFont.load_default')
|
||||
@@ -123,6 +126,17 @@ class TestTextHelper:
|
||||
def test_get_default_font_config(self, text_helper):
|
||||
"""Test getting default font configuration."""
|
||||
config = text_helper._get_default_font_config()
|
||||
|
||||
|
||||
assert isinstance(config, dict)
|
||||
assert len(config) > 0
|
||||
|
||||
def test_each_font_file_and_size_is_loaded_once(self):
|
||||
th = TextHelper()
|
||||
first = th.load_fonts()
|
||||
second = th.load_fonts()
|
||||
# Six names, three (file, size) pairs: PressStart2P at 10 and 8, 4x6 at 6.
|
||||
assert first["score"] is second["score"] is first["rank"]
|
||||
assert th.get_font_cache_stats()["cached_fonts"] == 3
|
||||
th.clear_font_cache()
|
||||
assert th.get_font_cache_stats()["cached_fonts"] == 0
|
||||
assert th.load_fonts()["score"] is not first["score"]
|
||||
|
||||
@@ -369,6 +369,26 @@ def test_default_config_has_no_saved_networks(tmp_path: Path) -> None:
|
||||
assert "saved_networks" not in json.loads(config_path.read_text())
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_config_reports_a_failed_write(manager: WiFiManager, tmp_path: Path) -> None:
|
||||
# A directory where the file should be: every write to it fails, as one
|
||||
# to a root-owned wifi_config.json does for the web user.
|
||||
blocked = tmp_path / "blocked.json"
|
||||
blocked.mkdir()
|
||||
manager.config_path = blocked
|
||||
|
||||
assert manager._save_config() is False
|
||||
assert list(tmp_path.glob(".blocked.json.tmp.*")) == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_config_round_trips(manager: WiFiManager) -> None:
|
||||
manager.config["auto_enable_ap_mode"] = False
|
||||
|
||||
assert manager._save_config() is True
|
||||
assert json.loads(manager.config_path.read_text())["auto_enable_ap_mode"] is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_connecting_does_not_store_the_password(manager: WiFiManager) -> None:
|
||||
commands = []
|
||||
@@ -389,3 +409,77 @@ def test_connecting_does_not_store_the_password(manager: WiFiManager) -> None:
|
||||
"the new-connection path was not reached"
|
||||
assert "hunter22" not in json.dumps(manager.config)
|
||||
assert "hunter22" not in manager.config_path.read_text()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Disconnect takes the saved profile down
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _profile_nmcli(profiles: dict):
|
||||
"""A fake subprocess.run for `nmcli connection show` over ``profiles``
|
||||
(profile name -> SSID), recording every command it is given."""
|
||||
commands = []
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
commands.append(cmd)
|
||||
if cmd == ["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"]:
|
||||
lines = [name.replace(":", "\\:") + ":802-11-wireless" for name in profiles]
|
||||
lines.append("Wired connection 1:802-3-ethernet")
|
||||
return _ok(stdout="\n".join(lines) + "\n")
|
||||
if cmd[:4] == ["nmcli", "-g", "802-11-wireless.ssid", "connection"]:
|
||||
return _ok(stdout=profiles.get(cmd[-1], "") + "\n")
|
||||
if cmd[:3] == ["nmcli", "connection", "show"]:
|
||||
return _ok() if cmd[3] in profiles else _fail()
|
||||
# nmcli rejects 802-11-wireless.ssid as a `connection show -f` column.
|
||||
if "802-11-wireless.ssid" in cmd:
|
||||
return _fail(stderr="Error: invalid field '802-11-wireless.ssid'")
|
||||
return _ok()
|
||||
|
||||
return fake_run, commands
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_find_profile_for_ssid_matches_by_ssid_not_name(manager: WiFiManager) -> None:
|
||||
fake_run, _ = _profile_nmcli({"home: upstairs": "HomeNet", "Office": "OfficeNet"})
|
||||
with patch("src.wifi_manager.subprocess.run", side_effect=fake_run):
|
||||
assert manager._find_profile_for_ssid("HomeNet") == "home: upstairs"
|
||||
assert manager._find_profile_for_ssid("OfficeNet") == "Office"
|
||||
assert manager._find_profile_for_ssid("Elsewhere") is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_disconnect_takes_the_profile_down(manager: WiFiManager) -> None:
|
||||
from src.wifi_manager import WiFiStatus
|
||||
|
||||
fake_run, commands = _profile_nmcli({"Home profile": "HomeNet"})
|
||||
with patch("src.wifi_manager.subprocess.run", side_effect=fake_run), \
|
||||
patch("src.wifi_manager.time.sleep"), \
|
||||
patch.object(manager, "get_wifi_status",
|
||||
return_value=WiFiStatus(connected=True, ssid="HomeNet")):
|
||||
ok, _ = manager.disconnect_from_network(skip_ap_check=True)
|
||||
|
||||
assert ok
|
||||
assert ["nmcli", "connection", "down", "Home profile"] in commands
|
||||
assert ["nmcli", "device", "disconnect", "wlan0"] in commands
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. The nmcli Wi-Fi list parser both scan paths share
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_nmcli_wifi_list_parsing() -> None:
|
||||
out = (
|
||||
"HomeNet:40:WPA2:2437 MHz\n"
|
||||
"Cafe:80::5180 MHz\n"
|
||||
"HomeNet:90:WPA2:5180 MHz\n" # duplicate SSID: first line wins
|
||||
":70:WPA2:2412 MHz\n" # hidden network
|
||||
"Broken:notanumber:WPA2:2412 MHz\n"
|
||||
"Modern:60:WPA3 SAE:5745 MHz\n"
|
||||
)
|
||||
networks = WiFiManager._parse_nmcli_wifi_list(out)
|
||||
assert [(n.ssid, n.signal, n.security, n.frequency) for n in networks] == [
|
||||
("Cafe", 80, "open", 5180.0),
|
||||
("Modern", 60, "wpa3", 5745.0),
|
||||
("HomeNet", 40, "wpa2", 2437.0),
|
||||
]
|
||||
|
||||
@@ -21,10 +21,7 @@ class TestPluginOperationsIntegration(unittest.TestCase):
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
# Initialize components
|
||||
self.operation_queue = PluginOperationQueue(
|
||||
history_file=str(self.temp_dir / "operations.json"),
|
||||
max_history=100
|
||||
)
|
||||
self.operation_queue = PluginOperationQueue(max_history=100)
|
||||
|
||||
self.state_manager = PluginStateManager(
|
||||
state_file=str(self.temp_dir / "state.json"),
|
||||
|
||||
Reference in New Issue
Block a user