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:
145
apps/web/src/hooks/__tests__/use-bulk-import.test.ts
Normal file
145
apps/web/src/hooks/__tests__/use-bulk-import.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
118
apps/web/src/hooks/__tests__/use-initiative-rolls.test.ts
Normal file
118
apps/web/src/hooks/__tests__/use-initiative-rolls.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
104
apps/web/src/hooks/__tests__/use-long-press.test.ts
Normal file
104
apps/web/src/hooks/__tests__/use-long-press.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
116
apps/web/src/hooks/__tests__/use-swipe-to-dismiss.test.ts
Normal file
116
apps/web/src/hooks/__tests__/use-swipe-to-dismiss.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user