Compare commits

...

12 Commits

Author SHA1 Message Date
Lukas c295840b7b Update CLAUDE.md for jsinspect, TS compiler props, parallel lefthook
CI / check (push) Successful in 1m39s
CI / build-image (push) Has been skipped
Adds jsinspect to check description and tech stack, removes incorrect
routing mention, notes prop checker uses TS compiler API, updates
quality gates to reflect parallel lefthook jobs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:29:48 +02:00
Lukas d13641152f Update README with setup guide, contributing workflow, and bestiary docs
Fixes packages/app → packages/application path, expands scripts table,
documents the parallel merge gate, adds contributing workflow with
spec-driven process and Claude Code skills, and documents bestiary
index regeneration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:48:11 +02:00
Lukas 110f4726ae Add tests for Dialog and Tooltip, raise components/ui threshold to 93%
Dialog: open/close lifecycle, cancel event handling, DialogHeader.
Tooltip: show on pointer enter, hide on pointer leave. Raises
components/ui coverage threshold to enforce testing of future
primitives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:22:17 +02:00
Lukas 2bc22369ce Add tests for ConditionTags and CreatePlayerModal
ConditionTags: rendering, remove callback, add picker callback.
CreatePlayerModal: create/edit modes, form validation (name, AC, HP,
level), error display and clearing, onSave/onClose callbacks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 12:36:07 +02:00
Lukas 2971d32f45 Add action-bar tests for overflow menu, dialogs, and custom stats
Tests browse mode toggle, export/import dialog opening, overflow menu
callbacks (manage players, settings), and custom stat field submission.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 03:26:57 +02:00
Lukas a97044ec3e Add tests for useActionBarState hook
Tests search/suggestion filtering, queued creature counting, form
submission with custom stats, browse mode, and dismiss/clear behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:16:54 +01:00
Lukas a77db0eeee Add quick-win tests for components and hooks
Adds tests for DifficultyIndicator, Toast, RollModeMenu, OverflowMenu,
useTheme, and useRulesEdition. Covers rendering, user interactions,
auto-dismiss timers, external store sync, and localStorage persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:32:15 +01:00
Lukas d8c8a0c44d Add direct reducer tests for encounterReducer
Exports encounterReducer and EncounterState for testing. Adds 26
pure-function tests covering all action types: CRUD, turn navigation,
HP/AC/conditions, undo/redo, bestiary add with auto-numbering,
player character add, import, and event accumulation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:50:45 +01:00
Lukas 80dd68752e Refactor useEncounter from useState to useReducer
Replaces 18 useCallback wrappers with a typed action union and
encounterReducer. Undo/redo wrapping is now systematic per-case in
the reducer instead of ad-hoc per operation. Complex cases (undo/redo,
bestiary add, player character add) are extracted into helper functions.

