Files
initiative/apps/web/src/components/__tests__/action-bar.test.tsx
Lukas 2c643cc98b
All checks were successful
CI / check (push) Successful in 2m13s
CI / build-image (push) Has been skipped
Introduce adapter injection and migrate test suite
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>
2026-04-01 23:55:45 +02:00

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();
});
});
});