8 Commits
0.7.3 ... 0.7.4

Author SHA1 Message Date
Lukas
ef0b755eec Add coverage thresholds for all tested directories, exclude dist from coverage
All checks were successful
CI / check (push) Successful in 1m4s
CI / build-image (push) Successful in 27s
Adds threshold entries for application, hooks, components, and components/ui
directories. Ratchets existing thresholds to match actual coverage. Excludes
**/dist/** from coverage to remove build output noise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:19:56 +01:00
Lukas
4be816d10f Add test coverage for 5 components: HpAdjustPopover, ConditionPicker, CombatantRow, ActionBar, SourceManager
Adds aria-label attributes to HP placeholder and source delete buttons
for both accessibility and testability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:53:01 +01:00
Lukas
e531d82d1b Add test coverage for 3 hooks: useEncounter, usePlayerCharacters, useSidePanelState
29 tests covering state transitions, persistence sync, domain error
propagation, bestiary/PC add flows, and panel state machine logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:38:51 +01:00
Lukas
5a262c66cd Add test coverage for all 17 application layer use cases
Tests verify the get→call→save wiring and error propagation for each
use case. The 15 formulaic use cases share a test file; rollInitiative
and rollAllInitiative have dedicated suites covering their multi-step
logic (creature lookup, modifier calculation, iteration, early return).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:31:35 +01:00
Lukas
32b69f8df1 Use Readonly props and optional chaining/nullish coalescing
Mark component props as Readonly<> across 15 component files and
simplify edit-player-character field access with optional chaining
and nullish coalescing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:13:39 +01:00
Lukas
8efba288f7 Use String.raw and RegExp.exec, add prefer-regexp-exec oxlint rule
Replace escaped backslash in template literal with String.raw in
auto-number.ts. Use RegExp#exec() instead of String#match() in
bestiary-adapter.ts. Enable typescript/prefer-regexp-exec in oxlint
for automated enforcement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:55:54 +01:00
Lukas
c94c30e459 Add oxlint for type-aware linting that Biome cannot cover
Install oxlint with tsgolint for TypeScript type information. Enable
rules for unnecessary type assertions, deprecated API usage, preferring
replaceAll over replace with global regex, and String.raw for escaped
backslashes. Fix all violations: remove redundant as-casts, replace
deprecated FormEvent with SubmitEvent, convert replace(/g) to
replaceAll, and use String.raw in escapeRegExp. Add oxlint to the
pnpm check gate alongside Biome.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:41:30 +01:00
Lukas
36768d3aa1 Upgrade Biome to 2.4.7 and enable 54 additional lint rules
Add rules covering bug prevention (noLeakedRender, noFloatingPromises,
noImportCycles, noReactForwardRef), security (noScriptUrl, noAlert),
performance (noAwaitInLoops, useTopLevelRegex), and code style
(noNestedTernary, useGlobalThis, useNullishCoalescing, useSortedClasses,
plus ~40 more). Fix all violations: extract top-level regex constants,
guard React && renders with boolean coercion, refactor nested ternaries,
replace window with globalThis, sort Tailwind classes, and introduce
expectDomainError test helper to eliminate conditional expects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:25:09 +01:00
73 changed files with 2680 additions and 531 deletions

27
.oxlintrc.json Normal file
View File

@@ -0,0 +1,27 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-type-annotations/refs/heads/main/packages/oxlint/configuration_file_schema.json",
"plugins": ["typescript", "unicorn", "jest"],
"categories": {},
"rules": {
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-deprecated": "warn",
"typescript/prefer-regexp-exec": "error",
"unicorn/prefer-string-replace-all": "error",
"unicorn/prefer-string-raw": "error",
"jest/expect-expect": [
"error",
{
"assertFunctionNames": ["expect", "expectDomainError"]
}
]
},
"ignorePatterns": [
"dist",
"coverage",
".claude",
".specify",
"specs",
".pnpm-store",
"scripts"
]
}

View File

@@ -5,7 +5,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands
```bash
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + typecheck + test/coverage + jscpd)
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd)
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
pnpm knip # Unused code detection (Knip)
pnpm test # Run all tests (Vitest)
pnpm test:watch # Tests in watch mode
@@ -58,12 +59,13 @@ docs/agents/ RPI skill artifacts (research reports, plans)
- React 19, Vite 6, Tailwind CSS v4
- Lucide React (icons)
- `idb` (IndexedDB wrapper for bestiary cache)
- Biome 2.0 (formatting + linting), Knip (unused code), jscpd (copy-paste detection)
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection)
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
## Conventions
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
- **oxlint** for type-aware linting that Biome can't do (unnecessary type assertions, deprecated APIs, `replaceAll` preference, `String.raw`). Configured in `.oxlintrc.json`.
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
- **Domain events** are plain data objects with a `type` discriminant — no classes.

View File

@@ -23,6 +23,7 @@
"@tailwindcss/vite": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",

View File

@@ -55,11 +55,10 @@ function useActionBarAnimation(combatantCount: number) {
const empty = combatantCount === 0;
const risingClass = rising ? " animate-rise-to-center" : "";
const settlingClass = settling ? " animate-settle-to-bottom" : "";
const topBarClass = settling
? " animate-slide-down-in"
: topBarExiting
const exitingClass = topBarExiting
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
: "";
const topBarClass = settling ? " animate-slide-down-in" : exitingClass;
const showTopBar = !empty || topBarExiting;
return {
@@ -158,8 +157,8 @@ export function App() {
(result: SearchResult) => {
const slug = result.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
sidePanel.showCreature(cId);
},
@@ -194,7 +193,7 @@ export function App() {
block: "nearest",
behavior: "smooth",
});
}, [encounter.activeIndex]);
}, []);
// Auto-show stat block for the active combatant when turn changes,
// but only when the viewport is wide enough to show it alongside the tracker.
@@ -203,10 +202,10 @@ export function App() {
useEffect(() => {
if (prevActiveIndexRef.current === encounter.activeIndex) return;
prevActiveIndexRef.current = encounter.activeIndex;
if (!window.matchMedia("(min-width: 1024px)").matches) return;
if (!globalThis.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return;
sidePanel.showCreature(active.creatureId as CreatureId);
sidePanel.showCreature(active.creatureId);
}, [
encounter.activeIndex,
encounter.combatants,
@@ -216,8 +215,8 @@ export function App() {
return (
<div className="flex h-screen flex-col">
<div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
{actionBarAnim.showTopBar && (
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
{!!actionBarAnim.showTopBar && (
<div
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
@@ -233,7 +232,7 @@ export function App() {
{isEmpty ? (
/* Empty state — ActionBar centered */
<div className="flex flex-1 items-center justify-center min-h-0 pb-[15%] pt-8">
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
<div
className={`w-full${actionBarAnim.risingClass}`}
onAnimationEnd={actionBarAnim.onRiseEnd}
@@ -263,7 +262,7 @@ export function App() {
) : (
<>
{/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex flex-col px-2 py-2">
{encounter.combatants.map((c, i) => (
<CombatantRow
@@ -322,7 +321,7 @@ export function App() {
</div>
{/* Pinned Stat Block Panel (left) */}
{sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
<StatBlockPanel
creatureId={sidePanel.pinnedCreatureId}
creature={pinnedCreature}

View File

@@ -200,6 +200,7 @@ describe("ConfirmButton", () => {
const parentHandler = vi.fn();
render(
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: test wrapper
<div onKeyDown={parentHandler}>
<ConfirmButton
icon={<XIcon />}

View File

@@ -6,6 +6,9 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StatBlockPanel } from "../components/stat-block-panel";
const CLOSE_REGEX = /close/i;
const COLLAPSE_REGEX = /collapse/i;
const CREATURE_ID = "srd:goblin" as CreatureId;
const CREATURE: Creature = {
id: CREATURE_ID,
@@ -26,7 +29,7 @@ const CREATURE: Creature = {
};
function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches,
@@ -92,7 +95,7 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
screen.getByRole("button", { name: "Collapse stat block panel" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /close/i }),
screen.queryByRole("button", { name: CLOSE_REGEX }),
).not.toBeInTheDocument();
});
@@ -247,7 +250,7 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
it("pinned panel has no collapse button", () => {
renderPanel({ panelRole: "pinned", side: "left" });
expect(
screen.queryByRole("button", { name: /collapse/i }),
screen.queryByRole("button", { name: COLLAPSE_REGEX }),
).not.toBeInTheDocument();
});

View File

@@ -9,6 +9,8 @@ import type {
import { creatureId, proficiencyBonus } from "@initiative/domain";
import { stripTags } from "./strip-tags.js";
const LEADING_DIGITS_REGEX = /^(\d+)/;
// --- Raw 5etools types (minimal, for parsing) ---
interface RawMonster {
@@ -168,7 +170,7 @@ function extractAc(ac: RawMonster["ac"]): {
}
if ("special" in first) {
// Variable AC (e.g. spell summons) — parse leading number if possible
const match = first.special.match(/^(\d+)/);
const match = LEADING_DIGITS_REGEX.exec(first.special);
return {
value: match ? Number(match[1]) : 0,
source: first.special,
@@ -371,8 +373,8 @@ function extractCr(cr: string | { cr: string } | undefined): string {
function makeCreatureId(source: string, name: string): CreatureId {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
return creatureId(`${source.toLowerCase()}:${slug}`);
}

View File

@@ -25,55 +25,58 @@ export function stripTags(text: string): string {
let result = text;
// {@h} → "Hit: "
result = result.replace(/\{@h\}/g, "Hit: ");
result = result.replaceAll("{@h}", "Hit: ");
// {@hom} → "Hit or Miss: "
result = result.replace(/\{@hom\}/g, "Hit or Miss: ");
result = result.replaceAll("{@hom}", "Hit or Miss: ");
// {@actTrigger} → "Trigger:"
result = result.replace(/\{@actTrigger\}/g, "Trigger:");
result = result.replaceAll("{@actTrigger}", "Trigger:");
// {@actResponse} → "Response:"
result = result.replace(/\{@actResponse\}/g, "Response:");
result = result.replaceAll("{@actResponse}", "Response:");
// {@actSaveSuccess} → "Success:"
result = result.replace(/\{@actSaveSuccess\}/g, "Success:");
result = result.replaceAll("{@actSaveSuccess}", "Success:");
// {@actSaveSuccessOrFail} → handled below as parameterized
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
result = result.replace(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
result = result.replace(/\{@recharge\}/g, "(Recharge 6)");
result = result.replaceAll(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
result = result.replaceAll("{@recharge}", "(Recharge 6)");
// {@dc N} → "DC N"
result = result.replace(/\{@dc\s+(\d+)\}/g, "DC $1");
result = result.replaceAll(/\{@dc\s+(\d+)\}/g, "DC $1");
// {@hit N} → "+N"
result = result.replace(/\{@hit\s+(\d+)\}/g, "+$1");
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
// {@atkr type} → mapped attack roll text
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
return ATKR_MAP[type.trim()] ?? `Attack Roll:`;
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
});
// {@actSave ability} → "Ability saving throw"
result = result.replace(/\{@actSave\s+([^}]+)\}/g, (_, ability: string) => {
result = result.replaceAll(
/\{@actSave\s+([^}]+)\}/g,
(_, ability: string) => {
const name = ABILITY_MAP[ability.trim().toLowerCase()];
return name ? `${name} saving throw` : `${ability} saving throw`;
});
},
);
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
result = result.replace(
result = result.replaceAll(
/\{@actSaveFail\s+(\d+)\}/g,
"Failure by $1 or More:",
);
result = result.replace(/\{@actSaveFail\}/g, "Failure:");
result = result.replaceAll("{@actSaveFail}", "Failure:");
// {@actSaveSuccessOrFail} → keep as-is label
result = result.replace(/\{@actSaveSuccessOrFail\}/g, "Success or Failure:");
result = result.replaceAll("{@actSaveSuccessOrFail}", "Success or Failure:");
// {@actSaveFailBy N} → "Failure by N or More:"
result = result.replace(
result = result.replaceAll(
/\{@actSaveFailBy\s+(\d+)\}/g,
"Failure by $1 or More:",
);
@@ -81,7 +84,7 @@ export function stripTags(text: string): string {
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
// Covers: spell, condition, damage, dice, variantrule, action, skill,
// creature, hazard, status, plus any unknown tags
result = result.replace(
result = result.replaceAll(
/\{@(\w+)\s+([^}]+)\}/g,
(_, tag: string, content: string) => {
// For tags with Display|Source format, extract first segment

View File

@@ -0,0 +1,88 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ActionBar } from "../action-bar";
afterEach(cleanup);
const defaultProps = {
onAddCombatant: vi.fn(),
onAddFromBestiary: vi.fn(),
bestiarySearch: () => [],
bestiaryLoaded: false,
};
function renderBar(overrides: Partial<Parameters<typeof ActionBar>[0]> = {}) {
const props = { ...defaultProps, ...overrides };
return render(<ActionBar {...props} />);
}
describe("ActionBar", () => {
it("renders input with placeholder '+ Add combatants'", () => {
renderBar();
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
});
it("submitting with a name calls onAddCombatant", async () => {
const user = userEvent.setup();
const onAddCombatant = vi.fn();
renderBar({ onAddCombatant });
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "Goblin");
// The Add button appears when name >= 2 chars and no suggestions
const addButton = screen.getByRole("button", { name: "Add" });
await user.click(addButton);
expect(onAddCombatant).toHaveBeenCalledWith("Goblin", undefined);
});
it("submitting with empty name does nothing", async () => {
const user = userEvent.setup();
const onAddCombatant = vi.fn();
renderBar({ onAddCombatant });
// Submit the form directly (Enter on empty input)
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "{Enter}");
expect(onAddCombatant).not.toHaveBeenCalled();
});
it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => {
const user = userEvent.setup();
renderBar();
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "Go");
expect(screen.getByPlaceholderText("Init")).toBeInTheDocument();
expect(screen.getByPlaceholderText("AC")).toBeInTheDocument();
expect(screen.getByPlaceholderText("MaxHP")).toBeInTheDocument();
});
it("shows Add button when name >= 2 chars and no suggestions", async () => {
const user = userEvent.setup();
renderBar();
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "Go");
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
});
it("shows roll all initiative button when showRollAllInitiative is true", () => {
const onRollAllInitiative = vi.fn();
renderBar({ showRollAllInitiative: true, onRollAllInitiative });
expect(
screen.getByRole("button", { name: "Roll all initiative" }),
).toBeInTheDocument();
});
it("roll all initiative button is disabled when rollAllInitiativeDisabled is true", () => {
const onRollAllInitiative = vi.fn();
renderBar({
showRollAllInitiative: true,
onRollAllInitiative,
rollAllInitiativeDisabled: true,
});
expect(
screen.getByRole("button", { name: "Roll all initiative" }),
).toBeDisabled();
});
});

View File

@@ -0,0 +1,164 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { combatantId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CombatantRow } from "../combatant-row";
import { PLAYER_COLOR_HEX } from "../player-icon-map";
afterEach(cleanup);
const defaultProps = {
onRename: vi.fn(),
onSetInitiative: vi.fn(),
onRemove: vi.fn(),
onSetHp: vi.fn(),
onAdjustHp: vi.fn(),
onSetAc: vi.fn(),
onToggleCondition: vi.fn(),
onToggleConcentration: vi.fn(),
};
function renderRow(
overrides: Partial<{
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
isActive: boolean;
onRollInitiative: (id: ReturnType<typeof combatantId>) => void;
onRemove: (id: ReturnType<typeof combatantId>) => void;
onShowStatBlock: () => void;
}> = {},
) {
const combatant = overrides.combatant ?? {
id: combatantId("1"),
name: "Goblin",
initiative: 15,
maxHp: 10,
currentHp: 10,
ac: 13,
};
const props = {
...defaultProps,
combatant,
isActive: overrides.isActive ?? false,
onRollInitiative: overrides.onRollInitiative,
onShowStatBlock: overrides.onShowStatBlock,
onRemove: overrides.onRemove ?? defaultProps.onRemove,
};
return render(<CombatantRow {...props} />);
}
describe("CombatantRow", () => {
it("renders combatant name", () => {
renderRow();
expect(screen.getByText("Goblin")).toBeInTheDocument();
});
it("renders initiative value", () => {
renderRow();
expect(screen.getByText("15")).toBeInTheDocument();
});
it("renders current HP", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 10,
currentHp: 7,
},
});
expect(screen.getByText("7")).toBeInTheDocument();
});
it("active combatant gets active border styling", () => {
const { container } = renderRow({ isActive: true });
const row = container.firstElementChild;
expect(row?.className).toContain("border-l-accent");
});
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 10,
currentHp: 0,
},
});
// The name area should have opacity-50
const nameEl = screen.getByText("Goblin");
const nameContainer = nameEl.closest(".opacity-50");
expect(nameContainer).not.toBeNull();
});
it("shows '--' for current HP when no maxHp is set", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
},
});
expect(screen.getByLabelText("No HP set")).toBeInTheDocument();
});
it("shows concentration icon when isConcentrating is true", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
isConcentrating: true,
},
});
const concButton = screen.getByRole("button", {
name: "Toggle concentration",
});
expect(concButton.className).toContain("text-purple-400");
});
it("shows player character icon and color when set", () => {
const { container } = renderRow({
combatant: {
id: combatantId("1"),
name: "Aragorn",
color: "red",
icon: "sword",
},
});
// The icon should be rendered with the player color
const svgIcon = container.querySelector("svg[style]");
expect(svgIcon).not.toBeNull();
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
});
it("remove button calls onRemove after confirmation", async () => {
const user = userEvent.setup();
const onRemove = vi.fn();
renderRow({ onRemove });
const removeBtn = screen.getByRole("button", {
name: "Remove combatant",
});
// First click enters confirm state
await user.click(removeBtn);
// Second click confirms
const confirmBtn = screen.getByRole("button", {
name: "Confirm remove combatant",
});
await user.click(confirmBtn);
expect(onRemove).toHaveBeenCalledWith(combatantId("1"));
});
it("shows d20 roll button when initiative is undefined and onRollInitiative is provided", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
},
onRollInitiative: vi.fn(),
});
expect(
screen.getByRole("button", { name: "Roll initiative" }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConditionPicker } from "../condition-picker";
afterEach(cleanup);
function renderPicker(
overrides: Partial<{
activeConditions: readonly ConditionId[];
onToggle: (conditionId: ConditionId) => void;
onClose: () => void;
}> = {},
) {
const onToggle = overrides.onToggle ?? vi.fn();
const onClose = overrides.onClose ?? vi.fn();
const result = render(
<ConditionPicker
activeConditions={overrides.activeConditions ?? []}
onToggle={onToggle}
onClose={onClose}
/>,
);
return { ...result, onToggle, onClose };
}
describe("ConditionPicker", () => {
it("renders all condition definitions from domain", () => {
renderPicker();
for (const def of CONDITION_DEFINITIONS) {
expect(screen.getByText(def.label)).toBeInTheDocument();
}
});
it("active conditions are visually distinguished", () => {
renderPicker({ activeConditions: ["blinded"] });
const blindedButton = screen.getByText("Blinded").closest("button");
expect(blindedButton?.className).toContain("bg-card/50");
});
it("clicking a condition calls onToggle with that condition's ID", async () => {
const user = userEvent.setup();
const { onToggle } = renderPicker();
await user.click(screen.getByText("Poisoned"));
expect(onToggle).toHaveBeenCalledWith("poisoned");
});
it("non-active conditions render with muted styling", () => {
renderPicker({ activeConditions: [] });
const label = screen.getByText("Charmed");
expect(label.className).toContain("text-muted-foreground");
});
it("active condition labels use foreground color", () => {
renderPicker({ activeConditions: ["charmed"] });
const label = screen.getByText("Charmed");
expect(label.className).toContain("text-foreground");
});
});

View File

@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { HpAdjustPopover } from "../hp-adjust-popover";
afterEach(cleanup);
function renderPopover(
overrides: Partial<{
onAdjust: (delta: number) => void;
onClose: () => void;
}> = {},
) {
const onAdjust = overrides.onAdjust ?? vi.fn();
const onClose = overrides.onClose ?? vi.fn();
const result = render(
<HpAdjustPopover onAdjust={onAdjust} onClose={onClose} />,
);
return { ...result, onAdjust, onClose };
}
describe("HpAdjustPopover", () => {
it("renders input with placeholder 'HP'", () => {
renderPopover();
expect(screen.getByPlaceholderText("HP")).toBeInTheDocument();
});
it("damage and heal buttons are disabled when input is empty", () => {
renderPopover();
expect(screen.getByRole("button", { name: "Apply damage" })).toBeDisabled();
expect(
screen.getByRole("button", { name: "Apply healing" }),
).toBeDisabled();
});
it("damage and heal buttons are disabled when input is '0'", async () => {
const user = userEvent.setup();
renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "0");
expect(screen.getByRole("button", { name: "Apply damage" })).toBeDisabled();
expect(
screen.getByRole("button", { name: "Apply healing" }),
).toBeDisabled();
});
it("typing a valid number enables both buttons", async () => {
const user = userEvent.setup();
renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "5");
expect(
screen.getByRole("button", { name: "Apply damage" }),
).not.toBeDisabled();
expect(
screen.getByRole("button", { name: "Apply healing" }),
).not.toBeDisabled();
});
it("clicking damage button calls onAdjust with negative value and onClose", async () => {
const user = userEvent.setup();
const { onAdjust, onClose } = renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "7");
await user.click(screen.getByRole("button", { name: "Apply damage" }));
expect(onAdjust).toHaveBeenCalledWith(-7);
expect(onClose).toHaveBeenCalled();
});
it("clicking heal button calls onAdjust with positive value and onClose", async () => {
const user = userEvent.setup();
const { onAdjust, onClose } = renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "3");
await user.click(screen.getByRole("button", { name: "Apply healing" }));
expect(onAdjust).toHaveBeenCalledWith(3);
expect(onClose).toHaveBeenCalled();
});
it("Enter key applies damage (negative)", async () => {
const user = userEvent.setup();
const { onAdjust, onClose } = renderPopover();
const input = screen.getByPlaceholderText("HP");
await user.type(input, "4");
await user.keyboard("{Enter}");
expect(onAdjust).toHaveBeenCalledWith(-4);
expect(onClose).toHaveBeenCalled();
});
it("Shift+Enter applies healing (positive)", async () => {
const user = userEvent.setup();
const { onAdjust, onClose } = renderPopover();
const input = screen.getByPlaceholderText("HP");
await user.type(input, "6");
await user.keyboard("{Shift>}{Enter}{/Shift}");
expect(onAdjust).toHaveBeenCalledWith(6);
expect(onClose).toHaveBeenCalled();
});
it("Escape key calls onClose", async () => {
const user = userEvent.setup();
const { onClose } = renderPopover();
const input = screen.getByPlaceholderText("HP");
await user.type(input, "2");
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
});
it("only accepts digit characters in input", async () => {
const user = userEvent.setup();
renderPopover();
const input = screen.getByPlaceholderText("HP");
await user.type(input, "12abc34");
expect(input).toHaveValue("1234");
});
});

