Add missing component and hook tests, raise coverage thresholds

13 new test files for untested components (color-palette, player-management,
stat-block, settings-modal, export/import dialogs, bulk-import-prompt,
source-fetch-prompt, player-character-section) and hooks (use-long-press,
use-swipe-to-dismiss, use-bulk-import, use-initiative-rolls). Expand
combatant-row tests with inline editing, HP popover, and condition picker.

Component coverage: 59% → 80% lines, 55% → 71% branches
Hook coverage: 72% → 83% lines, 55% → 66% branches

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-29 23:55:21 +02:00
parent 08b5db81ad
commit b229a0dac7
15 changed files with 1802 additions and 4 deletions

View File

@@ -0,0 +1,145 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useBulkImport } from "../use-bulk-import.js";
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
getDefaultFetchUrl: (code: string, baseUrl: string) =>
`${baseUrl}${code}.json`,
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getSourceDisplayName: (code: string) => code,
}));
/** Flush microtasks so the internal async IIFE inside startImport settles. */
function flushMicrotasks(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
describe("useBulkImport", () => {
it("starts in idle state with all counters at 0", () => {
const { result } = renderHook(() => useBulkImport());
expect(result.current.state).toEqual({
status: "idle",
total: 0,
completed: 0,
failed: 0,
});
});
it("reset returns to idle state", async () => {
const { result } = renderHook(() => useBulkImport());
const isSourceCached = vi.fn().mockResolvedValue(true);
const fetchAndCacheSource = vi.fn();
const refreshCache = vi.fn();
await act(async () => {
result.current.startImport(
"https://example.com/",
fetchAndCacheSource,
isSourceCached,
refreshCache,
);
await flushMicrotasks();
});
act(() => result.current.reset());
expect(result.current.state.status).toBe("idle");
});
it("goes straight to complete when all sources are cached", async () => {
const { result } = renderHook(() => useBulkImport());
const isSourceCached = vi.fn().mockResolvedValue(true);
const fetchAndCacheSource = vi.fn();
const refreshCache = vi.fn();
await act(async () => {
result.current.startImport(
"https://example.com/",
fetchAndCacheSource,
isSourceCached,
refreshCache,
);
await flushMicrotasks();
});
expect(result.current.state.status).toBe("complete");
expect(result.current.state.completed).toBe(3);
expect(fetchAndCacheSource).not.toHaveBeenCalled();
});
it("fetches uncached sources and completes", async () => {
const { result } = renderHook(() => useBulkImport());
const isSourceCached = vi.fn().mockResolvedValue(false);
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
const refreshCache = vi.fn().mockResolvedValue(undefined);
await act(async () => {
result.current.startImport(
"https://example.com/",
fetchAndCacheSource,
isSourceCached,
refreshCache,
);
await flushMicrotasks();
});
expect(result.current.state.status).toBe("complete");
expect(result.current.state.completed).toBe(3);
expect(result.current.state.failed).toBe(0);
expect(fetchAndCacheSource).toHaveBeenCalledTimes(3);
expect(refreshCache).toHaveBeenCalled();
});
it("reports partial-failure when some sources fail", async () => {
const { result } = renderHook(() => useBulkImport());
const isSourceCached = vi.fn().mockResolvedValue(false);
const fetchAndCacheSource = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("fail"))
.mockResolvedValueOnce(undefined);
const refreshCache = vi.fn().mockResolvedValue(undefined);
await act(async () => {
result.current.startImport(
"https://example.com/",
fetchAndCacheSource,
isSourceCached,
refreshCache,
);
await flushMicrotasks();
});
expect(result.current.state.status).toBe("partial-failure");
expect(result.current.state.completed).toBe(2);
expect(result.current.state.failed).toBe(1);
expect(refreshCache).toHaveBeenCalled();
});
it("calls refreshCache after all batches complete", async () => {
const { result } = renderHook(() => useBulkImport());
const isSourceCached = vi.fn().mockResolvedValue(false);
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
const refreshCache = vi.fn().mockResolvedValue(undefined);
await act(async () => {
result.current.startImport(
"https://example.com/",
fetchAndCacheSource,
isSourceCached,
refreshCache,
);
await flushMicrotasks();
});
expect(refreshCache).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { type CreatureId, combatantId } from "@initiative/domain";
import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useInitiativeRolls } from "../use-initiative-rolls.js";
const mockMakeStore = vi.fn(() => ({}));
const mockWithUndo = vi.fn((fn: () => unknown) => fn());
const mockGetCreature = vi.fn();
const mockShowCreature = vi.fn();
vi.mock("../../contexts/encounter-context.js", () => ({
useEncounterContext: () => ({
encounter: {
combatants: [
{
id: combatantId("c1"),
name: "Goblin",
creatureId: "srd:goblin" as CreatureId,
},
],
activeIndex: 0,
roundNumber: 1,
},
makeStore: mockMakeStore,
withUndo: mockWithUndo,
}),
}));
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: () => ({
getCreature: mockGetCreature,
}),
}));
vi.mock("../../contexts/side-panel-context.js", () => ({
useSidePanelContext: () => ({
showCreature: mockShowCreature,
}),
}));
const mockRollInitiativeUseCase = vi.fn();
const mockRollAllInitiativeUseCase = vi.fn();
vi.mock("@initiative/application", () => ({
rollInitiativeUseCase: (...args: unknown[]) =>
mockRollInitiativeUseCase(...args),
rollAllInitiativeUseCase: (...args: unknown[]) =>
mockRollAllInitiativeUseCase(...args),
}));
function wrapper({ children }: { children: ReactNode }) {
return children;
}
describe("useInitiativeRolls", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("handleRollInitiative calls rollInitiativeUseCase via withUndo", () => {
mockRollInitiativeUseCase.mockReturnValue({ initiative: 15 });
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
act(() => result.current.handleRollInitiative(combatantId("c1")));
expect(mockWithUndo).toHaveBeenCalled();
expect(mockRollInitiativeUseCase).toHaveBeenCalled();
});
it("sets rollSingleSkipped on domain error", () => {
mockRollInitiativeUseCase.mockReturnValue({
kind: "domain-error",
code: "missing-source",
message: "no source",
});
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
act(() => result.current.handleRollInitiative(combatantId("c1")));
expect(result.current.rollSingleSkipped).toBe(true);
expect(mockShowCreature).toHaveBeenCalledWith("srd:goblin");
});
it("dismissRollSingleSkipped resets the flag", () => {
mockRollInitiativeUseCase.mockReturnValue({
kind: "domain-error",
code: "missing-source",
message: "no source",
});
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
act(() => result.current.handleRollInitiative(combatantId("c1")));
expect(result.current.rollSingleSkipped).toBe(true);
act(() => result.current.dismissRollSingleSkipped());
expect(result.current.rollSingleSkipped).toBe(false);
});
it("handleRollAllInitiative sets rollSkippedCount when sources missing", () => {
mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 3 });
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
act(() => result.current.handleRollAllInitiative());
expect(result.current.rollSkippedCount).toBe(3);
});
it("dismissRollSkipped resets the count", () => {
mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 2 });
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
act(() => result.current.handleRollAllInitiative());
act(() => result.current.dismissRollSkipped());
expect(result.current.rollSkippedCount).toBe(0);
});
});

