From 4be816d10f453b8736509dc03068b0e5bc35d7bc Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 14 Mar 2026 15:53:01 +0100 Subject: [PATCH] Add test coverage for 5 components: HpAdjustPopover, ConditionPicker, CombatantRow, ActionBar, SourceManager Adds aria-label attributes to HP placeholder and source delete buttons for both accessibility and testability. Co-Authored-By: Claude Opus 4.6 --- apps/web/package.json | 1 + .../components/__tests__/action-bar.test.tsx | 88 ++++++++++ .../__tests__/combatant-row.test.tsx | 164 ++++++++++++++++++ .../__tests__/condition-picker.test.tsx | 63 +++++++ .../__tests__/hp-adjust-popover.test.tsx | 115 ++++++++++++ .../__tests__/source-manager.test.tsx | 127 ++++++++++++++ apps/web/src/components/combatant-row.tsx | 2 + apps/web/src/components/source-manager.tsx | 1 + pnpm-lock.yaml | 13 ++ 9 files changed, 574 insertions(+) create mode 100644 apps/web/src/components/__tests__/action-bar.test.tsx create mode 100644 apps/web/src/components/__tests__/combatant-row.test.tsx create mode 100644 apps/web/src/components/__tests__/condition-picker.test.tsx create mode 100644 apps/web/src/components/__tests__/hp-adjust-popover.test.tsx create mode 100644 apps/web/src/components/__tests__/source-manager.test.tsx diff --git a/apps/web/package.json b/apps/web/package.json index ef8fe7f..eee0b42 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@tailwindcss/vite": "^4.2.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", diff --git a/apps/web/src/components/__tests__/action-bar.test.tsx b/apps/web/src/components/__tests__/action-bar.test.tsx new file mode 100644 index 0000000..4ef5806 --- /dev/null +++ b/apps/web/src/components/__tests__/action-bar.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +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 { ActionBar } from "../action-bar"; + +afterEach(cleanup); + +const defaultProps = { + onAddCombatant: vi.fn(), + onAddFromBestiary: vi.fn(), + bestiarySearch: () => [], + bestiaryLoaded: false, +}; + +function renderBar(overrides: Partial[0]> = {}) { + const props = { ...defaultProps, ...overrides }; + return render(); +} + +describe("ActionBar", () => { + it("renders input with placeholder '+ Add combatants'", () => { + renderBar(); + expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument(); + }); + + it("submitting with a name calls onAddCombatant", async () => { + const user = userEvent.setup(); + const onAddCombatant = vi.fn(); + renderBar({ onAddCombatant }); + 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); + expect(onAddCombatant).toHaveBeenCalledWith("Goblin", undefined); + }); + + it("submitting with empty name does nothing", async () => { + const user = userEvent.setup(); + const onAddCombatant = vi.fn(); + renderBar({ onAddCombatant }); + // Submit the form directly (Enter on empty input) + const input = screen.getByPlaceholderText("+ Add combatants"); + await user.type(input, "{Enter}"); + expect(onAddCombatant).not.toHaveBeenCalled(); + }); + + 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("shows roll all initiative button when showRollAllInitiative is true", () => { + const onRollAllInitiative = vi.fn(); + renderBar({ showRollAllInitiative: true, onRollAllInitiative }); + expect( + screen.getByRole("button", { name: "Roll all initiative" }), + ).toBeInTheDocument(); + }); + + it("roll all initiative button is disabled when rollAllInitiativeDisabled is true", () => { + const onRollAllInitiative = vi.fn(); + renderBar({ + showRollAllInitiative: true, + onRollAllInitiative, + rollAllInitiativeDisabled: true, + }); + expect( + screen.getByRole("button", { name: "Roll all initiative" }), + ).toBeDisabled(); + }); +}); diff --git a/apps/web/src/components/__tests__/combatant-row.test.tsx b/apps/web/src/components/__tests__/combatant-row.test.tsx new file mode 100644 index 0000000..ce55baa --- /dev/null +++ b/apps/web/src/components/__tests__/combatant-row.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; + +import { combatantId } 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 { CombatantRow } from "../combatant-row"; +import { PLAYER_COLOR_HEX } from "../player-icon-map"; + +afterEach(cleanup); + +const defaultProps = { + onRename: vi.fn(), + onSetInitiative: vi.fn(), + onRemove: vi.fn(), + onSetHp: vi.fn(), + onAdjustHp: vi.fn(), + onSetAc: vi.fn(), + onToggleCondition: vi.fn(), + onToggleConcentration: vi.fn(), +}; + +function renderRow( + overrides: Partial<{ + combatant: Parameters[0]["combatant"]; + isActive: boolean; + onRollInitiative: (id: ReturnType) => void; + onRemove: (id: ReturnType) => void; + onShowStatBlock: () => void; + }> = {}, +) { + const combatant = overrides.combatant ?? { + id: combatantId("1"), + name: "Goblin", + initiative: 15, + maxHp: 10, + currentHp: 10, + ac: 13, + }; + const props = { + ...defaultProps, + combatant, + isActive: overrides.isActive ?? false, + onRollInitiative: overrides.onRollInitiative, + onShowStatBlock: overrides.onShowStatBlock, + onRemove: overrides.onRemove ?? defaultProps.onRemove, + }; + return render(); +} + +describe("CombatantRow", () => { + it("renders combatant name", () => { + renderRow(); + expect(screen.getByText("Goblin")).toBeInTheDocument(); + }); + + it("renders initiative value", () => { + renderRow(); + expect(screen.getByText("15")).toBeInTheDocument(); + }); + + it("renders current HP", () => { + renderRow({ + combatant: { + id: combatantId("1"), + name: "Goblin", + maxHp: 10, + currentHp: 7, + }, + }); + expect(screen.getByText("7")).toBeInTheDocument(); + }); + + it("active combatant gets active border styling", () => { + const { container } = renderRow({ isActive: true }); + const row = container.firstElementChild; + expect(row?.className).toContain("border-l-accent"); + }); + + it("unconscious combatant (currentHp === 0) gets dimmed styling", () => { + renderRow({ + combatant: { + id: combatantId("1"), + name: "Goblin", + maxHp: 10, + currentHp: 0, + }, + }); + // The name area should have opacity-50 + const nameEl = screen.getByText("Goblin"); + const nameContainer = nameEl.closest(".opacity-50"); + expect(nameContainer).not.toBeNull(); + }); + + it("shows '--' for current HP when no maxHp is set", () => { + renderRow({ + combatant: { + id: combatantId("1"), + name: "Goblin", + }, + }); + expect(screen.getByLabelText("No HP set")).toBeInTheDocument(); + }); + + it("shows concentration icon when isConcentrating is true", () => { + renderRow({ + combatant: { + id: combatantId("1"), + name: "Goblin", + isConcentrating: true, + }, + }); + const concButton = screen.getByRole("button", { + name: "Toggle concentration", + }); + expect(concButton.className).toContain("text-purple-400"); + }); + + it("shows player character icon and color when set", () => { + const { container } = renderRow({ + combatant: { + id: combatantId("1"), + name: "Aragorn", + color: "red", + icon: "sword", + }, + }); + // The icon should be rendered with the player color + const svgIcon = container.querySelector("svg[style]"); + expect(svgIcon).not.toBeNull(); + expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red }); + }); + + it("remove button calls onRemove after confirmation", async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + renderRow({ onRemove }); + const removeBtn = screen.getByRole("button", { + name: "Remove combatant", + }); + // First click enters confirm state + await user.click(removeBtn); + // Second click confirms + const confirmBtn = screen.getByRole("button", { + name: "Confirm remove combatant", + }); + await user.click(confirmBtn); + expect(onRemove).toHaveBeenCalledWith(combatantId("1")); + }); + + it("shows d20 roll button when initiative is undefined and onRollInitiative is provided", () => { + renderRow({ + combatant: { + id: combatantId("1"), + name: "Goblin", + }, + onRollInitiative: vi.fn(), + }); + expect( + screen.getByRole("button", { name: "Roll initiative" }), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/__tests__/condition-picker.test.tsx b/apps/web/src/components/__tests__/condition-picker.test.tsx new file mode 100644 index 0000000..8aa0bd2 --- /dev/null +++ b/apps/web/src/components/__tests__/condition-picker.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; + +import { CONDITION_DEFINITIONS, 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 { ConditionPicker } from "../condition-picker"; + +afterEach(cleanup); + +function renderPicker( + overrides: Partial<{ + activeConditions: readonly ConditionId[]; + onToggle: (conditionId: ConditionId) => void; + onClose: () => void; + }> = {}, +) { + const onToggle = overrides.onToggle ?? vi.fn(); + const onClose = overrides.onClose ?? vi.fn(); + const result = render( + , + ); + return { ...result, onToggle, onClose }; +} + +describe("ConditionPicker", () => { + it("renders all condition definitions from domain", () => { + renderPicker(); + for (const def of CONDITION_DEFINITIONS) { + expect(screen.getByText(def.label)).toBeInTheDocument(); + } + }); + + it("active conditions are visually distinguished", () => { + renderPicker({ activeConditions: ["blinded"] }); + const blindedButton = screen.getByText("Blinded").closest("button"); + expect(blindedButton?.className).toContain("bg-card/50"); + }); + + it("clicking a condition calls onToggle with that condition's ID", async () => { + const user = userEvent.setup(); + const { onToggle } = renderPicker(); + await user.click(screen.getByText("Poisoned")); + expect(onToggle).toHaveBeenCalledWith("poisoned"); + }); + + it("non-active conditions render with muted styling", () => { + renderPicker({ activeConditions: [] }); + const label = screen.getByText("Charmed"); + expect(label.className).toContain("text-muted-foreground"); + }); + + it("active condition labels use foreground color", () => { + renderPicker({ activeConditions: ["charmed"] }); + const label = screen.getByText("Charmed"); + expect(label.className).toContain("text-foreground"); + }); +}); diff --git a/apps/web/src/components/__tests__/hp-adjust-popover.test.tsx b/apps/web/src/components/__tests__/hp-adjust-popover.test.tsx new file mode 100644 index 0000000..eb078bf --- /dev/null +++ b/apps/web/src/components/__tests__/hp-adjust-popover.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom +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 { HpAdjustPopover } from "../hp-adjust-popover"; + +afterEach(cleanup); + +function renderPopover( + overrides: Partial<{ + onAdjust: (delta: number) => void; + onClose: () => void; + }> = {}, +) { + const onAdjust = overrides.onAdjust ?? vi.fn(); + const onClose = overrides.onClose ?? vi.fn(); + const result = render( + , + ); + return { ...result, onAdjust, onClose }; +} + +describe("HpAdjustPopover", () => { + it("renders input with placeholder 'HP'", () => { + renderPopover(); + expect(screen.getByPlaceholderText("HP")).toBeInTheDocument(); + }); + + it("damage and heal buttons are disabled when input is empty", () => { + renderPopover(); + expect(screen.getByRole("button", { name: "Apply damage" })).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Apply healing" }), + ).toBeDisabled(); + }); + + it("damage and heal buttons are disabled when input is '0'", async () => { + const user = userEvent.setup(); + renderPopover(); + await user.type(screen.getByPlaceholderText("HP"), "0"); + expect(screen.getByRole("button", { name: "Apply damage" })).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Apply healing" }), + ).toBeDisabled(); + }); + + it("typing a valid number enables both buttons", async () => { + const user = userEvent.setup(); + renderPopover(); + await user.type(screen.getByPlaceholderText("HP"), "5"); + expect( + screen.getByRole("button", { name: "Apply damage" }), + ).not.toBeDisabled(); + expect( + screen.getByRole("button", { name: "Apply healing" }), + ).not.toBeDisabled(); + }); + + it("clicking damage button calls onAdjust with negative value and onClose", async () => { + const user = userEvent.setup(); + const { onAdjust, onClose } = renderPopover(); + await user.type(screen.getByPlaceholderText("HP"), "7"); + await user.click(screen.getByRole("button", { name: "Apply damage" })); + expect(onAdjust).toHaveBeenCalledWith(-7); + expect(onClose).toHaveBeenCalled(); + }); + + it("clicking heal button calls onAdjust with positive value and onClose", async () => { + const user = userEvent.setup(); + const { onAdjust, onClose } = renderPopover(); + await user.type(screen.getByPlaceholderText("HP"), "3"); + await user.click(screen.getByRole("button", { name: "Apply healing" })); + expect(onAdjust).toHaveBeenCalledWith(3); + expect(onClose).toHaveBeenCalled(); + }); + + it("Enter key applies damage (negative)", async () => { + const user = userEvent.setup(); + const { onAdjust, onClose } = renderPopover(); + const input = screen.getByPlaceholderText("HP"); + await user.type(input, "4"); + await user.keyboard("{Enter}"); + expect(onAdjust).toHaveBeenCalledWith(-4); + expect(onClose).toHaveBeenCalled(); + }); + + it("Shift+Enter applies healing (positive)", async () => { + const user = userEvent.setup(); + const { onAdjust, onClose } = renderPopover(); + const input = screen.getByPlaceholderText("HP"); + await user.type(input, "6"); + await user.keyboard("{Shift>}{Enter}{/Shift}"); + expect(onAdjust).toHaveBeenCalledWith(6); + expect(onClose).toHaveBeenCalled(); + }); + + it("Escape key calls onClose", async () => { + const user = userEvent.setup(); + const { onClose } = renderPopover(); + const input = screen.getByPlaceholderText("HP"); + await user.type(input, "2"); + await user.keyboard("{Escape}"); + expect(onClose).toHaveBeenCalled(); + }); + + it("only accepts digit characters in input", async () => { + const user = userEvent.setup(); + renderPopover(); + const input = screen.getByPlaceholderText("HP"); + await user.type(input, "12abc34"); + expect(input).toHaveValue("1234"); + }); +}); diff --git a/apps/web/src/components/__tests__/source-manager.test.tsx b/apps/web/src/components/__tests__/source-manager.test.tsx new file mode 100644 index 0000000..e0a4d2a --- /dev/null +++ b/apps/web/src/components/__tests__/source-manager.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +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(), +})); + +import * as bestiaryCache from "../../adapters/bestiary-cache.js"; +import { SourceManager } from "../source-manager"; + +const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources); +const mockClearSource = vi.mocked(bestiaryCache.clearSource); +const mockClearAll = vi.mocked(bestiaryCache.clearAll); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("SourceManager", () => { + it("shows 'No cached sources' empty state when no sources", async () => { + mockGetCachedSources.mockResolvedValue([]); + render(); + await waitFor(() => { + expect(screen.getByText("No cached sources")).toBeInTheDocument(); + }); + }); + + it("lists cached sources with display name and creature count", async () => { + mockGetCachedSources.mockResolvedValue([ + { + sourceCode: "mm", + displayName: "Monster Manual", + creatureCount: 300, + cachedAt: Date.now(), + }, + { + sourceCode: "vgm", + displayName: "Volo's Guide", + creatureCount: 100, + cachedAt: Date.now(), + }, + ]); + render(); + await waitFor(() => { + expect(screen.getByText("Monster Manual")).toBeInTheDocument(); + }); + expect(screen.getByText("300 creatures")).toBeInTheDocument(); + expect(screen.getByText("Volo's Guide")).toBeInTheDocument(); + expect(screen.getByText("100 creatures")).toBeInTheDocument(); + }); + + it("Clear All button calls cache clear and onCacheCleared", async () => { + const user = userEvent.setup(); + const onCacheCleared = vi.fn(); + mockGetCachedSources + .mockResolvedValueOnce([ + { + sourceCode: "mm", + displayName: "Monster Manual", + creatureCount: 300, + cachedAt: Date.now(), + }, + ]) + .mockResolvedValue([]); + mockClearAll.mockResolvedValue(undefined); + render(); + + await waitFor(() => { + expect(screen.getByText("Monster Manual")).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: "Clear All" })); + await waitFor(() => { + expect(mockClearAll).toHaveBeenCalled(); + }); + expect(onCacheCleared).toHaveBeenCalled(); + }); + + it("individual source delete button calls clear for that source", async () => { + const user = userEvent.setup(); + const onCacheCleared = vi.fn(); + 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); + + render(); + await waitFor(() => { + expect(screen.getByText("Monster Manual")).toBeInTheDocument(); + }); + + await user.click( + screen.getByRole("button", { name: "Remove Monster Manual" }), + ); + await waitFor(() => { + expect(mockClearSource).toHaveBeenCalledWith("mm"); + }); + expect(onCacheCleared).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/combatant-row.tsx b/apps/web/src/components/combatant-row.tsx index f80473e..a27ffda 100644 --- a/apps/web/src/components/combatant-row.tsx +++ b/apps/web/src/components/combatant-row.tsx @@ -231,6 +231,8 @@ function ClickableHp({ "inline-block h-7 w-[4ch] text-center text-muted-foreground text-sm tabular-nums leading-7", dimmed && "opacity-50", )} + role="status" + aria-label="No HP set" > -- diff --git a/apps/web/src/components/source-manager.tsx b/apps/web/src/components/source-manager.tsx index d71d848..07ca7e2 100644 --- a/apps/web/src/components/source-manager.tsx +++ b/apps/web/src/components/source-manager.tsx @@ -88,6 +88,7 @@ export function SourceManager({ type="button" onClick={() => handleClearSource(source.sourceCode)} className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive" + aria-label={`Remove ${source.displayName}`} > diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d761cb2..d20953b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -1027,6 +1030,12 @@ packages: '@types/react-dom': optional: true + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -3031,6 +3040,10 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1