Files
initiative/apps/web/src/components/__tests__/stat-block.test.tsx
T
LukasandClaude Opus 5 be3778a0c9 Add min/max hit point variants for D&D creatures
A D&D stat block opened for a combatant now offers a Min/Avg/Max toggle
below the Hit Points line, deriving both ends from the Hit Dice formula
(3d6 + 6 -> 9 / 16 / 24). The switch is applied as a delta, so manual HP
edits and damage already taken survive it. Unlike the PF2e weak/elite
adjustment this is a convenience tool, not a rules mechanic: nothing but
HP changes and the combatant is not renamed. The toggle is hidden when
the HP field carries prose instead of a dice pool.

Also extracts a SegmentedControl primitive. Game system, theme and the
PF2e weak/elite toggle were three hand-rolled copies of the same markup,
which is why the D&D toggle first came out chunkier and in the wrong
accent color. All four now share one definition, and segments expose
aria-pressed instead of signalling the active state by color alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:06:01 +02:00

398 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { Creature, HpVariant } from "@initiative/domain";
import { combatantId, creatureId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
afterEach(cleanup);
const ARMOR_CLASS_REGEX = /Armor Class/;
const DEX_PLUS_4_REGEX = /Dex \+4/;
const CR_QUARTER_REGEX = /1\/4/;
const PROF_BONUS_2_REGEX = /Proficiency Bonus \+2/;
const NIMBLE_ESCAPE_REGEX = /Nimble Escape\./;
const SCIMITAR_REGEX = /Scimitar\./;
const DETECT_REGEX = /Detect\./;
const TAIL_ATTACK_REGEX = /Tail Attack\./;
const INNATE_SPELLCASTING_REGEX = /Innate Spellcasting\./;
const AT_WILL_REGEX = /At Will:/;
const DETECT_MAGIC_REGEX = /detect magic, suggestion/;
const DAILY_REGEX = /3\/day each:/;
const FIREBALL_REGEX = /fireball, wall of fire/;
const LONG_REST_REGEX = /1\/long rest:/;
const WISH_REGEX = /wish/;
const GOBLIN: Creature = {
id: creatureId("srd:goblin"),
name: "Goblin",
source: "MM",
sourceDisplayName: "Monster Manual",
size: "Small",
type: "humanoid",
alignment: "neutral evil",
ac: 15,
acSource: "leather armor, shield",
hp: { average: 7, formula: "2d6" },
speed: "30 ft.",
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
cr: "1/4",
initiativeProficiency: 0,
proficiencyBonus: 2,
passive: 9,
savingThrows: "Dex +4",
skills: "Stealth +6",
senses: "darkvision 60 ft., passive Perception 9",
languages: "Common, Goblin",
traits: [
{
name: "Nimble Escape",
segments: [{ type: "text", value: "Disengage or Hide as bonus." }],
},
],
actions: [
{
name: "Scimitar",
segments: [{ type: "text", value: "Melee: +4 to hit, 5 slashing." }],
},
],
bonusActions: [
{
name: "Nimble",
segments: [{ type: "text", value: "Disengage or Hide." }],
},
],
reactions: [
{
name: "Redirect",
segments: [{ type: "text", value: "Redirect attack to ally." }],
},
],
};
const DRAGON: Creature = {
id: creatureId("srd:dragon"),
name: "Ancient Red Dragon",
source: "MM",
sourceDisplayName: "Monster Manual",
size: "Gargantuan",
type: "dragon",
alignment: "chaotic evil",
ac: 22,
hp: { average: 546, formula: "28d20 + 252" },
speed: "40 ft., climb 40 ft., fly 80 ft.",
abilities: { str: 30, dex: 10, con: 29, int: 18, wis: 15, cha: 23 },
cr: "24",
initiativeProficiency: 0,
proficiencyBonus: 7,
passive: 26,
resist: "fire",
immune: "fire",
vulnerable: "cold",
conditionImmune: "frightened",
legendaryActions: {
preamble: "The dragon can take 3 legendary actions.",
entries: [
{
name: "Detect",
segments: [
{ type: "text" as const, value: "Wisdom (Perception) check." },
],
},
{
name: "Tail Attack",
segments: [{ type: "text" as const, value: "Tail attack." }],
},
],
},
spellcasting: [
{
name: "Innate Spellcasting",
headerText: "The dragon's spellcasting ability is Charisma.",
atWill: [{ name: "detect magic" }, { name: "suggestion" }],
daily: [
{
uses: 3,
each: true,
spells: [{ name: "fireball" }, { name: "wall of fire" }],
},
],
restLong: [{ uses: 1, each: false, spells: [{ name: "wish" }] }],
},
],
};
function renderStatBlock(creature: Creature) {
return render(<StatBlock creature={creature} />);
}
/** Owns the variant the way the encounter state does in the real app. */
function HpVariantHarness({ creature }: Readonly<{ creature: Creature }>) {
const [variant, setVariant] = useState<HpVariant | undefined>(undefined);
return (
<StatBlock
creature={creature}
combatantId={combatantId("c-1")}
hpVariant={variant}
onSetHpVariant={(_id, next) => setVariant(next)}
/>
);
}
describe("StatBlock", () => {
describe("header", () => {
it("renders creature name", () => {
renderStatBlock(GOBLIN);
expect(
screen.getByRole("heading", { name: "Goblin" }),
).toBeInTheDocument();
});
it("renders size, type, alignment", () => {
renderStatBlock(GOBLIN);
expect(
screen.getByText("Small humanoid, neutral evil"),
).toBeInTheDocument();
});
it("renders source display name", () => {
renderStatBlock(GOBLIN);
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
});
describe("stats bar", () => {
it("renders AC with source", () => {
renderStatBlock(GOBLIN);
expect(screen.getByText(ARMOR_CLASS_REGEX)).toBeInTheDocument();
expect(screen.getByText("15")).toBeInTheDocument();
expect(screen.getByText("(leather armor, shield)")).toBeInTheDocument();
});
it("renders AC without source when acSource is undefined", () => {
renderStatBlock(DRAGON);
expect(screen.getByText("22")).toBeInTheDocument();
});
it("renders HP average and formula", () => {
renderStatBlock(GOBLIN);
expect(screen.getByText("7")).toBeInTheDocument();
expect(screen.getByText("(2d6)")).toBeInTheDocument();
});
it("renders speed", () => {
renderStatBlock(GOBLIN);
expect(screen.getByText("30 ft.")).toBeInTheDocument();
});
});
describe("hit point variant", () => {
it("offers no variant buttons while browsing without a combatant", () => {
renderStatBlock(GOBLIN);
expect(
screen.queryByRole("button", { name: "Max" }),
).not.toBeInTheDocument();
});
it("hides the variant buttons when the HP formula is not a dice pool", () => {
render(
<HpVariantHarness
creature={{
...GOBLIN,
hp: { average: 50, formula: "special" },
}}
/>,
);
expect(
screen.queryByRole("button", { name: "Max" }),
).not.toBeInTheDocument();
expect(screen.getByText("50")).toBeInTheDocument();
});
it("shows the printed average until a variant is picked", () => {
render(<HpVariantHarness creature={GOBLIN} />);
expect(screen.getByText("7")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Avg" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
it("shows the maximum roll after picking Max", async () => {
const user = userEvent.setup();
render(<HpVariantHarness creature={GOBLIN} />);
await user.click(screen.getByRole("button", { name: "Max" }));
expect(screen.getByText("12")).toBeInTheDocument();
expect(screen.queryByText("7")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Max" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
it("shows the minimum roll after picking Min", async () => {
const user = userEvent.setup();
render(<HpVariantHarness creature={GOBLIN} />);
await user.click(screen.getByRole("button", { name: "Min" }));
expect(screen.getByText("2")).toBeInTheDocument();
});
it("returns to the average after picking Avg again", async () => {
const user = userEvent.setup();
render(<HpVariantHarness creature={GOBLIN} />);
await user.click(screen.getByRole("button", { name: "Max" }));
await user.click(screen.getByRole("button", { name: "Avg" }));
expect(screen.getByText("7")).toBeInTheDocument();
});
it("keeps showing the formula for every variant", async () => {
const user = userEvent.setup();
render(<HpVariantHarness creature={GOBLIN} />);
await user.click(screen.getByRole("button", { name: "Min" }));
expect(screen.getByText("(2d6)")).toBeInTheDocument();
});
});
describe("ability scores", () => {
it("renders all 6 ability labels", () => {
renderStatBlock(GOBLIN);
for (const label of ["STR", "DEX", "CON", "INT", "WIS", "CHA"]) {
expect(screen.getByText(label)).toBeInTheDocument();
}
});
it("renders ability scores with modifier notation", () => {
renderStatBlock(GOBLIN);
expect(screen.getByText("(+2)")).toBeInTheDocument();
});
});
describe("properties", () => {
it("renders saving throws when present", () => {
renderStatBlock(GOBLIN);
expect(screen.getByText("Saving Throws")).toBeInTheDocument();
expect(screen.getByText(DEX_PLUS_4_REGEX)).toBeInTheDocument();
});
it("renders skills when present", () => {
renderStatBlock(GOBLIN);
expect(screen.getByText("Skills")).toBeInTheDocument();
});
it("renders damage resistances, immunities, vulnerabilities", () => {
renderStatBlock(DRAGON);
expect(screen.getByText("Damage Resistances")).toBeInTheDocument();
expect(screen.getByText("Damage Immunities")).toBeInTheDocument();
expect(screen.getByText("Damage Vulnerabilities")).toBeInTheDocument();
expect(screen.getByText("Condition Immunities")).toBeInTheDocument();
});
it("omits properties when undefined", () => {
renderStatBlock(GOBLIN);
expect(screen.queryByText("Damage Resistances")).not.toBeInTheDocument();
expect(screen.queryByText("Damage Immunities")).not.toBeInTheDocument();
});
it("renders CR and proficiency bonus", () => {
renderStatBlock(GOBLIN);
expect(screen.getByText("Challenge")).toBeInTheDocument();
expect(screen.getByText(CR_QUARTER_REGEX)).toBeInTheDocument();
expect(screen.getByText(PROF_BONUS_2_REGEX)).toBeInTheDocument();
});
});
describe("traits", () => {
it("renders trait entries", () => {
renderStatBlock(GOBLIN);
expect(screen.getByText(NIMBLE_ESCAPE_REGEX)).toBeInTheDocument();
});
});
describe("actions / bonus actions / reactions", () => {
it("renders actions heading and entries", () => {
renderStatBlock(GOBLIN);
expect(
screen.getByRole("heading", { name: "Actions" }),
).toBeInTheDocument();
expect(screen.getByText(SCIMITAR_REGEX)).toBeInTheDocument();
});
it("renders bonus actions heading and entries", () => {
renderStatBlock(GOBLIN);
expect(
screen.getByRole("heading", { name: "Bonus Actions" }),
).toBeInTheDocument();
});
it("renders reactions heading and entries", () => {
renderStatBlock(GOBLIN);
expect(
screen.getByRole("heading", { name: "Reactions" }),
).toBeInTheDocument();
});
});
describe("legendary actions", () => {
it("renders legendary actions with preamble", () => {
renderStatBlock(DRAGON);
expect(
screen.getByRole("heading", { name: "Legendary Actions" }),
).toBeInTheDocument();
expect(
screen.getByText("The dragon can take 3 legendary actions."),
).toBeInTheDocument();
expect(screen.getByText(DETECT_REGEX)).toBeInTheDocument();
expect(screen.getByText(TAIL_ATTACK_REGEX)).toBeInTheDocument();
});
it("omits legendary actions when undefined", () => {
renderStatBlock(GOBLIN);
expect(
screen.queryByRole("heading", { name: "Legendary Actions" }),
).not.toBeInTheDocument();
});
});
describe("spellcasting", () => {
it("renders spellcasting block with header", () => {
renderStatBlock(DRAGON);
expect(screen.getByText(INNATE_SPELLCASTING_REGEX)).toBeInTheDocument();
});
it("renders at-will spells", () => {
renderStatBlock(DRAGON);
expect(screen.getByText(AT_WILL_REGEX)).toBeInTheDocument();
expect(screen.getByText(DETECT_MAGIC_REGEX)).toBeInTheDocument();
});
it("renders daily spells", () => {
renderStatBlock(DRAGON);
expect(screen.getByText(DAILY_REGEX)).toBeInTheDocument();
expect(screen.getByText(FIREBALL_REGEX)).toBeInTheDocument();
});
it("renders long rest spells", () => {
renderStatBlock(DRAGON);
expect(screen.getByText(LONG_REST_REGEX)).toBeInTheDocument();
expect(screen.getByText(WISH_REGEX)).toBeInTheDocument();
});
it("omits spellcasting when undefined", () => {
renderStatBlock(GOBLIN);
expect(screen.queryByText(AT_WILL_REGEX)).not.toBeInTheDocument();
});
});
});