Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30e7ed4121 | ||
|
|
5540baf14c | ||
|
|
1ae9e12cff | ||
|
|
2c643cc98b | ||
|
|
228c1c667f | ||
|
|
300d4b1f73 | ||
|
|
43546aaa7b | ||
|
|
09da9a8dfc | ||
|
|
b229a0dac7 | ||
|
|
08b5db81ad | ||
|
|
a89fac5c23 | ||
|
|
b6ee4c8c86 | ||
|
|
c295840b7b | ||
|
|
d13641152f | ||
|
|
110f4726ae | ||
|
|
2bc22369ce | ||
|
|
2971d32f45 | ||
|
|
a97044ec3e | ||
|
|
a77db0eeee | ||
|
|
d8c8a0c44d | ||
|
|
80dd68752e | ||
|
|
896fd427ed | ||
|
|
01b1bba6d6 | ||
|
|
b7a97c3d88 | ||
|
|
1de00e3d8e | ||
|
|
f4fb69dbc7 | ||
|
|
ef76b9c90b | ||
|
|
36122b500b | ||
|
|
f4355a8675 | ||
|
|
209df13c32 | ||
|
|
4969ed069b | ||
|
|
fba83bebd6 | ||
|
|
f6766b729d | ||
|
|
f10c67a5ba | ||
|
|
9437272fe0 | ||
|
|
541e04b732 |
75
.claude/skills/commit/SKILL.md
Normal file
75
.claude/skills/commit/SKILL.md
Normal file
@@ -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.
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,4 +12,6 @@ Thumbs.db
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
docs/agents/plans/
|
||||
docs/agents/research/
|
||||
.agent-tests/
|
||||
.rodney/
|
||||
|
||||
9
.jsinspectrc
Normal file
9
.jsinspectrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"threshold": 50,
|
||||
"minInstances": 3,
|
||||
"identifiers": false,
|
||||
"literals": false,
|
||||
"ignore": "dist|__tests__|node_modules",
|
||||
"reporter": "default",
|
||||
"truncate": 100
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
───────────────────
|
||||
Version change: 3.0.0 → 3.1.0 (MINOR — new principle II-A: context-based state flow)
|
||||
Version change: 3.1.0 → 3.2.0 (MINOR — artifact lifecycle guidance)
|
||||
Modified sections:
|
||||
- Core Principles: added II-A. Context-Based State Flow (max 8 props, context over prop drilling)
|
||||
- Development Workflow: added artifact lifecycle rules (spec.md living, plan/tasks bounded, tests authoritative)
|
||||
Templates requiring updates: none
|
||||
-->
|
||||
# Encounter Console Constitution
|
||||
@@ -113,6 +113,18 @@ architecture, and quality — not product behavior.
|
||||
(which creates a feature branch for the full speckit pipeline);
|
||||
changes to existing features update the existing spec via
|
||||
`/integrate-issue`.
|
||||
- **Artifact lifecycles differ by type**:
|
||||
- `spec.md` is a **living capability document** — it describes what
|
||||
the feature does and is updated whenever the feature meaningfully
|
||||
changes. It survives across multiple rounds of work.
|
||||
- `plan.md` and `tasks.md` are **bounded work packages** — they
|
||||
describe what to do for a specific increment of work. After
|
||||
completion they become historical records. The next round of work
|
||||
on the same feature gets a new plan, not an update to the old one.
|
||||
- Tests are the **executable ground truth**. When a spec's
|
||||
acceptance criteria and the tests disagree, the tests are
|
||||
authoritative. Spec prose captures intent and context; tests
|
||||
capture actual behavior.
|
||||
- The full pipeline (spec → plan → tasks → implement) applies to new
|
||||
features and significant additions. Bug fixes, tooling changes,
|
||||
and trivial UI adjustments do not require specs.
|
||||
@@ -156,4 +168,4 @@ MUST comply with its principles.
|
||||
**Compliance review**: Every spec and plan MUST include a
|
||||
Constitution Check section validating adherence to all principles.
|
||||
|
||||
**Version**: 3.1.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-19
|
||||
**Version**: 3.2.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-30
|
||||
|
||||
103
CLAUDE.md
103
CLAUDE.md
@@ -1,11 +1,11 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd)
|
||||
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd + jsinspect)
|
||||
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
|
||||
pnpm knip # Unused code detection (Knip)
|
||||
pnpm test # Run all tests (Vitest)
|
||||
@@ -30,7 +30,7 @@ apps/web (React 19 + Vite) → packages/application (use cases) → packages
|
||||
|
||||
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
|
||||
- **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
|
||||
- **Web** — React adapter. Implements ports using hooks/state. All UI components, routing, and user interaction live here.
|
||||
- **Web** — React adapter. Implements ports using hooks/state. All UI components and user interaction live here.
|
||||
|
||||
Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which runs as a Vitest test. Domain and application must never import from React, Vite, or upper layers.
|
||||
|
||||
@@ -60,20 +60,69 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
||||
- React 19, Vite 6, Tailwind CSS v4
|
||||
- Lucide React (icons)
|
||||
- `idb` (IndexedDB wrapper for bestiary cache)
|
||||
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection)
|
||||
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection), jsinspect-plus (structural duplication)
|
||||
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
||||
- **oxlint** for type-aware linting that Biome can't do (unnecessary type assertions, deprecated APIs, `replaceAll` preference, `String.raw`). Configured in `.oxlintrc.json`.
|
||||
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
|
||||
- **oxlint** for type-aware linting that Biome can't do. Configured in `.oxlintrc.json`.
|
||||
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports.
|
||||
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
||||
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
||||
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
|
||||
- **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.
|
||||
- **Tests** live in `__tests__/` directories adjacent to source. See the Testing section below for conventions on mocking, assertions, and per-layer approach.
|
||||
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
|
||||
|
||||
For component prop rules, export format compatibility, and ADRs, see [`docs/conventions.md`](docs/conventions.md).
|
||||
|
||||
## Testing
|
||||
|
||||
### Philosophy
|
||||
|
||||
Test **user-visible behavior**, not implementation details. A good test answers "does this feature work?" not "does this internal function get called?"
|
||||
|
||||
### Adapter Injection
|
||||
|
||||
Adapters (storage, cache, browser APIs) are provided via `AdapterContext`. Production wires real implementations; tests wire in-memory implementations. This means:
|
||||
- No `vi.mock()` for adapter or persistence modules
|
||||
- Tests control adapter behavior by configuring the in-memory implementation
|
||||
- Type changes in adapter interfaces are caught at compile time
|
||||
|
||||
### Per-Layer Approach
|
||||
|
||||
| Layer | How to test |
|
||||
|---|---|
|
||||
| Domain (`packages/domain`) | Pure unit tests, no mocks, test invariants and acceptance scenarios |
|
||||
| Application (`packages/application`) | Mock port interfaces only, use real domain logic |
|
||||
| Hooks (context-wrapped) | Test via `renderHook` with `AllProviders` wrapping in-memory adapters |
|
||||
| Hooks (component-specific) | Test through the component that uses them |
|
||||
| Components | Render with `AllProviders`, use in-memory adapters, use `userEvent` for interactions |
|
||||
|
||||
### Test Data
|
||||
|
||||
Use factory functions from `apps/web/src/__tests__/factories/` to construct domain objects. Each factory provides sensible defaults overridden via `Partial<T>`:
|
||||
|
||||
```typescript
|
||||
import { buildEncounter } from "../../__tests__/factories/build-encounter.js";
|
||||
import { buildCombatant } from "../../__tests__/factories/build-combatant.js";
|
||||
|
||||
const encounter = buildEncounter({
|
||||
combatants: [buildCombatant({ name: "Goblin" })],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
});
|
||||
```
|
||||
|
||||
Add new factory files as needed (one per domain type). Don't inline test data construction — use factories so type changes are caught at compile time.
|
||||
|
||||
### Anti-Patterns
|
||||
|
||||
- **`vi.mock()` for adapters**: Use in-memory adapter implementations via `AdapterContext` instead
|
||||
- **Mocking contexts**: Use `AllProviders` and drive state through real hooks instead of `vi.mock("../../contexts/...")`. Exception: context mocks are acceptable when the component under test requires specific state machine states that cannot be reached through adapter configuration alone — document the reason in a comment at the top of the test file.
|
||||
- **Stubbing child components**: Render real children; stub only if the child has heavy I/O that can't be mocked at the adapter level
|
||||
- **Asserting mock call counts**: Prefer asserting what the user sees (`screen.getByText(...)`) over `expect(mockFn).toHaveBeenCalledWith(...)`
|
||||
- **Testing internal state**: Don't assert `result.current.suggestionIndex === 0`; assert the first suggestion is highlighted
|
||||
- **Assertion-free tests**: Every `it()` block must contain at least one `expect()`. Tests that render without asserting inflate coverage without catching bugs.
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
@@ -85,19 +134,7 @@ Before finishing a change, consider:
|
||||
|
||||
## Speckit Workflow
|
||||
|
||||
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
||||
|
||||
### Issue-driven workflow
|
||||
- `/write-issue` — create a well-structured Gitea issue via interactive interview
|
||||
- `/integrate-issue <number>` — fetch an issue, route it to the right spec, and update the spec with the new/changed requirements. Then implement directly.
|
||||
- `/sync-issue <number>` — push acceptance criteria from the spec back to the Gitea issue
|
||||
|
||||
### RPI skills (Research → Plan → Implement)
|
||||
- `rpi-research` — deep codebase research producing a written report in `docs/agents/research/`
|
||||
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
|
||||
- `rpi-implement` — execute a plan file phase by phase with automated + manual verification
|
||||
|
||||
### Choosing the right workflow by scope
|
||||
Specs are **living documents** in `specs/NNN-feature-name/` that describe features, not individual changes. Use `/speckit.*` and RPI skills (`rpi-research`, `rpi-plan`, `rpi-implement`) to manage them — skill descriptions have full usage details.
|
||||
|
||||
| Scope | Workflow |
|
||||
|---|---|
|
||||
@@ -106,22 +143,8 @@ Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Spec
|
||||
| Larger addition to existing feature | `/integrate-issue` → `rpi-research` → `rpi-plan` → `rpi-implement` |
|
||||
| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` |
|
||||
|
||||
Speckit manages **what** to build (specs as living documents). RPI manages **how** to build it (research, planning, execution). The full speckit pipeline is for new features. For changes to existing features, update the spec via `/integrate-issue`, then use RPI skills if the change is non-trivial.
|
||||
**Research scope**: Always scan for existing patterns similar to what the feature needs. Identify extraction and consolidation opportunities before implementation, not during.
|
||||
|
||||
### Current feature specs
|
||||
- `specs/001-combatant-management/` — CRUD, persistence, clear, batch add, confirm buttons
|
||||
- `specs/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar
|
||||
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
||||
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
|
||||
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
|
||||
|
||||
## Constitution (key principles)
|
||||
|
||||
The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
||||
|
||||
1. **Deterministic Domain Core** — Pure state transitions only; no I/O, randomness, or clocks in domain.
|
||||
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
|
||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
||||
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
|
||||
## Constitution
|
||||
|
||||
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.
|
||||
|
||||
82
README.md
82
README.md
@@ -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.
|
||||
|
||||
@@ -7,8 +7,10 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
|
||||
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
|
||||
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
||||
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
||||
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||
- **Player characters** — create reusable player character templates with name, AC, HP, level, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||
- **Encounter difficulty** — live 3-bar indicator in the top bar showing encounter difficulty (Trivial/Low/Moderate/High) based on the 2024 5.5e XP budget system; automatically derived from PC levels and bestiary creature CRs
|
||||
- **Undo/redo** — reverse any encounter action with Undo/Redo buttons or keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac); history persists across page reloads
|
||||
- **Import/export** — export the full encounter state (combatants, undo/redo history, player characters) as a JSON file or copy to clipboard; import from file upload or pasted JSON with validation and confirmation
|
||||
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
||||
|
||||
## Prerequisites
|
||||
@@ -32,16 +34,42 @@ Open `http://localhost:5173`.
|
||||
| `pnpm --filter web dev` | Start the dev server |
|
||||
| `pnpm --filter web build` | Production build |
|
||||
| `pnpm test` | Run all tests (Vitest) |
|
||||
| `pnpm check` | Full merge gate (knip, biome, typecheck, test, jscpd) |
|
||||
| `pnpm test:watch` | Tests in watch mode |
|
||||
| `pnpm vitest run path/to/test.ts` | Run a single test file |
|
||||
| `pnpm typecheck` | TypeScript type checking |
|
||||
| `pnpm lint` | Biome lint |
|
||||
| `pnpm format` | Biome format (writes changes) |
|
||||
| `pnpm check` | Full merge gate (see below) |
|
||||
|
||||
### Merge gate (`pnpm check`)
|
||||
|
||||
All of these run at pre-commit via Lefthook (in parallel where possible):
|
||||
|
||||
- `pnpm audit` — security audit
|
||||
- `knip` — unused code detection
|
||||
- `biome check` — formatting + linting
|
||||
- `oxlint` — type-aware linting (complements Biome)
|
||||
- Custom scripts — lint-ignore caps, className enforcement, component prop limits
|
||||
- `tsc --build` — TypeScript strict mode
|
||||
- `vitest run` — tests with per-path coverage thresholds
|
||||
- `jscpd` + `jsinspect` — copy-paste and structural duplication detection
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- TypeScript 5.8 (strict mode), React 19, Vite 6
|
||||
- Tailwind CSS v4 (dark/light theme)
|
||||
- Biome 2.4 (formatting + linting), oxlint (type-aware linting)
|
||||
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||
- Knip (unused code), jscpd + jsinspect (duplication detection)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/web/ React 19 + Vite — UI components, hooks, adapters
|
||||
packages/domain/ Pure functions — state transitions, types, validation
|
||||
packages/app/ Use cases — orchestrates domain via port interfaces
|
||||
data/bestiary/ Bestiary index for creature search
|
||||
scripts/ Build tooling (layer boundary checks, index generation)
|
||||
packages/application/ Use cases — orchestrates domain via port interfaces
|
||||
data/bestiary/ Pre-built bestiary search index (~10k creatures)
|
||||
scripts/ Build tooling (layer checks, index generation)
|
||||
specs/ Feature specifications (spec → plan → tasks)
|
||||
```
|
||||
|
||||
@@ -53,5 +81,45 @@ Strict layered architecture with enforced dependency direction:
|
||||
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
|
||||
```
|
||||
|
||||
Domain is pure — no I/O, no randomness, no framework imports. Layer boundaries are enforced by automated import checks that run as part of the test suite. See [CLAUDE.md](./CLAUDE.md) for full conventions.
|
||||
- **Domain** — pure functions, no I/O, no randomness, no framework imports. Errors returned as values (`DomainError`), never thrown.
|
||||
- **Application** — orchestrates domain calls via port interfaces (`EncounterStore`, `PlayerCharacterStore`, etc.). No business logic.
|
||||
- **Web** — React adapter. Implements ports using hooks/state. All UI components, persistence, and external data access live here.
|
||||
|
||||
Layer boundaries are enforced by automated import checks that run as part of the test suite.
|
||||
|
||||
## Contributing
|
||||
|
||||
### Workflow
|
||||
|
||||
Development is spec-driven. Feature specs live in `specs/NNN-feature-name/` and are managed through Claude Code skills (see [CLAUDE.md](./CLAUDE.md) for full details).
|
||||
|
||||
| Scope | What to do |
|
||||
|-------|-----------|
|
||||
| Bug fix / CSS tweak | Fix it, run `pnpm check`, commit. Optionally use `/browser-interactive-testing` for visual verification. |
|
||||
| Change to existing feature | Update the feature spec, then implement |
|
||||
| Larger change to existing feature | Update the spec → `/rpi-research` → `/rpi-plan` → `/rpi-implement` |
|
||||
| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` |
|
||||
|
||||
Use `/write-issue` to create well-structured Gitea issues, and `/integrate-issue` to pull an existing issue's requirements into the relevant feature spec.
|
||||
|
||||
### Before committing
|
||||
|
||||
Run `pnpm check` — Lefthook runs this automatically at pre-commit, but running it manually first saves time. All checks must pass.
|
||||
|
||||
### Conventions
|
||||
|
||||
- **Biome** for formatting and linting — tab indentation, 80-char lines
|
||||
- **TypeScript strict mode** with `verbatimModuleSyntax` (type-only imports must use `import type`)
|
||||
- **Max 8 props** per component interface — use React context for shared state
|
||||
- **Tests** in `__tests__/` directories — test pure functions directly, use `renderHook` for hooks
|
||||
|
||||
See [CLAUDE.md](./CLAUDE.md) for the full conventions and project constitution.
|
||||
|
||||
## Bestiary Index
|
||||
|
||||
The bestiary search index (`data/bestiary/index.json`) is pre-built and checked into the repo. To regenerate it (e.g., after a new source book release):
|
||||
|
||||
1. Clone [5etools-mirror-3/5etools-src](https://github.com/5etools-mirror-3/5etools-src) locally
|
||||
2. Run `node scripts/generate-bestiary-index.mjs /path/to/5etools-src`
|
||||
|
||||
The script extracts creature names, stats, and source info into a compact search index.
|
||||
|
||||
@@ -30,6 +30,13 @@ export function App() {
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||
|
||||
// Close the side panel when the encounter becomes empty
|
||||
useEffect(() => {
|
||||
if (isEmpty) {
|
||||
sidePanel.dismissPanel();
|
||||
}
|
||||
}, [isEmpty, sidePanel.dismissPanel]);
|
||||
|
||||
// Auto-scroll to active combatant when turn changes
|
||||
const activeIndex = encounter.activeIndex;
|
||||
useEffect(() => {
|
||||
|
||||
108
apps/web/src/__tests__/adapters/in-memory-adapters.ts
Normal file
108
apps/web/src/__tests__/adapters/in-memory-adapters.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
type Creature,
|
||||
type CreatureId,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
type Encounter,
|
||||
type PlayerCharacter,
|
||||
type UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import type { Adapters } from "../../contexts/adapter-context.js";
|
||||
|
||||
export function createTestAdapters(options?: {
|
||||
encounter?: Encounter | null;
|
||||
undoRedoState?: UndoRedoState;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
sources?: Map<
|
||||
string,
|
||||
{ displayName: string; creatures: Creature[]; cachedAt: number }
|
||||
>;
|
||||
}): Adapters {
|
||||
let storedEncounter = options?.encounter ?? null;
|
||||
let storedUndoRedo = options?.undoRedoState ?? EMPTY_UNDO_REDO_STATE;
|
||||
let storedPCs = options?.playerCharacters ?? [];
|
||||
const sourceStore =
|
||||
options?.sources ??
|
||||
new Map<
|
||||
string,
|
||||
{ displayName: string; creatures: Creature[]; cachedAt: number }
|
||||
>();
|
||||
|
||||
// Pre-populate sourceStore from creatures map if provided
|
||||
if (options?.creatures && !options?.sources) {
|
||||
// No-op: creatures are accessed directly from the map
|
||||
}
|
||||
|
||||
const creatureMap = options?.creatures ?? new Map<CreatureId, Creature>();
|
||||
|
||||
return {
|
||||
encounterPersistence: {
|
||||
load: () => storedEncounter,
|
||||
save: (e) => {
|
||||
storedEncounter = e;
|
||||
},
|
||||
},
|
||||
undoRedoPersistence: {
|
||||
load: () => storedUndoRedo,
|
||||
save: (state) => {
|
||||
storedUndoRedo = state;
|
||||
},
|
||||
},
|
||||
playerCharacterPersistence: {
|
||||
load: () => [...storedPCs],
|
||||
save: (pcs) => {
|
||||
storedPCs = pcs;
|
||||
},
|
||||
},
|
||||
bestiaryCache: {
|
||||
cacheSource(sourceCode, displayName, creatures) {
|
||||
sourceStore.set(sourceCode, {
|
||||
displayName,
|
||||
creatures,
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
for (const c of creatures) {
|
||||
creatureMap.set(c.id, c);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
isSourceCached(sourceCode) {
|
||||
return Promise.resolve(sourceStore.has(sourceCode));
|
||||
},
|
||||
getCachedSources() {
|
||||
return Promise.resolve(
|
||||
[...sourceStore.entries()].map(([sourceCode, info]) => ({
|
||||
sourceCode,
|
||||
displayName: info.displayName,
|
||||
creatureCount: info.creatures.length,
|
||||
cachedAt: info.cachedAt,
|
||||
})),
|
||||
);
|
||||
},
|
||||
clearSource(sourceCode) {
|
||||
sourceStore.delete(sourceCode);
|
||||
return Promise.resolve();
|
||||
},
|
||||
clearAll() {
|
||||
sourceStore.clear();
|
||||
return Promise.resolve();
|
||||
},
|
||||
loadAllCachedCreatures() {
|
||||
return Promise.resolve(new Map(creatureMap));
|
||||
},
|
||||
},
|
||||
bestiaryIndex: {
|
||||
loadIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: (sourceCode, baseUrl) => {
|
||||
const filename = `bestiary-${sourceCode.toLowerCase()}.json`;
|
||||
if (baseUrl !== undefined) {
|
||||
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
return `${normalized}${filename}`;
|
||||
}
|
||||
return `https://example.com/${filename}`;
|
||||
},
|
||||
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,34 +7,6 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../App.js";
|
||||
import { AllProviders } from "./test-providers.js";
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
// Mock bestiary — no IndexedDB or JSON index
|
||||
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,
|
||||
}));
|
||||
|
||||
// DOM API stubs — jsdom doesn't implement these
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getAllSourceCodes,
|
||||
getDefaultFetchUrl,
|
||||
} from "../adapters/bestiary-index-adapter.js";
|
||||
|
||||
describe("getAllSourceCodes", () => {
|
||||
it("returns all keys from the index sources object", () => {
|
||||
const codes = getAllSourceCodes();
|
||||
expect(codes.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(codes)).toBe(true);
|
||||
for (const code of codes) {
|
||||
expect(typeof code).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultFetchUrl", () => {
|
||||
it("returns the default URL when no baseUrl is provided", () => {
|
||||
const url = getDefaultFetchUrl("XMM");
|
||||
expect(url).toBe(
|
||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-xmm.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("constructs URL from baseUrl with trailing slash", () => {
|
||||
const url = getDefaultFetchUrl("PHB", "https://example.com/data/");
|
||||
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||
});
|
||||
|
||||
it("normalizes baseUrl without trailing slash", () => {
|
||||
const url = getDefaultFetchUrl("PHB", "https://example.com/data");
|
||||
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||
});
|
||||
|
||||
it("lowercases the source code in the filename", () => {
|
||||
const url = getDefaultFetchUrl("MM", "https://example.com/");
|
||||
expect(url).toBe("https://example.com/bestiary-mm.json");
|
||||
});
|
||||
});
|
||||
258
apps/web/src/__tests__/export-import.test.ts
Normal file
258
apps/web/src/__tests__/export-import.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
combatantId,
|
||||
type Encounter,
|
||||
type ExportBundle,
|
||||
type PlayerCharacter,
|
||||
playerCharacterId,
|
||||
type UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assembleExportBundle,
|
||||
bundleToJson,
|
||||
resolveFilename,
|
||||
validateImportBundle,
|
||||
} from "../persistence/export-import.js";
|
||||
|
||||
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
||||
const DEFAULT_FILENAME_RE = /^initiative-export-\d{4}-\d{2}-\d{2}\.json$/;
|
||||
|
||||
const encounter: Encounter = {
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Goblin",
|
||||
initiative: 15,
|
||||
maxHp: 7,
|
||||
currentHp: 7,
|
||||
ac: 15,
|
||||
},
|
||||
{
|
||||
id: combatantId("c-2"),
|
||||
name: "Aria",
|
||||
initiative: 18,
|
||||
maxHp: 45,
|
||||
currentHp: 40,
|
||||
ac: 16,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
playerCharacterId: playerCharacterId("pc-1"),
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 2,
|
||||
};
|
||||
|
||||
const undoRedoState: UndoRedoState = {
|
||||
undoStack: [
|
||||
{
|
||||
combatants: [{ id: combatantId("c-1"), name: "Goblin", initiative: 15 }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const playerCharacters: PlayerCharacter[] = [
|
||||
{
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
];
|
||||
|
||||
describe("assembleExportBundle", () => {
|
||||
it("returns a bundle with version 1", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.version).toBe(1);
|
||||
});
|
||||
|
||||
it("includes an ISO timestamp", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.exportedAt).toMatch(ISO_TIMESTAMP_RE);
|
||||
});
|
||||
|
||||
it("includes the encounter", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.encounter).toEqual(encounter);
|
||||
});
|
||||
|
||||
it("includes undo and redo stacks", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
|
||||
});
|
||||
|
||||
it("includes player characters", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.playerCharacters).toEqual(playerCharacters);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assembleExportBundle with includeHistory", () => {
|
||||
it("excludes undo/redo stacks when includeHistory is false", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
false,
|
||||
);
|
||||
expect(bundle.undoStack).toHaveLength(0);
|
||||
expect(bundle.redoStack).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes undo/redo stacks when includeHistory is true", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
true,
|
||||
);
|
||||
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
|
||||
});
|
||||
|
||||
it("includes undo/redo stacks by default", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleToJson", () => {
|
||||
it("produces valid JSON that round-trips through validateImportBundle", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
const json = bundleToJson(bundle);
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
const result = validateImportBundle(parsed);
|
||||
expect(typeof result).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFilename", () => {
|
||||
it("uses date-based default when no name provided", () => {
|
||||
const result = resolveFilename();
|
||||
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||
});
|
||||
|
||||
it("uses date-based default for empty string", () => {
|
||||
const result = resolveFilename("");
|
||||
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||
});
|
||||
|
||||
it("uses date-based default for whitespace-only string", () => {
|
||||
const result = resolveFilename(" ");
|
||||
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||
});
|
||||
|
||||
it("appends .json to a custom name", () => {
|
||||
expect(resolveFilename("my-encounter")).toBe("my-encounter.json");
|
||||
});
|
||||
|
||||
it("does not double-append .json", () => {
|
||||
expect(resolveFilename("my-encounter.json")).toBe("my-encounter.json");
|
||||
});
|
||||
|
||||
it("trims whitespace from custom name", () => {
|
||||
expect(resolveFilename(" my-encounter ")).toBe("my-encounter.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip: export then import", () => {
|
||||
it("produces identical state after round-trip", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
|
||||
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||
const result = validateImportBundle(serialized);
|
||||
|
||||
expect(typeof result).toBe("object");
|
||||
const imported = result as ExportBundle;
|
||||
expect(imported.version).toBe(bundle.version);
|
||||
expect(imported.encounter).toEqual(bundle.encounter);
|
||||
expect(imported.undoStack).toEqual(bundle.undoStack);
|
||||
expect(imported.redoStack).toEqual(bundle.redoStack);
|
||||
expect(imported.playerCharacters).toEqual(bundle.playerCharacters);
|
||||
});
|
||||
|
||||
it("round-trips a combatant with cr field", () => {
|
||||
const encounterWithCr: Encounter = {
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Custom Thug",
|
||||
cr: "2",
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const emptyUndoRedo: UndoRedoState = {
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
const bundle = assembleExportBundle(encounterWithCr, emptyUndoRedo, []);
|
||||
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||
const result = validateImportBundle(serialized);
|
||||
|
||||
expect(typeof result).toBe("object");
|
||||
const imported = result as ExportBundle;
|
||||
expect(imported.encounter.combatants[0].cr).toBe("2");
|
||||
});
|
||||
|
||||
it("round-trips an empty encounter", () => {
|
||||
const emptyEncounter: Encounter = {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const emptyUndoRedo: UndoRedoState = {
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
const bundle = assembleExportBundle(emptyEncounter, emptyUndoRedo, []);
|
||||
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||
const result = validateImportBundle(serialized);
|
||||
|
||||
expect(typeof result).toBe("object");
|
||||
const imported = result as ExportBundle;
|
||||
expect(imported.encounter.combatants).toHaveLength(0);
|
||||
expect(imported.undoStack).toHaveLength(0);
|
||||
expect(imported.redoStack).toHaveLength(0);
|
||||
expect(imported.playerCharacters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
12
apps/web/src/__tests__/factories/build-combatant.ts
Normal file
12
apps/web/src/__tests__/factories/build-combatant.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Combatant } from "@initiative/domain";
|
||||
import { combatantId } from "@initiative/domain";
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export function buildCombatant(overrides?: Partial<Combatant>): Combatant {
|
||||
return {
|
||||
id: combatantId(`c-${++counter}`),
|
||||
name: "Combatant",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
26
apps/web/src/__tests__/factories/build-creature.ts
Normal file
26
apps/web/src/__tests__/factories/build-creature.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export function buildCreature(overrides?: Partial<Creature>): Creature {
|
||||
const id = ++counter;
|
||||
return {
|
||||
id: creatureId(`creature-${id}`),
|
||||
name: `Creature ${id}`,
|
||||
source: "srd",
|
||||
sourceDisplayName: "SRD",
|
||||
size: "Medium",
|
||||
type: "humanoid",
|
||||
alignment: "neutral",
|
||||
ac: 13,
|
||||
hp: { average: 7, formula: "2d6" },
|
||||
speed: "30 ft.",
|
||||
abilities: { str: 10, dex: 14, con: 10, int: 10, wis: 10, cha: 10 },
|
||||
cr: "1/4",
|
||||
initiativeProficiency: 0,
|
||||
proficiencyBonus: 2,
|
||||
passive: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
10
apps/web/src/__tests__/factories/build-encounter.ts
Normal file
10
apps/web/src/__tests__/factories/build-encounter.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
|
||||
export function buildEncounter(overrides?: Partial<Encounter>): Encounter {
|
||||
return {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
3
apps/web/src/__tests__/factories/index.ts
Normal file
3
apps/web/src/__tests__/factories/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { buildCombatant } from "./build-combatant.js";
|
||||
export { buildCreature } from "./build-creature.js";
|
||||
export { buildEncounter } from "./build-encounter.js";
|
||||
16
apps/web/src/__tests__/polyfill-dialog.ts
Normal file
16
apps/web/src/__tests__/polyfill-dialog.ts
Normal file
@@ -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");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the context modules
|
||||
// Uses context mocks because StatBlockPanel requires fine-grained control over
|
||||
// panel state (collapsed/expanded, pinned/unpinned, wide/narrow desktop) that
|
||||
// would need extensive setup to drive through real providers.
|
||||
vi.mock("../contexts/side-panel-context.js", () => ({
|
||||
useSidePanelContext: vi.fn(),
|
||||
}));
|
||||
@@ -14,14 +16,6 @@ vi.mock("../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock adapters to avoid IndexedDB
|
||||
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Adapters } from "../contexts/adapter-context.js";
|
||||
import { AdapterProvider } from "../contexts/adapter-context.js";
|
||||
import {
|
||||
BestiaryProvider,
|
||||
BulkImportProvider,
|
||||
@@ -9,9 +11,18 @@ import {
|
||||
SidePanelProvider,
|
||||
ThemeProvider,
|
||||
} from "../contexts/index.js";
|
||||
import { createTestAdapters } from "./adapters/in-memory-adapters.js";
|
||||
|
||||
export function AllProviders({ children }: { children: ReactNode }) {
|
||||
export function AllProviders({
|
||||
adapters,
|
||||
children,
|
||||
}: {
|
||||
adapters?: Adapters;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const resolved = adapters ?? createTestAdapters();
|
||||
return (
|
||||
<AdapterProvider adapters={resolved}>
|
||||
<ThemeProvider>
|
||||
<RulesEditionProvider>
|
||||
<EncounterProvider>
|
||||
@@ -19,7 +30,9 @@ export function AllProviders({ children }: { children: ReactNode }) {
|
||||
<PlayerCharactersProvider>
|
||||
<BulkImportProvider>
|
||||
<SidePanelProvider>
|
||||
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
|
||||
<InitiativeRollsProvider>
|
||||
{children}
|
||||
</InitiativeRollsProvider>
|
||||
</SidePanelProvider>
|
||||
</BulkImportProvider>
|
||||
</PlayerCharactersProvider>
|
||||
@@ -27,5 +40,6 @@ export function AllProviders({ children }: { children: ReactNode }) {
|
||||
</EncounterProvider>
|
||||
</RulesEditionProvider>
|
||||
</ThemeProvider>
|
||||
</AdapterProvider>
|
||||
);
|
||||
}
|
||||
|
||||
249
apps/web/src/__tests__/validate-import-bundle.test.ts
Normal file
249
apps/web/src/__tests__/validate-import-bundle.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import type { ExportBundle } from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateImportBundle } from "../persistence/export-import.js";
|
||||
|
||||
function validBundle(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: "2026-03-27T12:00:00.000Z",
|
||||
encounter: {
|
||||
combatants: [{ id: "c-1", name: "Goblin", initiative: 15 }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
playerCharacters: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("validateImportBundle", () => {
|
||||
it("accepts a valid bundle", () => {
|
||||
const result = validateImportBundle(validBundle());
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.version).toBe(1);
|
||||
expect(bundle.encounter.combatants).toHaveLength(1);
|
||||
expect(bundle.encounter.combatants[0].name).toBe("Goblin");
|
||||
});
|
||||
|
||||
it("accepts a valid bundle with empty encounter", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.encounter.combatants).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts a bundle with undo/redo stacks", () => {
|
||||
const enc = {
|
||||
combatants: [{ id: "c-1", name: "Orc" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const input = {
|
||||
...validBundle(),
|
||||
undoStack: [enc],
|
||||
redoStack: [enc],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.undoStack).toHaveLength(1);
|
||||
expect(bundle.redoStack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accepts a bundle with player characters", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(1);
|
||||
expect(bundle.playerCharacters[0].name).toBe("Aria");
|
||||
});
|
||||
|
||||
it("rejects non-object input", () => {
|
||||
expect(validateImportBundle(null)).toBe("Invalid file format");
|
||||
expect(validateImportBundle(42)).toBe("Invalid file format");
|
||||
expect(validateImportBundle("string")).toBe("Invalid file format");
|
||||
expect(validateImportBundle([])).toBe("Invalid file format");
|
||||
expect(validateImportBundle(undefined)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing version field", () => {
|
||||
const input = validBundle();
|
||||
delete input.version;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects version 0 or negative", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), version: 0 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
expect(validateImportBundle({ ...validBundle(), version: -1 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown version", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), version: 99 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing encounter field", () => {
|
||||
const input = validBundle();
|
||||
delete input.encounter;
|
||||
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("rejects invalid encounter data", () => {
|
||||
expect(
|
||||
validateImportBundle({ ...validBundle(), encounter: "not an object" }),
|
||||
).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("rejects missing undoStack", () => {
|
||||
const input = validBundle();
|
||||
delete input.undoStack;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing redoStack", () => {
|
||||
const input = validBundle();
|
||||
delete input.redoStack;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing playerCharacters", () => {
|
||||
const input = validBundle();
|
||||
delete input.playerCharacters;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects non-string exportedAt", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), exportedAt: 12345 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops invalid entries from undo stack", () => {
|
||||
const valid = {
|
||||
combatants: [{ id: "c-1", name: "Orc" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const input = {
|
||||
...validBundle(),
|
||||
undoStack: [valid, "invalid", { bad: true }, valid],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.undoStack).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops invalid player characters", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{ id: "pc-1", name: "Valid", ac: 10, maxHp: 20 },
|
||||
{ id: "", name: "Bad ID" },
|
||||
"not an object",
|
||||
{ id: "pc-3", name: "Also Valid", ac: 15, maxHp: 30 },
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects JSON array instead of object", () => {
|
||||
expect(validateImportBundle([1, 2, 3])).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects encounter that fails rehydration (missing combatant fields)", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
encounter: {
|
||||
combatants: [{ noId: true }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
};
|
||||
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("strips invalid color/icon from player characters but keeps the character", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 20,
|
||||
color: "neon-pink",
|
||||
icon: "bazooka",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
// rehydrateCharacter rejects characters with invalid color/icon members
|
||||
// that are not in the valid sets, so this character is dropped
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps player characters with valid optional color and icon", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(1);
|
||||
expect(bundle.playerCharacters[0].color).toBe("blue");
|
||||
expect(bundle.playerCharacters[0].icon).toBe("sword");
|
||||
});
|
||||
|
||||
it("ignores unknown extra fields on the bundle", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
unknownField: "should be ignored",
|
||||
anotherExtra: 42,
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.version).toBe(1);
|
||||
expect("unknownField" in bundle).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock idb to reject — simulates IndexedDB unavailable.
|
||||
// This must be a separate file from bestiary-cache.test.ts because the
|
||||
// module caches the db connection in a singleton; once openDB succeeds
|
||||
// in one test, the fallback path is unreachable.
|
||||
vi.mock("idb", () => ({
|
||||
openDB: vi.fn().mockRejectedValue(new Error("IndexedDB unavailable")),
|
||||
}));
|
||||
|
||||
const {
|
||||
cacheSource,
|
||||
isSourceCached,
|
||||
getCachedSources,
|
||||
clearSource,
|
||||
clearAll,
|
||||
loadAllCachedCreatures,
|
||||
} = await import("../bestiary-cache.js");
|
||||
|
||||
function makeCreature(id: string, name: string): Creature {
|
||||
return {
|
||||
id: creatureId(id),
|
||||
name,
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
size: "Small",
|
||||
type: "humanoid",
|
||||
alignment: "neutral evil",
|
||||
ac: 15,
|
||||
hp: { average: 7, formula: "2d6" },
|
||||
speed: "30 ft.",
|
||||
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
|
||||
cr: "1/4",
|
||||
initiativeProficiency: 0,
|
||||
proficiencyBonus: 2,
|
||||
passive: 9,
|
||||
};
|
||||
}
|
||||
|
||||
describe("bestiary-cache fallback (IndexedDB unavailable)", () => {
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
});
|
||||
|
||||
it("cacheSource falls back to in-memory store", async () => {
|
||||
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||
await cacheSource("MM", "Monster Manual", creatures);
|
||||
|
||||
expect(await isSourceCached("MM")).toBe(true);
|
||||
});
|
||||
|
||||
it("isSourceCached returns false for uncached source", async () => {
|
||||
expect(await isSourceCached("XGE")).toBe(false);
|
||||
});
|
||||
|
||||
it("getCachedSources returns sources from in-memory store", async () => {
|
||||
await cacheSource("MM", "Monster Manual", [
|
||||
makeCreature("mm:goblin", "Goblin"),
|
||||
]);
|
||||
|
||||
const sources = await getCachedSources();
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0].sourceCode).toBe("MM");
|
||||
expect(sources[0].creatureCount).toBe(1);
|
||||
});
|
||||
|
||||
it("loadAllCachedCreatures assembles creatures from in-memory store", async () => {
|
||||
const goblin = makeCreature("mm:goblin", "Goblin");
|
||||
await cacheSource("MM", "Monster Manual", [goblin]);
|
||||
|
||||
const map = await loadAllCachedCreatures();
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.get(creatureId("mm:goblin"))?.name).toBe("Goblin");
|
||||
});
|
||||
|
||||
it("clearSource removes a single source from in-memory store", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
await cacheSource("VGM", "Volo's Guide", []);
|
||||
|
||||
await clearSource("MM");
|
||||
|
||||
expect(await isSourceCached("MM")).toBe(false);
|
||||
expect(await isSourceCached("VGM")).toBe(true);
|
||||
});
|
||||
|
||||
it("clearAll removes all data from in-memory store", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
await clearAll();
|
||||
|
||||
const sources = await getCachedSources();
|
||||
expect(sources).toEqual([]);
|
||||
});
|
||||
});
|
||||
174
apps/web/src/adapters/__tests__/bestiary-cache.test.ts
Normal file
174
apps/web/src/adapters/__tests__/bestiary-cache.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock idb — the one legitimate use of vi.mock for a third-party I/O library.
|
||||
// We can't use real IndexedDB in jsdom; this tests the cache logic through
|
||||
// all public API methods with an in-memory backing store.
|
||||
const fakeStore = new Map<string, unknown>();
|
||||
|
||||
vi.mock("idb", () => ({
|
||||
openDB: vi.fn().mockResolvedValue({
|
||||
put: vi.fn((_storeName: string, value: unknown) => {
|
||||
const record = value as { sourceCode: string };
|
||||
fakeStore.set(record.sourceCode, value);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
get: vi.fn((_storeName: string, key: string) =>
|
||||
Promise.resolve(fakeStore.get(key)),
|
||||
),
|
||||
getAll: vi.fn((_storeName: string) =>
|
||||
Promise.resolve([...fakeStore.values()]),
|
||||
),
|
||||
delete: vi.fn((_storeName: string, key: string) => {
|
||||
fakeStore.delete(key);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
clear: vi.fn((_storeName: string) => {
|
||||
fakeStore.clear();
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
const {
|
||||
cacheSource,
|
||||
isSourceCached,
|
||||
getCachedSources,
|
||||
clearSource,
|
||||
clearAll,
|
||||
loadAllCachedCreatures,
|
||||
} = await import("../bestiary-cache.js");
|
||||
|
||||
function makeCreature(id: string, name: string): Creature {
|
||||
return {
|
||||
id: creatureId(id),
|
||||
name,
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
size: "Small",
|
||||
type: "humanoid",
|
||||
alignment: "neutral evil",
|
||||
ac: 15,
|
||||
hp: { average: 7, formula: "2d6" },
|
||||
speed: "30 ft.",
|
||||
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
|
||||
cr: "1/4",
|
||||
initiativeProficiency: 0,
|
||||
proficiencyBonus: 2,
|
||||
passive: 9,
|
||||
};
|
||||
}
|
||||
|
||||
describe("bestiary-cache", () => {
|
||||
beforeEach(() => {
|
||||
fakeStore.clear();
|
||||
});
|
||||
|
||||
describe("cacheSource", () => {
|
||||
it("stores creatures and metadata", async () => {
|
||||
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||
await cacheSource("MM", "Monster Manual", creatures);
|
||||
|
||||
expect(fakeStore.has("MM")).toBe(true);
|
||||
const record = fakeStore.get("MM") as {
|
||||
sourceCode: string;
|
||||
displayName: string;
|
||||
creatures: Creature[];
|
||||
creatureCount: number;
|
||||
cachedAt: number;
|
||||
};
|
||||
expect(record.sourceCode).toBe("MM");
|
||||
expect(record.displayName).toBe("Monster Manual");
|
||||
expect(record.creatures).toHaveLength(1);
|
||||
expect(record.creatureCount).toBe(1);
|
||||
expect(record.cachedAt).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSourceCached", () => {
|
||||
it("returns false for uncached source", async () => {
|
||||
expect(await isSourceCached("XGE")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after caching", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
expect(await isSourceCached("MM")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedSources", () => {
|
||||
it("returns empty array when no sources cached", async () => {
|
||||
const sources = await getCachedSources();
|
||||
expect(sources).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns source info with creature counts", async () => {
|
||||
await cacheSource("MM", "Monster Manual", [
|
||||
makeCreature("mm:goblin", "Goblin"),
|
||||
makeCreature("mm:orc", "Orc"),
|
||||
]);
|
||||
await cacheSource("VGM", "Volo's Guide", [
|
||||
makeCreature("vgm:flind", "Flind"),
|
||||
]);
|
||||
|
||||
const sources = await getCachedSources();
|
||||
expect(sources).toHaveLength(2);
|
||||
|
||||
const mm = sources.find((s) => s.sourceCode === "MM");
|
||||
expect(mm).toBeDefined();
|
||||
expect(mm?.displayName).toBe("Monster Manual");
|
||||
expect(mm?.creatureCount).toBe(2);
|
||||
|
||||
const vgm = sources.find((s) => s.sourceCode === "VGM");
|
||||
expect(vgm?.creatureCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAllCachedCreatures", () => {
|
||||
it("returns empty map when nothing cached", async () => {
|
||||
const map = await loadAllCachedCreatures();
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it("assembles creatures from all cached sources", async () => {
|
||||
const goblin = makeCreature("mm:goblin", "Goblin");
|
||||
const orc = makeCreature("mm:orc", "Orc");
|
||||
const flind = makeCreature("vgm:flind", "Flind");
|
||||
|
||||
await cacheSource("MM", "Monster Manual", [goblin, orc]);
|
||||
await cacheSource("VGM", "Volo's Guide", [flind]);
|
||||
|
||||
const map = await loadAllCachedCreatures();
|
||||
expect(map.size).toBe(3);
|
||||
expect(map.get(creatureId("mm:goblin"))?.name).toBe("Goblin");
|
||||
expect(map.get(creatureId("mm:orc"))?.name).toBe("Orc");
|
||||
expect(map.get(creatureId("vgm:flind"))?.name).toBe("Flind");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearSource", () => {
|
||||
it("removes a single source", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
await cacheSource("VGM", "Volo's Guide", []);
|
||||
|
||||
await clearSource("MM");
|
||||
|
||||
expect(await isSourceCached("MM")).toBe(false);
|
||||
expect(await isSourceCached("VGM")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearAll", () => {
|
||||
it("removes all cached data", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
await cacheSource("VGM", "Volo's Guide", []);
|
||||
|
||||
await clearAll();
|
||||
|
||||
const sources = await getCachedSources();
|
||||
expect(sources).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
107
apps/web/src/adapters/__tests__/bestiary-index-adapter.test.ts
Normal file
107
apps/web/src/adapters/__tests__/bestiary-index-adapter.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getAllSourceCodes,
|
||||
getDefaultFetchUrl,
|
||||
getSourceDisplayName,
|
||||
loadBestiaryIndex,
|
||||
} from "../bestiary-index-adapter.js";
|
||||
|
||||
describe("loadBestiaryIndex", () => {
|
||||
it("returns an object with sources and creatures", () => {
|
||||
const index = loadBestiaryIndex();
|
||||
expect(index.sources).toBeDefined();
|
||||
expect(index.creatures).toBeDefined();
|
||||
expect(Array.isArray(index.creatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("creatures have the expected shape", () => {
|
||||
const index = loadBestiaryIndex();
|
||||
expect(index.creatures.length).toBeGreaterThan(0);
|
||||
const first = index.creatures[0];
|
||||
expect(first).toHaveProperty("name");
|
||||
expect(first).toHaveProperty("source");
|
||||
expect(first).toHaveProperty("ac");
|
||||
expect(first).toHaveProperty("hp");
|
||||
expect(first).toHaveProperty("dex");
|
||||
expect(first).toHaveProperty("cr");
|
||||
expect(first).toHaveProperty("initiativeProficiency");
|
||||
expect(first).toHaveProperty("size");
|
||||
expect(first).toHaveProperty("type");
|
||||
});
|
||||
|
||||
it("returns the same cached instance on subsequent calls", () => {
|
||||
const a = loadBestiaryIndex();
|
||||
const b = loadBestiaryIndex();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("sources is a record of source code to display name", () => {
|
||||
const index = loadBestiaryIndex();
|
||||
const entries = Object.entries(index.sources);
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
for (const [code, name] of entries) {
|
||||
expect(typeof code).toBe("string");
|
||||
expect(typeof name).toBe("string");
|
||||
expect(code.length).toBeGreaterThan(0);
|
||||
expect(name.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllSourceCodes", () => {
|
||||
it("returns all keys from the index sources", () => {
|
||||
const codes = getAllSourceCodes();
|
||||
const index = loadBestiaryIndex();
|
||||
expect(codes).toEqual(Object.keys(index.sources));
|
||||
});
|
||||
|
||||
it("returns only strings", () => {
|
||||
for (const code of getAllSourceCodes()) {
|
||||
expect(typeof code).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultFetchUrl", () => {
|
||||
it("returns default GitHub URL when no baseUrl provided", () => {
|
||||
const url = getDefaultFetchUrl("MM");
|
||||
expect(url).toBe(
|
||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-mm.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("constructs URL from baseUrl with trailing slash", () => {
|
||||
const url = getDefaultFetchUrl("PHB", "https://example.com/data/");
|
||||
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||
});
|
||||
|
||||
it("normalizes baseUrl without trailing slash", () => {
|
||||
const url = getDefaultFetchUrl("PHB", "https://example.com/data");
|
||||
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||
});
|
||||
|
||||
it("lowercases the source code in the filename", () => {
|
||||
const url = getDefaultFetchUrl("XMM");
|
||||
expect(url).toContain("bestiary-xmm.json");
|
||||
});
|
||||
|
||||
it("applies filename override for Plane Shift sources", () => {
|
||||
expect(getDefaultFetchUrl("PSA")).toContain("bestiary-ps-a.json");
|
||||
expect(getDefaultFetchUrl("PSD")).toContain("bestiary-ps-d.json");
|
||||
expect(getDefaultFetchUrl("PSK")).toContain("bestiary-ps-k.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSourceDisplayName", () => {
|
||||
it("returns display name for a known source", () => {
|
||||
const index = loadBestiaryIndex();
|
||||
const [code, expectedName] = Object.entries(index.sources)[0];
|
||||
expect(getSourceDisplayName(code)).toBe(expectedName);
|
||||
});
|
||||
|
||||
it("falls back to source code for unknown source", () => {
|
||||
expect(getSourceDisplayName("UNKNOWN_SOURCE_XYZ")).toBe(
|
||||
"UNKNOWN_SOURCE_XYZ",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ const DB_NAME = "initiative-bestiary";
|
||||
const STORE_NAME = "sources";
|
||||
const DB_VERSION = 2;
|
||||
|
||||
export interface CachedSourceInfo {
|
||||
interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
readonly displayName: string;
|
||||
readonly creatureCount: number;
|
||||
@@ -40,7 +40,7 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
||||
}
|
||||
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
||||
// Clear cached creatures to pick up improved tag processing
|
||||
transaction.objectStore(STORE_NAME).clear();
|
||||
void transaction.objectStore(STORE_NAME).clear();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
50
apps/web/src/adapters/ports.ts
Normal file
50
apps/web/src/adapters/ports.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
BestiaryIndex,
|
||||
Creature,
|
||||
CreatureId,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
|
||||
export interface EncounterPersistence {
|
||||
load(): Encounter | null;
|
||||
save(encounter: Encounter): void;
|
||||
}
|
||||
|
||||
export interface UndoRedoPersistence {
|
||||
load(): UndoRedoState;
|
||||
save(state: UndoRedoState): void;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterPersistence {
|
||||
load(): PlayerCharacter[];
|
||||
save(characters: PlayerCharacter[]): void;
|
||||
}
|
||||
|
||||
export interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
readonly displayName: string;
|
||||
readonly creatureCount: number;
|
||||
readonly cachedAt: number;
|
||||
}
|
||||
|
||||
export interface BestiaryCachePort {
|
||||
cacheSource(
|
||||
sourceCode: string,
|
||||
displayName: string,
|
||||
creatures: Creature[],
|
||||
): Promise<void>;
|
||||
isSourceCached(sourceCode: string): Promise<boolean>;
|
||||
getCachedSources(): Promise<CachedSourceInfo[]>;
|
||||
clearSource(sourceCode: string): Promise<void>;
|
||||
clearAll(): Promise<void>;
|
||||
loadAllCachedCreatures(): Promise<Map<CreatureId, Creature>>;
|
||||
}
|
||||
|
||||
export interface BestiaryIndexPort {
|
||||
loadIndex(): BestiaryIndex;
|
||||
getAllSourceCodes(): string[];
|
||||
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||
getSourceDisplayName(sourceCode: string): string;
|
||||
}
|
||||
44
apps/web/src/adapters/production-adapters.ts
Normal file
44
apps/web/src/adapters/production-adapters.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Adapters } from "../contexts/adapter-context.js";
|
||||
import {
|
||||
loadEncounter,
|
||||
saveEncounter,
|
||||
} from "../persistence/encounter-storage.js";
|
||||
import {
|
||||
loadPlayerCharacters,
|
||||
savePlayerCharacters,
|
||||
} from "../persistence/player-character-storage.js";
|
||||
import {
|
||||
loadUndoRedoStacks,
|
||||
saveUndoRedoStacks,
|
||||
} from "../persistence/undo-redo-storage.js";
|
||||
import * as bestiaryCache from "./bestiary-cache.js";
|
||||
import * as bestiaryIndex from "./bestiary-index-adapter.js";
|
||||
|
||||
export const productionAdapters: Adapters = {
|
||||
encounterPersistence: {
|
||||
load: loadEncounter,
|
||||
save: saveEncounter,
|
||||
},
|
||||
undoRedoPersistence: {
|
||||
load: loadUndoRedoStacks,
|
||||
save: saveUndoRedoStacks,
|
||||
},
|
||||
playerCharacterPersistence: {
|
||||
load: loadPlayerCharacters,
|
||||
save: savePlayerCharacters,
|
||||
},
|
||||
bestiaryCache: {
|
||||
cacheSource: bestiaryCache.cacheSource,
|
||||
isSourceCached: bestiaryCache.isSourceCached,
|
||||
getCachedSources: bestiaryCache.getCachedSources,
|
||||
clearSource: bestiaryCache.clearSource,
|
||||
clearAll: bestiaryCache.clearAll,
|
||||
loadAllCachedCreatures: bestiaryCache.loadAllCachedCreatures,
|
||||
},
|
||||
bestiaryIndex: {
|
||||
loadIndex: bestiaryIndex.loadBestiaryIndex,
|
||||
getAllSourceCodes: bestiaryIndex.getAllSourceCodes,
|
||||
getDefaultFetchUrl: bestiaryIndex.getDefaultFetchUrl,
|
||||
getSourceDisplayName: bestiaryIndex.getSourceDisplayName,
|
||||
},
|
||||
};
|
||||
@@ -1,40 +1,16 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { playerCharacterId } from "@initiative/domain";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { ActionBar } from "../action-bar.js";
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
// Mock bestiary — no IndexedDB or JSON index
|
||||
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,
|
||||
}));
|
||||
|
||||
// DOM API stubs — jsdom doesn't implement these
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
@@ -50,6 +26,7 @@ beforeAll(() => {
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
polyfillDialog();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -58,10 +35,97 @@ function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
||||
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
||||
}
|
||||
|
||||
function renderBarWithBestiary(
|
||||
props: Partial<Parameters<typeof ActionBar>[0]> = {},
|
||||
) {
|
||||
const adapters = createTestAdapters();
|
||||
adapters.bestiaryIndex = {
|
||||
...adapters.bestiaryIndex,
|
||||
loadIndex: () => ({
|
||||
sources: { MM: "Monster Manual" },
|
||||
creatures: [
|
||||
{
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
cr: "1/4",
|
||||
initiativeProficiency: 0,
|
||||
size: "Small",
|
||||
type: "humanoid",
|
||||
},
|
||||
{
|
||||
name: "Golem, Iron",
|
||||
source: "MM",
|
||||
ac: 20,
|
||||
hp: 210,
|
||||
dex: 9,
|
||||
cr: "16",
|
||||
initiativeProficiency: 0,
|
||||
size: "Large",
|
||||
type: "construct",
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSourceDisplayName: (code: string) =>
|
||||
code === "MM" ? "Monster Manual" : code,
|
||||
};
|
||||
return render(<ActionBar {...props} />, {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function renderBarWithPCs(
|
||||
props: Partial<Parameters<typeof ActionBar>[0]> = {},
|
||||
) {
|
||||
const adapters = createTestAdapters({
|
||||
playerCharacters: [
|
||||
{
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Gandalf",
|
||||
ac: 15,
|
||||
maxHp: 40,
|
||||
},
|
||||
],
|
||||
});
|
||||
adapters.bestiaryIndex = {
|
||||
...adapters.bestiaryIndex,
|
||||
loadIndex: () => ({
|
||||
sources: { MM: "Monster Manual" },
|
||||
creatures: [
|
||||
{
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
cr: "1/4",
|
||||
initiativeProficiency: 0,
|
||||
size: "Small",
|
||||
type: "humanoid",
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSourceDisplayName: (code: string) =>
|
||||
code === "MM" ? "Monster Manual" : code,
|
||||
};
|
||||
return render(<ActionBar {...props} />, {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe("ActionBar", () => {
|
||||
describe("basic rendering and custom add", () => {
|
||||
it("renders input with placeholder '+ Add combatants'", () => {
|
||||
renderBar();
|
||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText("+ Add combatants"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submitting with a name adds a combatant", async () => {
|
||||
@@ -69,20 +133,16 @@ describe("ActionBar", () => {
|
||||
renderBar();
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "Goblin");
|
||||
// The Add button appears when name >= 2 chars and no suggestions
|
||||
const addButton = screen.getByRole("button", { name: "Add" });
|
||||
await user.click(addButton);
|
||||
// Input is cleared after adding (context handles the state)
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("submitting with empty name does nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBar();
|
||||
// Submit the form directly (Enter on empty input)
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "{Enter}");
|
||||
// Input stays empty, no error
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
@@ -104,6 +164,160 @@ describe("ActionBar", () => {
|
||||
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits custom stats with combatant", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBar();
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "Fighter");
|
||||
await user.type(screen.getByPlaceholderText("Init"), "15");
|
||||
await user.type(screen.getByPlaceholderText("AC"), "18");
|
||||
await user.type(screen.getByPlaceholderText("MaxHP"), "45");
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bestiary suggestions and queuing", () => {
|
||||
it("shows bestiary suggestions when typing a matching name", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBarWithBestiary();
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "Go");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Golem, Iron")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking a suggestion queues it with count badge", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBarWithBestiary();
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "Go");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the Goblin suggestion
|
||||
await user.click(screen.getByText("Goblin"));
|
||||
|
||||
// Should show count badge "1"
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking same suggestion again increments count", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBarWithBestiary();
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "Go");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Goblin"));
|
||||
await user.click(screen.getByText("Goblin"));
|
||||
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("confirming queued creatures adds them to the encounter", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBarWithBestiary();
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "Go");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Queue 1 Goblin
|
||||
await user.click(screen.getByText("Goblin"));
|
||||
|
||||
// Press Enter to confirm the queued creature
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
// Input should be cleared after confirming
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("clears queued when search text no longer matches", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBarWithBestiary();
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "Go");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Goblin"));
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
|
||||
// Change search to something with no matches
|
||||
await user.clear(input);
|
||||
await user.type(input, "xyz");
|
||||
|
||||
// Count badge should be gone
|
||||
expect(screen.queryByText("1")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("player character matching", () => {
|
||||
it("shows matching player characters in suggestions", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBarWithPCs();
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "Gan");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Gandalf")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Player")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("browse mode", () => {
|
||||
it("toggles browse mode via eye icon button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBarWithBestiary();
|
||||
|
||||
const browseButton = screen.getByRole("button", {
|
||||
name: "Browse stat blocks",
|
||||
});
|
||||
await user.click(browseButton);
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText("Search stat blocks..."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Switch to add mode" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("browse mode shows suggestions without add UI", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBarWithBestiary();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Browse stat blocks" }),
|
||||
);
|
||||
const input = screen.getByPlaceholderText("Search stat blocks...");
|
||||
await user.type(input, "Go");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
});
|
||||
// No Add button in browse mode
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Add" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("overflow menu", () => {
|
||||
it("does not show roll all initiative button when no creature combatants", () => {
|
||||
renderBar();
|
||||
expect(
|
||||
@@ -113,9 +327,49 @@ describe("ActionBar", () => {
|
||||
|
||||
it("shows overflow menu items", () => {
|
||||
renderBar({ onManagePlayers: vi.fn() });
|
||||
// The overflow menu should be present (it contains Player Characters etc.)
|
||||
expect(
|
||||
screen.getByRole("button", { name: "More actions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens export method dialog via overflow menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBar();
|
||||
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||
const items = screen.getAllByText("Export Encounter");
|
||||
await user.click(items[0]);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
163
apps/web/src/components/__tests__/bulk-import-prompt.test.tsx
Normal file
163
apps/web/src/components/__tests__/bulk-import-prompt.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// @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 { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||
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,
|
||||
};
|
||||
|
||||
// Uses context mocks because the bulk import state machine (idle → loading →
|
||||
// complete → partial-failure) is impractical to drive through user interactions
|
||||
// without real network calls. Consider migrating if adapter injection expands
|
||||
// to cover these state transitions.
|
||||
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,
|
||||
}),
|
||||
}));
|
||||
|
||||
function createAdaptersWithSources() {
|
||||
const adapters = createTestAdapters();
|
||||
adapters.bestiaryIndex = {
|
||||
...adapters.bestiaryIndex,
|
||||
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||
};
|
||||
return adapters;
|
||||
}
|
||||
|
||||
function renderWithAdapters() {
|
||||
const adapters = createAdaptersWithSources();
|
||||
return render(
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<BulkImportPrompt />
|
||||
</AdapterProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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", () => {
|
||||
renderWithAdapters();
|
||||
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();
|
||||
renderWithAdapters();
|
||||
|
||||
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();
|
||||
renderWithAdapters();
|
||||
|
||||
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,
|
||||
};
|
||||
renderWithAdapters();
|
||||
expect(screen.getByText(LOADING_PROGRESS_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("complete: shows success message and Done button", () => {
|
||||
mockImportState = {
|
||||
status: "complete",
|
||||
total: 10,
|
||||
completed: 10,
|
||||
failed: 0,
|
||||
};
|
||||
renderWithAdapters();
|
||||
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();
|
||||
renderWithAdapters();
|
||||
|
||||
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,
|
||||
};
|
||||
renderWithAdapters();
|
||||
expect(screen.getByText(SEVEN_OF_TEN_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(THREE_FAILED_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
56
apps/web/src/components/__tests__/color-palette.test.tsx
Normal file
56
apps/web/src/components/__tests__/color-palette.test.tsx
Normal file
@@ -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,34 +10,8 @@ import { CombatantRow } from "../combatant-row.js";
|
||||
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
||||
|
||||
const TEMP_HP_REGEX = /^\+\d/;
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
// Mock bestiary — no IndexedDB or JSON index
|
||||
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,
|
||||
}));
|
||||
const CURRENT_HP_7_REGEX = /Current HP: 7/;
|
||||
const CURRENT_HP_REGEX = /Current HP/;
|
||||
|
||||
// DOM API stubs
|
||||
beforeAll(() => {
|
||||
@@ -257,6 +231,172 @@ describe("CombatantRow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("inline name editing", () => {
|
||||
it("click rename → type new name → blur commits rename", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Rename" }));
|
||||
const input = screen.getByDisplayValue("Goblin");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Hobgoblin");
|
||||
await user.tab(); // blur
|
||||
// The input should be gone, name committed
|
||||
expect(screen.queryByDisplayValue("Hobgoblin")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Escape cancels without renaming", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Rename" }));
|
||||
const input = screen.getByDisplayValue("Goblin");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Changed");
|
||||
await user.keyboard("{Escape}");
|
||||
// Should revert to showing the original name
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inline AC editing", () => {
|
||||
it("click AC → type value → Enter commits", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
ac: 13,
|
||||
},
|
||||
});
|
||||
|
||||
// Click the AC shield button
|
||||
const acButton = screen.getByText("13").closest("button");
|
||||
expect(acButton).not.toBeNull();
|
||||
await user.click(acButton as HTMLElement);
|
||||
const input = screen.getByDisplayValue("13");
|
||||
await user.clear(input);
|
||||
await user.type(input, "16");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(screen.queryByDisplayValue("16")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inline max HP editing", () => {
|
||||
it("click max HP → type value → blur commits", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 10,
|
||||
currentHp: 10,
|
||||
},
|
||||
});
|
||||
|
||||
// The max HP button shows "10" as muted text
|
||||
const maxHpButton = screen
|
||||
.getAllByText("10")
|
||||
.find(
|
||||
(el) => el.closest("button") && el.className.includes("text-muted"),
|
||||
);
|
||||
expect(maxHpButton).toBeDefined();
|
||||
const maxHpBtn = (maxHpButton as HTMLElement).closest("button");
|
||||
expect(maxHpBtn).not.toBeNull();
|
||||
await user.click(maxHpBtn as HTMLElement);
|
||||
const input = screen.getByDisplayValue("10");
|
||||
await user.clear(input);
|
||||
await user.type(input, "25");
|
||||
await user.tab();
|
||||
expect(screen.queryByDisplayValue("25")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inline initiative editing", () => {
|
||||
it("click initiative → type value → Enter commits", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
initiative: 15,
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("15"));
|
||||
const input = screen.getByDisplayValue("15");
|
||||
await user.clear(input);
|
||||
await user.type(input, "20");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(screen.queryByDisplayValue("20")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clearing initiative and pressing Enter commits the edit", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
initiative: 15,
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("15"));
|
||||
const input = screen.getByDisplayValue("15");
|
||||
await user.clear(input);
|
||||
await user.keyboard("{Enter}");
|
||||
// Input should be dismissed (editing mode exited)
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HP popover", () => {
|
||||
it("clicking current HP opens the HP adjust popover", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 10,
|
||||
currentHp: 7,
|
||||
},
|
||||
});
|
||||
|
||||
const hpButton = screen.getByLabelText(CURRENT_HP_7_REGEX);
|
||||
await user.click(hpButton);
|
||||
// The popover should appear with damage/heal controls
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Apply damage" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Apply healing" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("HP section is absent when maxHp is undefined", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
},
|
||||
});
|
||||
expect(screen.queryByLabelText(CURRENT_HP_REGEX)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("condition picker", () => {
|
||||
it("clicking Add condition button opens the picker", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow();
|
||||
const addButton = screen.getByRole("button", {
|
||||
name: "Add condition",
|
||||
});
|
||||
await user.click(addButton);
|
||||
// Condition picker should render with condition options
|
||||
expect(screen.getByText("Blinded")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("temp HP display", () => {
|
||||
it("shows +N when combatant has temp HP", () => {
|
||||
renderRow({
|
||||
|
||||
69
apps/web/src/components/__tests__/condition-tags.test.tsx
Normal file
69
apps/web/src/components/__tests__/condition-tags.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// @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 { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||
import { ConditionTags } from "../condition-tags.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderTags(props: Partial<Parameters<typeof ConditionTags>[0]> = {}) {
|
||||
return render(
|
||||
<RulesEditionProvider>
|
||||
<ConditionTags
|
||||
conditions={props.conditions}
|
||||
onRemove={props.onRemove ?? (() => {})}
|
||||
onOpenPicker={props.onOpenPicker ?? (() => {})}
|
||||
/>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ConditionTags", () => {
|
||||
it("renders nothing when conditions is undefined", () => {
|
||||
const { container } = renderTags();
|
||||
// Only the add button should be present
|
||||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders a button per condition", () => {
|
||||
const conditions: ConditionId[] = ["blinded", "prone"];
|
||||
renderTags({ conditions });
|
||||
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();
|
||||
renderTags({
|
||||
conditions: ["blinded"] as ConditionId[],
|
||||
onRemove,
|
||||
});
|
||||
|
||||
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();
|
||||
renderTags({ conditions: [], onOpenPicker });
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Add condition" }),
|
||||
);
|
||||
|
||||
expect(onOpenPicker).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders empty conditions array without errors", () => {
|
||||
renderTags({ conditions: [] });
|
||||
// Only add button
|
||||
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
|
||||
});
|
||||
});
|
||||
165
apps/web/src/components/__tests__/create-player-modal.test.tsx
Normal file
165
apps/web/src/components/__tests__/create-player-modal.test.tsx
Normal file
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
63
apps/web/src/components/__tests__/dialog.test.tsx
Normal file
63
apps/web/src/components/__tests__/dialog.test.tsx
Normal file
@@ -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,232 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
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 { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { DifficultyBreakdownPanel } from "../difficulty-breakdown-panel.js";
|
||||
|
||||
beforeAll(() => {
|
||||
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);
|
||||
|
||||
const pcId1 = playerCharacterId("pc-1");
|
||||
const goblinCreature = buildCreature({
|
||||
id: creatureId("srd:goblin"),
|
||||
name: "Goblin",
|
||||
cr: "1/4",
|
||||
source: "srd",
|
||||
sourceDisplayName: "SRD",
|
||||
});
|
||||
|
||||
function renderPanel(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
encounter: options.encounter,
|
||||
playerCharacters: options.playerCharacters ?? [],
|
||||
creatures: options.creatures,
|
||||
});
|
||||
return render(
|
||||
<AllProviders adapters={adapters}>
|
||||
<DifficultyBreakdownPanel onClose={options.onClose ?? (() => {})} />
|
||||
</AllProviders>,
|
||||
);
|
||||
}
|
||||
|
||||
function defaultEncounter() {
|
||||
return buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
creatureId: goblinCreature.id,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-3"),
|
||||
name: "Custom Thug",
|
||||
cr: "2",
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-4"),
|
||||
name: "Bandit",
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const defaultPCs: PlayerCharacter[] = [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
];
|
||||
|
||||
describe("DifficultyBreakdownPanel", () => {
|
||||
it("renders party budget section", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Party Budget", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("1 PC", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText("Low:", { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders tier label", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Encounter Difficulty:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders bestiary combatant as read-only with source name", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Goblin (SRD)")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("CR 1/4").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders custom combatant with CR picker", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||
expect(pickers).toHaveLength(2);
|
||||
// First picker is "Custom Thug" with CR 2
|
||||
expect(pickers[0]).toHaveValue("2");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders unassigned combatant with Assign picker and dash for XP", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||
// Second picker is "Bandit" with no CR
|
||||
expect(pickers[1]).toHaveValue("");
|
||||
// "—" appears for unassigned XP
|
||||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("selecting a CR updates the visible XP value", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
// Wait for the panel to render with bestiary data
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The Bandit (second picker) has no CR — shows "—" for XP
|
||||
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||
|
||||
// Select CR 5 (1,800 XP) on Bandit
|
||||
await user.selectOptions(pickers[1], "5");
|
||||
|
||||
// XP should update — the "—" should be replaced with an XP value
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("1,800")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders total monster XP", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Total Monster XP")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing when breakdown data is insufficient", () => {
|
||||
// No PCs with level → breakdown returns null
|
||||
const { container } = renderPanel({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({ id: combatantId("c-1"), name: "Custom" }),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("calls onClose when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
onClose,
|
||||
});
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { DifficultyResult } 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 { 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();
|
||||
});
|
||||
|
||||
it("calls onClick when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult("moderate")}
|
||||
onClick={handleClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("img", {
|
||||
name: "Moderate encounter difficulty",
|
||||
}),
|
||||
);
|
||||
expect(handleClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders as div when onClick not provided", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator result={makeResult("moderate")} />,
|
||||
);
|
||||
const element = container.querySelector("[role='img']");
|
||||
expect(element?.tagName).toBe("DIV");
|
||||
});
|
||||
|
||||
it("renders as button when onClick provided", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult("moderate")}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
const element = container.querySelector("[role='img']");
|
||||
expect(element?.tagName).toBe("BUTTON");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
89
apps/web/src/components/__tests__/overflow-menu.test.tsx
Normal file
89
apps/web/src/components/__tests__/overflow-menu.test.tsx
Normal file
@@ -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,108 @@
|
||||
// @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);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
120
apps/web/src/components/__tests__/player-management.test.tsx
Normal file
120
apps/web/src/components/__tests__/player-management.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
55
apps/web/src/components/__tests__/roll-mode-menu.test.tsx
Normal file
55
apps/web/src/components/__tests__/roll-mode-menu.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
84
apps/web/src/components/__tests__/settings-modal.test.tsx
Normal file
84
apps/web/src/components/__tests__/settings-modal.test.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
// @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(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
130
apps/web/src/components/__tests__/source-fetch-prompt.test.tsx
Normal file
130
apps/web/src/components/__tests__/source-fetch-prompt.test.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
// @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 { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
||||
|
||||
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const mockFetchAndCacheSource = vi.fn();
|
||||
const mockUploadAndCacheSource = vi.fn();
|
||||
|
||||
// Uses context mock because fetchAndCacheSource/uploadAndCacheSource involve
|
||||
// real fetch() calls. The test controls success/failure to verify the
|
||||
// component's loading and error UI, not the fetching logic itself.
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: () => ({
|
||||
fetchAndCacheSource: mockFetchAndCacheSource,
|
||||
uploadAndCacheSource: mockUploadAndCacheSource,
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderPrompt(sourceCode = "MM") {
|
||||
const onSourceLoaded = vi.fn();
|
||||
const adapters = createTestAdapters();
|
||||
adapters.bestiaryIndex = {
|
||||
...adapters.bestiaryIndex,
|
||||
getDefaultFetchUrl: (code: string) =>
|
||||
`https://example.com/bestiary/${code}.json`,
|
||||
getSourceDisplayName: (code: string) =>
|
||||
code === "MM" ? "Monster Manual" : code,
|
||||
};
|
||||
const result = render(
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
onSourceLoaded={onSourceLoaded}
|
||||
/>
|
||||
</AdapterProvider>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,60 +3,68 @@ import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
getCachedSources: vi.fn(),
|
||||
clearSource: vi.fn(),
|
||||
clearAll: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the context module
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
|
||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import type { CachedSourceInfo } from "../../adapters/ports.js";
|
||||
import { SourceManager } from "../source-manager.js";
|
||||
|
||||
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
|
||||
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
||||
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
beforeAll(() => {
|
||||
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(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
function setupMockContext() {
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockUseBestiaryContext.mockReturnValue({
|
||||
refreshCache,
|
||||
search: vi.fn().mockReturnValue([]),
|
||||
getCreature: vi.fn(),
|
||||
isLoaded: true,
|
||||
isSourceCached: vi.fn().mockResolvedValue(false),
|
||||
fetchAndCacheSource: vi.fn(),
|
||||
uploadAndCacheSource: vi.fn(),
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
return { refreshCache };
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderWithSources(sources: CachedSourceInfo[] = []) {
|
||||
const adapters = createTestAdapters();
|
||||
// Wire getCachedSources to return the provided sources initially,
|
||||
// then empty after clear operations
|
||||
let currentSources = [...sources];
|
||||
adapters.bestiaryCache = {
|
||||
...adapters.bestiaryCache,
|
||||
getCachedSources: () => Promise.resolve(currentSources),
|
||||
clearSource(sourceCode) {
|
||||
currentSources = currentSources.filter(
|
||||
(s) => s.sourceCode !== sourceCode,
|
||||
);
|
||||
return Promise.resolve();
|
||||
},
|
||||
clearAll() {
|
||||
currentSources = [];
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
render(<SourceManager />, {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe("SourceManager", () => {
|
||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||
setupMockContext();
|
||||
mockGetCachedSources.mockResolvedValue([]);
|
||||
render(<SourceManager />);
|
||||
void renderWithSources([]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("lists cached sources with display name and creature count", async () => {
|
||||
setupMockContext();
|
||||
mockGetCachedSources.mockResolvedValue([
|
||||
void renderWithSources([
|
||||
{
|
||||
sourceCode: "mm",
|
||||
displayName: "Monster Manual",
|
||||
@@ -70,7 +78,6 @@ describe("SourceManager", () => {
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
@@ -79,38 +86,31 @@ describe("SourceManager", () => {
|
||||
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Clear All button calls cache clear and refreshCache", async () => {
|
||||
it("Clear All button removes all sources", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { refreshCache } = setupMockContext();
|
||||
mockGetCachedSources
|
||||
.mockResolvedValueOnce([
|
||||
void renderWithSources([
|
||||
{
|
||||
sourceCode: "mm",
|
||||
displayName: "Monster Manual",
|
||||
creatureCount: 300,
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
])
|
||||
.mockResolvedValue([]);
|
||||
mockClearAll.mockResolvedValue(undefined);
|
||||
render(<SourceManager />);
|
||||
]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Clear All" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClearAll).toHaveBeenCalled();
|
||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||
});
|
||||
expect(refreshCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("individual source delete button calls clear for that source", async () => {
|
||||
it("individual source delete button removes that source", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { refreshCache } = setupMockContext();
|
||||
mockGetCachedSources
|
||||
.mockResolvedValueOnce([
|
||||
void renderWithSources([
|
||||
{
|
||||
sourceCode: "mm",
|
||||
displayName: "Monster Manual",
|
||||
@@ -123,18 +123,8 @@ describe("SourceManager", () => {
|
||||
creatureCount: 100,
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
])
|
||||
.mockResolvedValue([
|
||||
{
|
||||
sourceCode: "vgm",
|
||||
displayName: "Volo's Guide",
|
||||
creatureCount: 100,
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
mockClearSource.mockResolvedValue(undefined);
|
||||
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
@@ -142,9 +132,10 @@ describe("SourceManager", () => {
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Remove Monster Manual" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClearSource).toHaveBeenCalledWith("mm");
|
||||
expect(screen.queryByText("Monster Manual")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(refreshCache).toHaveBeenCalled();
|
||||
expect(screen.getByText("Volo's Guide")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
273
apps/web/src/components/__tests__/stat-block.test.tsx
Normal file
273
apps/web/src/components/__tests__/stat-block.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
68
apps/web/src/components/__tests__/toast.test.tsx
Normal file
68
apps/web/src/components/__tests__/toast.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
42
apps/web/src/components/__tests__/tooltip.test.tsx
Normal file
42
apps/web/src/components/__tests__/tooltip.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,86 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { combatantId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the context module
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import {
|
||||
buildCombatant,
|
||||
buildEncounter,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { TurnNavigation } from "../turn-navigation.js";
|
||||
|
||||
const mockUseEncounterContext = vi.mocked(useEncounterContext);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
beforeAll(() => {
|
||||
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(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
function mockContext(overrides: Partial<Encounter> = {}) {
|
||||
const encounter: Encounter = {
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
...overrides,
|
||||
};
|
||||
afterEach(cleanup);
|
||||
|
||||
const value = {
|
||||
encounter,
|
||||
advanceTurn: vi.fn(),
|
||||
retreatTurn: vi.fn(),
|
||||
clearEncounter: vi.fn(),
|
||||
isEmpty: encounter.combatants.length === 0,
|
||||
hasCreatureCombatants: false,
|
||||
canRollAllInitiative: false,
|
||||
addCombatant: vi.fn(),
|
||||
removeCombatant: vi.fn(),
|
||||
editCombatant: vi.fn(),
|
||||
setInitiative: vi.fn(),
|
||||
setHp: vi.fn(),
|
||||
adjustHp: vi.fn(),
|
||||
setTempHp: vi.fn(),
|
||||
hasTempHp: false,
|
||||
setAc: vi.fn(),
|
||||
toggleCondition: vi.fn(),
|
||||
toggleConcentration: vi.fn(),
|
||||
addFromBestiary: vi.fn(),
|
||||
addFromPlayerCharacter: vi.fn(),
|
||||
makeStore: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
events: [],
|
||||
};
|
||||
|
||||
mockUseEncounterContext.mockReturnValue(
|
||||
value as ReturnType<typeof useEncounterContext>,
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
mockContext(overrides);
|
||||
return render(<TurnNavigation />);
|
||||
function renderNav(encounter = buildEncounter()) {
|
||||
const adapters = createTestAdapters({ encounter });
|
||||
return render(<TurnNavigation />, {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe("TurnNavigation", () => {
|
||||
describe("US1: Round badge and combatant name", () => {
|
||||
it("renders the round badge with correct round number", () => {
|
||||
renderNav({ roundNumber: 3 });
|
||||
renderNav(
|
||||
buildEncounter({
|
||||
combatants: [buildCombatant({ name: "Goblin" })],
|
||||
roundNumber: 3,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the combatant name separately from the round badge", () => {
|
||||
renderNav();
|
||||
renderNav(
|
||||
buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({ id: combatantId("c-1"), name: "Goblin" }),
|
||||
buildCombatant({ id: combatantId("c-2"), name: "Conjurer" }),
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}),
|
||||
);
|
||||
const badge = screen.getByText("R1");
|
||||
const name = screen.getByText("Goblin");
|
||||
expect(badge).toBeInTheDocument();
|
||||
@@ -90,41 +72,24 @@ describe("TurnNavigation", () => {
|
||||
});
|
||||
|
||||
it("does not render an em dash between round and name", () => {
|
||||
const { container } = renderNav();
|
||||
const { container } = renderNav(
|
||||
buildEncounter({
|
||||
combatants: [buildCombatant({ name: "Goblin" })],
|
||||
}),
|
||||
);
|
||||
expect(container.textContent).not.toContain("\u2014");
|
||||
});
|
||||
|
||||
it("round badge and combatant name are siblings in the center area", () => {
|
||||
renderNav();
|
||||
it("round badge is in the left zone and name is in the center zone", () => {
|
||||
renderNav(
|
||||
buildEncounter({
|
||||
combatants: [buildCombatant({ name: "Goblin" })],
|
||||
}),
|
||||
);
|
||||
const badge = screen.getByText("R1");
|
||||
const name = screen.getByText("Goblin");
|
||||
// badge text is inside inner span > outer span, name is a direct child
|
||||
expect(badge.closest(".flex")).toBe(name.parentElement);
|
||||
});
|
||||
|
||||
it("updates the round badge when round changes", () => {
|
||||
mockContext({ roundNumber: 2 });
|
||||
const { rerender } = render(<TurnNavigation />);
|
||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||
|
||||
mockContext({ roundNumber: 3 });
|
||||
rerender(<TurnNavigation />);
|
||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the next combatant name when turn advances", () => {
|
||||
const combatants = [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
];
|
||||
mockContext({ combatants, activeIndex: 0 });
|
||||
const { rerender } = render(<TurnNavigation />);
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
|
||||
mockContext({ combatants, activeIndex: 1 });
|
||||
rerender(<TurnNavigation />);
|
||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
||||
// Badge and name are in separate grid cells to prevent layout shifts
|
||||
expect(badge.parentElement).not.toBe(name.parentElement);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,17 +97,21 @@ describe("TurnNavigation", () => {
|
||||
it("applies truncation styles to long combatant names", () => {
|
||||
const longName =
|
||||
"Ancient Red Dragon Wyrm of the Northern Wastes and Beyond";
|
||||
renderNav({
|
||||
combatants: [{ id: combatantId("1"), name: longName }],
|
||||
});
|
||||
renderNav(
|
||||
buildEncounter({
|
||||
combatants: [buildCombatant({ name: longName })],
|
||||
}),
|
||||
);
|
||||
const nameEl = screen.getByText(longName);
|
||||
expect(nameEl.className).toContain("truncate");
|
||||
});
|
||||
|
||||
it("renders three-zone layout with a single-character name", () => {
|
||||
renderNav({
|
||||
combatants: [{ id: combatantId("1"), name: "O" }],
|
||||
});
|
||||
renderNav(
|
||||
buildEncounter({
|
||||
combatants: [buildCombatant({ name: "O" })],
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
expect(screen.getByText("O")).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -155,9 +124,11 @@ describe("TurnNavigation", () => {
|
||||
|
||||
it("keeps all action buttons accessible regardless of name length", () => {
|
||||
const longName = "A".repeat(60);
|
||||
renderNav({
|
||||
combatants: [{ id: combatantId("1"), name: longName }],
|
||||
});
|
||||
renderNav(
|
||||
buildEncounter({
|
||||
combatants: [buildCombatant({ name: longName })],
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous turn" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -168,29 +139,30 @@ describe("TurnNavigation", () => {
|
||||
|
||||
it("renders a 40-character name without truncation class issues", () => {
|
||||
const name40 = "A".repeat(40);
|
||||
renderNav({
|
||||
combatants: [{ id: combatantId("1"), name: name40 }],
|
||||
});
|
||||
renderNav(
|
||||
buildEncounter({
|
||||
combatants: [buildCombatant({ name: name40 })],
|
||||
}),
|
||||
);
|
||||
const nameEl = screen.getByText(name40);
|
||||
expect(nameEl).toBeInTheDocument();
|
||||
// The truncate class is applied but CSS only visually truncates if content overflows
|
||||
expect(nameEl.className).toContain("truncate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("US3: No combatants state", () => {
|
||||
it("shows the round badge when there are no combatants", () => {
|
||||
renderNav({ combatants: [], roundNumber: 1 });
|
||||
renderNav(buildEncounter({ combatants: [], roundNumber: 1 }));
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'No combatants' placeholder text", () => {
|
||||
renderNav({ combatants: [] });
|
||||
renderNav(buildEncounter({ combatants: [] }));
|
||||
expect(screen.getByText("No combatants")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables navigation buttons when there are no combatants", () => {
|
||||
renderNav({ combatants: [] });
|
||||
renderNav(buildEncounter({ combatants: [] }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous turn" }),
|
||||
).toBeDisabled();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
Check,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Import,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
Minus,
|
||||
Plus,
|
||||
Settings,
|
||||
Upload,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import React, { type RefObject, useCallback, useState } from "react";
|
||||
@@ -21,11 +23,16 @@ import {
|
||||
type SuggestionActions,
|
||||
useActionBarState,
|
||||
} from "../hooks/use-action-bar-state.js";
|
||||
import { useEncounterExportImport } from "../hooks/use-encounter-export-import.js";
|
||||
import { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
import { ExportMethodDialog } from "./export-method-dialog.js";
|
||||
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
||||
import { ImportMethodDialog } from "./import-method-dialog.js";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||
import { Toast } from "./toast.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
||||
@@ -345,6 +352,8 @@ function buildOverflowItems(opts: {
|
||||
bestiaryLoaded: boolean;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
onExportEncounter: () => void;
|
||||
onImportEncounter: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}): OverflowMenuItem[] {
|
||||
const items: OverflowMenuItem[] = [];
|
||||
@@ -370,6 +379,16 @@ function buildOverflowItems(opts: {
|
||||
disabled: opts.bulkImportDisabled,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
label: "Export Encounter",
|
||||
onClick: opts.onExportEncounter,
|
||||
});
|
||||
items.push({
|
||||
icon: <Upload className="h-4 w-4" />,
|
||||
label: "Import Encounter",
|
||||
onClick: opts.onImportEncounter,
|
||||
});
|
||||
if (opts.onOpenSettings) {
|
||||
items.push({
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
@@ -414,12 +433,31 @@ export function ActionBar({
|
||||
|
||||
const { state: bulkImportState } = useBulkImportContext();
|
||||
|
||||
const {
|
||||
importError,
|
||||
showExportMethod,
|
||||
showImportMethod,
|
||||
showImportConfirm,
|
||||
importFileRef,
|
||||
setImportError,
|
||||
setShowExportMethod,
|
||||
setShowImportMethod,
|
||||
handleExportDownload,
|
||||
handleExportClipboard,
|
||||
handleImportFile,
|
||||
handleImportClipboard,
|
||||
handleImportConfirm,
|
||||
handleImportCancel,
|
||||
} = useEncounterExportImport();
|
||||
|
||||
const overflowItems = buildOverflowItems({
|
||||
onManagePlayers,
|
||||
onOpenSourceManager: showSourceManager,
|
||||
bestiaryLoaded,
|
||||
onBulkImport: showBulkImport,
|
||||
bulkImportDisabled: bulkImportState.status === "loading",
|
||||
onExportEncounter: () => setShowExportMethod(true),
|
||||
onImportEncounter: () => setShowImportMethod(true),
|
||||
onOpenSettings,
|
||||
});
|
||||
|
||||
@@ -501,6 +539,37 @@ export function ActionBar({
|
||||
<RollAllButton />
|
||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||
</form>
|
||||
<input
|
||||
ref={importFileRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
{!!importError && (
|
||||
<Toast
|
||||
message={importError}
|
||||
onDismiss={() => setImportError(null)}
|
||||
autoDismissMs={5000}
|
||||
/>
|
||||
)}
|
||||
<ExportMethodDialog
|
||||
open={showExportMethod}
|
||||
onDownload={handleExportDownload}
|
||||
onCopyToClipboard={handleExportClipboard}
|
||||
onClose={() => setShowExportMethod(false)}
|
||||
/>
|
||||
<ImportMethodDialog
|
||||
open={showImportMethod}
|
||||
onSelectFile={() => importFileRef.current?.click()}
|
||||
onSubmitClipboard={handleImportClipboard}
|
||||
onClose={() => setShowImportMethod(false)}
|
||||
/>
|
||||
<ImportConfirmDialog
|
||||
open={showImportConfirm}
|
||||
onConfirm={handleImportConfirm}
|
||||
onCancel={handleImportCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
@@ -11,6 +11,7 @@ const DEFAULT_BASE_URL =
|
||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||
|
||||
export function BulkImportPrompt() {
|
||||
const { bestiaryIndex } = useAdapters();
|
||||
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
||||
useBestiaryContext();
|
||||
const { state: importState, startImport, reset } = useBulkImportContext();
|
||||
@@ -18,7 +19,7 @@ export function BulkImportPrompt() {
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||
const baseUrlId = useId();
|
||||
const totalSources = getAllSourceCodes().length;
|
||||
const totalSources = bestiaryIndex.getAllSourceCodes().length;
|
||||
|
||||
const handleStart = (url: string) => {
|
||||
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
||||
|
||||
@@ -430,7 +430,7 @@ function concentrationIconClass(
|
||||
dimmed: boolean,
|
||||
): string {
|
||||
if (!isConcentrating)
|
||||
return "opacity-0 group-hover:opacity-50 text-muted-foreground";
|
||||
return "opacity-0 pointer-coarse:opacity-50 group-hover:opacity-50 text-muted-foreground";
|
||||
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
||||
}
|
||||
|
||||
|
||||
@@ -3,66 +3,17 @@ import {
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
} from "@initiative/domain";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
Droplet,
|
||||
EarOff,
|
||||
EyeOff,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
Link,
|
||||
Moon,
|
||||
ShieldMinus,
|
||||
Siren,
|
||||
Snail,
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
CONDITION_COLOR_CLASSES,
|
||||
CONDITION_ICON_MAP,
|
||||
} from "./condition-styles.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
Heart,
|
||||
EarOff,
|
||||
BatteryLow,
|
||||
Siren,
|
||||
Hand,
|
||||
Ban,
|
||||
Ghost,
|
||||
ZapOff,
|
||||
Gem,
|
||||
Droplet,
|
||||
ArrowDown,
|
||||
Link,
|
||||
ShieldMinus,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Moon,
|
||||
};
|
||||
|
||||
const COLOR_CLASSES: Record<string, string> = {
|
||||
neutral: "text-muted-foreground",
|
||||
pink: "text-pink-400",
|
||||
amber: "text-amber-400",
|
||||
orange: "text-orange-400",
|
||||
gray: "text-gray-400",
|
||||
violet: "text-violet-400",
|
||||
yellow: "text-yellow-400",
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
};
|
||||
|
||||
interface ConditionPickerProps {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
activeConditions: readonly ConditionId[] | undefined;
|
||||
@@ -104,15 +55,7 @@ export function ConditionPicker({
|
||||
setPos({ top, left: anchorRect.left, maxHeight });
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
const { edition } = useRulesEditionContext();
|
||||
const conditions = getConditionsForEdition(edition);
|
||||
@@ -129,10 +72,11 @@ export function ConditionPicker({
|
||||
}
|
||||
>
|
||||
{conditions.map((def) => {
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const isActive = active.has(def.id);
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
const colorClass =
|
||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<Tooltip
|
||||
key={def.id}
|
||||
|
||||
54
apps/web/src/components/condition-styles.ts
Normal file
54
apps/web/src/components/condition-styles.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
Droplet,
|
||||
EarOff,
|
||||
EyeOff,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
Link,
|
||||
Moon,
|
||||
ShieldMinus,
|
||||
Siren,
|
||||
Snail,
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
|
||||
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
Heart,
|
||||
EarOff,
|
||||
BatteryLow,
|
||||
Siren,
|
||||
Hand,
|
||||
Ban,
|
||||
Ghost,
|
||||
ZapOff,
|
||||
Gem,
|
||||
Droplet,
|
||||
ArrowDown,
|
||||
Link,
|
||||
ShieldMinus,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Moon,
|
||||
};
|
||||
|
||||
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||
neutral: "text-muted-foreground",
|
||||
pink: "text-pink-400",
|
||||
amber: "text-amber-400",
|
||||
orange: "text-orange-400",
|
||||
gray: "text-gray-400",
|
||||
violet: "text-violet-400",
|
||||
yellow: "text-yellow-400",
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
};
|
||||
@@ -3,65 +3,15 @@ import {
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
} from "@initiative/domain";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
Droplet,
|
||||
EarOff,
|
||||
EyeOff,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
Link,
|
||||
Moon,
|
||||
Plus,
|
||||
ShieldMinus,
|
||||
Siren,
|
||||
Snail,
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
CONDITION_COLOR_CLASSES,
|
||||
CONDITION_ICON_MAP,
|
||||
} from "./condition-styles.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
Heart,
|
||||
EarOff,
|
||||
BatteryLow,
|
||||
Siren,
|
||||
Hand,
|
||||
Ban,
|
||||
Ghost,
|
||||
ZapOff,
|
||||
Gem,
|
||||
Droplet,
|
||||
ArrowDown,
|
||||
Link,
|
||||
ShieldMinus,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Moon,
|
||||
};
|
||||
|
||||
const COLOR_CLASSES: Record<string, string> = {
|
||||
neutral: "text-muted-foreground",
|
||||
pink: "text-pink-400",
|
||||
amber: "text-amber-400",
|
||||
orange: "text-orange-400",
|
||||
gray: "text-gray-400",
|
||||
violet: "text-violet-400",
|
||||
yellow: "text-yellow-400",
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
};
|
||||
|
||||
interface ConditionTagsProps {
|
||||
conditions: readonly ConditionId[] | undefined;
|
||||
onRemove: (conditionId: ConditionId) => void;
|
||||
@@ -79,9 +29,10 @@ export function ConditionTags({
|
||||
{conditions?.map((condId) => {
|
||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
|
||||
if (!def) return null;
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
const colorClass =
|
||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<Tooltip
|
||||
key={condId}
|
||||
|
||||
36
apps/web/src/components/cr-picker.tsx
Normal file
36
apps/web/src/components/cr-picker.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { VALID_CR_VALUES } from "@initiative/domain";
|
||||
|
||||
const CR_LABELS: Record<string, string> = {
|
||||
"0": "CR 0",
|
||||
"1/8": "CR 1/8",
|
||||
"1/4": "CR 1/4",
|
||||
"1/2": "CR 1/2",
|
||||
};
|
||||
|
||||
function formatCr(cr: string): string {
|
||||
return CR_LABELS[cr] ?? `CR ${cr}`;
|
||||
}
|
||||
|
||||
export function CrPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (cr: string | undefined) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className="rounded border border-border bg-card px-1.5 py-0.5 text-xs"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
aria-label="Challenge rating"
|
||||
>
|
||||
<option value="">Assign</option>
|
||||
{VALID_CR_VALUES.map((cr) => (
|
||||
<option key={cr} value={cr}>
|
||||
{formatCr(cr)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ColorPalette } from "./color-palette";
|
||||
import { IconGrid } from "./icon-grid";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog } from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
function parseLevel(value: string): number | undefined | "invalid" {
|
||||
if (value.trim() === "") return undefined;
|
||||
const n = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > 20) return "invalid";
|
||||
return n;
|
||||
}
|
||||
|
||||
interface CreatePlayerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -15,6 +23,7 @@ interface CreatePlayerModalProps {
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
level: number | undefined,
|
||||
) => void;
|
||||
playerCharacter?: PlayerCharacter;
|
||||
}
|
||||
@@ -25,12 +34,12 @@ export function CreatePlayerModal({
|
||||
onSave,
|
||||
playerCharacter,
|
||||
}: Readonly<CreatePlayerModalProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [ac, setAc] = useState("10");
|
||||
const [maxHp, setMaxHp] = useState("10");
|
||||
const [color, setColor] = useState("blue");
|
||||
const [icon, setIcon] = useState("sword");
|
||||
const [level, setLevel] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isEdit = !!playerCharacter;
|
||||
@@ -43,45 +52,23 @@ export function CreatePlayerModal({
|
||||
setMaxHp(String(playerCharacter.maxHp));
|
||||
setColor(playerCharacter.color ?? "");
|
||||
setIcon(playerCharacter.icon ?? "");
|
||||
setLevel(
|
||||
playerCharacter.level === undefined
|
||||
? ""
|
||||
: String(playerCharacter.level),
|
||||
);
|
||||
} else {
|
||||
setName("");
|
||||
setAc("10");
|
||||
setMaxHp("10");
|
||||
setColor("");
|
||||
setIcon("");
|
||||
setLevel("");
|
||||
}
|
||||
setError("");
|
||||
}
|
||||
}, [open, playerCharacter]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
@@ -99,15 +86,24 @@ export function CreatePlayerModal({
|
||||
setError("Max HP must be at least 1");
|
||||
return;
|
||||
}
|
||||
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
|
||||
const levelNum = parseLevel(level);
|
||||
if (levelNum === "invalid") {
|
||||
setError("Level must be between 1 and 20");
|
||||
return;
|
||||
}
|
||||
onSave(
|
||||
trimmed,
|
||||
acNum,
|
||||
hpNum,
|
||||
color || undefined,
|
||||
icon || undefined,
|
||||
levelNum,
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||
>
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
{isEdit ? "Edit Player" : "Create Player"}
|
||||
@@ -166,6 +162,20 @@ export function CreatePlayerModal({
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-muted-foreground text-sm">
|
||||
Level
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value)}
|
||||
placeholder="1-20"
|
||||
aria-label="Level"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -187,6 +197,6 @@ export function CreatePlayerModal({
|
||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
109
apps/web/src/components/difficulty-breakdown-panel.tsx
Normal file
109
apps/web/src/components/difficulty-breakdown-panel.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { DifficultyTier } from "@initiative/domain";
|
||||
import { useRef } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import {
|
||||
type BreakdownCombatant,
|
||||
useDifficultyBreakdown,
|
||||
} from "../hooks/use-difficulty-breakdown.js";
|
||||
import { CrPicker } from "./cr-picker.js";
|
||||
|
||||
const TIER_LABELS: Record<DifficultyTier, { label: string; color: string }> = {
|
||||
trivial: { label: "Trivial", color: "text-muted-foreground" },
|
||||
low: { label: "Low", color: "text-green-500" },
|
||||
moderate: { label: "Moderate", color: "text-yellow-500" },
|
||||
high: { label: "High", color: "text-red-500" },
|
||||
};
|
||||
|
||||
function formatXp(xp: number): string {
|
||||
return xp.toLocaleString();
|
||||
}
|
||||
|
||||
function CombatantRow({ entry }: { entry: BreakdownCombatant }) {
|
||||
const { setCr } = useEncounterContext();
|
||||
|
||||
const nameLabel = entry.source
|
||||
? `${entry.combatant.name} (${entry.source})`
|
||||
: entry.combatant.name;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="min-w-0 truncate" title={nameLabel}>
|
||||
{nameLabel}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{entry.editable ? (
|
||||
<CrPicker
|
||||
value={entry.cr}
|
||||
onChange={(cr) => setCr(entry.combatant.id, cr)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{entry.cr ? `CR ${entry.cr}` : "—"}
|
||||
</span>
|
||||
)}
|
||||
<span className="w-12 text-right tabular-nums">
|
||||
{entry.xp == null ? "—" : formatXp(entry.xp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
const breakdown = useDifficultyBreakdown();
|
||||
if (!breakdown) return null;
|
||||
|
||||
const tierConfig = TIER_LABELS[breakdown.tier];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full right-0 z-50 mt-1 w-72 rounded-lg border border-border bg-card p-3 shadow-lg"
|
||||
>
|
||||
<div className="mb-2 font-medium text-sm">
|
||||
Encounter Difficulty:{" "}
|
||||
<span className={tierConfig.color}>{tierConfig.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 border-border border-t pt-2">
|
||||
<div className="mb-1 text-muted-foreground text-xs">
|
||||
Party Budget ({breakdown.pcCount}{" "}
|
||||
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs">
|
||||
<span>
|
||||
Low: <strong>{formatXp(breakdown.partyBudget.low)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Mod: <strong>{formatXp(breakdown.partyBudget.moderate)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
High: <strong>{formatXp(breakdown.partyBudget.high)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border border-t pt-2">
|
||||
<div className="mb-1 flex justify-between text-muted-foreground text-xs">
|
||||
<span>Monsters</span>
|
||||
<span>XP</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{breakdown.combatants.map((entry) => (
|
||||
<CombatantRow key={entry.combatant.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
||||
<span>Total Monster XP</span>
|
||||
<span className="tabular-nums">
|
||||
{formatXp(breakdown.totalMonsterXp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
apps/web/src/components/difficulty-indicator.tsx
Normal file
52
apps/web/src/components/difficulty-indicator.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { DifficultyResult, DifficultyTier } from "@initiative/domain";
|
||||
import { cn } from "../lib/utils.js";
|
||||
|
||||
const TIER_CONFIG: Record<
|
||||
DifficultyTier,
|
||||
{ filledBars: number; color: string; label: string }
|
||||
> = {
|
||||
trivial: { filledBars: 0, color: "", label: "Trivial" },
|
||||
low: { filledBars: 1, color: "bg-green-500", label: "Low" },
|
||||
moderate: { filledBars: 2, color: "bg-yellow-500", label: "Moderate" },
|
||||
high: { filledBars: 3, color: "bg-red-500", label: "High" },
|
||||
};
|
||||
|
||||
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
||||
|
||||
export function DifficultyIndicator({
|
||||
result,
|
||||
onClick,
|
||||
}: {
|
||||
result: DifficultyResult;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const config = TIER_CONFIG[result.tier];
|
||||
const tooltip = `${config.label} encounter difficulty`;
|
||||
|
||||
const Element = onClick ? "button" : "div";
|
||||
|
||||
return (
|
||||
<Element
|
||||
className={cn(
|
||||
"flex items-end gap-0.5",
|
||||
onClick && "cursor-pointer rounded p-1 hover:bg-muted/50",
|
||||
)}
|
||||
title={tooltip}
|
||||
role="img"
|
||||
aria-label={tooltip}
|
||||
onClick={onClick}
|
||||
type={onClick ? "button" : undefined}
|
||||
>
|
||||
{BAR_HEIGHTS.map((height, i) => (
|
||||
<div
|
||||
key={height}
|
||||
className={cn(
|
||||
"w-1 rounded-sm",
|
||||
height,
|
||||
i < config.filledBars ? config.color : "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
93
apps/web/src/components/export-method-dialog.tsx
Normal file
93
apps/web/src/components/export-method-dialog.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Check, ClipboardCopy, Download } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface ExportMethodDialogProps {
|
||||
open: boolean;
|
||||
onDownload: (includeHistory: boolean, filename: string) => void;
|
||||
onCopyToClipboard: (includeHistory: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExportMethodDialog({
|
||||
open,
|
||||
onDownload,
|
||||
onCopyToClipboard,
|
||||
onClose,
|
||||
}: Readonly<ExportMethodDialogProps>) {
|
||||
const [includeHistory, setIncludeHistory] = useState(false);
|
||||
const [filename, setFilename] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIncludeHistory(false);
|
||||
setFilename("");
|
||||
setCopied(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||
<DialogHeader title="Export Encounter" onClose={handleClose} />
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
type="text"
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
placeholder="Filename (optional)"
|
||||
/>
|
||||
</div>
|
||||
<label className="mb-4 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeHistory}
|
||||
onChange={(e) => setIncludeHistory(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span className="text-foreground">Include undo/redo history</span>
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onDownload(includeHistory, filename);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Download className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Download file</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Save as a JSON file
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onCopyToClipboard(includeHistory);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
) : (
|
||||
<ClipboardCopy className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{copied ? "Copied!" : "Copy to clipboard"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Copy JSON to your clipboard
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||
@@ -48,15 +49,7 @@ export function HpAdjustPopover({
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
const parsedValue =
|
||||
inputValue === "" ? null : Number.parseInt(inputValue, 10);
|
||||
|
||||
32
apps/web/src/components/import-confirm-prompt.tsx
Normal file
32
apps/web/src/components/import-confirm-prompt.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
|
||||
interface ImportConfirmDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ImportConfirmDialog({
|
||||
open,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Readonly<ImportConfirmDialogProps>) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel}>
|
||||
<h2 className="mb-2 font-semibold text-lg">Replace current encounter?</h2>
|
||||
<p className="mb-4 text-muted-foreground text-sm">
|
||||
Importing will replace your current encounter, undo/redo history, and
|
||||
player characters. This cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm}>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/components/import-method-dialog.tsx
Normal file
114
apps/web/src/components/import-method-dialog.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ClipboardPaste, FileUp } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||
|
||||
interface ImportMethodDialogProps {
|
||||
open: boolean;
|
||||
onSelectFile: () => void;
|
||||
onSubmitClipboard: (text: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportMethodDialog({
|
||||
open,
|
||||
onSelectFile,
|
||||
onSubmitClipboard,
|
||||
onClose,
|
||||
}: Readonly<ImportMethodDialogProps>) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [mode, setMode] = useState<"pick" | "paste">("pick");
|
||||
const [pasteText, setPasteText] = useState("");
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setMode("pick");
|
||||
setPasteText("");
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMode("pick");
|
||||
setPasteText("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "paste") {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||
<DialogHeader title="Import Encounter" onClose={handleClose} />
|
||||
{mode === "pick" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onSelectFile();
|
||||
}}
|
||||
>
|
||||
<FileUp className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">From file</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Upload a JSON file
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => setMode("paste")}
|
||||
>
|
||||
<ClipboardPaste className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Paste content</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Paste JSON content directly
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{mode === "paste" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
placeholder="Paste exported JSON here..."
|
||||
className="h-32 w-full resize-none rounded-md border border-border bg-background px-3 py-2 font-mono text-foreground text-xs placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setMode("pick");
|
||||
setPasteText("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={pasteText.trim().length === 0}
|
||||
onClick={() => {
|
||||
const text = pasteText;
|
||||
handleClose();
|
||||
onSubmitClipboard(text);
|
||||
}}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
setEditingPlayer(undefined);
|
||||
setManagementOpen(true);
|
||||
}}
|
||||
onSave={(name, ac, maxHp, color, icon) => {
|
||||
onSave={(name, ac, maxHp, color, icon, level) => {
|
||||
if (editingPlayer) {
|
||||
editCharacter(editingPlayer.id, {
|
||||
name,
|
||||
@@ -43,9 +43,10 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
maxHp,
|
||||
color: color ?? null,
|
||||
icon: icon ?? null,
|
||||
level: level ?? null,
|
||||
});
|
||||
} else {
|
||||
createCharacter(name, ac, maxHp, color, icon);
|
||||
createCharacter(name, ac, maxHp, color, icon, level);
|
||||
}
|
||||
}}
|
||||
playerCharacter={editingPlayer}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog";
|
||||
|
||||
interface PlayerManagementProps {
|
||||
open: boolean;
|
||||
@@ -22,54 +22,9 @@ export function PlayerManagement({
|
||||
onDelete,
|
||||
onCreate,
|
||||
}: Readonly<PlayerManagementProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
Player Characters
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||
<DialogHeader title="Player Characters" onClose={onClose} />
|
||||
|
||||
{characters.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
@@ -101,6 +56,11 @@ export function PlayerManagement({
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
HP {pc.maxHp}
|
||||
</span>
|
||||
{pc.level !== undefined && (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
Lv {pc.level}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -128,6 +88,6 @@ export function PlayerManagement({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RollMode } from "@initiative/domain";
|
||||
import { ChevronsDown, ChevronsUp } from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
|
||||
interface RollModeMenuProps {
|
||||
readonly position: { x: number; y: number };
|
||||
@@ -34,22 +35,7 @@ export function RollModeMenu({
|
||||
setPos({ top, left });
|
||||
}, [position.x, position.y]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { RulesEdition } from "@initiative/domain";
|
||||
import { Monitor, Moon, Sun, X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useThemeContext } from "../contexts/theme-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
@@ -27,51 +26,12 @@ const THEME_OPTIONS: {
|
||||
];
|
||||
|
||||
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const { edition, setEdition } = useRulesEditionContext();
|
||||
const { preference, setPreference } = useThemeContext();
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="card-glow m-auto w-full max-w-sm rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
|
||||
<DialogHeader title="Settings" onClose={onClose} />
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
@@ -124,6 +84,6 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import {
|
||||
getDefaultFetchUrl,
|
||||
getSourceDisplayName,
|
||||
} from "../adapters/bestiary-index-adapter.js";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
@@ -17,9 +14,12 @@ export function SourceFetchPrompt({
|
||||
sourceCode,
|
||||
onSourceLoaded,
|
||||
}: Readonly<SourceFetchPromptProps>) {
|
||||
const { bestiaryIndex } = useAdapters();
|
||||
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
||||
const sourceDisplayName = getSourceDisplayName(sourceCode);
|
||||
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
||||
const sourceDisplayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
const [url, setUrl] = useState(() =>
|
||||
bestiaryIndex.getDefaultFetchUrl(sourceCode),
|
||||
);
|
||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||
const [error, setError] = useState<string>("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
useOptimistic,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||
import type { CachedSourceInfo } from "../adapters/ports.js";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
export function SourceManager() {
|
||||
const { bestiaryCache } = useAdapters();
|
||||
const { refreshCache } = useBestiaryContext();
|
||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||
const [filter, setFilter] = useState("");
|
||||
@@ -30,7 +31,7 @@ export function SourceManager() {
|
||||
const loadSources = useCallback(async () => {
|
||||
const cached = await bestiaryCache.getCachedSources();
|
||||
setSources(cached);
|
||||
}, []);
|
||||
}, [bestiaryCache]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSources();
|
||||
|
||||
@@ -34,6 +34,31 @@ function SectionDivider() {
|
||||
);
|
||||
}
|
||||
|
||||
function TraitSection({
|
||||
entries,
|
||||
heading,
|
||||
}: Readonly<{
|
||||
entries: readonly { name: string; text: string }[] | undefined;
|
||||
heading?: string;
|
||||
}>) {
|
||||
if (!entries || entries.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<SectionDivider />
|
||||
{heading ? (
|
||||
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{entries.map((e) => (
|
||||
<div key={e.name} className="text-sm">
|
||||
<span className="font-semibold italic">{e.name}.</span> {e.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
const abilities = [
|
||||
{ label: "STR", score: creature.abilities.str },
|
||||
@@ -134,19 +159,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traits */}
|
||||
{creature.traits && creature.traits.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<div className="space-y-2">
|
||||
{creature.traits.map((t) => (
|
||||
<div key={t.name} className="text-sm">
|
||||
<span className="font-semibold italic">{t.name}.</span> {t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TraitSection entries={creature.traits} />
|
||||
|
||||
{/* Spellcasting */}
|
||||
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
||||
@@ -190,52 +203,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{creature.actions && creature.actions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-base text-stat-heading">Actions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.actions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bonus Actions */}
|
||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-base text-stat-heading">
|
||||
Bonus Actions
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.bonusActions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
{creature.reactions && creature.reactions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-base text-stat-heading">Reactions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.reactions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TraitSection entries={creature.actions} heading="Actions" />
|
||||
<TraitSection entries={creature.bonusActions} heading="Bonus Actions" />
|
||||
<TraitSection entries={creature.reactions} heading="Reactions" />
|
||||
|
||||
{/* Legendary Actions */}
|
||||
{!!creature.legendaryActions && (
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useDifficulty } from "../hooks/use-difficulty.js";
|
||||
import { DifficultyBreakdownPanel } from "./difficulty-breakdown-panel.js";
|
||||
import { DifficultyIndicator } from "./difficulty-indicator.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
|
||||
@@ -15,12 +19,16 @@ export function TurnNavigation() {
|
||||
canRedo,
|
||||
} = useEncounterContext();
|
||||
|
||||
const difficulty = useDifficulty();
|
||||
const [showBreakdown, setShowBreakdown] = useState(false);
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||
|
||||
return (
|
||||
<div className="card-glow flex items-center gap-3 border-border border-b bg-card px-4 py-3 sm:rounded-lg sm:border">
|
||||
<div className="card-glow grid grid-cols-[1fr_minmax(0,auto)_1fr] items-center border-border border-b bg-card px-2 py-3 sm:rounded-lg sm:border sm:px-4">
|
||||
{/* Left zone: navigation + history + round */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -31,8 +39,6 @@ export function TurnNavigation() {
|
||||
>
|
||||
<StepBack className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -53,14 +59,13 @@ export function TurnNavigation() {
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
||||
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||
<span className="-mt-[3px] inline-block">
|
||||
<span className="ml-1 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm tabular-nums">
|
||||
R{encounter.roundNumber}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center zone: active combatant name */}
|
||||
<div className="min-w-0 px-2 text-center text-sm">
|
||||
{activeCombatant ? (
|
||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||
) : (
|
||||
@@ -68,7 +73,21 @@ export function TurnNavigation() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
{/* Right zone: difficulty + destructive + forward */}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{difficulty && (
|
||||
<div className="relative mr-1">
|
||||
<DifficultyIndicator
|
||||
result={difficulty}
|
||||
onClick={() => setShowBreakdown((prev) => !prev)}
|
||||
/>
|
||||
{showBreakdown ? (
|
||||
<DifficultyBreakdownPanel
|
||||
onClose={() => setShowBreakdown(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmButton
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
label="Clear encounter"
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useClickOutside } from "../../hooks/use-click-outside.js";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
@@ -42,32 +43,7 @@ export function ConfirmButton({
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
// Click-outside listener when confirming
|
||||
useEffect(() => {
|
||||
if (!isConfirming) return;
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(e.target as Node)
|
||||
) {
|
||||
revert();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscapeKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
revert();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleEscapeKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleEscapeKey);
|
||||
};
|
||||
}, [isConfirming, revert]);
|
||||
useClickOutside(wrapperRef, revert, isConfirming);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
|
||||
71
apps/web/src/components/ui/dialog.tsx
Normal file
71
apps/web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { X } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
import { Button } from "./button.js";
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Dialog({ open, onClose, className, children }: DialogProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={cn(
|
||||
"m-auto rounded-lg border border-border bg-card text-foreground shadow-xl backdrop:bg-black/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="p-6">{children}</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogHeader({
|
||||
title,
|
||||
onClose,
|
||||
}: Readonly<{ title: string; onClose: () => void }>) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">{title}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EllipsisVertical } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { type ReactNode, useRef, useState } from "react";
|
||||
import { useClickOutside } from "../../hooks/use-click-outside.js";
|
||||
import { Button } from "./button";
|
||||
|
||||
export interface OverflowMenuItem {
|
||||
@@ -18,23 +19,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
useClickOutside(ref, () => setOpen(false), open);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
|
||||
38
apps/web/src/contexts/adapter-context.tsx
Normal file
38
apps/web/src/contexts/adapter-context.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import type {
|
||||
BestiaryCachePort,
|
||||
BestiaryIndexPort,
|
||||
EncounterPersistence,
|
||||
PlayerCharacterPersistence,
|
||||
UndoRedoPersistence,
|
||||
} from "../adapters/ports.js";
|
||||
|
||||
export interface Adapters {
|
||||
encounterPersistence: EncounterPersistence;
|
||||
undoRedoPersistence: UndoRedoPersistence;
|
||||
playerCharacterPersistence: PlayerCharacterPersistence;
|
||||
bestiaryCache: BestiaryCachePort;
|
||||
bestiaryIndex: BestiaryIndexPort;
|
||||
}
|
||||
|
||||
const AdapterContext = createContext<Adapters | null>(null);
|
||||
|
||||
export function AdapterProvider({
|
||||
adapters,
|
||||
children,
|
||||
}: {
|
||||
adapters: Adapters;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AdapterContext.Provider value={adapters}>
|
||||
{children}
|
||||
</AdapterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAdapters(): Adapters {
|
||||
const ctx = useContext(AdapterContext);
|
||||
if (!ctx) throw new Error("useAdapters requires AdapterProvider");
|
||||
return ctx;
|
||||
}
|
||||
406
apps/web/src/hooks/__tests__/encounter-reducer.test.ts
Normal file
406
apps/web/src/hooks/__tests__/encounter-reducer.test.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
ConditionId,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
combatantId,
|
||||
createEncounter,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
isDomainError,
|
||||
playerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
168
apps/web/src/hooks/__tests__/use-bulk-import.test.tsx
Normal file
168
apps/web/src/hooks/__tests__/use-bulk-import.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useBulkImport } from "../use-bulk-import.js";
|
||||
|
||||
beforeAll(() => {
|
||||
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(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
const adapters = createTestAdapters();
|
||||
adapters.bestiaryIndex = {
|
||||
...adapters.bestiaryIndex,
|
||||
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||
getDefaultFetchUrl: (code: string, baseUrl?: string) =>
|
||||
`${baseUrl}${code}.json`,
|
||||
};
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <AllProviders adapters={adapters}>{children}</AllProviders>;
|
||||
}
|
||||
|
||||
/** 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(), { wrapper });
|
||||
expect(result.current.state).toEqual({
|
||||
status: "idle",
|
||||
total: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("reset returns to idle state", async () => {
|
||||
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||
|
||||
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(), { wrapper });
|
||||
|
||||
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(), { wrapper });
|
||||
|
||||
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(), { wrapper });
|
||||
|
||||
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(), { wrapper });
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
246
apps/web/src/hooks/__tests__/use-difficulty-breakdown.test.tsx
Normal file
246
apps/web/src/hooks/__tests__/use-difficulty-breakdown.test.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js";
|
||||
|
||||
beforeAll(() => {
|
||||
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(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
const pcId1 = playerCharacterId("pc-1");
|
||||
const goblinCreature = buildCreature({
|
||||
id: creatureId("srd:goblin"),
|
||||
name: "Goblin",
|
||||
cr: "1/4",
|
||||
source: "srd",
|
||||
sourceDisplayName: "SRD",
|
||||
});
|
||||
|
||||
function makeWrapper(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
encounter: options.encounter,
|
||||
playerCharacters: options.playerCharacters ?? [],
|
||||
creatures: options.creatures,
|
||||
});
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useDifficultyBreakdown", () => {
|
||||
it("returns null when no leveled PCs", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
creatureId: goblinCreature.id,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no monsters with CR", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Custom",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns per-combatant entries with correct data", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
creatureId: goblinCreature.id,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-3"),
|
||||
name: "Custom Thug",
|
||||
cr: "2",
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-4"),
|
||||
name: "Bandit",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const breakdown = result.current;
|
||||
expect(breakdown).not.toBeNull();
|
||||
expect(breakdown?.pcCount).toBe(1);
|
||||
// CR 1/4 = 50 + CR 2 = 450 → total 500
|
||||
expect(breakdown?.totalMonsterXp).toBe(500);
|
||||
expect(breakdown?.combatants).toHaveLength(3);
|
||||
|
||||
// Bestiary combatant
|
||||
const goblin = breakdown?.combatants[0];
|
||||
expect(goblin?.cr).toBe("1/4");
|
||||
expect(goblin?.xp).toBe(50);
|
||||
expect(goblin?.source).toBe("SRD");
|
||||
expect(goblin?.editable).toBe(false);
|
||||
|
||||
// Custom with CR
|
||||
const thug = breakdown?.combatants[1];
|
||||
expect(thug?.cr).toBe("2");
|
||||
expect(thug?.xp).toBe(450);
|
||||
expect(thug?.source).toBeNull();
|
||||
expect(thug?.editable).toBe(true);
|
||||
|
||||
// Custom without CR
|
||||
const bandit = breakdown?.combatants[2];
|
||||
expect(bandit?.cr).toBeNull();
|
||||
expect(bandit?.xp).toBeNull();
|
||||
expect(bandit?.source).toBeNull();
|
||||
expect(bandit?.editable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("bestiary combatant with missing creature is non-editable with null CR", () => {
|
||||
const missingCreatureId = creatureId("creature-missing");
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Ghost",
|
||||
creatureId: missingCreatureId,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-3"),
|
||||
name: "Thug",
|
||||
cr: "1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// With no bestiary creatures loaded, the Ghost has null CR
|
||||
const breakdown = result.current;
|
||||
expect(breakdown).not.toBeNull();
|
||||
const ghost = breakdown?.combatants[0];
|
||||
expect(ghost?.cr).toBeNull();
|
||||
expect(ghost?.xp).toBeNull();
|
||||
expect(ghost?.editable).toBe(false);
|
||||
});
|
||||
|
||||
it("excludes PC combatants from breakdown entries", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
creatureId: goblinCreature.id,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current?.combatants).toHaveLength(1);
|
||||
expect(result.current?.combatants[0].combatant.name).toBe("Goblin");
|
||||
});
|
||||
});
|
||||
});
|
||||
173
apps/web/src/hooks/__tests__/use-difficulty-custom-cr.test.tsx
Normal file
173
apps/web/src/hooks/__tests__/use-difficulty-custom-cr.test.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useDifficulty } from "../use-difficulty.js";
|
||||
|
||||
beforeAll(() => {
|
||||
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(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
const pcId1 = playerCharacterId("pc-1");
|
||||
const goblinCreature = buildCreature({
|
||||
id: creatureId("srd:goblin"),
|
||||
name: "Goblin",
|
||||
cr: "1/4",
|
||||
});
|
||||
|
||||
function makeWrapper(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
encounter: options.encounter,
|
||||
playerCharacters: options.playerCharacters ?? [],
|
||||
creatures: options.creatures,
|
||||
});
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useDifficulty with custom combatant CRs", () => {
|
||||
it("includes custom combatant with cr field in monster XP", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Custom Thug",
|
||||
cr: "2",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
expect(result.current?.totalMonsterXp).toBe(450);
|
||||
});
|
||||
|
||||
it("uses bestiary CR when combatant has both creatureId and cr", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
creatureId: goblinCreature.id,
|
||||
cr: "5",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// Should use bestiary CR 1/4 (50 XP), not the manual cr "5" (1800 XP)
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
it("mixes bestiary and custom-with-CR combatants correctly", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
creatureId: goblinCreature.id,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-3"),
|
||||
name: "Custom",
|
||||
cr: "1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// CR 1/4 = 50 XP, CR 1 = 200 XP → total 250
|
||||
expect(result.current?.totalMonsterXp).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
it("custom combatant without CR is still excluded", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Custom Monster",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
220
apps/web/src/hooks/__tests__/use-difficulty.test.ts
Normal file
220
apps/web/src/hooks/__tests__/use-difficulty.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
// @vitest-environment jsdom
|
||||
import type {
|
||||
Combatant,
|
||||
CreatureId,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
||||
usePlayerCharactersContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../../contexts/player-characters-context.js";
|
||||
import { useDifficulty } from "../use-difficulty.js";
|
||||
|
||||
const mockEncounterContext = vi.mocked(useEncounterContext);
|
||||
const mockPlayerCharactersContext = vi.mocked(usePlayerCharactersContext);
|
||||
const mockBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
|
||||
const pcId1 = playerCharacterId("pc-1");
|
||||
const pcId2 = playerCharacterId("pc-2");
|
||||
const crId1 = creatureId("creature-1");
|
||||
const _crId2 = creatureId("creature-2");
|
||||
|
||||
function setup(options: {
|
||||
combatants: Combatant[];
|
||||
characters: PlayerCharacter[];
|
||||
creatures: Map<CreatureId, { cr: string }>;
|
||||
}) {
|
||||
const encounter = {
|
||||
combatants: options.combatants,
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
} as Encounter;
|
||||
|
||||
mockEncounterContext.mockReturnValue({
|
||||
encounter,
|
||||
} as ReturnType<typeof useEncounterContext>);
|
||||
|
||||
mockPlayerCharactersContext.mockReturnValue({
|
||||
characters: options.characters,
|
||||
} as ReturnType<typeof usePlayerCharactersContext>);
|
||||
|
||||
mockBestiaryContext.mockReturnValue({
|
||||
getCreature: (id: CreatureId) => options.creatures.get(id),
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useDifficulty", () => {
|
||||
it("returns difficulty result for leveled PCs and bestiary monsters", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1 },
|
||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
||||
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
expect(result.current?.tier).toBe("low");
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
});
|
||||
|
||||
describe("returns null when data is insufficient (ED-2)", () => {
|
||||
it("returns null when encounter has no combatants", () => {
|
||||
setup({ combatants: [], characters: [], creatures: new Map() });
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when only custom combatants (no creatureId)", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Custom",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }],
|
||||
creatures: new Map(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when bestiary monsters present but no PC combatants", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{ id: combatantId("c1"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when PC combatants have no level", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when PC combatant references unknown player character", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId2,
|
||||
},
|
||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 }],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles mixed combatants: only leveled PCs and bestiary monsters contribute", () => {
|
||||
// Party: one leveled PC, one without level (excluded)
|
||||
// Monsters: one bestiary creature, one custom (excluded)
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Leveled",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{
|
||||
id: combatantId("c2"),
|
||||
name: "No Level",
|
||||
playerCharacterId: pcId2,
|
||||
},
|
||||
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
||||
{ id: combatantId("c4"), name: "Custom Monster" },
|
||||
],
|
||||
characters: [
|
||||
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
|
||||
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
|
||||
],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
// 1 level-1 PC: budget low=50, mod=75, high=100
|
||||
// 1 CR 1 monster: 200 XP → high (200 >= 100)
|
||||
expect(result.current?.tier).toBe("high");
|
||||
expect(result.current?.totalMonsterXp).toBe(200);
|
||||
expect(result.current?.partyBudget.low).toBe(50);
|
||||
});
|
||||
|
||||
it("includes duplicate PC combatants in budget", () => {
|
||||
// Same PC added twice → counts twice
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Hero 1",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{
|
||||
id: combatantId("c2"),
|
||||
name: "Hero 2",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
||||
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
// 2x level 1: budget low=100
|
||||
expect(result.current?.partyBudget.low).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { ExportBundle } from "@initiative/domain";
|
||||
import { combatantId, playerCharacterId } from "@initiative/domain";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import {
|
||||
buildCombatant,
|
||||
buildEncounter,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||
import { useEncounterExportImport } from "../use-encounter-export-import.js";
|
||||
|
||||
beforeAll(() => {
|
||||
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(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <AllProviders>{children}</AllProviders>;
|
||||
}
|
||||
|
||||
function wrapperWithEncounter(encounter: ReturnType<typeof buildEncounter>) {
|
||||
const adapters = createTestAdapters({ encounter });
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <AllProviders adapters={adapters}>{children}</AllProviders>;
|
||||
};
|
||||
}
|
||||
|
||||
const VALID_BUNDLE: ExportBundle = {
|
||||
version: 1,
|
||||
exportedAt: "2026-01-01T00:00:00.000Z",
|
||||
encounter: buildEncounter({
|
||||
combatants: [buildCombatant({ id: combatantId("c-1"), name: "Imported" })],
|
||||
}),
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
playerCharacters: [
|
||||
{
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Hero",
|
||||
ac: 16,
|
||||
maxHp: 30,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("useEncounterExportImport", () => {
|
||||
describe("import via clipboard", () => {
|
||||
it("imports valid JSON into empty encounter without error", () => {
|
||||
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleImportClipboard(JSON.stringify(VALID_BUNDLE));
|
||||
});
|
||||
|
||||
// Import should succeed without error and not show confirm
|
||||
expect(result.current.importError).toBeNull();
|
||||
expect(result.current.showImportConfirm).toBe(false);
|
||||
});
|
||||
|
||||
it("sets error for invalid JSON", () => {
|
||||
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleImportClipboard("not json{{{");
|
||||
});
|
||||
|
||||
expect(result.current.importError).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("sets error for valid JSON that fails validation", () => {
|
||||
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleImportClipboard(JSON.stringify({ version: 999 }));
|
||||
});
|
||||
|
||||
expect(result.current.importError).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("shows confirm dialog when encounter is not empty", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({ id: combatantId("c-1"), name: "Existing" }),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||
wrapper: wrapperWithEncounter(encounter),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleImportClipboard(JSON.stringify(VALID_BUNDLE));
|
||||
});
|
||||
|
||||
expect(result.current.showImportConfirm).toBe(true);
|
||||
expect(result.current.importError).toBeNull();
|
||||
});
|
||||
|
||||
it("handleImportConfirm clears confirm dialog", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({ id: combatantId("c-1"), name: "Existing" }),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||
wrapper: wrapperWithEncounter(encounter),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleImportClipboard(JSON.stringify(VALID_BUNDLE));
|
||||
});
|
||||
|
||||
expect(result.current.showImportConfirm).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.handleImportConfirm();
|
||||
});
|
||||
|
||||
expect(result.current.showImportConfirm).toBe(false);
|
||||
});
|
||||
|
||||
it("handleImportCancel clears pending without applying", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({ id: combatantId("c-1"), name: "Existing" }),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
exportImport: useEncounterExportImport(),
|
||||
encounter: useEncounterContext(),
|
||||
}),
|
||||
{ wrapper: wrapperWithEncounter(encounter) },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.exportImport.handleImportClipboard(
|
||||
JSON.stringify(VALID_BUNDLE),
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.exportImport.handleImportCancel();
|
||||
});
|
||||
|
||||
expect(result.current.exportImport.showImportConfirm).toBe(false);
|
||||
expect(result.current.encounter.encounter.combatants[0].name).toBe(
|
||||
"Existing",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("export", () => {
|
||||
it("handleExportDownload calls triggerDownload", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({ id: combatantId("c-1"), name: "Fighter" }),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||
wrapper: wrapperWithEncounter(encounter),
|
||||
});
|
||||
|
||||
// triggerDownload creates a blob URL and clicks an anchor — just verify it doesn't throw
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.handleExportDownload(false, "test-export.json");
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dialog state", () => {
|
||||
it("toggles export method dialog", () => {
|
||||
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.showExportMethod).toBe(false);
|
||||
act(() => result.current.setShowExportMethod(true));
|
||||
expect(result.current.showExportMethod).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles import method dialog", () => {
|
||||
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.showImportMethod).toBe(false);
|
||||
act(() => result.current.setShowImportMethod(true));
|
||||
expect(result.current.showImportMethod).toBe(true);
|
||||
});
|
||||
|
||||
it("clears import error", () => {
|
||||
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleImportClipboard("bad json");
|
||||
});
|
||||
expect(result.current.importError).toBe("Invalid file format");
|
||||
|
||||
act(() => result.current.setImportError(null));
|
||||
expect(result.current.importError).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,27 +2,35 @@
|
||||
import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useEncounter } from "../use-encounter.js";
|
||||
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: vi.fn().mockReturnValue(null),
|
||||
saveEncounter: vi.fn(),
|
||||
}));
|
||||
beforeAll(() => {
|
||||
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(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
const { loadEncounter: mockLoad, saveEncounter: mockSave } =
|
||||
await vi.importMock<typeof import("../../persistence/encounter-storage.js")>(
|
||||
"../../persistence/encounter-storage.js",
|
||||
);
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <AllProviders>{children}</AllProviders>;
|
||||
}
|
||||
|
||||
describe("useEncounter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLoad.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("initializes with empty encounter when persistence returns null", () => {
|
||||
const { result } = renderHook(() => useEncounter());
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
expect(result.current.encounter.combatants).toEqual([]);
|
||||
expect(result.current.encounter.activeIndex).toBe(0);
|
||||
@@ -32,13 +40,33 @@ describe("useEncounter", () => {
|
||||
|
||||
it("initializes from stored encounter", () => {
|
||||
const stored = {
|
||||
combatants: [{ id: combatantId("c-1"), name: "Goblin" }],
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Goblin",
|
||||
initiative: undefined,
|
||||
maxHp: undefined,
|
||||
currentHp: undefined,
|
||||
tempHp: undefined,
|
||||
ac: undefined,
|
||||
conditions: [],
|
||||
concentrating: false,
|
||||
creatureId: undefined,
|
||||
playerCharacterId: undefined,
|
||||
color: undefined,
|
||||
icon: undefined,
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 2,
|
||||
};
|
||||
mockLoad.mockReturnValue(stored);
|
||||
const adapters = createTestAdapters({ encounter: stored });
|
||||
|
||||
const { result } = renderHook(() => useEncounter());
|
||||
const { result } = renderHook(() => useEncounter(), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current.encounter.combatants).toHaveLength(1);
|
||||
expect(result.current.encounter.roundNumber).toBe(2);
|
||||
@@ -46,7 +74,7 @@ describe("useEncounter", () => {
|
||||
});
|
||||
|
||||
it("addCombatant adds a combatant with incremental IDs and persists", () => {
|
||||
const { result } = renderHook(() => useEncounter());
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
act(() => result.current.addCombatant("Goblin"));
|
||||
act(() => result.current.addCombatant("Orc"));
|
||||
@@ -55,11 +83,10 @@ describe("useEncounter", () => {
|
||||
expect(result.current.encounter.combatants[0].name).toBe("Goblin");
|
||||
expect(result.current.encounter.combatants[1].name).toBe("Orc");
|
||||
expect(result.current.isEmpty).toBe(false);
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removeCombatant removes a combatant and persists", () => {
|
||||
const { result } = renderHook(() => useEncounter());
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
act(() => result.current.addCombatant("Goblin"));
|
||||
const id = result.current.encounter.combatants[0].id;
|
||||
@@ -71,7 +98,7 @@ describe("useEncounter", () => {
|
||||
});
|
||||
|
||||
it("advanceTurn and retreatTurn update encounter state", () => {
|
||||
const { result } = renderHook(() => useEncounter());
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
act(() => result.current.addCombatant("Goblin"));
|
||||
act(() => result.current.addCombatant("Orc"));
|
||||
@@ -86,7 +113,7 @@ describe("useEncounter", () => {
|
||||
});
|
||||
|
||||
it("clearEncounter resets to empty and resets ID counter", () => {
|
||||
const { result } = renderHook(() => useEncounter());
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
act(() => result.current.addCombatant("Goblin"));
|
||||
act(() => result.current.clearEncounter());
|
||||
@@ -100,7 +127,7 @@ describe("useEncounter", () => {
|
||||
});
|
||||
|
||||
it("addCombatant with opts applies initiative, ac, maxHp", () => {
|
||||
const { result } = renderHook(() => useEncounter());
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
act(() =>
|
||||
result.current.addCombatant("Goblin", {
|
||||
@@ -118,7 +145,7 @@ describe("useEncounter", () => {
|
||||
});
|
||||
|
||||
it("derived flags: hasCreatureCombatants and canRollAllInitiative", () => {
|
||||
const { result } = renderHook(() => useEncounter());
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
// No creatures yet
|
||||
expect(result.current.hasCreatureCombatants).toBe(false);
|
||||
@@ -146,7 +173,7 @@ describe("useEncounter", () => {
|
||||
});
|
||||
|
||||
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
|
||||
const { result } = renderHook(() => useEncounter());
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
const entry: BestiaryIndexEntry = {
|
||||
name: "Goblin",
|
||||
@@ -173,7 +200,7 @@ describe("useEncounter", () => {
|
||||
});
|
||||
|
||||
it("addFromBestiary auto-numbers duplicate names", () => {
|
||||
const { result } = renderHook(() => useEncounter());
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
const entry: BestiaryIndexEntry = {
|
||||
name: "Goblin",
|
||||
@@ -200,7 +227,7 @@ describe("useEncounter", () => {
|
||||
});
|
||||
|
||||
it("addFromPlayerCharacter adds combatant with HP, AC, color, icon", () => {
|
||||
const { result } = renderHook(() => useEncounter());
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
const pc: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-1"),
|
||||
118
apps/web/src/hooks/__tests__/use-initiative-rolls.test.ts
Normal file
118
apps/web/src/hooks/__tests__/use-initiative-rolls.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
104
apps/web/src/hooks/__tests__/use-long-press.test.ts
Normal file
104
apps/web/src/hooks/__tests__/use-long-press.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { playerCharacterId } from "@initiative/domain";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePlayerCharacters } from "../use-player-characters.js";
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: vi.fn().mockReturnValue([]),
|
||||
savePlayerCharacters: vi.fn(),
|
||||
}));
|
||||
|
||||
const { loadPlayerCharacters: mockLoad, savePlayerCharacters: mockSave } =
|
||||
await vi.importMock<
|
||||
typeof import("../../persistence/player-character-storage.js")
|
||||
>("../../persistence/player-character-storage.js");
|
||||
|
||||
describe("usePlayerCharacters", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLoad.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it("initializes with characters from persistence", () => {
|
||||
const stored = [
|
||||
{
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 30,
|
||||
color: undefined,
|
||||
icon: undefined,
|
||||
},
|
||||
];
|
||||
mockLoad.mockReturnValue(stored);
|
||||
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
expect(result.current.characters).toEqual(stored);
|
||||
});
|
||||
|
||||
it("createCharacter adds a character and persists", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||
});
|
||||
|
||||
expect(result.current.characters).toHaveLength(1);
|
||||
expect(result.current.characters[0].name).toBe("Vex");
|
||||
expect(result.current.characters[0].ac).toBe(15);
|
||||
expect(result.current.characters[0].maxHp).toBe(28);
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("createCharacter returns domain error for empty name", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
let error: unknown;
|
||||
act(() => {
|
||||
error = result.current.createCharacter("", 15, 28, undefined, undefined);
|
||||
});
|
||||
|
||||
expect(error).toMatchObject({ kind: "domain-error" });
|
||||
expect(result.current.characters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("editCharacter updates character and persists", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||
});
|
||||
|
||||
const id = result.current.characters[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.editCharacter(id, { name: "Vex'ahlia" });
|
||||
});
|
||||
|
||||
expect(result.current.characters[0].name).toBe("Vex'ahlia");
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deleteCharacter removes character and persists", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||
});
|
||||
|
||||
const id = result.current.characters[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.deleteCharacter(id);
|
||||
});
|
||||
|
||||
expect(result.current.characters).toHaveLength(0);
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
137
apps/web/src/hooks/__tests__/use-player-characters.test.tsx
Normal file
137
apps/web/src/hooks/__tests__/use-player-characters.test.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment jsdom
|
||||
import { playerCharacterId } from "@initiative/domain";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { usePlayerCharacters } from "../use-player-characters.js";
|
||||
|
||||
beforeAll(() => {
|
||||
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(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <AllProviders>{children}</AllProviders>;
|
||||
}
|
||||
|
||||
describe("usePlayerCharacters", () => {
|
||||
it("initializes with characters from persistence", () => {
|
||||
const stored = [
|
||||
{
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 30,
|
||||
color: undefined,
|
||||
icon: undefined,
|
||||
},
|
||||
];
|
||||
const adapters = createTestAdapters({ playerCharacters: stored });
|
||||
|
||||
const { result } = renderHook(() => usePlayerCharacters(), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current.characters).toEqual(stored);
|
||||
});
|
||||
|
||||
it("createCharacter adds a character and persists", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter(
|
||||
"Vex",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.characters).toHaveLength(1);
|
||||
expect(result.current.characters[0].name).toBe("Vex");
|
||||
expect(result.current.characters[0].ac).toBe(15);
|
||||
expect(result.current.characters[0].maxHp).toBe(28);
|
||||
});
|
||||
|
||||
it("createCharacter returns domain error for empty name", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||
|
||||
let error: unknown;
|
||||
act(() => {
|
||||
error = result.current.createCharacter(
|
||||
"",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
expect(error).toMatchObject({ kind: "domain-error" });
|
||||
expect(result.current.characters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("editCharacter updates character and persists", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter(
|
||||
"Vex",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const id = result.current.characters[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.editCharacter(id, { name: "Vex'ahlia" });
|
||||
});
|
||||
|
||||
expect(result.current.characters[0].name).toBe("Vex'ahlia");
|
||||
});
|
||||
|
||||
it("deleteCharacter removes character and persists", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter(
|
||||
"Vex",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const id = result.current.characters[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.deleteCharacter(id);
|
||||
});
|
||||
|
||||
expect(result.current.characters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
45
apps/web/src/hooks/__tests__/use-rules-edition.test.ts
Normal file
45
apps/web/src/hooks/__tests__/use-rules-edition.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
116
apps/web/src/hooks/__tests__/use-swipe-to-dismiss.test.ts
Normal file
116
apps/web/src/hooks/__tests__/use-swipe-to-dismiss.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
63
apps/web/src/hooks/__tests__/use-theme.test.ts
Normal file
63
apps/web/src/hooks/__tests__/use-theme.test.ts
Normal file
@@ -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 { useCallback, useDeferredValue, useMemo, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
@@ -26,14 +33,33 @@ export function creatureKey(r: SearchResult): string {
|
||||
}
|
||||
|
||||
export function useActionBarState() {
|
||||
const { addCombatant, addFromBestiary, addFromPlayerCharacter } =
|
||||
useEncounterContext();
|
||||
const {
|
||||
addCombatant,
|
||||
addFromBestiary,
|
||||
addMultipleFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
lastCreatureId,
|
||||
} = useEncounterContext();
|
||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||
useBestiaryContext();
|
||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
useSidePanelContext();
|
||||
|
||||
// Auto-show stat block when a bestiary creature is added on desktop
|
||||
const prevCreatureIdRef = useRef(lastCreatureId);
|
||||
useEffect(() => {
|
||||
if (
|
||||
lastCreatureId &&
|
||||
lastCreatureId !== prevCreatureIdRef.current &&
|
||||
panelView.mode === "closed" &&
|
||||
globalThis.matchMedia("(min-width: 1024px)").matches
|
||||
) {
|
||||
showCreature(lastCreatureId);
|
||||
}
|
||||
prevCreatureIdRef.current = lastCreatureId;
|
||||
}, [lastCreatureId, panelView.mode, showCreature]);
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
@@ -69,13 +95,9 @@ export function useActionBarState() {
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
addFromBestiary(result);
|
||||
},
|
||||
[addFromBestiary, panelView.mode, showCreature],
|
||||
[addFromBestiary],
|
||||
);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
@@ -92,11 +114,13 @@ export function useActionBarState() {
|
||||
|
||||
const confirmQueued = useCallback(() => {
|
||||
if (!queued) return;
|
||||
for (let i = 0; i < queued.count; i++) {
|
||||
if (queued.count === 1) {
|
||||
handleAddFromBestiary(queued.result);
|
||||
} else {
|
||||
addMultipleFromBestiary(queued.result, queued.count);
|
||||
}
|
||||
clearInput();
|
||||
}, [queued, handleAddFromBestiary, clearInput]);
|
||||
}, [queued, handleAddFromBestiary, addMultipleFromBestiary, clearInput]);
|
||||
|
||||
const parseNum = (v: string): number | undefined => {
|
||||
if (v.trim() === "") return undefined;
|
||||
|
||||
@@ -8,11 +8,7 @@ import {
|
||||
normalizeBestiary,
|
||||
setSourceDisplayNames,
|
||||
} from "../adapters/bestiary-adapter.js";
|
||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||
import {
|
||||
getSourceDisplayName,
|
||||
loadBestiaryIndex,
|
||||
} from "../adapters/bestiary-index-adapter.js";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
|
||||
export interface SearchResult extends BestiaryIndexEntry {
|
||||
readonly sourceDisplayName: string;
|
||||
@@ -32,13 +28,14 @@ interface BestiaryHook {
|
||||
}
|
||||
|
||||
export function useBestiary(): BestiaryHook {
|
||||
const { bestiaryCache, bestiaryIndex } = useAdapters();
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [creatureMap, setCreatureMap] = useState(
|
||||
() => new Map<CreatureId, Creature>(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const index = loadBestiaryIndex();
|
||||
const index = bestiaryIndex.loadIndex();
|
||||
setSourceDisplayNames(index.sources as Record<string, string>);
|
||||
if (index.creatures.length > 0) {
|
||||
setIsLoaded(true);
|
||||
@@ -47,21 +44,24 @@ export function useBestiary(): BestiaryHook {
|
||||
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||
setCreatureMap(map);
|
||||
});
|
||||
}, []);
|
||||
}, [bestiaryCache, bestiaryIndex]);
|
||||
|
||||
const search = useCallback((query: string): SearchResult[] => {
|
||||
const search = useCallback(
|
||||
(query: string): SearchResult[] => {
|
||||
if (query.length < 2) return [];
|
||||
const lower = query.toLowerCase();
|
||||
const index = loadBestiaryIndex();
|
||||
const index = bestiaryIndex.loadIndex();
|
||||
return index.creatures
|
||||
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.slice(0, 10)
|
||||
.map((c) => ({
|
||||
...c,
|
||||
sourceDisplayName: getSourceDisplayName(c.source),
|
||||
sourceDisplayName: bestiaryIndex.getSourceDisplayName(c.source),
|
||||
}));
|
||||
}, []);
|
||||
},
|
||||
[bestiaryIndex],
|
||||
);
|
||||
|
||||
const getCreature = useCallback(
|
||||
(id: CreatureId): Creature | undefined => {
|
||||
@@ -74,7 +74,7 @@ export function useBestiary(): BestiaryHook {
|
||||
(sourceCode: string): Promise<boolean> => {
|
||||
return bestiaryCache.isSourceCached(sourceCode);
|
||||
},
|
||||
[],
|
||||
[bestiaryCache],
|
||||
);
|
||||
|
||||
const fetchAndCacheSource = useCallback(
|
||||
@@ -87,7 +87,7 @@ export function useBestiary(): BestiaryHook {
|
||||
}
|
||||
const json = await response.json();
|
||||
const creatures = normalizeBestiary(json);
|
||||
const displayName = getSourceDisplayName(sourceCode);
|
||||
const displayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||
setCreatureMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -97,14 +97,14 @@ export function useBestiary(): BestiaryHook {
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
[bestiaryCache, bestiaryIndex],
|
||||
);
|
||||
|
||||
const uploadAndCacheSource = useCallback(
|
||||
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies
|
||||
const creatures = normalizeBestiary(jsonData as any);
|
||||
const displayName = getSourceDisplayName(sourceCode);
|
||||
const displayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||
setCreatureMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -114,13 +114,13 @@ export function useBestiary(): BestiaryHook {
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
[bestiaryCache, bestiaryIndex],
|
||||
);
|
||||
|
||||
const refreshCache = useCallback(async (): Promise<void> => {
|
||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||
setCreatureMap(map);
|
||||
}, []);
|
||||
}, [bestiaryCache]);
|
||||
|
||||
return {
|
||||
search,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
getAllSourceCodes,
|
||||
getDefaultFetchUrl,
|
||||
} from "../adapters/bestiary-index-adapter.js";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
|
||||
const BATCH_SIZE = 6;
|
||||
|
||||
@@ -32,6 +29,7 @@ interface BulkImportHook {
|
||||
}
|
||||
|
||||
export function useBulkImport(): BulkImportHook {
|
||||
const { bestiaryIndex } = useAdapters();
|
||||
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
|
||||
const countersRef = useRef({ completed: 0, failed: 0 });
|
||||
|
||||
@@ -42,7 +40,7 @@ export function useBulkImport(): BulkImportHook {
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||
refreshCache: () => Promise<void>,
|
||||
) => {
|
||||
const allCodes = getAllSourceCodes();
|
||||
const allCodes = bestiaryIndex.getAllSourceCodes();
|
||||
const total = allCodes.length;
|
||||
|
||||
countersRef.current = { completed: 0, failed: 0 };
|
||||
@@ -83,7 +81,7 @@ export function useBulkImport(): BulkImportHook {
|
||||
chain.then(() =>
|
||||
Promise.allSettled(
|
||||
batch.map(async ({ code }) => {
|
||||
const url = getDefaultFetchUrl(code, baseUrl);
|
||||
const url = bestiaryIndex.getDefaultFetchUrl(code, baseUrl);
|
||||
try {
|
||||
await fetchAndCacheSource(code, url);
|
||||
countersRef.current.completed++;
|
||||
@@ -117,7 +115,7 @@ export function useBulkImport(): BulkImportHook {
|
||||
});
|
||||
})();
|
||||
},
|
||||
[],
|
||||
[bestiaryIndex],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
|
||||
27
apps/web/src/hooks/use-click-outside.ts
Normal file
27
apps/web/src/hooks/use-click-outside.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useClickOutside(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
onClose: () => void,
|
||||
active = true,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [ref, onClose, active]);
|
||||
}
|
||||
140
apps/web/src/hooks/use-difficulty-breakdown.ts
Normal file
140
apps/web/src/hooks/use-difficulty-breakdown.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type {
|
||||
Combatant,
|
||||
CreatureId,
|
||||
DifficultyTier,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { calculateEncounterDifficulty, crToXp } from "@initiative/domain";
|
||||
import { useMemo } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
|
||||
export interface BreakdownCombatant {
|
||||
readonly combatant: Combatant;
|
||||
readonly cr: string | null;
|
||||
readonly xp: number | null;
|
||||
readonly source: string | null;
|
||||
readonly editable: boolean;
|
||||
}
|
||||
|
||||
interface DifficultyBreakdown {
|
||||
readonly tier: DifficultyTier;
|
||||
readonly totalMonsterXp: number;
|
||||
readonly partyBudget: {
|
||||
readonly low: number;
|
||||
readonly moderate: number;
|
||||
readonly high: number;
|
||||
};
|
||||
readonly pcCount: number;
|
||||
readonly combatants: readonly BreakdownCombatant[];
|
||||
}
|
||||
|
||||
export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
const { encounter } = useEncounterContext();
|
||||
const { characters } = usePlayerCharactersContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
|
||||
return useMemo(() => {
|
||||
const partyLevels = derivePartyLevels(encounter.combatants, characters);
|
||||
const { entries, crs } = classifyCombatants(
|
||||
encounter.combatants,
|
||||
getCreature,
|
||||
);
|
||||
|
||||
if (partyLevels.length === 0 || crs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = calculateEncounterDifficulty(partyLevels, crs);
|
||||
|
||||
return {
|
||||
...result,
|
||||
pcCount: partyLevels.length,
|
||||
combatants: entries,
|
||||
};
|
||||
}, [encounter.combatants, characters, getCreature]);
|
||||
}
|
||||
|
||||
function classifyBestiaryCombatant(
|
||||
c: Combatant,
|
||||
getCreature: (
|
||||
id: CreatureId,
|
||||
) => { cr: string; source: string; sourceDisplayName: string } | undefined,
|
||||
): { entry: BreakdownCombatant; cr: string | null } {
|
||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
if (creature) {
|
||||
return {
|
||||
entry: {
|
||||
combatant: c,
|
||||
cr: creature.cr,
|
||||
xp: crToXp(creature.cr),
|
||||
source: creature.sourceDisplayName ?? creature.source,
|
||||
editable: false,
|
||||
},
|
||||
cr: creature.cr,
|
||||
};
|
||||
}
|
||||
return {
|
||||
entry: {
|
||||
combatant: c,
|
||||
cr: null,
|
||||
xp: null,
|
||||
source: null,
|
||||
editable: false,
|
||||
},
|
||||
cr: null,
|
||||
};
|
||||
}
|
||||
|
||||
function classifyCombatants(
|
||||
combatants: readonly Combatant[],
|
||||
getCreature: (
|
||||
id: CreatureId,
|
||||
) => { cr: string; source: string; sourceDisplayName: string } | undefined,
|
||||
): { entries: BreakdownCombatant[]; crs: string[] } {
|
||||
const entries: BreakdownCombatant[] = [];
|
||||
const crs: string[] = [];
|
||||
|
||||
for (const c of combatants) {
|
||||
if (c.playerCharacterId) continue;
|
||||
|
||||
if (c.creatureId) {
|
||||
const { entry, cr } = classifyBestiaryCombatant(c, getCreature);
|
||||
entries.push(entry);
|
||||
if (cr) crs.push(cr);
|
||||
} else if (c.cr) {
|
||||
crs.push(c.cr);
|
||||
entries.push({
|
||||
combatant: c,
|
||||
cr: c.cr,
|
||||
xp: crToXp(c.cr),
|
||||
source: null,
|
||||
editable: true,
|
||||
});
|
||||
} else {
|
||||
entries.push({
|
||||
combatant: c,
|
||||
cr: null,
|
||||
xp: null,
|
||||
source: null,
|
||||
editable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, crs };
|
||||
}
|
||||
|
||||
function derivePartyLevels(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
): number[] {
|
||||
const levels: number[] = [];
|
||||
for (const c of combatants) {
|
||||
if (!c.playerCharacterId) continue;
|
||||
const pc = characters.find((p) => p.id === c.playerCharacterId);
|
||||
if (pc?.level !== undefined) levels.push(pc.level);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
57
apps/web/src/hooks/use-difficulty.ts
Normal file
57
apps/web/src/hooks/use-difficulty.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
Combatant,
|
||||
CreatureId,
|
||||
DifficultyResult,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { calculateEncounterDifficulty } from "@initiative/domain";
|
||||
import { useMemo } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
|
||||
function derivePartyLevels(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
): number[] {
|
||||
const levels: number[] = [];
|
||||
for (const c of combatants) {
|
||||
if (!c.playerCharacterId) continue;
|
||||
const pc = characters.find((p) => p.id === c.playerCharacterId);
|
||||
if (pc?.level !== undefined) levels.push(pc.level);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
function deriveMonsterCrs(
|
||||
combatants: readonly Combatant[],
|
||||
getCreature: (id: CreatureId) => { cr: string } | undefined,
|
||||
): string[] {
|
||||
const crs: string[] = [];
|
||||
for (const c of combatants) {
|
||||
if (c.creatureId) {
|
||||
const creature = getCreature(c.creatureId);
|
||||
if (creature) crs.push(creature.cr);
|
||||
} else if (c.cr) {
|
||||
crs.push(c.cr);
|
||||
}
|
||||
}
|
||||
return crs;
|
||||
}
|
||||
|
||||
export function useDifficulty(): DifficultyResult | null {
|
||||
const { encounter } = useEncounterContext();
|
||||
const { characters } = usePlayerCharactersContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
|
||||
return useMemo(() => {
|
||||
const partyLevels = derivePartyLevels(encounter.combatants, characters);
|
||||
const monsterCrs = deriveMonsterCrs(encounter.combatants, getCreature);
|
||||
|
||||
if (partyLevels.length === 0 || monsterCrs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return calculateEncounterDifficulty(partyLevels, monsterCrs);
|
||||
}, [encounter.combatants, characters, getCreature]);
|
||||
}
|
||||
139
apps/web/src/hooks/use-encounter-export-import.ts
Normal file
139
apps/web/src/hooks/use-encounter-export-import.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { ExportBundle } from "@initiative/domain";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import {
|
||||
assembleExportBundle,
|
||||
bundleToJson,
|
||||
readImportFile,
|
||||
triggerDownload,
|
||||
validateImportBundle,
|
||||
} from "../persistence/export-import.js";
|
||||
|
||||
export function useEncounterExportImport() {
|
||||
const {
|
||||
encounter,
|
||||
undoRedoState,
|
||||
isEmpty: encounterIsEmpty,
|
||||
setEncounter,
|
||||
setUndoRedoState,
|
||||
} = useEncounterContext();
|
||||
const { characters: playerCharacters, replacePlayerCharacters } =
|
||||
usePlayerCharactersContext();
|
||||
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [showExportMethod, setShowExportMethod] = useState(false);
|
||||
const [showImportMethod, setShowImportMethod] = useState(false);
|
||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||
const pendingBundleRef = useRef<ExportBundle | null>(null);
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleExportDownload = useCallback(
|
||||
(includeHistory: boolean, filename: string) => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
includeHistory,
|
||||
);
|
||||
triggerDownload(bundle, filename);
|
||||
},
|
||||
[encounter, undoRedoState, playerCharacters],
|
||||
);
|
||||
|
||||
const handleExportClipboard = useCallback(
|
||||
(includeHistory: boolean) => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
includeHistory,
|
||||
);
|
||||
void navigator.clipboard.writeText(bundleToJson(bundle));
|
||||
},
|
||||
[encounter, undoRedoState, playerCharacters],
|
||||
);
|
||||
|
||||
const applyImport = useCallback(
|
||||
(bundle: ExportBundle) => {
|
||||
setEncounter(bundle.encounter);
|
||||
setUndoRedoState({
|
||||
undoStack: bundle.undoStack,
|
||||
redoStack: bundle.redoStack,
|
||||
});
|
||||
replacePlayerCharacters([...bundle.playerCharacters]);
|
||||
},
|
||||
[setEncounter, setUndoRedoState, replacePlayerCharacters],
|
||||
);
|
||||
|
||||
const handleValidatedBundle = useCallback(
|
||||
(result: ExportBundle | string) => {
|
||||
if (typeof result === "string") {
|
||||
setImportError(result);
|
||||
return;
|
||||
}
|
||||
if (encounterIsEmpty) {
|
||||
applyImport(result);
|
||||
} else {
|
||||
pendingBundleRef.current = result;
|
||||
setShowImportConfirm(true);
|
||||
}
|
||||
},
|
||||
[encounterIsEmpty, applyImport],
|
||||
);
|
||||
|
||||
const handleImportFile = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (importFileRef.current) importFileRef.current.value = "";
|
||||
|
||||
setImportError(null);
|
||||
handleValidatedBundle(await readImportFile(file));
|
||||
},
|
||||
[handleValidatedBundle],
|
||||
);
|
||||
|
||||
const handleImportClipboard = useCallback(
|
||||
(text: string) => {
|
||||
setImportError(null);
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
handleValidatedBundle(validateImportBundle(parsed));
|
||||
} catch {
|
||||
setImportError("Invalid file format");
|
||||
}
|
||||
},
|
||||
[handleValidatedBundle],
|
||||
);
|
||||
|
||||
const handleImportConfirm = useCallback(() => {
|
||||
if (pendingBundleRef.current) {
|
||||
applyImport(pendingBundleRef.current);
|
||||
pendingBundleRef.current = null;
|
||||
}
|
||||
setShowImportConfirm(false);
|
||||
}, [applyImport]);
|
||||
|
||||
const handleImportCancel = useCallback(() => {
|
||||
pendingBundleRef.current = null;
|
||||
setShowImportConfirm(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
importError,
|
||||
showExportMethod,
|
||||
showImportMethod,
|
||||
showImportConfirm,
|
||||
importFileRef,
|
||||
setImportError,
|
||||
setShowExportMethod,
|
||||
setShowImportMethod,
|
||||
handleExportDownload,
|
||||
handleExportClipboard,
|
||||
handleImportFile,
|
||||
handleImportClipboard,
|
||||
handleImportConfirm,
|
||||
handleImportCancel,
|
||||
} as const;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
removeCombatantUseCase,
|
||||
retreatTurnUseCase,
|
||||
setAcUseCase,
|
||||
setCrUseCase,
|
||||
setHpUseCase,
|
||||
setInitiativeUseCase,
|
||||
setTempHpUseCase,
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
CombatantInit,
|
||||
ConditionId,
|
||||
CreatureId,
|
||||
DomainError,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
@@ -35,15 +37,54 @@ import {
|
||||
pushUndo,
|
||||
resolveCreatureName,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
loadEncounter,
|
||||
saveEncounter,
|
||||
} from "../persistence/encounter-storage.js";
|
||||
import {
|
||||
loadUndoRedoStacks,
|
||||
saveUndoRedoStacks,
|
||||
} from "../persistence/undo-redo-storage.js";
|
||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import { useAdapters } from "../contexts/adapter-context.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: "set-cr"; id: CombatantId; value: string | 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+)$/;
|
||||
|
||||
@@ -53,12 +94,6 @@ const EMPTY_ENCOUNTER: Encounter = {
|
||||
roundNumber: 1,
|
||||
};
|
||||
|
||||
function initializeEncounter(): Encounter {
|
||||
const stored = loadEncounter();
|
||||
if (stored !== null) return stored;
|
||||
return EMPTY_ENCOUNTER;
|
||||
}
|
||||
|
||||
function deriveNextId(encounter: Encounter): number {
|
||||
let max = 0;
|
||||
for (const c of encounter.combatants) {
|
||||
@@ -71,40 +106,317 @@ function deriveNextId(encounter: Encounter): number {
|
||||
return max;
|
||||
}
|
||||
|
||||
function initializeState(
|
||||
loadEncounterFn: () => Encounter | null,
|
||||
loadUndoRedoFn: () => UndoRedoState,
|
||||
): EncounterState {
|
||||
const encounter = loadEncounterFn() ?? EMPTY_ENCOUNTER;
|
||||
return {
|
||||
encounter,
|
||||
undoRedoState: loadUndoRedoFn(),
|
||||
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: "set-cr" }
|
||||
| { 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 "set-cr":
|
||||
result = setCrUseCase(store, action.id, action.value);
|
||||
break;
|
||||
case "toggle-condition":
|
||||
result = toggleConditionUseCase(store, action.id, action.conditionId);
|
||||
break;
|
||||
case "toggle-concentration":
|
||||
result = toggleConcentrationUseCase(store, action.id);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isDomainError(result)) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
encounter: getEncounter(),
|
||||
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||
events: [...state.events, ...result],
|
||||
nextId: action.type === "add-combatant" ? state.nextId + 1 : state.nextId,
|
||||
lastCreatureId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Hook --
|
||||
|
||||
export function useEncounter() {
|
||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||
const [undoRedoState, setUndoRedoState] =
|
||||
useState<UndoRedoState>(loadUndoRedoStacks);
|
||||
const { encounterPersistence, undoRedoPersistence } = useAdapters();
|
||||
const [state, dispatch] = useReducer(encounterReducer, null, () =>
|
||||
initializeState(encounterPersistence.load, undoRedoPersistence.load),
|
||||
);
|
||||
const { encounter, undoRedoState, events } = state;
|
||||
|
||||
const encounterRef = useRef(encounter);
|
||||
encounterRef.current = encounter;
|
||||
const undoRedoRef = useRef(undoRedoState);
|
||||
undoRedoRef.current = undoRedoState;
|
||||
|
||||
useEffect(() => {
|
||||
saveEncounter(encounter);
|
||||
}, [encounter]);
|
||||
encounterPersistence.save(encounter);
|
||||
}, [encounter, encounterPersistence]);
|
||||
|
||||
useEffect(() => {
|
||||
saveUndoRedoStacks(undoRedoState);
|
||||
}, [undoRedoState]);
|
||||
undoRedoPersistence.save(undoRedoState);
|
||||
}, [undoRedoState, undoRedoPersistence]);
|
||||
|
||||
// Escape hatches for useInitiativeRolls (needs raw port access)
|
||||
const makeStore = useCallback((): EncounterStore => {
|
||||
return {
|
||||
get: () => encounterRef.current,
|
||||
save: (e) => {
|
||||
encounterRef.current = e;
|
||||
setEncounter(e);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
|
||||
return {
|
||||
get: () => undoRedoRef.current,
|
||||
save: (s) => {
|
||||
undoRedoRef.current = s;
|
||||
setUndoRedoState(s);
|
||||
dispatch({
|
||||
type: "import",
|
||||
encounter: e,
|
||||
undoRedoState: undoRedoRef.current,
|
||||
});
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
@@ -115,286 +427,21 @@ export function useEncounter() {
|
||||
if (!isDomainError(result)) {
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
dispatch({
|
||||
type: "import",
|
||||
encounter: encounterRef.current,
|
||||
undoRedoState: newState,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const advanceTurn = useCallback(() => {
|
||||
const result = withUndo(() => advanceTurnUseCase(makeStore()));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore, withUndo]);
|
||||
|
||||
const retreatTurn = useCallback(() => {
|
||||
const result = withUndo(() => retreatTurnUseCase(makeStore()));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore, withUndo]);
|
||||
|
||||
const nextId = useRef(deriveNextId(encounter));
|
||||
|
||||
const addCombatant = useCallback(
|
||||
(name: string, init?: CombatantInit) => {
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = withUndo(() =>
|
||||
addCombatantUseCase(makeStore(), id, name, init),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const removeCombatant = useCallback(
|
||||
(id: CombatantId) => {
|
||||
const result = withUndo(() => removeCombatantUseCase(makeStore(), id));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const editCombatant = useCallback(
|
||||
(id: CombatantId, newName: string) => {
|
||||
const result = withUndo(() =>
|
||||
editCombatantUseCase(makeStore(), id, newName),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setInitiative = useCallback(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = withUndo(() =>
|
||||
setInitiativeUseCase(makeStore(), id, value),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setHp = useCallback(
|
||||
(id: CombatantId, maxHp: number | undefined) => {
|
||||
const result = withUndo(() => setHpUseCase(makeStore(), id, maxHp));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const adjustHp = useCallback(
|
||||
(id: CombatantId, delta: number) => {
|
||||
const result = withUndo(() => adjustHpUseCase(makeStore(), id, delta));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setTempHp = useCallback(
|
||||
(id: CombatantId, tempHp: number | undefined) => {
|
||||
const result = withUndo(() => setTempHpUseCase(makeStore(), id, tempHp));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setAc = useCallback(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = withUndo(() => setAcUseCase(makeStore(), id, value));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const toggleCondition = useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId) => {
|
||||
const result = withUndo(() =>
|
||||
toggleConditionUseCase(makeStore(), id, conditionId),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const toggleConcentration = useCallback(
|
||||
(id: CombatantId) => {
|
||||
const result = withUndo(() =>
|
||||
toggleConcentrationUseCase(makeStore(), id),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
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 addFromBestiary = useCallback(
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
const snapshot = encounterRef.current;
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(
|
||||
entry.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);
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
store.save(snapshot);
|
||||
return null;
|
||||
}
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
return cId;
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const addFromPlayerCharacter = useCallback(
|
||||
(pc: PlayerCharacter) => {
|
||||
const snapshot = encounterRef.current;
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(pc.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);
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
store.save(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const undoAction = useCallback(() => {
|
||||
undoUseCase(makeStore(), makeUndoRedoStore());
|
||||
}, [makeStore, makeUndoRedoStore]);
|
||||
|
||||
const redoAction = useCallback(() => {
|
||||
redoUseCase(makeStore(), makeUndoRedoStore());
|
||||
}, [makeStore, makeUndoRedoStore]);
|
||||
|
||||
// Derived state
|
||||
const canUndo = undoRedoState.undoStack.length > 0;
|
||||
const canRedo = undoRedoState.redoStack.length > 0;
|
||||
|
||||
const hasTempHp = encounter.combatants.some(
|
||||
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||
);
|
||||
|
||||
const isEmpty = encounter.combatants.length === 0;
|
||||
const hasCreatureCombatants = encounter.combatants.some(
|
||||
(c) => c.creatureId != null,
|
||||
@@ -405,6 +452,7 @@ export function useEncounter() {
|
||||
|
||||
return {
|
||||
encounter,
|
||||
undoRedoState,
|
||||
events,
|
||||
isEmpty,
|
||||
hasTempHp,
|
||||
@@ -412,23 +460,110 @@ export function useEncounter() {
|
||||
canRollAllInitiative,
|
||||
canUndo,
|
||||
canRedo,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
clearEncounter,
|
||||
removeCombatant,
|
||||
editCombatant,
|
||||
setInitiative,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setTempHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
undo: undoAction,
|
||||
redo: redoAction,
|
||||
advanceTurn: useCallback(() => dispatch({ type: "advance-turn" }), []),
|
||||
retreatTurn: useCallback(() => dispatch({ type: "retreat-turn" }), []),
|
||||
addCombatant: useCallback(
|
||||
(name: string, init?: CombatantInit) =>
|
||||
dispatch({ type: "add-combatant", name, init }),
|
||||
[],
|
||||
),
|
||||
removeCombatant: useCallback(
|
||||
(id: CombatantId) => dispatch({ type: "remove-combatant", id }),
|
||||
[],
|
||||
),
|
||||
editCombatant: useCallback(
|
||||
(id: CombatantId, newName: string) =>
|
||||
dispatch({ type: "edit-combatant", id, newName }),
|
||||
[],
|
||||
),
|
||||
setInitiative: useCallback(
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatch({ type: "set-initiative", id, value }),
|
||||
[],
|
||||
),
|
||||
setHp: useCallback(
|
||||
(id: CombatantId, maxHp: number | undefined) =>
|
||||
dispatch({ type: "set-hp", id, maxHp }),
|
||||
[],
|
||||
),
|
||||
adjustHp: useCallback(
|
||||
(id: CombatantId, delta: number) =>
|
||||
dispatch({ type: "adjust-hp", id, delta }),
|
||||
[],
|
||||
),
|
||||
setTempHp: useCallback(
|
||||
(id: CombatantId, tempHp: number | undefined) =>
|
||||
dispatch({ type: "set-temp-hp", id, tempHp }),
|
||||
[],
|
||||
),
|
||||
setAc: useCallback(
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatch({ type: "set-ac", id, value }),
|
||||
[],
|
||||
),
|
||||
setCr: useCallback(
|
||||
(id: CombatantId, value: string | undefined) =>
|
||||
dispatch({ type: "set-cr", id, value }),
|
||||
[],
|
||||
),
|
||||
toggleCondition: useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId) =>
|
||||
dispatch({ type: "toggle-condition", id, conditionId }),
|
||||
[],
|
||||
),
|
||||
toggleConcentration: useCallback(
|
||||
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
||||
[],
|
||||
),
|
||||
clearEncounter: useCallback(
|
||||
() => dispatch({ type: "clear-encounter" }),
|
||||
[],
|
||||
),
|
||||
addFromBestiary: useCallback(
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
dispatch({ type: "add-from-bestiary", entry });
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
),
|
||||
addMultipleFromBestiary: useCallback(
|
||||
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
||||
dispatch({
|
||||
type: "add-multiple-from-bestiary",
|
||||
entry,
|
||||
count,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
),
|
||||
addFromPlayerCharacter: useCallback(
|
||||
(pc: PlayerCharacter) =>
|
||||
dispatch({ type: "add-from-player-character", pc }),
|
||||
[],
|
||||
),
|
||||
undo: useCallback(() => dispatch({ type: "undo" }), []),
|
||||
redo: useCallback(() => dispatch({ type: "redo" }), []),
|
||||
setEncounter: useCallback(
|
||||
(enc: Encounter) =>
|
||||
dispatch({
|
||||
type: "import",
|
||||
encounter: enc,
|
||||
undoRedoState: undoRedoRef.current,
|
||||
}),
|
||||
[],
|
||||
),
|
||||
setUndoRedoState: useCallback(
|
||||
(urs: UndoRedoState) =>
|
||||
dispatch({
|
||||
type: "import",
|
||||
encounter: encounterRef.current,
|
||||
undoRedoState: urs,
|
||||
}),
|
||||
[],
|
||||
),
|
||||
makeStore,
|
||||
withUndo,
|
||||
lastCreatureId: state.lastCreatureId,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ function rollDice(): number {
|
||||
}
|
||||
|
||||
export function useInitiativeRolls() {
|
||||
const { encounter, makeStore } = useEncounterContext();
|
||||
const { encounter, makeStore, withUndo } = useEncounterContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
const { showCreature } = useSidePanelContext();
|
||||
|
||||
@@ -28,12 +28,8 @@ export function useInitiativeRolls() {
|
||||
(id: CombatantId, mode: RollMode = "normal") => {
|
||||
const diceRolls: [number, ...number[]] =
|
||||
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
|
||||
const result = rollInitiativeUseCase(
|
||||
makeStore(),
|
||||
id,
|
||||
diceRolls,
|
||||
getCreature,
|
||||
mode,
|
||||
const result = withUndo(() =>
|
||||
rollInitiativeUseCase(makeStore(), id, diceRolls, getCreature, mode),
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
setRollSingleSkipped(true);
|
||||
@@ -43,22 +39,19 @@ export function useInitiativeRolls() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[makeStore, getCreature, encounter.combatants, showCreature],
|
||||
[makeStore, getCreature, withUndo, encounter.combatants, showCreature],
|
||||
);
|
||||
|
||||
const handleRollAllInitiative = useCallback(
|
||||
(mode: RollMode = "normal") => {
|
||||
const result = rollAllInitiativeUseCase(
|
||||
makeStore(),
|
||||
rollDice,
|
||||
getCreature,
|
||||
mode,
|
||||
const result = withUndo(() =>
|
||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature, mode),
|
||||
);
|
||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||
setRollSkippedCount(result.skippedNoSource);
|
||||
}
|
||||
},
|
||||
[makeStore, getCreature],
|
||||
[makeStore, getCreature, withUndo],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,14 +7,7 @@ import {
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import { isDomainError, playerCharacterId } from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
loadPlayerCharacters,
|
||||
savePlayerCharacters,
|
||||
} from "../persistence/player-character-storage.js";
|
||||
|
||||
function initializeCharacters(): PlayerCharacter[] {
|
||||
return loadPlayerCharacters();
|
||||
}
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
|
||||
let nextPcId = 0;
|
||||
|
||||
@@ -28,17 +21,20 @@ interface EditFields {
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string | null;
|
||||
readonly icon?: string | null;
|
||||
readonly level?: number | null;
|
||||
}
|
||||
|
||||
export function usePlayerCharacters() {
|
||||
const [characters, setCharacters] =
|
||||
useState<PlayerCharacter[]>(initializeCharacters);
|
||||
const { playerCharacterPersistence } = useAdapters();
|
||||
const [characters, setCharacters] = useState<PlayerCharacter[]>(() =>
|
||||
playerCharacterPersistence.load(),
|
||||
);
|
||||
const charactersRef = useRef(characters);
|
||||
charactersRef.current = characters;
|
||||
|
||||
useEffect(() => {
|
||||
savePlayerCharacters(characters);
|
||||
}, [characters]);
|
||||
playerCharacterPersistence.save(characters);
|
||||
}, [characters, playerCharacterPersistence]);
|
||||
|
||||
const makeStore = useCallback((): PlayerCharacterStore => {
|
||||
return {
|
||||
@@ -57,6 +53,7 @@ export function usePlayerCharacters() {
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
level: number | undefined,
|
||||
) => {
|
||||
const id = generatePcId();
|
||||
const result = createPlayerCharacterUseCase(
|
||||
@@ -67,6 +64,7 @@ export function usePlayerCharacters() {
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
level,
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
@@ -103,6 +101,7 @@ export function usePlayerCharacters() {
|
||||
createCharacter,
|
||||
editCharacter,
|
||||
deleteCharacter,
|
||||
replacePlayerCharacters: setCharacters,
|
||||
makeStore,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.js";
|
||||
import { productionAdapters } from "./adapters/production-adapters.js";
|
||||
import { AdapterProvider } from "./contexts/adapter-context.js";
|
||||
import {
|
||||
BestiaryProvider,
|
||||
BulkImportProvider,
|
||||
@@ -17,6 +19,7 @@ const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<AdapterProvider adapters={productionAdapters}>
|
||||
<ThemeProvider>
|
||||
<RulesEditionProvider>
|
||||
<EncounterProvider>
|
||||
@@ -34,6 +37,7 @@ if (root) {
|
||||
</EncounterProvider>
|
||||
</RulesEditionProvider>
|
||||
</ThemeProvider>
|
||||
</AdapterProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,64 +122,7 @@ describe("loadEncounter", () => {
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
// US3: Corrupt data scenarios
|
||||
it("returns null for non-object JSON (string)", () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify("hello"));
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-object JSON (number)", () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(42));
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-object JSON (array)", () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([1, 2, 3]));
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-object JSON (null)", () => {
|
||||
localStorage.setItem(STORAGE_KEY, "null");
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when combatants is a string instead of array", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: "not-array",
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}),
|
||||
);
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when activeIndex is a string instead of number", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: [{ id: "1", name: "Aria" }],
|
||||
activeIndex: "zero",
|
||||
roundNumber: 1,
|
||||
}),
|
||||
);
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when combatant entry is missing id", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: [{ name: "Aria" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}),
|
||||
);
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when combatant entry is missing name", () => {
|
||||
it("returns null when combatant has invalid required fields", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
@@ -191,86 +134,24 @@ describe("loadEncounter", () => {
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for negative roundNumber", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: [{ id: "1", name: "Aria" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: -1,
|
||||
}),
|
||||
);
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns empty encounter for zero combatants (cleared state)", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
|
||||
);
|
||||
const result = loadEncounter();
|
||||
expect(result).toEqual({
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trip preserves combatant AC value", () => {
|
||||
it("round-trip preserves combatant cr field", () => {
|
||||
const result = createEncounter(
|
||||
[{ id: combatantId("1"), name: "Aria", ac: 18 }],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error("unreachable");
|
||||
saveEncounter(result);
|
||||
const loaded = loadEncounter();
|
||||
expect(loaded?.combatants[0].ac).toBe(18);
|
||||
});
|
||||
|
||||
it("round-trip preserves combatant without AC", () => {
|
||||
const result = createEncounter(
|
||||
[{ id: combatantId("1"), name: "Aria" }],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error("unreachable");
|
||||
saveEncounter(result);
|
||||
const loaded = loadEncounter();
|
||||
expect(loaded?.combatants[0].ac).toBeUndefined();
|
||||
});
|
||||
|
||||
it("discards invalid AC values during rehydration", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: [
|
||||
{ id: "1", name: "Neg", ac: -1 },
|
||||
{ id: "2", name: "Float", ac: 3.5 },
|
||||
{ id: "3", name: "Str", ac: "high" },
|
||||
[
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Custom Thug",
|
||||
cr: "2",
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error("unreachable");
|
||||
saveEncounter(result);
|
||||
const loaded = loadEncounter();
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.combatants[0].ac).toBeUndefined();
|
||||
expect(loaded?.combatants[1].ac).toBeUndefined();
|
||||
expect(loaded?.combatants[2].ac).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves AC of 0 during rehydration", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: [{ id: "1", name: "Aria", ac: 0 }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}),
|
||||
);
|
||||
const loaded = loadEncounter();
|
||||
expect(loaded?.combatants[0].ac).toBe(0);
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.combatants[0].cr).toBe("2");
|
||||
});
|
||||
|
||||
it("saving after modifications persists the latest state", () => {
|
||||
|
||||
@@ -90,102 +90,7 @@ describe("player-character-storage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-character validation", () => {
|
||||
it("discards character with missing name", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{ id: "pc-1", ac: 10, maxHp: 50, color: "blue", icon: "sword" },
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with empty name", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with invalid color", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "neon",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with invalid icon", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "banana",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with negative AC", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: -1,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with maxHp of 0", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 0,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
describe("delegation to domain rehydration", () => {
|
||||
it("keeps valid characters and discards invalid ones", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user