// @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import type { Creature } from "@initiative/domain"; import { creatureId } from "@initiative/domain"; import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { StatBlock } from "../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", text: "Disengage or Hide as bonus." }], actions: [{ name: "Scimitar", text: "Melee: +4 to hit, 5 slashing." }], bonusActions: [{ name: "Nimble", text: "Disengage or Hide." }], reactions: [{ name: "Redirect", text: "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", text: "Wisdom (Perception) check." }, { name: "Tail Attack", text: "Tail attack." }, ], }, spellcasting: [ { name: "Innate Spellcasting", headerText: "The dragon's spellcasting ability is Charisma.", atWill: ["detect magic", "suggestion"], daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }], restLong: [{ uses: 1, each: false, spells: ["wish"] }], }, ], }; function renderStatBlock(creature: Creature) { return render(); } 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("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(); }); }); });