Add unified FEN/PGN import with auto-detection

- Create chessFormatDetector utility for automatic format detection
- Update StartScreen with textarea supporting both FEN and PGN input
- Add real-time format detection with visual feedback indicators
- Update translations for all 4 languages (EN, DE, FR, IT)
- Add markdown rendering for Tutor chat messages
- Add comprehensive tests for format detection (20 tests)
- Update game initialization to handle both FEN and PGN formats

This completes the fix/stale-analysis-data branch with:
- Fixed stale evaluation data in hint/best move requests
- Clarified AI's dual role (opponent + tutor) to prevent hint rejection
- Stored complete evaluation history (P0, P1, P2) for all moves
- Improved end-game analysis with better mistake detection
- Fixed duplicate analysis runs with useRef flag
- Added markdown rendering for formatted analysis output
- Added unified FEN/PGN import with auto-detection
This commit is contained in:
Stefan
2025-11-25 17:54:25 +01:00
parent 4d5ec5ecc3
commit cd6cab367a
10 changed files with 1736 additions and 115 deletions
@@ -0,0 +1,126 @@
import { detectChessFormat, parseChessNotation } from '../chessFormatDetector';
describe('chessFormatDetector', () => {
describe('detectChessFormat', () => {
describe('FEN detection', () => {
it('should detect standard starting position FEN', () => {
const fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
expect(detectChessFormat(fen)).toBe('fen');
});
it('should detect FEN with different position', () => {
const fen = 'r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3';
expect(detectChessFormat(fen)).toBe('fen');
});
it('should detect FEN with black to move', () => {
const fen = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1';
expect(detectChessFormat(fen)).toBe('fen');
});
it('should detect FEN with no castling rights', () => {
const fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1';
expect(detectChessFormat(fen)).toBe('fen');
});
it('should detect FEN with partial castling rights', () => {
const fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 0 1';
expect(detectChessFormat(fen)).toBe('fen');
});
});
describe('PGN detection', () => {
it('should detect PGN with headers', () => {
const pgn = `[Event "Casual Game"]
[Site "Chess Tutor"]
[Date "2024.01.15"]
[White "Player"]
[Black "Stockfish"]
[Result "1-0"]
1. e4 e5 2. Nf3 Nc6 3. Bb5 1-0`;
expect(detectChessFormat(pgn)).toBe('pgn');
});
it('should detect PGN with only moves (no headers)', () => {
const pgn = '1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6';
expect(detectChessFormat(pgn)).toBe('pgn');
});
it('should detect PGN with castling moves', () => {
const pgn = '1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. O-O';
expect(detectChessFormat(pgn)).toBe('pgn');
});
it('should detect PGN with long game', () => {
const pgn = '1. e4 c5 2. Nf3 d6 3. d4 cxd4 4. Nxd4 Nf6 5. Nc3 a6 6. Be3 e5 7. Nb3 Be6 8. f3 Be7 9. Qd2 O-O 10. O-O-O';
expect(detectChessFormat(pgn)).toBe('pgn');
});
it('should detect PGN with only headers', () => {
const pgn = `[Event "Test"]
[White "Player"]
[Black "Computer"]`;
expect(detectChessFormat(pgn)).toBe('pgn');
});
});
describe('Invalid input detection', () => {
it('should detect empty string as invalid', () => {
expect(detectChessFormat('')).toBe('invalid');
});
it('should detect whitespace-only string as invalid', () => {
expect(detectChessFormat(' \n \t ')).toBe('invalid');
});
it('should detect random text as invalid', () => {
expect(detectChessFormat('this is not chess notation')).toBe('invalid');
});
it('should detect incomplete FEN as invalid', () => {
expect(detectChessFormat('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR')).toBe('invalid');
});
it('should detect FEN with wrong number of slashes as invalid', () => {
expect(detectChessFormat('rnbqkbnr/pppppppp/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1')).toBe('invalid');
});
it('should detect FEN with invalid turn indicator as invalid', () => {
expect(detectChessFormat('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1')).toBe('invalid');
});
});
});
describe('parseChessNotation', () => {
it('should parse valid FEN', () => {
const fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
const result = parseChessNotation(fen);
expect(result).toEqual({
format: 'fen',
notation: fen
});
});
it('should parse valid PGN', () => {
const pgn = '1. e4 e5 2. Nf3 Nc6';
const result = parseChessNotation(pgn);
expect(result).toEqual({
format: 'pgn',
notation: pgn
});
});
it('should return null for invalid input', () => {
const result = parseChessNotation('invalid chess notation');
expect(result).toBeNull();
});
it('should trim whitespace', () => {
const fen = ' rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 ';
const result = parseChessNotation(fen);
expect(result?.notation).toBe(fen.trim());
});
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* Detects whether a chess notation string is FEN or PGN format
*/
export type ChessFormat = 'fen' | 'pgn' | 'invalid';
/**
* Automatically detects if the input is a FEN position or PGN game
*
* FEN (Forsyth-Edwards Notation) structure:
* - Single line with exactly 6 space-separated fields
* - First field: piece placement with 7 slashes (8 ranks)
* - Second field: active color ('w' or 'b')
* - Example: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
*
* PGN (Portable Game Notation) structure:
* - Contains headers in square brackets: [Event "..."]
* - Contains move numbers with periods: 1. e4 e5 2. Nf3
* - Can be multi-line
*
* @param input - The chess notation string to detect
* @returns 'fen' | 'pgn' | 'invalid'
*/
export function detectChessFormat(input: string): ChessFormat {
const trimmed = input.trim();
// Empty input
if (!trimmed) {
return 'invalid';
}
// Check for PGN indicators (most distinctive)
// PGN headers use square brackets: [Event "..."], [White "..."], etc.
if (trimmed.includes('[') && trimmed.includes(']')) {
return 'pgn';
}
// Check for PGN movetext pattern (move numbers with periods)
// Matches patterns like: "1. e4", "2. Nf3", "10. O-O"
// This catches PGN files that might not have headers
if (/\d+\.\s*[a-hNBRQKO]/.test(trimmed)) {
return 'pgn';
}
// Check for FEN structure
// FEN must have exactly 6 space-separated fields
const fields = trimmed.split(/\s+/);
if (fields.length === 6) {
// First field should contain exactly 7 slashes (separating 8 ranks)
const slashCount = (fields[0].match(/\//g) || []).length;
// Second field should be 'w' (white) or 'b' (black)
const validTurn = fields[1] === 'w' || fields[1] === 'b';
// Third field should be castling rights (KQkq, -, or combinations)
const validCastling = /^(-|[KQkq]{1,4})$/.test(fields[2]);
if (slashCount === 7 && validTurn && validCastling) {
return 'fen';
}
}
// If none of the patterns match, it's invalid
return 'invalid';
}
/**
* Validates and extracts the chess notation based on detected format
*
* @param input - The chess notation string
* @returns Object with format type and the cleaned notation, or null if invalid
*/
export function parseChessNotation(input: string): { format: 'fen' | 'pgn'; notation: string } | null {
const format = detectChessFormat(input);
if (format === 'invalid') {
return null;
}
return {
format,
notation: input.trim()
};
}
+28 -8
View File
@@ -32,6 +32,10 @@ export interface Translations {
chooseCoach: string;
importPosition: string;
importPositionPlaceholder: string;
formatDetected: string;
formatFen: string;
formatPgn: string;
formatInvalid: string;
apiKeyRequired: string;
colorSelection: string;
playAsWhite: string;
@@ -123,8 +127,12 @@ const en: Translations = {
resumeGame: 'Resume Previous Game',
startNewGame: 'Start New Game instead...',
chooseCoach: 'Choose Your Coach:',
importPosition: 'Import Position (Optional FEN)',
importPositionPlaceholder: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
importPosition: 'Import Position or Game (FEN or PGN)',
importPositionPlaceholder: 'Paste FEN position or PGN game here...',
formatDetected: 'Format detected:',
formatFen: 'FEN Position',
formatPgn: 'PGN Game',
formatInvalid: 'Invalid format - please paste a valid FEN or PGN',
apiKeyRequired: 'Please enter a valid API Key to continue.',
colorSelection: 'Choose Your Color:',
playAsWhite: 'Play as White',
@@ -206,8 +214,12 @@ const de: Translations = {
resumeGame: 'Vorheriges Spiel fortsetzen',
startNewGame: 'Stattdessen neues Spiel starten...',
chooseCoach: 'Wähle deinen Trainer:',
importPosition: 'Position importieren (Optional FEN)',
importPositionPlaceholder: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
importPosition: 'Position oder Partie importieren (FEN oder PGN)',
importPositionPlaceholder: 'FEN-Position oder PGN-Partie hier einfügen...',
formatDetected: 'Format erkannt:',
formatFen: 'FEN-Position',
formatPgn: 'PGN-Partie',
formatInvalid: 'Ungültiges Format - bitte gültiges FEN oder PGN einfügen',
apiKeyRequired: 'Bitte geben Sie einen gültigen API-Schlüssel ein, um fortzufahren.',
colorSelection: 'Wähle deine Farbe:',
playAsWhite: 'Als Weiß spielen',
@@ -289,8 +301,12 @@ const fr: Translations = {
resumeGame: 'Reprendre la partie précédente',
startNewGame: 'Démarrer une nouvelle partie...',
chooseCoach: 'Choisissez votre coach :',
importPosition: 'Importer une position (FEN optionnel)',
importPositionPlaceholder: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
importPosition: 'Importer une position ou partie (FEN ou PGN)',
importPositionPlaceholder: 'Collez une position FEN ou partie PGN ici...',
formatDetected: 'Format détecté :',
formatFen: 'Position FEN',
formatPgn: 'Partie PGN',
formatInvalid: 'Format invalide - veuillez coller un FEN ou PGN valide',
apiKeyRequired: 'Veuillez entrer une clé API valide pour continuer.',
colorSelection: 'Choisissez votre couleur :',
playAsWhite: 'Jouer Blancs',
@@ -372,8 +388,12 @@ const it: Translations = {
resumeGame: 'Riprendi partita precedente',
startNewGame: 'Inizia nuova partita...',
chooseCoach: 'Scegli il tuo allenatore:',
importPosition: 'Importa posizione (FEN opzionale)',
importPositionPlaceholder: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
importPosition: 'Importa posizione o partita (FEN o PGN)',
importPositionPlaceholder: 'Incolla posizione FEN o partita PGN qui...',
formatDetected: 'Formato rilevato:',
formatFen: 'Posizione FEN',
formatPgn: 'Partita PGN',
formatInvalid: 'Formato non valido - incolla un FEN o PGN valido',
apiKeyRequired: 'Inserisci una chiave API valida per continuare.',
colorSelection: 'Scegli il tuo colore:',
playAsWhite: 'Gioca Bianco',