Files
LEDMatrix/test/web_interface/test_style_editor_takeover.py
T
ChuckandClaude Opus 5 f9b1f87e8d fix: clamp colour components, and let the style editor actually take over (#569)
* fix(element-style): clamp out-of-range colour components instead of rejecting

A regression this framework shipped. The eight scoreboards used to read their
colours through sports_card.coerce_rgb, which clamps; routing them through the
shared element_color sent them through _normalize_color, which rejected any
component outside 0..255 and fell back to the default. So a configured
[999, -5, 20] -- a typo'd bright red -- rendered white instead of (255, 0, 20).

Their own test_element_text_colors.py caught it: one case of nineteen, in all
eight plugins, failing only once the core change reached main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): the style editor takes over its own blocks -- and gets to at all

Two defects, both found by rendering the real partial in a browser rather than
by reading the code.

It was losing a race to its own fields. The hand-off guard asked "do any
fallback controls differ from their server-rendered defaults?" as a proxy for
"is someone editing this?". But the fallback holds this block's own font
fields, and the font-selector widget populates them on the same 50ms timer --
so a plain page load, with nobody touching anything, raced into "dirty" and the
editor removed itself, leaving the 701-line accordion form it exists to
replace. Measured: seven customization.*.font selects dirty ~60ms after
injection, clean again by 400ms. The question is whether a *person* typed, and
event.isTrusted answers exactly that; the listeners now go on synchronously,
because the edit worth protecting can happen before initWidget runs.

It took over too much. Taking over removed the whole fallback section, but a
customization block can hold more than styling -- football keeps
favorite_result_colors there -- so that removed the only UI those fields had,
and the editor also rendered them as an element, giving every row an "enabled"
and three colour columns. Core now marks the blocks it recognises as styling
(the compact declaration already did; hand-written adoption did not), the
widget renders only those, and the template drops only the children the widget
reports owning.

Verified on football's real schema: 28 rows across four mode tabs, columns
Element/Font/Size/Colour/X/Y, favorite_result_colors still editable with its
ten inputs, no duplicated field names, no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): style editor no longer drops layout-only fields it never rendered

CodeRabbit flagged elementKeys() in style-editor.js: render() claims the
whole customization.layout child as the widget's own (removing it from the
generic fallback renderer, since posting the same offset twice is worse),
but elementKeys() only listed keys that also have their own top-level style
block. A hand-written schema can put a key under layout that never got one
-- a logo, a timeout indicator, a possession arrow with a position but no
font or colour -- and that key's only control silently disappeared: no row
in the style editor's table (elementKeys never listed it) and no fallback
section either (layout was removed wholesale).

elementKeys() now appends any layout-declared key not already covered by a
style element, so table() renders a row for it (layout columns only, no
style columns) and the wholesale layout ownership claim stays truthful.

Verified against current code before fixing. New regression test
(test/js/unit/test_style_editor_element_keys.js, following this repo's
existing eval-extraction pattern for testing widget JS without a browser)
fails against the reverted function and passes with the fix; added to
run_all.js and the suite table in test/js/README.md.

Full pytest suite: 4887 passed, 62 skipped, 2 failed -- both the
pre-existing Europe/Kiev/Asia/Calcutta tzdata-alias gap on this sandbox,
identical on origin/main, unrelated to this change.

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

* fix(web): style editor no longer strands leaf-valued layout fields

A prior fix on this PR made elementKeys() append any layout-only key with
no style block of its own (a logo, a timeout indicator, a possession
arrow), so table() draws a row for it instead of losing it when the
wholesale `layout` claim removes the generic fallback. That covers a
layout-only key shaped like an object (x_offset/y_offset, ...), because
columnsFor() only ever produced columns from a key's *sub-fields*.

It missed the case where the layout-only key's own value is itself a
leaf -- a plain "show_logo" boolean directly under layout, no x/y object
underneath. elementKeys() still lists it (any row: no matching column),
so it renders as an uneditable blank row and its only control -- the
generic fallback checkbox -- is still gone. Confirmed by executing the
real widget's render() against a synthetic schema in Node (a DOM-stub
harness, not committed): the field's name never appeared as an <input>.

