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();
  });
});