Co-Study4Grid / scripts /check_gesture_sequence.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
21 kB
#!/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<void>` 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())