Compare commits
27 Commits
1de00e3d8e
...
0.9.21
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f6eebc43b | |||
| 817cfddabc | |||
| 94e1806112 | |||
| 30e7ed4121 | |||
| 5540baf14c | |||
| 1ae9e12cff | |||
| 2c643cc98b | |||
| 228c1c667f | |||
| 300d4b1f73 | |||
| 43546aaa7b | |||
| 09da9a8dfc | |||
| b229a0dac7 | |||
| 08b5db81ad | |||
| a89fac5c23 | |||
| b6ee4c8c86 | |||
| c295840b7b | |||
| d13641152f | |||
| 110f4726ae | |||
| 2bc22369ce | |||
| 2971d32f45 | |||
| a97044ec3e | |||
| a77db0eeee | |||
| d8c8a0c44d | |||
| 80dd68752e | |||
| 896fd427ed | |||
| 01b1bba6d6 | |||
| b7a97c3d88 |
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: commit
|
||||
description: Create a git commit with pre-commit hooks (bypasses sandbox restrictions).
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(git *), Bash(pnpm *)
|
||||
---
|
||||
|
||||
## Instructions
|
||||
|
||||
Create a git commit for the current staged and/or unstaged changes.
|
||||
|
||||
### Step 1 — Assess changes
|
||||
|
||||
Run these in parallel:
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
```bash
|
||||
git diff
|
||||
```
|
||||
|
||||
```bash
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
### Step 2 — Draft commit message
|
||||
|
||||
- Summarize the nature of the changes (new feature, enhancement, bug fix, refactor, test, docs, etc.)
|
||||
- Keep the first line concise (under 72 chars), use imperative mood
|
||||
- Add a blank line and a short body if the "why" isn't obvious from the first line
|
||||
- Match the style of recent commits in the log
|
||||
- Do not commit files that likely contain secrets (.env, credentials, etc.)
|
||||
|
||||
### Step 3 — Stage and commit
|
||||
|
||||
Stage relevant files by name (avoid `git add -A` or `git add .`). Then commit.
|
||||
|
||||
**CRITICAL:** Always use `dangerouslyDisableSandbox: true` for the commit command. Lefthook pre-commit hooks spawn subprocesses (biome, oxlint, vitest, etc.) that require filesystem access beyond what the sandbox allows. They will always fail with "operation not permitted" in sandbox mode.
|
||||
|
||||
Append the co-author trailer:
|
||||
|
||||
```
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
Use a HEREDOC for the commit message:
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
<first line>
|
||||
|
||||
<optional body>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### Step 4 — Verify
|
||||
|
||||
Run `git status` after the commit to confirm success.
|
||||
|
||||
### If the commit fails
|
||||
|
||||
If a pre-commit hook fails, fix the issue, re-stage, and create a **new** commit. Never amend unless explicitly asked — amending after a hook failure would modify the previous commit.
|
||||
|
||||
## User arguments
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
If the user provided arguments, treat them as the commit message or guidance for what to commit.
|
||||
@@ -12,4 +12,6 @@ Thumbs.db
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
docs/agents/plans/
|
||||
docs/agents/research/
|
||||
.agent-tests/
|
||||
.rodney/
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
───────────────────
|
||||
Version change: 3.0.0 → 3.1.0 (MINOR — new principle II-A: context-based state flow)
|
||||
Version change: 3.1.0 → 3.2.0 (MINOR — artifact lifecycle guidance)
|
||||
Modified sections:
|
||||
- Core Principles: added II-A. Context-Based State Flow (max 8 props, context over prop drilling)
|
||||
- Development Workflow: added artifact lifecycle rules (spec.md living, plan/tasks bounded, tests authoritative)
|
||||
Templates requiring updates: none
|
||||
-->
|
||||
# Encounter Console Constitution
|
||||
@@ -113,6 +113,18 @@ architecture, and quality — not product behavior.
|
||||
(which creates a feature branch for the full speckit pipeline);
|
||||
changes to existing features update the existing spec via
|
||||
`/integrate-issue`.
|
||||
- **Artifact lifecycles differ by type**:
|
||||
- `spec.md` is a **living capability document** — it describes what
|
||||
the feature does and is updated whenever the feature meaningfully
|
||||
changes. It survives across multiple rounds of work.
|
||||
- `plan.md` and `tasks.md` are **bounded work packages** — they
|
||||
describe what to do for a specific increment of work. After
|
||||
completion they become historical records. The next round of work
|
||||
on the same feature gets a new plan, not an update to the old one.
|
||||
- Tests are the **executable ground truth**. When a spec's
|
||||
acceptance criteria and the tests disagree, the tests are
|
||||
authoritative. Spec prose captures intent and context; tests
|
||||
capture actual behavior.
|
||||
- The full pipeline (spec → plan → tasks → implement) applies to new
|
||||
features and significant additions. Bug fixes, tooling changes,
|
||||
and trivial UI adjustments do not require specs.
|
||||
@@ -156,4 +168,4 @@ MUST comply with its principles.
|
||||
**Compliance review**: Every spec and plan MUST include a
|
||||
Constitution Check section validating adherence to all principles.
|
||||
|
||||
**Version**: 3.1.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-19
|
||||
**Version**: 3.2.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-30
|
||||
|
||||
@@ -1,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,21 +60,70 @@ 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.
|
||||
- **Reuse UI primitives** — before creating custom interactive elements (buttons, inputs, selects, dialogs), check `apps/web/src/components/ui/` for existing components with established variants and hover styles.
|
||||
- **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).
|
||||
- **Export format compatibility** — When changing `Encounter`, `Combatant`, `PlayerCharacter`, or `UndoRedoState` types, verify that previously exported JSON files (version 1) still import correctly. If not, bump the `ExportBundle` version and add migration logic in `validateImportBundle()`.
|
||||
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
||||
- **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
|
||||
|
||||
@@ -86,21 +135,7 @@ Before finishing a change, consider:
|
||||
|
||||
## Speckit Workflow
|
||||
|
||||
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
||||
|
||||
### Issue-driven workflow
|
||||
- `/write-issue` — create a well-structured Gitea issue via interactive interview
|
||||
- `/integrate-issue <number>` — fetch an issue, route it to the right spec, and update the spec with the new/changed requirements. Then implement directly.
|
||||
- `/sync-issue <number>` — push acceptance criteria from the spec back to the Gitea issue
|
||||
|
||||
### RPI skills (Research → Plan → Implement)
|
||||
- `rpi-research` — deep codebase research producing a written report in `docs/agents/research/`
|
||||
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
|
||||
- `rpi-implement` — execute a plan file phase by phase with automated + manual verification
|
||||
|
||||
**Research scope**: Research should include a scan for existing patterns similar to what the feature needs (e.g., shared UI primitives, duplicated validation logic, repeated state management patterns). Identify extraction and consolidation opportunities before implementation, not during.
|
||||
|
||||
### Choosing the right workflow by scope
|
||||
Specs are **living documents** in `specs/NNN-feature-name/` that describe features, not individual changes. Use `/speckit.*` and RPI skills (`rpi-research`, `rpi-plan`, `rpi-implement`) to manage them — skill descriptions have full usage details.
|
||||
|
||||
| Scope | Workflow |
|
||||
|---|---|
|
||||
@@ -109,24 +144,8 @@ Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Spec
|
||||
| Larger addition to existing feature | `/integrate-issue` → `rpi-research` → `rpi-plan` → `rpi-implement` |
|
||||
| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` |
|
||||
|
||||
Speckit manages **what** to build (specs as living documents). RPI manages **how** to build it (research, planning, execution). The full speckit pipeline is for new features. For changes to existing features, update the spec via `/integrate-issue`, then use RPI skills if the change is non-trivial.
|
||||
**Research scope**: Always scan for existing patterns similar to what the feature needs. Identify extraction and consolidation opportunities before implementation, not during.
|
||||
|
||||
### Current feature specs
|
||||
- `specs/001-combatant-management/` — CRUD, persistence, clear, batch add, confirm buttons
|
||||
- `specs/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar
|
||||
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
||||
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
|
||||
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
|
||||
- `specs/006-undo-redo/` — undo/redo for encounter state mutations
|
||||
- `specs/007-json-import-export/` — JSON import/export for full encounter state (encounter, undo/redo, player characters)
|
||||
- `specs/008-encounter-difficulty/` — Live encounter difficulty indicator (5.5e XP budget system), optional PC level field
|
||||
## Constitution
|
||||
|
||||
## Constitution (key principles)
|
||||
|
||||
The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
||||
|
||||
1. **Deterministic Domain Core** — Pure state transitions only; no I/O, randomness, or clocks in domain.
|
||||
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
|
||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
||||
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
|
||||
Project principles governing all feature work are in [`.specify/memory/constitution.md`](.specify/memory/constitution.md). Key rules: deterministic domain core, strict layer boundaries, clarification before assumptions.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Encounter Console
|
||||
# Initiative
|
||||
|
||||
A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e / 2024). Runs entirely in the browser — no server, no account, no data leaves your machine.
|
||||
|
||||
@@ -34,16 +34,42 @@ Open `http://localhost:5173`.
|
||||
| `pnpm --filter web dev` | Start the dev server |
|
||||
| `pnpm --filter web build` | Production build |
|
||||
| `pnpm test` | Run all tests (Vitest) |
|
||||
| `pnpm check` | Full merge gate (knip, biome, typecheck, test, jscpd) |
|
||||
| `pnpm test:watch` | Tests in watch mode |
|
||||
| `pnpm vitest run path/to/test.ts` | Run a single test file |
|
||||
| `pnpm typecheck` | TypeScript type checking |
|
||||
| `pnpm lint` | Biome lint |
|
||||
| `pnpm format` | Biome format (writes changes) |
|
||||
| `pnpm check` | Full merge gate (see below) |
|
||||
|
||||
### Merge gate (`pnpm check`)
|
||||
|
||||
All of these run at pre-commit via Lefthook (in parallel where possible):
|
||||
|
||||
- `pnpm audit` — security audit
|
||||
- `knip` — unused code detection
|
||||
- `biome check` — formatting + linting
|
||||
- `oxlint` — type-aware linting (complements Biome)
|
||||
- Custom scripts — lint-ignore caps, className enforcement, component prop limits
|
||||
- `tsc --build` — TypeScript strict mode
|
||||
- `vitest run` — tests with per-path coverage thresholds
|
||||
- `jscpd` + `jsinspect` — copy-paste and structural duplication detection
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- TypeScript 5.8 (strict mode), React 19, Vite 6
|
||||
- Tailwind CSS v4 (dark/light theme)
|
||||
- Biome 2.4 (formatting + linting), oxlint (type-aware linting)
|
||||
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||
- Knip (unused code), jscpd + jsinspect (duplication detection)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/web/ React 19 + Vite — UI components, hooks, adapters
|
||||
packages/domain/ Pure functions — state transitions, types, validation
|
||||
packages/app/ Use cases — orchestrates domain via port interfaces
|
||||
data/bestiary/ Bestiary index for creature search
|
||||
scripts/ Build tooling (layer boundary checks, index generation)
|
||||
packages/application/ Use cases — orchestrates domain via port interfaces
|
||||
data/bestiary/ Pre-built bestiary search index (~10k creatures)
|
||||
scripts/ Build tooling (layer checks, index generation)
|
||||
specs/ Feature specifications (spec → plan → tasks)
|
||||
```
|
||||
|
||||
@@ -55,5 +81,45 @@ Strict layered architecture with enforced dependency direction:
|
||||
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
|
||||
```
|
||||
|
||||
Domain is pure — no I/O, no randomness, no framework imports. Layer boundaries are enforced by automated import checks that run as part of the test suite. See [CLAUDE.md](./CLAUDE.md) for full conventions.
|
||||
- **Domain** — pure functions, no I/O, no randomness, no framework imports. Errors returned as values (`DomainError`), never thrown.
|
||||
- **Application** — orchestrates domain calls via port interfaces (`EncounterStore`, `PlayerCharacterStore`, etc.). No business logic.
|
||||
- **Web** — React adapter. Implements ports using hooks/state. All UI components, persistence, and external data access live here.
|
||||
|
||||
Layer boundaries are enforced by automated import checks that run as part of the test suite.
|
||||
|
||||
## Contributing
|
||||
|
||||
### Workflow
|
||||
|
||||
Development is spec-driven. Feature specs live in `specs/NNN-feature-name/` and are managed through Claude Code skills (see [CLAUDE.md](./CLAUDE.md) for full details).
|
||||
|
||||
| Scope | What to do |
|
||||
|-------|-----------|
|
||||
| Bug fix / CSS tweak | Fix it, run `pnpm check`, commit. Optionally use `/browser-interactive-testing` for visual verification. |
|
||||
| Change to existing feature | Update the feature spec, then implement |
|
||||
| Larger change to existing feature | Update the spec → `/rpi-research` → `/rpi-plan` → `/rpi-implement` |
|
||||
| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` |
|
||||
|
||||
Use `/write-issue` to create well-structured Gitea issues, and `/integrate-issue` to pull an existing issue's requirements into the relevant feature spec.
|
||||
|
||||
### Before committing
|
||||
|
||||
Run `pnpm check` — Lefthook runs this automatically at pre-commit, but running it manually first saves time. All checks must pass.
|
||||
|
||||
### Conventions
|
||||
|
||||
- **Biome** for formatting and linting — tab indentation, 80-char lines
|
||||
- **TypeScript strict mode** with `verbatimModuleSyntax` (type-only imports must use `import type`)
|
||||
- **Max 8 props** per component interface — use React context for shared state
|
||||
- **Tests** in `__tests__/` directories — test pure functions directly, use `renderHook` for hooks
|
||||
|
||||
See [CLAUDE.md](./CLAUDE.md) for the full conventions and project constitution.
|
||||
|
||||
## Bestiary Index
|
||||
|
||||
The bestiary search index (`data/bestiary/index.json`) is pre-built and checked into the repo. To regenerate it (e.g., after a new source book release):
|
||||
|
||||
1. Clone [5etools-mirror-3/5etools-src](https://github.com/5etools-mirror-3/5etools-src) locally
|
||||
2. Run `node scripts/generate-bestiary-index.mjs /path/to/5etools-src`
|
||||
|
||||
The script extracts creature names, stats, and source info into a compact search index.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -209,6 +209,82 @@ describe("round-trip: export then import", () => {
|
||||
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 a combatant with side field", () => {
|
||||
const encounterWithSide: Encounter = {
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Allied Guard",
|
||||
cr: "2",
|
||||
side: "party",
|
||||
},
|
||||
{
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
side: "enemy",
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const emptyUndoRedo: UndoRedoState = {
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
const bundle = assembleExportBundle(encounterWithSide, 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].side).toBe("party");
|
||||
expect(imported.encounter.combatants[1].side).toBe("enemy");
|
||||
});
|
||||
|
||||
it("round-trips a combatant without side field as undefined", () => {
|
||||
const encounterNoSide: Encounter = {
|
||||
combatants: [{ id: combatantId("c-1"), name: "Custom" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const emptyUndoRedo: UndoRedoState = {
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
const bundle = assembleExportBundle(encounterNoSide, 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].side).toBeUndefined();
|
||||
});
|
||||
|
||||
it("round-trips an empty encounter", () => {
|
||||
const emptyEncounter: Encounter = {
|
||||
combatants: [],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
|
||||
export function buildEncounter(overrides?: Partial<Encounter>): Encounter {
|
||||
return {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { buildCombatant } from "./build-combatant.js";
|
||||
export { buildCreature } from "./build-creature.js";
|
||||
export { buildEncounter } from "./build-encounter.js";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import type { TraitBlock } from "@initiative/domain";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeBestiary,
|
||||
setSourceDisplayNames,
|
||||
} from "../bestiary-adapter.js";
|
||||
|
||||
/** Flatten segments to a single string for simple text assertions. */
|
||||
function flatText(trait: TraitBlock): string {
|
||||
return trait.segments
|
||||
.map((s) =>
|
||||
s.type === "text"
|
||||
? s.value
|
||||
: s.items
|
||||
.map((i) => (i.label ? `${i.label}. ${i.text}` : i.text))
|
||||
.join(" "),
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
setSourceDisplayNames({ XMM: "MM 2024" });
|
||||
});
|
||||
@@ -74,11 +88,11 @@ describe("normalizeBestiary", () => {
|
||||
expect(c.senses).toBe("Darkvision 60 ft.");
|
||||
expect(c.languages).toBe("Common, Goblin");
|
||||
expect(c.actions).toHaveLength(1);
|
||||
expect(c.actions?.[0].text).toContain("Melee Attack Roll:");
|
||||
expect(c.actions?.[0].text).not.toContain("{@");
|
||||
expect(flatText(c.actions![0])).toContain("Melee Attack Roll:");
|
||||
expect(flatText(c.actions![0])).not.toContain("{@");
|
||||
expect(c.bonusActions).toHaveLength(1);
|
||||
expect(c.bonusActions?.[0].text).toContain("Disengage");
|
||||
expect(c.bonusActions?.[0].text).not.toContain("{@");
|
||||
expect(flatText(c.bonusActions![0])).toContain("Disengage");
|
||||
expect(flatText(c.bonusActions![0])).not.toContain("{@");
|
||||
});
|
||||
|
||||
it("normalizes a creature with legendary actions", () => {
|
||||
@@ -333,9 +347,9 @@ describe("normalizeBestiary", () => {
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
const bite = creatures[0].actions?.[0];
|
||||
expect(bite?.text).toContain("Melee Weapon Attack:");
|
||||
expect(bite?.text).not.toContain("mw");
|
||||
expect(bite?.text).not.toContain("{@");
|
||||
expect(flatText(bite!)).toContain("Melee Weapon Attack:");
|
||||
expect(flatText(bite!)).not.toContain("mw");
|
||||
expect(flatText(bite!)).not.toContain("{@");
|
||||
});
|
||||
|
||||
it("handles fly speed with hover condition", () => {
|
||||
@@ -368,4 +382,129 @@ describe("normalizeBestiary", () => {
|
||||
const creatures = normalizeBestiary(raw);
|
||||
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
|
||||
});
|
||||
|
||||
it("renders list items with singular entry field (e.g. Confusing Burble d4 effects)", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Jabberwock",
|
||||
source: "WBtW",
|
||||
size: ["H"],
|
||||
type: "dragon",
|
||||
ac: [18],
|
||||
hp: { average: 115, formula: "10d12 + 50" },
|
||||
speed: { walk: 30 },
|
||||
str: 22,
|
||||
dex: 15,
|
||||
con: 20,
|
||||
int: 8,
|
||||
wis: 14,
|
||||
cha: 16,
|
||||
passive: 12,
|
||||
cr: "13",
|
||||
trait: [
|
||||
{
|
||||
name: "Confusing Burble",
|
||||
entries: [
|
||||
"The jabberwock burbles unless {@condition incapacitated}. Roll a {@dice d4}:",
|
||||
{
|
||||
type: "list",
|
||||
style: "list-hang-notitle",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
name: "1-2",
|
||||
entry: "The creature does nothing.",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
name: "3",
|
||||
entry:
|
||||
"The creature uses all its movement to move in a random direction.",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
name: "4",
|
||||
entry:
|
||||
"The creature makes one melee attack against a random creature.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
const trait = creatures[0].traits![0];
|
||||
expect(trait.name).toBe("Confusing Burble");
|
||||
expect(trait.segments).toHaveLength(2);
|
||||
expect(trait.segments[0]).toEqual({
|
||||
type: "text",
|
||||
value: expect.stringContaining("d4"),
|
||||
});
|
||||
expect(trait.segments[1]).toEqual({
|
||||
type: "list",
|
||||
items: [
|
||||
{ label: "1-2", text: "The creature does nothing." },
|
||||
{
|
||||
label: "3",
|
||||
text: expect.stringContaining("random direction"),
|
||||
},
|
||||
{ label: "4", text: expect.stringContaining("melee attack") },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders table entries as structured list segments", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Test Creature",
|
||||
source: "XMM",
|
||||
size: ["M"],
|
||||
type: "humanoid",
|
||||
ac: [12],
|
||||
hp: { average: 40, formula: "9d8" },
|
||||
speed: { walk: 30 },
|
||||
str: 10,
|
||||
dex: 10,
|
||||
con: 10,
|
||||
int: 10,
|
||||
wis: 10,
|
||||
cha: 10,
|
||||
passive: 10,
|
||||
cr: "1",
|
||||
trait: [
|
||||
{
|
||||
name: "Random Effect",
|
||||
entries: [
|
||||
"Roll on the table:",
|
||||
{
|
||||
type: "table",
|
||||
colLabels: ["d4", "Effect"],
|
||||
rows: [
|
||||
["1", "Nothing happens."],
|
||||
["2", "Something happens."],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
const trait = creatures[0].traits![0];
|
||||
expect(trait.segments[1]).toEqual({
|
||||
type: "list",
|
||||
items: [
|
||||
{ label: "1", text: "Nothing happens." },
|
||||
{ label: "2", text: "Something happens." },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,6 +5,8 @@ import type {
|
||||
LegendaryBlock,
|
||||
SpellcastingBlock,
|
||||
TraitBlock,
|
||||
TraitListItem,
|
||||
TraitSegment,
|
||||
} from "@initiative/domain";
|
||||
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
||||
import { stripTags } from "./strip-tags.js";
|
||||
@@ -63,11 +65,18 @@ interface RawEntryObject {
|
||||
type: string;
|
||||
items?: (
|
||||
| string
|
||||
| { type: string; name?: string; entries?: (string | RawEntryObject)[] }
|
||||
| {
|
||||
type: string;
|
||||
name?: string;
|
||||
entry?: string;
|
||||
entries?: (string | RawEntryObject)[];
|
||||
}
|
||||
)[];
|
||||
style?: string;
|
||||
name?: string;
|
||||
entries?: (string | RawEntryObject)[];
|
||||
colLabels?: string[];
|
||||
rows?: (string | RawEntryObject)[][];
|
||||
}
|
||||
|
||||
interface RawSpellcasting {
|
||||
@@ -257,23 +266,34 @@ function formatConditionImmunities(
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function renderListItem(item: string | RawEntryObject): string | undefined {
|
||||
function toListItem(
|
||||
item:
|
||||
| string
|
||||
| {
|
||||
type: string;
|
||||
name?: string;
|
||||
entry?: string;
|
||||
entries?: (string | RawEntryObject)[];
|
||||
},
|
||||
): TraitListItem | undefined {
|
||||
if (typeof item === "string") {
|
||||
return `• ${stripTags(item)}`;
|
||||
return { text: stripTags(item) };
|
||||
}
|
||||
if (item.name && item.entries) {
|
||||
return `• ${stripTags(item.name)}: ${renderEntries(item.entries)}`;
|
||||
return { label: stripTags(item.name), text: renderEntries(item.entries) };
|
||||
}
|
||||
if (item.name && item.entry) {
|
||||
return { label: stripTags(item.name), text: stripTags(item.entry) };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function renderEntryObject(entry: RawEntryObject, parts: string[]): void {
|
||||
if (entry.type === "list") {
|
||||
for (const item of entry.items ?? []) {
|
||||
const rendered = renderListItem(item);
|
||||
if (rendered) parts.push(rendered);
|
||||
if (entry.type === "list" || entry.type === "table") {
|
||||
// Handled structurally in segmentizeEntries
|
||||
return;
|
||||
}
|
||||
} else if (entry.type === "item" && entry.name && entry.entries) {
|
||||
if (entry.type === "item" && entry.name && entry.entries) {
|
||||
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
|
||||
} else if (entry.entries) {
|
||||
parts.push(renderEntries(entry.entries));
|
||||
@@ -292,11 +312,67 @@ function renderEntries(entries: (string | RawEntryObject)[]): string {
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function tableRowToListItem(row: (string | RawEntryObject)[]): TraitListItem {
|
||||
return {
|
||||
label: typeof row[0] === "string" ? stripTags(row[0]) : undefined,
|
||||
text: row
|
||||
.slice(1)
|
||||
.map((cell) =>
|
||||
typeof cell === "string" ? stripTags(cell) : renderEntries([cell]),
|
||||
)
|
||||
.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
function entryToListSegment(entry: RawEntryObject): TraitSegment | undefined {
|
||||
if (entry.type === "list") {
|
||||
const items = (entry.items ?? [])
|
||||
.map(toListItem)
|
||||
.filter((i): i is TraitListItem => i !== undefined);
|
||||
return items.length > 0 ? { type: "list", items } : undefined;
|
||||
}
|
||||
if (entry.type === "table" && entry.rows) {
|
||||
const items = entry.rows.map(tableRowToListItem);
|
||||
return items.length > 0 ? { type: "list", items } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function segmentizeEntries(
|
||||
entries: (string | RawEntryObject)[],
|
||||
): TraitSegment[] {
|
||||
const segments: TraitSegment[] = [];
|
||||
const textParts: string[] = [];
|
||||
|
||||
const flushText = () => {
|
||||
if (textParts.length > 0) {
|
||||
segments.push({ type: "text", value: textParts.join(" ") });
|
||||
textParts.length = 0;
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
if (typeof entry === "string") {
|
||||
textParts.push(stripTags(entry));
|
||||
continue;
|
||||
}
|
||||
const listSeg = entryToListSegment(entry);
|
||||
if (listSeg) {
|
||||
flushText();
|
||||
segments.push(listSeg);
|
||||
} else {
|
||||
renderEntryObject(entry, textParts);
|
||||
}
|
||||
}
|
||||
flushText();
|
||||
return segments;
|
||||
}
|
||||
|
||||
function normalizeTraits(raw?: RawEntry[]): TraitBlock[] | undefined {
|
||||
if (!raw || raw.length === 0) return undefined;
|
||||
return raw.map((t) => ({
|
||||
name: stripTags(t.name),
|
||||
text: renderEntries(t.entries),
|
||||
segments: segmentizeEntries(t.entries),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -361,7 +437,7 @@ function normalizeLegendary(
|
||||
preamble,
|
||||
entries: raw.map((e) => ({
|
||||
name: stripTags(e.name),
|
||||
text: renderEntries(e.entries),
|
||||
segments: segmentizeEntries(e.entries),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { type IDBPDatabase, openDB } from "idb";
|
||||
|
||||
const DB_NAME = "initiative-bestiary";
|
||||
const STORE_NAME = "sources";
|
||||
const DB_VERSION = 2;
|
||||
const DB_VERSION = 3;
|
||||
|
||||
export interface CachedSourceInfo {
|
||||
interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
readonly displayName: string;
|
||||
readonly creatureCount: number;
|
||||
@@ -38,9 +38,12 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
||||
keyPath: "sourceCode",
|
||||
});
|
||||
}
|
||||
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
||||
// Clear cached creatures to pick up improved tag processing
|
||||
transaction.objectStore(STORE_NAME).clear();
|
||||
if (
|
||||
oldVersion < DB_VERSION &&
|
||||
database.objectStoreNames.contains(STORE_NAME)
|
||||
) {
|
||||
// Clear cached creatures so they get re-normalized with latest rendering
|
||||
void transaction.objectStore(STORE_NAME).clear();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { playerCharacterId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { CreatePlayerModal } from "../create-player-modal.js";
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderModal(
|
||||
overrides: Partial<Parameters<typeof CreatePlayerModal>[0]> = {},
|
||||
) {
|
||||
const defaults = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
};
|
||||
const props = { ...defaults, ...overrides };
|
||||
return { ...render(<CreatePlayerModal {...props} />), ...props };
|
||||
}
|
||||
|
||||
describe("CreatePlayerModal", () => {
|
||||
it("renders create form with defaults", () => {
|
||||
renderModal();
|
||||
expect(screen.getByText("Create Player")).toBeDefined();
|
||||
expect(screen.getByLabelText("Name")).toBeDefined();
|
||||
expect(screen.getByLabelText("AC")).toBeDefined();
|
||||
expect(screen.getByLabelText("Max HP")).toBeDefined();
|
||||
expect(screen.getByLabelText("Level")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders edit form when playerCharacter is provided", () => {
|
||||
const pc: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Gandalf",
|
||||
ac: 15,
|
||||
maxHp: 40,
|
||||
color: "blue",
|
||||
icon: "wand",
|
||||
level: 10,
|
||||
};
|
||||
renderModal({ playerCharacter: pc });
|
||||
expect(screen.getByText("Edit Player")).toBeDefined();
|
||||
expect(screen.getByLabelText("Name")).toHaveProperty("value", "Gandalf");
|
||||
expect(screen.getByLabelText("AC")).toHaveProperty("value", "15");
|
||||
expect(screen.getByLabelText("Max HP")).toHaveProperty("value", "40");
|
||||
expect(screen.getByLabelText("Level")).toHaveProperty("value", "10");
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onSave with valid data", async () => {
|
||||
const { onSave, onClose } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Aria");
|
||||
await user.clear(screen.getByLabelText("AC"));
|
||||
await user.type(screen.getByLabelText("AC"), "16");
|
||||
await user.clear(screen.getByLabelText("Max HP"));
|
||||
await user.type(screen.getByLabelText("Max HP"), "30");
|
||||
await user.type(screen.getByLabelText("Level"), "5");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
"Aria",
|
||||
16,
|
||||
30,
|
||||
undefined,
|
||||
undefined,
|
||||
5,
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for empty name", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(screen.getByText("Name is required")).toBeDefined();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for invalid AC", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Test");
|
||||
await user.clear(screen.getByLabelText("AC"));
|
||||
await user.type(screen.getByLabelText("AC"), "abc");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(screen.getByText("AC must be a non-negative number")).toBeDefined();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for invalid Max HP", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Test");
|
||||
await user.clear(screen.getByLabelText("Max HP"));
|
||||
await user.type(screen.getByLabelText("Max HP"), "0");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(screen.getByText("Max HP must be at least 1")).toBeDefined();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for invalid level", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Test");
|
||||
await user.type(screen.getByLabelText("Level"), "25");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(screen.getByText("Level must be between 1 and 20")).toBeDefined();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears error when name is edited", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
expect(screen.getByText("Name is required")).toBeDefined();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "A");
|
||||
expect(screen.queryByText("Name is required")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onClose when cancel is clicked", async () => {
|
||||
const { onClose } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("omits level when field is empty", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Aria");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
"Aria",
|
||||
10,
|
||||
10,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { Dialog, DialogHeader } from "../ui/dialog.js";
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Dialog", () => {
|
||||
it("opens when open=true", () => {
|
||||
render(
|
||||
<Dialog open={true} onClose={() => {}}>
|
||||
Content
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("Content")).toBeDefined();
|
||||
});
|
||||
|
||||
it("closes when open changes from true to false", () => {
|
||||
const { rerender } = render(
|
||||
<Dialog open={true} onClose={() => {}}>
|
||||
Content
|
||||
</Dialog>,
|
||||
);
|
||||
const dialog = document.querySelector("dialog");
|
||||
expect(dialog?.hasAttribute("open")).toBe(true);
|
||||
|
||||
rerender(
|
||||
<Dialog open={false} onClose={() => {}}>
|
||||
Content
|
||||
</Dialog>,
|
||||
);
|
||||
expect(dialog?.hasAttribute("open")).toBe(false);
|
||||
});
|
||||
|
||||
it("calls onClose on cancel event", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Dialog open={true} onClose={onClose}>
|
||||
Content
|
||||
</Dialog>,
|
||||
);
|
||||
const dialog = document.querySelector("dialog");
|
||||
dialog?.dispatchEvent(new Event("cancel"));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DialogHeader", () => {
|
||||
it("renders title and close button", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<DialogHeader title="Test Title" onClose={onClose} />);
|
||||
|
||||
expect(screen.getByText("Test Title")).toBeDefined();
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
// @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,
|
||||
renderHook,
|
||||
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 { useRulesEdition } from "../../hooks/use-rules-edition.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("shows PC in party column with level", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Hero")).toBeInTheDocument();
|
||||
expect(screen.getByText("Lv 5")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows monsters in enemy column", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("CR 1/4").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders explanation text", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Allied NPC XP is subtracted from encounter difficulty",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders Net Monster XP footer", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Net Monster XP")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders custom combatant with CR picker in enemy column", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||
expect(pickers).toHaveLength(2);
|
||||
expect(pickers[0]).toHaveValue("2");
|
||||
});
|
||||
});
|
||||
|
||||
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]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByLabelText("Challenge rating")).toHaveLength(2);
|
||||
});
|
||||
|
||||
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||
await user.selectOptions(pickers[1], "5");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("1,800")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("non-PC combatants show toggle button", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Each non-PC enemy combatant has a toggle button
|
||||
expect(
|
||||
screen.getByLabelText("Move Goblin to party side"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("Move Custom Thug to party side"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("PC combatants do not show side toggle", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Hero")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText("Move Hero to enemy side"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("side toggle moves combatant between sections", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Toggle goblin to party side
|
||||
const toggleBtn = screen.getByLabelText("Move Goblin to party side");
|
||||
await user.click(toggleBtn);
|
||||
|
||||
// After toggle, the aria-label should change to "Move Goblin to enemy side"
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByLabelText("Move Goblin to enemy side"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing when breakdown data is insufficient", () => {
|
||||
const { container } = renderPanel({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({ id: combatantId("c-1"), name: "Custom" }),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("shows 4 threshold columns for 2014 edition", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("5e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Easy:", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText("Med:", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText("Hard:", { exact: false })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Deadly:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows multiplier and adjusted XP for 2014 edition", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("5e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster XP")).toBeInTheDocument();
|
||||
// 1 PC (<3) triggers party size adjustment
|
||||
expect(screen.getByText("Adjusted for 1 PC")).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Net Monster XP for 5.5e edition (no multiplier)", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Net Monster XP")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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,128 @@
|
||||
// @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,
|
||||
TIER_LABELS_5_5E,
|
||||
TIER_LABELS_2014,
|
||||
} from "../difficulty-indicator.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
||||
return {
|
||||
tier,
|
||||
totalMonsterXp: 100,
|
||||
thresholds: [
|
||||
{ label: "Low", value: 50 },
|
||||
{ label: "Moderate", value: 100 },
|
||||
{ label: "High", value: 200 },
|
||||
],
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DifficultyIndicator", () => {
|
||||
it("renders 3 bars", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||
expect(bars).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("shows 'Trivial encounter difficulty' for 5.5e tier 0", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(0)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Trivial encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Low encounter difficulty' for 5.5e tier 1", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(1)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Low encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Moderate encounter difficulty' for 5.5e tier 2", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Moderate encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'High encounter difficulty' for 5.5e tier 3", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "High encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Easy encounter difficulty' for 2014 tier 0", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(0)} labels={TIER_LABELS_2014} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Easy encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Deadly encounter difficulty' for 2014 tier 3", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_2014} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Deadly encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onClick when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult(2)}
|
||||
labels={TIER_LABELS_5_5E}
|
||||
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(2)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
const element = container.querySelector("[role='img']");
|
||||
expect(element?.tagName).toBe("DIV");
|
||||
});
|
||||
|
||||
it("renders as button when onClick provided", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult(2)}
|
||||
labels={TIER_LABELS_5_5E}
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { type PlayerCharacter, playerCharacterId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||
const LEVEL_REGEX = /^Lv /;
|
||||
|
||||
import { PlayerManagement } from "../player-management.js";
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
});
|
||||
|
||||
const PC_WARRIOR: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Thorin",
|
||||
ac: 18,
|
||||
maxHp: 45,
|
||||
color: "red",
|
||||
icon: "sword",
|
||||
};
|
||||
|
||||
const PC_WIZARD: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-2"),
|
||||
name: "Gandalf",
|
||||
ac: 12,
|
||||
maxHp: 30,
|
||||
color: "blue",
|
||||
icon: "wand",
|
||||
level: 10,
|
||||
};
|
||||
|
||||
function renderManagement(
|
||||
overrides: Partial<Parameters<typeof PlayerManagement>[0]> = {},
|
||||
) {
|
||||
const props = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
characters: [] as readonly PlayerCharacter[],
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onCreate: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return { ...render(<PlayerManagement {...props} />), props };
|
||||
}
|
||||
|
||||
describe("PlayerManagement", () => {
|
||||
it("shows empty state when no characters", () => {
|
||||
renderManagement();
|
||||
expect(screen.getByText("No player characters yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows create button in empty state that calls onCreate", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderManagement();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: CREATE_FIRST_PC_REGEX,
|
||||
}),
|
||||
);
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders each character with name, AC, HP", () => {
|
||||
renderManagement({ characters: [PC_WARRIOR, PC_WIZARD] });
|
||||
expect(screen.getByText("Thorin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Gandalf")).toBeInTheDocument();
|
||||
expect(screen.getByText("AC 18")).toBeInTheDocument();
|
||||
expect(screen.getByText("HP 45")).toBeInTheDocument();
|
||||
expect(screen.getByText("AC 12")).toBeInTheDocument();
|
||||
expect(screen.getByText("HP 30")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows level when present, omits when undefined", () => {
|
||||
renderManagement({ characters: [PC_WARRIOR, PC_WIZARD] });
|
||||
expect(screen.getByText("Lv 10")).toBeInTheDocument();
|
||||
// Thorin has no level — there should be only one "Lv" text
|
||||
expect(screen.queryAllByText(LEVEL_REGEX)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("edit button calls onEdit with the character", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
expect(props.onEdit).toHaveBeenCalledWith(PC_WARRIOR);
|
||||
});
|
||||
|
||||
it("delete button calls onDelete after confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||
|
||||
const deleteBtn = screen.getByRole("button", {
|
||||
name: "Delete player character",
|
||||
});
|
||||
await user.click(deleteBtn);
|
||||
const confirmBtn = screen.getByRole("button", {
|
||||
name: "Confirm delete player character",
|
||||
});
|
||||
await user.click(confirmBtn);
|
||||
expect(props.onDelete).toHaveBeenCalledWith(PC_WARRIOR.id);
|
||||
});
|
||||
|
||||
it("add button calls onCreate", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RollModeMenu } from "../roll-mode-menu.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("RollModeMenu", () => {
|
||||
it("renders advantage and disadvantage buttons", () => {
|
||||
render(
|
||||
<RollModeMenu
|
||||
position={{ x: 100, y: 100 }}
|
||||
onSelect={() => {}}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Advantage")).toBeDefined();
|
||||
expect(screen.getByText("Disadvantage")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onSelect with 'advantage' and onClose when clicked", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<RollModeMenu
|
||||
position={{ x: 100, y: 100 }}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Advantage"));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("advantage");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSelect with 'disadvantage' and onClose when clicked", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<RollModeMenu
|
||||
position={{ x: 100, y: 100 }}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Disadvantage"));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("disadvantage");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// @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 section with 'Rules Edition' label", () => {
|
||||
renderModal();
|
||||
expect(screen.getByText("Rules Edition")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "5e (2014)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "5.5e (2024)" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders theme toggle buttons", () => {
|
||||
renderModal();
|
||||
expect(screen.getByRole("button", { name: "System" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Light" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking an edition button switches the active edition", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
const btn5e = screen.getByRole("button", { name: "5e (2014)" });
|
||||
await user.click(btn5e);
|
||||
// After clicking 5e, it should have the active style
|
||||
expect(btn5e.className).toContain("bg-accent");
|
||||
});
|
||||
|
||||
it("clicking a theme button switches the active theme", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
const darkBtn = screen.getByRole("button", { name: "Dark" });
|
||||
await user.click(darkBtn);
|
||||
expect(darkBtn.className).toContain("bg-accent");
|
||||
});
|
||||
|
||||
it("close button calls onClose", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = renderModal();
|
||||
// DialogHeader renders an X button
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const closeBtn = buttons.find((b) => b.querySelector(".h-4.w-4") !== null);
|
||||
expect(closeBtn).toBeDefined();
|
||||
await user.click(closeBtn as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
// @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",
|
||||
segments: [{ type: "text", value: "Disengage or Hide as bonus." }],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
name: "Scimitar",
|
||||
segments: [{ type: "text", value: "Melee: +4 to hit, 5 slashing." }],
|
||||
},
|
||||
],
|
||||
bonusActions: [
|
||||
{
|
||||
name: "Nimble",
|
||||
segments: [{ type: "text", value: "Disengage or Hide." }],
|
||||
},
|
||||
],
|
||||
reactions: [
|
||||
{
|
||||
name: "Redirect",
|
||||
segments: [{ type: "text", value: "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",
|
||||
segments: [
|
||||
{ type: "text" as const, value: "Wisdom (Perception) check." },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Tail Attack",
|
||||
segments: [{ type: "text" as const, value: "Tail attack." }],
|
||||
},
|
||||
],
|
||||
},
|
||||
spellcasting: [
|
||||
{
|
||||
name: "Innate Spellcasting",
|
||||
headerText: "The dragon's spellcasting ability is Charisma.",
|
||||
atWill: ["detect magic", "suggestion"],
|
||||
daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
|
||||
restLong: [{ uses: 1, each: false, spells: ["wish"] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function renderStatBlock(creature: Creature) {
|
||||
return render(<StatBlock creature={creature} />);
|
||||
}
|
||||
|
||||
describe("StatBlock", () => {
|
||||
describe("header", () => {
|
||||
it("renders creature name", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Goblin" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders size, type, alignment", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByText("Small humanoid, neutral evil"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders source display name", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stats bar", () => {
|
||||
it("renders AC with source", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText(ARMOR_CLASS_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText("15")).toBeInTheDocument();
|
||||
expect(screen.getByText("(leather armor, shield)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders AC without source when acSource is undefined", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText("22")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders HP average and formula", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("7")).toBeInTheDocument();
|
||||
expect(screen.getByText("(2d6)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders speed", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("30 ft.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ability scores", () => {
|
||||
it("renders all 6 ability labels", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
for (const label of ["STR", "DEX", "CON", "INT", "WIS", "CHA"]) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders ability scores with modifier notation", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("(+2)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("properties", () => {
|
||||
it("renders saving throws when present", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("Saving Throws")).toBeInTheDocument();
|
||||
expect(screen.getByText(DEX_PLUS_4_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders skills when present", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("Skills")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders damage resistances, immunities, vulnerabilities", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText("Damage Resistances")).toBeInTheDocument();
|
||||
expect(screen.getByText("Damage Immunities")).toBeInTheDocument();
|
||||
expect(screen.getByText("Damage Vulnerabilities")).toBeInTheDocument();
|
||||
expect(screen.getByText("Condition Immunities")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits properties when undefined", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.queryByText("Damage Resistances")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Damage Immunities")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders CR and proficiency bonus", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("Challenge")).toBeInTheDocument();
|
||||
expect(screen.getByText(CR_QUARTER_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(PROF_BONUS_2_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("traits", () => {
|
||||
it("renders trait entries", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText(NIMBLE_ESCAPE_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("actions / bonus actions / reactions", () => {
|
||||
it("renders actions heading and entries", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Actions" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(SCIMITAR_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders bonus actions heading and entries", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Bonus Actions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders reactions heading and entries", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Reactions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("legendary actions", () => {
|
||||
it("renders legendary actions with preamble", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Legendary Actions" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("The dragon can take 3 legendary actions."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(DETECT_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(TAIL_ATTACK_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits legendary actions when undefined", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Legendary Actions" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spellcasting", () => {
|
||||
it("renders spellcasting block with header", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText(INNATE_SPELLCASTING_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders at-will spells", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText(AT_WILL_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(DETECT_MAGIC_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders daily spells", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText(DAILY_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(FIREBALL_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders long rest spells", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText(LONG_REST_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(WISH_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits spellcasting when undefined", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.queryByText(AT_WILL_REGEX)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Toast } from "../toast.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Toast", () => {
|
||||
it("renders message text", () => {
|
||||
render(<Toast message="Hello" onDismiss={() => {}} />);
|
||||
expect(screen.getByText("Hello")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders progress bar when progress is provided", () => {
|
||||
render(<Toast message="Loading" progress={0.5} onDismiss={() => {}} />);
|
||||
const bar = document.body.querySelector("[style*='width']") as HTMLElement;
|
||||
expect(bar).not.toBeNull();
|
||||
expect(bar.style.width).toBe("50%");
|
||||
});
|
||||
|
||||
it("does not render progress bar when progress is omitted", () => {
|
||||
render(<Toast message="Done" onDismiss={() => {}} />);
|
||||
const bar = document.body.querySelector("[style*='width']");
|
||||
expect(bar).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onDismiss when close button is clicked", async () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<Toast message="Hi" onDismiss={onDismiss} />);
|
||||
|
||||
const toast = screen.getByText("Hi").closest("div");
|
||||
const button = toast?.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
await userEvent.click(button as HTMLElement);
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe("auto-dismiss", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("auto-dismisses after specified timeout", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<Toast message="Auto" onDismiss={onDismiss} autoDismissMs={3000} />,
|
||||
);
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not auto-dismiss when autoDismissMs is omitted", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<Toast message="Stay" onDismiss={onDismiss} />);
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Tooltip } from "../ui/tooltip.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Tooltip", () => {
|
||||
it("renders children", () => {
|
||||
render(
|
||||
<Tooltip content="Hint">
|
||||
<button type="button">Hover me</button>
|
||||
</Tooltip>,
|
||||
);
|
||||
expect(screen.getByText("Hover me")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show tooltip initially", () => {
|
||||
render(
|
||||
<Tooltip content="Hint">
|
||||
<span>Target</span>
|
||||
</Tooltip>,
|
||||
);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows tooltip on pointer enter and hides on pointer leave", () => {
|
||||
render(
|
||||
<Tooltip content="Hint text">
|
||||
<span>Target</span>
|
||||
</Tooltip>,
|
||||
);
|
||||
|
||||
const wrapper = screen.getByText("Target").closest("span");
|
||||
fireEvent.pointerEnter(wrapper as HTMLElement);
|
||||
expect(screen.getByRole("tooltip")).toBeDefined();
|
||||
expect(screen.getByText("Hint text")).toBeDefined();
|
||||
|
||||
fireEvent.pointerLeave(wrapper as HTMLElement);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,99 +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 modules
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
||||
usePlayerCharactersContext: vi.fn().mockReturnValue({ characters: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn().mockReturnValue({ getCreature: () => undefined }),
|
||||
}));
|
||||
|
||||
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(),
|
||||
addMultipleFromBestiary: vi.fn(),
|
||||
addFromPlayerCharacter: vi.fn(),
|
||||
makeStore: vi.fn(),
|
||||
withUndo: vi.fn((action: () => unknown) => action()),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
undoRedoState: { undoStack: [], redoStack: [] },
|
||||
setEncounter: vi.fn(),
|
||||
setUndoRedoState: vi.fn(),
|
||||
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();
|
||||
@@ -103,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,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(
|
||||
@@ -168,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();
|
||||
@@ -181,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();
|
||||
|
||||
@@ -12,27 +12,20 @@ import {
|
||||
Upload,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import React, { type RefObject, useCallback, useRef, useState } from "react";
|
||||
import React, { type RefObject, useCallback, useState } from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import {
|
||||
creatureKey,
|
||||
type QueuedCreature,
|
||||
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 {
|
||||
assembleExportBundle,
|
||||
bundleToJson,
|
||||
readImportFile,
|
||||
triggerDownload,
|
||||
validateImportBundle,
|
||||
} from "../persistence/export-import.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
import { ExportMethodDialog } from "./export-method-dialog.js";
|
||||
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
||||
@@ -439,116 +432,23 @@ export function ActionBar({
|
||||
} = useActionBarState();
|
||||
|
||||
const { state: bulkImportState } = useBulkImportContext();
|
||||
|
||||
const {
|
||||
encounter,
|
||||
undoRedoState,
|
||||
isEmpty: encounterIsEmpty,
|
||||
setEncounter,
|
||||
setUndoRedoState,
|
||||
} = useEncounterContext();
|
||||
const { characters: playerCharacters, replacePlayerCharacters } =
|
||||
usePlayerCharactersContext();
|
||||
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
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<
|
||||
import("@initiative/domain").ExportBundle | null
|
||||
>(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: import("@initiative/domain").ExportBundle) => {
|
||||
setEncounter(bundle.encounter);
|
||||
setUndoRedoState({
|
||||
undoStack: bundle.undoStack,
|
||||
redoStack: bundle.redoStack,
|
||||
});
|
||||
replacePlayerCharacters([...bundle.playerCharacters]);
|
||||
},
|
||||
[setEncounter, setUndoRedoState, replacePlayerCharacters],
|
||||
);
|
||||
|
||||
const handleValidatedBundle = useCallback(
|
||||
(result: import("@initiative/domain").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);
|
||||
}, []);
|
||||
importError,
|
||||
showExportMethod,
|
||||
showImportMethod,
|
||||
showImportConfirm,
|
||||
importFileRef,
|
||||
setImportError,
|
||||
setShowExportMethod,
|
||||
setShowImportMethod,
|
||||
handleExportDownload,
|
||||
handleExportClipboard,
|
||||
handleImportFile,
|
||||
handleImportClipboard,
|
||||
handleImportConfirm,
|
||||
handleImportCancel,
|
||||
} = useEncounterExportImport();
|
||||
|
||||
const overflowItems = buildOverflowItems({
|
||||
onManagePlayers,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { DifficultyTier, RulesEdition } from "@initiative/domain";
|
||||
import { ArrowLeftRight } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-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";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
const TIER_LABEL_MAP: Record<
|
||||
RulesEdition,
|
||||
Record<DifficultyTier, { label: string; color: string }>
|
||||
> = {
|
||||
"5.5e": {
|
||||
0: { label: "Trivial", color: "text-muted-foreground" },
|
||||
1: { label: "Low", color: "text-green-500" },
|
||||
2: { label: "Moderate", color: "text-yellow-500" },
|
||||
3: { label: "High", color: "text-red-500" },
|
||||
},
|
||||
"5e": {
|
||||
0: { label: "Easy", color: "text-muted-foreground" },
|
||||
1: { label: "Medium", color: "text-green-500" },
|
||||
2: { label: "Hard", color: "text-yellow-500" },
|
||||
3: { label: "Deadly", color: "text-red-500" },
|
||||
},
|
||||
};
|
||||
|
||||
/** Short labels for threshold display where horizontal space is limited. */
|
||||
const SHORT_LABELS: Readonly<Record<string, string>> = {
|
||||
Moderate: "Mod",
|
||||
Medium: "Med",
|
||||
};
|
||||
|
||||
function shortLabel(label: string): string {
|
||||
return SHORT_LABELS[label] ?? label;
|
||||
}
|
||||
|
||||
function formatXp(xp: number): string {
|
||||
return xp.toLocaleString();
|
||||
}
|
||||
|
||||
function PcRow({ entry }: { entry: BreakdownCombatant }) {
|
||||
return (
|
||||
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
|
||||
<span className="min-w-0 truncate" title={entry.combatant.name}>
|
||||
{entry.combatant.name}
|
||||
</span>
|
||||
<span />
|
||||
<span className="text-muted-foreground">
|
||||
{entry.level === undefined ? "\u2014" : `Lv ${entry.level}`}
|
||||
</span>
|
||||
<span className="text-right tabular-nums">{"\u2014"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NpcRow({
|
||||
entry,
|
||||
onToggleSide,
|
||||
}: {
|
||||
entry: BreakdownCombatant;
|
||||
onToggleSide: () => void;
|
||||
}) {
|
||||
const { setCr } = useEncounterContext();
|
||||
const isParty = entry.side === "party";
|
||||
const targetSide = isParty ? "enemy" : "party";
|
||||
|
||||
let xpDisplay: string;
|
||||
if (entry.xp == null) {
|
||||
xpDisplay = "\u2014";
|
||||
} else if (isParty && entry.cr) {
|
||||
xpDisplay = `\u2212${formatXp(entry.xp)}`;
|
||||
} else {
|
||||
xpDisplay = formatXp(entry.xp);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
|
||||
<span className="min-w-0 truncate" title={entry.combatant.name}>
|
||||
{entry.combatant.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggleSide}
|
||||
aria-label={`Move ${entry.combatant.name} to ${targetSide} side`}
|
||||
>
|
||||
<ArrowLeftRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<span>
|
||||
{entry.editable ? (
|
||||
<CrPicker
|
||||
value={entry.cr}
|
||||
onChange={(cr) => setCr(entry.combatant.id, cr)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{entry.cr ? `CR ${entry.cr}` : "\u2014"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-right tabular-nums">{xpDisplay}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, onClose);
|
||||
const { setSide } = useEncounterContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
|
||||
const breakdown = useDifficultyBreakdown();
|
||||
if (!breakdown) return null;
|
||||
|
||||
const tierConfig = TIER_LABEL_MAP[edition][breakdown.tier];
|
||||
|
||||
const handleToggle = (entry: BreakdownCombatant) => {
|
||||
const newSide = entry.side === "party" ? "enemy" : "party";
|
||||
setSide(entry.combatant.id, newSide);
|
||||
};
|
||||
|
||||
const isPC = (entry: BreakdownCombatant) =>
|
||||
entry.combatant.playerCharacterId != null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full right-0 z-50 mt-1 w-80 rounded-lg border border-border bg-card p-3 shadow-lg max-sm:fixed max-sm:top-12 max-sm:right-3 max-sm:left-3 max-sm:w-auto"
|
||||
>
|
||||
<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">
|
||||
{breakdown.thresholds.map((t) => (
|
||||
<span key={t.label}>
|
||||
{shortLabel(t.label)}: <strong>{formatXp(t.value)}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border border-t pt-2 pb-2 text-muted-foreground text-xs italic">
|
||||
Allied NPC XP is subtracted from encounter difficulty
|
||||
</div>
|
||||
|
||||
<div className="border-border border-t pt-2">
|
||||
<div className="mb-1 flex justify-between text-muted-foreground text-xs">
|
||||
<span>Party</span>
|
||||
<span>XP</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_3.5rem] gap-x-2 gap-y-1">
|
||||
{breakdown.partyCombatants.map((entry) =>
|
||||
isPC(entry) ? (
|
||||
<PcRow key={entry.combatant.id} entry={entry} />
|
||||
) : (
|
||||
<NpcRow
|
||||
key={entry.combatant.id}
|
||||
entry={entry}
|
||||
onToggleSide={() => handleToggle(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 border-border border-t pt-2">
|
||||
<div className="mb-1 flex justify-between text-muted-foreground text-xs">
|
||||
<span>Enemy</span>
|
||||
<span>XP</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_3.5rem] gap-x-2 gap-y-1">
|
||||
{breakdown.enemyCombatants.map((entry) =>
|
||||
isPC(entry) ? (
|
||||
<PcRow key={entry.combatant.id} entry={entry} />
|
||||
) : (
|
||||
<NpcRow
|
||||
key={entry.combatant.id}
|
||||
entry={entry}
|
||||
onToggleSide={() => handleToggle(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{breakdown.encounterMultiplier !== undefined &&
|
||||
breakdown.adjustedXp !== undefined ? (
|
||||
<div className="mt-2 border-border border-t pt-2">
|
||||
<div className="flex justify-between font-medium text-xs">
|
||||
<span>Monster XP</span>
|
||||
<span className="tabular-nums">
|
||||
{formatXp(breakdown.totalMonsterXp)}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
×{breakdown.encounterMultiplier}
|
||||
</span>{" "}
|
||||
= {formatXp(breakdown.adjustedXp)}
|
||||
</span>
|
||||
</div>
|
||||
{breakdown.partySizeAdjusted === true ? (
|
||||
<div className="mt-0.5 text-muted-foreground text-xs italic">
|
||||
Adjusted for {breakdown.pcCount}{" "}
|
||||
{breakdown.pcCount === 1 ? "PC" : "PCs"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
||||
<span>Net Monster XP</span>
|
||||
<span className="tabular-nums">
|
||||
{formatXp(breakdown.totalMonsterXp)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,58 @@
|
||||
import type { DifficultyResult, DifficultyTier } from "@initiative/domain";
|
||||
import { cn } from "../lib/utils.js";
|
||||
|
||||
const TIER_CONFIG: Record<
|
||||
export const TIER_LABELS_5_5E: Record<DifficultyTier, string> = {
|
||||
0: "Trivial",
|
||||
1: "Low",
|
||||
2: "Moderate",
|
||||
3: "High",
|
||||
};
|
||||
|
||||
export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||
0: "Easy",
|
||||
1: "Medium",
|
||||
2: "Hard",
|
||||
3: "Deadly",
|
||||
};
|
||||
|
||||
const TIER_COLORS: Record<
|
||||
DifficultyTier,
|
||||
{ filledBars: number; color: string; label: string }
|
||||
{ filledBars: number; color: 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" },
|
||||
0: { filledBars: 0, color: "" },
|
||||
1: { filledBars: 1, color: "bg-green-500" },
|
||||
2: { filledBars: 2, color: "bg-yellow-500" },
|
||||
3: { filledBars: 3, color: "bg-red-500" },
|
||||
};
|
||||
|
||||
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
||||
|
||||
export function DifficultyIndicator({ result }: { result: DifficultyResult }) {
|
||||
const config = TIER_CONFIG[result.tier];
|
||||
const tooltip = `${config.label} encounter difficulty`;
|
||||
export function DifficultyIndicator({
|
||||
result,
|
||||
labels,
|
||||
onClick,
|
||||
}: {
|
||||
result: DifficultyResult;
|
||||
labels: Record<DifficultyTier, string>;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const config = TIER_COLORS[result.tier];
|
||||
const label = labels[result.tier];
|
||||
const tooltip = `${label} encounter difficulty`;
|
||||
|
||||
const Element = onClick ? "button" : "div";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-end gap-0.5"
|
||||
<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
|
||||
@@ -34,6 +64,6 @@ export function DifficultyIndicator({ result }: { result: DifficultyResult }) {
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
Conditions
|
||||
Rules Edition
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{EDITION_OPTIONS.map((opt) => (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Creature, TraitBlock, TraitSegment } from "@initiative/domain";
|
||||
import {
|
||||
type Creature,
|
||||
calculateInitiative,
|
||||
formatInitiativeModifier,
|
||||
} from "@initiative/domain";
|
||||
@@ -34,11 +34,56 @@ function SectionDivider() {
|
||||
);
|
||||
}
|
||||
|
||||
function segmentKey(seg: TraitSegment): string {
|
||||
return seg.type === "text"
|
||||
? seg.value.slice(0, 40)
|
||||
: seg.items.map((i) => i.label ?? i.text.slice(0, 20)).join(",");
|
||||
}
|
||||
|
||||
function TraitSegments({
|
||||
segments,
|
||||
}: Readonly<{ segments: readonly TraitSegment[] }>) {
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === "text") {
|
||||
return (
|
||||
<span key={segmentKey(seg)}>
|
||||
{i === 0 ? ` ${seg.value}` : seg.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
|
||||
{seg.items.map((item) => (
|
||||
<p key={item.label ?? item.text}>
|
||||
{item.label != null && (
|
||||
<span className="font-semibold">{item.label}. </span>
|
||||
)}
|
||||
{item.text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold italic">{trait.name}.</span>
|
||||
<TraitSegments segments={trait.segments} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TraitSection({
|
||||
entries,
|
||||
heading,
|
||||
}: Readonly<{
|
||||
entries: readonly { name: string; text: string }[] | undefined;
|
||||
entries: readonly TraitBlock[] | undefined;
|
||||
heading?: string;
|
||||
}>) {
|
||||
if (!entries || entries.length === 0) return null;
|
||||
@@ -50,9 +95,7 @@ function TraitSection({
|
||||
) : 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>
|
||||
<TraitEntry key={e.name} trait={e} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
@@ -219,9 +262,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{creature.legendaryActions.entries.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
<TraitEntry key={a.name} trait={a} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useDifficulty } from "../hooks/use-difficulty.js";
|
||||
import { DifficultyIndicator } from "./difficulty-indicator.js";
|
||||
import { DifficultyBreakdownPanel } from "./difficulty-breakdown-panel.js";
|
||||
import {
|
||||
DifficultyIndicator,
|
||||
TIER_LABELS_5_5E,
|
||||
TIER_LABELS_2014,
|
||||
} from "./difficulty-indicator.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
|
||||
@@ -18,12 +25,17 @@ export function TurnNavigation() {
|
||||
} = useEncounterContext();
|
||||
|
||||
const difficulty = useDifficulty();
|
||||
const { edition } = useRulesEditionContext();
|
||||
const tierLabels = edition === "5e" ? TIER_LABELS_2014 : TIER_LABELS_5_5E;
|
||||
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"
|
||||
@@ -34,8 +46,6 @@ export function TurnNavigation() {
|
||||
>
|
||||
<StepBack className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -56,23 +66,36 @@ 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>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No combatants</span>
|
||||
)}
|
||||
{difficulty && <DifficultyIndicator result={difficulty} />}
|
||||
</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}
|
||||
labels={tierLabels}
|
||||
onClick={() => setShowBreakdown((prev) => !prev)}
|
||||
/>
|
||||
{showBreakdown ? (
|
||||
<DifficultyBreakdownPanel
|
||||
onClose={() => setShowBreakdown(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmButton
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
label="Clear encounter"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
// @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";
|
||||
import { useRulesEdition } from "../use-rules-edition.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 split by side", 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);
|
||||
|
||||
// PC in party column
|
||||
expect(breakdown?.partyCombatants).toHaveLength(1);
|
||||
expect(breakdown?.partyCombatants[0].combatant.name).toBe("Hero");
|
||||
expect(breakdown?.partyCombatants[0].side).toBe("party");
|
||||
expect(breakdown?.partyCombatants[0].level).toBe(5);
|
||||
|
||||
// Enemies: goblin, thug, bandit
|
||||
expect(breakdown?.enemyCombatants).toHaveLength(3);
|
||||
|
||||
const goblin = breakdown?.enemyCombatants[0];
|
||||
expect(goblin?.cr).toBe("1/4");
|
||||
expect(goblin?.xp).toBe(50);
|
||||
expect(goblin?.source).toBe("SRD");
|
||||
expect(goblin?.editable).toBe(false);
|
||||
expect(goblin?.side).toBe("enemy");
|
||||
|
||||
const thug = breakdown?.enemyCombatants[1];
|
||||
expect(thug?.cr).toBe("2");
|
||||
expect(thug?.xp).toBe(450);
|
||||
expect(thug?.source).toBeNull();
|
||||
expect(thug?.editable).toBe(true);
|
||||
|
||||
const bandit = breakdown?.enemyCombatants[2];
|
||||
expect(bandit?.cr).toBeNull();
|
||||
expect(bandit?.xp).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,
|
||||
});
|
||||
|
||||
const breakdown = result.current;
|
||||
expect(breakdown).not.toBeNull();
|
||||
const ghost = breakdown?.enemyCombatants[0];
|
||||
expect(ghost?.cr).toBeNull();
|
||||
expect(ghost?.xp).toBeNull();
|
||||
expect(ghost?.editable).toBe(false);
|
||||
});
|
||||
|
||||
it("PC combatants appear in partyCombatants with level", 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?.partyCombatants).toHaveLength(1);
|
||||
expect(result.current?.partyCombatants[0].combatant.name).toBe("Hero");
|
||||
expect(result.current?.partyCombatants[0].level).toBe(1);
|
||||
expect(result.current?.partyCombatants[0].side).toBe("party");
|
||||
});
|
||||
});
|
||||
|
||||
it("combatant with explicit side override is placed correctly", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Allied Guard",
|
||||
creatureId: goblinCreature.id,
|
||||
side: "party",
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-3"),
|
||||
name: "Thug",
|
||||
cr: "1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const breakdown = result.current;
|
||||
expect(breakdown).not.toBeNull();
|
||||
// Allied Guard should be in party column
|
||||
expect(breakdown?.partyCombatants).toHaveLength(2);
|
||||
expect(breakdown?.partyCombatants[1].combatant.name).toBe("Allied Guard");
|
||||
expect(breakdown?.partyCombatants[1].side).toBe("party");
|
||||
// Thug in enemy column
|
||||
expect(breakdown?.enemyCombatants).toHaveLength(1);
|
||||
expect(breakdown?.enemyCombatants[0].combatant.name).toBe("Thug");
|
||||
});
|
||||
|
||||
it("exposes encounterMultiplier and adjustedXp for 5e edition", 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: "Thug",
|
||||
cr: "1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||
wrapper,
|
||||
});
|
||||
editionResult.current.setEdition("5e");
|
||||
|
||||
try {
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const breakdown = result.current;
|
||||
expect(breakdown).not.toBeNull();
|
||||
// 2 enemy monsters, 1 PC (<3) → base x1.5, shift up → x2
|
||||
expect(breakdown?.encounterMultiplier).toBe(2);
|
||||
// CR 1/4 (50) + CR 1 (200) = 250, x2 = 500
|
||||
expect(breakdown?.totalMonsterXp).toBe(250);
|
||||
expect(breakdown?.adjustedXp).toBe(500);
|
||||
expect(breakdown?.thresholds).toHaveLength(4);
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
// @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,427 @@
|
||||
// @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";
|
||||
import { useRulesEdition } from "../use-rules-edition.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 pcId2 = playerCharacterId("pc-2");
|
||||
const crId1 = creatureId("srd:goblin");
|
||||
|
||||
const goblinCreature = buildCreature({
|
||||
id: crId1,
|
||||
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", () => {
|
||||
it("returns difficulty result for leveled PCs and bestiary monsters", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c2"),
|
||||
name: "Goblin",
|
||||
creatureId: crId1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
creatures: new Map([[crId1, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
expect(result.current?.tier).toBe(1);
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("returns null when data is insufficient (ED-2)", () => {
|
||||
it("returns null when encounter has no combatants", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({ combatants: [] }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when only custom combatants (no creatureId)", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c1"),
|
||||
name: "Custom",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when bestiary monsters present but no PC combatants", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c1"),
|
||||
name: "Goblin",
|
||||
creatureId: crId1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
creatures: new Map([[crId1, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when PC combatants have no level", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c2"),
|
||||
name: "Goblin",
|
||||
creatureId: crId1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||
creatures: new Map([[crId1, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when PC combatant references unknown player character", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId2,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c2"),
|
||||
name: "Goblin",
|
||||
creatureId: crId1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[crId1, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles mixed combatants: only leveled PCs and CR-bearing monsters contribute", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c1"),
|
||||
name: "Leveled",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c2"),
|
||||
name: "No Level",
|
||||
playerCharacterId: pcId2,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c3"),
|
||||
name: "Goblin",
|
||||
creatureId: crId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c4"),
|
||||
name: "Custom Monster",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
|
||||
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
|
||||
],
|
||||
creatures: new Map([[crId1, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// 1 level-1 PC: budget low=50, mod=75, high=100
|
||||
// CR 1/4 = 50 XP -> low (50 >= 50)
|
||||
expect(result.current?.tier).toBe(1);
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
expect(result.current?.thresholds[0].value).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
it("includes duplicate PC combatants in budget", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c1"),
|
||||
name: "Hero 1",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c2"),
|
||||
name: "Hero 2",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c3"),
|
||||
name: "Goblin",
|
||||
creatureId: crId1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
creatures: new Map([[crId1, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// 2x level 1: budget low=100
|
||||
expect(result.current?.thresholds[0].value).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
it("combatant toggled to party side subtracts XP", async () => {
|
||||
const bugbear = buildCreature({
|
||||
id: creatureId("srd:bugbear"),
|
||||
name: "Bugbear",
|
||||
cr: "1",
|
||||
});
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c2"),
|
||||
name: "Allied Guard",
|
||||
creatureId: bugbear.id,
|
||||
side: "party",
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c3"),
|
||||
name: "Thug",
|
||||
cr: "1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
creatures: new Map([[bugbear.id, bugbear]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// Thug CR 1 = 200 XP, Allied Guard CR 1 = 200 XP subtracted, net = 0
|
||||
expect(result.current?.totalMonsterXp).toBe(0);
|
||||
expect(result.current?.tier).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("default side resolution: PC -> party, non-PC -> enemy", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c2"),
|
||||
name: "Goblin",
|
||||
creatureId: crId1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 3 },
|
||||
],
|
||||
creatures: new Map([[crId1, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// Level 3 budget: low=150, mod=225, high=400
|
||||
// CR 1/4 = 50 XP -> trivial
|
||||
expect(result.current?.thresholds[0].value).toBe(150);
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
expect(result.current?.tier).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 2014 difficulty when edition is 5e", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c2"),
|
||||
name: "Goblin",
|
||||
creatureId: crId1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
creatures: new Map([[crId1, goblinCreature]]),
|
||||
});
|
||||
|
||||
// Set edition via the hook's external store
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||
wrapper,
|
||||
});
|
||||
editionResult.current.setEdition("5e");
|
||||
|
||||
try {
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// 2014: 4 thresholds with Easy/Medium/Hard/Deadly labels
|
||||
expect(result.current?.thresholds).toHaveLength(4);
|
||||
expect(result.current?.thresholds[0].label).toBe("Easy");
|
||||
// CR 1/4 = 50 XP, 1 PC (<3) shifts x1 → x1.5, adjusted = 75
|
||||
expect(result.current?.encounterMultiplier).toBe(1.5);
|
||||
expect(result.current?.adjustedXp).toBe(75);
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("custom combatant with CR on party side subtracts XP", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c2"),
|
||||
name: "Ally",
|
||||
cr: "2",
|
||||
side: "party",
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c3"),
|
||||
name: "Goblin",
|
||||
creatureId: crId1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[crId1, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// CR 1/4 = 50 XP enemy, CR 2 = 450 XP ally subtracted, net = 0 (floored)
|
||||
expect(result.current?.totalMonsterXp).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+56
-29
@@ -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(),
|
||||
}));
|
||||
|
||||
const { loadEncounter: mockLoad, saveEncounter: mockSave } =
|
||||
await vi.importMock<typeof import("../../persistence/encounter-storage.js")>(
|
||||
"../../persistence/encounter-storage.js",
|
||||
);
|
||||
|
||||
describe("useEncounter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLoad.mockReturnValue(null);
|
||||
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("useEncounter", () => {
|
||||
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"),
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { type CreatureId, combatantId } from "@initiative/domain";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useInitiativeRolls } from "../use-initiative-rolls.js";
|
||||
|
||||
const mockMakeStore = vi.fn(() => ({}));
|
||||
const mockWithUndo = vi.fn((fn: () => unknown) => fn());
|
||||
const mockGetCreature = vi.fn();
|
||||
const mockShowCreature = vi.fn();
|
||||
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: () => ({
|
||||
encounter: {
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Goblin",
|
||||
creatureId: "srd:goblin" as CreatureId,
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
makeStore: mockMakeStore,
|
||||
withUndo: mockWithUndo,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: () => ({
|
||||
getCreature: mockGetCreature,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/side-panel-context.js", () => ({
|
||||
useSidePanelContext: () => ({
|
||||
showCreature: mockShowCreature,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockRollInitiativeUseCase = vi.fn();
|
||||
const mockRollAllInitiativeUseCase = vi.fn();
|
||||
|
||||
vi.mock("@initiative/application", () => ({
|
||||
rollInitiativeUseCase: (...args: unknown[]) =>
|
||||
mockRollInitiativeUseCase(...args),
|
||||
rollAllInitiativeUseCase: (...args: unknown[]) =>
|
||||
mockRollAllInitiativeUseCase(...args),
|
||||
}));
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
describe("useInitiativeRolls", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("handleRollInitiative calls rollInitiativeUseCase via withUndo", () => {
|
||||
mockRollInitiativeUseCase.mockReturnValue({ initiative: 15 });
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||
|
||||
expect(mockWithUndo).toHaveBeenCalled();
|
||||
expect(mockRollInitiativeUseCase).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets rollSingleSkipped on domain error", () => {
|
||||
mockRollInitiativeUseCase.mockReturnValue({
|
||||
kind: "domain-error",
|
||||
code: "missing-source",
|
||||
message: "no source",
|
||||
});
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||
expect(result.current.rollSingleSkipped).toBe(true);
|
||||
expect(mockShowCreature).toHaveBeenCalledWith("srd:goblin");
|
||||
});
|
||||
|
||||
it("dismissRollSingleSkipped resets the flag", () => {
|
||||
mockRollInitiativeUseCase.mockReturnValue({
|
||||
kind: "domain-error",
|
||||
code: "missing-source",
|
||||
message: "no source",
|
||||
});
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||
expect(result.current.rollSingleSkipped).toBe(true);
|
||||
|
||||
act(() => result.current.dismissRollSingleSkipped());
|
||||
expect(result.current.rollSingleSkipped).toBe(false);
|
||||
});
|
||||
|
||||
it("handleRollAllInitiative sets rollSkippedCount when sources missing", () => {
|
||||
mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 3 });
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollAllInitiative());
|
||||
expect(result.current.rollSkippedCount).toBe(3);
|
||||
});
|
||||
|
||||
it("dismissRollSkipped resets the count", () => {
|
||||
mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 2 });
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollAllInitiative());
|
||||
act(() => result.current.dismissRollSkipped());
|
||||
expect(result.current.rollSkippedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useLongPress } from "../use-long-press.js";
|
||||
|
||||
function touchEvent(overrides?: Partial<React.TouchEvent>): React.TouchEvent {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as React.TouchEvent;
|
||||
}
|
||||
|
||||
describe("useLongPress", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns onTouchStart, onTouchEnd, onTouchMove handlers", () => {
|
||||
const { result } = renderHook(() => useLongPress(vi.fn()));
|
||||
expect(result.current.onTouchStart).toBeInstanceOf(Function);
|
||||
expect(result.current.onTouchEnd).toBeInstanceOf(Function);
|
||||
expect(result.current.onTouchMove).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it("fires onLongPress after 500ms hold", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
const e = touchEvent();
|
||||
act(() => result.current.onTouchStart(e));
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(onLongPress).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not fire if released before 500ms", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
act(() => result.current.onTouchStart(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
act(() => result.current.onTouchEnd(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels on touch move", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
act(() => result.current.onTouchStart(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
act(() => result.current.onTouchMove());
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onTouchEnd calls preventDefault after long press fires", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
act(() => result.current.onTouchStart(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
const preventDefaultSpy = vi.fn();
|
||||
const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
|
||||
act(() => result.current.onTouchEnd(endEvent));
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onTouchEnd does not preventDefault when long press did not fire", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
act(() => result.current.onTouchStart(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
const preventDefaultSpy = vi.fn();
|
||||
const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
|
||||
act(() => result.current.onTouchEnd(endEvent));
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+33
-24
@@ -1,25 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
import { 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 { 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([]);
|
||||
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 = [
|
||||
{
|
||||
@@ -31,15 +39,19 @@ describe("usePlayerCharacters", () => {
|
||||
icon: undefined,
|
||||
},
|
||||
];
|
||||
mockLoad.mockReturnValue(stored);
|
||||
const adapters = createTestAdapters({ playerCharacters: stored });
|
||||
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
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());
|
||||
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter(
|
||||
@@ -56,11 +68,10 @@ describe("usePlayerCharacters", () => {
|
||||
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());
|
||||
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||
|
||||
let error: unknown;
|
||||
act(() => {
|
||||
@@ -79,7 +90,7 @@ describe("usePlayerCharacters", () => {
|
||||
});
|
||||
|
||||
it("editCharacter updates character and persists", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter(
|
||||
@@ -99,11 +110,10 @@ describe("usePlayerCharacters", () => {
|
||||
});
|
||||
|
||||
expect(result.current.characters[0].name).toBe("Vex'ahlia");
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deleteCharacter removes character and persists", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter(
|
||||
@@ -123,6 +133,5 @@ describe("usePlayerCharacters", () => {
|
||||
});
|
||||
|
||||
expect(result.current.characters).toHaveLength(0);
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useRulesEdition } from "../use-rules-edition.js";
|
||||
|
||||
const STORAGE_KEY = "initiative:rules-edition";
|
||||
|
||||
describe("useRulesEdition", () => {
|
||||
afterEach(() => {
|
||||
// Reset to default
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
act(() => result.current.setEdition("5.5e"));
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("defaults to 5.5e", () => {
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
expect(result.current.edition).toBe("5.5e");
|
||||
});
|
||||
|
||||
it("setEdition updates value", () => {
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
|
||||
act(() => result.current.setEdition("5e"));
|
||||
|
||||
expect(result.current.edition).toBe("5e");
|
||||
});
|
||||
|
||||
it("setEdition persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
|
||||
act(() => result.current.setEdition("5e"));
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("5e");
|
||||
});
|
||||
|
||||
it("multiple hooks stay in sync", () => {
|
||||
const { result: r1 } = renderHook(() => useRulesEdition());
|
||||
const { result: r2 } = renderHook(() => useRulesEdition());
|
||||
|
||||
act(() => r1.current.setEdition("5e"));
|
||||
|
||||
expect(r2.current.edition).toBe("5e");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSwipeToDismiss } from "../use-swipe-to-dismiss.js";
|
||||
|
||||
const PANEL_WIDTH = 300;
|
||||
|
||||
function makeTouchEvent(clientX: number, clientY = 0): React.TouchEvent {
|
||||
return {
|
||||
touches: [{ clientX, clientY }],
|
||||
currentTarget: {
|
||||
getBoundingClientRect: () => ({ width: PANEL_WIDTH }),
|
||||
},
|
||||
} as unknown as React.TouchEvent;
|
||||
}
|
||||
|
||||
describe("useSwipeToDismiss", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("starts with offsetX 0 and isSwiping false", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
expect(result.current.isSwiping).toBe(false);
|
||||
});
|
||||
|
||||
it("horizontal drag updates offsetX and sets isSwiping", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
|
||||
|
||||
expect(result.current.offsetX).toBe(50);
|
||||
expect(result.current.isSwiping).toBe(true);
|
||||
});
|
||||
|
||||
it("vertical drag is ignored after direction lock", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0, 0)));
|
||||
// Move vertically > 10px to lock vertical
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(0, 20)));
|
||||
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
});
|
||||
|
||||
it("small movement does not lock direction", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(5)));
|
||||
|
||||
// No direction locked yet, no update
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
expect(result.current.isSwiping).toBe(false);
|
||||
});
|
||||
|
||||
it("leftward drag is clamped to 0", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(100)));
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
|
||||
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
});
|
||||
|
||||
it("calls onDismiss when ratio exceeds threshold", () => {
|
||||
const onDismiss = vi.fn();
|
||||
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
// Move > 35% of panel width (300 * 0.35 = 105)
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(120)));
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(5000); // slow swipe
|
||||
act(() => result.current.handlers.onTouchEnd());
|
||||
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onDismiss with fast velocity", () => {
|
||||
const onDismiss = vi.fn();
|
||||
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
// Small distance but fast
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(30)));
|
||||
|
||||
// Very fast: 30px in 0.1s = 300px/s, velocity = 300/300 = 1.0 > 0.5
|
||||
vi.spyOn(Date, "now").mockReturnValue(100);
|
||||
act(() => result.current.handlers.onTouchEnd());
|
||||
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not dismiss when below thresholds", () => {
|
||||
const onDismiss = vi.fn();
|
||||
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
// Small distance, slow speed
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(20)));
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(5000);
|
||||
act(() => result.current.handlers.onTouchEnd());
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
expect(result.current.isSwiping).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useTheme } from "../use-theme.js";
|
||||
|
||||
const STORAGE_KEY = "initiative:theme";
|
||||
|
||||
describe("useTheme", () => {
|
||||
afterEach(() => {
|
||||
// Reset to default
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setPreference("system"));
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("defaults to system preference", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.preference).toBe("system");
|
||||
});
|
||||
|
||||
it("setPreference updates to light", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => result.current.setPreference("light"));
|
||||
|
||||
expect(result.current.preference).toBe("light");
|
||||
expect(result.current.resolved).toBe("light");
|
||||
});
|
||||
|
||||
it("setPreference updates to dark", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => result.current.setPreference("dark"));
|
||||
|
||||
expect(result.current.preference).toBe("dark");
|
||||
expect(result.current.resolved).toBe("dark");
|
||||
});
|
||||
|
||||
it("persists preference to localStorage", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => result.current.setPreference("light"));
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("light");
|
||||
});
|
||||
|
||||
it("applies theme to document element", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => result.current.setPreference("light"));
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("multiple hooks stay in sync", () => {
|
||||
const { result: r1 } = renderHook(() => useTheme());
|
||||
const { result: r2 } = renderHook(() => useTheme());
|
||||
|
||||
act(() => r1.current.setPreference("dark"));
|
||||
|
||||
expect(r2.current.preference).toBe("dark");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import { useCallback, useDeferredValue, useMemo, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
@@ -31,6 +38,7 @@ export function useActionBarState() {
|
||||
addFromBestiary,
|
||||
addMultipleFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
lastCreatureId,
|
||||
} = useEncounterContext();
|
||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||
useBestiaryContext();
|
||||
@@ -38,6 +46,20 @@ export function useActionBarState() {
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
useSidePanelContext();
|
||||
|
||||
// Auto-show stat block when a bestiary creature is added on desktop
|
||||
const prevCreatureIdRef = useRef(lastCreatureId);
|
||||
useEffect(() => {
|
||||
if (
|
||||
lastCreatureId &&
|
||||
lastCreatureId !== prevCreatureIdRef.current &&
|
||||
panelView.mode === "closed" &&
|
||||
globalThis.matchMedia("(min-width: 1024px)").matches
|
||||
) {
|
||||
showCreature(lastCreatureId);
|
||||
}
|
||||
prevCreatureIdRef.current = lastCreatureId;
|
||||
}, [lastCreatureId, panelView.mode, showCreature]);
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
@@ -73,13 +95,9 @@ export function useActionBarState() {
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
addFromBestiary(result);
|
||||
},
|
||||
[addFromBestiary, panelView.mode, showCreature],
|
||||
[addFromBestiary],
|
||||
);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
@@ -99,21 +117,10 @@ export function useActionBarState() {
|
||||
if (queued.count === 1) {
|
||||
handleAddFromBestiary(queued.result);
|
||||
} else {
|
||||
const creatureId = addMultipleFromBestiary(queued.result, queued.count);
|
||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
addMultipleFromBestiary(queued.result, queued.count);
|
||||
}
|
||||
clearInput();
|
||||
}, [
|
||||
queued,
|
||||
handleAddFromBestiary,
|
||||
addMultipleFromBestiary,
|
||||
panelView.mode,
|
||||
showCreature,
|
||||
clearInput,
|
||||
]);
|
||||
}, [queued, handleAddFromBestiary, addMultipleFromBestiary, clearInput]);
|
||||
|
||||
const parseNum = (v: string): number | undefined => {
|
||||
if (v.trim() === "") return undefined;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import type {
|
||||
Combatant,
|
||||
CreatureId,
|
||||
DifficultyThreshold,
|
||||
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";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { resolveSide } from "./use-difficulty.js";
|
||||
|
||||
export interface BreakdownCombatant {
|
||||
readonly combatant: Combatant;
|
||||
readonly cr: string | null;
|
||||
readonly xp: number | null;
|
||||
readonly source: string | null;
|
||||
readonly editable: boolean;
|
||||
readonly side: "party" | "enemy";
|
||||
readonly level: number | undefined;
|
||||
}
|
||||
|
||||
interface DifficultyBreakdown {
|
||||
readonly tier: DifficultyTier;
|
||||
readonly totalMonsterXp: number;
|
||||
readonly thresholds: readonly DifficultyThreshold[];
|
||||
readonly encounterMultiplier: number | undefined;
|
||||
readonly adjustedXp: number | undefined;
|
||||
readonly partySizeAdjusted: boolean | undefined;
|
||||
readonly pcCount: number;
|
||||
readonly partyCombatants: readonly BreakdownCombatant[];
|
||||
readonly enemyCombatants: readonly BreakdownCombatant[];
|
||||
}
|
||||
|
||||
export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
const { encounter } = useEncounterContext();
|
||||
const { characters } = usePlayerCharactersContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
|
||||
return useMemo(() => {
|
||||
const { partyCombatants, enemyCombatants, descriptors, pcCount } =
|
||||
classifyCombatants(encounter.combatants, characters, getCreature);
|
||||
|
||||
const hasPartyLevel = descriptors.some(
|
||||
(d) => d.side === "party" && d.level !== undefined,
|
||||
);
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
|
||||
const result = calculateEncounterDifficulty(descriptors, edition);
|
||||
|
||||
return {
|
||||
...result,
|
||||
pcCount,
|
||||
partyCombatants,
|
||||
enemyCombatants,
|
||||
};
|
||||
}, [encounter.combatants, characters, getCreature, edition]);
|
||||
}
|
||||
|
||||
type CreatureInfo = {
|
||||
cr: string;
|
||||
source: string;
|
||||
sourceDisplayName: string;
|
||||
};
|
||||
|
||||
function buildBreakdownEntry(
|
||||
c: Combatant,
|
||||
side: "party" | "enemy",
|
||||
level: number | undefined,
|
||||
creature: CreatureInfo | undefined,
|
||||
): BreakdownCombatant {
|
||||
if (c.playerCharacterId) {
|
||||
return {
|
||||
combatant: c,
|
||||
cr: null,
|
||||
xp: null,
|
||||
source: null,
|
||||
editable: false,
|
||||
side,
|
||||
level,
|
||||
};
|
||||
}
|
||||
if (creature) {
|
||||
return {
|
||||
combatant: c,
|
||||
cr: creature.cr,
|
||||
xp: crToXp(creature.cr),
|
||||
source: creature.sourceDisplayName ?? creature.source,
|
||||
editable: false,
|
||||
side,
|
||||
level: undefined,
|
||||
};
|
||||
}
|
||||
if (c.cr) {
|
||||
return {
|
||||
combatant: c,
|
||||
cr: c.cr,
|
||||
xp: crToXp(c.cr),
|
||||
source: null,
|
||||
editable: true,
|
||||
side,
|
||||
level: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
combatant: c,
|
||||
cr: null,
|
||||
xp: null,
|
||||
source: null,
|
||||
editable: !c.creatureId,
|
||||
side,
|
||||
level: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLevel(
|
||||
c: Combatant,
|
||||
characters: readonly PlayerCharacter[],
|
||||
): number | undefined {
|
||||
if (!c.playerCharacterId) return undefined;
|
||||
return characters.find((p) => p.id === c.playerCharacterId)?.level;
|
||||
}
|
||||
|
||||
function resolveCr(
|
||||
c: Combatant,
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
): { cr: string | null; creature: CreatureInfo | undefined } {
|
||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
const cr = creature ? creature.cr : (c.cr ?? null);
|
||||
return { cr, creature };
|
||||
}
|
||||
|
||||
function classifyCombatants(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
) {
|
||||
const partyCombatants: BreakdownCombatant[] = [];
|
||||
const enemyCombatants: BreakdownCombatant[] = [];
|
||||
const descriptors: {
|
||||
level?: number;
|
||||
cr?: string;
|
||||
side: "party" | "enemy";
|
||||
}[] = [];
|
||||
let pcCount = 0;
|
||||
|
||||
for (const c of combatants) {
|
||||
const side = resolveSide(c);
|
||||
const level = resolveLevel(c, characters);
|
||||
if (level !== undefined) pcCount++;
|
||||
|
||||
const { cr, creature } = resolveCr(c, getCreature);
|
||||
|
||||
if (level !== undefined || cr != null) {
|
||||
descriptors.push({ level, cr: cr ?? undefined, side });
|
||||
}
|
||||
|
||||
const entry = buildBreakdownEntry(c, side, level, creature);
|
||||
const target = side === "party" ? partyCombatants : enemyCombatants;
|
||||
target.push(entry);
|
||||
}
|
||||
|
||||
return { partyCombatants, enemyCombatants, descriptors, pcCount };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Combatant,
|
||||
CombatantDescriptor,
|
||||
CreatureId,
|
||||
DifficultyResult,
|
||||
PlayerCharacter,
|
||||
@@ -9,46 +10,55 @@ 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";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
|
||||
function derivePartyLevels(
|
||||
export function resolveSide(c: Combatant): "party" | "enemy" {
|
||||
if (c.side) return c.side;
|
||||
return c.playerCharacterId ? "party" : "enemy";
|
||||
}
|
||||
|
||||
function buildDescriptors(
|
||||
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[] = [];
|
||||
): CombatantDescriptor[] {
|
||||
const descriptors: CombatantDescriptor[] = [];
|
||||
for (const c of combatants) {
|
||||
if (!c.creatureId) continue;
|
||||
const creature = getCreature(c.creatureId);
|
||||
if (creature) crs.push(creature.cr);
|
||||
const side = resolveSide(c);
|
||||
const level = c.playerCharacterId
|
||||
? characters.find((p) => p.id === c.playerCharacterId)?.level
|
||||
: undefined;
|
||||
const cr = c.creatureId
|
||||
? getCreature(c.creatureId)?.cr
|
||||
: (c.cr ?? undefined);
|
||||
|
||||
if (level !== undefined || cr !== undefined) {
|
||||
descriptors.push({ level, cr, side });
|
||||
}
|
||||
return crs;
|
||||
}
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
export function useDifficulty(): DifficultyResult | null {
|
||||
const { encounter } = useEncounterContext();
|
||||
const { characters } = usePlayerCharactersContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
|
||||
return useMemo(() => {
|
||||
const partyLevels = derivePartyLevels(encounter.combatants, characters);
|
||||
const monsterCrs = deriveMonsterCrs(encounter.combatants, getCreature);
|
||||
const descriptors = buildDescriptors(
|
||||
encounter.combatants,
|
||||
characters,
|
||||
getCreature,
|
||||
);
|
||||
|
||||
if (partyLevels.length === 0 || monsterCrs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const hasPartyLevel = descriptors.some(
|
||||
(d) => d.side === "party" && d.level !== undefined,
|
||||
);
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
|
||||
return calculateEncounterDifficulty(partyLevels, monsterCrs);
|
||||
}, [encounter.combatants, characters, getCreature]);
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
|
||||
return calculateEncounterDifficulty(descriptors, edition);
|
||||
}, [encounter.combatants, characters, getCreature, edition]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+465
-284
@@ -9,8 +9,10 @@ import {
|
||||
removeCombatantUseCase,
|
||||
retreatTurnUseCase,
|
||||
setAcUseCase,
|
||||
setCrUseCase,
|
||||
setHpUseCase,
|
||||
setInitiativeUseCase,
|
||||
setSideUseCase,
|
||||
setTempHpUseCase,
|
||||
toggleConcentrationUseCase,
|
||||
toggleConditionUseCase,
|
||||
@@ -36,15 +38,55 @@ 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: "set-side"; id: CombatantId; value: "party" | "enemy" }
|
||||
| {
|
||||
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+)$/;
|
||||
|
||||
@@ -54,12 +96,6 @@ const EMPTY_ENCOUNTER: Encounter = {
|
||||
roundNumber: 1,
|
||||
};
|
||||
|
||||
function initializeEncounter(): Encounter {
|
||||
const stored = loadEncounter();
|
||||
if (stored !== null) return stored;
|
||||
return EMPTY_ENCOUNTER;
|
||||
}
|
||||
|
||||
function deriveNextId(encounter: Encounter): number {
|
||||
let max = 0;
|
||||
for (const c of encounter.combatants) {
|
||||
@@ -72,40 +108,321 @@ 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: "set-side" }
|
||||
| { 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 "set-side":
|
||||
result = setSideUseCase(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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
@@ -116,245 +433,21 @@ export function useEncounter() {
|
||||
if (!isDomainError(result)) {
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
dispatch({
|
||||
type: "import",
|
||||
encounter: encounterRef.current,
|
||||
undoRedoState: newState,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const dispatchAction = useCallback(
|
||||
(action: () => DomainEvent[] | DomainError) => {
|
||||
const result = withUndo(action);
|
||||
if (!isDomainError(result)) {
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}
|
||||
},
|
||||
[withUndo],
|
||||
);
|
||||
|
||||
const nextId = useRef(deriveNextId(encounter));
|
||||
|
||||
const advanceTurn = useCallback(
|
||||
() => dispatchAction(() => advanceTurnUseCase(makeStore())),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const retreatTurn = useCallback(
|
||||
() => dispatchAction(() => retreatTurnUseCase(makeStore())),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const addCombatant = useCallback(
|
||||
(name: string, init?: CombatantInit) => {
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
dispatchAction(() => addCombatantUseCase(makeStore(), id, name, init));
|
||||
},
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const removeCombatant = useCallback(
|
||||
(id: CombatantId) =>
|
||||
dispatchAction(() => removeCombatantUseCase(makeStore(), id)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const editCombatant = useCallback(
|
||||
(id: CombatantId, newName: string) =>
|
||||
dispatchAction(() => editCombatantUseCase(makeStore(), id, newName)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const setInitiative = useCallback(
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatchAction(() => setInitiativeUseCase(makeStore(), id, value)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const setHp = useCallback(
|
||||
(id: CombatantId, maxHp: number | undefined) =>
|
||||
dispatchAction(() => setHpUseCase(makeStore(), id, maxHp)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const adjustHp = useCallback(
|
||||
(id: CombatantId, delta: number) =>
|
||||
dispatchAction(() => adjustHpUseCase(makeStore(), id, delta)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const setTempHp = useCallback(
|
||||
(id: CombatantId, tempHp: number | undefined) =>
|
||||
dispatchAction(() => setTempHpUseCase(makeStore(), id, tempHp)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const setAc = useCallback(
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatchAction(() => setAcUseCase(makeStore(), id, value)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const toggleCondition = useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId) =>
|
||||
dispatchAction(() =>
|
||||
toggleConditionUseCase(makeStore(), id, conditionId),
|
||||
),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const toggleConcentration = useCallback(
|
||||
(id: CombatantId) =>
|
||||
dispatchAction(() => toggleConcentrationUseCase(makeStore(), id)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const clearEncounter = useCallback(() => {
|
||||
const result = clearEncounterUseCase(makeStore());
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleared = clearHistory();
|
||||
undoRedoRef.current = cleared;
|
||||
setUndoRedoState(cleared);
|
||||
|
||||
nextId.current = 0;
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore]);
|
||||
|
||||
const resolveAndRename = useCallback(
|
||||
(name: string): string => {
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(name, existingNames);
|
||||
|
||||
for (const { from, to } of renames) {
|
||||
const target = store.get().combatants.find((c) => c.name === from);
|
||||
if (target) {
|
||||
editCombatantUseCase(makeStore(), target.id, to);
|
||||
}
|
||||
}
|
||||
|
||||
return newName;
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const addOneFromBestiary = useCallback(
|
||||
(
|
||||
entry: BestiaryIndexEntry,
|
||||
): { cId: CreatureId; events: DomainEvent[] } | null => {
|
||||
const newName = resolveAndRename(entry.name);
|
||||
|
||||
const slug = entry.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = addCombatantUseCase(makeStore(), id, newName, {
|
||||
maxHp: entry.hp,
|
||||
ac: entry.ac > 0 ? entry.ac : undefined,
|
||||
creatureId: cId,
|
||||
});
|
||||
|
||||
if (isDomainError(result)) return null;
|
||||
|
||||
return { cId, events: result };
|
||||
},
|
||||
[makeStore, resolveAndRename],
|
||||
);
|
||||
|
||||
const addFromBestiary = useCallback(
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
const snapshot = encounterRef.current;
|
||||
const added = addOneFromBestiary(entry);
|
||||
|
||||
if (!added) {
|
||||
makeStore().save(snapshot);
|
||||
return null;
|
||||
}
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...added.events]);
|
||||
return added.cId;
|
||||
},
|
||||
[makeStore, addOneFromBestiary],
|
||||
);
|
||||
|
||||
const addMultipleFromBestiary = useCallback(
|
||||
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
||||
const snapshot = encounterRef.current;
|
||||
const allEvents: DomainEvent[] = [];
|
||||
let lastCId: CreatureId | null = null;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const added = addOneFromBestiary(entry);
|
||||
if (!added) {
|
||||
makeStore().save(snapshot);
|
||||
return null;
|
||||
}
|
||||
allEvents.push(...added.events);
|
||||
lastCId = added.cId;
|
||||
}
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...allEvents]);
|
||||
return lastCId;
|
||||
},
|
||||
[makeStore, addOneFromBestiary],
|
||||
);
|
||||
|
||||
const addFromPlayerCharacter = useCallback(
|
||||
(pc: PlayerCharacter) => {
|
||||
const snapshot = encounterRef.current;
|
||||
const newName = resolveAndRename(pc.name);
|
||||
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = addCombatantUseCase(makeStore(), id, newName, {
|
||||
maxHp: pc.maxHp,
|
||||
ac: pc.ac > 0 ? pc.ac : undefined,
|
||||
color: pc.color,
|
||||
icon: pc.icon,
|
||||
playerCharacterId: pc.id,
|
||||
});
|
||||
|
||||
if (isDomainError(result)) {
|
||||
makeStore().save(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, resolveAndRename],
|
||||
);
|
||||
|
||||
const undoAction = useCallback(() => {
|
||||
undoUseCase(makeStore(), makeUndoRedoStore());
|
||||
}, [makeStore, makeUndoRedoStore]);
|
||||
|
||||
const redoAction = useCallback(() => {
|
||||
redoUseCase(makeStore(), makeUndoRedoStore());
|
||||
}, [makeStore, makeUndoRedoStore]);
|
||||
|
||||
// Derived state
|
||||
const canUndo = undoRedoState.undoStack.length > 0;
|
||||
const canRedo = undoRedoState.redoStack.length > 0;
|
||||
|
||||
const hasTempHp = encounter.combatants.some(
|
||||
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||
);
|
||||
|
||||
const isEmpty = encounter.combatants.length === 0;
|
||||
const hasCreatureCombatants = encounter.combatants.some(
|
||||
(c) => c.creatureId != null,
|
||||
@@ -373,27 +466,115 @@ export function useEncounter() {
|
||||
canRollAllInitiative,
|
||||
canUndo,
|
||||
canRedo,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
clearEncounter,
|
||||
removeCombatant,
|
||||
editCombatant,
|
||||
setInitiative,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setTempHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addMultipleFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
undo: undoAction,
|
||||
redo: redoAction,
|
||||
setEncounter,
|
||||
setUndoRedoState,
|
||||
advanceTurn: useCallback(() => dispatch({ type: "advance-turn" }), []),
|
||||
retreatTurn: useCallback(() => dispatch({ type: "retreat-turn" }), []),
|
||||
addCombatant: useCallback(
|
||||
(name: string, init?: CombatantInit) =>
|
||||
dispatch({ type: "add-combatant", name, init }),
|
||||
[],
|
||||
),
|
||||
removeCombatant: useCallback(
|
||||
(id: CombatantId) => dispatch({ type: "remove-combatant", id }),
|
||||
[],
|
||||
),
|
||||
editCombatant: useCallback(
|
||||
(id: CombatantId, newName: string) =>
|
||||
dispatch({ type: "edit-combatant", id, newName }),
|
||||
[],
|
||||
),
|
||||
setInitiative: useCallback(
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatch({ type: "set-initiative", id, value }),
|
||||
[],
|
||||
),
|
||||
setHp: useCallback(
|
||||
(id: CombatantId, maxHp: number | undefined) =>
|
||||
dispatch({ type: "set-hp", id, maxHp }),
|
||||
[],
|
||||
),
|
||||
adjustHp: useCallback(
|
||||
(id: CombatantId, delta: number) =>
|
||||
dispatch({ type: "adjust-hp", id, delta }),
|
||||
[],
|
||||
),
|
||||
setTempHp: useCallback(
|
||||
(id: CombatantId, tempHp: number | undefined) =>
|
||||
dispatch({ type: "set-temp-hp", id, tempHp }),
|
||||
[],
|
||||
),
|
||||
setAc: useCallback(
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatch({ type: "set-ac", id, value }),
|
||||
[],
|
||||
),
|
||||
setCr: useCallback(
|
||||
(id: CombatantId, value: string | undefined) =>
|
||||
dispatch({ type: "set-cr", id, value }),
|
||||
[],
|
||||
),
|
||||
setSide: useCallback(
|
||||
(id: CombatantId, value: "party" | "enemy") =>
|
||||
dispatch({ type: "set-side", 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -32,14 +25,16 @@ interface EditFields {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,67 @@ describe("loadEncounter", () => {
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trip preserves combatant cr field", () => {
|
||||
const result = createEncounter(
|
||||
[
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Custom Thug",
|
||||
cr: "2",
|
||||
},
|
||||
],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error("unreachable");
|
||||
saveEncounter(result);
|
||||
const loaded = loadEncounter();
|
||||
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.combatants[0].cr).toBe("2");
|
||||
});
|
||||
|
||||
it("round-trip preserves combatant side field", () => {
|
||||
const result = createEncounter(
|
||||
[
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Allied Guard",
|
||||
cr: "2",
|
||||
side: "party",
|
||||
},
|
||||
{
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
side: "enemy",
|
||||
},
|
||||
],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error("unreachable");
|
||||
saveEncounter(result);
|
||||
const loaded = loadEncounter();
|
||||
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.combatants[0].side).toBe("party");
|
||||
expect(loaded?.combatants[1].side).toBe("enemy");
|
||||
});
|
||||
|
||||
it("round-trip preserves combatant without side field as undefined", () => {
|
||||
const result = createEncounter(
|
||||
[{ id: combatantId("c-1"), name: "Custom" }],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error("unreachable");
|
||||
saveEncounter(result);
|
||||
const loaded = loadEncounter();
|
||||
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.combatants[0].side).toBeUndefined();
|
||||
});
|
||||
|
||||
it("saving after modifications persists the latest state", () => {
|
||||
const encounter = makeEncounter();
|
||||
saveEncounter(encounter);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Conventions (detailed)
|
||||
|
||||
These conventions supplement the overview in `CLAUDE.md`. Load this file when working in the relevant areas.
|
||||
|
||||
## Component Props
|
||||
|
||||
Max 8 explicitly declared props per component interface, enforced by `scripts/check-component-props.mjs` (uses the TypeScript compiler API). Run `pnpm check:props` to verify.
|
||||
|
||||
- Use React context for shared state
|
||||
- Reserve props for per-instance config (data items, layout variants, refs)
|
||||
|
||||
## Export Format Compatibility
|
||||
|
||||
When changing `Encounter`, `Combatant`, `PlayerCharacter`, or `UndoRedoState` types, verify that previously exported JSON files (version 1) still import correctly. If not, bump the `ExportBundle` version and add migration logic in `validateImportBundle()`.
|
||||
|
||||
## Domain Patterns
|
||||
|
||||
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical. See [ADR-003](adr/003-branded-types-for-identity.md).
|
||||
- **Domain events** are plain data objects with a `type` discriminant — no classes. See [ADR-002](adr/002-domain-events-as-plain-data.md).
|
||||
- **Errors as values** (`DomainError`), never thrown. See [ADR-001](adr/001-errors-as-values.md).
|
||||
+27
-2
@@ -1,4 +1,29 @@
|
||||
pre-commit:
|
||||
parallel: true
|
||||
jobs:
|
||||
- name: check
|
||||
run: pnpm check
|
||||
- name: audit
|
||||
run: pnpm audit --audit-level=high
|
||||
- name: knip
|
||||
run: pnpm exec knip
|
||||
- name: biome
|
||||
run: pnpm exec biome check .
|
||||
- name: check-ignores
|
||||
run: node scripts/check-lint-ignores.mjs
|
||||
- name: check-classnames
|
||||
run: node scripts/check-cn-classnames.mjs
|
||||
- name: check-props
|
||||
run: node scripts/check-component-props.mjs
|
||||
- name: jscpd
|
||||
run: pnpm exec jscpd
|
||||
- name: jsinspect
|
||||
run: pnpm jsinspect
|
||||
- name: typecheck-oxlint-test
|
||||
group:
|
||||
piped: true
|
||||
jobs:
|
||||
- name: typecheck
|
||||
run: pnpm exec tsc --build
|
||||
- name: oxlint
|
||||
run: pnpm oxlint -- --deny warnings
|
||||
- name: test
|
||||
run: pnpm vitest run --reporter=dot --coverage.reporter=text-summary
|
||||
|
||||
+2
-2
@@ -31,10 +31,10 @@
|
||||
"knip": "knip",
|
||||
"jscpd": "jscpd",
|
||||
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny warnings",
|
||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||
"check:props": "node scripts/check-component-props.mjs",
|
||||
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && tsc --build && vitest run && jscpd && pnpm jsinspect"
|
||||
"check": "pnpm audit --audit-level=high && knip && biome check . && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && jscpd && pnpm jsinspect && tsc --build && oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny warnings && vitest run"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { Encounter, PlayerCharacter } from "@initiative/domain";
|
||||
import { isDomainError } from "@initiative/domain";
|
||||
import type { EncounterStore, PlayerCharacterStore } from "../ports.js";
|
||||
import type {
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import { EMPTY_UNDO_REDO_STATE, isDomainError } from "@initiative/domain";
|
||||
import type {
|
||||
EncounterStore,
|
||||
PlayerCharacterStore,
|
||||
UndoRedoStore,
|
||||
} from "../ports.js";
|
||||
|
||||
export function requireSaved<T>(value: T | null): T {
|
||||
if (value === null) throw new Error("Expected store.saved to be non-null");
|
||||
@@ -52,3 +60,17 @@ export function stubPlayerCharacterStore(
|
||||
};
|
||||
return stub;
|
||||
}
|
||||
|
||||
export function stubUndoRedoStore(
|
||||
initial: UndoRedoState = EMPTY_UNDO_REDO_STATE,
|
||||
): UndoRedoStore & { saved: UndoRedoState | null } {
|
||||
const stub = {
|
||||
saved: null as UndoRedoState | null,
|
||||
get: () => initial,
|
||||
save: (state: UndoRedoState) => {
|
||||
stub.saved = state;
|
||||
stub.get = () => state;
|
||||
},
|
||||
};
|
||||
return stub;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
type ConditionId,
|
||||
combatantId,
|
||||
createEncounter,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
isDomainError,
|
||||
playerCharacterId,
|
||||
pushUndo,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addCombatantUseCase } from "../add-combatant-use-case.js";
|
||||
@@ -14,17 +16,21 @@ import { createPlayerCharacterUseCase } from "../create-player-character-use-cas
|
||||
import { deletePlayerCharacterUseCase } from "../delete-player-character-use-case.js";
|
||||
import { editCombatantUseCase } from "../edit-combatant-use-case.js";
|
||||
import { editPlayerCharacterUseCase } from "../edit-player-character-use-case.js";
|
||||
import { redoUseCase } from "../redo-use-case.js";
|
||||
import { removeCombatantUseCase } from "../remove-combatant-use-case.js";
|
||||
import { retreatTurnUseCase } from "../retreat-turn-use-case.js";
|
||||
import { setAcUseCase } from "../set-ac-use-case.js";
|
||||
import { setHpUseCase } from "../set-hp-use-case.js";
|
||||
import { setInitiativeUseCase } from "../set-initiative-use-case.js";
|
||||
import { setTempHpUseCase } from "../set-temp-hp-use-case.js";
|
||||
import { toggleConcentrationUseCase } from "../toggle-concentration-use-case.js";
|
||||
import { toggleConditionUseCase } from "../toggle-condition-use-case.js";
|
||||
import { undoUseCase } from "../undo-use-case.js";
|
||||
import {
|
||||
requireSaved,
|
||||
stubEncounterStore,
|
||||
stubPlayerCharacterStore,
|
||||
stubUndoRedoStore,
|
||||
} from "./helpers.js";
|
||||
|
||||
const ID_A = combatantId("a");
|
||||
@@ -386,3 +392,80 @@ describe("editPlayerCharacterUseCase", () => {
|
||||
expect(store.saved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTempHpUseCase", () => {
|
||||
it("sets temp HP and saves", () => {
|
||||
const enc = encounterWithHp("Goblin", 10);
|
||||
const store = stubEncounterStore(enc);
|
||||
const result = setTempHpUseCase(store, combatantId("Goblin"), 5);
|
||||
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
expect(requireSaved(store.saved).combatants[0].tempHp).toBe(5);
|
||||
});
|
||||
|
||||
it("returns domain error for unknown combatant", () => {
|
||||
const store = stubEncounterStore(emptyEncounter());
|
||||
const result = setTempHpUseCase(store, ID_A, 5);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
expect(store.saved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("undoUseCase", () => {
|
||||
it("restores previous encounter and saves both stores", () => {
|
||||
const previous = encounterWith("A");
|
||||
const current = encounterWith("A", "B");
|
||||
const undoRedoState = pushUndo(EMPTY_UNDO_REDO_STATE, previous);
|
||||
const encounterStore = stubEncounterStore(current);
|
||||
const undoRedoStore = stubUndoRedoStore(undoRedoState);
|
||||
|
||||
const result = undoUseCase(encounterStore, undoRedoStore);
|
||||
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
expect(requireSaved(encounterStore.saved).combatants).toHaveLength(1);
|
||||
expect(undoRedoStore.saved).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns domain error when nothing to undo", () => {
|
||||
const encounterStore = stubEncounterStore(emptyEncounter());
|
||||
const undoRedoStore = stubUndoRedoStore();
|
||||
|
||||
const result = undoUseCase(encounterStore, undoRedoStore);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
expect(encounterStore.saved).toBeNull();
|
||||
expect(undoRedoStore.saved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("redoUseCase", () => {
|
||||
it("restores next encounter and saves both stores", () => {
|
||||
const previous = encounterWith("A");
|
||||
const current = encounterWith("A", "B");
|
||||
// Simulate: undo pushed current to redoStack
|
||||
const undoRedoState = {
|
||||
undoStack: [],
|
||||
redoStack: [current],
|
||||
};
|
||||
const encounterStore = stubEncounterStore(previous);
|
||||
const undoRedoStore = stubUndoRedoStore(undoRedoState);
|
||||
|
||||
const result = redoUseCase(encounterStore, undoRedoStore);
|
||||
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
expect(requireSaved(encounterStore.saved).combatants).toHaveLength(2);
|
||||
expect(undoRedoStore.saved).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns domain error when nothing to redo", () => {
|
||||
const encounterStore = stubEncounterStore(emptyEncounter());
|
||||
const undoRedoStore = stubUndoRedoStore();
|
||||
|
||||
const result = redoUseCase(encounterStore, undoRedoStore);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
expect(encounterStore.saved).toBeNull();
|
||||
expect(undoRedoStore.saved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,8 +21,10 @@ export {
|
||||
} from "./roll-all-initiative-use-case.js";
|
||||
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||
export { setCrUseCase } from "./set-cr-use-case.js";
|
||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||
export { setSideUseCase } from "./set-side-use-case.js";
|
||||
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
setCr,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function setCrUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
value: string | undefined,
|
||||
): DomainEvent[] | DomainError {
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
setCr(encounter, combatantId, value),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
setSide,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function setSideUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
value: "party" | "enemy",
|
||||
): DomainEvent[] | DomainError {
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
setSide(encounter, combatantId, value),
|
||||
);
|
||||
}
|
||||
@@ -36,98 +36,353 @@ describe("crToXp", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateEncounterDifficulty", () => {
|
||||
it("returns trivial when monster XP is below Low threshold", () => {
|
||||
/** Helper to build party-side descriptors with level. */
|
||||
function party(level: number) {
|
||||
return { level, side: "party" as const };
|
||||
}
|
||||
|
||||
/** Helper to build enemy-side descriptors with CR. */
|
||||
function enemy(cr: string) {
|
||||
return { cr, side: "enemy" as const };
|
||||
}
|
||||
|
||||
describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
||||
it("returns tier 0 when monster XP is below Low threshold", () => {
|
||||
// 4x level 1: Low = 200, Moderate = 300, High = 400
|
||||
// 1x CR 0 = 0 XP → trivial
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["0"]);
|
||||
expect(result.tier).toBe("trivial");
|
||||
// 1x CR 0 = 0 XP -> tier 0
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("0")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(0);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 200,
|
||||
moderate: 300,
|
||||
high: 400,
|
||||
});
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 200 },
|
||||
{ label: "Moderate", value: 300 },
|
||||
{ label: "High", value: 400 },
|
||||
]);
|
||||
expect(result.encounterMultiplier).toBeUndefined();
|
||||
expect(result.adjustedXp).toBeUndefined();
|
||||
expect(result.partySizeAdjusted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns low for 4x level 1 vs Bugbear (CR 1)", () => {
|
||||
// DMG example: 4x level 1 PCs vs 1 Bugbear (CR 1, 200 XP)
|
||||
// Low = 200, Moderate = 300 → 200 >= 200 but < 300 → Low
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1"]);
|
||||
expect(result.tier).toBe("low");
|
||||
it("returns tier 1 for 4x level 1 vs Bugbear (CR 1)", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(1);
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
});
|
||||
|
||||
it("returns moderate for 5x level 3 vs 1125 XP", () => {
|
||||
it("returns tier 2 for 5x level 3 vs 1150 XP", () => {
|
||||
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||||
// 1125 XP >= 1125 Moderate but < 2000 High → Moderate
|
||||
// Using CR 3 (700) + CR 2 (450) = 1150 XP ≈ 1125 threshold
|
||||
// Let's use exact: 5 * 225 = 1125 moderate budget
|
||||
// Need monsters that sum exactly to 1125: CR 3 (700) + CR 2 (450) = 1150
|
||||
const result = calculateEncounterDifficulty([3, 3, 3, 3, 3], ["3", "2"]);
|
||||
expect(result.tier).toBe("moderate");
|
||||
// CR 3 (700) + CR 2 (450) = 1150 XP >= 1125 Moderate
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
enemy("3"),
|
||||
enemy("2"),
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(2);
|
||||
expect(result.totalMonsterXp).toBe(1150);
|
||||
expect(result.partyBudget.moderate).toBe(1125);
|
||||
expect(result.thresholds[1].value).toBe(1125);
|
||||
});
|
||||
|
||||
it("returns high when XP meets High threshold", () => {
|
||||
it("returns tier 3 when XP meets High threshold", () => {
|
||||
// 4x level 1: High = 400
|
||||
// 2x CR 1 = 400 XP → High
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1", "1"]);
|
||||
expect(result.tier).toBe("high");
|
||||
// 2x CR 1 = 400 XP -> tier 3
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1"), enemy("1")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(3);
|
||||
expect(result.totalMonsterXp).toBe(400);
|
||||
});
|
||||
|
||||
it("caps at high when XP far exceeds threshold", () => {
|
||||
// 4x level 1: High = 400
|
||||
// CR 30 = 155000 XP → still High (no tier above)
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["30"]);
|
||||
expect(result.tier).toBe("high");
|
||||
it("caps at tier 3 when XP far exceeds threshold", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("30")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(3);
|
||||
expect(result.totalMonsterXp).toBe(155000);
|
||||
});
|
||||
|
||||
it("handles mixed party levels", () => {
|
||||
// 3x level 3 + 1x level 2
|
||||
// level 3: low=150, mod=225, high=400 (x3 = 450, 675, 1200)
|
||||
// level 2: low=100, mod=150, high=200 (x1 = 100, 150, 200)
|
||||
// Total: low=550, mod=825, high=1400
|
||||
const result = calculateEncounterDifficulty([3, 3, 3, 2], ["3"]);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 550,
|
||||
moderate: 825,
|
||||
high: 1400,
|
||||
});
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(3), party(3), party(3), party(2), enemy("3")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 550 },
|
||||
{ label: "Moderate", value: 825 },
|
||||
{ label: "High", value: 1400 },
|
||||
]);
|
||||
expect(result.totalMonsterXp).toBe(700);
|
||||
expect(result.tier).toBe("low");
|
||||
expect(result.tier).toBe(1);
|
||||
});
|
||||
|
||||
it("returns trivial with empty monster array", () => {
|
||||
const result = calculateEncounterDifficulty([5, 5], []);
|
||||
expect(result.tier).toBe("trivial");
|
||||
it("returns tier 0 with no enemies", () => {
|
||||
const result = calculateEncounterDifficulty([party(5), party(5)], "5.5e");
|
||||
expect(result.tier).toBe(0);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
});
|
||||
|
||||
it("returns high with empty party array (zero budget thresholds)", () => {
|
||||
// Domain function treats empty party as zero budgets — any XP exceeds all thresholds.
|
||||
// The useDifficulty hook guards this path by returning null when no leveled PCs exist.
|
||||
const result = calculateEncounterDifficulty([], ["1"]);
|
||||
expect(result.tier).toBe("high");
|
||||
it("returns tier 3 with no party levels (zero budget thresholds)", () => {
|
||||
const result = calculateEncounterDifficulty([enemy("1")], "5.5e");
|
||||
expect(result.tier).toBe(3);
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
expect(result.partyBudget).toEqual({ low: 0, moderate: 0, high: 0 });
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 0 },
|
||||
{ label: "Moderate", value: 0 },
|
||||
{ label: "High", value: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles fractional CRs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[1, 1, 1, 1],
|
||||
["1/8", "1/4", "1/2"],
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1/8"),
|
||||
enemy("1/4"),
|
||||
enemy("1/2"),
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
||||
expect(result.tier).toBe("trivial"); // 175 < 200 Low
|
||||
expect(result.tier).toBe(0); // 175 < 200 Low
|
||||
});
|
||||
|
||||
it("ignores unknown CRs (0 XP)", () => {
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["unknown"]);
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("unknown")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.tier).toBe("trivial");
|
||||
expect(result.tier).toBe(0);
|
||||
});
|
||||
|
||||
it("subtracts XP for party-side combatant with CR", () => {
|
||||
// 4x level 1 party, 1 enemy CR 2 (450 XP), 1 party CR 1 (200 XP)
|
||||
// Net = 450 - 200 = 250
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("2"),
|
||||
{ cr: "1", side: "party" },
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(250);
|
||||
expect(result.tier).toBe(1); // 250 >= 200 Low, < 300 Moderate
|
||||
});
|
||||
|
||||
it("floors net monster XP at 0", () => {
|
||||
// Party ally has more XP than enemy
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
{ cr: "5", side: "party" }, // 1800 XP subtracted
|
||||
enemy("1"), // 200 XP added
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.tier).toBe(0);
|
||||
});
|
||||
|
||||
it("dual contribution: combatant with both level and CR on party side", () => {
|
||||
// Party combatant with level 1 AND CR 1 on party side
|
||||
// Level contributes to budget, CR subtracts from monster XP
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
{ level: 1, cr: "1", side: "party" }, // budget += lv1, monsterXp -= 200
|
||||
enemy("2"), // monsterXp += 450
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 50 },
|
||||
{ label: "Moderate", value: 75 },
|
||||
{ label: "High", value: 100 },
|
||||
]);
|
||||
expect(result.totalMonsterXp).toBe(250); // 450 - 200
|
||||
});
|
||||
|
||||
it("enemy-side combatant with level does NOT contribute to budget", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), { level: 5, side: "enemy" }, enemy("1")],
|
||||
"5.5e",
|
||||
);
|
||||
// Only level 1 party contributes to budget
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 50 },
|
||||
{ label: "Moderate", value: 75 },
|
||||
{ label: "High", value: 100 },
|
||||
]);
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
});
|
||||
|
||||
it("mixed sides calculate correctly", () => {
|
||||
// 2 party PCs (level 3), 1 party ally (CR 1, 200 XP), 2 enemies (CR 2, 450 each)
|
||||
// Budget: 2x level 3 = low 300, mod 450, high 800
|
||||
// Monster XP: 900 - 200 = 700
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(3), party(3), { cr: "1", side: "party" }, enemy("2"), enemy("2")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 300 },
|
||||
{ label: "Moderate", value: 450 },
|
||||
{ label: "High", value: 800 },
|
||||
]);
|
||||
expect(result.totalMonsterXp).toBe(700);
|
||||
expect(result.tier).toBe(2); // 700 >= 450 Moderate, < 800 High
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateEncounterDifficulty — 2014 edition", () => {
|
||||
it("uses 2014 XP thresholds table", () => {
|
||||
// 4x level 1: Easy=100, Medium=200, Hard=300, Deadly=400
|
||||
// 1 enemy CR 1 = 200 XP, x1 multiplier = 200 adjusted
|
||||
// 200 >= 200 Medium → tier 1
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1")],
|
||||
"5e",
|
||||
);
|
||||
expect(result.tier).toBe(1);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Easy", value: 100 },
|
||||
{ label: "Medium", value: 200 },
|
||||
{ label: "Hard", value: 300 },
|
||||
{ label: "Deadly", value: 400 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies encounter multiplier for 3 monsters (x2)", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1/8"),
|
||||
enemy("1/8"),
|
||||
enemy("1/8"),
|
||||
],
|
||||
"5e",
|
||||
);
|
||||
// Base: 75 XP, 3 monsters → x2 = 150 adjusted
|
||||
expect(result.totalMonsterXp).toBe(75);
|
||||
expect(result.encounterMultiplier).toBe(2);
|
||||
expect(result.adjustedXp).toBe(150);
|
||||
});
|
||||
|
||||
it("shifts multiplier up for fewer than 3 PCs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), enemy("1")],
|
||||
"5e",
|
||||
);
|
||||
// 1 monster, 2 PCs → base x1 shifts up to x1.5
|
||||
expect(result.encounterMultiplier).toBe(1.5);
|
||||
expect(result.partySizeAdjusted).toBe(true);
|
||||
});
|
||||
|
||||
it("shifts multiplier down for 6+ PCs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
],
|
||||
"5e",
|
||||
);
|
||||
// 3 monsters, 6 PCs → base x2 shifts down to x1.5
|
||||
expect(result.encounterMultiplier).toBe(1.5);
|
||||
expect(result.partySizeAdjusted).toBe(true);
|
||||
});
|
||||
|
||||
it("shifts multiplier to x5 for 15+ monsters with <3 PCs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), ...Array.from({ length: 15 }, () => enemy("0"))],
|
||||
"5e",
|
||||
);
|
||||
// 15+ monsters = x4 base, shift up → x5
|
||||
expect(result.encounterMultiplier).toBe(5);
|
||||
expect(result.partySizeAdjusted).toBe(true);
|
||||
});
|
||||
|
||||
it("shifts multiplier to x0.5 for 1 monster with 6+ PCs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), party(1), party(1), enemy("1")],
|
||||
"5e",
|
||||
);
|
||||
expect(result.encounterMultiplier).toBe(0.5);
|
||||
expect(result.partySizeAdjusted).toBe(true);
|
||||
});
|
||||
|
||||
it("only counts enemy-side combatants for monster count", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
{ cr: "1", side: "party" },
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
],
|
||||
"5e",
|
||||
);
|
||||
// 3 enemy monsters → x2, NOT 4
|
||||
expect(result.encounterMultiplier).toBe(2);
|
||||
});
|
||||
|
||||
it("returns tier 0 when adjusted XP meets Easy but not Medium", () => {
|
||||
// 4x level 1: Easy=100, Medium=200
|
||||
// 1 enemy CR 1/2 = 100 XP, x1 multiplier = 100 adjusted
|
||||
// 100 >= Easy(100) but < Medium(200) → tier 0
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1/2")],
|
||||
"5e",
|
||||
);
|
||||
expect(result.tier).toBe(0);
|
||||
expect(result.adjustedXp).toBe(100);
|
||||
});
|
||||
|
||||
it("returns no party size adjustment for standard party (3-5)", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1")],
|
||||
"5e",
|
||||
);
|
||||
expect(result.partySizeAdjusted).toBe(false);
|
||||
});
|
||||
|
||||
it("returns undefined multiplier/adjustedXp for 5.5e", () => {
|
||||
const result = calculateEncounterDifficulty([party(1), enemy("1")], "5.5e");
|
||||
expect(result.encounterMultiplier).toBeUndefined();
|
||||
expect(result.adjustedXp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,6 +219,50 @@ describe("rehydrateCombatant", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves valid cr field", () => {
|
||||
for (const cr of ["5", "1/4", "0", "30"]) {
|
||||
const result = rehydrateCombatant({ ...minimalCombatant(), cr });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.cr).toBe(cr);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops invalid cr field", () => {
|
||||
for (const cr of ["99", "", 42, null, "abc"]) {
|
||||
const result = rehydrateCombatant({ ...minimalCombatant(), cr });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.cr).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("combatant without cr rehydrates as before", () => {
|
||||
const result = rehydrateCombatant(minimalCombatant());
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.cr).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves valid side field", () => {
|
||||
for (const side of ["party", "enemy"]) {
|
||||
const result = rehydrateCombatant({ ...minimalCombatant(), side });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.side).toBe(side);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops invalid side field", () => {
|
||||
for (const side of ["ally", "", 42, null, true]) {
|
||||
const result = rehydrateCombatant({ ...minimalCombatant(), side });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.side).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("combatant without side rehydrates as before", () => {
|
||||
const result = rehydrateCombatant(minimalCombatant());
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.side).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops invalid tempHp — keeps combatant", () => {
|
||||
for (const tempHp of [-1, 1.5, "3"]) {
|
||||
const result = rehydrateCombatant({
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setCr } from "../set-cr.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
function makeCombatant(name: string, cr?: string): Combatant {
|
||||
return cr === undefined
|
||||
? { id: combatantId(name), name }
|
||||
: { id: combatantId(name), name, cr };
|
||||
}
|
||||
|
||||
function enc(
|
||||
combatants: Combatant[],
|
||||
activeIndex = 0,
|
||||
roundNumber = 1,
|
||||
): Encounter {
|
||||
return { combatants, activeIndex, roundNumber };
|
||||
}
|
||||
|
||||
function successResult(
|
||||
encounter: Encounter,
|
||||
id: string,
|
||||
value: string | undefined,
|
||||
) {
|
||||
const result = setCr(encounter, combatantId(id), value);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("setCr", () => {
|
||||
it("sets CR to a valid integer value", () => {
|
||||
const e = enc([makeCombatant("A"), makeCombatant("B")]);
|
||||
const { encounter, events } = successResult(e, "A", "5");
|
||||
|
||||
expect(encounter.combatants[0].cr).toBe("5");
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "CrSet",
|
||||
combatantId: combatantId("A"),
|
||||
previousCr: undefined,
|
||||
newCr: "5",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets CR to 0", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const { encounter } = successResult(e, "A", "0");
|
||||
|
||||
expect(encounter.combatants[0].cr).toBe("0");
|
||||
});
|
||||
|
||||
it("sets CR to fractional values", () => {
|
||||
for (const cr of ["1/8", "1/4", "1/2"]) {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const { encounter } = successResult(e, "A", cr);
|
||||
expect(encounter.combatants[0].cr).toBe(cr);
|
||||
}
|
||||
});
|
||||
|
||||
it("sets CR to 30", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const { encounter } = successResult(e, "A", "30");
|
||||
|
||||
expect(encounter.combatants[0].cr).toBe("30");
|
||||
});
|
||||
|
||||
it("clears CR with undefined", () => {
|
||||
const e = enc([makeCombatant("A", "5")]);
|
||||
const { encounter, events } = successResult(e, "A", undefined);
|
||||
|
||||
expect(encounter.combatants[0].cr).toBeUndefined();
|
||||
expect(events[0]).toMatchObject({
|
||||
previousCr: "5",
|
||||
newCr: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns error for nonexistent combatant", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setCr(e, combatantId("nonexistent"), "1");
|
||||
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
|
||||
it("returns error for invalid CR string", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setCr(e, combatantId("A"), "99");
|
||||
|
||||
expectDomainError(result, "invalid-cr");
|
||||
});
|
||||
|
||||
it("returns error for empty string CR", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setCr(e, combatantId("A"), "");
|
||||
|
||||
expectDomainError(result, "invalid-cr");
|
||||
});
|
||||
|
||||
it("preserves other fields when setting CR", () => {
|
||||
const combatant: Combatant = {
|
||||
id: combatantId("A"),
|
||||
name: "Aria",
|
||||
initiative: 15,
|
||||
maxHp: 20,
|
||||
currentHp: 18,
|
||||
ac: 14,
|
||||
};
|
||||
const e = enc([combatant]);
|
||||
const { encounter } = successResult(e, "A", "2");
|
||||
|
||||
const updated = encounter.combatants[0];
|
||||
expect(updated.cr).toBe("2");
|
||||
expect(updated.name).toBe("Aria");
|
||||
expect(updated.initiative).toBe(15);
|
||||
expect(updated.maxHp).toBe(20);
|
||||
expect(updated.currentHp).toBe(18);
|
||||
expect(updated.ac).toBe(14);
|
||||
});
|
||||
|
||||
it("does not reorder combatants", () => {
|
||||
const e = enc([makeCombatant("A"), makeCombatant("B")]);
|
||||
const { encounter } = successResult(e, "B", "1");
|
||||
|
||||
expect(encounter.combatants[0].id).toBe(combatantId("A"));
|
||||
expect(encounter.combatants[1].id).toBe(combatantId("B"));
|
||||
});
|
||||
|
||||
it("preserves activeIndex and roundNumber", () => {
|
||||
const e = enc([makeCombatant("A"), makeCombatant("B")], 1, 5);
|
||||
const { encounter } = successResult(e, "A", "1/4");
|
||||
|
||||
expect(encounter.activeIndex).toBe(1);
|
||||
expect(encounter.roundNumber).toBe(5);
|
||||
});
|
||||
|
||||
it("does not mutate input encounter", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const original = JSON.parse(JSON.stringify(e));
|
||||
setCr(e, combatantId("A"), "10");
|
||||
expect(e).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setSide } from "../set-side.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
function makeCombatant(name: string, side?: "party" | "enemy"): Combatant {
|
||||
return side === undefined
|
||||
? { id: combatantId(name), name }
|
||||
: { id: combatantId(name), name, side };
|
||||
}
|
||||
|
||||
function enc(
|
||||
combatants: Combatant[],
|
||||
activeIndex = 0,
|
||||
roundNumber = 1,
|
||||
): Encounter {
|
||||
return { combatants, activeIndex, roundNumber };
|
||||
}
|
||||
|
||||
function successResult(
|
||||
encounter: Encounter,
|
||||
id: string,
|
||||
value: "party" | "enemy",
|
||||
) {
|
||||
const result = setSide(encounter, combatantId(id), value);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("setSide", () => {
|
||||
it("sets side to party", () => {
|
||||
const e = enc([makeCombatant("A"), makeCombatant("B")]);
|
||||
const { encounter, events } = successResult(e, "A", "party");
|
||||
|
||||
expect(encounter.combatants[0].side).toBe("party");
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "SideSet",
|
||||
combatantId: combatantId("A"),
|
||||
previousSide: undefined,
|
||||
newSide: "party",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets side to enemy", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const { encounter } = successResult(e, "A", "enemy");
|
||||
|
||||
expect(encounter.combatants[0].side).toBe("enemy");
|
||||
});
|
||||
|
||||
it("records previous side in event", () => {
|
||||
const e = enc([makeCombatant("A", "party")]);
|
||||
const { events } = successResult(e, "A", "enemy");
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
previousSide: "party",
|
||||
newSide: "enemy",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns error for nonexistent combatant", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setSide(e, combatantId("nonexistent"), "party");
|
||||
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
|
||||
it("preserves other fields when setting side", () => {
|
||||
const combatant: Combatant = {
|
||||
id: combatantId("A"),
|
||||
name: "Aria",
|
||||
initiative: 15,
|
||||
maxHp: 20,
|
||||
currentHp: 18,
|
||||
ac: 14,
|
||||
cr: "2",
|
||||
};
|
||||
const e = enc([combatant]);
|
||||
const { encounter } = successResult(e, "A", "party");
|
||||
|
||||
const updated = encounter.combatants[0];
|
||||
expect(updated.side).toBe("party");
|
||||
expect(updated.name).toBe("Aria");
|
||||
expect(updated.initiative).toBe(15);
|
||||
expect(updated.cr).toBe("2");
|
||||
});
|
||||
|
||||
it("does not reorder combatants", () => {
|
||||
const e = enc([makeCombatant("A"), makeCombatant("B")]);
|
||||
const { encounter } = successResult(e, "B", "party");
|
||||
|
||||
expect(encounter.combatants[0].id).toBe(combatantId("A"));
|
||||
expect(encounter.combatants[1].id).toBe(combatantId("B"));
|
||||
});
|
||||
|
||||
it("preserves activeIndex and roundNumber", () => {
|
||||
const e = enc([makeCombatant("A"), makeCombatant("B")], 1, 5);
|
||||
const { encounter } = successResult(e, "A", "party");
|
||||
|
||||
expect(encounter.activeIndex).toBe(1);
|
||||
expect(encounter.roundNumber).toBe(5);
|
||||
});
|
||||
|
||||
it("does not mutate input encounter", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const original = JSON.parse(JSON.stringify(e));
|
||||
setSide(e, combatantId("A"), "party");
|
||||
expect(e).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ export type ConditionId =
|
||||
| "stunned"
|
||||
| "unconscious";
|
||||
|
||||
export type RulesEdition = "5e" | "5.5e";
|
||||
import type { RulesEdition } from "./rules-edition.js";
|
||||
|
||||
export interface ConditionDefinition {
|
||||
readonly id: ConditionId;
|
||||
|
||||
@@ -5,9 +5,18 @@ export function creatureId(id: string): CreatureId {
|
||||
return id as CreatureId;
|
||||
}
|
||||
|
||||
export type TraitSegment =
|
||||
| { readonly type: "text"; readonly value: string }
|
||||
| { readonly type: "list"; readonly items: readonly TraitListItem[] };
|
||||
|
||||
export interface TraitListItem {
|
||||
readonly label?: string;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface TraitBlock {
|
||||
readonly name: string;
|
||||
readonly text: string;
|
||||
readonly segments: readonly TraitSegment[];
|
||||
}
|
||||
|
||||
export interface LegendaryBlock {
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
export type DifficultyTier = "trivial" | "low" | "moderate" | "high";
|
||||
import type { RulesEdition } from "./rules-edition.js";
|
||||
|
||||
/** Abstract difficulty severity: 0 = negligible, 3 = maximum. Maps to filled bar count. */
|
||||
export type DifficultyTier = 0 | 1 | 2 | 3;
|
||||
|
||||
export interface DifficultyThreshold {
|
||||
readonly label: string;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
export interface DifficultyResult {
|
||||
readonly tier: DifficultyTier;
|
||||
readonly totalMonsterXp: number;
|
||||
readonly partyBudget: {
|
||||
readonly low: number;
|
||||
readonly moderate: number;
|
||||
readonly high: number;
|
||||
};
|
||||
readonly thresholds: readonly DifficultyThreshold[];
|
||||
/** 2014 only: the encounter multiplier applied to base monster XP. */
|
||||
readonly encounterMultiplier: number | undefined;
|
||||
/** 2014 only: monster XP after applying the encounter multiplier. */
|
||||
readonly adjustedXp: number | undefined;
|
||||
/** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */
|
||||
readonly partySizeAdjusted: boolean | undefined;
|
||||
}
|
||||
|
||||
/** Maps challenge rating strings to XP values (standard 5e). */
|
||||
@@ -74,53 +84,215 @@ const XP_BUDGET_PER_CHARACTER: Readonly<
|
||||
20: { low: 6400, moderate: 13200, high: 22000 },
|
||||
};
|
||||
|
||||
/** Maps character level (1-20) to XP thresholds (2014 DMG). */
|
||||
const XP_THRESHOLDS_2014: Readonly<
|
||||
Record<number, { easy: number; medium: number; hard: number; deadly: number }>
|
||||
> = {
|
||||
1: { easy: 25, medium: 50, hard: 75, deadly: 100 },
|
||||
2: { easy: 50, medium: 100, hard: 150, deadly: 200 },
|
||||
3: { easy: 75, medium: 150, hard: 225, deadly: 400 },
|
||||
4: { easy: 125, medium: 250, hard: 375, deadly: 500 },
|
||||
5: { easy: 250, medium: 500, hard: 750, deadly: 1100 },
|
||||
6: { easy: 300, medium: 600, hard: 900, deadly: 1400 },
|
||||
7: { easy: 350, medium: 750, hard: 1100, deadly: 1700 },
|
||||
8: { easy: 450, medium: 900, hard: 1400, deadly: 2100 },
|
||||
9: { easy: 550, medium: 1100, hard: 1600, deadly: 2400 },
|
||||
10: { easy: 600, medium: 1200, hard: 1900, deadly: 2800 },
|
||||
11: { easy: 800, medium: 1600, hard: 2400, deadly: 3600 },
|
||||
12: { easy: 1000, medium: 2000, hard: 3000, deadly: 4500 },
|
||||
13: { easy: 1100, medium: 2200, hard: 3400, deadly: 5100 },
|
||||
14: { easy: 1250, medium: 2500, hard: 3800, deadly: 5700 },
|
||||
15: { easy: 1400, medium: 2800, hard: 4300, deadly: 6400 },
|
||||
16: { easy: 1600, medium: 3200, hard: 4800, deadly: 7200 },
|
||||
17: { easy: 2000, medium: 3900, hard: 5900, deadly: 8800 },
|
||||
18: { easy: 2100, medium: 4200, hard: 6300, deadly: 9500 },
|
||||
19: { easy: 2400, medium: 4900, hard: 7300, deadly: 10900 },
|
||||
20: { easy: 2800, medium: 5700, hard: 8500, deadly: 12700 },
|
||||
};
|
||||
|
||||
/** 2014 encounter multiplier by number of enemy-side monsters. */
|
||||
const ENCOUNTER_MULTIPLIER_TABLE: readonly {
|
||||
max: number;
|
||||
multiplier: number;
|
||||
}[] = [
|
||||
{ max: 1, multiplier: 1 },
|
||||
{ max: 2, multiplier: 1.5 },
|
||||
{ max: 6, multiplier: 2 },
|
||||
{ max: 10, multiplier: 2.5 },
|
||||
{ max: 14, multiplier: 3 },
|
||||
{ max: Number.POSITIVE_INFINITY, multiplier: 4 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Multiplier values in ascending order for party size shifting.
|
||||
* Extends beyond the base table: x0.5 (6+ PCs, 1 monster) and x5 (<3 PCs, 15+ monsters)
|
||||
* per 2014 DMG party size adjustment rules.
|
||||
*/
|
||||
const MULTIPLIER_STEPS = [0.5, 1, 1.5, 2, 2.5, 3, 4, 5] as const;
|
||||
|
||||
/** Index into MULTIPLIER_STEPS for each base table entry (before party size adjustment). */
|
||||
const BASE_STEP_INDEX = [1, 2, 3, 4, 5, 6] as const;
|
||||
|
||||
function getEncounterMultiplier(
|
||||
monsterCount: number,
|
||||
partySize: number,
|
||||
): { multiplier: number; partySizeAdjusted: boolean } {
|
||||
const tableIndex = ENCOUNTER_MULTIPLIER_TABLE.findIndex(
|
||||
(entry) => monsterCount <= entry.max,
|
||||
);
|
||||
let stepIndex: number =
|
||||
BASE_STEP_INDEX[
|
||||
tableIndex === -1 ? BASE_STEP_INDEX.length - 1 : tableIndex
|
||||
];
|
||||
let partySizeAdjusted = false;
|
||||
|
||||
if (partySize < 3) {
|
||||
stepIndex = Math.min(stepIndex + 1, MULTIPLIER_STEPS.length - 1);
|
||||
partySizeAdjusted = true;
|
||||
} else if (partySize >= 6) {
|
||||
stepIndex = Math.max(stepIndex - 1, 0);
|
||||
partySizeAdjusted = true;
|
||||
}
|
||||
|
||||
return {
|
||||
multiplier: MULTIPLIER_STEPS[stepIndex] as number,
|
||||
partySizeAdjusted,
|
||||
};
|
||||
}
|
||||
|
||||
/** All standard 5e challenge rating strings, in ascending order. */
|
||||
export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP);
|
||||
|
||||
/** Returns the XP value for a given CR string. Returns 0 for unknown CRs. */
|
||||
export function crToXp(cr: string): number {
|
||||
return CR_TO_XP[cr] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates encounter difficulty from party levels and monster CRs.
|
||||
* Both arrays should be pre-filtered (only PCs with levels, only bestiary-linked monsters).
|
||||
*/
|
||||
export function calculateEncounterDifficulty(
|
||||
partyLevels: readonly number[],
|
||||
monsterCrs: readonly string[],
|
||||
): DifficultyResult {
|
||||
let budgetLow = 0;
|
||||
let budgetModerate = 0;
|
||||
let budgetHigh = 0;
|
||||
|
||||
for (const level of partyLevels) {
|
||||
const budget = XP_BUDGET_PER_CHARACTER[level];
|
||||
if (budget) {
|
||||
budgetLow += budget.low;
|
||||
budgetModerate += budget.moderate;
|
||||
budgetHigh += budget.high;
|
||||
}
|
||||
export interface CombatantDescriptor {
|
||||
readonly level?: number;
|
||||
readonly cr?: string;
|
||||
readonly side: "party" | "enemy";
|
||||
}
|
||||
|
||||
function determineTier(
|
||||
xp: number,
|
||||
tierThresholds: readonly number[],
|
||||
): DifficultyTier {
|
||||
for (let i = tierThresholds.length - 1; i >= 0; i--) {
|
||||
if (xp >= tierThresholds[i]) return (i + 1) as DifficultyTier;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function accumulateBudget5_5e(levels: readonly number[]) {
|
||||
const budget = { low: 0, moderate: 0, high: 0 };
|
||||
for (const level of levels) {
|
||||
const b = XP_BUDGET_PER_CHARACTER[level];
|
||||
if (b) {
|
||||
budget.low += b.low;
|
||||
budget.moderate += b.moderate;
|
||||
budget.high += b.high;
|
||||
}
|
||||
}
|
||||
return budget;
|
||||
}
|
||||
|
||||
function accumulateBudget2014(levels: readonly number[]) {
|
||||
const budget = { easy: 0, medium: 0, hard: 0, deadly: 0 };
|
||||
for (const level of levels) {
|
||||
const b = XP_THRESHOLDS_2014[level];
|
||||
if (b) {
|
||||
budget.easy += b.easy;
|
||||
budget.medium += b.medium;
|
||||
budget.hard += b.hard;
|
||||
budget.deadly += b.deadly;
|
||||
}
|
||||
}
|
||||
return budget;
|
||||
}
|
||||
|
||||
function scanCombatants(combatants: readonly CombatantDescriptor[]) {
|
||||
let totalMonsterXp = 0;
|
||||
for (const cr of monsterCrs) {
|
||||
totalMonsterXp += crToXp(cr);
|
||||
}
|
||||
let monsterCount = 0;
|
||||
const partyLevels: number[] = [];
|
||||
|
||||
let tier: DifficultyTier = "trivial";
|
||||
if (totalMonsterXp >= budgetHigh) {
|
||||
tier = "high";
|
||||
} else if (totalMonsterXp >= budgetModerate) {
|
||||
tier = "moderate";
|
||||
} else if (totalMonsterXp >= budgetLow) {
|
||||
tier = "low";
|
||||
for (const c of combatants) {
|
||||
if (c.level !== undefined && c.side === "party") {
|
||||
partyLevels.push(c.level);
|
||||
}
|
||||
if (c.cr !== undefined) {
|
||||
const xp = crToXp(c.cr);
|
||||
if (c.side === "enemy") {
|
||||
totalMonsterXp += xp;
|
||||
monsterCount++;
|
||||
} else {
|
||||
totalMonsterXp -= xp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tier,
|
||||
totalMonsterXp,
|
||||
partyBudget: {
|
||||
low: budgetLow,
|
||||
moderate: budgetModerate,
|
||||
high: budgetHigh,
|
||||
},
|
||||
totalMonsterXp: Math.max(0, totalMonsterXp),
|
||||
monsterCount,
|
||||
partyLevels,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates encounter difficulty from combatant descriptors.
|
||||
* Party-side combatants with level contribute to the budget.
|
||||
* Enemy-side combatants with CR add XP; party-side with CR subtract XP (floored at 0).
|
||||
*/
|
||||
export function calculateEncounterDifficulty(
|
||||
combatants: readonly CombatantDescriptor[],
|
||||
edition: RulesEdition,
|
||||
): DifficultyResult {
|
||||
const { totalMonsterXp, monsterCount, partyLevels } =
|
||||
scanCombatants(combatants);
|
||||
|
||||
if (edition === "5.5e") {
|
||||
const budget = accumulateBudget5_5e(partyLevels);
|
||||
const thresholds: DifficultyThreshold[] = [
|
||||
{ label: "Low", value: budget.low },
|
||||
{ label: "Moderate", value: budget.moderate },
|
||||
{ label: "High", value: budget.high },
|
||||
];
|
||||
return {
|
||||
tier: determineTier(totalMonsterXp, [
|
||||
budget.low,
|
||||
budget.moderate,
|
||||
budget.high,
|
||||
]),
|
||||
totalMonsterXp,
|
||||
thresholds,
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 2014 edition
|
||||
const budget = accumulateBudget2014(partyLevels);
|
||||
const { multiplier: encounterMultiplier, partySizeAdjusted } =
|
||||
getEncounterMultiplier(monsterCount, partyLevels.length);
|
||||
const adjustedXp = Math.round(totalMonsterXp * encounterMultiplier);
|
||||
const thresholds: DifficultyThreshold[] = [
|
||||
{ label: "Easy", value: budget.easy },
|
||||
{ label: "Medium", value: budget.medium },
|
||||
{ label: "Hard", value: budget.hard },
|
||||
{ label: "Deadly", value: budget.deadly },
|
||||
];
|
||||
|
||||
return {
|
||||
tier: determineTier(adjustedXp, [
|
||||
budget.medium,
|
||||
budget.hard,
|
||||
budget.deadly,
|
||||
]),
|
||||
totalMonsterXp,
|
||||
thresholds,
|
||||
encounterMultiplier,
|
||||
adjustedXp,
|
||||
partySizeAdjusted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,6 +94,20 @@ export interface AcSet {
|
||||
readonly newAc: number | undefined;
|
||||
}
|
||||
|
||||
export interface CrSet {
|
||||
readonly type: "CrSet";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly previousCr: string | undefined;
|
||||
readonly newCr: string | undefined;
|
||||
}
|
||||
|
||||
export interface SideSet {
|
||||
readonly type: "SideSet";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly previousSide: "party" | "enemy" | undefined;
|
||||
readonly newSide: "party" | "enemy";
|
||||
}
|
||||
|
||||
export interface ConditionAdded {
|
||||
readonly type: "ConditionAdded";
|
||||
readonly combatantId: CombatantId;
|
||||
@@ -153,6 +167,8 @@ export type DomainEvent =
|
||||
| TurnRetreated
|
||||
| RoundRetreated
|
||||
| AcSet
|
||||
| CrSet
|
||||
| SideSet
|
||||
| ConditionAdded
|
||||
| ConditionRemoved
|
||||
| ConcentrationStarted
|
||||
|
||||
@@ -16,7 +16,6 @@ export {
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
type RulesEdition,
|
||||
VALID_CONDITION_IDS,
|
||||
} from "./conditions.js";
|
||||
export {
|
||||
@@ -35,6 +34,8 @@ export {
|
||||
proficiencyBonus,
|
||||
type SpellcastingBlock,
|
||||
type TraitBlock,
|
||||
type TraitListItem,
|
||||
type TraitSegment,
|
||||
} from "./creature-types.js";
|
||||
export {
|
||||
type DeletePlayerCharacterSuccess,
|
||||
@@ -49,10 +50,13 @@ export {
|
||||
editPlayerCharacter,
|
||||
} from "./edit-player-character.js";
|
||||
export {
|
||||
type CombatantDescriptor,
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
type DifficultyResult,
|
||||
type DifficultyThreshold,
|
||||
type DifficultyTier,
|
||||
VALID_CR_VALUES,
|
||||
} from "./encounter-difficulty.js";
|
||||
export type {
|
||||
AcSet,
|
||||
@@ -63,6 +67,7 @@ export type {
|
||||
ConcentrationStarted,
|
||||
ConditionAdded,
|
||||
ConditionRemoved,
|
||||
CrSet,
|
||||
CurrentHpAdjusted,
|
||||
DomainEvent,
|
||||
EncounterCleared,
|
||||
@@ -73,6 +78,7 @@ export type {
|
||||
PlayerCharacterUpdated,
|
||||
RoundAdvanced,
|
||||
RoundRetreated,
|
||||
SideSet,
|
||||
TempHpSet,
|
||||
TurnAdvanced,
|
||||
TurnRetreated,
|
||||
@@ -106,12 +112,15 @@ export {
|
||||
rollInitiative,
|
||||
selectRoll,
|
||||
} from "./roll-initiative.js";
|
||||
export type { RulesEdition } from "./rules-edition.js";
|
||||
export { type SetAcSuccess, setAc } from "./set-ac.js";
|
||||
export { type SetCrSuccess, setCr } from "./set-cr.js";
|
||||
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
||||
export {
|
||||
type SetInitiativeSuccess,
|
||||
setInitiative,
|
||||
} from "./set-initiative.js";
|
||||
export { type SetSideSuccess, setSide } from "./set-side.js";
|
||||
export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js";
|
||||
export {
|
||||
type ToggleConcentrationSuccess,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import { VALID_CONDITION_IDS } from "./conditions.js";
|
||||
import { creatureId } from "./creature-types.js";
|
||||
import { VALID_CR_VALUES } from "./encounter-difficulty.js";
|
||||
import {
|
||||
playerCharacterId,
|
||||
VALID_PLAYER_COLORS,
|
||||
@@ -69,6 +70,20 @@ function validateNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function validateCr(value: unknown): string | undefined {
|
||||
return typeof value === "string" && VALID_CR_VALUES.includes(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const VALID_SIDES = new Set(["party", "enemy"]);
|
||||
|
||||
function validateSide(value: unknown): "party" | "enemy" | undefined {
|
||||
return typeof value === "string" && VALID_SIDES.has(value)
|
||||
? (value as "party" | "enemy")
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseOptionalFields(entry: Record<string, unknown>) {
|
||||
return {
|
||||
initiative: validateInteger(entry.initiative),
|
||||
@@ -78,6 +93,8 @@ function parseOptionalFields(entry: Record<string, unknown>) {
|
||||
creatureId: validateNonEmptyString(entry.creatureId)
|
||||
? creatureId(entry.creatureId as string)
|
||||
: undefined,
|
||||
cr: validateCr(entry.cr),
|
||||
side: validateSide(entry.side),
|
||||
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
|
||||
icon: validateSetMember(entry.icon, VALID_PLAYER_ICONS),
|
||||
playerCharacterId: validateNonEmptyString(entry.playerCharacterId)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user