/** * Phase 2 verification: answer history in IndexedDB, SRS review card and * session, mistakes that clear themselves, the stats screen, exam mode with a * timer, and the push-reminder plumbing (hidden without VAPID, validated with * it). */ import { mkdirSync, readFileSync } from "node:fs"; import { spawn } from "node:child_process"; import path from "node:path"; import { BASE, DECK, REPO, launchBrowser, makeChecker, makePage, portInUse, readLearning, seed, seedLearning, spawnApp, waitForHttp, } from "./lib.mjs"; const { check, finish } = makeChecker(); const browser = await launchBrowser(); /* 1. studying records history, mistakes and a schedule --------------- */ { const { context, page } = await makePage(browser); await seed(page, { decks: [DECK] }); await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" }); await page.waitForTimeout(900); await page.getByRole("button", { name: "Green" }).click(); await page.waitForTimeout(500); check("study: wrong answer shows feedback", await page.getByText(/Correct answer/i).first().isVisible()); await page.getByRole("button", { name: "Next" }).click(); await page.waitForTimeout(900); await page.getByRole("button", { name: "Scattering" }).click(); await page.waitForTimeout(500); await page.getByRole("button", { name: "Next" }).click(); await page.waitForTimeout(900); const stored = await readLearning(page); check("history: two answers recorded", stored.answers.length === 2, String(stored.answers.length)); check("history: correct and wrong kept", stored.answers.some((a) => a.correct) && stored.answers.some((a) => !a.correct)); check("history: topic kept", stored.answers.every((a) => a.topic === "Sky")); check("srs: schedule created for both", stored.srs.length === 2, String(stored.srs.length)); const wrong = stored.srs.find((entry) => entry.activityId === "a1"); const right = stored.srs.find((entry) => entry.activityId === "a2"); check("srs: wrong comes back in ~10 min", wrong && wrong.dueAt - wrong.lastAt === 10 * 60_000); check("srs: correct scheduled a day out", right && right.intervalDays === 1 && right.dueAt - right.lastAt === 86_400_000); await page.goto(BASE, { waitUntil: "networkidle" }); await page.waitForTimeout(900); check("library: learning card visible", await page.getByText("Smart review").isVisible()); check("library: one mistake", await page.getByText("Mistakes · 1 mistake").isVisible()); check("library: review due is zero", await page.getByText("Review due · 0 due").isVisible()); check("library: today's goal line", await page.getByText(/Today: 2 \/ 20/).isVisible()); await page.getByText("Mistakes · 1 mistake").click(); await page.waitForTimeout(1200); check("mistakes: session opens on the wrong item", await page.getByText("What colour is a clear daytime sky?").isVisible()); await page.getByRole("button", { name: "Blue" }).click(); await page.waitForTimeout(400); await page.getByRole("button", { name: "See results" }).click(); await page.waitForTimeout(600); await page.getByRole("button", { name: "Back to the Library" }).click(); await page.waitForTimeout(1000); check("mistakes: cleared after a correct answer", await page.getByText("Mistakes · 0 mistakes").isVisible()); await context.close(); } /* 1b. AI grading feedback carries the disclaimer (stubbed model call) - */ { const { context, page } = await makePage(browser); const shortDeck = { ...DECK, id: "seed_short", title: "Short answers", // Two activities on purpose: with one, grading ends the session and the // results screen replaces the graded view before the check runs. activities: [ { type: "short_answer", id: "s1", xp: 10, topic: "Sky", question: "Why is the sky blue?", referenceAnswer: "Rayleigh scattering favours short wavelengths.", keyPoints: ["scattering", "short wavelengths"], explanation: "Small molecules scatter blue light more than red.", }, { type: "multiple_choice", id: "s2", xp: 10, topic: "Sky", question: "Which colour scatters most?", options: ["Blue", "Red", "Green", "Yellow"], correctIndex: 0, explanation: "Short wavelengths scatter more.", }, ], }; await page.route("**/api/grade", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ correct: true, feedback: "Nice — scattering does the work.", missing: [] }), }), ); await seed(page, { decks: [shortDeck] }); await page.goto(`${BASE}/deck/seed_short`, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); await page.locator("textarea").fill("Because small molecules scatter blue light more."); await page.getByRole("button", { name: "Submit answer" }).click(); await page.waitForTimeout(800); check("grading: AI feedback rendered", await page.getByText("Nice — scattering does the work.").isVisible()); check("grading: AI disclaimer under the feedback", await page.getByText("Freezmo uses AI and can make mistakes").isVisible()); await context.close(); } /* 2. SRS due → review session; stats screen -------------------------- */ { const now = Date.now(); const { context, page } = await makePage(browser); await seed(page, { decks: [DECK] }); await page.goto(BASE, { waitUntil: "networkidle" }); await page.waitForTimeout(600); await seedLearning(page, { answers: [ { id: "h1", deckId: "seed_deck", deckTitle: "Sky deck", activityId: "a1", type: "multiple_choice", topic: "Sky", correct: false, at: now - 3000 }, { id: "h2", deckId: "seed_deck", deckTitle: "Sky deck", activityId: "a2", type: "multiple_choice", topic: "Sky", correct: true, at: now - 2000 }, { id: "h3", deckId: "seed_deck", deckTitle: "Sky deck", activityId: "a1", type: "multiple_choice", topic: "Sky", correct: true, at: now - 1000 }, ], srs: [{ key: "seed_deck:a1", deckId: "seed_deck", activityId: "a1", ease: 2.5, reps: 1, intervalDays: 1, lapses: 0, dueAt: now - 60_000, lastAt: now - 1000, lastCorrect: true }], }); await page.reload({ waitUntil: "networkidle" }); await page.waitForTimeout(1000); check("review: due count from the schedule", await page.getByText("Review due · 1 due").isVisible()); await page.getByText("Review due · 1 due").click(); await page.waitForTimeout(1200); check("review: session shows the due activity", await page.getByText("What colour is a clear daytime sky?").isVisible()); await page.getByRole("button", { name: "Blue" }).click(); await page.waitForTimeout(400); await page.getByRole("button", { name: "See results" }).click(); await page.waitForTimeout(600); check("review: results slide", await page.getByText("100%").isVisible()); await page.getByRole("button", { name: "Back to the Library" }).click(); await page.waitForTimeout(900); await page.goto(`${BASE}/stats`, { waitUntil: "networkidle" }); await page.waitForTimeout(1200); check("stats: title", await page.getByRole("heading", { name: "Your progress" }).isVisible()); const streakValue = await page.locator("section").filter({ hasText: "Day streak" }).first().innerText(); check("stats: streak counts today", /\b1\b/.test(streakValue), streakValue.replace(/\s+/g, " ").slice(0, 40)); check("stats: calendar rendered", (await page.locator('[title*="-"]').count()) >= 35); check("stats: per deck row", await page.getByText("Sky deck").isVisible()); check("stats: per subject row", await page.getByText("Physics").isVisible()); check("stats: weakest topic", await page.getByText("Weakest topics").isVisible()); await context.close(); } /* 3. exam mode: setup, no hints, timer, breakdown -------------------- */ { const { context, page } = await makePage(browser); await seed(page, { decks: [DECK] }); await page.goto(`${BASE}/exam`, { waitUntil: "networkidle" }); await page.waitForTimeout(900); check("exam: setup headline", await page.getByRole("heading", { name: "Exam mode" }).isVisible()); await page.getByRole("button", { name: /Sky deck/ }).click(); await page.waitForTimeout(200); await page.getByRole("button", { name: "5 min", exact: true }).click(); await page.waitForTimeout(200); await page.getByRole("button", { name: /Start exam/ }).click(); await page.waitForTimeout(1000); check("exam: timer visible", await page.getByLabel(/left$/).isVisible()); check("exam: no hint rows", (await page.getByText(/Tap a term|Shortcuts|Use the arrow/i).count()) === 0); // Order-independent: MC listens for number keys on the active slide and the // feed for ArrowDown. Both activities have the correct answer at index 0. await page.keyboard.press("1"); await page.waitForTimeout(500); await page.keyboard.press("ArrowDown"); await page.waitForTimeout(800); await page.keyboard.press("2"); await page.waitForTimeout(500); await page.keyboard.press("ArrowDown"); await page.waitForTimeout(900); check("exam: results show the score", await page.getByText("Exam results").isVisible()); check("exam: per-deck breakdown", await page.getByText("By deck").isVisible() && (await page.getByText("Sky deck").count()) >= 1); check("exam: per-topic breakdown", await page.getByText("By topic").isVisible()); check("exam: wrong answers offered as mistakes", await page.getByRole("button", { name: "Study mistakes" }).isVisible()); const stored = await readLearning(page); check("exam: answers recorded", stored.answers.length >= 2, String(stored.answers.length)); await context.close(); } /* 4. reminders hidden without VAPID ---------------------------------- */ { const { context, page } = await makePage(browser); await seed(page, { decks: [] }); await page.goto(BASE, { waitUntil: "networkidle" }); await page.waitForTimeout(800); const status = await page.evaluate(async () => { const response = await fetch("/api/push", { headers: { "x-ollama-key": "test-key" } }); return { status: response.status, body: await response.json() }; }); check("push: status says disabled without VAPID", status.body.enabled === false, JSON.stringify(status.body)); await page.locator("nav.tabbar").getByRole("button").click(); await page.getByRole("dialog").waitFor({ timeout: 6000 }); await page.waitForTimeout(500); check("push: daily goal setting visible", await page.getByText("Daily goal").isVisible()); check("push: reminder UI hidden", (await page.getByText("Daily reminder").count()) === 0); await context.close(); } /* 5. reminders with VAPID: status, subscribe, delete, test send ------ */ { check("push: port 3209 is free", !(await portInUse(3209))); mkdirSync(path.join(REPO, "tests", ".tmp", "push"), { recursive: true }); const vapid = await new Promise((resolve) => { const child = spawn("npx", ["web-push", "generate-vapid-keys", "--json"], { cwd: REPO }); let output = ""; child.stdout.on("data", (chunk) => (output += chunk)); child.on("close", () => { try { resolve(JSON.parse(output.match(/\{[\s\S]*\}/)[0])); } catch { resolve(null); } }); }); check("push: vapid keys generated", Boolean(vapid?.publicKey && vapid?.privateKey)); const app = spawnApp(3209, { VAPID_PUBLIC_KEY: vapid.publicKey, VAPID_PRIVATE_KEY: vapid.privateKey, VAPID_SUBJECT: "mailto:test@example.com", PUSH_DIR: path.join(REPO, "tests", ".tmp", "push"), }); try { const up = await waitForHttp("http://127.0.0.1:3209/api/health"); check("push: second instance is up", up); if (up) { const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); const page = await context.newPage(); await seed(page, { decks: [] }); await page.goto("http://127.0.0.1:3209/", { waitUntil: "networkidle" }); await page.waitForTimeout(900); await page.locator("nav.tabbar").getByRole("button").click(); await page.getByRole("dialog").waitFor({ timeout: 6000 }); await page.waitForTimeout(700); check("push: reminder UI appears with VAPID", await page.getByText("Daily reminder").isVisible()); const subscription = { endpoint: "https://push.example.invalid/abc", keys: { p256dh: "B".repeat(64), auth: "A".repeat(22) }, }; const subscribe = await page.evaluate(async (sub) => { const response = await fetch("/api/push", { method: "POST", headers: { "Content-Type": "application/json", "x-ollama-key": "test-key" }, body: JSON.stringify(sub), }); return { status: response.status, body: await response.json() }; }, subscription); check("push: subscription stored", subscribe.status === 200 && subscribe.body.count === 1, JSON.stringify(subscribe.body)); const file = JSON.parse(readFileSync(path.join(REPO, "tests", ".tmp", "push", "subscriptions.json"), "utf8")); check("push: stored without personal data", file.length === 1 && Object.keys(file[0]).sort().join(",") === "createdAt,endpoint,keys"); const badBody = await page.evaluate(async () => { const response = await fetch("/api/push", { method: "POST", headers: { "Content-Type": "application/json", "x-ollama-key": "test-key" }, body: JSON.stringify({ endpoint: "not-a-url" }), }); return response.status; }); check("push: invalid subscription → 400", badBody === 400, String(badBody)); const testSend = await page.evaluate(async (sub) => { const response = await fetch("/api/push/test", { method: "POST", headers: { "Content-Type": "application/json", "x-ollama-key": "test-key" }, body: JSON.stringify({ endpoint: sub.endpoint }), }); return { status: response.status, body: await response.json() }; }, subscription); check("push: test send reaches the sender (unreachable endpoint → 502)", testSend.status === 502, JSON.stringify(testSend.body)); const removed = await page.evaluate(async (sub) => { const response = await fetch(`/api/push?endpoint=${encodeURIComponent(sub.endpoint)}`, { method: "DELETE", headers: { "x-ollama-key": "test-key" }, }); return { status: response.status, body: await response.json() }; }, subscription); check("push: delete path works", removed.status === 200 && removed.body.count === 0, JSON.stringify(removed.body)); await context.close(); } } finally { app.stop(); } } await browser.close(); finish();