mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-08 12:18:06 +00:00
* ci: run the whole test tree and make the plugin-safety job assert something real The unit-tests CI job ran an explicit 24-file allowlist that had rotted: 63 of 90 test files (display, vegas, store manager, web API, web_interface) never ran on a PR. The job now runs all of test/ (minus test/plugins, which the plugin-safety job owns) so new test files are enrolled by default and any exclusion needs a visible, commented --ignore. The plugin-safety job was a green no-op: plugins/ is empty in CI, so every test skipped with 'Manifest not found'. It now renders a bundled deterministic fixture plugin (test/fixtures/plugins/ci-fixture-plugin, golden images included for all 8 default sizes) via LEDMATRIX_PLUGINS_DIR, and sets LEDMATRIX_REQUIRE_PLUGINS=1 so discovering zero plugins fails loudly instead of skipping green. The per-plugin suites document that they target dev machines with real plugins installed. Coverage is now measured and enforced in exactly one place — the CI unit-tests step (--cov=src --cov=web_interface --cov-fail-under=45, from a measured 47% baseline). pytest.ini previously declared --cov-fail-under=30 but CI always passed --no-cov, so the gate had never run anywhere; local pytest is now coverage-free and fast. Enabling the 63 unenrolled files surfaced three cases of test rot, fixed here: test_display_controller_vegas_tick.py could not collect without the hardware rgbmatrix module (now uses the emulator convention), the state-reconciliation unrecoverable-cache tests broke when production added the is_plugin_uninstalled tombstone check (bare Mock returned truthy), and test_get_system_status assumed the optional psutil dependency (now installed via requirements-test.txt and guarded by importorskip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * test: replace can't-fail tests with real assertions test_font_manager.py was 5 of 6 tests shaped as 'try: call(); assert True / except: assert True' — running in CI while unable to fail on any regression. Rewritten against the real FontManager API and the bundled assets/fonts: returned font types, cache-hit identity, distinct entries per size, default-font fallback for unknown families and corrupt files (recorded in failed_loads), BDF native-size reading, text measurement, and cache lifecycle. test_display_manager.py's test_draw_text ended in 'assert True'; it now renders onto a known-black canvas and asserts pixels were actually lit — which required un-breaking the fixture's freetype MagicMock so draw_text's isinstance check doesn't silently swallow the draw. test_display_controller.py carried a permanently-skipped test whose skip reason already declared it redundant; deleted. Both display test files now set EMULATOR=true before importing display_manager (the same convention as test_display_dirty_tracking.py) so they collect standalone instead of depending on which test module imports display_manager first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * test: cover the untested fragile logic (compatibility gate, secrets, config merges, durations, skin cards) New unit tests for pure or filesystem-only logic that previously had zero direct coverage: - test_compatibility.py: the semver install gate (parse_semver suffix handling, every range operator, TRUSTWORTHY_FLOOR behavior for cores reporting untrustworthy versions, 'more restrictive wins', and the malformed-manifest shapes that used to raise). - test/web_interface/test_secret_helpers.py: the canonical x-secret helpers — find/separate/mask/remove, array-item secrets, no input mutation, and a separate->recombine round-trip. - test/web_interface/test_api_v3_helpers.py: the module-level helpers behind the plugin config save endpoint (_is_plugin_update_available, _coerce_to_bool including the int==1 quirk, deep_merge including its shared-subtree shallowness, _parse_form_value, dotted-key-aware _get_schema_property/_set_nested_value). - test_base_plugin_duration.py: get_display_duration's full coercion ladder (instance attr -> config -> 15.0), including the bool-is-int quirk where display_duration=True means one second. - test_config_manager_secrets.py: the secrets round-trip — deep-merge on load, strip on save, group pruning, the load fast path — and two characterized sharp edges marked SUSPECTED BUG: an unreadable secrets file at save time writes secrets into config.json in plaintext, and a same-mtime-same-size content swap is served stale. - test_schema_manager_merge.py: merge_with_defaults branch behavior (None replacement vs falsey preservation, dict-vs-scalar mismatches, arrays replaced wholesale, defaults never mutated). - test_skin_system.py (extended): render_skin_card shares _render_game's 3-strike counter but never resets it on success — the asymmetry is pinned in both directions, along with card fallthrough and the disable interaction between the two paths. Suspected bugs are characterized, not fixed — each carries a comment so a future behavior change is deliberate rather than accidental. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * test: add drift guards for cross-file contracts Three guard suites that pin contracts spanning multiple files, where one side changing unilaterally breaks the other silently: - test_version_comparison_consistency.py: the repo's four version comparators (compatibility.parse_semver, api_v3's packaging-based _is_plugin_update_available, store_manager update_plugin's raw string equality, skin_runtime._major) answer differently on the same inputs. A table pins each one's verdict; update_plugin is driven through its real code path to show the SUSPECTED BUGs: 'v1.2.0' vs '1.2.0' triggers a full reinstall the UI calls unnecessary, and a locally-ahead plugin gets downgraded. A pairwise-ordering check keeps parse_semver agreeing with packaging on plain X.Y.Z. - test/web_interface/test_secret_separation_parity.py: api_v3.py carries three inline copies of find_secret_fields/separate_secrets that lack the canonical module's array-item support. The copy count is asserted exact (it may only go down; new copies must import src/web_interface/secret_helpers), the missing-array-support gap is asserted so it can't grow silently, and the canonical behavior that migration will adopt is documented executably. - test_discovery_path_contract.py: the three 'where is plugin X' resolvers (PluginManager discovery, StoreManager._find_plugin_path, SchemaManager.get_schema_path) agree on the configured directory, and their divergent fallback chains are characterized. Also pins the .standalone-backup- naming contract shared by store rollback and discovery, and _resolve_skin_target's path-traversal rejection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * test: address review feedback — fixture lifecycle, test names, ClassVar - ci-fixture-plugin: call display_manager.clear() before rendering (per plugin guidelines — the fixture should model a well-behaved plugin), add a class docstring, and document why Pillow is deliberately not pinned in its requirements.txt (core dependency; harness installs nothing). - Rename two tests whose names contradicted their assertions: test_unparseable_core_version_is_compatible -> test_unparseable_core_with_high_floor_is_blocked, and test_unreadable_secrets_file... -> test_corrupt_secrets_file... - Annotate TestGetSchemaProperty.SCHEMA as ClassVar (RUF012). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * ci: allow manual test.yml runs via workflow_dispatch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * fix: unify version comparison, refuse secret-leaking saves, reset skin strikes on card success Fixes the three suspected bugs this PR's characterization tests pinned, flipping those tests to assert the corrected behavior: - plugins/store: ONE shared update comparator. New compatibility.is_update_available() (PEP 440 via packaging) is now used by both the web UI's update badge (api_v3._is_plugin_update_available is a thin alias) and store_manager.update_plugin's reinstall decision. Previously update_plugin used raw string equality: 'v1.2.0' vs '1.2.0' triggered a full reinstall the UI called unnecessary, and a locally- ahead plugin (2.0.0 installed, registry 1.9.0) was silently DOWNGRADED. Now equivalent spellings skip the reinstall and locally-ahead versions are never downgraded; unparseable versions still reconcile by reinstalling from the registry. - config: save_config and save_config_atomic now refuse (ConfigError) when config_secrets.json exists but cannot be loaded. Both previously proceeded without stripping, writing the merged secrets into config.json in plaintext. The shared _load_secrets_for_save() helper raises with an actionable message instead; a missing secrets file is still fine (nothing to strip), and _migrate_config's catch-all keeps boot resilient. - skins: render_skin_card resets _skin_failures on both success paths (vegas card returned, or mode renderer handled), mirroring _render_game. Transient card failures no longer accumulate across a session until they permanently disable a working skin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh * fix: harden shared comparator edges from review - is_update_available: reject truthy non-string versions (a malformed manifest can carry a number; packaging raises TypeError on those) by surfacing the mismatch instead of raising. - store_manager.update_plugin: drop the truthiness gate around the comparator so a missing version on either side follows the shared 'no update' verdict, keeping the store consistent with the UI badge; a missing manifest still uses the reinstall recovery path. - config_manager._load_secrets_for_save: catch only expected read/parse failures (OSError/ValueError/RecursionError) so implementation bugs propagate as themselves, and log with traceback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh --------- Co-authored-by: Claude <noreply@anthropic.com>
233 lines
8.7 KiB
Python
233 lines
8.7 KiB
Python
"""
|
|
Unit tests for the module-level helper functions in
|
|
web_interface/blueprints/api_v3.py.
|
|
|
|
These helpers back the plugin config save endpoint (the largest function in
|
|
the repo) and the store's update-available detection, but were previously
|
|
exercised only indirectly through full Flask route tests. Testing them
|
|
directly pins behavior that the routes rely on — including a few
|
|
characterized quirks marked below.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar, Dict
|
|
|
|
import pytest
|
|
|
|
project_root = Path(__file__).parent.parent.parent
|
|
sys.path.insert(0, str(project_root))
|
|
|
|
from web_interface.blueprints.api_v3 import ( # noqa: E402
|
|
_is_plugin_update_available,
|
|
_coerce_to_bool,
|
|
deep_merge,
|
|
_parse_form_value,
|
|
_get_schema_property,
|
|
_set_nested_value,
|
|
)
|
|
|
|
|
|
class TestIsPluginUpdateAvailable:
|
|
def test_equal_versions_no_update(self):
|
|
assert _is_plugin_update_available("1.2.0", "1.2.0") is False
|
|
|
|
def test_newer_registry_version_needs_update(self):
|
|
assert _is_plugin_update_available("1.2.0", "1.3.0") is True
|
|
|
|
def test_installed_ahead_of_registry_no_update(self):
|
|
# A locally modified plugin ahead of the registry must not be
|
|
# flagged — this is the whole point of semantic comparison here.
|
|
assert _is_plugin_update_available("2.0.0", "1.9.0") is False
|
|
|
|
def test_empty_versions_no_update(self):
|
|
assert _is_plugin_update_available("", "1.0.0") is False
|
|
assert _is_plugin_update_available("1.0.0", "") is False
|
|
assert _is_plugin_update_available("", "") is False
|
|
|
|
def test_v_prefix_parses_as_equal(self):
|
|
# packaging.version treats "v1.2.0" == "1.2.0" (PEP 440 tolerates the
|
|
# prefix), so no update is flagged. Contrast with store_manager's
|
|
# string-equality check — see test_version_comparison_consistency.py.
|
|
assert _is_plugin_update_available("v1.2.0", "1.2.0") is False
|
|
|
|
def test_two_part_version_parses_as_equal(self):
|
|
assert _is_plugin_update_available("1.2", "1.2.0") is False
|
|
|
|
def test_unparseable_version_surfaces_mismatch(self):
|
|
# Direction unknowable → surface the difference rather than hide a
|
|
# potential update.
|
|
assert _is_plugin_update_available("abc.def", "1.0.0") is True
|
|
|
|
def test_prerelease_below_release(self):
|
|
assert _is_plugin_update_available("1.2.0-rc1", "1.2.0") is True
|
|
|
|
|
|
class TestCoerceToBool:
|
|
@pytest.mark.parametrize("value", ["true", "TRUE", "on", "1", "yes", "YES"])
|
|
def test_truthy_strings(self, value):
|
|
assert _coerce_to_bool(value) is True
|
|
|
|
@pytest.mark.parametrize("value", ["false", "off", "0", "no", "", "banana"])
|
|
def test_falsey_strings(self, value):
|
|
assert _coerce_to_bool(value) is False
|
|
|
|
def test_none_is_false(self):
|
|
assert _coerce_to_bool(None) is False
|
|
|
|
def test_bools_pass_through(self):
|
|
assert _coerce_to_bool(True) is True
|
|
assert _coerce_to_bool(False) is False
|
|
|
|
def test_int_only_one_is_true(self):
|
|
# Characterized quirk: ints coerce via `value == 1`, so 2 (truthy in
|
|
# Python) is False here.
|
|
assert _coerce_to_bool(1) is True
|
|
assert _coerce_to_bool(2) is False
|
|
assert _coerce_to_bool(0) is False
|
|
|
|
def test_other_types_false(self):
|
|
assert _coerce_to_bool([1]) is False
|
|
assert _coerce_to_bool({"a": 1}) is False
|
|
|
|
|
|
class TestDeepMerge:
|
|
def test_nested_dicts_merge_recursively(self):
|
|
base = {"a": {"x": 1, "y": 2}, "b": 1}
|
|
update = {"a": {"y": 3, "z": 4}}
|
|
assert deep_merge(base, update) == {"a": {"x": 1, "y": 3, "z": 4}, "b": 1}
|
|
|
|
def test_scalar_over_dict_replaces(self):
|
|
assert deep_merge({"a": {"x": 1}}, {"a": 5}) == {"a": 5}
|
|
|
|
def test_dict_over_scalar_replaces(self):
|
|
assert deep_merge({"a": 5}, {"a": {"x": 1}}) == {"a": {"x": 1}}
|
|
|
|
def test_lists_replaced_wholesale(self):
|
|
assert deep_merge({"a": [1, 2]}, {"a": [3]}) == {"a": [3]}
|
|
|
|
def test_top_level_not_mutated_but_shallow_copy(self):
|
|
# Characterized: result = base.copy() protects base's top level, but
|
|
# nested dicts NOT touched by the update are shared by reference.
|
|
base = {"a": {"x": 1}, "keep": {"y": 2}}
|
|
result = deep_merge(base, {"a": {"x": 9}})
|
|
assert base == {"a": {"x": 1}, "keep": {"y": 2}} # base unchanged
|
|
assert result["keep"] is base["keep"] # untouched subtree is shared
|
|
|
|
|
|
class TestParseFormValue:
|
|
def test_boolean_strings(self):
|
|
assert _parse_form_value("true") is True
|
|
assert _parse_form_value("False") is False
|
|
|
|
def test_null_like_strings(self):
|
|
assert _parse_form_value("null") is None
|
|
assert _parse_form_value("none") is None
|
|
assert _parse_form_value("") is None
|
|
|
|
def test_none_passthrough(self):
|
|
assert _parse_form_value(None) is None
|
|
|
|
def test_numbers(self):
|
|
assert _parse_form_value("42") == 42
|
|
assert isinstance(_parse_form_value("42"), int)
|
|
assert _parse_form_value("3.5") == 3.5
|
|
assert isinstance(_parse_form_value("3.5"), float)
|
|
|
|
def test_json_array_parsed_before_numbers(self):
|
|
# RGB arrays like "[255, 0, 0]" must come back as lists.
|
|
assert _parse_form_value("[255, 0, 0]") == [255, 0, 0]
|
|
|
|
def test_json_object(self):
|
|
assert _parse_form_value('{"a": 1}') == {"a": 1}
|
|
|
|
def test_malformed_json_falls_back_to_string(self):
|
|
assert _parse_form_value("[not json") == "[not json"
|
|
|
|
def test_plain_string_returned_unstripped(self):
|
|
# The original value (not the stripped copy) is returned.
|
|
assert _parse_form_value(" hello ") == " hello "
|
|
|
|
def test_non_string_passthrough(self):
|
|
assert _parse_form_value(7) == 7
|
|
assert _parse_form_value([1, 2]) == [1, 2]
|
|
|
|
|
|
class TestGetSchemaProperty:
|
|
SCHEMA: ClassVar[Dict[str, Any]] = {
|
|
"properties": {
|
|
"brightness": {"type": "integer"},
|
|
"customization": {
|
|
"type": "object",
|
|
"properties": {
|
|
"time_text": {
|
|
"type": "object",
|
|
"properties": {"font": {"type": "string"}},
|
|
},
|
|
},
|
|
},
|
|
"fifa.world": {"type": "object",
|
|
"properties": {"enabled": {"type": "boolean"}}},
|
|
}
|
|
}
|
|
|
|
def test_top_level_lookup(self):
|
|
assert _get_schema_property(self.SCHEMA, "brightness") == {"type": "integer"}
|
|
|
|
def test_nested_dot_path(self):
|
|
prop = _get_schema_property(self.SCHEMA, "customization.time_text.font")
|
|
assert prop == {"type": "string"}
|
|
|
|
def test_dotted_schema_key_matched_longest_first(self):
|
|
# League keys like "fifa.world" contain a literal dot and must match
|
|
# as a single key, not be split into nested fifa -> world lookups.
|
|
prop = _get_schema_property(self.SCHEMA, "fifa.world.enabled")
|
|
assert prop == {"type": "boolean"}
|
|
|
|
def test_missing_path_returns_none(self):
|
|
assert _get_schema_property(self.SCHEMA, "nope.nope") is None
|
|
|
|
def test_no_properties_returns_none(self):
|
|
assert _get_schema_property({}, "a") is None
|
|
assert _get_schema_property(None, "a") is None
|
|
|
|
|
|
class TestSetNestedValue:
|
|
def test_sets_top_level(self):
|
|
config = {}
|
|
_set_nested_value(config, "brightness", 80)
|
|
assert config == {"brightness": 80}
|
|
|
|
def test_creates_intermediate_dicts(self):
|
|
config = {}
|
|
_set_nested_value(config, "customization.time_text.font", "5x7")
|
|
assert config == {"customization": {"time_text": {"font": "5x7"}}}
|
|
|
|
def test_merges_into_existing_nested_dict(self):
|
|
config = {"customization": {"color": "red"}}
|
|
_set_nested_value(config, "customization.font", "5x7")
|
|
assert config == {"customization": {"color": "red", "font": "5x7"}}
|
|
|
|
def test_scalar_intermediate_replaced_with_dict(self):
|
|
# Characterized: a non-dict intermediate is silently replaced.
|
|
config = {"customization": "oops"}
|
|
_set_nested_value(config, "customization.font", "5x7")
|
|
assert config == {"customization": {"font": "5x7"}}
|
|
|
|
def test_existing_dotted_key_preserved(self):
|
|
# An existing literal "fifa.world" key must be updated in place, not
|
|
# exploded into nested {"fifa": {"world": ...}}.
|
|
config = {"fifa.world": {"enabled": False}}
|
|
_set_nested_value(config, "fifa.world.enabled", True)
|
|
assert config == {"fifa.world": {"enabled": True}}
|
|
|
|
def test_none_does_not_overwrite_existing(self):
|
|
config = {"a": 1}
|
|
_set_nested_value(config, "a", None)
|
|
assert config == {"a": 1}
|
|
|
|
def test_none_sets_missing_key(self):
|
|
config = {}
|
|
_set_nested_value(config, "a", None)
|
|
assert config == {"a": None}
|