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