Add manual CR assignment and difficulty breakdown panel
Implement issue #21: custom combatants can now have a challenge rating assigned via a new breakdown panel, opened by tapping the difficulty indicator. Bestiary-linked combatants show read-only CR with source name; custom combatants get a CR picker with all standard 5e values. CR persists across reloads and round-trips through JSON export/import. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
// @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, 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 { 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("renders bestiary combatant as read-only with source name", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Goblin (SRD)")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("CR 1/4").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders custom combatant with CR picker", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||
expect(pickers).toHaveLength(2);
|
||||
// First picker is "Custom Thug" with CR 2
|
||||
expect(pickers[0]).toHaveValue("2");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders unassigned combatant with Assign picker and dash for XP", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||
// Second picker is "Bandit" with no CR
|
||||
expect(pickers[1]).toHaveValue("");
|
||||
// "—" appears for unassigned XP
|
||||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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]]),
|
||||
});
|
||||
|
||||
// Wait for the panel to render with bestiary data
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The Bandit (second picker) has no CR — shows "—" for XP
|
||||
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||
|
||||
// Select CR 5 (1,800 XP) on Bandit
|
||||
await user.selectOptions(pickers[1], "5");
|
||||
|
||||
// XP should update — the "—" should be replaced with an XP value
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("1,800")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders total monster XP", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Total Monster XP")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing when breakdown data is insufficient", () => {
|
||||
// No PCs with level → breakdown returns null
|
||||
const { container } = renderPanel({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({ id: combatantId("c-1"), name: "Custom" }),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user