diff --git a/.eslintrc b/.eslintrc index 7ff09b78..8f3cfac6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,4 +1,5 @@ { + "root": true, "plugins": ["@typescript-eslint", "prettier", "react", "react-hooks"], "extends": [ "next/core-web-vitals", @@ -18,6 +19,8 @@ "prettier/prettier": "error", "react/react-in-jsx-scope": "off", "react-hooks/exhaustive-deps": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-expressions": "off", "@typescript-eslint/no-unused-vars": "off", "@next/next/no-html-link-for-pages": "off", "import/no-named-as-default": "off", 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 3e21a2c9..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('TestPlayer (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 4b56a3ff..00000000 --- a/__tests__/lib/favorites.test.ts +++ /dev/null @@ -1,132 +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, -}) - -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', () => { - const favorite = addFavoriteGame(mockGame) - - expect(favorite.id).toBe(mockGame.id) - expect(favorite.customName).toBe(mockGame.label) - expect(favorite.originalLabel).toBe(mockGame.label) - expect(isFavoriteGame(mockGame.id)).toBe(true) - }) - - it('should add a game to favorites with custom name', () => { - const customName = 'My Best Game' - const favorite = addFavoriteGame(mockGame, customName) - - expect(favorite.customName).toBe(customName) - expect(favorite.originalLabel).toBe(mockGame.label) - }) - - it('should update existing favorite when added again', () => { - addFavoriteGame(mockGame, 'First Name') - addFavoriteGame(mockGame, 'Updated Name') - - const favorites = getFavoriteGames() - expect(favorites).toHaveLength(1) - expect(favorites[0].customName).toBe('Updated Name') - }) - }) - - describe('removeFavoriteGame', () => { - it('should remove a game from favorites', () => { - addFavoriteGame(mockGame) - expect(isFavoriteGame(mockGame.id)).toBe(true) - - removeFavoriteGame(mockGame.id) - expect(isFavoriteGame(mockGame.id)).toBe(false) - }) - }) - - describe('updateFavoriteName', () => { - it('should update favorite name', () => { - addFavoriteGame(mockGame, 'Original Name') - updateFavoriteName(mockGame.id, 'New Name') - - const favorite = getFavoriteGame(mockGame.id) - expect(favorite?.customName).toBe('New Name') - }) - - it('should do nothing if favorite does not exist', () => { - const initialFavorites = getFavoriteGames() - updateFavoriteName('non-existent', 'New Name') - - expect(getFavoriteGames()).toEqual(initialFavorites) - }) - }) - - describe('getFavoritesAsWebGames', () => { - it('should convert favorites to web games', () => { - const customName = 'Custom Game Name' - addFavoriteGame(mockGame, customName) - - const webGames = 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', () => { - // Add 101 favorites - for (let i = 0; i < 101; i++) { - const game: AnalysisWebGame = { - id: `game-${i}`, - type: 'play', - label: `Game ${i}`, - result: '1-0', - } - addFavoriteGame(game) - } - - const favorites = 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/package-lock.json b/package-lock.json index 02d090b7..ad38d228 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "lila-stockfish-web": "^0.0.7", "next": "^15.2.3", "next-transpile-modules": "^10.0.1", - "onnxruntime-web": "^1.21.0-dev.20250114-228dd16893", + "onnxruntime-web": "^1.23.0", "posthog-js": "^1.257.0", "posthog-node": "^5.3.1", "react": "^18.3.1", @@ -45,12 +45,13 @@ "@types/node": "17.0.8", "@types/react": "19.0.8", "@types/react-dom": "^19.1.6", - "@typescript-eslint/eslint-plugin": "^5.9.1", + "@typescript-eslint/eslint-plugin": "^8.15.0", + "@typescript-eslint/parser": "^8.15.0", "autoprefixer": "^10.4.20", "babel-loader": "^8.2.3", - "eslint": "8.6.0", + "eslint": "^8.57.0", "eslint-config-next": "15.1.6", - "eslint-config-prettier": "^8.3.0", + "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-prettier": "^5.0.0", @@ -63,7 +64,7 @@ "prettier-plugin-tailwindcss": "^0.6.6", "sass-loader": "^12.4.0", "tailwindcss": "^3.4.10", - "typescript": "^5.1.6" + "typescript": "^5.8.3" } }, "node_modules/@adobe/css-tools": { @@ -824,7 +825,6 @@ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, - "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -849,15 +849,14 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz", - "integrity": "sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, - "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.4.0", + "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -872,6 +871,15 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", @@ -904,28 +912,39 @@ "license": "MIT" }, "node_modules/@humanwhocodes/config-array": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", - "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.4" + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" }, "engines": { "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" + "dev": true }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.34.3", @@ -2382,13 +2401,6 @@ "@types/react": "^19.0.0" } }, - "node_modules/@types/semver": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", - "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2427,135 +2439,152 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", - "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz", + "integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==", "dev": true, - "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/type-utils": "5.62.0", - "@typescript-eslint/utils": "5.62.0", - "debug": "^4.3.4", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/type-utils": "8.39.0", + "@typescript-eslint/utils": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "@typescript-eslint/parser": "^8.39.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, "engines": { - "node": ">=10" + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", - "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.0.tgz", + "integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.0.tgz", + "integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.39.0", + "@typescript-eslint/types": "^8.39.0", + "debug": "^4.3.4" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", - "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz", + "integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0" + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz", + "integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", - "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz", + "integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "5.62.0", - "@typescript-eslint/utils": "5.62.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0", + "@typescript-eslint/utils": "8.39.0", "debug": "^4.3.4", - "tsutils": "^3.21.0" + "ts-api-utils": "^2.1.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", - "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.0.tgz", + "integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==", "dev": true, - "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -2563,31 +2592,83 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", - "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz", + "integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0", + "@typescript-eslint/project-service": "8.39.0", + "@typescript-eslint/tsconfig-utils": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { @@ -2595,7 +2676,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -2604,63 +2684,57 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", - "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.0.tgz", + "integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==", "dev": true, - "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "eslint-scope": "^5.1.1", - "semver": "^7.3.7" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", - "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz", + "integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.62.0", - "eslint-visitor-keys": "^3.3.0" + "@typescript-eslint/types": "8.39.0", + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2957,7 +3031,6 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -3044,16 +3117,6 @@ "ajv": "^6.9.1" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3141,8 +3204,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" + "dev": true }, "node_modules/aria-query": { "version": "5.3.0", @@ -3194,16 +3256,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -4546,19 +4598,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -4668,20 +4707,6 @@ "node": ">=10.13.0" } }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -4913,51 +4938,50 @@ } }, "node_modules/eslint": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.6.0.tgz", - "integrity": "sha512-UvxdOJ7mXFlw7iuHZA4jmzPaUqIw54mZrv+XPYKNbKdLR0et4rf60lIZUU9kiNtnzzMzGWxMV+tQ7uG7JG8DPw==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "license": "MIT", "dependencies": { - "@eslint/eslintrc": "^1.0.5", - "@humanwhocodes/config-array": "^0.9.2", - "ajv": "^6.10.0", + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", - "enquirer": "^2.3.5", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.0", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.1.0", - "espree": "^9.3.0", - "esquery": "^1.4.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^6.0.1", - "globals": "^13.6.0", - "ignore": "^4.0.6", - "import-fresh": "^3.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.0.4", + "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "progress": "^2.0.0", - "regexpp": "^3.2.0", - "semver": "^7.2.1", + "optionator": "^0.9.3", "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" + "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" @@ -5033,11 +5057,10 @@ } }, "node_modules/eslint-config-prettier": { - "version": "8.10.2", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.2.tgz", - "integrity": "sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, - "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5306,6 +5329,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -5320,39 +5344,11 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } }, - "node_modules/eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^2.0.0" - }, - "engines": { - "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=5" - } - }, - "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", @@ -5383,27 +5379,50 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, "engines": { - "node": ">= 4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "dependencies": { + "p-locate": "^5.0.0" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/espree": { @@ -5411,7 +5430,6 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -5927,13 +5945,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "dev": true, - "license": "MIT" - }, "node_modules/functions-have-names": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", @@ -6129,7 +6140,6 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, - "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -6157,27 +6167,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/goober": { "version": "2.1.16", "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", @@ -6492,7 +6481,6 @@ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, - "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -6961,6 +6949,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -8266,7 +8263,6 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, - "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -9450,13 +9446,6 @@ "dev": true, "license": "MIT" }, - "node_modules/natural-compare-lite": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", - "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", - "dev": true, - "license": "MIT" - }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -9816,21 +9805,19 @@ } }, "node_modules/onnxruntime-common": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0.tgz", - "integrity": "sha512-vcuaNWgtF2dGQu/EP5P8UI5rEPEYqXG2sPPe5j9lg2TY/biJF8eWklTMwlDO08iuXq48xJo0awqIpK5mPG+IxA==", - "license": "MIT" + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.23.0.tgz", + "integrity": "sha512-Auz8S9D7vpF8ok7fzTobvD1XdQDftRf/S7pHmjeCr3Xdymi4z1C7zx4vnT6nnUjbpelZdGwda0BmWHCCTMKUTg==" }, "node_modules/onnxruntime-web": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0.tgz", - "integrity": "sha512-Ud/+EBo6mhuaQWt/OjaOk0iNWjXqJoeeMFr6xQEERZdIZH2OWpGzuujz7lfuOBjUa6TEE/sc4nb7Da5dNL34fg==", - "license": "MIT", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.23.0.tgz", + "integrity": "sha512-w0bvC2RwDxphOUFF8jFGZ/dYw+duaX20jM6V4BIZJPCfK4QuCpB/pVREV+hjYbT3x4hyfa2ZbTaWx4e1Vot0fQ==", "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", - "onnxruntime-common": "1.22.0", + "onnxruntime-common": "1.23.0", "platform": "^1.3.6", "protobufjs": "^7.2.4" } @@ -9938,7 +9925,6 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, - "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -10074,16 +10060,6 @@ "dev": true, "license": "ISC" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10490,16 +10466,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -10992,19 +10958,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -11108,7 +11061,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, - "license": "MIT", "engines": { "node": ">=4" } @@ -12757,6 +12709,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -12806,29 +12770,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - } - }, - "node_modules/tsutils/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "license": "0BSD" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -12857,7 +12798,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -13159,13 +13099,6 @@ "dev": true, "license": "MIT" }, - "node_modules/v8-compile-cache": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz", - "integrity": "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==", - "dev": true, - "license": "MIT" - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index 8af34220..88a51c41 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "lila-stockfish-web": "^0.0.7", "next": "^15.2.3", "next-transpile-modules": "^10.0.1", - "onnxruntime-web": "^1.21.0-dev.20250114-228dd16893", + "onnxruntime-web": "^1.23.0", "posthog-js": "^1.257.0", "posthog-node": "^5.3.1", "react": "^18.3.1", @@ -49,12 +49,13 @@ "@types/node": "17.0.8", "@types/react": "19.0.8", "@types/react-dom": "^19.1.6", - "@typescript-eslint/eslint-plugin": "^5.9.1", + "@typescript-eslint/eslint-plugin": "^8.15.0", + "@typescript-eslint/parser": "^8.15.0", "autoprefixer": "^10.4.20", "babel-loader": "^8.2.3", - "eslint": "8.6.0", + "eslint": "^8.57.0", "eslint-config-next": "15.1.6", - "eslint-config-prettier": "^8.3.0", + "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-prettier": "^5.0.0", @@ -67,7 +68,7 @@ "prettier-plugin-tailwindcss": "^0.6.6", "sass-loader": "^12.4.0", "tailwindcss": "^3.4.10", - "typescript": "^5.1.6" + "typescript": "^5.8.3" }, "overrides": { "@types/react": "19.0.8" diff --git a/public/assets/icons/chessdotcom.svg b/public/assets/icons/chessdotcom.svg new file mode 100644 index 00000000..9a9ec9c6 --- /dev/null +++ b/public/assets/icons/chessdotcom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/icons/lichess.svg b/public/assets/icons/lichess.svg new file mode 100644 index 00000000..f51190f7 --- /dev/null +++ b/public/assets/icons/lichess.svg @@ -0,0 +1,4 @@ + + + diff --git a/public/embed.png b/public/embed.png new file mode 100644 index 00000000..aa2e6317 Binary files /dev/null and b/public/embed.png differ diff --git a/src/api/analysis.ts b/src/api/analysis.ts new file mode 100644 index 00000000..efd100d4 --- /dev/null +++ b/src/api/analysis.ts @@ -0,0 +1,376 @@ +/* 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<{ + game_id: 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() +} + +export const deleteCustomGame = async (gameId: string): Promise => { + const res = await fetch(buildUrl(`analysis/delete_custom_game/${gameId}`), { + method: 'DELETE', + }) + + if (!res.ok) { + console.error(`Failed to delete custom game: ${await res.text()}`) + } +} diff --git a/src/api/analysis/analysis.ts b/src/api/analysis/analysis.ts deleted file mode 100644 index 16ea177e..00000000 --- a/src/api/analysis/analysis.ts +++ /dev/null @@ -1,720 +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' - -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 } = await import('chess.ts') - 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 { saveCustomAnalysis } = await import('src/lib/customAnalysis') - - const stored = saveCustomAnalysis('pgn', pgn, name) - - return createAnalyzedGameFromPGN(pgn, stored.id) -} - -const createAnalyzedGameFromFEN = async ( - fen: string, - id?: string, -): Promise => { - const { Chess } = await import('chess.ts') - 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 { saveCustomAnalysis } = await import('src/lib/customAnalysis') - - const stored = saveCustomAnalysis('fen', fen, name) - - return createAnalyzedGameFromFEN(fen, stored.id) -} - -export const getAnalyzedCustomGame = async ( - id: string, -): Promise => { - const { getCustomAnalysisById } = await import('src/lib/customAnalysis') - - 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() -} 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/broadcasts.ts b/src/api/broadcasts.ts new file mode 100644 index 00000000..7526d1e9 --- /dev/null +++ b/src/api/broadcasts.ts @@ -0,0 +1,469 @@ +import { Chess } from 'chess.ts' +import { + Broadcast, + BroadcastGame, + PGNParseResult, + TopBroadcastsResponse, + TopBroadcastItem, +} 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 getLichessBroadcasts = async (): Promise => { + const response = await fetch('https://lichess.org/api/broadcast', { + headers: { + Accept: 'application/x-ndjson', + }, + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + if (!response.body) { + throw new Error('No response body') + } + + const broadcasts: Broadcast[] = [] + + return new Promise((resolve, reject) => { + const onMessage = (message: any) => { + try { + broadcasts.push(message as Broadcast) + } catch (error) { + console.error('Error parsing broadcast message:', error) + } + } + + const onComplete = () => { + resolve(broadcasts) + } + + readStream(onMessage)(response).then(onComplete).catch(reject) + }) +} + +export const getLichessBroadcastById = async ( + broadcastId: string, +): Promise => { + try { + console.log('Fetching broadcast by ID:', broadcastId) + const response = await fetch( + `https://lichess.org/api/broadcast/${broadcastId}`, + { + headers: { + Accept: 'application/json', + }, + }, + ) + + if (!response.ok) { + console.error(`Failed to fetch broadcast: ${response.status}`) + return null + } + + const data = await response.json() + console.log('Broadcast data received:', { + name: data.tour?.name, + rounds: data.rounds?.length, + roundNames: data.rounds?.map((r: any) => r.name), + }) + + // Validate that this looks like broadcast data + if (data.tour && data.rounds) { + return data as Broadcast + } + + console.error('Invalid broadcast data structure') + return null + } catch (error) { + console.error('Error fetching broadcast by ID:', error) + return null + } +} + +export const getLichessTopBroadcasts = + async (): Promise => { + const response = await fetch('https://lichess.org/api/broadcast/top', { + headers: { + Accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response.json() + } + +export const convertTopBroadcastToBroadcast = ( + item: TopBroadcastItem, +): Broadcast => { + return { + tour: item.tour, + rounds: [item.round], + defaultRoundId: item.round.id, + } +} + +export const getBroadcastRoundPGN = async ( + roundId: string, +): Promise => { + const response = await fetch( + `https://lichess.org/api/broadcast/round/${roundId}.pgn`, + { + headers: { + Accept: 'application/x-chess-pgn', + }, + }, + ) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return await response.text() +} + +export const streamBroadcastRound = async ( + roundId: string, + onPGNUpdate: (pgn: string) => void, + onComplete: () => void, + abortSignal?: AbortSignal, +) => { + const stream = fetch( + `https://lichess.org/api/stream/broadcast/round/${roundId}.pgn`, + { + signal: abortSignal, + headers: { + Accept: 'application/x-chess-pgn', + }, + }, + ) + + const onMessage = (data: string) => { + if (data.trim()) { + onPGNUpdate(data) + } + } + + 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') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + + if (done) { + if (buffer.trim()) { + onMessage(buffer) + } + break + } + + const chunk = decoder.decode(value, { stream: true }) + buffer += chunk + + // Split on double newlines to separate PGN games + const parts = buffer.split('\n\n\n') + buffer = parts.pop() || '' + + for (const part of parts) { + if (part.trim()) { + onMessage(part) + } + } + } + + onComplete() + } catch (error) { + if (abortSignal?.aborted) { + console.log('Broadcast stream aborted') + } else { + console.error('Broadcast stream error:', error) + throw error + } + } +} + +export const parsePGNData = (pgnData: string): PGNParseResult => { + const games: BroadcastGame[] = [] + const errors: string[] = [] + + try { + // Split the PGN data into individual games + const gameStrings = pgnData + .split(/\n\n\[Event/) + .filter((game) => game.trim()) + + for (let i = 0; i < gameStrings.length; i++) { + let gameString = gameStrings[i] + + // Add back the [Event header if it was removed by split + if (i > 0 && !gameString.startsWith('[Event')) { + gameString = '[Event' + gameString + } + + try { + const game = parseSinglePGN(gameString) + if (game) { + games.push(game) + } + } catch (error) { + errors.push(`Error parsing game ${i + 1}: ${error}`) + } + } + } catch (error) { + errors.push(`Error splitting PGN data: ${error}`) + } + + return { games, errors } +} + +const parseSinglePGN = (pgnString: string): BroadcastGame | null => { + const lines = pgnString.trim().split('\n') + const headers: Record = {} + let movesSection = '' + let inMoves = false + + // Parse headers and moves + for (const line of lines) { + const trimmedLine = line.trim() + + if (trimmedLine.startsWith('[') && trimmedLine.endsWith(']')) { + // Parse header + const match = trimmedLine.match(/^\[(\w+)\s+"([^"]*)"\]$/) + if (match) { + headers[match[1]] = match[2] + } + } else if (trimmedLine && !inMoves) { + inMoves = true + movesSection = trimmedLine + } else if (inMoves && trimmedLine) { + movesSection += ' ' + trimmedLine + } + } + + // Extract essential data + const white = headers.White || 'Unknown' + const black = headers.Black || 'Unknown' + const result = headers.Result || '*' + const event = headers.Event || '' + const site = headers.Site || '' + const date = headers.Date || headers.UTCDate || '' + const round = headers.Round || '' + + // Parse moves and clock information from full PGN + console.log(`Parsing PGN for ${white} vs ${black}`) + const parseResult = parseMovesAndClocksFromPGN(pgnString) + const moves = parseResult.moves + const { whiteClock, blackClock } = parseResult + const fen = extractFENFromMoves() + + // Debug clock parsing + if (whiteClock || blackClock) { + console.log(`Clock data for ${white} vs ${black}:`, { + whiteClock, + blackClock, + movesSection: movesSection.substring(0, 200) + '...', + }) + } + + const game: BroadcastGame = { + id: generateGameId(white, black, event, site), + white, + black, + result, + moves, + pgn: pgnString, + fen: fen || 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', + event, + site, + date, + round, + eco: headers.ECO, + opening: headers.Opening, + whiteElo: headers.WhiteElo ? parseInt(headers.WhiteElo) : undefined, + blackElo: headers.BlackElo ? parseInt(headers.BlackElo) : undefined, + timeControl: headers.TimeControl, + termination: headers.Termination, + annotator: headers.Annotator, + studyName: headers.StudyName, + chapterName: headers.ChapterName, + utcDate: headers.UTCDate, + utcTime: headers.UTCTime, + whiteClock, + blackClock, + } + + // Note: Last move extraction would need proper move parsing to convert SAN to UCI + // For now, we'll leave it undefined and handle in the controller + + return game +} + +const parseMovesAndClocksFromPGN = ( + pgnString: string, +): { + moves: string[] + whiteClock?: { + timeInSeconds: number + isActive: boolean + lastUpdateTime: number + } + blackClock?: { + timeInSeconds: number + isActive: boolean + lastUpdateTime: number + } +} => { + const moves: string[] = [] + let whiteClock: + | { timeInSeconds: number; isActive: boolean; lastUpdateTime: number } + | undefined + let blackClock: + | { timeInSeconds: number; isActive: boolean; lastUpdateTime: number } + | undefined + + try { + // Use chess.js to parse the full PGN + const chess = new Chess() + const success = chess.loadPgn(pgnString) + + if (!success) { + console.warn( + 'Failed to parse PGN with chess.js, falling back to manual parsing', + ) + return { moves } + } + + // Get all moves from the game history + const history = chess.history({ verbose: true }) + for (const move of history) { + moves.push(move.san) + } + + // Get comments which contain clock information + const comments = chess.getComments() + let lastWhiteClock: any = null + let lastBlackClock: any = null + + for (const commentData of comments) { + const comment = commentData.comment + + // Extract clock from comment using regex + const clockMatch = comment.match(/\[%clk\s+(\d+):(\d+)(?::(\d+))?\]/) + if (clockMatch) { + const hours = clockMatch[3] ? parseInt(clockMatch[1]) : 0 + const minutes = clockMatch[3] + ? parseInt(clockMatch[2]) + : parseInt(clockMatch[1]) + const seconds = clockMatch[3] + ? parseInt(clockMatch[3]) + : parseInt(clockMatch[2]) + + const timeInSeconds = hours * 3600 + minutes * 60 + seconds + const clockData = { + timeInSeconds, + isActive: false, + lastUpdateTime: Date.now(), + } + + // Determine if this is white or black's move based on the FEN + const chess_temp = new Chess(commentData.fen) + const isWhiteToMove = chess_temp.turn() === 'b' // After white's move, it's black's turn + + if (isWhiteToMove) { + lastWhiteClock = clockData + } else { + lastBlackClock = clockData + } + + console.log( + `Found clock for ${isWhiteToMove ? 'white' : 'black'}: ${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')} = ${timeInSeconds}s`, + ) + } + } + + whiteClock = lastWhiteClock + blackClock = lastBlackClock + + // Determine which clock is active based on current turn + if (moves.length > 0) { + const finalPosition = new Chess() + finalPosition.loadPgn(pgnString) + const isCurrentlyWhiteTurn = finalPosition.turn() === 'w' + + if (whiteClock) { + whiteClock.isActive = isCurrentlyWhiteTurn + } + if (blackClock) { + blackClock.isActive = !isCurrentlyWhiteTurn + } + } + } catch (error) { + console.warn('Error parsing PGN with chess.js:', error) + } + + return { moves, whiteClock, blackClock } +} + +const extractFENFromMoves = (): string | null => { + // This would require a full chess engine to calculate the FEN from moves + // For now, return null and handle in the controller with chess.js + return null +} + +const generateGameId = ( + white: string, + black: string, + event: string, + site: string, +): string => { + const baseString = `${white}-${black}-${event}-${site}` + // Use a simple hash instead of deprecated btoa for better compatibility + let hash = 0 + for (let i = 0; i < baseString.length; i++) { + const char = baseString.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32bit integer + } + return Math.abs(hash).toString(36).substring(0, 12) +} 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 7aa504f5..28374a70 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,9 +1,11 @@ -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 { getActiveUserCount } from './home' +export * from './openings' +export * from './lichess' +export * from './home' +export * from './utils' +export * from './broadcasts' 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/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/opening/opening.ts deleted file mode 100644 index 8ac24884..00000000 --- a/src/api/opening/opening.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { buildUrl } from '../utils' - -// API Types for opening drill logging -export interface OpeningDrillSelection { - opening_fen: string - side_played: string -} - -export interface SelectOpeningDrillsRequest { - openings: OpeningDrillSelection[] - opponent: string - num_moves: number - num_drills: number -} - -export interface SelectOpeningDrillsResponse { - session_id: string -} - -export interface SubmitOpeningDrillRequest { - session_id: string - opening_fen: string - side_played: string - moves_played_uci: string[] -} - -// API function to log opening drill selections and start a session -export const selectOpeningDrills = async ( - request: SelectOpeningDrillsRequest, -): Promise => { - const res = await fetch(buildUrl('opening/select_opening_drills'), { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request), - }) - - if (res.status === 401) { - throw new Error('Unauthorized') - } - - if (!res.ok) { - throw new Error(`Failed to select opening drills: ${res.statusText}`) - } - - const data = await res.json() - return data as SelectOpeningDrillsResponse -} - -// API function to submit a completed opening drill -export const submitOpeningDrill = async ( - request: SubmitOpeningDrillRequest, -): Promise => { - const res = await fetch(buildUrl('opening/record_opening_drill'), { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request), - }) - - if (res.status === 401) { - throw new Error('Unauthorized') - } - - if (!res.ok) { - throw new Error(`Failed to submit opening drill: ${res.statusText}`) - } -} diff --git a/src/api/openings.ts b/src/api/openings.ts new file mode 100644 index 00000000..cac80bd5 --- /dev/null +++ b/src/api/openings.ts @@ -0,0 +1,30 @@ +import { buildUrl } from './utils' + +export interface LogOpeningDrillRequest { + opening_fen: string + side_played: string + opponent: string + num_moves: number + moves_played_uci: string[] +} + +export const logOpeningDrill = async ( + request: LogOpeningDrillRequest, +): Promise => { + const res = await fetch(buildUrl('opening/log_opening_drill'), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }) + + if (res.status === 401) { + throw new Error('Unauthorized') + } + + if (!res.ok) { + throw new Error(`Failed to log opening drill: ${res.statusText}`) + } +} 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/AnalysisConfigModal.tsx b/src/components/Analysis/AnalysisConfigModal.tsx index bdb05d94..b40aa62f 100644 --- a/src/components/Analysis/AnalysisConfigModal.tsx +++ b/src/components/Analysis/AnalysisConfigModal.tsx @@ -55,7 +55,7 @@ export const AnalysisConfigModal: React.FC = ({ return ( = ({ }} > e.stopPropagation()} > -
- +
+ network_intelligence -

Analyze Entire Game

+

+ Analyze Entire Game +

-

+

Choose the Stockfish analysis depth for all positions in the game:

@@ -89,10 +91,10 @@ export const AnalysisConfigModal: React.FC = ({ {depthOptions.map((option) => (
-
- +
+ info -

+

Higher depths provide more accurate analysis but take longer to - complete. You can cancel the analysis at any time. Currently, - analysis only persists until you close the tab, but we are working - on a persistent analysis feature! + complete. You can cancel the analysis at any time. Analysis will + persist even after you close the tab,

-
+
+
+ )} + {/* H&B Subsections */} {selected === 'hb' && ( -
+
@@ -524,7 +897,6 @@ export const AnalysisGameList: React.FC = ({ selectedGameElement={ selectedGameElement as React.RefObject } - loadNewTournamentGame={loadNewTournamentGame} analysisTournamentList={analysisTournamentList} /> ))} @@ -533,17 +905,22 @@ export const AnalysisGameList: React.FC = ({ <> {getCurrentGames().map((game, index) => { const selectedGame = currentId && currentId[0] === game.id - const isFavorited = isFavoriteGame(game.id) + const isFavorited = + favoritedGameIds.has(game.id) || + (!favoritesInitialized && Boolean(game.is_favorited)) + const displayName = getDisplayName(game) return (
-

- {selected === 'play' || selected === 'hb' +

+ {selected === 'play' || + selected === 'hb' || + selected === 'favorites' ? (currentPage - 1) * 25 + index + 1 : index + 1}

@@ -551,12 +928,9 @@ export const AnalysisGameList: React.FC = ({ - )} - {selected !== 'favorites' && ( - - )} -

+ star + + + +

{game.result .replace('1/2', '½') .replace('1/2', '½')} @@ -629,7 +999,9 @@ export const AnalysisGameList: React.FC = ({

) })} - {(selected === 'play' || selected === 'hb') && + {(selected === 'play' || + selected === 'hb' || + selected === 'favorites') && totalPages > 1 && (
@@ -646,11 +1018,11 @@ export const AnalysisGameList: React.FC = ({ disabled={currentPage === 1} className="flex items-center justify-center text-secondary hover:text-primary disabled:opacity-50" > - + arrow_back_ios - + Page {currentPage} of {totalPages} @@ -667,7 +1039,7 @@ export const AnalysisGameList: React.FC = ({ disabled={currentPage === totalPages} className="flex items-center justify-center text-secondary hover:text-primary disabled:opacity-50" > - + last_page @@ -681,7 +1053,7 @@ export const AnalysisGameList: React.FC = ({ getCurrentGames().length === 0 && !loading && (
-

+

{selected === 'favorites' ? ' ⭐ Hit the star to favourite games...' : 'Play more games... ^. .^₎⟆'} @@ -689,30 +1061,19 @@ export const AnalysisGameList: React.FC = ({

)}
- {onCustomAnalysis && ( - - )} + {/* Removed bottom "Analyze Custom" button; now shown only under Custom tab */}
setFavoriteModal({ isOpen: false, game: null })} - onSave={handleSaveFavorite} - onRemove={ - favoriteModal.game && isFavoriteGame(favoriteModal.game.id) - ? handleRemoveFavorite + isOpen={editModal.isOpen} + currentName={editModal.game ? getDisplayName(editModal.game) : ''} + onClose={() => setEditModal({ isOpen: false, game: null })} + onSave={handleSaveGameName} + onDelete={ + editModal.game && editModal.game.type === 'custom' + ? handleDeleteCustomGame : undefined } + title="Edit Game" />
) : null @@ -734,11 +1095,11 @@ function Header({ return ( diff --git a/src/components/Analysis/AnalysisNotification.tsx b/src/components/Analysis/AnalysisNotification.tsx index 7cc07b7d..d03e3f34 100644 --- a/src/components/Analysis/AnalysisNotification.tsx +++ b/src/components/Analysis/AnalysisNotification.tsx @@ -1,9 +1,9 @@ import React from 'react' import { motion } from 'framer-motion' -import { GameAnalysisProgress } from 'src/hooks/useAnalysisController/useAnalysisController' +import { DeepAnalysisProgress } from 'src/types/analysis' interface Props { - progress: GameAnalysisProgress + progress: DeepAnalysisProgress onCancel: () => void } @@ -11,71 +11,76 @@ export const AnalysisNotification: React.FC = ({ progress, onCancel, }) => { - const progressPercentage = - progress.totalMoves > 0 - ? Math.round((progress.currentMoveIndex / progress.totalMoves) * 100) - : 0 + const hasTotals = progress.totalMoves > 0 + const progressPercentage = hasTotals + ? Math.round((progress.currentMoveIndex / progress.totalMoves) * 100) + : 0 if (!progress.isAnalyzing) return null return ( -
-
-
- - network_intelligence - -
-
-

Analyzing Game

-

- Position {progress.currentMoveIndex} of {progress.totalMoves} -

-
-
-
- - {progressPercentage}% +
+
+ + neurology -
-
-
-
- +
+
+

+ Analyzing Game +

+
+ + {hasTotals + ? `Position ${Math.max(progress.currentMoveIndex, 1)} of ${progress.totalMoves}` + : 'Calibrating engine positions…'} +
+ +
- {progress.currentMove && ( -
-

- Current:{' '} - - {progress.currentMove} - -

+
+
+
+ +
+ + {progressPercentage}% +
- )} + {progress.currentMove && ( +
+

+ Current Position +

+

+ {progress.currentMove} +

+
+ )} +
) } diff --git a/src/components/Analysis/AnalysisSidebar.tsx b/src/components/Analysis/AnalysisSidebar.tsx index e84a54a3..2198a89b 100644 --- a/src/components/Analysis/AnalysisSidebar.tsx +++ b/src/components/Analysis/AnalysisSidebar.tsx @@ -3,13 +3,22 @@ import { Highlight, BlunderMeter, MovesByRating, + SimplifiedAnalysisOverview, + SimplifiedBlunderMeter, } from 'src/components/Analysis' import { motion } from 'framer-motion' import type { DrawShape } from 'chessground/draw' import { Dispatch, SetStateAction, useCallback, useMemo } from 'react' +import type { ComponentProps } from 'react' +import { useLocalStorage } from 'src/hooks' import { useAnalysisController } from 'src/hooks/useAnalysisController' import type { MaiaEvaluation, StockfishEvaluation } from 'src/types' +type AnalysisViewMode = 'simple' | 'detailed' +type HighlightBoardDescription = ComponentProps< + typeof Highlight +>['boardDescription'] + interface Props { hover: (move?: string) => void makeMove: (move: string) => void @@ -52,7 +61,6 @@ export const AnalysisSidebar: React.FC = ({ handleToggleAnalysis, itemVariants, }) => { - // Mock handlers for when analysis is disabled const emptyBlunderMeterData = useMemo( () => ({ goodMoves: { moves: [], probability: 0 }, @@ -69,283 +77,294 @@ export const AnalysisSidebar: React.FC = ({ }), [], ) + const mockHover = useCallback(() => void 0, []) const mockSetHoverArrow = useCallback(() => void 0, []) const mockMakeMove = useCallback(() => void 0, []) - return ( - - {/* Analysis Toggle Bar */} -
+ const [analysisViewMode, setAnalysisViewMode] = + useLocalStorage('maia-analysis-view-mode', 'simple') + const isSimplifiedView = analysisViewMode !== 'detailed' + + const handleToggleViewMode = useCallback(() => { + setAnalysisViewMode(isSimplifiedView ? 'detailed' : 'simple') + }, [isSimplifiedView, setAnalysisViewMode]) + + const highlightMoveEvaluation = analysisEnabled + ? (controller.moveEvaluation as { + maia?: MaiaEvaluation + stockfish?: StockfishEvaluation + }) + : { + maia: undefined, + stockfish: undefined, + } + + const highlightBoardDescription: HighlightBoardDescription = analysisEnabled + ? controller.boardDescription + : { + segments: [ + { + type: 'text', + content: + 'Analysis is disabled. Enable analysis to see detailed move evaluations and recommendations.', + }, + ], + } + + const highlightProps: ComponentProps = { + hover: analysisEnabled ? hover : mockHover, + makeMove: analysisEnabled ? makeMove : mockMakeMove, + currentMaiaModel: controller.currentMaiaModel, + setCurrentMaiaModel: controller.setCurrentMaiaModel, + recommendations: analysisEnabled + ? controller.moveRecommendations + : emptyRecommendations, + moveEvaluation: highlightMoveEvaluation, + colorSanMapping: analysisEnabled ? controller.colorSanMapping : {}, + boardDescription: highlightBoardDescription, + currentNode: controller.currentNode ?? undefined, + simplified: isSimplifiedView, + } + + const simplifiedBlunderMeterProps: ComponentProps< + typeof SimplifiedBlunderMeter + > = { + hover: analysisEnabled ? hover : mockHover, + makeMove: analysisEnabled ? makeMove : mockMakeMove, + data: analysisEnabled ? controller.blunderMeter : emptyBlunderMeterData, + colorSanMapping: analysisEnabled ? controller.colorSanMapping : {}, + moveEvaluation: analysisEnabled ? controller.moveEvaluation : undefined, + playerToMove: analysisEnabled ? (controller.currentNode?.turn ?? 'w') : 'w', + } + + const blunderMeterProps: ComponentProps = { + ...simplifiedBlunderMeterProps, + } + + const moveMapProps = { + moveMap: analysisEnabled ? controller.moveMap : undefined, + colorSanMapping: analysisEnabled ? controller.colorSanMapping : {}, + setHoverArrow: analysisEnabled ? setHoverArrow : mockSetHoverArrow, + makeMove: analysisEnabled ? makeMove : mockMakeMove, + playerToMove: analysisEnabled ? (controller.currentNode?.turn ?? 'w') : 'w', + } + + const movesByRatingProps = { + moves: analysisEnabled ? controller.movesByRating : undefined, + colorSanMapping: analysisEnabled ? controller.colorSanMapping : {}, + } + + const renderHeader = ( + variant: 'desktop' | 'mobile', + extraClasses?: string, + ) => { + const containerClasses = [ + 'flex h-10 min-h-10 items-center justify-between border-b border-glass-border bg-transparent text-white/90', + variant === 'desktop' ? 'px-4' : 'px-3', + variant === 'mobile' ? 'backdrop-blur-md' : '', + extraClasses ?? '', + ] + .filter(Boolean) + .join(' ') + + const buttonBase = + variant === 'desktop' + ? 'flex items-center gap-1 rounded-md border border-glass-border bg-glass px-2 py-1 text-xs transition-colors hover:bg-glass-stronger' + : 'flex items-center gap-1 rounded-md border border-glass-border bg-glass px-2 py-1 text-xs transition-colors' + + const viewButtonClass = `${buttonBase} ${ + isSimplifiedView ? 'text-white' : 'text-white/80' + }` + const visibilityButtonClass = `${buttonBase} ${ + analysisEnabled ? 'text-white' : 'text-white/80' + }` + + return ( +
+
+ + analytics + +

+ Analysis +

+
- analytics -

Analysis

+ +
-
+ ) + } + + const renderDisabledOverlay = ( + message: string, + options: { offsetTop?: boolean } = {}, + ) => { + const offsetClasses = options.offsetTop + ? 'bottom-0 left-0 right-0 top-10' + : 'inset-0' + + return ( +
+
+ + lock - {analysisEnabled ? 'Visible' : 'Hidden'} - +

+ Analysis Disabled +

+

{message}

+
+ ) + } - {/* Large screens : 2-row layout */} -
- {/* Combined Highlight + MovesByRating container */} -
-
-
- -
-
- -
+ const simplifiedLayout = ( + <> +
+
+ {renderHeader('desktop')} +
+
- {!analysisEnabled && ( -
-
- - lock - -

Analysis Disabled

-

- Enable analysis to see move evaluations -

+ {!analysisEnabled && + renderDisabledOverlay('Enable analysis to see move evaluations', { + offsetTop: true, + })} +
+
+
+
+ {renderHeader('mobile', 'absolute left-0 top-0 z-10 w-full')} +
+ +
+ {!analysisEnabled && + renderDisabledOverlay('Enable analysis to see move evaluations', { + offsetTop: true, + })} +
+
+ + ) + + const detailedLayout = ( + <> +
+
+
+ {renderHeader('desktop')} +
+
+ +
+
+
- )} +
+ {!analysisEnabled && + renderDisabledOverlay('Enable analysis to see move evaluations', { + offsetTop: true, + })}
- {/* MoveMap + BlunderMeter container */} -
+
- +
- - {!analysisEnabled && ( -
-
- - lock - -

Analysis Disabled

-

- Enable analysis to see position evaluation -

-
-
- )} + + {!analysisEnabled && + renderDisabledOverlay('Enable analysis to see position evaluation')}
- {/* Smaller screens: 3-row layout */} -
- {/* Row 1: Combined Highlight + BlunderMeter container */} -
-
- +
+
+ {renderHeader('mobile', 'absolute left-0 top-0 z-10 w-full')} +
+
-
+
- +
- {!analysisEnabled && ( -
-
- - lock - -

Analysis Disabled

-

- Enable analysis to see move evaluations -

-
-
- )} + {!analysisEnabled && + renderDisabledOverlay('Enable analysis to see move evaluations', { + offsetTop: true, + })}
- {/* Row 2: MoveMap */}
- +
- {!analysisEnabled && ( -
-
- - lock - -

Analysis Disabled

-

- Enable analysis to see position evaluation -

-
-
- )} + {!analysisEnabled && + renderDisabledOverlay('Enable analysis to see position evaluation')}
- {/* Row 3: MovesByRating */}
-
- +
+ + {!analysisEnabled && + renderDisabledOverlay('Enable analysis to see move evaluations')}
- {!analysisEnabled && ( -
-
- - lock - -

Analysis Disabled

-

- Enable analysis to see move evaluations -

-
-
- )}
+ + ) + + return ( + + {isSimplifiedView ? simplifiedLayout : detailedLayout} ) } diff --git a/src/components/Analysis/BlunderMeter.tsx b/src/components/Analysis/BlunderMeter.tsx index 00636ce7..e468b9bd 100644 --- a/src/components/Analysis/BlunderMeter.tsx +++ b/src/components/Analysis/BlunderMeter.tsx @@ -20,6 +20,7 @@ interface Props { maia?: MaiaEvaluation stockfish?: StockfishEvaluation } | null + playerToMove?: 'w' | 'b' } export const BlunderMeter: React.FC = ({ @@ -29,6 +30,7 @@ export const BlunderMeter: React.FC = ({ colorSanMapping, showContainer = true, moveEvaluation, + playerToMove = 'w', }: Props) => { const { isMobile } = useContext(WindowSizeContext) @@ -40,6 +42,7 @@ export const BlunderMeter: React.FC = ({ colorSanMapping={colorSanMapping} showContainer={showContainer} moveEvaluation={moveEvaluation} + playerToMove={playerToMove} /> ) : ( = ({ colorSanMapping={colorSanMapping} showContainer={showContainer} moveEvaluation={moveEvaluation} + playerToMove={playerToMove} /> ) } @@ -60,11 +64,12 @@ const DesktopBlunderMeter: React.FC = ({ colorSanMapping, showContainer = true, moveEvaluation, + playerToMove = 'w', }: Props) => { return (

Blunder Meter

@@ -79,6 +84,7 @@ const DesktopBlunderMeter: React.FC = ({ probability={data.goodMoves.probability} colorSanMapping={colorSanMapping} moveEvaluation={moveEvaluation} + playerToMove={playerToMove} /> = ({ probability={data.okMoves.probability} colorSanMapping={colorSanMapping} moveEvaluation={moveEvaluation} + playerToMove={playerToMove} /> = ({ probability={data.blunderMoves.probability} colorSanMapping={colorSanMapping} moveEvaluation={moveEvaluation} + playerToMove={playerToMove} />
@@ -115,11 +123,12 @@ const MobileBlunderMeter: React.FC = ({ colorSanMapping, showContainer, moveEvaluation, + playerToMove = 'w', }: Props) => { return (

Blunder Meter

@@ -166,6 +175,8 @@ const MobileBlunderMeter: React.FC = ({ makeMove={makeMove} colorSanMapping={colorSanMapping} moveEvaluation={moveEvaluation} + playerToMove={playerToMove} + probability={data.goodMoves.probability} /> = ({ makeMove={makeMove} colorSanMapping={colorSanMapping} moveEvaluation={moveEvaluation} + playerToMove={playerToMove} + probability={data.okMoves.probability} /> = ({ makeMove={makeMove} colorSanMapping={colorSanMapping} moveEvaluation={moveEvaluation} + playerToMove={playerToMove} + probability={data.blunderMoves.probability} />
@@ -199,6 +214,8 @@ function MovesList({ makeMove, colorSanMapping, moveEvaluation, + playerToMove = 'w', + probability, }: { title: string textColor: string @@ -210,6 +227,8 @@ function MovesList({ maia?: MaiaEvaluation stockfish?: StockfishEvaluation } | null + playerToMove?: 'w' | 'b' + probability: number }) { const { isMobile } = useContext(WindowSizeContext) const [tooltipData, setTooltipData] = useState<{ @@ -227,7 +246,17 @@ function MovesList({ }, [colorSanMapping]) const filteredMoves = () => { - return moves.slice(0, 6).filter((move) => move.probability >= 8) + const threshold = 5 + const filtered = moves + .slice(0, 6) + .filter((move) => move.probability >= threshold) + + // If category has meaningful probability but no moves meet threshold, show top move + if (filtered.length === 0 && probability >= 10 && moves.length > 0) { + return [moves[0]] + } + + return filtered } const handleMouseEnter = (move: string, event: React.MouseEvent) => { @@ -309,6 +338,8 @@ function MovesList({ stockfishCpRelative={ moveEvaluation.stockfish?.cp_relative_vec?.[tooltipData.move] } + stockfishMate={moveEvaluation.stockfish?.mate_vec?.[tooltipData.move]} + playerToMove={playerToMove} position={tooltipData.position} onClickMove={isMobile ? handleTooltipClick : undefined} /> @@ -365,6 +396,7 @@ function Meter({ probability, colorSanMapping, moveEvaluation, + playerToMove = 'w', }: { title: string textColor: string @@ -378,6 +410,7 @@ function Meter({ maia?: MaiaEvaluation stockfish?: StockfishEvaluation } | null + playerToMove?: 'w' | 'b' }) { const { isMobile } = useContext(WindowSizeContext) const [tooltipData, setTooltipData] = useState<{ @@ -395,7 +428,17 @@ function Meter({ }, [colorSanMapping]) const filteredMoves = () => { - return moves.slice(0, 6).filter((move) => move.probability >= 8) + const threshold = 8 + const filtered = moves + .slice(0, 6) + .filter((move) => move.probability >= threshold) + + // If category has meaningful probability but no moves meet threshold, show top move + if (filtered.length === 0 && probability >= 10 && moves.length > 0) { + return [moves[0]] + } + + return filtered } const handleMouseEnter = (move: string, event: React.MouseEvent) => { @@ -493,6 +536,8 @@ function Meter({ stockfishCpRelative={ moveEvaluation.stockfish?.cp_relative_vec?.[tooltipData.move] } + stockfishMate={moveEvaluation.stockfish?.mate_vec?.[tooltipData.move]} + playerToMove={playerToMove} position={tooltipData.position} onClickMove={isMobile ? handleTooltipClick : undefined} /> diff --git a/src/components/Analysis/BroadcastAnalysis.tsx b/src/components/Analysis/BroadcastAnalysis.tsx new file mode 100644 index 00000000..06a3df95 --- /dev/null +++ b/src/components/Analysis/BroadcastAnalysis.tsx @@ -0,0 +1,532 @@ +import React, { + useMemo, + useState, + useEffect, + useCallback, + useContext, +} from 'react' +import { motion } from 'framer-motion' +import type { Key } from 'chessground/types' +import { Chess, PieceSymbol } from 'chess.ts' +import type { DrawShape } from 'chessground/draw' + +import { WindowSizeContext } from 'src/contexts' +import { MAIA_MODELS } from 'src/constants/common' +import { GameInfo } from 'src/components/Common/GameInfo' +import { GameBoard } from 'src/components/Board/GameBoard' +import { PlayerInfo } from 'src/components/Common/PlayerInfo' +import { MovesContainer } from 'src/components/Board/MovesContainer' +import { LiveGame, GameNode, BroadcastStreamController } from 'src/types' +import { BoardController } from 'src/components/Board/BoardController' +import { PromotionOverlay } from 'src/components/Board/PromotionOverlay' +import { AnalysisSidebar } from 'src/components/Analysis' +import { ConfigurableScreens } from 'src/components/Analysis/ConfigurableScreens' +import { BroadcastGameList } from 'src/components/Analysis/BroadcastGameList' +import { useAnalysisController } from 'src/hooks/useAnalysisController' + +interface Props { + game: LiveGame + broadcastController: BroadcastStreamController & { + currentLiveGame: LiveGame | null + } + analysisController: ReturnType +} + +export const BroadcastAnalysis: React.FC = ({ + game, + broadcastController, + analysisController, +}) => { + const { width } = useContext(WindowSizeContext) + const isMobile = useMemo(() => width > 0 && width <= 670, [width]) + + const [hoverArrow, setHoverArrow] = useState(null) + const [currentSquare, setCurrentSquare] = useState(null) + const [promotionFromTo, setPromotionFromTo] = useState< + [string, string] | null + >(null) + + useEffect(() => { + setHoverArrow(null) + }, [analysisController.currentNode]) + + const hover = (move?: string) => { + if (move) { + setHoverArrow({ + orig: move.slice(0, 2) as Key, + dest: move.slice(2, 4) as Key, + brush: 'green', + modifiers: { + lineWidth: 10, + }, + }) + } else { + setHoverArrow(null) + } + } + + const makeMove = (move: string) => { + if (!analysisController.currentNode || !game.tree) return + + const chess = new Chess(analysisController.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 (analysisController.currentNode.mainChild?.move === moveString) { + analysisController.goToNode(analysisController.currentNode.mainChild) + } else { + const newVariation = game.tree + .getLastMainlineNode() + .addChild( + newFen, + moveString, + san, + false, + analysisController.currentMaiaModel, + ) + analysisController.goToNode(newVariation) + } + } + } + + const onPlayerMakeMove = useCallback( + (playedMove: [string, string] | null) => { + if (!playedMove) return + + const availableMoves: { from: string; to: string }[] = [] + for (const [from, tos] of analysisController.availableMoves.entries()) { + for (const to of tos as string[]) { + availableMoves.push({ from, to }) + } + } + + const matching = availableMoves.filter((m) => { + return m.from === playedMove[0] && m.to === playedMove[1] + }) + + if (matching.length > 1) { + setPromotionFromTo(playedMove) + return + } + + const moveUci = playedMove[0] + playedMove[1] + makeMove(moveUci) + }, + [analysisController.availableMoves], + ) + + const onPlayerSelectPromotion = useCallback( + (piece: string) => { + if (!promotionFromTo) { + return + } + setPromotionFromTo(null) + const moveUci = promotionFromTo[0] + promotionFromTo[1] + piece + makeMove(moveUci) + }, + [promotionFromTo, setPromotionFromTo], + ) + + const launchContinue = useCallback(() => { + const fen = analysisController.currentNode?.fen as string + const url = '/play' + '?fen=' + encodeURIComponent(fen) + window.open(url) + }, [analysisController.currentNode]) + + const currentPlayer = useMemo(() => { + if (!analysisController.currentNode) return 'white' + const chess = new Chess(analysisController.currentNode.fen) + return chess.turn() === 'w' ? 'white' : 'black' + }, [analysisController.currentNode]) + + const NestedGameInfo = () => ( +
+
+ {[game.whitePlayer, game.blackPlayer].map((player, index) => ( +
+
+
+

+ {player.name} +

+ + {player.rating ? <>({player.rating}) : null} + +
+ {game.termination?.winner === (index == 0 ? 'white' : 'black') ? ( +

1

+ ) : game.termination?.winner !== 'none' ? ( +

0

+ ) : game.termination === undefined ? ( + <> + ) : ( +

½

+ )} +
+ ))} +
+ + {broadcastController.currentBroadcast?.tour.name} + {broadcastController.currentRound && ( + <> • {broadcastController.currentRound.name} + )} + +
+
+
+
+
+ {game.whitePlayer.name} + {game.whitePlayer.rating && ( + ({game.whitePlayer.rating}) + )} +
+
+ {broadcastController.broadcastState.isLive && !game.termination ? ( + LIVE + ) : game.termination?.winner === 'none' ? ( + ½-½ + ) : ( + + + {game.termination?.winner === 'white' ? '1' : '0'} + + - + + {game.termination?.winner === 'black' ? '1' : '0'} + + + )} +
+
+
+ {game.blackPlayer.name} + {game.blackPlayer.rating && ( + ({game.blackPlayer.rating}) + )} +
+
+
+ ) + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + duration: 0.2, + staggerChildren: 0.05, + }, + }, + } + + const itemVariants = { + hidden: { + opacity: 0, + y: 4, + }, + visible: { + opacity: 1, + y: 0, + transition: { + duration: 0.25, + ease: [0.25, 0.46, 0.45, 0.94], + type: 'tween', + }, + }, + exit: { + opacity: 0, + y: -4, + transition: { + duration: 0.2, + ease: [0.25, 0.46, 0.45, 0.94], + type: 'tween', + }, + }, + } + + const desktopLayout = ( + +
+ +
+ + + +
+
+
+ +
+
+
+
+ + +
+
+ + +
+ { + const clock = + analysisController.orientation === 'white' + ? broadcastController.currentGame?.blackClock + : broadcastController.currentGame?.whiteClock + console.log('Top PlayerInfo clock data:', { + orientation: analysisController.orientation, + currentGame: + broadcastController.currentGame?.white + + ' vs ' + + broadcastController.currentGame?.black, + whiteClock: broadcastController.currentGame?.whiteClock, + blackClock: broadcastController.currentGame?.blackClock, + selectedClock: clock, + }) + return clock + })()} + /> +
+ { + const baseShapes = [...analysisController.arrows] + if (hoverArrow) { + baseShapes.push(hoverArrow) + } + return baseShapes + })()} + currentNode={analysisController.currentNode as GameNode} + orientation={analysisController.orientation} + onPlayerMakeMove={onPlayerMakeMove} + goToNode={analysisController.goToNode} + gameTree={game.tree} + /> + {promotionFromTo ? ( + + ) : null} +
+ +
+ +
+ { + // Analysis toggle not needed for broadcast - always enabled + }} + itemVariants={itemVariants} + /> +
+ + ) + + const mobileLayout = ( + +
+ + + + +
+ { + const baseShapes = [...analysisController.arrows] + if (hoverArrow) { + baseShapes.push(hoverArrow) + } + return baseShapes + })()} + currentNode={analysisController.currentNode as GameNode} + orientation={analysisController.orientation} + onPlayerMakeMove={onPlayerMakeMove} + goToNode={analysisController.goToNode} + gameTree={game.tree} + /> + {promotionFromTo ? ( + + ) : null} +
+
+
+ +
+
+ +
+
+ +
+
+
+ ) + + return
{isMobile ? mobileLayout : desktopLayout}
+} diff --git a/src/components/Analysis/BroadcastGameList.tsx b/src/components/Analysis/BroadcastGameList.tsx new file mode 100644 index 00000000..66301194 --- /dev/null +++ b/src/components/Analysis/BroadcastGameList.tsx @@ -0,0 +1,236 @@ +import React, { useState, useMemo, useEffect } from 'react' +import { motion } from 'framer-motion' +import { BroadcastStreamController, BroadcastGame } from 'src/types' + +interface BroadcastGameListProps { + broadcastController: BroadcastStreamController + onGameSelected?: () => void + embedded?: boolean +} + +export const BroadcastGameList: React.FC = ({ + broadcastController, + onGameSelected, + embedded = false, +}) => { + const [selectedRoundId, setSelectedRoundId] = useState( + broadcastController.currentRound?.id || '', + ) + + // Sync selectedRoundId when currentRound changes + useEffect(() => { + if ( + broadcastController.currentRound?.id && + broadcastController.currentRound.id !== selectedRoundId + ) { + setSelectedRoundId(broadcastController.currentRound.id) + } + }, [broadcastController.currentRound?.id, selectedRoundId]) + + const handleRoundChange = (roundId: string) => { + setSelectedRoundId(roundId) + broadcastController.selectRound(roundId) + } + + const handleGameSelect = (game: BroadcastGame) => { + broadcastController.selectGame(game.id) + onGameSelected?.() + } + + const currentGames = useMemo(() => { + if (!broadcastController.roundData?.games) { + return [] + } + return Array.from(broadcastController.roundData.games.values()) + }, [broadcastController.roundData?.games]) + + const getGameStatus = (game: BroadcastGame) => { + if (game.result === '*') { + return { status: 'Live', color: 'text-red-400' } + } else if (game.result === '1-0') { + return { status: '1-0', color: 'text-primary' } + } else if (game.result === '0-1') { + return { status: '0-1', color: 'text-primary' } + } else if (game.result === '1/2-1/2') { + return { status: '½-½', color: 'text-primary' } + } + return { status: game.result, color: 'text-secondary' } + } + + const formatPlayerName = (name: string, elo?: number) => { + const displayName = name.length > 12 ? name.substring(0, 12) + '...' : name + return elo ? `${displayName} (${elo})` : displayName + } + + return ( +
+
+
+

+ {broadcastController.currentBroadcast?.tour.name || + 'Live Broadcast'} +

+
+ + {/* Round Selector */} + {broadcastController.currentBroadcast && ( + + )} + + {/* Connection Status */} + {broadcastController.broadcastState.error && ( +
+
+ Connection Error + +
+
+ )} + + {broadcastController.broadcastState.isConnecting && ( +
+
+
+ Connecting... +
+
+ )} +
+ +
+ {currentGames.length === 0 ? ( +
+
+ + live_tv + +

+ {broadcastController.broadcastState.isConnecting + ? 'Loading games...' + : broadcastController.currentRound?.ongoing + ? 'No games available' + : 'Round not started yet'} +

+
+
+ ) : ( + <> + {currentGames.map((game, index) => { + const isSelected = broadcastController.currentGame?.id === game.id + const gameStatus = getGameStatus(game) + + return ( +
+
+

{index + 1}

+
+ +
+ ) + })} + + )} +
+ + {/* Footer with broadcast info */} +
+
+

+ Watch on{' '} + + Lichess + +

+
+
+
+ ) +} diff --git a/src/components/Analysis/ConfigurableScreens.tsx b/src/components/Analysis/ConfigurableScreens.tsx index 1447afd6..bc8b45a8 100644 --- a/src/components/Analysis/ConfigurableScreens.tsx +++ b/src/components/Analysis/ConfigurableScreens.tsx @@ -6,7 +6,7 @@ import { ExportGame } from 'src/components/Common/ExportGame' import { AnalyzedGame, GameNode, - LearnFromMistakesState, + LearnFromMistakesConfiguration, MistakePosition, } from 'src/types' @@ -28,7 +28,7 @@ interface Props { status: 'saving' | 'unsaved' | 'saved' } // Learn from mistakes props - learnFromMistakesState?: LearnFromMistakesState + learnFromMistakesState?: LearnFromMistakesConfiguration learnFromMistakesCurrentInfo?: { mistake: MistakePosition progress: string @@ -82,8 +82,8 @@ export const ConfigurableScreens: React.FC = ({ (learnFromMistakesCurrentInfo || learnFromMistakesState.showPlayerSelection) ) { return ( -
-
+
+
= ({ ) } - // Normal state with configure/export tabs return ( -
-
+
+
{screens.map((s) => { const selected = s.id === screen.id return ( @@ -150,7 +149,7 @@ export const ConfigurableScreens: React.FC = ({ ) })}
-
+
{screen.id === 'configure' ? ( = ({ isLearnFromMistakesActive = false, autoSave, }: Props) => { - const isCustomGame = game.type === 'custom-pgn' || game.type === 'custom-fen' - return ( -
+
-

Analyze using:

- +

Analyze using:

+
+ + + keyboard_arrow_down + +
- {onAnalyzeEntireGame && ( )} - {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 && (
diff --git a/src/components/Analysis/CustomAnalysisModal.tsx b/src/components/Analysis/CustomAnalysisModal.tsx index 9aca1026..b2c85d95 100644 --- a/src/components/Analysis/CustomAnalysisModal.tsx +++ b/src/components/Analysis/CustomAnalysisModal.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { motion } from 'framer-motion' import { Chess } from 'chess.ts' import toast from 'react-hot-toast' -import { ModalContainer } from '../Common/ModalContainer' interface Props { onSubmit: (type: 'pgn' | 'fen', data: string, name?: string) => void @@ -42,135 +42,168 @@ export const CustomAnalysisModal: React.FC = ({ onSubmit, onClose }) => { const examplePGN = `1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Bb7 10. d4 Re8` const exampleFEN = `r1bqkb1r/pppp1ppp/2n2n2/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4` - return ( - -
- {/* Header */} -
-
-

Custom Analysis

-

- Import a chess game from PGN notation or analyze a specific - position using FEN notation -

-
- -
+ useEffect(() => { + const originalOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = originalOverflow + } + }, []) - {/* Content */} -
-
- {/* Mode selector */} -
- -
- - + return ( + <> + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
+
+ e.stopPropagation()} + > +
+
+ {/* Header */} +
+
+

+ Custom Analysis +

+

+ Import a chess game from PGN notation or analyze a specific + position using FEN notation +

-
- -
- - setName(e.target.value)} - /> + close +
-
- -