Compare commits
5 Commits
b6ee4c8c86
...
43546aaa7b
| Author | SHA1 | Date | |
|---|---|---|---|
| 43546aaa7b | |||
| 09da9a8dfc | |||
| b229a0dac7 | |||
| 08b5db81ad | |||
| a89fac5c23 |
@@ -0,0 +1,72 @@
|
||||
---
|
||||
description: Create a git commit with pre-commit hooks (bypasses sandbox restrictions).
|
||||
---
|
||||
|
||||
## Instructions
|
||||
|
||||
Create a git commit for the current staged and/or unstaged changes.
|
||||
|
||||
### Step 1 — Assess changes
|
||||
|
||||
Run these in parallel:
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
```bash
|
||||
git diff
|
||||
```
|
||||
|
||||
```bash
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
### Step 2 — Draft commit message
|
||||
|
||||
- Summarize the nature of the changes (new feature, enhancement, bug fix, refactor, test, docs, etc.)
|
||||
- Keep the first line concise (under 72 chars), use imperative mood
|
||||
- Add a blank line and a short body if the "why" isn't obvious from the first line
|
||||
- Match the style of recent commits in the log
|
||||
- Do not commit files that likely contain secrets (.env, credentials, etc.)
|
||||
|
||||
### Step 3 — Stage and commit
|
||||
|
||||
Stage relevant files by name (avoid `git add -A` or `git add .`). Then commit.
|
||||
|
||||
**CRITICAL:** Always use `dangerouslyDisableSandbox: true` for the commit command. Lefthook pre-commit hooks spawn subprocesses (biome, oxlint, vitest, etc.) that require filesystem access beyond what the sandbox allows. They will always fail with "operation not permitted" in sandbox mode.
|
||||
|
||||
Append the co-author trailer:
|
||||
|
||||
```
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
Use a HEREDOC for the commit message:
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
<first line>
|
||||
|
||||
<optional body>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### Step 4 — Verify
|
||||
|
||||
Run `git status` after the commit to confirm success.
|
||||
|
||||
### If the commit fails
|
||||
|
||||
If a pre-commit hook fails, fix the issue, re-stage, and create a **new** commit. Never amend unless explicitly asked — amending after a hook failure would modify the previous commit.
|
||||
|
||||
## User arguments
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
If the user provided arguments, treat them as the commit message or guidance for what to commit.
|
||||
@@ -12,4 +12,6 @@ Thumbs.db
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
docs/agents/plans/
|
||||
docs/agents/research/
|
||||
.agent-tests/
|
||||
.rodney/
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
───────────────────
|
||||
Version change: 3.0.0 → 3.1.0 (MINOR — new principle II-A: context-based state flow)
|
||||
Version change: 3.1.0 → 3.2.0 (MINOR — artifact lifecycle guidance)
|
||||
Modified sections:
|
||||
- Core Principles: added II-A. Context-Based State Flow (max 8 props, context over prop drilling)
|
||||
- Development Workflow: added artifact lifecycle rules (spec.md living, plan/tasks bounded, tests authoritative)
|
||||
Templates requiring updates: none
|
||||
-->
|
||||
# Encounter Console Constitution
|
||||
@@ -113,6 +113,18 @@ architecture, and quality — not product behavior.
|
||||
(which creates a feature branch for the full speckit pipeline);
|
||||
changes to existing features update the existing spec via
|
||||
`/integrate-issue`.
|
||||
- **Artifact lifecycles differ by type**:
|
||||
- `spec.md` is a **living capability document** — it describes what
|
||||
the feature does and is updated whenever the feature meaningfully
|
||||
changes. It survives across multiple rounds of work.
|
||||
- `plan.md` and `tasks.md` are **bounded work packages** — they
|
||||
describe what to do for a specific increment of work. After
|
||||
completion they become historical records. The next round of work
|
||||
on the same feature gets a new plan, not an update to the old one.
|
||||
- Tests are the **executable ground truth**. When a spec's
|
||||
acceptance criteria and the tests disagree, the tests are
|
||||
authoritative. Spec prose captures intent and context; tests
|
||||
capture actual behavior.
|
||||
- The full pipeline (spec → plan → tasks → implement) applies to new
|
||||
features and significant additions. Bug fixes, tooling changes,
|
||||
and trivial UI adjustments do not require specs.
|
||||
@@ -156,4 +168,4 @@ MUST comply with its principles.
|
||||
**Compliance review**: Every spec and plan MUST include a
|
||||
Constitution Check section validating adherence to all principles.
|
||||
|
||||
**Version**: 3.1.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-19
|
||||
**Version**: 3.2.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-30
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
**Initiative** is a browser-based combat encounter tracker for tabletop RPGs (D&D 5.5e, Pathfinder 2e). It runs entirely client-side — no backend, no accounts — with localStorage and IndexedDB for persistence.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -66,16 +66,15 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
||||
## Conventions
|
||||
|
||||
- **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`).
|
||||
- **oxlint** for type-aware linting that Biome can't do. Configured in `.oxlintrc.json`.
|
||||
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports.
|
||||
- **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.
|
||||
- **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` 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()`.
|
||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios from specs to individual `it()` blocks.
|
||||
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
|
||||
|
||||
For component prop rules, export format compatibility, and ADRs, see [`docs/conventions.md`](docs/conventions.md).
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
Before finishing a change, consider:
|
||||
@@ -86,21 +85,7 @@ Before finishing a change, consider:
|
||||
|
||||
## Speckit Workflow
|
||||
|
||||
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
||||
|
||||
### Issue-driven workflow
|
||||
- `/write-issue` — create a well-structured Gitea issue via interactive interview
|
||||
- `/integrate-issue <number>` — fetch an issue, route it to the right spec, and update the spec with the new/changed requirements. Then implement directly.
|
||||
- `/sync-issue <number>` — push acceptance criteria from the spec back to the Gitea issue
|
||||
|
||||
### RPI skills (Research → Plan → Implement)
|
||||
- `rpi-research` — deep codebase research producing a written report in `docs/agents/research/`
|
||||
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
|
||||
- `rpi-implement` — execute a plan file phase by phase with automated + manual verification
|
||||
|
||||
**Research scope**: Research should include a scan for existing patterns similar to what the feature needs (e.g., shared UI primitives, duplicated validation logic, repeated state management patterns). Identify extraction and consolidation opportunities before implementation, not during.
|
||||
|
||||
### Choosing the right workflow by scope
|
||||
Specs are **living documents** in `specs/NNN-feature-name/` that describe features, not individual changes. Use `/speckit.*` and RPI skills (`rpi-research`, `rpi-plan`, `rpi-implement`) to manage them — skill descriptions have full usage details.
|
||||
|
||||
| Scope | Workflow |
|
||||
|---|---|
|
||||
@@ -109,24 +94,8 @@ Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Spec
|
||||
| Larger addition to existing feature | `/integrate-issue` → `rpi-research` → `rpi-plan` → `rpi-implement` |
|
||||
| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` |
|
||||
|
||||
Speckit manages **what** to build (specs as living documents). RPI manages **how** to build it (research, planning, execution). The full speckit pipeline is for new features. For changes to existing features, update the spec via `/integrate-issue`, then use RPI skills if the change is non-trivial.
|
||||
**Research scope**: Always scan for existing patterns similar to what the feature needs. Identify extraction and consolidation opportunities before implementation, not during.
|
||||
|
||||
### Current feature specs
|
||||
- `specs/001-combatant-management/` — CRUD, persistence, clear, batch add, confirm buttons
|
||||
- `specs/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar
|
||||
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
||||
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
|
||||
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
|
||||
- `specs/006-undo-redo/` — undo/redo for encounter state mutations
|
||||
- `specs/007-json-import-export/` — JSON import/export for full encounter state (encounter, undo/redo, player characters)
|
||||
- `specs/008-encounter-difficulty/` — Live encounter difficulty indicator (5.5e XP budget system), optional PC level field
|
||||
## Constitution
|
||||
|
||||
## Constitution (key principles)
|
||||
|
||||
The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
||||
|
||||
1. **Deterministic Domain Core** — Pure state transitions only; no I/O, randomness, or clocks in domain.
|
||||
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
|
||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
||||
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
|
||||
Project principles governing all feature work are in [`.specify/memory/constitution.md`](.specify/memory/constitution.md). Key rules: deterministic domain core, strict layer boundaries, clarification before assumptions.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// @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 { BulkImportPrompt } from "../bulk-import-prompt.js";
|
||||
|
||||
const THREE_SOURCES_REGEX = /3 sources/;
|
||||
const GITHUB_URL_REGEX = /raw\.githubusercontent/;
|
||||
const LOADING_PROGRESS_REGEX = /Loading sources\.\.\. 4\/10/;
|
||||
const SEVEN_OF_TEN_REGEX = /7\/10 sources/;
|
||||
const THREE_FAILED_REGEX = /3 failed/;
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const mockFetchAndCacheSource = vi.fn();
|
||||
const mockIsSourceCached = vi.fn().mockResolvedValue(false);
|
||||
const mockRefreshCache = vi.fn();
|
||||
const mockStartImport = vi.fn();
|
||||
const mockReset = vi.fn();
|
||||
const mockDismissPanel = vi.fn();
|
||||
|
||||
let mockImportState = {
|
||||
status: "idle" as string,
|
||||
total: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: () => ({
|
||||
fetchAndCacheSource: mockFetchAndCacheSource,
|
||||
isSourceCached: mockIsSourceCached,
|
||||
refreshCache: mockRefreshCache,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bulk-import-context.js", () => ({
|
||||
useBulkImportContext: () => ({
|
||||
state: mockImportState,
|
||||
startImport: mockStartImport,
|
||||
reset: mockReset,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/side-panel-context.js", () => ({
|
||||
useSidePanelContext: () => ({
|
||||
dismissPanel: mockDismissPanel,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
}));
|
||||
|
||||
describe("BulkImportPrompt", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockImportState = { status: "idle", total: 0, completed: 0, failed: 0 };
|
||||
});
|
||||
|
||||
it("idle: shows base URL input, source count, Load All button", () => {
|
||||
render(<BulkImportPrompt />);
|
||||
expect(screen.getByText(THREE_SOURCES_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue(GITHUB_URL_REGEX)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Load All" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("idle: clearing URL disables the button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BulkImportPrompt />);
|
||||
|
||||
const input = screen.getByDisplayValue(GITHUB_URL_REGEX);
|
||||
await user.clear(input);
|
||||
expect(screen.getByRole("button", { name: "Load All" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("idle: clicking Load All calls startImport with URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BulkImportPrompt />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Load All" }));
|
||||
expect(mockStartImport).toHaveBeenCalledWith(
|
||||
expect.stringContaining("raw.githubusercontent"),
|
||||
mockFetchAndCacheSource,
|
||||
mockIsSourceCached,
|
||||
mockRefreshCache,
|
||||
);
|
||||
});
|
||||
|
||||
it("loading: shows progress text and progress bar", () => {
|
||||
mockImportState = {
|
||||
status: "loading",
|
||||
total: 10,
|
||||
completed: 3,
|
||||
failed: 1,
|
||||
};
|
||||
render(<BulkImportPrompt />);
|
||||
expect(screen.getByText(LOADING_PROGRESS_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("complete: shows success message and Done button", () => {
|
||||
mockImportState = {
|
||||
status: "complete",
|
||||
total: 10,
|
||||
completed: 10,
|
||||
failed: 0,
|
||||
};
|
||||
render(<BulkImportPrompt />);
|
||||
expect(screen.getByText("All sources loaded")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("complete: Done calls dismissPanel and reset", async () => {
|
||||
mockImportState = {
|
||||
status: "complete",
|
||||
total: 10,
|
||||
completed: 10,
|
||||
failed: 0,
|
||||
};
|
||||
const user = userEvent.setup();
|
||||
render(<BulkImportPrompt />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Done" }));
|
||||
expect(mockDismissPanel).toHaveBeenCalled();
|
||||
expect(mockReset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("partial-failure: shows loaded/failed counts", () => {
|
||||
mockImportState = {
|
||||
status: "partial-failure",
|
||||
total: 10,
|
||||
completed: 7,
|
||||
failed: 3,
|
||||
};
|
||||
render(<BulkImportPrompt />);
|
||||
expect(screen.getByText(SEVEN_OF_TEN_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(THREE_FAILED_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { VALID_PLAYER_COLORS } 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";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
import { ColorPalette } from "../color-palette.js";
|
||||
|
||||
describe("ColorPalette", () => {
|
||||
it("renders a button for each valid color", () => {
|
||||
render(<ColorPalette value="" onChange={() => {}} />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(VALID_PLAYER_COLORS.size);
|
||||
});
|
||||
|
||||
it("each button has an aria-label matching the color name", () => {
|
||||
render(<ColorPalette value="" onChange={() => {}} />);
|
||||
for (const color of VALID_PLAYER_COLORS) {
|
||||
expect(screen.getByRole("button", { name: color })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("clicking a color calls onChange with that color", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<ColorPalette value="" onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "blue" }));
|
||||
expect(onChange).toHaveBeenCalledWith("blue");
|
||||
});
|
||||
|
||||
it("clicking the selected color deselects it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<ColorPalette value="red" onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "red" }));
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("selected color has ring styling", () => {
|
||||
render(<ColorPalette value="green" onChange={() => {}} />);
|
||||
const selected = screen.getByRole("button", { name: "green" });
|
||||
expect(selected.className).toContain("ring-2");
|
||||
});
|
||||
|
||||
it("non-selected colors do not have ring styling", () => {
|
||||
render(<ColorPalette value="green" onChange={() => {}} />);
|
||||
const other = screen.getByRole("button", { name: "blue" });
|
||||
expect(other.className).not.toContain("ring-2");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import { CombatantRow } from "../combatant-row.js";
|
||||
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
||||
|
||||
const TEMP_HP_REGEX = /^\+\d/;
|
||||
const CURRENT_HP_7_REGEX = /Current HP: 7/;
|
||||
const CURRENT_HP_REGEX = /Current HP/;
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
@@ -257,6 +259,172 @@ describe("CombatantRow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("inline name editing", () => {
|
||||
it("click rename → type new name → blur commits rename", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Rename" }));
|
||||
const input = screen.getByDisplayValue("Goblin");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Hobgoblin");
|
||||
await user.tab(); // blur
|
||||
// The input should be gone, name committed
|
||||
expect(screen.queryByDisplayValue("Hobgoblin")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Escape cancels without renaming", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Rename" }));
|
||||
const input = screen.getByDisplayValue("Goblin");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Changed");
|
||||
await user.keyboard("{Escape}");
|
||||
// Should revert to showing the original name
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inline AC editing", () => {
|
||||
it("click AC → type value → Enter commits", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
ac: 13,
|
||||
},
|
||||
});
|
||||
|
||||
// Click the AC shield button
|
||||
const acButton = screen.getByText("13").closest("button");
|
||||
expect(acButton).not.toBeNull();
|
||||
await user.click(acButton as HTMLElement);
|
||||
const input = screen.getByDisplayValue("13");
|
||||
await user.clear(input);
|
||||
await user.type(input, "16");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(screen.queryByDisplayValue("16")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inline max HP editing", () => {
|
||||
it("click max HP → type value → blur commits", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 10,
|
||||
currentHp: 10,
|
||||
},
|
||||
});
|
||||
|
||||
// The max HP button shows "10" as muted text
|
||||
const maxHpButton = screen
|
||||
.getAllByText("10")
|
||||
.find(
|
||||
(el) => el.closest("button") && el.className.includes("text-muted"),
|
||||
);
|
||||
expect(maxHpButton).toBeDefined();
|
||||
const maxHpBtn = (maxHpButton as HTMLElement).closest("button");
|
||||
expect(maxHpBtn).not.toBeNull();
|
||||
await user.click(maxHpBtn as HTMLElement);
|
||||
const input = screen.getByDisplayValue("10");
|
||||
await user.clear(input);
|
||||
await user.type(input, "25");
|
||||
await user.tab();
|
||||
expect(screen.queryByDisplayValue("25")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inline initiative editing", () => {
|
||||
it("click initiative → type value → Enter commits", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
initiative: 15,
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("15"));
|
||||
const input = screen.getByDisplayValue("15");
|
||||
await user.clear(input);
|
||||
await user.type(input, "20");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(screen.queryByDisplayValue("20")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clearing initiative and pressing Enter commits the edit", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
initiative: 15,
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("15"));
|
||||
const input = screen.getByDisplayValue("15");
|
||||
await user.clear(input);
|
||||
await user.keyboard("{Enter}");
|
||||
// Input should be dismissed (editing mode exited)
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HP popover", () => {
|
||||
it("clicking current HP opens the HP adjust popover", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 10,
|
||||
currentHp: 7,
|
||||
},
|
||||
});
|
||||
|
||||
const hpButton = screen.getByLabelText(CURRENT_HP_7_REGEX);
|
||||
await user.click(hpButton);
|
||||
// The popover should appear with damage/heal controls
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Apply damage" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Apply healing" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("HP section is absent when maxHp is undefined", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
},
|
||||
});
|
||||
expect(screen.queryByLabelText(CURRENT_HP_REGEX)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("condition picker", () => {
|
||||
it("clicking Add condition button opens the picker", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow();
|
||||
const addButton = screen.getByRole("button", {
|
||||
name: "Add condition",
|
||||
});
|
||||
await user.click(addButton);
|
||||
// Condition picker should render with condition options
|
||||
expect(screen.getByText("Blinded")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("temp HP display", () => {
|
||||
it("shows +N when combatant has temp HP", () => {
|
||||
renderRow({
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// @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, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { ExportMethodDialog } from "../export-method-dialog.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
});
|
||||
|
||||
function renderDialog(open = true) {
|
||||
const onDownload = vi.fn();
|
||||
const onCopyToClipboard = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const result = render(
|
||||
<ExportMethodDialog
|
||||
open={open}
|
||||
onDownload={onDownload}
|
||||
onCopyToClipboard={onCopyToClipboard}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return { ...result, onDownload, onCopyToClipboard, onClose };
|
||||
}
|
||||
|
||||
describe("ExportMethodDialog", () => {
|
||||
it("renders filename input and unchecked history checkbox", () => {
|
||||
renderDialog();
|
||||
expect(
|
||||
screen.getByPlaceholderText("Filename (optional)"),
|
||||
).toBeInTheDocument();
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("download button calls onDownload with defaults", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onDownload } = renderDialog();
|
||||
|
||||
await user.click(screen.getByText("Download file"));
|
||||
expect(onDownload).toHaveBeenCalledWith(false, "");
|
||||
});
|
||||
|
||||
it("download with filename and history checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onDownload } = renderDialog();
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Filename (optional)"),
|
||||
"my-encounter",
|
||||
);
|
||||
await user.click(screen.getByRole("checkbox"));
|
||||
await user.click(screen.getByText("Download file"));
|
||||
expect(onDownload).toHaveBeenCalledWith(true, "my-encounter");
|
||||
});
|
||||
|
||||
it("copy to clipboard calls onCopyToClipboard and shows Copied", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onCopyToClipboard } = renderDialog();
|
||||
|
||||
await user.click(screen.getByText("Copy to clipboard"));
|
||||
expect(onCopyToClipboard).toHaveBeenCalledWith(false);
|
||||
expect(screen.getByText("Copied!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Copied! reverts after 2 seconds", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByText("Copy to clipboard"));
|
||||
expect(screen.getByText("Copied!")).toBeInTheDocument();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByText("Copied!")).not.toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
expect(screen.getByText("Copy to clipboard")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// @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, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { ImportMethodDialog } from "../import-method-dialog.js";
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderDialog(open = true) {
|
||||
const onSelectFile = vi.fn();
|
||||
const onSubmitClipboard = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const result = render(
|
||||
<ImportMethodDialog
|
||||
open={open}
|
||||
onSelectFile={onSelectFile}
|
||||
onSubmitClipboard={onSubmitClipboard}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return { ...result, onSelectFile, onSubmitClipboard, onClose };
|
||||
}
|
||||
|
||||
describe("ImportMethodDialog", () => {
|
||||
it("opens in pick mode with two method buttons", () => {
|
||||
renderDialog();
|
||||
expect(screen.getByText("From file")).toBeInTheDocument();
|
||||
expect(screen.getByText("Paste content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("From file button calls onSelectFile and closes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSelectFile, onClose } = renderDialog();
|
||||
|
||||
await user.click(screen.getByText("From file"));
|
||||
expect(onSelectFile).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Paste content button switches to paste mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByText("Paste content"));
|
||||
expect(
|
||||
screen.getByPlaceholderText("Paste exported JSON here..."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Import" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("typing text enables Import button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByText("Paste content"));
|
||||
const textarea = screen.getByPlaceholderText("Paste exported JSON here...");
|
||||
await user.type(textarea, "test-data");
|
||||
expect(screen.getByRole("button", { name: "Import" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("Import calls onSubmitClipboard with text and closes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmitClipboard, onClose } = renderDialog();
|
||||
|
||||
await user.click(screen.getByText("Paste content"));
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Paste exported JSON here..."),
|
||||
"some-json-content",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Import" }));
|
||||
expect(onSubmitClipboard).toHaveBeenCalledWith("some-json-content");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Back button returns to pick mode and clears text", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByText("Paste content"));
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Paste exported JSON here..."),
|
||||
"some text",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
|
||||
expect(screen.getByText("From file")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByPlaceholderText("Paste exported JSON here..."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRef } from "react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import {
|
||||
PlayerCharacterSection,
|
||||
type PlayerCharacterSectionHandle,
|
||||
} from "../player-character-section.js";
|
||||
|
||||
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||
isSourceCached: () => Promise.resolve(false),
|
||||
cacheSource: () => Promise.resolve(),
|
||||
getCachedSources: () => Promise.resolve([]),
|
||||
clearSource: () => Promise.resolve(),
|
||||
clearAll: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
function renderSection() {
|
||||
const ref = createRef<PlayerCharacterSectionHandle>();
|
||||
const result = render(<PlayerCharacterSection ref={ref} />, {
|
||||
wrapper: AllProviders,
|
||||
});
|
||||
return { ...result, ref };
|
||||
}
|
||||
|
||||
describe("PlayerCharacterSection", () => {
|
||||
it("openManagement ref handle opens the management dialog", async () => {
|
||||
const { ref } = renderSection();
|
||||
|
||||
const handle = ref.current;
|
||||
if (!handle) throw new Error("ref not set");
|
||||
act(() => handle.openManagement());
|
||||
|
||||
// Management dialog should now be open with its title visible
|
||||
await waitFor(() => {
|
||||
const dialogs = document.querySelectorAll("dialog");
|
||||
const managementDialog = Array.from(dialogs).find((d) =>
|
||||
d.textContent?.includes("Player Characters"),
|
||||
);
|
||||
expect(managementDialog).toHaveAttribute("open");
|
||||
});
|
||||
});
|
||||
|
||||
it("creating a character from management opens create modal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { ref } = renderSection();
|
||||
|
||||
const handle = ref.current;
|
||||
if (!handle) throw new Error("ref not set");
|
||||
act(() => handle.openManagement());
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: CREATE_FIRST_PC_REGEX,
|
||||
}),
|
||||
);
|
||||
|
||||
// Create modal should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Character name")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("saving a new character and returning to management", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { ref } = renderSection();
|
||||
|
||||
const handle = ref.current;
|
||||
if (!handle) throw new Error("ref not set");
|
||||
act(() => handle.openManagement());
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: CREATE_FIRST_PC_REGEX,
|
||||
}),
|
||||
);
|
||||
|
||||
// Fill in the create form
|
||||
await user.type(screen.getByPlaceholderText("Character name"), "Aria");
|
||||
await user.type(screen.getByPlaceholderText("AC"), "16");
|
||||
await user.type(screen.getByPlaceholderText("Max HP"), "30");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
// Should return to management dialog showing the new character
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Aria")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { type PlayerCharacter, 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 { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||
const LEVEL_REGEX = /^Lv /;
|
||||
|
||||
import { PlayerManagement } from "../player-management.js";
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
});
|
||||
|
||||
const PC_WARRIOR: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Thorin",
|
||||
ac: 18,
|
||||
maxHp: 45,
|
||||
color: "red",
|
||||
icon: "sword",
|
||||
};
|
||||
|
||||
const PC_WIZARD: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-2"),
|
||||
name: "Gandalf",
|
||||
ac: 12,
|
||||
maxHp: 30,
|
||||
color: "blue",
|
||||
icon: "wand",
|
||||
level: 10,
|
||||
};
|
||||
|
||||
function renderManagement(
|
||||
overrides: Partial<Parameters<typeof PlayerManagement>[0]> = {},
|
||||
) {
|
||||
const props = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
characters: [] as readonly PlayerCharacter[],
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onCreate: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return { ...render(<PlayerManagement {...props} />), props };
|
||||
}
|
||||
|
||||
describe("PlayerManagement", () => {
|
||||
it("shows empty state when no characters", () => {
|
||||
renderManagement();
|
||||
expect(screen.getByText("No player characters yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows create button in empty state that calls onCreate", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderManagement();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: CREATE_FIRST_PC_REGEX,
|
||||
}),
|
||||
);
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders each character with name, AC, HP", () => {
|
||||
renderManagement({ characters: [PC_WARRIOR, PC_WIZARD] });
|
||||
expect(screen.getByText("Thorin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Gandalf")).toBeInTheDocument();
|
||||
expect(screen.getByText("AC 18")).toBeInTheDocument();
|
||||
expect(screen.getByText("HP 45")).toBeInTheDocument();
|
||||
expect(screen.getByText("AC 12")).toBeInTheDocument();
|
||||
expect(screen.getByText("HP 30")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows level when present, omits when undefined", () => {
|
||||
renderManagement({ characters: [PC_WARRIOR, PC_WIZARD] });
|
||||
expect(screen.getByText("Lv 10")).toBeInTheDocument();
|
||||
// Thorin has no level — there should be only one "Lv" text
|
||||
expect(screen.queryAllByText(LEVEL_REGEX)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("edit button calls onEdit with the character", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
expect(props.onEdit).toHaveBeenCalledWith(PC_WARRIOR);
|
||||
});
|
||||
|
||||
it("delete button calls onDelete after confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||
|
||||
const deleteBtn = screen.getByRole("button", {
|
||||
name: "Delete player character",
|
||||
});
|
||||
await user.click(deleteBtn);
|
||||
const confirmBtn = screen.getByRole("button", {
|
||||
name: "Confirm delete player character",
|
||||
});
|
||||
await user.click(confirmBtn);
|
||||
expect(props.onDelete).toHaveBeenCalledWith(PC_WARRIOR.id);
|
||||
});
|
||||
|
||||
it("add button calls onCreate", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// @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, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { SettingsModal } from "../settings-modal.js";
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||
isSourceCached: () => Promise.resolve(false),
|
||||
cacheSource: () => Promise.resolve(),
|
||||
getCachedSources: () => Promise.resolve([]),
|
||||
clearSource: () => Promise.resolve(),
|
||||
clearAll: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
function renderModal(open = true) {
|
||||
const onClose = vi.fn();
|
||||
const result = render(<SettingsModal open={open} onClose={onClose} />, {
|
||||
wrapper: AllProviders,
|
||||
});
|
||||
return { ...result, onClose };
|
||||
}
|
||||
|
||||
describe("SettingsModal", () => {
|
||||
it("renders edition toggle buttons", () => {
|
||||
renderModal();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "5e (2014)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "5.5e (2024)" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders theme toggle buttons", () => {
|
||||
renderModal();
|
||||
expect(screen.getByRole("button", { name: "System" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Light" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking an edition button switches the active edition", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
const btn5e = screen.getByRole("button", { name: "5e (2014)" });
|
||||
await user.click(btn5e);
|
||||
// After clicking 5e, it should have the active style
|
||||
expect(btn5e.className).toContain("bg-accent");
|
||||
});
|
||||
|
||||
it("clicking a theme button switches the active theme", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
const darkBtn = screen.getByRole("button", { name: "Dark" });
|
||||
await user.click(darkBtn);
|
||||
expect(darkBtn.className).toContain("bg-accent");
|
||||
});
|
||||
|
||||
it("close button calls onClose", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = renderModal();
|
||||
// DialogHeader renders an X button
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const closeBtn = buttons.find((b) => b.querySelector(".h-4.w-4") !== null);
|
||||
expect(closeBtn).toBeDefined();
|
||||
await user.click(closeBtn as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// @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";
|
||||
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
||||
|
||||
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const mockFetchAndCacheSource = vi.fn();
|
||||
const mockUploadAndCacheSource = vi.fn();
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: () => ({
|
||||
fetchAndCacheSource: mockFetchAndCacheSource,
|
||||
uploadAndCacheSource: mockUploadAndCacheSource,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
getDefaultFetchUrl: (code: string) =>
|
||||
`https://example.com/bestiary/${code}.json`,
|
||||
getSourceDisplayName: (code: string) =>
|
||||
code === "MM" ? "Monster Manual" : code,
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
}));
|
||||
|
||||
function renderPrompt(sourceCode = "MM") {
|
||||
const onSourceLoaded = vi.fn();
|
||||
const result = render(
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
onSourceLoaded={onSourceLoaded}
|
||||
/>,
|
||||
);
|
||||
return { ...result, onSourceLoaded };
|
||||
}
|
||||
|
||||
describe("SourceFetchPrompt", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders source name, URL input, Load and Upload buttons", () => {
|
||||
renderPrompt();
|
||||
expect(screen.getByText(MONSTER_MANUAL_REGEX)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByDisplayValue("https://example.com/bestiary/MM.json"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Load")).toBeInTheDocument();
|
||||
expect(screen.getByText("Upload file")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => {
|
||||
mockFetchAndCacheSource.mockResolvedValueOnce(undefined);
|
||||
const user = userEvent.setup();
|
||||
const { onSourceLoaded } = renderPrompt();
|
||||
|
||||
await user.click(screen.getByText("Load"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAndCacheSource).toHaveBeenCalledWith(
|
||||
"MM",
|
||||
"https://example.com/bestiary/MM.json",
|
||||
);
|
||||
expect(onSourceLoaded).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("fetch error shows error message", async () => {
|
||||
mockFetchAndCacheSource.mockRejectedValueOnce(new Error("Network error"));
|
||||
const user = userEvent.setup();
|
||||
renderPrompt();
|
||||
|
||||
await user.click(screen.getByText("Load"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("upload file calls uploadAndCacheSource and onSourceLoaded", async () => {
|
||||
mockUploadAndCacheSource.mockResolvedValueOnce(undefined);
|
||||
const user = userEvent.setup();
|
||||
const { onSourceLoaded } = renderPrompt();
|
||||
|
||||
const file = new File(['{"monster":[]}'], "bestiary-mm.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
const fileInput = document.querySelector(
|
||||
'input[type="file"]',
|
||||
) as HTMLInputElement;
|
||||
await user.upload(fileInput, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUploadAndCacheSource).toHaveBeenCalledWith("MM", {
|
||||
monster: [],
|
||||
});
|
||||
expect(onSourceLoaded).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("upload error shows error message", async () => {
|
||||
mockUploadAndCacheSource.mockRejectedValueOnce(new Error("Invalid format"));
|
||||
const user = userEvent.setup();
|
||||
renderPrompt();
|
||||
|
||||
const file = new File(['{"bad": true}'], "bad.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
const fileInput = document.querySelector(
|
||||
'input[type="file"]',
|
||||
) as HTMLInputElement;
|
||||
await user.upload(fileInput, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Invalid format")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { StatBlock } from "../stat-block.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const ARMOR_CLASS_REGEX = /Armor Class/;
|
||||
const DEX_PLUS_4_REGEX = /Dex \+4/;
|
||||
const CR_QUARTER_REGEX = /1\/4/;
|
||||
const PROF_BONUS_2_REGEX = /Proficiency Bonus \+2/;
|
||||
const NIMBLE_ESCAPE_REGEX = /Nimble Escape\./;
|
||||
const SCIMITAR_REGEX = /Scimitar\./;
|
||||
const DETECT_REGEX = /Detect\./;
|
||||
const TAIL_ATTACK_REGEX = /Tail Attack\./;
|
||||
const INNATE_SPELLCASTING_REGEX = /Innate Spellcasting\./;
|
||||
const AT_WILL_REGEX = /At Will:/;
|
||||
const DETECT_MAGIC_REGEX = /detect magic, suggestion/;
|
||||
const DAILY_REGEX = /3\/day each:/;
|
||||
const FIREBALL_REGEX = /fireball, wall of fire/;
|
||||
const LONG_REST_REGEX = /1\/long rest:/;
|
||||
const WISH_REGEX = /wish/;
|
||||
|
||||
const GOBLIN: Creature = {
|
||||
id: creatureId("srd:goblin"),
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
size: "Small",
|
||||
type: "humanoid",
|
||||
alignment: "neutral evil",
|
||||
ac: 15,
|
||||
acSource: "leather armor, shield",
|
||||
hp: { average: 7, formula: "2d6" },
|
||||
speed: "30 ft.",
|
||||
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
|
||||
cr: "1/4",
|
||||
initiativeProficiency: 0,
|
||||
proficiencyBonus: 2,
|
||||
passive: 9,
|
||||
savingThrows: "Dex +4",
|
||||
skills: "Stealth +6",
|
||||
senses: "darkvision 60 ft., passive Perception 9",
|
||||
languages: "Common, Goblin",
|
||||
traits: [{ name: "Nimble Escape", text: "Disengage or Hide as bonus." }],
|
||||
actions: [{ name: "Scimitar", text: "Melee: +4 to hit, 5 slashing." }],
|
||||
bonusActions: [{ name: "Nimble", text: "Disengage or Hide." }],
|
||||
reactions: [{ name: "Redirect", text: "Redirect attack to ally." }],
|
||||
};
|
||||
|
||||
const DRAGON: Creature = {
|
||||
id: creatureId("srd:dragon"),
|
||||
name: "Ancient Red Dragon",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
size: "Gargantuan",
|
||||
type: "dragon",
|
||||
alignment: "chaotic evil",
|
||||
ac: 22,
|
||||
hp: { average: 546, formula: "28d20 + 252" },
|
||||
speed: "40 ft., climb 40 ft., fly 80 ft.",
|
||||
abilities: { str: 30, dex: 10, con: 29, int: 18, wis: 15, cha: 23 },
|
||||
cr: "24",
|
||||
initiativeProficiency: 0,
|
||||
proficiencyBonus: 7,
|
||||
passive: 26,
|
||||
resist: "fire",
|
||||
immune: "fire",
|
||||
vulnerable: "cold",
|
||||
conditionImmune: "frightened",
|
||||
legendaryActions: {
|
||||
preamble: "The dragon can take 3 legendary actions.",
|
||||
entries: [
|
||||
{ name: "Detect", text: "Wisdom (Perception) check." },
|
||||
{ name: "Tail Attack", text: "Tail attack." },
|
||||
],
|
||||
},
|
||||
spellcasting: [
|
||||
{
|
||||
name: "Innate Spellcasting",
|
||||
headerText: "The dragon's spellcasting ability is Charisma.",
|
||||
atWill: ["detect magic", "suggestion"],
|
||||
daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
|
||||
restLong: [{ uses: 1, each: false, spells: ["wish"] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function renderStatBlock(creature: Creature) {
|
||||
return render(<StatBlock creature={creature} />);
|
||||
}
|
||||
|
||||
describe("StatBlock", () => {
|
||||
describe("header", () => {
|
||||
it("renders creature name", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Goblin" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders size, type, alignment", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByText("Small humanoid, neutral evil"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders source display name", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stats bar", () => {
|
||||
it("renders AC with source", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText(ARMOR_CLASS_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText("15")).toBeInTheDocument();
|
||||
expect(screen.getByText("(leather armor, shield)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders AC without source when acSource is undefined", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText("22")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders HP average and formula", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("7")).toBeInTheDocument();
|
||||
expect(screen.getByText("(2d6)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders speed", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("30 ft.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ability scores", () => {
|
||||
it("renders all 6 ability labels", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
for (const label of ["STR", "DEX", "CON", "INT", "WIS", "CHA"]) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders ability scores with modifier notation", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("(+2)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("properties", () => {
|
||||
it("renders saving throws when present", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("Saving Throws")).toBeInTheDocument();
|
||||
expect(screen.getByText(DEX_PLUS_4_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders skills when present", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("Skills")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders damage resistances, immunities, vulnerabilities", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText("Damage Resistances")).toBeInTheDocument();
|
||||
expect(screen.getByText("Damage Immunities")).toBeInTheDocument();
|
||||
expect(screen.getByText("Damage Vulnerabilities")).toBeInTheDocument();
|
||||
expect(screen.getByText("Condition Immunities")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits properties when undefined", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.queryByText("Damage Resistances")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Damage Immunities")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders CR and proficiency bonus", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("Challenge")).toBeInTheDocument();
|
||||
expect(screen.getByText(CR_QUARTER_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(PROF_BONUS_2_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("traits", () => {
|
||||
it("renders trait entries", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText(NIMBLE_ESCAPE_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("actions / bonus actions / reactions", () => {
|
||||
it("renders actions heading and entries", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Actions" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(SCIMITAR_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders bonus actions heading and entries", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Bonus Actions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders reactions heading and entries", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Reactions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("legendary actions", () => {
|
||||
it("renders legendary actions with preamble", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Legendary Actions" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("The dragon can take 3 legendary actions."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(DETECT_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(TAIL_ATTACK_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits legendary actions when undefined", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Legendary Actions" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spellcasting", () => {
|
||||
it("renders spellcasting block with header", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText(INNATE_SPELLCASTING_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders at-will spells", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText(AT_WILL_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(DETECT_MAGIC_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders daily spells", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText(DAILY_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(FIREBALL_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders long rest spells", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText(LONG_REST_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(WISH_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits spellcasting when undefined", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.queryByText(AT_WILL_REGEX)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useBulkImport } from "../use-bulk-import.js";
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||
getDefaultFetchUrl: (code: string, baseUrl: string) =>
|
||||
`${baseUrl}${code}.json`,
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
/** Flush microtasks so the internal async IIFE inside startImport settles. */
|
||||
function flushMicrotasks(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
describe("useBulkImport", () => {
|
||||
it("starts in idle state with all counters at 0", () => {
|
||||
const { result } = renderHook(() => useBulkImport());
|
||||
expect(result.current.state).toEqual({
|
||||
status: "idle",
|
||||
total: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("reset returns to idle state", async () => {
|
||||
const { result } = renderHook(() => useBulkImport());
|
||||
|
||||
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||
const fetchAndCacheSource = vi.fn();
|
||||
const refreshCache = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
result.current.startImport(
|
||||
"https://example.com/",
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.state.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("goes straight to complete when all sources are cached", async () => {
|
||||
const { result } = renderHook(() => useBulkImport());
|
||||
|
||||
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||
const fetchAndCacheSource = vi.fn();
|
||||
const refreshCache = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
result.current.startImport(
|
||||
"https://example.com/",
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(result.current.state.status).toBe("complete");
|
||||
expect(result.current.state.completed).toBe(3);
|
||||
expect(fetchAndCacheSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches uncached sources and completes", async () => {
|
||||
const { result } = renderHook(() => useBulkImport());
|
||||
|
||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await act(async () => {
|
||||
result.current.startImport(
|
||||
"https://example.com/",
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(result.current.state.status).toBe("complete");
|
||||
expect(result.current.state.completed).toBe(3);
|
||||
expect(result.current.state.failed).toBe(0);
|
||||
expect(fetchAndCacheSource).toHaveBeenCalledTimes(3);
|
||||
expect(refreshCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports partial-failure when some sources fail", async () => {
|
||||
const { result } = renderHook(() => useBulkImport());
|
||||
|
||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||
const fetchAndCacheSource = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("fail"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await act(async () => {
|
||||
result.current.startImport(
|
||||
"https://example.com/",
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(result.current.state.status).toBe("partial-failure");
|
||||
expect(result.current.state.completed).toBe(2);
|
||||
expect(result.current.state.failed).toBe(1);
|
||||
expect(refreshCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls refreshCache after all batches complete", async () => {
|
||||
const { result } = renderHook(() => useBulkImport());
|
||||
|
||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await act(async () => {
|
||||
result.current.startImport(
|
||||
"https://example.com/",
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(refreshCache).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { type CreatureId, combatantId } from "@initiative/domain";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useInitiativeRolls } from "../use-initiative-rolls.js";
|
||||
|
||||
const mockMakeStore = vi.fn(() => ({}));
|
||||
const mockWithUndo = vi.fn((fn: () => unknown) => fn());
|
||||
const mockGetCreature = vi.fn();
|
||||
const mockShowCreature = vi.fn();
|
||||
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: () => ({
|
||||
encounter: {
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Goblin",
|
||||
creatureId: "srd:goblin" as CreatureId,
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
makeStore: mockMakeStore,
|
||||
withUndo: mockWithUndo,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: () => ({
|
||||
getCreature: mockGetCreature,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/side-panel-context.js", () => ({
|
||||
useSidePanelContext: () => ({
|
||||
showCreature: mockShowCreature,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockRollInitiativeUseCase = vi.fn();
|
||||
const mockRollAllInitiativeUseCase = vi.fn();
|
||||
|
||||
vi.mock("@initiative/application", () => ({
|
||||
rollInitiativeUseCase: (...args: unknown[]) =>
|
||||
mockRollInitiativeUseCase(...args),
|
||||
rollAllInitiativeUseCase: (...args: unknown[]) =>
|
||||
mockRollAllInitiativeUseCase(...args),
|
||||
}));
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
describe("useInitiativeRolls", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("handleRollInitiative calls rollInitiativeUseCase via withUndo", () => {
|
||||
mockRollInitiativeUseCase.mockReturnValue({ initiative: 15 });
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||
|
||||
expect(mockWithUndo).toHaveBeenCalled();
|
||||
expect(mockRollInitiativeUseCase).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets rollSingleSkipped on domain error", () => {
|
||||
mockRollInitiativeUseCase.mockReturnValue({
|
||||
kind: "domain-error",
|
||||
code: "missing-source",
|
||||
message: "no source",
|
||||
});
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||
expect(result.current.rollSingleSkipped).toBe(true);
|
||||
expect(mockShowCreature).toHaveBeenCalledWith("srd:goblin");
|
||||
});
|
||||
|
||||
it("dismissRollSingleSkipped resets the flag", () => {
|
||||
mockRollInitiativeUseCase.mockReturnValue({
|
||||
kind: "domain-error",
|
||||
code: "missing-source",
|
||||
message: "no source",
|
||||
});
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||
expect(result.current.rollSingleSkipped).toBe(true);
|
||||
|
||||
act(() => result.current.dismissRollSingleSkipped());
|
||||
expect(result.current.rollSingleSkipped).toBe(false);
|
||||
});
|
||||
|
||||
it("handleRollAllInitiative sets rollSkippedCount when sources missing", () => {
|
||||
mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 3 });
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollAllInitiative());
|
||||
expect(result.current.rollSkippedCount).toBe(3);
|
||||
});
|
||||
|
||||
it("dismissRollSkipped resets the count", () => {
|
||||
mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 2 });
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollAllInitiative());
|
||||
act(() => result.current.dismissRollSkipped());
|
||||
expect(result.current.rollSkippedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useLongPress } from "../use-long-press.js";
|
||||
|
||||
function touchEvent(overrides?: Partial<React.TouchEvent>): React.TouchEvent {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as React.TouchEvent;
|
||||
}
|
||||
|
||||
describe("useLongPress", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns onTouchStart, onTouchEnd, onTouchMove handlers", () => {
|
||||
const { result } = renderHook(() => useLongPress(vi.fn()));
|
||||
expect(result.current.onTouchStart).toBeInstanceOf(Function);
|
||||
expect(result.current.onTouchEnd).toBeInstanceOf(Function);
|
||||
expect(result.current.onTouchMove).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it("fires onLongPress after 500ms hold", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
const e = touchEvent();
|
||||
act(() => result.current.onTouchStart(e));
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(onLongPress).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not fire if released before 500ms", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
act(() => result.current.onTouchStart(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
act(() => result.current.onTouchEnd(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels on touch move", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
act(() => result.current.onTouchStart(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
act(() => result.current.onTouchMove());
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onTouchEnd calls preventDefault after long press fires", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
act(() => result.current.onTouchStart(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
const preventDefaultSpy = vi.fn();
|
||||
const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
|
||||
act(() => result.current.onTouchEnd(endEvent));
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onTouchEnd does not preventDefault when long press did not fire", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
act(() => result.current.onTouchStart(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
const preventDefaultSpy = vi.fn();
|
||||
const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
|
||||
act(() => result.current.onTouchEnd(endEvent));
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSwipeToDismiss } from "../use-swipe-to-dismiss.js";
|
||||
|
||||
const PANEL_WIDTH = 300;
|
||||
|
||||
function makeTouchEvent(clientX: number, clientY = 0): React.TouchEvent {
|
||||
return {
|
||||
touches: [{ clientX, clientY }],
|
||||
currentTarget: {
|
||||
getBoundingClientRect: () => ({ width: PANEL_WIDTH }),
|
||||
},
|
||||
} as unknown as React.TouchEvent;
|
||||
}
|
||||
|
||||
describe("useSwipeToDismiss", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("starts with offsetX 0 and isSwiping false", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
expect(result.current.isSwiping).toBe(false);
|
||||
});
|
||||
|
||||
it("horizontal drag updates offsetX and sets isSwiping", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
|
||||
|
||||
expect(result.current.offsetX).toBe(50);
|
||||
expect(result.current.isSwiping).toBe(true);
|
||||
});
|
||||
|
||||
it("vertical drag is ignored after direction lock", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0, 0)));
|
||||
// Move vertically > 10px to lock vertical
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(0, 20)));
|
||||
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
});
|
||||
|
||||
it("small movement does not lock direction", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(5)));
|
||||
|
||||
// No direction locked yet, no update
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
expect(result.current.isSwiping).toBe(false);
|
||||
});
|
||||
|
||||
it("leftward drag is clamped to 0", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(100)));
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
|
||||
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
});
|
||||
|
||||
it("calls onDismiss when ratio exceeds threshold", () => {
|
||||
const onDismiss = vi.fn();
|
||||
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
// Move > 35% of panel width (300 * 0.35 = 105)
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(120)));
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(5000); // slow swipe
|
||||
act(() => result.current.handlers.onTouchEnd());
|
||||
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onDismiss with fast velocity", () => {
|
||||
const onDismiss = vi.fn();
|
||||
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
// Small distance but fast
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(30)));
|
||||
|
||||
// Very fast: 30px in 0.1s = 300px/s, velocity = 300/300 = 1.0 > 0.5
|
||||
vi.spyOn(Date, "now").mockReturnValue(100);
|
||||
act(() => result.current.handlers.onTouchEnd());
|
||||
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not dismiss when below thresholds", () => {
|
||||
const onDismiss = vi.fn();
|
||||
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
// Small distance, slow speed
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(20)));
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(5000);
|
||||
act(() => result.current.handlers.onTouchEnd());
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
expect(result.current.isSwiping).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
# Conventions (detailed)
|
||||
|
||||
These conventions supplement the overview in `CLAUDE.md`. Load this file when working in the relevant areas.
|
||||
|
||||
## Component Props
|
||||
|
||||
Max 8 explicitly declared props per component interface, enforced by `scripts/check-component-props.mjs` (uses the TypeScript compiler API). Run `pnpm check:props` to verify.
|
||||
|
||||
- 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()`.
|
||||
|
||||
## Domain Patterns
|
||||
|
||||
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical. See [ADR-003](adr/003-branded-types-for-identity.md).
|
||||
- **Domain events** are plain data objects with a `type` discriminant — no classes. See [ADR-002](adr/002-domain-events-as-plain-data.md).
|
||||
- **Errors as values** (`DomainError`), never thrown. See [ADR-001](adr/001-errors-as-values.md).
|
||||
+1
-1
@@ -26,4 +26,4 @@ pre-commit:
|
||||
- name: oxlint
|
||||
run: pnpm oxlint -- --deny warnings
|
||||
- name: test
|
||||
run: pnpm test
|
||||
run: pnpm vitest run --reporter=dot --coverage.reporter=text-summary
|
||||
|
||||
+4
-4
@@ -26,12 +26,12 @@ export default defineConfig({
|
||||
branches: 70,
|
||||
},
|
||||
"apps/web/src/hooks": {
|
||||
lines: 72,
|
||||
branches: 55,
|
||||
lines: 83,
|
||||
branches: 66,
|
||||
},
|
||||
"apps/web/src/components": {
|
||||
lines: 59,
|
||||
branches: 55,
|
||||
lines: 80,
|
||||
branches: 71,
|
||||
},
|
||||
"apps/web/src/components/ui": {
|
||||
lines: 93,
|
||||
|
||||
Reference in New Issue
Block a user