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
+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();
}
});
});