freezmo/tests/mobile-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

263 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Phone suite: five-tab bottom bar (labels at 360/390 in EN/CS, dark/light,
* reduced motion), the library ⋯ sheet, the settings sheet (drag, touch
* targets, key flow), and an axe-core contrast audit.
*/
import { BASE, DECK, axeSource, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
const box = (locator) => locator.boundingBox();
const dragDown = async (page, locator, dy = 170, steps = 9) => {
const bounds = await locator.boundingBox();
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
for (let index = 1; index <= steps; index++) {
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + (dy * index) / steps);
await page.waitForTimeout(18);
}
await page.mouse.up();
};
/* 1. empty library: header, tab bar, ⋯ sheet ------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1100);
check("no hamburger on phones", (await page.getByRole("button", { name: "Open menu" }).count()) === 0);
check("no drawer on phones", (await page.getByRole("complementary", { name: "Menu" }).count()) === 0);
const themeBtn = page.getByRole("button", { name: "Switch to light mode" });
check("theme toggle present", await themeBtn.isVisible());
const themeBox = await box(themeBtn);
check("theme toggle >= 44px", themeBox.height >= 43.5 && themeBox.width >= 43.5, `${Math.round(themeBox.width)}x${Math.round(themeBox.height)}`);
check("exactly one theme toggle on phones", (await page.getByRole("button", { name: /Switch to (light|dark) mode/ }).count()) === 1);
const tabs = page.locator("nav.tabbar");
check("tab bar visible", await tabs.isVisible());
const tabTexts = (await tabs.locator("a, button").allInnerTexts()).map((text) => text.trim());
check(
"five tabs in order",
JSON.stringify(tabTexts) === JSON.stringify(["Library", "Progress", "New deck", "Exam", "Settings"]),
JSON.stringify(tabTexts),
);
const tabBoxes = await tabs.locator("a, button").evaluateAll((els) => els.map((el) => el.getBoundingClientRect().height));
check("every tab >= 44px tall", tabBoxes.every((height) => height >= 43.5), JSON.stringify(tabBoxes.map(Math.round)));
check("empty state hides the folder pills", (await page.locator(".folder-pill").count()) === 0);
const moreBtn = page.getByRole("button", { name: "More actions" });
check("⋯ button replaces the import icon", await moreBtn.isVisible());
const moreBox = await box(moreBtn);
check("⋯ >= 44px", moreBox.height >= 43.5 && moreBox.width >= 43.5, `${Math.round(moreBox.width)}x${Math.round(moreBox.height)}`);
await moreBtn.click();
await page.waitForTimeout(500);
const sheet = page.getByRole("dialog", { name: "Library actions" });
check(
"⋯ sheet lists Import JSON + New folder",
(await sheet.isVisible()) && (await sheet.getByText("Import JSON").isVisible()) && (await sheet.getByText("New folder").isVisible()),
);
await page.keyboard.press("Escape");
let sheetClosed = true;
try {
await sheet.waitFor({ state: "detached", timeout: 2500 });
} catch {
sheetClosed = false;
}
check("⋯ sheet closes with Escape", sheetClosed);
// Empty state checks before any folder exists.
const cta = page.getByRole("link", { name: "Create study deck", exact: true });
check("dedicated primary CTA", await cta.isVisible());
const ctaBox = await box(cta);
check("CTA >= 44px tall", ctaBox.height >= 43.5, String(Math.round(ctaBox.height)));
await page.getByRole("link", { name: "Homework", exact: true }).click();
await page.waitForTimeout(700);
const hwCta = page.getByRole("link", { name: "New homework", exact: true });
check("homework quick action", await hwCta.isVisible());
await page.getByRole("link", { name: "Study Decks", exact: true }).click();
await page.waitForTimeout(700);
// Creating a folder from the ⋯ sheet brings the chips row in.
await page.getByRole("button", { name: "More actions" }).click();
await page.waitForTimeout(500);
await page.getByRole("dialog").getByText("New folder").click();
await page.waitForTimeout(400);
await page.getByRole("dialog").locator("input").fill("Biology");
await page.getByRole("dialog").getByRole("button", { name: "Add" }).click();
await page.waitForTimeout(700);
check(
"⋯ sheet creates a folder",
(await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.folders.v1")).map((f) => f.name))).includes("Biology"),
);
check("folder chips appear once folders exist", (await page.locator(".folder-pill").count()) >= 1);
await context.close();
}
/* 2. tab-bar matrix: 360/390 × EN/CS × dark/light × reduced motion --- */
for (const locale of ["en", "cs"]) {
for (const width of [360, 390]) {
for (const theme of ["dark", "light"]) {
for (const reducedMotion of ["no-preference", "reduce"]) {
const label = `${locale}/${width}/${theme}/${reducedMotion === "reduce" ? "reduced" : "motion"}`;
const { context, page } = await makePage(browser, { width, height: 800, reducedMotion });
await seed(page, { locale, theme, decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
const tabs = page.locator("nav.tabbar");
const visible = await tabs.isVisible();
const overflowX = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
const labels = await tabs.locator("a > span:last-child, button > span:last-child").evaluateAll((els) =>
els.map((el) => ({ text: el.innerText, clipped: el.scrollWidth > el.clientWidth + 1 })),
);
check(`${label}: tab bar visible, no overflow`, visible && overflowX <= 1, String(overflowX));
check(`${label}: five labels, none clipped`, labels.length === 5 && labels.every((entry) => !entry.clipped), JSON.stringify(labels.map((entry) => entry.text)));
check(`${label}: active indicator present`, (await tabs.locator('[aria-current="page"]').count()) === 1);
await context.close();
}
}
}
}
/* 3. Czech settings sheet: touch targets, key flow, drag -------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { locale: "cs", decks: [DECK], folders: [{ id: "f1", name: "Škola", createdAt: 1 }] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1100);
check("cs folder pills are 44px tall", (await box(page.locator(".folder-pill").first())).height >= 43.5);
await page.locator("nav.tabbar").getByRole("button").click();
const dialog = page.getByRole("dialog");
await dialog.waitFor({ timeout: 6000 });
await page.waitForTimeout(700);
check("cs sheet title", await dialog.getByRole("heading", { name: "Nastavení" }).isVisible());
const targets = [
["close", dialog.getByRole("button", { name: "Zavřít nastavení" })],
["Show", dialog.getByRole("button", { name: "Zobrazit" })],
["Re-check", dialog.getByRole("button", { name: "Zkontrolovat připojení" })],
["Remove key", dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" })],
["segmented", dialog.locator(".segmented > button").first()],
["privacy summary", dialog.locator("summary").first()],
];
for (const [name, locator] of targets) {
const bounds = await box(locator);
check(`${name} >= 44px`, bounds.width >= 43.5 && bounds.height >= 43.5, `${Math.round(bounds.width)}x${Math.round(bounds.height)}`);
}
check("Save hidden until the key is edited", (await dialog.getByRole("button", { name: "Uložit" }).count()) === 0);
const keyInput = dialog.locator("#ollama-api-key-phone");
await keyInput.fill("test-key2");
await page.waitForTimeout(200);
const save = dialog.getByRole("button", { name: "Uložit" });
check("Save appears after editing, >= 44px", (await save.isVisible()) && (await box(save)).height >= 43.5);
await keyInput.fill("test-key");
await page.waitForTimeout(200);
check("Save hides again when unchanged", (await dialog.getByRole("button", { name: "Uložit" }).count()) === 0);
const tip = dialog.locator("details").first();
check("privacy note starts collapsed", !(await tip.evaluate((el) => el.open)));
await tip.locator("summary").click();
await page.waitForTimeout(250);
check("privacy expander opens", await tip.evaluate((el) => el.open));
check("privacy expander mentions .env.local", await tip.getByText(".env.local").isVisible());
await tip.locator("summary").click();
await page.waitForTimeout(200);
// drag while scrolled won't dismiss, at the top it will
await page.locator(".sheet-scroll").evaluate((el) => (el.scrollTop = 120));
await page.waitForTimeout(250);
await dragDown(page, page.locator(".sheet-scroll"));
await page.waitForTimeout(700);
check("scrolled: drag snaps back, sheet stays", (await dialog.count()) === 1);
await page.locator(".sheet-scroll").evaluate((el) => (el.scrollTop = 0));
await page.waitForTimeout(250);
await dragDown(page, dialog.locator("#settings-title"));
await page.waitForTimeout(800);
check("handle+header swipe closes the sheet", (await dialog.count()) === 0);
// backdrop tap
await page.locator("nav.tabbar").getByRole("button").click();
await dialog.waitFor({ timeout: 6000 });
const sheetBox = await box(dialog);
await page.mouse.click(195, Math.max(8, sheetBox.y - 24));
await page.waitForTimeout(800);
check("backdrop tap closes", (await dialog.count()) === 0);
// remove-key confirmation
await page.locator("nav.tabbar").getByRole("button").click();
await dialog.waitFor({ timeout: 6000 });
await dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" }).click();
await page.waitForTimeout(300);
check("confirm step appears", await dialog.getByText(/vrátí se celoobrazovková/).isVisible());
await dialog.getByRole("button", { name: "Zrušit" }).click();
await page.waitForTimeout(300);
check("cancel keeps the key", await dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" }).isVisible());
await dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" }).click();
await dialog.getByRole("button", { name: "Ano, odstranit" }).click();
await page.waitForTimeout(1400);
check("confirming brings the key gate back", await page.getByRole("heading", { name: "Připoj svůj klíč Ollama" }).isVisible());
await context.close();
}
/* 4. Czech at 360px: no overflow, key field fits --------------------- */
{
const { context, page } = await makePage(browser, { width: 360, height: 740 });
await seed(page, { locale: "cs", decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1100);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
check("360px: library has no horizontal overflow", overflow <= 1, String(overflow));
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
const overflow2 = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
check("360px: settings has no horizontal overflow", overflow2 <= 1, String(overflow2));
const inputBox = await box(page.getByRole("dialog").locator("#ollama-api-key-phone"));
const panel = await box(page.getByRole("dialog"));
check("key field fits the sheet", inputBox.x >= panel.x && inputBox.x + inputBox.width <= panel.x + panel.width + 1);
await context.close();
}
/* 5. axe-core contrast audit (360/390/desktop × dark/light × en/cs) -- */
const AXE = axeSource();
async function audit(label, { width = 390, locale, theme, openSettingsNow }) {
const { context, page } = await makePage(browser, { width, height: width >= 1280 ? 900 : 800, deviceScaleFactor: width >= 1280 ? 1 : 2 });
await seed(page, { locale, theme, decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1200);
if (openSettingsNow) {
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
}
await page.addScriptTag({ content: AXE });
const report = await page.evaluate(async () => {
const result = await window.axe.run(document, { runOnly: ["color-contrast"] });
return {
violations: result.violations.map((violation) => ({
id: violation.id,
nodes: violation.nodes.map((node) => ({ target: node.target.join(" "), summary: node.failureSummary?.split("\n")[0] })),
})),
};
});
check(`axe ${label}: no contrast violations`, report.violations.length === 0, JSON.stringify(report.violations).slice(0, 300));
await context.close();
}
for (const width of [360, 390, 1280]) {
for (const theme of ["dark", "light"]) {
for (const locale of ["en", "cs"]) {
await audit(`${width} ${theme}/${locale} library`, { width, locale, theme });
}
}
}
for (const theme of ["dark", "light"]) {
for (const locale of ["en", "cs"]) {
await audit(`390 ${theme}/${locale} settings`, { width: 390, locale, theme, openSettingsNow: true });
}
}
await browser.close();
finish();