Files
initiative/apps/web/src/components/__tests__/difficulty-breakdown-panel.test.tsx
Lukas 817cfddabc
All checks were successful
CI / check (push) Successful in 2m18s
CI / build-image (push) Successful in 17s
Add 2014 DMG encounter difficulty calculation
Support the 2014 DMG encounter difficulty as an alternative to the 5.5e
system behind the existing Rules Edition toggle. The 2014 system uses
Easy/Medium/Hard/Deadly thresholds, an encounter multiplier based on
monster count, and party size adjustment (×0.5–×5 range).

- Extract RulesEdition to its own domain module
- Refactor DifficultyTier to abstract numeric values (0–3)
- Restructure DifficultyResult with thresholds array
- Add 2014 XP thresholds table and encounter multiplier logic
- Wire edition from context into difficulty hooks
- Edition-aware labels in indicator and breakdown panel
- Show multiplier, adjusted XP, and party size note for 2014
- Rename settings label from "Conditions" to "Rules Edition"
- Update spec 008 with issue #23 requirements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:52:23 +02:00

361 lines
9.5 KiB
TypeScript

// @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<typeof buildEncounter>;
playerCharacters?: PlayerCharacter[];
creatures?: Map<CreatureId, Creature>;
onClose?: () => void;
}) {
const adapters = createTestAdapters({
encounter: options.encounter,
playerCharacters: options.playerCharacters ?? [],
creatures: options.creatures,
});
return render(
<AllProviders adapters={adapters}>
<DifficultyBreakdownPanel onClose={options.onClose ?? (() => {})} />
</AllProviders>,
);
}
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();
});
});