// @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(); } 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(); }); }); });