The stat block auto-show on bestiary add now uses lastCreatureId from
reducer state instead of the synchronous return value, with a useEffect
in use-action-bar-state to react to changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:41:40 +01:00
Lukas 896fd427ed Add tests for undo/redo/setTempHp use cases, fix coverage thresholds
Adds missing tests for undoUseCase, redoUseCase, and setTempHpUseCase,
bringing application layer coverage from ~81% to 97%. Removes
autoUpdate from coverage thresholds and sets floors to actual values
so they enforce a real minimum.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:19:15 +01:00
Lukas 01b1bba6d6 Replace regex prop counter with TypeScript compiler API
Uses ts.createProgram to parse real AST instead of regex + brace-depth
state machine. Immune to comments, strings, and complex type syntax.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:11:34 +01:00
Lukas b7a97c3d88 Parallelize pre-commit checks via lefthook jobs
Independent checks (audit, knip, biome, jscpd, jsinspect, custom
scripts) now run in parallel. Type-dependent checks (oxlint, vitest)
remain sequential after tsc --build via a piped group. Also reorder
pnpm check for fast-fail on cheap checks first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:07:38 +01:00
24 changed files with 2282 additions and 381 deletions
+5 -5
View File
@@ -5,7 +5,7 @@ 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 + oxlint + typecheck + test/coverage + jscpd)
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd + jsinspect)
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
pnpm knip # Unused code detection (Knip)
pnpm test # Run all tests (Vitest)
@@ -30,7 +30,7 @@ apps/web (React 19 + Vite) → packages/application (use cases) → packages
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
- **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
- **Web** — React adapter. Implements ports using hooks/state. All UI components, routing, and user interaction live here.
- **Web** — React adapter. Implements ports using hooks/state. All UI components and user interaction live here.
Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which runs as a Vitest test. Domain and application must never import from React, Vite, or upper layers.
@@ -60,7 +60,7 @@ 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.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection)
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection), jsinspect-plus (structural duplication)
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
## Conventions
@@ -72,9 +72,9 @@ docs/agents/ RPI skill artifacts (research reports, plans)
- **Domain events** are plain data objects with a `type` discriminant — no classes.
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs` using the TypeScript compiler API). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
- **Export format compatibility** — When changing `Encounter`, `Combatant`, `PlayerCharacter`, or `UndoRedoState` types, verify that previously exported JSON files (version 1) still import correctly. If not, bump the `ExportBundle` version and add migration logic in `validateImportBundle()`.
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
## Self-Review Checklist
+72 -6
View File
@@ -1,4 +1,4 @@
# Encounter Console
# Initiative
A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e / 2024). Runs entirely in the browser — no server, no account, no data leaves your machine.
@@ -34,16 +34,42 @@ Open `http://localhost:5173`.
| `pnpm --filter web dev` | Start the dev server |
| `pnpm --filter web build` | Production build |
| `pnpm test` | Run all tests (Vitest) |
| `pnpm check` | Full merge gate (knip, biome, typecheck, test, jscpd) |
| `pnpm test:watch` | Tests in watch mode |
| `pnpm vitest run path/to/test.ts` | Run a single test file |
| `pnpm typecheck` | TypeScript type checking |
| `pnpm lint` | Biome lint |
| `pnpm format` | Biome format (writes changes) |
| `pnpm check` | Full merge gate (see below) |
### Merge gate (`pnpm check`)
All of these run at pre-commit via Lefthook (in parallel where possible):
- `pnpm audit` — security audit
- `knip` — unused code detection
- `biome check` — formatting + linting
- `oxlint` — type-aware linting (complements Biome)
- Custom scripts — lint-ignore caps, className enforcement, component prop limits
- `tsc --build` — TypeScript strict mode
- `vitest run` — tests with per-path coverage thresholds
- `jscpd` + `jsinspect` — copy-paste and structural duplication detection
## Tech Stack
- TypeScript 5.8 (strict mode), React 19, Vite 6
- Tailwind CSS v4 (dark/light theme)
- Biome 2.4 (formatting + linting), oxlint (type-aware linting)
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
- Knip (unused code), jscpd + jsinspect (duplication detection)
## Project Structure
```
apps/web/ React 19 + Vite — UI components, hooks, adapters
packages/domain/ Pure functions — state transitions, types, validation
packages/app/ Use cases — orchestrates domain via port interfaces
data/bestiary/ Bestiary index for creature search
scripts/ Build tooling (layer boundary checks, index generation)
packages/application/ Use cases — orchestrates domain via port interfaces
data/bestiary/ Pre-built bestiary search index (~10k creatures)
scripts/ Build tooling (layer checks, index generation)
specs/ Feature specifications (spec → plan → tasks)
```
@@ -55,5 +81,45 @@ Strict layered architecture with enforced dependency direction:
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
```
Domain is pure no I/O, no randomness, no framework imports. Layer boundaries are enforced by automated import checks that run as part of the test suite. See [CLAUDE.md](./CLAUDE.md) for full conventions.
- **Domain** — pure functions, no I/O, no randomness, no framework imports. Errors returned as values (`DomainError`), never thrown.
- **Application** — orchestrates domain calls via port interfaces (`EncounterStore`, `PlayerCharacterStore`, etc.). No business logic.
- **Web** — React adapter. Implements ports using hooks/state. All UI components, persistence, and external data access live here.
Layer boundaries are enforced by automated import checks that run as part of the test suite.
## Contributing
### Workflow
Development is spec-driven. Feature specs live in `specs/NNN-feature-name/` and are managed through Claude Code skills (see [CLAUDE.md](./CLAUDE.md) for full details).
| Scope | What to do |
|-------|-----------|
| Bug fix / CSS tweak | Fix it, run `pnpm check`, commit. Optionally use `/browser-interactive-testing` for visual verification. |
| Change to existing feature | Update the feature spec, then implement |
| Larger change to existing feature | Update the spec → `/rpi-research``/rpi-plan``/rpi-implement` |
| New feature | `/speckit.specify``/speckit.clarify``/speckit.plan``/speckit.tasks``/speckit.implement` |
Use `/write-issue` to create well-structured Gitea issues, and `/integrate-issue` to pull an existing issue's requirements into the relevant feature spec.
### Before committing
Run `pnpm check` — Lefthook runs this automatically at pre-commit, but running it manually first saves time. All checks must pass.
### Conventions
- **Biome** for formatting and linting — tab indentation, 80-char lines
- **TypeScript strict mode** with `verbatimModuleSyntax` (type-only imports must use `import type`)
- **Max 8 props** per component interface — use React context for shared state
- **Tests** in `__tests__/` directories — test pure functions directly, use `renderHook` for hooks
See [CLAUDE.md](./CLAUDE.md) for the full conventions and project constitution.
## Bestiary Index
The bestiary search index (`data/bestiary/index.json`) is pre-built and checked into the repo. To regenerate it (e.g., after a new source book release):
1. Clone [5etools-mirror-3/5etools-src](https://github.com/5etools-mirror-3/5etools-src) locally
2. Run `node scripts/generate-bestiary-index.mjs /path/to/5etools-src`
The script extracts creature names, stats, and source info into a compact search index.
@@ -50,6 +50,16 @@ beforeAll(() => {
dispatchEvent: vi.fn(),
})),
});
HTMLDialogElement.prototype.showModal =
HTMLDialogElement.prototype.showModal ||
function showModal(this: HTMLDialogElement) {
this.setAttribute("open", "");
};
HTMLDialogElement.prototype.close =
HTMLDialogElement.prototype.close ||
function close(this: HTMLDialogElement) {
this.removeAttribute("open");
};
});
afterEach(cleanup);
@@ -118,4 +128,61 @@ describe("ActionBar", () => {
screen.getByRole("button", { name: "More actions" }),
).toBeInTheDocument();
});
it("opens export method dialog via overflow menu", async () => {
const user = userEvent.setup();
renderBar();
await user.click(screen.getByRole("button", { name: "More actions" }));
// Click the menu item
const items = screen.getAllByText("Export Encounter");
await user.click(items[0]);
// Dialog should now be open — it renders a second "Export Encounter" as heading
expect(
screen.getAllByText("Export Encounter").length,
).toBeGreaterThanOrEqual(1);
});
it("opens import method dialog via overflow menu", async () => {
const user = userEvent.setup();
renderBar();
await user.click(screen.getByRole("button", { name: "More actions" }));
const items = screen.getAllByText("Import Encounter");
await user.click(items[0]);
expect(
screen.getAllByText("Import Encounter").length,
).toBeGreaterThanOrEqual(1);
});
it("calls onManagePlayers from overflow menu", async () => {
const onManagePlayers = vi.fn();
const user = userEvent.setup();
renderBar({ onManagePlayers });
await user.click(screen.getByRole("button", { name: "More actions" }));
await user.click(screen.getByText("Player Characters"));
expect(onManagePlayers).toHaveBeenCalledOnce();
});
it("calls onOpenSettings from overflow menu", async () => {
const onOpenSettings = vi.fn();
const user = userEvent.setup();
renderBar({ onOpenSettings });
await user.click(screen.getByRole("button", { name: "More actions" }));
await user.click(screen.getByText("Settings"));
expect(onOpenSettings).toHaveBeenCalledOnce();
});
it("submits custom stats with combatant", async () => {
const user = userEvent.setup();
renderBar();
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "Fighter");
const initInput = screen.getByPlaceholderText("Init");
const acInput = screen.getByPlaceholderText("AC");
const hpInput = screen.getByPlaceholderText("MaxHP");
await user.type(initInput, "15");
await user.type(acInput, "18");
await user.type(hpInput, "45");
await user.click(screen.getByRole("button", { name: "Add" }));
expect(input).toHaveValue("");
});
});
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import 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 { ConditionTags } from "../condition-tags.js";
vi.mock("../../contexts/rules-edition-context.js", () => ({
useRulesEditionContext: () => ({ edition: "5.5e" }),
}));
afterEach(cleanup);
describe("ConditionTags", () => {
it("renders nothing when conditions is undefined", () => {
const { container } = render(
<ConditionTags
conditions={undefined}
onRemove={() => {}}
onOpenPicker={() => {}}
/>,
);
// Only the add button should be present
expect(container.querySelectorAll("button")).toHaveLength(1);
});
it("renders a button per condition", () => {
const conditions: ConditionId[] = ["blinded", "prone"];
render(
<ConditionTags
conditions={conditions}
onRemove={() => {}}
onOpenPicker={() => {}}
/>,
);
expect(
screen.getByRole("button", { name: "Remove Blinded" }),
).toBeDefined();
expect(screen.getByRole("button", { name: "Remove Prone" })).toBeDefined();
});
it("calls onRemove with condition id when clicked", async () => {
const onRemove = vi.fn();
render(
<ConditionTags
conditions={["blinded"] as ConditionId[]}
onRemove={onRemove}
onOpenPicker={() => {}}
/>,
);
await userEvent.click(
screen.getByRole("button", { name: "Remove Blinded" }),
);
expect(onRemove).toHaveBeenCalledWith("blinded");
});
it("calls onOpenPicker when add button is clicked", async () => {
const onOpenPicker = vi.fn();
render(
<ConditionTags
conditions={[]}
onRemove={() => {}}
onOpenPicker={onOpenPicker}
/>,
);
await userEvent.click(
screen.getByRole("button", { name: "Add condition" }),
);
expect(onOpenPicker).toHaveBeenCalledOnce();
});
it("renders empty conditions array without errors", () => {
render(
<ConditionTags
conditions={[]}
onRemove={() => {}}
onOpenPicker={() => {}}
/>,
);
// Only add button
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
});
});
@@ -0,0 +1,173 @@
// @vitest-environment jsdom
import type { PlayerCharacter } from "@initiative/domain";
import { playerCharacterId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { CreatePlayerModal } from "../create-player-modal.js";
beforeAll(() => {
HTMLDialogElement.prototype.showModal =
HTMLDialogElement.prototype.showModal ||
function showModal(this: HTMLDialogElement) {
this.setAttribute("open", "");
};
HTMLDialogElement.prototype.close =
HTMLDialogElement.prototype.close ||
function close(this: HTMLDialogElement) {
this.removeAttribute("open");
};
});
afterEach(cleanup);
function renderModal(
overrides: Partial<Parameters<typeof CreatePlayerModal>[0]> = {},
) {
const defaults = {
open: true,
onClose: vi.fn(),
onSave: vi.fn(),
};
const props = { ...defaults, ...overrides };
return { ...render(<CreatePlayerModal {...props} />), ...props };
}
describe("CreatePlayerModal", () => {
it("renders create form with defaults", () => {
renderModal();
expect(screen.getByText("Create Player")).toBeDefined();
expect(screen.getByLabelText("Name")).toBeDefined();
expect(screen.getByLabelText("AC")).toBeDefined();
expect(screen.getByLabelText("Max HP")).toBeDefined();
expect(screen.getByLabelText("Level")).toBeDefined();
expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
});
it("renders edit form when playerCharacter is provided", () => {
const pc: PlayerCharacter = {
id: playerCharacterId("pc-1"),
name: "Gandalf",
ac: 15,
maxHp: 40,
color: "blue",
icon: "wand",
level: 10,
};
renderModal({ playerCharacter: pc });
expect(screen.getByText("Edit Player")).toBeDefined();
expect(screen.getByLabelText("Name")).toHaveProperty("value", "Gandalf");
expect(screen.getByLabelText("AC")).toHaveProperty("value", "15");
expect(screen.getByLabelText("Max HP")).toHaveProperty("value", "40");
expect(screen.getByLabelText("Level")).toHaveProperty("value", "10");
expect(screen.getByRole("button", { name: "Save" })).toBeDefined();
});
it("calls onSave with valid data", async () => {
const { onSave, onClose } = renderModal();
const user = userEvent.setup();
await user.type(screen.getByLabelText("Name"), "Aria");
await user.clear(screen.getByLabelText("AC"));
await user.type(screen.getByLabelText("AC"), "16");
await user.clear(screen.getByLabelText("Max HP"));
await user.type(screen.getByLabelText("Max HP"), "30");
await user.type(screen.getByLabelText("Level"), "5");
await user.click(screen.getByRole("button", { name: "Create" }));
expect(onSave).toHaveBeenCalledWith(
"Aria",
16,
30,
undefined,
undefined,
5,
);
expect(onClose).toHaveBeenCalled();
});
it("shows error for empty name", async () => {
const { onSave } = renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Create" }));
expect(screen.getByText("Name is required")).toBeDefined();
expect(onSave).not.toHaveBeenCalled();
});
it("shows error for invalid AC", async () => {
const { onSave } = renderModal();
const user = userEvent.setup();
await user.type(screen.getByLabelText("Name"), "Test");
await user.clear(screen.getByLabelText("AC"));
await user.type(screen.getByLabelText("AC"), "abc");
await user.click(screen.getByRole("button", { name: "Create" }));
expect(screen.getByText("AC must be a non-negative number")).toBeDefined();
expect(onSave).not.toHaveBeenCalled();
});
it("shows error for invalid Max HP", async () => {
const { onSave } = renderModal();
const user = userEvent.setup();
await user.type(screen.getByLabelText("Name"), "Test");
await user.clear(screen.getByLabelText("Max HP"));
await user.type(screen.getByLabelText("Max HP"), "0");
await user.click(screen.getByRole("button", { name: "Create" }));
expect(screen.getByText("Max HP must be at least 1")).toBeDefined();
expect(onSave).not.toHaveBeenCalled();
});
it("shows error for invalid level", async () => {
const { onSave } = renderModal();
const user = userEvent.setup();
await user.type(screen.getByLabelText("Name"), "Test");
await user.type(screen.getByLabelText("Level"), "25");
await user.click(screen.getByRole("button", { name: "Create" }));
expect(screen.getByText("Level must be between 1 and 20")).toBeDefined();
expect(onSave).not.toHaveBeenCalled();
});
it("clears error when name is edited", async () => {
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Create" }));
expect(screen.getByText("Name is required")).toBeDefined();
await user.type(screen.getByLabelText("Name"), "A");
expect(screen.queryByText("Name is required")).toBeNull();
});
it("calls onClose when cancel is clicked", async () => {
const { onClose } = renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onClose).toHaveBeenCalledOnce();
});
it("omits level when field is empty", async () => {
const { onSave } = renderModal();
const user = userEvent.setup();
await user.type(screen.getByLabelText("Name"), "Aria");
await user.click(screen.getByRole("button", { name: "Create" }));
expect(onSave).toHaveBeenCalledWith(
"Aria",
10,
10,
undefined,
undefined,
undefined,
);
});
});
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { Dialog, DialogHeader } from "../ui/dialog.js";
beforeAll(() => {
HTMLDialogElement.prototype.showModal =
HTMLDialogElement.prototype.showModal ||
function showModal(this: HTMLDialogElement) {
this.setAttribute("open", "");
};
HTMLDialogElement.prototype.close =
HTMLDialogElement.prototype.close ||
function close(this: HTMLDialogElement) {
this.removeAttribute("open");
};
});
afterEach(cleanup);
describe("Dialog", () => {
it("opens when open=true", () => {
render(
<Dialog open={true} onClose={() => {}}>
Content
</Dialog>,
);
expect(screen.getByText("Content")).toBeDefined();
});
it("closes when open changes from true to false", () => {
const { rerender } = render(
<Dialog open={true} onClose={() => {}}>
Content
</Dialog>,
);
const dialog = document.querySelector("dialog");
expect(dialog?.hasAttribute("open")).toBe(true);
rerender(
<Dialog open={false} onClose={() => {}}>
Content
</Dialog>,
);
expect(dialog?.hasAttribute("open")).toBe(false);
});
it("calls onClose on cancel event", () => {
const onClose = vi.fn();
render(
<Dialog open={true} onClose={onClose}>
Content
</Dialog>,
);
const dialog = document.querySelector("dialog");
dialog?.dispatchEvent(new Event("cancel"));
expect(onClose).toHaveBeenCalledOnce();
});
});
describe("DialogHeader", () => {
it("renders title and close button", async () => {
const onClose = vi.fn();
render(<DialogHeader title="Test Title" onClose={onClose} />);
expect(screen.getByText("Test Title")).toBeDefined();
await userEvent.click(screen.getByRole("button"));
expect(onClose).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import type { DifficultyResult } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { DifficultyIndicator } from "../difficulty-indicator.js";
afterEach(cleanup);
function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
return {
tier,
totalMonsterXp: 100,
partyBudget: { low: 50, moderate: 100, high: 200 },
};
}
describe("DifficultyIndicator", () => {
it("renders 3 bars", () => {
const { container } = render(
<DifficultyIndicator result={makeResult("moderate")} />,
);
const bars = container.querySelectorAll("[class*='rounded-sm']");
expect(bars).toHaveLength(3);
});
it("shows 'Trivial encounter difficulty' label for trivial tier", () => {
render(<DifficultyIndicator result={makeResult("trivial")} />);
expect(
screen.getByRole("img", {
name: "Trivial encounter difficulty",
}),
).toBeDefined();
});
it("shows 'Low encounter difficulty' label for low tier", () => {
render(<DifficultyIndicator result={makeResult("low")} />);
expect(
screen.getByRole("img", { name: "Low encounter difficulty" }),
).toBeDefined();
});
it("shows 'Moderate encounter difficulty' label for moderate tier", () => {
render(<DifficultyIndicator result={makeResult("moderate")} />);
expect(
screen.getByRole("img", {
name: "Moderate encounter difficulty",
}),
).toBeDefined();
});
it("shows 'High encounter difficulty' label for high tier", () => {
render(<DifficultyIndicator result={makeResult("high")} />);
expect(
screen.getByRole("img", {
name: "High encounter difficulty",
}),
).toBeDefined();
});
});
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Circle } from "lucide-react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OverflowMenu } from "../ui/overflow-menu.js";
afterEach(cleanup);
const items = [
{ icon: <Circle />, label: "Action A", onClick: vi.fn() },
{ icon: <Circle />, label: "Action B", onClick: vi.fn() },
];
describe("OverflowMenu", () => {
it("renders toggle button", () => {
render(<OverflowMenu items={items} />);
expect(screen.getByRole("button", { name: "More actions" })).toBeDefined();
});
it("does not show menu items when closed", () => {
render(<OverflowMenu items={items} />);
expect(screen.queryByText("Action A")).toBeNull();
});
it("shows menu items when toggled open", async () => {
render(<OverflowMenu items={items} />);
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
expect(screen.getByText("Action A")).toBeDefined();
expect(screen.getByText("Action B")).toBeDefined();
});
it("closes menu after clicking an item", async () => {
const onClick = vi.fn();
render(
<OverflowMenu items={[{ icon: <Circle />, label: "Do it", onClick }]} />,
);
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
await userEvent.click(screen.getByText("Do it"));
expect(onClick).toHaveBeenCalledOnce();
expect(screen.queryByText("Do it")).toBeNull();
});
it("keeps menu open when keepOpen is true", async () => {
const onClick = vi.fn();
render(
<OverflowMenu
items={[
{
icon: <Circle />,
label: "Stay",
onClick,
keepOpen: true,
},
]}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
await userEvent.click(screen.getByText("Stay"));
expect(onClick).toHaveBeenCalledOnce();
expect(screen.getByText("Stay")).toBeDefined();
});
it("disables items when disabled is true", async () => {
const onClick = vi.fn();
render(
<OverflowMenu
items={[
{
icon: <Circle />,
label: "Nope",
onClick,
disabled: true,
},
]}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
const item = screen.getByText("Nope");
expect(item.closest("button")?.hasAttribute("disabled")).toBe(true);
});
});
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RollModeMenu } from "../roll-mode-menu.js";
afterEach(cleanup);
describe("RollModeMenu", () => {
it("renders advantage and disadvantage buttons", () => {
render(
<RollModeMenu
position={{ x: 100, y: 100 }}
onSelect={() => {}}
onClose={() => {}}
/>,
);
expect(screen.getByText("Advantage")).toBeDefined();
expect(screen.getByText("Disadvantage")).toBeDefined();
});
it("calls onSelect with 'advantage' and onClose when clicked", async () => {
const onSelect = vi.fn();
const onClose = vi.fn();
render(
<RollModeMenu
position={{ x: 100, y: 100 }}
onSelect={onSelect}
onClose={onClose}
/>,
);
await userEvent.click(screen.getByText("Advantage"));
expect(onSelect).toHaveBeenCalledWith("advantage");
expect(onClose).toHaveBeenCalled();
});
it("calls onSelect with 'disadvantage' and onClose when clicked", async () => {
const onSelect = vi.fn();
const onClose = vi.fn();
render(
<RollModeMenu
position={{ x: 100, y: 100 }}
onSelect={onSelect}
onClose={onClose}
/>,
);
await userEvent.click(screen.getByText("Disadvantage"));
expect(onSelect).toHaveBeenCalledWith("disadvantage");
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Toast } from "../toast.js";
afterEach(cleanup);
describe("Toast", () => {
it("renders message text", () => {
render(<Toast message="Hello" onDismiss={() => {}} />);
expect(screen.getByText("Hello")).toBeDefined();
});
it("renders progress bar when progress is provided", () => {
render(<Toast message="Loading" progress={0.5} onDismiss={() => {}} />);
const bar = document.body.querySelector("[style*='width']") as HTMLElement;
expect(bar).not.toBeNull();
expect(bar.style.width).toBe("50%");
});
it("does not render progress bar when progress is omitted", () => {
render(<Toast message="Done" onDismiss={() => {}} />);
const bar = document.body.querySelector("[style*='width']");
expect(bar).toBeNull();
});
it("calls onDismiss when close button is clicked", async () => {
const onDismiss = vi.fn();
render(<Toast message="Hi" onDismiss={onDismiss} />);
const toast = screen.getByText("Hi").closest("div");
const button = toast?.querySelector("button");
expect(button).not.toBeNull();
await userEvent.click(button as HTMLElement);
expect(onDismiss).toHaveBeenCalledOnce();
});
describe("auto-dismiss", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-dismisses after specified timeout", () => {
const onDismiss = vi.fn();
render(
<Toast message="Auto" onDismiss={onDismiss} autoDismissMs={3000} />,
);
expect(onDismiss).not.toHaveBeenCalled();
vi.advanceTimersByTime(3000);
expect(onDismiss).toHaveBeenCalledOnce();
});
it("does not auto-dismiss when autoDismissMs is omitted", () => {
const onDismiss = vi.fn();
render(<Toast message="Stay" onDismiss={onDismiss} />);
vi.advanceTimersByTime(10000);
expect(onDismiss).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { Tooltip } from "../ui/tooltip.js";
afterEach(cleanup);
describe("Tooltip", () => {
it("renders children", () => {
render(
<Tooltip content="Hint">
<button type="button">Hover me</button>
</Tooltip>,
);
expect(screen.getByText("Hover me")).toBeDefined();
});
it("does not show tooltip initially", () => {
render(
<Tooltip content="Hint">
<span>Target</span>
</Tooltip>,
);
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("shows tooltip on pointer enter and hides on pointer leave", () => {
render(
<Tooltip content="Hint text">
<span>Target</span>
</Tooltip>,
);
const wrapper = screen.getByText("Target").closest("span");
fireEvent.pointerEnter(wrapper as HTMLElement);
expect(screen.getByRole("tooltip")).toBeDefined();
expect(screen.getByText("Hint text")).toBeDefined();
fireEvent.pointerLeave(wrapper as HTMLElement);
expect(screen.queryByRole("tooltip")).toBeNull();
});
});
@@ -72,6 +72,7 @@ function mockContext(overrides: Partial<Encounter> = {}) {
setEncounter: vi.fn(),
setUndoRedoState: vi.fn(),
events: [],
lastCreatureId: null,
};
mockUseEncounterContext.mockReturnValue(
@@ -0,0 +1,416 @@
import type {
BestiaryIndexEntry,
ConditionId,
PlayerCharacter,
} from "@initiative/domain";
import {
combatantId,
createEncounter,
EMPTY_UNDO_REDO_STATE,
isDomainError,
playerCharacterId,
} from "@initiative/domain";
import { describe, expect, it, vi } from "vitest";
import { type EncounterState, encounterReducer } from "../use-encounter.js";
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: vi.fn().mockReturnValue(null),
saveEncounter: vi.fn(),
}));
vi.mock("../../persistence/undo-redo-storage.js", () => ({
loadUndoRedoStacks: vi.fn().mockReturnValue(EMPTY_UNDO_REDO_STATE),
saveUndoRedoStacks: vi.fn(),
}));
function emptyState(): EncounterState {
return {
encounter: {
combatants: [],
activeIndex: 0,
roundNumber: 1,
},
undoRedoState: EMPTY_UNDO_REDO_STATE,
events: [],
nextId: 0,
lastCreatureId: null,
};
}
function stateWith(...names: string[]): EncounterState {
let state = emptyState();
for (const name of names) {
state = encounterReducer(state, { type: "add-combatant", name });
}
return state;
}
function stateWithHp(name: string, maxHp: number): EncounterState {
const state = stateWith(name);
const id = state.encounter.combatants[0].id;
return encounterReducer(state, {
type: "set-hp",
id,
maxHp,
});
}
const BESTIARY_ENTRY: BestiaryIndexEntry = {
name: "Goblin",
source: "MM",
ac: 15,
hp: 7,
dex: 14,
cr: "1/4",
initiativeProficiency: 0,
size: "Small",
type: "humanoid",
};
describe("encounterReducer", () => {
describe("add-combatant", () => {
it("adds a combatant and pushes undo", () => {
const next = encounterReducer(emptyState(), {
type: "add-combatant",
name: "Goblin",
});
expect(next.encounter.combatants).toHaveLength(1);
expect(next.encounter.combatants[0].name).toBe("Goblin");
expect(next.undoRedoState.undoStack).toHaveLength(1);
expect(next.nextId).toBe(1);
});
it("applies optional init values", () => {
const next = encounterReducer(emptyState(), {
type: "add-combatant",
name: "Goblin",
init: { initiative: 15, ac: 13, maxHp: 7 },
});
const c = next.encounter.combatants[0];
expect(c.initiative).toBe(15);
expect(c.ac).toBe(13);
expect(c.maxHp).toBe(7);
expect(c.currentHp).toBe(7);
});
it("increments IDs", () => {
const s1 = encounterReducer(emptyState(), {
type: "add-combatant",
name: "A",
});
const s2 = encounterReducer(s1, {
type: "add-combatant",
name: "B",
});
expect(s2.encounter.combatants[0].id).toBe("c-1");
expect(s2.encounter.combatants[1].id).toBe("c-2");
});
it("returns unchanged state for invalid name", () => {
const state = emptyState();
const next = encounterReducer(state, {
type: "add-combatant",
name: "",
});
expect(next).toBe(state);
});
});
describe("remove-combatant", () => {
it("removes combatant and pushes undo", () => {
const state = stateWith("Goblin");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "remove-combatant",
id,
});
expect(next.encounter.combatants).toHaveLength(0);
expect(next.undoRedoState.undoStack).toHaveLength(2);
});
});
describe("edit-combatant", () => {
it("renames combatant", () => {
const state = stateWith("Goblin");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "edit-combatant",
id,
newName: "Hobgoblin",
});
expect(next.encounter.combatants[0].name).toBe("Hobgoblin");
});
});
describe("advance-turn / retreat-turn", () => {
it("advances and retreats turn", () => {
const state = stateWith("A", "B");
const advanced = encounterReducer(state, {
type: "advance-turn",
});
expect(advanced.encounter.activeIndex).toBe(1);
const retreated = encounterReducer(advanced, {
type: "retreat-turn",
});
expect(retreated.encounter.activeIndex).toBe(0);
});
it("returns unchanged state on empty encounter", () => {
const state = emptyState();
const next = encounterReducer(state, { type: "advance-turn" });
expect(next).toBe(state);
});
});
describe("set-hp / adjust-hp / set-temp-hp", () => {
it("sets max HP", () => {
const state = stateWith("Goblin");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "set-hp",
id,
maxHp: 20,
});
expect(next.encounter.combatants[0].maxHp).toBe(20);
expect(next.encounter.combatants[0].currentHp).toBe(20);
});
it("adjusts HP", () => {
const state = stateWithHp("Goblin", 20);
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "adjust-hp",
id,
delta: -5,
});
expect(next.encounter.combatants[0].currentHp).toBe(15);
});
it("sets temp HP", () => {
const state = stateWithHp("Goblin", 20);
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "set-temp-hp",
id,
tempHp: 5,
});
expect(next.encounter.combatants[0].tempHp).toBe(5);
});
});
describe("set-ac", () => {
it("sets AC", () => {
const state = stateWith("Goblin");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "set-ac",
id,
value: 15,
});
expect(next.encounter.combatants[0].ac).toBe(15);
});
});
describe("set-initiative", () => {
it("sets initiative", () => {
const state = stateWith("Goblin");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "set-initiative",
id,
value: 18,
});
expect(next.encounter.combatants[0].initiative).toBe(18);
});
});
describe("toggle-condition / toggle-concentration", () => {
it("toggles condition", () => {
const state = stateWith("Goblin");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "toggle-condition",
id,
conditionId: "blinded" as ConditionId,
});
expect(next.encounter.combatants[0].conditions).toContain("blinded");
});
it("toggles concentration", () => {
const state = stateWith("Wizard");
const id = state.encounter.combatants[0].id;
const next = encounterReducer(state, {
type: "toggle-concentration",
id,
});
expect(next.encounter.combatants[0].isConcentrating).toBe(true);
});
});
describe("clear-encounter", () => {
it("clears combatants, resets history and nextId", () => {
const state = stateWith("A", "B");
const next = encounterReducer(state, {
type: "clear-encounter",
});
expect(next.encounter.combatants).toHaveLength(0);
expect(next.undoRedoState.undoStack).toHaveLength(0);
expect(next.undoRedoState.redoStack).toHaveLength(0);
expect(next.nextId).toBe(0);
});
});
describe("undo / redo", () => {
it("undo restores previous state", () => {
const state = stateWith("Goblin");
const next = encounterReducer(state, { type: "undo" });
expect(next.encounter.combatants).toHaveLength(0);
expect(next.undoRedoState.undoStack).toHaveLength(0);
expect(next.undoRedoState.redoStack).toHaveLength(1);
});
it("redo restores undone state", () => {
const state = stateWith("Goblin");
const undone = encounterReducer(state, { type: "undo" });
const redone = encounterReducer(undone, { type: "redo" });
expect(redone.encounter.combatants).toHaveLength(1);
expect(redone.encounter.combatants[0].name).toBe("Goblin");
});
it("undo returns unchanged state when stack is empty", () => {
const state = emptyState();
const next = encounterReducer(state, { type: "undo" });
expect(next).toBe(state);
});
it("redo returns unchanged state when stack is empty", () => {
const state = emptyState();
const next = encounterReducer(state, { type: "redo" });
expect(next).toBe(state);
});
});
describe("add-from-bestiary", () => {
it("adds creature with HP, AC, and creatureId", () => {
const next = encounterReducer(emptyState(), {
type: "add-from-bestiary",
entry: BESTIARY_ENTRY,
});
const c = next.encounter.combatants[0];
expect(c.name).toBe("Goblin");
expect(c.maxHp).toBe(7);
expect(c.ac).toBe(15);
expect(c.creatureId).toBe("mm:goblin");
expect(next.lastCreatureId).toBe("mm:goblin");
expect(next.undoRedoState.undoStack).toHaveLength(1);
});
it("auto-numbers duplicate names", () => {
const s1 = encounterReducer(emptyState(), {
type: "add-from-bestiary",
entry: BESTIARY_ENTRY,
});
const s2 = encounterReducer(s1, {
type: "add-from-bestiary",
entry: BESTIARY_ENTRY,
});
const names = s2.encounter.combatants.map((c) => c.name);
expect(names).toContain("Goblin 1");
expect(names).toContain("Goblin 2");
});
});
describe("add-multiple-from-bestiary", () => {
it("adds multiple creatures in one action", () => {
const next = encounterReducer(emptyState(), {
type: "add-multiple-from-bestiary",
entry: BESTIARY_ENTRY,
count: 3,
});
expect(next.encounter.combatants).toHaveLength(3);
expect(next.undoRedoState.undoStack).toHaveLength(1);
expect(next.lastCreatureId).toBe("mm:goblin");
});
});
describe("add-from-player-character", () => {
it("adds combatant with PC attributes", () => {
const pc: PlayerCharacter = {
id: playerCharacterId("pc-1"),
name: "Aria",
ac: 16,
maxHp: 30,
color: "blue",
icon: "sword",
};
const next = encounterReducer(emptyState(), {
type: "add-from-player-character",
pc,
});
const c = next.encounter.combatants[0];
expect(c.name).toBe("Aria");
expect(c.maxHp).toBe(30);
expect(c.ac).toBe(16);
expect(c.color).toBe("blue");
expect(c.icon).toBe("sword");
expect(c.playerCharacterId).toBe("pc-1");
expect(next.lastCreatureId).toBeNull();
});
});
describe("import", () => {
it("replaces encounter and undo/redo state", () => {
const state = stateWith("A", "B");
const enc = createEncounter([
{ id: combatantId("c-5"), name: "Imported" },
]);
if (isDomainError(enc)) throw new Error("Setup failed");
const next = encounterReducer(state, {
type: "import",
encounter: enc,
undoRedoState: EMPTY_UNDO_REDO_STATE,
});
expect(next.encounter.combatants).toHaveLength(1);
expect(next.encounter.combatants[0].name).toBe("Imported");
expect(next.nextId).toBe(5);
});
});
describe("events accumulation", () => {
it("accumulates events across actions", () => {
const s1 = encounterReducer(emptyState(), {
type: "add-combatant",
name: "A",
});
const s2 = encounterReducer(s1, {
type: "add-combatant",
name: "B",
});
expect(s2.events.length).toBeGreaterThanOrEqual(2);
});
});
});
@@ -0,0 +1,328 @@
// @vitest-environment jsdom
import type { PlayerCharacter } from "@initiative/domain";
import { playerCharacterId } from "@initiative/domain";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SearchResult } from "../../contexts/bestiary-context.js";
import { useActionBarState } from "../use-action-bar-state.js";
const mockAddCombatant = vi.fn();
const mockAddFromBestiary = vi.fn();
const mockAddMultipleFromBestiary = vi.fn();
const mockAddFromPlayerCharacter = vi.fn();
const mockBestiarySearch = vi.fn<(q: string) => SearchResult[]>();
const mockShowCreature = vi.fn();
const mockShowBulkImport = vi.fn();
const mockShowSourceManager = vi.fn();
vi.mock("../../contexts/encounter-context.js", () => ({
useEncounterContext: () => ({
addCombatant: mockAddCombatant,
addFromBestiary: mockAddFromBestiary,
addMultipleFromBestiary: mockAddMultipleFromBestiary,
addFromPlayerCharacter: mockAddFromPlayerCharacter,
lastCreatureId: null,
}),
}));
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: () => ({
search: mockBestiarySearch,
isLoaded: true,
}),
}));
vi.mock("../../contexts/player-characters-context.js", () => ({
usePlayerCharactersContext: () => ({
characters: mockPlayerCharacters,
}),
}));
vi.mock("../../contexts/side-panel-context.js", () => ({
useSidePanelContext: () => ({
showCreature: mockShowCreature,
showBulkImport: mockShowBulkImport,
showSourceManager: mockShowSourceManager,
panelView: { mode: "closed" },
}),
}));
let mockPlayerCharacters: PlayerCharacter[] = [];
const GOBLIN: SearchResult = {
name: "Goblin",
source: "MM",
sourceDisplayName: "Monster Manual",
ac: 15,
hp: 7,
dex: 14,
cr: "1/4",
initiativeProficiency: 0,
size: "Small",
type: "humanoid",
};
const ORC: SearchResult = {
name: "Orc",
source: "MM",
sourceDisplayName: "Monster Manual",
ac: 13,
hp: 15,
dex: 12,
cr: "1/2",
initiativeProficiency: 0,
size: "Medium",
type: "humanoid",
};
function renderActionBar() {
return renderHook(() => useActionBarState());
}
describe("useActionBarState", () => {
beforeEach(() => {
vi.clearAllMocks();
mockBestiarySearch.mockReturnValue([]);
mockPlayerCharacters = [];
});
describe("search and suggestions", () => {
it("starts with empty state", () => {
const { result } = renderActionBar();
expect(result.current.nameInput).toBe("");
expect(result.current.suggestions).toEqual([]);
expect(result.current.queued).toBeNull();
expect(result.current.browseMode).toBe(false);
});
it("searches bestiary when input >= 2 chars", () => {
mockBestiarySearch.mockReturnValue([GOBLIN]);
const { result } = renderActionBar();
act(() => result.current.handleNameChange("go"));
expect(mockBestiarySearch).toHaveBeenCalledWith("go");
expect(result.current.nameInput).toBe("go");
});
it("does not search when input < 2 chars", () => {
const { result } = renderActionBar();
act(() => result.current.handleNameChange("g"));
expect(mockBestiarySearch).not.toHaveBeenCalled();
});
it("matches player characters by name", () => {
mockPlayerCharacters = [
{
id: playerCharacterId("pc-1"),
name: "Gandalf",
ac: 15,
maxHp: 40,
},
];
mockBestiarySearch.mockReturnValue([]);
const { result } = renderActionBar();
act(() => result.current.handleNameChange("gan"));
expect(result.current.pcMatches).toHaveLength(1);
});
});
describe("queued creatures", () => {
it("queues a creature on click", () => {
const { result } = renderActionBar();
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
expect(result.current.queued).toEqual({
result: GOBLIN,
count: 1,
});
});
it("increments count when same creature clicked again", () => {
const { result } = renderActionBar();
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
expect(result.current.queued?.count).toBe(2);
});
it("resets queue when different creature clicked", () => {
const { result } = renderActionBar();
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.clickSuggestion(ORC));
expect(result.current.queued).toEqual({
result: ORC,
count: 1,
});
});
it("confirmQueued calls addFromBestiary for count=1", () => {
const { result } = renderActionBar();
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.confirmQueued());
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
expect(result.current.queued).toBeNull();
});
it("confirmQueued calls addMultipleFromBestiary for count>1", () => {
const { result } = renderActionBar();
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.confirmQueued());
expect(mockAddMultipleFromBestiary).toHaveBeenCalledWith(GOBLIN, 3);
});
it("clears queued when search text changes and creature no longer visible", () => {
mockBestiarySearch.mockReturnValue([GOBLIN]);
const { result } = renderActionBar();
act(() => result.current.handleNameChange("go"));
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
// Change search to something that won't match
mockBestiarySearch.mockReturnValue([]);
act(() => result.current.handleNameChange("xyz"));
expect(result.current.queued).toBeNull();
});
});
describe("form submission", () => {
it("adds custom combatant on submit", () => {
const { result } = renderActionBar();
act(() => result.current.handleNameChange("Fighter"));
const event = {
preventDefault: vi.fn(),
} as unknown as React.SubmitEvent<HTMLFormElement>;
act(() => result.current.handleAdd(event));
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", undefined);
expect(result.current.nameInput).toBe("");
});
it("does not add when name is empty", () => {
const { result } = renderActionBar();
const event = {
preventDefault: vi.fn(),
} as unknown as React.SubmitEvent<HTMLFormElement>;
act(() => result.current.handleAdd(event));
expect(mockAddCombatant).not.toHaveBeenCalled();
});
it("passes custom init/ac/maxHp when set", () => {
const { result } = renderActionBar();
act(() => result.current.handleNameChange("Fighter"));
act(() => result.current.setCustomInit("15"));
act(() => result.current.setCustomAc("18"));
act(() => result.current.setCustomMaxHp("45"));
const event = {
preventDefault: vi.fn(),
} as unknown as React.SubmitEvent<HTMLFormElement>;
act(() => result.current.handleAdd(event));
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", {
initiative: 15,
ac: 18,
maxHp: 45,
});
});
it("does not submit in browse mode", () => {
const { result } = renderActionBar();
act(() => result.current.toggleBrowseMode());
act(() => result.current.handleNameChange("Fighter"));
const event = {
preventDefault: vi.fn(),
} as unknown as React.SubmitEvent<HTMLFormElement>;
act(() => result.current.handleAdd(event));
expect(mockAddCombatant).not.toHaveBeenCalled();
});
it("confirms queued on submit instead of adding by name", () => {
const { result } = renderActionBar();
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
const event = {
preventDefault: vi.fn(),
} as unknown as React.SubmitEvent<HTMLFormElement>;
act(() => result.current.handleAdd(event));
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
expect(mockAddCombatant).not.toHaveBeenCalled();
});
});
describe("browse mode", () => {
it("toggles browse mode", () => {
const { result } = renderActionBar();
act(() => result.current.toggleBrowseMode());
expect(result.current.browseMode).toBe(true);
act(() => result.current.toggleBrowseMode());
expect(result.current.browseMode).toBe(false);
});
it("handleBrowseSelect shows creature and exits browse mode", () => {
const { result } = renderActionBar();
act(() => result.current.toggleBrowseMode());
act(() => result.current.handleBrowseSelect(GOBLIN));
expect(mockShowCreature).toHaveBeenCalledWith("mm:goblin");
expect(result.current.browseMode).toBe(false);
expect(result.current.nameInput).toBe("");
});
});
describe("dismiss and clear", () => {
it("dismissSuggestions clears suggestions and queued", () => {
mockBestiarySearch.mockReturnValue([GOBLIN]);
const { result } = renderActionBar();
act(() => result.current.handleNameChange("go"));
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.dismiss());
expect(result.current.queued).toBeNull();
expect(result.current.suggestionIndex).toBe(-1);
});
it("clear resets everything", () => {
mockBestiarySearch.mockReturnValue([GOBLIN]);
const { result } = renderActionBar();
act(() => result.current.handleNameChange("go"));
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
act(() => result.current.suggestionActions.clear());
expect(result.current.nameInput).toBe("");
expect(result.current.queued).toBeNull();
expect(result.current.suggestionIndex).toBe(-1);
});
});
});
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { useRulesEdition } from "../use-rules-edition.js";
const STORAGE_KEY = "initiative:rules-edition";
describe("useRulesEdition", () => {
afterEach(() => {
// Reset to default
const { result } = renderHook(() => useRulesEdition());
act(() => result.current.setEdition("5.5e"));
localStorage.removeItem(STORAGE_KEY);
});
it("defaults to 5.5e", () => {
const { result } = renderHook(() => useRulesEdition());
expect(result.current.edition).toBe("5.5e");
});
it("setEdition updates value", () => {
const { result } = renderHook(() => useRulesEdition());
act(() => result.current.setEdition("5e"));
expect(result.current.edition).toBe("5e");
});
it("setEdition persists to localStorage", () => {
const { result } = renderHook(() => useRulesEdition());
act(() => result.current.setEdition("5e"));
expect(localStorage.getItem(STORAGE_KEY)).toBe("5e");
});
it("multiple hooks stay in sync", () => {
const { result: r1 } = renderHook(() => useRulesEdition());
const { result: r2 } = renderHook(() => useRulesEdition());
act(() => r1.current.setEdition("5e"));
expect(r2.current.edition).toBe("5e");
});
});
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { useTheme } from "../use-theme.js";
const STORAGE_KEY = "initiative:theme";
describe("useTheme", () => {
afterEach(() => {
// Reset to default
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("system"));
localStorage.removeItem(STORAGE_KEY);
});
it("defaults to system preference", () => {
const { result } = renderHook(() => useTheme());
expect(result.current.preference).toBe("system");
});
it("setPreference updates to light", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("light"));
expect(result.current.preference).toBe("light");
expect(result.current.resolved).toBe("light");
});
it("setPreference updates to dark", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("dark"));
expect(result.current.preference).toBe("dark");
expect(result.current.resolved).toBe("dark");
});
it("persists preference to localStorage", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("light"));
expect(localStorage.getItem(STORAGE_KEY)).toBe("light");
});
it("applies theme to document element", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setPreference("light"));
expect(document.documentElement.dataset.theme).toBe("light");
});
it("multiple hooks stay in sync", () => {
const { result: r1 } = renderHook(() => useTheme());
const { result: r2 } = renderHook(() => useTheme());
act(() => r1.current.setPreference("dark"));
expect(r2.current.preference).toBe("dark");
});
});
+27 -20
View File
@@ -1,5 +1,12 @@
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
import { useCallback, useDeferredValue, useMemo, useState } from "react";
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { SearchResult } from "../contexts/bestiary-context.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
@@ -31,6 +38,7 @@ export function useActionBarState() {
addFromBestiary,
addMultipleFromBestiary,
addFromPlayerCharacter,
lastCreatureId,
} = useEncounterContext();
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
useBestiaryContext();
@@ -38,6 +46,20 @@ export function useActionBarState() {
const { showBulkImport, showSourceManager, showCreature, panelView } =
useSidePanelContext();
// Auto-show stat block when a bestiary creature is added on desktop
const prevCreatureIdRef = useRef(lastCreatureId);
useEffect(() => {
if (
lastCreatureId &&
lastCreatureId !== prevCreatureIdRef.current &&
panelView.mode === "closed" &&
globalThis.matchMedia("(min-width: 1024px)").matches
) {
showCreature(lastCreatureId);
}
prevCreatureIdRef.current = lastCreatureId;
}, [lastCreatureId, panelView.mode, showCreature]);
const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
@@ -73,13 +95,9 @@ export function useActionBarState() {
const handleAddFromBestiary = useCallback(
(result: SearchResult) => {
const creatureId = addFromBestiary(result);
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
if (creatureId && panelView.mode === "closed" && isDesktop) {
showCreature(creatureId);
}
addFromBestiary(result);
},
[addFromBestiary, panelView.mode, showCreature],
[addFromBestiary],
);
const handleViewStatBlock = useCallback(
@@ -99,21 +117,10 @@ export function useActionBarState() {
if (queued.count === 1) {
handleAddFromBestiary(queued.result);
} else {
const creatureId = addMultipleFromBestiary(queued.result, queued.count);
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
if (creatureId && panelView.mode === "closed" && isDesktop) {
showCreature(creatureId);
}
addMultipleFromBestiary(queued.result, queued.count);
}
clearInput();
}, [
queued,
handleAddFromBestiary,
addMultipleFromBestiary,
panelView.mode,
showCreature,
clearInput,
]);
}, [queued, handleAddFromBestiary, addMultipleFromBestiary, clearInput]);
const parseNum = (v: string): number | undefined => {
if (v.trim() === "") return undefined;
+432 -272
View File
@@ -36,7 +36,7 @@ import {
pushUndo,
resolveCreatureName,
} from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useReducer, useRef } from "react";
import {
loadEncounter,
saveEncounter,
@@ -46,6 +46,51 @@ import {
saveUndoRedoStacks,
} from "../persistence/undo-redo-storage.js";
// -- Types --
type EncounterAction =
| { type: "advance-turn" }
| { type: "retreat-turn" }
| { type: "add-combatant"; name: string; init?: CombatantInit }
| { type: "remove-combatant"; id: CombatantId }
| { type: "edit-combatant"; id: CombatantId; newName: string }
| { type: "set-initiative"; id: CombatantId; value: number | undefined }
| { type: "set-hp"; id: CombatantId; maxHp: number | undefined }
| { type: "adjust-hp"; id: CombatantId; delta: number }
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
| { type: "set-ac"; id: CombatantId; value: number | undefined }
| {
type: "toggle-condition";
id: CombatantId;
conditionId: ConditionId;
}
| { type: "toggle-concentration"; id: CombatantId }
| { type: "clear-encounter" }
| { type: "undo" }
| { type: "redo" }
| { type: "add-from-bestiary"; entry: BestiaryIndexEntry }
| {
type: "add-multiple-from-bestiary";
entry: BestiaryIndexEntry;
count: number;
}
| { type: "add-from-player-character"; pc: PlayerCharacter }
| {
type: "import";
encounter: Encounter;
undoRedoState: UndoRedoState;
};
export interface EncounterState {
readonly encounter: Encounter;
readonly undoRedoState: UndoRedoState;
readonly events: readonly DomainEvent[];
readonly nextId: number;
readonly lastCreatureId: CreatureId | null;
}
// -- Initialization --
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
const EMPTY_ENCOUNTER: Encounter = {
@@ -54,12 +99,6 @@ const EMPTY_ENCOUNTER: Encounter = {
roundNumber: 1,
};
function initializeEncounter(): Encounter {
const stored = loadEncounter();
if (stored !== null) return stored;
return EMPTY_ENCOUNTER;
}
function deriveNextId(encounter: Encounter): number {
let max = 0;
for (const c of encounter.combatants) {
@@ -72,11 +111,283 @@ function deriveNextId(encounter: Encounter): number {
return max;
}
function initializeState(): EncounterState {
const encounter = loadEncounter() ?? EMPTY_ENCOUNTER;
return {
encounter,
undoRedoState: loadUndoRedoStacks(),
events: [],
nextId: deriveNextId(encounter),
lastCreatureId: null,
};
}
// -- Helpers --
function makeStoreFromState(state: EncounterState): {
store: EncounterStore;
getEncounter: () => Encounter;
} {
let current = state.encounter;
return {
store: {
get: () => current,
save: (e) => {
current = e;
},
},
getEncounter: () => current,
};
}
function resolveAndRename(store: EncounterStore, name: string): string {
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(name, existingNames);
for (const { from, to } of renames) {
const target = store.get().combatants.find((c) => c.name === from);
if (target) {
editCombatantUseCase(store, target.id, to);
}
}
return newName;
}
function addOneFromBestiary(
store: EncounterStore,
entry: BestiaryIndexEntry,
nextId: number,
): {
cId: CreatureId;
events: DomainEvent[];
nextId: number;
} | null {
const newName = resolveAndRename(store, entry.name);
const slug = entry.name
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
const id = combatantId(`c-${nextId + 1}`);
const result = addCombatantUseCase(store, id, newName, {
maxHp: entry.hp,
ac: entry.ac > 0 ? entry.ac : undefined,
creatureId: cId,
});
if (isDomainError(result)) return null;
return { cId, events: result, nextId: nextId + 1 };
}
// -- Reducer case handlers --
function handleUndoRedo(
state: EncounterState,
direction: "undo" | "redo",
): EncounterState {
const { store, getEncounter } = makeStoreFromState(state);
const undoRedoStore: UndoRedoStore = {
get: () => state.undoRedoState,
save: () => {},
};
const applyFn = direction === "undo" ? undoUseCase : redoUseCase;
const result = applyFn(store, undoRedoStore);
if (isDomainError(result)) return state;
const isUndo = direction === "undo";
return {
...state,
encounter: getEncounter(),
undoRedoState: {
undoStack: isUndo
? state.undoRedoState.undoStack.slice(0, -1)
: [...state.undoRedoState.undoStack, state.encounter],
redoStack: isUndo
? [...state.undoRedoState.redoStack, state.encounter]
: state.undoRedoState.redoStack.slice(0, -1),
},
};
}
function handleAddFromBestiary(
state: EncounterState,
entry: BestiaryIndexEntry,
count: number,
): EncounterState {
const { store, getEncounter } = makeStoreFromState(state);
const allEvents: DomainEvent[] = [];
let nextId = state.nextId;
let lastCId: CreatureId | null = null;
for (let i = 0; i < count; i++) {
const added = addOneFromBestiary(store, entry, nextId);
if (!added) return state;
allEvents.push(...added.events);
nextId = added.nextId;
lastCId = added.cId;
}
return {
...state,
encounter: getEncounter(),
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
events: [...state.events, ...allEvents],
nextId,
lastCreatureId: lastCId,
};
}
function handleAddFromPlayerCharacter(
state: EncounterState,
pc: PlayerCharacter,
): EncounterState {
const { store, getEncounter } = makeStoreFromState(state);
const newName = resolveAndRename(store, pc.name);
const id = combatantId(`c-${state.nextId + 1}`);
const result = addCombatantUseCase(store, id, newName, {
maxHp: pc.maxHp,
ac: pc.ac > 0 ? pc.ac : undefined,
color: pc.color,
icon: pc.icon,
playerCharacterId: pc.id,
});
if (isDomainError(result)) return state;
return {
...state,
encounter: getEncounter(),
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
events: [...state.events, ...result],
nextId: state.nextId + 1,
lastCreatureId: null,
};
}
// -- Reducer --
export function encounterReducer(
state: EncounterState,
action: EncounterAction,
): EncounterState {
switch (action.type) {
case "import":
return {
...state,
encounter: action.encounter,
undoRedoState: action.undoRedoState,
nextId: deriveNextId(action.encounter),
lastCreatureId: null,
};
case "undo":
case "redo":
return handleUndoRedo(state, action.type);
case "clear-encounter": {
const { store, getEncounter } = makeStoreFromState(state);
const result = clearEncounterUseCase(store);
if (isDomainError(result)) return state;
return {
...state,
encounter: getEncounter(),
undoRedoState: clearHistory(),
events: [...state.events, ...result],
nextId: 0,
lastCreatureId: null,
};
}
case "add-from-bestiary":
return handleAddFromBestiary(state, action.entry, 1);
case "add-multiple-from-bestiary":
return handleAddFromBestiary(state, action.entry, action.count);
case "add-from-player-character":
return handleAddFromPlayerCharacter(state, action.pc);
default:
return dispatchEncounterAction(state, action);
}
}
function dispatchEncounterAction(
state: EncounterState,
action: Extract<
EncounterAction,
| { type: "advance-turn" }
| { type: "retreat-turn" }
| { type: "add-combatant" }
| { type: "remove-combatant" }
| { type: "edit-combatant" }
| { type: "set-initiative" }
| { type: "set-hp" }
| { type: "adjust-hp" }
| { type: "set-temp-hp" }
| { type: "set-ac" }
| { type: "toggle-condition" }
| { type: "toggle-concentration" }
>,
): EncounterState {
const { store, getEncounter } = makeStoreFromState(state);
let result: DomainEvent[] | DomainError;
switch (action.type) {
case "advance-turn":
result = advanceTurnUseCase(store);
break;
case "retreat-turn":
result = retreatTurnUseCase(store);
break;
case "add-combatant": {
const id = combatantId(`c-${state.nextId + 1}`);
result = addCombatantUseCase(store, id, action.name, action.init);
break;
}
case "remove-combatant":
result = removeCombatantUseCase(store, action.id);
break;
case "edit-combatant":
result = editCombatantUseCase(store, action.id, action.newName);
break;
case "set-initiative":
result = setInitiativeUseCase(store, action.id, action.value);
break;
case "set-hp":
result = setHpUseCase(store, action.id, action.maxHp);
break;
case "adjust-hp":
result = adjustHpUseCase(store, action.id, action.delta);
break;
case "set-temp-hp":
result = setTempHpUseCase(store, action.id, action.tempHp);
break;
case "set-ac":
result = setAcUseCase(store, action.id, action.value);
break;
case "toggle-condition":
result = toggleConditionUseCase(store, action.id, action.conditionId);
break;
case "toggle-concentration":
result = toggleConcentrationUseCase(store, action.id);
break;
}
if (isDomainError(result)) return state;
return {
...state,
encounter: getEncounter(),
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
events: [...state.events, ...result],
nextId: action.type === "add-combatant" ? state.nextId + 1 : state.nextId,
lastCreatureId: null,
};
}
// -- Hook --
export function useEncounter() {
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
const [events, setEvents] = useState<DomainEvent[]>([]);
const [undoRedoState, setUndoRedoState] =
useState<UndoRedoState>(loadUndoRedoStacks);
const [state, dispatch] = useReducer(encounterReducer, null, initializeState);
const { encounter, undoRedoState, events } = state;
const encounterRef = useRef(encounter);
encounterRef.current = encounter;
const undoRedoRef = useRef(undoRedoState);
@@ -90,22 +401,17 @@ export function useEncounter() {
saveUndoRedoStacks(undoRedoState);
}, [undoRedoState]);
// Escape hatches for useInitiativeRolls (needs raw port access)
const makeStore = useCallback((): EncounterStore => {
return {
get: () => encounterRef.current,
save: (e) => {
encounterRef.current = e;
setEncounter(e);
},
};
}, []);
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
return {
get: () => undoRedoRef.current,
save: (s) => {
undoRedoRef.current = s;
setUndoRedoState(s);
dispatch({
type: "import",
encounter: e,
undoRedoState: undoRedoRef.current,
});
},
};
}, []);
@@ -116,245 +422,21 @@ export function useEncounter() {
if (!isDomainError(result)) {
const newState = pushUndo(undoRedoRef.current, snapshot);
undoRedoRef.current = newState;
setUndoRedoState(newState);
dispatch({
type: "import",
encounter: encounterRef.current,
undoRedoState: newState,
});
}
return result;
}, []);
const dispatchAction = useCallback(
(action: () => DomainEvent[] | DomainError) => {
const result = withUndo(action);
if (!isDomainError(result)) {
setEvents((prev) => [...prev, ...result]);
}
},
[withUndo],
);
const nextId = useRef(deriveNextId(encounter));
const advanceTurn = useCallback(
() => dispatchAction(() => advanceTurnUseCase(makeStore())),
[makeStore, dispatchAction],
);
const retreatTurn = useCallback(
() => dispatchAction(() => retreatTurnUseCase(makeStore())),
[makeStore, dispatchAction],
);
const addCombatant = useCallback(
(name: string, init?: CombatantInit) => {
const id = combatantId(`c-${++nextId.current}`);
dispatchAction(() => addCombatantUseCase(makeStore(), id, name, init));
},
[makeStore, dispatchAction],
);
const removeCombatant = useCallback(
(id: CombatantId) =>
dispatchAction(() => removeCombatantUseCase(makeStore(), id)),
[makeStore, dispatchAction],
);
const editCombatant = useCallback(
(id: CombatantId, newName: string) =>
dispatchAction(() => editCombatantUseCase(makeStore(), id, newName)),
[makeStore, dispatchAction],
);
const setInitiative = useCallback(
(id: CombatantId, value: number | undefined) =>
dispatchAction(() => setInitiativeUseCase(makeStore(), id, value)),
[makeStore, dispatchAction],
);
const setHp = useCallback(
(id: CombatantId, maxHp: number | undefined) =>
dispatchAction(() => setHpUseCase(makeStore(), id, maxHp)),
[makeStore, dispatchAction],
);
const adjustHp = useCallback(
(id: CombatantId, delta: number) =>
dispatchAction(() => adjustHpUseCase(makeStore(), id, delta)),
[makeStore, dispatchAction],
);
const setTempHp = useCallback(
(id: CombatantId, tempHp: number | undefined) =>
dispatchAction(() => setTempHpUseCase(makeStore(), id, tempHp)),
[makeStore, dispatchAction],
);
const setAc = useCallback(
(id: CombatantId, value: number | undefined) =>
dispatchAction(() => setAcUseCase(makeStore(), id, value)),
[makeStore, dispatchAction],
);
const toggleCondition = useCallback(
(id: CombatantId, conditionId: ConditionId) =>
dispatchAction(() =>
toggleConditionUseCase(makeStore(), id, conditionId),
),
[makeStore, dispatchAction],
);
const toggleConcentration = useCallback(
(id: CombatantId) =>
dispatchAction(() => toggleConcentrationUseCase(makeStore(), id)),
[makeStore, dispatchAction],
);
const clearEncounter = useCallback(() => {
const result = clearEncounterUseCase(makeStore());
if (isDomainError(result)) {
return;
}
const cleared = clearHistory();
undoRedoRef.current = cleared;
setUndoRedoState(cleared);
nextId.current = 0;
setEvents((prev) => [...prev, ...result]);
}, [makeStore]);
const resolveAndRename = useCallback(
(name: string): string => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(name, existingNames);
for (const { from, to } of renames) {
const target = store.get().combatants.find((c) => c.name === from);
if (target) {
editCombatantUseCase(makeStore(), target.id, to);
}
}
return newName;
},
[makeStore],
);
const addOneFromBestiary = useCallback(
(
entry: BestiaryIndexEntry,
): { cId: CreatureId; events: DomainEvent[] } | null => {
const newName = resolveAndRename(entry.name);
const slug = entry.name
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, newName, {
maxHp: entry.hp,
ac: entry.ac > 0 ? entry.ac : undefined,
creatureId: cId,
});
if (isDomainError(result)) return null;
return { cId, events: result };
},
[makeStore, resolveAndRename],
);
const addFromBestiary = useCallback(
(entry: BestiaryIndexEntry): CreatureId | null => {
const snapshot = encounterRef.current;
const added = addOneFromBestiary(entry);
if (!added) {
makeStore().save(snapshot);
return null;
}
const newState = pushUndo(undoRedoRef.current, snapshot);
undoRedoRef.current = newState;
setUndoRedoState(newState);
setEvents((prev) => [...prev, ...added.events]);
return added.cId;
},
[makeStore, addOneFromBestiary],
);
const addMultipleFromBestiary = useCallback(
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
const snapshot = encounterRef.current;
const allEvents: DomainEvent[] = [];
let lastCId: CreatureId | null = null;
for (let i = 0; i < count; i++) {
const added = addOneFromBestiary(entry);
if (!added) {
makeStore().save(snapshot);
return null;
}
allEvents.push(...added.events);
lastCId = added.cId;
}
const newState = pushUndo(undoRedoRef.current, snapshot);
undoRedoRef.current = newState;
setUndoRedoState(newState);
setEvents((prev) => [...prev, ...allEvents]);
return lastCId;
},
[makeStore, addOneFromBestiary],
);
const addFromPlayerCharacter = useCallback(
(pc: PlayerCharacter) => {
const snapshot = encounterRef.current;
const newName = resolveAndRename(pc.name);
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, newName, {
maxHp: pc.maxHp,
ac: pc.ac > 0 ? pc.ac : undefined,
color: pc.color,
icon: pc.icon,
playerCharacterId: pc.id,
});
if (isDomainError(result)) {
makeStore().save(snapshot);
return;
}
const newState = pushUndo(undoRedoRef.current, snapshot);
undoRedoRef.current = newState;
setUndoRedoState(newState);
setEvents((prev) => [...prev, ...result]);
},
[makeStore, resolveAndRename],
);
const undoAction = useCallback(() => {
undoUseCase(makeStore(), makeUndoRedoStore());
}, [makeStore, makeUndoRedoStore]);
const redoAction = useCallback(() => {
redoUseCase(makeStore(), makeUndoRedoStore());
}, [makeStore, makeUndoRedoStore]);
// Derived state
const canUndo = undoRedoState.undoStack.length > 0;
const canRedo = undoRedoState.redoStack.length > 0;
const hasTempHp = encounter.combatants.some(
(c) => c.tempHp !== undefined && c.tempHp > 0,
);
const isEmpty = encounter.combatants.length === 0;
const hasCreatureCombatants = encounter.combatants.some(
(c) => c.creatureId != null,
@@ -373,27 +455,105 @@ export function useEncounter() {
canRollAllInitiative,
canUndo,
canRedo,
advanceTurn,
retreatTurn,
addCombatant,
clearEncounter,
removeCombatant,
editCombatant,
setInitiative,
setHp,
adjustHp,
setTempHp,
setAc,
toggleCondition,
toggleConcentration,
addFromBestiary,
addMultipleFromBestiary,
addFromPlayerCharacter,
undo: undoAction,
redo: redoAction,
setEncounter,
setUndoRedoState,
advanceTurn: useCallback(() => dispatch({ type: "advance-turn" }), []),
retreatTurn: useCallback(() => dispatch({ type: "retreat-turn" }), []),
addCombatant: useCallback(
(name: string, init?: CombatantInit) =>
dispatch({ type: "add-combatant", name, init }),
[],
),
removeCombatant: useCallback(
(id: CombatantId) => dispatch({ type: "remove-combatant", id }),
[],
),
editCombatant: useCallback(
(id: CombatantId, newName: string) =>
dispatch({ type: "edit-combatant", id, newName }),
[],
),
setInitiative: useCallback(
(id: CombatantId, value: number | undefined) =>
dispatch({ type: "set-initiative", id, value }),
[],
),
setHp: useCallback(
(id: CombatantId, maxHp: number | undefined) =>
dispatch({ type: "set-hp", id, maxHp }),
[],
),
adjustHp: useCallback(
(id: CombatantId, delta: number) =>
dispatch({ type: "adjust-hp", id, delta }),
[],
),
setTempHp: useCallback(
(id: CombatantId, tempHp: number | undefined) =>
dispatch({ type: "set-temp-hp", id, tempHp }),
[],
),
setAc: useCallback(
(id: CombatantId, value: number | undefined) =>
dispatch({ type: "set-ac", id, value }),
[],
),
toggleCondition: useCallback(
(id: CombatantId, conditionId: ConditionId) =>
dispatch({ type: "toggle-condition", id, conditionId }),
[],
),
toggleConcentration: useCallback(
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
[],
),
clearEncounter: useCallback(
() => dispatch({ type: "clear-encounter" }),
[],
),
addFromBestiary: useCallback(
(entry: BestiaryIndexEntry): CreatureId | null => {
dispatch({ type: "add-from-bestiary", entry });
return null;
},
[],
),
addMultipleFromBestiary: useCallback(
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
dispatch({
type: "add-multiple-from-bestiary",
entry,
count,
});
return null;
},
[],
),
addFromPlayerCharacter: useCallback(
(pc: PlayerCharacter) =>
dispatch({ type: "add-from-player-character", pc }),
[],
),
undo: useCallback(() => dispatch({ type: "undo" }), []),
redo: useCallback(() => dispatch({ type: "redo" }), []),
setEncounter: useCallback(
(enc: Encounter) =>
dispatch({
type: "import",
encounter: enc,
undoRedoState: undoRedoRef.current,
}),
[],
),
setUndoRedoState: useCallback(
(urs: UndoRedoState) =>
dispatch({
type: "import",
encounter: encounterRef.current,
undoRedoState: urs,
}),
[],
),
makeStore,
withUndo,
lastCreatureId: state.lastCreatureId,
} as const;
}
+27 -2
View File
@@ -1,4 +1,29 @@
pre-commit:
parallel: true
jobs:
- name: check
run: pnpm check
- name: audit
run: pnpm audit --audit-level=high
- name: knip
run: pnpm exec knip
- name: biome
run: pnpm exec biome check .
- name: check-ignores
run: node scripts/check-lint-ignores.mjs
- name: check-classnames
run: node scripts/check-cn-classnames.mjs
- name: check-props
run: node scripts/check-component-props.mjs
- name: jscpd
run: pnpm exec jscpd
- name: jsinspect
run: pnpm jsinspect
- name: typecheck-oxlint-test
group:
piped: true
jobs:
- name: typecheck
run: pnpm exec tsc --build
- name: oxlint
run: pnpm oxlint
- name: test
run: pnpm test
+1 -1
View File
@@ -35,6 +35,6 @@
"check:ignores": "node scripts/check-lint-ignores.mjs",
"check:classnames": "node scripts/check-cn-classnames.mjs",
"check:props": "node scripts/check-component-props.mjs",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && tsc --build && vitest run && jscpd && pnpm jsinspect"
"check": "pnpm audit --audit-level=high && knip && biome check . && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && jscpd && pnpm jsinspect && tsc --build && oxlint --tsconfig apps/web/tsconfig.json --type-aware && vitest run"
}
}
+25 -3
View File
@@ -1,6 +1,14 @@
import type { Encounter, PlayerCharacter } from "@initiative/domain";
import { isDomainError } from "@initiative/domain";
import type { EncounterStore, PlayerCharacterStore } from "../ports.js";
import type {
Encounter,
PlayerCharacter,
UndoRedoState,
} from "@initiative/domain";
import { EMPTY_UNDO_REDO_STATE, isDomainError } from "@initiative/domain";
import type {
EncounterStore,
PlayerCharacterStore,
UndoRedoStore,
} from "../ports.js";
export function requireSaved<T>(value: T | null): T {
if (value === null) throw new Error("Expected store.saved to be non-null");
@@ -52,3 +60,17 @@ export function stubPlayerCharacterStore(
};
return stub;
}
export function stubUndoRedoStore(
initial: UndoRedoState = EMPTY_UNDO_REDO_STATE,
): UndoRedoStore & { saved: UndoRedoState | null } {
const stub = {
saved: null as UndoRedoState | null,
get: () => initial,
save: (state: UndoRedoState) => {
stub.saved = state;
stub.get = () => state;
},
};
return stub;
}
@@ -2,8 +2,10 @@ import {
type ConditionId,
combatantId,
createEncounter,
EMPTY_UNDO_REDO_STATE,
isDomainError,
playerCharacterId,
pushUndo,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { addCombatantUseCase } from "../add-combatant-use-case.js";
@@ -14,17 +16,21 @@ import { createPlayerCharacterUseCase } from "../create-player-character-use-cas
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 { redoUseCase } from "../redo-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 { setTempHpUseCase } from "../set-temp-hp-use-case.js";
import { toggleConcentrationUseCase } from "../toggle-concentration-use-case.js";
import { toggleConditionUseCase } from "../toggle-condition-use-case.js";
import { undoUseCase } from "../undo-use-case.js";
import {
requireSaved,
stubEncounterStore,
stubPlayerCharacterStore,
stubUndoRedoStore,
} from "./helpers.js";
const ID_A = combatantId("a");
@@ -386,3 +392,80 @@ describe("editPlayerCharacterUseCase", () => {
expect(store.saved).toBeNull();
});
});
describe("setTempHpUseCase", () => {
it("sets temp HP and saves", () => {
const enc = encounterWithHp("Goblin", 10);
const store = stubEncounterStore(enc);
const result = setTempHpUseCase(store, combatantId("Goblin"), 5);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].tempHp).toBe(5);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = setTempHpUseCase(store, ID_A, 5);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("undoUseCase", () => {
it("restores previous encounter and saves both stores", () => {
const previous = encounterWith("A");
const current = encounterWith("A", "B");
const undoRedoState = pushUndo(EMPTY_UNDO_REDO_STATE, previous);
const encounterStore = stubEncounterStore(current);
const undoRedoStore = stubUndoRedoStore(undoRedoState);
const result = undoUseCase(encounterStore, undoRedoStore);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(encounterStore.saved).combatants).toHaveLength(1);
expect(undoRedoStore.saved).not.toBeNull();
});
it("returns domain error when nothing to undo", () => {
const encounterStore = stubEncounterStore(emptyEncounter());
const undoRedoStore = stubUndoRedoStore();
const result = undoUseCase(encounterStore, undoRedoStore);
expect(isDomainError(result)).toBe(true);
expect(encounterStore.saved).toBeNull();
expect(undoRedoStore.saved).toBeNull();
});
});
describe("redoUseCase", () => {
it("restores next encounter and saves both stores", () => {
const previous = encounterWith("A");
const current = encounterWith("A", "B");
// Simulate: undo pushed current to redoStack
const undoRedoState = {
undoStack: [],
redoStack: [current],
};
const encounterStore = stubEncounterStore(previous);
const undoRedoStore = stubUndoRedoStore(undoRedoState);
const result = redoUseCase(encounterStore, undoRedoStore);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(encounterStore.saved).combatants).toHaveLength(2);
expect(undoRedoStore.saved).not.toBeNull();
});
it("returns domain error when nothing to redo", () => {
const encounterStore = stubEncounterStore(emptyEncounter());
const undoRedoStore = stubUndoRedoStore();
const result = redoUseCase(encounterStore, undoRedoStore);
expect(isDomainError(result)).toBe(true);
expect(encounterStore.saved).toBeNull();
expect(undoRedoStore.saved).toBeNull();
});
});
+24 -49
View File
@@ -9,11 +9,14 @@
* Only scans component files (not hooks, adapters, etc.) and only
* counts properties declared directly in *Props interfaces inherited
* or extended HTML attributes are not counted.
*
* Uses the TypeScript compiler API for accurate AST-based counting,
* immune to comments, strings, and complex type syntax.
*/
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { relative } from "node:path";
import ts from "typescript";
const MAX_PROPS = 8;
@@ -25,66 +28,38 @@ const files = execSync(
.split("\n")
.filter(Boolean);
const program = ts.createProgram(files, {
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
jsx: ts.JsxEmit.ReactJSX,
strict: true,
noEmit: true,
skipLibCheck: true,
});
let errors = 0;
const propsRegex = /^(?:export\s+)?interface\s+(\w+Props)\s*\{/;
for (const file of files) {
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");
const sourceFile = program.getSourceFile(file);
if (!sourceFile) continue;
let inInterface = false;
let interfaceName = "";
let braceDepth = 0;
let parenDepth = 0;
let propCount = 0;
let startLine = 0;
ts.forEachChild(sourceFile, (node) => {
if (!ts.isInterfaceDeclaration(node)) return;
if (!node.name.text.endsWith("Props")) return;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const propCount = node.members.filter((m) =>
ts.isPropertySignature(m),
).length;
if (!inInterface) {
const match = propsRegex.exec(line);
if (match) {
inInterface = true;
interfaceName = match[1];
braceDepth = 0;
parenDepth = 0;
propCount = 0;
startLine = i + 1;
}
}
if (inInterface) {
for (const ch of line) {
if (ch === "{") braceDepth++;
if (ch === "}") braceDepth--;
if (ch === "(") parenDepth++;
if (ch === ")") parenDepth--;
}
// Count prop lines at brace depth 1 and not inside function params:
// Matches " propName?: type" and " readonly propName: type"
if (
braceDepth === 1 &&
parenDepth === 0 &&
/^\s+(?:readonly\s+)?\w+\??\s*:/.test(line)
) {
propCount++;
}
if (braceDepth === 0) {
if (propCount > MAX_PROPS) {
const rel = relative(process.cwd(), file);
const { line } = sourceFile.getLineAndCharacterOfPosition(node.name.pos);
console.error(
`${rel}:${startLine}: ${interfaceName} has ${propCount} props (max ${MAX_PROPS})`,
`${rel}:${line + 1}: ${node.name.text} has ${propCount} props (max ${MAX_PROPS})`,
);
errors++;
}
inInterface = false;
}
}
}
});
}
if (errors > 0) {
+14 -15
View File
@@ -9,34 +9,33 @@ export default defineConfig({
enabled: true,
exclude: ["**/dist/**"],
thresholds: {
autoUpdate: true,
"packages/domain/src": {
lines: 99,
branches: 97,
lines: 98,
branches: 96,
},
"packages/application/src": {
lines: 97,
branches: 94,
lines: 96,
branches: 90,
},
"apps/web/src/adapters": {
lines: 72,
branches: 78,
lines: 68,
branches: 56,
},
"apps/web/src/persistence": {
lines: 90,
branches: 71,
lines: 85,
branches: 70,
},
"apps/web/src/hooks": {
lines: 59,
branches: 85,
lines: 72,
branches: 55,
},
"apps/web/src/components": {
lines: 52,
branches: 64,
lines: 59,
branches: 55,
},
"apps/web/src/components/ui": {
lines: 73,
branches: 96,
lines: 93,
branches: 90,
},
},
},