mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 13:07:49 +01:00
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:
parent
5a27acd9d0
commit
776e04db89
18
docs/superpowers/contracts/kcd2-test-split-2026-08-16.json
Normal file
18
docs/superpowers/contracts/kcd2-test-split-2026-08-16.json
Normal 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."
|
||||
}
|
||||
46
docs/superpowers/evidence/kcd2-test-split-2026-08-16.json
Normal file
46
docs/superpowers/evidence/kcd2-test-split-2026-08-16.json
Normal 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."
|
||||
]
|
||||
}
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
249
kcd2_dice_solver/src/AppLinks.test.tsx
Normal file
249
kcd2_dice_solver/src/AppLinks.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
173
kcd2_dice_solver/src/AppRecovery.test.tsx
Normal file
173
kcd2_dice_solver/src/AppRecovery.test.tsx
Normal 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 });
|
||||
});
|
||||
});
|
||||
227
kcd2_dice_solver/src/AppShell.test.tsx
Normal file
227
kcd2_dice_solver/src/AppShell.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
180
kcd2_dice_solver/src/AppStorage.test.tsx
Normal file
180
kcd2_dice_solver/src/AppStorage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
164
kcd2_dice_solver/src/components/componentInventory.test.tsx
Normal file
164
kcd2_dice_solver/src/components/componentInventory.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
71
kcd2_dice_solver/src/components/componentPickers.test.tsx
Normal file
71
kcd2_dice_solver/src/components/componentPickers.test.tsx
Normal 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
145
kcd2_dice_solver/src/components/componentResult.test.tsx
Normal file
145
kcd2_dice_solver/src/components/componentResult.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
120
kcd2_dice_solver/src/components/componentRows.test.tsx
Normal file
120
kcd2_dice_solver/src/components/componentRows.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
198
kcd2_dice_solver/src/core/simulateTurns.test.ts
Normal file
198
kcd2_dice_solver/src/core/simulateTurns.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
160
kcd2_dice_solver/src/lib/inventoryHash.test.ts
Normal file
160
kcd2_dice_solver/src/lib/inventoryHash.test.ts
Normal 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("#");
|
||||
});
|
||||
});
|
||||
@ -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("#");
|
||||
});
|
||||
});
|
||||
|
||||
35
kcd2_dice_solver/src/test/fixtures.ts
Normal file
35
kcd2_dice_solver/src/test/fixtures.ts
Normal 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,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user