View File

@@ -0,0 +1,127 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../../adapters/bestiary-cache.js", () => ({
getCachedSources: vi.fn(),
clearSource: vi.fn(),
clearAll: vi.fn(),
}));
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
import { SourceManager } from "../source-manager";
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("SourceManager", () => {
it("shows 'No cached sources' empty state when no sources", async () => {
mockGetCachedSources.mockResolvedValue([]);
render(<SourceManager onCacheCleared={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("No cached sources")).toBeInTheDocument();
});
});
it("lists cached sources with display name and creature count", async () => {
mockGetCachedSources.mockResolvedValue([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
{
sourceCode: "vgm",
displayName: "Volo's Guide",
creatureCount: 100,
cachedAt: Date.now(),
},
]);
render(<SourceManager onCacheCleared={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
expect(screen.getByText("300 creatures")).toBeInTheDocument();
expect(screen.getByText("Volo's Guide")).toBeInTheDocument();
expect(screen.getByText("100 creatures")).toBeInTheDocument();
});
it("Clear All button calls cache clear and onCacheCleared", async () => {
const user = userEvent.setup();
const onCacheCleared = vi.fn();
mockGetCachedSources
.mockResolvedValueOnce([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
])
.mockResolvedValue([]);
mockClearAll.mockResolvedValue(undefined);
render(<SourceManager onCacheCleared={onCacheCleared} />);
await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Clear All" }));
await waitFor(() => {
expect(mockClearAll).toHaveBeenCalled();
});
expect(onCacheCleared).toHaveBeenCalled();
});
it("individual source delete button calls clear for that source", async () => {
const user = userEvent.setup();
const onCacheCleared = vi.fn();
mockGetCachedSources
.mockResolvedValueOnce([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
{
sourceCode: "vgm",
displayName: "Volo's Guide",
creatureCount: 100,
cachedAt: Date.now(),
},
])
.mockResolvedValue([
{
sourceCode: "vgm",
displayName: "Volo's Guide",
creatureCount: 100,
cachedAt: Date.now(),
},
]);
mockClearSource.mockResolvedValue(undefined);
render(<SourceManager onCacheCleared={onCacheCleared} />);
await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
await user.click(
screen.getByRole("button", { name: "Remove Monster Manual" }),
);
await waitFor(() => {
expect(mockClearSource).toHaveBeenCalledWith("mm");
});
expect(onCacheCleared).toHaveBeenCalled();
});
});

View File

@@ -12,7 +12,7 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
type="button"
onClick={onClick}
className={cn(
"relative inline-flex items-center justify-center text-sm tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral",
"relative inline-flex items-center justify-center text-muted-foreground text-sm tabular-nums transition-colors hover:text-hover-neutral",
className,
)}
style={{ width: 28, height: 32 }}
@@ -29,8 +29,8 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
>
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
</svg>
<span className="relative text-xs font-medium leading-none">
{value !== undefined ? value : "\u2014"}
<span className="relative font-medium text-xs leading-none">
{value == null ? "\u2014" : String(value)}
</span>
</button>
);

View File

