freezmo/tests/review-test.mjs
Martin Dvorak 71c56ad06f Review breakdown: show each deck's own activity total
Follow-up to the smart-review breakdown: "I only have 2 decks" turned out to
be three deck cards (one clipped at the window edge) and 15 + 13 + 6 = 34
due. The line now carries each deck's activity total next to its due count -
"Due by deck: Sky deck - 15 of 15 - Humanismus - 13 of 15 - ... - 6 of 13" -
so the arithmetic is checkable inline without opening a deck.

groupByDeck returns {label, count, total}; new i18n key review.dueOfTotal
(en + cs). review-test pins the format. Version 0.6.9, SW v91.
2026-10-15 21:20:53 +00:00

232 lines
9.7 KiB
JavaScript

/**
* Smart Review behaviour: the loading feedback (a stalled first request must
* never look like a dead tap), the session end-to-end, and the health-probe
* cache.
*/
import { BASE, DECK, dueSrs, launchBrowser, makeChecker, makePage, seed, seedLearning, wrongAnswer } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
async function makeReviewPage(locale = "en") {
const { context, page } = await makePage(browser);
await seed(page, { locale, decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
await seedLearning(page, { answers: [wrongAnswer()], srs: [dueSrs()] });
await page.reload({ waitUntil: "networkidle" });
await page.waitForTimeout(900);
return { context, page };
}
/* 1. stalled navigation: loading screen appears at once ------------- */
{
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
let stalled = 0;
await context.route("**/review*", async (route) => {
stalled += 1;
await gate;
await route.continue().catch(() => {});
});
const page = await context.newPage();
await seed(page, { decks: [DECK] });
await page.goto(BASE, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(800);
await seedLearning(page, { answers: [wrongAnswer()], srs: [dueSrs()] });
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForTimeout(900);
check("library shows the due count", await page.getByText(/Review due · 1 due/).isVisible());
const tClick = Date.now();
await page.getByText(/Review due · 1 due/).click();
let loadingAt = null;
for (let i = 0; i < 40; i++) {
if ((await page.getByText("Preparing your review…").count()) > 0) {
loadingAt = Date.now();
break;
}
await page.waitForTimeout(50);
}
check("loading screen appears immediately on tap", loadingAt !== null && loadingAt - tClick < 1000, `${loadingAt ? loadingAt - tClick : "never"} ms`);
check("still on the library while stalled", page.url().replace(BASE, "") === "/", page.url());
check("spinner is visible", (await page.getByRole("status").count()) >= 1);
/* The overlay is portalled to <body>: rendered inside the glass card it was
confined to a 44px strip of that card (backdrop-filter is a containing
block for fixed descendants). */
const overlay = await page.evaluate(() => {
const status = document.querySelector('[role="status"][aria-busy="true"]');
const rect = status?.parentElement?.getBoundingClientRect();
return rect ? { w: Math.round(rect.width), h: Math.round(rect.height) } : null;
});
check(
"loading overlay covers the viewport",
overlay !== null && overlay.w >= 380 && overlay.h >= 800,
JSON.stringify(overlay),
);
release();
let sessionAt = null;
for (let i = 0; i < 200; i++) {
if ((await page.getByText("What colour is a clear daytime sky?").count()) > 0) {
sessionAt = Date.now();
break;
}
await page.waitForTimeout(50);
}
check("session arrives after the request releases", sessionAt !== null, `${sessionAt ? sessionAt - tClick : "never"} ms`);
check("loading screen is gone once the session is up", (await page.getByText("Preparing your review…").count()) === 0);
check("stalled requests were exercised", stalled > 0, String(stalled));
await context.close();
}
/* 2. normal flow stays fast; Czech copy; end-to-end grading ---------- */
{
const { context, page } = await makeReviewPage("cs");
const tClick = Date.now();
await page.getByText(/K opakování · 1 k opakování/).click();
let questionAt = null;
for (let i = 0; i < 200; i++) {
if ((await page.getByText("What colour is a clear daytime sky?").count()) > 0) {
questionAt = Date.now();
break;
}
await page.waitForTimeout(25);
}
check("cs: review opens fast without stalling", questionAt !== null && questionAt - tClick < 3000, `${questionAt ? questionAt - tClick : "never"} ms`);
check("cs: session feed present", (await page.locator('[data-testid="review-feed"]').count()) === 1);
await page.getByRole("button", { name: "Blue" }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Zobrazit výsledky" }).click();
await page.waitForTimeout(700);
check("cs: results slide after answering", await page.getByText("100%").isVisible());
await page.locator('[data-testid="review-feed"]').getByRole("button", { name: "Zpět do knihovny" }).click();
await page.waitForTimeout(900);
check("cs: back in the library", await page.getByRole("heading", { name: "Tvůj mrazák" }).isVisible());
await context.close();
}
/* 3. mistakes path --------------------------------------------------- */
{
const { context, page } = await makeReviewPage("en");
check("mistakes link present", await page.getByText(/Mistakes · 1 mistake/).isVisible());
await page.getByText(/Mistakes · 1 mistake/).click();
await page.waitForTimeout(1200);
check("mistakes session opens", (await page.getByText("What colour is a clear daytime sky?").count()) > 0 && page.url().includes("mistakes=1"));
await context.close();
}
/* 4. deleting a deck updates the smart review ------------------------ */
{
const { context, page } = await makePage(browser);
const second = { ...DECK, id: "seed_two", title: "Second deck" };
await seed(page, { decks: [DECK, second] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
await seedLearning(page, {
answers: [wrongAnswer("seed_deck", "a1"), wrongAnswer("seed_two", "a2")],
srs: [dueSrs("seed_deck", "a1"), dueSrs("seed_two", "a2")],
});
await page.reload({ waitUntil: "networkidle" });
await page.waitForTimeout(900);
const counts = () =>
page.evaluate(() => document.body.innerText.match(/(Review due · \d+ due|Mistakes · \d+ mistakes?)/g) ?? []);
const before = await counts();
check(
"deck delete: two decks are due with two mistakes",
JSON.stringify(before) === JSON.stringify(["Review due · 2 due", "Mistakes · 2 mistakes"]),
JSON.stringify(before),
);
await page
.locator("li")
.filter({ hasText: "Sky deck" })
.first()
.getByRole("button", { name: "Deck actions" })
.click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Delete deck" }).click();
await page.waitForTimeout(800);
const after = await counts();
check(
"deck delete: its schedules and mistakes leave the review",
JSON.stringify(after) === JSON.stringify(["Review due · 1 due", "Mistakes · 1 mistake"]),
JSON.stringify(after),
);
const undo = page.getByRole("button", { name: /Undo/ });
if ((await undo.count()) > 0) await undo.click();
await page.waitForTimeout(800);
const restored = await counts();
check(
"deck delete: Undo brings the counts back",
JSON.stringify(restored) === JSON.stringify(["Review due · 2 due", "Mistakes · 2 mistakes"]),
JSON.stringify(restored),
);
await context.close();
}
/* 4b. the review names its source decks ------------------------------ */
{
/* "It contains mistakes I either did more than once or from decks that I've
removed" — the count was honest (only live deck+activity pairs), but the
session never said WHERE an item came from, so a deck created twice from
the same notes read as one question repeating itself. Every card now
names its deck, and the Smart review card breaks the count down. */
const { context, page } = await makePage(browser);
const twin = { ...DECK, id: "seed_twin", title: "Twin deck" };
await seed(page, { decks: [DECK, twin] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
await seedLearning(page, {
srs: [dueSrs("seed_deck", "a1"), dueSrs("seed_deck", "a2"), dueSrs("seed_twin", "a1"), dueSrs("seed_twin", "a2")],
});
await page.reload({ waitUntil: "networkidle" });
await page.waitForTimeout(900);
const breakdown = await page.evaluate(
() => document.body.innerText.split("\n").find((line) => line.includes("Due by deck")) ?? "",
);
check(
"review: the card breaks the count down by deck",
breakdown === "Due by deck: Sky deck · 2 of 2 · Twin deck · 2 of 2",
breakdown,
);
await page.getByText(/Review due · 4 due/).click();
await page.waitForTimeout(1500);
const chips = await page.evaluate(() => {
const feed = document.querySelector("[data-testid='review-feed']");
return [...(feed?.querySelectorAll(".glass-chip") ?? [])].map((el) => el.textContent?.trim());
});
check(
"review: every card names its deck",
chips.includes("Sky deck") && chips.includes("Twin deck"),
JSON.stringify(chips.slice(0, 8)),
);
check("review: the twin's question is distinguishable", (await page.getByText("Twin deck").count()) >= 1);
await context.close();
}
/* 5. health probes are deduplicated ---------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
let healthCount = 0;
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/health") healthCount += 1;
});
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1500);
const afterLibrary = healthCount;
await page.locator("nav.tabbar").getByRole("link", { name: "Progress" }).click();
await page.waitForTimeout(1200);
await page.locator("nav.tabbar").getByRole("link", { name: "Library" }).click();
await page.waitForTimeout(1200);
check("library fires one health probe", afterLibrary <= 1, String(afterLibrary));
check("navigating reuses the cached probe", healthCount === afterLibrary, `${healthCount} total`);
await context.close();
}
await browser.close();
finish();