The wallpaper is the one from the mockup: "The S Aesthetic That You Definitely Recognize Frutiger Aero" via WallpaperSafari (https://wallpapersafari.com/w/IlWuz7) — the URL in the request pointed at a different (teal abstract) wallpaper, this is the grass/sky/water one that matches the screenshot. Re-encoded to a 237 kB WebP in public/aero/, used as the scene in aero only (light/dark never download it), and credited in Settings next to the theme picker. The drawn CSS scene (rays, reeds, skyline, caustics) is off over the photo; the ten rising bubbles stay, and the orbs became extra bubbles. Buttons and segments are liquid glass now — translucent, glossed top half, bright rim, backdrop blur — instead of the solid aqua fill. Helper lines that sit straight on the wallpaper (the keyboard tip, the AI note) get a small glass chip: dark text on grass measured 2.5:1. Also carries: the underwater scene + bubbles work, the floating cookie notice, the Czech title (Tvůj mrazák) and the review-suite fix that made the last deploy fail its gate. Contrast: 0 below-minimum findings, 0 axe violations across all three themes. Version 0.2.2, SW -> freezmo-v56.
191 lines
7.9 KiB
JavaScript
191 lines
7.9 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();
|
|
}
|
|
|
|
/* 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();
|