feat: universal element style system — resolver, schema expansion, live preview (#394)

* feat(element-style): universal per-element style resolver

One shared implementation of the three things every customizable plugin
re-invented: a font loader, the customization.layout x/y-offset reader,
and the "did the user actually override this font?" check.

The override check is the load-bearing piece: the web UI's save flow
writes full schema defaults into config.json on every save, and the
plugin manager merges defaults again before instantiation, so key
presence never means user intent. The resolver compares against the
plugin's own schema defaults (via schema_manager), degrading to
caller-supplied classic defaults when unavailable. This retires the
hand-maintained _CLASSIC_FONT_DEFAULTS dicts that shipped broken twice.

The loader is a superset of the four per-plugin variants: alias
resolution (baseball), truetype for TTF/OTF/BDF (FreeType loads BDF at
native size), .pil sidecar fallback for BDF (football), fallback font,
PIL default — never raises. Also introduces the text_color convention
([r,g,b], absent = keep the plugin's hardcoded color).

BasePlugin gains a lazy style_resolver property (invalidated on config
change) and element_style() sugar; standalone helpers receive the
resolver from their owning plugin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam

* feat(element-style): x-style-elements schema expansion + color provenance

Plugins can now declare styleable display elements once, compactly, on
their customization schema ("x-style-elements") instead of hand-copying
the ~50-line font/font_size/text_color/offset property blocks (currently
duplicated 52x across the plugin monorepo). SchemaManager.load_schema
expands declarations before caching, so the config form, save path,
validation, and defaults generation all see the same shape; the single
expansion implementation lives in src.element_style and is also applied
by defaults_from_schema_file, keeping the web UI's view and a plugin's
raw-schema-file view of the defaults provably identical (parity test).

Generated blocks use only widgets the config form already renders
(font-selector, color-picker, number inputs) and update x-propertyOrder
when present (the template only renders listed keys). Expansion is
idempotent, never mutates its input or the cached/on-disk schema, and a
hand-written block for the same element always wins.

Color gets the same provenance rule as fonts: the web form always posts
the RGB inputs, so a saved config carries the schema-default color
whether or not the user touched it — the resolver now only honors a
color that DIFFERS from the schema default, and keeps the plugin's
classic (possibly state-dependent, e.g. gold-on-touchdown) color
otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam

* feat(web): live plugin preview on the config page

Adds POST /api/v3/plugins/preview: renders a plugin headlessly with the
CANDIDATE (unsaved) config and returns a PNG — so users can see exactly
what the panel will show before saving, at their real panel size (from
display.hardware) or a chosen test size.

Two extractions make it drift-proof rather than parallel-implemented:
- dev_server's _render_once moves to
  src/plugin_system/testing/render_service.py (pure PIL via
  VisualTestDisplayManager, install_deps=False always — safe in the web
  process, which never touches display hardware); the dev server now
  wraps it.
- save_plugin_config's ~350-line form->config conversion is extracted
  verbatim as parse_plugin_config_form and shared by the preview
  endpoint, so preview and save can never interpret the form
  differently.

update() is skipped by default (no network on the request thread);
plugins with a test/harness.json get their mock-data fixture primed
instead, and ?skip_update=0 opts into a live update. The config page
gains a Live Preview panel (HTMX hx-include of the existing form, size
selector, pixelated img fragment) — works for every plugin with zero
per-plugin code, including the x-style-elements font/size/color fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam

* fix(web): expand x-style-elements in the config-page form render too

pages_v3's plugin-config partial loads config_schema.json directly from
disk rather than through SchemaManager.load_schema, so declared style
elements expanded everywhere EXCEPT the form the user actually sees.
Found live on the devpi: the API served the expanded schema and the
save path validated against it, but the config page rendered no
font/color/offset fields. Apply the same (idempotent) expansion here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam

* fix(web): preview size selector — htmx caches hx-post, use hx-vals

Found live on the devpi: selecting a preview size did nothing because
htmx snapshots the request path when it processes the button, so the
size dropdown's onchange mutation of hx-post never took effect. The size
now travels as a __preview_size=WxH form field attached via hx-vals
(evaluated at request time); the endpoint honors it (query args still
win for API callers) and strips it before form parsing so it can't leak
into the candidate config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam

* fix(web): allowlist plugin_id in the preview endpoint's dir lookup

Same defense as the dev server's find_plugin_dir (and pages_v3's
existing pattern): plugin_id arrives in request input and is used to
build filesystem paths — reject anything outside ^[a-zA-Z0-9_-]{1,64}$
before touching the filesystem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam

* fix: harden preview + resolver edges found in self-review

- extract_schema_defaults now matches SchemaManager's array handling
  ([] for arrays without defaults, [item] for item-level defaults) —
  the documented parity guarantee was false for array-typed properties;
  parity test extended to cover them.
- render_plugin_once calls plugin cleanup() in a finally (image captured
  first — cleanup may clear the canvas): preview instances could leak
  sessions/threads per request in the long-running web process.
- Preview JSON path deep-merges the candidate onto the saved config,
  matching the form path — a shallow update() silently dropped saved
  sibling values in any nested section the candidate touched.
- Preview render is bounded (15s, 504 on timeout, daemonized worker):
  a hanging plugin display() no longer pins a web worker forever.
- ElementStyleResolver.is_for(config): public staleness check for
  callers that cache a resolver, instead of poking _config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam

* fix(render-service): adopt the dev server's exception-detail policy

Port c6962701's convention into the shared render service (which
supersedes the inline _render_once it patched): full tracebacks to the
server log via exc_info, only the exception class name to the client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-07-12 10:40:03 -04:00
committed by GitHub
co-authored by Claude Fable 5 Chuck
parent a55504c0af
commit 68d03ea260
11 changed files with 2472 additions and 411 deletions
+519
View File
@@ -0,0 +1,519 @@
"""Tests for src/element_style.py — universal per-element style resolution.
The load-bearing behavior is the user-override check: saved configs ALWAYS
contain the schema defaults (merge_with_defaults runs at save time and again
before plugin instantiation), so "key present" must never be read as "user
set it". Only "present and different from the schema default" counts.
"""
import json
import os
import sys
import pytest
from PIL import ImageFont
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.element_style import ( # noqa: E402
ElementStyle,
ElementStyleResolver,
FONT_ALIASES,
defaults_from_schema_file,
extract_schema_defaults,
load_font,
resolve_font_name,
)
PRESS_START = "PressStart2P-Regular.ttf"
FOUR_BY_SIX = "4x6-font.ttf"
FIVE_BY_SEVEN_BDF = "5x7.bdf"
# ---------------------------------------------------------------------------
# load_font
# ---------------------------------------------------------------------------
class TestLoadFont:
def test_ttf(self):
font = load_font(PRESS_START, 8)
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.size == 8
def test_bdf_at_native_size(self):
"""FreeType loads BDF strikes directly at their native size."""
font = load_font(FIVE_BY_SEVEN_BDF, 7)
assert isinstance(font, ImageFont.FreeTypeFont)
def test_bdf_at_wrong_size_falls_back(self):
"""BDF fonts are fixed-size; a non-native size falls back to the
fallback font at the requested size rather than raising."""
font = load_font(FIVE_BY_SEVEN_BDF, 14)
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.size == 14 # fallback font honored the requested size
def test_alias_resolves(self):
assert resolve_font_name("press_start") == PRESS_START
font = load_font("press_start", 16)
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.size == 16
def test_filename_passes_through_alias(self):
assert resolve_font_name(FOUR_BY_SIX) == FOUR_BY_SIX
def test_missing_file_falls_back(self):
font = load_font("no-such-font.ttf", 10)
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.size == 10
@pytest.mark.parametrize("garbage", ["", None, "../../etc/passwd", "x" * 300])
def test_garbage_never_raises(self, garbage):
font = load_font(garbage, 8)
assert font is not None
def test_everything_missing_uses_pil_default(self):
font = load_font("nope.ttf", 8, fonts_dir="/nonexistent",
fallback_font="also-nope.ttf")
assert font is not None # ImageFont.load_default()
def test_aliases_cover_the_baseball_set(self):
"""The centralized map must be a superset of the per-plugin copies
it replaces (baseball game_renderer.py + sports.py)."""
assert FONT_ALIASES["press_start"] == PRESS_START
assert FONT_ALIASES["four_by_six"] == FOUR_BY_SIX
assert FONT_ALIASES["five_by_seven"] == FIVE_BY_SEVEN_BDF
# ---------------------------------------------------------------------------
# user_forced provenance
# ---------------------------------------------------------------------------
SCHEMA_DEFAULTS = {
"customization": {
"score_text": {"font": PRESS_START, "font_size": 10},
}
}
def _style(config, defaults=SCHEMA_DEFAULTS):
return ElementStyleResolver(config, defaults).style(
"score_text", classic_font=PRESS_START, classic_size=10)
class TestUserForced:
def test_absent_is_not_forced(self):
style = _style({})
assert not style.user_forced
assert style.font_name == PRESS_START
assert style.font_size == 10
def test_schema_default_present_is_not_forced(self):
"""THE bug this module exists to fix: the save flow writes schema
defaults into every saved config, so their presence means nothing."""
config = {"customization": {"score_text": {
"font": PRESS_START, "font_size": 10}}}
style = _style(config)
assert not style.user_forced
def test_different_font_is_forced(self):
config = {"customization": {"score_text": {
"font": FOUR_BY_SIX, "font_size": 10}}}
style = _style(config)
assert style.user_forced_font
assert not style.user_forced_size
assert style.user_forced
def test_different_size_is_forced(self):
config = {"customization": {"score_text": {
"font": PRESS_START, "font_size": 14}}}
style = _style(config)
assert style.user_forced_size
assert not style.user_forced_font
assert style.font_size == 14
def test_string_size_equal_to_default_is_not_forced(self):
config = {"customization": {"score_text": {"font_size": "10"}}}
assert not _style(config).user_forced
def test_without_schema_defaults_compares_against_classic(self):
"""Degraded mode (old cores, tests): classic_* is the reference."""
config = {"customization": {"score_text": {
"font": PRESS_START, "font_size": 10}}}
style = _style(config, defaults={})
assert not style.user_forced
forced = _style({"customization": {"score_text": {"font_size": 12}}},
defaults={})
assert forced.user_forced
def test_schema_default_differing_from_classic_wins_as_reference(self):
"""When the schema declares a different default than the classic_*
args, the schema is the reference — a config equal to the schema
default is untouched."""
defaults = {"customization": {"score_text": {
"font": FOUR_BY_SIX, "font_size": 6}}}
config = {"customization": {"score_text": {
"font": FOUR_BY_SIX, "font_size": 6}}}
style = ElementStyleResolver(config, defaults).style(
"score_text", classic_font=PRESS_START, classic_size=10)
assert not style.user_forced
def test_unknown_element_uses_classic(self):
style = ElementStyleResolver({}, SCHEMA_DEFAULTS).style(
"no_such_element", classic_font=FOUR_BY_SIX, classic_size=6)
assert not style.user_forced
assert style.font_name == FOUR_BY_SIX
assert style.font_size == 6
def test_malformed_customization_is_tolerated(self):
for bad in [{"customization": "oops"},
{"customization": {"score_text": "oops"}},
{"customization": {"score_text": {"font_size": "huge"}}},
None]:
style = _style(bad)
assert not style.user_forced
assert style.font_size == 10
# ---------------------------------------------------------------------------
# color
# ---------------------------------------------------------------------------
class TestColor:
"""Color provenance mirrors fonts: the web form ALWAYS posts the RGB
inputs, so a saved config carries the schema-default color whether or
not the user touched it. Only a value differing from the schema default
is an override; otherwise the plugin's classic color survives — critical
for state-dependent colors (a score that turns gold on a touchdown)."""
COLOR_DEFAULTS = {"customization": {"score_text": {
"font": PRESS_START, "font_size": 10, "text_color": [255, 255, 255]}}}
def _color_style(self, config, defaults=None, classic_color=(255, 215, 0)):
return ElementStyleResolver(config, defaults or self.COLOR_DEFAULTS).style(
"score_text", classic_font=PRESS_START, classic_size=10,
classic_color=classic_color)
def test_absent_returns_classic_color(self):
style = self._color_style({})
assert style.color == (255, 215, 0)
assert not style.user_forced_color
def test_absent_with_no_classic_is_none(self):
assert _style({}).color is None
def test_schema_default_present_keeps_classic_color(self):
"""A saved config always contains the default — it must not clobber
the plugin's (possibly semantic) classic color."""
config = {"customization": {"score_text": {"text_color": [255, 255, 255]}}}
style = self._color_style(config)
assert style.color == (255, 215, 0)
assert not style.user_forced_color
def test_changed_color_is_an_override(self):
config = {"customization": {"score_text": {"text_color": [0, 128, 255]}}}
style = self._color_style(config)
assert style.color == (0, 128, 255)
assert style.user_forced_color
def test_present_without_schema_default_is_an_override(self):
"""Hand-written schemas without a text_color default: presence is
intent (there is nothing to compare against)."""
config = {"customization": {"score_text": {"text_color": [0, 128, 255]}}}
style = self._color_style(config, defaults=SCHEMA_DEFAULTS)
assert style.color == (0, 128, 255)
assert style.user_forced_color
def test_values_clamped(self):
config = {"customization": {"score_text": {"text_color": [300, -5, 128]}}}
assert self._color_style(config).color == (255, 0, 128)
def test_color_never_affects_user_forced_sizing(self):
config = {"customization": {"score_text": {"text_color": [0, 128, 255]}}}
style = self._color_style(config)
assert style.user_forced_color
assert not style.user_forced
@pytest.mark.parametrize("bad", [[1, 2], [1, 2, 3, 4], "red",
["a", "b", "c"], 255, None])
def test_malformed_falls_back_to_classic(self, bad):
config = {"customization": {"score_text": {"text_color": bad}}}
style = self._color_style(config, classic_color=(1, 2, 3))
assert style.color == (1, 2, 3)
assert not style.user_forced_color
# ---------------------------------------------------------------------------
# offsets
# ---------------------------------------------------------------------------
class TestOffsets:
def test_unset_is_zero(self):
assert ElementStyleResolver({}).offset("score") == (0, 0)
def test_layout_section(self):
"""The deployed sports convention: customization.layout.<element>."""
config = {"customization": {"layout": {"score": {
"x_offset": 3, "y_offset": -2}}}}
assert ElementStyleResolver(config).offset("score") == (3, -2)
def test_element_section_fallback(self):
config = {"customization": {"score": {"x_offset": 5}}}
assert ElementStyleResolver(config).offset("score") == (5, 0)
def test_layout_section_wins_over_element_section(self):
config = {"customization": {
"layout": {"score": {"x_offset": 1}},
"score": {"x_offset": 9, "y_offset": 9},
}}
resolver = ElementStyleResolver(config)
assert resolver.offset_value("score", "x_offset") == 1
# y_offset absent from layout section -> element section supplies it
assert resolver.offset_value("score", "y_offset") == 9
@pytest.mark.parametrize("raw,expected", [
(2, 2), (2.7, 2), ("3", 3), ("2.0", 2), ("-4", -4),
(None, 0), ("junk", 0), ([], 0), (True, 0),
])
def test_coercion_matches_sports_helper(self, raw, expected):
"""Same tolerance as the sports.py/_get_layout_offset copies this
replaces: int/float/numeric-string pass, anything else -> default."""
config = {"customization": {"layout": {"e": {"x_offset": raw}}}}
assert ElementStyleResolver(config).offset_value("e", "x_offset") == expected
def test_custom_axis_names(self):
"""Football's records use away_x_offset/home_x_offset."""
config = {"customization": {"layout": {"records": {
"away_x_offset": 4, "home_x_offset": -4}}}}
resolver = ElementStyleResolver(config)
assert resolver.offset_value("records", "away_x_offset") == 4
assert resolver.offset_value("records", "home_x_offset") == -4
def test_style_carries_offset(self):
config = {"customization": {"layout": {"score_text": {
"x_offset": 2, "y_offset": 1}}}}
assert _style(config).offset == (2, 1)
# ---------------------------------------------------------------------------
# caching
# ---------------------------------------------------------------------------
class TestCaching:
def test_same_call_is_cached(self):
resolver = ElementStyleResolver({}, SCHEMA_DEFAULTS)
a = resolver.style("score_text", classic_font=PRESS_START, classic_size=10)
b = resolver.style("score_text", classic_font=PRESS_START, classic_size=10)
assert a is b
def test_clear_cache(self):
resolver = ElementStyleResolver({}, SCHEMA_DEFAULTS)
a = resolver.style("score_text", classic_font=PRESS_START, classic_size=10)
resolver.clear_cache()
b = resolver.style("score_text", classic_font=PRESS_START, classic_size=10)
assert a is not b
# PIL fonts compare by identity; compare the value fields
assert (a.font_name, a.font_size, a.color, a.offset, a.user_forced) == \
(b.font_name, b.font_size, b.color, b.offset, b.user_forced)
# ---------------------------------------------------------------------------
# schema default extraction
# ---------------------------------------------------------------------------
class TestSchemaDefaults:
def test_matches_schema_manager_extraction(self):
"""The pure helper must agree with SchemaManager.extract_defaults_from_schema
on a real plugin-style schema — it exists so plugins get the same
answer in harness contexts where the schema manager is absent."""
from src.plugin_system.schema_manager import SchemaManager
schema = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"customization": {
"type": "object",
"properties": {
"score_text": {
"type": "object",
"properties": {
"font": {"type": "string", "default": PRESS_START},
"font_size": {"type": "integer", "default": 10},
"y_percent": {"type": "number"},
},
},
"layout": {
"type": "object",
"properties": {
"score": {
"type": "object",
"properties": {
"x_offset": {"type": "integer", "default": 0},
},
},
},
},
},
},
"opaque_with_default": {"type": "object", "default": {},
"properties": {"x": {"default": 1}}},
# array shapes the schema manager special-cases — parity
# must hold for these too (found divergent in review)
"plain_array": {"type": "array", "items": {"type": "string"}},
"obj_array": {"type": "array", "items": {
"type": "object", "properties": {"x": {"default": 1}}}},
"item_default_array": {"type": "array",
"items": {"type": "string", "default": "a"}},
},
}
pure = extract_schema_defaults(schema)
managed = SchemaManager().extract_defaults_from_schema(schema)
assert pure == managed
assert pure["customization"]["score_text"]["font"] == PRESS_START
# object-level default short-circuits recursion (both must agree)
assert pure["opaque_with_default"] == {}
assert pure["plain_array"] == []
assert pure["obj_array"] == []
assert pure["item_default_array"] == ["a"]
def test_defaults_from_schema_file(self, tmp_path):
schema_path = tmp_path / "config_schema.json"
schema_path.write_text(json.dumps({
"type": "object",
"properties": {
"customization": {
"type": "object",
"properties": {
"title_text": {
"type": "object",
"properties": {
"font": {"type": "string", "default": PRESS_START},
},
},
},
},
},
}))
defaults = defaults_from_schema_file(str(schema_path))
assert defaults["customization"]["title_text"]["font"] == PRESS_START
def test_defaults_from_missing_or_bad_file(self, tmp_path):
assert defaults_from_schema_file("/nonexistent/schema.json") == {}
bad = tmp_path / "bad.json"
bad.write_text("{not json")
assert defaults_from_schema_file(str(bad)) == {}
# ---------------------------------------------------------------------------
# BasePlugin integration
# ---------------------------------------------------------------------------
class _StubSchemaManager:
"""Schema manager double exposing the two methods the resolver path uses."""
def __init__(self, schema):
self._schema = schema
def load_schema(self, plugin_id, use_cache=True):
return self._schema
def extract_defaults_from_schema(self, schema, prefix=""):
# Mirror the real nested-dict extraction for this simple shape
def walk(props):
out = {}
for key, spec in props.get("properties", {}).items():
if "default" in spec:
out[key] = spec["default"]
elif spec.get("type") == "object" and "properties" in spec:
nested = walk(spec)
if nested:
out[key] = nested
return out
return walk(schema)
def _make_plugin(config, schema=None):
from src.plugin_system.base_plugin import BasePlugin
from src.plugin_system.testing.mocks import (
MockCacheManager, MockPluginManager)
from src.plugin_system.testing.visual_display_manager import (
VisualTestDisplayManager)
class _Plugin(BasePlugin):
def update(self):
return True
def display(self, force_clear=False):
return None
plugin_manager = MockPluginManager()
if schema is not None:
plugin_manager.schema_manager = _StubSchemaManager(schema)
return _Plugin("test-plugin", config,
VisualTestDisplayManager(64, 32),
MockCacheManager(), plugin_manager)
TEST_SCHEMA = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"properties": {
"score_text": {
"type": "object",
"properties": {
"font": {"type": "string", "default": PRESS_START},
"font_size": {"type": "integer", "default": 10},
},
},
},
},
},
}
class TestBasePluginIntegration:
def test_element_style_with_schema_defaults(self):
"""The full path: saved config carries schema defaults, plugin's
element_style still reports not-forced."""
config = {"enabled": True, "customization": {"score_text": {
"font": PRESS_START, "font_size": 10}}}
plugin = _make_plugin(config, schema=TEST_SCHEMA)
style = plugin.element_style("score_text", classic_font=PRESS_START,
classic_size=10)
assert not style.user_forced
def test_element_style_detects_real_override(self):
config = {"enabled": True, "customization": {"score_text": {
"font": PRESS_START, "font_size": 14}}}
plugin = _make_plugin(config, schema=TEST_SCHEMA)
style = plugin.element_style("score_text", classic_font=PRESS_START,
classic_size=10)
assert style.user_forced_size
assert style.font_size == 14
def test_works_without_schema_manager(self):
"""MockPluginManager has no schema_manager attribute by default —
the resolver degrades to classic-default comparison, no crash."""
config = {"enabled": True, "customization": {"score_text": {
"font_size": 12}}}
plugin = _make_plugin(config, schema=None)
style = plugin.element_style("score_text", classic_font=PRESS_START,
classic_size=10)
assert style.user_forced_size # 12 != classic 10
def test_resolver_is_cached_and_invalidated_on_config_change(self):
plugin = _make_plugin({"enabled": True}, schema=TEST_SCHEMA)
first = plugin.style_resolver
assert plugin.style_resolver is first
plugin.on_config_change({"enabled": True, "customization": {
"score_text": {"font_size": 14}}})
second = plugin.style_resolver
assert second is not first
style = plugin.element_style("score_text", classic_font=PRESS_START,
classic_size=10)
assert style.user_forced_size
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
+273
View File
@@ -0,0 +1,273 @@
"""Tests for x-style-elements schema expansion.
A plugin declares styleable elements once, compactly; expansion generates
the full customization property blocks at schema-load time. The invariants
that matter:
- idempotent (expand(expand(s)) == expand(s)) and the input is never mutated
- both load paths (cached GET, uncached save) see the identical shape
- generated defaults flow into generate_default_config, and saving twice is
round-trip stable (merge_with_defaults produces no churn)
- hand-written property blocks for the same element always win
- defaults_from_schema_file (what plugins use to build resolvers from their
RAW schema file) agrees exactly with the schema manager's expanded view
"""
import copy
import json
import os
import sys
import jsonschema
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.element_style import ( # noqa: E402
defaults_from_schema_file,
expand_style_elements,
extract_schema_defaults,
get_style_elements,
)
from src.plugin_system.schema_manager import SchemaManager # noqa: E402
PRESS_START = "PressStart2P-Regular.ttf"
def _declared_schema():
return {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"customization": {
"type": "object",
"title": "Display Customization",
"x-style-elements": {
"score_text": {
"title": "Game Score",
"font": {"default": PRESS_START},
"size": {"default": 10, "min": 4, "max": 16},
"color": True,
"offsets": True,
},
"detail_text": {
"font": {"default": "4x6-font.ttf"},
"size": {"default": 6},
},
},
"properties": {},
"additionalProperties": False,
},
},
}
class TestExpansionShape:
def test_generates_element_blocks(self):
expanded = expand_style_elements(_declared_schema())
cust = expanded["properties"]["customization"]["properties"]
score = cust["score_text"]
assert score["x-style-managed"] is True
assert score["title"] == "Game Score"
assert score["properties"]["font"]["default"] == PRESS_START
assert score["properties"]["font"]["x-widget"] == "font-selector"
assert score["properties"]["font_size"]["default"] == 10
assert score["properties"]["font_size"]["minimum"] == 4
assert score["properties"]["font_size"]["maximum"] == 16
assert score["properties"]["text_color"]["x-widget"] == "color-picker"
assert score["properties"]["text_color"]["default"] == [255, 255, 255]
assert score["additionalProperties"] is False
def test_color_and_offsets_are_optional(self):
expanded = expand_style_elements(_declared_schema())
cust = expanded["properties"]["customization"]["properties"]
detail = cust["detail_text"]
assert "text_color" not in detail["properties"]
assert detail["title"] == "Detail Text" # prettified from the key
layout = cust["layout"]["properties"]
assert "score_text" in layout
assert "detail_text" not in layout
def test_offsets_block_shape(self):
expanded = expand_style_elements(_declared_schema())
layout = expanded["properties"]["customization"]["properties"]["layout"]
assert layout["x-style-managed"] is True
entry = layout["properties"]["score_text"]
assert entry["properties"]["x_offset"]["default"] == 0
assert entry["properties"]["y_offset"]["default"] == 0
def test_declared_color_default(self):
schema = _declared_schema()
decl = schema["properties"]["customization"]["x-style-elements"]
decl["score_text"]["color"] = {"default": [255, 215, 0]}
expanded = expand_style_elements(schema)
color = (expanded["properties"]["customization"]["properties"]
["score_text"]["properties"]["text_color"])
assert color["default"] == [255, 215, 0]
def test_declaration_survives_expansion(self):
"""The declaration is the element registry for tooling — it must
remain readable from the expanded schema."""
expanded = expand_style_elements(_declared_schema())
assert set(get_style_elements(expanded)) == {"score_text", "detail_text"}
assert set(SchemaManager.get_style_elements(expanded)) == {
"score_text", "detail_text"}
def test_no_declaration_returns_same_object(self):
schema = {"type": "object", "properties": {"enabled": {"default": True}}}
assert expand_style_elements(schema) is schema
def test_valid_draft7(self):
jsonschema.Draft7Validator.check_schema(
expand_style_elements(_declared_schema()))
def test_property_order_updated_when_present(self):
schema = _declared_schema()
schema["properties"]["customization"]["x-propertyOrder"] = []
expanded = expand_style_elements(schema)
order = expanded["properties"]["customization"]["x-propertyOrder"]
# generated elements before layout (the template only renders keys
# in x-propertyOrder when one exists)
assert set(order) == {"score_text", "detail_text", "layout"}
assert order.index("score_text") < order.index("layout")
def test_malformed_declaration_is_harmless(self):
schema = _declared_schema()
schema["properties"]["customization"]["x-style-elements"] = {
"bad": "not a dict", "score_text": {"size": {"default": 10}}}
expanded = expand_style_elements(schema)
cust = expanded["properties"]["customization"]["properties"]
assert "bad" not in cust
assert "score_text" in cust
class TestExpansionInvariants:
def test_idempotent(self):
once = expand_style_elements(_declared_schema())
twice = expand_style_elements(once)
assert once == twice
def test_input_never_mutated(self):
schema = _declared_schema()
snapshot = copy.deepcopy(schema)
expand_style_elements(schema)
assert schema == snapshot
def test_hand_written_block_wins(self):
schema = _declared_schema()
hand_written = {
"type": "object",
"properties": {"font": {"type": "string", "default": "custom.ttf"}},
}
schema["properties"]["customization"]["properties"]["score_text"] = \
copy.deepcopy(hand_written)
expanded = expand_style_elements(schema)
assert (expanded["properties"]["customization"]["properties"]["score_text"]
== hand_written)
def test_hand_written_layout_entry_wins(self):
schema = _declared_schema()
schema["properties"]["customization"]["properties"]["layout"] = {
"type": "object",
"properties": {"score_text": {"type": "object", "properties": {
"x_offset": {"type": "integer", "default": 5}}}},
}
expanded = expand_style_elements(schema)
layout = expanded["properties"]["customization"]["properties"]["layout"]
assert layout["properties"]["score_text"]["properties"]["x_offset"]["default"] == 5
class TestSchemaManagerIntegration:
def _manager_with_schema(self, tmp_path, schema):
plugin_dir = tmp_path / "test-plugin"
plugin_dir.mkdir()
(plugin_dir / "config_schema.json").write_text(json.dumps(schema))
return SchemaManager(plugins_dir=tmp_path)
def test_load_schema_expands(self, tmp_path):
mgr = self._manager_with_schema(tmp_path, _declared_schema())
loaded = mgr.load_schema("test-plugin")
assert "score_text" in loaded["properties"]["customization"]["properties"]
def test_cached_and_uncached_loads_agree(self, tmp_path):
"""The save path uses use_cache=False while the form GET uses the
cache — they must see the identical expanded shape."""
mgr = self._manager_with_schema(tmp_path, _declared_schema())
cached = mgr.load_schema("test-plugin", use_cache=True)
again = mgr.load_schema("test-plugin", use_cache=True)
uncached = mgr.load_schema("test-plugin", use_cache=False)
assert cached == uncached == again
def test_disk_file_untouched(self, tmp_path):
schema = _declared_schema()
mgr = self._manager_with_schema(tmp_path, schema)
mgr.load_schema("test-plugin")
on_disk = json.loads(
(tmp_path / "test-plugin" / "config_schema.json").read_text())
assert on_disk == schema
assert "score_text" not in on_disk["properties"]["customization"]["properties"]
def test_defaults_include_generated_elements(self, tmp_path):
mgr = self._manager_with_schema(tmp_path, _declared_schema())
defaults = mgr.generate_default_config("test-plugin")
assert defaults["customization"]["score_text"]["font"] == PRESS_START
assert defaults["customization"]["score_text"]["font_size"] == 10
assert defaults["customization"]["score_text"]["text_color"] == [255, 255, 255]
assert defaults["customization"]["layout"]["score_text"]["x_offset"] == 0
def test_save_twice_is_round_trip_stable(self, tmp_path):
"""merge_with_defaults(merged, defaults) must be a fixed point —
saving a config twice can't keep growing/altering it."""
mgr = self._manager_with_schema(tmp_path, _declared_schema())
defaults = mgr.generate_default_config("test-plugin")
user_config = {"enabled": True, "customization": {
"score_text": {"font_size": 14}}}
merged_once = mgr.merge_with_defaults(user_config, defaults)
merged_twice = mgr.merge_with_defaults(merged_once, defaults)
assert merged_once == merged_twice
assert merged_once["customization"]["score_text"]["font_size"] == 14
class TestResolverParity:
def test_defaults_from_schema_file_matches_manager_view(self, tmp_path):
"""Plugins build resolvers from their RAW schema file; the web UI
merges defaults from the EXPANDED schema. Both must produce the
same defaults or override detection diverges between contexts."""
schema = _declared_schema()
plugin_dir = tmp_path / "test-plugin"
plugin_dir.mkdir()
schema_path = plugin_dir / "config_schema.json"
schema_path.write_text(json.dumps(schema))
mgr = SchemaManager(plugins_dir=tmp_path)
manager_defaults = mgr.extract_defaults_from_schema(
mgr.load_schema("test-plugin"))
raw_file_defaults = defaults_from_schema_file(str(schema_path))
assert raw_file_defaults == manager_defaults
def test_resolver_treats_generated_defaults_as_untouched(self, tmp_path):
"""End to end: a config saved through the web UI (all generated
defaults baked in) must not read as a user override, and the
schema-default color must not clobber a classic color."""
from src.element_style import ElementStyleResolver
schema_path = tmp_path / "config_schema.json"
schema_path.write_text(json.dumps(_declared_schema()))
defaults = defaults_from_schema_file(str(schema_path))
saved_config = {"enabled": True, "customization": {
"score_text": {"font": PRESS_START, "font_size": 10,
"text_color": [255, 255, 255]},
"layout": {"score_text": {"x_offset": 0, "y_offset": 0}},
}}
resolver = ElementStyleResolver(saved_config, defaults)
style = resolver.style("score_text", classic_font=PRESS_START,
classic_size=10, classic_color=(255, 215, 0))
assert not style.user_forced
assert not style.user_forced_color
assert style.color == (255, 215, 0) # semantic classic color survives
assert style.offset == (0, 0)
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
+252
View File
@@ -0,0 +1,252 @@
"""Tests for POST /api/v3/plugins/preview — the config-page live preview.
The endpoint renders a plugin headlessly (pure PIL, no hardware, no pip)
with a CANDIDATE config: either the current form state (parsed by the same
parse_plugin_config_form used by save, so preview and save can never
disagree) or a JSON config body.
"""
import base64
import io
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from PIL import Image
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from web_interface.blueprints import api_v3 as api_v3_module # noqa: E402
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
PLUGIN_ID = "preview-test-plugin"
MANAGER_PY = '''
from PIL import ImageFont
from src.plugin_system.base_plugin import BasePlugin
class PreviewTestPlugin(BasePlugin):
def update(self):
return True
def display(self, force_clear=False):
if force_clear:
self.display_manager.clear()
text = self.config.get("message", "hello")
self.display_manager.draw.text((1, 1), text, fill=(255, 255, 255))
self.display_manager.update_display()
'''
MANIFEST = {
"id": PLUGIN_ID,
"name": "Preview Test Plugin",
"version": "1.0.0",
"class_name": "PreviewTestPlugin",
"entry_point": "manager.py",
"display_modes": ["preview_test"],
}
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"message": {"type": "string", "default": "hello"},
},
}
@pytest.fixture
def plugin_dir(tmp_path):
plugin = tmp_path / PLUGIN_ID
plugin.mkdir()
(plugin / "manager.py").write_text(MANAGER_PY)
(plugin / "manifest.json").write_text(json.dumps(MANIFEST))
(plugin / "config_schema.json").write_text(json.dumps(SCHEMA))
return plugin
@pytest.fixture
def client(plugin_dir, tmp_path):
from src.plugin_system.schema_manager import SchemaManager
test_app = Flask(__name__)
test_app.register_blueprint(api_v3, url_prefix="/api/v3")
config_manager = MagicMock()
config_manager.load_config.return_value = {
"display": {"hardware": {"cols": 64, "chain_length": 2,
"rows": 32, "parallel": 1}},
PLUGIN_ID: {"enabled": False, "message": "saved"},
}
plugin_manager = MagicMock()
plugin_manager.plugins_dir = str(tmp_path)
old = (getattr(api_v3_module.api_v3, "config_manager", None),
getattr(api_v3_module.api_v3, "plugin_manager", None),
getattr(api_v3_module.api_v3, "schema_manager", None))
api_v3_module.api_v3.config_manager = config_manager
api_v3_module.api_v3.plugin_manager = plugin_manager
api_v3_module.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path)
with test_app.test_client() as c:
yield c
(api_v3_module.api_v3.config_manager,
api_v3_module.api_v3.plugin_manager,
api_v3_module.api_v3.schema_manager) = old
def _decode_image(data_url):
assert data_url.startswith("data:image/png;base64,")
raw = base64.b64decode(data_url.split(",", 1)[1])
return Image.open(io.BytesIO(raw))
class TestPreviewEndpoint:
def test_json_body_renders_at_default_panel_size(self, client):
"""No width/height -> the user's real panel (64*2 x 32*1)."""
resp = client.post(f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}",
json={"config": {"message": "hi"}})
assert resp.status_code == 200
data = resp.get_json()["data"]
img = _decode_image(data["image"])
assert img.size == (128, 32)
assert data["errors"] == []
def test_explicit_size(self, client):
resp = client.post(
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=64&height=64",
json={"config": {}})
assert resp.status_code == 200
img = _decode_image(resp.get_json()["data"]["image"])
assert img.size == (64, 64)
def test_form_encoding_matches_json(self, client):
"""The form path (what HTMX posts) and the JSON path must render
the same candidate config identically."""
via_json = client.post(
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
json={"config": {"message": "same"}})
via_form = client.post(
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
data={"message": "same"})
a = _decode_image(via_json.get_json()["data"]["image"])
b = _decode_image(via_form.get_json()["data"]["image"])
assert list(a.getdata()) == list(b.getdata())
def test_candidate_config_wins_over_saved(self, client):
"""The preview must show the UNSAVED form state, not the saved
config ('saved' vs 'candidate' render differently)."""
saved = client.post(
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
json={"config": {}})
candidate = client.post(
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
json={"config": {"message": "candidate"}})
a = _decode_image(saved.get_json()["data"]["image"])
b = _decode_image(candidate.get_json()["data"]["image"])
assert list(a.getdata()) != list(b.getdata())
def test_disabled_plugin_still_previews(self, client):
"""Saved config has enabled: False — preview forces enabled."""
resp = client.post(f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}",
json={"config": {}})
assert resp.status_code == 200
assert resp.get_json()["data"]["errors"] == []
def test_preview_size_form_field(self, client):
"""The UI size selector posts __preview_size=WxH via hx-vals (htmx
caches hx-post's path, so it can't ride the query string). It must
set the render size and must NOT leak into the candidate config."""
resp = client.post(f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}",
data={"message": "hi", "__preview_size": "64x64"})
assert resp.status_code == 200
data = resp.get_json()["data"]
img = _decode_image(data["image"])
assert img.size == (64, 64)
assert data["errors"] == []
def test_query_args_beat_preview_size_field(self, client):
resp = client.post(
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
data={"message": "hi", "__preview_size": "64x64"})
img = _decode_image(resp.get_json()["data"]["image"])
assert img.size == (128, 32)
def test_malformed_preview_size_falls_back_to_panel(self, client):
resp = client.post(f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}",
data={"message": "hi", "__preview_size": "bogus x"})
img = _decode_image(resp.get_json()["data"]["image"])
assert img.size == (128, 32) # cols*chain x rows*parallel
def test_json_candidate_deep_merges_onto_saved_config(self, client):
"""A partial JSON candidate must not wipe saved sibling values in
the same nested section (form path and save both deep-merge)."""
# Saved config has message "saved"; posting an unrelated nested key
# must not discard it — render must still differ from a candidate
# that explicitly changes message.
keep_saved = client.post(
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
json={"config": {}})
explicit = client.post(
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32",
json={"config": {"message": "saved"}})
a = _decode_image(keep_saved.get_json()["data"]["image"])
b = _decode_image(explicit.get_json()["data"]["image"])
assert list(a.getdata()) == list(b.getdata())
def test_hanging_plugin_times_out(self, client, plugin_dir, monkeypatch):
"""A plugin whose display() hangs must not pin the web worker.
Uses its own plugin id: the loader caches the module per id, so
reusing PLUGIN_ID would run the already-imported (non-hanging) code
when this test follows others in the suite.
"""
from web_interface.blueprints import api_v3 as api_v3_module
monkeypatch.setattr(api_v3_module, "PREVIEW_RENDER_TIMEOUT_SEC", 1)
hang_id = "preview-hang-plugin"
hang_dir = plugin_dir.parent / hang_id
hang_dir.mkdir()
(hang_dir / "manager.py").write_text(MANAGER_PY.replace(
"self.display_manager.update_display()",
"import time; time.sleep(10); self.display_manager.update_display()"))
manifest = dict(MANIFEST, id=hang_id, name="Hang Plugin")
(hang_dir / "manifest.json").write_text(json.dumps(manifest))
(hang_dir / "config_schema.json").write_text(json.dumps(SCHEMA))
resp = client.post(f"/api/v3/plugins/preview?plugin_id={hang_id}",
json={"config": {}})
assert resp.status_code == 504
def test_htmx_gets_html_fragment(self, client):
resp = client.post(
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=64&height=32",
data={"message": "hi"}, headers={"HX-Request": "true"})
assert resp.status_code == 200
assert resp.mimetype == "text/html"
body = resp.get_data(as_text=True)
assert "<img" in body and "data:image/png;base64," in body
def test_unknown_plugin_404(self, client):
resp = client.post("/api/v3/plugins/preview?plugin_id=nope",
json={"config": {}})
assert resp.status_code == 404
def test_missing_plugin_id_400(self, client):
resp = client.post("/api/v3/plugins/preview", json={"config": {}})
assert resp.status_code == 400
def test_absurd_size_rejected(self, client):
resp = client.post(
f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=99999&height=32",
json={"config": {}})
assert resp.status_code == 400
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))