Co-Study4Grid / frontend /src /App.settings.test.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
21.5 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';
// ===== Mocks =====
// Mock child components to avoid their complexity
vi.mock('./components/VisualizationPanel', () => {
interface MockProps {
nDiagram: Record<string, unknown> | null;
n1Diagram: Record<string, unknown> | null;
configLoading: boolean;
layoutPath: string;
networkPath: string;
onOpenSettings: (tab: string) => void;
}
const MockVisualizationPanel = (props: MockProps) => {
const { nDiagram, n1Diagram, configLoading, layoutPath, networkPath, onOpenSettings } = props;
const [warningDismissed, setWarningDismissed] = React.useState(false);
const hasAnyDiagram = !!nDiagram?.svg || !!n1Diagram?.svg;
const showPathWarning = !warningDismissed && !hasAnyDiagram;
return (
<div
data-testid="visualization-panel"
data-n1-diagram-present={!!n1Diagram}
>
{!hasAnyDiagram && !configLoading && showPathWarning && (
<div>
<div>Configuration Paths</div>
<button onClick={() => setWarningDismissed(true)}>✕</button>
<div>Layout Path: {layoutPath}</div>
<div>Output Folder: {networkPath ? (networkPath.includes('/') ? networkPath.substring(0, networkPath.lastIndexOf('/')) : networkPath) : 'Not set'}</div>
<button onClick={() => onOpenSettings('paths')}>Change in settings</button>
</div>
)}
</div>
);
};
return { default: MockVisualizationPanel };
});
vi.mock('./components/ActionFeed', () => ({
default: (props: { linesOverloaded: string[]; pendingAnalysisResult: object | null; analysisLoading: boolean; onDisplayPrioritizedActions: () => void; onRunAnalysis: () => void; canRunAnalysis: boolean }) => (
<div
data-testid="action-feed"
data-ol-count={props.linesOverloaded?.length || 0}
data-pending={!!props.pendingAnalysisResult}
data-loading={!!props.analysisLoading}
>
{props.analysisLoading ? (
<button disabled style={{ background: '#fff3cd', color: '#856404' }}>⚙️ Analyzing…</button>
) : props.pendingAnalysisResult ? (
<button onClick={props.onDisplayPrioritizedActions}>Display prioritized actions</button>
) : (
<button onClick={props.onRunAnalysis} disabled={!props.canRunAnalysis}>🔍 Analyze & Suggest</button>
)}
</div>
),
}));
vi.mock('./components/OverloadPanel', () => ({
default: (props: { n1Overloads: string[]; selectedOverloads: Set<string> }) => (
<div
data-testid="overload-panel"
data-n1-ol-count={props.n1Overloads?.length || 0}
data-sel-ol-count={props.selectedOverloads?.size || 0}
/>
),
}));
// Mock hooks
vi.mock('./hooks/usePanZoom', () => ({
usePanZoom: () => ({ viewBox: null, setViewBox: vi.fn() }),
}));
// 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(() => () => {}),
}));
// Mock API — use vi.hoisted to define mock before vi.mock hoists
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,
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: pick ``branchName`` from the react-select multi-select
// then click the Trigger button to commit the contingency.
async function selectBranch(branchName: string) {
const combobox = screen.getByRole('combobox');
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('Save Results button', () => {
beforeEach(() => {
vi.restoreAllMocks(); // restores spied-on originals before each test
vi.clearAllMocks();
localStorage.clear();
vi.unstubAllGlobals();
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => { });
});
it('is present in the header after study load', async () => {
await renderAndLoadStudy();
expect(screen.getByTitle('Save session results to JSON')).toBeInTheDocument();
});
it('is disabled when no branch has been selected', async () => {
await renderAndLoadStudy();
const saveBtn = screen.getByTitle('Save session results to JSON');
expect(saveBtn).toBeDisabled();
});
it('is enabled after selecting a valid branch', async () => {
await renderAndLoadStudy();
await selectBranch('BRANCH_A');
const saveBtn = screen.getByTitle('Save session results to JSON');
expect(saveBtn).not.toBeDisabled();
});
it('triggers a JSON download when clicked after branch selection', async () => {
const createObjectURL = vi.spyOn(URL, 'createObjectURL');
// Spy on anchor click to capture download without browser navigation
const originalCreateElement = document.createElement.bind(document);
const anchorClicks: HTMLAnchorElement[] = [];
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
const el = originalCreateElement(tag as keyof HTMLElementTagNameMap);
if (tag === 'a') {
vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => {
anchorClicks.push(el as HTMLAnchorElement);
});
}
return el;
});
await renderAndLoadStudy();
await selectBranch('BRANCH_A');
const saveBtn = screen.getByTitle('Save session results to JSON');
await act(async () => {
await userEvent.click(saveBtn);
});
expect(createObjectURL).toHaveBeenCalledOnce();
expect(anchorClicks).toHaveLength(1);
const anchor = anchorClicks[0];
expect(anchor.download).toMatch(/^costudy4grid_session_BRANCH_A_/);
expect(anchor.download).toMatch(/\.json$/);
});
it('JSON download contains configuration, contingency, and overloads sections', async () => {
let capturedBlob: Blob | undefined;
vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
capturedBlob = blob as Blob;
return 'blob:mock-url';
});
const origCreate = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
const el = origCreate(tag as keyof HTMLElementTagNameMap);
if (tag === 'a') vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => { });
return el;
});
await renderAndLoadStudy();
await selectBranch('BRANCH_A');
const saveBtn = screen.getByTitle('Save session results to JSON');
await act(async () => {
await userEvent.click(saveBtn);
});
expect(capturedBlob).toBeDefined();
const text = await capturedBlob!.text();
const sessionItems = JSON.parse(text);
expect(sessionItems).toHaveProperty('saved_at');
expect(sessionItems).toHaveProperty('configuration');
expect(sessionItems.configuration).toHaveProperty('network_path');
expect(sessionItems.configuration).toHaveProperty('layout_path');
expect(sessionItems.configuration).toHaveProperty('monitoring_factor');
expect(sessionItems).toHaveProperty('contingency');
// New session schema: ordered list. ``disconnected_element`` is
// still emitted as a backwards-compat alias for single-element
// contingencies.
expect(sessionItems.contingency.disconnected_elements).toEqual(['BRANCH_A']);
expect(sessionItems.contingency.disconnected_element).toBe('BRANCH_A');
expect(sessionItems).toHaveProperty('overloads');
expect(sessionItems).toHaveProperty('analysis');
});
it('JSON analysis is null when no analysis has been run', async () => {
let capturedBlob: Blob | undefined;
vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
capturedBlob = blob as Blob;
return 'blob:mock-url';
});
const origCreate = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
const el = origCreate(tag as keyof HTMLElementTagNameMap);
if (tag === 'a') vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => { });
return el;
});
await renderAndLoadStudy();
await selectBranch('BRANCH_A');
const saveBtn = screen.getByTitle('Save session results to JSON');
await act(async () => {
await userEvent.click(saveBtn);
});
const sessionItems = JSON.parse(await capturedBlob!.text());
expect(sessionItems.analysis).toBeNull();
});
});
describe('Settings Modal Enhancements', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.unstubAllGlobals();
});
async function openSettings() {
render(<App />);
const settingsBtn = screen.getByTitle('Settings');
await act(async () => {
await userEvent.click(settingsBtn);
});
await waitFor(() => {
expect(screen.getByText('Apply')).toBeInTheDocument();
});
}
it('defaults to the Paths tab when opened', async () => {
await openSettings();
// The Paths tab should be active. We can check for a path-specific input.
expect(screen.getByLabelText(/Network File Path/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Layout File Path/i)).toBeInTheDocument();
});
it('auto-closes and fetches study data on Apply', async () => {
await openSettings();
mockApi.updateConfig.mockClear();
mockApi.getBranches.mockClear();
mockApi.getVoltageLevels.mockClear();
const applyBtn = screen.getByText('Apply');
await act(async () => {
await userEvent.click(applyBtn);
});
await waitFor(() => {
expect(mockApi.updateConfig).toHaveBeenCalled();
});
// Verification of auto-close
await waitFor(() => {
expect(screen.queryByText('Apply')).not.toBeInTheDocument();
});
// Verification of study data fetching
await waitFor(() => {
expect(mockApi.getBranches).toHaveBeenCalled();
expect(mockApi.getVoltageLevels).toHaveBeenCalled();
expect(mockApi.getNominalVoltages).toHaveBeenCalled();
expect(mockApi.getNetworkDiagram).toHaveBeenCalled();
});
});
it('switches config file before applying if path changed', async () => {
await openSettings();
const configPathInput = screen.getByLabelText(/Config File Path/i);
await userEvent.clear(configPathInput);
await userEvent.type(configPathInput, '/new/config.json');
mockApi.setConfigFilePath.mockClear();
mockApi.updateConfig.mockClear();
const applyBtn = screen.getByText('Apply');
await act(async () => {
await userEvent.click(applyBtn);
});
await waitFor(() => {
expect(mockApi.setConfigFilePath).toHaveBeenCalledWith('/new/config.json');
});
expect(mockApi.updateConfig).toHaveBeenCalled();
// Ensure setConfigFilePath was called BEFORE updateConfig
const setConfigCallOrder = mockApi.setConfigFilePath.mock.invocationCallOrder[0];
const updateConfigCallOrder = mockApi.updateConfig.mock.invocationCallOrder[0];
expect(setConfigCallOrder).toBeLessThan(updateConfigCallOrder);
});
it('updates configuration with new network and layout paths', async () => {
await openSettings();
const networkInput = screen.getByLabelText(/Network File Path/i);
const layoutInput = screen.getByLabelText(/Layout File Path/i);
await userEvent.clear(networkInput);
await userEvent.type(networkInput, '/path/to/network.xiidm');
await userEvent.clear(layoutInput);
await userEvent.type(layoutInput, '/path/to/layout.json');
mockApi.updateConfig.mockResolvedValue({
monitored_lines_count: 5,
total_lines_count: 5
});
const applyBtn = screen.getByText('Apply');
await act(async () => {
await userEvent.click(applyBtn);
});
await waitFor(() => {
expect(mockApi.updateConfig).toHaveBeenCalledWith(expect.objectContaining({
network_path: '/path/to/network.xiidm',
layout_path: '/path/to/layout.json'
}));
});
});
it('updates configuration with new monitoring factor', async () => {
await openSettings();
// Switch to Configurations tab
const configTab = screen.getByText('Configurations');
await userEvent.click(configTab);
const monitoringInput = screen.getByLabelText(/Monitoring Factor Thermal Limits/i);
await userEvent.clear(monitoringInput);
await userEvent.type(monitoringInput, '0.85');
const applyBtn = screen.getByText('Apply');
await act(async () => {
await userEvent.click(applyBtn);
});
await waitFor(() => {
expect(mockApi.updateConfig).toHaveBeenCalledWith(expect.objectContaining({
monitoring_factor: 0.85
}));
});
});
it('shows path warning when no network is loaded and allows dismissal', async () => {
// Initial render should show the warning since no diagram is loaded
render(<App />);
await waitFor(() => {
expect(screen.getByText(/Configuration Paths/i)).toBeInTheDocument();
});
expect(screen.getByText(/Layout Path:/i)).toBeInTheDocument();
expect(screen.getByText(/Output Folder:/i)).toBeInTheDocument();
// Dismiss the warning
const closeBtn = screen.getByText('✕');
await userEvent.click(closeBtn);
await waitFor(() => {
expect(screen.queryByText(/Configuration Paths/i)).not.toBeInTheDocument();
});
});
it('hides path warning when a network diagram is loaded', async () => {
render(<App />);
// Warning should be present initially
await waitFor(() => {
expect(screen.getByText(/Configuration Paths/i)).toBeInTheDocument();
});
// Simulate loading a study which will set nDiagram
const settingsBtn = screen.getByTitle('Settings');
await userEvent.click(settingsBtn);
const applyBtn = screen.getByText('Apply');
await userEvent.click(applyBtn);
// After apply, the warning should disappear because a diagram (nDiagram) will be "loaded"
// (mocked in our tests)
await waitFor(() => {
expect(screen.queryByText(/Configuration Paths/i)).not.toBeInTheDocument();
});
});
it('opens settings on Paths tab when "Change in settings" is clicked in warning banner', async () => {
render(<App />);
await waitFor(() => {
expect(screen.getByText(/Configuration Paths/i)).toBeInTheDocument();
});
const changeLink = screen.getByText(/Change in settings/i);
await userEvent.click(changeLink);
// Should open settings modal
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Should be on Paths tab (default for settings modal anyway, but let's be sure)
expect(screen.getByLabelText(/Network File Path/i)).toBeInTheDocument();
});
it('correctly derives and displays output folder from network path in warning banner', async () => {
localStorage.setItem('networkPath', '/home/user/data/grid.xiidm');
render(<App />);
await waitFor(() => {
expect(screen.getByText(/Configuration Paths/i)).toBeInTheDocument();
});
// The output folder should be /home/user/data
expect(screen.getByText(/Output Folder:/i)).toHaveTextContent('/home/user/data');
});
it('displays correct icons and placeholders in settings modal', async () => {
await openSettings();
const networkInput = screen.getByLabelText(/Network File Path/i);
expect(networkInput).toHaveAttribute('placeholder', 'load your grid xiidm file path');
// Check icons (they are emojis in buttons)
// Network button icon
const networkBtn = networkInput.parentElement?.querySelector('button');
expect(networkBtn?.textContent).toBe('📄');
// Layout button icon
const layoutInput = screen.getByLabelText(/Layout File Path/i);
const layoutBtn = layoutInput.parentElement?.querySelector('button');
expect(layoutBtn?.textContent).toBe('📄');
// Action button icon (was already 📄 but good to check)
const actionInput = screen.getByLabelText(/Action Dictionary File Path/i);
const actionBtn = actionInput.parentElement?.querySelector('button');
expect(actionBtn?.textContent).toBe('📄');
// Output folder icon (should be 📂)
const outputInput = screen.getByPlaceholderText('e.g. /home/user/sessions');
const outputBtn = outputInput.parentElement?.querySelector('button');
expect(outputBtn?.textContent).toBe('📂');
});
it('displays file icon for network path in main banner', async () => {
render(<App />);
// Anchor on the header's network-path input (unique test id) — the
// file-opener button shares its row. Robust to the label / Notices
// pill layout around it.
const bannerInput = screen.getByTestId('header-network-path-input');
expect(bannerInput).toHaveAttribute('placeholder', 'load your grid xiidm file path');
const inputRow = bannerInput.closest('div')!;
const bannerBtn = Array.from(inputRow.querySelectorAll('button'))
.find(b => b.textContent === '📄');
expect(bannerBtn?.textContent).toBe('📄');
});
describe('Processing State UI', () => {
it('shows yellow "Running..." button during analysis', async () => {
render(<App />);
const loadBtn = screen.getByText('🔄 Load Study');
await userEvent.click(loadBtn);
await waitFor(() => {
expect(screen.getByText('⚡ Select Contingency')).toBeInTheDocument();
});
// Pick a branch from the multi-select then click Trigger.
await selectBranch('BRANCH_A');
// Setup streaming mock for step2 (keep stream open to simulate "Running" state)
const mockStream = new ReadableStream({
start() {
// Keep it open to simulate "Running" state
},
});
mockApi.runAnalysisStep2Stream.mockResolvedValue({
ok: true,
body: mockStream,
});
const runBtn = screen.getByText('🔍 Analyze & Suggest');
await userEvent.click(runBtn);
// Button should change to "⚙️ Analyzing…"
const runningBtn = await screen.findByText('⚙️ Analyzing…');
expect(runningBtn).toBeInTheDocument();
});
});
});