Co-Study4Grid / frontend /src /api.test.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
16.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 { describe, it, expect, vi, beforeEach } from 'vitest';
import axios from 'axios';
import { api } from './api';
vi.mock('axios');
const mockedAxios = vi.mocked(axios, true);
describe('api client', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('updateConfig', () => {
it('sends POST to /api/config', async () => {
const config = {
network_path: '/test/path',
action_file_path: '/test/actions.json',
min_line_reconnections: 2.0,
min_close_coupling: 3.0,
min_open_coupling: 2.0,
min_line_disconnections: 3.0,
n_prioritized_actions: 10,
monitoring_factor: 0.95,
pre_existing_overload_threshold: 0.02,
ignore_reconnections: false,
pypowsybl_fast_mode: true
};
mockedAxios.post.mockResolvedValue({ data: { status: 'success' } });
const result = await api.updateConfig(config);
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://127.0.0.1:8000/api/config',
config,
);
expect(result).toEqual({ status: 'success' });
});
});
describe('getBranches', () => {
it('sends GET to /api/branches and returns branches array', async () => {
mockedAxios.get.mockResolvedValue({
data: { branches: ['LINE_A', 'LINE_B'], name_map: {} },
});
const result = await api.getBranches();
expect(mockedAxios.get).toHaveBeenCalledWith(
'http://127.0.0.1:8000/api/branches',
);
expect(result).toEqual({ branches: ['LINE_A', 'LINE_B'], name_map: {} });
});
});
describe('getVoltageLevels', () => {
it('returns voltage levels array', async () => {
mockedAxios.get.mockResolvedValue({
data: { voltage_levels: ['VL1', 'VL2'], name_map: {} },
});
const result = await api.getVoltageLevels();
expect(result).toEqual({ voltage_levels: ['VL1', 'VL2'], name_map: {} });
});
});
describe('getNominalVoltages', () => {
it('returns mapping and unique_kv', async () => {
const responseData = {
mapping: { VL1: 400, VL2: 225 },
unique_kv: [225, 400],
};
mockedAxios.get.mockResolvedValue({ data: responseData });
const result = await api.getNominalVoltages();
expect(result).toEqual(responseData);
});
});
describe('setRecommenderModel', () => {
it('POSTs the model + compute_overflow_graph to /api/recommender-model', async () => {
// Lightweight model swap — does NOT reload the network. Used
// by useSettings whenever the operator changes the model in
// the Settings modal OR the dropdown above Analyze & Suggest.
mockedAxios.post.mockResolvedValue({
data: { status: 'success', active_model: 'random_overflow', compute_overflow_graph: true },
});
const result = await api.setRecommenderModel('random_overflow', true);
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://127.0.0.1:8000/api/recommender-model',
{ model: 'random_overflow', compute_overflow_graph: true },
);
expect(result).toEqual({
status: 'success',
active_model: 'random_overflow',
compute_overflow_graph: true,
});
});
});
describe('getNetworkDiagram', () => {
it('parses the text/plain header+SVG response', async () => {
// Server now returns: {json header}\n<svg>...</svg>
// The client splits on the first newline rather than doing
// a JSON.parse of the full body (saves ~500 ms on 25 MB
// grids — see docs/performance/history/loading-parallel.md).
const header = { metadata: null, lines_overloaded: ['X'], lines_overloaded_rho: [1.05] };
const svg = '<svg id="n"/>';
const body = JSON.stringify(header) + '\n' + svg;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
text: async () => body,
} as Response);
const result = await api.getNetworkDiagram();
expect(fetchSpy).toHaveBeenCalledWith(
'http://127.0.0.1:8000/api/network-diagram?format=text'
);
expect(result).toEqual({ ...header, svg });
fetchSpy.mockRestore();
});
it('throws on non-ok status', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: false,
status: 500,
statusText: 'Server Error',
text: async () => 'boom',
} as Response);
await expect(api.getNetworkDiagram()).rejects.toThrow(/HTTP 500/);
fetchSpy.mockRestore();
});
});
describe('getContingencyDiagram', () => {
it('sends POST with the disconnected-elements list', async () => {
const diagramData = { svg: '<svg/>', metadata: '{}', lf_converged: true };
mockedAxios.post.mockResolvedValue({ data: diagramData });
const result = await api.getContingencyDiagram(['LINE_A']);
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://127.0.0.1:8000/api/contingency-diagram',
{ disconnected_elements: ['LINE_A'] },
);
expect(result).toEqual(diagramData);
});
});
describe('getActionVariantDiagram', () => {
it('sends POST with action ID', async () => {
mockedAxios.post.mockResolvedValue({
data: { svg: '<svg/>', metadata: '{}', action_id: 'act_1' },
});
const result = await api.getActionVariantDiagram('act_1');
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://127.0.0.1:8000/api/action-variant-diagram',
{ action_id: 'act_1' },
);
expect(result.action_id).toBe('act_1');
});
});
describe('getAvailableActions', () => {
it('returns actions list', async () => {
const actions = [{ id: 'act_1', description: 'Test' }];
mockedAxios.get.mockResolvedValue({
data: { actions },
});
const result = await api.getAvailableActions();
expect(result).toEqual(actions);
});
});
describe('simulateManualAction', () => {
it('sends POST with action_id and the disconnected-elements list', async () => {
const responseData = {
action_id: 'act_1',
description_unitaire: 'Open line',
rho_before: [0.95],
rho_after: [0.80],
max_rho: 0.80,
max_rho_line: 'LINE_A',
is_rho_reduction: true,
lines_overloaded: ['LINE_A'],
};
mockedAxios.post.mockResolvedValue({ data: responseData });
const result = await api.simulateManualAction('act_1', ['LINE_B']);
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://127.0.0.1:8000/api/simulate-manual-action',
{ action_id: 'act_1', disconnected_elements: ['LINE_B'], action_content: null, lines_overloaded: null, target_mw: null, target_tap: null },
);
expect(result).toEqual(responseData);
});
});
describe('pickPath', () => {
it('sends GET with file type parameter', async () => {
mockedAxios.get.mockResolvedValue({
data: { path: '/home/user/test.xiidm' },
});
const result = await api.pickPath('file');
expect(mockedAxios.get).toHaveBeenCalledWith(
'http://127.0.0.1:8000/api/pick-path?type=file',
);
expect(result).toBe('/home/user/test.xiidm');
});
it('sends GET with dir type parameter', async () => {
mockedAxios.get.mockResolvedValue({
data: { path: '/home/user/networks' },
});
const result = await api.pickPath('dir');
expect(mockedAxios.get).toHaveBeenCalledWith(
'http://127.0.0.1:8000/api/pick-path?type=dir',
);
expect(result).toBe('/home/user/networks');
});
// Bug 4: when the backend returns an error field (e.g. tkinter
// failed to launch), the API client must throw so the caller can
// surface a visible message instead of silently returning "".
it('throws when the backend response contains an error field', async () => {
mockedAxios.get.mockResolvedValue({
data: { path: '', error: 'tkinter: no display' },
});
await expect(api.pickPath('file')).rejects.toThrow('tkinter: no display');
});
});
describe('runAnalysisStep1', () => {
it('sends POST with the disconnected-elements list and returns detection result', async () => {
const responseData = {
lines_overloaded: ['LINE_A', 'LINE_B'],
message: 'Detected 2 overloads',
can_proceed: true,
};
mockedAxios.post.mockResolvedValue({ data: responseData });
const result = await api.runAnalysisStep1(['LINE_X']);
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://127.0.0.1:8000/api/run-analysis-step1',
{ disconnected_elements: ['LINE_X'] },
{ signal: undefined },
);
expect(result).toEqual(responseData);
});
it('returns can_proceed=false when no overloads detected', async () => {
const responseData = {
lines_overloaded: [],
message: 'No overloads detected',
can_proceed: false,
};
mockedAxios.post.mockResolvedValue({ data: responseData });
const result = await api.runAnalysisStep1(['LINE_Y']);
expect(result.can_proceed).toBe(false);
expect(result.lines_overloaded).toEqual([]);
});
});
describe('runAnalysisStep2Stream', () => {
it('sends POST with selected_overloads, all_overloads, and monitor_deselected', async () => {
const mockResponse = {
ok: true,
statusText: 'OK',
} as unknown as Response;
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);
try {
await api.runAnalysisStep2Stream({
selected_overloads: ['LINE_A'],
all_overloads: ['LINE_A', 'LINE_B'],
monitor_deselected: true,
});
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:8000/api/run-analysis-step2',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
selected_overloads: ['LINE_A'],
all_overloads: ['LINE_A', 'LINE_B'],
monitor_deselected: true,
additional_lines_to_cut: [],
}),
},
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('returns raw Response for streaming consumption', async () => {
const mockResponse = {
ok: true,
statusText: 'OK',
body: 'mock-body',
} as unknown as Response;
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);
try {
const result = await api.runAnalysisStep2Stream({
selected_overloads: ['LINE_A'],
all_overloads: ['LINE_A'],
monitor_deselected: false,
});
// Returns the raw Response, not parsed data
expect(result).toBe(mockResponse);
expect(result.body).toBe('mock-body');
} finally {
globalThis.fetch = originalFetch;
}
});
it('throws on non-ok response', async () => {
const mockResponse = {
ok: false,
statusText: 'Internal Server Error',
} as unknown as Response;
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);
try {
await expect(
api.runAnalysisStep2Stream({
selected_overloads: ['LINE_A'],
all_overloads: ['LINE_A'],
monitor_deselected: false,
}),
).rejects.toThrow('Analysis Resolution failed');
} finally {
globalThis.fetch = originalFetch;
}
});
it('passes monitor_deselected=false when not monitoring deselected overloads', async () => {
const mockResponse = { ok: true, statusText: 'OK' } as unknown as Response;
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn().mockResolvedValue(mockResponse);
try {
await api.runAnalysisStep2Stream({
selected_overloads: ['LINE_A', 'LINE_B'],
all_overloads: ['LINE_A', 'LINE_B', 'LINE_C'],
monitor_deselected: false,
});
const body = JSON.parse(
(globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body,
);
expect(body.monitor_deselected).toBe(false);
expect(body.all_overloads).toEqual(['LINE_A', 'LINE_B', 'LINE_C']);
expect(body.selected_overloads).toEqual(['LINE_A', 'LINE_B']);
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('legacy runAnalysis removal', () => {
it('api object does not have a runAnalysis method', () => {
expect((api as Record<string, unknown>).runAnalysis).toBeUndefined();
});
});
describe('restoreAnalysisContext', () => {
it('sends POST to /api/restore-analysis-context with the monitored-line set', async () => {
// Parity with the standalone HTML mirror
// (standalone_interface.html:3857). Re-pushes the session's
// `lines_we_care_about` to the backend on reload so later
// simulate-action calls use the per-study set instead of
// the backend's default.
mockedAxios.post.mockResolvedValue({
data: {
status: 'success',
lines_we_care_about_count: 3,
computed_pairs_count: 0,
},
});
const params = {
lines_we_care_about: ['LINE_MON1', 'LINE_MON2', 'LINE_MON3'],
disconnected_elements: ['LINE_A'],
lines_overloaded: ['LINE_OL1'],
computed_pairs: null,
};
const result = await api.restoreAnalysisContext(params);
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://127.0.0.1:8000/api/restore-analysis-context',
params,
);
expect(result.status).toBe('success');
expect(result.lines_we_care_about_count).toBe(3);
});
});
});