freezmo/tests/wizard-test.mjs
Martin Dvorak df34337b4a Accessibility pass, report control for homework exercises, clearer rail actions
- axe is clean across every screen in both themes, phone and desktop. Fixed:
  the hidden file inputs (aria-hidden + tabIndex -1 — they are opened by
  labelled buttons, so they should not be in the accessibility tree), the
  heading order (deck/homework/smart-review cards are h2 under the page h1;
  LearningSection takes a level so it stays h2 on the library and h3 inside
  the settings dialog) and landmarks around the study and review feeds.
- tests/contrast.mjs (new diagnostic): samples rendered pixels, because axe
  cannot judge text on blurred glass — it reports those nodes as incomplete.
  It skips disabled controls (1.4.3 exempts them) and content behind an open
  sheet. Result: no below-minimum text on any scanned screen.
- The report control exists in the homework guide too: under each "Try it
  yourself" exercise, labelled "Report exercise", without a skip (there is no
  queue to skip). lib/question-reports now takes deckId/deckTitle directly.
- The rail's two creation actions are buttons now (.nav-action) rather than
  quiet nav rows — once the library is full they were the only way to start
  something on desktop and read as just more navigation. "Make my homework"
  is "New homework" wherever it is an action (rail, empty state, share
  chooser, manifest shortcut); the page title and mode switch keep their
  wording.

Version 0.1.6 (three task bumps), SW -> freezmo-v52.
2026-10-12 21:15:56 +00:00

245 lines
13 KiB
JavaScript