View File

@@ -0,0 +1,104 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useLongPress } from "../use-long-press.js";
function touchEvent(overrides?: Partial<React.TouchEvent>): React.TouchEvent {
return {
preventDefault: vi.fn(),
...overrides,
} as unknown as React.TouchEvent;
}
describe("useLongPress", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns onTouchStart, onTouchEnd, onTouchMove handlers", () => {
const { result } = renderHook(() => useLongPress(vi.fn()));
expect(result.current.onTouchStart).toBeInstanceOf(Function);
expect(result.current.onTouchEnd).toBeInstanceOf(Function);
expect(result.current.onTouchMove).toBeInstanceOf(Function);
});
it("fires onLongPress after 500ms hold", () => {
const onLongPress = vi.fn();
const { result } = renderHook(() => useLongPress(onLongPress));
const e = touchEvent();
act(() => result.current.onTouchStart(e));
expect(onLongPress).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(500);
});
expect(onLongPress).toHaveBeenCalledOnce();
});
it("does not fire if released before 500ms", () => {
const onLongPress = vi.fn();
const { result } = renderHook(() => useLongPress(onLongPress));
act(() => result.current.onTouchStart(touchEvent()));
act(() => {
vi.advanceTimersByTime(300);
});
act(() => result.current.onTouchEnd(touchEvent()));
act(() => {
vi.advanceTimersByTime(500);
});
expect(onLongPress).not.toHaveBeenCalled();
});
it("cancels on touch move", () => {
const onLongPress = vi.fn();
const { result } = renderHook(() => useLongPress(onLongPress));
act(() => result.current.onTouchStart(touchEvent()));
act(() => {
vi.advanceTimersByTime(200);
});
act(() => result.current.onTouchMove());
act(() => {
vi.advanceTimersByTime(500);
});
expect(onLongPress).not.toHaveBeenCalled();
});
it("onTouchEnd calls preventDefault after long press fires", () => {
const onLongPress = vi.fn();
const { result } = renderHook(() => useLongPress(onLongPress));
act(() => result.current.onTouchStart(touchEvent()));
act(() => {
vi.advanceTimersByTime(500);
});
const preventDefaultSpy = vi.fn();
const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
act(() => result.current.onTouchEnd(endEvent));
expect(preventDefaultSpy).toHaveBeenCalled();
});
it("onTouchEnd does not preventDefault when long press did not fire", () => {
const onLongPress = vi.fn();
const { result } = renderHook(() => useLongPress(onLongPress));
act(() => result.current.onTouchStart(touchEvent()));
act(() => {
vi.advanceTimersByTime(100);
});
const preventDefaultSpy = vi.fn();
const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
act(() => result.current.onTouchEnd(endEvent));
expect(preventDefaultSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,116 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSwipeToDismiss } from "../use-swipe-to-dismiss.js";
const PANEL_WIDTH = 300;
function makeTouchEvent(clientX: number, clientY = 0): React.TouchEvent {
return {
touches: [{ clientX, clientY }],
currentTarget: {
getBoundingClientRect: () => ({ width: PANEL_WIDTH }),
},
} as unknown as React.TouchEvent;
}
describe("useSwipeToDismiss", () => {
beforeEach(() => {
vi.spyOn(Date, "now").mockReturnValue(0);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("starts with offsetX 0 and isSwiping false", () => {
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
expect(result.current.offsetX).toBe(0);
expect(result.current.isSwiping).toBe(false);
});
it("horizontal drag updates offsetX and sets isSwiping", () => {
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
expect(result.current.offsetX).toBe(50);
expect(result.current.isSwiping).toBe(true);
});
it("vertical drag is ignored after direction lock", () => {
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0, 0)));
// Move vertically > 10px to lock vertical
act(() => result.current.handlers.onTouchMove(makeTouchEvent(0, 20)));
expect(result.current.offsetX).toBe(0);
});
it("small movement does not lock direction", () => {
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
act(() => result.current.handlers.onTouchMove(makeTouchEvent(5)));
// No direction locked yet, no update
expect(result.current.offsetX).toBe(0);
expect(result.current.isSwiping).toBe(false);
});
it("leftward drag is clamped to 0", () => {
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
act(() => result.current.handlers.onTouchStart(makeTouchEvent(100)));
act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
expect(result.current.offsetX).toBe(0);
});
it("calls onDismiss when ratio exceeds threshold", () => {
const onDismiss = vi.fn();
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
// Move > 35% of panel width (300 * 0.35 = 105)
act(() => result.current.handlers.onTouchMove(makeTouchEvent(120)));
vi.spyOn(Date, "now").mockReturnValue(5000); // slow swipe
act(() => result.current.handlers.onTouchEnd());
expect(onDismiss).toHaveBeenCalled();
});
it("calls onDismiss with fast velocity", () => {
const onDismiss = vi.fn();
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
// Small distance but fast
act(() => result.current.handlers.onTouchMove(makeTouchEvent(30)));
// Very fast: 30px in 0.1s = 300px/s, velocity = 300/300 = 1.0 > 0.5
vi.spyOn(Date, "now").mockReturnValue(100);
act(() => result.current.handlers.onTouchEnd());
expect(onDismiss).toHaveBeenCalled();
});
it("does not dismiss when below thresholds", () => {
const onDismiss = vi.fn();
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
// Small distance, slow speed
act(() => result.current.handlers.onTouchMove(makeTouchEvent(20)));
vi.spyOn(Date, "now").mockReturnValue(5000);
act(() => result.current.handlers.onTouchEnd());
expect(onDismiss).not.toHaveBeenCalled();
expect(result.current.offsetX).toBe(0);
expect(result.current.isSwiping).toBe(false);
});
});