Spaces:
Paused
Paused
File size: 11,260 Bytes
1c730d1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 | // 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
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { api } from '../api';
import type { GameLeverStatsResponse } from '../types';
import { gameBridge } from './gameBridge';
import GameHintsPanel from './GameHintsPanel';
import type { GameStudy } from './types';
vi.mock('../api', () => ({
api: { getGameLeverStats: vi.fn() },
}));
const getGameLeverStats = vi.mocked(api.getGameLeverStats);
/** A promise whose resolution the test controls (to hold a simulation open). */
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((r) => { resolve = r; });
return { promise, resolve };
}
/** Publish an App-side snapshot with the given materialised action ids. */
function publishSimulated(simulatedActionIds: string[]): void {
gameBridge.publishSnapshot({
contingencyElementIds: ['ctg_1'],
baselineMaxRho: 1.2,
chosenActions: [],
simulatedActionIds,
});
}
const STUDY: GameStudy = {
id: 's1',
label: 'Study 1',
networkPath: 'data/grid/network.xiidm',
actionFilePath: 'actions.json',
contingencyElementId: 'ctg_1',
};
function stats(over: Partial<GameLeverStatsResponse> = {}): GameLeverStatsResponse {
return {
context_key: 'grid_network__ctg_1__abcd1234',
total_retentions: 7,
levers: [
{ signature: 'action:disco_LINE_A', label: 'disco_LINE_A', category: 'branch', count: 5, share: 5 / 7, sample_description: 'Ouverture LINE_A' },
{ signature: 'redispatch:G1', label: 'G1', category: 'generation', count: 3, share: 3 / 7, sample_description: null },
{ signature: 'switch:VL1_COUPL=true', label: 'VL1_COUPL', category: 'voltage_level', count: 2, share: 2 / 7, sample_description: null },
{ signature: 'ls:LOAD_9', label: 'LOAD_9', category: 'load', count: 1, share: 1 / 7, sample_description: null },
],
...over,
};
}
describe('GameHintsPanel', () => {
beforeEach(() => {
getGameLeverStats.mockReset();
gameBridge.reset();
});
afterEach(() => {
vi.restoreAllMocks();
gameBridge.reset();
});
it('lists the most-used levers with their equipment category', async () => {
getGameLeverStats.mockResolvedValue(stats());
render(<GameHintsPanel study={STUDY} />);
await waitFor(() => expect(screen.getByText(/Most-used levers here/)).toBeInTheDocument());
expect(getGameLeverStats).toHaveBeenCalledWith('data/grid/network.xiidm', 'ctg_1');
expect(screen.getByText(/disco_LINE_A/)).toBeInTheDocument();
expect(screen.getByText(/Branch · used 5×/)).toBeInTheDocument();
expect(screen.getByText(/Generation · used 3×/)).toBeInTheDocument();
expect(screen.getByText(/Voltage level · used 2×/)).toBeInTheDocument();
expect(screen.getByText(/Load · used 1×/)).toBeInTheDocument();
expect(screen.getByText(/From 7 retained solutions by all players/)).toBeInTheDocument();
});
it('requests an inspect interaction after the single-click delay', async () => {
const lever = vi.spyOn(gameBridge, 'requestLeverInteraction');
getGameLeverStats.mockResolvedValue(stats());
render(<GameHintsPanel study={STUDY} />);
await screen.findByText(/disco_LINE_A/);
// Single-click is deferred so a double-click can pre-empt it.
fireEvent.click(screen.getByText(/disco_LINE_A/));
expect(lever).not.toHaveBeenCalled();
await waitFor(() => expect(lever).toHaveBeenCalledWith(
expect.objectContaining({ inspectQuery: 'LINE_A', simulate: { actionId: 'disco_LINE_A' } }),
'inspect',
));
});
it('requests a simulate interaction on double-click and cancels the pending inspect', async () => {
const lever = vi.spyOn(gameBridge, 'requestLeverInteraction');
getGameLeverStats.mockResolvedValue(stats());
render(<GameHintsPanel study={STUDY} />);
await screen.findByText(/VL1_COUPL/);
const target = screen.getByText(/VL1_COUPL/);
fireEvent.click(target); // schedules the deferred inspect
fireEvent.doubleClick(target); // pre-empts it and fires the simulate now
expect(lever).toHaveBeenCalledTimes(1);
expect(lever).toHaveBeenCalledWith(
expect.objectContaining({ inspectQuery: 'VL1_COUPL', simulate: { switches: { VL1_COUPL: true } } }),
'simulate',
);
// Wait past the single-click delay — the pending inspect stays cancelled
// (also lets the async simulate settle its status state inside act).
await act(async () => { await new Promise((r) => setTimeout(r, 300)); });
expect(lever).toHaveBeenCalledTimes(1);
});
it('single-clicks an injection lever to inspect (even though it can simulate)', async () => {
const lever = vi.spyOn(gameBridge, 'requestLeverInteraction');
getGameLeverStats.mockResolvedValue(stats());
render(<GameHintsPanel study={STUDY} />);
await screen.findByText(/G1/);
fireEvent.click(screen.getByText(/G1/));
await waitFor(() => expect(lever).toHaveBeenCalled());
const [interaction, mode] = lever.mock.calls.at(-1)!;
// A redispatch lever now carries a simulate spec (default incremental
// delta), but a single-click still only locates & inspects it.
expect(interaction).toMatchObject({
inspectQuery: 'G1', category: 'generation', simulate: { actionId: 'redispatch_G1' },
});
expect(mode).toBe('inspect');
});
it('double-clicks a catalogue branch lever to a simulate-by-action-id', async () => {
const lever = vi.spyOn(gameBridge, 'requestLeverInteraction');
getGameLeverStats.mockResolvedValue(stats());
render(<GameHintsPanel study={STUDY} />);
await screen.findByText(/disco_LINE_A/);
const target = screen.getByText(/disco_LINE_A/);
fireEvent.click(target);
fireEvent.doubleClick(target);
expect(lever).toHaveBeenCalledTimes(1);
expect(lever).toHaveBeenCalledWith(
expect.objectContaining({ inspectQuery: 'LINE_A', simulate: { actionId: 'disco_LINE_A' } }),
'simulate',
);
await act(async () => { await Promise.resolve(); }); // settle the async status update
});
it('double-clicks a redispatch lever to simulate it (default incremental delta)', async () => {
const lever = vi.spyOn(gameBridge, 'requestLeverInteraction');
getGameLeverStats.mockResolvedValue(stats());
render(<GameHintsPanel study={STUDY} />);
await screen.findByText(/G1/);
const target = screen.getByText(/G1/);
fireEvent.click(target);
fireEvent.doubleClick(target);
// The lever maps to the backend dynamic-action id; simulated with no MW so
// the backend applies its default incremental injection delta.
expect(lever).toHaveBeenCalledWith(
expect.objectContaining({ inspectQuery: 'G1', simulate: { actionId: 'redispatch_G1' } }),
'simulate',
);
await waitFor(() => expect(lever).toHaveBeenCalled());
});
it('shows a simulating → simulated transition on a lever double-click', async () => {
const d = deferred();
const lever = vi.spyOn(gameBridge, 'requestLeverInteraction').mockReturnValue(d.promise);
getGameLeverStats.mockResolvedValue(stats());
render(<GameHintsPanel study={STUDY} />);
await screen.findByText(/disco_LINE_A/);
fireEvent.doubleClick(screen.getByText(/disco_LINE_A/));
expect(lever).toHaveBeenCalledWith(
expect.objectContaining({ simulate: { actionId: 'disco_LINE_A' } }), 'simulate');
const status = await screen.findByTestId('game-lever-status-action:disco_LINE_A');
expect(status).toHaveTextContent(/simulating/);
// Resolve the run and let App publish the materialised action id.
await act(async () => { d.resolve(); await d.promise; publishSimulated(['disco_LINE_A']); });
await waitFor(() => expect(
screen.getByTestId('game-lever-status-action:disco_LINE_A')).toHaveTextContent(/simulated/));
});
it('marks a lever simulated when its action arrives through the suggestions', async () => {
getGameLeverStats.mockResolvedValue(stats());
render(<GameHintsPanel study={STUDY} />);
await screen.findByText(/disco_LINE_A/);
// No double-click here — the recommender simulated it and App published it.
act(() => publishSimulated(['disco_LINE_A']));
await waitFor(() => expect(
screen.getByTestId('game-lever-status-action:disco_LINE_A')).toHaveTextContent(/simulated/));
});
it('blocks a second simulation once a lever is already simulated', async () => {
const lever = vi.spyOn(gameBridge, 'requestLeverInteraction');
getGameLeverStats.mockResolvedValue(stats());
render(<GameHintsPanel study={STUDY} />);
await screen.findByText(/disco_LINE_A/);
act(() => publishSimulated(['disco_LINE_A']));
await waitFor(() => expect(
screen.getByTestId('game-lever-status-action:disco_LINE_A')).toHaveTextContent(/simulated/));
fireEvent.doubleClick(screen.getByText(/disco_LINE_A/));
expect(lever).not.toHaveBeenCalled();
});
it('ignores a second double-click while a simulation is still in flight', async () => {
const d = deferred();
const lever = vi.spyOn(gameBridge, 'requestLeverInteraction').mockReturnValue(d.promise);
getGameLeverStats.mockResolvedValue(stats());
render(<GameHintsPanel study={STUDY} />);
await screen.findByText(/disco_LINE_A/);
const target = screen.getByText(/disco_LINE_A/);
fireEvent.doubleClick(target);
await screen.findByTestId('game-lever-status-action:disco_LINE_A');
fireEvent.doubleClick(target); // still simulating → ignored
expect(lever).toHaveBeenCalledTimes(1);
await act(async () => { d.resolve(); await d.promise; });
});
it('collapses to a pill and reopens', async () => {
getGameLeverStats.mockResolvedValue(stats());
render(<GameHintsPanel study={STUDY} />);
await waitFor(() => expect(screen.getByText(/Most-used levers here/)).toBeInTheDocument());
fireEvent.click(screen.getByTitle('Collapse hints'));
expect(screen.queryByText(/Most-used levers here/)).toBeNull();
fireEvent.click(screen.getByText(/💡 Hints/));
expect(screen.getByText(/Most-used levers here/)).toBeInTheDocument();
});
it('renders nothing when the base has no data for the contingency', async () => {
getGameLeverStats.mockResolvedValue(stats({ total_retentions: 0, levers: [] }));
const { container } = render(<GameHintsPanel study={STUDY} />);
await waitFor(() => expect(getGameLeverStats).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it('renders nothing when the stats fetch fails', async () => {
getGameLeverStats.mockRejectedValue(new Error('backend down'));
const { container } = render(<GameHintsPanel study={STUDY} />);
await waitFor(() => expect(getGameLeverStats).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});
|