freezmo/tests/activities-test.mjs
Martin Dvorak afe8c49bd4 activities-test: widen the fresh-budget window for loaded machines
The deploy gate (all 16 suites back to back) had the timed-run check read 23
where the suite demanded >= 25: under the fake clock the suite's own waits
advance the app's interval, so a loaded machine can tick a few extra seconds
between the card switch and the read. The assertion keeps its meaning - a
fresh budget, not the exhausted card's leftover (~0) - with a looser low end
and a comment explaining why. No app behaviour changed.
2026-10-16 05:43:37 +00:00

216 lines
9.6 KiB
JavaScript

/**
* 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();