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:
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user