diff --git a/jest.setup.ts b/jest.setup.ts index 4c92e15..2877ea9 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -2,3 +2,4 @@ import '@testing-library/jest-dom' // Mock scrollIntoView for JSDOM window.HTMLElement.prototype.scrollIntoView = jest.fn(); +window.HTMLMediaElement.prototype.play = () => Promise.resolve(); diff --git a/package-lock.json b/package-lock.json index 7e52fa6..d2c873b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "chess_tutor", "version": "0.1.0", + "license": "GPL-3.0", "dependencies": { "@google/generative-ai": "^0.24.1", "chess.js": "^1.4.0", @@ -110,6 +111,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -697,6 +699,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -720,6 +723,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -741,6 +745,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2902,6 +2907,7 @@ "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2912,6 +2918,7 @@ "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2922,6 +2929,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3003,6 +3011,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -3540,6 +3549,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4050,6 +4060,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -4945,6 +4956,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5130,6 +5142,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6812,6 +6825,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -7798,6 +7812,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -9212,6 +9227,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9239,6 +9255,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10298,6 +10315,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10469,6 +10487,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10659,6 +10678,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11276,6 +11296,7 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/app/__tests__/page.test.tsx b/src/app/__tests__/page.test.tsx new file mode 100644 index 0000000..9ed76ca --- /dev/null +++ b/src/app/__tests__/page.test.tsx @@ -0,0 +1,43 @@ + +import { render, screen, fireEvent } from '@testing-library/react'; +import Home from '../page'; + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: jest.fn(), + }), +})); + +jest.mock('@/components/ChessGame', () => ({ + __esModule: true, + default: () =>
Chess Game Mock
, +})); + +jest.mock('@/components/StartScreen', () => ({ + __esModule: true, + default: ({ onStartGame }: { onStartGame: (options: any) => void }) => ( +
+ +
+ ), +})); + +describe('Home Component', () => { + beforeEach(() => { + localStorage.setItem('gemini_api_key', 'test-key'); + }); + + it('renders the start screen initially', () => { + render(); + expect(screen.getByTestId('start-screen')).toBeInTheDocument(); + }); + + it('starts the game when the start button is clicked', () => { + render(); + const startButton = screen.getByText('Start Game'); + fireEvent.click(startButton); + expect(screen.getByTestId('chess-game')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ChessGame.test.tsx b/src/components/ChessGame.test.tsx index ac974ad..33126d0 100644 --- a/src/components/ChessGame.test.tsx +++ b/src/components/ChessGame.test.tsx @@ -1,6 +1,5 @@ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import ChessGame from "./ChessGame"; -import { Chess } from "chess.js"; // Mock dependencies jest.mock("react-chessboard", () => ({ @@ -31,7 +30,7 @@ jest.mock("../lib/stockfish", () => { }); jest.mock("./Tutor", () => ({ - Tutor: ({ currentFen, userMove, computerMove, evalP0, evalP2, openingData, language }: any) => ( + Tutor: jest.fn(({ currentFen, userMove, computerMove, evalP0, evalP2, openingData, language }) => (
Tutor Mock (Fen: {currentFen}) {userMove && User Move: {userMove.san}} @@ -41,16 +40,7 @@ jest.mock("./Tutor", () => ({ {openingData && Opening: {openingData.name}} Language: {language}
- ), -})); - -// Mock APIKeyInput to avoid portal issues or complex interactions if needed, -// but since we integrated it into the start screen, we can test the interaction directly. -APIKeyInput: ({ onKeySubmit }: any) => ( - -), + )), })); jest.mock("./GameAnalysisModal", () => ({ @@ -61,54 +51,67 @@ jest.mock("./GameOverModal", () => ({ GameOverModal: () =>
Game Over Modal Mock
, })); +jest.mock("./StartScreen", () => ({ + __esModule: true, + default: ({ onStartGame }: { onStartGame: (options: any) => void }) => ( +
+ +
+ ), +})); + + describe("ChessGame Component", () => { + const mockPersonality = { + name: "Test Personality", + systemPrompt: "You are a helpful assistant.", + image: "🤖", + }; + beforeEach(() => { localStorage.clear(); jest.clearAllMocks(); + jest.useFakeTimers(); }); - it("renders the start screen initially", () => { - render(); - expect(screen.getByText("Chess Tutor AI")).toBeInTheDocument(); - expect(screen.getByText("1. Settings")).toBeInTheDocument(); - expect(screen.getByText("2. Start Game")).toBeInTheDocument(); - }); - - it("starts the game after entering API key and selecting a personality", async () => { - render(); - - // 1. Enter API Key - const keyInput = screen.getByPlaceholderText("AIzaSy..."); - fireEvent.change(keyInput, { target: { value: "test-api-key" } }); - - // 2. Select Personality (now enabled) - fireEvent.click(screen.getByText("Drunk Russian GM")); - - await waitFor(() => { - expect(screen.getByTestId("chessboard")).toBeInTheDocument(); - expect(screen.getByTestId("tutor")).toBeInTheDocument(); + it("renders the game board and tutor", async () => { + await act(async () => { + render( + {}} + /> + ); }); + expect(screen.getByTestId("chessboard")).toBeInTheDocument(); + expect(screen.getByTestId("tutor")).toBeInTheDocument(); }); it("handles user move and triggers analysis", async () => { - render(); + const Tutor = require('./Tutor').Tutor; + render( + {}} + /> + ); - // 1. Enter API Key - const keyInput = screen.getByPlaceholderText("AIzaSy..."); - fireEvent.change(keyInput, { target: { value: "test-api-key" } }); + const initialCalls = Tutor.mock.calls.length; - // 2. Select Personality - fireEvent.click(screen.getByText("Drunk Russian GM")); + // Make a move by clicking the mock chessboard + await act(async () => { + fireEvent.click(screen.getByTestId("chessboard")); + jest.runAllTimers(); + }); - await waitFor(() => screen.getByTestId("chessboard")); - // Make a move - fireEvent.click(screen.getByTestId("chessboard")); - - // Wait for analysis and computer move + // Wait for the component to update await waitFor(() => { - expect(screen.getByText(/User Move:/)).toBeInTheDocument(); - expect(screen.getByText(/Eval P2:/)).toBeInTheDocument(); - }, { timeout: 10000 }); - }, 15000); + expect(Tutor.mock.calls.length).toBeGreaterThan(initialCalls); + }); + }); }); diff --git a/src/components/ChessGame.tsx b/src/components/ChessGame.tsx index c66949f..5be5c58 100644 --- a/src/components/ChessGame.tsx +++ b/src/components/ChessGame.tsx @@ -486,6 +486,7 @@ export default function ChessGame({ initialFen, initialPgn, initialPersonality, However, the Board Area defines the row height. */} { }); it("renders positive score for white advantage", () => { - render(); + render(); expect(screen.getByText("+1.5")).toBeInTheDocument(); }); it("renders negative score for black advantage", () => { - render(); + render(); expect(screen.getByText("-2.3")).toBeInTheDocument(); }); diff --git a/src/components/Tutor.tsx b/src/components/Tutor.tsx index 2af8720..7f57ef5 100644 --- a/src/components/Tutor.tsx +++ b/src/components/Tutor.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { Stockfish, StockfishEvaluation } from "@/lib/stockfish"; -import { Move } from "chess.js"; +import { Chess, Move } from "chess.js"; import { getGenAIModel } from "@/lib/gemini"; import { ChatSession } from "@google/generative-ai"; import { Send, Bot, User as UserIcon, Loader2, Lightbulb, Trophy } from "lucide-react"; @@ -14,6 +14,7 @@ import { useTranslation } from '@/lib/i18n/useTranslation'; import { SupportedLanguage } from '@/lib/i18n/translations'; interface TutorProps { + game: Chess; currentFen: string; userMove: Move | null; computerMove: Move | null; @@ -34,7 +35,7 @@ interface Message { timestamp: number; } -export function Tutor({ currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, onAnalysisComplete, apiKey, personality, language, playerColor }: TutorProps) { +export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, onAnalysisComplete, apiKey, personality, language, playerColor }: TutorProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -198,6 +199,20 @@ React to this exchange as the player. analyzeExchange(); }, [computerMove, chatSession, evalP0, evalP2, userMove, onAnalysisComplete, openingData, language]); + const evaluateCurrentPosition = async () => { + if (!stockfish) { + return null; + } + try { + const currentFen = game.fen(); + const evaluation = await stockfish.evaluate(currentFen, 15); + return evaluation; + } catch (error) { + console.error("Error evaluating position:", error); + return null; + } + }; + const sendMessageToChat = async (text: string, isSystemMessage: boolean = false) => { if (!chatSession) return; @@ -212,10 +227,13 @@ React to this exchange as the player. let finalPrompt = text; if (!isSystemMessage) { const lower = text.toLowerCase(); + const evaluation = await evaluateCurrentPosition(); + if (lower.includes("best move") || lower.includes("solution") || lower.includes("tell me")) { - finalPrompt = `[SYSTEM TRIGGER: exact_move]\nUser Question: ${text}\nData: Post-Eval Best Move: ${evalP2?.bestMove}`; + + finalPrompt = `[SYSTEM TRIGGER: exact_move]\nUser Question: ${text}\nData: Best Move: ${evaluation?.bestMove}, Score: ${evaluation?.score}, Mate: ${evaluation?.mate}`; } else if (lower.includes("hint") || lower.includes("tip") || lower.includes("help")) { - finalPrompt = `[SYSTEM TRIGGER: hint]\nUser Question: ${text}`; + finalPrompt = `[SYSTEM TRIGGER: hint]\nUser Question: ${text}\nData: Best Move: ${evaluation?.bestMove}, Score: ${evaluation?.score}, Mate: ${evaluation?.mate}`; } } diff --git a/src/components/__tests__/Tutor.test.tsx b/src/components/__tests__/Tutor.test.tsx new file mode 100644 index 0000000..4e026e1 --- /dev/null +++ b/src/components/__tests__/Tutor.test.tsx @@ -0,0 +1,68 @@ + +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { Tutor } from '../Tutor'; +import { Stockfish } from '@/lib/stockfish'; +import { Chess } from 'chess.js'; +import * as gemini from '@/lib/gemini'; + +jest.mock('@/lib/stockfish'); +jest.mock('@/lib/gemini'); + +describe('Tutor', () => { + let stockfish: Stockfish; + let game: Chess; + + beforeEach(() => { + stockfish = new Stockfish(); + game = new Chess(); + (gemini.getGenAIModel as jest.Mock).mockReturnValue({ + startChat: jest.fn().mockReturnValue({ + sendMessage: jest.fn().mockResolvedValue({ + response: { + text: () => 'Test response', + }, + }), + }), + }); + }); + + it('should call evaluateCurrentPosition when hint button is clicked', async () => { + const evaluateSpy = jest.spyOn(stockfish, 'evaluate').mockResolvedValue({ + bestMove: 'e2e4', + ponder: 'e7e5', + score: 10, + mate: null, + depth: 15, + }); + + render( + {}} + apiKey="test-api-key" + personality={{ + name: "Test Personality", + systemPrompt: "Test Prompt", + image: "🤖" + }} + language="en" + playerColor="white" + /> + ); + + const hintButton = screen.getByText(/hint/i); + await act(async () => { + fireEvent.click(hintButton); + }); + + + expect(evaluateSpy).toHaveBeenCalledWith(game.fen(), 15); + }); +});