testsAndMisc/kcd2_dice_solver/src/App.test.tsx
Krzysztof kuhy Rudnicki 9d7751cebb
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
kcd2 dice solver: serve it at dice.kuhy.duckdns.org, fit all 43 dice, move inventories between devices
The solver only ran behind ./run.sh, had one media query in the whole
stylesheet, and kept the inventory in a 46vh scroll box that showed maybe a
third of the 43 dice. Setting it up on the PC did nothing for the phone.

Deployment
  dice.kuhy.duckdns.org, behind the existing gitea-caddy edge. DuckDNS already
  wildcards *.kuhy.duckdns.org (checked against 1.1.1.1 and 8.8.8.8), so no
  dashboard step. deploy_build.sh builds to dist.staging and rsyncs into dist/
  with --delete-after --delay-updates; plain --delete removes stale assets
  before writing the new ones, which 404s a client mid-load. A post-commit hook
  starts a MemorySwapMax=0 systemd user unit, so a rebuild can neither slow a
  commit nor thrash zram.

  Three traps found by driving the live URL in a browser rather than curling it:
  a site address of "127.0.0.1:8089" reads as a HOSTNAME, so Caddy enabled
  auto-HTTPS and started an HTTP->HTTPS redirect listener that bound *:80 next
  to the edge -- SO_REUSEPORT would then split inbound HTTP and intermittently
  break ACME for every domain here. The same address also made a Host matcher,
  so the edge's forwarded Host matched nothing and served an empty 200 while the
  local health check passed. And watching dist/'s own inode could never detect a
  detached bind mount: rsync into an existing directory never changes it. Each
  now has a guard that asserts positively rather than failing open.

Dice list
  All 43 on screen at every width, no inner scrollbar. Column count and size
  tier are chosen together from a container query on the panel -- not the
  viewport, which mispredicts by 2x at the point the columns split. Two
  invariants govern it: the list fits above the fold, and the name column stays
  wide enough to tell Painter's die B, G and R apart. The left column holds the
  grid alone, so it is sized to fill the height it owns: one --die-row value
  drives the type, buttons and bar strip, giving a 52px row and 44px buttons on
  a 1296px-tall screen and 20px rows on a phone.

  Three safe-design rules are broken inside .die-list deliberately: sub-16px
  body text, square icon buttons, sub-8px intra-cell gaps. 43 items with a name,
  a chart and a counter in one viewport is arithmetically incompatible with 16px.

Import / export
  A file, the clipboard, or a link carrying the inventory in the URL fragment.
  One validator behind all four entry points including localStorage; it keeps
  what it can and reports what it dropped, so a die added in a later game patch
  costs you that entry, not the inventory. A link never silently overwrites what
  is saved here -- it offers Keep or Discard, and only a link that actually
  carries dice is applied at all, because one encoding a count of zero parsed
  cleanly, blanked the screen, disabled Clear and left Keep writing the emptiness
  over the visitor's own loadout.

session_autopsy
  parse.py (551) and report.py (504) were over the repo's 500-line limit, so
  test_file_length has been failing on main since 5ffd035. Extracted TurnTracker
  and the two candidate-cell helpers. Report output is byte-identical on real
  data apart from its timestamp, checked against HEAD in a worktree.

Verified: 288 TS tests and 911 Python tests, 100% coverage both, lint and
pre-commit clean, 8 viewports measured on the live HTTPS site, solver confirmed
through the real module worker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:01:21 +02:00

513 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 });
});
});