Co-Study4Grid / frontend /src /App.stateManagement.test.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
18.8 kB
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
// This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, act, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/vitest';
import App from './App';
import type { RecommenderDisplayConfig } from './types';
// ===== Mocks =====
// Track props passed to memoized components to verify callback stability and groupedprops
const vizPanelRenderLog: Array<Record<string, unknown>> = [];
const actionFeedRenderLog: Array<Record<string, unknown>> = [];
const overloadPanelRenderLog: Array<Record<string, unknown>> = [];
vi.mock('./components/VisualizationPanel', () => {
const MockVisualizationPanel = React.memo((props: Record<string, unknown>) => {
vizPanelRenderLog.push({ ...props });
return (
<div
data-testid="visualization-panel"
data-active-tab={props.activeTab}
/>
);
});
MockVisualizationPanel.displayName = 'MockVisualizationPanel';
return { default: MockVisualizationPanel };
});
vi.mock('./components/ActionFeed', () => {
const MockActionFeed = React.memo((props: Record<string, unknown>) => {
actionFeedRenderLog.push({ ...props });
return (
<div
data-testid="action-feed"
data-has-recommender-config={!!props.recommenderConfig}
data-loading={!!props.analysisLoading}
>
{(props.analysisLoading as boolean) ? (
<button disabled>⚙️ Analyzing…</button>
) : (props.pendingAnalysisResult as unknown) ? (
<button onClick={props.onDisplayPrioritizedActions as () => void}>Display prioritized actions</button>
) : (
<button onClick={props.onRunAnalysis as () => void} disabled={!(props.canRunAnalysis as boolean)}>🔍 Analyze & Suggest</button>
)}
</div>
);
});
MockActionFeed.displayName = 'MockActionFeed';
return { default: MockActionFeed };
});
vi.mock('./components/OverloadPanel', () => {
const MockOverloadPanel = React.memo((props: Record<string, unknown>) => {
overloadPanelRenderLog.push({ ...props });
return (
<div
data-testid="overload-panel"
/>
);
});
MockOverloadPanel.displayName = 'MockOverloadPanel';
return { default: MockOverloadPanel };
});
// Mock hooks
vi.mock('./hooks/usePanZoom', () => ({
usePanZoom: () => ({ viewBox: null, setViewBox: vi.fn() }),
}));
// Capture the handlers App wires into each attachVlInteractions call so
// the VL-disk interaction wiring tests can invoke onSelect / onOpenSld
// directly (the real delegation needs a live SVG the mock doesn't render).
const vlInteractionHandlers = vi.hoisted(
() => [] as Array<{ onSelect?: (id: string) => void; onOpenSld?: (id: string) => void }>,
);
// Mock SVG utilities
vi.mock('./utils/svgUtils', () => ({
processSvg: (svg: string) => ({ svg, viewBox: { x: 0, y: 0, w: 100, h: 100 } }),
buildMetadataIndex: () => null,
applyOverloadedHighlights: vi.fn(),
applyDeltaVisuals: vi.fn(),
applyActionTargetHighlights: vi.fn(),
applyContingencyHighlight: vi.fn(),
getIdMap: () => new Map(),
invalidateIdMapCache: vi.fn(),
isCouplingAction: vi.fn(() => false),
attachVlInteractions: vi.fn(
(_container: unknown, _meta: unknown, handlers: { onSelect?: (id: string) => void; onOpenSld?: (id: string) => void }) => {
vlInteractionHandlers.push(handlers);
return () => {};
},
),
}));
// Mock API
const mockApi = vi.hoisted(() => ({
getUserConfig: vi.fn().mockResolvedValue({
network_path: '/home/user/data/grid.xiidm',
action_file_path: '/home/user/data/actions.json',
layout_path: '',
output_folder_path: '',
lines_monitoring_path: '',
min_line_reconnections: 2.0,
min_close_coupling: 3.0,
min_open_coupling: 2.0,
min_line_disconnections: 3.0,
min_pst: 1.0,
min_load_shedding: 0.0,
min_renewable_curtailment_actions: 0.0,
n_prioritized_actions: 10,
monitoring_factor: 0.95,
pre_existing_overload_threshold: 0.02,
ignore_reconnections: false,
pypowsybl_fast_mode: true,
}),
saveUserConfig: vi.fn().mockResolvedValue({}),
getConfigFilePath: vi.fn().mockResolvedValue('/mock/config.json'),
setConfigFilePath: vi.fn().mockResolvedValue({ config_file_path: '/mock/config.json', config: {} }),
updateConfig: vi.fn().mockResolvedValue({ monitored_lines_count: 10, total_lines_count: 10 }),
getBranches: vi.fn().mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }),
getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }),
getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }),
getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }),
getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '<svg></svg>', metadata: null }),
getContingencyDiagram: vi.fn().mockResolvedValue({ svg: '<svg></svg>', metadata: null, lines_overloaded: [] }),
pickPath: vi.fn(),
runAnalysisStep1: vi.fn().mockResolvedValue({ can_proceed: true, lines_overloaded: ['LINE_OL1'] }),
runAnalysisStep2Stream: vi.fn(),
getActionVariantDiagram: vi.fn().mockResolvedValue({ svg: '<svg></svg>', metadata: null }),
getNSld: vi.fn(),
getContingencySld: vi.fn(),
getActionVariantSld: vi.fn(),
}));
vi.mock('./api', () => ({
api: mockApi,
}));
afterEach(() => {
cleanup();
});
// Helper: render App, load config, wait for branches to appear
async function renderAndLoadStudy() {
render(<App />);
// Click Load Study
const loadBtn = screen.getByText('🔄 Load Study');
await userEvent.click(loadBtn);
// Wait for branches to be loaded (which means handleLoadConfig is done)
await waitFor(() => {
expect(screen.getByText('⚡ Select Contingency')).toBeInTheDocument();
}, { timeout: 5000 });
}
// Helper: clear every chip currently displayed in the multi-select.
async function clearContingencyChips() {
// react-select renders each chip with a ✕ button identified
// by the ``cs4g-contingency__multi-value__remove`` class.
let removeBtn = document.querySelector('.cs4g-contingency__multi-value__remove') as HTMLElement | null;
while (removeBtn) {
await act(async () => {
await userEvent.click(removeBtn!);
});
removeBtn = document.querySelector('.cs4g-contingency__multi-value__remove') as HTMLElement | null;
}
}
// Helper: pick ``branchName`` from the react-select multi-select
// then click the Trigger button to commit the contingency. By default
// REPLACES whatever was previously selected so older single-branch
// tests keep their semantics; pass ``{ append: true }`` to grow a
// multi-element contingency on top of the existing chips.
async function selectBranch(branchName: string, opts: { append?: boolean } = {}) {
// The picker card hides once a contingency has been committed —
// the sticky banner Clear shortcut brings it back. Route through
// Clear when the combobox is missing so the helper keeps a
// legacy-feel "switch branch" API for the existing test suite.
let combobox = screen.queryByRole('combobox');
if (!combobox) {
const clearBtn = screen.queryByTestId('sidebar-summary-clear');
if (clearBtn) {
await act(async () => { await userEvent.click(clearBtn); });
const confirmDialog = screen.queryByText('Change Contingency?');
if (confirmDialog) {
await act(async () => { await userEvent.click(screen.getByText('Confirm')); });
}
}
combobox = screen.getByRole('combobox');
} else if (!opts.append) {
await clearContingencyChips();
}
await act(async () => {
await userEvent.click(combobox!);
await userEvent.type(combobox!, branchName);
await userEvent.keyboard('{Enter}');
});
const trigger = await screen.findByRole('button', { name: /Trigger/ });
await act(async () => {
await userEvent.click(trigger);
});
await waitFor(() => {
expect(mockApi.getContingencyDiagram).toHaveBeenCalledWith([branchName]);
});
}
describe('Phase 2: State Management Optimization', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.unstubAllGlobals();
vizPanelRenderLog.length = 0;
actionFeedRenderLog.length = 0;
overloadPanelRenderLog.length = 0;
});
describe('RecommenderDisplayConfig grouped prop', () => {
// tier-warning-system PR: the recommender threshold display moved from a yellow
// banner inside ActionFeed into a unified "Notices" pill in the
// sidebar header (`docs/proposals/ui-design-critique.md`
// recommendation #4). ActionFeed therefore no longer receives the
// grouped `recommenderConfig` prop.
it.skip('passes recommenderConfig as a single grouped object to ActionFeed', async () => {
await renderAndLoadStudy();
// ActionFeed should have been rendered at least once
expect(actionFeedRenderLog.length).toBeGreaterThan(0);
const lastRender = actionFeedRenderLog[actionFeedRenderLog.length - 1];
expect(lastRender).toHaveProperty('recommenderConfig');
const config = lastRender.recommenderConfig as RecommenderDisplayConfig;
expect(config).toEqual({
minLineReconnections: 2.0,
minCloseCoupling: 3.0,
minOpenCoupling: 2.0,
minLineDisconnections: 3.0,
minPst: 1.0,
minLoadShedding: 0.0,
minRenewableCurtailmentActions: 0.0,
nPrioritizedActions: 10,
ignoreReconnections: false,
});
});
it('does NOT pass individual recommender settings as separate props', async () => {
await renderAndLoadStudy();
// ActionFeed mounts only after a contingency is committed
// (sidebar visibility gate from the readability-feed PR).
await selectBranch('BRANCH_A');
const lastRender = actionFeedRenderLog[actionFeedRenderLog.length - 1];
// These should NOT exist as individual props since they're now grouped
expect(lastRender).not.toHaveProperty('minLineReconnections');
expect(lastRender).not.toHaveProperty('minCloseCoupling');
expect(lastRender).not.toHaveProperty('minOpenCoupling');
expect(lastRender).not.toHaveProperty('minLineDisconnections');
expect(lastRender).not.toHaveProperty('minPst');
expect(lastRender).not.toHaveProperty('nPrioritizedActions');
expect(lastRender).not.toHaveProperty('ignoreReconnections');
});
});
describe('Memoized callback stability', () => {
it('provides stable onTabChange callback across renders', async () => {
await renderAndLoadStudy();
// Capture onTabChange from first VisualizationPanel render after load
const renderCount = vizPanelRenderLog.length;
expect(renderCount).toBeGreaterThanOrEqual(1);
const firstOnTabChange = vizPanelRenderLog[renderCount - 1].onTabChange;
expect(typeof firstOnTabChange).toBe('function');
// Trigger a state change that should NOT change the callback
await selectBranch('BRANCH_A');
// Check that onTabChange is still the same reference (or at least that the
// component was provided a function, since our mock is React.memo'd)
const laterOnTabChange = vizPanelRenderLog[vizPanelRenderLog.length - 1].onTabChange;
expect(typeof laterOnTabChange).toBe('function');
});
it('provides stable onActionFavorite callback to ActionFeed', async () => {
await renderAndLoadStudy();
// ActionFeed only mounts post-contingency.
await selectBranch('BRANCH_A');
const lastRender = actionFeedRenderLog[actionFeedRenderLog.length - 1];
expect(typeof lastRender.onActionFavorite).toBe('function');
expect(typeof lastRender.onActionSelect).toBe('function');
expect(typeof lastRender.onActionReject).toBe('function');
expect(typeof lastRender.onRunAnalysis).toBe('function');
expect(typeof lastRender.onAssetClick).toBe('function');
expect(typeof lastRender.onManualActionAdded).toBe('function');
});
it.skip('provides stable toggle callbacks to OverloadPanel', async () => {
// readability-feed PR retired the inline OverloadPanel — the
// overload-toggle callbacks now route through the sticky
// banner info-bubble popover (SidebarSummary). The component
// is no longer mounted, so this assertion is dead weight.
await renderAndLoadStudy();
const lastRender = overloadPanelRenderLog[overloadPanelRenderLog.length - 1];
expect(typeof lastRender.onToggleOverload).toBe('function');
expect(typeof lastRender.onToggleMonitorDeselected).toBe('function');
});
it('provides stable onVoltageRangeChange and onInspectQueryChange to VisualizationPanel', async () => {
await renderAndLoadStudy();
const lastRender = vizPanelRenderLog[vizPanelRenderLog.length - 1];
expect(typeof lastRender.onVoltageRangeChange).toBe('function');
expect(typeof lastRender.onInspectQueryChange).toBe('function');
expect(typeof lastRender.onVlOpen).toBe('function');
});
});
describe('Voltage-level disk interaction wiring', () => {
it('routes a single-clicked VL into the shared Inspect query', async () => {
vlInteractionHandlers.length = 0;
await renderAndLoadStudy();
await waitFor(() => expect(vlInteractionHandlers.length).toBeGreaterThanOrEqual(3));
// The latest effect run binds [n, contingency, action] in order, so
// the first of the final three is the Network (N) tab's handler set.
const nTab = vlInteractionHandlers[vlInteractionHandlers.length - 3];
expect(typeof nTab.onSelect).toBe('function');
act(() => { nTab.onSelect!('VL1'); });
await waitFor(() => {
const last = vizPanelRenderLog[vizPanelRenderLog.length - 1];
expect(last.inspectQuery).toBe('VL1');
});
});
it('opens the SLD overlay when a VL disk is double-clicked', async () => {
mockApi.getNSld.mockResolvedValueOnce({ svg: '<svg></svg>', sld_metadata: null, switch_states: {} });
vlInteractionHandlers.length = 0;
await renderAndLoadStudy();
await waitFor(() => expect(vlInteractionHandlers.length).toBeGreaterThanOrEqual(3));
const handlers = vlInteractionHandlers[vlInteractionHandlers.length - 1];
expect(typeof handlers.onOpenSld).toBe('function');
await act(async () => { handlers.onOpenSld!('VL1'); });
await waitFor(() => {
const last = vizPanelRenderLog[vizPanelRenderLog.length - 1];
const overlay = last.vlOverlay as { vlName?: string } | null;
expect(overlay?.vlName).toBe('VL1');
});
});
});
describe('resetAllState consolidation', () => {
it('clears analysis state when Load Study is clicked after loading', async () => {
await renderAndLoadStudy();
await selectBranch('BRANCH_A');
// Reset mock call counts
mockApi.updateConfig.mockClear();
mockApi.getBranches.mockClear();
// Click Load Study again — this triggers resetAllState via handleLoadConfig
const loadBtn = screen.getByText('🔄 Load Study');
await userEvent.click(loadBtn);
await waitFor(() => {
expect(mockApi.updateConfig).toHaveBeenCalled();
});
// After reset, the activeTab should be 'n' (default)
const vizPanel = vizPanelRenderLog[vizPanelRenderLog.length - 1];
expect(vizPanel.activeTab).toBe('n');
});
it('clears contingency state when switching branches after analysis', async () => {
await renderAndLoadStudy();
await selectBranch('BRANCH_A');
// After selecting BRANCH_A, the action feed should show analysis-ready state
const lastActionFeed = actionFeedRenderLog[actionFeedRenderLog.length - 1];
expect(lastActionFeed.analysisLoading).toBe(false);
});
});
describe('React.memo integration', () => {
it.skip('renders ActionFeed with data-has-recommender-config attribute', async () => {
// tier-warning-system PR: the recommenderConfig prop was retired when the
// recommender-thresholds notice moved to NoticesPanel. The mock
// attribute it set is no longer meaningful.
await renderAndLoadStudy();
const actionFeed = screen.getByTestId('action-feed');
expect(actionFeed).toHaveAttribute('data-has-recommender-config', 'true');
});
it('renders the visualization and action-feed memoized panels after a contingency is committed', async () => {
// The OverloadPanel was removed in the readability-feed PR;
// its functionality moved to SidebarSummary's info bubble.
// ActionFeed mounts only after a contingency is committed.
await renderAndLoadStudy();
await selectBranch('BRANCH_A');
expect(screen.getByTestId('visualization-panel')).toBeInTheDocument();
expect(screen.getByTestId('action-feed')).toBeInTheDocument();
});
});
describe('Second contingency state reset', () => {
it('clearContingencyState does NOT reset activeTab to n', async () => {
await renderAndLoadStudy();
// Select first branch → should switch to n-1 tab
await selectBranch('BRANCH_A');
await waitFor(() => {
const lastViz = vizPanelRenderLog[vizPanelRenderLog.length - 1];
expect(lastViz.activeTab).toBe('contingency');
});
// Select second branch → should stay on n-1 tab (not flash to n)
await selectBranch('BRANCH_B');
await waitFor(() => {
const lastViz = vizPanelRenderLog[vizPanelRenderLog.length - 1];
// activeTab must be 'contingency' after contingency switch, NOT 'n'
expect(lastViz.activeTab).toBe('contingency');
});
});
it('n1Diagram is cleared and re-fetched on contingency switch', async () => {
await renderAndLoadStudy();
await selectBranch('BRANCH_A');
const firstCallCount = mockApi.getContingencyDiagram.mock.calls.length;
await selectBranch('BRANCH_B');
// Should have fetched N-1 diagram for the new branch
expect(mockApi.getContingencyDiagram).toHaveBeenCalledWith(['BRANCH_B']);
expect(mockApi.getContingencyDiagram.mock.calls.length).toBeGreaterThan(firstCallCount);
});
});
});