/**
* The two wizards: 3-step deck flow and 2-step homework flow, the action bar,
* the in-page mode switch, and the loading lock (a generating run cannot be
* swapped mid-flight; leaving it aborts the request).
*/
import { BASE, DECK, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
/** Samples the flat action bar's position across the entrance. */
async function sampleBar(page, label) {
const samples = [];
for (const delay of [0, 60, 140, 260, 450]) {
if (delay) await page.waitForTimeout(delay === 60 ? 60 : 80);
const state = await page.evaluate(() => {
const bar = document.querySelector("[data-wizard-bar]");
if (!bar) return null;
const box = bar.getBoundingClientRect();
return { bottom: Math.round(box.bottom), width: Math.round(box.width), viewport: window.innerHeight };
});
samples.push({ delay, state });
}
const present = samples.filter((sample) => sample.state);
const pinned = present.length >= 3 && present.every((sample) => Math.abs(sample.state.bottom - sample.state.viewport) <= 2);
check(`${label}: bar pinned to the bottom whenever visible`, pinned, JSON.stringify(present.map((s) => `${s.delay}ms:${s.state.bottom}/${s.state.viewport}`)));
const fullWidth = present.length >= 3 && present.every((sample) => sample.state.width >= 300);
check(`${label}: bar is a flat full-width bar`, fullWidth, JSON.stringify(present.map((s) => s.state.width)));
}
/* 1. deck wizard: entry, steps, summary, both sizes/languages/themes */
for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }]) {
for (const [locale, theme] of [["en", "dark"], ["cs", "light"]]) {
const label = `${viewport.width}/${locale}/${theme}`;
const { context, page } = await makePage(browser, viewport);
await seed(page, { locale, theme, decks: [DECK] });
const cs = locale === "cs";
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
await page.locator("nav.tabbar").getByRole("link", { name: cs ? "Nová sada" : "New deck" }).click();
await sampleBar(page, `${label} tab bar → create`);
check(`${label}: create headline is the step name`, await page.getByRole("heading", { name: cs ? "Tvoje poznámky" : "Your notes" }).isVisible());
check(`${label}: step 1 shows the accepted-formats hint`, await page.getByText(/(Fotky, PDF|Photos, PDF)/).first().isVisible());
check(`${label}: step 1 empty placeholder`, await page.getByText(cs ? "Zatím nic přidáno" : "Nothing added yet").isVisible());
check(`${label}: Next disabled with nothing added`, await page.getByRole("button", { name: cs ? "Další" : "Next" }).isDisabled());
check(`${label}: step 1 has Cancel, not Back`, (await page.getByRole("button", { name: cs ? "Zrušit" : "Cancel" }).count()) === 1);
check(`${label}: no tab bar inside the wizard`, (await page.locator("nav.tabbar").count()) === 0);
await page.getByRole("button", { name: cs ? "Místo toho vložit text" : "Paste text instead" }).click();
await page.locator("textarea").first().fill("Photosynthesis needs light and water.");
await page.waitForTimeout(200);
check(`${label}: Next enables after pasting`, await page.getByRole("button", { name: cs ? "Další" : "Next" }).isEnabled());
await page.getByRole("button", { name: cs ? "Další" : "Next" }).click();
await page.waitForTimeout(400);
check(`${label}: step 2 shows difficulty label`, await page.getByText(cs ? "Obtížnost" : "Difficulty").first().isVisible());
await page.getByRole("button", { name: cs ? "Další" : "Next" }).click();
await page.waitForTimeout(400);
check(`${label}: step 3 headline`, await page.getByRole("heading", { name: cs ? "Připraveno ke zmrazení" : "Ready to freeze" }).isVisible());
check(
`${label}: summary rows present`,
(await page.getByText(cs ? "Soubory" : "Files", { exact: true }).count()) >= 1 &&
(await page.getByText(cs ? "Složka" : "Folder", { exact: true }).count()) >= 1,
);
check(`${label}: Freeze enabled with content`, await page.getByRole("button", { name: cs ? "Zmrazit sadu" : "Freeze my deck" }).isEnabled());
check(
`${label}: AI disclaimer near the freeze button`,
await page.getByText(cs ? "Freezmo používá AI a může se mýlit" : "Freezmo uses AI and can make mistakes").isVisible(),
);
await page.getByRole("button", { name: cs ? "Upravit" : "Edit" }).first().click();
await page.waitForTimeout(400);
check(`${label}: Files Edit jumps to step 1`, await page.getByText("1 / 3").isVisible());
await context.close();
}
}
/* 2. entry from the empty state and the mode switch ------------------ */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
await page.getByRole("link", { name: "Create study deck", exact: true }).click();
await sampleBar(page, "empty state → create");
await page.getByRole("button", { name: "Cancel" }).click();
await page.waitForTimeout(600);
check("Cancel returns to the library", await page.getByRole("heading", { name: "Your icebox" }).isVisible());
await page.getByRole("link", { name: "Homework", exact: true }).click();
await page.waitForTimeout(600);
await page.getByRole("link", { name: "New homework", exact: true }).click();
await sampleBar(page, "homework empty state → homework");
check("homework headline is the step name", await page.getByRole("heading", { name: "The assignment" }).isVisible());
check("homework Next disabled with nothing", await page.getByRole("button", { name: "Next" }).isDisabled());
check("homework has Cancel on step 1", (await page.getByRole("button", { name: "Cancel" }).count()) === 1);
check("homework shows the empty placeholder", await page.getByText("Nothing added yet").isVisible());
check("no tab bar on homework wizard", (await page.locator("nav.tabbar").count()) === 0);
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
const switcher = page.getByRole("group", { name: "Study deck or homework" });
check("mode switch on the New deck screen", await switcher.isVisible());
check("mode switch marks the deck side active", (await switcher.locator('[aria-current="page"]').innerText()) === "Study deck");
await switcher.getByRole("link", { name: "Make my homework" }).click();
await page.waitForTimeout(900);
check("mode switch lands on the homework wizard", await page.getByRole("heading", { name: "The assignment" }).isVisible());
check(
"mode switch marks the homework side active",
(await page.getByRole("group", { name: "Study deck or homework" }).locator('[aria-current="page"]').innerText()) === "Make my homework",
);
await page.getByRole("group", { name: "Study deck or homework" }).getByRole("link", { name: "Study deck" }).click();
await page.waitForTimeout(900);
check("mode switch returns to the deck wizard", await page.getByRole("heading", { name: "Your notes" }).isVisible());
await context.close();
}
/* 2b. the wizard clears the top edge on desktop ----------------------- */
/* On desktop the app header is hidden (`lg:hidden`), so each page owns its
top padding. /create was missing it and the mode switch sat flush against
the top of the window while /homework had a comfortable gap. */
{
const { context, page } = await makePage(browser, { width: 1280, height: 900 });
await seed(page, { decks: [] });
const switcherTop = async () => {
await page.waitForTimeout(700);
return page.evaluate(() => {
const switcher = document.querySelector('[role="group"][aria-label="Study deck or homework"]');
return switcher ? Math.round(switcher.getBoundingClientRect().top) : null;
});
};
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
const createTop = await switcherTop();
check("create: the mode switch clears the top edge on desktop", createTop !== null && createTop >= 24, `${createTop}px`);
await page.goto(`${BASE}/homework`, { waitUntil: "networkidle" });
const homeworkTop = await switcherTop();
check(
"create and homework keep the same clearance",
homeworkTop !== null && homeworkTop >= 24 && createTop !== null && Math.abs(homeworkTop - createTop) <= 4,
`${createTop}px vs ${homeworkTop}px`,
);
await context.close();
}
/* 3. reduced motion: bar still pinned, wizard usable ----------------- */
{
const { context, page } = await makePage(browser, { reducedMotion: "reduce" });
await seed(page, { decks: [] });
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
await sampleBar(page, "reduced motion → create");
check("reduced: step 1 usable", await page.getByRole("heading", { name: "Your notes" }).isVisible());
await page.getByRole("button", { name: "Cancel" }).click();
await page.waitForTimeout(400);
check("reduced: cancel works", await page.getByRole("heading", { name: "Your icebox" }).isVisible());
await context.close();
}
/* 4. a loading run locks the mode switch ----------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.route("**/api/generate", () => new Promise(() => {}));
await page.route("**/api/homework", () => new Promise(() => {}));
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
await page.getByRole("button", { name: "Paste text instead" }).click();
await page.locator("textarea").first().fill("Photosynthesis needs light and water.");
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300);
check("loading: mode switch is there before the run", await page.getByRole("group", { name: "Study deck or homework" }).isVisible());
await page.getByRole("button", { name: "Freeze my deck" }).click();
await page.waitForTimeout(800);
check("loading: deck run reaches the loading screen", await page.getByRole("button", { name: "Cancel" }).isVisible());
check("loading: deck run hides the mode switch", (await page.getByRole("group", { name: "Study deck or homework" }).count()) === 0);
await page.getByRole("button", { name: "Cancel" }).click();
await page.waitForTimeout(900);
check(
"loading: deck Cancel returns to the wizard (notice + retry)",
(await page.getByRole("heading", { name: "Ready to freeze" }).isVisible()) && (await page.getByRole("button", { name: "Retry" }).isVisible()),
);
check("loading: mode switch returns after the cancel", await page.getByRole("group", { name: "Study deck or homework" }).isVisible());
await page.goto(`${BASE}/homework`, { waitUntil: "networkidle" });
await page.waitForTimeout(700);
await page.getByRole("button", { name: "Paste text instead" }).click();
await page.locator("textarea").first().fill("Solve x + 2 = 4");
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300);
check("homework: AI disclaimer near the start cards", await page.getByText("Freezmo uses AI and can make mistakes").isVisible());
await page.getByRole("button", { name: /Just make it/ }).click();
await page.waitForTimeout(800);
check("loading: homework run hides the mode switch", (await page.getByRole("group", { name: "Study deck or homework" }).count()) === 0);
await page.getByRole("button", { name: "Cancel" }).click();
await page.waitForTimeout(900);
check("loading: homework Cancel returns to the wizard", await page.getByRole("heading", { name: "The assignment" }).isVisible());
await context.close();
}
/* 5. leaving mid-run aborts: no surprise deck, no navigation --------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
const deckReply = {
id: "stall",
title: "Aborted run",
subject: "Biology",
summary: "",
language: "English",
sourceText: "Photosynthesis needs light and water.",
difficulty: "medium",
activities: [{ type: "flashcard", id: "a1", xp: 10, front: "Q", back: "A", explanation: "E" }],
};
await page.route("**/api/generate", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2500));
await route
.fulfill({ status: 200, contentType: "application/x-ndjson", body: JSON.stringify({ type: "deck", deck: deckReply }) + "\n" })
.catch(() => {});
});
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
await page.getByRole("button", { name: "Paste text instead" }).click();
await page.locator("textarea").first().fill("Photosynthesis needs light and water.");
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Freeze my deck" }).click();
await page.waitForTimeout(600);
await page.locator("header").getByRole("link", { name: "Freezmo home" }).click();
await page.waitForTimeout(3500);
const decksAfter = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1") ?? "[]").length);
check(
"loading: leaving aborts the run (no deck, no navigation)",
page.url().replace(BASE, "") === "/" && decksAfter === 0,
`${page.url()} decks:${decksAfter}`,
);
await context.close();
}
await browser.close();
finish();