Study cards: from-your-notes, read aloud, timed runs
From your notes: the generator now stores a verbatim source quote with every activity (schema + prompt + a server-side clamp so a chatty model cannot drop a card), and the feedback panel shows the sentence that carries it with the quote highlighted green. Decks made before the change, and quotes the model trimmed, fall back to a local IDF-weighted match over the saved notes text (lib/notes-source.ts): nothing is shown unless at least two distinct terms land. The passage hides in exam mode with the other reveals. Read aloud: a Web Speech API speaker control on the text a learner reads (flashcard faces, questions, statements, sentences, instructions) via lib/speech.ts and components/SpeakButton.tsx — one utterance app-wide, a voice matched to the deck's language, and no button at all where the browser has no speech synthesis. It used to sit inside the flashcard's flip button (nested buttons, tapping it flipped the card); it now lives next to the hint row. Timed runs: the feed chrome carries a timer chip (off by default) that gives each card 30 seconds; a card that runs out is recorded as a miss through the same path as a wrong answer (history + SRS agree), shows a toast, and the feed moves straight on with a fresh budget. handleAnswered ignores a second result per card, so a timeout plus a manual answer cannot record twice. The generator's type spec now names what each type suits (language decks lean on flashcards/matching/fill blank, ordering needs a real sequence, short answer reasoning), so the deck-aware guidance covers all seven types. New activities-test suite (24 checks: quote path, local path, the green mark, the speech stub, the timed run on Playwright's fake clock), registered in run-all; phase2 pins that the notes passage stays hidden in exam mode; en+cs keys for every new string; no new dependencies. Version 0.6.1, SW v83.
This commit is contained in:
parent
786d6cf1ae
commit
796e2e9fe9
26 changed files with 1068 additions and 51 deletions
209
tests/activities-test.mjs
Normal file
209
tests/activities-test.mjs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* 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));
|
||||
|
||||
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,
|
||||
);
|
||||
const fresh = Number.parseInt((await toggle.textContent()) ?? "", 10);
|
||||
check("timed: the next card gets a fresh budget", fresh >= 25 && 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();
|
||||
|
|
@ -391,6 +391,12 @@ const browser = await launchBrowser();
|
|||
// feed for ArrowDown. Both activities have the correct answer at index 0.
|
||||
await page.keyboard.press("1");
|
||||
await page.waitForTimeout(500);
|
||||
/* The feedback shows, but the "from your notes" passage stays hidden in
|
||||
exam mode with the other reveals (the deck's notes do contain the answer). */
|
||||
check(
|
||||
"exam: no notes source under the feedback",
|
||||
(await page.getByText("Why:").count()) >= 1 && (await page.locator("[data-notes-source]").count()) === 0,
|
||||
);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press("2");
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import path from "node:path";
|
|||
|
||||
const SUITES = [
|
||||
"regression",
|
||||
"activities-test",
|
||||
"bubbles-test",
|
||||
"mobile-test",
|
||||
"wizard-test",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue