feat: initial commit — Curio mastery tutor
Full Next.js App Router application with: - AI-graded lesson checkpoints (BKT/Elo mastery tracking) - Auth.js v5: credentials, Google, GitHub, generic OIDC - Anonymous-session-first with migrate-on-signin - Admin panel: users, blueprints, reports, site settings - Password reset + email verification (nodemailer/SMTP) - Site config: require_auth + signups_enabled flags - server-only guards on all DB/generation/verification modules - PostgreSQL 16 + pgvector, Redis cache, Drizzle ORM - 271 unit tests (Vitest), golden-eval harness, Playwright e2e stubs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
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('submit button enables when input has text', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.fill('[aria-label="Learning intent"]', 'JavaScript closures');
|
||||
await expect(page.locator('button[type="submit"]')).toBeEnabled();
|
||||
});
|
||||
Reference in New Issue
Block a user