// @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain"; import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; import { cleanup, render, renderHook, screen, waitFor, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js"; import { buildCombatant, buildCreature, buildEncounter, } from "../../__tests__/factories/index.js"; import { AllProviders } from "../../__tests__/test-providers.js"; import { useRulesEdition } from "../../hooks/use-rules-edition.js"; import { DifficultyBreakdownPanel } from "../difficulty-breakdown-panel.js"; beforeAll(() => { Object.defineProperty(globalThis, "matchMedia", { writable: true, value: vi.fn().mockImplementation((query: string) => ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), }); }); afterEach(cleanup); const pcId1 = playerCharacterId("pc-1"); const goblinCreature = buildCreature({ id: creatureId("srd:goblin"), name: "Goblin", cr: "1/4", source: "srd", sourceDisplayName: "SRD", }); function renderPanel(options: { encounter: ReturnType; playerCharacters?: PlayerCharacter[]; creatures?: Map; onClose?: () => void; }) { const adapters = createTestAdapters({ encounter: options.encounter, playerCharacters: options.playerCharacters ?? [], creatures: options.creatures, }); return render( {})} /> , ); } function defaultEncounter() { return buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c-1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c-2"), name: "Goblin", creatureId: goblinCreature.id, }), buildCombatant({ id: combatantId("c-3"), name: "Custom Thug", cr: "2", }), buildCombatant({ id: combatantId("c-4"), name: "Bandit", }), ], }); } const defaultPCs: PlayerCharacter[] = [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, ]; describe("DifficultyBreakdownPanel", () => { it("renders party budget section", async () => { renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { expect( screen.getByText("Party Budget", { exact: false }), ).toBeInTheDocument(); expect(screen.getByText("1 PC", { exact: false })).toBeInTheDocument(); expect(screen.getByText("Low:", { exact: false })).toBeInTheDocument(); }); }); it("renders tier label", async () => { renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { expect( screen.getByText("Encounter Difficulty:", { exact: false }), ).toBeInTheDocument(); }); }); it("shows PC in party column with level", async () => { renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { expect(screen.getByText("Hero")).toBeInTheDocument(); expect(screen.getByText("Lv 5")).toBeInTheDocument(); }); }); it("shows monsters in enemy column", async () => { renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { expect(screen.getByText("Goblin")).toBeInTheDocument(); expect(screen.getAllByText("CR 1/4").length).toBeGreaterThanOrEqual(1); }); }); it("renders explanation text", async () => { renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { expect( screen.getByText( "Allied NPC XP is subtracted from encounter difficulty", ), ).toBeInTheDocument(); }); }); it("renders Net Monster XP footer", async () => { renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { expect(screen.getByText("Net Monster XP")).toBeInTheDocument(); }); }); it("renders custom combatant with CR picker in enemy column", async () => { renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { const pickers = screen.getAllByLabelText("Challenge rating"); expect(pickers).toHaveLength(2); expect(pickers[0]).toHaveValue("2"); }); }); it("selecting a CR updates the visible XP value", async () => { const user = userEvent.setup(); renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { expect(screen.getAllByLabelText("Challenge rating")).toHaveLength(2); }); const pickers = screen.getAllByLabelText("Challenge rating"); await user.selectOptions(pickers[1], "5"); await waitFor(() => { expect(screen.getByText("1,800")).toBeInTheDocument(); }); }); it("non-PC combatants show toggle button", async () => { renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { // Each non-PC enemy combatant has a toggle button expect( screen.getByLabelText("Move Goblin to party side"), ).toBeInTheDocument(); expect( screen.getByLabelText("Move Custom Thug to party side"), ).toBeInTheDocument(); }); }); it("PC combatants do not show side toggle", async () => { renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { expect(screen.getByText("Hero")).toBeInTheDocument(); }); expect( screen.queryByLabelText("Move Hero to enemy side"), ).not.toBeInTheDocument(); }); it("side toggle moves combatant between sections", async () => { const user = userEvent.setup(); renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { expect(screen.getByText("Goblin")).toBeInTheDocument(); }); // Toggle goblin to party side const toggleBtn = screen.getByLabelText("Move Goblin to party side"); await user.click(toggleBtn); // After toggle, the aria-label should change to "Move Goblin to enemy side" await waitFor(() => { expect( screen.getByLabelText("Move Goblin to enemy side"), ).toBeInTheDocument(); }); }); it("renders nothing when breakdown data is insufficient", () => { const { container } = renderPanel({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c-1"), name: "Custom" }), ], }), }); expect(container.innerHTML).toBe(""); }); it("shows 4 threshold columns for 2014 edition", async () => { const { result: editionResult } = renderHook(() => useRulesEdition()); editionResult.current.setEdition("5e"); try { renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { expect(screen.getByText("Easy:", { exact: false })).toBeInTheDocument(); expect(screen.getByText("Med:", { exact: false })).toBeInTheDocument(); expect(screen.getByText("Hard:", { exact: false })).toBeInTheDocument(); expect( screen.getByText("Deadly:", { exact: false }), ).toBeInTheDocument(); }); } finally { editionResult.current.setEdition("5.5e"); } }); it("shows multiplier and adjusted XP for 2014 edition", async () => { const { result: editionResult } = renderHook(() => useRulesEdition()); editionResult.current.setEdition("5e"); try { renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { expect(screen.getByText("Monster XP")).toBeInTheDocument(); // 1 PC (<3) triggers party size adjustment expect(screen.getByText("Adjusted for 1 PC")).toBeInTheDocument(); }); } finally { editionResult.current.setEdition("5.5e"); } }); it("shows Net Monster XP for 5.5e edition (no multiplier)", async () => { renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), }); await waitFor(() => { expect(screen.getByText("Net Monster XP")).toBeInTheDocument(); }); }); it("calls onClose when Escape is pressed", async () => { const user = userEvent.setup(); const onClose = vi.fn(); renderPanel({ encounter: defaultEncounter(), playerCharacters: defaultPCs, creatures: new Map([[goblinCreature.id, goblinCreature]]), onClose, }); await user.keyboard("{Escape}"); expect(onClose).toHaveBeenCalledOnce(); }); });