diff --git a/test/test_checkbox_group_stale_values.py b/test/test_checkbox_group_stale_values.py new file mode 100644 index 00000000..c852372a --- /dev/null +++ b/test/test_checkbox_group_stale_values.py @@ -0,0 +1,113 @@ +"""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"", + 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'] diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index f789fd5b..9e5f3707 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -296,7 +296,27 @@ {% set enum_items = items_schema.get('enum') or [] %} {% set x_options = prop.get('x-options') or {} %} {% set labels = x_options.get('labels') or {} %} - + {# A saved value that is no longer one of the options -- a team + code the league retired, an option dropped from the schema -- + has no checkbox to render, so it would sit unseen in the + hidden input below and be posted back on save. The schema + rejects it and the save endpoint returns 400, which blocks + editing any other field on the plugin until the stale entry + is found and removed. Drop them here instead, and say which, + so the value is not lost silently. Only when the widget + actually has options: an empty enum means nothing to check + against, and filtering on it would wipe the field. #} + {% set stale_values = (array_value | reject('in', enum_items) | list) if enum_items else [] %} + {% set array_value = (array_value | select('in', enum_items) | list) if enum_items else array_value %} + + {% if stale_values %} +
+ No longer offered, and will be removed when you save: + {{ stale_values | join(', ') }}. +
+ {% endif %} +
{% for option in enum_items %} {% set is_checked = option in array_value %}