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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 20:03:33 +02:00
parent 5a27acd9d0
commit 776e04db89
17 changed files with 1789 additions and 1236 deletions

View File

@ -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."
}

View File

@ -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."
]
}

View File

@ -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<WorkerReply>) => 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<WorkerReply>);
}
},
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<Storage, "getItem" | "setItem"> {
const store = new Map<string, string>();
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<Storage, "getItem">): 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(
<App
createPort={createInlinePort}
storage={memoryStorage(initial)}
urlHash={urlHash}
clipboard={{ writeText: () => 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(<App createPort={createInlinePort} storage={storage} />);
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(<App createPort={createInlinePort} />);
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(<App createPort={createInlinePort} storage={storage} />);
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(<App createPort={createInlinePort} storage={storage} />);
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<WorkerReply>) => 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(
<App
createPort={failingPort}
storage={memoryStorage(JSON.stringify({ diceCounts: { weighted: 6 }, badgeIds: [] }))}
/>,
);
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(
<App
createPort={createInlinePort}
storage={storage}
urlHash={stubUrl(shared)}
clipboard={{ writeText: () => 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(
<App
createPort={createInlinePort}
storage={storage}
urlHash={stubUrl(shared)}
clipboard={{ writeText: () => 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(
<App
createPort={createInlinePort}
storage={storage}
urlHash={stubUrl(shared)}
clipboard={{ writeText: () => 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(
<App createPort={createInlinePort} storage={storage} urlHash={stubUrl()} />,
);
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(
<App createPort={createInlinePort} storage={storage} urlHash={stubUrl(empty)} />,
);
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(
<App createPort={createInlinePort} storage={storage} urlHash={stubUrl(link)} />,
);
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(<App createPort={createInlinePort} storage={storage} urlHash={stubUrl()} />);
// 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(<App createPort={createInlinePort} storage={storage} urlHash={stubUrl()} />);
expect(screen.getByText(/Unknown die “future_patch_die”/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Keep" }));
expect(readSaved(storage).diceCounts).toEqual({ lucky: 4 });
});
});

View File

@ -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<WorkerReply>) => 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<WorkerReply>);
}
},
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<Storage, "getItem" | "setItem"> {
const store = new Map<string, string>();
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<Storage, "getItem">): 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(
<App
createPort={createInlinePort}
storage={memoryStorage(initial)}
urlHash={urlHash}
clipboard={{ writeText: () => 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(
<App
createPort={createInlinePort}
storage={storage}
urlHash={stubUrl(shared)}
clipboard={{ writeText: () => 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(
<App
createPort={createInlinePort}
storage={storage}
urlHash={stubUrl(shared)}
clipboard={{ writeText: () => 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(
<App
createPort={createInlinePort}
storage={storage}
urlHash={stubUrl(shared)}
clipboard={{ writeText: () => 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(
<App createPort={createInlinePort} storage={storage} urlHash={stubUrl()} />,
);
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();
});
});

View File

@ -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<WorkerReply>) => 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<WorkerReply>);
}
},
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<Storage, "getItem" | "setItem"> {
const store = new Map<string, string>();
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<Storage, "getItem">): 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(
<App
createPort={createInlinePort}
storage={memoryStorage(initial)}
urlHash={urlHash}
clipboard={{ writeText: () => 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(
<App createPort={createInlinePort} storage={storage} urlHash={stubUrl(empty)} />,
);
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(
<App createPort={createInlinePort} storage={storage} urlHash={stubUrl(link)} />,
);
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(<App createPort={createInlinePort} storage={storage} urlHash={stubUrl()} />);
// 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(<App createPort={createInlinePort} storage={storage} urlHash={stubUrl()} />);
expect(screen.getByText(/Unknown die “future_patch_die”/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Keep" }));
expect(readSaved(storage).diceCounts).toEqual({ lucky: 4 });
});
});

View File

@ -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<WorkerReply>) => 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<WorkerReply>);
}
},
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<Storage, "getItem" | "setItem"> {
const store = new Map<string, string>();
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<Storage, "getItem">): 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(
<App
createPort={createInlinePort}
storage={memoryStorage(initial)}
urlHash={urlHash}
clipboard={{ writeText: () => 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(<App createPort={createInlinePort} storage={storage} />);
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();
});
});

View File

@ -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<WorkerReply>) => 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<WorkerReply>);
}
},
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<Storage, "getItem" | "setItem"> {
const store = new Map<string, string>();
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<Storage, "getItem">): 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(
<App
createPort={createInlinePort}
storage={memoryStorage(initial)}
urlHash={urlHash}
clipboard={{ writeText: () => 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(<App createPort={createInlinePort} />);
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(<App createPort={createInlinePort} storage={storage} />);
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(<App createPort={createInlinePort} storage={storage} />);
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<WorkerReply>) => 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(
<App
createPort={failingPort}
storage={memoryStorage(JSON.stringify({ diceCounts: { weighted: 6 }, badgeIds: [] }))}
/>,
);
await waitFor(() => {
expect(screen.getByText("solver exploded")).toBeInTheDocument();
});
});
});