@@ -1,4 +1,4 @@
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
import type { PlayerCharacter } from "@initiative/domain";
import {
Check,
Eye,
@@ -9,12 +9,7 @@ import {
Plus,
Users,
} from "lucide-react";
import {
type FormEvent,
type RefObject,
useDeferredValue,
useState,
} from "react";
import React, { type RefObject, useDeferredValue, useState } from "react";
import type { SearchResult } from "../hooks/use-bestiary.js";
import { cn } from "../lib/utils.js";
import { D20Icon } from "./d20-icon.js";
@@ -67,7 +62,7 @@ function AddModeSuggestions({
onConfirmQueued,
onAddFromPlayerCharacter,
onClear,
}: {
}: Readonly<{
nameInput: string;
suggestions: SearchResult[];
pcMatches: PlayerCharacter[];
@@ -80,51 +75,49 @@ function AddModeSuggestions({
onSetQueued: (q: QueuedCreature | null) => void;
onConfirmQueued: () => void;
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
}) {
}>) {
return (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<button
type="button"
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
onMouseDown={(e) => e.preventDefault()}
onClick={onDismiss}
>
<Plus className="h-3.5 w-3.5" />
<span className="flex-1">Add "{nameInput}" as custom</span>
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground">
<kbd className="rounded border border-border px-1.5 py-0.5 text-muted-foreground text-xs">
Esc
</kbd>
</button>
<div className="max-h-48 overflow-y-auto py-1">
{pcMatches.length > 0 && (
<>
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
<div className="px-3 py-1 font-medium text-muted-foreground text-xs">
Players
</div>
<ul>
{pcMatches.map((pc) => {
const PcIcon = pc.icon
? PLAYER_ICON_MAP[pc.icon as PlayerIcon]
: undefined;
const PcIcon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
const pcColor = pc.color
? PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX]
? PLAYER_COLOR_HEX[pc.color]
: undefined;
return (
<li key={pc.id}>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onAddFromPlayerCharacter?.(pc);
onClear();
}}
>
{PcIcon && (
{!!PcIcon && (
<PcIcon size={14} style={{ color: pcColor }} />
)}
<span className="flex-1 truncate">{pc.name}</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
Player
</span>
</button>
@@ -144,19 +137,18 @@ function AddModeSuggestions({
<li key={key}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
isQueued
? "bg-accent/30 text-foreground"
: i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${(() => {
if (isQueued) return "bg-accent/30 text-foreground";
if (i === suggestionIndex)
return "bg-accent/20 text-foreground";
return "text-foreground hover:bg-hover-neutral-bg";
})()}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onClickSuggestion(result)}
onMouseEnter={() => onSetSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1 text-muted-foreground text-xs">
{isQueued ? (
<>
<button
@@ -271,7 +263,7 @@ export function ActionBar({
rollAllInitiativeDisabled,
onOpenSourceManager,
autoFocus,
}: ActionBarProps) {
}: Readonly<ActionBarProps>) {
const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
@@ -319,7 +311,7 @@ export function ActionBar({
return Number.isNaN(n) ? undefined : n;
};
const handleAdd = (e: FormEvent) => {
const handleAdd = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (browseMode) return;
if (queued) {
@@ -482,12 +474,12 @@ export function ActionBar({
className="pr-8"
autoFocus={autoFocus}
/>
{bestiaryLoaded && onViewStatBlock && (
{bestiaryLoaded && !!onViewStatBlock && (
<button
type="button"
tabIndex={-1}
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
browseMode && "text-accent",
)}
onClick={toggleBrowseMode}
@@ -520,7 +512,7 @@ export function ActionBar({
onMouseEnter={() => setSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{result.sourceDisplayName}
</span>
</button>
@@ -578,7 +570,7 @@ export function ActionBar({
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<Button type="submit">Add</Button>
)}
{showRollAllInitiative && onRollAllInitiative && (
{showRollAllInitiative && !!onRollAllInitiative && (
<Button
type="button"
size="icon"

View File

@@ -1,5 +1,5 @@
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useId, useState } from "react";
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { Button } from "./ui/button.js";
@@ -18,14 +18,15 @@ export function BulkImportPrompt({
importState,
onStartImport,
onDone,
}: BulkImportPromptProps) {
}: Readonly<BulkImportPromptProps>) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const baseUrlId = useId();
const totalSources = getAllSourceCodes().length;
if (importState.status === "complete") {
return (
<div className="flex flex-col gap-4">
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400">
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
All sources loaded
</div>
<Button onClick={onDone}>Done</Button>
@@ -54,7 +55,7 @@ export function BulkImportPrompt({
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading sources... {processed}/{importState.total}
</div>
@@ -74,23 +75,20 @@ export function BulkImportPrompt({
return (
<div className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-foreground">
<h3 className="font-semibold text-foreground text-sm">
Import All Sources
</h3>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-muted-foreground text-xs">
Load stat block data for all {totalSources} sources at once.
</p>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="bulk-base-url"
className="text-xs text-muted-foreground"
>
<label htmlFor={baseUrlId} className="text-muted-foreground text-xs">
Base URL
</label>
<Input
id="bulk-base-url"
id={baseUrlId}
type="url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}

View File

@@ -11,7 +11,7 @@ export function BulkImportToasts({
state,
visible,
onReset,
}: BulkImportToastsProps) {
}: Readonly<BulkImportToastsProps>) {
if (!visible) return null;
if (state.status === "loading") {

View File

@@ -9,7 +9,7 @@ interface ColorPaletteProps {
const COLORS = [...VALID_PLAYER_COLORS] as string[];
export function ColorPalette({ value, onChange }: ColorPaletteProps) {
export function ColorPalette({ value, onChange }: Readonly<ColorPaletteProps>) {
return (
<div className="flex flex-wrap gap-2">
{COLORS.map((color) => (
@@ -20,7 +20,7 @@ export function ColorPalette({ value, onChange }: ColorPaletteProps) {
className={cn(
"h-8 w-8 rounded-full transition-all",
value === color
? "ring-2 ring-foreground ring-offset-2 ring-offset-background scale-110"
? "scale-110 ring-2 ring-foreground ring-offset-2 ring-offset-background"
: "hover:scale-110",
)}
style={{

View File

@@ -50,13 +50,13 @@ function EditableName({
onRename,
onShowStatBlock,
color,
}: {
}: Readonly<{
name: string;
combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void;
color?: string;
}) {
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement>(null);
@@ -136,7 +136,6 @@ function EditableName({
}
return (
<>
<button
type="button"
onClick={handleClick}
@@ -144,22 +143,21 @@ function EditableName({
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
onTouchMove={cancelLongPress}
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
style={color ? { color } : undefined}
>
{name}
</button>
</>
);
}
function MaxHpDisplay({
maxHp,
onCommit,
}: {
}: Readonly<{
maxHp: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
@@ -205,7 +203,7 @@ function MaxHpDisplay({
<button
type="button"
onClick={startEditing}
className="inline-block h-7 min-w-[3ch] text-center text-sm leading-7 tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral"
className="inline-block h-7 min-w-[3ch] text-center text-muted-foreground text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral"
>
{maxHp ?? "Max"}
</button>
@@ -217,12 +215,12 @@ function ClickableHp({
maxHp,
onAdjust,
dimmed,
}: {
}: Readonly<{
currentHp: number | undefined;
maxHp: number | undefined;
onAdjust: (delta: number) => void;
dimmed?: boolean;
}) {
}>) {
const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp);
@@ -230,9 +228,11 @@ function ClickableHp({
return (
<span
className={cn(
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground",
"inline-block h-7 w-[4ch] text-center text-muted-foreground text-sm tabular-nums leading-7",
dimmed && "opacity-50",
)}
role="status"
aria-label="No HP set"
>
--
</span>
@@ -245,7 +245,7 @@ function ClickableHp({
type="button"
onClick={() => setPopoverOpen(true)}
className={cn(
"inline-block h-7 min-w-[3ch] text-center text-sm font-medium leading-7 tabular-nums transition-colors hover:text-hover-neutral",
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral",
status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground",
@@ -254,7 +254,7 @@ function ClickableHp({
>
{currentHp}
</button>
{popoverOpen && (
{!!popoverOpen && (
<HpAdjustPopover
onAdjust={onAdjust}
onClose={() => setPopoverOpen(false)}
@@ -267,10 +267,10 @@ function ClickableHp({
function AcDisplay({
ac,
onCommit,
}: {
}: Readonly<{
ac: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(ac?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
@@ -321,13 +321,13 @@ function InitiativeDisplay({
dimmed,
onSetInitiative,
onRollInitiative,
}: {
}: Readonly<{
initiative: number | undefined;
combatantId: CombatantId;
dimmed: boolean;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRollInitiative?: (id: CombatantId) => void;
}) {
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initiative?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
@@ -397,10 +397,10 @@ function InitiativeDisplay({
type="button"
onClick={startEditing}
className={cn(
"h-7 w-full text-center text-sm leading-7 tabular-nums transition-colors",
initiative !== undefined
? "font-medium text-foreground hover:text-hover-neutral"
: "text-muted-foreground hover:text-hover-neutral",
"h-7 w-full text-center text-sm tabular-nums leading-7 transition-colors",
initiative === undefined
? "text-muted-foreground hover:text-hover-neutral"
: "font-medium text-foreground hover:text-hover-neutral",
dimmed && "opacity-50",
)}
>
@@ -491,6 +491,7 @@ export function CombatantRow({
return (
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
<div
ref={ref}
role={onShowStatBlock ? "button" : undefined}
@@ -517,7 +518,7 @@ export function CombatantRow({
title="Concentrating"
aria-label="Toggle concentration"
className={cn(
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
"-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
concentrationIconClass(combatant.isConcentrating, dimmed),
)}
>
@@ -526,6 +527,7 @@ export function CombatantRow({
{/* Initiative */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
@@ -542,22 +544,22 @@ export function CombatantRow({
{/* Name + Conditions */}
<div
className={cn(
"relative flex flex-wrap items-center gap-1 min-w-0",
"relative flex min-w-0 flex-wrap items-center gap-1",
dimmed && "opacity-50",
)}
>
{combatant.icon &&
combatant.color &&
{!!combatant.icon &&
!!combatant.color &&
(() => {
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
const pcColor =
const iconColor =
PLAYER_COLOR_HEX[
combatant.color as keyof typeof PLAYER_COLOR_HEX
];
return PcIcon ? (
<PcIcon
size={14}
style={{ color: pcColor }}
style={{ color: iconColor }}
className="shrink-0"
/>
) : null;
@@ -574,7 +576,7 @@ export function CombatantRow({
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
{pickerOpen && (
{!!pickerOpen && (
<ConditionPicker
activeConditions={combatant.conditions}
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
@@ -585,6 +587,7 @@ export function CombatantRow({
{/* AC */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
<div
className={cn(dimmed && "opacity-50")}
onClick={(e) => e.stopPropagation()}
@@ -595,6 +598,7 @@ export function CombatantRow({
{/* HP */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
@@ -609,7 +613,7 @@ export function CombatantRow({
{maxHp !== undefined && (
<span
className={cn(
"text-sm tabular-nums text-muted-foreground",
"text-muted-foreground text-sm tabular-nums",
dimmed && "opacity-50",
)}
>
@@ -626,7 +630,7 @@ export function CombatantRow({
icon={<X size={16} />}
label="Remove combatant"
onConfirm={() => onRemove(id)}
className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto transition-opacity"
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
/>
</div>
</div>

View File

@@ -61,7 +61,7 @@ export function ConditionPicker({
activeConditions,
onToggle,
onClose,
}: ConditionPickerProps) {
}: Readonly<ConditionPickerProps>) {
const ref = useRef<HTMLDivElement>(null);
const [flipped, setFlipped] = useState(false);
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);

View File

@@ -60,7 +60,7 @@ export function ConditionTags({
conditions,
onRemove,
onOpenPicker,
}: ConditionTagsProps) {
}: Readonly<ConditionTagsProps>) {
return (
<div className="flex flex-wrap items-center gap-0.5">
{conditions?.map((condId) => {
@@ -75,7 +75,7 @@ export function ConditionTags({
type="button"
title={def.label}
aria-label={`Remove ${def.label}`}
className={`inline-flex items-center rounded p-0.5 hover:bg-hover-neutral-bg transition-colors ${colorClass}`}
className={`inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg ${colorClass}`}
onClick={(e) => {
e.stopPropagation();
onRemove(condId);
@@ -89,7 +89,7 @@ export function ConditionTags({
type="button"
title="Add condition"
aria-label="Add condition"
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 transition-opacity"
className="inline-flex items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onOpenPicker();

View File

@@ -1,6 +1,6 @@
import type { PlayerCharacter } from "@initiative/domain";
import { X } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { ColorPalette } from "./color-palette";
import { IconGrid } from "./icon-grid";
import { Button } from "./ui/button";
@@ -24,7 +24,7 @@ export function CreatePlayerModal({
onClose,
onSave,
playerCharacter,
}: CreatePlayerModalProps) {
}: Readonly<CreatePlayerModalProps>) {
const [name, setName] = useState("");
const [ac, setAc] = useState("10");
const [maxHp, setMaxHp] = useState("10");
@@ -64,7 +64,7 @@ export function CreatePlayerModal({
if (!open) return null;
const handleSubmit = (e: FormEvent) => {
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmed = name.trim();
if (trimmed === "") {
@@ -87,17 +87,19 @@ export function CreatePlayerModal({
return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onMouseDown={onClose}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
<div
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
<h2 className="font-semibold text-foreground text-lg">
{isEdit ? "Edit Player" : "Create Player"}
</h2>
<Button
@@ -112,7 +114,7 @@ export function CreatePlayerModal({
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<span className="mb-1 block text-sm text-muted-foreground">
<span className="mb-1 block text-muted-foreground text-sm">
Name
</span>
<Input
@@ -126,12 +128,14 @@ export function CreatePlayerModal({
aria-label="Name"
autoFocus
/>
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
{!!error && (
<p className="mt-1 text-destructive text-sm">{error}</p>
)}
</div>
<div className="flex gap-3">
<div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground">
<span className="mb-1 block text-muted-foreground text-sm">
AC
</span>
<Input
@@ -145,7 +149,7 @@ export function CreatePlayerModal({
/>
</div>
<div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground">
<span className="mb-1 block text-muted-foreground text-sm">
Max HP
</span>
<Input
@@ -161,14 +165,14 @@ export function CreatePlayerModal({
</div>
<div>
<span className="mb-2 block text-sm text-muted-foreground">
<span className="mb-2 block text-muted-foreground text-sm">
Color
</span>
<ColorPalette value={color} onChange={setColor} />
</div>
<div>
<span className="mb-2 block text-sm text-muted-foreground">
<span className="mb-2 block text-muted-foreground text-sm">
Icon
</span>
<IconGrid value={icon} onChange={setIcon} />

View File

@@ -8,6 +8,8 @@ import {
} from "react";
import { Input } from "./ui/input";
const DIGITS_ONLY_REGEX = /^\d+$/;
interface HpAdjustPopoverProps {
readonly onAdjust: (delta: number) => void;
readonly onClose: () => void;
@@ -102,7 +104,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
className="h-7 w-[7ch] text-center text-sm tabular-nums"
onChange={(e) => {
const v = e.target.value;
if (v === "" || /^\d+$/.test(v)) {
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
setInputValue(v);
}
}}

View File

@@ -10,7 +10,7 @@ interface IconGridProps {
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
export function IconGrid({ value, onChange }: IconGridProps) {
export function IconGrid({ value, onChange }: Readonly<IconGridProps>) {
return (
<div className="flex flex-wrap gap-2">
{ICONS.map((iconId) => {
@@ -23,7 +23,7 @@ export function IconGrid({ value, onChange }: IconGridProps) {
className={cn(
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
value === iconId
? "bg-primary/20 ring-2 ring-primary text-foreground"
? "bg-primary/20 text-foreground ring-2 ring-primary"
: "text-muted-foreground hover:bg-card hover:text-foreground",
)}
aria-label={iconId}

View File

@@ -1,5 +1,5 @@
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import { forwardRef, useImperativeHandle, useState } from "react";
import { type RefObject, useImperativeHandle, useState } from "react";
import { CreatePlayerModal } from "./create-player-modal.js";
import { PlayerManagement } from "./player-management.js";
@@ -29,13 +29,15 @@ interface PlayerCharacterSectionProps {
onDeleteCharacter: (id: PlayerCharacterId) => void;
}
export const PlayerCharacterSection = forwardRef<
PlayerCharacterSectionHandle,
PlayerCharacterSectionProps
>(function PlayerCharacterSection(
{ characters, onCreateCharacter, onEditCharacter, onDeleteCharacter },
export const PlayerCharacterSection = function PlayerCharacterSectionInner({
characters,
onCreateCharacter,
onEditCharacter,
onDeleteCharacter,
ref,
) {
}: PlayerCharacterSectionProps & {
ref?: RefObject<PlayerCharacterSectionHandle | null>;
}) {
const [managementOpen, setManagementOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editingPlayer, setEditingPlayer] = useState<
@@ -88,4 +90,4 @@ export const PlayerCharacterSection = forwardRef<
/>
</>
);
});
};

View File

@@ -21,7 +21,7 @@ export function PlayerManagement({
onEdit,
onDelete,
onCreate,
}: PlayerManagementProps) {
}: Readonly<PlayerManagementProps>) {
useEffect(() => {
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
@@ -35,17 +35,19 @@ export function PlayerManagement({
return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onMouseDown={onClose}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
<div
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
<h2 className="font-semibold text-foreground text-lg">
Player Characters
</h2>
<Button
@@ -76,16 +78,16 @@ export function PlayerManagement({
key={pc.id}
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
>
{Icon && (
{!!Icon && (
<Icon size={18} style={{ color }} className="shrink-0" />
)}
<span className="flex-1 truncate text-sm text-foreground">
<span className="flex-1 truncate text-foreground text-sm">
{pc.name}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
<span className="text-muted-foreground text-xs tabular-nums">
AC {pc.ac}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
<span className="text-muted-foreground text-xs tabular-nums">
HP {pc.maxHp}
</span>
<Button

View File

@@ -1,5 +1,5 @@
import { Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { useId, useRef, useState } from "react";
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
@@ -18,11 +18,12 @@ export function SourceFetchPrompt({
fetchAndCacheSource,
onSourceLoaded,
onUploadSource,
}: SourceFetchPromptProps) {
}: Readonly<SourceFetchPromptProps>) {
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
const [error, setError] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null);
const sourceUrlId = useId();
const handleFetch = async () => {
setStatus("fetching");
@@ -64,21 +65,21 @@ export function SourceFetchPrompt({
return (
<div className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-foreground">
<h3 className="font-semibold text-foreground text-sm">
Load {sourceDisplayName}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-muted-foreground text-xs">
Stat block data for this source needs to be loaded. Enter a URL or
upload a JSON file.
</p>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="source-url" className="text-xs text-muted-foreground">
<label htmlFor={sourceUrlId} className="text-muted-foreground text-xs">
Source URL
</label>
<Input
id="source-url"
id={sourceUrlId}
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
@@ -97,7 +98,7 @@ export function SourceFetchPrompt({
{status === "fetching" ? "Loading..." : "Load"}
</Button>
<span className="text-xs text-muted-foreground">or</span>
<span className="text-muted-foreground text-xs">or</span>
<Button
variant="outline"
@@ -117,7 +118,7 @@ export function SourceFetchPrompt({
</div>
{status === "error" && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-destructive text-xs">
{error}
</div>
)}

View File

@@ -8,7 +8,9 @@ interface SourceManagerProps {
onCacheCleared: () => void;
}
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
export function SourceManager({
onCacheCleared,
}: Readonly<SourceManagerProps>) {
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
const [optimisticSources, applyOptimistic] = useOptimistic(
sources,
@@ -27,7 +29,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
}, []);
useEffect(() => {
loadSources();
void loadSources();
}, [loadSources]);
const handleClearSource = async (sourceCode: string) => {
@@ -48,7 +50,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
return (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Database className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No cached sources</p>
<p className="text-muted-foreground text-sm">No cached sources</p>
</div>
);
}
@@ -56,12 +58,12 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">
<span className="font-semibold text-foreground text-sm">
Cached Sources
</span>
<Button
variant="outline"
className="hover:text-hover-destructive hover:border-hover-destructive"
className="hover:border-hover-destructive hover:text-hover-destructive"
onClick={handleClearAll}
>
<Trash2 className="mr-1 h-3 w-3" />
@@ -75,10 +77,10 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
>
<div>
<span className="text-sm text-foreground">
<span className="text-foreground text-sm">
{source.displayName}
</span>
<span className="ml-2 text-xs text-muted-foreground">
<span className="ml-2 text-muted-foreground text-xs">
{source.creatureCount} creatures
</span>
</div>
@@ -86,6 +88,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
type="button"
onClick={() => handleClearSource(source.sourceCode)}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
aria-label={`Remove ${source.displayName}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>

View File

@@ -46,11 +46,11 @@ function CollapsedTab({
creatureName,
side,
onToggleCollapse,
}: {
}: Readonly<{
creatureName: string;
side: "left" | "right";
onToggleCollapse: () => void;
}) {
}>) {
return (
<button
type="button"
@@ -60,7 +60,7 @@ function CollapsedTab({
}`}
aria-label="Expand stat block panel"
>
<span className="writing-vertical-rl text-sm font-medium">
<span className="writing-vertical-rl font-medium text-sm">
{creatureName}
</span>
</button>
@@ -73,15 +73,15 @@ function PanelHeader({
onToggleCollapse,
onPin,
onUnpin,
}: {
}: Readonly<{
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleCollapse: () => void;
onPin: () => void;
onUnpin: () => void;
}) {
}>) {
return (
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center justify-between border-border border-b px-4 py-2">
<div className="flex items-center gap-1">
{panelRole === "browse" && (
<Button
@@ -133,7 +133,7 @@ function DesktopPanel({
onPin,
onUnpin,
children,
}: {
}: Readonly<{
isCollapsed: boolean;
side: "left" | "right";
creatureName: string;
@@ -143,7 +143,7 @@ function DesktopPanel({
onPin: () => void;
onUnpin: () => void;
children: ReactNode;
}) {
}>) {
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
const collapsedTranslate =
side === "right"
@@ -179,28 +179,28 @@ function DesktopPanel({
function MobileDrawer({
onDismiss,
children,
}: {
}: Readonly<{
onDismiss: () => void;
children: ReactNode;
}) {
}>) {
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
return (
<div className="fixed inset-0 z-50">
<button
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in"
className="fade-in absolute inset-0 animate-in bg-black/50"
onClick={onDismiss}
aria-label="Close stat block"
/>
<div
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
style={
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
}
{...handlers}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center justify-between border-border border-b px-4 py-2">
<Button
variant="ghost"
size="icon-sm"
@@ -239,15 +239,15 @@ export function StatBlockPanel({
onStartBulkImport,
onBulkImportDone,
sourceManagerMode,
}: StatBlockPanelProps) {
}: Readonly<StatBlockPanelProps>) {
const [isDesktop, setIsDesktop] = useState(
() => window.matchMedia("(min-width: 1024px)").matches,
() => globalThis.matchMedia("(min-width: 1024px)").matches,
);
const [needsFetch, setNeedsFetch] = useState(false);
const [checkingCache, setCheckingCache] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)");
const mq = globalThis.matchMedia("(min-width: 1024px)");
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
@@ -266,7 +266,7 @@ export function StatBlockPanel({
}
setCheckingCache(true);
isSourceCached(sourceCode).then((cached) => {
void isSourceCached(sourceCode).then((cached) => {
setNeedsFetch(!cached);
setCheckingCache(false);
});
@@ -303,7 +303,7 @@ export function StatBlockPanel({
if (checkingCache) {
return (
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
<div className="p-4 text-muted-foreground text-sm">Loading...</div>
);
}
@@ -324,19 +324,16 @@ export function StatBlockPanel({
}
return (
<div className="p-4 text-sm text-muted-foreground">
<div className="p-4 text-muted-foreground text-sm">
No stat block available
</div>
);
};
const creatureName =
creature?.name ??
(sourceManagerMode
? "Sources"
: bulkImportMode
? "Import All Sources"
: "Creature");
let fallbackName = "Creature";
if (sourceManagerMode) fallbackName = "Sources";
else if (bulkImportMode) fallbackName = "Import All Sources";
const creatureName = creature?.name ?? fallbackName;
if (isDesktop) {
return (

View File

@@ -16,10 +16,10 @@ function abilityMod(score: number): string {
function PropertyLine({
label,
value,
}: {
}: Readonly<{
label: string;
value: string | undefined;
}) {
}>) {
if (!value) return null;
return (
<div className="text-sm">
@@ -34,7 +34,7 @@ function SectionDivider() {
);
}
export function StatBlock({ creature }: StatBlockProps) {
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
const abilities = [
{ label: "STR", score: creature.abilities.str },
{ label: "DEX", score: creature.abilities.dex },
@@ -54,11 +54,11 @@ export function StatBlock({ creature }: StatBlockProps) {
<div className="space-y-1 text-foreground">
{/* Header */}
<div>
<h2 className="text-xl font-bold text-amber-400">{creature.name}</h2>
<p className="text-sm italic text-muted-foreground">
<h2 className="font-bold text-amber-400 text-xl">{creature.name}</h2>
<p className="text-muted-foreground text-sm italic">
{creature.size} {creature.type}, {creature.alignment}
</p>
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
{creature.sourceDisplayName}
</p>
</div>
@@ -69,7 +69,7 @@ export function StatBlock({ creature }: StatBlockProps) {
<div className="space-y-0.5 text-sm">
<div>
<span className="font-semibold">Armor Class</span> {creature.ac}
{creature.acSource && (
{!!creature.acSource && (
<span className="text-muted-foreground">
{" "}
({creature.acSource})
@@ -194,7 +194,7 @@ export function StatBlock({ creature }: StatBlockProps) {
{creature.actions && creature.actions.length > 0 && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">Actions</h3>
<h3 className="font-bold text-amber-400 text-base">Actions</h3>
<div className="space-y-2">
{creature.actions.map((a) => (
<div key={a.name} className="text-sm">
@@ -209,7 +209,7 @@ export function StatBlock({ creature }: StatBlockProps) {
{creature.bonusActions && creature.bonusActions.length > 0 && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">Bonus Actions</h3>
<h3 className="font-bold text-amber-400 text-base">Bonus Actions</h3>
<div className="space-y-2">
{creature.bonusActions.map((a) => (
<div key={a.name} className="text-sm">
@@ -224,7 +224,7 @@ export function StatBlock({ creature }: StatBlockProps) {
{creature.reactions && creature.reactions.length > 0 && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">Reactions</h3>
<h3 className="font-bold text-amber-400 text-base">Reactions</h3>
<div className="space-y-2">
{creature.reactions.map((a) => (
<div key={a.name} className="text-sm">
@@ -236,13 +236,13 @@ export function StatBlock({ creature }: StatBlockProps) {
)}
{/* Legendary Actions */}
{creature.legendaryActions && (
{!!creature.legendaryActions && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">
<h3 className="font-bold text-amber-400 text-base">
Legendary Actions
</h3>
<p className="text-sm italic text-muted-foreground">
<p className="text-muted-foreground text-sm italic">
{creature.legendaryActions.preamble}
</p>
<div className="space-y-2">

View File

@@ -25,7 +25,7 @@ export function Toast({
return createPortal(
<div className="fixed bottom-4 left-4 z-50">
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
<span className="text-sm text-foreground">{message}</span>
<span className="text-foreground text-sm">{message}</span>
{progress !== undefined && (
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
<div

View File

@@ -15,7 +15,7 @@ export function TurnNavigation({
onAdvanceTurn,
onRetreatTurn,
onClearEncounter,
}: TurnNavigationProps) {
}: Readonly<TurnNavigationProps>) {
const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
const activeCombatant = encounter.combatants[encounter.activeIndex];
@@ -33,8 +33,8 @@ export function TurnNavigation({
<StepBack className="h-5 w-5" />
</Button>
<div className="min-w-0 flex-1 flex items-center justify-center gap-2 text-sm">
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold shrink-0">
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
R{encounter.roundNumber}
</span>
{activeCombatant ? (

View File

@@ -55,17 +55,17 @@ export function ConfirmButton({
}
}
function handleKeyDown(e: KeyboardEvent) {
function handleEscapeKey(e: KeyboardEvent) {
if (e.key === "Escape") {
revert();
}
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keydown", handleEscapeKey);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keydown", handleEscapeKey);
};
}, [isConfirming, revert]);
@@ -100,7 +100,7 @@ export function ConfirmButton({
className={cn(
className,
isConfirming
? "bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground"
? "animate-confirm-pulse rounded-md bg-destructive text-primary-foreground hover:bg-destructive hover:text-primary-foreground"
: "hover:text-hover-destructive",
)}
onClick={handleClick}
@@ -110,7 +110,8 @@ export function ConfirmButton({
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
>
{isConfirming ? <Check size={16} /> : icon}
{isConfirming ? <Check size={16} /> : null}
{!isConfirming && icon}
</Button>
</div>
);

View File

@@ -1,19 +1,21 @@
import { forwardRef, type InputHTMLAttributes } from "react";
import type { InputHTMLAttributes, RefObject } from "react";
import { cn } from "../../lib/utils";
type InputProps = InputHTMLAttributes<HTMLInputElement>;
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
export const Input = ({
className,
ref,
...props
}: InputProps & { ref?: RefObject<HTMLInputElement | null> }) => {
return (
<input
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-foreground text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
},
);
};

View File

@@ -48,13 +48,13 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
>
<EllipsisVertical className="h-5 w-5" />
</Button>
{open && (
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
{!!open && (
<div className="absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
{items.map((item) => (
<button
key={item.label}
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
disabled={item.disabled}
onClick={() => {
item.onClick();

View File

@@ -0,0 +1,217 @@
// @vitest-environment jsdom
import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useEncounter } from "../use-encounter.js";
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: vi.fn().mockReturnValue(null),
saveEncounter: vi.fn(),
}));
const { loadEncounter: mockLoad, saveEncounter: mockSave } =
await vi.importMock<typeof import("../../persistence/encounter-storage.js")>(
"../../persistence/encounter-storage.js",
);
describe("useEncounter", () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoad.mockReturnValue(null);
});
it("initializes with empty encounter when persistence returns null", () => {
const { result } = renderHook(() => useEncounter());
expect(result.current.encounter.combatants).toEqual([]);
expect(result.current.encounter.activeIndex).toBe(0);
expect(result.current.encounter.roundNumber).toBe(1);
expect(result.current.isEmpty).toBe(true);
});
it("initializes from stored encounter", () => {
const stored = {
combatants: [{ id: combatantId("c-1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 2,
};
mockLoad.mockReturnValue(stored);
const { result } = renderHook(() => useEncounter());
expect(result.current.encounter.combatants).toHaveLength(1);
expect(result.current.encounter.roundNumber).toBe(2);
expect(result.current.isEmpty).toBe(false);
});
it("addCombatant adds a combatant with incremental IDs and persists", () => {
const { result } = renderHook(() => useEncounter());
act(() => result.current.addCombatant("Goblin"));
act(() => result.current.addCombatant("Orc"));
expect(result.current.encounter.combatants).toHaveLength(2);
expect(result.current.encounter.combatants[0].name).toBe("Goblin");
expect(result.current.encounter.combatants[1].name).toBe("Orc");
expect(result.current.isEmpty).toBe(false);
expect(mockSave).toHaveBeenCalled();
});
it("removeCombatant removes a combatant and persists", () => {
const { result } = renderHook(() => useEncounter());
act(() => result.current.addCombatant("Goblin"));
const id = result.current.encounter.combatants[0].id;
act(() => result.current.removeCombatant(id));
expect(result.current.encounter.combatants).toHaveLength(0);
expect(result.current.isEmpty).toBe(true);
});
it("advanceTurn and retreatTurn update encounter state", () => {
const { result } = renderHook(() => useEncounter());
act(() => result.current.addCombatant("Goblin"));
act(() => result.current.addCombatant("Orc"));
const initialActive = result.current.encounter.activeIndex;
act(() => result.current.advanceTurn());
expect(result.current.encounter.activeIndex).not.toBe(initialActive);
act(() => result.current.retreatTurn());
expect(result.current.encounter.activeIndex).toBe(initialActive);
});
it("clearEncounter resets to empty and resets ID counter", () => {
const { result } = renderHook(() => useEncounter());
act(() => result.current.addCombatant("Goblin"));
act(() => result.current.clearEncounter());
expect(result.current.encounter.combatants).toHaveLength(0);
expect(result.current.isEmpty).toBe(true);
// After clear, IDs restart from c-1
act(() => result.current.addCombatant("Orc"));
expect(result.current.encounter.combatants[0].id).toBe("c-1");
});
it("addCombatant with opts applies initiative, ac, maxHp", () => {
const { result } = renderHook(() => useEncounter());
act(() =>
result.current.addCombatant("Goblin", {
initiative: 15,
ac: 13,
maxHp: 7,
}),
);
const goblin = result.current.encounter.combatants[0];
expect(goblin.initiative).toBe(15);
expect(goblin.ac).toBe(13);
expect(goblin.maxHp).toBe(7);
expect(goblin.currentHp).toBe(7);
});
it("derived flags: hasCreatureCombatants and canRollAllInitiative", () => {
const { result } = renderHook(() => useEncounter());
// No creatures yet
expect(result.current.hasCreatureCombatants).toBe(false);
expect(result.current.canRollAllInitiative).toBe(false);
// Add from bestiary to get a creature combatant
const entry: BestiaryIndexEntry = {
name: "Goblin",
source: "MM",
ac: 15,
hp: 7,
dex: 14,
cr: "1/4",
initiativeProficiency: 0,
size: "Small",
type: "humanoid",
};
act(() => result.current.addFromBestiary(entry));
expect(result.current.hasCreatureCombatants).toBe(true);
expect(result.current.canRollAllInitiative).toBe(true);
});
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
const { result } = renderHook(() => useEncounter());
const entry: BestiaryIndexEntry = {
name: "Goblin",
source: "MM",
ac: 15,
hp: 7,
dex: 14,
cr: "1/4",
initiativeProficiency: 0,
size: "Small",
type: "humanoid",
};
act(() => result.current.addFromBestiary(entry));
const combatant = result.current.encounter.combatants[0];
expect(combatant.name).toBe("Goblin");
expect(combatant.maxHp).toBe(7);
expect(combatant.currentHp).toBe(7);
expect(combatant.ac).toBe(15);
expect(combatant.creatureId).toBe(creatureId("mm:goblin"));
});
it("addFromBestiary auto-numbers duplicate names", () => {
const { result } = renderHook(() => useEncounter());
const entry: BestiaryIndexEntry = {
name: "Goblin",
source: "MM",
ac: 15,
hp: 7,
dex: 14,
cr: "1/4",
initiativeProficiency: 0,
size: "Small",
type: "humanoid",
};
act(() => result.current.addFromBestiary(entry));
act(() => result.current.addFromBestiary(entry));
const names = result.current.encounter.combatants.map((c) => c.name);
expect(names).toContain("Goblin 1");
expect(names).toContain("Goblin 2");
});
it("addFromPlayerCharacter adds combatant with HP, AC, color, icon", () => {
const { result } = renderHook(() => useEncounter());
const pc: PlayerCharacter = {
id: playerCharacterId("pc-1"),
name: "Aria",
ac: 16,
maxHp: 30,
color: "blue",
icon: "sword",
};
act(() => result.current.addFromPlayerCharacter(pc));
const combatant = result.current.encounter.combatants[0];
expect(combatant.name).toBe("Aria");
expect(combatant.maxHp).toBe(30);
expect(combatant.currentHp).toBe(30);
expect(combatant.ac).toBe(16);
expect(combatant.color).toBe("blue");
expect(combatant.icon).toBe("sword");
expect(combatant.playerCharacterId).toBe(playerCharacterId("pc-1"));
});
});

View File

@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { playerCharacterId } from "@initiative/domain";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePlayerCharacters } from "../use-player-characters.js";
vi.mock("../../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: vi.fn().mockReturnValue([]),
savePlayerCharacters: vi.fn(),
}));
const { loadPlayerCharacters: mockLoad, savePlayerCharacters: mockSave } =
await vi.importMock<
typeof import("../../persistence/player-character-storage.js")
>("../../persistence/player-character-storage.js");
describe("usePlayerCharacters", () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoad.mockReturnValue([]);
});
it("initializes with characters from persistence", () => {
const stored = [
{
id: playerCharacterId("pc-1"),
name: "Aria",
ac: 16,
maxHp: 30,
color: undefined,
icon: undefined,
},
];
mockLoad.mockReturnValue(stored);
const { result } = renderHook(() => usePlayerCharacters());
expect(result.current.characters).toEqual(stored);
});
it("createCharacter adds a character and persists", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
});
expect(result.current.characters).toHaveLength(1);
expect(result.current.characters[0].name).toBe("Vex");
expect(result.current.characters[0].ac).toBe(15);
expect(result.current.characters[0].maxHp).toBe(28);
expect(mockSave).toHaveBeenCalled();
});
it("createCharacter returns domain error for empty name", () => {
const { result } = renderHook(() => usePlayerCharacters());
let error: unknown;
act(() => {
error = result.current.createCharacter("", 15, 28, undefined, undefined);
});
expect(error).toMatchObject({ kind: "domain-error" });
expect(result.current.characters).toHaveLength(0);
});
it("editCharacter updates character and persists", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
});
const id = result.current.characters[0].id;
act(() => {
result.current.editCharacter(id, { name: "Vex'ahlia" });
});
expect(result.current.characters[0].name).toBe("Vex'ahlia");
expect(mockSave).toHaveBeenCalled();
});
it("deleteCharacter removes character and persists", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
});
const id = result.current.characters[0].id;
act(() => {
result.current.deleteCharacter(id);
});
expect(result.current.characters).toHaveLength(0);
expect(mockSave).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,159 @@
// @vitest-environment jsdom
import { creatureId } from "@initiative/domain";
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useSidePanelState } from "../use-side-panel-state.js";
function mockMatchMedia(matches: boolean) {
const listeners: Array<(e: MediaQueryListEvent) => void> = [];
const mql = {
matches,
addEventListener: vi.fn(
(_event: string, handler: (e: MediaQueryListEvent) => void) => {
listeners.push(handler);
},
),
removeEventListener: vi.fn(),
};
globalThis.matchMedia = vi.fn().mockReturnValue(mql) as typeof matchMedia;
return { mql, listeners };
}
const CREATURE_A = creatureId("creature-a");
describe("useSidePanelState", () => {
it("starts with closed panel, no selection, not collapsed", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
expect(result.current.panelView).toEqual({ mode: "closed" });
expect(result.current.selectedCreatureId).toBeNull();
expect(result.current.isRightPanelCollapsed).toBe(false);
expect(result.current.bulkImportMode).toBe(false);
expect(result.current.sourceManagerMode).toBe(false);
expect(result.current.pinnedCreatureId).toBeNull();
});
it("showCreature sets creature mode and selectedCreatureId", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
expect(result.current.panelView).toEqual({
mode: "creature",
creatureId: CREATURE_A,
});
expect(result.current.selectedCreatureId).toBe(CREATURE_A);
});
it("showBulkImport sets bulk-import mode, selectedCreatureId null", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showBulkImport());
expect(result.current.panelView).toEqual({ mode: "bulk-import" });
expect(result.current.selectedCreatureId).toBeNull();
expect(result.current.bulkImportMode).toBe(true);
});
it("showSourceManager sets source-manager mode", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showSourceManager());
expect(result.current.panelView).toEqual({ mode: "source-manager" });
expect(result.current.sourceManagerMode).toBe(true);
});
it("dismissPanel sets mode to closed", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
act(() => result.current.dismissPanel());
expect(result.current.panelView).toEqual({ mode: "closed" });
});
it("toggleCollapse flips isRightPanelCollapsed", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
expect(result.current.isRightPanelCollapsed).toBe(false);
act(() => result.current.toggleCollapse());
expect(result.current.isRightPanelCollapsed).toBe(true);
act(() => result.current.toggleCollapse());
expect(result.current.isRightPanelCollapsed).toBe(false);
});
it("showCreature resets collapse state", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.toggleCollapse());
expect(result.current.isRightPanelCollapsed).toBe(true);
act(() => result.current.showCreature(CREATURE_A));
expect(result.current.isRightPanelCollapsed).toBe(false);
});
it("togglePin pins the selected creature", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
act(() => result.current.togglePin());
expect(result.current.pinnedCreatureId).toBe(CREATURE_A);
});
it("togglePin unpins when already pinned to same creature", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
act(() => result.current.togglePin());
act(() => result.current.togglePin());
expect(result.current.pinnedCreatureId).toBeNull();
});
it("togglePin does nothing when no creature is selected", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.togglePin());
expect(result.current.pinnedCreatureId).toBeNull();
});
it("unpin clears pinned creature", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
act(() => result.current.togglePin());
act(() => result.current.unpin());
expect(result.current.pinnedCreatureId).toBeNull();
});
it("isWideDesktop reflects matchMedia result", () => {
mockMatchMedia(true);
const { result } = renderHook(() => useSidePanelState());
expect(result.current.isWideDesktop).toBe(true);
});
it("isWideDesktop is false on narrow viewport", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
expect(result.current.isWideDesktop).toBe(false);
});
});

View File

@@ -44,7 +44,7 @@ export function useBestiary(): BestiaryHook {
setIsLoaded(true);
}
bestiaryCache.loadAllCachedCreatures().then((map) => {
void bestiaryCache.loadAllCachedCreatures().then((map) => {
setCreatureMap(map);
});
}, []);

View File

@@ -48,7 +48,7 @@ export function useBulkImport(): BulkImportHook {
countersRef.current = { completed: 0, failed: 0 };
setState({ status: "loading", total, completed: 0, failed: 0 });
(async () => {
void (async () => {
const cacheChecks = await Promise.all(
allCodes.map(async (code) => ({
code,
@@ -75,6 +75,7 @@ export function useBulkImport(): BulkImportHook {
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
const batch = uncached.slice(i, i + BATCH_SIZE);
// biome-ignore lint/performance/noAwaitInLoops: sequential batching is intentional to avoid overwhelming the server with too many concurrent requests
await Promise.allSettled(
batch.map(async ({ code }) => {
const url = getDefaultFetchUrl(code, baseUrl);

View File

@@ -33,6 +33,8 @@ import {
saveEncounter,
} from "../persistence/encounter-storage.js";
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
const EMPTY_ENCOUNTER: Encounter = {
combatants: [],
activeIndex: 0,
@@ -48,7 +50,7 @@ function initializeEncounter(): Encounter {
function deriveNextId(encounter: Encounter): number {
let max = 0;
for (const c of encounter.combatants) {
const match = /^c-(\d+)$/.exec(c.id);
const match = COMBATANT_ID_REGEX.exec(c.id);
if (match) {
const n = Number.parseInt(match[1], 10);
if (n > max) max = n;
@@ -301,8 +303,8 @@ export function useEncounter() {
// Derive creatureId from source + name
const slug = entry.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
@@ -316,7 +318,7 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...addResult]);
},
[makeStore, editCombatant],
[makeStore],
);
const addFromPlayerCharacter = useCallback(
@@ -368,7 +370,7 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...addResult]);
},
[makeStore, editCombatant],
[makeStore],
);
const isEmpty = encounter.combatants.length === 0;

View File

@@ -34,11 +34,11 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
null,
);
const [isWideDesktop, setIsWideDesktop] = useState(
() => window.matchMedia("(min-width: 1280px)").matches,
() => globalThis.matchMedia("(min-width: 1280px)").matches,
);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1280px)");
const mq = globalThis.matchMedia("(min-width: 1280px)");
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);

View File

@@ -1,14 +1,14 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
"files": {
"includes": [
"**",
"!**/dist/**",
"!.claude/**",
"!.specify/**",
"!specs/**",
"!coverage/**",
"!.pnpm-store/**"
"!**/dist",
"!.claude",
"!.specify",
"!specs",
"!coverage",
"!.pnpm-store"
]
},
"assist": {
@@ -21,6 +21,12 @@
}
}
},
"css": {
"parser": {
"cssModules": false,
"tailwindDirectives": true
}
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
@@ -30,13 +36,93 @@
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"noNoninteractiveElementInteractions": "error"
},
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "error",
"options": {
"maxAllowedComplexity": 15
}
}
},
"noUselessStringConcat": "error"
},
"correctness": {
"noNestedComponentDefinitions": "error",
"noReactPropAssignments": "error"
},
"nursery": {
"noConditionalExpect": "error",
"noDuplicatedSpreadProps": "error",
"noFloatingPromises": "error",
"noLeakedRender": "error",
"noMisusedPromises": "error",
"noNestedPromises": "error",
"noReturnAssign": "error",
"noScriptUrl": "error",
"noShadow": "error",
"noUnnecessaryConditions": "error",
"noUselessReturn": "error",
"useArraySome": "error",
"useArraySortCompare": "error",
"useAwaitThenable": "error",
"useErrorCause": "error",
"useExhaustiveSwitchCases": "error",
"useFind": "error",
"useGlobalThis": "error",
"useNullishCoalescing": "error",
"useRegexpExec": "error",
"useSortedClasses": "error",
"useSpread": "error"
},
"performance": {
"noAwaitInLoops": "error",
"useTopLevelRegex": "error"
},
"style": {
"noCommonJs": "error",
"noDoneCallback": "error",
"noExportedImports": "error",
"noInferrableTypes": "error",
"noNamespace": "error",
"noNegationElse": "error",
"noNestedTernary": "error",
"noParameterAssign": "error",
"noSubstr": "error",
"noUnusedTemplateLiteral": "error",
"noUselessElse": "error",
"noYodaExpression": "error",
"useAsConstAssertion": "error",
"useAtIndex": "error",
"useCollapsedElseIf": "error",
"useCollapsedIf": "error",
"useConsistentBuiltinInstantiation": "error",
"useDefaultParameterLast": "error",
"useExplicitLengthCheck": "error",
"useForOf": "error",
"useFragmentSyntax": "error",
"useNumberNamespace": "error",
"useSelfClosingElements": "error",
"useShorthandAssign": "error",
"useThrowNewError": "error",
"useThrowOnlyError": "error",
"useTrimStartEnd": "error"
},
"suspicious": {
"noAlert": "error",
"noConstantBinaryExpressions": "error",
"noDeprecatedImports": "error",
"noEvolvingTypes": "error",
"noImportCycles": "error",
"noReactForwardRef": "error",
"noSkippedTests": "error",
"noTemplateCurlyInString": "error",
"noTsIgnore": "error",
"noUnusedExpressions": "error",
"noVar": "error",
"useAwait": "error",
"useErrorMessage": "error"
}
}
}

View File

@@ -7,11 +7,13 @@
}
},
"devDependencies": {
"@biomejs/biome": "2.0.0",
"@biomejs/biome": "2.4.7",
"@vitest/coverage-v8": "^3.2.4",
"jscpd": "^4.0.8",
"knip": "^5.85.0",
"lefthook": "^1.11.0",
"oxlint": "^1.55.0",
"oxlint-tsgolint": "^0.16.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
},
@@ -26,6 +28,7 @@
"test:watch": "vitest",
"knip": "knip",
"jscpd": "jscpd",
"check": "pnpm audit --audit-level=high && knip && biome check . && tsc --build && vitest run && jscpd"
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && tsc --build && vitest run && jscpd"
}
}

View File

@@ -0,0 +1,54 @@
import type { Encounter, PlayerCharacter } from "@initiative/domain";
import { isDomainError } from "@initiative/domain";
import type { EncounterStore, PlayerCharacterStore } from "../ports.js";
export function requireSaved<T>(value: T | null): T {
if (value === null) throw new Error("Expected store.saved to be non-null");
return value;
}
export function expectSuccess<T>(
result: T,
): asserts result is Exclude<T, { kind: "domain-error" }> {
if (isDomainError(result)) {
throw new Error(`Expected success, got domain error: ${result.message}`);
}
}
export function expectError(result: unknown): asserts result is {
kind: "domain-error";
code: string;
message: string;
} {
if (!isDomainError(result)) {
throw new Error("Expected domain error");
}
}
export function stubEncounterStore(
initial: Encounter,
): EncounterStore & { saved: Encounter | null } {
const stub = {
saved: null as Encounter | null,
get: () => initial,
save: (e: Encounter) => {
stub.saved = e;
stub.get = () => e;
},
};
return stub;
}
export function stubPlayerCharacterStore(
initial: readonly PlayerCharacter[],
): PlayerCharacterStore & { saved: readonly PlayerCharacter[] | null } {
const stub = {
saved: null as readonly PlayerCharacter[] | null,
getAll: () => [...initial],
save: (characters: PlayerCharacter[]) => {
stub.saved = characters;
stub.getAll = () => [...characters];
},
};
return stub;
}

View File

@@ -0,0 +1,195 @@
import {
type Creature,
combatantId,
createEncounter,
creatureId,
isDomainError,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { rollAllInitiativeUseCase } from "../roll-all-initiative-use-case.js";
import {
expectError,
expectSuccess,
requireSaved,
stubEncounterStore,
} from "./helpers.js";
const CREATURE_A = creatureId("creature-a");
const CREATURE_B = creatureId("creature-b");
function makeCreature(id: string, dex = 14): Creature {
return {
id: creatureId(id),
name: `Creature ${id}`,
source: "mm",
sourceDisplayName: "Monster Manual",
size: "Medium",
type: "humanoid",
alignment: "neutral",
ac: 12,
hp: { average: 10, formula: "2d8+2" },
speed: "30 ft.",
abilities: { str: 10, dex, con: 10, int: 10, wis: 10, cha: 10 },
cr: "1",
initiativeProficiency: 0,
proficiencyBonus: 2,
passive: 10,
};
}
function encounterWithCombatants(
combatants: Array<{
name: string;
creatureId?: string;
initiative?: number;
}>,
) {
const result = createEncounter(
combatants.map((c) => ({
id: combatantId(c.name),
name: c.name,
creatureId: c.creatureId ? creatureId(c.creatureId) : undefined,
initiative: c.initiative,
})),
);
if (isDomainError(result)) throw new Error("Setup failed");
return result;
}
describe("rollAllInitiativeUseCase", () => {
it("skips combatants without creatureId", () => {
const enc = encounterWithCombatants([
{ name: "Fighter" },
{ name: "Goblin", creatureId: "creature-a" },
]);
const store = stubEncounterStore(enc);
const creature = makeCreature("creature-a");
const result = rollAllInitiativeUseCase(
store,
() => 10,
(id) => (id === CREATURE_A ? creature : undefined),
);
expectSuccess(result);
expect(result.events.length).toBeGreaterThan(0);
const saved = requireSaved(store.saved);
const fighter = saved.combatants.find((c) => c.name === "Fighter");
const goblin = saved.combatants.find((c) => c.name === "Goblin");
expect(fighter?.initiative).toBeUndefined();
expect(goblin?.initiative).toBeDefined();
});
it("skips combatants that already have initiative", () => {
const enc = encounterWithCombatants([
{ name: "Goblin", creatureId: "creature-a", initiative: 15 },
]);
const store = stubEncounterStore(enc);
const result = rollAllInitiativeUseCase(
store,
() => 10,
() => makeCreature("creature-a"),
);
expectSuccess(result);
expect(result.events).toHaveLength(0);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(15);
});
it("counts skippedNoSource when creature lookup returns undefined", () => {
const enc = encounterWithCombatants([
{ name: "Unknown", creatureId: "missing" },
]);
const store = stubEncounterStore(enc);
const result = rollAllInitiativeUseCase(
store,
() => 10,
() => undefined,
);
expectSuccess(result);
expect(result.skippedNoSource).toBe(1);
expect(result.events).toHaveLength(0);
});
it("accumulates events from multiple setInitiative calls", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
{ name: "B", creatureId: "creature-b" },
]);
const store = stubEncounterStore(enc);
const creatureA = makeCreature("creature-a");
const creatureB = makeCreature("creature-b");
const result = rollAllInitiativeUseCase(
store,
() => 10,
(id) => {
if (id === CREATURE_A) return creatureA;
if (id === CREATURE_B) return creatureB;
return undefined;
},
);
expectSuccess(result);
expect(result.events).toHaveLength(2);
});
it("returns early with domain error on invalid dice roll", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
{ name: "B", creatureId: "creature-b" },
]);
const store = stubEncounterStore(enc);
// rollDice returns 0 (invalid — must be 120), triggers early return
const result = rollAllInitiativeUseCase(
store,
() => 0,
(id) => {
if (id === CREATURE_A) return makeCreature("creature-a");
if (id === CREATURE_B) return makeCreature("creature-b");
return undefined;
},
);
expectError(result);
expect(result.code).toBe("invalid-dice-roll");
// Store should NOT have been saved since the loop aborted
expect(store.saved).toBeNull();
});
it("saves encounter once at the end", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
{ name: "B", creatureId: "creature-b" },
]);
const store = stubEncounterStore(enc);
const creatureA = makeCreature("creature-a");
const creatureB = makeCreature("creature-b");
let saveCount = 0;
const originalSave = store.save.bind(store);
store.save = (e) => {
saveCount++;
originalSave(e);
};
rollAllInitiativeUseCase(
store,
() => 10,
(id) => {
if (id === CREATURE_A) return creatureA;
if (id === CREATURE_B) return creatureB;
return undefined;
},
);
expect(saveCount).toBe(1);
const saved = requireSaved(store.saved);
expect(saved.combatants[0].initiative).toBeDefined();
expect(saved.combatants[1].initiative).toBeDefined();
});
});

View File

@@ -0,0 +1,155 @@
import {
type Creature,
type CreatureId,
combatantId,
createEncounter,
creatureId,
isDomainError,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { addCombatantUseCase } from "../add-combatant-use-case.js";
import { rollInitiativeUseCase } from "../roll-initiative-use-case.js";
import { expectError, requireSaved, stubEncounterStore } from "./helpers.js";
const GOBLIN_ID = creatureId("goblin");
function makeCreature(overrides?: Partial<Creature>): Creature {
return {
id: GOBLIN_ID,
name: "Goblin",
source: "mm",
sourceDisplayName: "Monster Manual",
size: "Small",
type: "humanoid",
alignment: "neutral evil",
ac: 15,
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,
...overrides,
};
}
function encounterWithCreatureLink(name: string, creature: CreatureId) {
const enc = createEncounter([]);
if (isDomainError(enc)) throw new Error("Setup failed");
const id = combatantId(name);
const store = stubEncounterStore(enc);
addCombatantUseCase(store, id, name);
const saved = requireSaved(store.saved);
const result = createEncounter(
saved.combatants.map((c) =>
c.id === id ? { ...c, creatureId: creature } : c,
),
saved.activeIndex,
saved.roundNumber,
);
if (isDomainError(result)) throw new Error("Setup failed");
return result;
}
describe("rollInitiativeUseCase", () => {
it("returns domain error when combatant not found", () => {
const enc = createEncounter([]);
if (isDomainError(enc)) throw new Error("Setup failed");
const store = stubEncounterStore(enc);
const result = rollInitiativeUseCase(
store,
combatantId("unknown"),
10,
() => undefined,
);
expectError(result);
expect(result.code).toBe("combatant-not-found");
expect(store.saved).toBeNull();
});
it("returns domain error when combatant has no creature link", () => {
const enc = createEncounter([]);
if (isDomainError(enc)) throw new Error("Setup failed");
const store1 = stubEncounterStore(enc);
addCombatantUseCase(store1, combatantId("Fighter"), "Fighter");
const store = stubEncounterStore(requireSaved(store1.saved));
const result = rollInitiativeUseCase(
store,
combatantId("Fighter"),
10,
() => undefined,
);
expectError(result);
expect(result.code).toBe("no-creature-link");
expect(store.saved).toBeNull();
});
it("returns domain error when creature not found in getter", () => {
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
10,
() => undefined,
);
expectError(result);
expect(result.code).toBe("creature-not-found");
expect(store.saved).toBeNull();
});
it("calculates initiative from creature and saves", () => {
const creature = makeCreature();
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
// Dex 14 -> modifier +2, CR 1/4 -> PB 2, initiativeProficiency 0
// So initiative modifier = 2 + 0*2 = 2
// Roll 10 + modifier 2 = 12
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
10,
(id) => (id === GOBLIN_ID ? creature : undefined),
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(12);
});
it("applies initiative proficiency bonus correctly", () => {
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
const creature = makeCreature({
abilities: {
str: 10,
dex: 16,
con: 10,
int: 10,
wis: 10,
cha: 10,
},
cr: "5",
initiativeProficiency: 1,
});
const enc = encounterWithCreatureLink("Monster", GOBLIN_ID);
const store = stubEncounterStore(enc);
const result = rollInitiativeUseCase(
store,
combatantId("Monster"),
8,
(id) => (id === GOBLIN_ID ? creature : undefined),
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(14);
});
});

View File

@@ -0,0 +1,388 @@
import {
type ConditionId,
combatantId,
createEncounter,
isDomainError,
playerCharacterId,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { addCombatantUseCase } from "../add-combatant-use-case.js";
import { adjustHpUseCase } from "../adjust-hp-use-case.js";
import { advanceTurnUseCase } from "../advance-turn-use-case.js";
import { clearEncounterUseCase } from "../clear-encounter-use-case.js";
import { createPlayerCharacterUseCase } from "../create-player-character-use-case.js";
import { deletePlayerCharacterUseCase } from "../delete-player-character-use-case.js";
import { editCombatantUseCase } from "../edit-combatant-use-case.js";
import { editPlayerCharacterUseCase } from "../edit-player-character-use-case.js";
import { removeCombatantUseCase } from "../remove-combatant-use-case.js";
import { retreatTurnUseCase } from "../retreat-turn-use-case.js";
import { setAcUseCase } from "../set-ac-use-case.js";
import { setHpUseCase } from "../set-hp-use-case.js";
import { setInitiativeUseCase } from "../set-initiative-use-case.js";
import { toggleConcentrationUseCase } from "../toggle-concentration-use-case.js";
import { toggleConditionUseCase } from "../toggle-condition-use-case.js";
import {
requireSaved,
stubEncounterStore,
stubPlayerCharacterStore,
} from "./helpers.js";
const ID_A = combatantId("a");
function emptyEncounter() {
const result = createEncounter([]);
if (isDomainError(result)) throw new Error("Test setup failed");
return result;
}
function encounterWith(...names: string[]) {
let enc = emptyEncounter();
for (const name of names) {
const id = combatantId(name);
const store = stubEncounterStore(enc);
const result = addCombatantUseCase(store, id, name);
if (isDomainError(result)) throw new Error(`Setup failed: ${name}`);
enc = requireSaved(store.saved);
}
return enc;
}
function encounterWithHp(name: string, maxHp: number) {
const enc = encounterWith(name);
const store = stubEncounterStore(enc);
const id = combatantId(name);
setHpUseCase(store, id, maxHp);
return requireSaved(store.saved);
}
function createPc(name: string) {
const store = stubPlayerCharacterStore([]);
const id = playerCharacterId("pc-1");
createPlayerCharacterUseCase(store, id, name, 15, 40, undefined, undefined);
return { id, characters: requireSaved(store.saved) };
}
describe("addCombatantUseCase", () => {
it("adds a combatant and saves", () => {
const store = stubEncounterStore(emptyEncounter());
const result = addCombatantUseCase(store, ID_A, "Goblin");
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants).toHaveLength(1);
expect(saved.combatants[0].name).toBe("Goblin");
});
it("returns domain error for empty name", () => {
const store = stubEncounterStore(emptyEncounter());
const result = addCombatantUseCase(store, ID_A, "");
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("adjustHpUseCase", () => {
it("adjusts HP and saves", () => {
const enc = encounterWithHp("Goblin", 10);
const store = stubEncounterStore(enc);
const result = adjustHpUseCase(store, combatantId("Goblin"), -3);
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants[0].currentHp).toBe(7);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = adjustHpUseCase(store, ID_A, -5);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("advanceTurnUseCase", () => {
it("advances turn and saves", () => {
const enc = encounterWith("A", "B");
const store = stubEncounterStore(enc);
const result = advanceTurnUseCase(store);
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.activeIndex).toBe(1);
});
it("returns domain error on empty encounter", () => {
const store = stubEncounterStore(emptyEncounter());
const result = advanceTurnUseCase(store);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("clearEncounterUseCase", () => {
it("clears encounter and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = clearEncounterUseCase(store);
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants).toHaveLength(0);
});
});
describe("editCombatantUseCase", () => {
it("edits combatant name and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = editCombatantUseCase(
store,
combatantId("Goblin"),
"Hobgoblin",
);
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants[0].name).toBe("Hobgoblin");
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = editCombatantUseCase(store, ID_A, "X");
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("removeCombatantUseCase", () => {
it("removes combatant and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = removeCombatantUseCase(store, combatantId("Goblin"));
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants).toHaveLength(0);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = removeCombatantUseCase(store, ID_A);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("retreatTurnUseCase", () => {
it("retreats turn and saves", () => {
const enc = encounterWith("A", "B");
const store1 = stubEncounterStore(enc);
advanceTurnUseCase(store1);
const store = stubEncounterStore(requireSaved(store1.saved));
const result = retreatTurnUseCase(store);
expect(isDomainError(result)).toBe(false);
expect(store.saved).not.toBeNull();
});
it("returns domain error on empty encounter", () => {
const store = stubEncounterStore(emptyEncounter());
const result = retreatTurnUseCase(store);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("setAcUseCase", () => {
it("sets AC and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = setAcUseCase(store, combatantId("Goblin"), 15);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].ac).toBe(15);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = setAcUseCase(store, ID_A, 15);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("setHpUseCase", () => {
it("sets max HP and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = setHpUseCase(store, combatantId("Goblin"), 20);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].maxHp).toBe(20);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = setHpUseCase(store, ID_A, 20);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("setInitiativeUseCase", () => {
it("sets initiative and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = setInitiativeUseCase(store, combatantId("Goblin"), 15);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(15);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = setInitiativeUseCase(store, ID_A, 15);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("toggleConcentrationUseCase", () => {
it("toggles concentration and saves", () => {
const enc = encounterWith("Wizard");
const store = stubEncounterStore(enc);
const result = toggleConcentrationUseCase(store, combatantId("Wizard"));
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].isConcentrating).toBe(true);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = toggleConcentrationUseCase(store, ID_A);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("toggleConditionUseCase", () => {
it("toggles condition and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = toggleConditionUseCase(
store,
combatantId("Goblin"),
"blinded" as ConditionId,
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].conditions).toContain(
"blinded",
);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = toggleConditionUseCase(
store,
ID_A,
"blinded" as ConditionId,
);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("createPlayerCharacterUseCase", () => {
it("creates a player character and saves", () => {
const store = stubPlayerCharacterStore([]);
const id = playerCharacterId("pc-1");
const result = createPlayerCharacterUseCase(
store,
id,
"Gandalf",
15,
40,
undefined,
undefined,
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved)).toHaveLength(1);
});
it("returns domain error for invalid input", () => {
const store = stubPlayerCharacterStore([]);
const id = playerCharacterId("pc-1");
const result = createPlayerCharacterUseCase(
store,
id,
"",
15,
40,
undefined,
undefined,
);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("deletePlayerCharacterUseCase", () => {
it("deletes a player character and saves", () => {
const { id, characters } = createPc("Gandalf");
const store = stubPlayerCharacterStore(characters);
const result = deletePlayerCharacterUseCase(store, id);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved)).toHaveLength(0);
});
it("returns domain error for unknown character", () => {
const store = stubPlayerCharacterStore([]);
const result = deletePlayerCharacterUseCase(
store,
playerCharacterId("unknown"),
);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("editPlayerCharacterUseCase", () => {
it("edits a player character and saves", () => {
const { id, characters } = createPc("Gandalf");
const store = stubPlayerCharacterStore(characters);
const result = editPlayerCharacterUseCase(store, id, {
name: "Gandalf the White",
});
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved)[0].name).toBe("Gandalf the White");
});
it("returns domain error for unknown character", () => {
const store = stubPlayerCharacterStore([]);
const result = editPlayerCharacterUseCase(
store,
playerCharacterId("unknown"),
{ name: "X" },
);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { addCombatant } from "../add-combatant.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -112,20 +113,14 @@ describe("addCombatant", () => {
const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), "");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("scenario 6: whitespace-only name returns error", () => {
const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), " ");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
});
@@ -146,12 +141,10 @@ describe("addCombatant", () => {
for (const e of scenarios) {
const result = successResult(e, "new", "New");
const { combatants, activeIndex } = result.encounter;
if (combatants.length > 0) {
// After adding a combatant, list is always non-empty
expect(combatants.length).toBeGreaterThan(0);
expect(activeIndex).toBeGreaterThanOrEqual(0);
expect(activeIndex).toBeLessThan(combatants.length);
} else {
expect(activeIndex).toBe(0);
}
}
});
@@ -188,7 +181,7 @@ describe("addCombatant", () => {
it("INV-7: new combatant is always appended at the end", () => {
const e = enc([A, B]);
const { encounter } = successResult(e, "C", "C");
expect(encounter.combatants[encounter.combatants.length - 1]).toEqual({
expect(encounter.combatants.at(-1)).toEqual({
id: combatantId("C"),
name: "C",
});

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { adjustHp } from "../adjust-hp.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(
name: string,
@@ -101,37 +102,25 @@ describe("adjustHp", () => {
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("Z"), -1);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
it("returns error when combatant has no HP tracking", () => {
const e = enc([makeCombatant("A")]);
const result = adjustHp(e, combatantId("A"), -1);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("no-hp-tracking");
}
expectDomainError(result, "no-hp-tracking");
});
it("returns error for zero delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 0);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("zero-delta");
}
expectDomainError(result, "zero-delta");
});
it("returns error for non-integer delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 1.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-delta");
}
expectDomainError(result, "invalid-delta");
});
});

View File

@@ -7,6 +7,7 @@ import {
createEncounter,
type Encounter,
} from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -150,10 +151,7 @@ describe("advanceTurn", () => {
};
const result = advanceTurn(enc);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-encounter");
}
expectDomainError(result, "invalid-encounter");
});
it("scenario 8: three advances on [A,B,C] completes a full round cycle", () => {

View File

@@ -3,6 +3,7 @@ import { createPlayerCharacter } from "../create-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
const id = playerCharacterId("pc-1");
@@ -80,10 +81,7 @@ describe("createPlayerCharacter", () => {
it("rejects empty name", () => {
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("rejects whitespace-only name", () => {
@@ -96,10 +94,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("rejects negative AC", () => {
@@ -112,10 +107,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
expectDomainError(result, "invalid-ac");
});
it("rejects non-integer AC", () => {
@@ -128,10 +120,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
expectDomainError(result, "invalid-ac");
});
it("allows AC of 0", () => {
@@ -149,10 +138,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects negative maxHp", () => {
@@ -165,10 +151,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects non-integer maxHp", () => {
@@ -181,10 +164,7 @@ describe("createPlayerCharacter", () => {
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects invalid color", () => {
@@ -197,10 +177,7 @@ describe("createPlayerCharacter", () => {
"neon",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-color");
}
expectDomainError(result, "invalid-color");
});
it("rejects invalid icon", () => {
@@ -213,10 +190,7 @@ describe("createPlayerCharacter", () => {
"blue",
"banana",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-icon");
}
expectDomainError(result, "invalid-icon");
});
it("allows undefined color", () => {

View File

@@ -3,6 +3,7 @@ import { deletePlayerCharacter } from "../delete-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
const id1 = playerCharacterId("pc-1");
const id2 = playerCharacterId("pc-2");
@@ -28,10 +29,7 @@ describe("deletePlayerCharacter", () => {
it("returns error for not-found id", () => {
const result = deletePlayerCharacter([makePC()], id2);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("player-character-not-found");
}
expectDomainError(result, "player-character-not-found");
});
it("emits PlayerCharacterDeleted event", () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { editCombatant } from "../edit-combatant.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -124,40 +125,28 @@ describe("editCombatant", () => {
const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("nonexistent"), "NewName");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
it("empty name returns invalid-name error", () => {
const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("Alice"), "");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("whitespace-only name returns invalid-name error", () => {
const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("Alice"), " ");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("empty encounter returns combatant-not-found for any id", () => {
const e = enc([]);
const result = editCombatant(e, combatantId("any"), "Name");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
});
});

View File

@@ -3,6 +3,7 @@ import { editPlayerCharacter } from "../edit-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
const id = playerCharacterId("pc-1");
@@ -42,50 +43,32 @@ describe("editPlayerCharacter", () => {
playerCharacterId("pc-999"),
{ name: "Nope" },
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("player-character-not-found");
}
expectDomainError(result, "player-character-not-found");
});
it("rejects empty name", () => {
const result = editPlayerCharacter([makePC()], id, { name: "" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
expectDomainError(result, "invalid-name");
});
it("rejects invalid AC", () => {
const result = editPlayerCharacter([makePC()], id, { ac: -1 });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
expectDomainError(result, "invalid-ac");
});
it("rejects invalid maxHp", () => {
const result = editPlayerCharacter([makePC()], id, { maxHp: 0 });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects invalid color", () => {
const result = editPlayerCharacter([makePC()], id, { color: "neon" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-color");
}
expectDomainError(result, "invalid-color");
});
it("rejects invalid icon", () => {
const result = editPlayerCharacter([makePC()], id, { icon: "banana" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-icon");
}
expectDomainError(result, "invalid-icon");
});
it("returns error when no fields changed", () => {
@@ -94,10 +77,7 @@ describe("editPlayerCharacter", () => {
name: pc.name,
ac: pc.ac,
});
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("no-changes");
}
expectDomainError(result, "no-changes");
});
it("emits exactly one event on success", () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { removeCombatant } from "../remove-combatant.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -92,10 +93,7 @@ describe("removeCombatant", () => {
const e = enc([A, B], 0, 1);
const result = removeCombatant(e, combatantId("nonexistent"));
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
});

View File

@@ -8,6 +8,7 @@ import {
type Encounter,
isDomainError,
} from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -83,10 +84,7 @@ describe("retreatTurn", () => {
const enc = encounter([A, B, C], 0, 1);
const result = retreatTurn(enc);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("no-previous-turn");
}
expectDomainError(result, "no-previous-turn");
});
it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => {
@@ -117,10 +115,7 @@ describe("retreatTurn", () => {
};
const result = retreatTurn(enc);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-encounter");
}
expectDomainError(result, "invalid-encounter");
});
});

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { rollInitiative } from "../roll-initiative.js";
import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
describe("rollInitiative", () => {
describe("valid rolls", () => {
@@ -32,18 +33,12 @@ describe("rollInitiative", () => {
describe("invalid dice rolls", () => {
it("rejects 0", () => {
const result = rollInitiative(0, 5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
expectDomainError(result, "invalid-dice-roll");
});
it("rejects 21", () => {
const result = rollInitiative(21, 5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
expectDomainError(result, "invalid-dice-roll");
});
it("rejects non-integer (3.5)", () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { setAc } from "../set-ac.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(name: string, ac?: number): Combatant {
return ac === undefined
@@ -67,30 +68,21 @@ describe("setAc", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("nonexistent"), 10);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
it("returns error for negative AC", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), -1);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
expectDomainError(result, "invalid-ac");
});
it("returns error for non-integer AC", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
expectDomainError(result, "invalid-ac");
});
it("returns error for NaN", () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { setHp } from "../set-hp.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(
name: string,
@@ -10,9 +11,9 @@ function makeCombatant(
return {
id: combatantId(name),
name,
...(opts?.maxHp !== undefined
? { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }
: {}),
...(opts?.maxHp === undefined
? {}
: { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }),
};
}
@@ -116,37 +117,25 @@ describe("setHp", () => {
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("Z"), 10);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
it("rejects maxHp of 0", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 0);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects negative maxHp", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), -5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
it("rejects non-integer maxHp", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
expectDomainError(result, "invalid-max-hp");
});
});

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { setInitiative } from "../set-initiative.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers ---
@@ -73,10 +74,7 @@ describe("setInitiative", () => {
const e = enc([A, B], 0);
const result = setInitiative(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-initiative");
}
expectDomainError(result, "invalid-initiative");
});
it("AS-3b: reject NaN", () => {
@@ -109,10 +107,7 @@ describe("setInitiative", () => {
const e = enc([A, B], 0);
const result = setInitiative(e, combatantId("nonexistent"), 10);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
});

View File

@@ -0,0 +1,9 @@
import { expect } from "vitest";
import { type DomainError, isDomainError } from "../types.js";
export function expectDomainError(result: unknown, code: string): DomainError {
expect(isDomainError(result)).toBe(true);
if (!isDomainError(result)) throw new Error("unreachable");
expect(result.code).toBe(code);
return result;
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { toggleConcentration } from "../toggle-concentration.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(name: string, isConcentrating?: boolean): Combatant {
return isConcentrating
@@ -46,10 +47,7 @@ describe("toggleConcentration", () => {
const e = enc([makeCombatant("A")]);
const result = toggleConcentration(e, combatantId("missing"));
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
it("does not mutate input encounter", () => {

View File

@@ -4,6 +4,7 @@ import { CONDITION_DEFINITIONS } from "../conditions.js";
import { toggleCondition } from "../toggle-condition.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(
name: string,
@@ -77,20 +78,14 @@ describe("toggleCondition", () => {
"flying" as ConditionId,
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("unknown-condition");
}
expectDomainError(result, "unknown-condition");
});
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = toggleCondition(e, combatantId("missing"), "blinded");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
expectDomainError(result, "combatant-not-found");
});
it("does not mutate input encounter", () => {

View File

@@ -1,6 +1,5 @@
import type { DomainEvent } from "./events.js";
import type { DomainError, Encounter } from "./types.js";
import { isDomainError } from "./types.js";
interface AdvanceTurnSuccess {
readonly encounter: Encounter;
@@ -62,4 +61,4 @@ export function advanceTurn(
};
}
export { isDomainError };
export { isDomainError } from "./types.js";

View File

@@ -23,7 +23,10 @@ export function resolveCreatureName(
if (name === baseName) {
exactMatches.push(i);
} else {
const match = new RegExp(`^${escapeRegExp(baseName)} (\\d+)$`).exec(name);
const match = new RegExp(
String.raw`^${escapeRegExp(baseName)} (\d+)$`,
).exec(name);
// biome-ignore lint/nursery/noUnnecessaryConditions: RegExp.exec() returns null on no match — false positive
if (match) {
const num = Number.parseInt(match[1], 10);
if (num > maxNumber) maxNumber = num;
@@ -50,5 +53,5 @@ export function resolveCreatureName(
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
}

View File

@@ -23,7 +23,7 @@ interface EditFields {
}
function validateFields(fields: EditFields): DomainError | null {
if (fields.name !== undefined && fields.name.trim() === "") {
if (fields.name?.trim() === "") {
return {
kind: "domain-error",
code: "invalid-name",
@@ -81,17 +81,17 @@ function applyFields(
): PlayerCharacter {
return {
id: existing.id,
name: fields.name !== undefined ? fields.name.trim() : existing.name,
ac: fields.ac !== undefined ? fields.ac : existing.ac,
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
name: fields.name?.trim() ?? existing.name,
ac: fields.ac ?? existing.ac,
maxHp: fields.maxHp ?? existing.maxHp,
color:
fields.color !== undefined
? ((fields.color as PlayerCharacter["color"]) ?? undefined)
: existing.color,
fields.color === undefined
? existing.color
: ((fields.color as PlayerCharacter["color"]) ?? undefined),
icon:
fields.icon !== undefined
? ((fields.icon as PlayerCharacter["icon"]) ?? undefined)
: existing.icon,
fields.icon === undefined
? existing.icon
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
};
}

View File

@@ -21,15 +21,13 @@ export function setAc(
};
}
if (value !== undefined) {
if (!Number.isInteger(value) || value < 0) {
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
return {
kind: "domain-error",
code: "invalid-ac",
message: `AC must be a non-negative integer, got ${value}`,
};
}
}
const target = encounter.combatants[targetIdx];
const previousAc = target.ac;

View File

@@ -28,15 +28,13 @@ export function setHp(
};
}
if (maxHp !== undefined) {
if (!Number.isInteger(maxHp) || maxHp < 1) {
if (maxHp !== undefined && (!Number.isInteger(maxHp) || maxHp < 1)) {
return {
kind: "domain-error",
code: "invalid-max-hp",
message: `Max HP must be a positive integer, got ${maxHp}`,
};
}
}
const target = encounter.combatants[targetIdx];
const previousMaxHp = target.maxHp;

View File

@@ -65,7 +65,7 @@ export function setInitiative(
const aInit = a.c.initiative as number;
const bInit = b.c.initiative as number;
const diff = bInit - aInit;
return diff !== 0 ? diff : a.i - b.i;
return diff === 0 ? a.i - b.i : diff;
}
if (aHas && !bHas) return -1;
if (!aHas && bHas) return 1;

358
pnpm-lock.yaml generated
View File

@@ -12,8 +12,8 @@ importers:
.:
devDependencies:
'@biomejs/biome':
specifier: 2.0.0
version: 2.0.0
specifier: 2.4.7
version: 2.4.7
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1))
@@ -26,6 +26,12 @@ importers:
lefthook:
specifier: ^1.11.0
version: 1.13.6
oxlint:
specifier: ^1.55.0
version: 1.55.0(oxlint-tsgolint@0.16.0)
oxlint-tsgolint:
specifier: ^0.16.0
version: 0.16.0
typescript:
specifier: ^5.8.0
version: 5.9.3
@@ -72,6 +78,9 @@ importers:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/react':
specifier: ^19.0.0
version: 19.2.14
@@ -212,55 +221,55 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@biomejs/biome@2.0.0':
resolution: {integrity: sha512-BlUoXEOI/UQTDEj/pVfnkMo8SrZw3oOWBDrXYFT43V7HTkIUDkBRY53IC5Jx1QkZbaB+0ai1wJIfYwp9+qaJTQ==}
'@biomejs/biome@2.4.7':
resolution: {integrity: sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.0.0':
resolution: {integrity: sha512-QvqWYtFFhhxdf8jMAdJzXW+Frc7X8XsnHQLY+TBM1fnT1TfeV/v9vsFI5L2J7GH6qN1+QEEJ19jHibCY2Ypplw==}
'@biomejs/cli-darwin-arm64@2.4.7':
resolution: {integrity: sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.0.0':
resolution: {integrity: sha512-5JFhls1EfmuIH4QGFPlNpxJQFC6ic3X1ltcoLN+eSRRIPr6H/lUS1ttuD0Fj7rPgPhZqopK/jfH8UVj/1hIsQw==}
'@biomejs/cli-darwin-x64@2.4.7':
resolution: {integrity: sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.0.0':
resolution: {integrity: sha512-Bxsz8ki8+b3PytMnS5SgrGV+mbAWwIxI3ydChb/d1rURlJTMdxTTq5LTebUnlsUWAX6OvJuFeiVq9Gjn1YbCyA==}
'@biomejs/cli-linux-arm64-musl@2.4.7':
resolution: {integrity: sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-arm64@2.0.0':
resolution: {integrity: sha512-BAH4QVi06TzAbVchXdJPsL0Z/P87jOfes15rI+p3EX9/EGTfIjaQ9lBVlHunxcmoptaA5y1Hdb9UYojIhmnjIw==}
'@biomejs/cli-linux-arm64@2.4.7':
resolution: {integrity: sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-x64-musl@2.0.0':
resolution: {integrity: sha512-tiQ0ABxMJb9I6GlfNp0ulrTiQSFacJRJO8245FFwE3ty3bfsfxlU/miblzDIi+qNrgGsLq5wIZcVYGp4c+HXZA==}
'@biomejs/cli-linux-x64-musl@2.4.7':
resolution: {integrity: sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-linux-x64@2.0.0':
resolution: {integrity: sha512-09PcOGYTtkopWRm6mZ/B6Mr6UHdkniUgIG/jLBv+2J8Z61ezRE+xQmpi3yNgUrFIAU4lPA9atg7mhvE/5Bo7Wg==}
'@biomejs/cli-linux-x64@2.4.7':
resolution: {integrity: sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-win32-arm64@2.0.0':
resolution: {integrity: sha512-vrTtuGu91xNTEQ5ZcMJBZuDlqr32DWU1r14UfePIGndF//s2WUAmer4FmgoPgruo76rprk37e8S2A2c0psXdxw==}
'@biomejs/cli-win32-arm64@2.4.7':
resolution: {integrity: sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.0.0':
resolution: {integrity: sha512-2USVQ0hklNsph/KIR72ZdeptyXNnQ3JdzPn3NbjI4Sna34CnxeiYAaZcZzXPDl5PYNFBivV4xmvT3Z3rTmyDBg==}
'@biomejs/cli-win32-x64@2.4.7':
resolution: {integrity: sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
@@ -632,6 +641,150 @@ packages:
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.16.0':
resolution: {integrity: sha512-WQt5lGwRPJBw7q2KNR0mSPDAaMmZmVvDlEEti96xLO7ONhyomQc6fBZxxwZ4qTFedjJnrHX94sFelZ4OKzS7UQ==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.16.0':
resolution: {integrity: sha512-VJo29XOzdkalvCTiE2v6FU3qZlgHaM8x8hUEVJGPU2i5W+FlocPpmn00+Ld2n7Q0pqIjyD5EyvZ5UmoIEJMfqg==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.16.0':
resolution: {integrity: sha512-MPfqRt1+XRHv9oHomcBMQ3KpTE+CSkZz14wUxDQoqTNdUlV0HWdzwIE9q65I3D9YyxEnqpM7j4qtDQ3apqVvbQ==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.16.0':
resolution: {integrity: sha512-XQSwVUsnwLokMhe1TD6IjgvW5WMTPzOGGkdFDtXWQmlN2YeTw94s/NN0KgDrn2agM1WIgAenEkvnm0u7NgwEyw==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.16.0':
resolution: {integrity: sha512-EWdlspQiiFGsP2AiCYdhg5dTYyAlj6y1nRyNI2dQWq4Q/LITFHiSRVPe+7m7K7lcsZCEz2icN/bCeSkZaORqIg==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.16.0':
resolution: {integrity: sha512-1ufk8cgktXJuJZHKF63zCHAkaLMwZrEXnZ89H2y6NO85PtOXqu4zbdNl0VBpPP3fCUuUBu9RvNqMFiv0VsbXWA==}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.55.0':
resolution: {integrity: sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.55.0':
resolution: {integrity: sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.55.0':
resolution: {integrity: sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.55.0':
resolution: {integrity: sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.55.0':
resolution: {integrity: sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.55.0':
resolution: {integrity: sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.55.0':
resolution: {integrity: sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.55.0':
resolution: {integrity: sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@oxlint/binding-linux-arm64-musl@1.55.0':
resolution: {integrity: sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@oxlint/binding-linux-ppc64-gnu@1.55.0':
resolution: {integrity: sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
'@oxlint/binding-linux-riscv64-gnu@1.55.0':
resolution: {integrity: sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
'@oxlint/binding-linux-riscv64-musl@1.55.0':
resolution: {integrity: sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
'@oxlint/binding-linux-s390x-gnu@1.55.0':
resolution: {integrity: sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
'@oxlint/binding-linux-x64-gnu@1.55.0':
resolution: {integrity: sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@oxlint/binding-linux-x64-musl@1.55.0':
resolution: {integrity: sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@oxlint/binding-openharmony-arm64@1.55.0':
resolution: {integrity: sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.55.0':
resolution: {integrity: sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.55.0':
resolution: {integrity: sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.55.0':
resolution: {integrity: sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -877,6 +1030,12 @@ packages:
'@types/react-dom':
optional: true
'@testing-library/user-event@14.6.1':
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -1712,6 +1871,20 @@ packages:
oxc-resolver@11.19.1:
resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==}
oxlint-tsgolint@0.16.0:
resolution: {integrity: sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==}
hasBin: true
oxlint@1.55.0:
resolution: {integrity: sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.15.0'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -2308,39 +2481,39 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@biomejs/biome@2.0.0':
'@biomejs/biome@2.4.7':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.0.0
'@biomejs/cli-darwin-x64': 2.0.0
'@biomejs/cli-linux-arm64': 2.0.0
'@biomejs/cli-linux-arm64-musl': 2.0.0
'@biomejs/cli-linux-x64': 2.0.0
'@biomejs/cli-linux-x64-musl': 2.0.0
'@biomejs/cli-win32-arm64': 2.0.0
'@biomejs/cli-win32-x64': 2.0.0
'@biomejs/cli-darwin-arm64': 2.4.7
'@biomejs/cli-darwin-x64': 2.4.7
'@biomejs/cli-linux-arm64': 2.4.7
'@biomejs/cli-linux-arm64-musl': 2.4.7
'@biomejs/cli-linux-x64': 2.4.7
'@biomejs/cli-linux-x64-musl': 2.4.7
'@biomejs/cli-win32-arm64': 2.4.7
'@biomejs/cli-win32-x64': 2.4.7
'@biomejs/cli-darwin-arm64@2.0.0':
'@biomejs/cli-darwin-arm64@2.4.7':
optional: true
'@biomejs/cli-darwin-x64@2.0.0':
'@biomejs/cli-darwin-x64@2.4.7':
optional: true
'@biomejs/cli-linux-arm64-musl@2.0.0':
'@biomejs/cli-linux-arm64-musl@2.4.7':
optional: true
'@biomejs/cli-linux-arm64@2.0.0':
'@biomejs/cli-linux-arm64@2.4.7':
optional: true
'@biomejs/cli-linux-x64-musl@2.0.0':
'@biomejs/cli-linux-x64-musl@2.4.7':
optional: true
'@biomejs/cli-linux-x64@2.0.0':
'@biomejs/cli-linux-x64@2.4.7':
optional: true
'@biomejs/cli-win32-arm64@2.0.0':
'@biomejs/cli-win32-arm64@2.4.7':
optional: true
'@biomejs/cli-win32-x64@2.0.0':
'@biomejs/cli-win32-x64@2.4.7':
optional: true
'@bramus/specificity@2.4.2':
@@ -2614,6 +2787,81 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.16.0':
optional: true
'@oxlint-tsgolint/darwin-x64@0.16.0':
optional: true
'@oxlint-tsgolint/linux-arm64@0.16.0':
optional: true
'@oxlint-tsgolint/linux-x64@0.16.0':
optional: true
'@oxlint-tsgolint/win32-arm64@0.16.0':
optional: true
'@oxlint-tsgolint/win32-x64@0.16.0':
optional: true
'@oxlint/binding-android-arm-eabi@1.55.0':
optional: true
'@oxlint/binding-android-arm64@1.55.0':
optional: true
'@oxlint/binding-darwin-arm64@1.55.0':
optional: true
'@oxlint/binding-darwin-x64@1.55.0':
optional: true
'@oxlint/binding-freebsd-x64@1.55.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.55.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.55.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.55.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.55.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.55.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.55.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.55.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.55.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.55.0':
optional: true
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -2792,6 +3040,10 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies:
'@testing-library/dom': 10.4.1
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -3668,6 +3920,38 @@ snapshots:
'@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
'@oxc-resolver/binding-win32-x64-msvc': 11.19.1
oxlint-tsgolint@0.16.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.16.0
'@oxlint-tsgolint/darwin-x64': 0.16.0
'@oxlint-tsgolint/linux-arm64': 0.16.0
'@oxlint-tsgolint/linux-x64': 0.16.0
'@oxlint-tsgolint/win32-arm64': 0.16.0
'@oxlint-tsgolint/win32-x64': 0.16.0
oxlint@1.55.0(oxlint-tsgolint@0.16.0):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.55.0
'@oxlint/binding-android-arm64': 1.55.0
'@oxlint/binding-darwin-arm64': 1.55.0
'@oxlint/binding-darwin-x64': 1.55.0
'@oxlint/binding-freebsd-x64': 1.55.0
'@oxlint/binding-linux-arm-gnueabihf': 1.55.0
'@oxlint/binding-linux-arm-musleabihf': 1.55.0
'@oxlint/binding-linux-arm64-gnu': 1.55.0
'@oxlint/binding-linux-arm64-musl': 1.55.0
'@oxlint/binding-linux-ppc64-gnu': 1.55.0
'@oxlint/binding-linux-riscv64-gnu': 1.55.0
'@oxlint/binding-linux-riscv64-musl': 1.55.0
'@oxlint/binding-linux-s390x-gnu': 1.55.0
'@oxlint/binding-linux-x64-gnu': 1.55.0
'@oxlint/binding-linux-x64-musl': 1.55.0
'@oxlint/binding-openharmony-arm64': 1.55.0
'@oxlint/binding-win32-arm64-msvc': 1.55.0
'@oxlint/binding-win32-ia32-msvc': 1.55.0
'@oxlint/binding-win32-x64-msvc': 1.55.0
oxlint-tsgolint: 0.16.0
package-json-from-dist@1.0.1: {}
parse5@8.0.0:

View File

@@ -7,19 +7,36 @@ export default defineConfig({
coverage: {
provider: "v8",
enabled: true,
exclude: ["**/dist/**"],
thresholds: {
autoUpdate: true,
"packages/domain/src": {
lines: 96,
branches: 96,
lines: 99,
branches: 97,
},
"packages/application/src": {
lines: 97,
branches: 94,
},
"apps/web/src/adapters": {
lines: 71,
lines: 72,
branches: 78,
},
"apps/web/src/persistence": {
lines: 87,
branches: 67,
lines: 90,
branches: 71,
},
"apps/web/src/hooks": {
lines: 59,
branches: 85,
},
"apps/web/src/components": {
lines: 52,
branches: 64,
},
"apps/web/src/components/ui": {
lines: 73,
branches: 96,
},
},
},