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>
498 lines
15 KiB
TypeScript
498 lines
15 KiB
TypeScript
// @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();
|
||
});
|
||
});
|
||
});
|