/** * Integration Tests for OpeningTrainingContext * * These tests verify the full integration of: * - React Context (state management) * - SessionReducer (state machine) * - SessionOrchestrator (async operations) * - ChessEngine (Stockfish evaluation) * * This is Phase 5 of the refactoring plan - comprehensive testing. */ // Mock uuid before any imports jest.mock('uuid', () => ({ v4: jest.fn(() => 'test-uuid-1234'), })); import React from 'react'; import { renderHook, act, waitFor } from '@testing-library/react'; import { OpeningTrainingProvider, useOpeningTraining } from '../OpeningTrainingContext'; import { ChessEngine } from '@/lib/engine'; import { StockfishEvaluation } from '@/lib/stockfish'; import { OpeningMetadata } from '@/lib/openings'; // ============================================================================ // Mock Stockfish Engine // ============================================================================ class MockStockfish implements ChessEngine { private mockEvaluations: Map = new Map(); private evaluationDelay: number = 0; async evaluate(fen: string, depth: number = 15): Promise { // Simulate async delay if (this.evaluationDelay > 0) { await new Promise(resolve => setTimeout(resolve, this.evaluationDelay)); } // Return mocked evaluation if available if (this.mockEvaluations.has(fen)) { return this.mockEvaluations.get(fen)!; } // Default evaluation return { score: 25, mate: null, depth, bestMove: 'e4', ponder: null, }; } async findBestMove(fen: string, depth: number = 15): Promise { const eval_ = await this.evaluate(fen, depth); return eval_.bestMove; } terminate(): void { // Mock - do nothing } // Test helpers setMockEvaluation(fen: string, evaluation: StockfishEvaluation): void { this.mockEvaluations.set(fen, evaluation); } setEvaluationDelay(ms: number): void { this.evaluationDelay = ms; } clearMocks(): void { this.mockEvaluations.clear(); this.evaluationDelay = 0; } } // ============================================================================ // Mock Engine Factory // ============================================================================ let mockStockfishInstance: MockStockfish; jest.mock('@/lib/engine', () => ({ createEngine: jest.fn(async () => { mockStockfishInstance = new MockStockfish(); return mockStockfishInstance; }), })); // ============================================================================ // Test Setup // ============================================================================ const mockOpening: OpeningMetadata = { eco: 'C00', name: 'French Defense', moves: '1. e4 e6 2. d4 d5', wikipediaSlug: 'French_Defence', }; const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); // ============================================================================ // Tests // ============================================================================ describe('OpeningTrainingContext Integration Tests', () => { beforeEach(() => { // Clear localStorage before each test localStorage.clear(); // Reset mocks if (mockStockfishInstance) { mockStockfishInstance.clearMocks(); } }); // ========================================================================== // Context Initialization // ========================================================================== describe('Context Initialization', () => { test('initializes with null session', () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); expect(result.current.session).toBeNull(); expect(result.current.opening).toBeNull(); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBeNull(); }); test('stockfish engine is created on mount', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); }); }); // ========================================================================== // Session Creation // ========================================================================== describe('Session Creation', () => { test('creates new session successfully', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); // Wait for stockfish to be ready await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); // Initialize session await act(async () => { await result.current.initializeSession(mockOpening, true); }); // Verify session was created await waitFor(() => { expect(result.current.session).not.toBeNull(); expect(result.current.session?.phase).toBe('user_turn'); expect(result.current.opening?.eco).toBe('C00'); }); }); test('session has initial evaluation', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); await act(async () => { await result.current.initializeSession(mockOpening, true); }); await waitFor(() => { expect(result.current.session?.initialEvaluation).toBeDefined(); expect(typeof result.current.session?.initialEvaluation).toBe('number'); }); }); test('shows loading state during initialization', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); // Set a delay to catch loading state mockStockfishInstance.setEvaluationDelay(100); let loadingWasTrue = false; act(() => { result.current.initializeSession(mockOpening, true); }); // Check if loading becomes true await waitFor(() => { if (result.current.isLoading) { loadingWasTrue = true; } return !result.current.isLoading; }, { timeout: 3000 }); expect(loadingWasTrue).toBe(true); }); }); // ========================================================================== // User Move Processing // ========================================================================== describe('User Move Processing', () => { test('processes legal user move', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); await act(async () => { await result.current.initializeSession(mockOpening, true); }); await waitFor(() => { expect(result.current.session?.phase).toBe('user_turn'); }); // Make a move await act(async () => { await result.current.makeMove('e4'); }); // Verify move was added await waitFor(() => { expect(result.current.session?.moveHistory.length).toBe(1); expect(result.current.session?.moveHistory[0].san).toBe('e4'); }); }); test('classifies in-theory move correctly', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); await act(async () => { await result.current.initializeSession(mockOpening, true); }); await waitFor(() => { expect(result.current.session?.phase).toBe('user_turn'); }); await act(async () => { await result.current.makeMove('e4'); }); await waitFor(() => { const lastMove = result.current.session?.moveHistory[0]; expect(lastMove?.classification.category).toBe('in-theory'); expect(lastMove?.classification.inRepertoire).toBe(true); }); }); test('provides move feedback', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); await act(async () => { await result.current.initializeSession(mockOpening, true); }); await waitFor(() => { expect(result.current.session?.phase).toBe('user_turn'); }); await act(async () => { await result.current.makeMove('e4'); }); await waitFor(() => { expect(result.current.currentFeedback).not.toBeNull(); expect(result.current.currentFeedback?.move.san).toBe('e4'); }); }); test('handles off-book move', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); await act(async () => { await result.current.initializeSession(mockOpening, true); }); await waitFor(() => { expect(result.current.session?.phase).toBe('user_turn'); }); // Make an off-book move await act(async () => { await result.current.makeMove('d4'); }); await waitFor(() => { const lastMove = result.current.session?.moveHistory[0]; expect(lastMove?.classification.inRepertoire).toBe(false); expect(['playable', 'weak']).toContain(lastMove?.classification.category); }); }); }); // ========================================================================== // Opponent Move Processing // ========================================================================== describe('Opponent Move Processing', () => { test('triggers automatic opponent move after user move', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); await act(async () => { await result.current.initializeSession(mockOpening, true); }); await waitFor(() => { expect(result.current.session?.phase).toBe('user_turn'); }); // Make user move await act(async () => { await result.current.makeMove('e4'); }); // Wait for opponent's automatic move await waitFor(() => { expect(result.current.session?.moveHistory.length).toBe(2); expect(result.current.session?.moveHistory[1].san).toBe('e6'); expect(result.current.session?.moveHistory[1].color).toBe('black'); }, { timeout: 3000 }); }); test('opponent moves are classified as in-theory', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); await act(async () => { await result.current.initializeSession(mockOpening, true); }); await waitFor(() => { expect(result.current.session?.phase).toBe('user_turn'); }); await act(async () => { await result.current.makeMove('e4'); }); await waitFor(() => { const opponentMove = result.current.session?.moveHistory[1]; expect(opponentMove?.classification.category).toBe('in-theory'); expect(opponentMove?.classification.inRepertoire).toBe(true); }, { timeout: 3000 }); }); }); // ========================================================================== // Move Navigation // ========================================================================== describe('Move Navigation', () => { test('navigates to specific move index', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); await act(async () => { await result.current.initializeSession(mockOpening, true); }); await waitFor(() => { expect(result.current.session?.phase).toBe('user_turn'); }); // Make two moves await act(async () => { await result.current.makeMove('e4'); }); await waitFor(() => { expect(result.current.session?.moveHistory.length).toBe(2); }, { timeout: 3000 }); await act(async () => { await result.current.makeMove('d4'); }); await waitFor(() => { expect(result.current.session?.moveHistory.length).toBeGreaterThanOrEqual(3); }, { timeout: 3000 }); // Navigate back to move 1 act(() => { result.current.navigateToMove(0); }); expect(result.current.session?.currentMoveIndex).toBe(0); }); test('updates feedback when navigating', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); await act(async () => { await result.current.initializeSession(mockOpening, true); }); await waitFor(() => { expect(result.current.session?.phase).toBe('user_turn'); }); await act(async () => { await result.current.makeMove('e4'); }); await waitFor(() => { expect(result.current.session?.moveHistory.length).toBe(2); }, { timeout: 3000 }); // Navigate to first move (index 1 = after e4, index 0 = start) await act(async () => { result.current.navigateToMove(1); }); // Wait for feedback to update await waitFor(() => { expect(result.current.currentFeedback?.move.san).toBe('e4'); }); }); }); // ========================================================================== // Session Persistence // ========================================================================== describe('Session Persistence', () => { test('saves session to localStorage', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); await act(async () => { await result.current.initializeSession(mockOpening, true); }); await waitFor(() => { expect(result.current.session?.phase).toBe('user_turn'); }); await act(async () => { await result.current.makeMove('e4'); }); // Wait for save to occur await waitFor(() => { const saved = localStorage.getItem('openingTraining_session_C00'); expect(saved).not.toBeNull(); }, { timeout: 3000 }); }); test('loads existing session', async () => { // Pre-populate localStorage with a session const existingSession = { sessionId: 'test-session-123', openingId: 'C00', openingName: 'French Defense', status: 'active', currentFEN: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1', currentMoveIndex: 1, moveHistory: [ { moveNumber: 1, color: 'white', san: 'e4', uci: 'e2e4', fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1', evaluation: { score: 25, mate: null, depth: 15, bestMove: 'e6', ponder: null }, classification: { category: 'in-theory', inRepertoire: true, evaluationChange: 0, isSignificantSwing: false, theoreticalAlternatives: [], }, timestamp: Date.now(), }, ], deviationMoveIndex: null, initialEvaluation: 25, startedAt: Date.now(), lastUpdatedAt: Date.now(), }; localStorage.setItem('openingTraining_session_C00', JSON.stringify(existingSession)); // Verify it's in localStorage const stored = localStorage.getItem('openingTraining_session_C00'); expect(stored).not.toBeNull(); const parsed = JSON.parse(stored!); expect(parsed.moveHistory.length).toBe(1); const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); // Load without forcing new await act(async () => { await result.current.initializeSession(mockOpening, false); }); // Wait for session to be loaded await waitFor(() => { expect(result.current.session).not.toBeNull(); expect(result.current.session?.moveHistory).toBeDefined(); expect(result.current.session?.moveHistory.length).toBe(1); }, { timeout: 3000 }); // Verify the loaded move expect(result.current.session?.moveHistory[0].san).toBe('e4'); }); }); // ========================================================================== // Session Reset // ========================================================================== describe('Session Reset', () => { test('resets session to initial state', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); await act(async () => { await result.current.initializeSession(mockOpening, true); }); await waitFor(() => { expect(result.current.session?.phase).toBe('user_turn'); }); await act(async () => { await result.current.makeMove('e4'); }); await waitFor(() => { expect(result.current.session?.moveHistory.length).toBeGreaterThan(0); }); // Reset session - this clears opening and feedback but creates a new initial session state act(() => { result.current.resetSession(); }); // After reset: session is cleared, UI state is reset expect(result.current.session).toBeNull(); expect(result.current.opening).toBeNull(); expect(result.current.currentFeedback).toBeNull(); }); }); // ========================================================================== // Error Handling // ========================================================================== describe('Error Handling', () => { test('handles initialization error gracefully', async () => { // Mock engine to throw error const { createEngine } = require('@/lib/engine'); createEngine.mockRejectedValueOnce(new Error('Engine initialization failed')); const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.error).toBe('Failed to initialize chess engine'); }, { timeout: 3000 }); }); test('handles move error when session not initialized', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); // Try to make move without initializing session await act(async () => { await result.current.makeMove('e4'); }); expect(result.current.error).toBe('Session not initialized'); }); }); // ========================================================================== // Deviation Tracking // ========================================================================== describe('Deviation Tracking', () => { test('tracks deviation from repertoire', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); await act(async () => { await result.current.initializeSession(mockOpening, true); }); await waitFor(() => { expect(result.current.session?.phase).toBe('user_turn'); }); // Make off-book move await act(async () => { await result.current.makeMove('d4'); }); await waitFor(() => { expect(result.current.session?.deviationMoveIndex).toBe(0); }); }); test('deviationMoveIndex stays null for in-theory moves', async () => { const { result } = renderHook(() => useOpeningTraining(), { wrapper }); await waitFor(() => { expect(result.current.stockfish).not.toBeNull(); }, { timeout: 3000 }); await act(async () => { await result.current.initializeSession(mockOpening, true); }); await waitFor(() => { expect(result.current.session?.phase).toBe('user_turn'); }); // Make in-theory move await act(async () => { await result.current.makeMove('e4'); }); await waitFor(() => { expect(result.current.session?.moveHistory.length).toBe(2); expect(result.current.session?.deviationMoveIndex).toBeNull(); }, { timeout: 3000 }); }); }); });