// @vitest-environment jsdom import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain"; import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; import { renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { 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 { useDifficulty } from "../use-difficulty.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(), })), }); }); const pcId1 = playerCharacterId("pc-1"); const goblinCreature = buildCreature({ id: creatureId("srd:goblin"), name: "Goblin", cr: "1/4", }); function makeWrapper(options: { encounter: ReturnType; playerCharacters?: PlayerCharacter[]; creatures?: Map; }) { const adapters = createTestAdapters({ encounter: options.encounter, playerCharacters: options.playerCharacters ?? [], creatures: options.creatures, }); return ({ children }: { children: ReactNode }) => ( {children} ); } describe("useDifficulty with custom combatant CRs", () => { it("includes custom combatant with cr field in monster XP", () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c-1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c-2"), name: "Custom Thug", cr: "2", }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }, ], }); const { result } = renderHook(() => useDifficulty(), { wrapper }); expect(result.current).not.toBeNull(); expect(result.current?.totalMonsterXp).toBe(450); }); it("uses bestiary CR when combatant has both creatureId and cr", async () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c-1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c-2"), name: "Goblin", creatureId: goblinCreature.id, cr: "5", }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }, ], creatures: new Map([[goblinCreature.id, goblinCreature]]), }); const { result } = renderHook(() => useDifficulty(), { wrapper }); await waitFor(() => { expect(result.current).not.toBeNull(); // Should use bestiary CR 1/4 (50 XP), not the manual cr "5" (1800 XP) expect(result.current?.totalMonsterXp).toBe(50); }); }); it("mixes bestiary and custom-with-CR combatants correctly", async () => { const wrapper = makeWrapper({ encounter: 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", cr: "1", }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, ], creatures: new Map([[goblinCreature.id, goblinCreature]]), }); const { result } = renderHook(() => useDifficulty(), { wrapper }); await waitFor(() => { expect(result.current).not.toBeNull(); // CR 1/4 = 50 XP, CR 1 = 200 XP → total 250 expect(result.current?.totalMonsterXp).toBe(250); }); }); it("custom combatant without CR is still excluded", () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c-1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c-2"), name: "Custom Monster", }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }, ], }); const { result } = renderHook(() => useDifficulty(), { wrapper }); expect(result.current).toBeNull(); }); });