- "Check my work" now takes photos and documents of the attempt: documents
are read in the browser into text, photos go to /api/homework as
solutionImages and are transcribed by the vision model before the check
("Reading your solution..."). The finished attempt comes back in
meta.solutionText and is stored as the record's attempt, so "Redo"
re-checks the same work without keeping the files.
- Deleting a deck now drops its due schedules and mistakes from the library's
smart review immediately (the counts resolve against the live deck list);
Undo brings them back, because the learning data itself is never erased.
- The phone override for the glacier blobs was still at 0.1/0.2: it is now a
quarter of the original value there too (0.025/0.05), matching the desktop.
- License: GPL-3.0-only (LICENSE, package.json, README section).
Tests: phase-3 photos-only API + desktop attachment checks (28/28), review
suite deck-delete checks (18/18), i18n/mobile/phase-2 green. README:
decisions 57-58. SW -> freezmo-v45.
178 lines
7.3 KiB
JavaScript
178 lines
7.3 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);
|
|
|
|
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: "Tvoje mraznička" }).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();
|