From e3b7bbbd6caf54b260b745c2f7a51ba7ad1169a3 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Wed, 28 Oct 2026 19:11:39 +0000 Subject: [PATCH] Bigger decks: pick up to 30 activities, built in batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create wizard stopped at 15 activities because the API, the prompt and the salvage path all capped at 18: one reply must be one valid JSON object, and past ~18 activities models truncate or drop cards. Raising the cap alone would have shipped decks that came back short. - The count picker offers 8 / 10 / 12 / 15 / 20 / 30; a deck's "add more" row gained +15. The help line says bigger decks take longer. - /api/generate accepts up to 30 (FREEZMO_DEFAULT_ACTIVITY_COUNT now clamps to 30 as well) and sizes its overall deadline from the number of model calls instead of a fixed two. - generateActivities splits anything above 18 evenly across calls (30 → 15 + 15, 25 → 13 + 12) and merges them into one deck. The first call keeps the "add to this deck" contract; later calls are told the title, the types already used and the topics already covered. A normalised content signature drops a card that repeats an earlier one, so the same idea never ships twice. requested/generated still travel in the stream meta. - phase1-test: a stub Ollama answers two 15-card batches (the second repeats one card on purpose) → two calls, the even split, the "already covered" prompt, 29 merged cards, the duplicate shipped once, title from the first reply, and count 31 answered 400 (59 checks). - wizard-test: the picker offers 30, the wizard sends count: 30, and the 30-card deck is stored and opened (99 checks). Version 0.8.4, SW v106. --- .env.example | 1 + README.md | 31 +++++++- app/api/generate/route.ts | 11 ++- components/DeckCard.tsx | 2 +- components/UploadScreen.tsx | 4 +- lib/ai.ts | 144 +++++++++++++++++++++++++++++------- lib/config.ts | 2 +- lib/i18n-dict.ts | 4 +- lib/prompts.ts | 11 ++- package.json | 2 +- public/sw.js | 2 +- tests/phase1-test.mjs | 110 +++++++++++++++++++++++++++ tests/wizard-test.mjs | 65 ++++++++++++++++ 13 files changed, 344 insertions(+), 45 deletions(-) diff --git a/.env.example b/.env.example index fcdce26..9a3121a 100644 --- a/.env.example +++ b/.env.example @@ -90,4 +90,5 @@ TEXT_MODEL=gpt-oss:120b-cloud # OLLAMA_HEALTH_TIMEOUT_MS=8000 # Send `think: false` to thinking models for faster JSON output. # OLLAMA_THINK=false +# Deck size when the request does not send one (6–30; the wizard offers 8–30). # FREEZMO_DEFAULT_ACTIVITY_COUNT=10 diff --git a/README.md b/README.md index d0b48c0..3c461a9 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **Turn photos of your study notes into a swipeable feed of mini study games.** Freezmo is a TikTok/Gizmo-style study app. You paste or upload photos of your notes, an AI reads -them and builds a deck of 8–15 interactive activities — flashcards, quizzes, matching games and +them and builds a deck of 8–30 interactive activities — flashcards, quizzes, matching games and more — that you swipe through vertically like a feed. Every answer gives instant feedback, XP and streaks, and the deck is saved in your browser so you can retry it later. @@ -105,7 +105,8 @@ no database, no data leaving your machine except the requests you send to Ollama - Up to 8 photos + 6 documents per deck (max 12 images reach the model); photos are resized to max 1600 px and re-encoded as JPEG before upload - Optional extra text (pasted notes) and a topic hint -- Easy / Medium / Hard difficulty and 8 / 10 / 12 / 15 activities per deck +- Easy / Medium / Hard difficulty and 8 / 10 / 12 / 15 / 20 / 30 activities per deck (a big deck + is built in several model calls — see the generation pipeline) - Fun staged loading state (`Reading your notes… → Finding the key ideas… → Building your activities…`) - Friendly error cards with a **Retry** button that point at Settings when a key is needed @@ -782,7 +783,7 @@ Limits and behaviour worth knowing: ▼ ▼ ┌──────────────────────────┐ ┌───────────────────────────┐ │ browser: study feed │ ◄───────────────────── │ TEXT_MODEL │ - │ JSON validated by zod │ deck JSON │ design 8–15 activities │ + │ JSON validated by zod │ deck JSON │ design 8–30 activities │ └──────────────────────────┘ └───────────────────────────┘ ``` @@ -794,8 +795,14 @@ Limits and behaviour worth knowing: with the validation error fed back to the model (`lib/ai.ts`). - Individual broken activities are dropped rather than failing the whole deck; if fewer than half the requested activities survive, the step is retried too. +- **Bigger decks are built in batches.** One model reply has to be one valid JSON object, and past + ~18 activities replies get truncated or lose cards, so a count above that is split across several + calls (30 → 15 + 15, 25 → 13 + 12) with the same notes. Each call after the first is told the + deck's title, the types already used and the topics already covered, and the merged deck drops + cards whose content repeats (so the same idea never ships twice). The route's overall deadline + scales with the number of calls. - Requests use `stream: false` with per-request timeouts and a hard overall deadline - (two sequential model calls). Errors surface as friendly messages + (the vision step plus one model call per batch). Errors surface as friendly messages ("Ollama isn't reachable, check your settings") with a retry button. - `POST /api/generate` answers with newline-delimited JSON (`stage` / `deck` / `error` events) so the UI can show real progress between the vision and generation steps. @@ -1513,6 +1520,22 @@ Things I chose while building, with the reasoning: into view as you type. No strings, so no i18n; the additive change sits on top of the merged scene-heat rework unchanged. +112. **Bigger decks are one request but several model calls.** The create wizard topped out at 15 + activities (`COUNT_OPTIONS`) because the API, the prompt and the salvage path all capped at 18: + one reply has to be one valid JSON object, and past that size models start truncating or + quietly dropping cards. Raising the cap alone would have shipped decks that came back short. + The picker now offers 8 / 10 / 12 / 15 / 20 / 30 (and a deck's "add more" row gained +15); the + route accepts up to 30; and `generateActivities` splits anything above 18 evenly across calls + (30 → 15 + 15), merging them into one deck. The first call keeps the existing "add to this + deck" contract; later calls are told the title, the types already used and the topics already + covered, and a normalised content signature (`activitySignature`) drops a card that repeats an + earlier one — the stub in phase 1 returns the same first card in both batches to pin exactly + that (29 from two 15-card replies). The route's overall deadline now scales with the number of + calls, and the requested/generated pair still travels in the stream's meta. Verified by the + phase-1 suite (59 checks: the two calls, the even split, the "already covered" prompt, the + merge + dedupe, and a 31-card request answered 400) and the wizard suite (99: the picker + offers 30, the wizard sends `count: 30`, and the 30-card deck is stored and opened). + 111. **The model dropdown was clipped by the card it lived in.** "The model selection in teach it back is kinda broken at least on desktop": the pickers' desktop panels were `position: absolute` children of whatever container held their chip. In chat that is the diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts index 7acbe6f..60946c1 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { extractNotes, generateActivities, notesToText, type PipelineStage } from "@/lib/ai"; +import { activityBatches, extractNotes, generateActivities, notesToText, type PipelineStage } from "@/lib/ai"; import { getServerConfig, resolveModelName } from "@/lib/config"; import { INERT_OLLAMA_AUTH, resolveModelTarget } from "@/lib/providers-server"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; @@ -26,7 +26,8 @@ const requestSchema = z.object({ images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), text: z.string().max(120_000).optional(), difficulty: difficultySchema.default("medium"), - count: z.number().int().min(4).max(18).optional(), + /** One deck; a big count is built in several model calls (lib/ai.ts). */ + count: z.number().int().min(4).max(30).optional(), focus: z.string().max(300).optional(), /** Language the deck must be written in (usually detected by the vision step). */ language: z.string().max(60).optional(), @@ -129,8 +130,10 @@ export async function POST(request: Request) { const requestedCount = count ?? config.defaultActivityCount; const encode = encoder(); const abort = new AbortController(); - // A single hard deadline for the whole pipeline (two sequential model calls). - const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2); + // A single hard deadline for the whole pipeline: the vision step (when there + // are photos) plus one model call per activity batch. + const modelCalls = activityBatches(requestedCount).length + 1; + const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * modelCalls); const onRequestAbort = () => abort.abort(); if (request.signal.aborted) abort.abort(); else request.signal.addEventListener("abort", onRequestAbort, { once: true }); diff --git a/components/DeckCard.tsx b/components/DeckCard.tsx index 3d676dd..74d329a 100644 --- a/components/DeckCard.tsx +++ b/components/DeckCard.tsx @@ -22,7 +22,7 @@ import { Button, ProgressBar } from "@/components/ui"; type Busy = null | "more" | "regenerate"; -const ADD_OPTIONS = [3, 5, 8]; +const ADD_OPTIONS = [3, 5, 8, 15]; /** * One deck in the dashboard grid: title, muted metadata, tags, summary and a diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 33d0ce5..9b2cd8a 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -83,7 +83,9 @@ const DIFFICULTIES: Array<{ value: Difficulty; labelKey: string; icon: React.Rea { value: "hard", labelKey: "upload.hard", icon: , blurbKey: "upload.hardBlurb" }, ]; -const COUNT_OPTIONS = [8, 10, 12, 15]; +/** Deck sizes the wizard offers. Above 18 the server builds the deck in + * several model calls; 30 is the largest it accepts. */ +const COUNT_OPTIONS = [8, 10, 12, 15, 20, 30]; const GENERATION_TIPS = ["upload.tip1", "upload.tip2", "upload.tip3", "upload.tip4"]; diff --git a/lib/ai.ts b/lib/ai.ts index 295b9ce..fdbba31 100644 --- a/lib/ai.ts +++ b/lib/ai.ts @@ -358,6 +358,59 @@ function validateDeck(parsed: unknown, minimum: number, requested: number) { return { ok: true as const, value: { title, subject, summary, language, activities } }; } +/** One model reply's deck shape (validated by validateDeck). */ +type ActivityDeck = { + title: string; + subject?: string; + summary?: string; + language?: string; + activities: GeneratedActivity[]; +}; + +/** The largest deck one request may ask for (the wizard offers up to 30). */ +export const MAX_ACTIVITY_COUNT = 30; +/** + * The most activities asked for in one model call. A single reply has to be + * valid JSON in one go; past this size replies get truncated or start losing + * cards, so a bigger deck is built in several calls and merged. + */ +export const ACTIVITY_BATCH_SIZE = 18; + +/** + * How to split a requested deck across model calls: as few calls as possible, + * spread evenly (30 → 15 + 15, 25 → 13 + 12, 8 → 8). Exported so the route can + * size its deadline from the number of calls. + */ +export function activityBatches(count: number): number[] { + const requested = Math.min(Math.max(Math.round(count) || 4, 4), MAX_ACTIVITY_COUNT); + const calls = Math.ceil(requested / ACTIVITY_BATCH_SIZE); + const base = Math.floor(requested / calls); + const extra = requested % calls; + return Array.from({ length: calls }, (_, index) => base + (index < extra ? 1 : 0)); +} + +/** A card's content, normalised — catches the same idea reappearing in a later + * batch (the model cannot see the earlier replies). */ +function activitySignature(activity: GeneratedActivity): string { + const normal = (value: string) => value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim(); + switch (activity.type) { + case "flashcard": + return `flashcard:${normal(activity.front)}|${normal(activity.back)}`; + case "multiple_choice": + return `multiple_choice:${normal(activity.question)}`; + case "true_false": + return `true_false:${normal(activity.statement)}`; + case "fill_blank": + return `fill_blank:${normal(activity.sentence)}`; + case "matching": + return `matching:${normal(activity.instruction)}|${activity.pairs.map((pair) => normal(pair.term)).sort().join(",")}`; + case "ordering": + return `ordering:${normal(activity.instruction)}|${activity.items.map(normal).sort().join(",")}`; + case "short_answer": + return `short_answer:${normal(activity.question)}`; + } +} + export async function generateActivities(input: { model: string; auth: OllamaAuth; @@ -374,35 +427,72 @@ export async function generateActivities(input: { deck: { title: string; subject?: string; summary?: string; language?: string; activities: GeneratedActivity[] }; requested: number; }> { - const requested = Math.min(Math.max(input.count, 4), 18); + const batches = activityBatches(input.count); + const requested = batches.reduce((total, size) => total + size, 0); + const activities: GeneratedActivity[] = []; + /** Cards already produced, by content — a later batch must add new ones. */ + const seen = new Set(); + const types = new Set(input.existing?.types ?? []); + let deck: ActivityDeck | null = null; - const { value } = await runJsonStep<{ - title: string; - subject?: string; - summary?: string; - language?: string; - activities: GeneratedActivity[]; - }>({ - model: input.model, - auth: input.auth, - provider: input.provider, - system: GENERATION_SYSTEM_PROMPT, - user: buildGenerationUserPrompt({ - notes: input.notes, - difficulty: input.difficulty, - count: requested, - language: input.language, - existing: input.existing, - focus: input.focus, - }), - validate: (parsed) => validateDeck(parsed, Math.max(4, Math.ceil(requested * 0.5)), requested), - shapeHint: GENERATION_SHAPE_HINT, - label: "Building your activities", - signal: input.signal, - temperature: 0.7, - }); + for (let index = 0; index < batches.length; index += 1) { + /* The first call continues an existing deck when the user asked for "more"; + later calls already have this run's cards, so the prompt tells the model + which material and types are taken. */ + const previous = + index === 0 + ? input.existing + : { + title: deck?.title ?? input.existing?.title ?? "Your study deck", + types: [...types], + count: activities.length, + topics: [...new Set(activities.map((activity) => activity.topic).filter((topic): topic is string => Boolean(topic)))], + }; + const step = await runJsonStep({ + model: input.model, + auth: input.auth, + provider: input.provider, + system: GENERATION_SYSTEM_PROMPT, + user: buildGenerationUserPrompt({ + notes: input.notes, + difficulty: input.difficulty, + count: batches[index], + language: input.language ?? deck?.language, + existing: previous, + focus: input.focus, + }), + validate: (parsed) => validateDeck(parsed, Math.max(4, Math.ceil(batches[index] * 0.5)), batches[index]), + shapeHint: GENERATION_SHAPE_HINT, + label: "Building your activities", + signal: input.signal, + temperature: 0.7, + }); + const value: ActivityDeck = step.value; - return { deck: value, requested }; + if (!deck) deck = value; + for (const activity of value.activities) { + const signature = activitySignature(activity); + if (seen.has(signature)) continue; + seen.add(signature); + activities.push(activity); + types.add(activity.type); + } + if (activities.length >= requested) break; + /* Enough cards to stop early: a batch that repeats itself is not going to + invent new material, and every extra call costs the user time. */ + if (value.activities.length === 0) break; + } + + return { + deck: { + title: deck?.title ?? "Your study deck", + subject: deck?.subject, + summary: deck?.summary, + language: deck?.language, + activities: activities.slice(0, requested), + }, + requested, + }; } /* ------------------------------------------------------------------ */ diff --git a/lib/config.ts b/lib/config.ts index 565fd0e..afd980a 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -159,7 +159,7 @@ export function getServerConfig(): ServerConfig { healthTimeoutMs: Number.parseInt(process.env.OLLAMA_HEALTH_TIMEOUT_MS ?? "", 10) || 8_000, think, - defaultActivityCount: Number.isFinite(count) ? Math.min(Math.max(count, 6), 20) : 10, + defaultActivityCount: Number.isFinite(count) ? Math.min(Math.max(count, 6), 30) : 10, rateLimits: { windowMs: positiveInt(process.env.RATE_LIMIT_WINDOW_MS, 300_000), generate: positiveInt(process.env.RATE_LIMIT_GENERATE, 12), diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index 6635485..c1f50c5 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -502,7 +502,7 @@ const en: Record = { "upload.phone.summary": "Ready to freeze", "upload.phone.summaryDesc": "Check what goes in, then freeze.", "upload.phone.difficultyHelp": "How deep the questions go.", - "upload.phone.activitiesHelp": "How many activities to build.", + "upload.phone.activitiesHelp": "How many activities to build — bigger decks take longer.", "upload.phone.folderHelp": "Where the deck lands in your library.", "upload.nothingAdded": "Nothing added yet", "upload.summary.files": "Files", @@ -1663,7 +1663,7 @@ const cs: Record = { "upload.phone.summary": "Připraveno ke zmrazení", "upload.phone.summaryDesc": "Zkontroluj, co se použije, a zmraz.", "upload.phone.difficultyHelp": "Jak hluboko otázky jdou.", - "upload.phone.activitiesHelp": "Kolik aktivit se vytvoří.", + "upload.phone.activitiesHelp": "Kolik aktivit se vytvoří — větší sady trvají déle.", "upload.phone.folderHelp": "Kam se sada v knihovně zařadí.", "upload.nothingAdded": "Zatím nic přidáno", "upload.summary.files": "Soubory", diff --git a/lib/prompts.ts b/lib/prompts.ts index bae2b90..cc3edba 100644 --- a/lib/prompts.ts +++ b/lib/prompts.ts @@ -138,8 +138,9 @@ export type GenerationRequest = { count: number; /** Language the output must be written in (from the vision step or detected). */ language?: string; - /** Set when adding more activities to an existing deck. */ - existing?: { title: string; types: string[]; count: number }; + /** Set when adding more activities to an existing deck (or to the part of a + * big deck the earlier model calls already built). */ + existing?: { title: string; types: string[]; count: number; topics?: string[] }; /** Extra instructions from the student (topic hint). */ focus?: string; }; @@ -159,7 +160,11 @@ export function buildGenerationUserPrompt({ : "LANGUAGE: detect the language of the notes below and write everything in that same language.", focus ? `The student especially wants to practise: ${focus}` : "", existing - ? `This is an addition to the existing deck "${existing.title}" which already has ${existing.count} activities (types used so far: ${existing.types.join(", ") || "none"}). Generate ${count} NEW activities on material that was not already covered, and prefer types that are underrepresented.` + ? `This is an addition to the existing deck "${existing.title}" which already has ${existing.count} activities (types used so far: ${existing.types.join(", ") || "none"}).${ + existing.topics?.length + ? ` Topics already covered: ${existing.topics.slice(0, 12).join("; ")} — leave them alone.` + : "" + } Generate ${count} NEW activities on material that was not already covered, and prefer types that are underrepresented.` : "", "", "=== STUDENT NOTES (the only allowed source of facts) ===", diff --git a/package.json b/package.json index 3e1f8fc..4b5d73e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.8.3", + "version": "0.8.4", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/sw.js b/public/sw.js index 7c7182e..56d7c14 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v105"; +const CACHE_VERSION = "freezmo-v106"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/phase1-test.mjs b/tests/phase1-test.mjs index f84c5c4..985765c 100644 --- a/tests/phase1-test.mjs +++ b/tests/phase1-test.mjs @@ -500,6 +500,116 @@ async function openSettings(page) { } } +/* 9b. big decks: built in batches, merged and deduplicated ----------- */ +{ + check("batches: app port free", !(await portInUse(3216))); + const promptBodies = []; + const batchReply = (batch) => ({ + title: "Big deck", + language: "English", + summary: "Two batches of the same notes.", + activities: Array.from({ length: 15 }, (_, index) => ({ + type: "flashcard", + topic: `Topic ${batch}`, + front: `BATCH-${batch}-Q${index}`, + back: `BATCH-${batch}-A${index}`, + explanation: "One gap in the explanation.", + })), + }); + const stub = http.createServer((request, response) => { + if (request.url?.startsWith("/api/tags")) { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] })); + return; + } + let raw = ""; + request.on("data", (chunk) => (raw += chunk)); + request.on("end", () => { + let body = {}; + try { + body = JSON.parse(raw); + } catch { + /* keep the default */ + } + promptBodies.push(body); + const reply = batchReply(promptBodies.length); + /* The second call repeats one card on purpose: the merge must not ship + the same card twice. */ + if (promptBodies.length === 2) { + reply.activities[0] = { + type: "flashcard", + topic: "Topic 1", + front: "BATCH-1-Q0", + back: "BATCH-1-A0", + explanation: "One gap in the explanation.", + }; + } + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ message: { content: JSON.stringify(reply) } })); + }); + }); + await new Promise((resolve) => stub.listen(12003, "127.0.0.1", resolve)); + + const app = spawnApp(3216, { + OLLAMA_BASE_URL: "http://127.0.0.1:12003", + OLLAMA_AUTH: "local", + OLLAMA_VERIFY_KEY: "false", + }); + + try { + const up = await waitForHttp("http://127.0.0.1:3216/api/health"); + check("batches: stub app is up", up); + if (up) { + const response = await fetch("http://127.0.0.1:3216/api/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "The sky is blue because molecules scatter light.", difficulty: "easy", count: 30 }), + }); + const events = (await response.text()) + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + const deck = events.find((event) => event.type === "deck")?.deck; + const meta = events.find((event) => event.type === "deck")?.meta; + const activities = deck?.activities ?? []; + const prompts = promptBodies.map((body) => String(body.messages?.[1]?.content ?? "")); + check("batches: a deck of 30 is built in two model calls", promptBodies.length === 2, `calls: ${promptBodies.length}`); + check( + "batches: each call asks for an even share", + prompts[0]?.includes("Create a study deck with 15 activities") && prompts[1]?.includes("Create a study deck with 15 activities"), + prompts.map((prompt) => prompt.slice(0, 60)).join(" | "), + ); + check( + "batches: the second call is told what is already covered", + prompts[1]?.includes("addition to the existing deck") && + prompts[1]?.includes("Topics already covered") && + prompts[1]?.includes("Topic 1"), + ); + check("batches: both batches are merged", activities.length === 29, `activities: ${activities.length}`); + check( + "batches: a repeated card is shipped once", + activities.filter((activity) => activity.front === "BATCH-1-Q0").length === 1, + ); + check( + "batches: the deck keeps the first reply's title and the requested count", + deck?.title === "Big deck" && meta?.requested === 30 && meta?.generated === 29, + JSON.stringify({ title: deck?.title, meta }), + ); + + const tooMany = await fetch("http://127.0.0.1:3216/api/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "The sky is blue.", difficulty: "easy", count: 31 }), + }); + check("batches: more than 30 in one deck → 400", tooMany.status === 400, String(tooMany.status)); + } + } finally { + app.stop(); + stub.close(); + } +} + /* 10. reports: appended as JSON lines, write-only over HTTP --------- */ { check("reports: app port free", !(await portInUse(3211))); diff --git a/tests/wizard-test.mjs b/tests/wizard-test.mjs index 9e3e6b7..3c61057 100644 --- a/tests/wizard-test.mjs +++ b/tests/wizard-test.mjs @@ -305,5 +305,70 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 } await context.close(); } +/* 6. bigger decks: the count picker goes up to 30 -------------------- */ +{ + const { context, page } = await makePage(browser); + await seed(page, { decks: [] }); + let request = null; + const reply = { + id: "big", + title: "Big deck", + subject: "Biology", + summary: "Thirty cards.", + language: "English", + sourceText: "Photosynthesis needs light and water.", + difficulty: "medium", + activities: Array.from({ length: 30 }, (_, index) => ({ + type: "flashcard", + id: `big-${index}`, + xp: 10, + topic: "Photosynthesis", + front: `Question ${index}`, + back: `Answer ${index}`, + explanation: "One gap in the explanation.", + })), + }; + await page.route("**/api/generate", async (route) => { + request = JSON.parse(route.request().postData() ?? "{}"); + await route.fulfill({ + status: 200, + contentType: "application/x-ndjson", + body: JSON.stringify({ type: "deck", deck: reply }) + "\n", + }); + }); + + await page.goto(`${BASE}/create`, { waitUntil: "networkidle" }); + await page.waitForTimeout(600); + 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(400); + + const sizes = ["8", "10", "12", "15", "20", "30"]; + const offered = []; + for (const size of sizes) { + offered.push((await page.getByRole("button", { name: size, exact: true }).count()) >= 1); + } + check("count: the picker offers up to 30", offered.every(Boolean), JSON.stringify(sizes.map((size, index) => `${size}:${offered[index]}`))); + + await page.getByRole("button", { name: "30", exact: true }).click(); + check( + "count: 30 reads as the chosen size", + (await page.getByRole("button", { name: "30", exact: true }).getAttribute("aria-pressed")) === "true", + ); + await page.getByRole("button", { name: "Next" }).click(); + await page.waitForTimeout(300); + await page.getByRole("button", { name: "Freeze my deck" }).click(); + await page.waitForTimeout(1600); + check("count: the wizard sends the chosen size", request?.count === 30, JSON.stringify({ count: request?.count })); + const decks = await page.evaluate(() => JSON.parse(window.localStorage.getItem("freezmo.decks.v1") ?? "[]")); + check( + "count: a 30-card deck is stored and opened", + (decks[0]?.activities ?? []).length === 30 && /\/deck\//.test(page.url()), + `activities: ${decks[0]?.activities?.length} url: ${page.url()}`, + ); + await context.close(); +} + await browser.close(); finish();