Files
LEDMatrix/test/test_compatibility.py
T
Claude dfba88b599 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
2026-08-06 22:30:48 +00:00

272 lines
11 KiB
Python

"""
Tests for src/plugin_system/compatibility.py — the "can this plugin run on
this core?" gate used by both the plugin loader (advisory) and the store
manager (blocking at install/update time).
This module had zero direct test coverage despite guarding every install.
These tests pin the documented contract: refuse only on evidence, resolve
every uncertain case (unparseable versions, missing fields, untrustworthy
core) to compatible.
"""
import pytest
from src.plugin_system.compatibility import (
TRUSTWORTHY_FLOOR,
parse_semver,
_parse_strict,
_satisfies_range,
satisfies_compatible_versions,
declared_min_version,
check,
)
class TestParseSemver:
def test_plain_triplet(self):
assert parse_semver("1.2.3") == (1, 2, 3)
def test_leading_v_tolerated(self):
assert parse_semver("v3.2.1") == (3, 2, 1)
def test_prerelease_suffix_stripped(self):
# "3.2.0-rc1" must NOT parse as (3, 2, 1) — a release candidate must
# not rank above its own release.
assert parse_semver("3.2.0-rc1") == (3, 2, 0)
def test_build_suffix_stripped(self):
# "3.2.0+build42" must NOT parse as (3, 2, 42).
assert parse_semver("3.2.0+build42") == (3, 2, 0)
def test_two_part_version_pads_zero(self):
assert parse_semver("1.2") == (1, 2, 0)
def test_one_part_version_pads_zeros(self):
assert parse_semver("2") == (2, 0, 0)
def test_extra_parts_ignored(self):
assert parse_semver("1.2.3.4") == (1, 2, 3)
def test_non_string_returns_none(self):
assert parse_semver(None) is None
assert parse_semver(123) is None
assert parse_semver((1, 2, 3)) is None
def test_garbage_with_no_digits_is_lenient_zero(self):
# Documented leniency: digit-scraping yields (0, 0, 0) for pure
# garbage. Fine for a floor (0.0.0 never blocks), wrong for ranges —
# which is why ranges go through _parse_strict instead.
assert parse_semver("garbage") == (0, 0, 0)
def test_whitespace_stripped(self):
assert parse_semver(" 1.2.3 ") == (1, 2, 3)
class TestParseStrict:
def test_accepts_real_versions(self):
assert _parse_strict("1.2.3") == (1, 2, 3)
assert _parse_strict("v1.2.3-rc1") == (1, 2, 3)
assert _parse_strict("2.0") == (2, 0, 0)
def test_rejects_garbage(self):
assert _parse_strict("not-a-version") is None
assert _parse_strict("") is None
def test_rejects_non_string(self):
assert _parse_strict(None) is None
class TestSatisfiesRange:
CORE = (3, 1, 0)
@pytest.mark.parametrize("spec,expected", [
(">=3.0.0", True),
(">=3.1.0", True),
(">=3.2.0", False),
("<=3.1.0", True),
("<=3.0.9", False),
(">3.0.9", True),
(">3.1.0", False),
("<3.2.0", True),
("<3.1.0", False),
])
def test_comparison_operators(self, spec, expected):
assert _satisfies_range(self.CORE, spec) is expected
def test_tilde_allows_patch_only(self):
# ~3.1.0 means >=3.1.0, <3.2.0
assert _satisfies_range((3, 1, 5), "~3.1.0") is True
assert _satisfies_range((3, 2, 0), "~3.1.0") is False
assert _satisfies_range((3, 0, 9), "~3.1.0") is False
def test_caret_allows_minor_and_patch(self):
# ^3.1.0 means >=3.1.0, <4.0.0
assert _satisfies_range((3, 9, 9), "^3.1.0") is True
assert _satisfies_range((4, 0, 0), "^3.1.0") is False
assert _satisfies_range((3, 0, 0), "^3.1.0") is False
def test_bare_exact_version(self):
assert _satisfies_range((3, 1, 0), "3.1.0") is True
assert _satisfies_range((3, 1, 1), "3.1.0") is False
def test_inclusive_dash_range(self):
assert _satisfies_range((2, 5, 0), "2.0.0 - 3.1.0") is True
assert _satisfies_range((2, 0, 0), "2.0.0 - 3.1.0") is True
assert _satisfies_range((3, 1, 0), "2.0.0 - 3.1.0") is True
assert _satisfies_range((3, 1, 1), "2.0.0 - 3.1.0") is False
def test_unparseable_spec_returns_none_not_false(self):
# Garbage must read as "no evidence", never as a refusal — an
# unrecognised spelling must not cost a user a working install.
assert _satisfies_range(self.CORE, "banana") is None
assert _satisfies_range(self.CORE, ">=banana") is None
assert _satisfies_range(self.CORE, "") is None
assert _satisfies_range(self.CORE, "banana - 3.0.0") is None
class TestSatisfiesCompatibleVersions:
def test_any_entry_satisfying_wins(self):
manifest = {"compatible_versions": ["<1.0.0", ">=3.0.0"]}
assert satisfies_compatible_versions(manifest, (3, 1, 0)) is True
def test_all_entries_failing_is_false(self):
manifest = {"compatible_versions": ["<1.0.0", "2.0.0 - 2.9.9"]}
assert satisfies_compatible_versions(manifest, (3, 1, 0)) is False
def test_absent_field_returns_none(self):
assert satisfies_compatible_versions({}, (3, 1, 0)) is None
def test_empty_list_returns_none(self):
assert satisfies_compatible_versions(
{"compatible_versions": []}, (3, 1, 0)) is None
def test_non_list_returns_none(self):
assert satisfies_compatible_versions(
{"compatible_versions": ">=2.0.0"}, (3, 1, 0)) is None
def test_all_unparseable_entries_returns_none(self):
manifest = {"compatible_versions": ["banana", 42, None]}
assert satisfies_compatible_versions(manifest, (3, 1, 0)) is None
def test_mixed_parseable_and_garbage_uses_parseable(self):
manifest = {"compatible_versions": ["banana", ">=3.0.0"]}
assert satisfies_compatible_versions(manifest, (3, 1, 0)) is True
class TestDeclaredMinVersion:
def test_top_level_field(self):
assert declared_min_version({"min_ledmatrix_version": "2.1.0"}) == "2.1.0"
def test_requires_dict_fallback(self):
manifest = {"requires": {"min_ledmatrix_version": "2.2.0"}}
assert declared_min_version(manifest) == "2.2.0"
def test_versions_array_fallback(self):
manifest = {"versions": [{"ledmatrix_min_version": "2.3.0"}]}
assert declared_min_version(manifest) == "2.3.0"
def test_versions_array_deprecated_spelling(self):
manifest = {"versions": [{"ledmatrix_min": "2.4.0"}]}
assert declared_min_version(manifest) == "2.4.0"
def test_top_level_wins_over_versions_array(self):
manifest = {
"min_ledmatrix_version": "2.1.0",
"versions": [{"ledmatrix_min_version": "9.9.9"}],
}
assert declared_min_version(manifest) == "2.1.0"
def test_requires_as_list_does_not_raise(self):
# A hand-edited manifest can carry `requires` as a list; this used to
# raise AttributeError and one malformed manifest would take down the
# whole install path.
assert declared_min_version({"requires": ["something"]}) is None
def test_versions_as_dict_does_not_raise(self):
# Same for `versions` as a mapping (used to raise KeyError).
assert declared_min_version({"versions": {"0": {}}}) is None
def test_nothing_declared_returns_none(self):
assert declared_min_version({}) is None
class TestCheck:
def test_compatible_when_nothing_declared(self):
assert check({}, "3.1.0") == (True, None)
def test_min_version_blocks_older_core(self):
manifest = {"name": "Test Plugin", "min_ledmatrix_version": "3.2.0"}
ok, reason = check(manifest, "3.1.0")
assert ok is False
assert "3.2.0" in reason and "3.1.0" in reason
def test_min_version_allows_equal_core(self):
manifest = {"min_ledmatrix_version": "3.1.0"}
assert check(manifest, "3.1.0") == (True, None)
def test_compatible_versions_upper_bound_blocks(self):
# A range is the only field that can express "not compatible with
# newer cores" — it must win even when the floor passes.
manifest = {
"name": "Old Plugin",
"min_ledmatrix_version": "2.0.0",
"compatible_versions": ["2.0.0 - 2.9.9"],
}
ok, reason = check(manifest, "3.1.0")
assert ok is False
assert "2.0.0 - 2.9.9" in reason
def test_unparseable_core_version_is_compatible(self):
manifest = {"min_ledmatrix_version": "3.2.0",
"compatible_versions": [">=3.2.0"]}
# An unparseable core version is "unknown", not "old"... but note
# parse_semver("garbage") == (0,0,0) which is below TRUSTWORTHY_FLOOR,
# so this rides the untrustworthy-core branch: floor > 2.0.0 blocks.
ok, reason = check(manifest, "garbage")
assert ok is False
assert "too old to identify reliably" in reason
def test_untrustworthy_core_allows_ecosystem_baseline_floor(self):
# A core reporting 1.0.0 may really be v3.1.0 (which shipped with a
# wrong __version__). Floors at or below TRUSTWORTHY_FLOOR must not
# block, or that population could install nothing.
manifest = {"min_ledmatrix_version": "2.0.0",
"compatible_versions": [">=2.0.0"]}
assert check(manifest, "1.0.0") == (True, None)
def test_untrustworthy_core_blocks_floor_above_baseline(self):
# But a floor above 2.0.0 needs modules that no core reporting below
# the floor can have — the one refusal on that branch.
manifest = {"name": "New Plugin", "min_ledmatrix_version": "3.2.0"}
ok, reason = check(manifest, "1.0.0")
assert ok is False
assert "too old to identify reliably" in reason
def test_untrustworthy_core_ignores_compatible_versions(self):
# On the untrustworthy branch only the declared floor is consulted;
# ranges cannot be evaluated against a version that isn't evidence.
manifest = {"compatible_versions": ["2.0.0 - 2.9.9"]}
assert check(manifest, "1.0.0") == (True, None)
def test_floor_exactly_at_trustworthy_floor_is_allowed(self):
floor = ".".join(str(n) for n in TRUSTWORTHY_FLOOR)
manifest = {"min_ledmatrix_version": floor}
assert check(manifest, "1.0.0") == (True, None)
def test_reason_uses_manifest_name(self):
manifest = {"name": "Fancy Clock", "min_ledmatrix_version": "9.0.0"}
ok, reason = check(manifest, "3.1.0")
assert ok is False
assert reason.startswith("Fancy Clock")
def test_reason_falls_back_to_id(self):
manifest = {"id": "fancy-clock", "min_ledmatrix_version": "9.0.0"}
ok, reason = check(manifest, "3.1.0")
assert ok is False
assert reason.startswith("fancy-clock")
def test_prerelease_core_compares_equal_to_release(self):
# Documented: prereleases compare equal to their release.
manifest = {"min_ledmatrix_version": "3.2.0"}
assert check(manifest, "3.2.0-rc1") == (True, None)