mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-07 11:48:06 +00:00
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
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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 = {
|
||||
"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}
|
||||
Reference in New Issue
Block a user