Files
LEDMatrix/test/test_base_plugin_styles.py
T
ChuckandClaude Opus 5 6cd886cd78 feat(plugins): BasePlugin.styles -- per-element styling every plugin inherits
Adopting the element-style system meant repeating three things in every
plugin: a guarded import, finding its own config_schema.json, and
rebuilding the resolver when on_config_change swapped the config dict.
This is those three things once, on the class all 45 plugins already
inherit from.

    title = self.styles.style('title_text',
                              classic_font='PressStart2P-Regular.ttf',
                              classic_size=8, classic_color=(255, 255, 255))

The classic_* arguments are the adoption contract: with nothing configured
they come back verbatim, so a plugin that switches to this renders exactly
as before until a user changes something.

A plugin with one instance per display mode sets STYLE_MODE on the class
and every existing lookup becomes mode-aware without a call site changing
-- which is the point of binding the mode to the resolver rather than
passing it per call. styles_for() covers a plugin that renders several
modes from one instance.

Schema discovery reads the concrete class's own module rather than this
file, because this file lives in src/plugin_system where no plugin schema
exists -- the same trap SportsCore._config_schema_path documents. The
first mutation test for that passed anyway: an installed plugin's module
directory and its entry under plugins_dir are the same path, so the test
could not tell the two apart. The case where they diverge is a plugin
symlinked in for development, and the test now forces that shape.

Getting discovery wrong is silent rather than loud: with no schema the
resolver has no defaults to compare against, so every configured value
reads as a deliberate override and the plugin quietly stops honouring its
own shipped styling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:04:17 -04:00

225 lines
9.1 KiB
Python

