Files
initiative/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx
Lukas 1eaeecad32
All checks were successful
CI / check (push) Successful in 2m31s
CI / build-image (push) Successful in 17s
Add PF2e equipment display with detail popovers in stat blocks
Extract shared DetailPopover shell from spell popovers. Normalize
weapon/consumable/equipment/armor items from Foundry data into
mundane (Items line) and detailed (Equipment section with clickable
popovers). Scrolls/wands show embedded spell info. Bump IDB cache v7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:11:54 +02:00

498 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { Pf2eCreature } from "@initiative/domain";
import { creatureId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Pf2eStatBlock } from "../pf2e-stat-block.js";
afterEach(cleanup);
const USES_PER_DAY_REGEX = /×3/;
const HEAL_DESCRIPTION_REGEX = /channel positive energy/;
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
const SAVE_CONDITIONAL_ABSENT_REGEX = /status to all saves/;
const HP_DETAILS_REGEX = /115.*regeneration 20/;
const REGEN_REGEX = /regeneration/;
const ATTACK_NAME_REGEX = /Dogslicer/;
const ATTACK_DAMAGE_REGEX = /1d6 slashing/;
const SPELLCASTING_ENTRY_REGEX = /Divine Innate Spells\./;
const ABILITY_MID_NAME_REGEX = /Goblin Scuttle/;
const ABILITY_MID_DESC_REGEX = /The goblin Steps\./;
const CANTRIPS_REGEX = /Cantrips:/;
const AC_REGEX = /16/;
const RK_DC_13_REGEX = /DC 13/;
const RK_DC_15_REGEX = /DC 15/;
const RK_DC_25_REGEX = /DC 25/;
const RK_HUMANOID_SOCIETY_REGEX = /Humanoid \(Society\)/;
const RK_UNDEAD_RELIGION_REGEX = /Undead \(Religion\)/;
const RK_BEAST_SKILLS_REGEX = /Beast \(Arcana\/Nature\)/;
const SCROLL_NAME_REGEX = /Scroll of Teleport/;
const GOBLIN_WARRIOR: Pf2eCreature = {
system: "pf2e",
id: creatureId("pathfinder-monster-core:goblin-warrior"),
name: "Goblin Warrior",
source: "pathfinder-monster-core",
sourceDisplayName: "Monster Core",
level: -1,
traits: ["small", "goblin", "humanoid"],
perception: 2,
senses: "Darkvision",
languages: "Common, Goblin",
skills: "Acrobatics +5, Athletics +2, Nature +1, Stealth +5",
abilityMods: { str: 0, dex: 3, con: 1, int: 0, wis: -1, cha: 1 },
ac: 16,
saveFort: 5,
saveRef: 7,
saveWill: 3,
hp: 6,
speed: "25 feet",
attacks: [
{
name: "Dogslicer",
activity: { number: 1, unit: "action" },
segments: [
{
type: "text",
value: "+7 (agile, backstabber, finesse), 1d6 slashing",
},
],
},
],
abilitiesMid: [
{
name: "Goblin Scuttle",
activity: { number: 1, unit: "reaction" },
segments: [{ type: "text", value: "The goblin Steps." }],
},
],
};
const NAUNET: Pf2eCreature = {
system: "pf2e",
id: creatureId("pathfinder-monster-core-2:naunet"),
name: "Naunet",
source: "pathfinder-monster-core-2",
sourceDisplayName: "Monster Core 2",
level: 7,
traits: ["large", "monitor", "protean"],
perception: 14,
senses: "Darkvision",
languages: "Chthonian, Empyrean, Protean",
skills:
"Acrobatics +14, Athletics +16, Intimidation +16, Stealth +14, Survival +12",
abilityMods: { str: 5, dex: 3, con: 5, int: 0, wis: 3, cha: 3 },
ac: 24,
saveFort: 18,
saveRef: 14,
saveWill: 12,
saveConditional: "+1 status to all saves vs. magic",
hp: 120,
resistances: "Precision 5, Protean anatomy 10",
speed: "25 feet, Fly 30 feet, Swim 25 feet (unfettered movement)",
spellcasting: [
{
name: "Divine Innate Spells",
headerText: "DC 25, attack +17",
daily: [
{
uses: 4,
each: true,
spells: [{ name: "Unfettered Movement (Constant)" }],
},
],
atWill: [{ name: "Detect Magic" }],
},
],
};
const TROLL: Pf2eCreature = {
system: "pf2e",
id: creatureId("pathfinder-monster-core:forest-troll"),
name: "Forest Troll",
source: "pathfinder-monster-core",
sourceDisplayName: "Monster Core",
level: 5,
traits: ["large", "giant", "troll"],
perception: 11,
senses: "Darkvision",
languages: "Jotun",
skills: "Athletics +12, Intimidation +12",
abilityMods: { str: 5, dex: 2, con: 6, int: -2, wis: 0, cha: -2 },
ac: 20,
saveFort: 17,
saveRef: 11,
saveWill: 7,
hp: 115,
hpDetails: "regeneration 20 (deactivated by acid or fire)",
weaknesses: "Fire 10",
speed: "30 feet",
};
function renderStatBlock(creature: Pf2eCreature) {
return render(<Pf2eStatBlock creature={creature} />);
}
describe("Pf2eStatBlock", () => {
describe("header", () => {
it("renders creature name and level", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(
screen.getByRole("heading", { name: "Goblin Warrior" }),
).toBeInTheDocument();
expect(screen.getByText("Level -1")).toBeInTheDocument();
});
it("renders traits as tags", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.getByText("Small")).toBeInTheDocument();
expect(screen.getByText("Goblin")).toBeInTheDocument();
expect(screen.getByText("Humanoid")).toBeInTheDocument();
});
it("renders source display name", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.getByText("Monster Core")).toBeInTheDocument();
});
});
describe("recall knowledge", () => {
it("renders Recall Knowledge line for a creature with a recognized type trait", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.getByText("Recall Knowledge")).toBeInTheDocument();
expect(screen.getByText(RK_DC_13_REGEX)).toBeInTheDocument();
expect(screen.getByText(RK_HUMANOID_SOCIETY_REGEX)).toBeInTheDocument();
});
it("adjusts DC for uncommon rarity", () => {
const uncommonCreature: Pf2eCreature = {
...GOBLIN_WARRIOR,
traits: ["uncommon", "small", "humanoid"],
};
renderStatBlock(uncommonCreature);
expect(screen.getByText(RK_DC_15_REGEX)).toBeInTheDocument();
});
it("adjusts DC for rare rarity", () => {
const rareCreature: Pf2eCreature = {
...GOBLIN_WARRIOR,
level: 5,
traits: ["rare", "medium", "undead"],
};
renderStatBlock(rareCreature);
expect(screen.getByText(RK_DC_25_REGEX)).toBeInTheDocument();
expect(screen.getByText(RK_UNDEAD_RELIGION_REGEX)).toBeInTheDocument();
});
it("shows multiple skills for types with dual skill mapping", () => {
const beastCreature: Pf2eCreature = {
...GOBLIN_WARRIOR,
traits: ["small", "beast"],
};
renderStatBlock(beastCreature);
expect(screen.getByText(RK_BEAST_SKILLS_REGEX)).toBeInTheDocument();
});
it("omits Recall Knowledge when no type trait is recognized", () => {
const noTypeCreature: Pf2eCreature = {
...GOBLIN_WARRIOR,
traits: ["small", "goblin"],
};
renderStatBlock(noTypeCreature);
expect(screen.queryByText("Recall Knowledge")).not.toBeInTheDocument();
});
});
describe("perception and senses", () => {
it("renders perception modifier and senses", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.getByText("Perception")).toBeInTheDocument();
expect(screen.getByText(PERCEPTION_SENSES_REGEX)).toBeInTheDocument();
});
it("renders languages", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.getByText("Languages")).toBeInTheDocument();
expect(screen.getByText("Common, Goblin")).toBeInTheDocument();
});
it("renders skills", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.getByText("Skills")).toBeInTheDocument();
expect(screen.getByText(SKILLS_REGEX)).toBeInTheDocument();
});
});
describe("ability modifiers", () => {
it("renders all six ability labels", () => {
renderStatBlock(GOBLIN_WARRIOR);
for (const label of ["Str", "Dex", "Con", "Int", "Wis", "Cha"]) {
expect(screen.getByText(label)).toBeInTheDocument();
}
});
it("renders positive and negative modifiers", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.getByText("+3")).toBeInTheDocument();
expect(screen.getByText("-1")).toBeInTheDocument();
});
});
describe("defenses", () => {
it("renders AC and saves", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.getByText("AC")).toBeInTheDocument();
expect(screen.getByText(AC_REGEX)).toBeInTheDocument();
expect(screen.getByText("Fort")).toBeInTheDocument();
expect(screen.getByText("Ref")).toBeInTheDocument();
expect(screen.getByText("Will")).toBeInTheDocument();
});
it("renders HP", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.getByText("HP")).toBeInTheDocument();
expect(screen.getByText("6")).toBeInTheDocument();
});
it("renders saveConditional inline with saves", () => {
renderStatBlock(NAUNET);
expect(screen.getByText(SAVE_CONDITIONAL_REGEX)).toBeInTheDocument();
});
it("omits saveConditional when absent", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(
screen.queryByText(SAVE_CONDITIONAL_ABSENT_REGEX),
).not.toBeInTheDocument();
});
it("renders hpDetails in parentheses after HP", () => {
renderStatBlock(TROLL);
expect(screen.getByText(HP_DETAILS_REGEX)).toBeInTheDocument();
});
it("omits hpDetails when absent", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.queryByText(REGEN_REGEX)).not.toBeInTheDocument();
});
it("renders resistances and weaknesses", () => {
renderStatBlock(NAUNET);
expect(screen.getByText("Resistances")).toBeInTheDocument();
expect(
screen.getByText("Precision 5, Protean anatomy 10"),
).toBeInTheDocument();
});
});
describe("abilities", () => {
it("renders mid (defensive) abilities", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.getByText(ABILITY_MID_NAME_REGEX)).toBeInTheDocument();
expect(screen.getByText(ABILITY_MID_DESC_REGEX)).toBeInTheDocument();
});
});
describe("speed and attacks", () => {
it("renders speed", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.getByText("Speed")).toBeInTheDocument();
expect(screen.getByText("25 feet")).toBeInTheDocument();
});
it("renders attacks", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.getByText(ATTACK_NAME_REGEX)).toBeInTheDocument();
expect(screen.getByText(ATTACK_DAMAGE_REGEX)).toBeInTheDocument();
});
});
describe("spellcasting", () => {
it("renders spellcasting entry with header", () => {
renderStatBlock(NAUNET);
expect(screen.getByText(SPELLCASTING_ENTRY_REGEX)).toBeInTheDocument();
expect(screen.getByText("DC 25, attack +17")).toBeInTheDocument();
});
it("renders ranked spells", () => {
renderStatBlock(NAUNET);
expect(screen.getByText("Rank 4:")).toBeInTheDocument();
expect(
screen.getByText("Unfettered Movement (Constant)"),
).toBeInTheDocument();
});
it("renders cantrips", () => {
renderStatBlock(NAUNET);
expect(screen.getByText("Cantrips:")).toBeInTheDocument();
expect(screen.getByText("Detect Magic")).toBeInTheDocument();
});
it("omits spellcasting when absent", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(screen.queryByText(CANTRIPS_REGEX)).not.toBeInTheDocument();
});
});
describe("equipment section", () => {
const CREATURE_WITH_EQUIPMENT: Pf2eCreature = {
...GOBLIN_WARRIOR,
id: creatureId("test:equipped"),
name: "Equipped NPC",
items: "longsword, leather armor",
equipment: [
{
name: "Giant Wasp Venom",
level: 7,
category: "poison",
traits: ["consumable", "poison"],
description: "A deadly poison extracted from giant wasps.",
},
{
name: "Scroll of Teleport",
level: 11,
category: "scroll",
traits: ["consumable", "magical", "scroll"],
description: "A scroll containing Teleport.",
spellName: "Teleport",
spellRank: 6,
},
{
name: "Plain Talisman",
level: 1,
traits: ["magical"],
},
],
};
it("renders Equipment section with item names", () => {
renderStatBlock(CREATURE_WITH_EQUIPMENT);
expect(
screen.getByRole("heading", { name: "Equipment" }),
).toBeInTheDocument();
expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument();
});
it("renders scroll name as-is from Foundry data", () => {
renderStatBlock(CREATURE_WITH_EQUIPMENT);
expect(screen.getByText(SCROLL_NAME_REGEX)).toBeInTheDocument();
});
it("does not render Equipment section when creature has no equipment", () => {
renderStatBlock(GOBLIN_WARRIOR);
expect(
screen.queryByRole("heading", { name: "Equipment" }),
).not.toBeInTheDocument();
});
it("renders equipment items with descriptions as clickable buttons", () => {
renderStatBlock(CREATURE_WITH_EQUIPMENT);
expect(
screen.getByRole("button", { name: "Giant Wasp Venom" }),
).toBeInTheDocument();
});
it("renders equipment items without descriptions as plain text", () => {
renderStatBlock(CREATURE_WITH_EQUIPMENT);
expect(
screen.queryByRole("button", { name: "Plain Talisman" }),
).not.toBeInTheDocument();
expect(screen.getByText("Plain Talisman")).toBeInTheDocument();
});
it("renders Items line with mundane item names", () => {
renderStatBlock(CREATURE_WITH_EQUIPMENT);
expect(screen.getByText("Items")).toBeInTheDocument();
expect(screen.getByText("longsword, leather armor")).toBeInTheDocument();
});
});
describe("clickable spells", () => {
const SPELLCASTER: Pf2eCreature = {
...NAUNET,
id: creatureId("test:spellcaster"),
name: "Spellcaster",
spellcasting: [
{
name: "Divine Innate Spells",
headerText: "DC 30, attack +20",
atWill: [{ name: "Detect Magic", rank: 1 }],
daily: [
{
uses: 4,
each: true,
spells: [
{
name: "Heal",
description: "You channel positive energy to heal.",
rank: 4,
usesPerDay: 3,
},
{ name: "Restoration", rank: 4 },
],
},
],
},
],
};
beforeEach(() => {
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn().mockImplementation(() => ({
matches: true,
media: "(min-width: 1024px)",
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
it("renders a spell with a description as a clickable button", () => {
renderStatBlock(SPELLCASTER);
expect(screen.getByRole("button", { name: "Heal" })).toBeInTheDocument();
});
it("renders a spell without description as plain text (not a button)", () => {
renderStatBlock(SPELLCASTER);
expect(
screen.queryByRole("button", { name: "Restoration" }),
).not.toBeInTheDocument();
expect(screen.getByText("Restoration")).toBeInTheDocument();
});
it("renders usesPerDay as plain text alongside the spell button", () => {
renderStatBlock(SPELLCASTER);
expect(screen.getByText(USES_PER_DAY_REGEX)).toBeInTheDocument();
});
it("opens the spell popover when a spell button is clicked", async () => {
const user = userEvent.setup();
renderStatBlock(SPELLCASTER);
await user.click(screen.getByRole("button", { name: "Heal" }));
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
});
it("closes the popover when Escape is pressed", async () => {
const user = userEvent.setup();
renderStatBlock(SPELLCASTER);
await user.click(screen.getByRole("button", { name: "Heal" }));
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(
screen.queryByText(HEAL_DESCRIPTION_REGEX),
).not.toBeInTheDocument();
});
});
});