Spaces:
Paused
Paused
| // 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(<GameConfigScreen onStart={vi.fn()} />); | |
| expect(screen.getByTestId('game-start')).toBeDisabled(); | |
| }); | |
| it('auto-fills the session name from the player + next session index', async () => { | |
| render(<GameConfigScreen onStart={vi.fn()} />); | |
| 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(<GameConfigScreen onStart={vi.fn()} />); | |
| 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(<GameConfigScreen onStart={vi.fn()} />); | |
| 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(<GameConfigScreen onStart={vi.fn()} />); | |
| 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(<GameConfigScreen onStart={vi.fn()} />); | |
| 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(<GameConfigScreen onStart={onStart} />); | |
| 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(<GameConfigScreen onStart={vi.fn()} />); | |
| 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(<GameConfigScreen onStart={vi.fn()} />); | |
| 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(<GameConfigScreen onStart={vi.fn()} />); | |
| 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(<GameConfigScreen onStart={vi.fn()} />); | |
| 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(<GameConfigScreen onStart={onStart} />); | |
| 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(<GameConfigScreen onStart={vi.fn()} />); | |
| // 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(<GameConfigScreen onStart={onStart} />); | |
| 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(<GameConfigScreen onStart={vi.fn()} />); | |
| 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(<GameConfigScreen onStart={vi.fn()} />); | |
| 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(<GameConfigScreen onStart={vi.fn()} />); | |
| 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(<GameConfigScreen onStart={onStart} />); | |
| 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(<GameConfigScreen onStart={vi.fn()} />); | |
| openModePicker(); | |
| fireEvent.click(screen.getByTestId('game-mode-matpower')); | |
| expect(screen.getByTestId('game-start')).toBeDisabled(); | |
| }); | |
| }); | |