The phone (touch) chat chrome carried a 55% flat white veil while every other Aero surface uses the thin 32% Ask-pill tint, so the header, welcome card and composer read as plain white on a bright sky. They now use the same flat glass as the cards, the answer/user bubbles keep 42% for long reading, and the composer's field is a lighter 42% tile. The shared Aero wash block moved above the touch media query so its background-image can no longer resurrect the bake on phones. The timed run worked but was invisible: the only cue was a small chip among the streak/XP counters. The chrome pill now draws the countdown along its lower edge, the chip is bordered (a control, not a stat), turns rose with the bar in the last ten seconds, and fires one soft haptic tick at ten seconds. Contrast scan clean on the phone chat in all three themes; activities suite pins the countdown bar (25 checks). Version 0.6.2, SW v84.
212 lines
9.4 KiB
JavaScript
212 lines
9.4 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,
|
|
);
|
|
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();
|