Bigger decks: pick up to 30 activities, built in batches

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.
This commit is contained in:
Martin Dvořák 2026-10-28 19:11:39 +00:00
commit e3b7bbbd6c
13 changed files with 345 additions and 46 deletions

View file

@ -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();