mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-20 09:59:09 +00:00
The enum that lets a checkbox group draw its options is also what validates the saved value. When an option goes away -- a league retires a team code, a schema drops a choice -- a config still holding the old value has no checkbox to render for it, but the value stayed in the hidden _data input anyway: that input is seeded from the stored array and only rebuilt by updateCheckboxGroupData() on change. So the stale value was posted back on every save the user did not happen to touch that widget for. The schema rejected it and the save endpoint returned 400 CONFIG_VALIDATION_FAILED, which blocks editing *any* field on that plugin until the user works out which invisible entry is at fault -- with nothing on screen naming it, because the offending value is precisely the one with no checkbox. Runtime was never affected: load_plugin() treats schema violations as warn/degrade, and a retired code already matched nothing. Only the web UI blocked. Values not in the enum are now dropped before the hidden input is seeded, and listed above the group so the selection is not lost silently. Only when the widget has options -- an empty enum means there is nothing to check against, and filtering on it would wipe the field. This is not hypothetical. ledmatrix-plugins #212 ("correct team abbreviations so config save no longer 400s") and #234 (removed the retired NHL code UTA from a picker across four plugins) are both this failure mode, fixed one league at a time. Nine shipped plugins use checkbox-group today; all of them get the fix. Tested by rendering the checkbox-group block lifted out of the shipped template, following test_enum_option_labels.py, so the tests exercise the production expression rather than a copy. Mutation-checked: removing the filter fails 2 tests, filtering unconditionally fails the empty-enum test, and dropping the notice fails the one asserting the value is named.
114 lines
4.4 KiB
Python
114 lines
4.4 KiB
Python
"""A checkbox group must not post back options it cannot show.
|
|
|
|
The enum that lets the widget draw checkboxes is also what validates the
|
|
saved value. When a league retires a team code -- OAK for the Athletics, ARI
|
|
for the Coyotes -- or a schema drops an option, a config that still holds the
|
|
old value has nothing to render for it. The value stayed in the hidden
|
|
``_data`` input regardless, because that input is seeded from the stored array
|
|
and only rebuilt by ``updateCheckboxGroupData()`` on change. Editing any other
|
|
field on that plugin therefore posted the stale value back, the schema
|
|
rejected it, and the save endpoint returned 400
|
|
``CONFIG_VALIDATION_FAILED`` -- so the whole plugin became uneditable until
|
|
the user worked out which invisible entry was at fault.
|
|
|
|
Runtime was never affected: plugin loading treats schema violations as
|
|
warn/degrade, and the stale code already matched no team. Only the web UI
|
|
blocked.
|
|
|
|
These tests render the checkbox-group block lifted *out of the shipped
|
|
template*, following test_enum_option_labels.py, so they exercise the
|
|
production expression rather than a copy that could drift from it.
|
|
"""
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from jinja2 import DictLoader, Environment
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
CONFIG_FORM = (PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'partials'
|
|
/ 'plugin_config.html')
|
|
|
|
# The checkbox-group branch: from its `{% elif %}` guard through the sentinel
|
|
# hidden input that closes it. Anchored on the guard so the match cannot run on
|
|
# into a neighbouring widget branch.
|
|
BLOCK_RE = re.compile(
|
|
r"\{%\s*elif x_widget == 'checkbox-group'\s*%\}(.*?)"
|
|
r"<input type=\"hidden\" name=\"\{\{ full_key \}\}\[\]\" value=\"\">",
|
|
re.S,
|
|
)
|
|
|
|
|
|
def _shipped_block() -> str:
|
|
"""Return the live checkbox-group block lifted from plugin_config.html."""
|
|
source = CONFIG_FORM.read_text(encoding='utf-8')
|
|
match = BLOCK_RE.search(source)
|
|
assert match, (
|
|
'could not find the checkbox-group block in plugin_config.html — the '
|
|
'template changed shape and this guard needs updating'
|
|
)
|
|
block = match.group(1)
|
|
assert 'data-option-value' in block, 'extracted the wrong branch'
|
|
assert '{% elif' not in block, 'extraction ran past the checkbox-group branch'
|
|
return block
|
|
|
|
|
|
def _render(prop: dict, value=None) -> str:
|
|
env = Environment(loader=DictLoader({'f': _shipped_block()}), autoescape=True)
|
|
return env.get_template('f').render(
|
|
prop=prop, value=value, field_id='fid', full_key='k'
|
|
)
|
|
|
|
|
|
def _submitted(html: str) -> list:
|
|
"""The array the form will actually post: the hidden _data input."""
|
|
match = re.search(r'id="fid_data"[^>]*\svalue=\'([^\']*)\'', html)
|
|
assert match, f'hidden _data input not found in:\n{html}'
|
|
return json.loads(match.group(1).replace(''', "'"))
|
|
|
|
|
|
def _checked(html: str) -> list:
|
|
return re.findall(r'data-option-value="([^"]+)"[^>]*checked', html)
|
|
|
|
|
|
MLB = {'type': 'array', 'items': {'type': 'string', 'enum': ['NYY', 'BOS', 'ATH']},
|
|
'x-widget': 'checkbox-group'}
|
|
|
|
|
|
def test_a_retired_code_is_not_posted_back() -> None:
|
|
"""The regression: OAK became ATH, and OAK used to ride along on save."""
|
|
html = _render(MLB, ['NYY', 'OAK'])
|
|
assert _submitted(html) == ['NYY'], 'stale value would still be submitted'
|
|
|
|
|
|
def test_the_dropped_value_is_named_rather_than_vanishing() -> None:
|
|
html = _render(MLB, ['NYY', 'OAK'])
|
|
assert 'OAK' in html
|
|
assert 'data-stale-options' in html
|
|
|
|
|
|
def test_valid_values_are_untouched_and_still_checked() -> None:
|
|
html = _render(MLB, ['NYY', 'ATH'])
|
|
assert _submitted(html) == ['NYY', 'ATH']
|
|
assert sorted(_checked(html)) == ['ATH', 'NYY']
|
|
assert 'data-stale-options' not in html
|
|
|
|
|
|
def test_an_all_stale_selection_clears_rather_than_blocking() -> None:
|
|
html = _render(MLB, ['OAK', 'SD'])
|
|
assert _submitted(html) == []
|
|
|
|
|
|
def test_an_empty_enum_leaves_the_value_alone() -> None:
|
|
"""No options means nothing to validate against — filtering would wipe it."""
|
|
prop = {'type': 'array', 'items': {'type': 'string'}, 'x-widget': 'checkbox-group'}
|
|
html = _render(prop, ['ANYTHING', 'GOES'])
|
|
assert _submitted(html) == ['ANYTHING', 'GOES']
|
|
|
|
|
|
def test_unset_value_falls_back_to_the_default() -> None:
|
|
prop = dict(MLB, default=['BOS'])
|
|
html = _render(prop, None)
|
|
assert _submitted(html) == ['BOS']
|
|
assert _checked(html) == ['BOS']
|