View File

@ -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<InventoryIoProps> = {}) {
const onImport = vi.fn();
const saveFile = vi.fn();
const clipboard = { writeText: vi.fn<(text: string) => Promise<void>>().mockResolvedValue() };
render(
<InventoryIo
inventory={inventory}
onImport={onImport}
clipboard={clipboard}
saveFile={saveFile}
shareUrl="https://dice.example/#i=abc"
{...overrides}
/>,
);
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<void>>().mockRejectedValue(new Error("denied")),
};
setup({ clipboard });
await userEvent.click(screen.getByRole("button", { name: "Copy JSON" }));
await screen.findByText(/Copy failed/);
const box = screen.getByLabelText<HTMLTextAreaElement>("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();
});
});

View File

@ -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(<BadgePicker query="" owned={new Set()} onToggle={vi.fn()} />);
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(<BadgePicker query="emperor" owned={new Set()} onToggle={vi.fn()} />);
expect(screen.queryByRole("heading", { name: "tin" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "gold" })).toBeInTheDocument();
});
it("reflects which badges are owned", () => {
render(<BadgePicker query="emperor" owned={new Set(["gold_emperors"])} onToggle={vi.fn()} />);
expect(screen.getByRole("checkbox")).toBeChecked();
});
it("reports a toggle in both directions", async () => {
const onToggle = vi.fn();
const { rerender } = render(
<BadgePicker query="emperor" owned={new Set()} onToggle={onToggle} />,
);
await userEvent.click(screen.getByRole("checkbox"));
expect(onToggle).toHaveBeenLastCalledWith("gold_emperors", true);
rerender(
<BadgePicker query="emperor" owned={new Set(["gold_emperors"])} onToggle={onToggle} />,
);
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 },
]);
});
});

View File

@ -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(<ResultPanel result={null} error={null} solving={false} />);
expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument();
});
it("says it is working while a solve is in flight", () => {
render(<ResultPanel result={null} error={null} solving />);
expect(screen.getByText("Solving…")).toBeInTheDocument();
});
it("shows an error instead of a stale answer", () => {
render(<ResultPanel result={null} error="Need at least 6 dice" solving={false} />);
expect(screen.getByText("Need at least 6 dice")).toBeInTheDocument();
});
it("shows the loadout and its numbers", () => {
render(<ResultPanel result={response} error={null} solving={false} />);
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(<ResultPanel result={response} error={null} solving={false} />);
expect(screen.getByText(/Provably optimal/)).toBeInTheDocument();
});
it("admits when the answer is only a local search result", () => {
render(
<ResultPanel result={{ ...response, optimal: false }} error={null} solving={false} />,
);
expect(screen.getByText(/not proven/)).toBeInTheDocument();
});
it("lists runners-up when there are any", () => {
const withAlternatives: SolveResponse = {
...response,
alternatives: [
{ dice: new Array<Die>(6).fill(ordinary), evaluation: response.evaluation },
],
};
render(<ResultPanel result={withAlternatives} error={null} solving={false} />);
expect(screen.getByText("Runners-up")).toBeInTheDocument();
});
it("asks for badges when none are owned", () => {
render(<ResultPanel result={response} error={null} solving={false} />);
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<Die>(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(<ResultPanel result={withBadges} error={null} solving={false} />);
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(
<ResultPanel result={response} error={null} solving />,
);
expect(container.querySelector(".result")).toHaveClass("stale");
});
});

