diff --git a/src/app/analysis/__tests__/page.test.tsx b/src/app/analysis/__tests__/page.test.tsx new file mode 100644 index 0000000..ec19104 --- /dev/null +++ b/src/app/analysis/__tests__/page.test.tsx @@ -0,0 +1,131 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +jest.mock("react-chessboard", () => ({ + Chessboard: ({ position }: { position: string }) => ( +
+ ), +})); + +jest.mock("@/lib/stockfish", () => { + const stockfishInstance = { + evaluate: jest.fn(), + terminate: jest.fn(), + }; + const Stockfish = jest.fn(() => stockfishInstance); + return { + __esModule: true, + Stockfish, + __mock: { stockfishInstance }, + }; +}); + +jest.mock("@/lib/tacticDetection", () => { + const detectMissedTactics = jest.fn(); + const uciToSan = jest.fn(); + return { + __esModule: true, + detectMissedTactics, + uciToSan, + __mock: { detectMissedTactics, uciToSan }, + }; +}); + +const { __mock: stockfishMocks } = jest.requireMock("@/lib/stockfish") as { + __mock: { stockfishInstance: { evaluate: jest.Mock; terminate: jest.Mock } }; +}; +const { __mock: tacticMocks } = jest.requireMock("@/lib/tacticDetection") as { + __mock: { detectMissedTactics: jest.Mock; uciToSan: jest.Mock }; +}; + +const evaluateMock = stockfishMocks.stockfishInstance.evaluate; +const detectMissedTacticsMock = tacticMocks.detectMissedTactics; +const uciToSanMock = tacticMocks.uciToSan; + +import AnalysisPage from "../page"; + +describe("AnalysisPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + evaluateMock.mockImplementation((fen: string) => { + const sideToMove = fen.split(" ")[1]; + const baseEval = sideToMove === "w" ? 0 : 50; + return Promise.resolve({ + bestMove: "e2e4", + ponder: null, + score: baseEval, + mate: null, + depth: 12, + }); + }); + detectMissedTacticsMock.mockReturnValue([]); + uciToSanMock.mockReturnValue("e4"); + }); + + const samplePgn = ` +[Event "Casual Game"] +[Site "Berlin GER"] +[Date "1852.??.??"] +[Round "?"] +[White "Adolf Anderssen"] +[Black "Jean Dufresne"] +[Result "1-0"] + +1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 +`; + + const loadGame = () => { + render(); + const textarea = screen.getByPlaceholderText(/Paste PGN or FEN here/i); + fireEvent.change(textarea, { target: { value: samplePgn } }); + fireEvent.click(screen.getByText(/Start Analysis/i)); + }; + + it("replays PGN moves with engine evaluations", async () => { + loadGame(); + + fireEvent.click(screen.getByText(/Next Move/i)); + + await waitFor(() => { + expect(screen.getByText(/Move 1 \/ 6/)).toBeInTheDocument(); + expect(screen.getByText("+0.50")).toBeInTheDocument(); + expect(screen.getByText("-0.50")).toBeInTheDocument(); + expect(screen.getAllByText("e4")[0]).toBeInTheDocument(); + }); + + expect(detectMissedTacticsMock).toHaveBeenCalled(); + expect(uciToSanMock).toHaveBeenCalledWith(expect.any(String), "e2e4"); + }); + + it("surfaces detected tactics with material context", async () => { + detectMissedTacticsMock.mockReturnValue([ + { + tactic_type: "fork", + affected_squares: ["e5"], + material_delta: 300, + piece_roles: ["white knight"], + move: "Nf3", + }, + ]); + + loadGame(); + fireEvent.click(screen.getByText(/Next Move/i)); + + await waitFor(() => { + expect(screen.getByText(/fork \(~3.0 pawns\) on e5/)).toBeInTheDocument(); + }); + }); + + it("shows an error when the notation cannot be parsed", () => { + render(); + const textarea = screen.getByPlaceholderText(/Paste PGN or FEN here/i); + fireEvent.change(textarea, { target: { value: "invalid" } }); + fireEvent.click(screen.getByText(/Start Analysis/i)); + + expect( + screen.getByText( + "Could not load that PGN or FEN. Please check the notation." + ) + ).toBeInTheDocument(); + }); +}); diff --git a/src/app/analysis/page.tsx b/src/app/analysis/page.tsx new file mode 100644 index 0000000..eee3ecf --- /dev/null +++ b/src/app/analysis/page.tsx @@ -0,0 +1,459 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Chess } from "chess.js"; +import { Chessboard } from "react-chessboard"; +import { Brain, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"; + +import Header from "@/components/Header"; +import { SupportedLanguage } from "@/lib/i18n/translations"; +import { useTranslation } from "@/lib/i18n/useTranslation"; +import { Personality, PERSONALITIES } from "@/lib/personalities"; +import { Stockfish, StockfishEvaluation } from "@/lib/stockfish"; +import { detectChessFormat, ChessFormat } from "@/lib/chessFormatDetector"; +import { detectMissedTactics, DetectedTactic, uciToSan } from "@/lib/tacticDetection"; +import { lookupOpening } from "@/lib/openings"; +import { getGenAIModel } from "@/lib/gemini"; +import ReactMarkdown from "react-markdown"; + +interface MoveStep { + san: string; + color: "white" | "black"; + moveNumber: number; + fenBefore: string; + fenAfter: string; +} + +interface StepDetails { + evalBefore?: StockfishEvaluation; + evalAfter?: StockfishEvaluation; + cpLoss?: number; + missedTactics?: DetectedTactic[]; + bestMoveSan?: string | null; + comment?: string; +} + +const DEFAULT_START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; + +export default function AnalysisPage() { + const [language, setLanguage] = useState("en"); + const [apiKey, setApiKey] = useState(null); + const t = useTranslation(language); + + const [input, setInput] = useState(""); + const [detectedFormat, setDetectedFormat] = useState(null); + const [selectedPersonality, setSelectedPersonality] = useState(PERSONALITIES[0]); + const [orientation, setOrientation] = useState<"white" | "black">("white"); + + const [initialFen, setInitialFen] = useState(DEFAULT_START); + const [steps, setSteps] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); // 0 = starting position + const [error, setError] = useState(null); + + const [stockfish, setStockfish] = useState(null); + const evaluationCache = useRef>({}); + const [evaluationVersion, setEvaluationVersion] = useState(0); + const [stepDetails, setStepDetails] = useState>({}); + const [isCommenting, setIsCommenting] = useState(false); + const [comments, setComments] = useState>({}); + + useEffect(() => { + const storedKey = localStorage.getItem("gemini_api_key"); + const storedLang = localStorage.getItem("chess_tutor_language"); + if (storedKey) setApiKey(storedKey); + if (storedLang) setLanguage(storedLang as SupportedLanguage); + }, []); + + useEffect(() => { + const sf = new Stockfish(); + setStockfish(sf); + return () => sf.terminate(); + }, []); + + const currentFen = useMemo(() => { + if (currentIndex === 0) return initialFen; + return steps[currentIndex - 1]?.fenAfter || initialFen; + }, [currentIndex, steps, initialFen]); + + const openingInfo = useMemo(() => lookupOpening(currentFen), [currentFen]); + + const handleInputChange = (value: string) => { + setInput(value); + setDetectedFormat(value.trim() ? detectChessFormat(value) : null); + }; + + const ensureEvaluation = useCallback(async (fen: string) => { + if (!stockfish) return null; + if (evaluationCache.current[fen]) return evaluationCache.current[fen]; + const result = await stockfish.evaluate(fen, 14); + evaluationCache.current[fen] = result; + setEvaluationVersion(v => v + 1); + return result; + }, [stockfish]); + + const handleLoadGame = () => { + const trimmed = input.trim(); + const format = detectChessFormat(trimmed); + + if (!trimmed || format === "invalid") { + setError(t.analysis.importError); + return; + } + + try { + const parsedGame = new Chess(); + const nextSteps: MoveStep[] = []; + let startFen = DEFAULT_START; + + if (format === "fen") { + parsedGame.load(trimmed); + startFen = parsedGame.fen(); + } else { + parsedGame.loadPgn(trimmed); + const headers = parsedGame.header(); + if (headers.FEN) { + const base = new Chess(); + base.load(headers.FEN); + startFen = base.fen(); + } else { + parsedGame.reset(); + startFen = parsedGame.fen(); + } + + const replay = new Chess(); + replay.load(startFen); + const history = new Chess(); + history.loadPgn(trimmed); + history.history({ verbose: true }).forEach((move, idx) => { + const before = replay.fen(); + const applied = replay.move({ from: move.from, to: move.to, promotion: move.promotion || "q" }); + if (applied) { + nextSteps.push({ + san: applied.san, + color: applied.color === "w" ? "white" : "black", + moveNumber: Math.floor(idx / 2) + 1, + fenBefore: before, + fenAfter: replay.fen(), + }); + } + }); + } + + evaluationCache.current = {}; + setEvaluationVersion(v => v + 1); + setInitialFen(startFen); + setSteps(nextSteps); + setCurrentIndex(0); + setStepDetails({}); + setComments({}); + setError(null); + ensureEvaluation(startFen); + } catch (e) { + console.error("Failed to load game", e); + setError(t.analysis.importError); + } + }; + + useEffect(() => { + if (!stockfish || !currentFen) return; + ensureEvaluation(currentFen); + const currentStep = steps[currentIndex - 1]; + if (currentStep) { + ensureEvaluation(currentStep.fenBefore); + } + }, [stockfish, currentFen, steps, currentIndex, ensureEvaluation]); + + useEffect(() => { + if (currentIndex === 0) return; + const step = steps[currentIndex - 1]; + if (!step) return; + + const evalBefore = evaluationCache.current[step.fenBefore]; + const evalAfter = evaluationCache.current[step.fenAfter]; + if (!evalBefore || !evalAfter) return; + + setStepDetails(prev => { + if (prev[currentIndex]?.evalBefore && prev[currentIndex]?.evalAfter) return prev; + const cpLoss = step.color === "white" + ? evalBefore.score - evalAfter.score + : evalAfter.score - evalBefore.score; + const missedTactics = detectMissedTactics({ + fen: step.fenBefore, + playerColor: step.color, + playerMoveSan: step.san, + bestMoveUci: evalBefore.bestMove, + cpLoss, + }); + const bestMoveSan = uciToSan(step.fenBefore, evalBefore.bestMove); + return { + ...prev, + [currentIndex]: { + evalBefore, + evalAfter, + cpLoss, + missedTactics, + bestMoveSan, + } + }; + }); + }, [currentIndex, steps, evaluationVersion]); + + useEffect(() => { + if (!apiKey) return; + if (currentIndex === 0) return; + const step = steps[currentIndex - 1]; + const details = stepDetails[currentIndex]; + if (!step || !details?.evalBefore || !details?.evalAfter) return; + if (comments[currentIndex]) return; + + let cancelled = false; + setIsCommenting(true); + const timeout = setTimeout(async () => { + try { + const model = getGenAIModel(apiKey, "gemini-2.5-flash"); + const delta = details.cpLoss ?? 0; + const evalBefore = details.evalBefore.score / 100; + const evalAfter = details.evalAfter.score / 100; + const mateInfo = details.evalAfter.mate !== null ? `Mate in ${details.evalAfter.mate}` : "No mate detected"; + const tactics = (details.missedTactics || []) + .filter(t => t.tactic_type !== "none") + .map(t => `${t.tactic_type}${t.material_delta ? ` (~${(t.material_delta / 100).toFixed(1)} pawns)` : ""}`) + .join("; ") || "None"; + + const prompt = ` +You are ${selectedPersonality.name}. Stay in character. +Language: ${language.toUpperCase()}. +Explain the move that was just played. + +DATA: +- Move number: ${step.moveNumber} +- Side to move: ${step.color} +- Move played (SAN): ${step.san} +- Evaluation before move: ${evalBefore.toFixed(2)} pawns +- Evaluation after move: ${evalAfter.toFixed(2)} pawns +- Best move suggestion: ${details.bestMoveSan ?? details.evalBefore.bestMove} +- Evaluation shift (centipawns): ${delta} +- Opening context: ${openingInfo ? `${openingInfo.name} (${openingInfo.eco})` : "Unknown"} +- Missed tactics: ${tactics} +- Mate hint: ${mateInfo} + +INSTRUCTIONS: +- Be concise (3-4 sentences). +- Mention whether the move improved or worsened the position and why. +- Highlight any tactical ideas the player may have missed. +- Refer to the player's side as ${step.color}. +- Keep it educational and stay true to your personality tone.`; + + const result = await model.generateContent(prompt); + if (!cancelled) { + setComments(prev => ({ ...prev, [currentIndex]: result.response.text() })); + } + } catch (err) { + console.error("Commentary failed", err); + } finally { + if (!cancelled) setIsCommenting(false); + } + }, 400); + + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [apiKey, currentIndex, stepDetails, steps, comments, selectedPersonality, language, openingInfo]); + + const formatEval = (evaluation?: StockfishEvaluation) => { + if (!evaluation) return t.analysis.enginePending; + if (evaluation.mate !== null) return `#${evaluation.mate}`; + return `${evaluation.score >= 0 ? "+" : ""}${(evaluation.score / 100).toFixed(2)}`; + }; + + const formatCpLoss = (cp?: number) => { + if (cp === undefined) return t.analysis.enginePending; + const pawns = (cp / 100).toFixed(2); + return `${cp > 0 ? "+" : ""}${pawns}`; + }; + + const currentDetails = currentIndex > 0 ? stepDetails[currentIndex] : undefined; + const tacticSummary = (currentDetails?.missedTactics || []).filter(t => t.tactic_type !== "none"); + + return ( +
+
+
+
+
+
+
+

+ {t.analysis.modeTitle} +

+

{t.analysis.modeDescription}

+
+
+ + +
+
+ +
+
+ +