columnsFor() now gives such a leaf key a column keyed to itself
('layout-leaf'), and elementRow() binds it to the leaf's own path
(customization.layout.<key>, matching the name the fallback would have
used) instead of leaving every cell blank.

New regression test (test/js/unit/test_style_editor_layout_leaf_columns.js,
following this PR's existing eval-extraction pattern) checks the leaf
column is produced, is self-keyed, doesn't duplicate, and that a schema
with no leaf-valued layout key is unaffected; wired into run_all.js and
the suite table in test/js/README.md.

test/js/run_all.js: 84 + 6 + 6 = all suites passed (jsdom unavailable
here, DOM suites skip as before). Python suite untouched by this change;
test_style_editor_extra_fields.py, test_style_editor_save_roundtrip.py
and the one PIL-dependent style_editor_takeover.py case fail identically
before this commit -- missing flask/PIL in this sandbox, not this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): keep layout-leaf style-editor columns distinct from name collisions

columnsFor() keyed a layout-only leaf field's column by its bare field
name. If an unrelated element's style block or another element's layout
axis block happened to declare a sub-field with that same name, the
`!seen.has(key)` guard skipped creating the leaf's column, silently
dropping its only control again -- the same failure the leaf-column fix
was meant to close, just reached through a name collision (CodeRabbit
review on 324a7ea).

Key layout-leaf columns under a namespaced id so they can never be
shadowed by an unrelated column sharing their name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): CSS.escape() the owned key before it becomes a selector