View File

@ -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(<DieRow die={ordinary} count={0} highlight={[]} onChange={onChange} />);
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(
<DieRow die={ordinary} count={MAX_PER_DIE} highlight={[]} onChange={onChange} />,
);
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(
<DieRow die={ordinary} count={2} highlight={[]} onChange={vi.fn()} />,
);
expect(container.querySelector(".die-row")).toHaveClass("owned");
});
it("tags the wildcard dice", () => {
const balatro: Die = {
...ordinary,
id: "balatro",
wildcardFaces: [1],
wildScoresAlone: true,
};
render(<DieRow die={balatro} count={0} highlight={[]} onChange={vi.fn()} />);
expect(screen.getByText("wild")).toBeInTheDocument();
});
});
describe("DiceList", () => {
it("shows every die when the query is empty", () => {
render(<DiceList query="" counts={{}} onChange={vi.fn()} />);
expect(screen.getAllByRole("listitem")).toHaveLength(43);
});
it("narrows to the Weighted die on a three-letter query", () => {
render(<DiceList query="wei" counts={{}} onChange={vi.fn()} />);
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(<DiceList query="wei" counts={{}} onChange={vi.fn()} />);
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(<DiceList query="grozav" counts={{}} onChange={vi.fn()} />);
expect(screen.getAllByRole("listitem")).toHaveLength(1);
});
it("says so when nothing matches", () => {
render(<DiceList query="qqqqq" counts={{}} onChange={vi.fn()} />);
expect(screen.getByText(/No die matches/)).toBeInTheDocument();
});
it("passes the owned count through to the row", () => {
render(<DiceList query="wei" counts={{ weighted: 4 }} onChange={vi.fn()} />);
expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("4");
});
it("reports which die changed", async () => {
const onChange = vi.fn();
render(<DiceList query="wei" counts={{}} onChange={onChange} />);
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);
});
});

View File

