mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-14 06:50:55 +01:00
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
228 lines
8.1 KiB
XML
228 lines
8.1 KiB
XML
/** 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();
|
||
});
|
||
});
|