/** The App shell's core behaviour. */ /** * End-to-end tests for the app, driving the real flow: search for a die, click * to add it, scroll the wheel over a counter, and get a real recommendation. * * The worker is replaced with a port that runs the *real* solver synchronously — * jsdom has no module-worker support, but nothing about the solver is stubbed, * so these exercise the actual recommendation path. */ import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { App } from "./App.tsx"; import { STORAGE_KEY, } from "./lib/inventoryIo.ts"; import type { SavedInventory, UrlHashPort } from "./lib/inventoryIo.ts"; import { solve } from "./core/solve.ts"; import type { SolverPort } from "./hooks/useSolver.ts"; import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts"; /** * A solver port that runs the real solver in-process. * * @returns A port usable in place of the Web Worker. */ function createInlinePort(): SolverPort { const listeners = new Set<(event: MessageEvent) => void>(); return { postMessage: ({ id, request }: WorkerRequest) => { let reply: WorkerReply; try { // Keep the simulation short: these tests are about the wiring. reply = { id, ok: true, response: solve({ ...request, simulationTurns: 200 }) }; } catch (error) { reply = { id, ok: false, error: error instanceof Error ? error.message : "failed" }; } for (const listener of listeners) { listener({ data: reply } as MessageEvent); } }, addEventListener: (_type, listener) => listeners.add(listener), removeEventListener: (_type, listener) => listeners.delete(listener), }; } /** * An in-memory Storage stand-in, so tests do not share persisted state. * * @param initial - Optional starting contents. * @returns A minimal storage object. */ function memoryStorage(initial?: string): Pick { const store = new Map(); if (initial !== undefined) { store.set(STORAGE_KEY, initial); } return { getItem: (key) => store.get(key) ?? null, setItem: (key, value) => { store.set(key, value); }, }; } /** * Read back what the app persisted, typed so assertions are not on `any`. * * @param storage - The storage the app was given. * @returns The parsed inventory. */ function readSaved(storage: Pick): SavedInventory { return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory; } /** * A URL port that reports a fragment without touching the real location. * * @param hash - The fragment the page was "opened with". * @returns The port plus a record of whether the app cleared the fragment. */ function stubUrl(hash = ""): UrlHashPort & { cleared: () => number } { let clears = 0; return { read: () => hash, clear: () => { clears += 1; }, base: () => "https://dice.example/", cleared: () => clears, }; } const renderApp = (initial?: string, urlHash: UrlHashPort = stubUrl()) => render( Promise.resolve() }} saveFile={() => undefined} />, ); describe("App", () => { it("asks for dice before anything is entered", () => { renderApp(); expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument(); expect(screen.getByText("0 dice")).toBeInTheDocument(); }); it("finds a die by fuzzy search, adds six by clicking, and solves", async () => { renderApp(); await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); const row = screen.getAllByRole("listitem")[0]; const add = within(row).getByRole("button", { name: "Weighted die" }); for (let i = 0; i < 6; i += 1) { await userEvent.click(add); } expect(screen.getByText("6 dice")).toBeInTheDocument(); await waitFor(() => { expect(screen.getByText("Best loadout")).toBeInTheDocument(); }); // Six Weighted dice is the right answer and the panel must name it. expect(screen.getByText("6×")).toBeInTheDocument(); expect(screen.getByText("Weighted die")).toBeInTheDocument(); expect(screen.getByText(/Provably optimal/)).toBeInTheDocument(); }); it("adds dice by scrolling the wheel over the counter", async () => { renderApp(); await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); const counter = screen.getByLabelText("How many Weighted die"); const stepper = counter.parentElement; if (!stepper) { throw new Error("no stepper"); } for (let i = 0; i < 6; i += 1) { fireEvent.wheel(stepper, { deltaY: -120 }); } expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("6"); expect(screen.getByText("6 dice")).toBeInTheDocument(); fireEvent.wheel(stepper, { deltaY: 120 }); expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("5"); expect(screen.getByText("5 dice")).toBeInTheDocument(); }); it("accumulates a rapid burst of clicks and wheel ticks", async () => { // Driving the real app in headless Chrome caught this: six fast clicks used // to land as one, because each handler computed from the count captured at // render time. React batches faster than it re-renders. renderApp(); await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); const row = screen.getAllByRole("listitem")[0]; const add = within(row).getByRole("button", { name: "Weighted die" }); act(() => { for (let i = 0; i < 4; i += 1) { fireEvent.click(add); } }); expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("4"); const stepper = screen.getByLabelText("How many Weighted die").parentElement; if (!stepper) { throw new Error("no stepper"); } act(() => { for (let i = 0; i < 2; i += 1) { fireEvent.wheel(stepper, { deltaY: -120 }); } }); expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("6"); expect(screen.getByText("6 dice")).toBeInTheDocument(); }); it("ranks the badges you own alongside the dice", async () => { const saved = JSON.stringify({ diceCounts: { weighted: 6 }, badgeIds: ["gold_emperors", "gold_defence"], }); renderApp(saved); await waitFor(() => { expect(screen.getByText("Best badge per tier")).toBeInTheDocument(); }); // The name also appears in the ownership checkbox, so scope to the result. const results = document.querySelector(".result"); if (!results) { throw new Error("no result panel"); } expect(within(results as HTMLElement).getByText("Gold Emperor's badge")).toBeInTheDocument(); // The Defence badge is owned too, and must be reported as situational // rather than given an invented number. expect(within(results as HTMLElement).getByText(/situational/)).toBeInTheDocument(); }); it("restores a saved inventory on load", () => { renderApp(JSON.stringify({ diceCounts: { weighted: 4 }, badgeIds: [] })); expect(screen.getByText("4 dice")).toBeInTheDocument(); }); it("persists changes for next time", async () => { const storage = memoryStorage(); render(); await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); await userEvent.click(screen.getByLabelText("Add one Weighted die")); expect(readSaved(storage)).toEqual({ diceCounts: { weighted: 1 }, badgeIds: [] }); }); it("clears everything on request", async () => { renderApp(JSON.stringify({ diceCounts: { weighted: 6 }, badgeIds: ["tin_might"] })); await waitFor(() => { expect(screen.getByText("Best loadout")).toBeInTheDocument(); }); await userEvent.click(screen.getByRole("button", { name: "Clear" })); expect(screen.getByText("0 dice")).toBeInTheDocument(); expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument(); }); });