mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 23:35:08 +00:00
Whole-tree audit. Every symbol was checked against core, the plugin monorepo and all eight third-party plugins in plugins.json first. - Deprecate (removal 3.10.0) plugin-facing methods nothing calls: LogoDownloader bulk download, ConfigManager backup/secret wrappers, APIHelper extras, BackgroundDataService poll API, PluginManager / PluginStateManager info readers, and a few CacheManager, FontManager, BaseOddsManager, DynamicTeamResolver methods and PluginTestCase. plugin_api_usage.py learns their receiver names; DEPRECATIONS doc regenerated. - Remove core-internal dead code: CacheMetrics, Vegas status/stats plumbing, sync "new cycle" message (followers ignore unknown types), unused operation types, test-only PluginCatalog readers, IPC to_dict and ping, _parse_form_value, CacheStrategyProtocol, ErrorAggregator callbacks, duplicate web response helpers. - Web UI: drop never-mounted json-file-manager.js, the example widget, utils/error_handler.js, four uncalled PluginAPI methods, and 29 escapeHtml shims (call window.LEDEscape directly). Public globals, BaseWidget and widget names unchanged. - Remove six one-off scripts (owner decision) and the unused markupsafe and pytest-mock pins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
8.5 KiB
Python
217 lines
8.5 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,
|
|
_get_schema_property,
|
|
_set_nested_value,
|
|
_SKIP_FIELD,
|
|
)
|
|
|
|
|
|
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 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"}}},
|
|
"cities": {"type": "array",
|
|
"items": {"type": "object",
|
|
"properties": {"timezone": {"type": "string"}}}},
|
|
"color": {"type": ["array", "null"], "items": {"type": "integer"}},
|
|
}
|
|
}
|
|
|
|
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_an_index_steps_into_the_array_items(self):
|
|
# How a table row posts its cells
|
|
assert _get_schema_property(self.SCHEMA, "cities.0.timezone") == {"type": "string"}
|
|
assert _get_schema_property(self.SCHEMA, "color.2") == {"type": "integer"}
|
|
|
|
def test_a_non_index_under_an_array_is_not_found(self):
|
|
assert _get_schema_property(self.SCHEMA, "cities.timezone") is None
|
|
assert _get_schema_property(self.SCHEMA, "cities.0.nope") is None
|
|
|
|
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_overwrites_existing(self):
|
|
# Regression: None is a real value here (the per-mode "inherit the
|
|
# base" override for a nullable field, or a blank indexed color
|
|
# channel) and must replace whatever was already stored. Only the
|
|
# _SKIP_FIELD sentinel means "leave it alone" -- see the next test.
|
|
config = {"a": 1}
|
|
_set_nested_value(config, "a", None)
|
|
assert config == {"a": None}
|
|
|
|
def test_none_sets_missing_key(self):
|
|
config = {}
|
|
_set_nested_value(config, "a", None)
|
|
assert config == {"a": None}
|
|
|
|
def test_skip_field_does_not_overwrite_existing(self):
|
|
config = {"a": 1}
|
|
_set_nested_value(config, "a", _SKIP_FIELD)
|
|
assert config == {"a": 1}
|