// 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... // 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 = ''; 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: '', 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: '', 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).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).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); }); }); });