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>
361 lines
9.5 KiB
TypeScript
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();
|
|
});
|
|
});
|