Introduce adapter injection and migrate test suite
All checks were successful
CI / check (push) Successful in 2m13s
CI / build-image (push) Has been skipped

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>
This commit is contained in:
Lukas
2026-04-01 23:55:45 +02:00
parent 228c1c667f
commit 2c643cc98b
42 changed files with 1879 additions and 1190 deletions

View File

@@ -1,41 +1,16 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
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";
// Mock persistence — no localStorage interaction
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: () => null,
saveEncounter: () => {},
}));
vi.mock("../../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: () => [],
savePlayerCharacters: () => {},
}));
// Mock bestiary — no IndexedDB or JSON index
vi.mock("../../adapters/bestiary-cache.js", () => ({
loadAllCachedCreatures: () => Promise.resolve(new Map()),
isSourceCached: () => Promise.resolve(false),
cacheSource: () => Promise.resolve(),
getCachedSources: () => Promise.resolve([]),
clearSource: () => Promise.resolve(),
clearAll: () => Promise.resolve(),
}));
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
// DOM API stubs — jsdom doesn't implement these
beforeAll(() => {
Object.defineProperty(globalThis, "matchMedia", {
@@ -60,121 +35,341 @@ 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", () => {
it("renders input with placeholder '+ Add combatants'", () => {
renderBar();
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
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("");
});
});
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");
// The Add button appears when name >= 2 chars and no suggestions
const addButton = screen.getByRole("button", { name: "Add" });
await user.click(addButton);
// Input is cleared after adding (context handles the state)
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();
});
});
it("submitting with empty name does nothing", async () => {
const user = userEvent.setup();
renderBar();
// Submit the form directly (Enter on empty input)
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "{Enter}");
// Input stays empty, no error
expect(input).toHaveValue("");
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();
});
});
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();
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();
});
});
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();
});
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("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("shows overflow menu items", () => {
renderBar({ onManagePlayers: vi.fn() });
// The overflow menu should be present (it contains Player Characters etc.)
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 export method dialog via overflow menu", async () => {
const user = userEvent.setup();
renderBar();
await user.click(screen.getByRole("button", { name: "More actions" }));
// Click the menu item
const items = screen.getAllByText("Export Encounter");
await user.click(items[0]);
// Dialog should now be open — it renders a second "Export Encounter" as heading
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("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 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();
});
it("submits custom stats with combatant", async () => {
const user = userEvent.setup();
renderBar();
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "Fighter");
const initInput = screen.getByPlaceholderText("Init");
const acInput = screen.getByPlaceholderText("AC");
const hpInput = screen.getByPlaceholderText("MaxHP");
await user.type(initInput, "15");
await user.type(acInput, "18");
await user.type(hpInput, "45");
await user.click(screen.getByRole("button", { name: "Add" }));
expect(input).toHaveValue("");
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();
});
});
});

View File

