#!/usr/bin/env python3 # Copyright (c) 2025-2026, RTE (https://www.rte-france.com) # SPDX-License-Identifier: MPL-2.0 """Layer-3 (static simulation) — gesture-sequence parity check. The real Layer-3 check is the Playwright spec in ``scripts/parity_e2e/e2e_parity.spec.ts``: it drives both UIs through an identical canonical session and diffs the resulting ``interaction_log.json`` artefacts. That spec requires a real browser, which is NOT installable in every CI or development environment (notably not in the sandbox this script was authored in — Playwright's browser download was blocked there). This Python script delivers a weaker but still useful proxy: 1. A canonical GESTURE_SEQUENCE — the same 11 user gestures the Playwright spec drives. 2. A GESTURE_MODEL mapping each gesture to the ordered list of ``interactionLogger.record(...)`` events the replay contract (``docs/features/interaction-logging.md``) says MUST fire. 3. For each gesture the script: - verifies the React implementation exists (a handler is defined AND it emits the expected events in order); - verifies the standalone implementation exists and emits the same events in the same order. It does NOT execute the UI — it walks the source with regexes and the existing call-site index from :mod:`check_standalone_parity`. The guarantees it offers are therefore coarser than a browser run: - ✅ will catch "gesture G should emit event E but no code path emits E from G's handler" (same thing Layer 1 catches, phrased per-gesture). - ✅ will catch "gesture G should emit E then F (in order) but the handler emits F then E" — something Layer 1 misses because it's set-based. - ❌ will NOT catch runtime ordering issues (e.g. an async race that reverses events intermittently). When Playwright becomes runnable, this check becomes redundant — the browser spec covers the same invariants with runtime proof. Keep it as a Layer-3-lite for sandboxes where a browser can't be installed. Run:: python scripts/check_gesture_sequence.py # human text python scripts/check_gesture_sequence.py --json # CI-friendly Exits non-zero if any gesture is missing, misordered, or incompletely implemented on either side. """ from __future__ import annotations import argparse import json import os import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent FRONTEND_SRC = REPO_ROOT / "frontend" / "src" # Default target: the auto-generated `frontend/dist-standalone/standalone.html` # if it exists (run `npm run build:standalone` first); otherwise the legacy # hand-maintained file retained for reference. Override via # `COSTUDY4GRID_STANDALONE_PATH` to audit a different artifact. _DEFAULT_STANDALONE = REPO_ROOT / "frontend" / "dist-standalone" / "standalone.html" _LEGACY_STANDALONE = REPO_ROOT / "standalone_interface_legacy.html" STANDALONE = Path( os.environ.get( "COSTUDY4GRID_STANDALONE_PATH", str(_DEFAULT_STANDALONE if _DEFAULT_STANDALONE.exists() else _LEGACY_STANDALONE), ) ) # --------------------------------------------------------------------- # Canonical gesture model. # --------------------------------------------------------------------- # # Each gesture entry specifies: # - ``name``: human label for the report. # - ``react_handler``: the identifier to look for in the React # source that implements this gesture. The walker then extracts # the function body and scans it for ``interactionLogger.record`` # + ``recordCompletion`` calls. # - ``standalone_handler``: same, for the single-file HTML. # - ``expected_events``: ordered list of event types the gesture # MUST emit in this order. Events not listed here are ignored # (the gesture is allowed to emit additional events; parity only # cares about the declared ones firing in the right order). # # The sequence mirrors the 11-step canonical session in # ``scripts/parity_e2e/e2e_parity.spec.ts``. GESTURE_SEQUENCE = [ { "name": "1. Load Study", "react_handler": "handleLoadConfig", "standalone_handler": "handleLoadConfig", "expected_events": ["config_loaded"], }, { # The N-K contingency refactor split the legacy single-step # ``contingency_selected`` event into: # - ``contingency_element_added`` / ``contingency_element_removed`` # for each chip the user toggles in the multi-select, # - ``contingency_applied`` when the user clicks the # Trigger button to commit the pending list. # The "select" gesture is now operationally satisfied by the # COMMIT action — that's what drives the diagram fetch — so # the parity script tracks ``contingency_applied`` here. The # standalone snapshot still uses the legacy event name; the # ``standalone_*`` keys keep the legacy contract until the # auto-generated bundle catches up. "name": "2. Select contingency", "react_handler": "handleContingencyApply", "react_handler_fallbacks": ["contingency_applied"], "standalone_handler": "contingency_selected", "standalone_handler_fallbacks": ["contingency_applied"], "expected_events": ["contingency_applied"], }, { "name": "3. Run analysis step 1", "react_handler": "handleRunAnalysisStep1", "react_handler_fallbacks": ["handleRunAnalysis"], "standalone_handler": "handleRunAnalysisStep1", "standalone_handler_fallbacks": ["runAnalysisStep1"], "expected_events": ["analysis_step1_started", "analysis_step1_completed"], }, { "name": "4. Toggle overload", "react_handler": "handleOverloadToggle", "react_handler_fallbacks": ["overload_toggled"], "standalone_handler": "overload_toggled", "expected_events": ["overload_toggled"], }, { "name": "5. Run analysis step 2", "react_handler": "handleRunAnalysisStep2", "react_handler_fallbacks": ["handleRunAnalysis"], "standalone_handler": "handleRunAnalysisStep2", "standalone_handler_fallbacks": ["handleRunAnalysis", "runAnalysisStep2"], "expected_events": ["analysis_step2_started", "analysis_step2_completed"], }, { "name": "6. Display prioritized actions", "react_handler": "handleDisplayPrioritizedActions", "standalone_handler": "handleDisplayPrioritized", "standalone_handler_fallbacks": ["prioritized_actions_displayed"], "expected_events": ["prioritized_actions_displayed"], }, { "name": "7. Select action card", "react_handler": "handleActionSelect", "standalone_handler": "handleActionSelect", "expected_events": ["action_selected"], }, { "name": "8. Favorite action", "react_handler": "handleActionFavorite", "standalone_handler": "handleActionFavorite", "standalone_handler_fallbacks": ["action_favorited"], "expected_events": ["action_favorited"], }, { "name": "9. Change diagram tab", "react_handler": "handleTabChange", "react_handler_fallbacks": ["diagram_tab_changed"], "standalone_handler": "diagram_tab_changed", "expected_events": ["diagram_tab_changed"], }, { "name": "10. Zoom in / out", "react_handler": "handleManualZoomIn", "react_handler_fallbacks": ["zoom_in"], "standalone_handler": "handleManualZoomIn", "standalone_handler_fallbacks": ["zoom_in"], "expected_events": ["zoom_in"], }, { "name": "11. Save results", "react_handler": "handleSaveResults", "standalone_handler": "handleSaveSession", "standalone_handler_fallbacks": ["handleSaveResults"], "expected_events": ["session_saved"], }, # Gestures 12-15 added after the Action-Overview + Detached-Tabs # ports shipped — the original 11-step canonical sequence stopped # at Save Results and never visited the pin map, which is # precisely where the last four rounds of user-observed bugs hid. # These four entries exercise the surface area Layer 3b's # Playwright spec should also cover. { "name": "12. Overview pin single-click", "react_handler": "handlePinClick", "react_handler_fallbacks": ["overview_pin_clicked"], "standalone_handler": "handlePinClick", "standalone_handler_fallbacks": ["overview_pin_clicked"], "expected_events": ["overview_pin_clicked"], }, { "name": "13. Overview pin double-click → drill-down", "react_handler": "handlePinDoubleClick", "react_handler_fallbacks": ["overview_pin_double_clicked"], "standalone_handler": "handlePinDoubleClick", "standalone_handler_fallbacks": ["overview_pin_double_clicked"], "expected_events": ["overview_pin_double_clicked"], }, { "name": "14. Detach the Action tab", "react_handler": "handleDetachTab", "react_handler_fallbacks": ["tab_detached", "detach"], "standalone_handler": "handleDetachTab", "standalone_handler_fallbacks": ["tab_detached"], "expected_events": ["tab_detached"], }, { "name": "15. Deselect action (Action tab stays, Overview re-appears)", "react_handler": "handleActionSelect", "standalone_handler": "handleActionSelect", "expected_events": ["action_deselected"], }, ] # --------------------------------------------------------------------- # Extraction helpers. # --------------------------------------------------------------------- RECORD_CALL = re.compile( r"interactionLogger\.record\(\s*['\"]([a-z0-9_]+)['\"]" ) RECORD_COMPLETION_CALL = re.compile( r"interactionLogger\.recordCompletion\(\s*['\"]([a-z0-9_]+)['\"]" ) def _ts_files() -> list[Path]: out: list[Path] = [] for pat in ("*.ts", "*.tsx"): for p in FRONTEND_SRC.rglob(pat): if ".test." in p.name: continue out.append(p) return sorted(out) def _standalone_src() -> str: return STANDALONE.read_text(encoding="utf-8", errors="replace") def _skip_balanced(src: str, i: int, open_c: str, close_c: str) -> int: """Assuming ``src[i] == open_c``, advance past the matching ``close_c`` (including nested pairs) and return the index AFTER the close char. Returns -1 if no match found. """ assert src[i] == open_c depth = 0 while i < len(src): c = src[i] if c == open_c: depth += 1 elif c == close_c: depth -= 1 if depth == 0: return i + 1 i += 1 return -1 def _find_handler_range(src: str, name: str) -> tuple[int, int] | None: """Return (start_offset, end_offset) of a function/handler with the given name in ``src``. Matches these JS/TS declaration shapes: function handleFoo(...) { ... } const handleFoo = function (...) { ... } const handleFoo = useCallback(async (...) => { ... }, [...]) const handleFoo = async (...) => { ... } const handleFoo = (...) => { ... } The walker uses regex only to find the start anchor (``const handleFoo = ...`` or ``function handleFoo``); from there it uses paren-balance scanning to skip over the parameter list (which may contain nested function types like ``(x: T) => R``) and reaches the body's opening ``{``. """ # Anchor #1 — plain function declaration. m = re.search(rf"function\s+{re.escape(name)}\s*\(", src) if m: i = m.end() - 1 # position of `(` j = _skip_balanced(src, i, "(", ")") if j < 0: return None # Skip whitespace + optional return-type annotation. k = _skip_ws_and_return_type(src, j) if k < len(src) and src[k] == "{": end = _skip_balanced(src, k, "{", "}") if end > 0: return (m.start(), end) # Anchor #2 — `const handleFoo = ...`. m = re.search(rf"\bconst\s+{re.escape(name)}\s*=\s*", src) if m: i = m.end() # Optional modifiers before the arrow function. # - useCallback( → step into the paren # - reactExports.useCallback( → step into the paren # (vite/rollup imports the hook through a namespace # object, so the auto-generated standalone emits the # dotted form even though the React source had a bare # `useCallback(`). # - async → skip keyword # - ( → already at arrow-fn param list wrap = re.match( r"(?:[A-Za-z_$][\w$]*\s*\.\s*)?useCallback\b", src[i:i + 64], ) if wrap: # Advance to the `(` after the wrapper identifier. i = src.find("(", i + wrap.end()) if i < 0: return None i += 1 # past the opening wrapper `(`. i = _skip_ws(src, i) if src[i:i + 6].startswith("async "): i += 6 i = _skip_ws(src, i) if i >= len(src) or src[i] != "(": return None j = _skip_balanced(src, i, "(", ")") if j < 0: return None j = _skip_ws_and_return_type(src, j) # Expect `=>` if src[j:j + 2] != "=>": return None j += 2 j = _skip_ws(src, j) if j < len(src) and src[j] == "{": end = _skip_balanced(src, j, "{", "}") if end > 0: return (m.start(), end) return None def _skip_ws(src: str, i: int) -> int: while i < len(src) and src[i] in " \t\n\r": i += 1 return i def _skip_ws_and_return_type(src: str, i: int) -> int: """After a `)` in an arrow-fn head, there may be an optional TypeScript return-type annotation like `: Promise` before the `=>`. Skip whitespace and any colon-prefixed annotation up to (but not including) the `=>` or `{`. """ i = _skip_ws(src, i) if i < len(src) and src[i] == ":": # Consume until we hit `=>` or `{`. The annotation can # contain generics `<...>` with nested commas, but not # unbalanced braces or parens. depth_angle = 0 while i < len(src): c = src[i] if c == "<": depth_angle += 1 elif c == ">": depth_angle -= 1 elif depth_angle == 0 and src[i:i + 2] == "=>": break elif depth_angle == 0 and c == "{": break i += 1 return _skip_ws(src, i) def _find_event_record_site(src: str, event_type: str) -> tuple[int, int] | None: """Return a small window around the first ``interactionLogger.record('event_type', …)`` call site in ``src`` — used as a fallback when a handler name isn't recoverable (many gestures are inline arrow functions in JSX onClick). We walk up ~200 chars before the record call to capture enough context that subsequent ordering checks still see nearby calls. """ m = re.search( r"interactionLogger\.record\(\s*['\"]" + re.escape(event_type) + r"['\"]", src, ) if m: start = max(0, m.start() - 400) end = min(len(src), m.end() + 2000) return (start, end) return None def _extract_events(window: str) -> list[str]: """Return the ordered list of record() and recordCompletion() event types inside ``window``. """ events: list[tuple[int, str]] = [] for m in RECORD_CALL.finditer(window): events.append((m.start(), m.group(1))) for m in RECORD_COMPLETION_CALL.finditer(window): events.append((m.start(), m.group(1))) events.sort() return [t for _, t in events] def resolve_gesture(src: str, gesture: dict, side: str) -> dict: """Locate the gesture in ``src`` and extract its emitted events.""" key = f"{side}_handler" fallback_key = f"{side}_handler_fallbacks" candidates = [gesture[key]] + gesture.get(fallback_key, []) found_via = None span: tuple[int, int] | None = None for name in candidates: span = _find_handler_range(src, name) if span is not None: found_via = f"handler:{name}" break if span is None: # Fallback: locate the call site for the first expected event # directly. This gives us a synthetic window even when the # gesture is an inline onClick arrow. for event in gesture["expected_events"]: span = _find_event_record_site(src, event) if span is not None: found_via = f"callsite:{event}" break if span is None: return {"found_via": None, "events": [], "span": None} start, end = span window = src[start:end] events = _extract_events(window) return {"found_via": found_via, "events": events, "span": span} def _check_order(expected: list[str], emitted: list[str]) -> dict: """Check that ``expected`` appears as an ordered subsequence of ``emitted``. Returns structured diff.""" missing = [e for e in expected if e not in emitted] # Order-preserving subsequence check via two-pointer walk. i = 0 for e in emitted: if i < len(expected) and e == expected[i]: i += 1 ordered_ok = i == len(expected) and not missing return { "ordered_ok": ordered_ok, "missing": missing, "emitted": emitted, } # --------------------------------------------------------------------- # Main. # --------------------------------------------------------------------- def run_checks() -> dict: # Concatenate all TS/TSX source into one string for React — we # don't care which file a handler lives in, only that it exists. react_src = "\n".join(p.read_text(encoding="utf-8", errors="replace") for p in _ts_files()) standalone_src = _standalone_src() findings = [] for gesture in GESTURE_SEQUENCE: react = resolve_gesture(react_src, gesture, "react") standalone = resolve_gesture(standalone_src, gesture, "standalone") react_diff = _check_order(gesture["expected_events"], react["events"]) sa_diff = _check_order(gesture["expected_events"], standalone["events"]) findings.append({ "gesture": gesture["name"], "expected_events": gesture["expected_events"], "react": { "found_via": react["found_via"], "emitted": react["events"], "ordered_ok": react_diff["ordered_ok"], "missing": react_diff["missing"], }, "standalone": { "found_via": standalone["found_via"], "emitted": standalone["events"], "ordered_ok": sa_diff["ordered_ok"], "missing": sa_diff["missing"], }, }) return {"gestures": findings} def render_human(report: dict) -> str: out: list[str] = [] out.append("GESTURE-SEQUENCE PARITY REPORT (Layer-3 static)") out.append("=" * 72) out.append("See scripts/parity_e2e/e2e_parity.spec.ts for the runtime equivalent.") out.append("") fails = 0 for f in report["gestures"]: header = f" {f['gesture']} expects: {', '.join(f['expected_events'])}" out.append(header) for side in ("react", "standalone"): info = f[side] tag = "OK" if info["ordered_ok"] else "FAIL" if tag == "FAIL": fails += 1 marker = "✅" if tag == "OK" else "❌" via = info["found_via"] or "(not found)" emitted = ", ".join(info["emitted"]) or "(none)" out.append(f" {marker} {side:10s} via={via!s:28s} emitted=[{emitted}]") if info["missing"]: out.append(f" missing: {info['missing']}") out.append("") total = sum(1 for _ in report["gestures"]) * 2 passed = total - fails out.append(f"Summary: {passed}/{total} gesture-side parity checks passed.") return "\n".join(out) def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--json", action="store_true", help="emit JSON instead of text") args = parser.parse_args() report = run_checks() if args.json: print(json.dumps(report, indent=2, sort_keys=True)) else: print(render_human(report)) hard_fail = any( not g["react"]["ordered_ok"] or not g["standalone"]["ordered_ok"] for g in report["gestures"] ) return 1 if hard_fail else 0 if __name__ == "__main__": sys.exit(main())