@ -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(<DieRow die={ordinary} count={0} highlight={[]} onChange={onChange} />);
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(
<DieRow die={ordinary} count={MAX_PER_DIE} highlight={[]} onChange={onChange} />,
);
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(
<DieRow die={ordinary} count={2} highlight={[]} onChange={vi.fn()} />,
);
expect(container.querySelector(".die-row")).toHaveClass("owned");
});
it("tags the wildcard dice", () => {
const balatro: Die = {
...ordinary,
id: "balatro",
wildcardFaces: [1],
wildScoresAlone: true,
};
render(<DieRow die={balatro} count={0} highlight={[]} onChange={vi.fn()} />);
expect(screen.getByText("wild")).toBeInTheDocument();
});
});
describe("DiceList", () => {
it("shows every die when the query is empty", () => {
render(<DiceList query="" counts={{}} onChange={vi.fn()} />);
expect(screen.getAllByRole("listitem")).toHaveLength(43);
});
it("narrows to the Weighted die on a three-letter query", () => {
render(<DiceList query="wei" counts={{}} onChange={vi.fn()} />);
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(<DiceList query="wei" counts={{}} onChange={vi.fn()} />);
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(<DiceList query="grozav" counts={{}} onChange={vi.fn()} />);
expect(screen.getAllByRole("listitem")).toHaveLength(1);
});
it("says so when nothing matches", () => {
render(<DiceList query="qqqqq" counts={{}} onChange={vi.fn()} />);
expect(screen.getByText(/No die matches/)).toBeInTheDocument();
});
it("passes the owned count through to the row", () => {
render(<DiceList query="wei" counts={{ weighted: 4 }} onChange={vi.fn()} />);
expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("4");
});
it("reports which die changed", async () => {
const onChange = vi.fn();
render(<DiceList query="wei" counts={{}} onChange={onChange} />);
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(<BadgePicker query="" owned={new Set()} onToggle={vi.fn()} />);
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(<BadgePicker query="emperor" owned={new Set()} onToggle={vi.fn()} />);
expect(screen.queryByRole("heading", { name: "tin" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "gold" })).toBeInTheDocument();
});
it("reflects which badges are owned", () => {
render(<BadgePicker query="emperor" owned={new Set(["gold_emperors"])} onToggle={vi.fn()} />);
expect(screen.getByRole("checkbox")).toBeChecked();
});
it("reports a toggle in both directions", async () => {
const onToggle = vi.fn();
const { rerender } = render(
<BadgePicker query="emperor" owned={new Set()} onToggle={onToggle} />,
);
await userEvent.click(screen.getByRole("checkbox"));
expect(onToggle).toHaveBeenLastCalledWith("gold_emperors", true);
rerender(
<BadgePicker query="emperor" owned={new Set(["gold_emperors"])} onToggle={onToggle} />,
);
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<Die>(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(<ResultPanel result={null} error={null} solving={false} />);
expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument();
});
it("says it is working while a solve is in flight", () => {
render(<ResultPanel result={null} error={null} solving />);
expect(screen.getByText("Solving…")).toBeInTheDocument();
});
it("shows an error instead of a stale answer", () => {
render(<ResultPanel result={null} error="Need at least 6 dice" solving={false} />);
expect(screen.getByText("Need at least 6 dice")).toBeInTheDocument();
});
it("shows the loadout and its numbers", () => {
render(<ResultPanel result={response} error={null} solving={false} />);
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(<ResultPanel result={response} error={null} solving={false} />);
expect(screen.getByText(/Provably optimal/)).toBeInTheDocument();
});
it("admits when the answer is only a local search result", () => {
render(
<ResultPanel result={{ ...response, optimal: false }} error={null} solving={false} />,
);
expect(screen.getByText(/not proven/)).toBeInTheDocument();
});
it("lists runners-up when there are any", () => {
const withAlternatives: SolveResponse = {
...response,
alternatives: [
{ dice: new Array<Die>(6).fill(ordinary), evaluation: response.evaluation },
],
};
render(<ResultPanel result={withAlternatives} error={null} solving={false} />);
expect(screen.getByText("Runners-up")).toBeInTheDocument();
});
it("asks for badges when none are owned", () => {
render(<ResultPanel result={response} error={null} solving={false} />);
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<Die>(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(<ResultPanel result={withBadges} error={null} solving={false} />);
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(
<ResultPanel result={response} error={null} solving />,
);
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<InventoryIoProps> = {}) {
const onImport = vi.fn();
const saveFile = vi.fn();
const clipboard = { writeText: vi.fn<(text: string) => Promise<void>>().mockResolvedValue() };
render(
<InventoryIo
inventory={inventory}
onImport={onImport}
clipboard={clipboard}
saveFile={saveFile}
shareUrl="https://dice.example/#i=abc"
{...overrides}
/>,
);
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<void>>().mockRejectedValue(new Error("denied")),
};
setup({ clipboard });
await userEvent.click(screen.getByRole("button", { name: "Copy JSON" }));
await screen.findByText(/Copy failed/);
const box = screen.getByLabelText<HTMLTextAreaElement>("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();
});
});

View File

@ -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<Die>(6).fill(die("ordinary"));
/**
* A die that always rolls the given face, for pinning deterministic behaviour.
@ -38,18 +24,7 @@ const sixOrdinary = new Array<Die>(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<Die>(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<Die>(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<Die>(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);
});
});

View File

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

View File

@ -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<void>>().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("#");
});
});

View File

@ -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<void>>().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("#");
});
});

View File

@ -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<Die>(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,
};