diff --git a/apps/web/src/__tests__/factories/build-player-character.ts b/apps/web/src/__tests__/factories/build-player-character.ts new file mode 100644 index 0000000..cc12bcf --- /dev/null +++ b/apps/web/src/__tests__/factories/build-player-character.ts @@ -0,0 +1,16 @@ +import type { PlayerCharacter } from "@initiative/domain"; +import { playerCharacterId } from "@initiative/domain"; + +let counter = 0; + +export function buildPlayerCharacter( + overrides?: Partial, +): PlayerCharacter { + return { + id: playerCharacterId(`pc-${++counter}`), + name: "Player Character", + ac: 15, + maxHp: 25, + ...overrides, + }; +} diff --git a/apps/web/src/__tests__/factories/index.ts b/apps/web/src/__tests__/factories/index.ts index 2b5d189..5d08a33 100644 --- a/apps/web/src/__tests__/factories/index.ts +++ b/apps/web/src/__tests__/factories/index.ts @@ -2,3 +2,4 @@ export { buildCombatant } from "./build-combatant.js"; export { buildCreature } from "./build-creature.js"; export { buildEncounter } from "./build-encounter.js"; export { buildPf2eCreature } from "./build-pf2e-creature.js"; +export { buildPlayerCharacter } from "./build-player-character.js"; diff --git a/apps/web/src/components/__tests__/player-character-section.test.tsx b/apps/web/src/components/__tests__/player-character-section.test.tsx index 62cdf71..f3b2146 100644 --- a/apps/web/src/components/__tests__/player-character-section.test.tsx +++ b/apps/web/src/components/__tests__/player-character-section.test.tsx @@ -1,10 +1,13 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; +import type { PlayerCharacter } from "@initiative/domain"; import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createRef } from "react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js"; +import { buildPlayerCharacter } from "../../__tests__/factories/index.js"; import { polyfillDialog } from "../../__tests__/polyfill-dialog.js"; import { AllProviders } from "../../__tests__/test-providers.js"; import { @@ -13,6 +16,7 @@ import { } from "../player-character-section.js"; const CREATE_FIRST_PC_REGEX = /create your first player character/i; +const ADD_PARTY_REGEX = /add party to encounter/i; beforeAll(() => { polyfillDialog(); @@ -33,21 +37,28 @@ beforeAll(() => { afterEach(cleanup); -function renderSection() { +function renderSection(playerCharacters?: PlayerCharacter[]) { const ref = createRef(); + const adapters = createTestAdapters({ playerCharacters }); const result = render(, { - wrapper: AllProviders, + wrapper: ({ children }) => ( + {children} + ), }); return { ...result, ref }; } +function openManagement(ref: { current: PlayerCharacterSectionHandle | null }) { + const handle = ref.current; + if (!handle) throw new Error("ref not set"); + act(() => handle.openManagement()); +} + describe("PlayerCharacterSection", () => { it("openManagement ref handle opens the management dialog", async () => { const { ref } = renderSection(); - const handle = ref.current; - if (!handle) throw new Error("ref not set"); - act(() => handle.openManagement()); + openManagement(ref); // Management dialog should now be open with its title visible await waitFor(() => { @@ -63,9 +74,7 @@ describe("PlayerCharacterSection", () => { const user = userEvent.setup(); const { ref } = renderSection(); - const handle = ref.current; - if (!handle) throw new Error("ref not set"); - act(() => handle.openManagement()); + openManagement(ref); await user.click( screen.getByRole("button", { @@ -83,9 +92,7 @@ describe("PlayerCharacterSection", () => { const user = userEvent.setup(); const { ref } = renderSection(); - const handle = ref.current; - if (!handle) throw new Error("ref not set"); - act(() => handle.openManagement()); + openManagement(ref); await user.click( screen.getByRole("button", { @@ -105,4 +112,25 @@ describe("PlayerCharacterSection", () => { expect(screen.getByText("Aria")).toBeInTheDocument(); }); }); + + it("adding the party puts every character into the encounter", async () => { + const user = userEvent.setup(); + const party = [ + buildPlayerCharacter({ name: "Thorin" }), + buildPlayerCharacter({ name: "Gandalf" }), + ]; + const { ref } = renderSection(party); + + openManagement(ref); + await user.click(screen.getByRole("button", { name: ADD_PARTY_REGEX })); + + // Dialog closes; reopening shows both characters marked as in the encounter + openManagement(ref); + await waitFor(() => { + expect(screen.getAllByLabelText("Already in encounter")).toHaveLength(2); + }); + expect( + screen.getByRole("button", { name: ADD_PARTY_REGEX }), + ).toBeDisabled(); + }); }); diff --git a/apps/web/src/components/__tests__/player-management.test.tsx b/apps/web/src/components/__tests__/player-management.test.tsx index 69c988b..fdfe05f 100644 --- a/apps/web/src/components/__tests__/player-management.test.tsx +++ b/apps/web/src/components/__tests__/player-management.test.tsx @@ -1,7 +1,11 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { type PlayerCharacter, playerCharacterId } from "@initiative/domain"; +import { + type PlayerCharacter, + type PlayerCharacterId, + 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"; @@ -11,6 +15,7 @@ afterEach(cleanup); const CREATE_FIRST_PC_REGEX = /create your first player character/i; const LEVEL_REGEX = /^Lv /; +const ADD_PARTY_REGEX = /add party to encounter/i; import { PlayerManagement } from "../player-management.js"; @@ -47,6 +52,8 @@ function renderManagement( onEdit: vi.fn(), onDelete: vi.fn(), onCreate: vi.fn(), + inEncounterIds: new Set(), + onAddParty: vi.fn(), ...overrides, }; return { ...render(), props }; @@ -117,4 +124,52 @@ describe("PlayerManagement", () => { await user.click(screen.getByRole("button", { name: "Add" })); expect(props.onCreate).toHaveBeenCalled(); }); + + it("party button calls onAddParty", async () => { + const user = userEvent.setup(); + const { props } = renderManagement({ + characters: [PC_WARRIOR, PC_WIZARD], + }); + + await user.click(screen.getByRole("button", { name: ADD_PARTY_REGEX })); + expect(props.onAddParty).toHaveBeenCalled(); + }); + + it("party button stays enabled while some characters are missing", () => { + renderManagement({ + characters: [PC_WARRIOR, PC_WIZARD], + inEncounterIds: new Set([PC_WARRIOR.id]), + }); + + expect(screen.getByRole("button", { name: ADD_PARTY_REGEX })).toBeEnabled(); + }); + + it("party button is disabled when every character is in the encounter", () => { + renderManagement({ + characters: [PC_WARRIOR, PC_WIZARD], + inEncounterIds: new Set([PC_WARRIOR.id, PC_WIZARD.id]), + }); + + expect( + screen.getByRole("button", { name: ADD_PARTY_REGEX }), + ).toBeDisabled(); + }); + + it("marks only the characters already in the encounter", () => { + renderManagement({ + characters: [PC_WARRIOR, PC_WIZARD], + inEncounterIds: new Set([PC_WIZARD.id]), + }); + + const markers = screen.getAllByLabelText("Already in encounter"); + expect(markers).toHaveLength(1); + expect(markers[0].closest("div")).toHaveTextContent("Gandalf"); + }); + + it("has no party button in the empty state", () => { + renderManagement(); + expect( + screen.queryByRole("button", { name: ADD_PARTY_REGEX }), + ).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/player-character-section.tsx b/apps/web/src/components/player-character-section.tsx index a1ade82..0de34d7 100644 --- a/apps/web/src/components/player-character-section.tsx +++ b/apps/web/src/components/player-character-section.tsx @@ -1,5 +1,6 @@ -import type { PlayerCharacter } from "@initiative/domain"; -import { type RefObject, useImperativeHandle, useState } from "react"; +import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain"; +import { type RefObject, useImperativeHandle, useMemo, useState } from "react"; +import { useEncounterContext } from "../contexts/encounter-context.js"; import { usePlayerCharactersContext } from "../contexts/player-characters-context.js"; import { CreatePlayerModal } from "./create-player-modal.js"; import { PlayerManagement } from "./player-management.js"; @@ -15,6 +16,15 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({ }) { const { characters, createCharacter, editCharacter, deleteCharacter } = usePlayerCharactersContext(); + const { encounter, addParty } = useEncounterContext(); + + const inEncounterIds = useMemo(() => { + const ids = new Set(); + for (const c of encounter.combatants) { + if (c.playerCharacterId) ids.add(c.playerCharacterId); + } + return ids; + }, [encounter.combatants]); const [managementOpen, setManagementOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); @@ -66,6 +76,11 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({ setCreateOpen(true); setManagementOpen(false); }} + inEncounterIds={inEncounterIds} + onAddParty={() => { + addParty(characters); + setManagementOpen(false); + }} /> ); diff --git a/apps/web/src/components/player-management.tsx b/apps/web/src/components/player-management.tsx index 76c48b1..31e16d1 100644 --- a/apps/web/src/components/player-management.tsx +++ b/apps/web/src/components/player-management.tsx @@ -1,5 +1,5 @@ import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain"; -import { Pencil, Plus, Trash2 } from "lucide-react"; +import { Pencil, Plus, Swords, Trash2 } from "lucide-react"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { Button } from "./ui/button"; import { ConfirmButton } from "./ui/confirm-button"; @@ -12,8 +12,12 @@ interface PlayerManagementProps { onEdit: (pc: PlayerCharacter) => void; onDelete: (id: PlayerCharacterId) => void; onCreate: () => void; + inEncounterIds: ReadonlySet; + onAddParty: () => void; } +const IN_ENCOUNTER_LABEL = "Already in encounter"; + export function PlayerManagement({ open, onClose, @@ -21,7 +25,13 @@ export function PlayerManagement({ onEdit, onDelete, onCreate, + inEncounterIds, + onAddParty, }: Readonly) { + const addableCount = characters.filter( + (pc) => !inEncounterIds.has(pc.id), + ).length; + return ( @@ -61,6 +71,16 @@ export function PlayerManagement({ Lv {pc.level} )} + {inEncounterIds.has(pc.id) && ( + + + + )}