From 2bc22369ce8c142e80a65a936a8a3d5e05b6af5a Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 29 Mar 2026 12:35:35 +0200 Subject: [PATCH] Add tests for ConditionTags and CreatePlayerModal ConditionTags: rendering, remove callback, add picker callback. CreatePlayerModal: create/edit modes, form validation (name, AC, HP, level), error display and clearing, onSave/onClose callbacks. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/condition-tags.test.tsx | 87 +++++++++ .../__tests__/create-player-modal.test.tsx | 173 ++++++++++++++++++ vitest.config.ts | 8 +- 3 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/__tests__/condition-tags.test.tsx create mode 100644 apps/web/src/components/__tests__/create-player-modal.test.tsx diff --git a/apps/web/src/components/__tests__/condition-tags.test.tsx b/apps/web/src/components/__tests__/condition-tags.test.tsx new file mode 100644 index 0000000..3513b1f --- /dev/null +++ b/apps/web/src/components/__tests__/condition-tags.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +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 { ConditionTags } from "../condition-tags.js"; + +vi.mock("../../contexts/rules-edition-context.js", () => ({ + useRulesEditionContext: () => ({ edition: "5.5e" }), +})); + +afterEach(cleanup); + +describe("ConditionTags", () => { + it("renders nothing when conditions is undefined", () => { + const { container } = render( + {}} + onOpenPicker={() => {}} + />, + ); + // 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( + {}} + onOpenPicker={() => {}} + />, + ); + expect( + screen.getByRole("button", { name: "Remove Blinded" }), + ).toBeDefined(); + expect(screen.getByRole("button", { name: "Remove Prone" })).toBeDefined(); + }); + + it("calls onRemove with condition id when clicked", async () => { + const onRemove = vi.fn(); + render( + {}} + />, + ); + + await userEvent.click( + screen.getByRole("button", { name: "Remove Blinded" }), + ); + + expect(onRemove).toHaveBeenCalledWith("blinded"); + }); + + it("calls onOpenPicker when add button is clicked", async () => { + const onOpenPicker = vi.fn(); + render( + {}} + onOpenPicker={onOpenPicker} + />, + ); + + await userEvent.click( + screen.getByRole("button", { name: "Add condition" }), + ); + + expect(onOpenPicker).toHaveBeenCalledOnce(); + }); + + it("renders empty conditions array without errors", () => { + render( + {}} + onOpenPicker={() => {}} + />, + ); + // Only add button + expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined(); + }); +}); diff --git a/apps/web/src/components/__tests__/create-player-modal.test.tsx b/apps/web/src/components/__tests__/create-player-modal.test.tsx new file mode 100644 index 0000000..58ff562 --- /dev/null +++ b/apps/web/src/components/__tests__/create-player-modal.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment jsdom +import type { PlayerCharacter } from "@initiative/domain"; +import { playerCharacterId } from "@initiative/domain"; +import { cleanup, render, screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { CreatePlayerModal } from "../create-player-modal.js"; + +beforeAll(() => { + HTMLDialogElement.prototype.showModal = + HTMLDialogElement.prototype.showModal || + function showModal(this: HTMLDialogElement) { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = + HTMLDialogElement.prototype.close || + function close(this: HTMLDialogElement) { + this.removeAttribute("open"); + }; +}); + +afterEach(cleanup); + +function renderModal( + overrides: Partial[0]> = {}, +) { + const defaults = { + open: true, + onClose: vi.fn(), + onSave: vi.fn(), + }; + const props = { ...defaults, ...overrides }; + return { ...render(), ...props }; +} + +describe("CreatePlayerModal", () => { + it("renders create form with defaults", () => { + renderModal(); + expect(screen.getByText("Create Player")).toBeDefined(); + expect(screen.getByLabelText("Name")).toBeDefined(); + expect(screen.getByLabelText("AC")).toBeDefined(); + expect(screen.getByLabelText("Max HP")).toBeDefined(); + expect(screen.getByLabelText("Level")).toBeDefined(); + expect(screen.getByRole("button", { name: "Create" })).toBeDefined(); + }); + + it("renders edit form when playerCharacter is provided", () => { + const pc: PlayerCharacter = { + id: playerCharacterId("pc-1"), + name: "Gandalf", + ac: 15, + maxHp: 40, + color: "blue", + icon: "wand", + level: 10, + }; + renderModal({ playerCharacter: pc }); + expect(screen.getByText("Edit Player")).toBeDefined(); + expect(screen.getByLabelText("Name")).toHaveProperty("value", "Gandalf"); + expect(screen.getByLabelText("AC")).toHaveProperty("value", "15"); + expect(screen.getByLabelText("Max HP")).toHaveProperty("value", "40"); + expect(screen.getByLabelText("Level")).toHaveProperty("value", "10"); + expect(screen.getByRole("button", { name: "Save" })).toBeDefined(); + }); + + it("calls onSave with valid data", async () => { + const { onSave, onClose } = renderModal(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Name"), "Aria"); + await user.clear(screen.getByLabelText("AC")); + await user.type(screen.getByLabelText("AC"), "16"); + await user.clear(screen.getByLabelText("Max HP")); + await user.type(screen.getByLabelText("Max HP"), "30"); + await user.type(screen.getByLabelText("Level"), "5"); + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(onSave).toHaveBeenCalledWith( + "Aria", + 16, + 30, + undefined, + undefined, + 5, + ); + expect(onClose).toHaveBeenCalled(); + }); + + it("shows error for empty name", async () => { + const { onSave } = renderModal(); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(screen.getByText("Name is required")).toBeDefined(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("shows error for invalid AC", async () => { + const { onSave } = renderModal(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Name"), "Test"); + await user.clear(screen.getByLabelText("AC")); + await user.type(screen.getByLabelText("AC"), "abc"); + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(screen.getByText("AC must be a non-negative number")).toBeDefined(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("shows error for invalid Max HP", async () => { + const { onSave } = renderModal(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Name"), "Test"); + await user.clear(screen.getByLabelText("Max HP")); + await user.type(screen.getByLabelText("Max HP"), "0"); + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(screen.getByText("Max HP must be at least 1")).toBeDefined(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("shows error for invalid level", async () => { + const { onSave } = renderModal(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Name"), "Test"); + await user.type(screen.getByLabelText("Level"), "25"); + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(screen.getByText("Level must be between 1 and 20")).toBeDefined(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("clears error when name is edited", async () => { + renderModal(); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Create" })); + expect(screen.getByText("Name is required")).toBeDefined(); + + await user.type(screen.getByLabelText("Name"), "A"); + expect(screen.queryByText("Name is required")).toBeNull(); + }); + + it("calls onClose when cancel is clicked", async () => { + const { onClose } = renderModal(); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("omits level when field is empty", async () => { + const { onSave } = renderModal(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("Name"), "Aria"); + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(onSave).toHaveBeenCalledWith( + "Aria", + 10, + 10, + undefined, + undefined, + undefined, + ); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 638abf6..0c38f55 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,12 +30,12 @@ export default defineConfig({ branches: 55, }, "apps/web/src/components": { - lines: 52, - branches: 49, + lines: 59, + branches: 55, }, "apps/web/src/components/ui": { - lines: 83, - branches: 74, + lines: 86, + branches: 83, }, }, },