Co-Study4Grid / frontend /src /App.configUpload.test.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
15.5 kB
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, renderHook, 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 { useSettings } from './hooks/useSettings';
import type { UserConfig } from './api';
vi.mock('./components/VisualizationPanel', () => ({
default: () => <div data-testid="viz" />,
}));
vi.mock('./components/ActionFeed', () => ({ default: () => <div /> }));
vi.mock('./components/OverloadPanel', () => ({ default: () => <div /> }));
vi.mock('./hooks/usePanZoom', () => ({ usePanZoom: () => ({ viewBox: null, setViewBox: vi.fn() }) }));
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(() => () => {}),
}));
const mockApi = vi.hoisted(() => ({
getUserConfig: vi.fn().mockResolvedValue({
network_path: '/old/network.xiidm',
action_file_path: '/old/actions.json',
layout_path: '/old/layout.json',
output_folder_path: '/old/sessions',
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('/old/config.json'),
// KEY: mock returns a NEW config that is different from the initial.
setConfigFilePath: vi.fn().mockResolvedValue({
config_file_path: '/picked/new_config.json',
config: {
network_path: '/picked/new_network.xiidm',
action_file_path: '/picked/new_actions.json',
layout_path: '/picked/new_layout.json',
output_folder_path: '/picked/new_sessions',
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: 15,
monitoring_factor: 0.95,
pre_existing_overload_threshold: 0.02,
ignore_reconnections: false,
pypowsybl_fast_mode: true,
},
}),
pickPath: vi.fn().mockResolvedValue('/picked/new_config.json'),
updateConfig: vi.fn().mockResolvedValue({ monitored_lines_count: 10, total_lines_count: 10 }),
getBranches: vi.fn().mockResolvedValue({ branches: [], name_map: {} }),
getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: [], name_map: {} }),
getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [] }),
getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }),
getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '<svg></svg>', metadata: null }),
getN1Diagram: vi.fn(),
runAnalysisStep1: vi.fn(),
runAnalysisStep2Stream: vi.fn(),
getActionVariantDiagram: vi.fn(),
getNSld: vi.fn(),
getN1Sld: vi.fn(),
getActionVariantSld: vi.fn(),
}));
vi.mock('./api', () => ({ api: mockApi }));
afterEach(() => cleanup());
describe('Config upload state propagation', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it('updates network/action/layout/output/parameter inputs after picking a new config file', async () => {
render(<App />);
await waitFor(() => expect(mockApi.getUserConfig).toHaveBeenCalled());
const settingsBtn = screen.getByTitle('Settings');
await act(async () => { await userEvent.click(settingsBtn); });
await waitFor(() => expect(screen.getByText('Apply')).toBeInTheDocument());
const networkInput = screen.getByLabelText(/Network File Path/i) as HTMLInputElement;
expect(networkInput.value).toBe('/old/network.xiidm');
const configInput = screen.getByLabelText(/Config File Path/i) as HTMLInputElement;
expect(configInput.value).toBe('/old/config.json');
const configPickerBtn = configInput.parentElement!.querySelectorAll('button')[0] as HTMLButtonElement;
await act(async () => { await userEvent.click(configPickerBtn); });
await waitFor(() => expect(configInput.value).toBe('/picked/new_config.json'));
expect(networkInput.value).toBe('/picked/new_network.xiidm');
const actionInput = screen.getByLabelText(/Action Dictionary File Path/i) as HTMLInputElement;
expect(actionInput.value).toBe('/picked/new_actions.json');
const layoutInput = screen.getByLabelText(/Layout File Path/i) as HTMLInputElement;
expect(layoutInput.value).toBe('/picked/new_layout.json');
const outputInput = screen.getByPlaceholderText(/sessions/i) as HTMLInputElement;
expect(outputInput.value).toBe('/picked/new_sessions');
});
it('updates inputs when user TYPES a new config path and blurs the input', async () => {
render(<App />);
await waitFor(() => expect(mockApi.getUserConfig).toHaveBeenCalled());
const settingsBtn = screen.getByTitle('Settings');
await act(async () => { await userEvent.click(settingsBtn); });
await waitFor(() => expect(screen.getByText('Apply')).toBeInTheDocument());
const configInput = screen.getByLabelText(/Config File Path/i) as HTMLInputElement;
await act(async () => {
await userEvent.clear(configInput);
await userEvent.type(configInput, '/picked/new_config.json');
// Trigger onBlur explicitly
configInput.blur();
});
// Wait for the blur-triggered backend roundtrip + state updates
await waitFor(() => {
const networkInput = screen.getByLabelText(/Network File Path/i) as HTMLInputElement;
expect(networkInput.value).toBe('/picked/new_network.xiidm');
});
});
it('Load Study button drives updateConfig from React state when the config path is unchanged (handleLoadConfig parity)', async () => {
// The stale-closure fix lives in two symmetrical call sites:
// applySettingsImmediate AND handleLoadConfig. The 'typed but never
// blurred' test below covers the path-CHANGED branch via Apply
// Settings (where the bug actually surfaced). This test pins the
// unchanged-path branch on the Load Study button so the
// buildConfigRequest fallback in handleLoadConfig is also guarded.
// Together they cover both arms of the freshlyLoadedCfg ternary.
render(<App />);
await waitFor(() => expect(mockApi.getUserConfig).toHaveBeenCalled());
mockApi.updateConfig.mockClear();
mockApi.setConfigFilePath.mockClear();
// Click Load Study without ever opening Settings → configFilePath
// never changed, lastActive matches, freshlyLoadedCfg stays null,
// buildConfigRequest() is what feeds updateConfig.
await act(async () => { await userEvent.click(screen.getByText('🔄 Load Study')); });
await waitFor(() => expect(mockApi.updateConfig).toHaveBeenCalled());
expect(mockApi.setConfigFilePath).not.toHaveBeenCalled();
expect(mockApi.updateConfig).toHaveBeenCalledWith(expect.objectContaining({
network_path: '/old/network.xiidm',
action_file_path: '/old/actions.json',
layout_path: '/old/layout.json',
}));
});
it('Apply Settings with UNCHANGED config path skips changeConfigFilePath and uses React state directly', async () => {
// The fix added a `freshlyLoadedCfg` branch but kept buildConfigRequest()
// as the fallback when the path didn't move. This test guards against
// an over-eager refactor that always calls setConfigFilePath, which
// would (a) make a needless backend round-trip on every Apply and
// (b) re-apply localStorage state that the operator may have just
// edited in place.
render(<App />);
await waitFor(() => expect(mockApi.getUserConfig).toHaveBeenCalled());
await act(async () => { await userEvent.click(screen.getByTitle('Settings')); });
await waitFor(() => expect(screen.getByText('Apply')).toBeInTheDocument());
// Edit a Recommender field but DO NOT touch the config-file path.
await act(async () => { await userEvent.click(screen.getByText('Recommender')); });
const minLineRecInput = screen.getByLabelText(/Min Line Reconnections/i) as HTMLInputElement;
await act(async () => {
await userEvent.clear(minLineRecInput);
await userEvent.type(minLineRecInput, '4');
});
mockApi.setConfigFilePath.mockClear();
mockApi.updateConfig.mockClear();
await act(async () => { await userEvent.click(screen.getByText('Apply')); });
await waitFor(() => expect(mockApi.updateConfig).toHaveBeenCalled());
// Path didn't change → no setConfigFilePath round-trip.
expect(mockApi.setConfigFilePath).not.toHaveBeenCalled();
// updateConfig is fed by buildConfigRequest from current React state,
// including the field the operator just edited.
expect(mockApi.updateConfig).toHaveBeenCalledWith(expect.objectContaining({
network_path: '/old/network.xiidm',
min_line_reconnections: 4,
}));
});
it('configRequestFromUserConfig falls back to safe defaults when the UserConfig is missing optional fields', async () => {
// Hook-level unit test: drive `configRequestFromUserConfig` directly
// with a stripped-down UserConfig (older pypowsybl-side configs
// predate `layout_path` / `lines_monitoring_path`).
// The helper MUST apply `?? ''` defaults so the
// backend never receives `undefined` — otherwise FastAPI rejects
// the body with a validation error and the operator's Apply
// silently fails. Driving this from the integration test surface
// is racy because input onBlur and Apply both call
// `changeConfigFilePath`, and only one of them consumes the
// mockResolvedValueOnce; a hook-level call is deterministic.
const { result } = renderHook(() => useSettings());
// Wait for the initial useEffect (`api.getUserConfig` + `api.getConfigFilePath`)
// to resolve so the hook is in its post-mount steady state.
await waitFor(() => expect(mockApi.getUserConfig).toHaveBeenCalled());
const partial: UserConfig = {
network_path: '/picked/legacy_network.xiidm',
action_file_path: '/picked/legacy_actions.json',
// layout_path: missing on purpose
// lines_monitoring_path: missing on purpose
min_line_reconnections: 2,
min_close_coupling: 3,
min_open_coupling: 2,
min_line_disconnections: 3,
min_pst: 1,
min_load_shedding: 0,
min_renewable_curtailment_actions: 0,
n_prioritized_actions: 10,
monitoring_factor: 0.95,
pre_existing_overload_threshold: 0.02,
ignore_reconnections: false,
pypowsybl_fast_mode: true,
} as UserConfig;
const req = result.current.configRequestFromUserConfig(partial);
expect(req.network_path).toBe('/picked/legacy_network.xiidm');
expect(req.action_file_path).toBe('/picked/legacy_actions.json');
// Defaults — no `undefined` ever leaks past the helper.
expect(req.layout_path).toBe('');
expect(req.lines_monitoring_path).toBe('');
// Required fields pass through verbatim.
expect(req.min_line_reconnections).toBe(2);
expect(req.monitoring_factor).toBe(0.95);
});
it('configRequestFromUserConfig preserves the provided values when they ARE present', async () => {
// Companion to the partial-UserConfig test above. With a full
// UserConfig the helper must be a faithful pass-through (no
// defaults clobbering provided values).
const { result } = renderHook(() => useSettings());
await waitFor(() => expect(mockApi.getUserConfig).toHaveBeenCalled());
const full: UserConfig = {
network_path: '/picked/full_network.xiidm',
action_file_path: '/picked/full_actions.json',
layout_path: '/picked/full_layout.json',
output_folder_path: '/picked/full_sessions',
lines_monitoring_path: '/picked/full_monitoring.json',
min_line_reconnections: 5,
min_close_coupling: 6,
min_open_coupling: 7,
min_line_disconnections: 8,
min_pst: 9,
min_load_shedding: 10,
min_renewable_curtailment_actions: 11,
n_prioritized_actions: 42,
monitoring_factor: 0.5,
pre_existing_overload_threshold: 0.01,
ignore_reconnections: true,
pypowsybl_fast_mode: false,
};
const req = result.current.configRequestFromUserConfig(full);
expect(req.layout_path).toBe('/picked/full_layout.json');
expect(req.lines_monitoring_path).toBe('/picked/full_monitoring.json');
expect(req.ignore_reconnections).toBe(true);
expect(req.pypowsybl_fast_mode).toBe(false);
expect(req.n_prioritized_actions).toBe(42);
});
it('config-file-input typed but never blurred: Apply still loads the new config and propagates it', async () => {
render(<App />);
await waitFor(() => expect(mockApi.getUserConfig).toHaveBeenCalled());
const settingsBtn = screen.getByTitle('Settings');
await act(async () => { await userEvent.click(settingsBtn); });
await waitFor(() => expect(screen.getByText('Apply')).toBeInTheDocument());
const configInput = screen.getByLabelText(/Config File Path/i) as HTMLInputElement;
await act(async () => {
await userEvent.clear(configInput);
await userEvent.type(configInput, '/picked/new_config.json');
// DO NOT blur. The user clicks Apply directly, focus moves to button.
});
// Click Apply — modal closes, but state should reflect the loaded config.
const applyBtn = screen.getByText('Apply');
await act(async () => { await userEvent.click(applyBtn); });
// Modal closes after Apply, so we re-open to inspect state.
await waitFor(() => expect(screen.queryByText('Apply')).not.toBeInTheDocument());
await act(async () => { await userEvent.click(screen.getByTitle('Settings')); });
await waitFor(() => expect(screen.getByText('Apply')).toBeInTheDocument());
// After Apply round-trip, the modal should show the new loaded values
// (matches the operator's symptom: "paths are not updated in the modal").
const networkInput = screen.getByLabelText(/Network File Path/i) as HTMLInputElement;
const actionInput = screen.getByLabelText(/Action Dictionary File Path/i) as HTMLInputElement;
const layoutInput = screen.getByLabelText(/Layout File Path/i) as HTMLInputElement;
expect(networkInput.value).toBe('/picked/new_network.xiidm');
expect(actionInput.value).toBe('/picked/new_actions.json');
expect(layoutInput.value).toBe('/picked/new_layout.json');
// Critical: backend must have been called with the NEW network path
// (not the old stale closure value). This asserts the absence of the
// stale-closure bug between `await changeConfigFilePath()` and the
// immediately-following `buildConfigRequest()` in `applySettingsImmediate`.
expect(mockApi.updateConfig).toHaveBeenCalledWith(expect.objectContaining({
network_path: '/picked/new_network.xiidm',
action_file_path: '/picked/new_actions.json',
layout_path: '/picked/new_layout.json',
}));
});
});