// 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, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; import GameConfigScreen from './GameConfigScreen'; const mockApi = vi.hoisted(() => ({ getPlayerSessions: vi.fn() })); vi.mock('../api', () => ({ api: mockApi })); beforeEach(() => { mockApi.getPlayerSessions.mockResolvedValue({ player: 'amarot', session_count: 2, session_names: ['amarot — session 1', 'amarot — session 2'], }); }); afterEach(() => { cleanup(); vi.clearAllMocks(); }); const sessionInput = () => screen.getByTestId('game-session-name') as HTMLInputElement; /** France THT / Matpower live behind Configure settings' mode toggle. */ const openModePicker = () => { fireEvent.click(screen.getByTestId('game-settings-toggle')); fireEvent.click(screen.getByTestId('game-mode-picker-toggle')); }; describe('GameConfigScreen landing', () => { it('keeps Start disabled until a player name is entered', () => { render(); expect(screen.getByTestId('game-start')).toBeDisabled(); }); it('auto-fills the session name from the player + next session index', async () => { render(); fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); await waitFor(() => expect(sessionInput().value).toBe('amarot — session 3')); expect(mockApi.getPlayerSessions).toHaveBeenCalledWith('amarot'); }); it('falls back to session 1 when the backend is unreachable', async () => { mockApi.getPlayerSessions.mockRejectedValue(new Error('offline')); render(); fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'zoe' } }); await waitFor(() => expect(sessionInput().value).toBe('zoe — session 1')); }); it('does not overwrite a session name the user typed', async () => { render(); fireEvent.change(sessionInput(), { target: { value: 'My custom run' } }); fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); await new Promise((r) => setTimeout(r, 400)); expect(sessionInput().value).toBe('My custom run'); // The names are still fetched (they drive the duplicate block), but the // typed name is preserved. expect(mockApi.getPlayerSessions).toHaveBeenCalledWith('amarot'); }); it('auto-suggests the first FREE index, skipping gaps in the recorded names', async () => { // Recorded {1, 3} → the count-plus-one heuristic would collide on 3; the // names-based suggestion fills the gap and picks 2. mockApi.getPlayerSessions.mockResolvedValue({ player: 'amarot', session_count: 2, session_names: ['amarot — session 1', 'amarot — session 3'], }); render(); fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); await waitFor(() => expect(sessionInput().value).toBe('amarot — session 2')); }); it('suggests the next index after finishing a session (no collision)', async () => { // Sessions 1-3 already recorded → the next free index is 4, never a // re-suggested 3 (the reported bug). mockApi.getPlayerSessions.mockResolvedValue({ player: 'amarot', session_count: 3, session_names: ['amarot — session 1', 'amarot — session 2', 'amarot — session 3'], }); render(); fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); await waitFor(() => expect(sessionInput().value).toBe('amarot — session 4')); }); it('blocks Start and shows an error when the name collides with an existing session', async () => { const onStart = vi.fn(); render(); fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); await waitFor(() => expect(sessionInput().value).toBe('amarot — session 3')); // Type a name that already exists (case-insensitive). fireEvent.change(sessionInput(), { target: { value: 'Amarot — Session 1' } }); await waitFor(() => expect(screen.getByTestId('game-session-name-error')).toBeInTheDocument()); expect(screen.getByTestId('game-start')).toBeDisabled(); fireEvent.click(screen.getByTestId('game-start')); expect(onStart).not.toHaveBeenCalled(); }); it('re-enables Start once the colliding name is changed to a free one', async () => { render(); fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); await waitFor(() => expect(sessionInput().value).toBe('amarot — session 3')); fireEvent.change(sessionInput(), { target: { value: 'amarot — session 2' } }); await waitFor(() => expect(screen.getByTestId('game-start')).toBeDisabled()); fireEvent.change(sessionInput(), { target: { value: 'amarot — session 9' } }); expect(screen.queryByTestId('game-session-name-error')).toBeNull(); expect(screen.getByTestId('game-start')).not.toBeDisabled(); }); it('lists the configured studies and shows the network preview', () => { render(); expect(screen.getByTestId('game-studies-summary').querySelectorAll('li').length) .toBeGreaterThan(0); expect(screen.getByTestId('game-network-preview')).toBeInTheDocument(); }); it('hides settings by default and reveals timer / mode toggle / studies on toggle', () => { render(); expect(screen.queryByText(/Time limit per study/)).not.toBeInTheDocument(); fireEvent.click(screen.getByTestId('game-settings-toggle')); expect(screen.getByText(/Time limit per study/)).toBeInTheDocument(); expect(screen.getByTestId('game-mode-picker-toggle')).toBeInTheDocument(); expect(screen.getByText(/Studies \(/)).toBeInTheDocument(); }); it('keeps other modes and the network-difficulty picker hidden until the mode toggle is used', () => { render(); fireEvent.click(screen.getByTestId('game-settings-toggle')); expect(screen.queryByTestId('game-mode-tht')).not.toBeInTheDocument(); expect(screen.queryByTestId('game-mode-matpower')).not.toBeInTheDocument(); expect(screen.queryByText(/Difficulty \(network\)/)).not.toBeInTheDocument(); fireEvent.click(screen.getByTestId('game-mode-picker-toggle')); expect(screen.getByTestId('game-mode-demo')).toBeInTheDocument(); expect(screen.getByTestId('game-mode-tht')).toBeInTheDocument(); expect(screen.getByTestId('game-mode-matpower')).toBeInTheDocument(); // Demo is still the active mode, so its network-difficulty select shows too. expect(screen.getByText(/Difficulty \(network\)/)).toBeInTheDocument(); }); it('starts a session with the entered config', async () => { const onStart = vi.fn(); render(); fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); await waitFor(() => expect(sessionInput().value).toBe('amarot — session 3')); fireEvent.click(screen.getByTestId('game-start')); expect(onStart).toHaveBeenCalledTimes(1); const cfg = onStart.mock.calls[0][0]; expect(cfg.player).toBe('amarot'); expect(cfg.sessionName).toBe('amarot — session 3'); expect(cfg.assistance).toBe(true); expect(cfg.timerSeconds).toBe(300); expect(cfg.studies.length).toBeGreaterThan(0); }); }); describe('GameConfigScreen — France THT mode', () => { it('reveals the difficulty + case-count pickers and hides the demo studies/preview', () => { render(); // Demo is the default: its studies summary + network preview are shown. expect(screen.getByTestId('game-studies-summary')).toBeInTheDocument(); openModePicker(); fireEvent.click(screen.getByTestId('game-mode-tht')); expect(screen.getByTestId('game-tht-difficulty')).toBeInTheDocument(); expect(screen.getByTestId('game-tht-count')).toBeInTheDocument(); expect(screen.getByTestId('game-tht-summary')).toBeInTheDocument(); // France THT shows its own network map (the shared RTE7000 backbone). expect(screen.getByTestId('game-tht-preview')).toBeInTheDocument(); // The demo studies list + per-network preview belong to demo mode only. expect(screen.queryByTestId('game-studies-summary')).not.toBeInTheDocument(); expect(screen.queryByTestId('game-network-preview')).not.toBeInTheDocument(); }); it('starts a France THT session by sampling the chosen number of cases', async () => { const onStart = vi.fn(); render(); fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); await waitFor(() => expect(sessionInput().value).toBe('amarot — session 3')); openModePicker(); fireEvent.click(screen.getByTestId('game-mode-tht')); fireEvent.change(screen.getByTestId('game-tht-count'), { target: { value: '3' } }); fireEvent.click(screen.getByTestId('game-start')); expect(onStart).toHaveBeenCalledTimes(1); const cfg = onStart.mock.calls[0][0]; expect(cfg.studies).toHaveLength(3); for (const s of cfg.studies) { expect(s.networkPath).toContain('data/rte7000_tht/grids/'); expect(s.contingencyElementId).toBeTruthy(); } }); it('caps the number of cases at the pool size', () => { render(); openModePicker(); fireEvent.click(screen.getByTestId('game-mode-tht')); const count = screen.getByTestId('game-tht-count') as HTMLInputElement; fireEvent.change(count, { target: { value: '999999' } }); // Clamped to the difficulty's available cases (a positive, finite pool). expect(Number(count.value)).toBeGreaterThan(0); expect(Number(count.value)).toBeLessThan(999999); }); it('still requires a player name to start in THT mode', () => { render(); openModePicker(); fireEvent.click(screen.getByTestId('game-mode-tht')); expect(screen.getByTestId('game-start')).toBeDisabled(); }); }); describe('GameConfigScreen — France EHV (Matpower) mode', () => { it('is a third top-level mode, exclusive with demo and France THT', () => { render(); openModePicker(); fireEvent.click(screen.getByTestId('game-mode-matpower')); expect(screen.getByTestId('game-matpower-difficulty')).toBeInTheDocument(); expect(screen.getByTestId('game-matpower-count')).toBeInTheDocument(); expect(screen.getByTestId('game-matpower-summary')).toBeInTheDocument(); expect(screen.getByTestId('game-matpower-preview')).toBeInTheDocument(); // The other two modes' panels are gone. expect(screen.queryByTestId('game-tht-summary')).not.toBeInTheDocument(); expect(screen.queryByTestId('game-studies-summary')).not.toBeInTheDocument(); // ...and switching back to France THT restores its own panel. fireEvent.click(screen.getByTestId('game-mode-tht')); expect(screen.getByTestId('game-tht-summary')).toBeInTheDocument(); expect(screen.queryByTestId('game-matpower-summary')).not.toBeInTheDocument(); }); it('samples its studies from the Matpower grids, not the THT ones', async () => { const onStart = vi.fn(); render(); fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); await waitFor(() => expect(sessionInput().value).toBe('amarot — session 3')); openModePicker(); fireEvent.click(screen.getByTestId('game-mode-matpower')); fireEvent.change(screen.getByTestId('game-matpower-count'), { target: { value: '1' } }); fireEvent.click(screen.getByTestId('game-start')); expect(onStart).toHaveBeenCalledTimes(1); const cfg = onStart.mock.calls[0][0]; expect(cfg.studies).toHaveLength(1); for (const s of cfg.studies) { expect(s.networkPath).toContain('data/rte_matpower/grids/'); expect(s.contingencyElementId).toBeTruthy(); } }); it('still requires a player name to start', () => { render(); openModePicker(); fireEvent.click(screen.getByTestId('game-mode-matpower')); expect(screen.getByTestId('game-start')).toBeDisabled(); }); });