diff --git a/apps/web/src/components/__tests__/difficulty-indicator.test.tsx b/apps/web/src/components/__tests__/difficulty-indicator.test.tsx new file mode 100644 index 0000000..7c81df5 --- /dev/null +++ b/apps/web/src/components/__tests__/difficulty-indicator.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import type { DifficultyResult } from "@initiative/domain"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { DifficultyIndicator } from "../difficulty-indicator.js"; + +afterEach(cleanup); + +function makeResult(tier: DifficultyResult["tier"]): DifficultyResult { + return { + tier, + totalMonsterXp: 100, + partyBudget: { low: 50, moderate: 100, high: 200 }, + }; +} + +describe("DifficultyIndicator", () => { + it("renders 3 bars", () => { + const { container } = render( + , + ); + const bars = container.querySelectorAll("[class*='rounded-sm']"); + expect(bars).toHaveLength(3); + }); + + it("shows 'Trivial encounter difficulty' label for trivial tier", () => { + render(); + expect( + screen.getByRole("img", { + name: "Trivial encounter difficulty", + }), + ).toBeDefined(); + }); + + it("shows 'Low encounter difficulty' label for low tier", () => { + render(); + expect( + screen.getByRole("img", { name: "Low encounter difficulty" }), + ).toBeDefined(); + }); + + it("shows 'Moderate encounter difficulty' label for moderate tier", () => { + render(); + expect( + screen.getByRole("img", { + name: "Moderate encounter difficulty", + }), + ).toBeDefined(); + }); + + it("shows 'High encounter difficulty' label for high tier", () => { + render(); + expect( + screen.getByRole("img", { + name: "High encounter difficulty", + }), + ).toBeDefined(); + }); +}); diff --git a/apps/web/src/components/__tests__/overflow-menu.test.tsx b/apps/web/src/components/__tests__/overflow-menu.test.tsx new file mode 100644 index 0000000..93d9a10 --- /dev/null +++ b/apps/web/src/components/__tests__/overflow-menu.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { Circle } from "lucide-react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { OverflowMenu } from "../ui/overflow-menu.js"; + +afterEach(cleanup); + +const items = [ + { icon: , label: "Action A", onClick: vi.fn() }, + { icon: , label: "Action B", onClick: vi.fn() }, +]; + +describe("OverflowMenu", () => { + it("renders toggle button", () => { + render(); + expect(screen.getByRole("button", { name: "More actions" })).toBeDefined(); + }); + + it("does not show menu items when closed", () => { + render(); + expect(screen.queryByText("Action A")).toBeNull(); + }); + + it("shows menu items when toggled open", async () => { + render(); + + await userEvent.click(screen.getByRole("button", { name: "More actions" })); + + expect(screen.getByText("Action A")).toBeDefined(); + expect(screen.getByText("Action B")).toBeDefined(); + }); + + it("closes menu after clicking an item", async () => { + const onClick = vi.fn(); + render( + , label: "Do it", onClick }]} />, + ); + + await userEvent.click(screen.getByRole("button", { name: "More actions" })); + await userEvent.click(screen.getByText("Do it")); + + expect(onClick).toHaveBeenCalledOnce(); + expect(screen.queryByText("Do it")).toBeNull(); + }); + + it("keeps menu open when keepOpen is true", async () => { + const onClick = vi.fn(); + render( + , + label: "Stay", + onClick, + keepOpen: true, + }, + ]} + />, + ); + + await userEvent.click(screen.getByRole("button", { name: "More actions" })); + await userEvent.click(screen.getByText("Stay")); + + expect(onClick).toHaveBeenCalledOnce(); + expect(screen.getByText("Stay")).toBeDefined(); + }); + + it("disables items when disabled is true", async () => { + const onClick = vi.fn(); + render( + , + label: "Nope", + onClick, + disabled: true, + }, + ]} + />, + ); + + await userEvent.click(screen.getByRole("button", { name: "More actions" })); + const item = screen.getByText("Nope"); + expect(item.closest("button")?.hasAttribute("disabled")).toBe(true); + }); +}); diff --git a/apps/web/src/components/__tests__/roll-mode-menu.test.tsx b/apps/web/src/components/__tests__/roll-mode-menu.test.tsx new file mode 100644 index 0000000..38d64e5 --- /dev/null +++ b/apps/web/src/components/__tests__/roll-mode-menu.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RollModeMenu } from "../roll-mode-menu.js"; + +afterEach(cleanup); + +describe("RollModeMenu", () => { + it("renders advantage and disadvantage buttons", () => { + render( + {}} + onClose={() => {}} + />, + ); + expect(screen.getByText("Advantage")).toBeDefined(); + expect(screen.getByText("Disadvantage")).toBeDefined(); + }); + + it("calls onSelect with 'advantage' and onClose when clicked", async () => { + const onSelect = vi.fn(); + const onClose = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByText("Advantage")); + + expect(onSelect).toHaveBeenCalledWith("advantage"); + expect(onClose).toHaveBeenCalled(); + }); + + it("calls onSelect with 'disadvantage' and onClose when clicked", async () => { + const onSelect = vi.fn(); + const onClose = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByText("Disadvantage")); + + expect(onSelect).toHaveBeenCalledWith("disadvantage"); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/__tests__/toast.test.tsx b/apps/web/src/components/__tests__/toast.test.tsx new file mode 100644 index 0000000..79b70ad --- /dev/null +++ b/apps/web/src/components/__tests__/toast.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Toast } from "../toast.js"; + +afterEach(cleanup); + +describe("Toast", () => { + it("renders message text", () => { + render( {}} />); + expect(screen.getByText("Hello")).toBeDefined(); + }); + + it("renders progress bar when progress is provided", () => { + render( {}} />); + const bar = document.body.querySelector("[style*='width']") as HTMLElement; + expect(bar).not.toBeNull(); + expect(bar.style.width).toBe("50%"); + }); + + it("does not render progress bar when progress is omitted", () => { + render( {}} />); + const bar = document.body.querySelector("[style*='width']"); + expect(bar).toBeNull(); + }); + + it("calls onDismiss when close button is clicked", async () => { + const onDismiss = vi.fn(); + render(); + + const toast = screen.getByText("Hi").closest("div"); + const button = toast?.querySelector("button"); + expect(button).not.toBeNull(); + await userEvent.click(button as HTMLElement); + + expect(onDismiss).toHaveBeenCalledOnce(); + }); + + describe("auto-dismiss", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("auto-dismisses after specified timeout", () => { + const onDismiss = vi.fn(); + render( + , + ); + + expect(onDismiss).not.toHaveBeenCalled(); + vi.advanceTimersByTime(3000); + expect(onDismiss).toHaveBeenCalledOnce(); + }); + + it("does not auto-dismiss when autoDismissMs is omitted", () => { + const onDismiss = vi.fn(); + render(); + + vi.advanceTimersByTime(10000); + expect(onDismiss).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/hooks/__tests__/use-rules-edition.test.ts b/apps/web/src/hooks/__tests__/use-rules-edition.test.ts new file mode 100644 index 0000000..5127f2c --- /dev/null +++ b/apps/web/src/hooks/__tests__/use-rules-edition.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { useRulesEdition } from "../use-rules-edition.js"; + +const STORAGE_KEY = "initiative:rules-edition"; + +describe("useRulesEdition", () => { + afterEach(() => { + // Reset to default + const { result } = renderHook(() => useRulesEdition()); + act(() => result.current.setEdition("5.5e")); + localStorage.removeItem(STORAGE_KEY); + }); + + it("defaults to 5.5e", () => { + const { result } = renderHook(() => useRulesEdition()); + expect(result.current.edition).toBe("5.5e"); + }); + + it("setEdition updates value", () => { + const { result } = renderHook(() => useRulesEdition()); + + act(() => result.current.setEdition("5e")); + + expect(result.current.edition).toBe("5e"); + }); + + it("setEdition persists to localStorage", () => { + const { result } = renderHook(() => useRulesEdition()); + + act(() => result.current.setEdition("5e")); + + expect(localStorage.getItem(STORAGE_KEY)).toBe("5e"); + }); + + it("multiple hooks stay in sync", () => { + const { result: r1 } = renderHook(() => useRulesEdition()); + const { result: r2 } = renderHook(() => useRulesEdition()); + + act(() => r1.current.setEdition("5e")); + + expect(r2.current.edition).toBe("5e"); + }); +}); diff --git a/apps/web/src/hooks/__tests__/use-theme.test.ts b/apps/web/src/hooks/__tests__/use-theme.test.ts new file mode 100644 index 0000000..6f5d9e6 --- /dev/null +++ b/apps/web/src/hooks/__tests__/use-theme.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { useTheme } from "../use-theme.js"; + +const STORAGE_KEY = "initiative:theme"; + +describe("useTheme", () => { + afterEach(() => { + // Reset to default + const { result } = renderHook(() => useTheme()); + act(() => result.current.setPreference("system")); + localStorage.removeItem(STORAGE_KEY); + }); + + it("defaults to system preference", () => { + const { result } = renderHook(() => useTheme()); + expect(result.current.preference).toBe("system"); + }); + + it("setPreference updates to light", () => { + const { result } = renderHook(() => useTheme()); + + act(() => result.current.setPreference("light")); + + expect(result.current.preference).toBe("light"); + expect(result.current.resolved).toBe("light"); + }); + + it("setPreference updates to dark", () => { + const { result } = renderHook(() => useTheme()); + + act(() => result.current.setPreference("dark")); + + expect(result.current.preference).toBe("dark"); + expect(result.current.resolved).toBe("dark"); + }); + + it("persists preference to localStorage", () => { + const { result } = renderHook(() => useTheme()); + + act(() => result.current.setPreference("light")); + + expect(localStorage.getItem(STORAGE_KEY)).toBe("light"); + }); + + it("applies theme to document element", () => { + const { result } = renderHook(() => useTheme()); + + act(() => result.current.setPreference("light")); + + expect(document.documentElement.dataset.theme).toBe("light"); + }); + + it("multiple hooks stay in sync", () => { + const { result: r1 } = renderHook(() => useTheme()); + const { result: r2 } = renderHook(() => useTheme()); + + act(() => r1.current.setPreference("dark")); + + expect(r2.current.preference).toBe("dark"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index f7fc12f..89aadb6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,16 +26,16 @@ export default defineConfig({ branches: 70, }, "apps/web/src/hooks": { - lines: 59, - branches: 41, + lines: 64, + branches: 48, }, "apps/web/src/components": { - lines: 49, - branches: 47, + lines: 52, + branches: 49, }, "apps/web/src/components/ui": { - lines: 73, - branches: 67, + lines: 83, + branches: 74, }, }, },