13 new test files for untested components (color-palette, player-management, stat-block, settings-modal, export/import dialogs, bulk-import-prompt, source-fetch-prompt, player-character-section) and hooks (use-long-press, use-swipe-to-dismiss, use-bulk-import, use-initiative-rolls). Expand combatant-row tests with inline editing, HP popover, and condition picker. Component coverage: 59% → 80% lines, 55% → 71% branches Hook coverage: 72% → 83% lines, 55% → 66% branches Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
274 lines
8.3 KiB
TypeScript
274 lines
8.3 KiB
TypeScript
// @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(<StatBlock creature={creature} />);
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|