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.
232 lines
9.7 KiB
JavaScript
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();
|