container.dataset.ownedKeys round-trips schema property keys through a
DOM dataset attribute, and the takeover handoff spliced each one
straight into '[data-child-key="' + k + '"]' with no escaping --
inconsistent with this codebase's own convention elsewhere
(plugin-file-manager.js, app-shell.js's escapeCssSelector) for building
a selector from a dynamic value. A key containing a quote or backslash
would break the selector or be steerable; Codacy's static analysis
flagged this pattern (1 high ErrorProne finding on PR #569, current
head at the time) as a new issue, though its dashboard is unreachable
from this sandbox (egress to app.codacy.com is blocked) and the
check-run API returned no detail text -- verified and fixed by reading
the diff directly rather than the tool's own description.

Added a source-assertion regression test alongside this file's
existing ones (this behavior lives in an inline script no Python test
executes).

Full suite: 4888 passed, 62 skipped, 2 failed -- both the pre-existing
Europe/Kiev/Asia/Calcutta tzdata-alias gap in this sandbox, identical
on origin/main, unrelated to this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(web): every advertised layout offset gets a control in the style editor

The editor took the whole layout section over but matched offsets to style
rows by exact key. A hand-written schema's two blocks were never named alike --
football styles score_text but positions score -- so of football's eleven
positionable things only status_text had a control. Score, odds, both logos,
timeouts, possession, down-and-distance, date, time and records were options
the schema advertised and the renderer reads, reachable nowhere in the UI.

Core now resolves each style element's layout key through alias_keys, the map
the resolver already reads offsets with, and records it as x-layout-key. The
widget reads that rather than carrying a second copy of the rules, and posts
under the key the schema declares: football's own offset reader looks up
layout.score, so a value saved as layout.score_text would be kept and never
drawn. Layout entries no style element claims get an "Other positions" table
with its own columns, in every mode panel as well as the base one, in the order
the plugin declared them.

Verified in a browser against football's real schema: 92 of 92 layout fields
(23 base, 23 per mode) rendered exactly once under their declared names, none
posted under a style key, no duplicated field names, favorite_result_colors
still editable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:48:36 -04:00

252 lines
12 KiB
Python

"""When the style editor takes over a customization block, it takes over
exactly its own part of it -- and it actually gets to take over at all.
Two defects this pins, both found by rendering the real partial in a browser
rather than by reading the code:
1. The hand-off guard asked "do any fallback controls differ from their
server-rendered defaults?" as a proxy for "is someone editing this?". The
fallback contains this block's own font fields, and the font-selector
widget populates them on the same 50ms timer -- so a plain page load with
nobody touching anything raced into "dirty" (seven customization.*.font
selects, measured ~60ms after injection) and the style editor removed
itself, leaving the accordion form it exists to replace. The question is
whether a *person* typed, and isTrusted answers exactly that.
2. Taking over used to remove the whole fallback section. A customization
block can hold more than styling -- football keeps favorite_result_colors
there -- so that removed the only UI those fields had.
These assert on the template source because the behaviour lives in an inline
script that no Python test executes; the browser check that found both is not
something CI runs.
"""
import re
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
TEMPLATE = (PROJECT_ROOT / "web_interface" / "templates" / "v3" / "partials"
/ "plugin_config.html")
@pytest.fixture(scope="module")
def source():
return TEMPLATE.read_text(encoding="utf-8")
class TestTheHandOffGuard:
def test_it_asks_whether_a_person_typed(self, source):
assert "isTrusted" in source, (
"a programmatic change by a sibling widget must not read as a "
"user edit -- that is what removed the editor on a plain load")
assert "userEdited" in source
def test_it_no_longer_diffs_values_against_defaults(self, source):
assert "fallbackIsDirty" not in source, (
"value-diffing cannot distinguish a user's edit from another "
"widget populating this block's own fields")
assert "defaultSelected" not in source
def test_the_listeners_are_attached_synchronously(self, source):
"""The edit worth protecting can happen before initWidget runs, so
the listeners cannot wait for it.
Scoped to this branch: the template defines an initWidget for every
widget it can render, and the first one in the file belongs to a
different branch entirely.
"""
start = source.index("obj_widget == 'style-editor'")
branch = source[start:source.index("{% elif prop.properties %}", start)]
assert "watchForRealEdits" in branch
assert branch.index("watchForRealEdits") < branch.index("function initWidget"), (
"the watcher must be installed above this branch's initWidget, "
"and invoked immediately rather than from inside it")
def test_the_guard_still_backs_off_for_a_real_edit(self, source):
assert re.search(r"if \(userEdited\) \{ container\.remove\(\); return; \}",
source), "a genuine in-progress edit must still win"
class TestItTakesOverOnlyItsOwnBlocks:
def test_children_are_individually_addressable(self, source):
assert 'data-child-key="{{ nested_key }}"' in source, (
"without a handle per child the only options are removing the "
"whole section or none of it")
def test_removal_is_driven_by_what_the_widget_reported(self, source):
assert "ownedKeys" in source
assert "fallback.querySelector(" in source
def test_an_unowned_child_survives(self, source):
"""The empty-section case still removes the lot, so a block that is
entirely styling looks exactly as it did before."""
assert "!fallback.querySelector('[data-child-key]')" in source
def test_the_owned_key_is_escaped_before_it_becomes_a_selector(self, source):
"""ownedKeys comes back off a dataset attribute, not a literal --
splicing it into '[data-child-key="' + k + '"]' unescaped breaks (or
is steerable) on a key containing a quote or backslash. The rest of
this codebase (plugin-file-manager.js, app-shell.js) always routes a
dynamic value through CSS.escape() before it lands in a selector."""
assert "CSS.escape(k)" in source, (
"an owned key must be CSS.escape()'d before being interpolated "
"into the data-child-key attribute selector")
class TestTheSchemaSaysWhichBlocksAreStyling:
def test_adopted_blocks_are_marked_and_others_are_not(self):
from src.element_style import expand_style_elements
schema = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"properties": {
"score_text": {
"type": "object",
"properties": {
"font": {"type": "string",
"default": "PressStart2P-Regular.ttf"},
"font_size": {"type": "integer", "default": 10},
"text_color": {"type": "array",
"default": [255, 255, 255]},
},
},
# Not styling: a feature that happens to live here.
"favorite_result_colors": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"win_color": {"type": "array",
"default": [0, 255, 0]},
},
},
},
},
},
}
props = expand_style_elements(schema)["properties"]["customization"]["properties"]
assert props["score_text"]["x-style-managed"] is True
assert "x-style-managed" not in props["favorite_result_colors"]
# Football's real layout keys, in its schema's order. The style block and the
# layout block were written years apart and never agreed on names.
FOOTBALL_LAYOUT = ["home_logo", "away_logo", "score", "status_text", "date",
"time", "down_distance", "timeouts", "possession",
"records", "odds"]
def _offsets(*axes):
return {"type": "object",
"properties": {a: {"type": "integer", "default": 0} for a in axes}}
def _style_block():
return {"type": "object", "properties": {
"font": {"type": "string", "default": "PressStart2P-Regular.ttf"},
"font_size": {"type": "integer", "default": 10},
"text_color": {"type": "array", "default": [255, 255, 255]},
}}
def _football_shaped():
layout = {k: _offsets("x_offset", "y_offset") for k in FOOTBALL_LAYOUT}
layout["records"] = _offsets("away_x_offset", "home_x_offset", "y_offset")
return {"type": "object", "properties": {"customization": {
"type": "object",
"x-style-modes": ["live", "recent"],
"properties": {
**{k: _style_block() for k in (
"score_text", "period_text", "team_name", "status_text",
"detail_text", "odds_text", "rank_text")},
"layout": {"type": "object", "properties": layout},
}}}}
class TestEveryAdvertisedOffsetGetsAControl:
"""The style editor took the layout section over but only drew offsets
whose layout key matched a style key exactly. In football that was
status_text alone: score, odds, both logos, timeouts, possession,
down-and-distance, date, time and records -- options the schema
advertises and the renderer reads -- had no control anywhere."""
def _customization(self):
from src.element_style import expand_style_elements
return expand_style_elements(_football_shaped())[
"properties"]["customization"]
def test_a_style_element_is_told_where_its_offsets_live(self):
"""Resolved in core through the same alias map the resolver reads
offsets with, so the editor cannot drift from the renderer."""
props = self._customization()["properties"]
assert props["score_text"]["x-layout-key"] == "score"
assert props["odds_text"]["x-layout-key"] == "odds"
assert props["status_text"]["x-layout-key"] == "status_text"
def test_an_element_with_no_offsets_claims_nothing(self):
props = self._customization()["properties"]
for key in ("period_text", "detail_text", "team_name", "rank_text"):
assert "x-layout-key" not in props[key], key
def test_the_mode_copies_carry_it_too(self):
live = self._customization()["properties"]["modes"]["properties"][
"live"]["properties"]
assert live["score_text"]["x-layout-key"] == "score"
def test_positions_keep_the_order_the_plugin_declared(self):
"""Flask sorts keys when it serialises the schema, which would
otherwise list the logos after the date."""
c = self._customization()
assert c["properties"]["layout"]["x-propertyOrder"] == FOOTBALL_LAYOUT
live_layout = c["properties"]["modes"]["properties"]["live"][
"properties"]["layout"]
assert live_layout["x-propertyOrder"] == FOOTBALL_LAYOUT
def test_every_layout_entry_is_claimed_or_listed_as_a_position(self):
"""Mirrors the widget's split: a layout entry either belongs to a
style row or gets a row of its own. Nothing is left over."""
props = self._customization()["properties"]
layout = props["layout"]["properties"]
claimed = {props[k]["x-layout-key"] for k in props
if isinstance(props[k], dict) and "x-layout-key" in props[k]}
positions = [k for k in props["layout"]["x-propertyOrder"]
if k not in claimed]
assert claimed | set(positions) == set(layout)
assert positions == ["home_logo", "away_logo", "date", "time",
"down_distance", "timeouts", "possession",
"records"]
def test_the_widget_reads_the_annotation_and_lists_positions(self):
js = (PROJECT_ROOT / "web_interface" / "static" / "v3" / "js"
/ "widgets" / "style-editor.js").read_text(encoding="utf-8")
assert "x-layout-key" in js, (
"the alias rules live in element_style.py; the widget must read "
"their result rather than carry a second copy")
assert "function positionRows" in js
def test_a_leaf_under_layout_is_never_claimed_as_offsets(self):
"""A show_logo toggle straight under layout has no x/y object, so it
cannot hold an element's offsets. Claiming it would give that row no
layout columns and remove the leaf from the positions list -- the
field would lose its only control."""
from src.element_style import expand_style_elements
schema = _football_shaped()
cust = schema["properties"]["customization"]
# A style element whose alias ("show_logo") names a leaf.
cust["properties"]["show_logo_text"] = _style_block()
cust["properties"]["layout"]["properties"]["show_logo"] = {
"type": "boolean", "default": True}
props = expand_style_elements(schema)["properties"]["customization"][
"properties"]
assert "x-layout-key" not in props["show_logo_text"]
assert "show_logo" in props["layout"]["x-propertyOrder"]