mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 18:46:37 +00:00
Four commits landed on this branch from review while the layout wiring was in progress, and the two solved overlapping problems differently. Theirs made elementKeys() append every layout-only key, matched by exact name. Against football that also lists `score` and `odds` -- entries the alias resolution already pairs with score_text and odds_text -- so those offsets would get two controls posting the same field. The row split here (styleRows via core's x-layout-key, positionRows for what nothing claims) replaces that append; elementKeys() is back to listing style elements only. Theirs also covered cases this side had missed, all kept: - A leaf straight under layout (a show_logo toggle, no x/y object) now gets a position row with a self-keyed column bound to customization.layout.<key>. Core no longer lets an element claim a leaf as its offsets. - Leaf columns keep their namespaced id, so a leaf sharing a name with another entry's axis cannot be shadowed. - columnsFor(schema) still works with one argument (every row), so their column tests run unchanged. - The owned key is CSS.escape()'d before it becomes a selector. test_style_editor_element_keys.js is rewritten for the new contract, and says why the old one had to go. All six JS suites pass; in a browser football's 92 layout fields each render once, and a synthetic leaf -- including one named `scale` alongside a `scale` axis -- gets exactly one correctly named control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
252 lines
12 KiB
Python
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"]
|