diff --git a/test/test_base_plugin_duration.py b/test/test_base_plugin_duration.py new file mode 100644 index 00000000..4039e1fc --- /dev/null +++ b/test/test_base_plugin_duration.py @@ -0,0 +1,110 @@ +""" +Tests for BasePlugin.get_display_duration — ~100 lines of type coercion that +every plugin's rotation slot depends on, previously untested. + +The contract: a positive number wins wherever it comes from; everything else +falls through instance attr → config → the 15.0 default, logging on the way. +""" + +from unittest.mock import MagicMock + +import pytest + +from src.plugin_system.base_plugin import BasePlugin + + +class _MinimalPlugin(BasePlugin): + def update(self): + pass + + def display(self, force_clear=False): + pass + + +def make_plugin(config=None, instance_duration="__unset__"): + plugin = _MinimalPlugin( + plugin_id="duration-test", + config=config or {}, + display_manager=MagicMock(), + cache_manager=MagicMock(), + plugin_manager=MagicMock(), + ) + if instance_duration != "__unset__": + plugin.display_duration = instance_duration + return plugin + + +class TestInstanceVariable: + def test_positive_int_wins(self): + assert make_plugin(instance_duration=30).get_display_duration() == 30.0 + + def test_positive_float_wins(self): + assert make_plugin(instance_duration=12.5).get_display_duration() == 12.5 + + def test_returns_float_type(self): + result = make_plugin(instance_duration=30).get_display_duration() + assert isinstance(result, float) + + def test_numeric_string_wins(self): + assert make_plugin(instance_duration="25").get_display_duration() == 25.0 + + def test_zero_falls_through_to_config(self): + plugin = make_plugin(config={"display_duration": 20}, + instance_duration=0) + assert plugin.get_display_duration() == 20.0 + + def test_negative_falls_through_to_config(self): + plugin = make_plugin(config={"display_duration": 20}, + instance_duration=-5) + assert plugin.get_display_duration() == 20.0 + + def test_none_falls_through_to_config(self): + plugin = make_plugin(config={"display_duration": 20}, + instance_duration=None) + assert plugin.get_display_duration() == 20.0 + + def test_garbage_string_falls_through(self): + plugin = make_plugin(config={"display_duration": 20}, + instance_duration="abc") + assert plugin.get_display_duration() == 20.0 + + def test_non_positive_string_falls_through(self): + plugin = make_plugin(config={"display_duration": 20}, + instance_duration="0") + assert plugin.get_display_duration() == 20.0 + + def test_unexpected_type_falls_through(self): + plugin = make_plugin(config={"display_duration": 20}, + instance_duration=[30]) + assert plugin.get_display_duration() == 20.0 + + def test_bool_true_is_one_second(self): + # Characterized quirk: bool is an int subclass, so display_duration = + # True passes the isinstance((int, float)) branch and returns 1.0. + assert make_plugin(instance_duration=True).get_display_duration() == 1.0 + + +class TestConfigFallback: + def test_config_number(self): + assert make_plugin({"display_duration": 20}).get_display_duration() == 20.0 + + def test_config_numeric_string(self): + assert make_plugin({"display_duration": "12.5"}).get_display_duration() == 12.5 + + def test_missing_config_uses_default(self): + assert make_plugin({}).get_display_duration() == 15.0 + + def test_config_zero_uses_default(self): + assert make_plugin({"display_duration": 0}).get_display_duration() == 15.0 + + def test_config_negative_uses_default(self): + assert make_plugin({"display_duration": -10}).get_display_duration() == 15.0 + + def test_config_garbage_string_uses_default(self): + assert make_plugin({"display_duration": "soon"}).get_display_duration() == 15.0 + + def test_config_unexpected_type_uses_default(self): + assert make_plugin({"display_duration": {"s": 5}}).get_display_duration() == 15.0 + + def test_config_none_uses_default(self): + assert make_plugin({"display_duration": None}).get_display_duration() == 15.0 diff --git a/test/test_compatibility.py b/test/test_compatibility.py new file mode 100644 index 00000000..0de4118a --- /dev/null +++ b/test/test_compatibility.py @@ -0,0 +1,271 @@ +""" +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) diff --git a/test/test_config_manager_secrets.py b/test/test_config_manager_secrets.py new file mode 100644 index 00000000..72365718 --- /dev/null +++ b/test/test_config_manager_secrets.py @@ -0,0 +1,161 @@ +""" +Tests for the ConfigManager secrets round-trip and the load_config fast path. + +The contract under test: config_secrets.json values are deep-merged INTO the +in-memory config at load time, and stripped back OUT before anything is +written to config.json — so secrets live in exactly one file on disk. This +suite pins that round-trip plus its known sharp edges (some marked as +SUSPECTED BUG and characterized rather than fixed). + +Complements test_config_manager.py, which covers loading/migration/validation. +""" + +import json +import os + +import pytest + +from src.config_manager import ConfigManager + + +def make_manager(tmp_path, config=None, secrets=None): + """A ConfigManager over tmp_path files, template migration neutralized.""" + config_file = tmp_path / "config.json" + secrets_file = tmp_path / "config_secrets.json" + config_file.write_text(json.dumps(config if config is not None else {})) + if secrets is not None: + secrets_file.write_text(json.dumps(secrets)) + manager = ConfigManager(config_path=str(config_file), + secrets_path=str(secrets_file)) + # Point the (CWD-relative) template at nothing so migration never runs — + # these tests assert exact on-disk contents. + manager.template_path = str(tmp_path / "no-template.json") + return manager + + +class TestLoadMergesSecrets: + def test_secrets_deep_merged_into_config(self, tmp_path): + manager = make_manager( + tmp_path, + config={"weather": {"city": "Austin"}, "timezone": "UTC"}, + secrets={"weather": {"api_key": "s3cret"}}, + ) + loaded = manager.load_config() + assert loaded["weather"] == {"city": "Austin", "api_key": "s3cret"} + assert loaded["timezone"] == "UTC" + + def test_secret_scalar_overrides_config_value(self, tmp_path): + manager = make_manager( + tmp_path, + config={"weather": {"api_key": "YOUR_API_KEY"}}, + secrets={"weather": {"api_key": "real-key"}}, + ) + assert manager.load_config()["weather"]["api_key"] == "real-key" + + def test_missing_secrets_file_loads_config_fine(self, tmp_path): + manager = make_manager(tmp_path, config={"timezone": "UTC"}) + assert manager.load_config() == {"timezone": "UTC"} + + def test_corrupt_secrets_file_loads_config_without_secrets(self, tmp_path): + manager = make_manager(tmp_path, config={"timezone": "UTC"}) + (tmp_path / "config_secrets.json").write_text("{not json") + loaded = manager.load_config() + assert loaded["timezone"] == "UTC" + + +class TestSaveStripsSecrets: + def test_round_trip_keeps_secrets_out_of_config_json(self, tmp_path): + manager = make_manager( + tmp_path, + config={"weather": {"city": "Austin"}}, + secrets={"weather": {"api_key": "s3cret"}}, + ) + loaded = manager.load_config() + assert loaded["weather"]["api_key"] == "s3cret" # merged in memory + + manager.save_config(loaded) + + on_disk = json.loads((tmp_path / "config.json").read_text()) + assert "api_key" not in on_disk.get("weather", {}) + assert on_disk["weather"]["city"] == "Austin" + # In-memory config still carries the secret for runtime use. + assert manager.config["weather"]["api_key"] == "s3cret" + + def test_group_dropped_when_only_secrets_remain(self, tmp_path): + # _strip_secrets_recursive drops a group entirely when nothing + # non-secret is left in it. + manager = make_manager( + tmp_path, + config={}, + secrets={"weather": {"api_key": "s3cret"}}, + ) + manager.save_config({"weather": {"api_key": "s3cret"}, "timezone": "UTC"}) + on_disk = json.loads((tmp_path / "config.json").read_text()) + assert on_disk == {"timezone": "UTC"} + + def test_scalar_secret_key_stripped_at_top_level(self, tmp_path): + manager = make_manager(tmp_path, config={}, secrets={"token": "t"}) + manager.save_config({"token": "t", "timezone": "UTC"}) + on_disk = json.loads((tmp_path / "config.json").read_text()) + assert on_disk == {"timezone": "UTC"} + + def test_unreadable_secrets_file_writes_secrets_to_config_json(self, tmp_path): + # SUSPECTED BUG (characterized, not fixed): when the secrets file is + # unreadable/corrupt at save time, save_config proceeds without + # stripping — writing the merged secrets into config.json in + # plaintext. The code comments acknowledge the tradeoff (it prevents + # data loss); this test pins the behavior so any future change to it + # is deliberate. + manager = make_manager( + tmp_path, + config={"weather": {"city": "Austin"}}, + secrets={"weather": {"api_key": "s3cret"}}, + ) + loaded = manager.load_config() + (tmp_path / "config_secrets.json").write_text("{corrupt") + + manager.save_config(loaded) + + on_disk = json.loads((tmp_path / "config.json").read_text()) + assert on_disk["weather"].get("api_key") == "s3cret" # leaked + + +class TestLoadFastPath: + def test_unchanged_files_return_cached_dict(self, tmp_path): + manager = make_manager(tmp_path, config={"timezone": "UTC"}) + first = manager.load_config() + second = manager.load_config() + assert second is first # same aliased dict, no re-read + + def test_touching_secrets_file_invalidates_cache(self, tmp_path): + manager = make_manager( + tmp_path, + config={"weather": {}}, + secrets={"weather": {"api_key": "old"}}, + ) + assert manager.load_config()["weather"]["api_key"] == "old" + + secrets_file = tmp_path / "config_secrets.json" + secrets_file.write_text(json.dumps({"weather": {"api_key": "new"}})) + # Force a different mtime_ns in case the write landed within the + # filesystem's timestamp granularity. + os.utime(secrets_file, ns=(1, 1)) + + assert manager.load_config()["weather"]["api_key"] == "new" + + def test_same_mtime_same_size_change_served_stale(self, tmp_path): + # Characterized fast-path blind spot: the signature is (mtime_ns, + # size) only, so a same-length content swap with a forged identical + # mtime is not detected. Real writes bump mtime_ns, so this is + # acceptable — but it is a contract worth pinning. + manager = make_manager(tmp_path, config={"timezone": "AAA"}) + config_file = tmp_path / "config.json" + os.utime(config_file, ns=(1_000_000_000, 1_000_000_000)) + manager._loaded_sig = None + first = manager.load_config() + assert first["timezone"] == "AAA" + + config_file.write_text(json.dumps({"timezone": "BBB"})) # same length + os.utime(config_file, ns=(1_000_000_000, 1_000_000_000)) + + assert manager.load_config()["timezone"] == "AAA" # stale, by design diff --git a/test/test_schema_manager_merge.py b/test/test_schema_manager_merge.py new file mode 100644 index 00000000..64a82c9e --- /dev/null +++ b/test/test_schema_manager_merge.py @@ -0,0 +1,104 @@ +""" +Tests for SchemaManager.merge_with_defaults — the merge every plugin config +passes through at load time (schema defaults + user config, with None +replacement). A regression here silently changes every plugin's effective +config, so the exact branch behavior is pinned, including the +characterized type-mismatch cases. +""" + +import pytest + +from src.plugin_system.schema_manager import SchemaManager + + +@pytest.fixture +def sm(tmp_path): + return SchemaManager(plugins_dir=str(tmp_path)) + + +class TestBasicMerge: + def test_missing_keys_filled_from_defaults(self, sm): + merged = sm.merge_with_defaults( + {"city": "Austin"}, {"city": "NYC", "units": "metric"}) + assert merged == {"city": "Austin", "units": "metric"} + + def test_present_keys_preserved(self, sm): + merged = sm.merge_with_defaults({"enabled": False}, {"enabled": True}) + assert merged["enabled"] is False + + def test_nested_three_level_merge(self, sm): + config = {"a": {"b": {"c": 1}}} + defaults = {"a": {"b": {"c": 0, "d": 2}, "e": 3}} + merged = sm.merge_with_defaults(config, defaults) + assert merged == {"a": {"b": {"c": 1, "d": 2}, "e": 3}} + + def test_inputs_not_mutated(self, sm): + config = {"a": {"b": 1}} + defaults = {"a": {"b": 0, "c": 2}, "d": 3} + sm.merge_with_defaults(config, defaults) + assert config == {"a": {"b": 1}} + assert defaults == {"a": {"b": 0, "c": 2}, "d": 3} + + def test_merged_values_are_copies_not_aliases(self, sm): + config = {"teams": ["DAL"]} + merged = sm.merge_with_defaults(config, {"teams": []}) + merged["teams"].append("HOU") + assert config["teams"] == ["DAL"] # user's list untouched + + +class TestNoneReplacement: + def test_none_replaced_by_default(self, sm): + merged = sm.merge_with_defaults({"units": None}, {"units": "metric"}) + assert merged["units"] == "metric" + + def test_falsey_non_none_values_kept(self, sm): + merged = sm.merge_with_defaults( + {"enabled": False, "count": 0, "label": ""}, + {"enabled": True, "count": 5, "label": "x"}, + ) + assert merged == {"enabled": False, "count": 0, "label": ""} + + def test_nested_none_replaced(self, sm): + merged = sm.merge_with_defaults( + {"style": {"color": None}}, {"style": {"color": "red"}}) + assert merged["style"]["color"] == "red" + + def test_none_with_no_default_stays_none(self, sm): + merged = sm.merge_with_defaults({"extra": None}, {}) + assert merged["extra"] is None + + def test_none_replaced_by_dict_default_is_a_copy(self, sm): + defaults = {"style": {"color": "red"}} + merged = sm.merge_with_defaults({"style": None}, defaults) + assert merged["style"] == {"color": "red"} + merged["style"]["color"] = "blue" + assert defaults["style"]["color"] == "red" + + +class TestTypeMismatches: + def test_user_scalar_over_dict_default_wins(self, sm): + # Characterized: a scalar user value replaces a dict default outright. + merged = sm.merge_with_defaults( + {"style": "compact"}, {"style": {"color": "red"}}) + assert merged["style"] == "compact" + + def test_user_dict_over_scalar_default_wins(self, sm): + merged = sm.merge_with_defaults( + {"style": {"color": "red"}}, {"style": "compact"}) + assert merged["style"] == {"color": "red"} + + def test_arrays_replaced_wholesale_not_merged(self, sm): + # Pinned contract: arrays never element-merge — the user's array is + # the whole answer, even when shorter than the default. + merged = sm.merge_with_defaults( + {"teams": ["DAL"]}, {"teams": ["NYG", "PHI", "WAS"]}) + assert merged["teams"] == ["DAL"] + + def test_empty_user_array_beats_default(self, sm): + merged = sm.merge_with_defaults({"teams": []}, {"teams": ["NYG"]}) + assert merged["teams"] == [] + + def test_extra_user_keys_survive(self, sm): + # Keys with no schema default pass through untouched. + merged = sm.merge_with_defaults({"custom_flag": 7}, {"known": 1}) + assert merged == {"known": 1, "custom_flag": 7} diff --git a/test/test_skin_system.py b/test/test_skin_system.py index eb94b8fb..4b1f9074 100644 --- a/test/test_skin_system.py +++ b/test/test_skin_system.py @@ -450,3 +450,112 @@ class TestExampleSkin: ctx = skin_runtime.build_context(host, game, size=size) assert getattr(skin, f"render_{mode}")(ctx, game) is True assert ctx.canvas.convert("L").getbbox() is not None + + +class TestRenderSkinCard: + """render_skin_card (vegas cards) shares _render_game's 3-strike counter. + + The asymmetry pinned here: _render_game resets the counter on success, + render_skin_card does NOT — card successes never clear strikes, so + failures accumulated across card renders (however far apart) still + disable the skin for the session. + """ + + def _probe(self, skin): + from src.base_classes.sports import SportsCore + probe = _FallbackProbe(skin) + probe.render_skin_card = ( + lambda game, size: SportsCore.render_skin_card(probe, game, size)) + return probe + + def test_vegas_card_returned_when_skin_provides_one(self): + card_img = Image.new("RGB", (96, 32), (0, 0, 255)) + + class CardSkin(ScoreboardSkin): + def render_vegas_card(self, ctx, game): + return card_img + + probe = self._probe(CardSkin({}, {})) + assert probe.render_skin_card({}, (96, 32)) is card_img + + def test_vegas_card_none_falls_through_to_mode_renderer(self): + class ModeOnlySkin(ScoreboardSkin): + def render_live(self, ctx, game): + ctx.draw.rectangle([0, 0, 5, 5], fill=(255, 0, 0)) + return True + + probe = self._probe(ModeOnlySkin({}, {})) + card = probe.render_skin_card({}, (96, 32)) + assert card is not None + assert card.size == (96, 32) + assert card.convert("L").getbbox() is not None + + def test_skin_declining_returns_none(self): + probe = self._probe(ScoreboardSkin({}, {})) # all renders -> False + assert probe.render_skin_card({}, (96, 32)) is None + assert probe._skin_failures == 0 # declining is not a failure + + def test_no_skin_returns_none(self): + probe = self._probe(None) + assert probe.render_skin_card({}, (96, 32)) is None + + def test_card_failures_count_toward_shared_disable(self): + class BrokenCardSkin(ScoreboardSkin): + calls = 0 + + def render_vegas_card(self, ctx, game): + BrokenCardSkin.calls += 1 + raise ValueError("kaboom") + + probe = self._probe(BrokenCardSkin({}, {})) + for _ in range(5): + assert probe.render_skin_card({}, (96, 32)) is None + # Skin stopped being consulted after the 3rd failure... + assert BrokenCardSkin.calls == 3 + assert probe._skin_failures == 3 + # ...and the shared counter also disables _render_game's skin path. + probe._render_game({"status_text": "Q1"}) + assert probe.builtin_calls == 1 + assert BrokenCardSkin.calls == 3 # not consulted again + + def test_card_success_does_not_reset_strikes(self): + """Characterized asymmetry: unlike _render_game (which resets the + counter on success, core.py _render_game), a successful card render + leaves accumulated strikes in place — 2 failures + N successes + 1 + failure still disables the skin.""" + card_img = Image.new("RGB", (96, 32), (0, 0, 255)) + + class FlakyCardSkin(ScoreboardSkin): + fail = True + + def render_vegas_card(self, ctx, game): + if FlakyCardSkin.fail: + raise ValueError("kaboom") + return card_img + + probe = self._probe(FlakyCardSkin({}, {})) + FlakyCardSkin.fail = True + probe.render_skin_card({}, (96, 32)) + probe.render_skin_card({}, (96, 32)) + assert probe._skin_failures == 2 + + FlakyCardSkin.fail = False + for _ in range(10): + assert probe.render_skin_card({}, (96, 32)) is card_img + assert probe._skin_failures == 2 # successes did NOT clear strikes + + FlakyCardSkin.fail = True + probe.render_skin_card({}, (96, 32)) + assert probe._skin_failures == 3 + assert probe.render_skin_card({}, (96, 32)) is None # disabled + + def test_render_game_success_does_reset_strikes(self): + """The other half of the asymmetry, for contrast with the above.""" + class GoodSkin(ScoreboardSkin): + def render_live(self, ctx, game): + return True + + probe = _FallbackProbe(GoodSkin({}, {})) + probe._skin_failures = 2 + probe._render_game({"status_text": "Q1"}) + assert probe._skin_failures == 0 diff --git a/test/web_interface/test_api_v3_helpers.py b/test/web_interface/test_api_v3_helpers.py new file mode 100644 index 00000000..b32b0454 --- /dev/null +++ b/test/web_interface/test_api_v3_helpers.py @@ -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} diff --git a/test/web_interface/test_secret_helpers.py b/test/web_interface/test_secret_helpers.py new file mode 100644 index 00000000..5f33e899 --- /dev/null +++ b/test/web_interface/test_secret_helpers.py @@ -0,0 +1,241 @@ +""" +Tests for src/web_interface/secret_helpers.py — the canonical secret +identification / separation / masking helpers. + +This module is the extracted single source of truth for x-secret handling, +but until now had zero test coverage (only ``mask_secret_fields`` is even +imported by production code, from pages_v3). api_v3.py still carries three +inline re-implementations of ``find_secret_fields``/``separate_secrets`` — +see test_secret_separation_parity.py — so pinning the canonical behavior +here is a precondition for ever migrating those copies. +""" + +import copy + +from src.web_interface.secret_helpers import ( + find_secret_fields, + separate_secrets, + mask_secret_fields, + mask_all_secret_values, + remove_empty_secrets, +) + + +SCHEMA_PROPS = { + "api_key": {"type": "string", "x-secret": True}, + "city": {"type": "string"}, + "auth": { + "type": "object", + "properties": { + "token": {"type": "string", "x-secret": True}, + "username": {"type": "string"}, + }, + }, + "accounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "token": {"type": "string", "x-secret": True}, + }, + }, + }, + "recovery_codes": { + "type": "array", + "items": {"type": "string", "x-secret": True}, + }, +} + + +class TestFindSecretFields: + def test_top_level_secret(self): + assert "api_key" in find_secret_fields(SCHEMA_PROPS) + + def test_non_secret_not_included(self): + assert "city" not in find_secret_fields(SCHEMA_PROPS) + + def test_nested_object_secret_uses_dot_path(self): + assert "auth.token" in find_secret_fields(SCHEMA_PROPS) + assert "auth.username" not in find_secret_fields(SCHEMA_PROPS) + + def test_array_item_object_secret_uses_bracket_path(self): + assert "accounts[].token" in find_secret_fields(SCHEMA_PROPS) + + def test_array_of_secrets_uses_bracket_path(self): + assert "recovery_codes[]" in find_secret_fields(SCHEMA_PROPS) + + def test_full_set(self): + assert find_secret_fields(SCHEMA_PROPS) == { + "api_key", "auth.token", "accounts[].token", "recovery_codes[]", + } + + def test_non_dict_properties_tolerated(self): + assert find_secret_fields({"weird": "not-a-dict"}) == set() + + def test_non_dict_input_returns_empty(self): + assert find_secret_fields(None) == set() + assert find_secret_fields([]) == set() + + +class TestSeparateSecrets: + def test_flat_partition(self): + regular, secrets = separate_secrets( + {"api_key": "s3cret", "city": "Austin"}, {"api_key"}) + assert regular == {"city": "Austin"} + assert secrets == {"api_key": "s3cret"} + + def test_nested_partition(self): + config = {"auth": {"token": "t0k", "username": "chuck"}} + regular, secrets = separate_secrets(config, {"auth.token"}) + assert regular == {"auth": {"username": "chuck"}} + assert secrets == {"auth": {"token": "t0k"}} + + def test_empty_nested_dicts_pruned_from_regular(self): + # A dict that is all secrets leaves nothing behind on the regular + # side — the key must be dropped, not kept as {}. + config = {"auth": {"token": "t0k"}} + regular, secrets = separate_secrets(config, {"auth.token"}) + assert regular == {} + assert secrets == {"auth": {"token": "t0k"}} + + def test_whole_array_secret(self): + config = {"recovery_codes": ["a", "b"], "city": "Austin"} + regular, secrets = separate_secrets(config, {"recovery_codes[]"}) + assert regular == {"city": "Austin"} + assert secrets == {"recovery_codes": ["a", "b"]} + + def test_array_item_secrets_produce_parallel_lists(self): + # Per-item secrets keep the arrays index-aligned so they can be + # recombined: regular gets the stripped items, secrets a parallel + # list of the extracted values. + config = {"accounts": [ + {"name": "a", "token": "ta"}, + {"name": "b", "token": "tb"}, + ]} + regular, secrets = separate_secrets(config, {"accounts[].token"}) + assert regular == {"accounts": [{"name": "a"}, {"name": "b"}]} + assert secrets == {"accounts": [{"token": "ta"}, {"token": "tb"}]} + + def test_array_item_non_dict_items_get_placeholder(self): + config = {"accounts": [{"name": "a", "token": "ta"}, "oddball"]} + regular, secrets = separate_secrets(config, {"accounts[].token"}) + assert regular == {"accounts": [{"name": "a"}, "oddball"]} + assert secrets == {"accounts": [{"token": "ta"}, {}]} + + def test_array_without_secret_paths_stays_regular(self): + config = {"teams": ["DAL", "HOU"]} + regular, secrets = separate_secrets(config, {"api_key"}) + assert regular == {"teams": ["DAL", "HOU"]} + assert secrets == {} + + def test_round_trip_loses_nothing(self): + # separate + naive recombine must reconstruct the original config. + config = { + "api_key": "k", + "city": "Austin", + "auth": {"token": "t", "username": "chuck"}, + "recovery_codes": ["a", "b"], + } + paths = find_secret_fields(SCHEMA_PROPS) + regular, secrets = separate_secrets(copy.deepcopy(config), paths) + + def recombine(reg, sec): + out = copy.deepcopy(reg) + for k, v in sec.items(): + if isinstance(v, dict) and isinstance(out.get(k), dict): + out[k] = recombine(out[k], v) + else: + out[k] = v + return out + + assert recombine(regular, secrets) == config + + +class TestMaskSecretFields: + def test_masks_present_secret_to_empty_string(self): + result = mask_secret_fields({"api_key": "s3cret"}, SCHEMA_PROPS) + assert result["api_key"] == "" + + def test_leaves_non_secret_untouched(self): + result = mask_secret_fields({"city": "Austin"}, SCHEMA_PROPS) + assert result["city"] == "Austin" + + def test_none_and_empty_left_alone(self): + result = mask_secret_fields({"api_key": None}, SCHEMA_PROPS) + assert result["api_key"] is None + result = mask_secret_fields({"api_key": ""}, SCHEMA_PROPS) + assert result["api_key"] == "" + + def test_falsey_but_set_values_are_masked(self): + # 0 and False are real values; the check is `is not None and != ''`. + # Note False == '' is False in Python, so False IS masked; 0 == '' is + # also False, so 0 is masked too. + result = mask_secret_fields({"api_key": 0}, SCHEMA_PROPS) + assert result["api_key"] == "" + result = mask_secret_fields({"api_key": False}, SCHEMA_PROPS) + assert result["api_key"] == "" + + def test_nested_object_masked_without_mutating_input(self): + config = {"auth": {"token": "t0k", "username": "chuck"}} + original = copy.deepcopy(config) + result = mask_secret_fields(config, SCHEMA_PROPS) + assert result["auth"]["token"] == "" + assert result["auth"]["username"] == "chuck" + assert config == original # input not mutated + + def test_array_of_secrets_masked_elementwise(self): + result = mask_secret_fields( + {"recovery_codes": ["a", "b"]}, SCHEMA_PROPS) + assert result["recovery_codes"] == ["", ""] + + def test_array_of_objects_masked_per_item(self): + config = {"accounts": [{"name": "a", "token": "ta"}, "oddball"]} + result = mask_secret_fields(config, SCHEMA_PROPS) + assert result["accounts"][0] == {"name": "a", "token": ""} + assert result["accounts"][1] == "oddball" + + def test_non_dict_schema_property_tolerated(self): + assert mask_secret_fields({"x": 1}, {"x": "bogus"}) == {"x": 1} + + +class TestMaskAllSecretValues: + def test_real_values_replaced_with_bullets(self): + assert mask_all_secret_values({"key": "abc"}) == {"key": "••••••••"} + + def test_placeholders_preserved(self): + # YOUR_* placeholders must survive so the UI can show "not set". + result = mask_all_secret_values({"key": "YOUR_API_KEY_HERE"}) + assert result == {"key": "YOUR_API_KEY_HERE"} + + def test_empty_and_none_preserved(self): + assert mask_all_secret_values({"a": "", "b": None}) == {"a": "", "b": None} + + def test_recurses_into_nested_dicts(self): + result = mask_all_secret_values({"plugin": {"token": "t", "empty": ""}}) + assert result == {"plugin": {"token": "••••••••", "empty": ""}} + + def test_non_string_real_values_masked(self): + assert mask_all_secret_values({"port": 8080}) == {"port": "••••••••"} + + +class TestRemoveEmptySecrets: + def test_strips_empty_string(self): + assert remove_empty_secrets({"a": "", "b": "real"}) == {"b": "real"} + + def test_strips_whitespace_only(self): + assert remove_empty_secrets({"a": " "}) == {} + + def test_strips_none(self): + assert remove_empty_secrets({"a": None}) == {} + + def test_prunes_empty_nested_dicts(self): + assert remove_empty_secrets({"plugin": {"token": ""}}) == {} + + def test_keeps_nested_real_values(self): + result = remove_empty_secrets({"plugin": {"token": "t", "empty": ""}}) + assert result == {"plugin": {"token": "t"}} + + def test_keeps_falsey_non_string_values(self): + # 0 and False are neither None nor blank strings — they are kept. + assert remove_empty_secrets({"a": 0, "b": False}) == {"a": 0, "b": False}