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>
398 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|