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