feat: improve opening trainer flow and add deviation dialog

- Add tutor message guardrail to prevent rapid-fire messages
  - Track last message by move index
  - Wait for both player and opponent moves before speaking
  - Speak immediately when player deviates from theory

- Add deviation dialog with three options:
  - Continue Playing (Start Game) - transitions to game mode
  - Undo & Return to Opening - returns to theory
  - Explore This Variation - continues off-book practice

- Implement smooth game mode transition:
  - Pass opening context from trainer to game
  - Tutor welcomes player with context about their study
  - Computer makes first move if needed in starting position

- Fix generate-test-fixtures script:
  - Add safety check to prevent overwriting real data
  - Restore full opening database (12,379 openings)
  - Rebuild move index (12,377 sequences)
  - Add clear warnings about test fixtures

- Update ChessGame to accept openingContext prop
- Update Tutor to display contextual greeting in game mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Stefan
2025-12-08 15:05:05 +01:00
parent 1e78d8114e
commit 8ed402bbe0
34 changed files with 111269 additions and 480 deletions
+48
View File
@@ -0,0 +1,48 @@
{
"rn1qkbnr/ppp2ppp/8/3p4/5p2/6PB/PPPPP2P/RNBQK2R w KQkq - 0 5": {
"src": "eco_tsv",
"eco": "A00",
"moves": "1. Nh3 d5 2. g3 e5 3. f4 Bxh3 4. Bxh3 exf4",
"name": "Amar Gambit"
},
"rnbqkbnr/pppppppp/8/8/8/7N/PPPPPPPP/RNBQKB1R b KQkq - 1 1": {
"src": "eco_tsv",
"eco": "A00",
"moves": "1. Nh3",
"name": "Amar Opening",
"scid": "A00g",
"aliases": {
"scid": "Amar/Paris Opening",
"eco_wikip": "Irregular Openings: Amar Opening",
"ct": "Amar Opening, General",
"chessGraph": "Amar; Paris Opening",
"chronos": "Amar (Paris) opening",
"icsbot": "Amar; Paris Opening "
}
},
"rn1qkbnr/ppp2ppp/8/3p4/8/6PB/PPPPP3/RNBQ1RK1 b kq - 0 6": {
"src": "eco_tsv",
"eco": "A00",
"moves": "1. Nh3 d5 2. g3 e5 3. f4 Bxh3 4. Bxh3 exf4 5. O-O fxg3 6. hxg3",
"name": "Amar Opening: Paris Gambit, Gent Gambit",
"aliases": {
"ct": "Amar Opening, Gent Gambit"
}
},
"rnbqkbnr/ppp2ppp/8/3pp3/5P2/6PN/PPPPP2P/RNBQKB1R b KQkq - 0 3": {
"src": "eco_tsv",
"eco": "A00",
"moves": "1. Nh3 d5 2. g3 e5 3. f4",
"name": "Amar Opening: Paris Gambit",
"scid": "A00g",
"aliases": {
"scid": "Amar: Paris Gambit"
}
},
"r1bqkb1r/ppp2ppp/2np1n2/4p3/2P5/1PN1P3/P2P1PPP/R1BQKBNR w KQkq - 1 5": {
"src": "eco_tsv",
"eco": "A00",
"moves": "1. e3 e5 2. c4 d6 3. Nc3 Nc6 4. b3 Nf6",
"name": "Amsterdam Attack"
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"rnbqkbnr/ppppp1pp/5p2/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2": {
"src": "eco_tsv",
"eco": "B00",
"moves": "1. e4 f6",
"name": "Barnes Defense"
},
"rnbqkbnr/1pppppp1/p6p/8/3PP3/8/PPP2PPP/RNBQKBNR w KQkq - 0 3": {
"src": "scid",
"eco": "B00",
"moves": "1. e4 a6 2. d4 h6",
"name": "Basman's Creepy-Crawly System (as Black)",
"scid": "B00c"
},
"rnbqkbnr/pppppp1p/8/6p1/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2": {
"src": "eco_tsv",
"eco": "B00",
"moves": "1. e4 g5",
"name": "Borg Defense",
"scid": "B00b",
"aliases": {
"scid": "Reversed Grob (Borg/Basman Defence)",
"ct": "Borg Defense, General",
"chessGraph": "Basman Defense; Macho Grob",
"chronos": "Reversed Grob (Borg/Basman defence/macho Grob)",
"icsbot": "Basman Defense; Macho Grob "
}
},
"rnbqk1nr/ppppppbp/8/6p1/3PP3/8/PPP2PPP/RNBQKBNR w KQkq - 1 3": {
"src": "eco_tsv",
"eco": "B00",
"moves": "1. e4 g5 2. d4 Bg7",
"name": "Borg Defense: Borg Gambit",
"aliases": {
"eco_js": "Borg Defense: Borg Gambit, 2. d4 Bg7",
"chessGraph": "Borg Gambit",
"icsbot": "Borg Gambit "
}
},
"rnbqkbnr/p2p1p1p/1p2p3/2P3p1/4P3/2P5/PP3PPP/RNBQKBNR w KQkq - 0 5": {
"src": "eco_js",
"eco": "B00",
"moves": "1. e4 g5 2. d4 e6 3. c3 c5 4. dxc5 b6",
"name": "Borg Defense: Langhorst Gambit"
}
}
+66
View File
@@ -0,0 +1,66 @@
{
"rnbqkbnr/pppp1ppp/4p3/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2": {
"src": "eco_tsv",
"eco": "C00",
"moves": "1. e4 e6",
"name": "French Defense",
"scid": "C00a",
"aliases": {
"scid": "French",
"ct": "French Defense, General",
"chessGraph": "French Defense; C00",
"icsbot": "French Defense; C00 "
},
"isEcoRoot": true,
"wikipediaSlug": "french-defense"
},
"rnbqkbnr/ppp2ppp/4p3/3p4/3PP3/8/PPP2PPP/RNBQKBNR w KQkq - 0 3": {
"src": "eco_tsv",
"eco": "C00",
"moves": "1. e4 e6 2. d4 d5",
"name": "French Defense",
"scid": "C00x",
"aliases": {
"scid": "French: 2.d4 d5",
"eco_js": "French Defense, 2. d4 d5",
"ct": "French Defense, Main Line"
},
"wikipediaSlug": "french-defense"
},
"rnbqkbnr/ppp2ppp/4p3/3p4/3PP3/4B3/PPP2PPP/RN1QKBNR b KQkq - 1 3": {
"src": "eco_tsv",
"eco": "C00",
"moves": "1. e4 e6 2. d4 d5 3. Be3",
"name": "French Defense: Alapin Gambit",
"scid": "C00x",
"aliases": {
"scid": "French: Alapin-Diemer Gambit (ADG)",
"chessGraph": "Alapin; Witaker Gambit, French",
"chronos": "French: Alapin variation",
"icsbot": "Alapin; Witaker Gambit, French "
},
"wikipediaSlug": "french-defense"
},
"rnbqkbnr/p1pp1ppp/4p3/1p6/3PP3/8/PPP2PPP/RNBQKBNR w KQkq - 0 3": {
"src": "eco_tsv",
"eco": "C00",
"moves": "1. e4 e6 2. d4 b5",
"name": "French Defense: Baeuerle Gambit",
"aliases": {
"chessGraph": "Baeuerle Gambit, French",
"icsbot": "Baeuerle Gambit, French "
},
"wikipediaSlug": "french-defense"
},
"rnbqkbnr/pppp1ppp/4p3/8/1P2P3/8/P1PP1PPP/RNBQKBNR b KQkq - 0 2": {
"src": "eco_tsv",
"eco": "C00",
"moves": "1. e4 e6 2. b4",
"name": "French Defense: Banzai-Leong Gambit",
"aliases": {
"chessGraph": "Banzai-Leong Gambit, French",
"icsbot": "Banzai-Leong Gambit, French "
},
"wikipediaSlug": "french-defense"
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"rnbqkbnr/ppp1pppp/8/3p4/3P4/3Q4/PPP1PPPP/RNB1KBNR b KQkq - 1 2": {
"src": "eco_tsv",
"eco": "D00",
"moves": "1. d4 d5 2. Qd3",
"name": "Amazon Attack"
},
"rnbqkbnr/ppp1pppp/8/3p4/3PP3/8/PPP2PPP/RNBQKBNR b KQkq - 0 2": {
"src": "eco_tsv",
"eco": "D00",
"moves": "1. d4 d5 2. e4",
"name": "Blackmar-Diemer Gambit",
"scid": "D00l",
"aliases": {
"scid": "Blackmar-Diemer Gambit (BDG): 2.e4",
"ct": "Blackmar-Diemer Gambit, General",
"chronos": "Blackmar gambit"
}
},
"rnbqkb1r/ppp1pppp/5n2/8/3Pp3/2N5/PPP2PPP/R1BQKBNR w KQkq - 2 4": {
"src": "eco_tsv",
"eco": "D00",
"moves": "1. d4 d5 2. e4 dxe4 3. Nc3 Nf6",
"name": "Blackmar-Diemer Gambit",
"aliases": {
"chessGraph": "Vienna Variation, BDG",
"icsbot": "Vienna Variation, BDG "
}
},
"rnbqkbnr/ppp1pppp/8/8/3Pp3/8/PPP2PPP/RNBQKBNR w KQkq - 0 3": {
"src": "scid",
"eco": "D00",
"moves": "1. d4 d5 2. e4 dxe4",
"name": "Blackmar-Diemer Gambit (BDG): 2.e4 dxe4",
"scid": "D00l"
},
"rnbqkb1r/ppp1pppp/5n2/8/3P4/2N2p2/PPP3PP/R1BQKBNR w KQkq - 0 5": {
"src": "eco_tsv",
"eco": "D00",
"moves": "1. d4 d5 2. e4 dxe4 3. Nc3 Nf6 4. f3 exf3",
"name": "Blackmar-Diemer Gambit Accepted",
"scid": "D00v",
"aliases": {
"scid": "Blackmar-Diemer: Accepted"
}
}
}
+56
View File
@@ -0,0 +1,56 @@
{
"rnbqkb1r/pppp1ppp/4pn2/8/2PP4/6P1/PP2PP1P/RNBQKBNR b KQkq - 0 3": {
"src": "eco_tsv",
"eco": "E00",
"moves": "1. d4 Nf6 2. c4 e6 3. g3",
"name": "Catalan Opening",
"scid": "E00g",
"aliases": {
"scid": "Catalan",
"ct": "Catalan Opening, General"
},
"wikipediaSlug": "catalan-opening"
},
"rnbqkb1r/ppp2ppp/4pn2/3p4/2PP4/6P1/PP2PP1P/RNBQKBNR w KQkq - 0 4": {
"src": "eco_tsv",
"eco": "E00",
"moves": "1. d4 Nf6 2. c4 e6 3. g3 d5",
"name": "Catalan Opening",
"scid": "E00n",
"aliases": {
"scid": "Catalan: 3...d5",
"eco_js": "Catalan Opening, 2. c4 e6 3. g3 d5",
"chessGraph": "E01"
},
"wikipediaSlug": "catalan-opening"
},
"rnbqkb1r/pppp1ppp/5n2/4p3/2PP4/6P1/PP2PP1P/RNBQKBNR w KQkq - 0 4": {
"src": "eco_tsv",
"eco": "E00",
"moves": "1. d4 Nf6 2. c4 e6 3. g3 e5",
"name": "Catalan Opening: Hungarian Gambit",
"scid": "E00g",
"aliases": {
"scid": "Catalan: Hungarian Gambit",
"chessGraph": "Hungarian Gambit, Catalan",
"icsbot": "Hungarian Gambit, Catalan "
},
"wikipediaSlug": "catalan-opening"
},
"rnbqk2r/pppp1ppp/4pn2/8/1bPP4/6P1/PP2PP1P/RNBQKBNR w KQkq - 1 4": {
"src": "scid",
"eco": "E00",
"moves": "1. d4 Nf6 2. c4 e6 3. g3 Bb4+",
"name": "Catalan: 3...Bb4+",
"scid": "E00i",
"wikipediaSlug": "catalan"
},
"rnbqk2r/pppp1ppp/4pn2/8/1bPP4/6P1/PP1BPP1P/RN1QKBNR b KQkq - 2 4": {
"src": "scid",
"eco": "E00",
"moves": "1. d4 Nf6 2. c4 e6 3. g3 Bb4+ 4. Bd2",
"name": "Catalan: 3...Bb4+ 4.Bd2",
"scid": "E00j",
"wikipediaSlug": "catalan"
}
}
+305
View File
@@ -0,0 +1,305 @@
{
"1. Nh3 d5 2. g3 e5 3. f4 Bxh3 4. Bxh3 exf4": [
{
"src": "eco_tsv",
"eco": "A00",
"moves": "1. Nh3 d5 2. g3 e5 3. f4 Bxh3 4. Bxh3 exf4",
"name": "Amar Gambit"
}
],
"1. Nh3": [
{
"src": "eco_tsv",
"eco": "A00",
"moves": "1. Nh3",
"name": "Amar Opening",
"scid": "A00g",
"aliases": {
"scid": "Amar/Paris Opening",
"eco_wikip": "Irregular Openings: Amar Opening",
"ct": "Amar Opening, General",
"chessGraph": "Amar; Paris Opening",
"chronos": "Amar (Paris) opening",
"icsbot": "Amar; Paris Opening "
}
}
],
"1. Nh3 d5 2. g3 e5 3. f4 Bxh3 4. Bxh3 exf4 5. O-O fxg3 6. hxg3": [
{
"src": "eco_tsv",
"eco": "A00",
"moves": "1. Nh3 d5 2. g3 e5 3. f4 Bxh3 4. Bxh3 exf4 5. O-O fxg3 6. hxg3",
"name": "Amar Opening: Paris Gambit, Gent Gambit",
"aliases": {
"ct": "Amar Opening, Gent Gambit"
}
}
],
"1. Nh3 d5 2. g3 e5 3. f4": [
{
"src": "eco_tsv",
"eco": "A00",
"moves": "1. Nh3 d5 2. g3 e5 3. f4",
"name": "Amar Opening: Paris Gambit",
"scid": "A00g",
"aliases": {
"scid": "Amar: Paris Gambit"
}
}
],
"1. e3 e5 2. c4 d6 3. Nc3 Nc6 4. b3 Nf6": [
{
"src": "eco_tsv",
"eco": "A00",
"moves": "1. e3 e5 2. c4 d6 3. Nc3 Nc6 4. b3 Nf6",
"name": "Amsterdam Attack"
}
],
"1. e4 f6": [
{
"src": "eco_tsv",
"eco": "B00",
"moves": "1. e4 f6",
"name": "Barnes Defense"
}
],
"1. e4 a6 2. d4 h6": [
{
"src": "scid",
"eco": "B00",
"moves": "1. e4 a6 2. d4 h6",
"name": "Basman's Creepy-Crawly System (as Black)",
"scid": "B00c"
}
],
"1. e4 g5": [
{
"src": "eco_tsv",
"eco": "B00",
"moves": "1. e4 g5",
"name": "Borg Defense",
"scid": "B00b",
"aliases": {
"scid": "Reversed Grob (Borg/Basman Defence)",
"ct": "Borg Defense, General",
"chessGraph": "Basman Defense; Macho Grob",
"chronos": "Reversed Grob (Borg/Basman defence/macho Grob)",
"icsbot": "Basman Defense; Macho Grob "
}
}
],
"1. e4 g5 2. d4 Bg7": [
{
"src": "eco_tsv",
"eco": "B00",
"moves": "1. e4 g5 2. d4 Bg7",
"name": "Borg Defense: Borg Gambit",
"aliases": {
"eco_js": "Borg Defense: Borg Gambit, 2. d4 Bg7",
"chessGraph": "Borg Gambit",
"icsbot": "Borg Gambit "
}
}
],
"1. e4 g5 2. d4 e6 3. c3 c5 4. dxc5 b6": [
{
"src": "eco_js",
"eco": "B00",
"moves": "1. e4 g5 2. d4 e6 3. c3 c5 4. dxc5 b6",
"name": "Borg Defense: Langhorst Gambit"
}
],
"1. e4 e6": [
{
"src": "eco_tsv",
"eco": "C00",
"moves": "1. e4 e6",
"name": "French Defense",
"scid": "C00a",
"aliases": {
"scid": "French",
"ct": "French Defense, General",
"chessGraph": "French Defense; C00",
"icsbot": "French Defense; C00 "
},
"isEcoRoot": true,
"wikipediaSlug": "french-defense"
}
],
"1. e4 e6 2. d4 d5": [
{
"src": "eco_tsv",
"eco": "C00",
"moves": "1. e4 e6 2. d4 d5",
"name": "French Defense",
"scid": "C00x",
"aliases": {
"scid": "French: 2.d4 d5",
"eco_js": "French Defense, 2. d4 d5",
"ct": "French Defense, Main Line"
},
"wikipediaSlug": "french-defense"
}
],
"1. e4 e6 2. d4 d5 3. Be3": [
{
"src": "eco_tsv",
"eco": "C00",
"moves": "1. e4 e6 2. d4 d5 3. Be3",
"name": "French Defense: Alapin Gambit",
"scid": "C00x",
"aliases": {
"scid": "French: Alapin-Diemer Gambit (ADG)",
"chessGraph": "Alapin; Witaker Gambit, French",
"chronos": "French: Alapin variation",
"icsbot": "Alapin; Witaker Gambit, French "
},
"wikipediaSlug": "french-defense"
}
],
"1. e4 e6 2. d4 b5": [
{
"src": "eco_tsv",
"eco": "C00",
"moves": "1. e4 e6 2. d4 b5",
"name": "French Defense: Baeuerle Gambit",
"aliases": {
"chessGraph": "Baeuerle Gambit, French",
"icsbot": "Baeuerle Gambit, French "
},
"wikipediaSlug": "french-defense"
}
],
"1. e4 e6 2. b4": [
{
"src": "eco_tsv",
"eco": "C00",
"moves": "1. e4 e6 2. b4",
"name": "French Defense: Banzai-Leong Gambit",
"aliases": {
"chessGraph": "Banzai-Leong Gambit, French",
"icsbot": "Banzai-Leong Gambit, French "
},
"wikipediaSlug": "french-defense"
}
],
"1. d4 d5 2. Qd3": [
{
"src": "eco_tsv",
"eco": "D00",
"moves": "1. d4 d5 2. Qd3",
"name": "Amazon Attack"
}
],
"1. d4 d5 2. e4": [
{
"src": "eco_tsv",
"eco": "D00",
"moves": "1. d4 d5 2. e4",
"name": "Blackmar-Diemer Gambit",
"scid": "D00l",
"aliases": {
"scid": "Blackmar-Diemer Gambit (BDG): 2.e4",
"ct": "Blackmar-Diemer Gambit, General",
"chronos": "Blackmar gambit"
}
}
],
"1. d4 d5 2. e4 dxe4 3. Nc3 Nf6": [
{
"src": "eco_tsv",
"eco": "D00",
"moves": "1. d4 d5 2. e4 dxe4 3. Nc3 Nf6",
"name": "Blackmar-Diemer Gambit",
"aliases": {
"chessGraph": "Vienna Variation, BDG",
"icsbot": "Vienna Variation, BDG "
}
}
],
"1. d4 d5 2. e4 dxe4": [
{
"src": "scid",
"eco": "D00",
"moves": "1. d4 d5 2. e4 dxe4",
"name": "Blackmar-Diemer Gambit (BDG): 2.e4 dxe4",
"scid": "D00l"
}
],
"1. d4 d5 2. e4 dxe4 3. Nc3 Nf6 4. f3 exf3": [
{
"src": "eco_tsv",
"eco": "D00",
"moves": "1. d4 d5 2. e4 dxe4 3. Nc3 Nf6 4. f3 exf3",
"name": "Blackmar-Diemer Gambit Accepted",
"scid": "D00v",
"aliases": {
"scid": "Blackmar-Diemer: Accepted"
}
}
],
"1. d4 Nf6 2. c4 e6 3. g3": [
{
"src": "eco_tsv",
"eco": "E00",
"moves": "1. d4 Nf6 2. c4 e6 3. g3",
"name": "Catalan Opening",
"scid": "E00g",
"aliases": {
"scid": "Catalan",
"ct": "Catalan Opening, General"
},
"wikipediaSlug": "catalan-opening"
}
],
"1. d4 Nf6 2. c4 e6 3. g3 d5": [
{
"src": "eco_tsv",
"eco": "E00",
"moves": "1. d4 Nf6 2. c4 e6 3. g3 d5",
"name": "Catalan Opening",
"scid": "E00n",
"aliases": {
"scid": "Catalan: 3...d5",
"eco_js": "Catalan Opening, 2. c4 e6 3. g3 d5",
"chessGraph": "E01"
},
"wikipediaSlug": "catalan-opening"
}
],
"1. d4 Nf6 2. c4 e6 3. g3 e5": [
{
"src": "eco_tsv",
"eco": "E00",
"moves": "1. d4 Nf6 2. c4 e6 3. g3 e5",
"name": "Catalan Opening: Hungarian Gambit",
"scid": "E00g",
"aliases": {
"scid": "Catalan: Hungarian Gambit",
"chessGraph": "Hungarian Gambit, Catalan",
"icsbot": "Hungarian Gambit, Catalan "
},
"wikipediaSlug": "catalan-opening"
}
],
"1. d4 Nf6 2. c4 e6 3. g3 Bb4+": [
{
"src": "scid",
"eco": "E00",
"moves": "1. d4 Nf6 2. c4 e6 3. g3 Bb4+",
"name": "Catalan: 3...Bb4+",
"scid": "E00i",
"wikipediaSlug": "catalan"
}
],
"1. d4 Nf6 2. c4 e6 3. g3 Bb4+ 4. Bd2": [
{
"src": "scid",
"eco": "E00",
"moves": "1. d4 Nf6 2. c4 e6 3. g3 Bb4+ 4. Bd2",
"name": "Catalan: 3...Bb4+ 4.Bd2",
"scid": "E00j",
"wikipediaSlug": "catalan"
}
]
}
+144
View File
@@ -0,0 +1,144 @@
import { test, expect } from '@playwright/test';
test.describe('Game Analysis Feature', () => {
test.beforeEach(async ({ page }) => {
// Set a mock API key in localStorage BEFORE any page loads
await page.addInitScript(() => {
localStorage.setItem('gemini_api_key', 'mock-api-key-for-testing');
});
// Mock the LLM API
await page.route('**/api/v1/llm/chat', async (route) => {
const request = route.request();
const postData = request.postDataJSON();
let reply = "Let me analyze this position for you.";
if (postData.message?.includes('ANALYSIS MODE')) {
reply = "I'll help you understand this game. Let's go through it move by move.";
} else if (postData.message?.includes('Move')) {
reply = "This is an interesting move. Let me explain the key ideas.";
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ reply }),
});
});
});
test('should navigate to analysis mode', async ({ page }) => {
// Navigate directly to analysis page
await page.goto('/analysis');
// Should be on analysis page
await expect(page).toHaveURL('/analysis');
// Should see the chessboard (react-chessboard uses SVG/images, not canvas)
// Look for the textarea which is always visible in analysis mode
await expect(page.locator('textarea').first()).toBeVisible({ timeout: 10000 });
});
test('should analyze a PGN game', async ({ page }) => {
await page.goto('/analysis');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000); // Give Stockfish time to initialize
// Should see the chessboard (react-chessboard uses SVG/images, not canvas)
await expect(page.locator('textarea').first()).toBeVisible({ timeout: 20000 });
// Should see PGN input
const pgnInput = page.locator('textarea').first();
await expect(pgnInput).toBeVisible();
// Enter a simple PGN (just moves)
const scholarsPgn = `1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6`;
await pgnInput.fill(scholarsPgn);
// Wait for the game to load automatically
await page.waitForTimeout(2000);
// Should see navigation controls
const nextButton = page.locator('button').filter({ has: page.locator('svg') }).filter({ hasText: '' });
// Just verify the textarea is still visible after loading
await expect(page.locator('textarea').first()).toBeVisible();
});
test('should analyze a FEN position', async ({ page }) => {
await page.goto('/analysis');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000); // Give Stockfish time to initialize
const fenInput = page.locator('textarea').first();
// Enter a FEN position
await fenInput.fill('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1');
// Wait for position to load
await page.waitForTimeout(2000);
// Should see the chessboard (react-chessboard uses SVG/images, not canvas)
await expect(page.locator('textarea').first()).toBeVisible({ timeout: 20000 });
});
test('should navigate through moves in analysis', async ({ page }) => {
await page.goto('/analysis');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000); // Give Stockfish time to initialize
// Enter a PGN with multiple moves
const pgnInput = page.locator('textarea').first();
await pgnInput.fill('1. e4 e5 2. Nf3 Nc6 3. Bb5');
// Wait for game to load
await page.waitForTimeout(2000);
// Should see the chessboard (react-chessboard uses SVG/images, not canvas)
await expect(page.locator('textarea').first()).toBeVisible({ timeout: 20000 });
// Look for navigation buttons (they use icons, not text)
const buttons = page.locator('button').filter({ has: page.locator('svg') });
// Just verify we have navigation buttons
await expect(buttons.first()).toBeVisible();
});
test('should display analysis interface', async ({ page }) => {
await page.goto('/analysis');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000); // Give Stockfish time to initialize
// Should see the chessboard (react-chessboard uses SVG/images, not canvas)
await expect(page.locator('textarea').first()).toBeVisible({ timeout: 20000 });
// Should see input area
await expect(page.locator('textarea').first()).toBeVisible();
// Should see evaluation bar (or check for evaluation-related elements)
const evaluationElement = page.locator('text=Evaluation').or(page.locator('[class*="evaluation"]'));
if (await evaluationElement.count() > 0) {
await expect(evaluationElement.first()).toBeVisible();
}
});
test('should allow switching board orientation in analysis', async ({ page }) => {
await page.goto('/analysis');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000); // Give Stockfish time to initialize
// Should see the chessboard (react-chessboard uses SVG/images, not canvas)
await expect(page.locator('textarea').first()).toBeVisible({ timeout: 20000 });
// Look for orientation buttons (White/Black)
const whiteButton = page.locator('button').filter({ hasText: /white/i });
const blackButton = page.locator('button').filter({ hasText: /black/i });
// At least one orientation button should be visible
const orientationButtons = page.locator('button').filter({ hasText: /white|black/i });
if (await orientationButtons.count() > 0) {
await expect(orientationButtons.first()).toBeVisible();
}
});
});
+147
View File
@@ -0,0 +1,147 @@
import { test, expect } from '@playwright/test';
test.describe('Game Playing Feature', () => {
test.beforeEach(async ({ page }) => {
// Set a mock API key in localStorage BEFORE any page loads
await page.addInitScript(() => {
localStorage.setItem('gemini_api_key', 'mock-api-key-for-testing');
});
// Mock the LLM API to avoid needing a real API key
await page.route('**/api/v1/llm/chat', async (route) => {
const request = route.request();
const postData = request.postDataJSON();
// Generate contextual responses based on the message
let reply = "Let's play chess! Make your move.";
if (postData.message?.includes('NEW GAME')) {
reply = "Welcome! I'm ready to help you improve your chess. Let's begin!";
} else if (postData.message?.includes('PLAYER MOVE')) {
reply = "Interesting move! Let me think about my response.";
} else if (postData.message?.includes('COMPUTER MOVE')) {
reply = "I've made my move. What do you think about the position?";
} else if (postData.message?.includes('GAME OVER')) {
reply = "Good game! Let's review what we learned.";
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ reply }),
});
});
});
test('should start a new game as white', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Should see the start screen heading
await expect(page.getByRole('heading', { name: 'Chess Tutor AI' }).first()).toBeVisible({ timeout: 10000 });
// Click "Start New Game" button
const startButton = page.locator('button:has-text("Start New Game")');
await expect(startButton).toBeVisible();
await startButton.click();
// Should see personality selection
await expect(page.locator('button:has-text("Professional Coach")')).toBeVisible();
// White should be selected by default
await expect(page.locator('button:has-text("Play as White")')).toHaveClass(/border-blue-600/);
// Click on a personality to start the game
await page.locator('button:has-text("Professional Coach")').first().click();
// Wait for game to load
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000); // Give Stockfish time to initialize
// Should see the chessboard (react-chessboard uses SVG/images, not canvas)
// Look for the "Undo Last Move" button which appears when the game is loaded
await expect(page.locator('button:has-text("Undo Last Move")')).toBeVisible({ timeout: 20000 });
// Should see evaluation bar (if present)
const evaluationElement = page.locator('text=Evaluation').or(page.locator('[class*="evaluation"]'));
if (await evaluationElement.count() > 0) {
await expect(evaluationElement.first()).toBeVisible();
}
// Should see chat interface
await expect(page.locator('input[placeholder*="Ask"]')).toBeVisible();
});
test('should start a new game as black', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const startButton = page.locator('button:has-text("Start New Game")');
await startButton.click();
// Select black
await page.locator('button:has-text("Play as Black")').click();
await expect(page.locator('button:has-text("Play as Black")')).toHaveClass(/border-blue-600/);
// Click on a personality to start the game
await page.locator('button:has-text("Professional Coach")').first().click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000);
// Should see the chessboard (react-chessboard uses SVG/images, not canvas)
await expect(page.locator('button:has-text("Undo Last Move")')).toBeVisible({ timeout: 20000 });
// Board should be flipped (black at bottom)
// The computer should make the first move since we're playing as black
await page.waitForTimeout(2000);
});
test('should import a FEN position', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const startButton = page.locator('button:has-text("Start New Game")');
await startButton.click();
// Expand advanced options
const advancedButton = page.locator('button:has-text("Advanced")');
await advancedButton.click();
// Enter a FEN position (Scholar's Mate position)
const fenInput = page.locator('textarea[placeholder*="Paste FEN"]');
await expect(fenInput).toBeVisible();
await fenInput.fill('r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4');
// Should detect FEN format
await expect(page.locator('text=FEN Position')).toBeVisible();
// Click on a personality to start the game
await page.locator('button:has-text("Professional Coach")').first().click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000);
// Should load the position (react-chessboard uses SVG/images, not canvas)
await expect(page.locator('button:has-text("Undo Last Move")')).toBeVisible({ timeout: 20000 });
});
test('should show game controls', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Start a quick game
const startButton = page.locator('button:has-text("Start New Game")');
await startButton.click();
await page.locator('button:has-text("Professional Coach")').first().click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000);
// Wait for game to load (react-chessboard uses SVG/images, not canvas)
await expect(page.locator('button:has-text("Undo Last Move")')).toBeVisible({ timeout: 20000 });
// Should see game control buttons (using more flexible selectors)
await expect(page.locator('button').filter({ hasText: /Resign|Give up/i }).first()).toBeVisible();
await expect(page.locator('button').filter({ hasText: /Download|Save/i }).first()).toBeVisible();
});
});
+35
View File
@@ -0,0 +1,35 @@
import fs from 'fs';
import path from 'path';
/**
* Global setup for Playwright e2e tests
* Copies test fixtures to public directory before tests run
*/
export default function globalSetup() {
console.log('🔧 Setting up e2e test environment...');
const fixturesDir = path.join(__dirname, 'fixtures/openings');
const targetDir = path.join(__dirname, '../public/openings');
// Ensure target directory exists
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// Copy fixture files
const files = ['ecoA.json', 'ecoB.json', 'ecoC.json', 'ecoD.json', 'ecoE.json', 'moveIndex.json'];
for (const file of files) {
const source = path.join(fixturesDir, file);
const target = path.join(targetDir, file);
if (fs.existsSync(source)) {
fs.copyFileSync(source, target);
console.log(` ✓ Copied ${file}`);
} else {
console.warn(` ⚠️ Fixture not found: ${file}`);
}
}
console.log('✅ Test fixtures ready\n');
}
+214
View File
@@ -0,0 +1,214 @@
import { test, expect } from '@playwright/test';
/**
* Opening Training - Interactive Move Tests
*
* These tests verify the core opening training functionality including:
* - User makes correct moves in theory
* - User makes incorrect moves
* - Navigation back and forth through move history
* - Opponent automatically responds in theory
* - Correcting mistakes after navigating back
*/
test.describe('Opening Training - Interactive Scenarios', () => {
test.beforeEach(async ({ page }) => {
// Set a mock API key in localStorage BEFORE any page loads
await page.addInitScript(() => {
localStorage.setItem('gemini_api_key', 'mock-api-key-for-testing');
});
await page.goto('/learning/openings');
await page.waitForLoadState('networkidle');
});
test('should handle user playing as White - correct moves in theory', async ({ page }) => {
// Select an opening where user plays White (e.g., C00 - French Defense)
// In French Defense, White plays first with 1.e4
await page.goto('/learning/openings/C00');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
// Verify board is visible
await expect(page.locator('[aria-label*="Chess board"]')).toBeVisible();
// Verify it's White's turn (user's turn)
// The board should show the starting position
await expect(page.getByText('No moves yet. Make your first move!')).toBeVisible();
// Make the first move: e2 to e4 (drag and drop)
const board = page.locator('[aria-label*="Chess board"]').first();
await expect(board).toBeVisible();
// Wait a bit for Stockfish to be ready
await page.waitForTimeout(2000);
// For now, we'll log this test as a placeholder since actual drag-drop
// requires more complex interaction with react-chessboard
console.log('[Test] Would make move e2-e4 here');
// TODO: Implement actual drag-drop interaction
// This requires:
// 1. Finding the e2 square element
// 2. Finding the e4 square element
// 3. Performing drag and drop
// 4. Waiting for move to be processed
});
test('should handle user playing as Black - opponent moves first', async ({ page }) => {
// Select an opening where user plays Black
// For example, a 1.e4 opening from Black's perspective
await page.goto('/learning/openings/C00'); // French Defense from Black's perspective
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
// In this case, we expect the opponent (White) to make the first move automatically
// We should see that White has moved e2-e4
// Wait for auto-move to happen
await page.waitForTimeout(3000);
// Check if move history shows a move
const moveHistory = page.locator('[aria-label="List of moves played"]');
await expect(moveHistory).toBeVisible();
// If the opening is from Black's perspective, White should move first
// We should see "1. e4" or similar in the move list
console.log('[Test] Would verify opponent moved first');
});
test('should handle navigation: make wrong move, go back, make correct move', async ({ page }) => {
// This is the key scenario the user reported
// 1. User makes a wrong move
// 2. System identifies it as wrong
// 3. User clicks "Back" button to navigate back
// 4. User makes the correct move
// 5. Opponent should respond (this was the bug)
await page.goto('/learning/openings/C00');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
console.log('[Test] Scenario:');
console.log('[Test] 1. User would make a WRONG move');
console.log('[Test] 2. System would categorize it as "weak" or "playable" not "in-theory"');
console.log('[Test] 3. User clicks BACK button');
console.log('[Test] 4. User makes CORRECT move');
console.log('[Test] 5. Opponent should respond');
// Verify navigation buttons exist
const backButton = page.locator('button[aria-label="Go to previous move"]');
const forwardButton = page.locator('button[aria-label="Go to next move"]');
await expect(backButton).toBeVisible();
await expect(forwardButton).toBeVisible();
// Verify back button is disabled initially (no moves yet)
await expect(backButton).toBeDisabled();
});
test('should show move classification in history', async ({ page }) => {
// After making moves, each move should show classification
// - in-theory (green)
// - playable (yellow)
// - weak (red)
await page.goto('/learning/openings/C00');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
// Check that move history section is ready
await expect(page.getByText('Move History')).toBeVisible();
console.log('[Test] After moves are made, would verify:');
console.log('[Test] - Moves show colored badges (green/yellow/red)');
console.log('[Test] - Move classification text is visible');
console.log('[Test] - Can click on moves to navigate');
});
test('should clear future moves when making new move after navigation back', async ({ page }) => {
// This test verifies that when a user:
// 1. Makes moves A, B, C
// 2. Navigates back to after move A
// 3. Makes a different move D
// Then moves B and C should be discarded
await page.goto('/learning/openings/C00');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
console.log('[Test] Scenario:');
console.log('[Test] 1. Make moves: e4, e5, Nf3 (for example)');
console.log('[Test] 2. Navigate back to position after e4');
console.log('[Test] 3. Make different move: d4 instead of e5');
console.log('[Test] 4. Verify e5 and Nf3 are removed from history');
console.log('[Test] 5. Verify new branch starts from e4, d4');
});
test('should resume session and continue if opponent turn', async ({ page }) => {
// Create a session, make some moves, leave, come back
// If it's opponent's turn when resuming, they should move
await page.goto('/learning/openings/C00');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
// Make initial session with some moves
console.log('[Test] Would make moves to create a session');
// Navigate away
await page.goto('/learning');
await page.waitForTimeout(500);
// Navigate back to the opening
await page.goto('/learning/openings/C00');
await page.waitForLoadState('networkidle');
// User might see "Resume Session" dialog
const resumeButton = page.locator('button:has-text("Resume Session")');
const startFreshButton = page.locator('button:has-text("Start Fresh")');
// If dialog appears, click Resume
if (await resumeButton.isVisible({ timeout: 2000 })) {
await resumeButton.click();
await page.waitForTimeout(1000);
}
console.log('[Test] After resuming:');
console.log('[Test] - If opponent turn, they should move automatically');
console.log('[Test] - If user turn, board should wait for user');
});
});
/**
* Helper test for debugging chess board interaction
*/
test.describe('Opening Training - Board Interaction Debugging', () => {
test.skip('debug: inspect chess board structure', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('gemini_api_key', 'mock-api-key-for-testing');
});
await page.goto('/learning/openings/C00');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000);
// Take a screenshot to see the board
await page.screenshot({ path: 'e2e/debug-screenshots/chessboard.png', fullPage: true });
// Try to find pieces and squares
const boardElement = page.locator('[aria-label*="Chess board"]').first();
await expect(boardElement).toBeVisible();
// Log the board HTML structure for debugging
const boardHTML = await boardElement.innerHTML();
console.log('[Debug] Board HTML structure:');
console.log(boardHTML.substring(0, 500)); // First 500 chars
// Try to find SVG pieces
const pieces = page.locator('svg image[href*="piece"]');
const pieceCount = await pieces.count();
console.log(`[Debug] Found ${pieceCount} piece elements`);
});
});
+334
View File
@@ -0,0 +1,334 @@
import { test, expect } from '@playwright/test';
/**
* E2E Tests for Opening Training Feature
*
* These tests verify the complete training flow from opening selection
* to receiving move feedback with engine evaluation and LLM explanations.
*/
test.describe('Opening Training', () => {
test.beforeEach(async ({ page }) => {
// Set a mock API key in localStorage BEFORE any page loads
await page.addInitScript(() => {
localStorage.setItem('gemini_api_key', 'mock-api-key-for-testing');
});
// Navigate to opening selection page
await page.goto('/learning/openings');
await page.waitForLoadState('networkidle');
});
test('should display opening selection page with family groups', async ({ page }) => {
// Check page title
await expect(page.getByText('Opening Training')).toBeVisible({ timeout: 10000 });
// Check that opening family cards are present (look for the family buttons instead of category headers)
const familyCards = page.locator('button[aria-label*="opening family"]');
await expect(familyCards.first()).toBeVisible({ timeout: 10000 });
// Should have multiple families
const count = await familyCards.count();
expect(count).toBeGreaterThan(0);
});
test('should display opening families by category', async ({ page }) => {
// Check that family cards are displayed (wait for them to load)
const familyCards = page.locator('button[aria-label*="opening family"]');
// Wait for at least one family card to be visible
await expect(familyCards.first()).toBeVisible({ timeout: 10000 });
// Give a moment for other cards to render
await page.waitForTimeout(500);
// Should have at least one family displayed (could be more depending on data)
const count = await familyCards.count();
expect(count).toBeGreaterThan(0);
});
test('should select an opening family', async ({ page }) => {
// Look for a family card (e.g., one with "Variations:" text)
const familyCard = page.locator('button[aria-label*="opening family"]').first();
await expect(familyCard).toBeVisible({ timeout: 10000 });
// Get the family name before clicking
const familyName = await familyCard.locator('h3').textContent();
// Click on the family
await familyCard.click();
// Should see the family name in the selector or openings list
await page.waitForTimeout(1000);
});
test('should navigate directly to an opening', async ({ page }) => {
// Test direct navigation first
await page.goto('/learning/openings/A01');
await page.waitForTimeout(2000);
console.log('Current URL:', page.url());
// Check if we're on the right page
const pageContent = await page.textContent('body');
console.log('Page contains Opening Training:', pageContent?.includes('Opening Training'));
console.log('Page contains Nimzo-Larsen:', pageContent?.includes('Nimzo-Larsen'));
// Verify chess board or loading state
const hasBoard = await page.locator('[aria-label*="Chess board"]').count();
const hasLoading = await page.locator('text="Initializing training session"').count();
const hasNoSession = await page.locator('text="No active session"').count();
console.log('Has chessboard:', hasBoard);
console.log('Has loading:', hasLoading);
console.log('Has no session:', hasNoSession);
});
test('should start a training session for an opening', async ({ page }) => {
// Enable console logging
const consoleLogs: Array<{ type: string; text: string }> = [];
page.on('console', msg => {
consoleLogs.push({ type: msg.type(), text: msg.text() });
console.log(`[Browser ${msg.type()}]:`, msg.text());
});
// Track page errors
const pageErrors: string[] = [];
page.on('pageerror', error => {
pageErrors.push(error.message);
console.error('Page Error:', error.message);
});
// First, select a family to show opening cards
const familyCard = page.locator('button[aria-label*="opening family"]').first();
await expect(familyCard).toBeVisible({ timeout: 10000 });
await familyCard.click();
await page.waitForTimeout(1000);
// Now get opening cards using data-testid
const openingCards = page.locator('[data-testid^="opening-card-"]');
const count = await openingCards.count();
console.log('Found opening cards:', count);
// Get first opening card
const firstOpening = openingCards.first();
const eco = await firstOpening.getAttribute('data-opening-eco');
const openingText = await firstOpening.textContent();
console.log('Clicking on opening:', eco, openingText?.substring(0, 30));
const currentUrl = page.url();
console.log('Current URL before click:', currentUrl);
await firstOpening.click();
// Wait a bit for navigation to start
await page.waitForTimeout(500);
console.log('URL after click:', page.url());
// Wait for training page to load
try {
await page.waitForURL(/\/learning\/openings\/[A-E]\d{2}/, { timeout: 10000 });
console.log('Successfully navigated to:', page.url());
} catch (e) {
console.error('Failed to navigate. Current URL:', page.url());
console.error('Console logs:', consoleLogs.slice(-10)); // Last 10 logs
console.error('Page errors:', pageErrors);
// Take screenshot for debugging
await page.screenshot({ path: 'e2e/screenshots/navigation-failed.png', fullPage: true });
throw e;
}
// Wait for the page to settle
await page.waitForTimeout(2000);
// Log any errors that occurred
if (pageErrors.length > 0) {
console.error('Page errors found:', pageErrors);
await page.screenshot({ path: 'e2e/screenshots/page-errors.png', fullPage: true });
}
// Verify chess board is visible
await expect(page.locator('[aria-label*="Chess board"]')).toBeVisible({ timeout: 10000 });
// Verify move history section
await expect(page.getByText('Move History')).toBeVisible();
// Verify Wikipedia summary loads (check for any of the possible states)
const wikiSection = page.locator('text=/Loading opening background|No background information|Wikipedia/i');
await expect(wikiSection.first()).toBeVisible({ timeout: 10000 });
// Ensure no critical errors occurred
expect(pageErrors.length).toBe(0);
});
test('should show session recovery dialog for existing session', async ({
page,
}) => {
// First, select a family to show opening cards
const familyCard = page.locator('button[aria-label*="opening family"]').first();
await expect(familyCard).toBeVisible({ timeout: 10000 });
await familyCard.click();
await page.waitForTimeout(1000);
// Start a session
const firstOpening = page.locator('[data-testid^="opening-card-"]').first();
await firstOpening.click();
// Wait for session to initialize
await page.waitForURL(/\/learning\/openings\/[A-E]\d{2}/);
await page.waitForTimeout(2000);
// Navigate away and back
await page.goBack();
// Select family again
const familyCardAgain = page.locator('button[aria-label*="opening family"]').first();
await familyCardAgain.click();
await page.waitForTimeout(1000);
const firstOpeningAgain = page.locator('[data-testid^="opening-card-"]').first();
await firstOpeningAgain.click();
// Expect recovery dialog if session has moves
// (This would need actual moves to be made in a more complete test)
await page.waitForURL(/\/learning\/openings\/[A-E]\d{2}/);
});
test.skip('should make a move and receive feedback', async ({ page }) => {
// NOTE: This test is skipped because it requires:
// 1. Stockfish engine to be loaded
// 2. Move interaction with the chess board
// 3. LLM API to be available
//
// To implement:
// 1. Start a training session
// 2. Make a move on the board (drag and drop)
// 3. Wait for engine evaluation
// 4. Verify move feedback is displayed
// 5. Verify categorization (in-theory/playable/weak)
// 6. Verify LLM explanation appears
});
test.skip('should navigate through move history', async ({ page }) => {
// NOTE: This test is skipped - requires moves to be made first
//
// To implement:
// 1. Make several moves
// 2. Click "Back" button
// 3. Verify board position changes
// 4. Verify feedback updates
// 5. Click "Forward" button
// 6. Verify board returns to later position
});
test.skip('should detect deviation from theory', async ({ page }) => {
// NOTE: This test is skipped - requires specific opening and off-book move
//
// To implement:
// 1. Start a known opening
// 2. Make moves following theory
// 3. Make an off-book move
// 4. Verify "Off-book since move X" indicator appears
// 5. Verify feedback categorizes move as playable or weak (not in-theory)
});
test.skip('should cache LLM explanations on navigation', async ({ page }) => {
// NOTE: This test is skipped - requires checking network requests
//
// To implement:
// 1. Make a move and wait for LLM explanation
// 2. Navigate to different move
// 3. Navigate back to original move
// 4. Verify no new LLM API call was made (check network tab)
// 5. Verify explanation is displayed immediately
});
});
/**
* Performance Tests
*/
test.describe('Opening Training Performance', () => {
test.beforeEach(async ({ page }) => {
// Set a mock API key in localStorage BEFORE any page loads
await page.addInitScript(() => {
localStorage.setItem('gemini_api_key', 'mock-api-key-for-testing');
});
});
test.skip('should initialize session within 3 seconds', async ({ page }) => {
const startTime = Date.now();
await page.goto('/learning/openings');
const firstOpening = page.locator('[data-testid^="opening-card-"]').first();
await firstOpening.click();
await page.waitForURL(/\/learning\/openings\/[A-E]\d{2}/);
await page.waitForSelector('[aria-label*="Chess board"]');
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(3000);
});
test.skip('should evaluate position within 2 seconds', async ({ page }) => {
// NOTE: Requires making a move and measuring eval time
// Target: Engine evaluation completes in <2s for 95% of moves
});
test.skip('should provide complete feedback within 5 seconds', async ({
page,
}) => {
// NOTE: Requires making a move and measuring total feedback time
// Target: Engine eval + LLM explanation in <5s for 90% of moves
});
});
/**
* Accessibility Tests
*/
test.describe('Opening Training Accessibility', () => {
test.beforeEach(async ({ page }) => {
// Set a mock API key in localStorage BEFORE any page loads
await page.addInitScript(() => {
localStorage.setItem('gemini_api_key', 'mock-api-key-for-testing');
});
});
test('should have proper ARIA labels on key elements', async ({ page }) => {
await page.goto('/learning/openings');
await page.waitForLoadState('networkidle');
// Select a family first
const familyCard = page.locator('button[aria-label*="opening family"]').first();
await expect(familyCard).toBeVisible({ timeout: 10000 });
await familyCard.click();
await page.waitForTimeout(1000);
// Click on first opening
const firstOpening = page.locator('[data-testid^="opening-card-"]').first();
await firstOpening.click();
await page.waitForURL(/\/learning\/openings\/[A-E]\d{2}/);
// Verify ARIA labels
await expect(page.locator('[aria-label*="Chess board"]')).toBeVisible();
await expect(
page.locator('[aria-label="Move history and navigation"]')
).toBeVisible();
await expect(page.locator('[aria-label="Go to previous move"]')).toBeVisible();
await expect(page.locator('[aria-label="Go to next move"]')).toBeVisible();
});
test('should be keyboard navigable', async ({ page }) => {
await page.goto('/learning/openings');
// Tab through elements
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// Verify focus is visible
const focused = await page.evaluate(() => document.activeElement?.tagName);
expect(focused).toBeTruthy();
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

+170
View File
@@ -0,0 +1,170 @@
import { test, expect } from '@playwright/test';
test.describe('Settings and UI Features', () => {
test.beforeEach(async ({ page }) => {
// Set a mock API key in localStorage BEFORE any page loads
await page.addInitScript(() => {
localStorage.setItem('gemini_api_key', 'mock-api-key-for-testing');
});
});
test('should navigate to settings page', async ({ page }) => {
// Navigate directly to settings page
await page.goto('/settings');
// Should be on settings page
await expect(page).toHaveURL('/settings');
// Should see settings heading
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
});
test('should display language selector', async ({ page }) => {
await page.goto('/settings');
await page.waitForLoadState('networkidle');
// Should see language options (buttons show language codes like EN, DE, FR)
await expect(page.getByRole('button', { name: 'EN', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'DE', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'FR', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'IT', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'PL', exact: true })).toBeVisible();
});
test('should switch language', async ({ page }) => {
await page.goto('/settings');
await page.waitForLoadState('networkidle');
// Click German language
const deutschButton = page.getByRole('button', { name: 'DE', exact: true });
await deutschButton.click();
// Wait for language to change
await page.waitForTimeout(500);
// DE button should now be selected (blue background)
await expect(deutschButton).toHaveClass(/bg-blue-600/);
});
test('should display API key management', async ({ page }) => {
await page.goto('/settings');
await page.waitForLoadState('networkidle');
// Should see API key section
await expect(page.locator('text=Google Gemini API Key')).toBeVisible();
// Should see API key input
const apiKeyInput = page.locator('input[type="password"]').or(page.locator('input[placeholder*="API"]'));
await expect(apiKeyInput.first()).toBeVisible();
});
test('should save API key', async ({ page }) => {
await page.goto('/settings');
await page.waitForLoadState('networkidle');
// Enter API key
const apiKeyInput = page.locator('input[type="password"]').or(page.locator('input[placeholder*="API"]'));
await apiKeyInput.first().fill('test-api-key-12345');
// Should see save button
const saveButton = page.locator('button:has-text("Save")').or(page.locator('button:has-text("Update")'));
if (await saveButton.count() > 0) {
await saveButton.first().click();
// Should see success message or confirmation
await page.waitForTimeout(500);
}
// Verify API key is saved in localStorage
const savedKey = await page.evaluate(() => localStorage.getItem('gemini_api_key'));
expect(savedKey).toBe('test-api-key-12345');
});
test('should display save button', async ({ page }) => {
await page.goto('/settings');
await page.waitForLoadState('networkidle');
// Should see save button
await expect(page.locator('button:has-text("Save")')).toBeVisible();
});
test('should navigate back from settings', async ({ page }) => {
await page.goto('/settings');
await page.waitForLoadState('networkidle');
// Click back arrow button
const backButton = page.locator('button').filter({ has: page.locator('svg') }).first();
await backButton.click();
// Should return to onboarding or home page
await page.waitForTimeout(500);
// URL should change from /settings
expect(page.url()).not.toContain('/settings');
});
test('should display header on all pages', async ({ page }) => {
// Check home page
await page.goto('/');
await page.waitForLoadState('networkidle');
const homeHeader = page.getByRole('heading', { name: 'Chess Tutor AI' });
if (await homeHeader.count() > 0) {
await expect(homeHeader.first()).toBeVisible();
}
// Check learning page
await page.goto('/learning');
await page.waitForLoadState('networkidle');
const learningHeader = page.getByRole('heading', { name: 'Chess Tutor AI' });
if (await learningHeader.count() > 0) {
await expect(learningHeader.first()).toBeVisible();
}
// Check settings page
await page.goto('/settings');
await page.waitForLoadState('networkidle');
const settingsHeader = page.getByRole('heading', { name: 'Chess Tutor AI' });
if (await settingsHeader.count() > 0) {
await expect(settingsHeader.first()).toBeVisible();
}
});
test('should show personality options on start screen', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Click Start New Game
const startButton = page.locator('button:has-text("Start New Game")');
await startButton.click();
// Should see personality options (check for at least 3 of the available personalities)
await expect(page.locator('button:has-text("Professional Coach")').first()).toBeVisible();
await expect(page.locator('button:has-text("Friendly Motivator")').first()).toBeVisible();
await expect(page.locator('button:has-text("Opening Professor")').first()).toBeVisible();
});
test('should display start screen when API key exists', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Should see the start screen heading
await expect(page.getByRole('heading', { name: 'Chess Tutor AI' }).first()).toBeVisible();
// Should see main action buttons
await expect(page.locator('button:has-text("Start New Game")')).toBeVisible();
});
test('should redirect to onboarding when API key is missing', async ({ browser }) => {
// Create a new context without the API key
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Should redirect to onboarding
await expect(page).toHaveURL('/onboarding');
await context.close();
});
});
+220
View File
@@ -0,0 +1,220 @@
import { test, expect } from '@playwright/test';
test.describe('Tactical Practice Feature', () => {
test.beforeEach(async ({ page }) => {
// Set a mock API key in localStorage BEFORE any page loads
await page.addInitScript(() => {
localStorage.setItem('gemini_api_key', 'mock-api-key-for-testing');
});
});
test('should navigate to learning area from start screen', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Wait for the Learning Area button to be visible
const learningButton = page.locator('button:has-text("Learning Area")').last();
await expect(learningButton).toBeVisible({ timeout: 10000 });
// Click on Learning Area button
await learningButton.click();
// Should be on the learning page
await expect(page).toHaveURL('/learning');
// Should see tactical pattern buttons
await expect(page.locator('text=PIN')).toBeVisible();
await expect(page.locator('text=FORK')).toBeVisible();
await expect(page.locator('text=SKEWER')).toBeVisible();
});
test('should select a coach before starting practice', async ({ page }) => {
await page.goto('/learning');
await page.waitForLoadState('networkidle');
// Should see personality selection
await expect(page.locator('button:has-text("Professional Coach")').first()).toBeVisible();
// Select a coach
const professionalCoach = page.locator('button:has-text("Professional Coach")').first();
await professionalCoach.click();
// Coach should be selected (button should have blue border)
await expect(professionalCoach).toHaveClass(/border-blue-500/);
});
test('should start PIN tactical practice', async ({ page }) => {
await page.goto('/learning');
await page.waitForLoadState('networkidle');
// Select a coach first
await page.locator('button:has-text("Professional Coach")').first().click();
// Click on PIN button
await page.click('text=PIN');
// Should navigate to PIN practice page
await expect(page).toHaveURL('/learning/tactics/pin');
// Wait for the page to load
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000); // Give time for puzzle to load
// Should see the chessboard (react-chessboard uses SVG/images, not canvas)
// Look for statistics which are always visible
await expect(page.getByText('Correct', { exact: true })).toBeVisible({ timeout: 15000 });
// Should see statistics display
await expect(page.getByText('Correct', { exact: true })).toBeVisible();
await expect(page.getByText('Incorrect', { exact: true })).toBeVisible();
await expect(page.getByText('Current Streak', { exact: true })).toBeVisible();
await expect(page.getByText('Best Streak', { exact: true })).toBeVisible();
// Should see difficulty selector
await expect(page.locator('button:has-text("Easy")')).toBeVisible();
await expect(page.locator('button:has-text("Medium")')).toBeVisible();
await expect(page.locator('button:has-text("Hard")')).toBeVisible();
// Should see puzzle rating
await expect(page.locator('text=Puzzle Rating')).toBeVisible();
});
test('should display skip button', async ({ page }) => {
await page.goto('/learning');
await page.waitForLoadState('networkidle');
await page.locator('button:has-text("Professional Coach")').first().click();
await page.click('text=PIN');
await page.waitForLoadState('networkidle');
// Should see Skip Puzzle button
await expect(page.locator('button:has-text("Skip Puzzle")')).toBeVisible({ timeout: 10000 });
});
test('should switch difficulty levels', async ({ page }) => {
await page.goto('/learning');
await page.waitForLoadState('networkidle');
await page.locator('button:has-text("Professional Coach")').first().click();
await page.click('text=PIN');
await page.waitForLoadState('networkidle');
// Wait for initial puzzle to load
await page.waitForSelector('text=Puzzle Rating', { timeout: 10000 });
// Close any modals or overlays that might be blocking
const closeButtons = page.locator('button[aria-label*="Close"]').or(page.locator('button:has-text("×")'));
if (await closeButtons.count() > 0) {
await closeButtons.first().click({ force: true });
await page.waitForTimeout(500);
}
// Get initial difficulty button state (Easy is selected by default with green background)
const easyButton = page.locator('button:has-text("Easy")');
await expect(easyButton).toHaveClass(/bg-green-500/); // Easy is green when selected
// Click Medium difficulty - use force to bypass any overlays
await page.locator('button:has-text("Medium")').click({ force: true });
// Wait for new puzzle to load
await page.waitForTimeout(1500);
// Medium button should now be selected (yellow background)
const mediumButton = page.locator('button:has-text("Medium")');
await expect(mediumButton).toHaveClass(/bg-yellow-500/);
});
test('should show coach chat interface', async ({ page }) => {
await page.goto('/learning');
await page.waitForLoadState('networkidle');
await page.locator('button:has-text("Professional Coach")').first().click();
await page.click('text=FORK');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000); // Give time for puzzle to load
// Wait for page to load (react-chessboard uses SVG/images, not canvas)
await expect(page.getByText('Correct', { exact: true })).toBeVisible({ timeout: 15000 });
// Close any modals or overlays that might be open
const closeButtons = page.locator('button[aria-label*="Close"]').or(page.locator('button:has-text("×")'));
if (await closeButtons.count() > 0) {
await closeButtons.first().click({ force: true });
await page.waitForTimeout(500);
}
// Should see input field for asking questions
await expect(page.locator('input[placeholder*="Ask"]')).toBeVisible({ timeout: 10000 });
// Should see send button (it's an icon-only button with the Send icon)
const sendButton = page.locator('button[type="submit"]').or(page.locator('form button').last());
await expect(sendButton.first()).toBeVisible();
});
test('should update statistics when skipping puzzle', async ({ page }) => {
await page.goto('/learning');
await page.waitForLoadState('networkidle');
await page.locator('button:has-text("Professional Coach")').first().click();
await page.click('text=PIN');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000); // Give time for puzzle to load
// Wait for puzzle to load (react-chessboard uses SVG/images, not canvas)
await expect(page.getByText('Correct', { exact: true })).toBeVisible({ timeout: 15000 });
// Close any modals or overlays that might be blocking
const closeButtons = page.locator('button[aria-label*="Close"]').or(page.locator('button:has-text("×")'));
if (await closeButtons.count() > 0) {
await closeButtons.first().click({ force: true });
await page.waitForTimeout(500);
}
// Find the incorrect stat card - it's the second stat card
const statCards = page.locator('.bg-white.dark\\:bg-gray-800.rounded-lg.p-4');
const incorrectCard = statCards.nth(1);
const incorrectValue = incorrectCard.locator('.text-2xl.font-bold');
// Initial stats should be 0
await expect(incorrectValue).toHaveText('0');
// Click Skip Puzzle - use force if needed to bypass overlays
await page.locator('button:has-text("Skip Puzzle")').click({ force: true });
// Wait for stats to update and new puzzle to load
await page.waitForTimeout(1000);
// Incorrect count should increase
await expect(incorrectValue).toHaveText('1');
});
test('should navigate back to learning area', async ({ page }) => {
await page.goto('/learning');
await page.waitForLoadState('networkidle');
await page.locator('button:has-text("Professional Coach")').first().click();
await page.click('text=PIN');
await page.waitForLoadState('networkidle');
// Click back button
await page.click('text=Back to Learning');
// Should be back on learning page
await expect(page).toHaveURL('/learning');
});
test('should display all 8 tactical patterns', async ({ page }) => {
await page.goto('/learning');
const patterns = [
'PIN',
'SKEWER',
'FORK',
'DISCOVERED CHECK',
'DOUBLE ATTACK',
'OVERLOADING',
'BACK RANK WEAKNESS',
'TRAPPED PIECE'
];
for (const pattern of patterns) {
await expect(page.locator(`text=${pattern}`)).toBeVisible();
}
});
});