// Copyright (c) 2025-2026, RTE (https://www.rte-france.com) // SPDX-License-Identifier: MPL-2.0 // This file is part of Co-Study4Grid. /** * Runtime spec-conformance check — the Vitest mirror of * `scripts/check_standalone_parity.py`'s three-way diff. * * Walks every ``interactionLogger.record(...)`` call site in the * React sources and verifies that the `details` object-literal keys * match the replay contract in ``docs/features/interaction-logging.md``. * Runs as part of the normal ``npm run test`` suite, so spec drift * is caught BEFORE a PR lands — not just when the Python parity * script happens to be run. * * The SPEC table below is the TypeScript twin of the Python * ``SPEC_DETAILS`` dict in ``scripts/check_standalone_parity.py``. * When the replay contract changes: * - update ``docs/features/interaction-logging.md`` (source of truth), * - update this SPEC table, * - update the Python one. * Keeping the two in sync is the cost of redundant enforcement; the * benefit is a per-test-run gate that the dev feedback loop uses. */ import { describe, it, expect } from 'vitest'; import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; interface SpecRow { required: ReadonlySet; optional?: ReadonlySet; } const CONFIG_FIELDS = new Set([ 'network_path', 'action_file_path', 'layout_path', 'output_folder_path', 'min_line_reconnections', 'min_close_coupling', 'min_open_coupling', 'min_line_disconnections', 'min_pst', 'min_load_shedding', 'min_renewable_curtailment_actions', 'n_prioritized_actions', 'lines_monitoring_path', 'monitoring_factor', 'pre_existing_overload_threshold', 'ignore_reconnections', 'pypowsybl_fast_mode', ]); const SPEC: Record = { // --- Configuration & Study Loading --- config_loaded: { required: CONFIG_FIELDS }, settings_opened: { required: new Set(['tab']) }, settings_tab_changed: { required: new Set(['from_tab', 'to_tab']) }, settings_applied: { required: CONFIG_FIELDS }, settings_cancelled: { required: new Set() }, path_picked: { required: new Set(['type', 'path']) }, // --- Contingency (single + N-K multi-element) --- contingency_selected: { required: new Set(['element']) }, contingency_confirmed: { required: new Set(['type']), optional: new Set(['pending_branch']) }, contingency_element_added: { required: new Set(['element']) }, contingency_element_removed: { required: new Set(['element']) }, contingency_applied: { required: new Set(['elements']) }, // --- Two-Step Analysis --- analysis_step1_started: { required: new Set(['element']) }, overload_toggled: { required: new Set(['overload', 'selected']) }, additional_line_to_cut_toggled: { required: new Set(['line', 'selected']) }, recommender_model_changed: { required: new Set(['model']), optional: new Set(['source']) }, suggested_actions_cleared: { required: new Set(['n_cleared']) }, analysis_step2_started: { required: new Set(['element', 'selected_overloads', 'all_overloads', 'monitor_deselected']), optional: new Set(['additional_lines_to_cut']) }, prioritized_actions_displayed: { required: new Set(['n_actions']) }, // --- Action --- action_selected: { required: new Set(['action_id']) }, action_deselected: { required: new Set(['previous_action_id']) }, action_favorited: { required: new Set(['action_id']) }, action_unfavorited: { required: new Set(['action_id']) }, action_rejected: { required: new Set(['action_id']) }, action_unrejected: { required: new Set(['action_id']) }, manual_action_simulated: { required: new Set(['action_id']) }, action_mw_resimulated: { required: new Set(['action_id', 'target_mw']) }, pst_tap_resimulated: { required: new Set(['action_id', 'target_tap']) }, // --- Combined Actions --- combine_modal_opened: { required: new Set() }, combine_modal_closed: { required: new Set() }, combine_pair_toggled: { required: new Set(['action_id', 'selected']) }, combine_pair_estimated: { required: new Set(['action1_id', 'action2_id', 'estimated_max_rho', 'estimated_max_rho_line']) }, combine_pair_simulated: { required: new Set(['combined_id', 'action1_id', 'action2_id', 'simulated_max_rho']) }, // --- Visualization --- diagram_tab_changed: { required: new Set(['tab']) }, tab_detached: { required: new Set(['tab']) }, tab_reattached: { required: new Set(['tab']) }, tab_tied: { required: new Set(['tab']) }, tab_untied: { required: new Set(['tab']) }, view_mode_changed: { required: new Set(['mode', 'tab', 'scope']) }, // Overflow Analysis tab layout toggle. `to` = target mode ('hierarchical' | 'geo'); // start event only. Completion event carries `cached` or `error`. overflow_layout_mode_toggled: { required: new Set(['to']) }, // Overflow Analysis tab — Co-Study4Grid pin overlay toggle. overflow_pins_toggled: { required: new Set(['enabled']) }, // Click on a pin overlaid on the overflow graph (forwarded from // the iframe via postMessage). overflow_pin_clicked: { required: new Set(['actionId']) }, // Double-click on an action pin in the overflow graph — drills // into the SLD overlay on the post-action ('action') sub-tab for // that pin's substation. overflow_pin_double_clicked: { required: new Set(['actionId', 'substation']) }, // Layer-toggle gesture inside the overflow-graph viewer (forwarded // from the iframe). `key` = layer key (e.g. "color:coral", // "semantic:is_hub"); `visible` = checkbox new state. overflow_layer_toggled: { required: new Set(['key', 'label', 'visible']) }, // Select-all / Unselect-all on the overflow-graph layer list. overflow_select_all_layers: { required: new Set(['visible']) }, // Double-click on a node in the overflow-graph viewer — forwarded // from the iframe; opens the SLD overlay for that voltage level. overflow_node_double_clicked: { required: new Set(['name']) }, voltage_range_changed: { required: new Set(['min', 'max']) }, asset_clicked: { required: new Set(['action_id', 'asset_name', 'tab']) }, zoom_in: { required: new Set(['tab']) }, zoom_out: { required: new Set(['tab']) }, zoom_reset: { required: new Set(['tab']) }, inspect_query_changed: { required: new Set(['query']), optional: new Set(['target_tab']) }, vl_names_toggled: { required: new Set(['show']) }, // --- Action Overview Diagram --- overview_shown: { required: new Set(['has_pins', 'pin_count']) }, overview_hidden: { required: new Set() }, overview_pin_clicked: { required: new Set(['action_id']) }, overview_pin_double_clicked: { required: new Set(['action_id']) }, overview_popover_closed: { required: new Set(['reason']) }, overview_zoom_in: { required: new Set() }, overview_zoom_out: { required: new Set() }, overview_zoom_fit: { required: new Set() }, overview_inspect_changed: { required: new Set(['query', 'action']) }, overview_filter_changed: { required: new Set(['kind']), optional: new Set(['category', 'enabled', 'threshold', 'action_type']) }, overview_unsimulated_toggled: { required: new Set(['enabled']) }, overview_unsimulated_pin_simulated: { required: new Set(['action_id']) }, // --- SLD Overlay --- sld_overlay_opened: { required: new Set(['vl_name', 'action_id']) }, sld_overlay_tab_changed: { required: new Set(['tab', 'vl_name']) }, sld_overlay_closed: { required: new Set() }, sld_edit_mode_toggled: { required: new Set(['enabled']) }, sld_switch_toggled: { required: new Set(['equipment_id']) }, sld_maneuver_removed: { required: new Set(['equipment_ids']) }, sld_maneuver_focused: { required: new Set(['equipment_id']) }, sld_edit_reset: { required: new Set() }, sld_injection_staged: { required: new Set(['equipment_id', 'kind', 'target_mw']) }, sld_injection_removed: { required: new Set(['equipment_id']) }, sld_topology_simulated: { required: new Set(['voltage_level_id', 'switches']), optional: new Set(['combined_with', 'injections']) }, // --- Session Management --- session_saved: { required: new Set(['output_folder']) }, session_reload_modal_opened: { required: new Set() }, session_reloaded: { required: new Set(['session_name']) }, // --- Sidebar layout / contingency clear --- sidebar_collapsed_toggled: { required: new Set(['collapsed']) }, contingency_clear_requested: { required: new Set(['had_analysis_state']) }, // --- Theme --- theme_toggled: { required: new Set(['theme']) }, // --- Smooth pan/zoom (GPU) preference --- smooth_pan_zoom_toggled: { required: new Set(['enabled', 'mode']) }, }; // --------------------------------------------------------------------- // Source walker // --------------------------------------------------------------------- function collectTsSources(root: string, out: string[] = []): string[] { for (const entry of readdirSync(root)) { const full = join(root, entry); const st = statSync(full); if (st.isDirectory()) { collectTsSources(full, out); } else if (/\.(ts|tsx)$/.test(entry) && !/\.test\.tsx?$/.test(entry)) { out.push(full); } } return out; } interface CallSite { file: string; line: number; eventType: string; detailKeys: Set; isBareIdentifier: boolean; } // Matches: interactionLogger.record('type', { ... }) // Also tolerates a bare-identifier second arg (e.g. buildConfigInteractionDetails()). const RECORD_RE = /interactionLogger\.record\(\s*['"]([a-z0-9_]+)['"]\s*(?:,\s*(\{[^{}]*?\}|\w+[^,)]*))?\s*[,)]/gs; // Matches a property key at the start of an object-literal property: // { name: value, ... } → name // { name, ... } → name (shorthand) // { ...spread } → ignored const KEY_RE = /[{,]\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?=[:,}])/g; function extractCallSites(src: string, file: string): CallSite[] { const sites: CallSite[] = []; let m: RegExpExecArray | null; while ((m = RECORD_RE.exec(src)) !== null) { const eventType = m[1]; const secondArg = m[2] ?? ''; const line = src.slice(0, m.index).split('\n').length; const isObjectLiteral = secondArg.trim().startsWith('{'); const detailKeys = new Set(); if (isObjectLiteral) { let km: RegExpExecArray | null; while ((km = KEY_RE.exec(secondArg)) !== null) { detailKeys.add(km[1]); } } sites.push({ file, line, eventType, detailKeys, isBareIdentifier: !isObjectLiteral && secondArg.trim().length > 0, }); } return sites; } // --------------------------------------------------------------------- // Tests // --------------------------------------------------------------------- describe('interactionLogger.record — spec conformance', () => { // __dirname during Vitest run is `frontend/src/utils`; walk from // `frontend/src` downwards so the test finds every call site. const ROOT = join(__dirname, '..'); const allSites: CallSite[] = collectTsSources(ROOT) .flatMap((file) => extractCallSites(readFileSync(file, 'utf-8'), file)); it('walks at least one source file', () => { // Smoke test for the walker itself — if the directory layout // changes, this fails fast rather than silently passing no sites. expect(allSites.length).toBeGreaterThan(10); }); it('every recorded event type is declared in the InteractionType union + SPEC', () => { const unknown = allSites .map((s) => s.eventType) .filter((t) => !(t in SPEC)); expect(unknown, 'Add missing rows to SPEC above AND the Python SPEC_DETAILS') .toEqual([]); }); // Each event type gets its own per-call-site conformance check — // Vitest reports them individually, so a single drift doesn't mask // all the others. for (const [eventType, row] of Object.entries(SPEC)) { const known = new Set([...row.required, ...(row.optional ?? new Set())]); it(`${eventType}: emitted keys match the replay contract`, () => { const sites = allSites.filter((s) => s.eventType === eventType && !s.isBareIdentifier); if (sites.length === 0) return; // event never emitted; covered by Layer-1 missing-event check for (const site of sites) { const rel = site.file.slice(site.file.indexOf('frontend/')); const missing = [...row.required].filter((k) => !site.detailKeys.has(k)); const extras = [...site.detailKeys].filter((k) => !known.has(k)); expect( missing, `${rel}:${site.line} — ${eventType} missing required key(s). Details: ${[...site.detailKeys].join(', ') || '(empty)'}`, ).toEqual([]); expect( extras, `${rel}:${site.line} — ${eventType} has extra key(s) not in required|optional: ${extras.join(', ')}`, ).toEqual([]); } }); } });