From 1a548fc8a843cf4d662e9eb5cfe5233a6e839a3a Mon Sep 17 00:00:00 2001 From: GeneAI Date: Thu, 20 Aug 2026 07:43:53 -0400 Subject: [PATCH] =?UTF-8?q?test:=20widget=20gate=20parity=20pin=20?= =?UTF-8?q?=E2=80=94=20client=20gate=20=E2=87=94=20server=20validators=20(?= =?UTF-8?q?architecture=20review=20F2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The submit script's required-field gate re-implements the server validators' completeness rules (required boards fully ruled, ranking all-or-nothing, blank edit text) with nothing keeping the two in sync. Port the gate rule-for-rule into the roundtrip simulator and assert, per construct × fill state (35 fixtures), that the gate blocks exactly when collect_form_response would reject the posted payload. Add a structural anchor pin (data-required / [data-item] / data-rank-n) in the same style as the reader's collect-mode drift catcher. Mutation-checked: disabling the blank-edit rule in the port fails assume-blank-edit red. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 14 ++ tests/test_widget_roundtrip.py | 285 ++++++++++++++++++++++++++++++++- 2 files changed, 298 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f90eb89..e80ee7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ follow [SemVer](https://semver.org/). ## [Unreleased] +### Added +- Widget gate parity pin (architecture review finding F2, 2026-08-20): + the submit script's client-side required-field gate (incomplete + rulings boards, unfilled ranking slots, blank `edit` text, the + optional partial-ranking block) is now ported rule-for-rule into the + round-trip simulator and asserted equivalent to the server + validators — for every construct × fill state, the gate blocks the + post exactly when `collect_form_response` would reject the payload. + A structural anchor check also pins the DOM attributes the gate + queries (`data-required`, `[data-item]`, `data-rank-n`) to what the + renderer emits. Tests only — no behavior change; the previously + untested drift class (gate lets an invalid answer post, or blocks a + valid one, after the widget is dead) now fails red in CI + ### Fixed - Confirmation-pass-1 needs-a-look items (library review, 2026-08-20): - `submission_count()` and `inference_rate()` accept the same diff --git a/tests/test_widget_roundtrip.py b/tests/test_widget_roundtrip.py index 1fa7474..f0a395f 100644 --- a/tests/test_widget_roundtrip.py +++ b/tests/test_widget_roundtrip.py @@ -79,6 +79,7 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None "fid": a["data-fid"], "ftype": a.get("data-ftype"), "collect": a.get("data-collect"), + "required": "data-required" in a, "controls": [], } ) @@ -98,8 +99,11 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None self._in_ranked = False if "data-item" in a and self.fields: # A triage row: subsequent controls belong to this item, the - # way the submit script scopes its per-row query. + # way the submit script scopes its per-row query. The row + # count is what the gate's completeness check divides by + # (`f.querySelectorAll('[data-item]').length`). self._item = a["data-item"] + self.fields[-1]["rows"] = self.fields[-1].get("rows", 0) + 1 if "data-control" in a and self.fields: controls = self.fields[-1]["controls"] if self._in_ranked and tag == "input": @@ -224,6 +228,58 @@ def _submit(dom: _WidgetDOM) -> dict[str, Any]: return json.loads(json.dumps(payload)) +def _gate(dom: _WidgetDOM, answers: dict[str, Any]) -> tuple[list[str], list[str]]: + """Port of the submit script's required-field gate, rule for rule. + + Mirrors the two gate passes the script runs before posting: the + required-field check (empty answer, incomplete rulings board, an + unfilled ranking slot, an ``edit`` ruling with blank replacement + text) and the all-or-nothing check for OPTIONAL partially-filled + rankings. Returns ``(missing, partial)`` field-id lists — the + script blocks the post when either is non-empty. + """ + missing: list[str] = [] + for field in dom.fields: + if not field.get("required"): + continue + v = answers.get(field["fid"]) + rows = field.get("rows", 0) + slots = field.get("slots", 0) + blank_edit = False + if isinstance(v, dict): + for ruling in v.values(): + if isinstance(ruling, dict) and not str(ruling.get("edit") or "").strip(): + blank_edit = True + empty = ( + v is None + or v == "" + or (isinstance(v, list) and not v) + or (rows > 0 and (not v or len(v) < rows)) + or (slots > 0 and (not v or len(v) < slots)) + or blank_edit + ) + if empty: + missing.append(field["fid"]) + partial: list[str] = [] + for field in dom.fields: + if field.get("required"): + continue + slots = field.get("slots", 0) + if not slots: + continue + v = answers.get(field["fid"]) + if not isinstance(v, list) or len(v) == 0 or len(v) >= slots: + continue + partial.append(field["fid"]) + return missing, partial + + +def _gate_allows(dom: _WidgetDOM) -> bool: + """True iff the gate would let the current DOM state post.""" + missing, partial = _gate(dom, _submit(dom)["answers"]) + return not missing and not partial + + def _render_reference() -> tuple[Any, _WidgetDOM]: form = form_from_dict(REFERENCE_FORM) dom = _WidgetDOM() @@ -372,3 +428,230 @@ def test_fill_caps_ranking_at_the_slot_budget(self) -> None: _fill(dom, {"rollout_order": ["us-prod", "eu-prod", "canary", "staging"]}) field = next(f for f in dom.fields if f["fid"] == "rollout_order") assert field["ranked"] == ["us-prod", "eu-prod", "canary"] + + +def _gate_form(field: dict[str, Any]) -> tuple[Any, _WidgetDOM]: + form = form_from_dict({"title": "Gate parity", "fields": [field]}) + dom = _WidgetDOM() + dom.feed(form_to_widget_html(form, instance_id="gate")) + return form, dom + + +#: One fixture per construct type × fill state: ``(case id, field dict, +#: fill)`` — ``fill=None`` leaves the rendered DOM untouched. No case +#: hardcodes an expected verdict: the parity assertion is the property +#: itself (gate blocks ⇔ validator rejects), so a fixture whose two +#: sides disagree fails no matter which side is "right". +_TRIAGE_FIELD: dict[str, Any] = { + "id": "board", + "type": "triage", + "text": "Rule each finding.", + "triage_items": [{"id": "a", "label": "Finding A"}, {"id": "b", "label": "Finding B"}], + "dispositions": ["fix", "dismiss"], +} +_ASSUME_FIELD: dict[str, Any] = { + "id": "review", + "type": "assumption_review", + "text": "Rule each assumption.", + "assumptions": [{"id": "a1", "label": "Py 3.10 floor"}, {"id": "a2", "label": "CI on push"}], +} +_RANK_FIELD: dict[str, Any] = { + "id": "order", + "type": "ranking", + "text": "Ship order?", + "options": ["staging", "canary", "prod"], + "top_n": 2, +} +_GATE_CASES: list[tuple[str, dict[str, Any], Any]] = [ + ("text-empty", {"id": "f", "type": "text_input", "text": "Name?"}, None), + ("text-filled", {"id": "f", "type": "text_input", "text": "Name?"}, "Dark mode"), + ( + "select-empty", + {"id": "f", "type": "single_select", "text": "?", "options": ["a", "b"]}, + None, + ), + ( + "select-filled", + {"id": "f", "type": "single_select", "text": "?", "options": ["a", "b"]}, + "b", + ), + ("multi-empty", {"id": "f", "type": "multi_select", "text": "?", "options": ["a", "b"]}, None), + ( + "multi-filled", + {"id": "f", "type": "multi_select", "text": "?", "options": ["a", "b"]}, + ["a"], + ), + ("boolean-empty", {"id": "f", "type": "boolean", "text": "?"}, None), + ("boolean-filled", {"id": "f", "type": "boolean", "text": "?"}, "Yes"), + ("number-empty", {"id": "f", "type": "number", "text": "?", "minimum": 0}, None), + ("number-zero", {"id": "f", "type": "number", "text": "?", "minimum": 0}, 0), + ("date-empty", {"id": "f", "type": "date", "text": "?"}, None), + ("date-filled", {"id": "f", "type": "date", "text": "?"}, "2026-08-01"), + ("textarea-empty", {"id": "f", "type": "textarea", "text": "?"}, None), + ("textarea-filled", {"id": "f", "type": "textarea", "text": "?"}, "notes"), + ( + "decision-empty", + { + "id": "f", + "type": "decision", + "text": "?", + "options": ["flag", "all at once"], + "recommended": "flag", + "rationale": "safer", + }, + None, + ), + ( + "decision-filled", + { + "id": "f", + "type": "decision", + "text": "?", + "options": ["flag", "all at once"], + "recommended": "flag", + "rationale": "safer", + }, + "flag", + ), + ( + "pushback-empty", + { + "id": "f", + "type": "pushback", + "text": "?", + "options": ["branch", "stacked PRs"], + "user_position": "branch", + "recommended": "stacked PRs", + "rationale": "drift", + }, + None, + ), + ( + "deliberation-filled", + { + "id": "f", + "type": "deliberation", + "text": "?", + "options": ["lru", "redis"], + "endorsements": {"lru": ["claude"]}, + "recommended": "lru", + "rationale": "one consumer", + }, + "lru", + ), + ( + "progress-empty", + { + "id": "f", + "type": "progress", + "text": "?", + "options": ["Design sign-off"], + "progress_items": [ + {"label": "Prototype", "status": "done"}, + {"label": "Design sign-off", "status": "blocked"}, + ], + }, + None, + ), + ( + "progress-filled", + { + "id": "f", + "type": "progress", + "text": "?", + "options": ["Design sign-off"], + "progress_items": [ + {"label": "Prototype", "status": "done"}, + {"label": "Design sign-off", "status": "blocked"}, + ], + }, + "Design sign-off", + ), + ( + "confirm-empty", + {"id": "f", "type": "confirm", "text": "Flip the flag?", "consequences": [{"label": "x"}]}, + None, + ), + ( + "confirm-approved", + {"id": "f", "type": "confirm", "text": "Flip the flag?", "consequences": [{"label": "x"}]}, + "Approve", + ), + ("triage-empty", _TRIAGE_FIELD, None), + ("triage-partial", _TRIAGE_FIELD, {"a": "fix"}), + ("triage-full", _TRIAGE_FIELD, {"a": "fix", "b": "dismiss"}), + ("triage-optional-partial", {**_TRIAGE_FIELD, "required": False}, {"a": "fix"}), + ("ranking-empty", _RANK_FIELD, None), + ("ranking-partial", _RANK_FIELD, ["staging"]), + ("ranking-full", _RANK_FIELD, ["staging", "prod"]), + ("ranking-optional-empty", {**_RANK_FIELD, "required": False}, None), + ("ranking-optional-partial", {**_RANK_FIELD, "required": False}, ["staging"]), + ("assume-empty", _ASSUME_FIELD, None), + ("assume-partial", _ASSUME_FIELD, {"a1": "accept"}), + ("assume-blank-edit", _ASSUME_FIELD, {"a1": {"edit": ""}, "a2": "accept"}), + ("assume-edit-text", _ASSUME_FIELD, {"a1": {"edit": "3.11 floor"}, "a2": "accept"}), + ("assume-full", _ASSUME_FIELD, {"a1": "accept", "a2": "reject"}), +] + + +class TestGateParity: + """The submit script's client-side gate re-implements the server + validators' completeness rules (required boards fully ruled, ranking + all-or-nothing, blank ``edit`` text). Nothing in production keeps the + two in sync — this class is the sync test (architecture review + finding F2, 2026-08-20): for every construct × fill state, the gate + blocks the post exactly when ``collect_form_response`` would reject + the posted payload. + """ + + @pytest.mark.parametrize( + ("field", "fill"), + [pytest.param(f, v, id=cid) for cid, f, v in _GATE_CASES], + ) + def test_gate_blocks_iff_validator_rejects(self, field: dict[str, Any], fill: Any) -> None: + form, dom = _gate_form(field) + if fill is not None: + _fill(dom, {field["id"]: fill}) + payload = _submit(dom) + try: + collect_form_response(form, payload["answers"]) + validator_clean = True + except FormValidationError: + validator_clean = False + assert _gate_allows(dom) == validator_clean + + def test_untouched_reference_form_gate_matches_validator(self) -> None: + """The all-construct form, untouched: the gate must block for + the same reason the validator names required fields.""" + form, dom = _render_reference() + payload = _submit(dom) + with pytest.raises(FormValidationError): + collect_form_response(form, payload["answers"]) + assert not _gate_allows(dom) + + def test_filled_reference_form_gate_matches_validator(self) -> None: + """The all-construct form, fully filled: both sides pass.""" + form, dom = _render_reference() + _fill(dom, EXAMPLE_ANSWERS) + payload = _submit(dom) + collect_form_response(form, payload["answers"]) # no problems + assert _gate_allows(dom) + + def test_gate_reads_the_anchors_the_renderer_emits(self) -> None: + """Structural drift catcher, gate edition (extends the reader's + collect-mode pin at :meth:`TestSentinelContract`): every DOM + anchor the gate JS queries must appear in the emitted script, + and the ``data-required`` flags the renderer emits must match + the form's required questions — a renamed attribute on either + side fails here before it silently disables a gate rule.""" + form, dom = _render_reference() + for anchor in ( + "data-required", + "[data-item]", + "data-rank-n", + ".ae-rank", + "ae-field-missing", + ): + assert anchor in dom.script, f"gate anchor {anchor!r} missing from script" + required_fids = {f["fid"] for f in dom.fields if f.get("required")} + assert required_fids == {q.id for q in form.questions if q.required}