Replace direct adapter/persistence imports with context-based injection (AdapterContext + useAdapters) so tests use in-memory implementations instead of vi.mock. Migrate component tests from context mocking to AllProviders with real hooks. Extract export/import logic from ActionBar into useEncounterExportImport hook. Add bestiary-cache and bestiary-index-adapter test suites. Raise adapter coverage thresholds (68→80 lines, 56→62 branches). 77 test files, 891 tests, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
376 lines
11 KiB
TypeScript
376 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
import "@testing-library/jest-dom/vitest";
|
|
|
|
import { playerCharacterId } from "@initiative/domain";
|
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { ReactNode } from "react";
|
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
|
import { ActionBar } from "../action-bar.js";
|
|
|
|
// DOM API stubs — jsdom doesn't implement these
|
|
beforeAll(() => {
|
|
Object.defineProperty(globalThis, "matchMedia", {
|
|
writable: true,
|
|
value: vi.fn().mockImplementation((query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})),
|
|
});
|
|
polyfillDialog();
|
|
});
|
|
|
|
afterEach(cleanup);
|
|
|
|
function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
|
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
|
}
|
|
|
|
function renderBarWithBestiary(
|
|
props: Partial<Parameters<typeof ActionBar>[0]> = {},
|
|
) {
|
|
const adapters = createTestAdapters();
|
|
adapters.bestiaryIndex = {
|
|
...adapters.bestiaryIndex,
|
|
loadIndex: () => ({
|
|
sources: { MM: "Monster Manual" },
|
|
creatures: [
|
|
{
|
|
name: "Goblin",
|
|
source: "MM",
|
|
ac: 15,
|
|
hp: 7,
|
|
dex: 14,
|
|
cr: "1/4",
|
|
initiativeProficiency: 0,
|
|
size: "Small",
|
|
type: "humanoid",
|
|
},
|
|
{
|
|
name: "Golem, Iron",
|
|
source: "MM",
|
|
ac: 20,
|
|
hp: 210,
|
|
dex: 9,
|
|
cr: "16",
|
|
initiativeProficiency: 0,
|
|
size: "Large",
|
|
type: "construct",
|
|
},
|
|
],
|
|
}),
|
|
getSourceDisplayName: (code: string) =>
|
|
code === "MM" ? "Monster Manual" : code,
|
|
};
|
|
return render(<ActionBar {...props} />, {
|
|
wrapper: ({ children }: { children: ReactNode }) => (
|
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
|
),
|
|
});
|
|
}
|
|
|
|
function renderBarWithPCs(
|
|
props: Partial<Parameters<typeof ActionBar>[0]> = {},
|
|
) {
|
|
const adapters = createTestAdapters({
|
|
playerCharacters: [
|
|
{
|
|
id: playerCharacterId("pc-1"),
|
|
name: "Gandalf",
|
|
ac: 15,
|
|
maxHp: 40,
|
|
},
|
|
],
|
|
});
|
|
adapters.bestiaryIndex = {
|
|
...adapters.bestiaryIndex,
|
|
loadIndex: () => ({
|
|
sources: { MM: "Monster Manual" },
|
|
creatures: [
|
|
{
|
|
name: "Goblin",
|
|
source: "MM",
|
|
ac: 15,
|
|
hp: 7,
|
|
dex: 14,
|
|
cr: "1/4",
|
|
initiativeProficiency: 0,
|
|
size: "Small",
|
|
type: "humanoid",
|
|
},
|
|
],
|
|
}),
|
|
getSourceDisplayName: (code: string) =>
|
|
code === "MM" ? "Monster Manual" : code,
|
|
};
|
|
return render(<ActionBar {...props} />, {
|
|
wrapper: ({ children }: { children: ReactNode }) => (
|
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
|
),
|
|
});
|
|
}
|
|
|
|
describe("ActionBar", () => {
|
|
describe("basic rendering and custom add", () => {
|
|
it("renders input with placeholder '+ Add combatants'", () => {
|
|
renderBar();
|
|
expect(
|
|
screen.getByPlaceholderText("+ Add combatants"),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("submitting with a name adds a combatant", async () => {
|
|
const user = userEvent.setup();
|
|
renderBar();
|
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
|
await user.type(input, "Goblin");
|
|
const addButton = screen.getByRole("button", { name: "Add" });
|
|
await user.click(addButton);
|
|
expect(input).toHaveValue("");
|
|
});
|
|
|
|
it("submitting with empty name does nothing", async () => {
|
|
const user = userEvent.setup();
|
|
renderBar();
|
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
|
await user.type(input, "{Enter}");
|
|
expect(input).toHaveValue("");
|
|
});
|
|
|
|
it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => {
|
|
const user = userEvent.setup();
|
|
renderBar();
|
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
|
await user.type(input, "Go");
|
|
expect(screen.getByPlaceholderText("Init")).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText("AC")).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText("MaxHP")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows Add button when name >= 2 chars and no suggestions", async () => {
|
|
const user = userEvent.setup();
|
|
renderBar();
|
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
|
await user.type(input, "Go");
|
|
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("submits custom stats with combatant", async () => {
|
|
const user = userEvent.setup();
|
|
renderBar();
|
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
|
await user.type(input, "Fighter");
|
|
await user.type(screen.getByPlaceholderText("Init"), "15");
|
|
await user.type(screen.getByPlaceholderText("AC"), "18");
|
|
await user.type(screen.getByPlaceholderText("MaxHP"), "45");
|
|
await user.click(screen.getByRole("button", { name: "Add" }));
|
|
expect(input).toHaveValue("");
|
|
});
|
|
});
|
|
|
|
describe("bestiary suggestions and queuing", () => {
|
|
it("shows bestiary suggestions when typing a matching name", async () => {
|
|
const user = userEvent.setup();
|
|
renderBarWithBestiary();
|
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
|
await user.type(input, "Go");
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
|
});
|
|
expect(screen.getByText("Golem, Iron")).toBeInTheDocument();
|
|
});
|
|
|
|
it("clicking a suggestion queues it with count badge", async () => {
|
|
const user = userEvent.setup();
|
|
renderBarWithBestiary();
|
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
|
await user.type(input, "Go");
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
|
});
|
|
|
|
// Click the Goblin suggestion
|
|
await user.click(screen.getByText("Goblin"));
|
|
|
|
// Should show count badge "1"
|
|
expect(screen.getByText("1")).toBeInTheDocument();
|
|
});
|
|
|
|
it("clicking same suggestion again increments count", async () => {
|
|
const user = userEvent.setup();
|
|
renderBarWithBestiary();
|
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
|
await user.type(input, "Go");
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText("Goblin"));
|
|
await user.click(screen.getByText("Goblin"));
|
|
|
|
expect(screen.getByText("2")).toBeInTheDocument();
|
|
});
|
|
|
|
it("confirming queued creatures adds them to the encounter", async () => {
|
|
const user = userEvent.setup();
|
|
renderBarWithBestiary();
|
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
|
await user.type(input, "Go");
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
|
});
|
|
|
|
// Queue 1 Goblin
|
|
await user.click(screen.getByText("Goblin"));
|
|
|
|
// Press Enter to confirm the queued creature
|
|
await user.keyboard("{Enter}");
|
|
|
|
// Input should be cleared after confirming
|
|
expect(input).toHaveValue("");
|
|
});
|
|
|
|
it("clears queued when search text no longer matches", async () => {
|
|
const user = userEvent.setup();
|
|
renderBarWithBestiary();
|
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
|
await user.type(input, "Go");
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText("Goblin"));
|
|
expect(screen.getByText("1")).toBeInTheDocument();
|
|
|
|
// Change search to something with no matches
|
|
await user.clear(input);
|
|
await user.type(input, "xyz");
|
|
|
|
// Count badge should be gone
|
|
expect(screen.queryByText("1")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("player character matching", () => {
|
|
it("shows matching player characters in suggestions", async () => {
|
|
const user = userEvent.setup();
|
|
renderBarWithPCs();
|
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
|
await user.type(input, "Gan");
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Gandalf")).toBeInTheDocument();
|
|
});
|
|
expect(screen.getByText("Player")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("browse mode", () => {
|
|
it("toggles browse mode via eye icon button", async () => {
|
|
const user = userEvent.setup();
|
|
renderBarWithBestiary();
|
|
|
|
const browseButton = screen.getByRole("button", {
|
|
name: "Browse stat blocks",
|
|
});
|
|
await user.click(browseButton);
|
|
|
|
expect(
|
|
screen.getByPlaceholderText("Search stat blocks..."),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("button", { name: "Switch to add mode" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("browse mode shows suggestions without add UI", async () => {
|
|
const user = userEvent.setup();
|
|
renderBarWithBestiary();
|
|
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Browse stat blocks" }),
|
|
);
|
|
const input = screen.getByPlaceholderText("Search stat blocks...");
|
|
await user.type(input, "Go");
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
|
});
|
|
// No Add button in browse mode
|
|
expect(
|
|
screen.queryByRole("button", { name: "Add" }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("overflow menu", () => {
|
|
it("does not show roll all initiative button when no creature combatants", () => {
|
|
renderBar();
|
|
expect(
|
|
screen.queryByRole("button", { name: "Roll all initiative" }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows overflow menu items", () => {
|
|
renderBar({ onManagePlayers: vi.fn() });
|
|
expect(
|
|
screen.getByRole("button", { name: "More actions" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("opens export method dialog via overflow menu", async () => {
|
|
const user = userEvent.setup();
|
|
renderBar();
|
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
|
const items = screen.getAllByText("Export Encounter");
|
|
await user.click(items[0]);
|
|
expect(
|
|
screen.getAllByText("Export Encounter").length,
|
|
).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("opens import method dialog via overflow menu", async () => {
|
|
const user = userEvent.setup();
|
|
renderBar();
|
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
|
const items = screen.getAllByText("Import Encounter");
|
|
await user.click(items[0]);
|
|
expect(
|
|
screen.getAllByText("Import Encounter").length,
|
|
).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("calls onManagePlayers from overflow menu", async () => {
|
|
const onManagePlayers = vi.fn();
|
|
const user = userEvent.setup();
|
|
renderBar({ onManagePlayers });
|
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
|
await user.click(screen.getByText("Player Characters"));
|
|
expect(onManagePlayers).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("calls onOpenSettings from overflow menu", async () => {
|
|
const onOpenSettings = vi.fn();
|
|
const user = userEvent.setup();
|
|
renderBar({ onOpenSettings });
|
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
|
await user.click(screen.getByText("Settings"));
|
|
expect(onOpenSettings).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
});
|