/** * E2E: full learn flow. * * Runs against the live dev server with seeded data (pnpm seed). * Mocks only the grade and socratic API calls (avoid real LLM cost). * Lesson data comes from the DB (seeded js-closures corpus). * * Pre-condition: `docker compose up -d && pnpm seed` must have run. */ import { test, expect } from '@playwright/test'; // ── Mock payloads ───────────────────────────────────────────────────────────── const MOCK_GRADE_MASTERED = { responseId: 'resp-uuid-001', gradeId: 'grade-uuid-001', grade: { verdict: 'mastered', rubric_hits: ['r1', 'r2'], misconception_tag: 'none', evidence_span: 'retains access to variables from its enclosing scope', feedback_directive: 'advance', confidence: 0.92, }, }; const MOCK_GRADE_PARTIAL = { responseId: 'resp-uuid-002', gradeId: 'grade-uuid-002', grade: { verdict: 'partial', rubric_hits: ['r1'], misconception_tag: 'none', evidence_span: 'a function inside a function', feedback_directive: 'probe: Can you say more about what happens after the outer function returns?', confidence: 0.78, }, }; const MOCK_GRADE_BUDGET_EXCEEDED = { status: 429, json: { error: 'Session limit reached. Start a new session to continue.' }, }; // ── Tests ───────────────────────────────────────────────────────────────────── test.describe('learn flow (seeded DB)', () => { test('homepage loads with intent input', async ({ page }) => { await page.goto('/'); await expect(page.locator('h1')).toContainText('Curio'); await expect(page.locator('[aria-label="Learning intent"]')).toBeVisible(); await expect(page.locator('button[type="submit"]')).toBeDisabled(); }); test('intent submission navigates to lesson page', async ({ page }) => { await page.route('/api/intent', (route) => route.fulfill({ json: { intentKey: 'javascript-closures', blueprintId: 'bp-seed', isNew: false }, }), ); await page.goto('/'); await page.fill('[aria-label="Learning intent"]', 'JavaScript closures'); await page.click('button[type="submit"]'); await expect(page).toHaveURL('/learn/javascript-closures'); }); test('lesson page renders seeded content', async ({ page }) => { await page.goto('/learn/javascript-closures'); // Blueprint title await expect(page.locator('h1')).toContainText('JavaScript Closures'); // First segment body is visible await expect(page.getByText(/closure/i).first()).toBeVisible(); // First checkpoint prompt is visible await expect(page.getByText(/explain what a closure is/i)).toBeVisible(); }); test('mastered verdict unlocks next segment', async ({ page }) => { await page.route('/api/grade', (route) => route.fulfill({ json: MOCK_GRADE_MASTERED })); await page.goto('/learn/javascript-closures'); const textarea = page.locator('textarea').first(); await textarea.fill('A closure retains access to variables from its enclosing scope even after the outer function returns.'); await page.locator('button[type="submit"]').first().click(); await expect(page.getByText(/mastered/i)).toBeVisible(); // Second segment should now be unlocked (no longer shows "locked" hint) await expect(page.getByText('Complete the previous checkpoint to continue.')).not.toBeVisible(); }); test('partial verdict shows probe question', async ({ page }) => { await page.route('/api/grade', (route) => route.fulfill({ json: MOCK_GRADE_PARTIAL })); await page.goto('/learn/javascript-closures'); const textarea = page.locator('textarea').first(); await textarea.fill('a function inside a function'); await page.locator('button[type="submit"]').first().click(); await expect(page.getByText(/partial/i)).toBeVisible(); await expect( page.getByText(/what happens after the outer function returns/i), ).toBeVisible(); }); test('budget exceeded shows friendly error', async ({ page }) => { await page.route('/api/grade', (route) => route.fulfill(MOCK_GRADE_BUDGET_EXCEEDED), ); await page.goto('/learn/javascript-closures'); const textarea = page.locator('textarea').first(); await textarea.fill('some answer'); await page.locator('button[type="submit"]').first().click(); await expect(page.getByText(/session limit/i)).toBeVisible(); }); });