@@ -4,6 +4,8 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
import { AdapterProvider } from "../../contexts/adapter-context.js";
import { BulkImportPrompt } from "../bulk-import-prompt.js";
const THREE_SOURCES_REGEX = /3 sources/;
@@ -28,6 +30,10 @@ let mockImportState = {
failed: 0,
};
// Uses context mocks because the bulk import state machine (idle → loading →
// complete → partial-failure) is impractical to drive through user interactions
// without real network calls. Consider migrating if adapter injection expands
// to cover these state transitions.
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: () => ({
fetchAndCacheSource: mockFetchAndCacheSource,
@@ -50,12 +56,23 @@ vi.mock("../../contexts/side-panel-context.js", () => ({
}),
}));
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
}));
function createAdaptersWithSources() {
const adapters = createTestAdapters();
adapters.bestiaryIndex = {
...adapters.bestiaryIndex,
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
};
return adapters;
}
function renderWithAdapters() {
const adapters = createAdaptersWithSources();
return render(
<AdapterProvider adapters={adapters}>
<BulkImportPrompt />
</AdapterProvider>,
);
}
describe("BulkImportPrompt", () => {
afterEach(() => {
@@ -64,7 +81,7 @@ describe("BulkImportPrompt", () => {
});
it("idle: shows base URL input, source count, Load All button", () => {
render(<BulkImportPrompt />);
renderWithAdapters();
expect(screen.getByText(THREE_SOURCES_REGEX)).toBeInTheDocument();
expect(screen.getByDisplayValue(GITHUB_URL_REGEX)).toBeInTheDocument();
expect(
@@ -74,7 +91,7 @@ describe("BulkImportPrompt", () => {
it("idle: clearing URL disables the button", async () => {
const user = userEvent.setup();
render(<BulkImportPrompt />);
renderWithAdapters();
const input = screen.getByDisplayValue(GITHUB_URL_REGEX);
await user.clear(input);
@@ -83,7 +100,7 @@ describe("BulkImportPrompt", () => {
it("idle: clicking Load All calls startImport with URL", async () => {
const user = userEvent.setup();
render(<BulkImportPrompt />);
renderWithAdapters();
await user.click(screen.getByRole("button", { name: "Load All" }));
expect(mockStartImport).toHaveBeenCalledWith(
@@ -101,7 +118,7 @@ describe("BulkImportPrompt", () => {
completed: 3,
failed: 1,
};
render(<BulkImportPrompt />);
renderWithAdapters();
expect(screen.getByText(LOADING_PROGRESS_REGEX)).toBeInTheDocument();
});
@@ -112,7 +129,7 @@ describe("BulkImportPrompt", () => {
completed: 10,
failed: 0,
};
render(<BulkImportPrompt />);
renderWithAdapters();
expect(screen.getByText("All sources loaded")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
});
@@ -125,7 +142,7 @@ describe("BulkImportPrompt", () => {
failed: 0,
};
const user = userEvent.setup();
render(<BulkImportPrompt />);
renderWithAdapters();
await user.click(screen.getByRole("button", { name: "Done" }));
expect(mockDismissPanel).toHaveBeenCalled();
@@ -139,7 +156,7 @@ describe("BulkImportPrompt", () => {
completed: 7,
failed: 3,
};
render(<BulkImportPrompt />);
renderWithAdapters();
expect(screen.getByText(SEVEN_OF_TEN_REGEX)).toBeInTheDocument();
expect(screen.getByText(THREE_FAILED_REGEX)).toBeInTheDocument();
});

View File

@@ -13,34 +13,6 @@ const TEMP_HP_REGEX = /^\+\d/;
const CURRENT_HP_7_REGEX = /Current HP: 7/;
const CURRENT_HP_REGEX = /Current HP/;
// Mock persistence — no localStorage interaction
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: () => null,
saveEncounter: () => {},
}));
vi.mock("../../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: () => [],
savePlayerCharacters: () => {},
}));
// Mock bestiary — no IndexedDB or JSON index
vi.mock("../../adapters/bestiary-cache.js", () => ({
loadAllCachedCreatures: () => Promise.resolve(new Map()),
isSourceCached: () => Promise.resolve(false),
cacheSource: () => Promise.resolve(),
getCachedSources: () => Promise.resolve([]),
clearSource: () => Promise.resolve(),
clearAll: () => Promise.resolve(),
}));
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
// DOM API stubs
beforeAll(() => {
Object.defineProperty(globalThis, "matchMedia", {

View File

@@ -3,36 +3,33 @@ import type { ConditionId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
import { ConditionTags } from "../condition-tags.js";
vi.mock("../../contexts/rules-edition-context.js", () => ({
useRulesEditionContext: () => ({ edition: "5.5e" }),
}));
afterEach(cleanup);
function renderTags(props: Partial<Parameters<typeof ConditionTags>[0]> = {}) {
return render(
<RulesEditionProvider>
<ConditionTags
conditions={props.conditions}
onRemove={props.onRemove ?? (() => {})}
onOpenPicker={props.onOpenPicker ?? (() => {})}
/>
</RulesEditionProvider>,
);
}
describe("ConditionTags", () => {
it("renders nothing when conditions is undefined", () => {
const { container } = render(
<ConditionTags
conditions={undefined}
onRemove={() => {}}
onOpenPicker={() => {}}
/>,
);
const { container } = renderTags();
// Only the add button should be present
expect(container.querySelectorAll("button")).toHaveLength(1);
});
it("renders a button per condition", () => {
const conditions: ConditionId[] = ["blinded", "prone"];
render(
<ConditionTags
conditions={conditions}
onRemove={() => {}}
onOpenPicker={() => {}}
/>,
);
renderTags({ conditions });
expect(
screen.getByRole("button", { name: "Remove Blinded" }),
).toBeDefined();
@@ -41,13 +38,10 @@ describe("ConditionTags", () => {
it("calls onRemove with condition id when clicked", async () => {
const onRemove = vi.fn();
render(
<ConditionTags
conditions={["blinded"] as ConditionId[]}
onRemove={onRemove}
onOpenPicker={() => {}}
/>,
);
renderTags({
conditions: ["blinded"] as ConditionId[],
onRemove,
});
await userEvent.click(
screen.getByRole("button", { name: "Remove Blinded" }),
@@ -58,13 +52,7 @@ describe("ConditionTags", () => {
it("calls onOpenPicker when add button is clicked", async () => {
const onOpenPicker = vi.fn();
render(
<ConditionTags
conditions={[]}
onRemove={() => {}}
onOpenPicker={onOpenPicker}
/>,
);
renderTags({ conditions: [], onOpenPicker });
await userEvent.click(
screen.getByRole("button", { name: "Add condition" }),
@@ -74,13 +62,7 @@ describe("ConditionTags", () => {
});
it("renders empty conditions array without errors", () => {
render(
<ConditionTags
conditions={[]}
onRemove={() => {}}
onOpenPicker={() => {}}
/>,
);
renderTags({ conditions: [] });
// Only add button
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
});

View File

@@ -33,32 +33,6 @@ beforeAll(() => {
afterEach(cleanup);
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: () => null,
saveEncounter: () => {},
}));
vi.mock("../../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: () => [],
savePlayerCharacters: () => {},
}));
vi.mock("../../adapters/bestiary-cache.js", () => ({
loadAllCachedCreatures: () => Promise.resolve(new Map()),
isSourceCached: () => Promise.resolve(false),
cacheSource: () => Promise.resolve(),
getCachedSources: () => Promise.resolve([]),
clearSource: () => Promise.resolve(),
clearAll: () => Promise.resolve(),
}));
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
function renderSection() {
const ref = createRef<PlayerCharacterSectionHandle>();
const result = render(<PlayerCharacterSection ref={ref} />, {

View File

@@ -28,32 +28,6 @@ beforeAll(() => {
});
});
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: () => null,
saveEncounter: () => {},
}));
vi.mock("../../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: () => [],
savePlayerCharacters: () => {},
}));
vi.mock("../../adapters/bestiary-cache.js", () => ({
loadAllCachedCreatures: () => Promise.resolve(new Map()),
isSourceCached: () => Promise.resolve(false),
cacheSource: () => Promise.resolve(),
getCachedSources: () => Promise.resolve([]),
clearSource: () => Promise.resolve(),
clearAll: () => Promise.resolve(),
}));
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
function renderModal(open = true) {
const onClose = vi.fn();
const result = render(<SettingsModal open={open} onClose={onClose} />, {

View File

@@ -4,6 +4,8 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
import { AdapterProvider } from "../../contexts/adapter-context.js";
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
const MONSTER_MANUAL_REGEX = /Monster Manual/;
@@ -13,6 +15,9 @@ afterEach(cleanup);
const mockFetchAndCacheSource = vi.fn();
const mockUploadAndCacheSource = vi.fn();
// Uses context mock because fetchAndCacheSource/uploadAndCacheSource involve
// real fetch() calls. The test controls success/failure to verify the
// component's loading and error UI, not the fetching logic itself.
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: () => ({
fetchAndCacheSource: mockFetchAndCacheSource,
@@ -20,22 +25,23 @@ vi.mock("../../contexts/bestiary-context.js", () => ({
}),
}));
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
getDefaultFetchUrl: (code: string) =>
`https://example.com/bestiary/${code}.json`,
getSourceDisplayName: (code: string) =>
code === "MM" ? "Monster Manual" : code,
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
}));
function renderPrompt(sourceCode = "MM") {
const onSourceLoaded = vi.fn();
const adapters = createTestAdapters();
adapters.bestiaryIndex = {
...adapters.bestiaryIndex,
getDefaultFetchUrl: (code: string) =>
`https://example.com/bestiary/${code}.json`,
getSourceDisplayName: (code: string) =>
code === "MM" ? "Monster Manual" : code,
};
const result = render(
<SourceFetchPrompt
sourceCode={sourceCode}
onSourceLoaded={onSourceLoaded}
/>,
<AdapterProvider adapters={adapters}>
<SourceFetchPrompt
sourceCode={sourceCode}
onSourceLoaded={onSourceLoaded}
/>
</AdapterProvider>,
);
return { ...result, onSourceLoaded };
}

View File

@@ -3,60 +3,68 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../../adapters/bestiary-cache.js", () => ({
getCachedSources: vi.fn(),
clearSource: vi.fn(),
clearAll: vi.fn(),
}));
// Mock the context module
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: vi.fn(),
}));
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
import type { ReactNode } from "react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
import { AllProviders } from "../../__tests__/test-providers.js";
import type { CachedSourceInfo } from "../../adapters/ports.js";
import { SourceManager } from "../source-manager.js";
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
afterEach(() => {
cleanup();
vi.clearAllMocks();
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(),
})),
});
});
function setupMockContext() {
const refreshCache = vi.fn().mockResolvedValue(undefined);
mockUseBestiaryContext.mockReturnValue({
refreshCache,
search: vi.fn().mockReturnValue([]),
getCreature: vi.fn(),
isLoaded: true,
isSourceCached: vi.fn().mockResolvedValue(false),
fetchAndCacheSource: vi.fn(),
uploadAndCacheSource: vi.fn(),
} as ReturnType<typeof useBestiaryContext>);
return { refreshCache };
afterEach(cleanup);
function renderWithSources(sources: CachedSourceInfo[] = []) {
const adapters = createTestAdapters();
// Wire getCachedSources to return the provided sources initially,
// then empty after clear operations
let currentSources = [...sources];
adapters.bestiaryCache = {
...adapters.bestiaryCache,
getCachedSources: () => Promise.resolve(currentSources),
clearSource(sourceCode) {
currentSources = currentSources.filter(
(s) => s.sourceCode !== sourceCode,
);
return Promise.resolve();
},
clearAll() {
currentSources = [];
return Promise.resolve();
},
};
render(<SourceManager />, {
wrapper: ({ children }: { children: ReactNode }) => (
<AllProviders adapters={adapters}>{children}</AllProviders>
),
});
}
describe("SourceManager", () => {
it("shows 'No cached sources' empty state when no sources", async () => {
setupMockContext();
mockGetCachedSources.mockResolvedValue([]);
render(<SourceManager />);
void renderWithSources([]);
await waitFor(() => {
expect(screen.getByText("No cached sources")).toBeInTheDocument();
});
});
it("lists cached sources with display name and creature count", async () => {
setupMockContext();
mockGetCachedSources.mockResolvedValue([
void renderWithSources([
{
sourceCode: "mm",
displayName: "Monster Manual",
@@ -70,7 +78,6 @@ describe("SourceManager", () => {
cachedAt: Date.now(),
},
]);
render(<SourceManager />);
await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
@@ -79,62 +86,45 @@ describe("SourceManager", () => {
expect(screen.getByText("100 creatures")).toBeInTheDocument();
});
it("Clear All button calls cache clear and refreshCache", async () => {
it("Clear All button removes all sources", async () => {
const user = userEvent.setup();
const { refreshCache } = setupMockContext();
mockGetCachedSources
.mockResolvedValueOnce([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
])
.mockResolvedValue([]);
mockClearAll.mockResolvedValue(undefined);
render(<SourceManager />);
void renderWithSources([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
]);
await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Clear All" }));
await waitFor(() => {
expect(mockClearAll).toHaveBeenCalled();
expect(screen.getByText("No cached sources")).toBeInTheDocument();
});
expect(refreshCache).toHaveBeenCalled();
});
it("individual source delete button calls clear for that source", async () => {
it("individual source delete button removes that source", async () => {
const user = userEvent.setup();
const { refreshCache } = setupMockContext();
mockGetCachedSources
.mockResolvedValueOnce([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
{
sourceCode: "vgm",
displayName: "Volo's Guide",
creatureCount: 100,
cachedAt: Date.now(),
},
])
.mockResolvedValue([
{
sourceCode: "vgm",
displayName: "Volo's Guide",
creatureCount: 100,
cachedAt: Date.now(),
},
]);
mockClearSource.mockResolvedValue(undefined);
void renderWithSources([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
{
sourceCode: "vgm",
displayName: "Volo's Guide",
creatureCount: 100,
cachedAt: Date.now(),
},
]);
render(<SourceManager />);
await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
@@ -142,9 +132,10 @@ describe("SourceManager", () => {
await user.click(
screen.getByRole("button", { name: "Remove Monster Manual" }),
);
await waitFor(() => {
expect(mockClearSource).toHaveBeenCalledWith("mm");
expect(screen.queryByText("Monster Manual")).not.toBeInTheDocument();
});
expect(refreshCache).toHaveBeenCalled();
expect(screen.getByText("Volo's Guide")).toBeInTheDocument();
});
});

View File

@@ -1,100 +1,68 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { Encounter } from "@initiative/domain";
import { combatantId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock the context modules
vi.mock("../../contexts/encounter-context.js", () => ({
useEncounterContext: vi.fn(),
}));
vi.mock("../../contexts/player-characters-context.js", () => ({
usePlayerCharactersContext: vi.fn().mockReturnValue({ characters: [] }),
}));
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: vi.fn().mockReturnValue({ getCreature: () => undefined }),
}));
import { useEncounterContext } from "../../contexts/encounter-context.js";
import type { ReactNode } from "react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
import {
buildCombatant,
buildEncounter,
} from "../../__tests__/factories/index.js";
import { AllProviders } from "../../__tests__/test-providers.js";
import { TurnNavigation } from "../turn-navigation.js";
const mockUseEncounterContext = vi.mocked(useEncounterContext);
afterEach(() => {
cleanup();
vi.clearAllMocks();
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(),
})),
});
});
function mockContext(overrides: Partial<Encounter> = {}) {
const encounter: Encounter = {
combatants: [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
],
activeIndex: 0,
roundNumber: 1,
...overrides,
};
afterEach(cleanup);
const value = {
encounter,
advanceTurn: vi.fn(),
retreatTurn: vi.fn(),
clearEncounter: vi.fn(),
isEmpty: encounter.combatants.length === 0,
hasCreatureCombatants: false,
canRollAllInitiative: false,
addCombatant: vi.fn(),
removeCombatant: vi.fn(),
editCombatant: vi.fn(),
setInitiative: vi.fn(),
setHp: vi.fn(),
adjustHp: vi.fn(),
setTempHp: vi.fn(),
hasTempHp: false,
setAc: vi.fn(),
toggleCondition: vi.fn(),
toggleConcentration: vi.fn(),
addFromBestiary: vi.fn(),
addMultipleFromBestiary: vi.fn(),
addFromPlayerCharacter: vi.fn(),
makeStore: vi.fn(),
withUndo: vi.fn((action: () => unknown) => action()),
undo: vi.fn(),
redo: vi.fn(),
canUndo: false,
canRedo: false,
undoRedoState: { undoStack: [], redoStack: [] },
setEncounter: vi.fn(),
setUndoRedoState: vi.fn(),
events: [],
lastCreatureId: null,
};
mockUseEncounterContext.mockReturnValue(
value as ReturnType<typeof useEncounterContext>,
);
return value;
}
function renderNav(overrides: Partial<Encounter> = {}) {
mockContext(overrides);
return render(<TurnNavigation />);
function renderNav(encounter = buildEncounter()) {
const adapters = createTestAdapters({ encounter });
return render(<TurnNavigation />, {
wrapper: ({ children }: { children: ReactNode }) => (
<AllProviders adapters={adapters}>{children}</AllProviders>
),
});
}
describe("TurnNavigation", () => {
describe("US1: Round badge and combatant name", () => {
it("renders the round badge with correct round number", () => {
renderNav({ roundNumber: 3 });
renderNav(
buildEncounter({
combatants: [buildCombatant({ name: "Goblin" })],
roundNumber: 3,
}),
);
expect(screen.getByText("R3")).toBeInTheDocument();
});
it("renders the combatant name separately from the round badge", () => {
renderNav();
renderNav(
buildEncounter({
combatants: [
buildCombatant({ id: combatantId("c-1"), name: "Goblin" }),
buildCombatant({ id: combatantId("c-2"), name: "Conjurer" }),
],
activeIndex: 0,
roundNumber: 1,
}),
);
const badge = screen.getByText("R1");
const name = screen.getByText("Goblin");
expect(badge).toBeInTheDocument();
@@ -104,59 +72,45 @@ describe("TurnNavigation", () => {
});
it("does not render an em dash between round and name", () => {
const { container } = renderNav();
const { container } = renderNav(
buildEncounter({
combatants: [buildCombatant({ name: "Goblin" })],
}),
);
expect(container.textContent).not.toContain("\u2014");
});
it("round badge and combatant name are siblings in the center area", () => {
renderNav();
renderNav(
buildEncounter({
combatants: [buildCombatant({ name: "Goblin" })],
}),
);
const badge = screen.getByText("R1");
const name = screen.getByText("Goblin");
// badge text is inside inner span > outer span, name is a direct child
expect(badge.closest(".flex")).toBe(name.parentElement);
});
it("updates the round badge when round changes", () => {
mockContext({ roundNumber: 2 });
const { rerender } = render(<TurnNavigation />);
expect(screen.getByText("R2")).toBeInTheDocument();
mockContext({ roundNumber: 3 });
rerender(<TurnNavigation />);
expect(screen.getByText("R3")).toBeInTheDocument();
expect(screen.queryByText("R2")).not.toBeInTheDocument();
});
it("renders the next combatant name when turn advances", () => {
const combatants = [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
];
mockContext({ combatants, activeIndex: 0 });
const { rerender } = render(<TurnNavigation />);
expect(screen.getByText("Goblin")).toBeInTheDocument();
mockContext({ combatants, activeIndex: 1 });
rerender(<TurnNavigation />);
expect(screen.getByText("Conjurer")).toBeInTheDocument();
});
});
describe("US2: Layout robustness", () => {
it("applies truncation styles to long combatant names", () => {
const longName =
"Ancient Red Dragon Wyrm of the Northern Wastes and Beyond";
renderNav({
combatants: [{ id: combatantId("1"), name: longName }],
});
renderNav(
buildEncounter({
combatants: [buildCombatant({ name: longName })],
}),
);
const nameEl = screen.getByText(longName);
expect(nameEl.className).toContain("truncate");
});
it("renders three-zone layout with a single-character name", () => {
renderNav({
combatants: [{ id: combatantId("1"), name: "O" }],
});
renderNav(
buildEncounter({
combatants: [buildCombatant({ name: "O" })],
}),
);
expect(screen.getByText("R1")).toBeInTheDocument();
expect(screen.getByText("O")).toBeInTheDocument();
expect(
@@ -169,9 +123,11 @@ describe("TurnNavigation", () => {
it("keeps all action buttons accessible regardless of name length", () => {
const longName = "A".repeat(60);
renderNav({
combatants: [{ id: combatantId("1"), name: longName }],
});
renderNav(
buildEncounter({
combatants: [buildCombatant({ name: longName })],
}),
);
expect(
screen.getByRole("button", { name: "Previous turn" }),
).toBeInTheDocument();
@@ -182,29 +138,30 @@ describe("TurnNavigation", () => {
it("renders a 40-character name without truncation class issues", () => {
const name40 = "A".repeat(40);
renderNav({
combatants: [{ id: combatantId("1"), name: name40 }],
});
renderNav(
buildEncounter({
combatants: [buildCombatant({ name: name40 })],
}),
);
const nameEl = screen.getByText(name40);
expect(nameEl).toBeInTheDocument();
// The truncate class is applied but CSS only visually truncates if content overflows
expect(nameEl.className).toContain("truncate");
});
});
describe("US3: No combatants state", () => {
it("shows the round badge when there are no combatants", () => {
renderNav({ combatants: [], roundNumber: 1 });
renderNav(buildEncounter({ combatants: [], roundNumber: 1 }));
expect(screen.getByText("R1")).toBeInTheDocument();
});
it("shows 'No combatants' placeholder text", () => {
renderNav({ combatants: [] });
renderNav(buildEncounter({ combatants: [] }));
expect(screen.getByText("No combatants")).toBeInTheDocument();
});
it("disables navigation buttons when there are no combatants", () => {
renderNav({ combatants: [] });
renderNav(buildEncounter({ combatants: [] }));
expect(
screen.getByRole("button", { name: "Previous turn" }),
).toBeDisabled();