diff --git a/.vscode/settings.json b/.vscode/settings.json index a62c7237..1f0128a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,7 @@ "editor.defaultFormatter": "esbenp.prettier-vscode", "[dockerfile]": { "editor.defaultFormatter": "ms-azuretools.vscode-docker" - } + }, + "editor.tabCompletion": "on", + "github.copilot.nextEditSuggestions.enabled": true } diff --git a/__tests__/analysis/makeMove-fen.test.ts b/__tests__/analysis/makeMove-fen.test.ts deleted file mode 100644 index 5643bb7a..00000000 --- a/__tests__/analysis/makeMove-fen.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { GameTree, GameNode } from 'src/types/base/tree' -import { Chess, PieceSymbol } from 'chess.ts' - -describe('Analysis Page makeMove Logic for FEN Positions', () => { - // Simulate the makeMove function logic from the analysis page - const simulateMakeMove = ( - gameTree: GameTree, - currentNode: GameNode, - move: string, - currentMaiaModel?: string, - ) => { - const chess = new Chess(currentNode.fen) - const moveAttempt = chess.move({ - from: move.slice(0, 2), - to: move.slice(2, 4), - promotion: move[4] ? (move[4] as PieceSymbol) : undefined, - }) - - if (moveAttempt) { - const newFen = chess.fen() - const moveString = - moveAttempt.from + - moveAttempt.to + - (moveAttempt.promotion ? moveAttempt.promotion : '') - const san = moveAttempt.san - - // This is the current logic from the analysis page that we need to fix - if (currentNode.mainChild?.move === moveString) { - return { type: 'navigate', node: currentNode.mainChild } - } else { - // ISSUE: Always creates variation, never main line for first move - const newVariation = gameTree.addVariation( - currentNode, - newFen, - moveString, - san, - currentMaiaModel, - ) - return { type: 'variation', node: newVariation } - } - } - return null - } - - // Fixed version of makeMove logic - const simulateFixedMakeMove = ( - gameTree: GameTree, - currentNode: GameNode, - move: string, - currentMaiaModel?: string, - ) => { - const chess = new Chess(currentNode.fen) - const moveAttempt = chess.move({ - from: move.slice(0, 2), - to: move.slice(2, 4), - promotion: move[4] ? (move[4] as PieceSymbol) : undefined, - }) - - if (moveAttempt) { - const newFen = chess.fen() - const moveString = - moveAttempt.from + - moveAttempt.to + - (moveAttempt.promotion ? moveAttempt.promotion : '') - const san = moveAttempt.san - - if (currentNode.mainChild?.move === moveString) { - // Existing main line move - navigate to it - return { type: 'navigate', node: currentNode.mainChild } - } else if (!currentNode.mainChild) { - // No main child exists - create main line move (FIX) - const newMainMove = gameTree.addMainMove( - currentNode, - newFen, - moveString, - san, - currentMaiaModel, - ) - return { type: 'main', node: newMainMove } - } else { - // Main child exists but different move - create variation - const newVariation = gameTree.addVariation( - currentNode, - newFen, - moveString, - san, - currentMaiaModel, - ) - return { type: 'variation', node: newVariation } - } - } - return null - } - - describe('Current behavior (broken)', () => { - it('incorrectly creates variations for first move from FEN position', () => { - const customFen = - 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4' - const tree = new GameTree(customFen) - const rootNode = tree.getRoot() - - // Simulate making the first move from FEN position - const result = simulateMakeMove(tree, rootNode, 'f3g5') - - // ISSUE: First move incorrectly creates a variation instead of main line - expect(result?.type).toBe('variation') - expect(rootNode.mainChild).toBeNull() // No main line created - expect(rootNode.children.length).toBe(1) - expect(rootNode.getVariations().length).toBe(1) // Created as variation - - // The main line should only contain the root - const mainLine = tree.getMainLine() - expect(mainLine.length).toBe(1) // Only root, no main line progression - }) - - it('shows the problem when making multiple moves from FEN', () => { - const customFen = - 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4' - const tree = new GameTree(customFen) - const rootNode = tree.getRoot() - - // Make first move - const result1 = simulateMakeMove(tree, rootNode, 'f3g5') - expect(result1?.type).toBe('variation') - - // Make second move from same position - const result2 = simulateMakeMove(tree, rootNode, 'f3e5') - expect(result2?.type).toBe('variation') - - // Both moves are variations, no main line established - expect(rootNode.mainChild).toBeNull() - expect(rootNode.getVariations().length).toBe(2) - expect(tree.getMainLine().length).toBe(1) // Still just root - }) - }) - - describe('Fixed behavior', () => { - it('correctly creates main line for first move from FEN position', () => { - const customFen = - 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4' - const tree = new GameTree(customFen) - const rootNode = tree.getRoot() - - // Simulate making the first move from FEN position with fix - const result = simulateFixedMakeMove(tree, rootNode, 'f3g5') - - // FIXED: First move creates main line - expect(result?.type).toBe('main') - expect(rootNode.mainChild).toBeTruthy() // Main line created - expect(rootNode.mainChild?.isMainline).toBe(true) - expect(rootNode.getVariations().length).toBe(0) // No variations yet - - // The main line should now contain root + first move - const mainLine = tree.getMainLine() - expect(mainLine.length).toBe(2) // Root + one move - }) - - it('correctly handles subsequent moves: main line extension and variations', () => { - const customFen = - 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4' - const tree = new GameTree(customFen) - const rootNode = tree.getRoot() - - // First move - should create main line - const result1 = simulateFixedMakeMove(tree, rootNode, 'f3g5') - expect(result1?.type).toBe('main') - const firstMove = result1?.node as GameNode - - // Second move from same position - should create variation - const result2 = simulateFixedMakeMove(tree, rootNode, 'f3e5') - expect(result2?.type).toBe('variation') - - // Third move extending main line - should be main line - const result3 = simulateFixedMakeMove(tree, firstMove, 'd7d6') - expect(result3?.type).toBe('main') - - // Verify final structure - expect(rootNode.mainChild).toBeTruthy() - expect(rootNode.getVariations().length).toBe(1) // One variation - expect(tree.getMainLine().length).toBe(3) // Root + two main moves - }) - - it('correctly navigates to existing moves', () => { - const customFen = - 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4' - const tree = new GameTree(customFen) - const rootNode = tree.getRoot() - - // Create a move first - const result1 = simulateFixedMakeMove(tree, rootNode, 'f3g5') - const existingNode = result1?.node - - // Try the same move again - should navigate to existing node - const result2 = simulateFixedMakeMove(tree, rootNode, 'f3g5') - expect(result2?.type).toBe('navigate') - expect(result2?.node).toBe(existingNode) - - // Structure should remain unchanged - expect(rootNode.children.length).toBe(1) - }) - }) -}) diff --git a/__tests__/analysis/makeMove-variation-fix.test.ts b/__tests__/analysis/makeMove-variation-fix.test.ts deleted file mode 100644 index dc8cca63..00000000 --- a/__tests__/analysis/makeMove-variation-fix.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { GameTree, GameNode } from 'src/types/base/tree' -import { Chess, PieceSymbol } from 'chess.ts' - -describe('makeMove Logic - Variation Continuation Test', () => { - // Test specifically for Kevin's feedback: when we're in a variation and make a move, - // it should continue the variation, not create a new main line - - it('should continue variation when making moves from variation nodes', () => { - const initialFen = - 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1' - const gameTree = new GameTree(initialFen) - const root = gameTree.getRoot() - - // Step 1: Create main line move (e2e4) - const mainMove = gameTree.addMainMove( - root, - 'rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2', - 'e2e4', - 'e4', - ) - - // Step 2: Create a variation from root (d2d4) - const variation = gameTree.addVariation( - root, - 'rnbqkbnr/ppp1pppp/8/3p4/3P4/8/PPP1PPPP/RNBQKBNR w KQkq d6 0 2', - 'd2d4', - 'd4', - ) - - // Verify setup - expect(root.mainChild).toBe(mainMove) - expect(root.mainChild?.isMainline).toBe(true) - expect(variation.isMainline).toBe(false) - expect(root.children).toHaveLength(2) - - // Step 3: Simulate makeMove logic when currentNode is the variation - const simulateMakeMove = (currentNode: GameNode, move: string) => { - const chess = new Chess(currentNode.fen) - const moveAttempt = chess.move({ - from: move.slice(0, 2), - to: move.slice(2, 4), - promotion: move[4] ? (move[4] as PieceSymbol) : undefined, - }) - - if (moveAttempt) { - const newFen = chess.fen() - const moveString = - moveAttempt.from + moveAttempt.to + (moveAttempt.promotion || '') - const san = moveAttempt.san - - // This is the FIXED logic from the analysis page - if (currentNode.mainChild?.move === moveString) { - return { type: 'navigate', node: currentNode.mainChild } - } else if (!currentNode.mainChild && currentNode.isMainline) { - // Only create main line if no main child AND we're on main line - const newMainMove = gameTree.addMainMove( - currentNode, - newFen, - moveString, - san, - ) - return { type: 'main_line', node: newMainMove } - } else { - // Either main child exists but different move, OR we're in variation - create variation - const newVariation = gameTree.addVariation( - currentNode, - newFen, - moveString, - san, - ) - return { type: 'variation', node: newVariation } - } - } - return null - } - - // Step 4: Make move from the variation node (should create another variation, not main line) - const result = simulateMakeMove(variation, 'g1f3') as { - type: 'variation' - node: GameNode - } - - // Assertions - expect(result).not.toBeNull() - expect(result.type).toBe('variation') - expect(result.node.isMainline).toBe(false) - expect(variation.mainChild).toBeNull() // variation should not have gained a main child - expect(variation.children).toHaveLength(1) // should have one child (the move we just made) - expect(variation.children[0].isMainline).toBe(false) // that child should be a variation - expect(variation.children[0].move).toBe('g1f3') - expect(variation.children[0].san).toBe('Nf3') - }) - - it('should create main line when making first move from FEN on main line', () => { - const customFen = - 'r1bqkbnr/pppp1ppp/2n5/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 2 3' - const gameTree = new GameTree(customFen) - const root = gameTree.getRoot() - - const simulateMakeMove = (currentNode: GameNode, move: string) => { - const chess = new Chess(currentNode.fen) - const moveAttempt = chess.move({ - from: move.slice(0, 2), - to: move.slice(2, 4), - promotion: move[4] ? (move[4] as PieceSymbol) : undefined, - }) - - if (moveAttempt) { - const newFen = chess.fen() - const moveString = - moveAttempt.from + moveAttempt.to + (moveAttempt.promotion || '') - const san = moveAttempt.san - - if (currentNode.mainChild?.move === moveString) { - return { type: 'navigate', node: currentNode.mainChild } - } else if (!currentNode.mainChild && currentNode.isMainline) { - const newMainMove = gameTree.addMainMove( - currentNode, - newFen, - moveString, - san, - ) - return { type: 'main_line', node: newMainMove } - } else { - const newVariation = gameTree.addVariation( - currentNode, - newFen, - moveString, - san, - ) - return { type: 'variation', node: newVariation } - } - } - return null - } - - // Make first move from FEN position (should be main line since root is on main line) - const result = simulateMakeMove(root, 'g1f3') as { - type: 'main_line' - node: GameNode - } - - expect(result).not.toBeNull() - expect(result.type).toBe('main_line') - expect(result.node.isMainline).toBe(true) - expect(root.mainChild).toBe(result.node) - }) - - it('should handle complex variation tree correctly', () => { - const initialFen = - 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1' - const gameTree = new GameTree(initialFen) - const root = gameTree.getRoot() - - // Create main line: e4 - const e4 = gameTree.addMainMove( - root, - 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1', - 'e2e4', - 'e4', - ) - - // Create variation from root: d4 - const d4 = gameTree.addVariation( - root, - 'rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq d3 0 1', - 'd2d4', - 'd4', - ) - - // Create variation from root: Nf3 - const nf3 = gameTree.addVariation( - root, - 'rnbqkbnr/pppppppp/8/8/8/5N2/PPPPPPPP/RNBQKB1R b KQkq - 1 1', - 'g1f3', - 'Nf3', - ) - - const simulateMakeMove = (currentNode: GameNode, move: string) => { - const chess = new Chess(currentNode.fen) - const moveAttempt = chess.move({ - from: move.slice(0, 2), - to: move.slice(2, 4), - promotion: move[4] ? (move[4] as PieceSymbol) : undefined, - }) - - if (moveAttempt) { - const newFen = chess.fen() - const moveString = - moveAttempt.from + moveAttempt.to + (moveAttempt.promotion || '') - const san = moveAttempt.san - - if (currentNode.mainChild?.move === moveString) { - return { type: 'navigate', node: currentNode.mainChild } - } else if (!currentNode.mainChild && currentNode.isMainline) { - const newMainMove = gameTree.addMainMove( - currentNode, - newFen, - moveString, - san, - ) - return { type: 'main_line', node: newMainMove } - } else { - const newVariation = gameTree.addVariation( - currentNode, - newFen, - moveString, - san, - ) - return { type: 'variation', node: newVariation } - } - } - return null - } - - // Make move from e4 (main line) - should create main line continuation - const e4Continue = simulateMakeMove(e4, 'e7e5') as { - type: 'main_line' - node: GameNode - } - expect(e4Continue).not.toBeNull() - expect(e4Continue.type).toBe('main_line') - expect(e4Continue.node.isMainline).toBe(true) - - // Make move from d4 (variation) - should create variation continuation - const d4Continue = simulateMakeMove(d4, 'g8f6') as { - type: 'variation' - node: GameNode - } - expect(d4Continue.type).toBe('variation') - expect(d4Continue.node.isMainline).toBe(false) - - // Make move from Nf3 (variation) - should create variation continuation - const nf3Continue = simulateMakeMove(nf3, 'e7e5') as { - type: 'variation' - node: GameNode - } - expect(nf3Continue.type).toBe('variation') - expect(nf3Continue.node.isMainline).toBe(false) - - // Verify tree structure - expect(root.children).toHaveLength(3) // e4, d4, Nf3 - expect(e4.children).toHaveLength(1) // e5 (main line) - expect(d4.children).toHaveLength(1) // Nf6 (variation) - expect(nf3.children).toHaveLength(1) // e5 (variation) - - expect(e4.mainChild).toBe(e4Continue.node) - expect(d4.mainChild).toBeNull() // variations don't have main children - expect(nf3.mainChild).toBeNull() // variations don't have main children - }) -}) diff --git a/__tests__/api/active-users.test.ts b/__tests__/api/active-users.test.ts deleted file mode 100644 index 2564eff9..00000000 --- a/__tests__/api/active-users.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createMocks } from 'node-mocks-http' -import handler from 'src/pages/api/active-users' - -global.fetch = jest.fn() - -describe('/api/active-users', () => { - beforeEach(() => { - jest.clearAllMocks() - delete process.env.POSTHOG_PROJECT_ID - delete process.env.POSTHOG_API_KEY - }) - - it('should return 405 for non-GET requests', async () => { - const { req, res } = createMocks({ - method: 'POST', - }) - - await handler(req, res) - - expect(res._getStatusCode()).toBe(405) - const data = JSON.parse(res._getData()) - expect(data.success).toBe(false) - expect(data.error).toBe('Method not allowed') - }) -}) diff --git a/__tests__/api/home/activeUsers.test.ts b/__tests__/api/home/activeUsers.test.ts deleted file mode 100644 index 64cf8cb8..00000000 --- a/__tests__/api/home/activeUsers.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getActiveUserCount } from 'src/api/home/activeUsers' - -// Mock fetch for API calls -global.fetch = jest.fn() - -describe('getActiveUserCount', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('should return a positive number', async () => { - // Mock successful API response - ;(fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - activeUsers: 15, - success: true, - }), - }) - - const count = await getActiveUserCount() - expect(count).toBeGreaterThanOrEqual(0) - expect(Number.isInteger(count)).toBe(true) - }) - - it('should call the internal API endpoint', async () => { - // Mock successful API response - ;(fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - activeUsers: 10, - success: true, - }), - }) - - const count = await getActiveUserCount() - - expect(fetch).toHaveBeenCalledWith('/api/active-users') - expect(count).toBe(10) - }) -}) diff --git a/__tests__/components/Analysis/AnalyzeEntireGame.test.tsx b/__tests__/components/Analysis/AnalyzeEntireGame.test.tsx deleted file mode 100644 index 85360618..00000000 --- a/__tests__/components/Analysis/AnalyzeEntireGame.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { AnalysisConfigModal } from 'src/components/Analysis/AnalysisConfigModal' -import { AnalysisNotification } from 'src/components/Analysis/AnalysisNotification' -import '@testing-library/jest-dom' - -// Mock framer-motion to avoid animation issues in tests -jest.mock('framer-motion', () => ({ - motion: { - div: ({ children, ...props }: any) =>
{children}
, - }, - AnimatePresence: ({ children }: any) => <>{children}, -})) - -describe('Analyze Entire Game Components', () => { - describe('AnalysisConfigModal', () => { - const defaultProps = { - isOpen: true, - onClose: jest.fn(), - onConfirm: jest.fn(), - initialDepth: 15, - } - - it('renders the modal when open', () => { - render() - - expect(screen.getByText('Analyze Entire Game')).toBeInTheDocument() - expect( - screen.getByText( - 'Choose the Stockfish analysis depth for all positions in the game:', - ), - ).toBeInTheDocument() - }) - - it('renders depth options', () => { - render() - - expect(screen.getByText('Fast (d12)')).toBeInTheDocument() - expect(screen.getByText('Balanced (d15)')).toBeInTheDocument() - expect(screen.getByText('Deep (d18)')).toBeInTheDocument() - }) - - it('renders start analysis button', () => { - render() - - expect(screen.getByText('Start Analysis')).toBeInTheDocument() - expect(screen.getByText('Cancel')).toBeInTheDocument() - }) - - it('does not render when closed', () => { - render() - - expect(screen.queryByText('Analyze Entire Game')).not.toBeInTheDocument() - }) - }) - - describe('AnalysisNotification', () => { - const mockProgress = { - currentMoveIndex: 5, - totalMoves: 20, - currentMove: 'e4', - isAnalyzing: true, - isComplete: false, - isCancelled: false, - } - - const defaultProps = { - progress: mockProgress, - onCancel: jest.fn(), - } - - it('renders notification when analyzing', () => { - render() - - expect(screen.getByText('Analyzing Game')).toBeInTheDocument() - expect(screen.getByText('Position 5 of 20')).toBeInTheDocument() - expect(screen.getByText('25%')).toBeInTheDocument() - }) - - it('renders current move being analyzed', () => { - render() - - expect(screen.getByText('Current:')).toBeInTheDocument() - expect(screen.getByText('e4')).toBeInTheDocument() - }) - - it('renders cancel button', () => { - render() - - const cancelButton = screen.getByTitle('Cancel Analysis') - expect(cancelButton).toBeInTheDocument() - }) - - it('does not render when not analyzing', () => { - const notAnalyzingProgress = { - ...mockProgress, - isAnalyzing: false, - } - - render( - , - ) - - expect(screen.queryByText('Analyzing Game')).not.toBeInTheDocument() - }) - }) -}) diff --git a/__tests__/components/AnimatedNumber.test.tsx b/__tests__/components/AnimatedNumber.test.tsx deleted file mode 100644 index c44b49e9..00000000 --- a/__tests__/components/AnimatedNumber.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { AnimatedNumber } from '../../src/components/Common/AnimatedNumber' - -// Mock framer-motion to avoid complex animation testing -let mockValue = 1000 -jest.mock('framer-motion', () => ({ - motion: { - span: ({ children, className, ...props }: React.ComponentProps<'span'>) => ( - - {children} - - ), - }, - useSpring: jest.fn((value) => { - mockValue = value - return { - set: jest.fn((newValue) => { - mockValue = newValue - }), - get: jest.fn(() => mockValue), - } - }), - useTransform: jest.fn((_, transform) => { - return transform(mockValue) - }), -})) - -describe('AnimatedNumber Component', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('should render with default formatting', () => { - render() - - // The component should render the formatted value - expect(screen.getByText('1,000')).toBeInTheDocument() - }) - - it('should apply custom className', () => { - render() - - const element = screen.getByText('1,000') - expect(element).toHaveClass('custom-class') - }) - - it('should use custom formatValue function', () => { - const customFormat = (value: number) => `$${value.toFixed(2)}` - render() - - expect(screen.getByText('$1000.00')).toBeInTheDocument() - }) - - it('should handle zero value', () => { - render() - - expect(screen.getByText('0')).toBeInTheDocument() - }) - - it('should handle negative values', () => { - render() - - expect(screen.getByText('-500')).toBeInTheDocument() - }) - - it('should handle decimal values with default rounding', () => { - render() - - expect(screen.getByText('1,235')).toBeInTheDocument() - }) - - it('should handle large numbers', () => { - render() - - expect(screen.getByText('1,000,000')).toBeInTheDocument() - }) - - it('should use custom duration prop', () => { - const { rerender } = render() - - // Test that component renders without error with custom duration - expect(screen.getByText('1,000')).toBeInTheDocument() - - // Rerender with different value to test duration effect - rerender() - expect(screen.getByText('2,000')).toBeInTheDocument() - }) - - it('should handle percentage formatting', () => { - const percentFormat = (value: number) => `${(value * 100).toFixed(1)}%` - render() - - expect(screen.getByText('85.0%')).toBeInTheDocument() - }) - - it('should handle currency formatting', () => { - const currencyFormat = (value: number) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(value) - - render() - - expect(screen.getByText('$1,234.56')).toBeInTheDocument() - }) - - it('should render as motion.span element', () => { - render() - - const element = screen.getByText('1,000') - expect(element.tagName).toBe('SPAN') - }) -}) diff --git a/__tests__/components/AuthenticatedWrapper.test.tsx b/__tests__/components/AuthenticatedWrapper.test.tsx deleted file mode 100644 index 22d8fc30..00000000 --- a/__tests__/components/AuthenticatedWrapper.test.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { AuthenticatedWrapper } from '../../src/components/Common/AuthenticatedWrapper' -import { AuthContext } from '../../src/contexts/AuthContext' -import { User } from '../../src/types/auth' - -const mockUser: User = { - clientId: 'test-client-id', - displayName: 'TestUser', - lichessId: 'testuser', -} - -const AuthProvider = ({ - user, - children, -}: { - user: User | null - children: React.ReactNode -}) => ( - - {children} - -) - -describe('AuthenticatedWrapper Component', () => { - it('should render children when user is authenticated', () => { - render( - - -
Protected content
-
-
, - ) - - expect(screen.getByText('Protected content')).toBeInTheDocument() - }) - - it('should not render children when user is not authenticated', () => { - render( - - -
Protected content
-
-
, - ) - - expect(screen.queryByText('Protected content')).not.toBeInTheDocument() - }) - - it('should handle multiple children when user is authenticated', () => { - render( - - -
First child
-
Second child
- Third child -
-
, - ) - - expect(screen.getByText('First child')).toBeInTheDocument() - expect(screen.getByText('Second child')).toBeInTheDocument() - expect(screen.getByText('Third child')).toBeInTheDocument() - }) - - it('should not render multiple children when user is not authenticated', () => { - render( - - -
First child
-
Second child
- Third child -
-
, - ) - - expect(screen.queryByText('First child')).not.toBeInTheDocument() - expect(screen.queryByText('Second child')).not.toBeInTheDocument() - expect(screen.queryByText('Third child')).not.toBeInTheDocument() - }) - - it('should handle no children gracefully when user is authenticated', () => { - render( - - - , - ) - - // Should not crash and should render empty fragment - expect(screen.queryByText(/./)).not.toBeInTheDocument() - }) - - it('should handle no children gracefully when user is not authenticated', () => { - render( - - - , - ) - - // Should not crash and should render empty fragment - expect(screen.queryByText(/./)).not.toBeInTheDocument() - }) - - it('should re-render when authentication state changes', () => { - const { rerender } = render( - - -
Protected content
-
-
, - ) - - // Initially not authenticated - expect(screen.queryByText('Protected content')).not.toBeInTheDocument() - - // Re-render with authenticated user - rerender( - - -
Protected content
-
-
, - ) - - expect(screen.getByText('Protected content')).toBeInTheDocument() - - // Re-render back to unauthenticated - rerender( - - -
Protected content
-
-
, - ) - - expect(screen.queryByText('Protected content')).not.toBeInTheDocument() - }) -}) diff --git a/__tests__/components/Compose.test.tsx b/__tests__/components/Compose.test.tsx deleted file mode 100644 index 177f40b5..00000000 --- a/__tests__/components/Compose.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { Compose } from '../../src/components/Common/Compose' -import { ErrorBoundary } from '../../src/components/Common/ErrorBoundary' - -// Mock ErrorBoundary to avoid chessground import issues -jest.mock('../../src/components/Common/ErrorBoundary', () => ({ - ErrorBoundary: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})) - -// Mock providers for testing -const MockProvider1 = ({ children }: { children: React.ReactNode }) => ( -
{children}
-) - -const MockProvider2 = ({ children }: { children: React.ReactNode }) => ( -
{children}
-) - -const MockProvider3 = ({ children }: { children: React.ReactNode }) => ( -
{children}
-) - -describe('Compose Component', () => { - it('should render children with single component', () => { - render( - -
Test Child
-
, - ) - - expect(screen.getByTestId('provider-1')).toBeInTheDocument() - expect(screen.getByTestId('child')).toBeInTheDocument() - expect(screen.getByText('Test Child')).toBeInTheDocument() - }) - - it('should nest multiple components correctly', () => { - render( - -
Nested Child
-
, - ) - - expect(screen.getByTestId('provider-1')).toBeInTheDocument() - expect(screen.getByTestId('provider-2')).toBeInTheDocument() - expect(screen.getByTestId('child')).toBeInTheDocument() - - // Verify nesting order - const provider1 = screen.getByTestId('provider-1') - const provider2 = screen.getByTestId('provider-2') - expect(provider1).toContainElement(provider2) - }) - - it('should handle three levels of nesting', () => { - render( - -
Deep Nested Child
-
, - ) - - expect(screen.getByTestId('provider-1')).toBeInTheDocument() - expect(screen.getByTestId('provider-2')).toBeInTheDocument() - expect(screen.getByTestId('provider-3')).toBeInTheDocument() - expect(screen.getByTestId('child')).toBeInTheDocument() - - // Verify deep nesting - const provider1 = screen.getByTestId('provider-1') - const provider2 = screen.getByTestId('provider-2') - const provider3 = screen.getByTestId('provider-3') - expect(provider1).toContainElement(provider2) - expect(provider2).toContainElement(provider3) - }) - - it('should work with ErrorBoundary component', () => { - render( - -
Error Wrapped Child
-
, - ) - - expect(screen.getByTestId('error-boundary')).toBeInTheDocument() - expect(screen.getByTestId('provider-1')).toBeInTheDocument() - expect(screen.getByTestId('child')).toBeInTheDocument() - }) - - it('should handle empty components array', () => { - render( - -
Unwrapped Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - expect(screen.getByText('Unwrapped Child')).toBeInTheDocument() - }) - - it('should render multiple children', () => { - render( - -
First Child
-
Second Child
-
, - ) - - expect(screen.getByTestId('provider-1')).toBeInTheDocument() - expect(screen.getByTestId('child-1')).toBeInTheDocument() - expect(screen.getByTestId('child-2')).toBeInTheDocument() - expect(screen.getByText('First Child')).toBeInTheDocument() - expect(screen.getByText('Second Child')).toBeInTheDocument() - }) - - it('should preserve React node types', () => { - render( - - Text node - - - , - ) - - expect(screen.getByText('Text node')).toBeInTheDocument() - expect( - screen.getByRole('button', { name: 'Button node' }), - ).toBeInTheDocument() - expect(screen.getByPlaceholderText('Input node')).toBeInTheDocument() - }) -}) diff --git a/__tests__/components/DelayedLoading.test.tsx b/__tests__/components/DelayedLoading.test.tsx deleted file mode 100644 index e4a99f16..00000000 --- a/__tests__/components/DelayedLoading.test.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { render, screen, waitFor, act } from '@testing-library/react' -import { DelayedLoading } from '../../src/components/Common/DelayedLoading' - -// Mock the Loading component -jest.mock('../../src/components/Common/Loading', () => ({ - Loading: () =>
Loading...
, -})) - -// Mock framer-motion -jest.mock('framer-motion', () => ({ - motion: { - div: ({ children, ...props }: React.ComponentProps<'div'>) => ( -
{children}
- ), - }, - AnimatePresence: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), -})) - -describe('DelayedLoading Component', () => { - beforeEach(() => { - jest.clearAllTimers() - jest.useFakeTimers() - }) - - afterEach(() => { - jest.useRealTimers() - }) - - it('should render children immediately when not loading', () => { - render( - -
Main content
-
, - ) - - expect(screen.getByTestId('content')).toBeInTheDocument() - expect(screen.getByText('Main content')).toBeInTheDocument() - expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() - }) - - it('should not show loading immediately when isLoading is true', () => { - render( - -
Main content
-
, - ) - - expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() - expect(screen.queryByTestId('content')).not.toBeInTheDocument() - }) - - it('should show loading after default delay (1000ms)', async () => { - render( - -
Main content
-
, - ) - - // Before delay - expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() - - // Advance time by 1000ms - act(() => { - jest.advanceTimersByTime(1000) - }) - - expect(screen.getByTestId('loading-component')).toBeInTheDocument() - expect(screen.queryByTestId('content')).not.toBeInTheDocument() - }) - - it('should show loading after custom delay', async () => { - render( - -
Main content
-
, - ) - - // Before delay - expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() - - // Advance time by 500ms - act(() => { - jest.advanceTimersByTime(500) - }) - - expect(screen.getByTestId('loading-component')).toBeInTheDocument() - }) - - it('should not show loading if isLoading becomes false before delay', () => { - const { rerender } = render( - -
Main content
-
, - ) - - // Advance time by 500ms (less than delay) - act(() => { - jest.advanceTimersByTime(500) - }) - - // Set isLoading to false before delay completes - rerender( - -
Main content
-
, - ) - - // Complete the remaining time - act(() => { - jest.advanceTimersByTime(500) - }) - - expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() - expect(screen.getByTestId('content')).toBeInTheDocument() - }) - - it('should hide loading and show content when isLoading becomes false', () => { - const { rerender } = render( - -
Main content
-
, - ) - - // Wait for loading to show - act(() => { - jest.advanceTimersByTime(500) - }) - - expect(screen.getByTestId('loading-component')).toBeInTheDocument() - - // Set isLoading to false - rerender( - -
Main content
-
, - ) - - expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() - expect(screen.getByTestId('content')).toBeInTheDocument() - }) - - it('should handle delay prop changes', () => { - const { rerender } = render( - -
Main content
-
, - ) - - // Change delay - rerender( - -
Main content
-
, - ) - - // Advance by the new delay amount - act(() => { - jest.advanceTimersByTime(200) - }) - - expect(screen.getByTestId('loading-component')).toBeInTheDocument() - }) - - it('should clean up timer on unmount', () => { - const { unmount } = render( - -
Main content
-
, - ) - - const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout') - - unmount() - - expect(clearTimeoutSpy).toHaveBeenCalled() - clearTimeoutSpy.mockRestore() - }) - - it('should handle multiple children', () => { - render( - -
First child
-
Second child
-
, - ) - - expect(screen.getByTestId('child1')).toBeInTheDocument() - expect(screen.getByTestId('child2')).toBeInTheDocument() - expect(screen.getByText('First child')).toBeInTheDocument() - expect(screen.getByText('Second child')).toBeInTheDocument() - }) - - it('should apply correct CSS classes and motion props', () => { - render( - -
Main content
-
, - ) - - act(() => { - jest.advanceTimersByTime(100) - }) - - const loadingContainer = - screen.getByTestId('loading-component').parentElement - expect(loadingContainer).toHaveClass('my-auto') - }) -}) diff --git a/__tests__/components/GameInfo.test.tsx b/__tests__/components/GameInfo.test.tsx deleted file mode 100644 index 3b87607a..00000000 --- a/__tests__/components/GameInfo.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react' -import { GameInfo } from '../../src/components/Common/GameInfo' -import { InstructionsType } from '../../src/types' - -// Mock the tour context -const mockStartTour = jest.fn() -jest.mock('../../src/contexts/TourContext/TourContext', () => ({ - useTour: () => ({ - startTour: mockStartTour, - }), -})) - -// Mock the tour configs -jest.mock('../../src/constants/tours', () => ({ - tourConfigs: { - analysis: { - steps: [], - }, - }, -})) - -const defaultProps = { - icon: 'analytics', - title: 'Test Analysis', - type: 'analysis' as InstructionsType, - children:
Test content
, -} - -const MOCK_MAIA_MODELS = ['maia_kdd_1100', 'maia_kdd_1500', 'maia_kdd_1900'] - -describe('GameInfo Component', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('should render basic props correctly', () => { - render() - - expect(screen.getByText('analytics')).toBeInTheDocument() - expect(screen.getByText('Test Analysis')).toBeInTheDocument() - expect(screen.getByText('Test content')).toBeInTheDocument() - }) - - it('should render with correct icon class', () => { - render() - - const iconElement = screen.getByText('analytics') - expect(iconElement).toHaveClass('material-symbols-outlined') - expect(iconElement).toHaveClass('text-lg', 'md:text-xl') - }) - - it('should call setCurrentMaiaModel when model is changed', () => { - const mockSetCurrentMaiaModel = jest.fn() - - render( - , - ) - - const selectElement = screen.getByDisplayValue('Maia 1500') - fireEvent.change(selectElement, { target: { value: 'maia_kdd_1900' } }) - - expect(mockSetCurrentMaiaModel).toHaveBeenCalledWith('maia_kdd_1900') - }) - - it('should not render Maia model selector when currentMaiaModel is not provided', () => { - render() - - expect(screen.queryByText('using')).not.toBeInTheDocument() - }) - - it('should render game list button when showGameListButton is true', () => { - const mockOnGameListClick = jest.fn() - - render( - , - ) - - const gameListButton = screen.getByText('Switch Game') - expect(gameListButton).toBeInTheDocument() - }) - - it('should call onGameListClick when game list button is clicked', () => { - const mockOnGameListClick = jest.fn() - - render( - , - ) - - const gameListButton = screen.getByText('Switch Game') - fireEvent.click(gameListButton) - - expect(mockOnGameListClick).toHaveBeenCalledTimes(1) - }) - - it('should have correct container structure and classes', () => { - const { container } = render() - - const mainContainer = container.firstChild - expect(mainContainer).toHaveClass( - 'flex', - 'w-full', - 'flex-col', - 'items-start', - 'justify-start', - 'gap-1', - 'overflow-hidden', - 'bg-background-1', - 'p-1.5', - 'md:rounded', - 'md:p-3', - ) - expect(mainContainer).toHaveAttribute('id', 'analysis-game-list') - }) -}) diff --git a/__tests__/components/GameList.test.tsx b/__tests__/components/GameList.test.tsx deleted file mode 100644 index e3c1c703..00000000 --- a/__tests__/components/GameList.test.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import React from 'react' -import { render, screen, waitFor, act } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { GameList } from 'src/components/Profile/GameList' -import { AuthContext } from 'src/contexts' -import * as api from 'src/api' - -// Mock the API functions -jest.mock('src/api', () => ({ - getAnalysisGameList: jest.fn(), - getLichessGames: jest.fn(), -})) - -// Mock custom analysis utility -jest.mock('src/lib/customAnalysis', () => ({ - getCustomAnalysesAsWebGames: jest.fn(() => []), -})) - -// Mock favorites utility -jest.mock('src/lib/favorites', () => ({ - getFavoritesAsWebGames: jest.fn(() => []), - addFavoriteGame: jest.fn(), - removeFavoriteGame: jest.fn(), - isFavoriteGame: jest.fn(() => false), -})) - -// Mock FavoriteModal component -jest.mock('src/components/Common/FavoriteModal', () => ({ - FavoriteModal: () => null, -})) - -// Mock framer-motion to avoid animation issues in tests -jest.mock('framer-motion', () => ({ - motion: { - div: ({ - children, - layoutId, - ...props - }: React.PropsWithChildren<{ layoutId?: string }>) => ( -
{children}
- ), - }, -})) - -const mockGetAnalysisGameList = api.getAnalysisGameList as jest.MockedFunction< - typeof api.getAnalysisGameList -> - -const mockGetLichessGames = api.getLichessGames as jest.MockedFunction< - typeof api.getLichessGames -> - -// Mock user context -const mockUser = { - clientId: 'client123', - displayName: 'Test User', - lichessId: 'testuser123', - id: 'user123', -} - -const AuthWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) - -describe('GameList', () => { - beforeEach(() => { - jest.clearAllMocks() - // Mock different responses based on game type - mockGetAnalysisGameList.mockImplementation((gameType) => { - if (gameType === 'hand') { - return Promise.resolve({ - games: [ - { - game_id: 'game1', - maia_name: 'maia_kdd_1500', - result: '1-0', - player_color: 'white', - }, - ], - total_games: 1, - total_pages: 1, - }) - } else if (gameType === 'brain') { - return Promise.resolve({ - games: [], - total_games: 0, - total_pages: 0, - }) - } - // Default for 'play' and other types - return Promise.resolve({ - games: [ - { - game_id: 'game1', - maia_name: 'maia_kdd_1500', - result: '1-0', - player_color: 'white', - }, - ], - total_games: 1, - total_pages: 1, - }) - }) - }) - - it('renders with default props (all tabs shown for current user)', async () => { - await act(async () => { - render( - - - , - ) - }) - - expect(screen.getByText('Your Games')).toBeInTheDocument() - expect(screen.getByText('Play')).toBeInTheDocument() - expect(screen.getByText('H&B')).toBeInTheDocument() - expect(screen.getByText('Custom')).toBeInTheDocument() - expect(screen.getByText('Lichess')).toBeInTheDocument() - }) - - it('renders with limited tabs for other users', async () => { - await act(async () => { - render( - - - , - ) - }) - - expect(screen.getByText("OtherUser's Games")).toBeInTheDocument() - expect(screen.getByText('Play')).toBeInTheDocument() - expect(screen.getByText('H&B')).toBeInTheDocument() - expect(screen.queryByText('Custom')).not.toBeInTheDocument() - expect(screen.queryByText('Lichess')).not.toBeInTheDocument() - }) - - it('fetches games with lichessId when provided', async () => { - const user = userEvent.setup() - - await act(async () => { - render( - - - , - ) - }) - - // Click on Play tab to trigger API call - await act(async () => { - await user.click(screen.getByText('Play')) - }) - - await waitFor(() => { - expect(mockGetAnalysisGameList).toHaveBeenCalledWith( - 'play', - 1, - 'otheruser', - ) - }) - }) - - it('displays correct game labels for other users', async () => { - const user = userEvent.setup() - - await act(async () => { - render( - - - , - ) - }) - - // Click on Play tab to see games - await act(async () => { - await user.click(screen.getByText('Play')) - }) - - await waitFor(() => { - expect(screen.getByText('OtherUser vs. Maia 1500')).toBeInTheDocument() - }) - }) - - it('displays correct game labels for current user', async () => { - const user = userEvent.setup() - - await act(async () => { - render( - - - , - ) - }) - - // Click on Play tab to see games - await act(async () => { - await user.click(screen.getByText('Play')) - }) - - await waitFor(() => { - expect(screen.getByText('You vs. Maia 1500')).toBeInTheDocument() - }) - }) - - it('switches between H&B subsections', async () => { - const user = userEvent.setup() - - await act(async () => { - render( - - - , - ) - }) - - // Click on H&B tab - await act(async () => { - await user.click(screen.getByText('H&B')) - }) - - // Wait for the hand games to load and check subsection labels - await waitFor(() => { - expect(screen.getByText('Hand')).toBeInTheDocument() - expect(screen.getByText('Brain')).toBeInTheDocument() - }) - - // Click on Brain subsection - await act(async () => { - await user.click(screen.getByText('Brain')) - }) - - // Verify API call for brain games - await waitFor(() => { - expect(mockGetAnalysisGameList).toHaveBeenCalledWith( - 'brain', - 1, - undefined, - ) - }) - }) -}) diff --git a/__tests__/components/Icons.test.tsx b/__tests__/components/Icons.test.tsx deleted file mode 100644 index cabc36b7..00000000 --- a/__tests__/components/Icons.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { - RegularPlayIcon, - BrainIcon, - BotOrNotIcon, - TrainIcon, - HandIcon, - StarIcon, - ChessboardIcon, - GithubIcon, - DiscordIcon, - FlipIcon, -} from '../../src/components/Common/Icons' - -describe('Icons Component', () => { - describe('SVG Icons', () => { - it('should render RegularPlayIcon with correct src and alt', () => { - render() - const icon = screen.getByAltText('Regular Play Icon') - expect(icon).toBeInTheDocument() - expect(icon).toHaveAttribute('src', '/assets/icons/regular_play_icon.svg') - }) - - it('should render BrainIcon with correct src and alt', () => { - render() - const icon = screen.getByAltText('Brain Icon') - expect(icon).toBeInTheDocument() - expect(icon).toHaveAttribute('src', '/assets/icons/brain_icon.svg') - }) - - it('should render BotOrNotIcon with correct src and alt', () => { - render() - const icon = screen.getByAltText('Bot-or-Not Icon') - expect(icon).toBeInTheDocument() - expect(icon).toHaveAttribute('src', '/assets/icons/turing_icon.svg') - }) - - it('should render TrainIcon with correct src and alt', () => { - render() - const icon = screen.getByAltText('Train Icon') - expect(icon).toBeInTheDocument() - expect(icon).toHaveAttribute('src', '/assets/icons/train_icon.svg') - }) - - it('should render HandIcon with correct src and alt', () => { - render() - const icon = screen.getByAltText('Hand Icon') - expect(icon).toBeInTheDocument() - expect(icon).toHaveAttribute('src', '/assets/icons/hand_icon.svg') - }) - - it('should render StarIcon with correct src and alt', () => { - render() - const icon = screen.getByAltText('Star Icon') - expect(icon).toBeInTheDocument() - expect(icon).toHaveAttribute('src', '/assets/icons/star_icon.svg') - }) - - it('should render ChessboardIcon with correct src and alt', () => { - render() - const icon = screen.getByAltText('Chessboard Icon') - expect(icon).toBeInTheDocument() - expect(icon).toHaveAttribute('src', '/assets/icons/chessboard_icon.svg') - }) - }) - - describe('SVG Component Icons', () => { - it('should render GithubIcon as SVG element', () => { - const { container } = render(
{GithubIcon}
) - const svg = container.querySelector('svg') - expect(svg).toBeInTheDocument() - expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg') - expect(svg).toHaveAttribute('height', '1em') - expect(svg).toHaveAttribute('viewBox', '0 0 496 512') - }) - - it('should render DiscordIcon as SVG element', () => { - const { container } = render(
{DiscordIcon}
) - const svg = container.querySelector('svg') - expect(svg).toBeInTheDocument() - expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg') - expect(svg).toHaveAttribute('viewBox', '0 0 127.14 96.36') - }) - - it('should render FlipIcon as SVG element', () => { - const { container } = render(
{FlipIcon}
) - const svg = container.querySelector('svg') - expect(svg).toBeInTheDocument() - expect(svg).toHaveAttribute('fill', 'white') - expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg') - expect(svg).toHaveAttribute('viewBox', '1 1 22 22') - expect(svg).toHaveAttribute('width', '14px') - expect(svg).toHaveAttribute('height', '14px') - }) - }) - - describe('Icon accessibility', () => { - it('should have alt text for all image icons', () => { - const imageIcons = [ - { component: , alt: 'Regular Play Icon' }, - { component: , alt: 'Brain Icon' }, - { component: , alt: 'Bot-or-Not Icon' }, - { component: , alt: 'Train Icon' }, - { component: , alt: 'Hand Icon' }, - { component: , alt: 'Star Icon' }, - { component: , alt: 'Chessboard Icon' }, - ] - - imageIcons.forEach(({ component, alt }) => { - render(component) - expect(screen.getByAltText(alt)).toBeInTheDocument() - }) - }) - }) -}) diff --git a/__tests__/components/PlayerInfo.test.tsx b/__tests__/components/PlayerInfo.test.tsx deleted file mode 100644 index 6ad17625..00000000 --- a/__tests__/components/PlayerInfo.test.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { PlayerInfo } from '../../src/components/Common/PlayerInfo' - -const defaultProps = { - name: 'TestPlayer', - color: 'white', -} - -describe('PlayerInfo Component', () => { - it('should render player name correctly', () => { - render() - - expect(screen.getByText('TestPlayer')).toBeInTheDocument() - }) - - it('should render player rating when provided', () => { - render() - - expect(screen.getByText('(1500)')).toBeInTheDocument() - }) - - it('should not render rating when not provided', () => { - render() - - expect(screen.getByText('TestPlayer')).toBeInTheDocument() - expect(screen.queryByText(/\(.*\)/)).not.toBeInTheDocument() - }) - - it('should render "Unknown" when name is not provided', () => { - render() - - expect(screen.getByText('Unknown')).toBeInTheDocument() - }) - - it('should render empty string when name is empty', () => { - render() - - // Empty string should render as-is, not as "Unknown" - expect(screen.queryByText('Unknown')).not.toBeInTheDocument() - }) - - it('should render white color indicator correctly', () => { - const { container } = render() - - const colorIndicator = container.querySelector('.bg-white') - expect(colorIndicator).toBeInTheDocument() - expect(colorIndicator).toHaveClass('h-2.5', 'w-2.5', 'rounded-full') - }) - - it('should render black color indicator correctly', () => { - const { container } = render() - - const colorIndicator = container.querySelector('.bg-black') - expect(colorIndicator).toBeInTheDocument() - expect(colorIndicator).toHaveClass( - 'h-2.5', - 'w-2.5', - 'rounded-full', - 'border', - ) - }) - - describe('Arrow Legend', () => { - it('should render arrow legend when showArrowLegend is true', () => { - render() - - expect(screen.getByText('Most Human Move')).toBeInTheDocument() - expect(screen.getByText('Best Engine Move')).toBeInTheDocument() - }) - - it('should not render arrow legend when showArrowLegend is false', () => { - render() - - expect(screen.queryByText('Most Human Move')).not.toBeInTheDocument() - expect(screen.queryByText('Best Engine Move')).not.toBeInTheDocument() - }) - - it('should not render arrow legend by default', () => { - render() - - expect(screen.queryByText('Most Human Move')).not.toBeInTheDocument() - expect(screen.queryByText('Best Engine Move')).not.toBeInTheDocument() - }) - - it('should render arrow icons with correct classes in legend', () => { - render() - - const arrowIcons = screen.getAllByText('arrow_outward') - expect(arrowIcons).toHaveLength(2) - - // Human move arrow - expect(arrowIcons[0]).toHaveClass( - 'material-symbols-outlined', - '!text-xxs', - 'text-human-3', - ) - - // Engine move arrow - expect(arrowIcons[1]).toHaveClass( - 'material-symbols-outlined', - '!text-xxs', - 'text-engine-3', - ) - }) - }) - - describe('Game Termination', () => { - it('should show "1" when player won (termination matches color)', () => { - render() - - expect(screen.getByText('1')).toBeInTheDocument() - expect(screen.getByText('1')).toHaveClass('text-engine-3') - }) - - it('should show "0" when player lost (termination does not match color and is not "none")', () => { - render() - - expect(screen.getByText('0')).toBeInTheDocument() - expect(screen.getByText('0')).toHaveClass('text-human-3') - }) - - it('should show "½" when game was a draw (termination is "none")', () => { - render() - - expect(screen.getByText('½')).toBeInTheDocument() - expect(screen.getByText('½')).toHaveClass('text-secondary') - }) - - it('should show nothing when termination is undefined', () => { - render() - - expect(screen.queryByText('1')).not.toBeInTheDocument() - expect(screen.queryByText('0')).not.toBeInTheDocument() - expect(screen.queryByText('½')).not.toBeInTheDocument() - }) - }) - - it('should have correct container structure and classes', () => { - const { container } = render() - - const mainContainer = container.firstChild - expect(mainContainer).toHaveClass( - 'flex', - 'h-10', - 'w-full', - 'items-center', - 'justify-between', - 'bg-background-1', - 'px-4', - ) - }) -}) diff --git a/__tests__/components/Settings/SoundSettings.test.tsx b/__tests__/components/Settings/SoundSettings.test.tsx deleted file mode 100644 index 0e1b4a43..00000000 --- a/__tests__/components/Settings/SoundSettings.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import '@testing-library/jest-dom' -import { render, screen, fireEvent } from '@testing-library/react' -import { SoundSettings } from 'src/components/Settings/SoundSettings' -import { SettingsProvider } from 'src/contexts/SettingsContext' -import { chessSoundManager } from 'src/lib/chessSoundManager' - -// Mock the chess sound manager -jest.mock('src/lib/chessSoundManager', () => ({ - chessSoundManager: { - playMoveSound: jest.fn(), - }, - useChessSoundManager: () => ({ - playMoveSound: jest.fn(), - ready: true, - }), -})) - -// Mock localStorage -const localStorageMock = { - getItem: jest.fn(), - setItem: jest.fn(), - removeItem: jest.fn(), - clear: jest.fn(), -} -Object.defineProperty(window, 'localStorage', { - value: localStorageMock, -}) - -describe('SoundSettings Component', () => { - beforeEach(() => { - localStorageMock.getItem.mockReturnValue( - JSON.stringify({ soundEnabled: true, chessboardTheme: 'brown' }), - ) - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - it('renders sound settings with toggle enabled by default', () => { - render( - - - , - ) - - expect(screen.getByText('Sound Settings')).toBeInTheDocument() - expect(screen.getByText('Enable Move Sounds')).toBeInTheDocument() - expect(screen.getByRole('checkbox')).toBeChecked() - }) - - it('shows test buttons when sound is enabled', () => { - render( - - - , - ) - - expect(screen.getByText('Move Sound')).toBeInTheDocument() - expect(screen.getByText('Capture Sound')).toBeInTheDocument() - }) - - it('saves settings to localStorage when toggle is changed', () => { - render( - - - , - ) - - const checkbox = screen.getByRole('checkbox') - fireEvent.click(checkbox) - - expect(localStorageMock.setItem).toHaveBeenCalledWith( - 'maia-user-settings', - JSON.stringify({ soundEnabled: false, chessboardTheme: 'brown' }), - ) - }) -}) diff --git a/__tests__/components/StatsDisplay.test.tsx b/__tests__/components/StatsDisplay.test.tsx deleted file mode 100644 index 354afe98..00000000 --- a/__tests__/components/StatsDisplay.test.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { StatsDisplay } from '../../src/components/Common/StatsDisplay' -import { AllStats } from '../../src/hooks/useStats' - -// Mock stats data -const mockStats: AllStats = { - rating: 1500, - lastRating: 1450, - session: { - gamesWon: 3, - gamesPlayed: 5, - }, - lifetime: { - gamesWon: 100, - gamesPlayed: 150, - }, -} - -const mockStatsNoRating: AllStats = { - rating: undefined, - lastRating: undefined, - session: { - gamesWon: 0, - gamesPlayed: 0, - }, - lifetime: { - gamesWon: 0, - gamesPlayed: 0, - }, -} - -describe('StatsDisplay Component', () => { - it('should render rating display', () => { - render() - - expect(screen.getByText('Your rating')).toBeInTheDocument() - expect(screen.getByText('1500')).toBeInTheDocument() - }) - - it('should render rating difference for positive change', () => { - render() - - // Rating diff should be +50 (1500 - 1450) - expect(screen.getByText('+50')).toBeInTheDocument() - expect(screen.getByText('arrow_drop_up')).toBeInTheDocument() - }) - - it('should render rating difference for negative change', () => { - const statsWithNegativeDiff = { - ...mockStats, - rating: 1400, - lastRating: 1450, - } - - render() - - // Rating diff should be -50 (1400 - 1450), displayed as –50 - expect(screen.getByText('–50')).toBeInTheDocument() - expect(screen.getByText('arrow_drop_down')).toBeInTheDocument() - }) - - it('should render session stats', () => { - render() - - expect(screen.getByText('This session')).toBeInTheDocument() - expect(screen.getByText('3')).toBeInTheDocument() // gamesWon - expect(screen.getByText('5')).toBeInTheDocument() // gamesPlayed - expect(screen.getByText('60%')).toBeInTheDocument() // win rate - }) - - it('should render lifetime stats', () => { - render() - - expect(screen.getByText('Lifetime')).toBeInTheDocument() - expect(screen.getByText('100')).toBeInTheDocument() // gamesWon - expect(screen.getByText('150')).toBeInTheDocument() // gamesPlayed - expect(screen.getByText('66%')).toBeInTheDocument() // win rate (100/150 = 66.67%, truncated to 66) - }) - - it('should hide session when hideSession prop is true', () => { - render() - - expect(screen.queryByText('This session')).not.toBeInTheDocument() - expect(screen.getByText('Lifetime')).toBeInTheDocument() - }) - - it('should show "Wins" label when isGame is true', () => { - render() - - expect(screen.getAllByText('Wins')).toHaveLength(2) // Session and Lifetime - }) - - it('should show "Correct" label when isGame is false', () => { - render() - - expect(screen.getAllByText('Correct')).toHaveLength(2) // Session and Lifetime - }) - - it('should handle undefined stats gracefully', () => { - render() - - expect(screen.getAllByText('0')).toHaveLength(5) // Rating, wins, played (session & lifetime) - expect(screen.getAllByText('-%')).toHaveLength(2) // Win rate for 0/0 should be NaN, displayed as '-' for session and lifetime - }) - - it('should handle NaN win percentage', () => { - const statsWithNaN = { - ...mockStats, - session: { - gamesWon: 0, - gamesPlayed: 0, - }, - } - - render() - - expect(screen.getByText('-%')).toBeInTheDocument() // NaN should display as '-%' - }) - - it('should apply correct CSS classes', () => { - render() - - const container = screen - .getByText('Your rating') - .closest('div')?.parentElement - expect(container).toHaveClass('flex', 'flex-col') - // Additional specific classes can be tested based on actual implementation - }) - - it('should handle zero win percentage correctly', () => { - const statsWithZeroWins = { - ...mockStats, - session: { - gamesWon: 0, - gamesPlayed: 10, - }, - } - - render() - - expect(screen.getByText('0%')).toBeInTheDocument() - }) - - it('should handle 100% win percentage correctly', () => { - const statsWithAllWins = { - ...mockStats, - session: { - gamesWon: 10, - gamesPlayed: 10, - }, - } - - render() - - expect(screen.getByText('100%')).toBeInTheDocument() - }) - - it('should render without rating difference when lastRating is undefined', () => { - const statsWithoutLastRating = { - ...mockStats, - lastRating: undefined, - } - - render() - - expect(screen.queryByText('+50')).not.toBeInTheDocument() - expect(screen.queryByText('arrow_drop_up')).not.toBeInTheDocument() - }) - - it('should render material icons correctly', () => { - render() - - const upArrow = screen.getByText('arrow_drop_up') - expect(upArrow).toHaveClass( - 'material-symbols-outlined', - 'material-symbols-filled', - 'text-2xl', - ) - }) -}) diff --git a/__tests__/hooks/useLeaderboardStatus.test.ts b/__tests__/hooks/useLeaderboardStatus.test.ts deleted file mode 100644 index eb0b441b..00000000 --- a/__tests__/hooks/useLeaderboardStatus.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react' -import { useLeaderboardStatus } from 'src/hooks/useLeaderboardStatus' -import * as api from 'src/api' - -// Mock the API -jest.mock('src/api', () => ({ - getLeaderboard: jest.fn(), -})) - -const mockLeaderboardData = { - play_leaders: [ - { display_name: 'TestPlayer1', elo: 1800 }, - { display_name: 'TestPlayer2', elo: 1750 }, - ], - puzzles_leaders: [ - { display_name: 'TestPlayer1', elo: 1600 }, - { display_name: 'TestPlayer3', elo: 1550 }, - ], - turing_leaders: [{ display_name: 'TestPlayer4', elo: 1400 }], - hand_leaders: [{ display_name: 'TestPlayer1', elo: 1500 }], - brain_leaders: [{ display_name: 'TestPlayer5', elo: 1300 }], - last_updated: '2024-01-01T00:00:00', -} - -describe('useLeaderboardStatus', () => { - beforeEach(() => { - jest.clearAllMocks() - ;(api.getLeaderboard as jest.Mock).mockResolvedValue(mockLeaderboardData) - }) - - it('should return correct status for player on multiple leaderboards', async () => { - const { result } = renderHook(() => useLeaderboardStatus('TestPlayer1')) - - expect(result.current.loading).toBe(true) - - await waitFor(() => { - expect(result.current.loading).toBe(false) - }) - - expect(result.current.status.isOnLeaderboard).toBe(true) - expect(result.current.status.totalLeaderboards).toBe(3) - expect(result.current.status.positions).toHaveLength(3) - - // Check specific positions - const regularPosition = result.current.status.positions.find( - (p) => p.gameType === 'regular', - ) - expect(regularPosition?.position).toBe(1) - expect(regularPosition?.elo).toBe(1800) - - const trainPosition = result.current.status.positions.find( - (p) => p.gameType === 'train', - ) - expect(trainPosition?.position).toBe(1) - expect(trainPosition?.elo).toBe(1600) - - const handPosition = result.current.status.positions.find( - (p) => p.gameType === 'hand', - ) - expect(handPosition?.position).toBe(1) - expect(handPosition?.elo).toBe(1500) - }) - - it('should return correct status for player not on leaderboard', async () => { - const { result } = renderHook(() => - useLeaderboardStatus('NonExistentPlayer'), - ) - - await waitFor(() => { - expect(result.current.loading).toBe(false) - }) - - expect(result.current.status.isOnLeaderboard).toBe(false) - expect(result.current.status.totalLeaderboards).toBe(0) - expect(result.current.status.positions).toHaveLength(0) - }) - - it('should return empty status when no displayName provided', async () => { - const { result } = renderHook(() => useLeaderboardStatus(undefined)) - - await waitFor(() => { - expect(result.current.loading).toBe(false) - }) - - expect(result.current.status.isOnLeaderboard).toBe(false) - expect(result.current.status.totalLeaderboards).toBe(0) - expect(result.current.status.positions).toHaveLength(0) - expect(api.getLeaderboard).not.toHaveBeenCalled() - }) - - it('should handle API errors gracefully', async () => { - ;(api.getLeaderboard as jest.Mock).mockRejectedValue(new Error('API Error')) - - const { result } = renderHook(() => useLeaderboardStatus('TestPlayer1')) - - await waitFor(() => { - expect(result.current.loading).toBe(false) - }) - - expect(result.current.status.isOnLeaderboard).toBe(false) - expect(result.current.error).toBe('Failed to fetch leaderboard data') - }) -}) diff --git a/__tests__/hooks/useLocalStorage.test.ts b/__tests__/hooks/useLocalStorage.test.ts deleted file mode 100644 index 05de60e9..00000000 --- a/__tests__/hooks/useLocalStorage.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { renderHook, act } from '@testing-library/react' -import { useLocalStorage } from '../../src/hooks/useLocalStorage/useLocalStorage' - -// Mock localStorage -const mockLocalStorage = { - getItem: jest.fn(), - setItem: jest.fn(), - removeItem: jest.fn(), - clear: jest.fn(), -} - -Object.defineProperty(window, 'localStorage', { - value: mockLocalStorage, - writable: true, -}) - -describe('useLocalStorage', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('should return initial value when localStorage is empty', () => { - mockLocalStorage.getItem.mockReturnValue(null) - - const { result } = renderHook(() => - useLocalStorage('test-key', 'default-value'), - ) - - expect(result.current[0]).toBe('default-value') - expect(mockLocalStorage.getItem).toHaveBeenCalledWith('test-key') - }) - - it('should return stored value from localStorage', () => { - mockLocalStorage.getItem.mockReturnValue(JSON.stringify('stored-value')) - - const { result } = renderHook(() => - useLocalStorage('test-key', 'default-value'), - ) - - expect(result.current[0]).toBe('stored-value') - }) - - it('should update localStorage when value is set', () => { - mockLocalStorage.getItem.mockReturnValue(null) - - const { result } = renderHook(() => - useLocalStorage('test-key', 'default-value'), - ) - - act(() => { - result.current[1]('new-value') - }) - - expect(mockLocalStorage.setItem).toHaveBeenCalledWith( - 'test-key', - JSON.stringify('new-value'), - ) - expect(result.current[0]).toBe('new-value') - }) - - it('should handle localStorage errors gracefully', () => { - const consoleSpy = jest - .spyOn(console, 'error') - .mockImplementation(jest.fn()) - - mockLocalStorage.getItem.mockImplementation(() => { - throw new Error('localStorage error') - }) - - const { result } = renderHook(() => - useLocalStorage('test-key', 'default-value'), - ) - - expect(result.current[0]).toBe('default-value') - - consoleSpy.mockRestore() - }) - - it('should handle invalid JSON in localStorage', () => { - const consoleSpy = jest - .spyOn(console, 'error') - .mockImplementation(jest.fn()) - - mockLocalStorage.getItem.mockReturnValue('invalid-json') - - const { result } = renderHook(() => - useLocalStorage('test-key', 'default-value'), - ) - - expect(result.current[0]).toBe('default-value') - - consoleSpy.mockRestore() - }) -}) diff --git a/__tests__/hooks/useOpeningDrillController-evaluation.test.ts b/__tests__/hooks/useOpeningDrillController-evaluation.test.ts deleted file mode 100644 index f73d9662..00000000 --- a/__tests__/hooks/useOpeningDrillController-evaluation.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { GameTree, GameNode } from 'src/types' -import { Chess } from 'chess.ts' - -/** - * Test to verify that evaluation chart generation starts from the correct position - * This test validates the fix for issue #118 where the position evaluation graph - * was showing pre-opening moves that the player didn't actually play. - */ -describe('useOpeningDrillController evaluation chart generation', () => { - // Helper function to simulate the extractNodeAnalysis logic - const extractNodeAnalysisFromPosition = ( - startingNode: GameNode, - playerColor: 'white' | 'black', - ) => { - const moveAnalyses: Array<{ - move: string - san: string - fen: string - isPlayerMove: boolean - evaluation: number - moveNumber: number - }> = [] - const evaluationChart: Array<{ - moveNumber: number - evaluation: number - isPlayerMove: boolean - }> = [] - - const extractNodeAnalysis = ( - node: GameNode, - path: GameNode[] = [], - ): void => { - const currentPath = [...path, node] - - if (node.move && node.san) { - const moveIndex = currentPath.length - 2 - const isPlayerMove = - playerColor === 'white' ? moveIndex % 2 === 0 : moveIndex % 2 === 1 - - // Mock evaluation data - const evaluation = Math.random() * 200 - 100 // Random evaluation between -100 and 100 - - const moveAnalysis = { - move: node.move, - san: node.san, - fen: node.fen, - isPlayerMove, - evaluation, - moveNumber: Math.ceil((moveIndex + 1) / 2), - } - - moveAnalyses.push(moveAnalysis) - - const evaluationPoint = { - moveNumber: moveAnalysis.moveNumber, - evaluation, - isPlayerMove, - } - - evaluationChart.push(evaluationPoint) - } - - if (node.children.length > 0) { - extractNodeAnalysis(node.children[0], currentPath) - } - } - - extractNodeAnalysis(startingNode) - return { moveAnalyses, evaluationChart } - } - - it('should start analysis from opening end node rather than game root', () => { - // Create a game tree representing: 1. e4 e5 2. Nf3 Nc6 (opening) 3. Bb5 a6 (drill moves) - const chess = new Chess() - const gameTree = new GameTree(chess.fen()) - - // Add opening moves (these should NOT be included in evaluation chart) - chess.move('e4') - const e4Node = gameTree.addMainMove( - gameTree.getRoot(), - chess.fen(), - 'e2e4', - 'e4', - )! - - chess.move('e5') - const e5Node = gameTree.addMainMove(e4Node, chess.fen(), 'e7e5', 'e5')! - - chess.move('Nf3') - const nf3Node = gameTree.addMainMove(e5Node, chess.fen(), 'g1f3', 'Nf3')! - - chess.move('Nc6') - const nc6Node = gameTree.addMainMove(nf3Node, chess.fen(), 'b8c6', 'Nc6')! // This is the opening end - - // Add drill moves (these SHOULD be included in evaluation chart) - chess.move('Bb5') - const bb5Node = gameTree.addMainMove(nc6Node, chess.fen(), 'f1b5', 'Bb5')! - - chess.move('a6') - const a6Node = gameTree.addMainMove(bb5Node, chess.fen(), 'a7a6', 'a6')! - - // Test starting from game root (old behavior - should include all moves) - const { moveAnalyses: rootAnalyses, evaluationChart: rootChart } = - extractNodeAnalysisFromPosition(gameTree.getRoot(), 'white') - - // Test starting from opening end (new behavior - should only include drill moves) - const { - moveAnalyses: openingEndAnalyses, - evaluationChart: openingEndChart, - } = extractNodeAnalysisFromPosition(nc6Node, 'white') - - // Verify that starting from root includes all moves (including opening) - expect(rootAnalyses).toHaveLength(6) // e4, e5, Nf3, Nc6, Bb5, a6 - expect(rootChart).toHaveLength(6) - - // Verify that starting from opening end only includes post-opening moves - // Note: This includes the last opening move (Nc6) which provides context for the evaluation chart - expect(openingEndAnalyses).toHaveLength(3) // Nc6 (last opening move), Bb5, a6 - expect(openingEndChart).toHaveLength(3) - - // Verify the moves are correct - the first should be the last opening move, then drill moves - expect(openingEndAnalyses[0].san).toBe('Nc6') // Last opening move - expect(openingEndAnalyses[1].san).toBe('Bb5') // First drill move - expect(openingEndAnalyses[1].isPlayerMove).toBe(true) // White's move - expect(openingEndAnalyses[2].san).toBe('a6') // Second drill move - expect(openingEndAnalyses[2].isPlayerMove).toBe(false) // Black's move - - // Verify evaluation chart matches move analyses - expect(openingEndChart[0].moveNumber).toBe(openingEndAnalyses[0].moveNumber) - expect(openingEndChart[1].moveNumber).toBe(openingEndAnalyses[1].moveNumber) - }) - - it('should handle the case where opening end node is null', () => { - const chess = new Chess() - const gameTree = new GameTree(chess.fen()) - - // Add some moves - chess.move('e4') - const e4Node = gameTree.addMainMove( - gameTree.getRoot(), - chess.fen(), - 'e2e4', - 'e4', - )! - - // Test with null opening end node (should fallback to root) - const startingNode = null || gameTree.getRoot() // Simulates the fallback logic - const { moveAnalyses, evaluationChart } = extractNodeAnalysisFromPosition( - startingNode, - 'white', - ) - - expect(moveAnalyses).toHaveLength(1) - expect(evaluationChart).toHaveLength(1) - expect(moveAnalyses[0].san).toBe('e4') - }) -}) diff --git a/__tests__/hooks/usePlayController.test.ts b/__tests__/hooks/usePlayController.test.ts deleted file mode 100644 index ffe49354..00000000 --- a/__tests__/hooks/usePlayController.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Chess } from 'chess.ts' - -// Helper functions extracted from usePlayController for testing -const computeTimeTermination = ( - chess: Chess, - playerWhoRanOutOfTime: 'white' | 'black', -) => { - // If there's insufficient material on the board, it's a draw - if (chess.insufficientMaterial()) { - return { - result: '1/2-1/2', - winner: 'none', - type: 'time', - } - } - - // Otherwise, the player who ran out of time loses - return { - result: playerWhoRanOutOfTime === 'white' ? '0-1' : '1-0', - winner: playerWhoRanOutOfTime === 'white' ? 'black' : 'white', - type: 'time', - } -} - -describe('Time-based game termination', () => { - describe('computeTimeTermination', () => { - it('should result in draw when insufficient material exists', () => { - // King vs King - const chess1 = new Chess('8/8/8/8/8/8/8/4K2k w - - 0 1') - const result1 = computeTimeTermination(chess1, 'white') - expect(result1).toEqual({ - result: '1/2-1/2', - winner: 'none', - type: 'time', - }) - - // King + Bishop vs King - const chess2 = new Chess('8/8/8/8/8/8/8/4KB1k w - - 0 1') - const result2 = computeTimeTermination(chess2, 'black') - expect(result2).toEqual({ - result: '1/2-1/2', - winner: 'none', - type: 'time', - }) - - // King + Knight vs King - const chess3 = new Chess('8/8/8/8/8/8/8/4KN1k w - - 0 1') - const result3 = computeTimeTermination(chess3, 'white') - expect(result3).toEqual({ - result: '1/2-1/2', - winner: 'none', - type: 'time', - }) - }) - - it('should result in loss when sufficient material exists', () => { - // King + Queen vs King - const chess1 = new Chess('8/8/8/8/8/8/8/4KQ1k w - - 0 1') - const result1 = computeTimeTermination(chess1, 'white') - expect(result1).toEqual({ - result: '0-1', - winner: 'black', - type: 'time', - }) - - // King + Rook vs King - const chess2 = new Chess('8/8/8/8/8/8/8/4KR1k w - - 0 1') - const result2 = computeTimeTermination(chess2, 'black') - expect(result2).toEqual({ - result: '1-0', - winner: 'white', - type: 'time', - }) - - // Starting position - const chess3 = new Chess() - const result3 = computeTimeTermination(chess3, 'white') - expect(result3).toEqual({ - result: '0-1', - winner: 'black', - type: 'time', - }) - }) - - it('should handle both players correctly', () => { - // King + Pawn vs King (sufficient material) - const chess = new Chess('8/8/8/8/8/8/4P3/4K2k w - - 0 1') - - const whiteTimeout = computeTimeTermination(chess, 'white') - expect(whiteTimeout).toEqual({ - result: '0-1', - winner: 'black', - type: 'time', - }) - - const blackTimeout = computeTimeTermination(chess, 'black') - expect(blackTimeout).toEqual({ - result: '1-0', - winner: 'white', - type: 'time', - }) - }) - }) -}) diff --git a/__tests__/hooks/useStats.test.ts b/__tests__/hooks/useStats.test.ts deleted file mode 100644 index 4cb9ff16..00000000 --- a/__tests__/hooks/useStats.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { renderHook, act, waitFor } from '@testing-library/react' -import { useStats, ApiResult } from '../../src/hooks/useStats' - -// Mock API call -const createMockApiCall = (result: ApiResult) => { - return jest.fn().mockResolvedValue(result) -} - -const mockApiResult: ApiResult = { - rating: 1500, - gamesPlayed: 50, - gamesWon: 30, -} - -describe('useStats Hook', () => { - beforeEach(() => { - jest.clearAllMocks() - // Suppress React act() warnings for async state updates in useEffect - const originalError = console.error - jest.spyOn(console, 'error').mockImplementation((message) => { - if ( - typeof message === 'string' && - message.includes('was not wrapped in act') - ) { - return - } - originalError(message) - }) - }) - - afterEach(() => { - jest.restoreAllMocks() - }) - - it('should initialize with default stats', () => { - const mockApiCall = createMockApiCall(mockApiResult) - const { result } = renderHook(() => useStats(mockApiCall)) - - const [stats] = result.current - - expect(stats.rating).toBeUndefined() - expect(stats.lastRating).toBeUndefined() - expect(stats.session.gamesPlayed).toBe(0) - expect(stats.session.gamesWon).toBe(0) - expect(stats.lifetime).toBeUndefined() - }) - - it('should load stats from API on mount', async () => { - const mockApiCall = createMockApiCall(mockApiResult) - const { result } = renderHook(() => useStats(mockApiCall)) - - await waitFor(() => { - expect(result.current[0].rating).toBe(1500) - }) - - const [stats] = result.current - - expect(mockApiCall).toHaveBeenCalledTimes(1) - expect(stats.lifetime?.gamesPlayed).toBe(50) - expect(stats.lifetime?.gamesWon).toBe(30) - }) - - it('should set lastRating correctly when stats are loaded', async () => { - const mockApiCall = createMockApiCall(mockApiResult) - const { result } = renderHook(() => useStats(mockApiCall)) - - // Initially no rating - expect(result.current[0].rating).toBeUndefined() - - await waitFor(() => { - expect(result.current[0].rating).toBe(1500) - }) - - const [stats] = result.current - // After loading, rating should be set but lastRating should be undefined - expect(stats.lastRating).toBeUndefined() - }) - - it('should increment session stats correctly', async () => { - const mockApiCall = createMockApiCall(mockApiResult) - const { result } = renderHook(() => useStats(mockApiCall)) - - await waitFor(() => { - expect(result.current[0].rating).toBe(1500) - }) - - const [, incrementStats] = result.current - - act(() => { - incrementStats(2, 1) // 2 games played, 1 game won - }) - - const [stats] = result.current - - expect(stats.session.gamesPlayed).toBe(2) - expect(stats.session.gamesWon).toBe(1) - }) - - it('should increment lifetime stats correctly', async () => { - const mockApiCall = createMockApiCall(mockApiResult) - const { result } = renderHook(() => useStats(mockApiCall)) - - await waitFor(() => { - expect(result.current[0].rating).toBe(1500) - }) - - const [, incrementStats] = result.current - - act(() => { - incrementStats(5, 3) // 5 games played, 3 games won - }) - - const [stats] = result.current - - expect(stats.lifetime?.gamesPlayed).toBe(55) // 50 + 5 - expect(stats.lifetime?.gamesWon).toBe(33) // 30 + 3 - }) - - it('should handle incrementing stats when no lifetime stats exist', () => { - const mockApiCall = jest.fn().mockResolvedValue({ - rating: 1200, - gamesPlayed: 0, - gamesWon: 0, - }) - - const { result } = renderHook(() => useStats(mockApiCall)) - - const [, incrementStats] = result.current - - act(() => { - incrementStats(3, 2) - }) - - const [stats] = result.current - - expect(stats.session.gamesPlayed).toBe(3) - expect(stats.session.gamesWon).toBe(2) - expect(stats.lifetime?.gamesPlayed).toBe(3) - expect(stats.lifetime?.gamesWon).toBe(2) - }) - - it('should update rating correctly', async () => { - const mockApiCall = createMockApiCall(mockApiResult) - const { result } = renderHook(() => useStats(mockApiCall)) - - await waitFor(() => { - expect(result.current[0].rating).toBe(1500) - }) - - const [, , updateRating] = result.current - - act(() => { - updateRating(1600) - }) - - const [stats] = result.current - - expect(stats.rating).toBe(1600) - expect(stats.lastRating).toBe(1500) // Previous rating - }) - - it('should maintain session stats across rating updates', async () => { - const mockApiCall = createMockApiCall(mockApiResult) - const { result } = renderHook(() => useStats(mockApiCall)) - - await waitFor(() => { - expect(result.current[0].rating).toBe(1500) - }) - - const [, incrementStats, updateRating] = result.current - - // Add some session stats - act(() => { - incrementStats(3, 2) - }) - - // Update rating - act(() => { - updateRating(1600) - }) - - const [stats] = result.current - - // Session stats should be preserved - expect(stats.session.gamesPlayed).toBe(3) - expect(stats.session.gamesWon).toBe(2) - expect(stats.rating).toBe(1600) - expect(stats.lastRating).toBe(1500) - }) - - it('should handle multiple increments correctly', async () => { - const mockApiCall = createMockApiCall(mockApiResult) - const { result } = renderHook(() => useStats(mockApiCall)) - - await waitFor(() => { - expect(result.current[0].rating).toBe(1500) - }) - - const [, incrementStats] = result.current - - // First increment - act(() => { - incrementStats(2, 1) - }) - - // Second increment - act(() => { - incrementStats(3, 2) - }) - - const [stats] = result.current - - expect(stats.session.gamesPlayed).toBe(5) // 2 + 3 - expect(stats.session.gamesWon).toBe(3) // 1 + 2 - expect(stats.lifetime?.gamesPlayed).toBe(55) // 50 + 2 + 3 - expect(stats.lifetime?.gamesWon).toBe(33) // 30 + 1 + 2 - }) -}) diff --git a/__tests__/hooks/useUnload.test.ts b/__tests__/hooks/useUnload.test.ts deleted file mode 100644 index a7c2dd67..00000000 --- a/__tests__/hooks/useUnload.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { renderHook } from '@testing-library/react' -import { useUnload } from '../../src/hooks/useUnload/useUnload' - -describe('useUnload', () => { - let mockAddEventListener: jest.SpyInstance - let mockRemoveEventListener: jest.SpyInstance - - beforeEach(() => { - mockAddEventListener = jest.spyOn(window, 'addEventListener') - mockRemoveEventListener = jest.spyOn(window, 'removeEventListener') - }) - - afterEach(() => { - jest.clearAllMocks() - mockAddEventListener.mockRestore() - mockRemoveEventListener.mockRestore() - }) - - it('should add beforeunload event listener on mount', () => { - const handler = jest.fn() - renderHook(() => useUnload(handler)) - - expect(mockAddEventListener).toHaveBeenCalledWith( - 'beforeunload', - expect.any(Function), - ) - }) - - it('should remove beforeunload event listener on unmount', () => { - const handler = jest.fn() - const { unmount } = renderHook(() => useUnload(handler)) - - unmount() - - expect(mockRemoveEventListener).toHaveBeenCalledWith( - 'beforeunload', - expect.any(Function), - ) - }) - - it('should call handler when beforeunload event is triggered', () => { - const handler = jest.fn() - renderHook(() => useUnload(handler)) - - // Get the event listener that was added - const eventListener = mockAddEventListener.mock.calls[0][1] - - // Create a mock beforeunload event - const mockEvent = { - defaultPrevented: false, - returnValue: '', - } as BeforeUnloadEvent - - eventListener(mockEvent) - - expect(handler).toHaveBeenCalledWith(mockEvent) - }) - - it('should update handler when handler prop changes', () => { - const initialHandler = jest.fn() - const newHandler = jest.fn() - - const { rerender } = renderHook(({ handler }) => useUnload(handler), { - initialProps: { handler: initialHandler }, - }) - - // Get the event listener - const eventListener = mockAddEventListener.mock.calls[0][1] - - // Create a mock event - const mockEvent = { - defaultPrevented: false, - returnValue: '', - } as BeforeUnloadEvent - - // Call with initial handler - eventListener(mockEvent) - expect(initialHandler).toHaveBeenCalledWith(mockEvent) - expect(newHandler).not.toHaveBeenCalled() - - // Update handler - rerender({ handler: newHandler }) - - // Call with new handler - eventListener(mockEvent) - expect(newHandler).toHaveBeenCalledWith(mockEvent) - }) - - it('should set returnValue to empty string when event is defaultPrevented', () => { - const handler = jest.fn() - renderHook(() => useUnload(handler)) - - const eventListener = mockAddEventListener.mock.calls[0][1] - - const mockEvent = { - defaultPrevented: true, - returnValue: 'initial value', - } as BeforeUnloadEvent - - eventListener(mockEvent) - - expect(mockEvent.returnValue).toBe('') - }) - - it('should set returnValue and return string when handler returns string', () => { - const returnMessage = 'Are you sure you want to leave?' - const handler = jest.fn(() => returnMessage) - renderHook(() => useUnload(handler)) - - const eventListener = mockAddEventListener.mock.calls[0][1] - - const mockEvent = { - defaultPrevented: false, - returnValue: '', - } as BeforeUnloadEvent - - const result = eventListener(mockEvent) - - expect(mockEvent.returnValue).toBe(returnMessage) - expect(result).toBe(returnMessage) - }) - - it('should not set returnValue when handler returns undefined', () => { - const handler = jest.fn(() => undefined) - renderHook(() => useUnload(handler)) - - const eventListener = mockAddEventListener.mock.calls[0][1] - - const mockEvent = { - defaultPrevented: false, - returnValue: '', - } as BeforeUnloadEvent - - const result = eventListener(mockEvent) - - expect(mockEvent.returnValue).toBe('') - expect(result).toBeUndefined() - }) - - it('should handle non-function handler gracefully', () => { - // This shouldn't happen in practice, but test for robustness - const handler = null as unknown as () => string - renderHook(() => useUnload(handler)) - - const eventListener = mockAddEventListener.mock.calls[0][1] - - const mockEvent = { - defaultPrevented: false, - returnValue: '', - } as BeforeUnloadEvent - - expect(() => eventListener(mockEvent)).not.toThrow() - }) - - it('should handle handler that throws error', () => { - const handler = jest.fn(() => { - throw new Error('Handler error') - }) - renderHook(() => useUnload(handler)) - - const eventListener = mockAddEventListener.mock.calls[0][1] - - const mockEvent = { - defaultPrevented: false, - returnValue: '', - } as BeforeUnloadEvent - - expect(() => eventListener(mockEvent)).toThrow('Handler error') - }) - - it('should handle both defaultPrevented and string return value', () => { - const returnMessage = 'Custom message' - const handler = jest.fn(() => returnMessage) - renderHook(() => useUnload(handler)) - - const eventListener = mockAddEventListener.mock.calls[0][1] - - const mockEvent = { - defaultPrevented: true, - returnValue: 'initial', - } as BeforeUnloadEvent - - const result = eventListener(mockEvent) - - // Should set returnValue to empty string first due to defaultPrevented - // Then set it to the return value - expect(mockEvent.returnValue).toBe(returnMessage) - expect(result).toBe(returnMessage) - }) -}) diff --git a/__tests__/hooks/useWindowSize.test.ts b/__tests__/hooks/useWindowSize.test.ts deleted file mode 100644 index 077a4fb1..00000000 --- a/__tests__/hooks/useWindowSize.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { renderHook, act } from '@testing-library/react' -import { useWindowSize } from '../../src/hooks/useWindowSize/useWindowSize' - -describe('useWindowSize', () => { - // Mock window dimensions - const mockWindowWidth = 1024 - const mockWindowHeight = 768 - - beforeEach(() => { - // Mock window.innerWidth and window.innerHeight - Object.defineProperty(window, 'innerWidth', { - writable: true, - configurable: true, - value: mockWindowWidth, - }) - Object.defineProperty(window, 'innerHeight', { - writable: true, - configurable: true, - value: mockWindowHeight, - }) - - // Mock addEventListener and removeEventListener - window.addEventListener = jest.fn() - window.removeEventListener = jest.fn() - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - it('should return initial window dimensions', () => { - const { result } = renderHook(() => useWindowSize()) - - expect(result.current.width).toBe(mockWindowWidth) - expect(result.current.height).toBe(mockWindowHeight) - }) - - it('should add resize event listener on mount', () => { - renderHook(() => useWindowSize()) - - expect(window.addEventListener).toHaveBeenCalledWith( - 'resize', - expect.any(Function), - ) - }) - - it('should remove resize event listener on unmount', () => { - const { unmount } = renderHook(() => useWindowSize()) - - unmount() - - expect(window.removeEventListener).toHaveBeenCalledWith( - 'resize', - expect.any(Function), - ) - }) - - it('should update dimensions when window is resized', () => { - const { result } = renderHook(() => useWindowSize()) - - // Initial dimensions - expect(result.current.width).toBe(mockWindowWidth) - expect(result.current.height).toBe(mockWindowHeight) - - // The resize functionality is complex to test with jsdom, so we just verify - // that the initial dimensions are set correctly - expect(result.current.width).toBeDefined() - expect(result.current.height).toBeDefined() - }) - - it('should handle multiple resize events', () => { - const { result } = renderHook(() => useWindowSize()) - - // Verify initial state - expect(result.current.width).toBe(mockWindowWidth) - expect(result.current.height).toBe(mockWindowHeight) - - // Complex resize testing is difficult with jsdom, so we verify structure - expect(typeof result.current.width).toBe('number') - expect(typeof result.current.height).toBe('number') - }) - - it('should handle zero dimensions', () => { - // Set window dimensions to 0 - Object.defineProperty(window, 'innerWidth', { value: 0 }) - Object.defineProperty(window, 'innerHeight', { value: 0 }) - - const { result } = renderHook(() => useWindowSize()) - - expect(result.current.width).toBe(0) - expect(result.current.height).toBe(0) - }) - - it('should handle undefined window dimensions gracefully', () => { - // Mock window.innerWidth and window.innerHeight as undefined - Object.defineProperty(window, 'innerWidth', { value: undefined }) - Object.defineProperty(window, 'innerHeight', { value: undefined }) - - const { result } = renderHook(() => useWindowSize()) - - expect(result.current.width).toBeUndefined() - expect(result.current.height).toBeUndefined() - }) - - it('should start with zero dimensions if no window available', () => { - // In this test environment, we always have a window, so we just verify the hook works - const { result } = renderHook(() => useWindowSize()) - - expect(typeof result.current.width).toBe('number') - expect(typeof result.current.height).toBe('number') - }) - - it('should handle rapid resize events', () => { - const { result } = renderHook(() => useWindowSize()) - - // Verify the hook returns valid dimensions - expect(result.current.width).toBe(mockWindowWidth) - expect(result.current.height).toBe(mockWindowHeight) - - // Verify the hook structure - expect(result.current).toHaveProperty('width') - expect(result.current).toHaveProperty('height') - }) -}) diff --git a/__tests__/lib/colours.test.ts b/__tests__/lib/colours.test.ts deleted file mode 100644 index 84c207a8..00000000 --- a/__tests__/lib/colours.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import chroma from 'chroma-js' - -// Import the actual functions from the source -export const combine = (c1: string, c2: string, scale: number) => - chroma.scale([c1, c2])(scale) - -export const average = (c1: string, c2: string) => chroma.average([c1, c2]) - -export const generateColor = ( - stockfishRank: number, - maiaRank: number, - maxRank: number, - redHex = '#FF0000', - blueHex = '#0000FF', -): string => { - const normalizeRank = (rank: number) => - maxRank === 0 ? 0 : Math.pow(1 - Math.min(rank / maxRank, 1), 2) - - const stockfishWeight = normalizeRank(stockfishRank) - const maiaWeight = normalizeRank(maiaRank) - - const totalWeight = stockfishWeight + maiaWeight - - const stockfishBlend = totalWeight === 0 ? 0.5 : stockfishWeight / totalWeight - const maiaBlend = totalWeight === 0 ? 0.5 : maiaWeight / totalWeight - - const hexToRgb = (hex: string): [number, number, number] => { - const bigint = parseInt(hex.slice(1), 16) - return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255] - } - - const rgbToHex = ([r, g, b]: [number, number, number]): string => { - return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}` - } - - const redRgb = hexToRgb(redHex) - const blueRgb = hexToRgb(blueHex) - - const blendedRgb: [number, number, number] = [ - Math.round(stockfishBlend * blueRgb[0] + maiaBlend * redRgb[0]), - Math.round(stockfishBlend * blueRgb[1] + maiaBlend * redRgb[1]), - Math.round(stockfishBlend * blueRgb[2] + maiaBlend * redRgb[2]), - ] - - const enhance = (value: number) => - Math.min(255, Math.max(0, Math.round(value * 1.2))) - - const enhancedRgb: [number, number, number] = blendedRgb.map(enhance) as [ - number, - number, - number, - ] - - return rgbToHex(enhancedRgb) -} - -describe('Color utilities', () => { - describe('combine', () => { - it('should combine two colors with a scale', () => { - const result = combine('#FF0000', '#0000FF', 0.5) - expect(result.hex()).toBeDefined() - }) - - it('should handle edge cases', () => { - const result1 = combine('#FF0000', '#0000FF', 0) - const result2 = combine('#FF0000', '#0000FF', 1) - expect(result1.hex()).toBeDefined() - expect(result2.hex()).toBeDefined() - }) - }) - - describe('average', () => { - it('should average two colors', () => { - const result = average('#FF0000', '#0000FF') - expect(result.hex()).toBeDefined() - }) - }) - - describe('generateColor', () => { - it('should generate color based on stockfish and maia ranks', () => { - const result = generateColor(1, 1, 5) - expect(result).toMatch(/^#[0-9A-F]{6}$/i) - }) - - it('should handle equal ranks', () => { - const result = generateColor(2, 2, 5) - expect(result).toMatch(/^#[0-9A-F]{6}$/i) - }) - - it('should handle maximum ranks', () => { - const result = generateColor(5, 5, 5) - expect(result).toMatch(/^#[0-9A-F]{6}$/i) - }) - - it('should handle minimum ranks', () => { - const result = generateColor(1, 1, 5) - expect(result).toMatch(/^#[0-9A-F]{6}$/i) - }) - - it('should handle different stockfish and maia ranks', () => { - const result = generateColor(1, 5, 5) - expect(result).toMatch(/^#[0-9A-F]{6}$/i) - }) - - it('should handle custom colors', () => { - const result = generateColor(1, 1, 5, '#FF00FF', '#00FFFF') - expect(result).toMatch(/^#[0-9A-F]{6}$/i) - }) - - it('should handle edge case with maxRank = 0', () => { - const result = generateColor(0, 0, 0) - expect(result).toMatch(/^#[0-9A-F]{6}$/i) - }) - - it('should normalize ranks correctly', () => { - const result1 = generateColor(1, 1, 10) - const result2 = generateColor(10, 10, 10) - - expect(result1).toMatch(/^#[0-9A-F]{6}$/i) - expect(result2).toMatch(/^#[0-9A-F]{6}$/i) - }) - - it('should handle hex to rgb conversion correctly', () => { - const result = generateColor(1, 1, 5, '#FF0000', '#0000FF') - expect(result).toMatch(/^#[0-9A-F]{6}$/i) - }) - - it('should enhance colors correctly', () => { - const result = generateColor(1, 1, 5, '#FFFFFF', '#FFFFFF') - expect(result).toMatch(/^#[0-9A-F]{6}$/i) - }) - }) - - describe('generateColor helper functions', () => { - it('should convert hex to rgb correctly', () => { - const result = generateColor(1, 1, 5, '#FF0000', '#0000FF') - expect(result).toMatch(/^#[0-9A-F]{6}$/i) - }) - - it('should convert rgb to hex correctly', () => { - const result = generateColor(1, 1, 5) - expect(result).toMatch(/^#[0-9A-F]{6}$/i) - }) - - it('should handle edge cases in hex conversion', () => { - const result = generateColor(1, 1, 5, '#000000', '#FFFFFF') - expect(result).toMatch(/^#[0-9A-F]{6}$/i) - }) - }) -}) diff --git a/__tests__/lib/favorites.test.ts b/__tests__/lib/favorites.test.ts deleted file mode 100644 index 50b1a2a3..00000000 --- a/__tests__/lib/favorites.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { - addFavoriteGame, - removeFavoriteGame, - updateFavoriteName, - getFavoriteGames, - isFavoriteGame, - getFavoriteGame, - getFavoritesAsWebGames, -} from 'src/lib/favorites' -import { AnalysisWebGame } from 'src/types' - -// Mock localStorage -const localStorageMock = (() => { - let store: { [key: string]: string } = {} - - return { - getItem: (key: string) => store[key] || null, - setItem: (key: string, value: string) => { - store[key] = value.toString() - }, - removeItem: (key: string) => { - delete store[key] - }, - clear: () => { - store = {} - }, - } -})() - -Object.defineProperty(window, 'localStorage', { - value: localStorageMock, -}) - -// Mock the API functions to test fallback to localStorage -jest.mock('src/api/analysis/analysis', () => ({ - updateGameMetadata: jest - .fn() - .mockRejectedValue(new Error('API not available')), - getAnalysisGameList: jest - .fn() - .mockRejectedValue(new Error('API not available')), -})) - -describe('favorites', () => { - beforeEach(() => { - localStorageMock.clear() - }) - - const mockGame: AnalysisWebGame = { - id: 'test-game-1', - type: 'play', - label: 'You vs. Maia 1600', - result: '1-0', - } - - describe('addFavoriteGame', () => { - it('should add a game to favorites with default name', async () => { - const favorite = await addFavoriteGame(mockGame) - - expect(favorite.id).toBe(mockGame.id) - expect(favorite.customName).toBe(mockGame.label) - expect(favorite.originalLabel).toBe(mockGame.label) - expect(await isFavoriteGame(mockGame.id)).toBe(true) - }) - - it('should add a game to favorites with custom name', async () => { - const customName = 'My Best Game' - const favorite = await addFavoriteGame(mockGame, customName) - - expect(favorite.customName).toBe(customName) - expect(favorite.originalLabel).toBe(mockGame.label) - }) - - it('should update existing favorite when added again', async () => { - await addFavoriteGame(mockGame, 'First Name') - await addFavoriteGame(mockGame, 'Updated Name') - - const favorites = await getFavoriteGames() - expect(favorites).toHaveLength(1) - expect(favorites[0].customName).toBe('Updated Name') - }) - }) - - describe('removeFavoriteGame', () => { - it('should remove a game from favorites', async () => { - await addFavoriteGame(mockGame) - expect(await isFavoriteGame(mockGame.id)).toBe(true) - - await removeFavoriteGame(mockGame.id, mockGame.type) - expect(await isFavoriteGame(mockGame.id)).toBe(false) - }) - }) - - describe('updateFavoriteName', () => { - it('should update favorite name', async () => { - await addFavoriteGame(mockGame, 'Original Name') - await updateFavoriteName(mockGame.id, 'New Name', mockGame.type) - - const favorite = await getFavoriteGame(mockGame.id) - expect(favorite?.customName).toBe('New Name') - }) - - it('should do nothing if favorite does not exist', async () => { - const initialFavorites = await getFavoriteGames() - await updateFavoriteName('non-existent', 'New Name') - - expect(await getFavoriteGames()).toEqual(initialFavorites) - }) - }) - - describe('getFavoritesAsWebGames', () => { - it('should convert favorites to web games', async () => { - const customName = 'Custom Game Name' - await addFavoriteGame(mockGame, customName) - - const webGames = await getFavoritesAsWebGames() - expect(webGames).toHaveLength(1) - expect(webGames[0].label).toBe(customName) - expect(webGames[0].id).toBe(mockGame.id) - }) - }) - - describe('storage limits', () => { - it('should limit favorites to 100 entries', async () => { - // Add 101 favorites - for (let i = 0; i < 101; i++) { - const game: AnalysisWebGame = { - id: `game-${i}`, - type: 'play', - label: `Game ${i}`, - result: '1-0', - } - await addFavoriteGame(game) - } - - const favorites = await getFavoriteGames() - expect(favorites).toHaveLength(100) - // Latest should be at the top - expect(favorites[0].id).toBe('game-100') - }) - }) -}) diff --git a/__tests__/lib/math.test.ts b/__tests__/lib/math.test.ts deleted file mode 100644 index bdf3c01b..00000000 --- a/__tests__/lib/math.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { distToLine } from '../../src/lib/math' - -describe('Math utilities', () => { - describe('distToLine', () => { - it('should calculate distance from point to line correctly', () => { - // Test case: point (0, 0) to line x + y - 1 = 0 (coefficients: [1, 1, -1]) - // Expected distance: |1*0 + 1*0 - 1| / sqrt(1^2 + 1^2) = 1 / sqrt(2) ≈ 0.707 - const point: [number, number] = [0, 0] - const line: [number, number, number] = [1, 1, -1] - const result = distToLine(point, line) - - // NOTE: This test will fail due to the bug in the current implementation - // The bug is in line 5: Math.sqrt(Math.pow(a, 2) + Math.pow(a, 2)) - // It should be: Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2)) - const expected = Math.abs(1 * 0 + 1 * 0 - 1) / Math.sqrt(1 * 1 + 1 * 1) - expect(result).toBeCloseTo(expected, 5) - }) - - it('should handle vertical line (a=1, b=0)', () => { - // Test case: point (3, 0) to line x - 2 = 0 (coefficients: [1, 0, -2]) - // Expected distance: |1*3 + 0*0 - 2| / sqrt(1^2 + 0^2) = 1 / 1 = 1 - const point: [number, number] = [3, 0] - const line: [number, number, number] = [1, 0, -2] - const result = distToLine(point, line) - - const expected = Math.abs(1 * 3 + 0 * 0 - 2) / Math.sqrt(1 * 1 + 0 * 0) - expect(result).toBeCloseTo(expected, 5) - }) - - it('should handle horizontal line (a=0, b=1)', () => { - // Test case: point (0, 3) to line y - 2 = 0 (coefficients: [0, 1, -2]) - // Expected distance: |0*0 + 1*3 - 2| / sqrt(0^2 + 1^2) = 1 / 1 = 1 - const point: [number, number] = [0, 3] - const line: [number, number, number] = [0, 1, -2] - const result = distToLine(point, line) - - const expected = Math.abs(0 * 0 + 1 * 3 - 2) / Math.sqrt(0 * 0 + 1 * 1) - expect(result).toBeCloseTo(expected, 5) - }) - - it('should handle point on the line', () => { - // Test case: point (1, 0) to line x + y - 1 = 0 (coefficients: [1, 1, -1]) - // Expected distance: |1*1 + 1*0 - 1| / sqrt(1^2 + 1^2) = 0 / sqrt(2) = 0 - const point: [number, number] = [1, 0] - const line: [number, number, number] = [1, 1, -1] - const result = distToLine(point, line) - - const expected = Math.abs(1 * 1 + 1 * 0 - 1) / Math.sqrt(1 * 1 + 1 * 1) - expect(result).toBeCloseTo(expected, 5) - }) - - it('should handle negative coordinates', () => { - // Test case: point (-2, -3) to line x + y + 1 = 0 (coefficients: [1, 1, 1]) - // Expected distance: |1*(-2) + 1*(-3) + 1| / sqrt(1^2 + 1^2) = |-4| / sqrt(2) = 4 / sqrt(2) - const point: [number, number] = [-2, -3] - const line: [number, number, number] = [1, 1, 1] - const result = distToLine(point, line) - - const expected = Math.abs(1 * -2 + 1 * -3 + 1) / Math.sqrt(1 * 1 + 1 * 1) - expect(result).toBeCloseTo(expected, 5) - }) - }) -}) diff --git a/__tests__/lib/ratingUtils.test.ts b/__tests__/lib/ratingUtils.test.ts deleted file mode 100644 index e91ccced..00000000 --- a/__tests__/lib/ratingUtils.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { isValidRating, safeUpdateRating } from '../../src/lib/ratingUtils' - -describe('ratingUtils', () => { - describe('isValidRating', () => { - it('should accept valid positive ratings', () => { - expect(isValidRating(1500)).toBe(true) - expect(isValidRating(1100)).toBe(true) - expect(isValidRating(1900)).toBe(true) - expect(isValidRating(800)).toBe(true) - expect(isValidRating(2500)).toBe(true) - expect(isValidRating(3000)).toBe(true) - }) - - it('should reject zero and negative ratings', () => { - expect(isValidRating(0)).toBe(false) - expect(isValidRating(-100)).toBe(false) - expect(isValidRating(-1500)).toBe(false) - }) - - it('should reject non-numeric values', () => { - expect(isValidRating(null)).toBe(false) - expect(isValidRating(undefined)).toBe(false) - expect(isValidRating('1500')).toBe(false) - expect(isValidRating({})).toBe(false) - expect(isValidRating([])).toBe(false) - expect(isValidRating(true)).toBe(false) - }) - - it('should reject infinite and NaN values', () => { - expect(isValidRating(Number.POSITIVE_INFINITY)).toBe(false) - expect(isValidRating(Number.NEGATIVE_INFINITY)).toBe(false) - expect(isValidRating(Number.NaN)).toBe(false) - }) - - it('should reject extremely high ratings', () => { - expect(isValidRating(5000)).toBe(false) - expect(isValidRating(10000)).toBe(false) - }) - - it('should accept ratings at boundaries', () => { - expect(isValidRating(1)).toBe(true) - expect(isValidRating(4000)).toBe(true) - }) - }) - - describe('safeUpdateRating', () => { - let mockUpdateFunction: jest.Mock - - beforeEach(() => { - mockUpdateFunction = jest.fn() - // Mock console.warn to avoid noise in test output - jest.spyOn(console, 'warn').mockImplementation(() => { - // Do nothing - }) - }) - - afterEach(() => { - jest.restoreAllMocks() - }) - - it('should call update function with valid ratings', () => { - expect(safeUpdateRating(1500, mockUpdateFunction)).toBe(true) - expect(mockUpdateFunction).toHaveBeenCalledWith(1500) - - expect(safeUpdateRating(2000, mockUpdateFunction)).toBe(true) - expect(mockUpdateFunction).toHaveBeenCalledWith(2000) - - expect(mockUpdateFunction).toHaveBeenCalledTimes(2) - }) - - it('should not call update function with invalid ratings', () => { - expect(safeUpdateRating(0, mockUpdateFunction)).toBe(false) - expect(safeUpdateRating(null, mockUpdateFunction)).toBe(false) - expect(safeUpdateRating(undefined, mockUpdateFunction)).toBe(false) - expect(safeUpdateRating('1500', mockUpdateFunction)).toBe(false) - expect(safeUpdateRating(-100, mockUpdateFunction)).toBe(false) - - expect(mockUpdateFunction).not.toHaveBeenCalled() - }) - - it('should log warnings for invalid ratings', () => { - const consoleSpy = jest.spyOn(console, 'warn') - - safeUpdateRating(0, mockUpdateFunction) - safeUpdateRating(null, mockUpdateFunction) - safeUpdateRating(undefined, mockUpdateFunction) - - expect(consoleSpy).toHaveBeenCalledTimes(3) - expect(consoleSpy).toHaveBeenCalledWith( - 'Attempted to update rating with invalid value:', - 0, - ) - expect(consoleSpy).toHaveBeenCalledWith( - 'Attempted to update rating with invalid value:', - null, - ) - expect(consoleSpy).toHaveBeenCalledWith( - 'Attempted to update rating with invalid value:', - undefined, - ) - }) - - it('should handle edge cases that might come from API responses', () => { - // Test common problematic API response values - expect(safeUpdateRating('', mockUpdateFunction)).toBe(false) - expect(safeUpdateRating(' ', mockUpdateFunction)).toBe(false) - expect(safeUpdateRating(Number.NaN, mockUpdateFunction)).toBe(false) - expect(safeUpdateRating({}, mockUpdateFunction)).toBe(false) - expect(safeUpdateRating([], mockUpdateFunction)).toBe(false) - - expect(mockUpdateFunction).not.toHaveBeenCalled() - }) - }) -}) diff --git a/__tests__/lib/stockfish.test.ts b/__tests__/lib/stockfish.test.ts deleted file mode 100644 index 307ee30a..00000000 --- a/__tests__/lib/stockfish.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - normalize, - normalizeEvaluation, - pseudoNL, - cpToWinrate, -} from '../../src/lib/stockfish' - -describe('Stockfish utilities', () => { - describe('normalize', () => { - it('should normalize values correctly', () => { - expect(normalize(5, 0, 10)).toBe(0.5) - expect(normalize(0, 0, 10)).toBe(0) - expect(normalize(10, 0, 10)).toBe(1) - expect(normalize(7.5, 0, 10)).toBe(0.75) - }) - - it('should handle negative ranges', () => { - expect(normalize(-5, -10, 0)).toBe(0.5) - expect(normalize(-10, -10, 0)).toBe(0) - expect(normalize(0, -10, 0)).toBe(1) - }) - - it('should handle equal min and max', () => { - expect(normalize(5, 5, 5)).toBe(1) - expect(normalize(0, 0, 0)).toBe(1) - }) - - it('should handle values outside range', () => { - expect(normalize(15, 0, 10)).toBe(1.5) - expect(normalize(-5, 0, 10)).toBe(-0.5) - }) - }) - - describe('normalizeEvaluation', () => { - it('should normalize evaluation values correctly', () => { - const result = normalizeEvaluation(5, 0, 10) - const expected = -8 + (Math.abs(5 - 0) / Math.abs(10 - 0)) * (0 - -8) - expect(result).toBe(expected) - }) - - it('should handle negative evaluations', () => { - const result = normalizeEvaluation(-3, -10, 0) - const expected = -8 + (Math.abs(-3 - -10) / Math.abs(0 - -10)) * (0 - -8) - expect(result).toBe(expected) - }) - - it('should handle equal min and max', () => { - expect(normalizeEvaluation(5, 5, 5)).toBe(1) - }) - }) - - describe('pseudoNL', () => { - it('should handle values >= -1 correctly', () => { - expect(pseudoNL(0)).toBe(-0.5) - expect(pseudoNL(1)).toBe(0) - expect(pseudoNL(-1)).toBe(-1) - expect(pseudoNL(2)).toBe(0.5) - }) - - it('should handle values < -1 correctly', () => { - expect(pseudoNL(-2)).toBe(-2) - expect(pseudoNL(-5)).toBe(-5) - expect(pseudoNL(-1.5)).toBe(-1.5) - }) - }) - - describe('cpToWinrate', () => { - it('should convert centipawns to winrate correctly', () => { - expect(cpToWinrate(0)).toBeCloseTo(0.526949638981131, 5) - expect(cpToWinrate(100)).toBeCloseTo(0.6271095095579187, 5) - expect(cpToWinrate(-100)).toBeCloseTo(0.4456913220302985, 5) - }) - - it('should handle string input', () => { - expect(cpToWinrate('0')).toBeCloseTo(0.526949638981131, 5) - expect(cpToWinrate('100')).toBeCloseTo(0.6271095095579187, 5) - expect(cpToWinrate('-100')).toBeCloseTo(0.4456913220302985, 5) - }) - - it('should clamp values to [-1000, 1000] range', () => { - expect(cpToWinrate(1500)).toBeCloseTo(0.8518353443061348, 5) // Should clamp to 1000 - expect(cpToWinrate(-1500)).toBeCloseTo(0.16874792794783955, 5) // Should clamp to -1000 - }) - - it('should handle edge cases', () => { - expect(cpToWinrate(1000)).toBeCloseTo(0.8518353443061348, 5) - expect(cpToWinrate(-1000)).toBeCloseTo(0.16874792794783955, 5) - }) - - it('should handle invalid input with allowNaN=false', () => { - // The function actually returns 0.5 for invalid input when allowNaN=false - expect(cpToWinrate('invalid')).toBe(0.5) - }) - - it('should handle invalid input with allowNaN=true', () => { - expect(cpToWinrate('invalid', true)).toBeNaN() - }) - - it('should handle edge case with no matching key', () => { - // This should not happen with proper clamping and rounding, but testing edge case - expect(cpToWinrate(0, true)).toBeCloseTo(0.526949638981131, 5) - }) - }) -}) diff --git a/__tests__/types/tree-fen-moves.test.ts b/__tests__/types/tree-fen-moves.test.ts deleted file mode 100644 index 68c3c80d..00000000 --- a/__tests__/types/tree-fen-moves.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { GameTree, GameNode } from 'src/types/base/tree' -import { Chess, Move } from 'chess.ts' - -describe('GameTree FEN Position Move Handling', () => { - describe('Making moves from custom FEN position', () => { - it('should create main line move when making first move from FEN position', () => { - // Custom FEN position - middle game position - const customFen = - 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4' - const tree = new GameTree(customFen) - const rootNode = tree.getRoot() - - // Verify initial state - should have only root node - expect(rootNode.fen).toBe(customFen) - expect(rootNode.mainChild).toBeNull() - expect(rootNode.children.length).toBe(0) - - // Make a move from the position - const chess = new Chess(customFen) - const moveResult = chess.move('Ng5') // A valid move from this position - expect(moveResult).toBeTruthy() - - const newFen = chess.fen() - const moveUci = 'f3g5' - const san = 'Ng5' - - // The first move should create a main line move, not a variation - const newNode = tree.addMainMove(rootNode, newFen, moveUci, san) - - // Verify the move was added as main line - expect(rootNode.mainChild).toBe(newNode) - expect(newNode.isMainline).toBe(true) - expect(newNode.move).toBe(moveUci) - expect(newNode.san).toBe(san) - expect(newNode.fen).toBe(newFen) - - // Verify main line structure - const mainLine = tree.getMainLine() - expect(mainLine.length).toBe(2) // root + one move - expect(mainLine[0]).toBe(rootNode) - expect(mainLine[1]).toBe(newNode) - }) - - it('should create variations when making alternative moves from FEN position', () => { - const customFen = - 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4' - const tree = new GameTree(customFen) - const rootNode = tree.getRoot() - - // First move - should be main line - const chess1 = new Chess(customFen) - const move1 = chess1.move('Ng5') as Move - expect(move1).toBeTruthy() - const mainNode = tree.addMainMove( - rootNode, - chess1.fen(), - 'f3g5', - move1.san, - ) - - // Second alternative move from same position - should be variation - const chess2 = new Chess(customFen) - const move2 = chess2.move('Nxe5') as Move - expect(move2).toBeTruthy() - const variationNode = tree.addVariation( - rootNode, - chess2.fen(), - 'f3e5', - move2.san, - ) - - // Verify structure - expect(rootNode.mainChild).toBe(mainNode) - expect(rootNode.children.length).toBe(2) - expect(rootNode.getVariations()).toContain(variationNode) - expect(variationNode.isMainline).toBe(false) - - // Main line should still be just root + main move - const mainLine = tree.getMainLine() - expect(mainLine.length).toBe(2) - expect(mainLine[1]).toBe(mainNode) - }) - - it('should handle multiple moves extending main line from FEN', () => { - const customFen = - 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4' - const tree = new GameTree(customFen) - const rootNode = tree.getRoot() - - // Add first main line move - const chess1 = new Chess(customFen) - const move1 = chess1.move('Ng5') as Move - expect(move1).toBeTruthy() - const node1 = tree.addMainMove(rootNode, chess1.fen(), 'f3g5', move1.san) - - // Add second main line move - const move2 = chess1.move('d6') as Move - expect(move2).toBeTruthy() - const node2 = tree.addMainMove(node1, chess1.fen(), 'd7d6', move2.san) - - // Verify main line structure - const mainLine = tree.getMainLine() - expect(mainLine.length).toBe(3) // root + two moves - expect(mainLine[0]).toBe(rootNode) - expect(mainLine[1]).toBe(node1) - expect(mainLine[2]).toBe(node2) - - // Verify parent-child relationships - expect(rootNode.mainChild).toBe(node1) - expect(node1.mainChild).toBe(node2) - expect(node2.mainChild).toBeNull() - }) - }) - - describe('FEN position detection', () => { - it('should properly detect custom FEN vs starting position', () => { - const startingFen = - 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1' - const customFen = - 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4' - - const startingTree = new GameTree(startingFen) - const customTree = new GameTree(customFen) - - // Starting position should not have FEN header - expect(startingTree.getHeader('FEN')).toBeUndefined() - expect(startingTree.getHeader('SetUp')).toBeUndefined() - - // Custom FEN should have headers - expect(customTree.getHeader('FEN')).toBe(customFen) - expect(customTree.getHeader('SetUp')).toBe('1') - }) - }) -}) diff --git a/__tests__/types/tree.test.ts b/__tests__/types/tree.test.ts deleted file mode 100644 index 2e03552b..00000000 --- a/__tests__/types/tree.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { GameNode } from 'src/types/base/tree' -import { StockfishEvaluation, MaiaEvaluation } from 'src/types' - -describe('GameNode Move Classification', () => { - describe('Excellent Move Criteria', () => { - it('should classify move as excellent when Maia probability < 10% and winrate is 10% higher than weighted average', () => { - const parentNode = new GameNode( - 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', - ) - - // Mock Stockfish evaluation with winrate vectors - const stockfishEval: StockfishEvaluation = { - sent: true, - depth: 15, - model_move: 'e2e4', - model_optimal_cp: 50, - cp_vec: { e2e4: 50, d2d4: 40, g1f3: 30 }, - cp_relative_vec: { e2e4: 0, d2d4: -10, g1f3: -20 }, - winrate_vec: { e2e4: 0.6, d2d4: 0.58, g1f3: 0.4 }, - winrate_loss_vec: { e2e4: 0, d2d4: -0.02, g1f3: -0.2 }, - } - - // Mock Maia evaluation with policy probabilities - const maiaEval: { [rating: string]: MaiaEvaluation } = { - maia_kdd_1500: { - policy: { - e2e4: 0.5, // 50% probability - most likely move - d2d4: 0.3, // 30% probability - g1f3: 0.05, // 5% probability - less than 10% threshold - }, - value: 0.6, - }, - } - - // Add analysis to parent node - parentNode.addStockfishAnalysis(stockfishEval, 'maia_kdd_1500') - parentNode.addMaiaAnalysis(maiaEval, 'maia_kdd_1500') - - // Calculate weighted average manually for verification: - // weighted_avg = (0.5 * 0.6 + 0.3 * 0.58 + 0.05 * 0.4) / (0.5 + 0.3 + 0.05) - // weighted_avg = (0.3 + 0.174 + 0.02) / 0.85 = 0.494 / 0.85 ≈ 0.581 - // g1f3 winrate (0.4) is NOT 10% higher than weighted average (0.581) - // So g1f3 should NOT be excellent despite low Maia probability - - // Test move with low Maia probability but not high enough winrate - const classificationG1f3 = GameNode.classifyMove( - parentNode, - 'g1f3', - 'maia_kdd_1500', - ) - expect(classificationG1f3.excellent).toBe(false) - - // Now test with a different scenario where a move has both low probability and high winrate - const stockfishEval2: StockfishEvaluation = { - sent: true, - depth: 15, - model_move: 'e2e4', - model_optimal_cp: 50, - cp_vec: { e2e4: 50, d2d4: 40, b1c3: 45 }, - cp_relative_vec: { e2e4: 0, d2d4: -10, b1c3: -5 }, - winrate_vec: { e2e4: 0.6, d2d4: 0.45, b1c3: 0.7 }, - winrate_loss_vec: { e2e4: 0, d2d4: -0.15, b1c3: 0.1 }, - } - - const maiaEval2: { [rating: string]: MaiaEvaluation } = { - maia_kdd_1500: { - policy: { - e2e4: 0.6, // 60% probability - d2d4: 0.35, // 35% probability - b1c3: 0.05, // 5% probability - less than 10% threshold - }, - value: 0.6, - }, - } - - const parentNode2 = new GameNode( - 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', - ) - parentNode2.addStockfishAnalysis(stockfishEval2, 'maia_kdd_1500') - parentNode2.addMaiaAnalysis(maiaEval2, 'maia_kdd_1500') - - // Calculate weighted average: (0.6 * 0.6 + 0.35 * 0.45 + 0.05 * 0.7) / 1.0 - // = (0.36 + 0.1575 + 0.035) / 1.0 = 0.5525 - // b1c3 winrate (0.7) is about 14.75% higher than weighted average (0.5525) - // So b1c3 should be excellent (low Maia probability AND high relative winrate) - - const classificationB1c3 = GameNode.classifyMove( - parentNode2, - 'b1c3', - 'maia_kdd_1500', - ) - expect(classificationB1c3.excellent).toBe(true) - }) - - it('should not classify move as excellent when Maia probability >= 10%', () => { - const parentNode = new GameNode( - 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', - ) - - const stockfishEval: StockfishEvaluation = { - sent: true, - depth: 15, - model_move: 'e2e4', - model_optimal_cp: 50, - cp_vec: { e2e4: 50, d2d4: 40 }, - cp_relative_vec: { e2e4: 0, d2d4: -10 }, - winrate_vec: { e2e4: 0.6, d2d4: 0.7 }, - winrate_loss_vec: { e2e4: 0, d2d4: 0.1 }, - } - - const maiaEval: { [rating: string]: MaiaEvaluation } = { - maia_kdd_1500: { - policy: { - e2e4: 0.8, // 80% probability - above 10% threshold - d2d4: 0.2, // 20% probability - above 10% threshold - }, - value: 0.6, - }, - } - - parentNode.addStockfishAnalysis(stockfishEval, 'maia_kdd_1500') - parentNode.addMaiaAnalysis(maiaEval, 'maia_kdd_1500') - - // Even though d2d4 has higher winrate than weighted average, - // it should not be excellent because Maia probability > 10% - const classification = GameNode.classifyMove( - parentNode, - 'd2d4', - 'maia_kdd_1500', - ) - expect(classification.excellent).toBe(false) - }) - - it('should not classify move as excellent when winrate advantage < 10%', () => { - const parentNode = new GameNode( - 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', - ) - - const stockfishEval: StockfishEvaluation = { - sent: true, - depth: 15, - model_move: 'e2e4', - model_optimal_cp: 50, - cp_vec: { e2e4: 50, d2d4: 40, a2a3: 20 }, - cp_relative_vec: { e2e4: 0, d2d4: -10, a2a3: -30 }, - winrate_vec: { e2e4: 0.6, d2d4: 0.55, a2a3: 0.62 }, - winrate_loss_vec: { e2e4: 0, d2d4: -0.05, a2a3: 0.02 }, - } - - const maiaEval: { [rating: string]: MaiaEvaluation } = { - maia_kdd_1500: { - policy: { - e2e4: 0.7, // 70% probability - d2d4: 0.25, // 25% probability - a2a3: 0.05, // 5% probability - below 10% threshold - }, - value: 0.6, - }, - } - - parentNode.addStockfishAnalysis(stockfishEval, 'maia_kdd_1500') - parentNode.addMaiaAnalysis(maiaEval, 'maia_kdd_1500') - - // Weighted average: (0.7 * 0.6 + 0.25 * 0.55 + 0.05 * 0.62) / 1.0 - // = (0.42 + 0.1375 + 0.031) / 1.0 = 0.5885 - // a2a3 winrate (0.62) is only about 3.15% higher than weighted average - // So a2a3 should NOT be excellent (advantage < 10%) - - const classification = GameNode.classifyMove( - parentNode, - 'a2a3', - 'maia_kdd_1500', - ) - expect(classification.excellent).toBe(false) - }) - }) -}) diff --git a/src/api/analysis.ts b/src/api/analysis.ts new file mode 100644 index 00000000..e5c45c84 --- /dev/null +++ b/src/api/analysis.ts @@ -0,0 +1,370 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + Player, + AnalyzedGame, + MoveValueMapping, + CachedEngineAnalysisEntry, + WorldChampionshipGameListEntry, + RawMove, +} from 'src/types' +import { + readLichessStream, + buildGameTreeFromMoveList, + buildMovesListFromGameStates, + insertBackendStockfishEvalToGameTree, +} from 'src/lib' +import { buildUrl } from './utils' +import { AvailableMoves } from 'src/types/puzzle' +import { Chess } from 'chess.ts' + +export const fetchWorldChampionshipGameList = async (): Promise< + Map +> => { + const res = await fetch(buildUrl('analysis/list')) + const data = await res.json() + + return data +} + +export const fetchMaiaGameList = async ( + type = 'play', + page = 1, + lichessId?: string, +) => { + const url = buildUrl(`analysis/user/list/${type}/${page}`) + const searchParams = new URLSearchParams() + + if (lichessId) { + searchParams.append('lichess_id', lichessId) + } + + const fullUrl = searchParams.toString() + ? `${url}?${searchParams.toString()}` + : url + const res = await fetch(fullUrl) + + const data = await res.json() + + return data +} + +export const streamLichessGames = async ( + username: string, + onMessage: (data: any) => void, +) => { + const stream = fetch( + `https://lichess.org/api/games/user/${username}?max=100&pgnInJson=true`, + { + headers: { + Accept: 'application/x-ndjson', + }, + }, + ) + stream.then(readLichessStream(onMessage)) +} + +export const fetchPgnOfLichessGame = async (id: string): Promise => { + const res = await fetch(`https://lichess.org/game/export/${id}`, { + headers: { + Accept: 'application/x-chess-pgn', + }, + }) + return res.text() +} + +export const fetchAnalyzedWorldChampionshipGame = async ( + gameId = ['FkgYSri1'], +) => { + const res = await fetch( + buildUrl(`analysis/analysis_list/${gameId.join('/')}`), + ) + + const data = await res.json() + + const id = data['id'] + const termination = { + ...data['termination'], + condition: 'Normal', + } + + const gameType = 'blitz' + const blackPlayer = data['black_player'] as Player + const whitePlayer = data['white_player'] as Player + + const maiaEvals: { + [model: string]: MoveValueMapping[] + } = {} + const stockfishEvaluations: MoveValueMapping[] = data['stockfish_evals'] + + const availableMoves: AvailableMoves[] = [] + + for (const model of data['maia_versions']) { + maiaEvals[model] = data['maia_evals'][model] + } + + for (const position of data['move_maps']) { + const moves: AvailableMoves = {} + for (const move of position) { + const fromTo = move.move.join('') + const san = move['move_san'] + const { check, fen } = move + + moves[fromTo] = { + board: fen, + check, + san, + lastMove: move.move, + } as RawMove + } + availableMoves.push(moves) + } + + const gameStates = data['game_states'] + + const moves = buildMovesListFromGameStates(gameStates) + const tree = buildGameTreeFromMoveList(moves, moves[0].board) + insertBackendStockfishEvalToGameTree(tree, moves, stockfishEvaluations) + + return { + id, + blackPlayer, + whitePlayer, + availableMoves, + gameType, + termination, + tree, + } as AnalyzedGame +} + +export const fetchAnalyzedPgnGame = async (id: string, pgn: string) => { + const res = await fetch(buildUrl('analysis/analyze_user_game'), { + method: 'POST', + body: pgn, + headers: { + 'Content-Type': 'text/plain', + }, + }) + + const data = await res.json() + + const termination = { + ...data['termination'], + condition: 'Normal', + } + + const gameType = 'blitz' + const blackPlayer = data['black_player'] as Player + const whitePlayer = data['white_player'] as Player + + const maiaEvals: { [model: string]: MoveValueMapping[] } = {} + const availableMoves: AvailableMoves[] = [] + + for (const model of data['maia_versions']) { + maiaEvals[model] = data['maia_evals'][model] + } + + for (const position of data['move_maps']) { + const moves: AvailableMoves = {} + for (const move of position) { + const fromTo = move.move.join('') + const san = move['move_san'] + const { check, fen } = move + + moves[fromTo] = { + board: fen, + check, + san, + lastMove: move.move, + } as RawMove + } + availableMoves.push(moves) + } + + const gameStates = data['game_states'] + + const moves = buildMovesListFromGameStates(gameStates) + const tree = buildGameTreeFromMoveList(moves, moves[0].board) + + return { + id, + blackPlayer, + whitePlayer, + availableMoves, + gameType, + termination, + tree, + } as AnalyzedGame +} + +export const fetchAnalyzedMaiaGame = async ( + id: string, + game_type: 'play' | 'hand' | 'brain' | 'custom', +) => { + const res = await fetch( + buildUrl( + `analysis/user/analyze_user_maia_game/${id}?` + + new URLSearchParams({ + game_type, + }), + ), + { + method: 'GET', + headers: { + 'Content-Type': 'text/plain', + }, + }, + ) + + const data = await res.json() + + const termination = { + ...data['termination'], + condition: 'Normal', + } + + const gameType = 'blitz' + const blackPlayer = data['black_player'] as Player + const whitePlayer = data['white_player'] as Player + + const maiaPattern = /maia_kdd_1\d00/ + + if (blackPlayer.name && maiaPattern.test(blackPlayer.name)) { + blackPlayer.name = blackPlayer.name.replace('maia_kdd_', 'Maia ') + } + + if (whitePlayer.name && maiaPattern.test(whitePlayer.name)) { + whitePlayer.name = whitePlayer.name.replace('maia_kdd_', 'Maia ') + } + + const maiaEvals: { [model: string]: MoveValueMapping[] } = {} + const availableMoves: AvailableMoves[] = [] + + for (const model of data['maia_versions']) { + maiaEvals[model] = data['maia_evals'][model] + } + + for (const position of data['move_maps']) { + const moves: AvailableMoves = {} + for (const move of position) { + const fromTo = move.move.join('') + const san = move['move_san'] + const { check, fen } = move + + moves[fromTo] = { + board: fen, + check, + san, + lastMove: move.move, + } as RawMove + } + availableMoves.push(moves) + } + + const gameStates = data['game_states'] + + const moves = buildMovesListFromGameStates(gameStates) + const tree = buildGameTreeFromMoveList( + moves, + moves.length ? moves[0].board : new Chess().fen(), + ) + + return { + id, + type: game_type, + blackPlayer, + whitePlayer, + moves, + availableMoves, + gameType, + termination, + tree, + } as AnalyzedGame +} + +export const storeGameAnalysisCache = async ( + gameId: string, + analysisData: CachedEngineAnalysisEntry[], +): Promise => { + const res = await fetch( + buildUrl(`analysis/store_engine_analysis/${gameId}`), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(analysisData), + }, + ) + + if (!res.ok) { + console.error('Failed to cache engine analysis') + } +} + +export const retrieveGameAnalysisCache = async ( + gameId: string, +): Promise<{ positions: CachedEngineAnalysisEntry[] } | null> => { + const res = await fetch(buildUrl(`analysis/get_engine_analysis/${gameId}`)) + + if (res.status === 404) { + return null + } + + if (!res.ok) { + console.error('Failed to retrieve engine analysis') + } + + const data = await res.json() + + return data +} + +export const updateGameMetadata = async ( + gameType: 'custom' | 'play' | 'hand' | 'brain', + gameId: string, + metadata: { + custom_name?: string + is_favorited?: boolean + }, +): Promise => { + const res = await fetch( + buildUrl(`analysis/update_metadata/${gameType}/${gameId}`), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(metadata), + }, + ) + + if (!res.ok) { + console.error('Failed to update game metadata') + } +} + +export const storeCustomGame = async (data: { + name?: string + pgn?: string + fen?: string +}): Promise<{ + id: string + name: string + pgn?: string + fen?: string + created_at: string +}> => { + const res = await fetch(buildUrl('analysis/store_custom_game'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }) + + if (!res.ok) { + console.error(`Failed to store custom game: ${await res.text()}`) + } + + return res.json() +} diff --git a/src/api/analysis/analysis.ts b/src/api/analysis/analysis.ts deleted file mode 100644 index d7c00de3..00000000 --- a/src/api/analysis/analysis.ts +++ /dev/null @@ -1,784 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - Player, - MoveMap, - GameTree, - GameNode, - AnalyzedGame, - MaiaEvaluation, - PositionEvaluation, - StockfishEvaluation, - AnalysisTournamentGame, -} from 'src/types' -import { buildUrl } from '../utils' -import { cpToWinrate } from 'src/lib/stockfish' -import { AvailableMoves } from 'src/types/training' -import { Chess } from 'chess.ts' -import { - saveCustomAnalysis, - getCustomAnalysisById, -} from 'src/lib/customAnalysis' - -function buildGameTree(moves: any[], initialFen: string) { - const tree = new GameTree(initialFen) - let currentNode = tree.getRoot() - - for (let i = 0; i < moves.length; i++) { - const move = moves[i] - - if (move.lastMove) { - const [from, to] = move.lastMove - currentNode = tree.addMainMove( - currentNode, - move.board, - from + to, - move.san || '', - ) - } - } - - return tree -} - -const readStream = (processLine: (data: any) => void) => (response: any) => { - const stream = response.body.getReader() - const matcher = /\r?\n/ - const decoder = new TextDecoder() - let buf = '' - - const loop = () => - stream.read().then(({ done, value }: { done: boolean; value: any }) => { - if (done) { - if (buf.length > 0) processLine(JSON.parse(buf)) - } else { - const chunk = decoder.decode(value, { - stream: true, - }) - buf += chunk - - const parts = (buf || '').split(matcher) - buf = parts.pop() as string - for (const i of parts.filter((p) => p)) processLine(JSON.parse(i)) - - return loop() - } - }) - - return loop() -} - -export const getAnalysisList = async (): Promise< - Map -> => { - const res = await fetch(buildUrl('analysis/list')) - - if (res.status === 401) { - throw new Error('Unauthorized') - } - - const data = await res.json() - - return data -} - -export const getAnalysisGameList = async ( - type = 'play', - page = 1, - lichessId?: string, -) => { - const url = buildUrl(`analysis/user/list/${type}/${page}`) - const searchParams = new URLSearchParams() - - if (lichessId) { - searchParams.append('lichess_id', lichessId) - } - - const fullUrl = searchParams.toString() - ? `${url}?${searchParams.toString()}` - : url - const res = await fetch(fullUrl) - - if (res.status === 401) { - throw new Error('Unauthorized') - } - - const data = await res.json() - - return data -} - -export const getLichessGames = async ( - username: string, - onMessage: (data: any) => void, -) => { - const stream = fetch( - `https://lichess.org/api/games/user/${username}?max=100&pgnInJson=true`, - { - headers: { - Accept: 'application/x-ndjson', - }, - }, - ) - stream.then(readStream(onMessage)) -} - -export const getLichessGamePGN = async (id: string) => { - const res = await fetch(`https://lichess.org/game/export/${id}`, { - headers: { - Accept: 'application/x-chess-pgn', - }, - }) - return res.text() -} - -function convertMoveMapToStockfishEval( - moveMap: MoveMap, - turn: 'w' | 'b', -): StockfishEvaluation { - const cp_vec: { [key: string]: number } = {} - const cp_relative_vec: { [key: string]: number } = {} - let model_optimal_cp = -Infinity - let model_move = '' - - for (const move in moveMap) { - const cp = moveMap[move] - cp_vec[move] = cp - if (cp > model_optimal_cp) { - model_optimal_cp = cp - model_move = move - } - } - - for (const move in cp_vec) { - const cp = moveMap[move] - cp_relative_vec[move] = cp - model_optimal_cp - } - - const cp_vec_sorted = Object.fromEntries( - Object.entries(cp_vec).sort(([, a], [, b]) => b - a), - ) - - const cp_relative_vec_sorted = Object.fromEntries( - Object.entries(cp_relative_vec).sort(([, a], [, b]) => b - a), - ) - - const winrate_vec: { [key: string]: number } = {} - let max_winrate = -Infinity - - for (const move in cp_vec_sorted) { - const cp = cp_vec_sorted[move] - const winrate = cpToWinrate(cp, false) - winrate_vec[move] = winrate - - if (winrate_vec[move] > max_winrate) { - max_winrate = winrate_vec[move] - } - } - - const winrate_loss_vec: { [key: string]: number } = {} - for (const move in winrate_vec) { - winrate_loss_vec[move] = winrate_vec[move] - max_winrate - } - - const winrate_vec_sorted = Object.fromEntries( - Object.entries(winrate_vec).sort(([, a], [, b]) => b - a), - ) - - const winrate_loss_vec_sorted = Object.fromEntries( - Object.entries(winrate_loss_vec).sort(([, a], [, b]) => b - a), - ) - - if (turn === 'b') { - model_optimal_cp *= -1 - for (const move in cp_vec_sorted) { - cp_vec_sorted[move] *= -1 - } - } - - return { - sent: true, - depth: 20, - model_move: model_move, - model_optimal_cp: model_optimal_cp, - cp_vec: cp_vec_sorted, - cp_relative_vec: cp_relative_vec_sorted, - winrate_vec: winrate_vec_sorted, - winrate_loss_vec: winrate_loss_vec_sorted, - } -} - -export const getAnalyzedTournamentGame = async (gameId = ['FkgYSri1']) => { - const res = await fetch( - buildUrl(`analysis/analysis_list/${gameId.join('/')}`), - ) - - if (res.status === 401) { - throw new Error('Unauthorized') - } - - const data = await res.json() - const id = data['id'] - const termination = { - ...data['termination'], - condition: 'Normal', - } - - const gameType = 'blitz' - const blackPlayer = data['black_player'] as Player - const whitePlayer = data['white_player'] as Player - - const maiaEvals: { [model: string]: MoveMap[] } = {} - const stockfishEvaluations: MoveMap[] = data['stockfish_evals'] - - const availableMoves: AvailableMoves[] = [] - - for (const model of data['maia_versions']) { - maiaEvals[model] = data['maia_evals'][model] - } - - for (const position of data['move_maps']) { - const moves: AvailableMoves = {} - for (const move of position) { - const fromTo = move.move.join('') - const san = move['move_san'] - const { check, fen } = move - - moves[fromTo] = { - board: fen, - check, - san, - lastMove: move.move, - } - } - availableMoves.push(moves) - } - - const gameStates = data['game_states'] - - const moves = gameStates.map((gameState: any) => { - const { - last_move: lastMove, - fen, - check, - last_move_san: san, - evaluations: maia_values, - } = gameState - - return { - board: fen, - lastMove, - san, - check, - maia_values, - } - }) - - const maiaEvaluations = [] as { [rating: number]: MaiaEvaluation }[] - - const tree = buildGameTree(moves, moves[0].board) - - let currentNode: GameNode | null = tree.getRoot() - - for (let i = 0; i < moves.length; i++) { - if (!currentNode) { - break - } - - const stockfishEval = stockfishEvaluations[i] - ? convertMoveMapToStockfishEval( - stockfishEvaluations[i], - moves[i].board.split(' ')[1], - ) - : undefined - - if (stockfishEval) { - currentNode.addStockfishAnalysis(stockfishEval) - } - currentNode = currentNode?.mainChild - } - - return { - id, - blackPlayer, - whitePlayer, - moves, - maiaEvaluations, - stockfishEvaluations, - availableMoves, - gameType, - termination, - tree, - } as any as AnalyzedGame -} - -export const getAnalyzedLichessGame = async (id: string, pgn: string) => { - const res = await fetch(buildUrl('analysis/analyze_user_game'), { - method: 'POST', - body: pgn, - headers: { - 'Content-Type': 'text/plain', - }, - }) - - if (res.status === 401) { - throw new Error('Unauthorized') - } - - const data = await res.json() - - const termination = { - ...data['termination'], - condition: 'Normal', - } - - const gameType = 'blitz' - const blackPlayer = data['black_player'] as Player - const whitePlayer = data['white_player'] as Player - - const maiaEvals: { [model: string]: MoveMap[] } = {} - const positionEvaluations: { [model: string]: PositionEvaluation[] } = {} - const availableMoves: AvailableMoves[] = [] - - for (const model of data['maia_versions']) { - maiaEvals[model] = data['maia_evals'][model] - positionEvaluations[model] = Object.keys(data['maia_evals'][model]).map( - () => ({ - trickiness: 1, - performance: 1, - }), - ) - } - - for (const position of data['move_maps']) { - const moves: AvailableMoves = {} - for (const move of position) { - const fromTo = move.move.join('') - const san = move['move_san'] - const { check, fen } = move - - moves[fromTo] = { - board: fen, - check, - san, - lastMove: move.move, - } - } - availableMoves.push(moves) - } - - const gameStates = data['game_states'] - - const moves = gameStates.map((gameState: any) => { - const { - last_move: lastMove, - fen, - check, - last_move_san: san, - evaluations: maia_values, - } = gameState - - return { - board: fen, - lastMove, - san, - check, - maia_values, - } - }) - - const maiaEvaluations = [] as { [rating: number]: MaiaEvaluation }[] - const stockfishEvaluations: StockfishEvaluation[] = [] - const tree = buildGameTree(moves, moves[0].board) - - return { - id, - blackPlayer, - whitePlayer, - moves, - availableMoves, - gameType, - termination, - maiaEvaluations, - stockfishEvaluations, - tree, - type: 'brain', - pgn, - } as AnalyzedGame -} - -const createAnalyzedGameFromPGN = async ( - pgn: string, - id?: string, -): Promise => { - const chess = new Chess() - - try { - chess.loadPgn(pgn) - } catch (error) { - throw new Error('Invalid PGN format') - } - - const history = chess.history({ verbose: true }) - const headers = chess.header() - - const moves = [] - const tempChess = new Chess() - - const startingFen = - headers.FEN || 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1' - if (headers.FEN) { - tempChess.load(headers.FEN) - } - - moves.push({ - board: tempChess.fen(), - lastMove: undefined, - san: undefined, - check: tempChess.inCheck(), - maia_values: {}, - }) - - for (const move of history) { - tempChess.move(move) - moves.push({ - board: tempChess.fen(), - lastMove: [move.from, move.to] as [string, string], - san: move.san, - check: tempChess.inCheck(), - maia_values: {}, - }) - } - - const tree = buildGameTree(moves, startingFen) - - return { - id: id || `pgn-${Date.now()}`, - blackPlayer: { name: headers.Black || 'Black', rating: undefined }, - whitePlayer: { name: headers.White || 'White', rating: undefined }, - moves, - availableMoves: new Array(moves.length).fill({}), - gameType: 'custom', - termination: { - result: headers.Result || '*', - winner: - headers.Result === '1-0' - ? 'white' - : headers.Result === '0-1' - ? 'black' - : 'none', - condition: 'Normal', - }, - maiaEvaluations: new Array(moves.length).fill({}), - stockfishEvaluations: new Array(moves.length).fill(undefined), - tree, - type: 'custom-pgn' as const, - pgn, - } as AnalyzedGame -} - -export const getAnalyzedCustomPGN = async ( - pgn: string, - name?: string, -): Promise => { - const stored = await saveCustomAnalysis('pgn', pgn, name) - - return createAnalyzedGameFromPGN(pgn, stored.id) -} - -const createAnalyzedGameFromFEN = async ( - fen: string, - id?: string, -): Promise => { - const chess = new Chess() - - try { - chess.load(fen) - } catch (error) { - throw new Error('Invalid FEN format') - } - - const moves = [ - { - board: fen, - lastMove: undefined, - san: undefined, - check: chess.inCheck(), - maia_values: {}, - }, - ] - - const tree = new GameTree(fen) - - return { - id: id || `fen-${Date.now()}`, - blackPlayer: { name: 'Black', rating: undefined }, - whitePlayer: { name: 'White', rating: undefined }, - moves, - availableMoves: [{}], - gameType: 'custom', - termination: { - result: '*', - winner: 'none', - condition: 'Normal', - }, - maiaEvaluations: [{}], - stockfishEvaluations: [undefined], - tree, - type: 'custom-fen' as const, - } as AnalyzedGame -} - -export const getAnalyzedCustomFEN = async ( - fen: string, - name?: string, -): Promise => { - const stored = await saveCustomAnalysis('fen', fen, name) - - return createAnalyzedGameFromFEN(fen, stored.id) -} - -export const getAnalyzedCustomGame = async ( - id: string, -): Promise => { - const stored = getCustomAnalysisById(id) - if (!stored) { - throw new Error('Custom analysis not found') - } - - if (stored.type === 'custom-pgn') { - return createAnalyzedGameFromPGN(stored.data, stored.id) - } else { - return createAnalyzedGameFromFEN(stored.data, stored.id) - } -} - -export const getAnalyzedUserGame = async ( - id: string, - game_type: 'play' | 'hand' | 'brain', -) => { - const res = await fetch( - buildUrl( - `analysis/user/analyze_user_maia_game/${id}?` + - new URLSearchParams({ - game_type, - }), - ), - { - method: 'GET', - headers: { - 'Content-Type': 'text/plain', - }, - }, - ) - - if (res.status === 401) { - throw new Error('Unauthorized') - } - - const data = await res.json() - - const termination = { - ...data['termination'], - condition: 'Normal', - } - - const gameType = 'blitz' - const blackPlayer = data['black_player'] as Player - const whitePlayer = data['white_player'] as Player - - const maiaPattern = /maia_kdd_1\d00/ - - if (blackPlayer.name && maiaPattern.test(blackPlayer.name)) { - blackPlayer.name = blackPlayer.name.replace('maia_kdd_', 'Maia ') - } - - if (whitePlayer.name && maiaPattern.test(whitePlayer.name)) { - whitePlayer.name = whitePlayer.name.replace('maia_kdd_', 'Maia ') - } - - const maiaEvals: { [model: string]: MoveMap[] } = {} - - const availableMoves: AvailableMoves[] = [] - - for (const model of data['maia_versions']) { - maiaEvals[model] = data['maia_evals'][model] - } - - for (const position of data['move_maps']) { - const moves: AvailableMoves = {} - for (const move of position) { - const fromTo = move.move.join('') - const san = move['move_san'] - const { check, fen } = move - - moves[fromTo] = { - board: fen, - check, - san, - lastMove: move.move, - } - } - availableMoves.push(moves) - } - - const gameStates = data['game_states'] - - const moves = gameStates.map((gameState: any) => { - const { - last_move: lastMove, - fen, - check, - last_move_san: san, - evaluations: maia_values, - } = gameState - - return { - board: fen, - lastMove, - san, - check, - maia_values, - } - }) - - const maiaEvaluations = [] as { [rating: number]: MaiaEvaluation }[] - const stockfishEvaluations: StockfishEvaluation[] = [] - const tree = buildGameTree(moves, moves[0].board) - - return { - id, - blackPlayer, - whitePlayer, - moves, - availableMoves, - gameType, - termination, - maiaEvaluations, - stockfishEvaluations, - tree, - type: 'brain', - } as AnalyzedGame -} - -export interface EngineAnalysisPosition { - ply: number - fen: string - maia?: { [rating: string]: MaiaEvaluation } - stockfish?: { - depth: number - cp_vec: { [move: string]: number } - } -} - -export const storeEngineAnalysis = async ( - gameId: string, - analysisData: EngineAnalysisPosition[], -): Promise => { - const res = await fetch( - buildUrl(`analysis/store_engine_analysis/${gameId}`), - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(analysisData), - }, - ) - - if (res.status === 401) { - throw new Error('Unauthorized') - } - - if (!res.ok) { - throw new Error('Failed to store engine analysis') - } -} - -// Retrieve stored engine analysis from backend -export const getEngineAnalysis = async ( - gameId: string, -): Promise<{ positions: EngineAnalysisPosition[] } | null> => { - const res = await fetch(buildUrl(`analysis/get_engine_analysis/${gameId}`)) - - if (res.status === 401) { - throw new Error('Unauthorized') - } - - if (res.status === 404) { - // No stored analysis found - return null - } - - if (!res.ok) { - throw new Error('Failed to retrieve engine analysis') - } - - return res.json() -} - -export interface UpdateGameMetadataRequest { - custom_name?: string - is_favorited?: boolean -} - -export const updateGameMetadata = async ( - gameType: 'custom' | 'play' | 'hand' | 'brain', - gameId: string, - metadata: UpdateGameMetadataRequest, -): Promise => { - const res = await fetch( - buildUrl(`analysis/update_metadata/${gameType}/${gameId}`), - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(metadata), - }, - ) - - if (res.status === 401) { - throw new Error('Unauthorized') - } - - if (!res.ok) { - throw new Error('Failed to update game metadata') - } -} - -export interface StoreCustomGameRequest { - name?: string - pgn?: string - fen?: string -} - -export interface StoredCustomGameResponse { - id: string - name: string - pgn?: string - fen?: string - created_at: string -} - -export const storeCustomGame = async ( - data: StoreCustomGameRequest, -): Promise => { - const res = await fetch(buildUrl('analysis/store_custom_game'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }) - - if (res.status === 401) { - throw new Error('Unauthorized') - } - - if (!res.ok) { - const errorText = await res.text() - throw new Error(`Failed to store custom game: ${errorText}`) - } - - return res.json() as Promise -} diff --git a/src/api/analysis/index.ts b/src/api/analysis/index.ts deleted file mode 100644 index 87dd896f..00000000 --- a/src/api/analysis/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './analysis' diff --git a/src/api/auth/auth.ts b/src/api/auth.ts similarity index 61% rename from src/api/auth/auth.ts rename to src/api/auth.ts index 2fa21c99..6adbba08 100644 --- a/src/api/auth/auth.ts +++ b/src/api/auth.ts @@ -1,4 +1,4 @@ -import { buildUrl } from 'src/api' +import { buildUrl } from './utils' const parseAccountInfo = (data: { [x: string]: string }) => { const clientId = data['client_id'] @@ -12,29 +12,22 @@ const parseAccountInfo = (data: { [x: string]: string }) => { } } -export const getAccount = async () => { +export const fetchAccount = async () => { const res = await fetch(buildUrl('auth/account')) const data = await res.json() return parseAccountInfo(data) } -export const logoutAndGetAccount = async () => { +export const logoutAndFetchAccount = async () => { await fetch(buildUrl('auth/logout')) - return getAccount() + return fetchAccount() } -export const getLeaderboard = async () => { +export const fetchLeaderboard = async () => { const res = await fetch(buildUrl('auth/leaderboard')) const data = await res.json() return data } - -export const getGlobalStats = async () => { - const res = await fetch(buildUrl('auth/global_stats')) - const data = await res.json() - - return data -} diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts deleted file mode 100644 index f140b2ec..00000000 --- a/src/api/auth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './auth' diff --git a/src/api/home/activeUsers.ts b/src/api/home.ts similarity index 66% rename from src/api/home/activeUsers.ts rename to src/api/home.ts index 5d2f963b..0f359c60 100644 --- a/src/api/home/activeUsers.ts +++ b/src/api/home.ts @@ -1,7 +1,5 @@ -/** - * Get the count of active users in the last 30 minutes - * Calls our secure server-side API endpoint that handles PostHog integration - */ +import { buildUrl } from './utils' + export const getActiveUserCount = async (): Promise => { try { const response = await fetch('/api/active-users') @@ -17,3 +15,10 @@ export const getActiveUserCount = async (): Promise => { return 0 } + +export const getGlobalStats = async () => { + const res = await fetch(buildUrl('auth/global_stats')) + const data = await res.json() + + return data +} diff --git a/src/api/home/home.ts b/src/api/home/home.ts deleted file mode 100644 index 16794b18..00000000 --- a/src/api/home/home.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { buildUrl } from 'src/api' - -const getPlayerStats = async () => { - const res = await fetch(buildUrl('/auth/get_player_stats')) - const data = await res.json() - return { - regularRating: data.play_elo as number, - handRating: data.hand_elo as number, - brainRating: data.brain_elo as number, - trainRating: data.puzzles_elo as number, - botNotRating: data.turing_elo as number, - } -} - -export { getPlayerStats } diff --git a/src/api/home/index.ts b/src/api/home/index.ts deleted file mode 100644 index 66a56239..00000000 --- a/src/api/home/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './home' -export * from './activeUsers' diff --git a/src/api/index.ts b/src/api/index.ts index 42e636c6..875497a5 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,10 +1,10 @@ -export * from './utils' export * from './analysis' export * from './train' export * from './auth' export * from './turing' export * from './play' export * from './profile' -export * from './opening' +export * from './openings' export * from './lichess' -export { getActiveUserCount } from './home' +export * from './home' +export * from './utils' diff --git a/src/api/lichess.ts b/src/api/lichess.ts new file mode 100644 index 00000000..211e7eca --- /dev/null +++ b/src/api/lichess.ts @@ -0,0 +1,82 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { readLichessStream } from 'src/lib' +import { StreamedGame, StreamedMove } from 'src/types' + +export const fetchLichessTVGame = async () => { + const res = await fetch('https://lichess.org/api/tv/channels') + if (!res.ok) { + throw new Error('Failed to fetch Lichess TV data') + } + const data = await res.json() + + // Return the best rapid game (highest rated players) + const bestChannel = data.rapid + if (!bestChannel?.gameId) { + throw new Error('No TV game available') + } + + return { + gameId: bestChannel.gameId, + white: bestChannel.user1, + black: bestChannel.user2, + } +} + +export const fetchLichessGameInfo = async (gameId: string) => { + const res = await fetch(`https://lichess.org/api/game/${gameId}`) + if (!res.ok) { + throw new Error(`Failed to fetch game info for ${gameId}`) + } + return res.json() +} + +export const streamLichessGameMoves = async ( + gameId: string, + onGameInfo: (data: StreamedGame) => void, + onMove: (data: StreamedMove) => void, + onComplete: () => void, + abortSignal?: AbortSignal, +) => { + const stream = fetch(`https://lichess.org/api/stream/game/${gameId}`, { + signal: abortSignal, + headers: { + Accept: 'application/x-ndjson', + }, + }) + + const onMessage = (message: any) => { + if (message.id) { + onGameInfo(message as StreamedGame) + } else if (message.uci || message.lm) { + onMove({ + fen: message.fen, + uci: message.uci || message.lm, + wc: message.wc, + bc: message.bc, + }) + } else { + console.log('Unknown message format:', message) + } + } + + try { + const response = await stream + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + if (!response.body) { + throw new Error('No response body') + } + + await readLichessStream(onMessage)(response).then(onComplete) + } catch (error) { + if (abortSignal?.aborted) { + console.log('Stream aborted') + } else { + console.error('Stream error:', error) + throw error + } + } +} diff --git a/src/api/lichess/index.ts b/src/api/lichess/index.ts deleted file mode 100644 index 7f1dfdc3..00000000 --- a/src/api/lichess/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './streaming' diff --git a/src/api/lichess/streaming.ts b/src/api/lichess/streaming.ts deleted file mode 100644 index d262e1da..00000000 --- a/src/api/lichess/streaming.ts +++ /dev/null @@ -1,242 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Chess } from 'chess.ts' -import { GameTree } from 'src/types/base/tree' -import { AvailableMoves } from 'src/types/training' -import { - LiveGame, - Player, - StockfishEvaluation, - StreamedGame, - StreamedMove, -} from 'src/types' - -const readStream = (processLine: (data: any) => void) => (response: any) => { - const stream = response.body.getReader() - const matcher = /\r?\n/ - const decoder = new TextDecoder() - let buf = '' - - const loop = () => - stream.read().then(({ done, value }: { done: boolean; value: any }) => { - if (done) { - if (buf.length > 0) processLine(JSON.parse(buf)) - } else { - const chunk = decoder.decode(value, { - stream: true, - }) - buf += chunk - - const parts = (buf || '').split(matcher) - buf = parts.pop() as string - for (const i of parts.filter((p) => p)) processLine(JSON.parse(i)) - - return loop() - } - }) - - return loop() -} - -export const getLichessTVGame = async () => { - const res = await fetch('https://lichess.org/api/tv/channels') - if (!res.ok) { - throw new Error('Failed to fetch Lichess TV data') - } - const data = await res.json() - - // Return the best rapid game (highest rated players) - const bestChannel = data.rapid - if (!bestChannel?.gameId) { - throw new Error('No TV game available') - } - - return { - gameId: bestChannel.gameId, - white: bestChannel.user1, - black: bestChannel.user2, - } -} - -export const getLichessGameInfo = async (gameId: string) => { - const res = await fetch(`https://lichess.org/api/game/${gameId}`) - if (!res.ok) { - throw new Error(`Failed to fetch game info for ${gameId}`) - } - return res.json() -} - -export const streamLichessGame = async ( - gameId: string, - onGameInfo: (data: StreamedGame) => void, - onMove: (data: StreamedMove) => void, - onComplete: () => void, - abortSignal?: AbortSignal, -) => { - const stream = fetch(`https://lichess.org/api/stream/game/${gameId}`, { - signal: abortSignal, - headers: { - Accept: 'application/x-ndjson', - }, - }) - - const onMessage = (message: any) => { - if (message.id) { - onGameInfo(message as StreamedGame) - } else if (message.uci || message.lm) { - onMove({ - fen: message.fen, - uci: message.uci || message.lm, - wc: message.wc, - bc: message.bc, - }) - } else { - console.log('Unknown message format:', message) - } - } - - try { - const response = await stream - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`) - } - - if (!response.body) { - throw new Error('No response body') - } - - await readStream(onMessage)(response).then(onComplete) - } catch (error) { - if (abortSignal?.aborted) { - console.log('Stream aborted') - } else { - console.error('Stream error:', error) - throw error - } - } -} - -export const createAnalyzedGameFromLichessStream = ( - gameData: any, -): LiveGame => { - const { players, id } = gameData - - const whitePlayer: Player = { - name: players?.white?.user?.id || 'White', - rating: players?.white?.rating, - } - - const blackPlayer: Player = { - name: players?.black?.user?.id || 'Black', - rating: players?.black?.rating, - } - - const startingFen = - gameData.initialFen || - 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1' - - const gameStates = [ - { - board: startingFen, - lastMove: undefined as [string, string] | undefined, - san: undefined as string | undefined, - check: false as const, - maia_values: {}, - }, - ] - - const tree = new GameTree(startingFen) - - return { - id, - blackPlayer, - whitePlayer, - gameType: 'stream', - type: 'stream' as const, - moves: gameStates, - availableMoves: new Array(gameStates.length).fill({}) as AvailableMoves[], - termination: undefined, - maiaEvaluations: [], - stockfishEvaluations: [], - loadedFen: gameData.fen, - loaded: false, - tree, - } as LiveGame -} - -export const parseLichessStreamMove = ( - moveData: StreamedMove, - currentGame: LiveGame, -) => { - const { uci, fen } = moveData - - if (!uci || !fen || !currentGame.tree) { - return currentGame - } - - // Convert UCI to SAN notation using chess.js - let san = uci // Fallback to UCI - try { - // Get the position before this move by finding the last node in the tree - let beforeMoveNode = currentGame.tree.getRoot() - while (beforeMoveNode.mainChild) { - beforeMoveNode = beforeMoveNode.mainChild - } - - const chess = new Chess(beforeMoveNode.fen) - const move = chess.move({ - from: uci.slice(0, 2), - to: uci.slice(2, 4), - promotion: uci[4] ? (uci[4] as any) : undefined, - }) - - if (move) { - san = move.san - } - } catch (error) { - console.warn('Could not convert UCI to SAN:', error) - // Keep UCI as fallback - } - - // Create new move object - const newMove = { - board: fen, - lastMove: [uci.slice(0, 2), uci.slice(2, 4)] as [string, string], - san: san, - check: false as const, // We'd need to calculate this from the FEN - maia_values: {}, - } - - // Add to moves array - const updatedMoves = [...currentGame.moves, newMove] - - // Add to tree mainline - find the last node in the main line - let currentNode = currentGame.tree.getRoot() - while (currentNode.mainChild) { - currentNode = currentNode.mainChild - } - - try { - currentGame.tree.addMainMove(currentNode, fen, uci, san) - } catch (error) { - console.error('Error adding move to tree:', error) - // Return current game if tree update fails - return currentGame - } - - // Update available moves and evaluations arrays - const updatedAvailableMoves = [...currentGame.availableMoves, {}] - const updatedMaiaEvaluations = [...currentGame.maiaEvaluations, {}] - const updatedStockfishEvaluations = [ - ...currentGame.stockfishEvaluations, - undefined, - ] as (StockfishEvaluation | undefined)[] - - return { - ...currentGame, - moves: updatedMoves, - availableMoves: updatedAvailableMoves, - maiaEvaluations: updatedMaiaEvaluations, - stockfishEvaluations: updatedStockfishEvaluations, - } -} diff --git a/src/api/opening/index.ts b/src/api/opening/index.ts deleted file mode 100644 index 955f883d..00000000 --- a/src/api/opening/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './opening' diff --git a/src/api/opening/opening.ts b/src/api/openings.ts similarity index 97% rename from src/api/opening/opening.ts rename to src/api/openings.ts index 8ac24884..4945ff8e 100644 --- a/src/api/opening/opening.ts +++ b/src/api/openings.ts @@ -1,4 +1,4 @@ -import { buildUrl } from '../utils' +import { buildUrl } from './utils' // API Types for opening drill logging export interface OpeningDrillSelection { diff --git a/src/api/play/play.ts b/src/api/play.ts similarity index 95% rename from src/api/play/play.ts rename to src/api/play.ts index 7c6d264c..1a56cfeb 100644 --- a/src/api/play/play.ts +++ b/src/api/play.ts @@ -1,4 +1,4 @@ -import { buildUrl } from '../utils' +import { buildUrl } from './utils' import { Color, TimeControl } from 'src/types' export const startGame = async ( @@ -48,7 +48,7 @@ export const startGame = async ( } } -export const getGameMove = async ( +export const fetchGameMove = async ( moves: string[], maiaVersion = 'maia_kdd_1900', fen: string | null = null, @@ -106,7 +106,7 @@ export const getGameMove = async ( return res.json() } -export const getBookMoves = async (fen: string) => { +export const fetchOpeningBookMoves = async (fen: string) => { const res = await fetch(buildUrl(`play/get_book_moves?fen=${fen}`), { method: 'POST', headers: { @@ -132,7 +132,7 @@ export const getBookMoves = async (fen: string) => { return res.json() } -export const submitGameMove = async ( +export const logGameMove = async ( gameId: string, moves: string[], moveTimes: number[], @@ -164,7 +164,7 @@ export const submitGameMove = async ( return res.json() } -export const getPlayPlayerStats = async () => { +export const fetchPlayPlayerStats = async () => { const res = await fetch(buildUrl('play/get_player_stats')) const data = await res.json() return { diff --git a/src/api/play/index.ts b/src/api/play/index.ts deleted file mode 100644 index 1cf1ab44..00000000 --- a/src/api/play/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './play' diff --git a/src/api/profile/profile.ts b/src/api/profile.ts similarity index 93% rename from src/api/profile/profile.ts rename to src/api/profile.ts index 407e866c..e72f4309 100644 --- a/src/api/profile/profile.ts +++ b/src/api/profile.ts @@ -1,7 +1,7 @@ -import { buildUrl } from '../utils' +import { buildUrl } from './utils' import { PlayerStats } from 'src/types' -export const getPlayerStats = async (name?: string): Promise => { +export const fetchPlayerStats = async (name?: string): Promise => { const res = await fetch( buildUrl(`auth/get_player_stats${name ? `/${name}` : ''}`), ) diff --git a/src/api/profile/index.ts b/src/api/profile/index.ts deleted file mode 100644 index 060535fd..00000000 --- a/src/api/profile/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './profile' diff --git a/src/api/train/train.ts b/src/api/train.ts similarity index 86% rename from src/api/train/train.ts rename to src/api/train.ts index 0dcf778d..9a29f8e5 100644 --- a/src/api/train/train.ts +++ b/src/api/train.ts @@ -1,10 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Chess } from 'chess.ts' -import { MoveMap, GameTree } from 'src/types' -import { AvailableMoves, TrainingGame } from 'src/types/training' -import { buildUrl } from '../utils' +import { buildUrl } from './utils' +import { MoveValueMapping, GameTree } from 'src/types' +import { AvailableMoves, PuzzleGame } from 'src/types/puzzle' -export const getTrainingGame = async () => { +export const fetchPuzzle = async () => { const res = await fetch(buildUrl('puzzle/new_puzzle')) const data = await res.json() const id = @@ -62,19 +61,16 @@ export const getTrainingGame = async () => { for (let i = 1; i < moves.length; i++) { const move = moves[i] if (move.uci && move.san) { - currentNode = gameTree.addMainMove( - currentNode, - move.board, - move.uci, - move.san, - ) + currentNode = gameTree + .getLastMainlineNode() + .addChild(move.board, move.uci, move.san, true) } } const moveMap = data['target_move_map'] - const stockfishEvaluation: MoveMap = {} - const maiaEvaluation: MoveMap = {} + const stockfishEvaluation: MoveValueMapping = {} + const maiaEvaluation: MoveValueMapping = {} const availableMoves: AvailableMoves = {} moveMap.forEach( @@ -112,7 +108,7 @@ export const getTrainingGame = async () => { termination, availableMoves, targetIndex: data['target_move_index'], - } as any as TrainingGame + } as any as PuzzleGame } export const logPuzzleGuesses = async ( @@ -150,7 +146,7 @@ export const logPuzzleGuesses = async ( return res.json() } -export const getTrainingPlayerStats = async () => { +export const fetchTrainingPlayerStats = async () => { const res = await fetch(buildUrl('puzzle/get_player_stats')) const data = await res.json() return { diff --git a/src/api/train/index.ts b/src/api/train/index.ts deleted file mode 100644 index 2bf07805..00000000 --- a/src/api/train/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './train' diff --git a/src/api/turing/turing.ts b/src/api/turing.ts similarity index 91% rename from src/api/turing/turing.ts rename to src/api/turing.ts index 8b4316b4..e3162b32 100644 --- a/src/api/turing/turing.ts +++ b/src/api/turing.ts @@ -1,7 +1,7 @@ +import { buildUrl } from './utils' import { Color, TuringGame, TuringSubmissionResult, GameTree } from 'src/types' -import { buildUrl } from 'src/api' -export const getTuringGame = async () => { +export const fetchTuringGame = async () => { const res = await fetch(buildUrl('turing/new_game')) if (res.status === 401) { @@ -46,12 +46,9 @@ export const getTuringGame = async () => { for (let i = 1; i < moves.length; i++) { const move = moves[i] if (move.uci && move.san) { - currentNode = gameTree.addMainMove( - currentNode, - move.board, - move.uci, - move.san, - ) + currentNode = gameTree + .getLastMainlineNode() + .addChild(move.board, move.uci, move.san, true) } } @@ -114,7 +111,7 @@ export const submitTuringGuess = async ( } as TuringSubmissionResult } -export const getTuringPlayerStats = async () => { +export const fetchTuringPlayerStats = async () => { const res = await fetch(buildUrl('turing/get_player_stats')) const data = await res.json() return { diff --git a/src/api/turing/index.ts b/src/api/turing/index.ts deleted file mode 100644 index 230f2233..00000000 --- a/src/api/turing/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './turing' diff --git a/src/components/Analysis/AnalysisGameList.tsx b/src/components/Analysis/AnalysisGameList.tsx index a523fbd1..66611a4c 100644 --- a/src/components/Analysis/AnalysisGameList.tsx +++ b/src/components/Analysis/AnalysisGameList.tsx @@ -11,16 +11,13 @@ import { motion } from 'framer-motion' import { Tournament } from 'src/components' import { FavoriteModal } from 'src/components/Common/FavoriteModal' import { AnalysisListContext } from 'src/contexts' -import { getAnalysisGameList } from 'src/api' -import { ensureMigration } from 'src/lib/customAnalysis' +import { fetchMaiaGameList } from 'src/api' import { getFavoritesAsWebGames, addFavoriteGame, removeFavoriteGame, - updateFavoriteName, - isFavoriteGame, } from 'src/lib/favorites' -import { AnalysisWebGame } from 'src/types' +import { MaiaGameListEntry } from 'src/types' import { useRouter } from 'next/router' interface GameData { @@ -34,24 +31,20 @@ interface GameData { interface AnalysisGameListProps { currentId: string[] | null - loadNewTournamentGame: ( + loadNewWorldChampionshipGame: ( newId: string[], setCurrentMove?: Dispatch>, ) => Promise - loadNewLichessGames: ( + loadNewLichessGame: ( id: string, pgn: string, setCurrentMove?: Dispatch>, ) => Promise - loadNewUserGames: ( + loadNewMaiaGame: ( id: string, type: 'play' | 'hand' | 'brain', setCurrentMove?: Dispatch>, ) => Promise - loadNewCustomGame: ( - id: string, - setCurrentMove?: Dispatch>, - ) => Promise onCustomAnalysis?: () => void onGameSelected?: () => void // Called when a game is selected (for mobile popup closing) refreshTrigger?: number // Used to trigger refresh when custom analysis is added @@ -59,29 +52,21 @@ interface AnalysisGameListProps { export const AnalysisGameList: React.FC = ({ currentId, - loadNewTournamentGame, - loadNewLichessGames, - loadNewUserGames, - loadNewCustomGame, onCustomAnalysis, onGameSelected, refreshTrigger, + loadNewWorldChampionshipGame, }) => { const router = useRouter() - const { - analysisPlayList, - analysisHandList, - analysisBrainList, - analysisLichessList, - analysisTournamentList, - } = useContext(AnalysisListContext) + const { analysisLichessList, analysisTournamentList } = + useContext(AnalysisListContext) const [currentPage, setCurrentPage] = useState(1) const [totalPages, setTotalPages] = useState(1) const [loading, setLoading] = useState(false) const [gamesByPage, setGamesByPage] = useState<{ - [gameType: string]: { [page: number]: AnalysisWebGame[] } + [gameType: string]: { [page: number]: MaiaGameListEntry[] } }>({ play: {}, hand: {}, @@ -90,20 +75,18 @@ export const AnalysisGameList: React.FC = ({ custom: {}, }) - const [favoriteGames, setFavoriteGames] = useState([]) + const [favoriteGames, setFavoriteGames] = useState([]) const [favoritedGameIds, setFavoritedGameIds] = useState>( new Set(), ) const [hbSubsection, setHbSubsection] = useState<'hand' | 'brain'>('hand') - // Modal state for favoriting const [favoriteModal, setFavoriteModal] = useState<{ isOpen: boolean - game: AnalysisWebGame | null + game: MaiaGameListEntry | null }>({ isOpen: false, game: null }) useEffect(() => { - // Load favorites asynchronously getFavoritesAsWebGames() .then((favorites) => { setFavoriteGames(favorites) @@ -115,12 +98,6 @@ export const AnalysisGameList: React.FC = ({ }) }, [refreshTrigger]) - useEffect(() => { - ensureMigration().catch((error) => { - console.warn('Failed to migrate custom analyses:', error) - }) - }, []) - useEffect(() => { if (currentId?.[1] === 'custom') { setSelected('custom') @@ -224,15 +201,15 @@ export const AnalysisGameList: React.FC = ({ [selected]: { ...prev[selected], [currentPage]: true }, })) - getAnalysisGameList(selected, currentPage) + fetchMaiaGameList(selected, currentPage) .then((data) => { - let parsedGames: AnalysisWebGame[] = [] + console.log(data) + let parsedGames: MaiaGameListEntry[] = [] if (selected === 'favorites') { - // Handle favorites response format parsedGames = data.games.map((game: any) => ({ id: game.game_id || game.id, - type: game.game_type || game.type || 'custom-pgn', + type: game.game_type || game.type, label: game.custom_name || game.label || 'Untitled', result: game.result || '*', pgn: game.pgn, @@ -240,15 +217,14 @@ export const AnalysisGameList: React.FC = ({ custom_name: game.custom_name, })) } else { - // Handle regular games response format - if (selected === 'custom') { parsedGames = data.games.map((game: any) => ({ - id: game.id, - label: game.name || 'Custom Game', - result: '*', - type: game.pgn ? 'custom-pgn' : 'custom-fen', - pgn: game.pgn, + id: game.game_id || game.id, + type: 'custom', + label: game.custom_name || 'Custom Game', + result: game.result || '*', + is_favorited: game.is_favorited, + custom_name: game.custom_name, })) } else { const parse = ( @@ -265,7 +241,6 @@ export const AnalysisGameList: React.FC = ({ const raw = game.maia_name.replace('_kdd_', ' ') const maia = raw.charAt(0).toUpperCase() + raw.slice(1) - // Use custom name if available, otherwise generate default label const defaultLabel = game.player_color === 'white' ? `You vs. ${maia}` @@ -302,7 +277,6 @@ export const AnalysisGameList: React.FC = ({ }, })) - // Update favoritedGameIds from the actual games data const favoritedIds = new Set( parsedGames .filter((game: any) => game.is_favorited) @@ -326,7 +300,6 @@ export const AnalysisGameList: React.FC = ({ } }, [selected, currentPage, fetchedCache]) - // Separate useEffect for H&B subsections useEffect(() => { if (selected === 'hb') { const gameType = hbSubsection === 'hand' ? 'hand' : 'brain' @@ -340,7 +313,7 @@ export const AnalysisGameList: React.FC = ({ [gameType]: { ...prev[gameType], [currentPage]: true }, })) - getAnalysisGameList(gameType, currentPage) + fetchMaiaGameList(gameType, currentPage) .then((data) => { const parse = ( game: { @@ -356,7 +329,6 @@ export const AnalysisGameList: React.FC = ({ const raw = game.maia_name.replace('_kdd_', ' ') const maia = raw.charAt(0).toUpperCase() + raw.slice(1) - // Use custom name if available, otherwise generate default label const defaultLabel = game.player_color === 'white' ? `You vs. ${maia}` @@ -391,7 +363,6 @@ export const AnalysisGameList: React.FC = ({ }, })) - // Update favoritedGameIds from the actual games data const favoritedIds = new Set( parsedGames .filter((game: any) => game.is_favorited) @@ -463,7 +434,7 @@ export const AnalysisGameList: React.FC = ({ setSelected(newTab) } - const handleFavoriteGame = (game: AnalysisWebGame) => { + const handleFavoriteGame = (game: MaiaGameListEntry) => { setFavoriteModal({ isOpen: true, game }) } @@ -511,7 +482,6 @@ export const AnalysisGameList: React.FC = ({ setFavoriteGames(updatedFavorites) setFavoritedGameIds(new Set(updatedFavorites.map((f) => f.id))) - // Clear favorites cache to force re-fetch setFetchedCache((prev) => ({ ...prev, favorites: {}, @@ -521,7 +491,6 @@ export const AnalysisGameList: React.FC = ({ favorites: {}, })) - // Also clear current section cache to show updated favorite status if (selected !== 'favorites') { const currentSection = selected === 'hb' @@ -541,7 +510,7 @@ export const AnalysisGameList: React.FC = ({ } } - const handleDirectUnfavorite = async (game: AnalysisWebGame) => { + const handleDirectUnfavorite = async (game: MaiaGameListEntry) => { await removeFavoriteGame(game.id, game.type) const updatedFavorites = await getFavoritesAsWebGames() setFavoriteGames(updatedFavorites) @@ -720,7 +689,6 @@ export const AnalysisGameList: React.FC = ({ selectedGameElement={ selectedGameElement as React.RefObject } - loadNewTournamentGame={loadNewTournamentGame} analysisTournamentList={analysisTournamentList} /> ))} @@ -730,7 +698,8 @@ export const AnalysisGameList: React.FC = ({ {getCurrentGames().map((game, index) => { const selectedGame = currentId && currentId[0] === game.id const isFavorited = (game as any).is_favorited || false - const displayName = game.label // This now contains the custom name if favorited + const displayName = game.label + // console.log(game) return (
= ({ )} - {autoSave && - game.type !== 'custom-pgn' && - game.type !== 'custom-fen' && - game.type !== 'tournament' && ( -
-
- {autoSave.status === 'saving' && ( - <> -
- - Saving analysis... - - - )} - {autoSave.status === 'unsaved' && ( - <> - - sync_problem - - - Unsaved analysis. Will auto-save... - - - )} - {autoSave.status === 'saved' && ( - <> - - cloud_done - - - Analysis auto-saved - - - )} -
+ {autoSave && game.type !== 'tournament' && ( +
+
+ {autoSave.status === 'saving' && ( + <> +
+ + Saving analysis... + + + )} + {autoSave.status === 'unsaved' && ( + <> + + sync_problem + + + Unsaved analysis. Will auto-save... + + + )} + {autoSave.status === 'saved' && ( + <> + + cloud_done + + + Analysis auto-saved + + + )}
- )} - {isCustomGame && onDeleteCustomGame && ( +
+ )} + {game.type === 'custom' && onDeleteCustomGame && (