Compare commits
20 Commits
1de00e3d8e
..
0.9.16
| Author | SHA1 | Date | |
|---|---|---|---|
| 228c1c667f | |||
| 300d4b1f73 | |||
| 43546aaa7b | |||
| 09da9a8dfc | |||
| b229a0dac7 | |||
| 08b5db81ad | |||
| a89fac5c23 | |||
| b6ee4c8c86 | |||
| c295840b7b | |||
| d13641152f | |||
| 110f4726ae | |||
| 2bc22369ce | |||
| 2971d32f45 | |||
| a97044ec3e | |||
| a77db0eeee | |||
| d8c8a0c44d | |||
| 80dd68752e | |||
| 896fd427ed | |||
| 01b1bba6d6 | |||
| b7a97c3d88 |
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
name: commit
|
||||||
|
description: Create a git commit with pre-commit hooks (bypasses sandbox restrictions).
|
||||||
|
disable-model-invocation: true
|
||||||
|
allowed-tools: Bash(git *), Bash(pnpm *)
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
docs/agents/plans/
|
docs/agents/plans/
|
||||||
|
docs/agents/research/
|
||||||
|
.agent-tests/
|
||||||
.rodney/
|
.rodney/
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
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:
|
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
|
Templates requiring updates: none
|
||||||
-->
|
-->
|
||||||
# Encounter Console Constitution
|
# Encounter Console Constitution
|
||||||
@@ -113,6 +113,18 @@ architecture, and quality — not product behavior.
|
|||||||
(which creates a feature branch for the full speckit pipeline);
|
(which creates a feature branch for the full speckit pipeline);
|
||||||
changes to existing features update the existing spec via
|
changes to existing features update the existing spec via
|
||||||
`/integrate-issue`.
|
`/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
|
- The full pipeline (spec → plan → tasks → implement) applies to new
|
||||||
features and significant additions. Bug fixes, tooling changes,
|
features and significant additions. Bug fixes, tooling changes,
|
||||||
and trivial UI adjustments do not require specs.
|
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
|
**Compliance review**: Every spec and plan MUST include a
|
||||||
Constitution Check section validating adherence to all principles.
|
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,11 +1,11 @@
|
|||||||
# CLAUDE.md
|
# 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
|
## Commands
|
||||||
|
|
||||||
```bash
|
```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 oxlint # Type-aware linting (oxlint — complements Biome)
|
||||||
pnpm knip # Unused code detection (Knip)
|
pnpm knip # Unused code detection (Knip)
|
||||||
pnpm test # Run all tests (Vitest)
|
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.
|
- **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.
|
- **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.
|
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,21 +60,20 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
- React 19, Vite 6, Tailwind CSS v4
|
- React 19, Vite 6, Tailwind CSS v4
|
||||||
- Lucide React (icons)
|
- Lucide React (icons)
|
||||||
- `idb` (IndexedDB wrapper for bestiary cache)
|
- `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)
|
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
||||||
- **oxlint** for type-aware linting that Biome can't do (unnecessary type assertions, deprecated APIs, `replaceAll` preference, `String.raw`). Configured in `.oxlintrc.json`.
|
- **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 when required by the repo's ESM settings (e.g., `./types.js`).
|
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports.
|
||||||
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
- **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.
|
- **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.
|
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios 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`.
|
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
|
||||||
- **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).
|
|
||||||
- **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()`.
|
For component prop rules, export format compatibility, and ADRs, see [`docs/conventions.md`](docs/conventions.md).
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Self-Review Checklist
|
## Self-Review Checklist
|
||||||
|
|
||||||
@@ -86,21 +85,7 @@ Before finishing a change, consider:
|
|||||||
|
|
||||||
## Speckit Workflow
|
## Speckit Workflow
|
||||||
|
|
||||||
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
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.
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
| Scope | Workflow |
|
| 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` |
|
| 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` |
|
| 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
|
## Constitution
|
||||||
- `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 (key principles)
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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.
|
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 dev` | Start the dev server |
|
||||||
| `pnpm --filter web build` | Production build |
|
| `pnpm --filter web build` | Production build |
|
||||||
| `pnpm test` | Run all tests (Vitest) |
|
| `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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/web/ React 19 + Vite — UI components, hooks, adapters
|
apps/web/ React 19 + Vite — UI components, hooks, adapters
|
||||||
packages/domain/ Pure functions — state transitions, types, validation
|
packages/domain/ Pure functions — state transitions, types, validation
|
||||||
packages/app/ Use cases — orchestrates domain via port interfaces
|
packages/application/ Use cases — orchestrates domain via port interfaces
|
||||||
data/bestiary/ Bestiary index for creature search
|
data/bestiary/ Pre-built bestiary search index (~10k creatures)
|
||||||
scripts/ Build tooling (layer boundary checks, index generation)
|
scripts/ Build tooling (layer checks, index generation)
|
||||||
specs/ Feature specifications (spec → plan → tasks)
|
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)
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* jsdom doesn't implement HTMLDialogElement.showModal/close.
|
||||||
|
* Call this in beforeAll() for tests that render <Dialog>.
|
||||||
|
*/
|
||||||
|
export function polyfillDialog(): void {
|
||||||
|
if (typeof HTMLDialogElement.prototype.showModal !== "function") {
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal() {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof HTMLDialogElement.prototype.close !== "function") {
|
||||||
|
HTMLDialogElement.prototype.close = function close() {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
|||||||
}
|
}
|
||||||
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
||||||
// Clear cached creatures to pick up improved tag processing
|
// Clear cached creatures to pick up improved tag processing
|
||||||
transaction.objectStore(STORE_NAME).clear();
|
void transaction.objectStore(STORE_NAME).clear();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { ActionBar } from "../action-bar.js";
|
import { ActionBar } from "../action-bar.js";
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ beforeAll(() => {
|
|||||||
dispatchEvent: vi.fn(),
|
dispatchEvent: vi.fn(),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
polyfillDialog();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
@@ -118,4 +120,61 @@ describe("ActionBar", () => {
|
|||||||
screen.getByRole("button", { name: "More actions" }),
|
screen.getByRole("button", { name: "More actions" }),
|
||||||
).toBeInTheDocument();
|
).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,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";
|
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
||||||
|
|
||||||
const TEMP_HP_REGEX = /^\+\d/;
|
const TEMP_HP_REGEX = /^\+\d/;
|
||||||
|
const CURRENT_HP_7_REGEX = /Current HP: 7/;
|
||||||
|
const CURRENT_HP_REGEX = /Current HP/;
|
||||||
|
|
||||||
// Mock persistence — no localStorage interaction
|
// Mock persistence — no localStorage interaction
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
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", () => {
|
describe("temp HP display", () => {
|
||||||
it("shows +N when combatant has temp HP", () => {
|
it("shows +N when combatant has temp HP", () => {
|
||||||
renderRow({
|
renderRow({
|
||||||
|
|||||||
@@ -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,165 @@
|
|||||||
|
// @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 { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { CreatePlayerModal } from "../create-player-modal.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
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,63 @@
|
|||||||
|
// @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 { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { Dialog, DialogHeader } from "../ui/dialog.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
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,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,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,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,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,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,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(),
|
setEncounter: vi.fn(),
|
||||||
setUndoRedoState: vi.fn(),
|
setUndoRedoState: vi.fn(),
|
||||||
events: [],
|
events: [],
|
||||||
|
lastCreatureId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
mockUseEncounterContext.mockReturnValue(
|
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,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,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,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,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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
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 type { SearchResult } from "../contexts/bestiary-context.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
@@ -31,6 +38,7 @@ export function useActionBarState() {
|
|||||||
addFromBestiary,
|
addFromBestiary,
|
||||||
addMultipleFromBestiary,
|
addMultipleFromBestiary,
|
||||||
addFromPlayerCharacter,
|
addFromPlayerCharacter,
|
||||||
|
lastCreatureId,
|
||||||
} = useEncounterContext();
|
} = useEncounterContext();
|
||||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||||
useBestiaryContext();
|
useBestiaryContext();
|
||||||
@@ -38,6 +46,20 @@ export function useActionBarState() {
|
|||||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||||
useSidePanelContext();
|
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 [nameInput, setNameInput] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||||
@@ -73,13 +95,9 @@ export function useActionBarState() {
|
|||||||
|
|
||||||
const handleAddFromBestiary = useCallback(
|
const handleAddFromBestiary = useCallback(
|
||||||
(result: SearchResult) => {
|
(result: SearchResult) => {
|
||||||
const creatureId = addFromBestiary(result);
|
addFromBestiary(result);
|
||||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
|
||||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
|
||||||
showCreature(creatureId);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[addFromBestiary, panelView.mode, showCreature],
|
[addFromBestiary],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleViewStatBlock = useCallback(
|
const handleViewStatBlock = useCallback(
|
||||||
@@ -99,21 +117,10 @@ export function useActionBarState() {
|
|||||||
if (queued.count === 1) {
|
if (queued.count === 1) {
|
||||||
handleAddFromBestiary(queued.result);
|
handleAddFromBestiary(queued.result);
|
||||||
} else {
|
} else {
|
||||||
const creatureId = addMultipleFromBestiary(queued.result, queued.count);
|
addMultipleFromBestiary(queued.result, queued.count);
|
||||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
|
||||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
|
||||||
showCreature(creatureId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
clearInput();
|
clearInput();
|
||||||
}, [
|
}, [queued, handleAddFromBestiary, addMultipleFromBestiary, clearInput]);
|
||||||
queued,
|
|
||||||
handleAddFromBestiary,
|
|
||||||
addMultipleFromBestiary,
|
|
||||||
panelView.mode,
|
|
||||||
showCreature,
|
|
||||||
clearInput,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const parseNum = (v: string): number | undefined => {
|
const parseNum = (v: string): number | undefined => {
|
||||||
if (v.trim() === "") return undefined;
|
if (v.trim() === "") return undefined;
|
||||||
|
|||||||
+432
-272
@@ -36,7 +36,7 @@ import {
|
|||||||
pushUndo,
|
pushUndo,
|
||||||
resolveCreatureName,
|
resolveCreatureName,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
loadEncounter,
|
loadEncounter,
|
||||||
saveEncounter,
|
saveEncounter,
|
||||||
@@ -46,6 +46,51 @@ import {
|
|||||||
saveUndoRedoStacks,
|
saveUndoRedoStacks,
|
||||||
} from "../persistence/undo-redo-storage.js";
|
} 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 COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||||
|
|
||||||
const EMPTY_ENCOUNTER: Encounter = {
|
const EMPTY_ENCOUNTER: Encounter = {
|
||||||
@@ -54,12 +99,6 @@ const EMPTY_ENCOUNTER: Encounter = {
|
|||||||
roundNumber: 1,
|
roundNumber: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
function initializeEncounter(): Encounter {
|
|
||||||
const stored = loadEncounter();
|
|
||||||
if (stored !== null) return stored;
|
|
||||||
return EMPTY_ENCOUNTER;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveNextId(encounter: Encounter): number {
|
function deriveNextId(encounter: Encounter): number {
|
||||||
let max = 0;
|
let max = 0;
|
||||||
for (const c of encounter.combatants) {
|
for (const c of encounter.combatants) {
|
||||||
@@ -72,11 +111,283 @@ function deriveNextId(encounter: Encounter): number {
|
|||||||
return max;
|
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 > 0 ? entry.hp : undefined,
|
||||||
|
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() {
|
export function useEncounter() {
|
||||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
const [state, dispatch] = useReducer(encounterReducer, null, initializeState);
|
||||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
const { encounter, undoRedoState, events } = state;
|
||||||
const [undoRedoState, setUndoRedoState] =
|
|
||||||
useState<UndoRedoState>(loadUndoRedoStacks);
|
|
||||||
const encounterRef = useRef(encounter);
|
const encounterRef = useRef(encounter);
|
||||||
encounterRef.current = encounter;
|
encounterRef.current = encounter;
|
||||||
const undoRedoRef = useRef(undoRedoState);
|
const undoRedoRef = useRef(undoRedoState);
|
||||||
@@ -90,22 +401,17 @@ export function useEncounter() {
|
|||||||
saveUndoRedoStacks(undoRedoState);
|
saveUndoRedoStacks(undoRedoState);
|
||||||
}, [undoRedoState]);
|
}, [undoRedoState]);
|
||||||
|
|
||||||
|
// Escape hatches for useInitiativeRolls (needs raw port access)
|
||||||
const makeStore = useCallback((): EncounterStore => {
|
const makeStore = useCallback((): EncounterStore => {
|
||||||
return {
|
return {
|
||||||
get: () => encounterRef.current,
|
get: () => encounterRef.current,
|
||||||
save: (e) => {
|
save: (e) => {
|
||||||
encounterRef.current = e;
|
encounterRef.current = e;
|
||||||
setEncounter(e);
|
dispatch({
|
||||||
},
|
type: "import",
|
||||||
};
|
encounter: e,
|
||||||
}, []);
|
undoRedoState: undoRedoRef.current,
|
||||||
|
});
|
||||||
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
|
|
||||||
return {
|
|
||||||
get: () => undoRedoRef.current,
|
|
||||||
save: (s) => {
|
|
||||||
undoRedoRef.current = s;
|
|
||||||
setUndoRedoState(s);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -116,245 +422,21 @@ export function useEncounter() {
|
|||||||
if (!isDomainError(result)) {
|
if (!isDomainError(result)) {
|
||||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||||
undoRedoRef.current = newState;
|
undoRedoRef.current = newState;
|
||||||
setUndoRedoState(newState);
|
dispatch({
|
||||||
|
type: "import",
|
||||||
|
encounter: encounterRef.current,
|
||||||
|
undoRedoState: newState,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const dispatchAction = useCallback(
|
// Derived state
|
||||||
(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]);
|
|
||||||
|
|
||||||
const canUndo = undoRedoState.undoStack.length > 0;
|
const canUndo = undoRedoState.undoStack.length > 0;
|
||||||
const canRedo = undoRedoState.redoStack.length > 0;
|
const canRedo = undoRedoState.redoStack.length > 0;
|
||||||
|
|
||||||
const hasTempHp = encounter.combatants.some(
|
const hasTempHp = encounter.combatants.some(
|
||||||
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isEmpty = encounter.combatants.length === 0;
|
const isEmpty = encounter.combatants.length === 0;
|
||||||
const hasCreatureCombatants = encounter.combatants.some(
|
const hasCreatureCombatants = encounter.combatants.some(
|
||||||
(c) => c.creatureId != null,
|
(c) => c.creatureId != null,
|
||||||
@@ -373,27 +455,105 @@ export function useEncounter() {
|
|||||||
canRollAllInitiative,
|
canRollAllInitiative,
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
advanceTurn,
|
advanceTurn: useCallback(() => dispatch({ type: "advance-turn" }), []),
|
||||||
retreatTurn,
|
retreatTurn: useCallback(() => dispatch({ type: "retreat-turn" }), []),
|
||||||
addCombatant,
|
addCombatant: useCallback(
|
||||||
clearEncounter,
|
(name: string, init?: CombatantInit) =>
|
||||||
removeCombatant,
|
dispatch({ type: "add-combatant", name, init }),
|
||||||
editCombatant,
|
[],
|
||||||
setInitiative,
|
),
|
||||||
setHp,
|
removeCombatant: useCallback(
|
||||||
adjustHp,
|
(id: CombatantId) => dispatch({ type: "remove-combatant", id }),
|
||||||
setTempHp,
|
[],
|
||||||
setAc,
|
),
|
||||||
toggleCondition,
|
editCombatant: useCallback(
|
||||||
toggleConcentration,
|
(id: CombatantId, newName: string) =>
|
||||||
addFromBestiary,
|
dispatch({ type: "edit-combatant", id, newName }),
|
||||||
addMultipleFromBestiary,
|
[],
|
||||||
addFromPlayerCharacter,
|
),
|
||||||
undo: undoAction,
|
setInitiative: useCallback(
|
||||||
redo: redoAction,
|
(id: CombatantId, value: number | undefined) =>
|
||||||
setEncounter,
|
dispatch({ type: "set-initiative", id, value }),
|
||||||
setUndoRedoState,
|
[],
|
||||||
|
),
|
||||||
|
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,
|
makeStore,
|
||||||
withUndo,
|
withUndo,
|
||||||
|
lastCreatureId: state.lastCreatureId,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
+27
-2
@@ -1,4 +1,29 @@
|
|||||||
pre-commit:
|
pre-commit:
|
||||||
|
parallel: true
|
||||||
jobs:
|
jobs:
|
||||||
- name: check
|
- name: audit
|
||||||
run: pnpm check
|
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 -- --deny warnings
|
||||||
|
- name: test
|
||||||
|
run: pnpm vitest run --reporter=dot --coverage.reporter=text-summary
|
||||||
|
|||||||
+2
-2
@@ -31,10 +31,10 @@
|
|||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"jscpd": "jscpd",
|
"jscpd": "jscpd",
|
||||||
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
||||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny warnings",
|
||||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||||
"check:props": "node scripts/check-component-props.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 --deny warnings && vitest run"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { Encounter, PlayerCharacter } from "@initiative/domain";
|
import type {
|
||||||
import { isDomainError } from "@initiative/domain";
|
Encounter,
|
||||||
import type { EncounterStore, PlayerCharacterStore } from "../ports.js";
|
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 {
|
export function requireSaved<T>(value: T | null): T {
|
||||||
if (value === null) throw new Error("Expected store.saved to be non-null");
|
if (value === null) throw new Error("Expected store.saved to be non-null");
|
||||||
@@ -52,3 +60,17 @@ export function stubPlayerCharacterStore(
|
|||||||
};
|
};
|
||||||
return stub;
|
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,
|
type ConditionId,
|
||||||
combatantId,
|
combatantId,
|
||||||
createEncounter,
|
createEncounter,
|
||||||
|
EMPTY_UNDO_REDO_STATE,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
playerCharacterId,
|
playerCharacterId,
|
||||||
|
pushUndo,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { addCombatantUseCase } from "../add-combatant-use-case.js";
|
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 { deletePlayerCharacterUseCase } from "../delete-player-character-use-case.js";
|
||||||
import { editCombatantUseCase } from "../edit-combatant-use-case.js";
|
import { editCombatantUseCase } from "../edit-combatant-use-case.js";
|
||||||
import { editPlayerCharacterUseCase } from "../edit-player-character-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 { removeCombatantUseCase } from "../remove-combatant-use-case.js";
|
||||||
import { retreatTurnUseCase } from "../retreat-turn-use-case.js";
|
import { retreatTurnUseCase } from "../retreat-turn-use-case.js";
|
||||||
import { setAcUseCase } from "../set-ac-use-case.js";
|
import { setAcUseCase } from "../set-ac-use-case.js";
|
||||||
import { setHpUseCase } from "../set-hp-use-case.js";
|
import { setHpUseCase } from "../set-hp-use-case.js";
|
||||||
import { setInitiativeUseCase } from "../set-initiative-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 { toggleConcentrationUseCase } from "../toggle-concentration-use-case.js";
|
||||||
import { toggleConditionUseCase } from "../toggle-condition-use-case.js";
|
import { toggleConditionUseCase } from "../toggle-condition-use-case.js";
|
||||||
|
import { undoUseCase } from "../undo-use-case.js";
|
||||||
import {
|
import {
|
||||||
requireSaved,
|
requireSaved,
|
||||||
stubEncounterStore,
|
stubEncounterStore,
|
||||||
stubPlayerCharacterStore,
|
stubPlayerCharacterStore,
|
||||||
|
stubUndoRedoStore,
|
||||||
} from "./helpers.js";
|
} from "./helpers.js";
|
||||||
|
|
||||||
const ID_A = combatantId("a");
|
const ID_A = combatantId("a");
|
||||||
@@ -386,3 +392,80 @@ describe("editPlayerCharacterUseCase", () => {
|
|||||||
expect(store.saved).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,11 +9,14 @@
|
|||||||
* Only scans component files (not hooks, adapters, etc.) and only
|
* Only scans component files (not hooks, adapters, etc.) and only
|
||||||
* counts properties declared directly in *Props interfaces — inherited
|
* counts properties declared directly in *Props interfaces — inherited
|
||||||
* or extended HTML attributes are not counted.
|
* 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 { execSync } from "node:child_process";
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { relative } from "node:path";
|
import { relative } from "node:path";
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
const MAX_PROPS = 8;
|
const MAX_PROPS = 8;
|
||||||
|
|
||||||
@@ -25,66 +28,38 @@ const files = execSync(
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
.filter(Boolean);
|
.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;
|
let errors = 0;
|
||||||
|
|
||||||
const propsRegex = /^(?:export\s+)?interface\s+(\w+Props)\s*\{/;
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const content = readFileSync(file, "utf-8");
|
const sourceFile = program.getSourceFile(file);
|
||||||
const lines = content.split("\n");
|
if (!sourceFile) continue;
|
||||||
|
|
||||||
let inInterface = false;
|
ts.forEachChild(sourceFile, (node) => {
|
||||||
let interfaceName = "";
|
if (!ts.isInterfaceDeclaration(node)) return;
|
||||||
let braceDepth = 0;
|
if (!node.name.text.endsWith("Props")) return;
|
||||||
let parenDepth = 0;
|
|
||||||
let propCount = 0;
|
|
||||||
let startLine = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
const propCount = node.members.filter((m) =>
|
||||||
const line = lines[i];
|
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) {
|
if (propCount > MAX_PROPS) {
|
||||||
const rel = relative(process.cwd(), file);
|
const rel = relative(process.cwd(), file);
|
||||||
|
const { line } = sourceFile.getLineAndCharacterOfPosition(node.name.pos);
|
||||||
console.error(
|
console.error(
|
||||||
`${rel}:${startLine}: ${interfaceName} has ${propCount} props (max ${MAX_PROPS})`,
|
`${rel}:${line + 1}: ${node.name.text} has ${propCount} props (max ${MAX_PROPS})`,
|
||||||
);
|
);
|
||||||
errors++;
|
errors++;
|
||||||
}
|
}
|
||||||
inInterface = false;
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors > 0) {
|
if (errors > 0) {
|
||||||
|
|||||||
+14
-15
@@ -9,34 +9,33 @@ export default defineConfig({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
exclude: ["**/dist/**"],
|
exclude: ["**/dist/**"],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
autoUpdate: true,
|
|
||||||
"packages/domain/src": {
|
"packages/domain/src": {
|
||||||
lines: 99,
|
lines: 98,
|
||||||
branches: 97,
|
branches: 96,
|
||||||
},
|
},
|
||||||
"packages/application/src": {
|
"packages/application/src": {
|
||||||
lines: 97,
|
lines: 96,
|
||||||
branches: 94,
|
branches: 90,
|
||||||
},
|
},
|
||||||
"apps/web/src/adapters": {
|
"apps/web/src/adapters": {
|
||||||
lines: 72,
|
lines: 68,
|
||||||
branches: 78,
|
branches: 56,
|
||||||
},
|
},
|
||||||
"apps/web/src/persistence": {
|
"apps/web/src/persistence": {
|
||||||
lines: 90,
|
lines: 85,
|
||||||
branches: 71,
|
branches: 70,
|
||||||
},
|
},
|
||||||
"apps/web/src/hooks": {
|
"apps/web/src/hooks": {
|
||||||
lines: 59,
|
lines: 83,
|
||||||
branches: 85,
|
branches: 66,
|
||||||
},
|
},
|
||||||
"apps/web/src/components": {
|
"apps/web/src/components": {
|
||||||
lines: 52,
|
lines: 80,
|
||||||
branches: 64,
|
branches: 71,
|
||||||
},
|
},
|
||||||
"apps/web/src/components/ui": {
|
"apps/web/src/components/ui": {
|
||||||
lines: 73,
|
lines: 93,
|
||||||
branches: 96,
|
branches: 90,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user