mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 11:06:39 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8d344ae60 | ||
|
|
c0e473882a |
+11
-2
@@ -763,6 +763,12 @@ def _adopt_handwritten_block(schema: Dict[str, Any],
|
|||||||
|
|
||||||
for key in element_keys:
|
for key in element_keys:
|
||||||
_upgrade_font_property(props[key])
|
_upgrade_font_property(props[key])
|
||||||
|
# Marked like a declared element, so consumers can tell the style
|
||||||
|
# blocks from whatever else the plugin keeps under customization.
|
||||||
|
# Football's block also holds favorite_result_colors, which is a
|
||||||
|
# feature with its own fields -- without this the editor treats it
|
||||||
|
# as an element and every row grows an "enabled"/"win color" column.
|
||||||
|
props[key]['x-style-managed'] = True
|
||||||
|
|
||||||
modes = customization.get('x-style-modes')
|
modes = customization.get('x-style-modes')
|
||||||
if isinstance(modes, list) and modes:
|
if isinstance(modes, list) and modes:
|
||||||
@@ -904,10 +910,13 @@ def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
|
|||||||
return None
|
return None
|
||||||
if isinstance(value, (list, tuple)) and len(value) == 3:
|
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||||
try:
|
try:
|
||||||
rgb = tuple(int(c) for c in value)
|
# Clamped, not rejected. The readers this replaced clamped
|
||||||
|
# (sports_card.coerce_rgb), and the eight scoreboards' own tests
|
||||||
|
# pin it: a configured [999, -5, 20] is a typo'd bright red, and
|
||||||
|
# answering "unusable, take the default" turned it white instead.
|
||||||
|
rgb = tuple(max(0, min(255, int(c))) for c in value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
if all(0 <= c <= 255 for c in rgb):
|
|
||||||
return rgb # type: ignore[return-value]
|
return rgb # type: ignore[return-value]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ class TestColour:
|
|||||||
cfg = {"customization": {"score_text": {"text_color": value}}}
|
cfg = {"customization": {"score_text": {"text_color": value}}}
|
||||||
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (9, 9, 9)
|
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (9, 9, 9)
|
||||||
|
|
||||||
|
def test_out_of_range_components_are_clamped_not_rejected(self):
|
||||||
|
"""The readers this replaced clamped, and the eight scoreboards' own
|
||||||
|
tests pin it. Rejecting instead turned a typo'd [999, -5, 20] -- a
|
||||||
|
bright red -- into the default white, which is a colour the user never
|
||||||
|
asked for rather than the one they nearly asked for."""
|
||||||
|
cfg = {"customization": {"score_text": {"text_color": [999, -5, 20]}}}
|
||||||
|
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (255, 0, 20)
|
||||||
|
|
||||||
def test_coerce_rgb_clamps_rather_than_rejecting(self):
|
def test_coerce_rgb_clamps_rather_than_rejecting(self):
|
||||||
assert C.coerce_rgb([300, -5, 20], (0, 0, 0)) == (255, 0, 20)
|
assert C.coerce_rgb([300, -5, 20], (0, 0, 0)) == (255, 0, 20)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
||||||
@@ -146,10 +146,20 @@
|
|||||||
function elementKeys(schema) {
|
function elementKeys(schema) {
|
||||||
var props = (schema && schema.properties) || {};
|
var props = (schema && schema.properties) || {};
|
||||||
var order = schema['x-propertyOrder'] || Object.keys(props);
|
var order = schema['x-propertyOrder'] || Object.keys(props);
|
||||||
return order.filter(function (k) {
|
var shaped = order.filter(function (k) {
|
||||||
return k !== 'layout' && k !== 'modes'
|
return k !== 'layout' && k !== 'modes'
|
||||||
&& own(props, k) && ownObj(props, k).properties;
|
&& own(props, k) && ownObj(props, k).properties;
|
||||||
});
|
});
|
||||||
|
// Core marks the blocks it recognises as styling. Prefer that: a
|
||||||
|
// customization block can also hold a feature of its own -- football
|
||||||
|
// keeps favorite_result_colors there -- and treating one as an element
|
||||||
|
// gives every row that feature's fields as extra columns.
|
||||||
|
var managed = shaped.filter(function (k) {
|
||||||
|
return ownObj(props, k)['x-style-managed'] === true;
|
||||||
|
});
|
||||||
|
// Nothing marked means the schema never went through expansion, so
|
||||||
|
// fall back to the shape test rather than rendering an empty table.
|
||||||
|
return managed.length ? managed : shaped;
|
||||||
}
|
}
|
||||||
|
|
||||||
function titleOf(schema, key) {
|
function titleOf(schema, key) {
|
||||||
@@ -555,6 +565,20 @@
|
|||||||
var root = el('div', { class: 'style-editor' });
|
var root = el('div', { class: 'style-editor' });
|
||||||
container.appendChild(root);
|
container.appendChild(root);
|
||||||
|
|
||||||
|
// Published synchronously, because the host reads it the moment
|
||||||
|
// this returns while the panels below wait on the font catalog.
|
||||||
|
// layout and modes count as ours: their fields appear as columns
|
||||||
|
// in these rows, so leaving them to the generic renderer would
|
||||||
|
// post every offset twice from two different controls.
|
||||||
|
var owned = elementKeys(schema);
|
||||||
|
if (ownObj(schema.properties || {}, 'layout').properties) {
|
||||||
|
owned = owned.concat(['layout']);
|
||||||
|
}
|
||||||
|
if (ownObj(schema.properties || {}, 'modes').properties) {
|
||||||
|
owned = owned.concat(['modes']);
|
||||||
|
}
|
||||||
|
container.dataset.ownedKeys = owned.join(',');
|
||||||
|
|
||||||
loadFonts().then(function (fonts) {
|
loadFonts().then(function (fonts) {
|
||||||
var modeProps = ownObj(schema.properties || {}, 'modes').properties || {};
|
var modeProps = ownObj(schema.properties || {}, 'modes').properties || {};
|
||||||
var modes = Object.keys(modeProps);
|
var modes = Object.keys(modeProps);
|
||||||
|
|||||||
@@ -88,23 +88,30 @@
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
function fallbackIsDirty(root) {
|
// Did a *person* type into the fallback before this widget
|
||||||
if (!root) { return false; }
|
// took over? Comparing values against their defaults cannot
|
||||||
var controls = root.querySelectorAll('input, select, textarea');
|
// answer that. The fallback holds this block's own font
|
||||||
for (var i = 0; i < controls.length; i++) {
|
// fields, and the font-selector widget populates them on the
|
||||||
var c = controls[i];
|
// same 50ms timer -- so a plain page load with nobody
|
||||||
if (c.type === 'checkbox' || c.type === 'radio') {
|
// touching anything raced into "dirty" (measured: seven
|
||||||
if (c.checked !== c.defaultChecked) { return true; }
|
// customization.*.font selects, ~60ms in) and silently
|
||||||
} else if (c.tagName === 'SELECT') {
|
// removed the style editor, leaving the accordion form it
|
||||||
for (var j = 0; j < c.options.length; j++) {
|
// exists to replace.
|
||||||
if (c.options[j].selected !== c.options[j].defaultSelected) { return true; }
|
//
|
||||||
}
|
// isTrusted is false for programmatic changes and true only
|
||||||
} else if (c.value !== c.defaultValue) {
|
// for real input, which is exactly the question. The
|
||||||
return true;
|
// listeners go on now, synchronously, because the edit to
|
||||||
}
|
// protect can happen before initWidget ever runs.
|
||||||
}
|
var userEdited = false;
|
||||||
return false;
|
(function watchForRealEdits() {
|
||||||
}
|
var fb = document.getElementById('{{ field_id }}_fallback');
|
||||||
|
if (!fb) { return; }
|
||||||
|
['input', 'change'].forEach(function (evt) {
|
||||||
|
fb.addEventListener(evt, function (e) {
|
||||||
|
if (e && e.isTrusted) { userEdited = true; }
|
||||||
|
}, true);
|
||||||
|
});
|
||||||
|
})();
|
||||||
function initWidget() {
|
function initWidget() {
|
||||||
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
|
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
|
||||||
var widget = window.LEDMatrixWidgets.get('style-editor');
|
var widget = window.LEDMatrixWidgets.get('style-editor');
|
||||||
@@ -116,7 +123,7 @@
|
|||||||
// while this widget's script is still loading; swapping
|
// while this widget's script is still loading; swapping
|
||||||
// in the widget now would replace them with the stale
|
// in the widget now would replace them with the stale
|
||||||
// server-rendered value and silently drop that edit.
|
// server-rendered value and silently drop that edit.
|
||||||
if (fallbackIsDirty(fallback)) { container.remove(); return; }
|
if (userEdited) { container.remove(); return; }
|
||||||
var value = {{ obj_value|tojson|safe }};
|
var value = {{ obj_value|tojson|safe }};
|
||||||
var config = { schema: {{ prop|tojson|safe }} };
|
var config = { schema: {{ prop|tojson|safe }} };
|
||||||
try {
|
try {
|
||||||
@@ -130,9 +137,27 @@
|
|||||||
container.remove();
|
container.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The widget owns these fields now; drop the generic
|
// The widget owns the style blocks now, so drop those from
|
||||||
// rendering so the form does not post both.
|
// the generic rendering or the form would post both. Only
|
||||||
if (fallback) { fallback.remove(); }
|
// those: a customization block can also hold a feature of
|
||||||
|
// its own (football keeps favorite_result_colors there),
|
||||||
|
// and removing the section wholesale took the only UI that
|
||||||
|
// feature had with it.
|
||||||
|
if (fallback) {
|
||||||
|
var owned = (container.dataset.ownedKeys || '')
|
||||||
|
.split(',').filter(function (k) { return k; });
|
||||||
|
owned.forEach(function (k) {
|
||||||
|
var node = fallback.querySelector(
|
||||||
|
'[data-child-key="' + k + '"]');
|
||||||
|
if (node) { node.remove(); }
|
||||||
|
});
|
||||||
|
// Nothing left but the section heading, or a widget
|
||||||
|
// that reported nothing: drop the lot, as before.
|
||||||
|
if (!owned.length
|
||||||
|
|| !fallback.querySelector('[data-child-key]')) {
|
||||||
|
fallback.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initWidget);
|
document.addEventListener('DOMContentLoaded', initWidget);
|
||||||
@@ -1036,7 +1061,12 @@
|
|||||||
{% if nested_key in prop.properties %}
|
{% if nested_key in prop.properties %}
|
||||||
{% set nested_prop = prop.properties[nested_key] %}
|
{% set nested_prop = prop.properties[nested_key] %}
|
||||||
{% set nested_val = nested_value[nested_key] if nested_key in nested_value else none %}
|
{% set nested_val = nested_value[nested_key] if nested_key in nested_value else none %}
|
||||||
|
{# Tagged so a widget that takes over part of a block can
|
||||||
|
drop exactly the children it owns and leave the rest
|
||||||
|
rendered, rather than removing the whole section. #}
|
||||||
|
<div data-child-key="{{ nested_key }}">
|
||||||
{{ render_field(nested_key, nested_prop, nested_val, full_key, plugin_id) }}
|
{{ render_field(nested_key, nested_prop, nested_val, full_key, plugin_id) }}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user