Spaces:
Sleeping
Sleeping
| // 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, renderHook, waitFor } from '@testing-library/react'; | |
| import { gameBridge } from './gameBridge'; | |
| import { useGameSession } from './useGameSession'; | |
| import type { GameSessionConfig, GameStudy } from './types'; | |
| function study(id: string): GameStudy { | |
| return { | |
| id, | |
| label: `Study ${id}`, | |
| networkPath: 'net.xiidm', | |
| actionFilePath: 'actions.json', | |
| contingencyElementId: `ctg_${id}`, | |
| contingencyLabel: `Contingency ${id}`, | |
| }; | |
| } | |
| const CONFIG: GameSessionConfig = { | |
| sessionName: 'Test session', | |
| player: 'tester', | |
| // Large timer so the per-second countdown never auto-advances mid-test. | |
| timerSeconds: 600, | |
| maxActions: 3, | |
| studies: [study('s1'), study('s2'), study('s3')], | |
| }; | |
| describe('useGameSession', () => { | |
| beforeEach(() => { | |
| // App is not mounted in this unit test, so no loader is registered with | |
| // the bridge — stub the load so each study reaches the "playing" phase. | |
| vi.spyOn(gameBridge, 'loadStudy').mockResolvedValue(undefined); | |
| gameBridge.reset(); | |
| }); | |
| afterEach(() => { | |
| vi.restoreAllMocks(); | |
| gameBridge.reset(); | |
| }); | |
| /** Publish the player's chosen actions for the current study, then commit. */ | |
| async function playAndAdvance( | |
| result: { current: ReturnType<typeof useGameSession> }, | |
| rho: number, | |
| ) { | |
| act(() => { | |
| gameBridge.publishSnapshot({ | |
| contingencyElementIds: [], | |
| baselineMaxRho: 1.3, | |
| chosenActions: [{ actionId: 'a', maxRho: rho, solved: rho < 1.0 }], | |
| }); | |
| }); | |
| await act(async () => { | |
| result.current.advance(); | |
| }); | |
| } | |
| it('records EVERY study — including the last one — in the final log', async () => { | |
| const { result } = renderHook(() => useGameSession()); | |
| await act(async () => { | |
| result.current.startSession(CONFIG); | |
| }); | |
| await waitFor(() => expect(result.current.phase).toBe('playing')); | |
| expect(result.current.currentIndex).toBe(0); | |
| // Study 1 (solved) → advance to study 2. | |
| await playAndAdvance(result, 0.9); | |
| await waitFor(() => expect(result.current.currentIndex).toBe(1)); | |
| await waitFor(() => expect(result.current.phase).toBe('playing')); | |
| // Study 2 (unsolved) → advance to study 3. | |
| await playAndAdvance(result, 1.1); | |
| await waitFor(() => expect(result.current.currentIndex).toBe(2)); | |
| await waitFor(() => expect(result.current.phase).toBe('playing')); | |
| // Study 3 is the LAST one: committing it ends the session and builds the | |
| // log on the next synchronous line. This is exactly the case the | |
| // resultsRef timing bug used to drop. | |
| await playAndAdvance(result, 0.7); | |
| await waitFor(() => expect(result.current.phase).toBe('results')); | |
| const log = result.current.sessionLog!; | |
| expect(log).not.toBeNull(); | |
| expect(log.studies).toHaveLength(3); | |
| expect(log.studies.map((s) => s.studyId)).toEqual(['s1', 's2', 's3']); | |
| // The last study's recorded outcome must be present and correct. | |
| const last = log.studies[2]; | |
| expect(last.studyId).toBe('s3'); | |
| expect(last.finalMaxRho).toBeCloseTo(0.7); | |
| expect(last.solved).toBe(true); | |
| // And the per-study solved flags survive for all three. | |
| expect(log.studies.map((s) => s.solved)).toEqual([true, false, true]); | |
| expect(log.config.nStudies).toBe(3); | |
| }); | |
| // QW24: mid-session load failure must be recoverable, and must not destroy | |
| // the results already committed. | |
| it('retryStudy re-attempts a study that failed to load', async () => { | |
| const loadSpy = vi.spyOn(gameBridge, 'loadStudy') | |
| .mockRejectedValueOnce(new Error('backend down')) | |
| .mockResolvedValue(undefined); | |
| const { result } = renderHook(() => useGameSession()); | |
| await act(async () => { result.current.startSession(CONFIG); }); | |
| // First load failed → stuck on the loading screen with an error. | |
| expect(result.current.phase).toBe('loading'); | |
| expect(result.current.loadError).toBe('backend down'); | |
| await act(async () => { result.current.retryStudy(); }); | |
| expect(result.current.phase).toBe('playing'); | |
| expect(result.current.loadError).toBeNull(); | |
| expect(loadSpy).toHaveBeenCalledTimes(2); | |
| }); | |
| it('finishEarly ends the session with the already-committed results (no data loss)', async () => { | |
| vi.spyOn(gameBridge, 'loadStudy') | |
| .mockResolvedValueOnce(undefined) // study 1 loads | |
| .mockRejectedValue(new Error('backend down')); // study 2 fails | |
| const { result } = renderHook(() => useGameSession()); | |
| await act(async () => { result.current.startSession(CONFIG); }); | |
| await playAndAdvance(result, 0.8); // commit study 1, then study 2's load fails | |
| expect(result.current.phase).toBe('loading'); | |
| expect(result.current.loadError).toBe('backend down'); | |
| expect(result.current.results).toHaveLength(1); | |
| await act(async () => { result.current.finishEarly(); }); | |
| expect(result.current.phase).toBe('results'); | |
| const log = result.current.sessionLog; | |
| expect(log).not.toBeNull(); | |
| expect(log!.studies).toHaveLength(1); | |
| expect(log!.studies[0].studyId).toBe('s1'); | |
| }); | |
| }); | |