/** * The study-card extras: "From your notes" (the AI's own quote, and the local * sentence match for decks made before quotes existed), read-aloud cards * (Web Speech API, with the engine stubbed so the suite stays deterministic) * and timed runs (the restart-able countdown in the feed chrome). */ import { BASE, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs"; const { check, finish } = makeChecker(); const browser = await launchBrowser(); const NOTES = "Rayleigh scattering makes the sky blue: molecules scatter short wavelengths far more than long ones. Chlorophyll absorbs red and blue light, which is why plants look green."; /** Two cards: one with the generator's verbatim quote, one from before that. */ const DECK = { id: "seed_deck", title: "Sky deck", subject: "Physics", summary: "Light in the atmosphere.", language: "English", sourceText: NOTES, difficulty: "easy", createdAt: 1, updatedAt: 1, attempts: 0, activities: [ { type: "multiple_choice", id: "a1", xp: 10, topic: "Sky", question: "What colour is a clear daytime sky?", options: ["Blue", "Green", "Orange", "Pink"], correctIndex: 0, explanation: "Short wavelengths scatter more.", source: "molecules scatter short wavelengths far more than long ones", }, { type: "flashcard", id: "a2", xp: 5, topic: "Sky", front: "What makes the sky blue?", back: "Rayleigh scattering", explanation: "Small molecules scatter short wavelengths.", }, ], }; /* 1. From your notes + read aloud --------------------------------------- */ { const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 }); await seed(page, { decks: [DECK], theme: "dark" }); // Deterministic speech: the engine is a recorder, the utterance API is real, // and nothing ever fires "end" — so the button settles on its playing state. await page.addInitScript(() => { window.__spoken = []; window.__cancelled = 0; Object.defineProperty(window, "speechSynthesis", { configurable: true, value: { speak: (utterance) => window.__spoken.push(String(utterance.text)), cancel: () => { window.__cancelled += 1; }, getVoices: () => [], speaking: false, paused: false, }, }); }); await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" }); await page.waitForTimeout(1200); const first = page.locator("section[aria-label]").nth(0); const second = page.locator("section[aria-label]").nth(1); /* The AI's own quote: located in the stored notes, quote highlighted. */ await first.getByRole("button", { name: "Blue" }).click(); await page.waitForTimeout(600); const quoted = first.locator("[data-notes-source]"); check("source: the feedback shows where the answer came from", await quoted.isVisible()); check( "source: the quote's sentence from the notes is shown", ((await quoted.textContent()) ?? "").includes("Rayleigh scattering makes the sky blue"), ((await quoted.textContent()) ?? "").slice(0, 80), ); const quotedMark = quoted.locator("mark").first(); check( "source: the quote itself is the highlighted part", ((await quotedMark.textContent()) ?? "").includes("molecules scatter short wavelengths far more than long ones"), String(await quotedMark.textContent()), ); const markRgb = await quotedMark.evaluate((element) => { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); ctx.fillStyle = getComputedStyle(element).backgroundColor; ctx.fillRect(0, 0, 1, 1); const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; return { r, g, b }; }); check("source: the highlight is green", markRgb.g > markRgb.r + 10 && markRgb.g > markRgb.b, JSON.stringify(markRgb)); check("source: the quote path is flagged", (await quoted.getAttribute("data-notes-quote")) === "1"); /* Read aloud, on the flashcard that follows. */ await first.getByRole("button", { name: "Next" }).click(); await page.waitForTimeout(900); const speak = second.getByRole("button", { name: "Read aloud" }).first(); check("speak: the card carries a read-aloud control", (await speak.count()) === 1); await speak.click(); await page.waitForTimeout(300); check( "speak: the visible side is handed to the speech engine", ((await page.evaluate(() => window.__spoken)) ?? []).includes("What makes the sky blue?"), JSON.stringify(await page.evaluate(() => window.__spoken)), ); check("speak: the control flips to stop", (await second.getByRole("button", { name: "Stop reading" }).count()) === 1); check( "speak: tapping the speaker does not flip the card", (await second.getByRole("button", { name: "Reveal the answer" }).count()) === 1, ); await second.getByRole("button", { name: "Stop reading" }).click(); await page.waitForTimeout(300); check("speak: stopping cancels the utterance", (await page.evaluate(() => window.__cancelled)) >= 1); check("speak: the control flips back", (await second.getByRole("button", { name: "Read aloud" }).count()) === 1); /* The local fallback: this card has no quote, so the sentence is matched. */ await second.getByRole("button", { name: "Reveal the answer" }).click(); await page.waitForTimeout(600); await second.getByRole("button", { name: /I knew it/ }).click(); await page.waitForTimeout(800); const local = second.locator("[data-notes-source]"); check("source: a deck without quotes still points at the notes", await local.isVisible()); check( "source: the local match is the right sentence", ((await local.textContent()) ?? "").includes("Rayleigh scattering makes the sky blue"), ((await local.textContent()) ?? "").slice(0, 80), ); check("source: the matched words are marked", (await local.locator("mark").count()) >= 1); check("source: the local path is flagged", (await local.getAttribute("data-notes-quote")) === "0"); await context.close(); } /* 2. timed runs --------------------------------------------------------- */ { const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 }); await seed(page, { decks: [DECK] }); await page.clock.install(); // The fake clock has no animation frames, so the browser's smooth scrolling // would never arrive (the feed tracks its slide from the scroll position). // The harness scrolls instantly; everything else is the real app. await page.addInitScript(() => { const original = Element.prototype.scrollTo; Element.prototype.scrollTo = function (options, y) { if (options && typeof options === "object") return original.call(this, { ...options, behavior: "auto" }); return original.call(this, options, y); }; }); await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" }); await page.waitForTimeout(1200); const toggle = page.locator("[data-timed-toggle]"); check( "timed: the chrome carries the toggle, off by default", (await toggle.count()) === 1 && (await toggle.getAttribute("aria-pressed")) === "false", ); check("timed: the label names the per-card budget", /30s/.test((await toggle.textContent()) ?? ""), String(await toggle.textContent())); await toggle.click(); await page.waitForTimeout(300); check("timed: toggled on", (await toggle.getAttribute("aria-pressed")) === "true"); await page.clock.runFor(5000); await page.waitForTimeout(200); const afterFive = Number.parseInt((await toggle.textContent()) ?? "", 10); check("timed: the countdown ticks down", afterFive <= 26 && afterFive > 0, String(afterFive)); const bar = await page.locator("[data-timed-bar]").evaluate((element) => element.getBoundingClientRect().width); const track = await page.locator("[data-timed-bar]").evaluate((element) => element.parentElement.getBoundingClientRect().width); check("timed: the countdown is drawn along the chrome", bar > 10 && bar < track, `${Math.round(bar)}/${Math.round(track)}`); await page.clock.runFor(26_000); await page.waitForTimeout(300); /* the feed advances a beat after the countdown hits zero */ await page.clock.runFor(1000); await page.waitForTimeout(300); check("timed: running out tells the learner", (await page.getByText(/Time's up/).count()) >= 1); /* The feed's position, not the smooth-scroll animation: under the fake clock the browser's smooth scrolling has no frames of its own. */ const position = await page.evaluate(() => { const chip = document.querySelector("header .study-chrome span.tabular-nums"); return chip ? chip.textContent?.trim() ?? "" : ""; }); check("timed: the feed moves on by itself", position.startsWith("2/"), position); check( "timed: the timeout is recorded as a miss", (await page.locator("[data-testid=study-feed]").evaluate((element) => element.scrollTop)) > 100, ); /* A fresh budget, not the exhausted card's leftover (which reads ~0). The window is deliberately loose at the low end: under the fake clock the suite's own waits advance the app's interval, and a loaded machine can tick a few extra seconds between the switch and this read. */ const fresh = Number.parseInt((await toggle.textContent()) ?? "", 10); check("timed: the next card gets a fresh budget", fresh >= 20 && fresh <= 30, String(fresh)); /* The second card times out too: the results screen counts the miss. */ await page.clock.runFor(31_000); await page.waitForTimeout(600); check( "timed: both timeouts reach the results slide as misses", (await page.getByText(/You got 0 of 2 right/).count()) === 1 && (await page.getByText(/Missed items \(2\)/).count()) === 1, ); await context.close(); } await browser.close(); finish();