"""BasePlugin.styles -- the per-element styling seam every plugin inherits.
Before this, each consumer of src.element_style repeated the same three
things: a guarded import, finding its own config_schema.json, and rebuilding
the resolver when on_config_change swapped the config dict. Getting the
second one wrong is silent -- the resolver simply has no defaults to compare
against, so every configured value reads as a deliberate override and the
plugin quietly stops honouring its own shipped styling.
"""
import json
import sys
import types
import pytest
from unittest.mock import MagicMock
from src.plugin_system.base_plugin import BasePlugin
SCHEMA = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"x-style-modes": ["live", "recent"],
"x-style-elements": {
"score_text": {
"title": "Score",
"font": {"default": "PressStart2P-Regular.ttf"},
"size": {"default": 10, "min": 4, "max": 16},
"color": {"default": [255, 255, 255]},
"offsets": True,
},
},
},
},
}
def _plugin_class(module_name, module_file, **attrs):
"""A plugin class whose module sits in a plugin directory, as a real
one does -- that is what schema discovery keys on."""
module = types.ModuleType(module_name)
module.__file__ = str(module_file)
sys.modules[module_name] = module
namespace = dict(attrs)
namespace["update"] = lambda self: None
namespace["display"] = lambda self, force_clear=False: None
cls = type("DemoPlugin", (BasePlugin,), namespace)
cls.__module__ = module_name
return cls
@pytest.fixture
def plugin_dir(tmp_path):
d = tmp_path / "demo"
d.mkdir()
(d / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
return d
@pytest.fixture
def make(tmp_path, plugin_dir, request):
created = []
def _make(config, plugin_id="demo", schema_in_module_dir=True, **attrs):
name = f"demo_module_{len(created)}_{id(request)}"
module_file = (plugin_dir if schema_in_module_dir
else tmp_path / "elsewhere") / "manager.py"
module_file.parent.mkdir(parents=True, exist_ok=True)
cls = _plugin_class(name, module_file, **attrs)
created.append(name)
pm = MagicMock()
pm.plugins_dir = str(tmp_path)
return cls(plugin_id, config, MagicMock(), MagicMock(), pm)
try:
yield _make
finally:
for name in created:
sys.modules.pop(name, None)
CONFIG = {"customization": {
"score_text": {"font_size": 12},
"layout": {"score_text": {"y_offset": -3}},
"modes": {"live": {"score_text": {"font_size": 16}}},
}}
class TestSchemaDiscovery:
def test_it_finds_the_schema_beside_the_plugin_module(self, make, plugin_dir):
"""Not beside base_plugin.py, which lives in src/plugin_system where
no plugin schema exists."""
p = make({})
assert p._config_schema_path() == str(plugin_dir / "config_schema.json")
def test_it_falls_back_to_the_plugins_directory(self, make):
p = make({}, schema_in_module_dir=False)
assert p._config_schema_path() is not None
def test_it_accepts_the_ledmatrix_prefix_form(self, tmp_path, make):
(tmp_path / "ledmatrix-music").mkdir()
(tmp_path / "ledmatrix-music" / "config_schema.json").write_text(
json.dumps(SCHEMA), encoding="utf-8")
p = make({}, plugin_id="music", schema_in_module_dir=False)
assert "ledmatrix-music" in p._config_schema_path()
def test_a_missing_schema_is_not_fatal(self, tmp_path, make):
p = make({}, plugin_id="ghost", schema_in_module_dir=False)
# 'elsewhere' has no schema and neither does tmp_path/ghost
assert p._config_schema_path() is None
assert p.styles.style("score_text", classic_size=8).font_size == 8
def test_the_module_directory_wins_over_the_plugins_directory(
self, tmp_path, plugin_dir):
"""These two are the same path for an installed plugin, so the test
has to force them apart -- and they genuinely diverge for a plugin
symlinked in for development, where the module lives outside the
configured plugins directory. Sourcing the path from this module's
own __file__ instead would find neither, then quietly fall through
to whatever the plugins directory happened to hold.
"""
dev_dir = tmp_path / "dev-checkout"
dev_dir.mkdir()
dev_schema = json.loads(json.dumps(SCHEMA))
(dev_schema["properties"]["customization"]["x-style-elements"]
["score_text"]["size"]["default"]) = 99
(dev_dir / "config_schema.json").write_text(json.dumps(dev_schema),
encoding="utf-8")
cls = _plugin_class("demo_dev_checkout", dev_dir / "manager.py")
try:
pm = MagicMock()
pm.plugins_dir = str(tmp_path) # holds demo/config_schema.json
p = cls("demo", {}, MagicMock(), MagicMock(), pm)
assert p._config_schema_path() == str(dev_dir / "config_schema.json")
# and the difference is observable: 99 is this schema's default,
# so configuring 99 must read as "not a choice".
p.on_config_change({"customization": {"score_text": {"font_size": 99}}})
assert p.styles.style("score_text", classic_size=10).font_size == 10
finally:
sys.modules.pop("demo_dev_checkout", None)
def test_the_lookup_is_cached(self, make):
"""A miss must not re-scan the disk on every frame."""
p = make({})
first = p._config_schema_path()
assert p._config_schema_path() is first
class TestResolution:
def test_an_untouched_config_gets_the_classic_values(self, make):
"""The invariant that makes adopting this safe."""
style = make({}).styles.style(
"score_text", classic_font="4x6-font.ttf", classic_size=6,
classic_color=(1, 2, 3))
assert (style.font_name, style.font_size, style.color) == (
"4x6-font.ttf", 6, (1, 2, 3))
assert style.user_forced is False
def test_a_users_choice_comes_through(self, make):
style = make(CONFIG).styles.style("score_text", classic_size=10)
assert style.font_size == 12
assert style.offset == (0, -3)
def test_style_mode_binds_without_touching_call_sites(self, make):
"""The whole point: a per-mode subclass sets one attribute and its
existing lookups become mode-aware."""
assert make(CONFIG).styles.style(
"score_text", classic_size=10).font_size == 12
assert make(CONFIG, STYLE_MODE="live").styles.style(
"score_text", classic_size=10).font_size == 16
def test_styles_for_handles_a_one_off_mode(self, make):
p = make(CONFIG)
assert p.styles_for("live").style("score_text", classic_size=10).font_size == 16
assert p.styles_for("recent").style("score_text", classic_size=10).font_size == 12
assert p.styles_for(None).style("score_text", classic_size=10).font_size == 12
class TestInvalidation:
def test_the_resolver_is_reused(self, make):
p = make(CONFIG)
assert p.styles is p.styles
def test_a_config_swap_rebuilds_it(self, make):
"""on_config_change replaces the dict rather than mutating it, so
identity is the signal."""
p = make(CONFIG)
assert p.styles.style("score_text", classic_size=10).font_size == 12
p.on_config_change({"customization": {"score_text": {"font_size": 6}}})
assert p.styles.style("score_text", classic_size=10).font_size == 6
def test_a_config_swap_rebuilds_the_per_mode_cache_too(self, make):
p = make(CONFIG)
assert p.styles_for("live").style("score_text", classic_size=10).font_size == 16
p.on_config_change({"customization": {
"modes": {"live": {"score_text": {"font_size": 5}}}}})
assert p.styles_for("live").style("score_text", classic_size=10).font_size == 5
class TestDegradation:
# config=None is not in this list on purpose: BasePlugin.__init__ reads
# config.get("enabled") before styles exists, so a None config is a
# constructor precondition rather than something styles should absorb.
@pytest.mark.parametrize("config", [
{}, {"customization": None}, {"customization": "nonsense"},
{"customization": {"score_text": "nonsense"}},
{"customization": {"layout": "nonsense"}},
{"customization": {"modes": "nonsense"}},
])
def test_a_hostile_config_still_resolves(self, config, make):
style = make(config).styles.style("score_text", classic_size=7)
assert style.font_size == 7
def test_it_works_without_a_plugin_manager(self, plugin_dir):
cls = _plugin_class("demo_no_pm", plugin_dir / "manager.py")
try:
p = cls("demo", {}, MagicMock(), MagicMock(), None)
assert p.styles.style("score_text", classic_size=8).font_size == 8
finally:
sys.modules.pop("demo_no_pm", None)