Files
chess-project/src/contexts/__tests__/OpeningTrainingContext.test.tsx
T
Claude 47c37487b3 refactor: code review improvements
- Add 30s timeout to Stockfish evaluation to prevent hanging promises
- Add FEN validation and depth cap (max 30) to Stockfish API route
- Create ErrorBoundary component with specialized fallbacks for chess game and tutor
- Extract useChessSounds hook for better audio management
- Add React.memo to EvaluationBar and CapturedPieces for performance
- Add useMemo to CapturedPieces for sorted pieces calculation
- Improve tacticDetection to return empty array instead of "none" type
- Add filterMeaningfulTactics and hasTactics helper functions
- Translate hardcoded UI strings (stockfishLevel, download, evalChange)
- Update translations for EN, DE, FR, IT, PL
- Add uuid to Jest transformIgnorePatterns for ESM compatibility
- Update tests to use new filterMeaningfulTactics function
2025-12-10 21:30:28 +00:00

691 lines
21 KiB
TypeScript

/**
* 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<string, StockfishEvaluation> = new Map();
private evaluationDelay: number = 0;
async evaluate(fen: string, depth: number = 15): Promise<StockfishEvaluation> {
// 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<string> {
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 }) => (
<OpeningTrainingProvider>{children}</OpeningTrainingProvider>
);
// ============================================================================
// 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 });
});
});
});