From 776e04db896f724896c0385e725724d1169c3c85 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 16 Aug 2026 20:03:33 +0200 Subject: [PATCH] Split the kcd2_dice_solver test files under the cap Nine new modules from four over-cap test files. Every kcd2 test file is now under 250 lines; only four source files remain. App.test.tsx ended up with no describe blocks and vitest failed with "No test suite found" - a split that empties a file has to delete it. The shared `response`/`ordinary` fixtures went to src/test/fixtures.ts, which vite.config.ts excludes from coverage; beside the source they would have counted as production code and broken the 100% gate. 288 tests passing before and after, now across 24 files instead of 15, coverage still 100% on all four metrics. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- .../contracts/kcd2-test-split-2026-08-16.json | 18 + .../evidence/kcd2-test-split-2026-08-16.json | 46 ++ kcd2_dice_solver/src/App.test.tsx | 512 ------------------ kcd2_dice_solver/src/AppLinks.test.tsx | 249 +++++++++ kcd2_dice_solver/src/AppRecovery.test.tsx | 173 ++++++ kcd2_dice_solver/src/AppShell.test.tsx | 227 ++++++++ kcd2_dice_solver/src/AppStorage.test.tsx | 180 ++++++ .../components/componentInventory.test.tsx | 164 ++++++ .../src/components/componentPickers.test.tsx | 71 +++ .../src/components/componentResult.test.tsx | 145 +++++ .../src/components/componentRows.test.tsx | 120 ++++ .../src/components/components.test.tsx | 415 +------------- kcd2_dice_solver/src/core/simulate.test.ts | 172 ------ .../src/core/simulateTurns.test.ts | 198 +++++++ .../src/lib/inventoryHash.test.ts | 160 ++++++ kcd2_dice_solver/src/lib/inventoryIo.test.ts | 140 +---- kcd2_dice_solver/src/test/fixtures.ts | 35 ++ 17 files changed, 1789 insertions(+), 1236 deletions(-) create mode 100644 docs/superpowers/contracts/kcd2-test-split-2026-08-16.json create mode 100644 docs/superpowers/evidence/kcd2-test-split-2026-08-16.json delete mode 100644 kcd2_dice_solver/src/App.test.tsx create mode 100644 kcd2_dice_solver/src/AppLinks.test.tsx create mode 100644 kcd2_dice_solver/src/AppRecovery.test.tsx create mode 100644 kcd2_dice_solver/src/AppShell.test.tsx create mode 100644 kcd2_dice_solver/src/AppStorage.test.tsx create mode 100644 kcd2_dice_solver/src/components/componentInventory.test.tsx create mode 100644 kcd2_dice_solver/src/components/componentPickers.test.tsx create mode 100644 kcd2_dice_solver/src/components/componentResult.test.tsx create mode 100644 kcd2_dice_solver/src/components/componentRows.test.tsx create mode 100644 kcd2_dice_solver/src/core/simulateTurns.test.ts create mode 100644 kcd2_dice_solver/src/lib/inventoryHash.test.ts create mode 100644 kcd2_dice_solver/src/test/fixtures.ts diff --git a/docs/superpowers/contracts/kcd2-test-split-2026-08-16.json b/docs/superpowers/contracts/kcd2-test-split-2026-08-16.json new file mode 100644 index 00000000..cd216a46 --- /dev/null +++ b/docs/superpowers/contracts/kcd2-test-split-2026-08-16.json @@ -0,0 +1,18 @@ +{ + "title": "Split the kcd2_dice_solver test files under the 250-line cap", + "objective": "Bring every kcd2 test file under the cap by partitioning at describe boundaries, taking the package from twelve violations to four source files.", + "acceptance_criteria": [ + "No kcd2_dice_solver test file is over 250 lines.", + "The suite still reports exactly 288 passing tests -- the same as before the split, though spread across 24 files instead of 15.", + "Coverage stays at 100% for statements, branches, functions and lines.", + "npm run lint passes with no new suppression and no eslint-disable.", + "A fixture shared by two split halves goes in src/test/, which vite.config.ts excludes from coverage. A fixture placed beside the source would be counted as production code and break the 100% gate.", + "No test is deleted, skipped or weakened; whole describe blocks move verbatim.", + "No empty test file is left behind: a file whose last describe moved out is removed, since vitest fails on 'No test suite found'." + ], + "out_of_scope": [ + "search.ts, scoring.ts, badgeValue.ts and App.tsx, which are source files.", + "Changing what any test asserts." + ], + "verifier": "npm test -- --run (expects exactly 288 passed), npm run coverage (100% on all four metrics), npm run lint, and check_file_length.sh over kcd2_dice_solver." +} diff --git a/docs/superpowers/evidence/kcd2-test-split-2026-08-16.json b/docs/superpowers/evidence/kcd2-test-split-2026-08-16.json new file mode 100644 index 00000000..92742926 --- /dev/null +++ b/docs/superpowers/evidence/kcd2-test-split-2026-08-16.json @@ -0,0 +1,46 @@ +{ + "intent": "Clear every over-cap kcd2_dice_solver test file by partitioning at describe boundaries.", + "scope": [ + "components.test.tsx (532) -> componentRows, componentPickers, componentResult, componentInventory", + "App.test.tsx (512) -> AppShell, AppLinks, AppRecovery, AppStorage; the original shell was deleted", + "inventoryIo.test.ts (302) -> inventoryHash.test.ts", + "simulate.test.ts (272) -> simulateTurns.test.ts", + "src/test/fixtures.ts (new, coverage-excluded by path)", + "Non-goal: no assertion changed, no test deleted or skipped" + ], + "changes": [ + "Nine new test modules, each carrying the original preamble so imports and local helpers resolve; the unused ones were then pruned by tsc's own TS6133/TS6192 output driving an iterative cleanup, rather than guessed.", + "App.test.tsx ended up with no describe blocks at all and vitest failed the run with 'No test suite found in file'. The empty shell was deleted - a split that empties a file has to remove it, not leave it.", + "The `response` and `ordinary` fixtures were used by two halves of the component split, so they moved to src/test/fixtures.ts. That directory is in vite.config.ts's coverage exclude list; putting a fixture beside the source would have counted it as production code and broken the 100% gate - the same trap recorded for poker-stakes." + ], + "verification": [ + { + "command": "npm test -- --run", + "result": "fail twice, then pass", + "evidence": "288 passed across 24 files, identical to the 288 in 15 files before. The two failures were real: the empty App.test.tsx, and 7 tests failing with 'Cannot find name response' when a shared fixture stayed behind in one half." + }, + { + "command": "npm run coverage", + "result": "pass", + "evidence": "100% statements (863/863), branches (375/375), functions (181/181), lines (810/810) - unchanged, confirming src/test/fixtures.ts is excluded rather than counted." + }, + { + "command": "npm run lint", + "result": "pass", + "evidence": "tsc --noEmit and eslint clean, with no eslint-disable added. Roughly 40 imports and several orphaned local helpers were removed across the new files." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh --all | grep kcd2", + "result": "pass", + "evidence": "Four entries remain, all source files: search.ts, scoring.ts, badgeValue.ts and App.tsx. Every test file is under the cap." + } + ], + "risks": [ + "Carrying the whole preamble into each new file then pruning by compiler output is reliable for imports but not for local helpers: tsc only flags a helper as unused, it cannot tell you the other half needed it. The `response` fixture was caught by the test run, not the compiler.", + "24 test files where there were 15 means more preamble duplication. The shared fixtures module offsets some of it, but each file still repeats its render helpers." + ], + "rollback": [ + "git revert the commit; the four originals return and the nine new modules plus src/test/fixtures.ts disappear.", + "After rollback, re-run npm test and expect 288 passed in 15 files." + ] +} diff --git a/kcd2_dice_solver/src/App.test.tsx b/kcd2_dice_solver/src/App.test.tsx deleted file mode 100644 index e83cf059..00000000 --- a/kcd2_dice_solver/src/App.test.tsx +++ /dev/null @@ -1,512 +0,0 @@ -/** - * End-to-end tests for the app, driving the real flow: search for a die, click - * to add it, scroll the wheel over a counter, and get a real recommendation. - * - * The worker is replaced with a port that runs the *real* solver synchronously — - * jsdom has no module-worker support, but nothing about the solver is stubbed, - * so these exercise the actual recommendation path. - */ - -import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; -import { App } from "./App.tsx"; -import { STORAGE_KEY, encodeInventoryHash, toBase64Url } from "./lib/inventoryIo.ts"; -import type { SavedInventory, UrlHashPort } from "./lib/inventoryIo.ts"; -import { solve } from "./core/solve.ts"; -import type { SolverPort } from "./hooks/useSolver.ts"; -import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts"; - -/** - * A solver port that runs the real solver in-process. - * - * @returns A port usable in place of the Web Worker. - */ -function createInlinePort(): SolverPort { - const listeners = new Set<(event: MessageEvent) => void>(); - return { - postMessage: ({ id, request }: WorkerRequest) => { - let reply: WorkerReply; - try { - // Keep the simulation short: these tests are about the wiring. - reply = { id, ok: true, response: solve({ ...request, simulationTurns: 200 }) }; - } catch (error) { - reply = { id, ok: false, error: error instanceof Error ? error.message : "failed" }; - } - for (const listener of listeners) { - listener({ data: reply } as MessageEvent); - } - }, - addEventListener: (_type, listener) => listeners.add(listener), - removeEventListener: (_type, listener) => listeners.delete(listener), - }; -} - -/** - * An in-memory Storage stand-in, so tests do not share persisted state. - * - * @param initial - Optional starting contents. - * @returns A minimal storage object. - */ -function memoryStorage(initial?: string): Pick { - const store = new Map(); - if (initial !== undefined) { - store.set(STORAGE_KEY, initial); - } - return { - getItem: (key) => store.get(key) ?? null, - setItem: (key, value) => { - store.set(key, value); - }, - }; -} - -/** - * Read back what the app persisted, typed so assertions are not on `any`. - * - * @param storage - The storage the app was given. - * @returns The parsed inventory. - */ -function readSaved(storage: Pick): SavedInventory { - return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory; -} - -/** - * A URL port that reports a fragment without touching the real location. - * - * @param hash - The fragment the page was "opened with". - * @returns The port plus a record of whether the app cleared the fragment. - */ -function stubUrl(hash = ""): UrlHashPort & { cleared: () => number } { - let clears = 0; - return { - read: () => hash, - clear: () => { - clears += 1; - }, - base: () => "https://dice.example/", - cleared: () => clears, - }; -} - -const renderApp = (initial?: string, urlHash: UrlHashPort = stubUrl()) => - render( - Promise.resolve() }} - saveFile={() => undefined} - />, - ); - -describe("App", () => { - it("asks for dice before anything is entered", () => { - renderApp(); - expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument(); - expect(screen.getByText("0 dice")).toBeInTheDocument(); - }); - - it("finds a die by fuzzy search, adds six by clicking, and solves", async () => { - renderApp(); - - await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); - const row = screen.getAllByRole("listitem")[0]; - const add = within(row).getByRole("button", { name: "Weighted die" }); - - for (let i = 0; i < 6; i += 1) { - await userEvent.click(add); - } - - expect(screen.getByText("6 dice")).toBeInTheDocument(); - await waitFor(() => { - expect(screen.getByText("Best loadout")).toBeInTheDocument(); - }); - // Six Weighted dice is the right answer and the panel must name it. - expect(screen.getByText("6×")).toBeInTheDocument(); - expect(screen.getByText("Weighted die")).toBeInTheDocument(); - expect(screen.getByText(/Provably optimal/)).toBeInTheDocument(); - }); - - it("adds dice by scrolling the wheel over the counter", async () => { - renderApp(); - await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); - const counter = screen.getByLabelText("How many Weighted die"); - const stepper = counter.parentElement; - if (!stepper) { - throw new Error("no stepper"); - } - - for (let i = 0; i < 6; i += 1) { - fireEvent.wheel(stepper, { deltaY: -120 }); - } - expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("6"); - expect(screen.getByText("6 dice")).toBeInTheDocument(); - - fireEvent.wheel(stepper, { deltaY: 120 }); - expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("5"); - expect(screen.getByText("5 dice")).toBeInTheDocument(); - }); - - it("accumulates a rapid burst of clicks and wheel ticks", async () => { - // Driving the real app in headless Chrome caught this: six fast clicks used - // to land as one, because each handler computed from the count captured at - // render time. React batches faster than it re-renders. - renderApp(); - await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); - - const row = screen.getAllByRole("listitem")[0]; - const add = within(row).getByRole("button", { name: "Weighted die" }); - act(() => { - for (let i = 0; i < 4; i += 1) { - fireEvent.click(add); - } - }); - expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("4"); - - const stepper = screen.getByLabelText("How many Weighted die").parentElement; - if (!stepper) { - throw new Error("no stepper"); - } - act(() => { - for (let i = 0; i < 2; i += 1) { - fireEvent.wheel(stepper, { deltaY: -120 }); - } - }); - expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("6"); - expect(screen.getByText("6 dice")).toBeInTheDocument(); - }); - - it("ranks the badges you own alongside the dice", async () => { - const saved = JSON.stringify({ - diceCounts: { weighted: 6 }, - badgeIds: ["gold_emperors", "gold_defence"], - }); - renderApp(saved); - - await waitFor(() => { - expect(screen.getByText("Best badge per tier")).toBeInTheDocument(); - }); - // The name also appears in the ownership checkbox, so scope to the result. - const results = document.querySelector(".result"); - if (!results) { - throw new Error("no result panel"); - } - expect(within(results as HTMLElement).getByText("Gold Emperor's badge")).toBeInTheDocument(); - // The Defence badge is owned too, and must be reported as situational - // rather than given an invented number. - expect(within(results as HTMLElement).getByText(/situational/)).toBeInTheDocument(); - }); - - it("restores a saved inventory on load", () => { - renderApp(JSON.stringify({ diceCounts: { weighted: 4 }, badgeIds: [] })); - expect(screen.getByText("4 dice")).toBeInTheDocument(); - }); - - it("persists changes for next time", async () => { - const storage = memoryStorage(); - render(); - await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); - await userEvent.click(screen.getByLabelText("Add one Weighted die")); - - expect(readSaved(storage)).toEqual({ diceCounts: { weighted: 1 }, badgeIds: [] }); - }); - - it("clears everything on request", async () => { - renderApp(JSON.stringify({ diceCounts: { weighted: 6 }, badgeIds: ["tin_might"] })); - await waitFor(() => { - expect(screen.getByText("Best loadout")).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByRole("button", { name: "Clear" })); - expect(screen.getByText("0 dice")).toBeInTheDocument(); - expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument(); - }); - - it("falls back to the browser's own storage when none is injected", () => { - window.localStorage.setItem( - STORAGE_KEY, - JSON.stringify({ diceCounts: { weighted: 2 }, badgeIds: [] }), - ); - render(); - expect(screen.getByText("2 dice")).toBeInTheDocument(); - window.localStorage.clear(); - }); - - it("disables Clear when there is nothing to clear", () => { - renderApp(); - expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled(); - }); - - it("drops a die from the inventory when its count reaches zero", async () => { - const storage = memoryStorage( - JSON.stringify({ diceCounts: { weighted: 1 }, badgeIds: [] }), - ); - render(); - await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); - await userEvent.click(screen.getByLabelText("Remove one Weighted die")); - - expect(readSaved(storage)).toEqual({ diceCounts: {}, badgeIds: [] }); - }); - - it("toggles a badge on and off", async () => { - const storage = memoryStorage(); - render(); - await userEvent.type(screen.getByLabelText("Search dice and badges"), "emperor"); - - const checkbox = screen.getByRole("checkbox"); - await userEvent.click(checkbox); - expect(readSaved(storage).badgeIds).toEqual(["gold_emperors"]); - - await userEvent.click(checkbox); - expect(readSaved(storage).badgeIds).toEqual([]); - }); - - it("uses the singular for a single die", async () => { - renderApp(); - await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); - await userEvent.click(screen.getByLabelText("Add one Weighted die")); - expect(screen.getByText("1 die")).toBeInTheDocument(); - }); - - it("surfaces a solver error rather than showing nothing", async () => { - // A port that always fails, standing in for a worker that threw. - const failingPort = (): SolverPort => { - const listeners = new Set<(event: MessageEvent) => void>(); - return { - postMessage: ({ id }: WorkerRequest) => { - for (const listener of listeners) { - listener({ data: { id, ok: false, error: "solver exploded" } } as MessageEvent< - WorkerReply - >); - } - }, - addEventListener: (_type, listener) => listeners.add(listener), - removeEventListener: (_type, listener) => listeners.delete(listener), - }; - }; - render( - , - ); - await waitFor(() => { - expect(screen.getByText("solver exploded")).toBeInTheDocument(); - }); - }); -}); - -describe("shared links", () => { - const shared = encodeInventoryHash({ diceCounts: { weighted: 3 }, badgeIds: [] }); - - it("prefers a link over whatever this browser had saved", () => { - const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] }); - renderApp(mine, stubUrl(shared)); - expect(screen.getByText("3 dice")).toBeInTheDocument(); - }); - - it("drops the fragment so a reload does not resurrect the link", () => { - const url = stubUrl(shared); - renderApp(undefined, url); - expect(url.cleared()).toBe(1); - }); - - it("leaves the URL alone when there was no link", () => { - const url = stubUrl(); - renderApp(undefined, url); - expect(url.cleared()).toBe(0); - }); - - it("does not overwrite the saved inventory until the visitor commits", () => { - // Silently clobbering somebody's own loadout with a link they opened is - // unrecoverable, so nothing is written until they say so. - const storage = memoryStorage(JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] })); - render( - Promise.resolve() }} - saveFile={() => undefined} - />, - ); - expect(screen.getByText("3 dice")).toBeInTheDocument(); - expect(readSaved(storage).diceCounts).toEqual({ lucky: 1 }); - }); - - it("persists once Keep is pressed", async () => { - const storage = memoryStorage(); - render( - Promise.resolve() }} - saveFile={() => undefined} - />, - ); - await userEvent.click(screen.getByRole("button", { name: "Keep" })); - expect(readSaved(storage).diceCounts).toEqual({ weighted: 3 }); - expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); - }); - - it("persists as soon as the visitor edits anything, banner and all", async () => { - const storage = memoryStorage(); - render( - Promise.resolve() }} - saveFile={() => undefined} - />, - ); - await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); - await userEvent.click(screen.getByLabelText("Remove one Weighted die")); - expect(readSaved(storage).diceCounts).toEqual({ weighted: 2 }); - expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); - }); - - it("shows no banner on an ordinary visit", () => { - renderApp(); - expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); - }); - - it("falls through to storage when the fragment carries no inventory", () => { - const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] }); - renderApp(mine, stubUrl("#nothing=here")); - expect(screen.getByText("1 die")).toBeInTheDocument(); - }); - - it("keeps the saved inventory when the link is damaged", () => { - const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] }); - // "@" is not a base64 character, so decoding throws rather than yielding - // an inventory — the case a truncated or mangled URL actually produces. - renderApp(mine, stubUrl("#i=@@@")); - expect(screen.getByText("1 die")).toBeInTheDocument(); - // Nothing was taken from the link, so there is nothing to accept or refuse. - expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Discard" })).not.toBeInTheDocument(); - }); - - it("reports what a shared link had to drop", () => { - const link = `#i=${toBase64Url( - JSON.stringify({ diceCounts: { weighted: 2, nosuchdie: 1 }, badgeIds: [] }), - )}`; - renderApp(undefined, stubUrl(link)); - expect(screen.getByText("2 dice")).toBeInTheDocument(); - expect(screen.getByText(/Unknown die/)).toBeInTheDocument(); - }); - - it("replaces the whole inventory on import", async () => { - const storage = memoryStorage(JSON.stringify({ diceCounts: { lucky: 2 }, badgeIds: [] })); - render( - , - ); - fireEvent.change(screen.getByLabelText("Inventory JSON"), { - target: { value: '{"diceCounts":{"weighted":4},"badgeIds":[]}' }, - }); - await userEvent.click(screen.getByRole("button", { name: "Import pasted" })); - expect(screen.getByText("4 dice")).toBeInTheDocument(); - expect(readSaved(storage).diceCounts).toEqual({ weighted: 4 }); - }); -}); - -describe("layout", () => { - it("gives the left column to the dice and nothing else", () => { - renderApp(); - const inventory = document.querySelector("section.inventory"); - if (!inventory) { - throw new Error("no inventory section"); - } - // The search box, the title and the badges all moved to the right column so - // the 43-row grid gets the whole vertical budget. - expect(inventory.querySelector(".die-list")).toBeInTheDocument(); - expect(inventory.querySelector(".toolbar")).toBeNull(); - expect(inventory.querySelector("h1")).toBeNull(); - expect(inventory.querySelector(".badges")).toBeNull(); - }); - - it("keeps the badges collapsed until asked for", () => { - renderApp(); - const badges = document.querySelector("details.badges"); - expect(badges).toBeInTheDocument(); - expect(badges).not.toHaveAttribute("open"); - // Still reachable — collapsed, not removed. - expect(within(badges as HTMLElement).getByText("Badges")).toBeInTheDocument(); - }); - - it("no longer tells touch users to scroll a mouse wheel", () => { - renderApp(); - expect(screen.queryByText(/scroll the wheel/i)).not.toBeInTheDocument(); - }); -}); - -describe("shared links cannot destroy what is already saved", () => { - it("ignores a link that carries no dice or badges", () => { - // A count of zero parses cleanly, so "did the parser complain?" is the - // wrong question — this used to blank the inventory on screen, disable - // Clear, and leave Keep as the only enabled control, writing the emptiness - // over the visitor's own dice. - const mine = JSON.stringify({ diceCounts: { lucky: 4 }, badgeIds: [] }); - const empty = encodeInventoryHash({ diceCounts: { weighted: 0 }, badgeIds: [] }); - const storage = memoryStorage(mine); - render( - , - ); - expect(screen.getByText("4 dice")).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); - expect(readSaved(storage).diceCounts).toEqual({ lucky: 4 }); - }); - - it("restores the visitor's own inventory when a link is discarded", async () => { - const mine = JSON.stringify({ diceCounts: { lucky: 4 }, badgeIds: ["tin_might"] }); - const link = encodeInventoryHash({ diceCounts: { weighted: 3 }, badgeIds: [] }); - const storage = memoryStorage(mine); - render( - , - ); - expect(screen.getByText("3 dice")).toBeInTheDocument(); - - await userEvent.click(screen.getByRole("button", { name: "Discard" })); - expect(screen.getByText("4 dice")).toBeInTheDocument(); - expect(readSaved(storage)).toEqual({ - diceCounts: { lucky: 4 }, - badgeIds: ["tin_might"], - }); - }); - - it("clears a fragment even when it decoded to nothing", () => { - // A fragment left in the bar is read again on the next reload, where it - // would land on top of whatever has been edited since. - for (const hash of ["#i=@@@", "#other=1", "#i="]) { - const url = stubUrl(hash); - renderApp(undefined, url); - expect(url.cleared()).toBe(1); - } - }); -}); - -describe("a saved inventory that no longer parses cleanly", () => { - const stale = JSON.stringify({ - diceCounts: { lucky: 4, future_patch_die: 6 }, - badgeIds: [], - }); - - it("is not silently truncated in storage on load", () => { - const storage = memoryStorage(stale); - render(); - // Still the original text: nothing was written over it on first paint. - expect(readSaved(storage).diceCounts).toEqual({ lucky: 4, future_patch_die: 6 }); - }); - - it("says what it could not read, and only commits once accepted", async () => { - const storage = memoryStorage(stale); - render(); - expect(screen.getByText(/Unknown die “future_patch_die”/)).toBeInTheDocument(); - - await userEvent.click(screen.getByRole("button", { name: "Keep" })); - expect(readSaved(storage).diceCounts).toEqual({ lucky: 4 }); - }); -}); diff --git a/kcd2_dice_solver/src/AppLinks.test.tsx b/kcd2_dice_solver/src/AppLinks.test.tsx new file mode 100644 index 00000000..7775b277 --- /dev/null +++ b/kcd2_dice_solver/src/AppLinks.test.tsx @@ -0,0 +1,249 @@ +/** Shared-link and layout behaviour of the App shell. */ + +/** + * End-to-end tests for the app, driving the real flow: search for a die, click + * to add it, scroll the wheel over a counter, and get a real recommendation. + * + * The worker is replaced with a port that runs the *real* solver synchronously — + * jsdom has no module-worker support, but nothing about the solver is stubbed, + * so these exercise the actual recommendation path. + */ + +import { fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { App } from "./App.tsx"; +import { STORAGE_KEY, encodeInventoryHash, toBase64Url } from "./lib/inventoryIo.ts"; +import type { SavedInventory, UrlHashPort } from "./lib/inventoryIo.ts"; +import { solve } from "./core/solve.ts"; +import type { SolverPort } from "./hooks/useSolver.ts"; +import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts"; + +/** + * A solver port that runs the real solver in-process. + * + * @returns A port usable in place of the Web Worker. + */ +function createInlinePort(): SolverPort { + const listeners = new Set<(event: MessageEvent) => void>(); + return { + postMessage: ({ id, request }: WorkerRequest) => { + let reply: WorkerReply; + try { + // Keep the simulation short: these tests are about the wiring. + reply = { id, ok: true, response: solve({ ...request, simulationTurns: 200 }) }; + } catch (error) { + reply = { id, ok: false, error: error instanceof Error ? error.message : "failed" }; + } + for (const listener of listeners) { + listener({ data: reply } as MessageEvent); + } + }, + addEventListener: (_type, listener) => listeners.add(listener), + removeEventListener: (_type, listener) => listeners.delete(listener), + }; +} + +/** + * An in-memory Storage stand-in, so tests do not share persisted state. + * + * @param initial - Optional starting contents. + * @returns A minimal storage object. + */ +function memoryStorage(initial?: string): Pick { + const store = new Map(); + if (initial !== undefined) { + store.set(STORAGE_KEY, initial); + } + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + }; +} + +/** + * Read back what the app persisted, typed so assertions are not on `any`. + * + * @param storage - The storage the app was given. + * @returns The parsed inventory. + */ +function readSaved(storage: Pick): SavedInventory { + return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory; +} + +/** + * A URL port that reports a fragment without touching the real location. + * + * @param hash - The fragment the page was "opened with". + * @returns The port plus a record of whether the app cleared the fragment. + */ +function stubUrl(hash = ""): UrlHashPort & { cleared: () => number } { + let clears = 0; + return { + read: () => hash, + clear: () => { + clears += 1; + }, + base: () => "https://dice.example/", + cleared: () => clears, + }; +} + +const renderApp = (initial?: string, urlHash: UrlHashPort = stubUrl()) => + render( + Promise.resolve() }} + saveFile={() => undefined} + />, + ); + +describe("shared links", () => { + const shared = encodeInventoryHash({ diceCounts: { weighted: 3 }, badgeIds: [] }); + + it("prefers a link over whatever this browser had saved", () => { + const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] }); + renderApp(mine, stubUrl(shared)); + expect(screen.getByText("3 dice")).toBeInTheDocument(); + }); + + it("drops the fragment so a reload does not resurrect the link", () => { + const url = stubUrl(shared); + renderApp(undefined, url); + expect(url.cleared()).toBe(1); + }); + + it("leaves the URL alone when there was no link", () => { + const url = stubUrl(); + renderApp(undefined, url); + expect(url.cleared()).toBe(0); + }); + + it("does not overwrite the saved inventory until the visitor commits", () => { + // Silently clobbering somebody's own loadout with a link they opened is + // unrecoverable, so nothing is written until they say so. + const storage = memoryStorage(JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] })); + render( + Promise.resolve() }} + saveFile={() => undefined} + />, + ); + expect(screen.getByText("3 dice")).toBeInTheDocument(); + expect(readSaved(storage).diceCounts).toEqual({ lucky: 1 }); + }); + + it("persists once Keep is pressed", async () => { + const storage = memoryStorage(); + render( + Promise.resolve() }} + saveFile={() => undefined} + />, + ); + await userEvent.click(screen.getByRole("button", { name: "Keep" })); + expect(readSaved(storage).diceCounts).toEqual({ weighted: 3 }); + expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); + }); + + it("persists as soon as the visitor edits anything, banner and all", async () => { + const storage = memoryStorage(); + render( + Promise.resolve() }} + saveFile={() => undefined} + />, + ); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + await userEvent.click(screen.getByLabelText("Remove one Weighted die")); + expect(readSaved(storage).diceCounts).toEqual({ weighted: 2 }); + expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); + }); + + it("shows no banner on an ordinary visit", () => { + renderApp(); + expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); + }); + + it("falls through to storage when the fragment carries no inventory", () => { + const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] }); + renderApp(mine, stubUrl("#nothing=here")); + expect(screen.getByText("1 die")).toBeInTheDocument(); + }); + + it("keeps the saved inventory when the link is damaged", () => { + const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] }); + // "@" is not a base64 character, so decoding throws rather than yielding + // an inventory — the case a truncated or mangled URL actually produces. + renderApp(mine, stubUrl("#i=@@@")); + expect(screen.getByText("1 die")).toBeInTheDocument(); + // Nothing was taken from the link, so there is nothing to accept or refuse. + expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Discard" })).not.toBeInTheDocument(); + }); + + it("reports what a shared link had to drop", () => { + const link = `#i=${toBase64Url( + JSON.stringify({ diceCounts: { weighted: 2, nosuchdie: 1 }, badgeIds: [] }), + )}`; + renderApp(undefined, stubUrl(link)); + expect(screen.getByText("2 dice")).toBeInTheDocument(); + expect(screen.getByText(/Unknown die/)).toBeInTheDocument(); + }); + + it("replaces the whole inventory on import", async () => { + const storage = memoryStorage(JSON.stringify({ diceCounts: { lucky: 2 }, badgeIds: [] })); + render( + , + ); + fireEvent.change(screen.getByLabelText("Inventory JSON"), { + target: { value: '{"diceCounts":{"weighted":4},"badgeIds":[]}' }, + }); + await userEvent.click(screen.getByRole("button", { name: "Import pasted" })); + expect(screen.getByText("4 dice")).toBeInTheDocument(); + expect(readSaved(storage).diceCounts).toEqual({ weighted: 4 }); + }); +}); + +describe("layout", () => { + it("gives the left column to the dice and nothing else", () => { + renderApp(); + const inventory = document.querySelector("section.inventory"); + if (!inventory) { + throw new Error("no inventory section"); + } + // The search box, the title and the badges all moved to the right column so + // the 43-row grid gets the whole vertical budget. + expect(inventory.querySelector(".die-list")).toBeInTheDocument(); + expect(inventory.querySelector(".toolbar")).toBeNull(); + expect(inventory.querySelector("h1")).toBeNull(); + expect(inventory.querySelector(".badges")).toBeNull(); + }); + + it("keeps the badges collapsed until asked for", () => { + renderApp(); + const badges = document.querySelector("details.badges"); + expect(badges).toBeInTheDocument(); + expect(badges).not.toHaveAttribute("open"); + // Still reachable — collapsed, not removed. + expect(within(badges as HTMLElement).getByText("Badges")).toBeInTheDocument(); + }); + + it("no longer tells touch users to scroll a mouse wheel", () => { + renderApp(); + expect(screen.queryByText(/scroll the wheel/i)).not.toBeInTheDocument(); + }); +}); diff --git a/kcd2_dice_solver/src/AppRecovery.test.tsx b/kcd2_dice_solver/src/AppRecovery.test.tsx new file mode 100644 index 00000000..35acf1de --- /dev/null +++ b/kcd2_dice_solver/src/AppRecovery.test.tsx @@ -0,0 +1,173 @@ +/** How the App recovers a shared link that would clobber saved state. */ + +/** Shared-link and layout behaviour of the App shell. */ + +/** + * End-to-end tests for the app, driving the real flow: search for a die, click + * to add it, scroll the wheel over a counter, and get a real recommendation. + * + * The worker is replaced with a port that runs the *real* solver synchronously — + * jsdom has no module-worker support, but nothing about the solver is stubbed, + * so these exercise the actual recommendation path. + */ + +import { render, screen, } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { App } from "./App.tsx"; +import { STORAGE_KEY, encodeInventoryHash, } from "./lib/inventoryIo.ts"; +import type { SavedInventory, UrlHashPort } from "./lib/inventoryIo.ts"; +import { solve } from "./core/solve.ts"; +import type { SolverPort } from "./hooks/useSolver.ts"; +import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts"; + +/** + * A solver port that runs the real solver in-process. + * + * @returns A port usable in place of the Web Worker. + */ +function createInlinePort(): SolverPort { + const listeners = new Set<(event: MessageEvent) => void>(); + return { + postMessage: ({ id, request }: WorkerRequest) => { + let reply: WorkerReply; + try { + // Keep the simulation short: these tests are about the wiring. + reply = { id, ok: true, response: solve({ ...request, simulationTurns: 200 }) }; + } catch (error) { + reply = { id, ok: false, error: error instanceof Error ? error.message : "failed" }; + } + for (const listener of listeners) { + listener({ data: reply } as MessageEvent); + } + }, + addEventListener: (_type, listener) => listeners.add(listener), + removeEventListener: (_type, listener) => listeners.delete(listener), + }; +} + +/** + * An in-memory Storage stand-in, so tests do not share persisted state. + * + * @param initial - Optional starting contents. + * @returns A minimal storage object. + */ +function memoryStorage(initial?: string): Pick { + const store = new Map(); + if (initial !== undefined) { + store.set(STORAGE_KEY, initial); + } + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + }; +} + +/** + * Read back what the app persisted, typed so assertions are not on `any`. + * + * @param storage - The storage the app was given. + * @returns The parsed inventory. + */ +function readSaved(storage: Pick): SavedInventory { + return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory; +} + +/** + * A URL port that reports a fragment without touching the real location. + * + * @param hash - The fragment the page was "opened with". + * @returns The port plus a record of whether the app cleared the fragment. + */ +function stubUrl(hash = ""): UrlHashPort & { cleared: () => number } { + let clears = 0; + return { + read: () => hash, + clear: () => { + clears += 1; + }, + base: () => "https://dice.example/", + cleared: () => clears, + }; +} + +const renderApp = (initial?: string, urlHash: UrlHashPort = stubUrl()) => + render( + Promise.resolve() }} + saveFile={() => undefined} + />, + ); + +describe("shared links cannot destroy what is already saved", () => { + it("ignores a link that carries no dice or badges", () => { + // A count of zero parses cleanly, so "did the parser complain?" is the + // wrong question — this used to blank the inventory on screen, disable + // Clear, and leave Keep as the only enabled control, writing the emptiness + // over the visitor's own dice. + const mine = JSON.stringify({ diceCounts: { lucky: 4 }, badgeIds: [] }); + const empty = encodeInventoryHash({ diceCounts: { weighted: 0 }, badgeIds: [] }); + const storage = memoryStorage(mine); + render( + , + ); + expect(screen.getByText("4 dice")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); + expect(readSaved(storage).diceCounts).toEqual({ lucky: 4 }); + }); + + it("restores the visitor's own inventory when a link is discarded", async () => { + const mine = JSON.stringify({ diceCounts: { lucky: 4 }, badgeIds: ["tin_might"] }); + const link = encodeInventoryHash({ diceCounts: { weighted: 3 }, badgeIds: [] }); + const storage = memoryStorage(mine); + render( + , + ); + expect(screen.getByText("3 dice")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Discard" })); + expect(screen.getByText("4 dice")).toBeInTheDocument(); + expect(readSaved(storage)).toEqual({ + diceCounts: { lucky: 4 }, + badgeIds: ["tin_might"], + }); + }); + + it("clears a fragment even when it decoded to nothing", () => { + // A fragment left in the bar is read again on the next reload, where it + // would land on top of whatever has been edited since. + for (const hash of ["#i=@@@", "#other=1", "#i="]) { + const url = stubUrl(hash); + renderApp(undefined, url); + expect(url.cleared()).toBe(1); + } + }); +}); + +describe("a saved inventory that no longer parses cleanly", () => { + const stale = JSON.stringify({ + diceCounts: { lucky: 4, future_patch_die: 6 }, + badgeIds: [], + }); + + it("is not silently truncated in storage on load", () => { + const storage = memoryStorage(stale); + render(); + // Still the original text: nothing was written over it on first paint. + expect(readSaved(storage).diceCounts).toEqual({ lucky: 4, future_patch_die: 6 }); + }); + + it("says what it could not read, and only commits once accepted", async () => { + const storage = memoryStorage(stale); + render(); + expect(screen.getByText(/Unknown die “future_patch_die”/)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Keep" })); + expect(readSaved(storage).diceCounts).toEqual({ lucky: 4 }); + }); +}); diff --git a/kcd2_dice_solver/src/AppShell.test.tsx b/kcd2_dice_solver/src/AppShell.test.tsx new file mode 100644 index 00000000..127b2540 --- /dev/null +++ b/kcd2_dice_solver/src/AppShell.test.tsx @@ -0,0 +1,227 @@ +/** The App shell's core behaviour. */ + +/** + * End-to-end tests for the app, driving the real flow: search for a die, click + * to add it, scroll the wheel over a counter, and get a real recommendation. + * + * The worker is replaced with a port that runs the *real* solver synchronously — + * jsdom has no module-worker support, but nothing about the solver is stubbed, + * so these exercise the actual recommendation path. + */ + +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { App } from "./App.tsx"; +import { STORAGE_KEY, } from "./lib/inventoryIo.ts"; +import type { SavedInventory, UrlHashPort } from "./lib/inventoryIo.ts"; +import { solve } from "./core/solve.ts"; +import type { SolverPort } from "./hooks/useSolver.ts"; +import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts"; + +/** + * A solver port that runs the real solver in-process. + * + * @returns A port usable in place of the Web Worker. + */ +function createInlinePort(): SolverPort { + const listeners = new Set<(event: MessageEvent) => void>(); + return { + postMessage: ({ id, request }: WorkerRequest) => { + let reply: WorkerReply; + try { + // Keep the simulation short: these tests are about the wiring. + reply = { id, ok: true, response: solve({ ...request, simulationTurns: 200 }) }; + } catch (error) { + reply = { id, ok: false, error: error instanceof Error ? error.message : "failed" }; + } + for (const listener of listeners) { + listener({ data: reply } as MessageEvent); + } + }, + addEventListener: (_type, listener) => listeners.add(listener), + removeEventListener: (_type, listener) => listeners.delete(listener), + }; +} + +/** + * An in-memory Storage stand-in, so tests do not share persisted state. + * + * @param initial - Optional starting contents. + * @returns A minimal storage object. + */ +function memoryStorage(initial?: string): Pick { + const store = new Map(); + if (initial !== undefined) { + store.set(STORAGE_KEY, initial); + } + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + }; +} + +/** + * Read back what the app persisted, typed so assertions are not on `any`. + * + * @param storage - The storage the app was given. + * @returns The parsed inventory. + */ +function readSaved(storage: Pick): SavedInventory { + return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory; +} + +/** + * A URL port that reports a fragment without touching the real location. + * + * @param hash - The fragment the page was "opened with". + * @returns The port plus a record of whether the app cleared the fragment. + */ +function stubUrl(hash = ""): UrlHashPort & { cleared: () => number } { + let clears = 0; + return { + read: () => hash, + clear: () => { + clears += 1; + }, + base: () => "https://dice.example/", + cleared: () => clears, + }; +} + +const renderApp = (initial?: string, urlHash: UrlHashPort = stubUrl()) => + render( + Promise.resolve() }} + saveFile={() => undefined} + />, + ); + +describe("App", () => { + it("asks for dice before anything is entered", () => { + renderApp(); + expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument(); + expect(screen.getByText("0 dice")).toBeInTheDocument(); + }); + + it("finds a die by fuzzy search, adds six by clicking, and solves", async () => { + renderApp(); + + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + const row = screen.getAllByRole("listitem")[0]; + const add = within(row).getByRole("button", { name: "Weighted die" }); + + for (let i = 0; i < 6; i += 1) { + await userEvent.click(add); + } + + expect(screen.getByText("6 dice")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("Best loadout")).toBeInTheDocument(); + }); + // Six Weighted dice is the right answer and the panel must name it. + expect(screen.getByText("6×")).toBeInTheDocument(); + expect(screen.getByText("Weighted die")).toBeInTheDocument(); + expect(screen.getByText(/Provably optimal/)).toBeInTheDocument(); + }); + + it("adds dice by scrolling the wheel over the counter", async () => { + renderApp(); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + const counter = screen.getByLabelText("How many Weighted die"); + const stepper = counter.parentElement; + if (!stepper) { + throw new Error("no stepper"); + } + + for (let i = 0; i < 6; i += 1) { + fireEvent.wheel(stepper, { deltaY: -120 }); + } + expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("6"); + expect(screen.getByText("6 dice")).toBeInTheDocument(); + + fireEvent.wheel(stepper, { deltaY: 120 }); + expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("5"); + expect(screen.getByText("5 dice")).toBeInTheDocument(); + }); + + it("accumulates a rapid burst of clicks and wheel ticks", async () => { + // Driving the real app in headless Chrome caught this: six fast clicks used + // to land as one, because each handler computed from the count captured at + // render time. React batches faster than it re-renders. + renderApp(); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + + const row = screen.getAllByRole("listitem")[0]; + const add = within(row).getByRole("button", { name: "Weighted die" }); + act(() => { + for (let i = 0; i < 4; i += 1) { + fireEvent.click(add); + } + }); + expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("4"); + + const stepper = screen.getByLabelText("How many Weighted die").parentElement; + if (!stepper) { + throw new Error("no stepper"); + } + act(() => { + for (let i = 0; i < 2; i += 1) { + fireEvent.wheel(stepper, { deltaY: -120 }); + } + }); + expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("6"); + expect(screen.getByText("6 dice")).toBeInTheDocument(); + }); + + it("ranks the badges you own alongside the dice", async () => { + const saved = JSON.stringify({ + diceCounts: { weighted: 6 }, + badgeIds: ["gold_emperors", "gold_defence"], + }); + renderApp(saved); + + await waitFor(() => { + expect(screen.getByText("Best badge per tier")).toBeInTheDocument(); + }); + // The name also appears in the ownership checkbox, so scope to the result. + const results = document.querySelector(".result"); + if (!results) { + throw new Error("no result panel"); + } + expect(within(results as HTMLElement).getByText("Gold Emperor's badge")).toBeInTheDocument(); + // The Defence badge is owned too, and must be reported as situational + // rather than given an invented number. + expect(within(results as HTMLElement).getByText(/situational/)).toBeInTheDocument(); + }); + + it("restores a saved inventory on load", () => { + renderApp(JSON.stringify({ diceCounts: { weighted: 4 }, badgeIds: [] })); + expect(screen.getByText("4 dice")).toBeInTheDocument(); + }); + + it("persists changes for next time", async () => { + const storage = memoryStorage(); + render(); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + await userEvent.click(screen.getByLabelText("Add one Weighted die")); + + expect(readSaved(storage)).toEqual({ diceCounts: { weighted: 1 }, badgeIds: [] }); + }); + + it("clears everything on request", async () => { + renderApp(JSON.stringify({ diceCounts: { weighted: 6 }, badgeIds: ["tin_might"] })); + await waitFor(() => { + expect(screen.getByText("Best loadout")).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("button", { name: "Clear" })); + expect(screen.getByText("0 dice")).toBeInTheDocument(); + expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument(); + }); +}); diff --git a/kcd2_dice_solver/src/AppStorage.test.tsx b/kcd2_dice_solver/src/AppStorage.test.tsx new file mode 100644 index 00000000..b7fc6f47 --- /dev/null +++ b/kcd2_dice_solver/src/AppStorage.test.tsx @@ -0,0 +1,180 @@ +/** How the App shell falls back to browser storage. */ + +/** The App shell's core behaviour. */ + +/** + * End-to-end tests for the app, driving the real flow: search for a die, click + * to add it, scroll the wheel over a counter, and get a real recommendation. + * + * The worker is replaced with a port that runs the *real* solver synchronously — + * jsdom has no module-worker support, but nothing about the solver is stubbed, + * so these exercise the actual recommendation path. + */ + +import { render, screen, waitFor, } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { App } from "./App.tsx"; +import { STORAGE_KEY, } from "./lib/inventoryIo.ts"; +import type { SavedInventory, UrlHashPort } from "./lib/inventoryIo.ts"; +import { solve } from "./core/solve.ts"; +import type { SolverPort } from "./hooks/useSolver.ts"; +import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts"; + +/** + * A solver port that runs the real solver in-process. + * + * @returns A port usable in place of the Web Worker. + */ +function createInlinePort(): SolverPort { + const listeners = new Set<(event: MessageEvent) => void>(); + return { + postMessage: ({ id, request }: WorkerRequest) => { + let reply: WorkerReply; + try { + // Keep the simulation short: these tests are about the wiring. + reply = { id, ok: true, response: solve({ ...request, simulationTurns: 200 }) }; + } catch (error) { + reply = { id, ok: false, error: error instanceof Error ? error.message : "failed" }; + } + for (const listener of listeners) { + listener({ data: reply } as MessageEvent); + } + }, + addEventListener: (_type, listener) => listeners.add(listener), + removeEventListener: (_type, listener) => listeners.delete(listener), + }; +} + +/** + * An in-memory Storage stand-in, so tests do not share persisted state. + * + * @param initial - Optional starting contents. + * @returns A minimal storage object. + */ +function memoryStorage(initial?: string): Pick { + const store = new Map(); + if (initial !== undefined) { + store.set(STORAGE_KEY, initial); + } + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + }; +} + +/** + * Read back what the app persisted, typed so assertions are not on `any`. + * + * @param storage - The storage the app was given. + * @returns The parsed inventory. + */ +function readSaved(storage: Pick): SavedInventory { + return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory; +} + +/** + * A URL port that reports a fragment without touching the real location. + * + * @param hash - The fragment the page was "opened with". + * @returns The port plus a record of whether the app cleared the fragment. + */ +function stubUrl(hash = ""): UrlHashPort & { cleared: () => number } { + let clears = 0; + return { + read: () => hash, + clear: () => { + clears += 1; + }, + base: () => "https://dice.example/", + cleared: () => clears, + }; +} + +const renderApp = (initial?: string, urlHash: UrlHashPort = stubUrl()) => + render( + Promise.resolve() }} + saveFile={() => undefined} + />, + ); + +describe("App storage", () => { + it("falls back to the browser's own storage when none is injected", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ diceCounts: { weighted: 2 }, badgeIds: [] }), + ); + render(); + expect(screen.getByText("2 dice")).toBeInTheDocument(); + window.localStorage.clear(); + }); + + it("disables Clear when there is nothing to clear", () => { + renderApp(); + expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled(); + }); + + it("drops a die from the inventory when its count reaches zero", async () => { + const storage = memoryStorage( + JSON.stringify({ diceCounts: { weighted: 1 }, badgeIds: [] }), + ); + render(); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + await userEvent.click(screen.getByLabelText("Remove one Weighted die")); + + expect(readSaved(storage)).toEqual({ diceCounts: {}, badgeIds: [] }); + }); + + it("toggles a badge on and off", async () => { + const storage = memoryStorage(); + render(); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "emperor"); + + const checkbox = screen.getByRole("checkbox"); + await userEvent.click(checkbox); + expect(readSaved(storage).badgeIds).toEqual(["gold_emperors"]); + + await userEvent.click(checkbox); + expect(readSaved(storage).badgeIds).toEqual([]); + }); + + it("uses the singular for a single die", async () => { + renderApp(); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + await userEvent.click(screen.getByLabelText("Add one Weighted die")); + expect(screen.getByText("1 die")).toBeInTheDocument(); + }); + + it("surfaces a solver error rather than showing nothing", async () => { + // A port that always fails, standing in for a worker that threw. + const failingPort = (): SolverPort => { + const listeners = new Set<(event: MessageEvent) => void>(); + return { + postMessage: ({ id }: WorkerRequest) => { + for (const listener of listeners) { + listener({ data: { id, ok: false, error: "solver exploded" } } as MessageEvent< + WorkerReply + >); + } + }, + addEventListener: (_type, listener) => listeners.add(listener), + removeEventListener: (_type, listener) => listeners.delete(listener), + }; + }; + render( + , + ); + await waitFor(() => { + expect(screen.getByText("solver exploded")).toBeInTheDocument(); + }); + }); +}); diff --git a/kcd2_dice_solver/src/components/componentInventory.test.tsx b/kcd2_dice_solver/src/components/componentInventory.test.tsx new file mode 100644 index 00000000..98cc0dea --- /dev/null +++ b/kcd2_dice_solver/src/components/componentInventory.test.tsx @@ -0,0 +1,164 @@ +/** Inventory IO component tests. */ + +/** Result panel and inventory IO component tests. */ + +/** Badge picker and result panel component tests. */ + +/** Row, list and picker component tests. */ + +/** + * Component tests for the three ways of entering an inventory: clicking a die, + * scrolling the wheel over its counter, and fuzzy-searching by name. + */ + +import { fireEvent, render, screen, waitFor, } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { SavedInventory } from "../lib/inventoryIo.ts"; +import { InventoryIo } from "./InventoryIo.tsx"; +import type { InventoryIoProps } from "./InventoryIo.tsx"; + +/** + * Apply the updater a component emitted, to assert on the resulting count. + * + * @param mock - The mocked onChange. + * @param previous - The count it should be applied to. + * @param call - Which call to read, counting from the end by default. + * @returns The count the component asked for. + */ + + +describe("InventoryIo", () => { + const inventory: SavedInventory = { + diceCounts: { weighted: 2 }, + badgeIds: ["tin_might"], + }; + + /** + * Render the panel with every port stubbed, and open it. + * + * @param overrides - Ports or handlers to replace. + * @returns The stubs the caller will want to assert on. + */ + function setup(overrides: Partial = {}) { + const onImport = vi.fn(); + const saveFile = vi.fn(); + const clipboard = { writeText: vi.fn<(text: string) => Promise>().mockResolvedValue() }; + render( + , + ); + return { onImport, saveFile, clipboard }; + } + + it("starts collapsed so it costs no vertical space", () => { + setup(); + expect(screen.getByText("Import / export").closest("details")).not.toHaveAttribute("open"); + }); + + it("downloads the inventory as parseable JSON", async () => { + const { saveFile } = setup(); + await userEvent.click(screen.getByRole("button", { name: "Download .json" })); + expect(saveFile).toHaveBeenCalledTimes(1); + const [filename, body] = saveFile.mock.calls[0] as [string, string]; + expect(filename).toBe("kcd2-dice.json"); + expect(JSON.parse(body)).toEqual(inventory); + expect(screen.getByRole("status")).toHaveTextContent("Saved kcd2-dice.json."); + }); + + it("copies the JSON to the clipboard", async () => { + const { clipboard } = setup(); + await userEvent.click(screen.getByRole("button", { name: "Copy JSON" })); + expect(JSON.parse(clipboard.writeText.mock.calls[0][0])).toEqual(inventory); + await screen.findByText("JSON copied."); + }); + + it("copies the share link", async () => { + const { clipboard } = setup(); + await userEvent.click(screen.getByRole("button", { name: "Copy link" })); + expect(clipboard.writeText).toHaveBeenCalledWith("https://dice.example/#i=abc"); + await screen.findByText("Link copied."); + }); + + it("falls back to the textarea when the clipboard refuses", async () => { + // Insecure origins reject; the payload must still be reachable by hand. + const clipboard = { + writeText: vi.fn<(text: string) => Promise>().mockRejectedValue(new Error("denied")), + }; + setup({ clipboard }); + await userEvent.click(screen.getByRole("button", { name: "Copy JSON" })); + await screen.findByText(/Copy failed/); + const box = screen.getByLabelText("Inventory JSON"); + expect(JSON.parse(box.value)).toEqual(inventory); + }); + + it("imports a pasted inventory", async () => { + const { onImport } = setup(); + // fireEvent rather than userEvent.type: the latter treats "{" and "[" as + // key-descriptor syntax, which mangles JSON. + fireEvent.change(screen.getByLabelText("Inventory JSON"), { + target: { value: JSON.stringify({ diceCounts: { lucky: 3 }, badgeIds: [] }) }, + }); + await userEvent.click(screen.getByRole("button", { name: "Import pasted" })); + expect(onImport).toHaveBeenCalledWith({ diceCounts: { lucky: 3 }, badgeIds: [] }); + expect(screen.getByRole("status")).toHaveTextContent("Imported."); + }); + + it("refuses an unusable payload without calling onImport", async () => { + const { onImport } = setup(); + fireEvent.change(screen.getByLabelText("Inventory JSON"), { target: { value: "{not json" } }); + await userEvent.click(screen.getByRole("button", { name: "Import pasted" })); + expect(onImport).not.toHaveBeenCalled(); + expect(screen.getByRole("status")).toHaveTextContent("Nothing imported. Not valid JSON."); + }); + + it("imports what it can and says what it ignored", async () => { + const { onImport } = setup(); + fireEvent.change(screen.getByLabelText("Inventory JSON"), { + target: { value: '{"diceCounts":{"lucky":1,"nope":2},"badgeIds":[]}' }, + }); + await userEvent.click(screen.getByRole("button", { name: "Import pasted" })); + expect(onImport).toHaveBeenCalledWith({ diceCounts: { lucky: 1 }, badgeIds: [] }); + expect(screen.getByRole("status")).toHaveTextContent("Ignored: Unknown die “nope”."); + }); + + it("keeps the import button disabled until something is pasted", () => { + setup(); + expect(screen.getByRole("button", { name: "Import pasted" })).toBeDisabled(); + }); + + it("imports an uploaded file", async () => { + const { onImport } = setup(); + const file = new File([JSON.stringify({ diceCounts: { lucky: 5 }, badgeIds: [] })], "inv.json", { + type: "application/json", + }); + await userEvent.upload(screen.getByLabelText(/Load a .json file/), file); + await waitFor(() => { + expect(onImport).toHaveBeenCalledWith({ diceCounts: { lucky: 5 }, badgeIds: [] }); + }); + }); + + it("does nothing when the file picker is dismissed", () => { + const { onImport } = setup(); + const input = screen.getByLabelText(/Load a .json file/); + fireEvent.change(input, { target: { files: [] } }); + expect(onImport).not.toHaveBeenCalled(); + fireEvent.change(input, { target: { files: null } }); + expect(onImport).not.toHaveBeenCalled(); + }); + + it("reports a file it cannot read", async () => { + const { onImport } = setup(); + const unreadable = new File(["x"], "bad.json", { type: "application/json" }); + vi.spyOn(unreadable, "text").mockRejectedValue(new Error("I/O")); + await userEvent.upload(screen.getByLabelText(/Load a .json file/), unreadable); + await screen.findByText("Could not read that file."); + expect(onImport).not.toHaveBeenCalled(); + }); +}); diff --git a/kcd2_dice_solver/src/components/componentPickers.test.tsx b/kcd2_dice_solver/src/components/componentPickers.test.tsx new file mode 100644 index 00000000..28afa83f --- /dev/null +++ b/kcd2_dice_solver/src/components/componentPickers.test.tsx @@ -0,0 +1,71 @@ +/** Badge picker and result panel component tests. */ + +/** Row, list and picker component tests. */ + +/** + * Component tests for the three ways of entering an inventory: clicking a die, + * scrolling the wheel over its counter, and fuzzy-searching by name. + */ + +import { render, screen, } from "@testing-library/react"; +import { ordinary } from "../test/fixtures.ts"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { Die } from "../data/dice.ts"; +import { BadgePicker } from "./BadgePicker.tsx"; +import { summariseDice } from "./ResultPanel.tsx"; + +/** + * Apply the updater a component emitted, to assert on the resulting count. + * + * @param mock - The mocked onChange. + * @param previous - The count it should be applied to. + * @param call - Which call to read, counting from the end by default. + * @returns The count the component asked for. + */ + + +describe("BadgePicker", () => { + it("groups badges under their tier", () => { + render(); + expect(screen.getByRole("heading", { name: "tin" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "silver" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "gold" })).toBeInTheDocument(); + }); + + it("hides a tier with no matching badge", () => { + render(); + expect(screen.queryByRole("heading", { name: "tin" })).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "gold" })).toBeInTheDocument(); + }); + + it("reflects which badges are owned", () => { + render(); + expect(screen.getByRole("checkbox")).toBeChecked(); + }); + + it("reports a toggle in both directions", async () => { + const onToggle = vi.fn(); + const { rerender } = render( + , + ); + await userEvent.click(screen.getByRole("checkbox")); + expect(onToggle).toHaveBeenLastCalledWith("gold_emperors", true); + + rerender( + , + ); + await userEvent.click(screen.getByRole("checkbox")); + expect(onToggle).toHaveBeenLastCalledWith("gold_emperors", false); + }); +}); + +describe("summariseDice", () => { + it("collapses duplicates and orders by count", () => { + const weighted: Die = { ...ordinary, id: "weighted", name: "Weighted die" }; + expect(summariseDice([ordinary, weighted, weighted, weighted])).toEqual([ + { die: weighted, count: 3 }, + { die: ordinary, count: 1 }, + ]); + }); +}); diff --git a/kcd2_dice_solver/src/components/componentResult.test.tsx b/kcd2_dice_solver/src/components/componentResult.test.tsx new file mode 100644 index 00000000..07181bb1 --- /dev/null +++ b/kcd2_dice_solver/src/components/componentResult.test.tsx @@ -0,0 +1,145 @@ +/** Result panel and inventory IO component tests. */ + +/** Badge picker and result panel component tests. */ + +/** Row, list and picker component tests. */ + +/** + * Component tests for the three ways of entering an inventory: clicking a die, + * scrolling the wheel over its counter, and fuzzy-searching by name. + */ + +import { render, screen, } from "@testing-library/react"; +import { ordinary, response } from "../test/fixtures.ts"; +import { describe, expect, it, } from "vitest"; +import type { Die } from "../data/dice.ts"; +import type { SolveResponse } from "../core/solve.ts"; +import { ResultPanel, } from "./ResultPanel.tsx"; + +/** + * Apply the updater a component emitted, to assert on the resulting count. + * + * @param mock - The mocked onChange. + * @param previous - The count it should be applied to. + * @param call - Which call to read, counting from the end by default. + * @returns The count the component asked for. + */ + + +describe("ResultPanel", () => { + it("prompts for dice before the first solve", () => { + render(); + expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument(); + }); + + it("says it is working while a solve is in flight", () => { + render(); + expect(screen.getByText("Solving…")).toBeInTheDocument(); + }); + + it("shows an error instead of a stale answer", () => { + render(); + expect(screen.getByText("Need at least 6 dice")).toBeInTheDocument(); + }); + + it("shows the loadout and its numbers", () => { + render(); + expect(screen.getByText("6×")).toBeInTheDocument(); + expect(screen.getByText("Ordinary die")).toBeInTheDocument(); + expect(screen.getByText("399")).toBeInTheDocument(); + expect(screen.getByText("3.09%")).toBeInTheDocument(); + expect(screen.getByText("900")).toBeInTheDocument(); + }); + + it("says when the answer is proven rather than merely good", () => { + render(); + expect(screen.getByText(/Provably optimal/)).toBeInTheDocument(); + }); + + it("admits when the answer is only a local search result", () => { + render( + , + ); + expect(screen.getByText(/not proven/)).toBeInTheDocument(); + }); + + it("lists runners-up when there are any", () => { + const withAlternatives: SolveResponse = { + ...response, + alternatives: [ + { dice: new Array(6).fill(ordinary), evaluation: response.evaluation }, + ], + }; + render(); + expect(screen.getByText("Runners-up")).toBeInTheDocument(); + }); + + it("asks for badges when none are owned", () => { + render(); + expect(screen.getByText(/Tick the badges you own/)).toBeInTheDocument(); + }); + + it("shows the pick, its value and its reasoning for each tier", () => { + const withBadges: SolveResponse = { + ...response, + badges: [ + { + tier: "gold", + ranked: [ + { + badge: { + id: "gold_emperors", + name: "Gold Emperor's badge", + description: "", + tier: "gold", + effect: { kind: "scoring", rules: { emperorTriple: true } }, + }, + pointsPerGame: 1234, + reason: "Changes the scoring table.", + dice: new Array(6).fill(ordinary), + }, + { + badge: { + id: "gold_warlord", + name: "Gold Warlord badge", + description: "", + tier: "gold", + effect: { kind: "multiplier", factor: 2, uses: 1 }, + }, + pointsPerGame: 567, + reason: "Doubles a good turn.", + dice: null, + }, + { + badge: { + id: "gold_defence", + name: "Gold Defence badge", + description: "", + tier: "gold", + effect: { kind: "defence" }, + }, + pointsPerGame: null, + reason: "Situational.", + dice: null, + }, + ], + }, + ], + }; + render(); + expect(screen.getByText("Gold Emperor's badge")).toBeInTheDocument(); + expect(screen.getByText(/~1,234 pts\/game/)).toBeInTheDocument(); + expect(screen.getByText("Changes the scoring table.")).toBeInTheDocument(); + expect(screen.getByText(/With this badge, bring/)).toBeInTheDocument(); + // The runners-up list renders both a valued badge and a situational one. + expect(screen.getByText(/Gold Warlord badge — ~567 pts\/game/)).toBeInTheDocument(); + expect(screen.getByText(/Gold Defence badge — situational/)).toBeInTheDocument(); + }); + + it("dims the panel while a fresh solve is running", () => { + const { container } = render( + , + ); + expect(container.querySelector(".result")).toHaveClass("stale"); + }); +}); diff --git a/kcd2_dice_solver/src/components/componentRows.test.tsx b/kcd2_dice_solver/src/components/componentRows.test.tsx new file mode 100644 index 00000000..50409f9e --- /dev/null +++ b/kcd2_dice_solver/src/components/componentRows.test.tsx @@ -0,0 +1,120 @@ +/** Row, list and picker component tests. */ + +/** + * Component tests for the three ways of entering an inventory: clicking a die, + * scrolling the wheel over its counter, and fuzzy-searching by name. + */ + +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { MAX_PER_DIE } from "../data/dice.ts"; +import type { Die } from "../data/dice.ts"; +import { DiceList } from "./DiceList.tsx"; +import { DieRow, } from "./DieRow.tsx"; + +/** + * Apply the updater a component emitted, to assert on the resulting count. + * + * @param mock - The mocked onChange. + * @param previous - The count it should be applied to. + * @param call - Which call to read, counting from the end by default. + * @returns The count the component asked for. + */ +function applied( + mock: { mock: { calls: unknown[][] } }, + previous: number, + call = mock.mock.calls.length - 1, +): number { + const update = mock.mock.calls[call][0] as (p: number) => number; + return update(previous); +} + +const ordinary: Die = { + id: "ordinary", + name: "Ordinary die", + description: "An ordinary playing die.", + weights: [1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6], + wildcardFaces: [], + wildScoresAlone: false, +}; + +describe("DieRow", () => { + it("adds one die when the row is clicked", async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Ordinary die" })); + expect(applied(onChange, 0)).toBe(1); + }); + + it("stops at the maximum when clicked repeatedly", async () => { + const onChange = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Ordinary die" })); + expect(applied(onChange, MAX_PER_DIE)).toBe(MAX_PER_DIE); + }); + + it("marks a row you own", () => { + const { container } = render( + , + ); + expect(container.querySelector(".die-row")).toHaveClass("owned"); + }); + + it("tags the wildcard dice", () => { + const balatro: Die = { + ...ordinary, + id: "balatro", + wildcardFaces: [1], + wildScoresAlone: true, + }; + render(); + expect(screen.getByText("wild")).toBeInTheDocument(); + }); +}); + +describe("DiceList", () => { + it("shows every die when the query is empty", () => { + render(); + expect(screen.getAllByRole("listitem")).toHaveLength(43); + }); + + it("narrows to the Weighted die on a three-letter query", () => { + render(); + const rows = screen.getAllByRole("listitem"); + // The name is split into highlighted and plain runs, so match on the row + // button's accessible name rather than on a single text node. + expect(within(rows[0]).getByRole("button", { name: "Weighted die" })).toBeInTheDocument(); + }); + + it("highlights the characters the query matched", () => { + render(); + const rows = screen.getAllByRole("listitem"); + expect(within(rows[0]).getByText("Wei").tagName).toBe("MARK"); + }); + + it("finds a die by a fragment from the middle of its name", () => { + render(); + expect(screen.getAllByRole("listitem")).toHaveLength(1); + }); + + it("says so when nothing matches", () => { + render(); + expect(screen.getByText(/No die matches/)).toBeInTheDocument(); + }); + + it("passes the owned count through to the row", () => { + render(); + expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("4"); + }); + + it("reports which die changed", async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Weighted die" })); + expect(onChange.mock.calls[0][0]).toBe("weighted"); + expect((onChange.mock.calls[0][1] as (p: number) => number)(0)).toBe(1); + }); +}); diff --git a/kcd2_dice_solver/src/components/components.test.tsx b/kcd2_dice_solver/src/components/components.test.tsx index bd214cf0..3abf9a92 100644 --- a/kcd2_dice_solver/src/components/components.test.tsx +++ b/kcd2_dice_solver/src/components/components.test.tsx @@ -3,20 +3,11 @@ * scrolling the wheel over its counter, and fuzzy-searching by name. */ -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, render, screen, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { MAX_PER_DIE } from "../data/dice.ts"; -import type { Die } from "../data/dice.ts"; -import type { SolveResponse } from "../core/solve.ts"; -import type { SavedInventory } from "../lib/inventoryIo.ts"; -import { BadgePicker } from "./BadgePicker.tsx"; -import { DiceList } from "./DiceList.tsx"; -import { DieRow, highlightName } from "./DieRow.tsx"; -import { InventoryIo } from "./InventoryIo.tsx"; -import type { InventoryIoProps } from "./InventoryIo.tsx"; +import { highlightName } from "./DieRow.tsx"; import { QuantityStepper, clampCount } from "./QuantityStepper.tsx"; -import { ResultPanel, summariseDice } from "./ResultPanel.tsx"; /** * Apply the updater a component emitted, to assert on the resulting count. @@ -35,14 +26,6 @@ function applied( return update(previous); } -const ordinary: Die = { - id: "ordinary", - name: "Ordinary die", - description: "An ordinary playing die.", - weights: [1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6], - wildcardFaces: [], - wildScoresAlone: false, -}; describe("clampCount", () => { it("keeps a value inside the range", () => { @@ -136,397 +119,3 @@ describe("highlightName", () => { expect(container.textContent).toBe("Die"); }); }); - -describe("DieRow", () => { - it("adds one die when the row is clicked", async () => { - const onChange = vi.fn(); - render(); - await userEvent.click(screen.getByRole("button", { name: "Ordinary die" })); - expect(applied(onChange, 0)).toBe(1); - }); - - it("stops at the maximum when clicked repeatedly", async () => { - const onChange = vi.fn(); - render( - , - ); - await userEvent.click(screen.getByRole("button", { name: "Ordinary die" })); - expect(applied(onChange, MAX_PER_DIE)).toBe(MAX_PER_DIE); - }); - - it("marks a row you own", () => { - const { container } = render( - , - ); - expect(container.querySelector(".die-row")).toHaveClass("owned"); - }); - - it("tags the wildcard dice", () => { - const balatro: Die = { - ...ordinary, - id: "balatro", - wildcardFaces: [1], - wildScoresAlone: true, - }; - render(); - expect(screen.getByText("wild")).toBeInTheDocument(); - }); -}); - -describe("DiceList", () => { - it("shows every die when the query is empty", () => { - render(); - expect(screen.getAllByRole("listitem")).toHaveLength(43); - }); - - it("narrows to the Weighted die on a three-letter query", () => { - render(); - const rows = screen.getAllByRole("listitem"); - // The name is split into highlighted and plain runs, so match on the row - // button's accessible name rather than on a single text node. - expect(within(rows[0]).getByRole("button", { name: "Weighted die" })).toBeInTheDocument(); - }); - - it("highlights the characters the query matched", () => { - render(); - const rows = screen.getAllByRole("listitem"); - expect(within(rows[0]).getByText("Wei").tagName).toBe("MARK"); - }); - - it("finds a die by a fragment from the middle of its name", () => { - render(); - expect(screen.getAllByRole("listitem")).toHaveLength(1); - }); - - it("says so when nothing matches", () => { - render(); - expect(screen.getByText(/No die matches/)).toBeInTheDocument(); - }); - - it("passes the owned count through to the row", () => { - render(); - expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("4"); - }); - - it("reports which die changed", async () => { - const onChange = vi.fn(); - render(); - await userEvent.click(screen.getByRole("button", { name: "Weighted die" })); - expect(onChange.mock.calls[0][0]).toBe("weighted"); - expect((onChange.mock.calls[0][1] as (p: number) => number)(0)).toBe(1); - }); -}); - -describe("BadgePicker", () => { - it("groups badges under their tier", () => { - render(); - expect(screen.getByRole("heading", { name: "tin" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "silver" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "gold" })).toBeInTheDocument(); - }); - - it("hides a tier with no matching badge", () => { - render(); - expect(screen.queryByRole("heading", { name: "tin" })).not.toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "gold" })).toBeInTheDocument(); - }); - - it("reflects which badges are owned", () => { - render(); - expect(screen.getByRole("checkbox")).toBeChecked(); - }); - - it("reports a toggle in both directions", async () => { - const onToggle = vi.fn(); - const { rerender } = render( - , - ); - await userEvent.click(screen.getByRole("checkbox")); - expect(onToggle).toHaveBeenLastCalledWith("gold_emperors", true); - - rerender( - , - ); - await userEvent.click(screen.getByRole("checkbox")); - expect(onToggle).toHaveBeenLastCalledWith("gold_emperors", false); - }); -}); - -describe("summariseDice", () => { - it("collapses duplicates and orders by count", () => { - const weighted: Die = { ...ordinary, id: "weighted", name: "Weighted die" }; - expect(summariseDice([ordinary, weighted, weighted, weighted])).toEqual([ - { die: weighted, count: 3 }, - { die: ordinary, count: 1 }, - ]); - }); -}); - -const response: SolveResponse = { - dice: new Array(6).fill(ordinary), - evaluation: { ev: 399.1, bustProbability: 0.0309, p90: 900 }, - simulation: { - meanPerTurn: 412.5, - standardError: 3.2, - bustRate: 0.2, - throwsPerTurn: 1.4, - turns: 20000, - }, - optimal: true, - alternatives: [], - badges: [], - inventorySize: 6, -}; - -describe("ResultPanel", () => { - it("prompts for dice before the first solve", () => { - render(); - expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument(); - }); - - it("says it is working while a solve is in flight", () => { - render(); - expect(screen.getByText("Solving…")).toBeInTheDocument(); - }); - - it("shows an error instead of a stale answer", () => { - render(); - expect(screen.getByText("Need at least 6 dice")).toBeInTheDocument(); - }); - - it("shows the loadout and its numbers", () => { - render(); - expect(screen.getByText("6×")).toBeInTheDocument(); - expect(screen.getByText("Ordinary die")).toBeInTheDocument(); - expect(screen.getByText("399")).toBeInTheDocument(); - expect(screen.getByText("3.09%")).toBeInTheDocument(); - expect(screen.getByText("900")).toBeInTheDocument(); - }); - - it("says when the answer is proven rather than merely good", () => { - render(); - expect(screen.getByText(/Provably optimal/)).toBeInTheDocument(); - }); - - it("admits when the answer is only a local search result", () => { - render( - , - ); - expect(screen.getByText(/not proven/)).toBeInTheDocument(); - }); - - it("lists runners-up when there are any", () => { - const withAlternatives: SolveResponse = { - ...response, - alternatives: [ - { dice: new Array(6).fill(ordinary), evaluation: response.evaluation }, - ], - }; - render(); - expect(screen.getByText("Runners-up")).toBeInTheDocument(); - }); - - it("asks for badges when none are owned", () => { - render(); - expect(screen.getByText(/Tick the badges you own/)).toBeInTheDocument(); - }); - - it("shows the pick, its value and its reasoning for each tier", () => { - const withBadges: SolveResponse = { - ...response, - badges: [ - { - tier: "gold", - ranked: [ - { - badge: { - id: "gold_emperors", - name: "Gold Emperor's badge", - description: "", - tier: "gold", - effect: { kind: "scoring", rules: { emperorTriple: true } }, - }, - pointsPerGame: 1234, - reason: "Changes the scoring table.", - dice: new Array(6).fill(ordinary), - }, - { - badge: { - id: "gold_warlord", - name: "Gold Warlord badge", - description: "", - tier: "gold", - effect: { kind: "multiplier", factor: 2, uses: 1 }, - }, - pointsPerGame: 567, - reason: "Doubles a good turn.", - dice: null, - }, - { - badge: { - id: "gold_defence", - name: "Gold Defence badge", - description: "", - tier: "gold", - effect: { kind: "defence" }, - }, - pointsPerGame: null, - reason: "Situational.", - dice: null, - }, - ], - }, - ], - }; - render(); - expect(screen.getByText("Gold Emperor's badge")).toBeInTheDocument(); - expect(screen.getByText(/~1,234 pts\/game/)).toBeInTheDocument(); - expect(screen.getByText("Changes the scoring table.")).toBeInTheDocument(); - expect(screen.getByText(/With this badge, bring/)).toBeInTheDocument(); - // The runners-up list renders both a valued badge and a situational one. - expect(screen.getByText(/Gold Warlord badge — ~567 pts\/game/)).toBeInTheDocument(); - expect(screen.getByText(/Gold Defence badge — situational/)).toBeInTheDocument(); - }); - - it("dims the panel while a fresh solve is running", () => { - const { container } = render( - , - ); - expect(container.querySelector(".result")).toHaveClass("stale"); - }); -}); - -describe("InventoryIo", () => { - const inventory: SavedInventory = { - diceCounts: { weighted: 2 }, - badgeIds: ["tin_might"], - }; - - /** - * Render the panel with every port stubbed, and open it. - * - * @param overrides - Ports or handlers to replace. - * @returns The stubs the caller will want to assert on. - */ - function setup(overrides: Partial = {}) { - const onImport = vi.fn(); - const saveFile = vi.fn(); - const clipboard = { writeText: vi.fn<(text: string) => Promise>().mockResolvedValue() }; - render( - , - ); - return { onImport, saveFile, clipboard }; - } - - it("starts collapsed so it costs no vertical space", () => { - setup(); - expect(screen.getByText("Import / export").closest("details")).not.toHaveAttribute("open"); - }); - - it("downloads the inventory as parseable JSON", async () => { - const { saveFile } = setup(); - await userEvent.click(screen.getByRole("button", { name: "Download .json" })); - expect(saveFile).toHaveBeenCalledTimes(1); - const [filename, body] = saveFile.mock.calls[0] as [string, string]; - expect(filename).toBe("kcd2-dice.json"); - expect(JSON.parse(body)).toEqual(inventory); - expect(screen.getByRole("status")).toHaveTextContent("Saved kcd2-dice.json."); - }); - - it("copies the JSON to the clipboard", async () => { - const { clipboard } = setup(); - await userEvent.click(screen.getByRole("button", { name: "Copy JSON" })); - expect(JSON.parse(clipboard.writeText.mock.calls[0][0])).toEqual(inventory); - await screen.findByText("JSON copied."); - }); - - it("copies the share link", async () => { - const { clipboard } = setup(); - await userEvent.click(screen.getByRole("button", { name: "Copy link" })); - expect(clipboard.writeText).toHaveBeenCalledWith("https://dice.example/#i=abc"); - await screen.findByText("Link copied."); - }); - - it("falls back to the textarea when the clipboard refuses", async () => { - // Insecure origins reject; the payload must still be reachable by hand. - const clipboard = { - writeText: vi.fn<(text: string) => Promise>().mockRejectedValue(new Error("denied")), - }; - setup({ clipboard }); - await userEvent.click(screen.getByRole("button", { name: "Copy JSON" })); - await screen.findByText(/Copy failed/); - const box = screen.getByLabelText("Inventory JSON"); - expect(JSON.parse(box.value)).toEqual(inventory); - }); - - it("imports a pasted inventory", async () => { - const { onImport } = setup(); - // fireEvent rather than userEvent.type: the latter treats "{" and "[" as - // key-descriptor syntax, which mangles JSON. - fireEvent.change(screen.getByLabelText("Inventory JSON"), { - target: { value: JSON.stringify({ diceCounts: { lucky: 3 }, badgeIds: [] }) }, - }); - await userEvent.click(screen.getByRole("button", { name: "Import pasted" })); - expect(onImport).toHaveBeenCalledWith({ diceCounts: { lucky: 3 }, badgeIds: [] }); - expect(screen.getByRole("status")).toHaveTextContent("Imported."); - }); - - it("refuses an unusable payload without calling onImport", async () => { - const { onImport } = setup(); - fireEvent.change(screen.getByLabelText("Inventory JSON"), { target: { value: "{not json" } }); - await userEvent.click(screen.getByRole("button", { name: "Import pasted" })); - expect(onImport).not.toHaveBeenCalled(); - expect(screen.getByRole("status")).toHaveTextContent("Nothing imported. Not valid JSON."); - }); - - it("imports what it can and says what it ignored", async () => { - const { onImport } = setup(); - fireEvent.change(screen.getByLabelText("Inventory JSON"), { - target: { value: '{"diceCounts":{"lucky":1,"nope":2},"badgeIds":[]}' }, - }); - await userEvent.click(screen.getByRole("button", { name: "Import pasted" })); - expect(onImport).toHaveBeenCalledWith({ diceCounts: { lucky: 1 }, badgeIds: [] }); - expect(screen.getByRole("status")).toHaveTextContent("Ignored: Unknown die “nope”."); - }); - - it("keeps the import button disabled until something is pasted", () => { - setup(); - expect(screen.getByRole("button", { name: "Import pasted" })).toBeDisabled(); - }); - - it("imports an uploaded file", async () => { - const { onImport } = setup(); - const file = new File([JSON.stringify({ diceCounts: { lucky: 5 }, badgeIds: [] })], "inv.json", { - type: "application/json", - }); - await userEvent.upload(screen.getByLabelText(/Load a .json file/), file); - await waitFor(() => { - expect(onImport).toHaveBeenCalledWith({ diceCounts: { lucky: 5 }, badgeIds: [] }); - }); - }); - - it("does nothing when the file picker is dismissed", () => { - const { onImport } = setup(); - const input = screen.getByLabelText(/Load a .json file/); - fireEvent.change(input, { target: { files: [] } }); - expect(onImport).not.toHaveBeenCalled(); - fireEvent.change(input, { target: { files: null } }); - expect(onImport).not.toHaveBeenCalled(); - }); - - it("reports a file it cannot read", async () => { - const { onImport } = setup(); - const unreadable = new File(["x"], "bad.json", { type: "application/json" }); - vi.spyOn(unreadable, "text").mockRejectedValue(new Error("I/O")); - await userEvent.upload(screen.getByLabelText(/Load a .json file/), unreadable); - await screen.findByText("Could not read that file."); - expect(onImport).not.toHaveBeenCalled(); - }); -}); diff --git a/kcd2_dice_solver/src/core/simulate.test.ts b/kcd2_dice_solver/src/core/simulate.test.ts index 7349a943..fa2846ae 100644 --- a/kcd2_dice_solver/src/core/simulate.test.ts +++ b/kcd2_dice_solver/src/core/simulate.test.ts @@ -7,30 +7,16 @@ import { describe, expect, it } from "vitest"; import { BASE_RULES } from "../data/badges.ts"; -import { DICE_BY_ID } from "../data/dice.ts"; -import type { Die } from "../data/dice.ts"; -import { mulberry32 } from "../lib/rng.ts"; import { Scorer } from "./scoring.ts"; import { DEFAULT_POLICY, - NO_CHARGES, applySetDie, chooseHold, - simulateTurn, - simulateTurns, subMultisets, } from "./simulate.ts"; -const die = (id: string): Die => { - const found = DICE_BY_ID.get(id); - if (!found) { - throw new Error(`no such die: ${id}`); - } - return found; -}; const scorer = new Scorer(BASE_RULES); -const sixOrdinary = new Array(6).fill(die("ordinary")); /** * A die that always rolls the given face, for pinning deterministic behaviour. @@ -38,18 +24,7 @@ const sixOrdinary = new Array(6).fill(die("ordinary")); * @param face - The face it always shows, 1-6. * @returns Six copies of that die. */ -const alwaysRolls = (face: number): Die[] => - new Array(6).fill({ - id: `always-${face}`, - name: `always ${face}`, - description: "", - weights: [1, 2, 3, 4, 5, 6].map((f) => (f === face ? 1 : 0)) as unknown as Die["weights"], - wildcardFaces: [], - wildScoresAlone: false, - }); -const alwaysTwo = alwaysRolls(2); -const alwaysThree = alwaysRolls(3); describe("subMultisets", () => { it("enumerates every sub-multiset including the empty one", () => { @@ -123,150 +98,3 @@ describe("applySetDie", () => { expect(applySetDie(counts, 3, scorer)).toBe(false); }); }); - -describe("simulateTurn", () => { - it("banks once the threshold is reached", () => { - const random = mulberry32(1); - const banked = simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, random); - expect(banked).toBeGreaterThanOrEqual(0); - }); - - it("is reproducible for a given seed", () => { - const first = simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, mulberry32(7)); - const second = simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, mulberry32(7)); - expect(first).toBe(second); - }); - - it("counts the throws it made", () => { - const counter = { throws: 0 }; - simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, mulberry32(3), counter); - expect(counter.throws).toBeGreaterThanOrEqual(1); - }); - - it("brings the whole set back when every die scores", () => { - // Six weighted dice are ones about two thirds of the time, and with no - // per-die bonus the policy holds every scoring die — so all six get set - // aside and the hot-dice branch has to refill the hand. A threshold above - // one throw's typical score keeps the turn going long enough to see it. - const weighted = new Array(6).fill(die("weighted")); - const counter = { throws: 0 }; - simulateTurn( - weighted, - scorer, - { bankThreshold: 20_000, dieValueBonus: 0 }, - NO_CHARGES, - mulberry32(5), - counter, - ); - expect(counter.throws).toBeGreaterThan(1); - }); - - it("always terminates, even for dice that can never bust", () => { - // Six dice that always roll a 2 always score (six of a kind, 1600). Before - // banking was checked ahead of the hot-dice re-roll, this looped forever. - const banked = simulateTurn( - alwaysTwo, - scorer, - DEFAULT_POLICY, - NO_CHARGES, - mulberry32(1), - ); - expect(banked).toBe(1600); - }); -}); - -describe("badge charges in a turn", () => { - const misfortune = new Array(6).fill(die("misfortune")); - - it("an anti-bust charge can only raise the mean", () => { - const base = simulateTurns(misfortune, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 11); - const rescued = simulateTurns( - misfortune, - scorer, - 2000, - DEFAULT_POLICY, - { ...NO_CHARGES, antibust: 1 }, - 11, - ); - expect(rescued.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn); - expect(rescued.bustRate).toBeLessThanOrEqual(base.bustRate); - }); - - it("a re-roll charge can only raise the mean", () => { - const base = simulateTurns(misfortune, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 13); - const rerolled = simulateTurns( - misfortune, - scorer, - 2000, - DEFAULT_POLICY, - { ...NO_CHARGES, reroll: 1, rerollDice: 3 }, - 13, - ); - expect(rerolled.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn); - }); - - it("an extra die can only raise the mean", () => { - const base = simulateTurns(sixOrdinary, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 17); - const bigger = simulateTurns( - sixOrdinary, - scorer, - 2000, - DEFAULT_POLICY, - { ...NO_CHARGES, extraDice: 1 }, - 17, - ); - expect(bigger.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn); - }); - - it("a transmutation charge can only raise the mean", () => { - const base = simulateTurns(sixOrdinary, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 19); - const transmuted = simulateTurns( - sixOrdinary, - scorer, - 2000, - DEFAULT_POLICY, - { ...NO_CHARGES, setDie: 1, setDieValue: 1 }, - 19, - ); - expect(transmuted.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn); - }); -}); - -describe("simulateTurns", () => { - it("reports a mean, an error bar, a bust rate and a throw count", () => { - const result = simulateTurns(sixOrdinary, scorer, 1000); - expect(result.turns).toBe(1000); - expect(result.meanPerTurn).toBeGreaterThan(0); - expect(result.standardError).toBeGreaterThan(0); - expect(result.bustRate).toBeGreaterThan(0); - expect(result.bustRate).toBeLessThan(1); - expect(result.throwsPerTurn).toBeGreaterThanOrEqual(1); - }); - - it("is reproducible for a given seed", () => { - const a = simulateTurns(sixOrdinary, scorer, 500, DEFAULT_POLICY, NO_CHARGES, 42); - const b = simulateTurns(sixOrdinary, scorer, 500, DEFAULT_POLICY, NO_CHARGES, 42); - expect(a.meanPerTurn).toBe(b.meanPerTurn); - }); - - it("reports zero variance when every turn is identical", () => { - const result = simulateTurns(alwaysTwo, scorer, 50); - expect(result.meanPerTurn).toBe(1600); - expect(result.standardError).toBe(0); - expect(result.bustRate).toBe(0); - }); - - it("reports a total bust for dice that can never score", () => { - // Two each of 2, 3 and 4 every single throw: no 1, no 5, no three of a - // kind and no straight, so every turn busts. - const noScore = [ - ...alwaysTwo.slice(0, 2), - ...alwaysThree.slice(0, 2), - ...alwaysRolls(4).slice(0, 2), - ]; - const result = simulateTurns(noScore, scorer, 20); - expect(result.meanPerTurn).toBe(0); - expect(result.standardError).toBe(0); - expect(result.bustRate).toBe(1); - }); -}); diff --git a/kcd2_dice_solver/src/core/simulateTurns.test.ts b/kcd2_dice_solver/src/core/simulateTurns.test.ts new file mode 100644 index 00000000..9e18268c --- /dev/null +++ b/kcd2_dice_solver/src/core/simulateTurns.test.ts @@ -0,0 +1,198 @@ +/** Whole-turn and multi-turn simulation tests. */ + +/** + * Tests for the turn simulator. + * + * The simulator encodes a *policy*, not a fact, so these tests pin the policy's + * stated rules rather than asserting that its output is "correct" play. + */ + +import { describe, expect, it } from "vitest"; +import { BASE_RULES } from "../data/badges.ts"; +import { DICE_BY_ID } from "../data/dice.ts"; +import type { Die } from "../data/dice.ts"; +import { mulberry32 } from "../lib/rng.ts"; +import { Scorer } from "./scoring.ts"; +import { + DEFAULT_POLICY, + NO_CHARGES, + simulateTurn, + simulateTurns, + } from "./simulate.ts"; + +const die = (id: string): Die => { + const found = DICE_BY_ID.get(id); + if (!found) { + throw new Error(`no such die: ${id}`); + } + return found; +}; + +const scorer = new Scorer(BASE_RULES); +const sixOrdinary = new Array(6).fill(die("ordinary")); + +/** + * A die that always rolls the given face, for pinning deterministic behaviour. + * + * @param face - The face it always shows, 1-6. + * @returns Six copies of that die. + */ +const alwaysRolls = (face: number): Die[] => + new Array(6).fill({ + id: `always-${face}`, + name: `always ${face}`, + description: "", + weights: [1, 2, 3, 4, 5, 6].map((f) => (f === face ? 1 : 0)) as unknown as Die["weights"], + wildcardFaces: [], + wildScoresAlone: false, + }); + +const alwaysTwo = alwaysRolls(2); +const alwaysThree = alwaysRolls(3); + +describe("simulateTurn", () => { + it("banks once the threshold is reached", () => { + const random = mulberry32(1); + const banked = simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, random); + expect(banked).toBeGreaterThanOrEqual(0); + }); + + it("is reproducible for a given seed", () => { + const first = simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, mulberry32(7)); + const second = simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, mulberry32(7)); + expect(first).toBe(second); + }); + + it("counts the throws it made", () => { + const counter = { throws: 0 }; + simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, mulberry32(3), counter); + expect(counter.throws).toBeGreaterThanOrEqual(1); + }); + + it("brings the whole set back when every die scores", () => { + // Six weighted dice are ones about two thirds of the time, and with no + // per-die bonus the policy holds every scoring die — so all six get set + // aside and the hot-dice branch has to refill the hand. A threshold above + // one throw's typical score keeps the turn going long enough to see it. + const weighted = new Array(6).fill(die("weighted")); + const counter = { throws: 0 }; + simulateTurn( + weighted, + scorer, + { bankThreshold: 20_000, dieValueBonus: 0 }, + NO_CHARGES, + mulberry32(5), + counter, + ); + expect(counter.throws).toBeGreaterThan(1); + }); + + it("always terminates, even for dice that can never bust", () => { + // Six dice that always roll a 2 always score (six of a kind, 1600). Before + // banking was checked ahead of the hot-dice re-roll, this looped forever. + const banked = simulateTurn( + alwaysTwo, + scorer, + DEFAULT_POLICY, + NO_CHARGES, + mulberry32(1), + ); + expect(banked).toBe(1600); + }); +}); + +describe("badge charges in a turn", () => { + const misfortune = new Array(6).fill(die("misfortune")); + + it("an anti-bust charge can only raise the mean", () => { + const base = simulateTurns(misfortune, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 11); + const rescued = simulateTurns( + misfortune, + scorer, + 2000, + DEFAULT_POLICY, + { ...NO_CHARGES, antibust: 1 }, + 11, + ); + expect(rescued.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn); + expect(rescued.bustRate).toBeLessThanOrEqual(base.bustRate); + }); + + it("a re-roll charge can only raise the mean", () => { + const base = simulateTurns(misfortune, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 13); + const rerolled = simulateTurns( + misfortune, + scorer, + 2000, + DEFAULT_POLICY, + { ...NO_CHARGES, reroll: 1, rerollDice: 3 }, + 13, + ); + expect(rerolled.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn); + }); + + it("an extra die can only raise the mean", () => { + const base = simulateTurns(sixOrdinary, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 17); + const bigger = simulateTurns( + sixOrdinary, + scorer, + 2000, + DEFAULT_POLICY, + { ...NO_CHARGES, extraDice: 1 }, + 17, + ); + expect(bigger.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn); + }); + + it("a transmutation charge can only raise the mean", () => { + const base = simulateTurns(sixOrdinary, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 19); + const transmuted = simulateTurns( + sixOrdinary, + scorer, + 2000, + DEFAULT_POLICY, + { ...NO_CHARGES, setDie: 1, setDieValue: 1 }, + 19, + ); + expect(transmuted.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn); + }); +}); + +describe("simulateTurns", () => { + it("reports a mean, an error bar, a bust rate and a throw count", () => { + const result = simulateTurns(sixOrdinary, scorer, 1000); + expect(result.turns).toBe(1000); + expect(result.meanPerTurn).toBeGreaterThan(0); + expect(result.standardError).toBeGreaterThan(0); + expect(result.bustRate).toBeGreaterThan(0); + expect(result.bustRate).toBeLessThan(1); + expect(result.throwsPerTurn).toBeGreaterThanOrEqual(1); + }); + + it("is reproducible for a given seed", () => { + const a = simulateTurns(sixOrdinary, scorer, 500, DEFAULT_POLICY, NO_CHARGES, 42); + const b = simulateTurns(sixOrdinary, scorer, 500, DEFAULT_POLICY, NO_CHARGES, 42); + expect(a.meanPerTurn).toBe(b.meanPerTurn); + }); + + it("reports zero variance when every turn is identical", () => { + const result = simulateTurns(alwaysTwo, scorer, 50); + expect(result.meanPerTurn).toBe(1600); + expect(result.standardError).toBe(0); + expect(result.bustRate).toBe(0); + }); + + it("reports a total bust for dice that can never score", () => { + // Two each of 2, 3 and 4 every single throw: no 1, no 5, no three of a + // kind and no straight, so every turn busts. + const noScore = [ + ...alwaysTwo.slice(0, 2), + ...alwaysThree.slice(0, 2), + ...alwaysRolls(4).slice(0, 2), + ]; + const result = simulateTurns(noScore, scorer, 20); + expect(result.meanPerTurn).toBe(0); + expect(result.standardError).toBe(0); + expect(result.bustRate).toBe(1); + }); +}); diff --git a/kcd2_dice_solver/src/lib/inventoryHash.test.ts b/kcd2_dice_solver/src/lib/inventoryHash.test.ts new file mode 100644 index 00000000..d585f754 --- /dev/null +++ b/kcd2_dice_solver/src/lib/inventoryHash.test.ts @@ -0,0 +1,160 @@ +/** Serialisation, base64url and inventory-hash round trips. */ + +/** + * Tests for inventory persistence and transport. + * + * The validator is the security boundary of the import features: everything a + * user can paste, upload, or follow a link to lands here first. So the cases + * below lean on malformed input rather than the happy path. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + EMPTY, + EXPORT_FILENAME, + browserClipboard, + browserSaveFile, + browserUrlHash, + decodeInventoryHash, + encodeInventoryHash, + fromBase64Url, + parseInventoryJson, + serialiseInventory, + toBase64Url, +} from "./inventoryIo.ts"; +import type { SavedInventory } from "./inventoryIo.ts"; + +/** + * A storage stub holding one entry. + * + * @param initial - What the key already contains, if anything. + * @returns A minimal Storage. + */ + +describe("serialiseInventory", () => { + it("round-trips through the parser", () => { + const inventory: SavedInventory = { + diceCounts: { weighted: 4 }, + badgeIds: ["tin_might"], + }; + expect(parseInventoryJson(serialiseInventory(inventory)).inventory).toEqual(inventory); + }); +}); + +describe("base64url", () => { + it("round-trips ASCII", () => { + expect(fromBase64Url(toBase64Url("hello world"))).toBe("hello world"); + }); + + it("really produces URL-safe characters, not merely claims to", () => { + // Standard base64 of these bytes contains both '+' and '/', so this + // exercises the substitutions rather than trivially covering the line. + const raw = "ÿï¿"; + const encoded = toBase64Url(raw); + expect(encoded).not.toMatch(/[+/=]/); + expect(encoded).toMatch(/[-_]/); + expect(fromBase64Url(encoded)).toBe(raw); + }); + + it("round-trips every padding length", () => { + for (const text of ["abc", "abcd", "abcde"]) { + expect(fromBase64Url(toBase64Url(text))).toBe(text); + } + }); +}); + +describe("inventory hash", () => { + const inventory: SavedInventory = { + diceCounts: { weighted: 2 }, + badgeIds: ["tin_might"], + }; + + it("round-trips an inventory", () => { + const result = decodeInventoryHash(encodeInventoryHash(inventory)); + expect(result?.inventory).toEqual(inventory); + expect(result?.dropped).toEqual([]); + }); + + it("decodes with or without the leading hash", () => { + const hash = encodeInventoryHash(inventory); + expect(decodeInventoryHash(hash.slice(1))?.inventory).toEqual(inventory); + }); + + it("returns null when there is no fragment", () => { + expect(decodeInventoryHash("")).toBeNull(); + }); + + it("returns null when the fragment carries something else", () => { + expect(decodeInventoryHash("#other=1")).toBeNull(); + }); + + it("returns null for an empty inventory parameter", () => { + expect(decodeInventoryHash("#i=")).toBeNull(); + }); + + it("reports a damaged link rather than throwing", () => { + // Lone '!' is not valid base64, so atob throws — and it must be caught in + // the same place a JSON failure would be. + expect(decodeInventoryHash("#i=!!!")).toEqual({ + inventory: EMPTY, + dropped: ["This link is damaged."], + }); + }); + + it("reports a link that decodes but is not an inventory", () => { + const result = decodeInventoryHash(`#i=${toBase64Url("42")}`); + expect(result?.dropped).toEqual(["Not an inventory."]); + }); +}); + +describe("browser ports", () => { + afterEach(() => { + vi.restoreAllMocks(); + window.location.hash = ""; + }); + + it("browserClipboard writes through navigator.clipboard", async () => { + const writeText = vi.fn<(text: string) => Promise>().mockResolvedValue(); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + await browserClipboard.writeText("payload"); + expect(writeText).toHaveBeenCalledWith("payload"); + }); + + it("browserSaveFile builds, clicks, and revokes an object URL", () => { + // jsdom has no createObjectURL, but the properties are assignable — so the + // real implementation can be exercised rather than mocked away wholesale. + const createObjectURL = vi.fn(() => "blob:kcd2"); + const revokeObjectURL = vi.fn(); + Object.defineProperty(URL, "createObjectURL", { value: createObjectURL, configurable: true }); + Object.defineProperty(URL, "revokeObjectURL", { value: revokeObjectURL, configurable: true }); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(function mockClick(this: HTMLAnchorElement) { + expect(this.download).toBe(EXPORT_FILENAME); + expect(this.getAttribute("href")).toBe("blob:kcd2"); + }); + + browserSaveFile(EXPORT_FILENAME, "{}"); + + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(click).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:kcd2"); + }); + + it("browserUrlHash reads the fragment", () => { + window.location.hash = "#i=abc"; + expect(browserUrlHash.read()).toBe("#i=abc"); + }); + + it("browserUrlHash clears the fragment without reloading", () => { + window.location.hash = "#i=abc"; + browserUrlHash.clear(); + expect(window.location.hash).toBe(""); + }); + + it("browserUrlHash builds a base URL with no fragment", () => { + window.location.hash = "#i=abc"; + expect(browserUrlHash.base()).toBe(window.location.origin + window.location.pathname); + expect(browserUrlHash.base()).not.toContain("#"); + }); +}); diff --git a/kcd2_dice_solver/src/lib/inventoryIo.test.ts b/kcd2_dice_solver/src/lib/inventoryIo.test.ts index a6d07802..011e7588 100644 --- a/kcd2_dice_solver/src/lib/inventoryIo.test.ts +++ b/kcd2_dice_solver/src/lib/inventoryIo.test.ts @@ -6,24 +6,14 @@ * below lean on malformed input rather than the happy path. */ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, } from "vitest"; import { EMPTY, - EXPORT_FILENAME, STORAGE_KEY, - browserClipboard, - browserSaveFile, - browserUrlHash, - decodeInventoryHash, - encodeInventoryHash, - fromBase64Url, loadInventory, parseInventory, parseInventoryJson, - serialiseInventory, - toBase64Url, } from "./inventoryIo.ts"; -import type { SavedInventory } from "./inventoryIo.ts"; /** * A storage stub holding one entry. @@ -172,131 +162,3 @@ describe("loadInventory", () => { ).toEqual(EMPTY); }); }); - -describe("serialiseInventory", () => { - it("round-trips through the parser", () => { - const inventory: SavedInventory = { - diceCounts: { weighted: 4 }, - badgeIds: ["tin_might"], - }; - expect(parseInventoryJson(serialiseInventory(inventory)).inventory).toEqual(inventory); - }); -}); - -describe("base64url", () => { - it("round-trips ASCII", () => { - expect(fromBase64Url(toBase64Url("hello world"))).toBe("hello world"); - }); - - it("really produces URL-safe characters, not merely claims to", () => { - // Standard base64 of these bytes contains both '+' and '/', so this - // exercises the substitutions rather than trivially covering the line. - const raw = "ÿï¿"; - const encoded = toBase64Url(raw); - expect(encoded).not.toMatch(/[+/=]/); - expect(encoded).toMatch(/[-_]/); - expect(fromBase64Url(encoded)).toBe(raw); - }); - - it("round-trips every padding length", () => { - for (const text of ["abc", "abcd", "abcde"]) { - expect(fromBase64Url(toBase64Url(text))).toBe(text); - } - }); -}); - -describe("inventory hash", () => { - const inventory: SavedInventory = { - diceCounts: { weighted: 2 }, - badgeIds: ["tin_might"], - }; - - it("round-trips an inventory", () => { - const result = decodeInventoryHash(encodeInventoryHash(inventory)); - expect(result?.inventory).toEqual(inventory); - expect(result?.dropped).toEqual([]); - }); - - it("decodes with or without the leading hash", () => { - const hash = encodeInventoryHash(inventory); - expect(decodeInventoryHash(hash.slice(1))?.inventory).toEqual(inventory); - }); - - it("returns null when there is no fragment", () => { - expect(decodeInventoryHash("")).toBeNull(); - }); - - it("returns null when the fragment carries something else", () => { - expect(decodeInventoryHash("#other=1")).toBeNull(); - }); - - it("returns null for an empty inventory parameter", () => { - expect(decodeInventoryHash("#i=")).toBeNull(); - }); - - it("reports a damaged link rather than throwing", () => { - // Lone '!' is not valid base64, so atob throws — and it must be caught in - // the same place a JSON failure would be. - expect(decodeInventoryHash("#i=!!!")).toEqual({ - inventory: EMPTY, - dropped: ["This link is damaged."], - }); - }); - - it("reports a link that decodes but is not an inventory", () => { - const result = decodeInventoryHash(`#i=${toBase64Url("42")}`); - expect(result?.dropped).toEqual(["Not an inventory."]); - }); -}); - -describe("browser ports", () => { - afterEach(() => { - vi.restoreAllMocks(); - window.location.hash = ""; - }); - - it("browserClipboard writes through navigator.clipboard", async () => { - const writeText = vi.fn<(text: string) => Promise>().mockResolvedValue(); - Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); - await browserClipboard.writeText("payload"); - expect(writeText).toHaveBeenCalledWith("payload"); - }); - - it("browserSaveFile builds, clicks, and revokes an object URL", () => { - // jsdom has no createObjectURL, but the properties are assignable — so the - // real implementation can be exercised rather than mocked away wholesale. - const createObjectURL = vi.fn(() => "blob:kcd2"); - const revokeObjectURL = vi.fn(); - Object.defineProperty(URL, "createObjectURL", { value: createObjectURL, configurable: true }); - Object.defineProperty(URL, "revokeObjectURL", { value: revokeObjectURL, configurable: true }); - const click = vi - .spyOn(HTMLAnchorElement.prototype, "click") - .mockImplementation(function mockClick(this: HTMLAnchorElement) { - expect(this.download).toBe(EXPORT_FILENAME); - expect(this.getAttribute("href")).toBe("blob:kcd2"); - }); - - browserSaveFile(EXPORT_FILENAME, "{}"); - - expect(createObjectURL).toHaveBeenCalledTimes(1); - expect(click).toHaveBeenCalledTimes(1); - expect(revokeObjectURL).toHaveBeenCalledWith("blob:kcd2"); - }); - - it("browserUrlHash reads the fragment", () => { - window.location.hash = "#i=abc"; - expect(browserUrlHash.read()).toBe("#i=abc"); - }); - - it("browserUrlHash clears the fragment without reloading", () => { - window.location.hash = "#i=abc"; - browserUrlHash.clear(); - expect(window.location.hash).toBe(""); - }); - - it("browserUrlHash builds a base URL with no fragment", () => { - window.location.hash = "#i=abc"; - expect(browserUrlHash.base()).toBe(window.location.origin + window.location.pathname); - expect(browserUrlHash.base()).not.toContain("#"); - }); -}); diff --git a/kcd2_dice_solver/src/test/fixtures.ts b/kcd2_dice_solver/src/test/fixtures.ts new file mode 100644 index 00000000..e29ca3d3 --- /dev/null +++ b/kcd2_dice_solver/src/test/fixtures.ts @@ -0,0 +1,35 @@ +/** + * Shared component-test fixtures. + * + * In src/test/ because vite.config.ts excludes that path from coverage; + * a fixture beside the source would be counted as production code and + * break the 100% gate. + */ + +import type { Die } from "../data/dice.ts"; +import type { SolveResponse } from "../core/solve.ts"; + +export const ordinary: Die = { + id: "ordinary", + name: "Ordinary die", + description: "An ordinary playing die.", + weights: [1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6], + wildcardFaces: [], + wildScoresAlone: false, +}; + +export const response: SolveResponse = { + dice: new Array(6).fill(ordinary), + evaluation: { ev: 399.1, bustProbability: 0.0309, p90: 900 }, + simulation: { + meanPerTurn: 412.5, + standardError: 3.2, + bustRate: 0.2, + throwsPerTurn: 1.4, + turns: 20000, + }, + optimal: true, + alternatives: [], + badges: [], + inventorySize: 6, +};