Compare commits
226 Commits
42a07a07ff
...
0.9.41
| Author | SHA1 | Date | |
|---|---|---|---|
| d9fb271607 | |||
| 064af16f95 | |||
| 0f640601b6 | |||
| 4b1c1deda2 | |||
| 09a801487d | |||
| a44f82127e | |||
| c3707cf0b6 | |||
| 1eaeecad32 | |||
| e2e8297c95 | |||
| e161645228 | |||
| 9b0cb38897 | |||
| 5cb5721a6f | |||
| 48795071f7 | |||
| f721d7e5da | |||
| e7930a1431 | |||
| 553e09f280 | |||
| 1c107a500b | |||
| 0c235112ee | |||
| 57278e0c82 | |||
| f9cfaa2570 | |||
| 3e62e54274 | |||
| 12a089dfd7 | |||
| 65e4db153b | |||
| 8dbff66ce1 | |||
| e62c49434c | |||
| 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 | |||
| 1de00e3d8e | |||
| f4fb69dbc7 | |||
| ef76b9c90b | |||
| 36122b500b | |||
| f4355a8675 | |||
| 209df13c32 | |||
| 4969ed069b | |||
| fba83bebd6 | |||
| f6766b729d | |||
| f10c67a5ba | |||
| 9437272fe0 | |||
| 541e04b732 | |||
| e9fd896934 | |||
| 29cdd19cab | |||
| 17cc6ed72c | |||
| 9d81c8ad27 | |||
| 7199b9d2d9 | |||
| 158bcf1468 | |||
| fab9301b20 | |||
| d653cfe489 | |||
| 228a2603e8 | |||
| 27ff8ba1ad | |||
| 4cfcefe6c3 | |||
| 8baccf3cd3 | |||
| a9ca31e9bc | |||
| 64a1f0b8db | |||
| 5e5812bcaa | |||
| 9e09c8ae2a | |||
| 4d0ec0c7b2 | |||
| fe62f2eb2f | |||
| 7092677273 | |||
| e1a06c9d59 | |||
| 4043612ccf | |||
| cfd4aef724 | |||
| 968cc7239b | |||
| d9562f850c | |||
| ec9f2e7877 | |||
| c4079c384b | |||
| a4285fc415 | |||
| 9c0e3398f1 | |||
| 9cdf004c15 | |||
| 8bf69fd47d | |||
| 7b83e3c3ea | |||
| c3c2cad798 | |||
| 3f6140303d | |||
| fd30278474 | |||
| 278c06221f | |||
| 722e8cc627 | |||
| 64741956dd | |||
| 6336dec38a | |||
| 9def2d7c24 | |||
| f729e37689 | |||
| 86768842ff | |||
| 6584d8d064 | |||
| 7f38cbab73 | |||
| 2971898f0c | |||
| 43780772f6 | |||
| 7b3dbe2069 | |||
| 827a3978e9 | |||
| f024562a7d | |||
| dfef2194a5 | |||
| 502adca81b | |||
| 12e8bf6e69 | |||
| 472574ac31 | |||
| f4a7b53393 | |||
| 8aec460ee4 | |||
| 6e10238fe0 | |||
| b6e882add2 | |||
| 7a87d979bf | |||
| 02096bcee6 | |||
| c092192b0e | |||
| 4d1a7c6420 | |||
| 46b444caba | |||
| e68145319f | |||
| d64e1f5e4a | |||
| ef0b755eec | |||
| 4be816d10f | |||
| e531d82d1b | |||
| 5a262c66cd | |||
| 32b69f8df1 | |||
| 8efba288f7 | |||
| c94c30e459 | |||
| 36768d3aa1 | |||
| 473f1eaefe | |||
| 971e0ded49 | |||
| 36dcfc5076 | |||
| 127ed01064 | |||
| 179c3658ad | |||
| 01f2bb3ff1 | |||
| 930301de71 | |||
| aa806d4fb9 | |||
| 61bc274715 | |||
| 1932e837fb | |||
| cce87318fb | |||
| 3ef2370a34 | |||
| c75d148d1e | |||
| 63e233bd8d | |||
| 8c62ec28f2 | |||
| 72195e90f6 | |||
| 6ac8e67970 | |||
| a4797d5b15 | |||
| d48e39ced4 | |||
| b7406c4b54 | |||
| 07cdd4867a | |||
| 85acb5c185 | |||
| f9ef64bb00 | |||
| bd39808000 | |||
| 75778884bd | |||
| 72d4f30e60 | |||
| 96b37d4bdd | |||
| 76ca78c169 | |||
| b0c27b8ab9 | |||
| 458c277e9f | |||
| 91703ddebc | |||
| 768e7a390f | |||
| 7feaf90eab | |||
| b39e4923e1 | |||
| 369feb3cc8 | |||
| 51bdb799ae | |||
| 1baddad939 | |||
| e701e4dd70 | |||
| e2b0e7d5ee | |||
| 635e9c0705 | |||
| 582a42e62d | |||
| fc43f440aa | |||
| 1cf30b3622 | |||
| 2ce0ff50b9 | |||
| 96a7b2d00e | |||
| 2d8e823eff | |||
| 613bb70065 | |||
| b6e052f198 | |||
| 460c65bf49 | |||
| 95cb2edc23 | |||
| 55d322a727 | |||
| 0c903bc9a5 | |||
| 236c3bf64a | |||
| 0747d044f3 | |||
| d101906776 | |||
| 69363d4f7d | |||
| 47da942b73 | |||
| 94d125d9c4 | |||
| c323adc343 | |||
| 91120d7c82 | |||
| 99d1ba1bcd | |||
| f029c1a85b | |||
| d5f7b6ee36 | |||
| 5b0bac880d | |||
| c6349928eb | |||
| 24198c25f1 | |||
| 11c4c0237e | |||
| fa078be2f9 | |||
| 04a4f18f98 | |||
| 0c0da9b90e | |||
| e59fd83292 | |||
| febe892e15 | |||
| 78c6591973 | |||
| 2793a66672 | |||
| 56bced8481 | |||
| 97d3918cef | |||
| 7d440677be | |||
| a0d85a07e3 | |||
| 1c40bf7889 | |||
| 8185fde0e8 | |||
| a9c280a6d6 | |||
| c4a90c9982 | |||
| 0bbd6f27f9 | |||
| fea2bfe39d | |||
| a9df826fef | |||
| aed234de7b | |||
| 9d7b174867 | |||
| 0de68100c8 | |||
| 187f98fc52 | |||
| 2f7b4b82c1 | |||
| 4c2e0a47e6 |
@@ -0,0 +1,143 @@
|
|||||||
|
---
|
||||||
|
description: Fetch a Gitea issue, identify the affected feature spec(s), and integrate the issue's requirements into the spec. For new features, hands off to /speckit.specify.
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** provide an issue number as the argument (e.g. `/integrate-issue 6`). If `$ARGUMENTS` is empty or not a valid number, ask the user for the issue number.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Verify the `GITEA_TOKEN_ISSUES` environment variable is set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -n "$GITEA_TOKEN_ISSUES" && echo "TOKEN_OK" || echo "TOKEN_MISSING"
|
||||||
|
```
|
||||||
|
|
||||||
|
If missing, tell the user to set it:
|
||||||
|
```
|
||||||
|
export GITEA_TOKEN_ISSUES="your-gitea-personal-access-token"
|
||||||
|
```
|
||||||
|
Then abort.
|
||||||
|
|
||||||
|
2. Parse the git remote to extract the Gitea API base URL, owner, and repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --get remote.origin.url
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected format: `ssh://git@<host>:<port>/<owner>/<repo>.git` or `https://<host>/<owner>/<repo>.git`
|
||||||
|
|
||||||
|
Extract:
|
||||||
|
- `GITEA_HOST` — the hostname
|
||||||
|
- `OWNER` — the repo owner/org
|
||||||
|
- `REPO` — the repo name (strip `.git` suffix)
|
||||||
|
- `API_BASE` — `https://<GITEA_HOST>/api/v1`
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
### Step 1 — Fetch the issue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/issues/<NUMBER>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract from the JSON response:
|
||||||
|
- `title` — the issue title
|
||||||
|
- `body` — the issue body (markdown)
|
||||||
|
- `labels` — array of label names (if any)
|
||||||
|
|
||||||
|
If the API call fails or returns no issue, abort with a clear error.
|
||||||
|
|
||||||
|
### Step 2 — Fetch issue comments (if any)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/issues/<NUMBER>/comments"
|
||||||
|
```
|
||||||
|
|
||||||
|
If comments exist, include them as additional context (they may contain clarifications or requirements discussed after the issue was created).
|
||||||
|
|
||||||
|
### Step 3 — Route: new feature or existing feature?
|
||||||
|
|
||||||
|
List the existing feature specs by reading the `specs/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -d specs/*/
|
||||||
|
```
|
||||||
|
|
||||||
|
Present the issue summary and existing specs to the user. Ask:
|
||||||
|
|
||||||
|
**"Does this issue belong to an existing feature, or is it a new feature?"**
|
||||||
|
|
||||||
|
Present options:
|
||||||
|
- Each existing spec as a numbered option (show spec name and one-line description from CLAUDE.md or the spec's overview)
|
||||||
|
- A "New feature" option
|
||||||
|
|
||||||
|
If the user selects **New feature**, compose the feature description from the issue content (title + body + comments) and hand off to `/speckit.specify`. Stop here.
|
||||||
|
|
||||||
|
If the user selects an **existing spec**, continue to Step 4.
|
||||||
|
|
||||||
|
### Step 4 — Read the affected spec
|
||||||
|
|
||||||
|
Load the selected spec file. Identify the sections that the issue's requirements affect:
|
||||||
|
- Which user stories need updating?
|
||||||
|
- Which requirements (FR-NNN) need adding or modifying?
|
||||||
|
- Which acceptance scenarios change?
|
||||||
|
- Are new edge cases introduced?
|
||||||
|
|
||||||
|
Present your analysis to the user:
|
||||||
|
- **Stories affected**: list the story IDs/titles that need changes
|
||||||
|
- **New stories needed**: if the issue introduces behavior not covered by any existing story
|
||||||
|
- **Requirements to add/modify**: list specific FR numbers or new ones needed
|
||||||
|
|
||||||
|
Ask the user to confirm or adjust the scope.
|
||||||
|
|
||||||
|
### Step 5 — Draft spec changes
|
||||||
|
|
||||||
|
For each affected section, draft the specific changes:
|
||||||
|
|
||||||
|
- **Modified stories**: show the before/after for acceptance scenarios
|
||||||
|
- **New stories**: write them in the spec's format (matching the existing story naming convention — e.g., `**Story HP-7**` for combatant-state, `**Story A4**` for combatant-management)
|
||||||
|
- **New/modified requirements**: write them with the next available FR number
|
||||||
|
- **New edge cases**: add to the relevant edge cases section
|
||||||
|
|
||||||
|
For per-topic specs (003-combatant-state, 004-bestiary), place changes in the correct topic section.
|
||||||
|
|
||||||
|
### Step 6 — Preview and confirm
|
||||||
|
|
||||||
|
Show the user a complete preview of all changes:
|
||||||
|
- Which file(s) will be modified
|
||||||
|
- The exact additions/modifications (as diffs or before/after blocks)
|
||||||
|
|
||||||
|
Ask for confirmation before writing.
|
||||||
|
|
||||||
|
### Step 7 — Write changes
|
||||||
|
|
||||||
|
On confirmation:
|
||||||
|
- Write the updated spec file(s)
|
||||||
|
- Report what was changed (sections touched, stories added/modified, requirements added)
|
||||||
|
|
||||||
|
### Step 8 — Suggest next steps
|
||||||
|
|
||||||
|
Report completion and suggest next steps based on scope:
|
||||||
|
|
||||||
|
- **Straightforward change** (1-2 stories, clear acceptance scenarios): "Implement the changes and commit"
|
||||||
|
- **Larger change** (multiple stories, cross-cutting concerns): "Use `rpi-research` to investigate the affected code, then `rpi-plan` to create a phased implementation plan, then `rpi-implement` to execute it"
|
||||||
|
- **Complex or ambiguous change**: "Run `/speckit.clarify` to resolve remaining ambiguities before implementing"
|
||||||
|
- Only if the spec adds substantive new criteria not already captured in the issue: "Run `/sync-issue <number>` to update the Gitea issue with the new acceptance criteria". Skip this if the spec merely reformulates what the issue already says into Given/When/Then format.
|
||||||
|
|
||||||
|
## Behavior Rules
|
||||||
|
|
||||||
|
- Never modify the issue on Gitea — this is a read-only operation on the issue side.
|
||||||
|
- Always preview before writing spec changes — never write without user confirmation.
|
||||||
|
- Include comment authors in the context so requirements can be attributed.
|
||||||
|
- If the issue body is empty, warn the user but still proceed with just the title.
|
||||||
|
- Strip HTML tags from the body/comments if present (Gitea sometimes includes rendered HTML).
|
||||||
|
- Use `curl` for all API calls — do not rely on `gh` CLI.
|
||||||
|
- Match the existing spec's naming conventions for stories, requirements, and structure.
|
||||||
|
- When adding to per-topic specs (003, 004), place content in the correct topic section — do not create new top-level sections unless the change introduces an entirely new topic area.
|
||||||
|
- Increment FR/SC numbers from the highest existing number in the spec.
|
||||||
@@ -75,14 +75,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
|
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
|
||||||
- Skip if project is purely internal (build scripts, one-off tools, etc.)
|
- Skip if project is purely internal (build scripts, one-off tools, etc.)
|
||||||
|
|
||||||
3. **Agent context update**:
|
**Output**: data-model.md, /contracts/*, quickstart.md
|
||||||
- Run `.specify/scripts/bash/update-agent-context.sh claude`
|
|
||||||
- These scripts detect which AI agent is in use
|
|
||||||
- Update the appropriate agent-specific context file
|
|
||||||
- Add only new technology from current plan
|
|
||||||
- Preserve manual additions between markers
|
|
||||||
|
|
||||||
**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file
|
|
||||||
|
|
||||||
## Key rules
|
## Key rules
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
---
|
||||||
|
description: Update a Gitea issue with business-level acceptance criteria extracted from the feature spec's user stories.
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** provide an issue number as the argument (e.g. `/sync-issue 42`). If `$ARGUMENTS` is empty or not a valid number, ask the user for the issue number.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Verify the `GITEA_TOKEN_ISSUES` environment variable is set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -n "$GITEA_TOKEN_ISSUES" && echo "TOKEN_OK" || echo "TOKEN_MISSING"
|
||||||
|
```
|
||||||
|
|
||||||
|
If missing, tell the user to set it:
|
||||||
|
```
|
||||||
|
export GITEA_TOKEN_ISSUES="your-gitea-personal-access-token"
|
||||||
|
```
|
||||||
|
Then abort.
|
||||||
|
|
||||||
|
2. Parse the git remote to extract the Gitea API base URL, owner, and repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --get remote.origin.url
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected format: `ssh://git@<host>:<port>/<owner>/<repo>.git` or `https://<host>/<owner>/<repo>.git`
|
||||||
|
|
||||||
|
Extract:
|
||||||
|
- `GITEA_HOST` — the hostname
|
||||||
|
- `OWNER` — the repo owner/org
|
||||||
|
- `REPO` — the repo name (strip `.git` suffix)
|
||||||
|
- `API_BASE` — `https://<GITEA_HOST>/api/v1`
|
||||||
|
|
||||||
|
3. Locate the spec file. List the available feature specs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls specs/*/spec.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Present the specs to the user and ask which one contains the acceptance criteria for this issue. If only one spec exists, use it automatically.
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
### Step 1 — Read the spec
|
||||||
|
|
||||||
|
Load the spec file at `FEATURE_SPEC`. Extract user stories and acceptance scenarios using these patterns:
|
||||||
|
|
||||||
|
**Flat specs** (001-combatant-management, 002-turn-tracking):
|
||||||
|
- Look for the `## User Scenarios & Testing` section
|
||||||
|
- Each `### ... Story ...` or `**Story ...** ` block
|
||||||
|
- The **Acceptance Scenarios** numbered list within each story (Given/When/Then format)
|
||||||
|
- The **Edge Cases** section(s)
|
||||||
|
|
||||||
|
**Per-topic specs** (003-combatant-state, 004-bestiary):
|
||||||
|
- Stories are nested inside topic sections (e.g., `## Hit Points` > `### User Stories` > `**Story HP-1 — ...**`)
|
||||||
|
- Scan ALL `##` sections for `**Story ...` or `**US-...` patterns
|
||||||
|
- Extract acceptance scenarios from each story regardless of nesting depth
|
||||||
|
- Collect edge cases from each topic section's `### Edge Cases` subsection
|
||||||
|
|
||||||
|
### Step 2 — Condense into business-level acceptance criteria
|
||||||
|
|
||||||
|
For each user story, extract the acceptance scenarios and rewrite them as concise, business-level checkbox items. Group by user story title.
|
||||||
|
|
||||||
|
**Transformation rules:**
|
||||||
|
- Strip Given/When/Then syntax — write as plain outcomes
|
||||||
|
- Remove implementation details (API names, database references, component names, file paths, config values, tool names)
|
||||||
|
- Focus on what the user **can do** or **can see**
|
||||||
|
- Keep each item to one line
|
||||||
|
- Preserve the grouping by user story for readability
|
||||||
|
|
||||||
|
**Example transformation:**
|
||||||
|
|
||||||
|
Input (from spec):
|
||||||
|
```
|
||||||
|
**Given** no sources are cached, **When** the user clicks the import button in the top bar,
|
||||||
|
**Then** the stat block side panel opens showing a descriptive explanation, an editable
|
||||||
|
pre-filled base URL, and a "Load All" button.
|
||||||
|
```
|
||||||
|
|
||||||
|
Output (for issue):
|
||||||
|
```
|
||||||
|
- [ ] Clicking the import button opens a panel with a description, editable URL, and "Load All" button
|
||||||
|
```
|
||||||
|
|
||||||
|
Also include edge cases as a separate group if they describe user-facing behavior.
|
||||||
|
|
||||||
|
### Step 3 — Fetch the existing issue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/issues/<NUMBER>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract the current `body` from the response.
|
||||||
|
|
||||||
|
### Step 4 — Update the issue body
|
||||||
|
|
||||||
|
Merge the acceptance criteria into the existing issue body:
|
||||||
|
|
||||||
|
- If the body already has an `## Acceptance Criteria` section, **replace** its contents (everything between `## Acceptance Criteria` and the next `##` heading or end of body).
|
||||||
|
- If the body does not have an `## Acceptance Criteria` section, insert it after the `## Summary` section (or at the end if no Summary exists).
|
||||||
|
|
||||||
|
Preserve all other sections of the issue body unchanged.
|
||||||
|
|
||||||
|
The acceptance criteria section should look like:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### <User Story 1 Title>
|
||||||
|
- [ ] <criterion from acceptance scenario>
|
||||||
|
- [ ] <criterion from acceptance scenario>
|
||||||
|
|
||||||
|
### <User Story 2 Title>
|
||||||
|
- [ ] <criterion from acceptance scenario>
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- [ ] <edge case behavior>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5 — Preview and confirm
|
||||||
|
|
||||||
|
Show the user:
|
||||||
|
- The full updated issue body
|
||||||
|
- A diff summary of what changed (sections added/replaced)
|
||||||
|
|
||||||
|
Ask for confirmation before updating.
|
||||||
|
|
||||||
|
### Step 6 — Push the update
|
||||||
|
|
||||||
|
On confirmation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sf -X PATCH \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN_ISSUES" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"$API_BASE/repos/$OWNER/$REPO/issues/<NUMBER>" \
|
||||||
|
-d @- <<'PAYLOAD'
|
||||||
|
{
|
||||||
|
"body": "<updated body>"
|
||||||
|
}
|
||||||
|
PAYLOAD
|
||||||
|
```
|
||||||
|
|
||||||
|
Report success with the issue URL.
|
||||||
|
|
||||||
|
## Behavior Rules
|
||||||
|
|
||||||
|
- Never modify the issue title, labels, milestone, or assignees — only the body.
|
||||||
|
- Always preview before updating — never push without user confirmation.
|
||||||
|
- If the spec has no user stories or acceptance scenarios, abort with a clear message suggesting the user run `/speckit.specify` first.
|
||||||
|
- Acceptance criteria must be business-level. If you find yourself writing implementation details, rewrite at a higher level of abstraction.
|
||||||
|
- Do NOT sync when the issue's existing acceptance criteria already capture the same requirements as the spec. The spec's Given/When/Then format is not needed in the issue — if the only difference is formatting, skip the sync and tell the user the criteria already align.
|
||||||
|
- Use `curl` for all API calls — do not rely on `gh` CLI.
|
||||||
|
- Always use HEREDOC for the JSON payload to handle special characters in the body.
|
||||||
|
- Escape double quotes and newlines properly in the JSON body.
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
---
|
||||||
|
description: Interactive interview to create a well-structured Gitea issue with business-level acceptance criteria.
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Verify the `GITEA_TOKEN_ISSUES` environment variable is set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -n "$GITEA_TOKEN_ISSUES" && echo "TOKEN_OK" || echo "TOKEN_MISSING"
|
||||||
|
```
|
||||||
|
|
||||||
|
If missing, tell the user to set it:
|
||||||
|
```
|
||||||
|
export GITEA_TOKEN_ISSUES="your-gitea-personal-access-token"
|
||||||
|
```
|
||||||
|
Then abort.
|
||||||
|
|
||||||
|
2. Parse the git remote to extract the Gitea base URL, owner, and repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --get remote.origin.url
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected format: `ssh://git@<host>:<port>/<owner>/<repo>.git` or `https://<host>/<owner>/<repo>.git`
|
||||||
|
|
||||||
|
Extract:
|
||||||
|
- `GITEA_HOST` — the hostname (e.g. `git.bahamut.nitrix.one`)
|
||||||
|
- `GITEA_PORT` — the port if present in SSH URL (for API, always use HTTPS on default port)
|
||||||
|
- `OWNER` — the repo owner/org
|
||||||
|
- `REPO` — the repo name (strip `.git` suffix)
|
||||||
|
- `API_BASE` — `https://<GITEA_HOST>/api/v1`
|
||||||
|
|
||||||
|
Verify the remote is reachable:
|
||||||
|
```bash
|
||||||
|
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO" | head -c 200
|
||||||
|
```
|
||||||
|
|
||||||
|
If this fails, abort with an error explaining the API is unreachable.
|
||||||
|
|
||||||
|
## Interview Flow
|
||||||
|
|
||||||
|
### Step 1 — What's the feature?
|
||||||
|
|
||||||
|
If `$ARGUMENTS` is not empty, use it as the initial feature description and skip asking.
|
||||||
|
|
||||||
|
Otherwise, ask the user: **"What feature or change do you want to build?"**
|
||||||
|
|
||||||
|
Accept a free-form description (1-3 sentences is fine).
|
||||||
|
|
||||||
|
### Step 2 — Contextual business questions
|
||||||
|
|
||||||
|
Analyze the feature description and identify what's ambiguous or underspecified **for this specific feature**. Generate 1-3 targeted questions that would materially improve the issue.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Questions MUST be derived from the specific feature description, not from a generic checklist.
|
||||||
|
- Only ask questions whose answers change the scope, behavior, or acceptance criteria.
|
||||||
|
- If the feature is straightforward and well-described, skip this step entirely.
|
||||||
|
- Present one question at a time using the AskUserQuestion tool.
|
||||||
|
- For each question, provide 2-4 concrete options plus the ability to give a custom answer.
|
||||||
|
- After each answer, decide if you need another question or have enough clarity.
|
||||||
|
|
||||||
|
Examples of good contextual questions:
|
||||||
|
- For a "color overhaul" feature: "Are you targeting specific components or a global palette change?"
|
||||||
|
- For a "bulk import" feature: "Should the user see progress during import or just a final result?"
|
||||||
|
- For an "initiative tiebreaker" feature: "Should ties be broken by DEX score, manual choice, or random roll?"
|
||||||
|
|
||||||
|
Examples of bad questions (never ask these):
|
||||||
|
- Generic: "Who is the target user?"
|
||||||
|
- Obvious: "Should errors be handled?"
|
||||||
|
- Implementation: "What database should we use?"
|
||||||
|
|
||||||
|
### Step 3 — Acceptance criteria
|
||||||
|
|
||||||
|
Based on the description and clarifications, draft 3-8 business-level acceptance criteria. These should describe **what the user can do or see**, not how the system implements it.
|
||||||
|
|
||||||
|
Present the draft to the user and ask if they want to add, remove, or change any.
|
||||||
|
|
||||||
|
Good: "User can load all sources with a single click"
|
||||||
|
Bad: "System fires concurrent fetch requests to IndexedDB"
|
||||||
|
|
||||||
|
### Step 4 — Labels and milestone (optional)
|
||||||
|
|
||||||
|
Fetch available labels and milestones from the Gitea API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/labels"
|
||||||
|
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/milestones"
|
||||||
|
```
|
||||||
|
|
||||||
|
If labels/milestones exist, present them to the user and ask which (if any) to apply. If none exist or the API returns empty, skip this step.
|
||||||
|
|
||||||
|
### Step 5 — Branch linking
|
||||||
|
|
||||||
|
Check if we're on a feature branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git branch --show-current
|
||||||
|
```
|
||||||
|
|
||||||
|
If the current branch is not `main` (and looks like a feature branch, e.g. `NNN-feature-name`), offer to include it in the issue body as a linked branch.
|
||||||
|
|
||||||
|
### Step 6 — Preview and create
|
||||||
|
|
||||||
|
Compose the issue body using this template:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<2-3 sentence description synthesized from the interview>
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] <criterion 1>
|
||||||
|
- [ ] <criterion 2>
|
||||||
|
- [ ] <criterion 3>
|
||||||
|
...
|
||||||
|
|
||||||
|
## Branch
|
||||||
|
|
||||||
|
`<branch-name>` *(if linked in step 5)*
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the full preview (title + body) to the user and ask for confirmation.
|
||||||
|
|
||||||
|
On confirmation, create the issue:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN_ISSUES" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"$API_BASE/repos/$OWNER/$REPO/issues" \
|
||||||
|
-d @- <<'PAYLOAD'
|
||||||
|
{
|
||||||
|
"title": "<issue title>",
|
||||||
|
"body": "<issue body>",
|
||||||
|
"labels": [<label IDs if selected>],
|
||||||
|
"milestone": <milestone ID if selected or null>
|
||||||
|
}
|
||||||
|
PAYLOAD
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse the response and report:
|
||||||
|
- Issue number and URL (`https://<GITEA_HOST>/<OWNER>/<REPO>/issues/<number>`)
|
||||||
|
- Suggest next step: `/integrate-issue <number>` to integrate into a feature spec
|
||||||
|
|
||||||
|
## Behavior Rules
|
||||||
|
|
||||||
|
- Keep the title short — under 70 characters.
|
||||||
|
- Acceptance criteria must be business-level, not implementation details.
|
||||||
|
- Never create an issue without user confirmation of the preview.
|
||||||
|
- If any API call fails, show the error and suggest the user check their token permissions.
|
||||||
|
- Use `curl` for all API calls — do not rely on `gh` CLI.
|
||||||
|
- Always use HEREDOC for the JSON payload to handle special characters in the body.
|
||||||
|
- Escape double quotes and newlines properly in the JSON body.
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
---
|
||||||
|
name: browser-interactive-testing
|
||||||
|
description: >
|
||||||
|
This skill should be used when the user asks to "test a web page",
|
||||||
|
"take a screenshot of a site", "automate browser interaction",
|
||||||
|
"create a test report", "verify a page works", or mentions
|
||||||
|
rodney, showboat, headless Chrome testing, or browser automation.
|
||||||
|
version: 0.1.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Browser Interactive Testing
|
||||||
|
|
||||||
|
Test web pages interactively using **rodney** (headless Chrome automation) and document results with **showboat** (executable demo reports).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Ensure `uv` is installed. If missing, instruct the user to run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT install rodney or showboat globally. Run them via `uvx`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx rodney <command>
|
||||||
|
uvx showboat <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rodney Quick Reference
|
||||||
|
|
||||||
|
### Start a browser session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx rodney start # Launch headless Chrome
|
||||||
|
uvx rodney start --show # Launch visible browser (for debugging)
|
||||||
|
uvx rodney connect host:port # Connect to existing Chrome with remote debugging
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--local` on all commands to scope the session to the current directory.
|
||||||
|
|
||||||
|
### Navigate and inspect
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx rodney open "https://example.com"
|
||||||
|
uvx rodney waitload
|
||||||
|
uvx rodney title
|
||||||
|
uvx rodney url
|
||||||
|
uvx rodney text "h1"
|
||||||
|
uvx rodney html "#content"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interact with elements
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx rodney click "#submit-btn"
|
||||||
|
uvx rodney input "#email" "user@example.com"
|
||||||
|
uvx rodney select "#country" "US"
|
||||||
|
uvx rodney js "document.querySelector('#app').dataset.ready"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Assert and verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx rodney assert "document.title" "My App" -m "Title must match"
|
||||||
|
uvx rodney exists ".error-banner"
|
||||||
|
uvx rodney visible "#loading-spinner"
|
||||||
|
uvx rodney count ".list-item"
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code `0` = pass, `1` = fail, `2` = error.
|
||||||
|
|
||||||
|
### Screenshots and cleanup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx rodney screenshot -w 1280 -h 720 page.png
|
||||||
|
uvx rodney screenshot-el "#chart" chart.png
|
||||||
|
uvx rodney stop
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `uvx rodney --help` for the full command list, including tab management, navigation, waiting, accessibility tree inspection, and PDF export.
|
||||||
|
|
||||||
|
## Showboat Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx showboat init report.md "Test Report Title"
|
||||||
|
uvx showboat note report.md "Description of what we are testing."
|
||||||
|
uvx showboat exec report.md bash "uvx rodney title --local"
|
||||||
|
uvx showboat image report.md ''
|
||||||
|
uvx showboat pop report.md # Remove last entry (fix mistakes)
|
||||||
|
uvx showboat verify report.md # Re-run all code blocks and diff
|
||||||
|
uvx showboat extract report.md # Print commands that recreate the document
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `uvx showboat --help` for details on `--workdir`, `--output`, `--filename`, and stdin piping.
|
||||||
|
|
||||||
|
## Output Directory
|
||||||
|
|
||||||
|
Save all reports under `.agent-tests/` in the project root:
|
||||||
|
|
||||||
|
```
|
||||||
|
.agent-tests/
|
||||||
|
└── YYYY-MM-DD-<slug>/
|
||||||
|
├── report.md
|
||||||
|
└── screenshots/
|
||||||
|
```
|
||||||
|
|
||||||
|
Derive the slug from the test subject (e.g., `login-flow`, `homepage-layout`). Keep it lowercase, hyphen-separated, max ~30 chars. If a directory with the same date and slug already exists, append a numeric suffix (e.g., `tetris-game-2`) or choose a more specific slug (e.g., `tetris-controls` instead of reusing `tetris-game`).
|
||||||
|
|
||||||
|
### Setup Script
|
||||||
|
|
||||||
|
Run the bundled `scripts/setup.py` to create the directory, init the report, start the browser, and capture `DIR` in one step. Replace `<SKILL_DIR>` with the actual path to the directory containing this skill's files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DIR=$(python3 <SKILL_DIR>/scripts/setup.py "<slug>" "<Report Title>")
|
||||||
|
```
|
||||||
|
|
||||||
|
This single command:
|
||||||
|
1. Creates `.agent-tests/YYYY-MM-DD-<slug>/screenshots/`
|
||||||
|
2. Adds `.rodney/` to `.gitignore` (if `.gitignore` exists)
|
||||||
|
3. Runs `showboat init` for the report
|
||||||
|
4. Starts a browser (connects to existing, launches system Chrome/Chromium, or falls back to rodney's built-in launcher)
|
||||||
|
5. Prints the directory path to stdout (all status messages go to stderr)
|
||||||
|
|
||||||
|
After setup, `$DIR` is ready for use with all subsequent commands.
|
||||||
|
|
||||||
|
**Important:** The `--local` flag stores session data in `.rodney/` relative to the current working directory. Do NOT `cd` to a different directory during the session, or rodney will lose the connection. Use absolute paths for file arguments instead.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Setup** — Run the setup script to create the dir, init the report, start the browser, and set `$DIR`
|
||||||
|
2. **Describe the test** — `uvx showboat note "$DIR/report.md" "Testing [subject] for [goals]."` so the report has context up front
|
||||||
|
3. **Open page** — `uvx showboat exec "$DIR/report.md" bash "uvx rodney open --local 'URL' && uvx rodney waitload --local"`
|
||||||
|
4. **Add a note** before each test group — Use a heading followed by a short explanation of what the tests in this section verify and why it matters. Use unique section titles; avoid duplicating headings within the same report.
|
||||||
|
```bash
|
||||||
|
uvx showboat note "$DIR/report.md" "## Keyboard Controls"
|
||||||
|
uvx showboat note "$DIR/report.md" "Verify arrow keys move and rotate the active piece, and that soft/hard drop work correctly."
|
||||||
|
```
|
||||||
|
5. **Run assertions** — Before each assertion, add a short `showboat note` explaining what it checks. Then wrap the `rodney assert` / `rodney js` call in `showboat exec`:
|
||||||
|
```bash
|
||||||
|
uvx showboat note "$DIR/report.md" "The left arrow key should move the piece one cell to the left."
|
||||||
|
uvx showboat exec "$DIR/report.md" bash "uvx rodney assert --local '...' '...' -m 'Piece moved left'"
|
||||||
|
```
|
||||||
|
6. **Capture screenshots** — Take the screenshot with `rodney screenshot`, then embed with `showboat image`. **Important:** `showboat image` resolves image paths relative to the current working directory, NOT relative to the report file. Always use absolute paths (`$DIR/screenshots/...`) in the markdown image reference to avoid "image file not found" errors:
|
||||||
|
```bash
|
||||||
|
uvx rodney screenshot --local -w 1280 -h 720 "$DIR/screenshots/01-initial-load.png"
|
||||||
|
uvx showboat image "$DIR/report.md" ""
|
||||||
|
```
|
||||||
|
Number screenshots sequentially (`01-`, `02-`, ...) and use descriptive filenames.
|
||||||
|
7. **Pop on failure** — If a command fails, run `showboat pop` then retry
|
||||||
|
8. **Stop browser** — `uvx rodney stop --local`
|
||||||
|
9. **Write summary** — Add a final `showboat note` with a summary section listing all pass/fail results and any bugs found. Every report must end with a summary.
|
||||||
|
10. **Verify report** — `uvx showboat verify "$DIR/report.md"`
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
- Use `uvx rodney waitload` or `uvx rodney wait <selector>` before interacting with page content.
|
||||||
|
- Run `uvx showboat pop` immediately after a failed `exec` to keep the report clean.
|
||||||
|
- Prefer `rodney assert` for checks — clear exit codes and self-documenting output.
|
||||||
|
- Use `rodney js` only for complex checks or state manipulation that `assert` cannot express.
|
||||||
|
- Take screenshots at key stages (initial load, after interaction, error states) for visual evidence.
|
||||||
|
- Add a `showboat note` before each logical group of tests with a heading and a short explanation of what the section tests. Use unique heading titles — duplicate headings make the report confusing.
|
||||||
|
- Always end reports with a summary `showboat note` listing pass/fail results and any bugs found. This is required, not optional.
|
||||||
|
|
||||||
|
## Quoting Rules for `rodney js`
|
||||||
|
|
||||||
|
`rodney js` evaluates a single JS **expression** (not statements). Nested shell quoting with `showboat exec` causes most errors. Follow these rules strictly:
|
||||||
|
|
||||||
|
1. **Wrap multi-statement JS in an IIFE** — bare `const`, `let`, `for` fail at top level:
|
||||||
|
```bash
|
||||||
|
# WRONG
|
||||||
|
uvx rodney js --local 'const x = 1; x + 2'
|
||||||
|
# CORRECT
|
||||||
|
uvx rodney js --local '(function(){ var x = 1; return x + 2; })()'
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use `var` instead of `const`/`let`** inside IIFEs to avoid strict-mode eval scoping issues.
|
||||||
|
|
||||||
|
3. **Direct `rodney js` calls** — use single quotes for the outer shell, double quotes inside JS:
|
||||||
|
```bash
|
||||||
|
uvx rodney js --local '(function(){ var el = document.querySelector("#app"); return el.textContent; })()'
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Inside `showboat exec`** — use a heredoc with a **quoted delimiter** (`<<'JSEOF'`) to prevent all shell expansion (`$`, backticks, etc.):
|
||||||
|
```bash
|
||||||
|
uvx showboat exec "$DIR/report.md" bash "$(cat <<'JSEOF'
|
||||||
|
uvx rodney js --local '
|
||||||
|
(function(){
|
||||||
|
var x = score;
|
||||||
|
hardDrop();
|
||||||
|
return "before:" + x + ",after:" + score;
|
||||||
|
})()
|
||||||
|
'
|
||||||
|
JSEOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
For simple one-liners, single quotes inside the double-quoted bash arg also work:
|
||||||
|
```bash
|
||||||
|
uvx showboat exec "$DIR/report.md" bash "uvx rodney js --local '(function(){ return String(score); })()'"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Avoid without heredoc**: backticks, `$` signs, unescaped double quotes. The heredoc pattern avoids all of these.
|
||||||
|
|
||||||
|
6. **Prefer `rodney assert` over `rodney js`** when possible — separate arguments avoid quoting entirely.
|
||||||
|
|
||||||
|
7. **Pop after syntax errors** — always `showboat pop` before retrying to keep the report clean.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Set up a browser-interactive-testing session.
|
||||||
|
|
||||||
|
Creates the output directory, inits the showboat report, starts a browser,
|
||||||
|
and prints the DIR path. Automatically detects whether rodney can launch
|
||||||
|
its own Chromium or falls back to a system-installed browser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
REMOTE_DEBUG_PORT = 9222
|
||||||
|
|
||||||
|
|
||||||
|
def find_system_browser():
|
||||||
|
"""Return the path to a system Chrome/Chromium binary, or None."""
|
||||||
|
for name in ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable"]:
|
||||||
|
path = shutil.which(name)
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def port_listening(port):
|
||||||
|
"""Check if something is already listening on the given port."""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.settimeout(1)
|
||||||
|
return s.connect_ex(("localhost", port)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def try_connect(port):
|
||||||
|
"""Try to connect rodney to a browser on the given port. Returns True on success."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["uvx", "rodney", "connect", "--local", f"localhost:{port}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(f"Connected to existing browser on port {port}", file=sys.stderr)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def launch_system_browser(browser_path):
|
||||||
|
"""Launch a system browser with remote debugging and wait for it to be ready."""
|
||||||
|
subprocess.Popen(
|
||||||
|
[
|
||||||
|
browser_path,
|
||||||
|
"--headless",
|
||||||
|
"--disable-gpu",
|
||||||
|
f"--remote-debugging-port={REMOTE_DEBUG_PORT}",
|
||||||
|
"--no-sandbox",
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
# Wait for the browser to start listening
|
||||||
|
for _ in range(20):
|
||||||
|
if port_listening(REMOTE_DEBUG_PORT):
|
||||||
|
return True
|
||||||
|
time.sleep(0.25)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def start_browser():
|
||||||
|
"""Start a headless browser and connect rodney to it.
|
||||||
|
|
||||||
|
Strategy order (fastest path first):
|
||||||
|
1. Connect to an already-running browser on the debug port.
|
||||||
|
2. Launch a system Chrome/Chromium (avoids rodney's Chromium download,
|
||||||
|
which fails on some architectures like Linux ARM64).
|
||||||
|
3. Let rodney launch its own browser as a last resort.
|
||||||
|
"""
|
||||||
|
# Strategy 1: connect to an already-running browser
|
||||||
|
if port_listening(REMOTE_DEBUG_PORT) and try_connect(REMOTE_DEBUG_PORT):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Strategy 2: launch a system browser (most reliable on Linux)
|
||||||
|
browser = find_system_browser()
|
||||||
|
if browser:
|
||||||
|
print(f"Launching system browser: {browser}", file=sys.stderr)
|
||||||
|
if launch_system_browser(browser):
|
||||||
|
if try_connect(REMOTE_DEBUG_PORT):
|
||||||
|
return
|
||||||
|
print("WARNING: system browser started but rodney could not connect", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print("WARNING: system browser did not start in time", file=sys.stderr)
|
||||||
|
|
||||||
|
# Strategy 3: let rodney try its built-in launcher
|
||||||
|
result = subprocess.run(
|
||||||
|
["uvx", "rodney", "start", "--local"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print("Browser started via rodney", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
print(
|
||||||
|
"ERROR: Could not start a browser. Tried:\n"
|
||||||
|
f" - Connecting to localhost:{REMOTE_DEBUG_PORT} (no browser found)\n"
|
||||||
|
f" - System browser: {browser or 'not found'}\n"
|
||||||
|
" - rodney start (failed)\n"
|
||||||
|
"Install chromium or google-chrome and try again.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_gitignore_entry(entry):
|
||||||
|
"""Add entry to .gitignore if the file exists and the entry is missing."""
|
||||||
|
gitignore = ".gitignore"
|
||||||
|
if not os.path.isfile(gitignore):
|
||||||
|
return
|
||||||
|
with open(gitignore, "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
# Check if the entry (with or without trailing slash/newline variations) is already present
|
||||||
|
lines = content.splitlines()
|
||||||
|
if any(line.strip() == entry or line.strip() == entry.rstrip("/") for line in lines):
|
||||||
|
return
|
||||||
|
# Append the entry
|
||||||
|
with open(gitignore, "a") as f:
|
||||||
|
if content and not content.endswith("\n"):
|
||||||
|
f.write("\n")
|
||||||
|
f.write(f"{entry}\n")
|
||||||
|
print(f"Added '{entry}' to .gitignore", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(f"Usage: {sys.argv[0]} <slug> <report-title>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
slug = sys.argv[1]
|
||||||
|
title = sys.argv[2]
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
d = f".agent-tests/{datetime.date.today()}-{slug}"
|
||||||
|
os.makedirs(f"{d}/screenshots", exist_ok=True)
|
||||||
|
|
||||||
|
# Ensure .rodney/ is in .gitignore (rodney stores session files there)
|
||||||
|
ensure_gitignore_entry(".rodney/")
|
||||||
|
|
||||||
|
# Init showboat report
|
||||||
|
subprocess.run(["uvx", "showboat", "init", f"{d}/report.md", title], check=True)
|
||||||
|
|
||||||
|
# Start browser
|
||||||
|
start_browser()
|
||||||
|
|
||||||
|
# Print the directory path (only real stdout, everything else goes to stderr)
|
||||||
|
print(d)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
name: commit
|
||||||
|
description: Create a git commit with pre-commit hooks (bypasses sandbox restrictions).
|
||||||
|
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.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: rpi-implement
|
||||||
|
description: Execute approved implementation plans phase by phase with automated and manual verification. Use when the user explicitly says "implement the plan", "execute the plan", or "start implementing" and has a plan file ready. Do not use for ad-hoc coding tasks without a plan.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Implement Plan
|
||||||
|
|
||||||
|
You are tasked with implementing an approved technical plan. These plans contain phases with specific changes and success criteria.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
If the user provided a plan path, proceed directly. If no plan path was provided, check `docs/agents/plans/` for recent plans. If none found, ask the user for a path.
|
||||||
|
|
||||||
|
When you have a plan:
|
||||||
|
- Read the plan completely and check for any existing checkmarks (`- [x]`)
|
||||||
|
- Read all files mentioned in the plan
|
||||||
|
- **Read files fully** - never use limit/offset parameters, you need complete context
|
||||||
|
- Think deeply about how the pieces fit together
|
||||||
|
- If you have a todo list, use it to track your progress
|
||||||
|
- Start implementing if you understand what needs to be done
|
||||||
|
|
||||||
|
## Implementation Philosophy
|
||||||
|
|
||||||
|
Plans are carefully designed, but reality can be messy. Your job is to:
|
||||||
|
- Follow the plan's intent while adapting to what you find
|
||||||
|
- Implement each phase fully before moving to the next
|
||||||
|
- Verify your work makes sense in the broader codebase context
|
||||||
|
- Keep plan checkboxes current: `[-]` before starting an item, `[x]` right after it passes verification. Never batch updates.
|
||||||
|
|
||||||
|
When things don't match the plan exactly, think about why and communicate clearly. The plan is your guide, but your judgment matters too.
|
||||||
|
|
||||||
|
If you encounter a mismatch:
|
||||||
|
- STOP and think deeply about why the plan can't be followed
|
||||||
|
- Present the issue clearly:
|
||||||
|
```
|
||||||
|
Issue in Phase [N]:
|
||||||
|
Expected: [what the plan says]
|
||||||
|
Found: [actual situation]
|
||||||
|
Why this matters: [explanation]
|
||||||
|
|
||||||
|
How should I proceed?
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification Approach
|
||||||
|
|
||||||
|
After implementing a phase:
|
||||||
|
- Run the success criteria checks listed in the plan (test commands, linters, type checkers, etc.)
|
||||||
|
- Fix any issues before proceeding
|
||||||
|
- **Check if manual verification is needed**: Look at the plan's success criteria for the current phase.
|
||||||
|
- If the phase has **manual verification steps**, pause and inform the human:
|
||||||
|
```
|
||||||
|
Phase [N] Complete - Ready for Manual Verification
|
||||||
|
|
||||||
|
Automated verification passed:
|
||||||
|
- [List automated checks that passed]
|
||||||
|
|
||||||
|
Please perform the manual verification steps listed in the plan:
|
||||||
|
- [List manual verification items from the plan]
|
||||||
|
|
||||||
|
Let me know when manual testing is complete so I can proceed to Phase [N+1].
|
||||||
|
```
|
||||||
|
- If the phase has **only automated verification** (no manual steps), continue directly to the next phase without pausing. Just note in passing that the phase is complete and automated checks passed.
|
||||||
|
|
||||||
|
Do not check off items in the manual testing steps until confirmed by the user.
|
||||||
|
|
||||||
|
## If You Get Stuck
|
||||||
|
|
||||||
|
When something isn't working as expected:
|
||||||
|
- First, make sure you've read and understood all the relevant code
|
||||||
|
- Consider if the codebase has evolved since the plan was written
|
||||||
|
- Present the mismatch clearly and ask for guidance
|
||||||
|
|
||||||
|
Use sub-agents sparingly - mainly for targeted debugging or exploring unfamiliar territory.
|
||||||
|
|
||||||
|
## Resuming Work
|
||||||
|
|
||||||
|
If the plan has existing checkmarks:
|
||||||
|
- Trust that completed work is done
|
||||||
|
- Pick up from the first unchecked item
|
||||||
|
- Verify previous work only if something seems off
|
||||||
|
|
||||||
|
Remember: You're implementing a solution, not just checking boxes. Keep the end goal in mind and maintain forward momentum.
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
---
|
||||||
|
name: rpi-plan
|
||||||
|
description: Create detailed, phased implementation plans through interactive research and iteration. Use when the user explicitly asks to "create a plan", "plan the implementation", or "design an approach" for a feature, refactor, or bug fix. Do not use for quick questions or simple tasks.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
You are tasked with creating detailed implementation plans through an interactive, iterative process. You should be skeptical, thorough, and work collaboratively with the user to produce high-quality technical specifications.
|
||||||
|
|
||||||
|
## Initial Setup
|
||||||
|
|
||||||
|
If the user already provided a task description, file path, or topic alongside this command, proceed directly to step 1 below. Only if no context was given, respond with:
|
||||||
|
```
|
||||||
|
I'll help you create a detailed implementation plan. Let me start by understanding what we're building.
|
||||||
|
|
||||||
|
Please provide:
|
||||||
|
1. A description of what you want to build or change
|
||||||
|
2. Any relevant context, constraints, or specific requirements
|
||||||
|
3. Pointers to related files or previous research
|
||||||
|
|
||||||
|
I'll analyze this information and work with you to create a comprehensive plan.
|
||||||
|
```
|
||||||
|
Then wait for the user's input.
|
||||||
|
|
||||||
|
## Process Steps
|
||||||
|
|
||||||
|
### Step 1: Context Gathering & Initial Analysis
|
||||||
|
|
||||||
|
1. **Read all mentioned files immediately and FULLY**:
|
||||||
|
- Any files the user referenced (docs, research, code)
|
||||||
|
- **IMPORTANT**: Use the Read tool WITHOUT limit/offset parameters to read entire files
|
||||||
|
- **CRITICAL**: DO NOT spawn sub-tasks before reading these files yourself in the main context
|
||||||
|
- **NEVER** read files partially - if a file is mentioned, read it completely
|
||||||
|
|
||||||
|
2. **Determine if research already exists**:
|
||||||
|
- If the user provided a research document (e.g. from `docs/agents/research/`), **trust it as the source of truth**. Do NOT re-research topics that the document already covers. Use its findings, file references, and architecture analysis directly as the basis for planning.
|
||||||
|
- **NEVER repeat or re-do research that has already been provided.** The plan phase is about turning existing research into actionable implementation steps, not about gathering information that's already available.
|
||||||
|
- If NO research document was provided, proceed with targeted research as described below.
|
||||||
|
|
||||||
|
3. **Read the most relevant files directly into your main context**:
|
||||||
|
- Based on the research document and/or user input, identify the most relevant source files
|
||||||
|
- **Read these files yourself using the Read tool** — do NOT delegate this to sub-agents. You need these files in your own context to write an accurate plan.
|
||||||
|
- Focus on files that will be modified or that define interfaces/patterns you need to follow
|
||||||
|
|
||||||
|
4. **Only spawn sub-agents for genuinely missing information**:
|
||||||
|
- Do NOT spawn sub-agents to re-discover what the research document already covers
|
||||||
|
- Only use sub-agents if there are specific gaps: e.g. the research doesn't cover test conventions, a specific API surface, or a file that was added after the research was written
|
||||||
|
- Each sub-agent should have a narrow, specific question to answer — not broad exploration
|
||||||
|
|
||||||
|
5. **Analyze and verify understanding**:
|
||||||
|
- Cross-reference the requirements with actual code (and research document if provided)
|
||||||
|
- Identify any discrepancies or misunderstandings
|
||||||
|
- Note assumptions that need verification
|
||||||
|
- Determine true scope based on codebase reality
|
||||||
|
|
||||||
|
6. **Present informed understanding and focused questions**:
|
||||||
|
```
|
||||||
|
Based on the task and my research of the codebase, I understand we need to [accurate summary].
|
||||||
|
|
||||||
|
I've found that:
|
||||||
|
- [Current implementation detail with file:line reference]
|
||||||
|
- [Relevant pattern or constraint discovered]
|
||||||
|
- [Potential complexity or edge case identified]
|
||||||
|
|
||||||
|
Questions that my research couldn't answer:
|
||||||
|
- [Specific technical question that requires human judgment]
|
||||||
|
- [Business logic clarification]
|
||||||
|
- [Design preference that affects implementation]
|
||||||
|
```
|
||||||
|
|
||||||
|
Only ask questions that you genuinely cannot answer through code investigation.
|
||||||
|
|
||||||
|
### Step 2: Targeted Research & Discovery
|
||||||
|
|
||||||
|
After getting initial clarifications:
|
||||||
|
|
||||||
|
1. **If the user corrects any misunderstanding**:
|
||||||
|
- DO NOT just accept the correction
|
||||||
|
- Read the specific files/directories they mention directly into your context
|
||||||
|
- Only proceed once you've verified the facts yourself
|
||||||
|
|
||||||
|
2. If you have a todo list, use it to track exploration progress
|
||||||
|
|
||||||
|
3. **Fill in gaps — do NOT redo existing research**:
|
||||||
|
- If a research document was provided, identify only the specific gaps that need filling
|
||||||
|
- Read additional files directly when possible — only spawn sub-agents for searches where you don't know the file paths
|
||||||
|
- **Ask yourself before any research action: "Is this already covered by the provided research?"** If yes, skip it and use what's there.
|
||||||
|
|
||||||
|
4. **Present findings and design options**:
|
||||||
|
```
|
||||||
|
Based on my research, here's what I found:
|
||||||
|
|
||||||
|
**Current State:**
|
||||||
|
- [Key discovery about existing code]
|
||||||
|
- [Pattern or convention to follow]
|
||||||
|
|
||||||
|
**Design Options:**
|
||||||
|
1. [Option A] - [pros/cons]
|
||||||
|
2. [Option B] - [pros/cons]
|
||||||
|
|
||||||
|
**Open Questions:**
|
||||||
|
- [Technical uncertainty]
|
||||||
|
- [Design decision needed]
|
||||||
|
|
||||||
|
Which approach aligns best with your vision?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Plan Structure Development
|
||||||
|
|
||||||
|
Once aligned on approach:
|
||||||
|
|
||||||
|
1. **Create initial plan outline**:
|
||||||
|
```
|
||||||
|
Here's my proposed plan structure:
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
[1-2 sentence summary]
|
||||||
|
|
||||||
|
## Implementation Phases:
|
||||||
|
1. [Phase name] - [what it accomplishes]
|
||||||
|
2. [Phase name] - [what it accomplishes]
|
||||||
|
3. [Phase name] - [what it accomplishes]
|
||||||
|
|
||||||
|
Does this phasing make sense? Should I adjust the order or granularity?
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Get feedback on structure** before writing details
|
||||||
|
|
||||||
|
### Step 4: Detailed Plan Writing
|
||||||
|
|
||||||
|
After structure approval:
|
||||||
|
|
||||||
|
1. **Gather metadata**:
|
||||||
|
- Run `python <skill_directory>/scripts/metadata.py` to get date, commit, branch, and repository info
|
||||||
|
- Determine the output filename: `docs/agents/plans/YYYY-MM-DD-description.md`
|
||||||
|
- YYYY-MM-DD is today's date
|
||||||
|
- description is a brief kebab-case description
|
||||||
|
- Example: `2025-01-08-improve-error-handling.md`
|
||||||
|
- The output folder (`docs/agents/plans/`) can be overridden by instructions in the project's `AGENTS.md` or `CLAUDE.md`
|
||||||
|
|
||||||
|
2. **Write the plan** to `docs/agents/plans/YYYY-MM-DD-description.md`
|
||||||
|
- Ensure the `docs/agents/plans/` directory exists (create if needed)
|
||||||
|
- **Every actionable item must have a checkbox** (`- [ ]`) so progress can be tracked during implementation. This includes each change in "Changes Required" and each verification step in "Success Criteria".
|
||||||
|
- Use the template structure below:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
---
|
||||||
|
date: [ISO date/time from metadata]
|
||||||
|
git_commit: [Current commit hash from metadata]
|
||||||
|
branch: [Current branch name from metadata]
|
||||||
|
topic: "[Feature/Task Name]"
|
||||||
|
tags: [plan, relevant-component-names]
|
||||||
|
status: draft
|
||||||
|
---
|
||||||
|
|
||||||
|
# [Feature/Task Name] Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
[Brief description of what we're implementing and why]
|
||||||
|
|
||||||
|
## Current State Analysis
|
||||||
|
|
||||||
|
[What exists now, what's missing, key constraints discovered]
|
||||||
|
|
||||||
|
## Desired End State
|
||||||
|
|
||||||
|
[A specification of the desired end state after this plan is complete, and how to verify it]
|
||||||
|
|
||||||
|
### UI Mockups (if applicable)
|
||||||
|
[If the changes involve user-facing interfaces (CLI output, web UI, terminal UI, etc.), include ASCII mockups
|
||||||
|
that visually illustrate the intended result. This helps the reader quickly grasp the change.]
|
||||||
|
|
||||||
|
### Key Discoveries:
|
||||||
|
- [Important finding with file:line reference]
|
||||||
|
- [Pattern to follow]
|
||||||
|
- [Constraint to work within]
|
||||||
|
|
||||||
|
## What We're NOT Doing
|
||||||
|
|
||||||
|
[Explicitly list out-of-scope items to prevent scope creep]
|
||||||
|
|
||||||
|
## Implementation Approach
|
||||||
|
|
||||||
|
[High-level strategy and reasoning]
|
||||||
|
|
||||||
|
## Phase 1: [Descriptive Name]
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
[What this phase accomplishes]
|
||||||
|
|
||||||
|
### Changes Required:
|
||||||
|
|
||||||
|
#### [ ] 1. [Component/File Group]
|
||||||
|
**File**: `path/to/file.ext`
|
||||||
|
**Changes**: [Summary of changes]
|
||||||
|
|
||||||
|
```[language]
|
||||||
|
// Specific code to add/modify
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Criteria:
|
||||||
|
|
||||||
|
#### Automated Verification:
|
||||||
|
- [ ] Tests pass: `[test command]`
|
||||||
|
- [ ] Type checking passes: `[typecheck command]`
|
||||||
|
- [ ] Linting passes: `[lint command]`
|
||||||
|
|
||||||
|
#### Manual Verification:
|
||||||
|
- [ ] Feature works as expected when tested
|
||||||
|
- [ ] Edge case handling verified
|
||||||
|
- [ ] No regressions in related features
|
||||||
|
|
||||||
|
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: [Descriptive Name]
|
||||||
|
|
||||||
|
[Similar structure with both automated and manual success criteria...]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests:
|
||||||
|
- [What to test]
|
||||||
|
- [Key edge cases]
|
||||||
|
|
||||||
|
### Integration Tests:
|
||||||
|
- [End-to-end scenarios]
|
||||||
|
|
||||||
|
### Manual Testing Steps:
|
||||||
|
1. [Specific step to verify feature]
|
||||||
|
2. [Another verification step]
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
[Any performance implications or optimizations needed]
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
[If applicable, how to handle existing data/systems]
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Related research or documentation]
|
||||||
|
- [Similar implementation: file:line]
|
||||||
|
````
|
||||||
|
|
||||||
|
### Step 5: Review & Iterate
|
||||||
|
|
||||||
|
1. **Present the draft plan location**:
|
||||||
|
```
|
||||||
|
I've created the initial implementation plan at:
|
||||||
|
`docs/agents/plans/YYYY-MM-DD-description.md`
|
||||||
|
|
||||||
|
Please review it and let me know:
|
||||||
|
- Are the phases properly scoped?
|
||||||
|
- Are the success criteria specific enough?
|
||||||
|
- Any technical details that need adjustment?
|
||||||
|
- Missing edge cases or considerations?
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Iterate based on feedback** - be ready to:
|
||||||
|
- Add missing phases
|
||||||
|
- Adjust technical approach
|
||||||
|
- Clarify success criteria (both automated and manual)
|
||||||
|
- Add/remove scope items
|
||||||
|
|
||||||
|
3. **Continue refining** until the user is satisfied
|
||||||
|
|
||||||
|
## Important Guidelines
|
||||||
|
|
||||||
|
1. **Be Skeptical**:
|
||||||
|
- Question vague requirements
|
||||||
|
- Identify potential issues early
|
||||||
|
- Ask "why" and "what about"
|
||||||
|
- Don't assume - verify with code
|
||||||
|
|
||||||
|
2. **Be Interactive**:
|
||||||
|
- Don't write the full plan in one shot
|
||||||
|
- Get buy-in at each major step
|
||||||
|
- Allow course corrections
|
||||||
|
- Work collaboratively
|
||||||
|
|
||||||
|
3. **Be Thorough But Not Redundant**:
|
||||||
|
- Read all context files COMPLETELY before planning
|
||||||
|
- Use provided research as-is — do not re-investigate what's already documented
|
||||||
|
- Read key source files directly into your context rather than delegating to sub-agents
|
||||||
|
- Only spawn sub-agents for narrow, specific questions that aren't answered by existing research
|
||||||
|
- Include specific file paths and line numbers
|
||||||
|
- Write measurable success criteria with clear automated vs manual distinction
|
||||||
|
|
||||||
|
4. **Be Visual**:
|
||||||
|
- If the change involves any user-facing interface (web UI, CLI output, terminal UI, forms, dashboards, etc.), include ASCII mockups in the plan
|
||||||
|
- Mockups make the intended result immediately understandable and help catch misunderstandings early
|
||||||
|
- Show both the current state and the proposed state when the change modifies an existing UI
|
||||||
|
- Keep mockups simple but accurate enough to convey layout, key elements, and interactions
|
||||||
|
|
||||||
|
5. **Be Practical**:
|
||||||
|
- Focus on incremental, testable changes
|
||||||
|
- Consider migration and rollback
|
||||||
|
- Think about edge cases
|
||||||
|
- Include "what we're NOT doing"
|
||||||
|
|
||||||
|
6. **No Open Questions in Final Plan**:
|
||||||
|
- If you encounter open questions during planning, STOP
|
||||||
|
- Research or ask for clarification immediately
|
||||||
|
- Do NOT write the plan with unresolved questions
|
||||||
|
- The implementation plan must be complete and actionable
|
||||||
|
- Every decision must be made before finalizing the plan
|
||||||
|
|
||||||
|
## Success Criteria Guidelines
|
||||||
|
|
||||||
|
**Always separate success criteria into two categories:**
|
||||||
|
|
||||||
|
1. **Automated Verification** (can be run by agents):
|
||||||
|
- Commands that can be run: test suites, linters, type checkers
|
||||||
|
- Specific files that should exist
|
||||||
|
- Code compilation/type checking
|
||||||
|
|
||||||
|
2. **Manual Verification** (requires human testing):
|
||||||
|
- UI/UX functionality
|
||||||
|
- Performance under real conditions
|
||||||
|
- Edge cases that are hard to automate
|
||||||
|
- User acceptance criteria
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### For Database Changes:
|
||||||
|
- Start with schema/migration
|
||||||
|
- Add store methods
|
||||||
|
- Update business logic
|
||||||
|
- Expose via API
|
||||||
|
- Update clients
|
||||||
|
|
||||||
|
### For New Features:
|
||||||
|
- Research existing patterns first
|
||||||
|
- Start with data model
|
||||||
|
- Build backend logic
|
||||||
|
- Add API endpoints
|
||||||
|
- Implement UI last
|
||||||
|
|
||||||
|
### For Refactoring:
|
||||||
|
- Document current behavior
|
||||||
|
- Plan incremental changes
|
||||||
|
- Maintain backwards compatibility
|
||||||
|
- Include migration strategy
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Get git metadata for plan documents."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str]) -> str:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
return result.stdout.strip() if result.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_repo_name() -> str:
|
||||||
|
remote = run(["git", "remote", "get-url", "origin"])
|
||||||
|
if remote:
|
||||||
|
name = remote.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
return name.removesuffix(".git")
|
||||||
|
root = run(["git", "rev-parse", "--show-toplevel"])
|
||||||
|
return Path(root).name if root else Path.cwd().name
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
metadata = {
|
||||||
|
"date": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"commit": run(["git", "rev-parse", "HEAD"]),
|
||||||
|
"branch": run(["git", "branch", "--show-current"]),
|
||||||
|
"repository": get_repo_name(),
|
||||||
|
}
|
||||||
|
json.dump(metadata, sys.stdout, indent=2)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
---
|
||||||
|
name: rpi-research
|
||||||
|
description: Conduct deep codebase research and produce a written report. Use when the user explicitly requests research like "start a research for", "deeply investigate", or "fully understand how X works". Do not use for quick questions or simple code lookups.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research Codebase
|
||||||
|
|
||||||
|
You are tasked with conducting comprehensive research across the codebase to answer user questions by spawning parallel sub-agents and synthesizing their findings.
|
||||||
|
|
||||||
|
## CRITICAL: YOUR ONLY JOB IS TO DOCUMENT AND EXPLAIN THE CODEBASE AS IT EXISTS TODAY
|
||||||
|
- DO NOT suggest improvements or changes unless the user explicitly asks for them
|
||||||
|
- DO NOT perform root cause analysis unless the user explicitly asks for them
|
||||||
|
- DO NOT propose future enhancements unless the user explicitly asks for them
|
||||||
|
- DO NOT critique the implementation or identify problems
|
||||||
|
- DO NOT recommend refactoring, optimization, or architectural changes
|
||||||
|
- ONLY describe what exists, where it exists, how it works, and how components interact
|
||||||
|
- You are creating a technical map/documentation of the existing system
|
||||||
|
|
||||||
|
## Initial Setup
|
||||||
|
|
||||||
|
If the user already provided a research question or topic alongside this command, proceed directly to step 1 below. Only if no query was given, respond with:
|
||||||
|
```
|
||||||
|
I'm ready to research the codebase. Please provide your research question or area of interest, and I'll analyze it thoroughly by exploring relevant components and connections.
|
||||||
|
```
|
||||||
|
Then wait for the user's research query.
|
||||||
|
|
||||||
|
## Steps to follow after receiving the research query:
|
||||||
|
|
||||||
|
1. **Read any directly mentioned files first:**
|
||||||
|
- If the user mentions specific files or docs, read them FULLY first
|
||||||
|
- **IMPORTANT**: Use the Read tool WITHOUT limit/offset parameters to read entire files
|
||||||
|
- **CRITICAL**: Read these files yourself in the main context before spawning any sub-tasks
|
||||||
|
- This ensures you have full context before decomposing the research
|
||||||
|
|
||||||
|
2. **Analyze and decompose the research question:**
|
||||||
|
- Break down the user's query into composable research areas
|
||||||
|
- Take time to think deeply about the underlying patterns, connections, and architectural implications the user might be seeking
|
||||||
|
- Identify specific components, patterns, or concepts to investigate
|
||||||
|
- If you have a todo list, use it to track progress
|
||||||
|
- Consider which directories, files, or architectural patterns are relevant
|
||||||
|
|
||||||
|
3. **Spawn parallel sub-agents to identify relevant files and map the landscape:**
|
||||||
|
- Create multiple Task agents to search for files and identify what's relevant
|
||||||
|
- Each sub-agent should focus on locating files and reporting back paths and brief summaries — NOT on deeply analyzing code
|
||||||
|
- The key is to use these agents for discovery:
|
||||||
|
- Search for files related to each research area
|
||||||
|
- Identify entry points, key types, and important functions
|
||||||
|
- Report back file paths, line numbers, and short descriptions of what each file contains
|
||||||
|
- Run multiple agents in parallel when they're searching for different things
|
||||||
|
- Remind agents they are documenting, not evaluating or improving
|
||||||
|
- **If the user explicitly asks for web research**, spawn additional agents with WebSearch/WebFetch tools and instruct them to return links with their findings
|
||||||
|
|
||||||
|
4. **Read the most relevant files yourself in the main context:**
|
||||||
|
- After sub-agents report back, identify the most important files for answering the research question
|
||||||
|
- **Read these files yourself using the Read tool** — you need them in your own context to write an accurate, detailed research document
|
||||||
|
- Do NOT rely solely on sub-agent summaries for the core findings — sub-agent summaries may miss nuances, connections, or important details
|
||||||
|
- Prioritize files that are central to the research question; skip peripheral files that sub-agents already summarized adequately
|
||||||
|
- This is the step where you build deep understanding — the previous step was just finding what to read
|
||||||
|
|
||||||
|
5. **Synthesize findings into a complete picture:**
|
||||||
|
- Combine your own reading with sub-agent discoveries
|
||||||
|
- Connect findings across different components
|
||||||
|
- Include specific file paths and line numbers for reference
|
||||||
|
- Highlight patterns, connections, and architectural decisions
|
||||||
|
- Answer the user's specific questions with concrete evidence
|
||||||
|
|
||||||
|
6. **Gather metadata for the research document:**
|
||||||
|
- Run `python <skill_directory>/scripts/metadata.py` to get date, commit, branch, and repository info
|
||||||
|
- Determine the output filename: `docs/agents/research/YYYY-MM-DD-description.md`
|
||||||
|
- YYYY-MM-DD is today's date
|
||||||
|
- description is a brief kebab-case description of the research topic
|
||||||
|
- Example: `2025-01-08-authentication-flow.md`
|
||||||
|
- The output folder (`docs/agents/research/`) can be overridden by instructions in the project's `AGENTS.md` or `CLAUDE.md`
|
||||||
|
|
||||||
|
7. **Generate research document:**
|
||||||
|
- Use the metadata gathered in step 5
|
||||||
|
- Ensure the `docs/agents/research/` directory exists (create if needed)
|
||||||
|
- Structure the document with YAML frontmatter followed by content:
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
date: [ISO date/time from metadata]
|
||||||
|
git_commit: [Current commit hash from metadata]
|
||||||
|
branch: [Current branch name from metadata]
|
||||||
|
topic: "[User's Question/Topic]"
|
||||||
|
tags: [research, codebase, relevant-component-names]
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research: [User's Question/Topic]
|
||||||
|
|
||||||
|
## Research Question
|
||||||
|
[Original user query]
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
[High-level documentation of what was found, answering the user's question by describing what exists]
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
### [Component/Area 1]
|
||||||
|
- Description of what exists (file.ext:line)
|
||||||
|
- How it connects to other components
|
||||||
|
- Current implementation details (without evaluation)
|
||||||
|
|
||||||
|
### [Component/Area 2]
|
||||||
|
...
|
||||||
|
|
||||||
|
## Code References
|
||||||
|
- `path/to/file.py:123` - Description of what's there
|
||||||
|
- `another/file.ts:45-67` - Description of the code block
|
||||||
|
|
||||||
|
## Architecture Documentation
|
||||||
|
[Current patterns, conventions, and design implementations found in the codebase]
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
[Any areas that need further investigation]
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Present findings to the user:**
|
||||||
|
- Present a concise summary of findings
|
||||||
|
- Include key file references for easy navigation
|
||||||
|
- Ask if they have follow-up questions or need clarification
|
||||||
|
|
||||||
|
9. **Handle follow-up questions:**
|
||||||
|
- If the user has follow-up questions, append to the same research document
|
||||||
|
- Add a new section: `## Follow-up Research [timestamp]`
|
||||||
|
- Spawn new sub-agents as needed for additional investigation
|
||||||
|
- Continue updating the document
|
||||||
|
|
||||||
|
## Important notes:
|
||||||
|
- Use parallel sub-agents for file discovery and landscape mapping, but **read the most important files yourself** in the main context
|
||||||
|
- Sub-agents are scouts that find relevant files — the main agent must read key files to build deep understanding
|
||||||
|
- Do NOT rely solely on sub-agent summaries for your core findings; they may miss nuances and connections
|
||||||
|
- Focus on finding concrete file paths and line numbers for developer reference
|
||||||
|
- Research documents should be self-contained with all necessary context
|
||||||
|
- Each sub-agent prompt should be specific and focused on locating files and reporting back paths
|
||||||
|
- Document cross-component connections and how systems interact
|
||||||
|
- **CRITICAL**: You and all sub-agents are documentarians, not evaluators
|
||||||
|
- **REMEMBER**: Document what IS, not what SHOULD BE
|
||||||
|
- **NO RECOMMENDATIONS**: Only describe the current state of the codebase
|
||||||
|
- **File reading**: Always read mentioned files FULLY (no limit/offset) before spawning sub-tasks
|
||||||
|
- **Critical ordering**: Follow the numbered steps exactly
|
||||||
|
- ALWAYS read mentioned files first before spawning sub-tasks (step 1)
|
||||||
|
- ALWAYS read key files yourself after sub-agents report back (step 4)
|
||||||
|
- ALWAYS wait for your own reading to complete before synthesizing (step 5)
|
||||||
|
- ALWAYS gather metadata before writing the document (step 6 before step 7)
|
||||||
|
- NEVER write the research document with placeholder values
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Get git metadata for research documents."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str]) -> str:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
return result.stdout.strip() if result.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_repo_name() -> str:
|
||||||
|
remote = run(["git", "remote", "get-url", "origin"])
|
||||||
|
if remote:
|
||||||
|
# Handle both HTTPS and SSH URLs
|
||||||
|
name = remote.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
return name.removesuffix(".git")
|
||||||
|
# Fall back to directory name
|
||||||
|
root = run(["git", "rev-parse", "--show-toplevel"])
|
||||||
|
return Path(root).name if root else Path.cwd().name
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
metadata = {
|
||||||
|
"date": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"commit": run(["git", "rev-parse", "HEAD"]),
|
||||||
|
"branch": run(["git", "branch", "--show-current"]),
|
||||||
|
"repository": get_repo_name(),
|
||||||
|
}
|
||||||
|
json.dump(metadata, sys.stdout, indent=2)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: ship
|
||||||
|
description: Commit, tag with the next version, and push to remote.
|
||||||
|
disable-model-invocation: true
|
||||||
|
allowed-tools: Bash(git *), Bash(pnpm *), Skill
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
Commit current changes, create the next version tag, and push everything to remote.
|
||||||
|
|
||||||
|
### Step 1 — Commit
|
||||||
|
|
||||||
|
Use the `/commit` skill to stage and commit changes. Pass along any user arguments as the commit message.
|
||||||
|
|
||||||
|
```
|
||||||
|
/commit $ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Tag
|
||||||
|
|
||||||
|
Get the latest tag and increment the patch number (e.g., `0.9.27` → `0.9.28`). Create the tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag --sort=-v:refname | head -1
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag <next-version>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3 — Push
|
||||||
|
|
||||||
|
Push the commit and tag to remote:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4 — Verify
|
||||||
|
|
||||||
|
Confirm the tag exists on the pushed commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -1 --decorate
|
||||||
|
```
|
||||||
|
|
||||||
|
## User arguments
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
If the user provided arguments, treat them as the commit message or guidance for what to commit.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
|
.specify
|
||||||
|
specs
|
||||||
|
docs
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm check
|
||||||
|
|
||||||
|
build-image:
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
needs: check
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.bahamut.nitrix.one -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
run: |
|
||||||
|
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
|
||||||
|
TAG=${GITHUB_REF#refs/tags/}
|
||||||
|
docker build -t $IMAGE:$TAG -t $IMAGE:latest .
|
||||||
|
docker push $IMAGE:$TAG
|
||||||
|
docker push $IMAGE:latest
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
|
||||||
|
TAG=${GITHUB_REF#refs/tags/}
|
||||||
|
docker stop initiative || true
|
||||||
|
docker rm initiative || true
|
||||||
|
docker run -d --name initiative --restart unless-stopped -p 8080:80 $IMAGE:$TAG
|
||||||
@@ -11,3 +11,7 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
docs/agents/plans/
|
||||||
|
docs/agents/research/
|
||||||
|
.agent-tests/
|
||||||
|
.rodney/
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"threshold": 5,
|
||||||
|
"minLines": 5,
|
||||||
|
"minTokens": 50,
|
||||||
|
"pattern": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"ignore": ["node_modules", "dist", "build", "coverage", ".specify", "specs"],
|
||||||
|
"reporters": ["console"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"threshold": 50,
|
||||||
|
"minInstances": 3,
|
||||||
|
"identifiers": false,
|
||||||
|
"literals": false,
|
||||||
|
"ignore": "dist|__tests__|node_modules",
|
||||||
|
"reporter": "default",
|
||||||
|
"truncate": 100
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-type-annotations/refs/heads/main/packages/oxlint/configuration_file_schema.json",
|
||||||
|
"plugins": ["typescript", "unicorn", "jest"],
|
||||||
|
"categories": {},
|
||||||
|
"rules": {
|
||||||
|
"typescript/no-unnecessary-type-assertion": "error",
|
||||||
|
"typescript/no-deprecated": "warn",
|
||||||
|
"typescript/prefer-regexp-exec": "error",
|
||||||
|
"unicorn/prefer-string-replace-all": "error",
|
||||||
|
"unicorn/prefer-string-raw": "error",
|
||||||
|
"jest/expect-expect": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"assertFunctionNames": ["expect", "expectDomainError"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ignorePatterns": [
|
||||||
|
"dist",
|
||||||
|
"coverage",
|
||||||
|
".claude",
|
||||||
|
".specify",
|
||||||
|
"specs",
|
||||||
|
".pnpm-store",
|
||||||
|
"scripts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
───────────────────
|
───────────────────
|
||||||
Version change: 1.0.2 → 1.0.3 (PATCH — add merge-gate rule)
|
Version change: 3.1.0 → 3.2.0 (MINOR — artifact lifecycle guidance)
|
||||||
Modified sections:
|
Modified sections:
|
||||||
- Development Workflow: added automated-checks merge gate
|
- Development Workflow: added artifact lifecycle rules (spec.md living, plan/tasks bounded, tests authoritative)
|
||||||
Templates requiring updates:
|
Templates requiring updates: none
|
||||||
- .specify/templates/plan-template.md ✅ no update needed
|
|
||||||
- .specify/templates/spec-template.md ✅ no update needed
|
|
||||||
- .specify/templates/tasks-template.md ✅ no update needed
|
|
||||||
Follow-up TODOs: none
|
|
||||||
-->
|
-->
|
||||||
# Encounter Console Constitution
|
# Encounter Console Constitution
|
||||||
|
|
||||||
@@ -29,7 +25,7 @@ be injected at the boundary, never sourced inside the domain layer.
|
|||||||
|
|
||||||
### II. Layered Architecture
|
### II. Layered Architecture
|
||||||
|
|
||||||
The codebase MUST be organized into four layers with strict
|
The codebase MUST be organized into three layers with strict
|
||||||
dependency direction:
|
dependency direction:
|
||||||
|
|
||||||
1. **Domain** — pure types, state transitions, validation rules.
|
1. **Domain** — pure types, state transitions, validation rules.
|
||||||
@@ -39,34 +35,37 @@ dependency direction:
|
|||||||
interfaces that Adapters implement. May import Domain only.
|
interfaces that Adapters implement. May import Domain only.
|
||||||
3. **Adapters** — I/O, persistence, UI rendering, external APIs.
|
3. **Adapters** — I/O, persistence, UI rendering, external APIs.
|
||||||
May import Application and Domain.
|
May import Application and Domain.
|
||||||
4. **Agent** — AI-assisted features (suggestions, analysis).
|
|
||||||
May import Application and Domain as read-only consumers.
|
|
||||||
|
|
||||||
A module in an inner layer MUST NOT import from an outer layer.
|
A module in an inner layer MUST NOT import from an outer layer.
|
||||||
|
|
||||||
### III. Agent Boundary
|
### II-A. Context-Based State Flow
|
||||||
|
|
||||||
The agent layer MAY read domain events and current state. The agent
|
UI components MUST consume shared application state via React context
|
||||||
MAY produce suggestions, annotations, or recommendations. The agent
|
providers, not prop drilling. Props are reserved for per-instance
|
||||||
MUST NOT mutate domain state directly. All agent-originated changes
|
configuration (e.g., a specific data item, a layout variant, a ref).
|
||||||
MUST flow through the Application layer as explicit user-confirmed
|
|
||||||
commands.
|
|
||||||
|
|
||||||
- Agent output MUST be clearly labeled as suggestions.
|
- Components MUST NOT declare more than 8 explicit props in their
|
||||||
- No silent or automatic application of agent recommendations.
|
own interface. This is enforced by `scripts/check-component-props.mjs`
|
||||||
|
at pre-commit.
|
||||||
|
- Generic UI primitives (`components/ui/`) that extend HTML element
|
||||||
|
attributes are exempt — only explicitly declared props count, not
|
||||||
|
inherited HTML attributes.
|
||||||
|
- Coordinating hooks that consume multiple contexts (e.g.,
|
||||||
|
`useInitiativeRolls`) are preferred over wiring callbacks through
|
||||||
|
a parent component.
|
||||||
|
|
||||||
### IV. Clarification-First
|
### III. Clarification-First
|
||||||
|
|
||||||
Before making any non-trivial assumption during specification,
|
Before making any non-trivial assumption during specification,
|
||||||
planning, or implementation, the agent MUST surface a clarification
|
planning, or implementation, Claude Code MUST surface a clarification
|
||||||
question to the user. "Non-trivial" means any decision that would
|
question to the user. "Non-trivial" means any decision that would
|
||||||
alter observable behavior, data model shape, or public API surface.
|
alter observable behavior, data model shape, or public API surface.
|
||||||
The agent MUST also ask when multiple valid interpretations exist,
|
Claude Code MUST also ask when multiple valid interpretations exist,
|
||||||
when a choice would affect architectural layering, or when scope
|
when a choice would affect architectural layering, or when scope
|
||||||
would expand beyond the current spec. The agent MUST NOT silently
|
would expand beyond the current spec. Claude Code MUST NOT silently
|
||||||
choose among valid alternatives.
|
choose among valid alternatives.
|
||||||
|
|
||||||
### V. Escalation Gates
|
### IV. Escalation Gates
|
||||||
|
|
||||||
Any feature, requirement, or scope change not present in the current
|
Any feature, requirement, or scope change not present in the current
|
||||||
spec MUST be rejected at implementation time until the spec is
|
spec MUST be rejected at implementation time until the spec is
|
||||||
@@ -77,7 +76,7 @@ explicitly updated. The workflow is:
|
|||||||
3. Update the spec via `/speckit.specify` or `/speckit.clarify`.
|
3. Update the spec via `/speckit.specify` or `/speckit.clarify`.
|
||||||
4. Only then proceed with implementation.
|
4. Only then proceed with implementation.
|
||||||
|
|
||||||
### VI. MVP Baseline Language
|
### V. MVP Baseline Language
|
||||||
|
|
||||||
Constraints in this constitution and in specs MUST use MVP baseline
|
Constraints in this constitution and in specs MUST use MVP baseline
|
||||||
language ("MVP baseline does not include X") rather than permanent
|
language ("MVP baseline does not include X") rather than permanent
|
||||||
@@ -86,7 +85,7 @@ add capabilities in future iterations without constitutional
|
|||||||
amendment. The current MVP baseline is local-first and single-user;
|
amendment. The current MVP baseline is local-first and single-user;
|
||||||
this is a starting scope, not a permanent restriction.
|
this is a starting scope, not a permanent restriction.
|
||||||
|
|
||||||
### VII. No Gameplay Rules in Constitution
|
### VI. No Gameplay Rules in Constitution
|
||||||
|
|
||||||
This constitution MUST NOT contain concrete gameplay mechanics,
|
This constitution MUST NOT contain concrete gameplay mechanics,
|
||||||
rule-system specifics, or encounter resolution logic. Such details
|
rule-system specifics, or encounter resolution logic. Such details
|
||||||
@@ -96,9 +95,9 @@ architecture, and quality — not product behavior.
|
|||||||
## Scope Constraints
|
## Scope Constraints
|
||||||
|
|
||||||
- The Encounter Console's primary focus is initiative tracking and
|
- The Encounter Console's primary focus is initiative tracking and
|
||||||
encounter state management. Adjacent capabilities (e.g., richer
|
encounter state management. Adjacent capabilities (e.g., bestiary
|
||||||
game-engine features) are not in the MVP baseline but may be
|
integration, richer game-engine features) may be added via spec
|
||||||
added via spec updates in future iterations.
|
updates.
|
||||||
- Technology choices, UI framework, and storage mechanism are
|
- Technology choices, UI framework, and storage mechanism are
|
||||||
spec-level decisions, not constitutional mandates.
|
spec-level decisions, not constitutional mandates.
|
||||||
- Testing strategy (unit, integration, contract) is determined per
|
- Testing strategy (unit, integration, contract) is determined per
|
||||||
@@ -109,16 +108,43 @@ architecture, and quality — not product behavior.
|
|||||||
|
|
||||||
- No change may be merged unless all automated checks (tests and
|
- No change may be merged unless all automated checks (tests and
|
||||||
static analysis as defined by the project) pass.
|
static analysis as defined by the project) pass.
|
||||||
- Every feature begins with a spec (`/speckit.specify`).
|
- Specs describe **features**, not individual changes. Each spec is
|
||||||
- Implementation follows the plan → tasks → implement pipeline.
|
a living document. New features begin with `/speckit.specify`
|
||||||
|
(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.
|
||||||
- Domain logic MUST be testable without mocks for external systems.
|
- Domain logic MUST be testable without mocks for external systems.
|
||||||
- Long-running or multi-step state transitions SHOULD be verifiable
|
- Long-running or multi-step state transitions SHOULD be verifiable
|
||||||
through reproducible event logs or snapshot-style tests.
|
through reproducible event logs or snapshot-style tests.
|
||||||
- Commits SHOULD be atomic and map to individual tasks where
|
- Commits SHOULD be atomic and map to individual tasks where
|
||||||
practical.
|
practical.
|
||||||
- Layer boundary compliance MUST be verified by automated import
|
- Layer boundary compliance MUST be verified by automated import
|
||||||
rules or architectural tests. Agent-assisted or manual review MAY
|
rules or architectural tests.
|
||||||
supplement but not replace automated checks.
|
- All automated quality gates MUST run at the earliest feasible
|
||||||
|
enforcement point (currently pre-commit via Lefthook). No gate
|
||||||
|
may exist only as a CI step or manual process.
|
||||||
|
- When a feature adds, removes, or changes user-facing capabilities
|
||||||
|
described in README.md, the README MUST be updated in the same
|
||||||
|
change. Features that materially alter what the product does or
|
||||||
|
how it is set up SHOULD also be reflected in the README.
|
||||||
|
- When a feature changes the tech stack, project structure, or
|
||||||
|
architectural patterns documented in CLAUDE.md, the CLAUDE.md
|
||||||
|
MUST be updated in the same change.
|
||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
@@ -142,4 +168,4 @@ MUST comply with its principles.
|
|||||||
**Compliance review**: Every spec and plan MUST include a
|
**Compliance review**: Every spec and plan MUST include a
|
||||||
Constitution Check section validating adherence to all principles.
|
Constitution Check section validating adherence to all principles.
|
||||||
|
|
||||||
**Version**: 1.0.3 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-03
|
**Version**: 3.2.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-30
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ fi
|
|||||||
# Build list of available documents
|
# Build list of available documents
|
||||||
docs=()
|
docs=()
|
||||||
|
|
||||||
|
# Include required docs that passed validation above
|
||||||
|
[[ -f "$FEATURE_SPEC" ]] && docs+=("spec.md")
|
||||||
|
[[ -f "$IMPL_PLAN" ]] && docs+=("plan.md")
|
||||||
|
|
||||||
# Always check these optional docs
|
# Always check these optional docs
|
||||||
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
||||||
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
**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 + jsinspect)
|
||||||
|
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
|
||||||
|
pnpm knip # Unused code detection (Knip)
|
||||||
|
pnpm test # Run all tests (Vitest)
|
||||||
|
pnpm test:watch # Tests in watch mode
|
||||||
|
pnpm typecheck # tsc --build (project references)
|
||||||
|
pnpm lint # Biome lint
|
||||||
|
pnpm format # Biome format (writes)
|
||||||
|
pnpm check:props # Component prop count enforcement (max 8)
|
||||||
|
pnpm --filter web dev # Vite dev server (localhost:5173)
|
||||||
|
pnpm --filter web build # Production build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a single test file: `pnpm vitest run packages/domain/src/__tests__/advance-turn.test.ts`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Strict layered architecture with ports/adapters and enforced dependency direction:
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web (React 19 + Vite) → packages/application (use cases) → packages/domain (pure logic)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **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 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.
|
||||||
|
|
||||||
|
### Data & Storage
|
||||||
|
|
||||||
|
- **localStorage** — encounter persistence (adapter layer, JSON serialization)
|
||||||
|
- **IndexedDB** — bestiary source cache (`apps/web/src/adapters/bestiary-cache.ts`, via `idb` wrapper)
|
||||||
|
- **`data/bestiary/index.json`** — pre-built search index for creature lookup, generated by `scripts/generate-bestiary-index.mjs`
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/ React app — components, hooks, adapters
|
||||||
|
packages/domain/src/ Pure state transitions, types, validation
|
||||||
|
packages/application/src/ Use cases, port interfaces
|
||||||
|
data/bestiary/ Bestiary search index
|
||||||
|
scripts/ Build tooling (layer checks, index generation)
|
||||||
|
specs/NNN-feature-name/ Feature specs (spec.md, plan.md, tasks.md)
|
||||||
|
.specify/ Speckit config (templates, scripts, constitution)
|
||||||
|
docs/agents/ RPI skill artifacts (research reports, plans)
|
||||||
|
.claude/skills/ Agent skills (rpi-research, rpi-plan, rpi-implement)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
|
||||||
|
- 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), 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. 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 `__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
|
||||||
|
|
||||||
|
Before finishing a change, consider:
|
||||||
|
- Is this the simplest approach that solves the current problem?
|
||||||
|
- Is there duplication that hurts readability? (But don't abstract prematurely.)
|
||||||
|
- Are errors handled correctly and communicated sensibly to the user?
|
||||||
|
- Does the UI follow modern patterns and feel intuitive to interact with?
|
||||||
|
|
||||||
|
## Speckit Workflow
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|---|---|
|
||||||
|
| Bug fix / CSS tweak | Just fix it, commit |
|
||||||
|
| Small change to existing feature | `/integrate-issue` → implement → commit |
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
**Research scope**: Always scan for existing patterns similar to what the feature needs. Identify extraction and consolidation opportunities before implementation, not during.
|
||||||
|
|
||||||
|
## Constitution
|
||||||
|
|
||||||
|
Project principles governing all feature work are in [`.specify/memory/constitution.md`](.specify/memory/constitution.md). Key rules: deterministic domain core, strict layer boundaries, clarification before assumptions.
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-slim AS build
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY packages/domain/package.json packages/domain/
|
||||||
|
COPY packages/application/package.json packages/application/
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm --filter web build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
|
||||||
|
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
||||||
|
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
||||||
|
- **Player characters** — create reusable player character templates with name, AC, HP, level, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||||
|
- **Encounter difficulty** — live 3-bar indicator in the top bar showing encounter difficulty (Trivial/Low/Moderate/High) based on the 2024 5.5e XP budget system; automatically derived from PC levels and bestiary creature CRs
|
||||||
|
- **Undo/redo** — reverse any encounter action with Undo/Redo buttons or keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac); history persists across page reloads
|
||||||
|
- **Import/export** — export the full encounter state (combatants, undo/redo history, player characters) as a JSON file or copy to clipboard; import from file upload or pasted JSON with validation and confirmation
|
||||||
|
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 22+
|
||||||
|
- pnpm 10.6+
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter web dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:5173`.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `pnpm --filter web dev` | Start the dev server |
|
||||||
|
| `pnpm --filter web build` | Production build |
|
||||||
|
| `pnpm test` | Run all tests (Vitest) |
|
||||||
|
| `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/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)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Strict layered architecture with enforced dependency direction:
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **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.
|
||||||
+10
-1
@@ -2,7 +2,16 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0e1a2e" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta property="og:title" content="Initiative Tracker" />
|
||||||
|
<meta property="og:description" content="D&D combat initiative tracker" />
|
||||||
|
<meta property="og:image" content="https://initiative.dostulata.rocks/icon-512.png" />
|
||||||
|
<meta property="og:url" content="https://initiative.dostulata.rocks/" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
<title>Initiative Tracker</title>
|
<title>Initiative Tracker</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+14
-3
@@ -11,13 +11,24 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@initiative/application": "workspace:*",
|
"@initiative/application": "workspace:*",
|
||||||
"@initiative/domain": "workspace:*",
|
"@initiative/domain": "workspace:*",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"idb": "^8.0.3",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"vite": "^6.2.0"
|
"jsdom": "^29.0.1",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"vite": "^8.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="f" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#60a5fa"/>
|
||||||
|
<stop offset="100%" stop-color="#2563eb"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(16 15) scale(1.55)" fill="none" stroke="url(#f)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#f)" fill-opacity="0.15"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#f)" fill-opacity="0.25"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#f)" fill-opacity="0.2"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#f)" fill-opacity="0.1"/>
|
||||||
|
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
|
||||||
|
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
|
||||||
|
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg" cx="50%" cy="40%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#1a2e4a"/>
|
||||||
|
<stop offset="100%" stop-color="#0e1a2e"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="d20fill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#60a5fa"/>
|
||||||
|
<stop offset="100%" stop-color="#2563eb"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="d20stroke" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#93c5fd"/>
|
||||||
|
<stop offset="100%" stop-color="#3b82f6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||||
|
<g transform="translate(256 256) scale(8.5)" fill="none" stroke="url(#d20stroke)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#d20fill)" fill-opacity="0.15"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#d20fill)" fill-opacity="0.25"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.2"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.1"/>
|
||||||
|
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
|
||||||
|
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
|
||||||
|
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
|
||||||
|
</g>
|
||||||
|
<text x="256" y="278" text-anchor="middle" dominant-baseline="central" font-family="Inter, system-ui, sans-serif" font-weight="700" font-size="52" fill="#93c5fd" letter-spacing="1">20</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Initiative Tracker",
|
||||||
|
"short_name": "Initiative",
|
||||||
|
"description": "D&D combat initiative tracker",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0e1a2e",
|
||||||
|
"theme_color": "#0e1a2e",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+145
-1
@@ -1,3 +1,147 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ActionBar } from "./components/action-bar.js";
|
||||||
|
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
|
||||||
|
import { CombatantRow } from "./components/combatant-row.js";
|
||||||
|
import {
|
||||||
|
PlayerCharacterSection,
|
||||||
|
type PlayerCharacterSectionHandle,
|
||||||
|
} from "./components/player-character-section.js";
|
||||||
|
import { SettingsModal } from "./components/settings-modal.js";
|
||||||
|
import { StatBlockPanel } from "./components/stat-block-panel.js";
|
||||||
|
import { Toast } from "./components/toast.js";
|
||||||
|
import { TurnNavigation } from "./components/turn-navigation.js";
|
||||||
|
import { useEncounterContext } from "./contexts/encounter-context.js";
|
||||||
|
import { useInitiativeRollsContext } from "./contexts/initiative-rolls-context.js";
|
||||||
|
import { useSidePanelContext } from "./contexts/side-panel-context.js";
|
||||||
|
import { useActionBarAnimation } from "./hooks/use-action-bar-animation.js";
|
||||||
|
import { useAutoStatBlock } from "./hooks/use-auto-stat-block.js";
|
||||||
|
import { cn } from "./lib/utils.js";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return <div>Initiative Tracker</div>;
|
const { encounter, isEmpty } = useEncounterContext();
|
||||||
|
const sidePanel = useSidePanelContext();
|
||||||
|
const rolls = useInitiativeRollsContext();
|
||||||
|
|
||||||
|
useAutoStatBlock();
|
||||||
|
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||||
|
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||||
|
|
||||||
|
// Close the side panel when the encounter becomes empty
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEmpty) {
|
||||||
|
sidePanel.dismissPanel();
|
||||||
|
}
|
||||||
|
}, [isEmpty, sidePanel.dismissPanel]);
|
||||||
|
|
||||||
|
// Auto-scroll to active combatant when turn changes
|
||||||
|
const activeIndex = encounter.activeIndex;
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeIndex >= 0) {
|
||||||
|
activeRowRef.current?.scrollIntoView({
|
||||||
|
block: "nearest",
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activeIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh flex-col">
|
||||||
|
<div className="relative mx-auto flex min-h-0 w-full flex-1 flex-col gap-3 sm:max-w-2xl sm:px-4">
|
||||||
|
{!!actionBarAnim.showTopBar && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 pt-[env(safe-area-inset-top)] sm:pt-[max(env(safe-area-inset-top),2rem)]",
|
||||||
|
actionBarAnim.topBarClass,
|
||||||
|
)}
|
||||||
|
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||||
|
>
|
||||||
|
<TurnNavigation />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEmpty ? (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
||||||
|
<div
|
||||||
|
className={cn("w-full", actionBarAnim.risingClass)}
|
||||||
|
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||||
|
>
|
||||||
|
<ActionBar
|
||||||
|
inputRef={actionBarInputRef}
|
||||||
|
onManagePlayers={() =>
|
||||||
|
playerCharacterRef.current?.openManagement()
|
||||||
|
}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<div className="flex flex-col px-2 py-2">
|
||||||
|
{encounter.combatants.map((c, i) => (
|
||||||
|
<CombatantRow
|
||||||
|
key={c.id}
|
||||||
|
ref={i === encounter.activeIndex ? activeRowRef : null}
|
||||||
|
combatant={c}
|
||||||
|
isActive={i === encounter.activeIndex}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 pb-[env(safe-area-inset-bottom)] sm:pb-[max(env(safe-area-inset-bottom),2rem)]",
|
||||||
|
actionBarAnim.settlingClass,
|
||||||
|
)}
|
||||||
|
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||||
|
>
|
||||||
|
<ActionBar
|
||||||
|
inputRef={actionBarInputRef}
|
||||||
|
onManagePlayers={() =>
|
||||||
|
playerCharacterRef.current?.openManagement()
|
||||||
|
}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||||
|
<StatBlockPanel panelRole="pinned" side="left" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<StatBlockPanel panelRole="browse" side="right" />
|
||||||
|
|
||||||
|
<BulkImportToasts />
|
||||||
|
|
||||||
|
{rolls.rollSkippedCount > 0 && (
|
||||||
|
<Toast
|
||||||
|
message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`}
|
||||||
|
onDismiss={rolls.dismissRollSkipped}
|
||||||
|
autoDismissMs={4000}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!rolls.rollSingleSkipped && (
|
||||||
|
<Toast
|
||||||
|
message="Can't roll — bestiary source not loaded"
|
||||||
|
onDismiss={rolls.dismissRollSingleSkipped}
|
||||||
|
autoDismissMs={4000}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
<PlayerCharacterSection ref={playerCharacterRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
type AnyCreature,
|
||||||
|
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, AnyCreature>;
|
||||||
|
sources?: Map<
|
||||||
|
string,
|
||||||
|
{ displayName: string; creatures: AnyCreature[]; 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: AnyCreature[]; 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, AnyCreature>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounterPersistence: {
|
||||||
|
load: () => storedEncounter,
|
||||||
|
save: (e) => {
|
||||||
|
storedEncounter = e;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undoRedoPersistence: {
|
||||||
|
load: () => storedUndoRedo,
|
||||||
|
save: (state) => {
|
||||||
|
storedUndoRedo = state;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
playerCharacterPersistence: {
|
||||||
|
load: () => [...storedPCs],
|
||||||
|
save: (pcs) => {
|
||||||
|
storedPCs = pcs;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bestiaryCache: {
|
||||||
|
cacheSource(system, sourceCode, displayName, creatures) {
|
||||||
|
const key = `${system}:${sourceCode}`;
|
||||||
|
sourceStore.set(key, {
|
||||||
|
displayName,
|
||||||
|
creatures,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
});
|
||||||
|
for (const c of creatures) {
|
||||||
|
creatureMap.set(c.id, c);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
isSourceCached(system, sourceCode) {
|
||||||
|
return Promise.resolve(sourceStore.has(`${system}:${sourceCode}`));
|
||||||
|
},
|
||||||
|
getCachedSources(system) {
|
||||||
|
return Promise.resolve(
|
||||||
|
[...sourceStore.entries()]
|
||||||
|
.filter(([key]) => !system || key.startsWith(`${system}:`))
|
||||||
|
.map(([key, info]) => ({
|
||||||
|
sourceCode: key.includes(":")
|
||||||
|
? key.slice(key.indexOf(":") + 1)
|
||||||
|
: key,
|
||||||
|
displayName: info.displayName,
|
||||||
|
creatureCount: info.creatures.length,
|
||||||
|
cachedAt: info.cachedAt,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
clearSource(system, sourceCode) {
|
||||||
|
sourceStore.delete(`${system}:${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,
|
||||||
|
},
|
||||||
|
pf2eBestiaryIndex: {
|
||||||
|
loadIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: (sourceCode) =>
|
||||||
|
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
|
||||||
|
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||||
|
getCreaturePathsForSource: () => [],
|
||||||
|
getCreatureNamesByPaths: () => new Map(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// @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 { App } from "../App.js";
|
||||||
|
import { AllProviders } from "./test-providers.js";
|
||||||
|
|
||||||
|
// DOM API stubs — jsdom doesn't implement these
|
||||||
|
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(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
async function addCombatant(
|
||||||
|
user: ReturnType<typeof userEvent.setup>,
|
||||||
|
name: string,
|
||||||
|
opts?: { maxHp?: string },
|
||||||
|
) {
|
||||||
|
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
|
||||||
|
const input = inputs.at(-1) ?? inputs[0];
|
||||||
|
await user.type(input, name);
|
||||||
|
|
||||||
|
if (opts?.maxHp) {
|
||||||
|
const maxHpInput = screen.getByPlaceholderText("MaxHP");
|
||||||
|
await user.type(maxHpInput, opts.maxHp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: "Add" });
|
||||||
|
await user.click(addButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("App integration", () => {
|
||||||
|
it("adds a combatant and removes it, returning to empty state", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />, { wrapper: AllProviders });
|
||||||
|
|
||||||
|
// Empty state: centered input visible, no TurnNavigation
|
||||||
|
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("R1")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Add a combatant
|
||||||
|
await addCombatant(user, "Goblin");
|
||||||
|
|
||||||
|
// Verify combatant appears and TurnNavigation shows
|
||||||
|
expect(screen.getByRole("button", { name: "Goblin" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Goblin").length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Remove combatant via ConfirmButton (two clicks)
|
||||||
|
const removeBtn = screen.getByRole("button", {
|
||||||
|
name: "Remove combatant",
|
||||||
|
});
|
||||||
|
await user.click(removeBtn);
|
||||||
|
const confirmBtn = screen.getByRole("button", {
|
||||||
|
name: "Confirm remove combatant",
|
||||||
|
});
|
||||||
|
await user.click(confirmBtn);
|
||||||
|
|
||||||
|
// Back to empty state (R1 badge may linger due to exit animation in jsdom)
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Goblin" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances and retreats turns across two combatants", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />, { wrapper: AllProviders });
|
||||||
|
|
||||||
|
await addCombatant(user, "Fighter");
|
||||||
|
await addCombatant(user, "Wizard");
|
||||||
|
|
||||||
|
// Initial state — R1, Fighter active (Previous turn disabled)
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Previous turn" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
|
// Advance turn — Wizard becomes active
|
||||||
|
await user.click(screen.getByRole("button", { name: "Next turn" }));
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
|
||||||
|
|
||||||
|
// Advance again — wraps to R2, Fighter active
|
||||||
|
await user.click(screen.getByRole("button", { name: "Next turn" }));
|
||||||
|
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
|
||||||
|
|
||||||
|
// Retreat — back to R1, Wizard active
|
||||||
|
await user.click(screen.getByRole("button", { name: "Previous turn" }));
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />, { wrapper: AllProviders });
|
||||||
|
|
||||||
|
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||||
|
|
||||||
|
// Verify HP displays — currentHp and maxHp both show "59"
|
||||||
|
const hpButton = screen.getByRole("button", {
|
||||||
|
name: "Current HP: 59 (healthy)",
|
||||||
|
});
|
||||||
|
expect(hpButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click currentHp to open HpAdjustPopover, apply full damage
|
||||||
|
await user.click(hpButton);
|
||||||
|
const hpInput = screen.getByPlaceholderText("HP");
|
||||||
|
expect(hpInput).toBeInTheDocument();
|
||||||
|
await user.type(hpInput, "59");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Apply damage" }));
|
||||||
|
|
||||||
|
// Verify HP decreased to 0 and unconscious state
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Current HP: 0 (unconscious)" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {
|
||||||
|
act,
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ConfirmButton } from "../components/ui/confirm-button";
|
||||||
|
|
||||||
|
function XIcon() {
|
||||||
|
return <span data-testid="x-icon">X</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderButton(
|
||||||
|
props: Partial<Parameters<typeof ConfirmButton>[0]> = {},
|
||||||
|
) {
|
||||||
|
const onConfirm = props.onConfirm ?? vi.fn();
|
||||||
|
render(
|
||||||
|
<ConfirmButton
|
||||||
|
icon={<XIcon />}
|
||||||
|
label="Remove combatant"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
{...props}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { onConfirm };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ConfirmButton", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the default icon in idle state", () => {
|
||||||
|
renderButton();
|
||||||
|
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button")).toHaveAttribute(
|
||||||
|
"aria-label",
|
||||||
|
"Remove combatant",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions to confirm state with Check icon on first click", () => {
|
||||||
|
renderButton();
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("x-icon")).toBeNull();
|
||||||
|
expect(screen.getByRole("button")).toHaveAttribute(
|
||||||
|
"aria-label",
|
||||||
|
"Confirm remove combatant",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button").className).toContain("bg-destructive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onConfirm on second click in confirm state", () => {
|
||||||
|
const { onConfirm } = renderButton();
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(onConfirm).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-reverts after 5 seconds", () => {
|
||||||
|
renderButton();
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("x-icon")).toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverts on Escape key", () => {
|
||||||
|
renderButton();
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("x-icon")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverts on click outside", () => {
|
||||||
|
renderButton();
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("x-icon")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.mouseDown(document.body);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not enter confirm state when disabled", () => {
|
||||||
|
renderButton({ disabled: true });
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up timer on unmount", () => {
|
||||||
|
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||||
|
renderButton();
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||||
|
clearTimeoutSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manages independent instances separately", () => {
|
||||||
|
const onConfirm1 = vi.fn();
|
||||||
|
const onConfirm2 = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<ConfirmButton
|
||||||
|
icon={<span data-testid="icon-1">1</span>}
|
||||||
|
label="Remove first"
|
||||||
|
onConfirm={onConfirm1}
|
||||||
|
/>
|
||||||
|
<ConfirmButton
|
||||||
|
icon={<span data-testid="icon-2">2</span>}
|
||||||
|
label="Remove second"
|
||||||
|
onConfirm={onConfirm2}
|
||||||
|
/>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
fireEvent.click(buttons[0]);
|
||||||
|
|
||||||
|
// First is confirming, second is still idle
|
||||||
|
expect(screen.queryByTestId("icon-1")).toBeNull();
|
||||||
|
expect(screen.getByTestId("icon-2")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// T008: Keyboard-specific tests
|
||||||
|
it("Enter key triggers confirm state", () => {
|
||||||
|
renderButton();
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
|
||||||
|
// Native button handles Enter via click event
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("x-icon")).toBeNull();
|
||||||
|
expect(button).toHaveAttribute("aria-label", "Confirm remove combatant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter in confirm state calls onConfirm", () => {
|
||||||
|
const { onConfirm } = renderButton();
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
|
||||||
|
fireEvent.click(button); // enter confirm state
|
||||||
|
fireEvent.click(button); // confirm
|
||||||
|
|
||||||
|
expect(onConfirm).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape in confirm state reverts", () => {
|
||||||
|
renderButton();
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button")).toHaveAttribute(
|
||||||
|
"aria-label",
|
||||||
|
"Remove combatant",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blur event reverts confirm state", () => {
|
||||||
|
renderButton();
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(screen.queryByTestId("x-icon")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.blur(button);
|
||||||
|
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter/Space keydown stops propagation to prevent parent handlers", () => {
|
||||||
|
const parentHandler = vi.fn();
|
||||||
|
function Wrapper() {
|
||||||
|
return (
|
||||||
|
<button type="button" onKeyDown={parentHandler}>
|
||||||
|
<ConfirmButton
|
||||||
|
icon={<XIcon />}
|
||||||
|
label="Remove combatant"
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Wrapper />);
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const confirmButton = buttons.at(-1) ?? buttons[0];
|
||||||
|
|
||||||
|
fireEvent.keyDown(confirmButton, { key: "Enter" });
|
||||||
|
fireEvent.keyDown(confirmButton, { key: " " });
|
||||||
|
|
||||||
|
expect(parentHandler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import {
|
||||||
|
combatantId,
|
||||||
|
type Encounter,
|
||||||
|
type ExportBundle,
|
||||||
|
type PlayerCharacter,
|
||||||
|
playerCharacterId,
|
||||||
|
type UndoRedoState,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
assembleExportBundle,
|
||||||
|
bundleToJson,
|
||||||
|
resolveFilename,
|
||||||
|
validateImportBundle,
|
||||||
|
} from "../persistence/export-import.js";
|
||||||
|
|
||||||
|
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
||||||
|
const DEFAULT_FILENAME_RE = /^initiative-export-\d{4}-\d{2}-\d{2}\.json$/;
|
||||||
|
|
||||||
|
const encounter: Encounter = {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: 15,
|
||||||
|
maxHp: 7,
|
||||||
|
currentHp: 7,
|
||||||
|
ac: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Aria",
|
||||||
|
initiative: 18,
|
||||||
|
maxHp: 45,
|
||||||
|
currentHp: 40,
|
||||||
|
ac: 16,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
playerCharacterId: playerCharacterId("pc-1"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const undoRedoState: UndoRedoState = {
|
||||||
|
undoStack: [
|
||||||
|
{
|
||||||
|
combatants: [{ id: combatantId("c-1"), name: "Goblin", initiative: 15 }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
redoStack: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const playerCharacters: PlayerCharacter[] = [
|
||||||
|
{
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Aria",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 45,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("assembleExportBundle", () => {
|
||||||
|
it("returns a bundle with version 1", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
expect(bundle.version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes an ISO timestamp", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
expect(bundle.exportedAt).toMatch(ISO_TIMESTAMP_RE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the encounter", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
expect(bundle.encounter).toEqual(encounter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes undo and redo stacks", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||||
|
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes player characters", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
expect(bundle.playerCharacters).toEqual(playerCharacters);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assembleExportBundle with includeHistory", () => {
|
||||||
|
it("excludes undo/redo stacks when includeHistory is false", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(bundle.undoStack).toHaveLength(0);
|
||||||
|
expect(bundle.redoStack).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes undo/redo stacks when includeHistory is true", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||||
|
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes undo/redo stacks by default", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bundleToJson", () => {
|
||||||
|
it("produces valid JSON that round-trips through validateImportBundle", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
const json = bundleToJson(bundle);
|
||||||
|
const parsed: unknown = JSON.parse(json);
|
||||||
|
const result = validateImportBundle(parsed);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveFilename", () => {
|
||||||
|
it("uses date-based default when no name provided", () => {
|
||||||
|
const result = resolveFilename();
|
||||||
|
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses date-based default for empty string", () => {
|
||||||
|
const result = resolveFilename("");
|
||||||
|
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses date-based default for whitespace-only string", () => {
|
||||||
|
const result = resolveFilename(" ");
|
||||||
|
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends .json to a custom name", () => {
|
||||||
|
expect(resolveFilename("my-encounter")).toBe("my-encounter.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-append .json", () => {
|
||||||
|
expect(resolveFilename("my-encounter.json")).toBe("my-encounter.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace from custom name", () => {
|
||||||
|
expect(resolveFilename(" my-encounter ")).toBe("my-encounter.json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("round-trip: export then import", () => {
|
||||||
|
it("produces identical state after round-trip", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
|
||||||
|
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||||
|
const result = validateImportBundle(serialized);
|
||||||
|
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const imported = result as ExportBundle;
|
||||||
|
expect(imported.version).toBe(bundle.version);
|
||||||
|
expect(imported.encounter).toEqual(bundle.encounter);
|
||||||
|
expect(imported.undoStack).toEqual(bundle.undoStack);
|
||||||
|
expect(imported.redoStack).toEqual(bundle.redoStack);
|
||||||
|
expect(imported.playerCharacters).toEqual(bundle.playerCharacters);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a combatant with cr field", () => {
|
||||||
|
const encounterWithCr: Encounter = {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Custom Thug",
|
||||||
|
cr: "2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const emptyUndoRedo: UndoRedoState = {
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
};
|
||||||
|
const bundle = assembleExportBundle(encounterWithCr, emptyUndoRedo, []);
|
||||||
|
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||||
|
const result = validateImportBundle(serialized);
|
||||||
|
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const imported = result as ExportBundle;
|
||||||
|
expect(imported.encounter.combatants[0].cr).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips 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: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const emptyUndoRedo: UndoRedoState = {
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
};
|
||||||
|
const bundle = assembleExportBundle(emptyEncounter, emptyUndoRedo, []);
|
||||||
|
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||||
|
const result = validateImportBundle(serialized);
|
||||||
|
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const imported = result as ExportBundle;
|
||||||
|
expect(imported.encounter.combatants).toHaveLength(0);
|
||||||
|
expect(imported.undoStack).toHaveLength(0);
|
||||||
|
expect(imported.redoStack).toHaveLength(0);
|
||||||
|
expect(imported.playerCharacters).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,28 @@
|
|||||||
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
export function buildPf2eCreature(
|
||||||
|
overrides?: Partial<Pf2eCreature>,
|
||||||
|
): Pf2eCreature {
|
||||||
|
const id = ++counter;
|
||||||
|
return {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId(`pf2e-creature-${id}`),
|
||||||
|
name: `PF2e Creature ${id}`,
|
||||||
|
source: "crb",
|
||||||
|
sourceDisplayName: "Core Rulebook",
|
||||||
|
level: 1,
|
||||||
|
traits: ["humanoid"],
|
||||||
|
perception: 5,
|
||||||
|
abilityMods: { str: 2, dex: 1, con: 2, int: 0, wis: 1, cha: -1 },
|
||||||
|
ac: 15,
|
||||||
|
saveFort: 7,
|
||||||
|
saveRef: 4,
|
||||||
|
saveWill: 5,
|
||||||
|
hp: 20,
|
||||||
|
speed: "25 ft.",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { buildCombatant } from "./build-combatant.js";
|
||||||
|
export { buildCreature } from "./build-creature.js";
|
||||||
|
export { buildEncounter } from "./build-encounter.js";
|
||||||
|
export { buildPf2eCreature } from "./build-pf2e-creature.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");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../contexts/bestiary-context.js", () => ({
|
||||||
|
useBestiaryContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../contexts/encounter-context.js", () => ({
|
||||||
|
useEncounterContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||||
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
|
||||||
|
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
||||||
|
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||||
|
const mockUseEncounterContext = vi.mocked(useEncounterContext);
|
||||||
|
|
||||||
|
const CLOSE_REGEX = /close/i;
|
||||||
|
const COLLAPSE_REGEX = /collapse/i;
|
||||||
|
const CREATURE_ID = "srd:goblin" as CreatureId;
|
||||||
|
const CREATURE: Creature = {
|
||||||
|
id: CREATURE_ID,
|
||||||
|
name: "Goblin",
|
||||||
|
source: "SRD",
|
||||||
|
sourceDisplayName: "SRD",
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockMatchMedia(matches: boolean) {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PanelOverrides {
|
||||||
|
creatureId?: CreatureId | null;
|
||||||
|
creature?: Creature | null;
|
||||||
|
panelRole?: "browse" | "pinned";
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
side?: "left" | "right";
|
||||||
|
bulkImportMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMocks(overrides: PanelOverrides = {}) {
|
||||||
|
const panelRole = overrides.panelRole ?? "browse";
|
||||||
|
const creatureId = overrides.creatureId ?? CREATURE_ID;
|
||||||
|
const creature = overrides.creature ?? CREATURE;
|
||||||
|
const isCollapsed = overrides.isCollapsed ?? false;
|
||||||
|
|
||||||
|
const onToggleCollapse = vi.fn();
|
||||||
|
const onPin = vi.fn();
|
||||||
|
const onUnpin = vi.fn();
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
|
||||||
|
mockUseSidePanelContext.mockReturnValue({
|
||||||
|
selectedCreatureId: panelRole === "browse" ? creatureId : null,
|
||||||
|
selectedCombatantId: null,
|
||||||
|
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
|
||||||
|
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
|
||||||
|
isWideDesktop: false,
|
||||||
|
bulkImportMode: overrides.bulkImportMode ?? false,
|
||||||
|
sourceManagerMode: false,
|
||||||
|
panelView: creatureId
|
||||||
|
? { mode: "creature" as const, creatureId }
|
||||||
|
: { mode: "closed" as const },
|
||||||
|
showCreature: vi.fn(),
|
||||||
|
updateCreature: vi.fn(),
|
||||||
|
showBulkImport: vi.fn(),
|
||||||
|
showSourceManager: vi.fn(),
|
||||||
|
dismissPanel: onDismiss,
|
||||||
|
toggleCollapse: onToggleCollapse,
|
||||||
|
togglePin: onPin,
|
||||||
|
unpin: onUnpin,
|
||||||
|
} as ReturnType<typeof useSidePanelContext>);
|
||||||
|
|
||||||
|
mockUseBestiaryContext.mockReturnValue({
|
||||||
|
getCreature: (id: CreatureId) => (id === creatureId ? creature : undefined),
|
||||||
|
isSourceCached: vi.fn().mockResolvedValue(true),
|
||||||
|
search: vi.fn().mockReturnValue([]),
|
||||||
|
isLoaded: true,
|
||||||
|
fetchAndCacheSource: vi.fn(),
|
||||||
|
uploadAndCacheSource: vi.fn(),
|
||||||
|
refreshCache: vi.fn(),
|
||||||
|
} as ReturnType<typeof useBestiaryContext>);
|
||||||
|
|
||||||
|
mockUseEncounterContext.mockReturnValue({
|
||||||
|
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
|
||||||
|
setCreatureAdjustment: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof useEncounterContext>);
|
||||||
|
|
||||||
|
return { onToggleCollapse, onPin, onUnpin, onDismiss };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(overrides: PanelOverrides = {}) {
|
||||||
|
const callbacks = setupMocks(overrides);
|
||||||
|
const panelRole = overrides.panelRole ?? "browse";
|
||||||
|
const side = overrides.side ?? (panelRole === "pinned" ? "left" : "right");
|
||||||
|
render(<StatBlockPanel panelRole={panelRole} side={side} />);
|
||||||
|
return callbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMatchMedia(true); // desktop by default
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("US1: Collapse and Expand", () => {
|
||||||
|
it("shows collapse button instead of close button on desktop", () => {
|
||||||
|
renderPanel();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: CLOSE_REGEX }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show 'Stat Block' heading", () => {
|
||||||
|
renderPanel();
|
||||||
|
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders collapsed tab with creature name when isCollapsed is true", () => {
|
||||||
|
renderPanel({ isCollapsed: true });
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onToggleCollapse when collapse button is clicked", () => {
|
||||||
|
const callbacks = renderPanel();
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
|
);
|
||||||
|
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
||||||
|
const callbacks = renderPanel({ isCollapsed: true });
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||||
|
);
|
||||||
|
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies translate-x class when collapsed (right side)", () => {
|
||||||
|
renderPanel({ isCollapsed: true, side: "right" });
|
||||||
|
const panel = screen
|
||||||
|
.getByRole("button", { name: "Expand stat block panel" })
|
||||||
|
.closest("div");
|
||||||
|
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies translate-x-0 when expanded", () => {
|
||||||
|
renderPanel({ isCollapsed: false });
|
||||||
|
const foldBtn = screen.getByRole("button", {
|
||||||
|
name: "Collapse stat block panel",
|
||||||
|
});
|
||||||
|
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
||||||
|
expect(panel?.className).toContain("translate-x-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("US1: Mobile behavior", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMatchMedia(false); // mobile
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows collapse button instead of X close button on mobile drawer", () => {
|
||||||
|
renderPanel();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// No X close icon button — only backdrop dismiss and collapse toggle
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
|
||||||
|
expect(buttonLabels).not.toContain("Close");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onDismiss when backdrop is clicked on mobile", () => {
|
||||||
|
const callbacks = renderPanel();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
|
||||||
|
expect(callbacks.onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render pinned panel on mobile", () => {
|
||||||
|
const { container } = render(
|
||||||
|
(() => {
|
||||||
|
setupMocks({ panelRole: "pinned" });
|
||||||
|
return <StatBlockPanel panelRole="pinned" side="left" />;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("US2: Pin and Unpin", () => {
|
||||||
|
it("shows pin button when isWideDesktop is true on desktop", () => {
|
||||||
|
setupMocks();
|
||||||
|
// Override to set isWideDesktop
|
||||||
|
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
|
||||||
|
typeof useSidePanelContext
|
||||||
|
>;
|
||||||
|
mockUseSidePanelContext.mockReturnValue({
|
||||||
|
...ctx,
|
||||||
|
isWideDesktop: true,
|
||||||
|
});
|
||||||
|
render(<StatBlockPanel panelRole="browse" side="right" />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Pin creature" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides pin button when isWideDesktop is false", () => {
|
||||||
|
renderPanel();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Pin creature" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onPin when pin button is clicked", () => {
|
||||||
|
setupMocks();
|
||||||
|
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
|
||||||
|
typeof useSidePanelContext
|
||||||
|
>;
|
||||||
|
mockUseSidePanelContext.mockReturnValue({
|
||||||
|
...ctx,
|
||||||
|
isWideDesktop: true,
|
||||||
|
});
|
||||||
|
render(<StatBlockPanel panelRole="browse" side="right" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
|
||||||
|
expect(ctx.togglePin).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows unpin button for pinned role", () => {
|
||||||
|
renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Unpin creature" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onUnpin when unpin button is clicked", () => {
|
||||||
|
const callbacks = renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
|
||||||
|
expect(callbacks.onUnpin).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("positions pinned panel on the left side", () => {
|
||||||
|
renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
|
const unpinBtn = screen.getByRole("button", {
|
||||||
|
name: "Unpin creature",
|
||||||
|
});
|
||||||
|
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
|
||||||
|
expect(panel?.className).toContain("left-0");
|
||||||
|
expect(panel?.className).toContain("border-r");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("positions browse panel on the right side", () => {
|
||||||
|
renderPanel({ panelRole: "browse", side: "right" });
|
||||||
|
const foldBtn = screen.getByRole("button", {
|
||||||
|
name: "Collapse stat block panel",
|
||||||
|
});
|
||||||
|
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
||||||
|
expect(panel?.className).toContain("right-0");
|
||||||
|
expect(panel?.className).toContain("border-l");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("US3: Collapse independence with pinned panel", () => {
|
||||||
|
it("pinned panel has no collapse button", () => {
|
||||||
|
renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: COLLAPSE_REGEX }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pinned panel is always expanded (no translate offset)", () => {
|
||||||
|
renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
|
const unpinBtn = screen.getByRole("button", {
|
||||||
|
name: "Unpin creature",
|
||||||
|
});
|
||||||
|
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
|
||||||
|
expect(panel?.className).toContain("translate-x-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { Adapters } from "../contexts/adapter-context.js";
|
||||||
|
import { AdapterProvider } from "../contexts/adapter-context.js";
|
||||||
|
import {
|
||||||
|
BestiaryProvider,
|
||||||
|
BulkImportProvider,
|
||||||
|
EncounterProvider,
|
||||||
|
InitiativeRollsProvider,
|
||||||
|
PlayerCharactersProvider,
|
||||||
|
RulesEditionProvider,
|
||||||
|
SidePanelProvider,
|
||||||
|
ThemeProvider,
|
||||||
|
} from "../contexts/index.js";
|
||||||
|
import { createTestAdapters } from "./adapters/in-memory-adapters.js";
|
||||||
|
|
||||||
|
export function AllProviders({
|
||||||
|
adapters,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
adapters?: Adapters;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const resolved = adapters ?? createTestAdapters();
|
||||||
|
return (
|
||||||
|
<AdapterProvider adapters={resolved}>
|
||||||
|
<ThemeProvider>
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<EncounterProvider>
|
||||||
|
<BestiaryProvider>
|
||||||
|
<PlayerCharactersProvider>
|
||||||
|
<BulkImportProvider>
|
||||||
|
<SidePanelProvider>
|
||||||
|
<InitiativeRollsProvider>
|
||||||
|
{children}
|
||||||
|
</InitiativeRollsProvider>
|
||||||
|
</SidePanelProvider>
|
||||||
|
</BulkImportProvider>
|
||||||
|
</PlayerCharactersProvider>
|
||||||
|
</BestiaryProvider>
|
||||||
|
</EncounterProvider>
|
||||||
|
</RulesEditionProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</AdapterProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import * as indexAdapter from "../adapters/bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
// We test the bulk import logic by extracting and exercising the async flow.
|
||||||
|
// Since useBulkImport is a thin React wrapper around async logic,
|
||||||
|
// we test the core behavior via a direct simulation.
|
||||||
|
|
||||||
|
vi.mock("../adapters/bestiary-index-adapter.js", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("../adapters/bestiary-index-adapter.js")
|
||||||
|
>("../adapters/bestiary-index-adapter.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getAllSourceCodes: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockGetAllSourceCodes = vi.mocked(indexAdapter.getAllSourceCodes);
|
||||||
|
|
||||||
|
interface BulkImportState {
|
||||||
|
status: "idle" | "loading" | "complete" | "partial-failure";
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulate the core bulk import logic extracted from the hook */
|
||||||
|
async function runBulkImport(
|
||||||
|
baseUrl: string,
|
||||||
|
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
||||||
|
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||||
|
refreshCache: () => Promise<void>,
|
||||||
|
): Promise<BulkImportState> {
|
||||||
|
const allCodes = indexAdapter.getAllSourceCodes();
|
||||||
|
const total = allCodes.length;
|
||||||
|
|
||||||
|
const cacheChecks = await Promise.all(
|
||||||
|
allCodes.map(async (code) => ({
|
||||||
|
code,
|
||||||
|
cached: await isSourceCached(code),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const alreadyCached = cacheChecks.filter((c) => c.cached).length;
|
||||||
|
const uncached = cacheChecks.filter((c) => !c.cached);
|
||||||
|
|
||||||
|
if (uncached.length === 0) {
|
||||||
|
return { status: "complete", total, completed: total, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let completed = alreadyCached;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
uncached.map(async ({ code }) => {
|
||||||
|
const url = indexAdapter.getDefaultFetchUrl(code, baseUrl);
|
||||||
|
try {
|
||||||
|
await fetchAndCacheSource(code, url);
|
||||||
|
completed++;
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await refreshCache();
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: failed > 0 ? "partial-failure" : "complete",
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
failed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bulk import logic", () => {
|
||||||
|
it("skips already-cached sources and counts them into completed", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["SRC1", "SRC2", "SRC3"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const isSourceCached = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((code: string) =>
|
||||||
|
Promise.resolve(code === "SRC1" || code === "SRC3"),
|
||||||
|
);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchAndCache).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchAndCache).toHaveBeenCalledWith(
|
||||||
|
"SRC2",
|
||||||
|
"https://example.com/bestiary-src2.json",
|
||||||
|
);
|
||||||
|
expect(result.completed).toBe(3);
|
||||||
|
expect(result.status).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments completed on successful fetch", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["SRC1"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.completed).toBe(1);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
expect(result.status).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments failed on rejected fetch", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["SRC1"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn().mockRejectedValue(new Error("fail"));
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(result.completed).toBe(0);
|
||||||
|
expect(result.status).toBe("partial-failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions to complete when all succeed", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe("complete");
|
||||||
|
expect(result.completed).toBe(2);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions to partial-failure when any fetch fails", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockRejectedValueOnce(new Error("fail"));
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe("partial-failure");
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("immediately transitions to complete when all sources are cached", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["A", "B", "C"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn();
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe("complete");
|
||||||
|
expect(result.total).toBe(3);
|
||||||
|
expect(result.completed).toBe(3);
|
||||||
|
expect(fetchAndCache).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls refreshCache exactly once when all settle", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(refreshCache).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call refreshCache when all sources are already cached", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["A"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn();
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(refreshCache).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import type { ExportBundle } from "@initiative/domain";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateImportBundle } from "../persistence/export-import.js";
|
||||||
|
|
||||||
|
function validBundle(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: "2026-03-27T12:00:00.000Z",
|
||||||
|
encounter: {
|
||||||
|
combatants: [{ id: "c-1", name: "Goblin", initiative: 15 }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
playerCharacters: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validateImportBundle", () => {
|
||||||
|
it("accepts a valid bundle", () => {
|
||||||
|
const result = validateImportBundle(validBundle());
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.version).toBe(1);
|
||||||
|
expect(bundle.encounter.combatants).toHaveLength(1);
|
||||||
|
expect(bundle.encounter.combatants[0].name).toBe("Goblin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a valid bundle with empty encounter", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.encounter.combatants).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a bundle with undo/redo stacks", () => {
|
||||||
|
const enc = {
|
||||||
|
combatants: [{ id: "c-1", name: "Orc" }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
undoStack: [enc],
|
||||||
|
redoStack: [enc],
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.undoStack).toHaveLength(1);
|
||||||
|
expect(bundle.redoStack).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a bundle with player characters", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
playerCharacters: [
|
||||||
|
{
|
||||||
|
id: "pc-1",
|
||||||
|
name: "Aria",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 45,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.playerCharacters).toHaveLength(1);
|
||||||
|
expect(bundle.playerCharacters[0].name).toBe("Aria");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-object input", () => {
|
||||||
|
expect(validateImportBundle(null)).toBe("Invalid file format");
|
||||||
|
expect(validateImportBundle(42)).toBe("Invalid file format");
|
||||||
|
expect(validateImportBundle("string")).toBe("Invalid file format");
|
||||||
|
expect(validateImportBundle([])).toBe("Invalid file format");
|
||||||
|
expect(validateImportBundle(undefined)).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing version field", () => {
|
||||||
|
const input = validBundle();
|
||||||
|
delete input.version;
|
||||||
|
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects version 0 or negative", () => {
|
||||||
|
expect(validateImportBundle({ ...validBundle(), version: 0 })).toBe(
|
||||||
|
"Invalid file format",
|
||||||
|
);
|
||||||
|
expect(validateImportBundle({ ...validBundle(), version: -1 })).toBe(
|
||||||
|
"Invalid file format",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown version", () => {
|
||||||
|
expect(validateImportBundle({ ...validBundle(), version: 99 })).toBe(
|
||||||
|
"Invalid file format",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing encounter field", () => {
|
||||||
|
const input = validBundle();
|
||||||
|
delete input.encounter;
|
||||||
|
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid encounter data", () => {
|
||||||
|
expect(
|
||||||
|
validateImportBundle({ ...validBundle(), encounter: "not an object" }),
|
||||||
|
).toBe("Invalid encounter data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing undoStack", () => {
|
||||||
|
const input = validBundle();
|
||||||
|
delete input.undoStack;
|
||||||
|
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing redoStack", () => {
|
||||||
|
const input = validBundle();
|
||||||
|
delete input.redoStack;
|
||||||
|
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing playerCharacters", () => {
|
||||||
|
const input = validBundle();
|
||||||
|
delete input.playerCharacters;
|
||||||
|
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-string exportedAt", () => {
|
||||||
|
expect(validateImportBundle({ ...validBundle(), exportedAt: 12345 })).toBe(
|
||||||
|
"Invalid file format",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid entries from undo stack", () => {
|
||||||
|
const valid = {
|
||||||
|
combatants: [{ id: "c-1", name: "Orc" }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
undoStack: [valid, "invalid", { bad: true }, valid],
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.undoStack).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid player characters", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: "pc-1", name: "Valid", ac: 10, maxHp: 20 },
|
||||||
|
{ id: "", name: "Bad ID" },
|
||||||
|
"not an object",
|
||||||
|
{ id: "pc-3", name: "Also Valid", ac: 15, maxHp: 30 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.playerCharacters).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects JSON array instead of object", () => {
|
||||||
|
expect(validateImportBundle([1, 2, 3])).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects encounter that fails rehydration (missing combatant fields)", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
encounter: {
|
||||||
|
combatants: [{ noId: true }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips invalid color/icon from player characters but keeps the character", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
playerCharacters: [
|
||||||
|
{
|
||||||
|
id: "pc-1",
|
||||||
|
name: "Test",
|
||||||
|
ac: 10,
|
||||||
|
maxHp: 20,
|
||||||
|
color: "neon-pink",
|
||||||
|
icon: "bazooka",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
// rehydrateCharacter rejects characters with invalid color/icon members
|
||||||
|
// that are not in the valid sets, so this character is dropped
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.playerCharacters).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps player characters with valid optional color and icon", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
playerCharacters: [
|
||||||
|
{
|
||||||
|
id: "pc-1",
|
||||||
|
name: "Aria",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 45,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.playerCharacters).toHaveLength(1);
|
||||||
|
expect(bundle.playerCharacters[0].color).toBe("blue");
|
||||||
|
expect(bundle.playerCharacters[0].icon).toBe("sword");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unknown extra fields on the bundle", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
unknownField: "should be ignored",
|
||||||
|
anotherExtra: 42,
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.version).toBe(1);
|
||||||
|
expect("unknownField" in bundle).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
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 | undefined): string {
|
||||||
|
if (!trait) return "";
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeBestiary", () => {
|
||||||
|
it("normalizes a simple creature", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Goblin Warrior",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["S"],
|
||||||
|
type: { type: "fey", tags: ["goblinoid"] },
|
||||||
|
alignment: ["C", "N"],
|
||||||
|
ac: [15],
|
||||||
|
hp: { average: 10, formula: "3d6" },
|
||||||
|
speed: { walk: 30 },
|
||||||
|
str: 8,
|
||||||
|
dex: 15,
|
||||||
|
con: 10,
|
||||||
|
int: 10,
|
||||||
|
wis: 8,
|
||||||
|
cha: 8,
|
||||||
|
skill: { stealth: "+6" },
|
||||||
|
senses: ["Darkvision 60 ft."],
|
||||||
|
passive: 9,
|
||||||
|
languages: ["Common", "Goblin"],
|
||||||
|
cr: "1/4",
|
||||||
|
action: [
|
||||||
|
{
|
||||||
|
name: "Scimitar",
|
||||||
|
entries: [
|
||||||
|
"{@atkr m} {@hit 4}, reach 5 ft. {@h}5 ({@damage 1d6 + 2}) Slashing damage.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bonus: [
|
||||||
|
{
|
||||||
|
name: "Nimble Escape",
|
||||||
|
entries: [
|
||||||
|
"The goblin takes the {@action Disengage|XPHB} or {@action Hide|XPHB} action.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
expect(creatures).toHaveLength(1);
|
||||||
|
|
||||||
|
const c = creatures[0];
|
||||||
|
expect(c.id).toBe("xmm:goblin-warrior");
|
||||||
|
expect(c.name).toBe("Goblin Warrior");
|
||||||
|
expect(c.source).toBe("XMM");
|
||||||
|
expect(c.sourceDisplayName).toBe("MM 2024");
|
||||||
|
expect(c.size).toBe("Small");
|
||||||
|
expect(c.type).toBe("Fey (Goblinoid)");
|
||||||
|
expect(c.alignment).toBe("Chaotic Neutral");
|
||||||
|
expect(c.ac).toBe(15);
|
||||||
|
expect(c.hp).toEqual({ average: 10, formula: "3d6" });
|
||||||
|
expect(c.speed).toBe("30 ft.");
|
||||||
|
expect(c.abilities.dex).toBe(15);
|
||||||
|
expect(c.cr).toBe("1/4");
|
||||||
|
expect(c.proficiencyBonus).toBe(2);
|
||||||
|
expect(c.passive).toBe(9);
|
||||||
|
expect(c.skills).toBe("Stealth +6");
|
||||||
|
expect(c.senses).toBe("Darkvision 60 ft.");
|
||||||
|
expect(c.languages).toBe("Common, Goblin");
|
||||||
|
expect(c.actions).toHaveLength(1);
|
||||||
|
expect(flatText(c.actions?.[0])).toContain("Melee Attack Roll:");
|
||||||
|
expect(flatText(c.actions?.[0])).not.toContain("{@");
|
||||||
|
expect(c.bonusActions).toHaveLength(1);
|
||||||
|
expect(flatText(c.bonusActions?.[0])).toContain("Disengage");
|
||||||
|
expect(flatText(c.bonusActions?.[0])).not.toContain("{@");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a creature with legendary actions", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Aboleth",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["L"],
|
||||||
|
type: "aberration",
|
||||||
|
alignment: ["L", "E"],
|
||||||
|
ac: [17],
|
||||||
|
hp: { average: 135, formula: "18d10 + 36" },
|
||||||
|
speed: { walk: 10, swim: 40 },
|
||||||
|
str: 21,
|
||||||
|
dex: 9,
|
||||||
|
con: 15,
|
||||||
|
int: 18,
|
||||||
|
wis: 15,
|
||||||
|
cha: 18,
|
||||||
|
save: { con: "+6", int: "+8", wis: "+6" },
|
||||||
|
senses: ["Darkvision 120 ft."],
|
||||||
|
passive: 12,
|
||||||
|
languages: ["Deep Speech", "Telepathy 120 ft."],
|
||||||
|
cr: "10",
|
||||||
|
legendary: [
|
||||||
|
{
|
||||||
|
name: "Lash",
|
||||||
|
entries: ["The aboleth makes one Tentacle attack."],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const c = creatures[0];
|
||||||
|
expect(c.legendaryActions).toBeDefined();
|
||||||
|
expect(c.legendaryActions?.preamble).toContain("3 Legendary Actions");
|
||||||
|
expect(c.legendaryActions?.entries).toHaveLength(1);
|
||||||
|
expect(c.legendaryActions?.entries[0].name).toBe("Lash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a creature with spellcasting", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Test Caster",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["M"],
|
||||||
|
type: "humanoid",
|
||||||
|
ac: [12],
|
||||||
|
hp: { average: 40, formula: "9d8" },
|
||||||
|
speed: { walk: 30 },
|
||||||
|
str: 10,
|
||||||
|
dex: 14,
|
||||||
|
con: 10,
|
||||||
|
int: 17,
|
||||||
|
wis: 12,
|
||||||
|
cha: 11,
|
||||||
|
passive: 11,
|
||||||
|
cr: "6",
|
||||||
|
spellcasting: [
|
||||||
|
{
|
||||||
|
name: "Spellcasting",
|
||||||
|
headerEntries: [
|
||||||
|
"The caster casts spells using Intelligence (spell save {@dc 15}):",
|
||||||
|
],
|
||||||
|
will: ["{@spell Detect Magic|XPHB}", "{@spell Mage Hand|XPHB}"],
|
||||||
|
daily: {
|
||||||
|
"2e": ["{@spell Fireball|XPHB}"],
|
||||||
|
"1": ["{@spell Dimension Door|XPHB}"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const c = creatures[0];
|
||||||
|
expect(c.spellcasting).toHaveLength(1);
|
||||||
|
const sc = c.spellcasting?.[0];
|
||||||
|
expect(sc).toBeDefined();
|
||||||
|
expect(sc?.name).toBe("Spellcasting");
|
||||||
|
expect(sc?.headerText).toContain("DC 15");
|
||||||
|
expect(sc?.headerText).not.toContain("{@");
|
||||||
|
expect(sc?.atWill).toEqual([
|
||||||
|
{ name: "Detect Magic" },
|
||||||
|
{ name: "Mage Hand" },
|
||||||
|
]);
|
||||||
|
expect(sc?.daily).toHaveLength(2);
|
||||||
|
expect(sc?.daily).toContainEqual({
|
||||||
|
uses: 2,
|
||||||
|
each: true,
|
||||||
|
spells: [{ name: "Fireball" }],
|
||||||
|
});
|
||||||
|
expect(sc?.daily).toContainEqual({
|
||||||
|
uses: 1,
|
||||||
|
each: false,
|
||||||
|
spells: [{ name: "Dimension Door" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a creature with object-type type field", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Swarm of Bats",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["L"],
|
||||||
|
type: { type: "beast", swarmSize: "T" },
|
||||||
|
ac: [12],
|
||||||
|
hp: { average: 11, formula: "2d10" },
|
||||||
|
speed: { walk: 5, fly: 30 },
|
||||||
|
str: 5,
|
||||||
|
dex: 15,
|
||||||
|
con: 10,
|
||||||
|
int: 2,
|
||||||
|
wis: 12,
|
||||||
|
cha: 4,
|
||||||
|
passive: 11,
|
||||||
|
resist: ["bludgeoning", "piercing", "slashing"],
|
||||||
|
conditionImmune: ["charmed", "frightened"],
|
||||||
|
cr: "1/4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const c = creatures[0];
|
||||||
|
expect(c.type).toBe("Swarm of Tiny Beasts");
|
||||||
|
expect(c.resist).toBe("Bludgeoning, Piercing, Slashing");
|
||||||
|
expect(c.conditionImmune).toBe("Charmed, Frightened");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a creature with conditional resistances", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Half-Dragon",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["M"],
|
||||||
|
type: "humanoid",
|
||||||
|
ac: [18],
|
||||||
|
hp: { average: 65, formula: "10d8 + 20" },
|
||||||
|
speed: { walk: 30 },
|
||||||
|
str: 16,
|
||||||
|
dex: 13,
|
||||||
|
con: 14,
|
||||||
|
int: 10,
|
||||||
|
wis: 11,
|
||||||
|
cha: 10,
|
||||||
|
passive: 10,
|
||||||
|
cr: "5",
|
||||||
|
resist: [
|
||||||
|
{
|
||||||
|
special: "Damage type chosen for the Draconic Origin trait",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const c = creatures[0];
|
||||||
|
expect(c.resist).toBe("Damage type chosen for the Draconic Origin trait");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a creature with multiple sizes", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Aberrant Cultist",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["S", "M"],
|
||||||
|
type: "humanoid",
|
||||||
|
ac: [13],
|
||||||
|
hp: { average: 22, formula: "4d8 + 4" },
|
||||||
|
speed: { walk: 30 },
|
||||||
|
str: 11,
|
||||||
|
dex: 14,
|
||||||
|
con: 12,
|
||||||
|
int: 10,
|
||||||
|
wis: 13,
|
||||||
|
cha: 8,
|
||||||
|
passive: 11,
|
||||||
|
cr: "1/2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
expect(creatures[0].size).toBe("Small or Medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a creature with CR as object", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Dragon",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["H"],
|
||||||
|
type: "dragon",
|
||||||
|
ac: [19],
|
||||||
|
hp: { average: 256, formula: "19d12 + 133" },
|
||||||
|
speed: { walk: 40 },
|
||||||
|
str: 27,
|
||||||
|
dex: 10,
|
||||||
|
con: 25,
|
||||||
|
int: 16,
|
||||||
|
wis: 13,
|
||||||
|
cha: 23,
|
||||||
|
passive: 23,
|
||||||
|
cr: { cr: "17", xpLair: 20000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
expect(creatures[0].cr).toBe("17");
|
||||||
|
expect(creatures[0].proficiencyBonus).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes pre-2024 {@atk mw} tags to full attack type text", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Adult Black Dragon",
|
||||||
|
source: "MM",
|
||||||
|
size: ["H"],
|
||||||
|
type: "dragon",
|
||||||
|
ac: [19],
|
||||||
|
hp: { average: 195, formula: "17d12 + 85" },
|
||||||
|
speed: { walk: 40, fly: 80, swim: 40 },
|
||||||
|
str: 23,
|
||||||
|
dex: 14,
|
||||||
|
con: 21,
|
||||||
|
int: 14,
|
||||||
|
wis: 13,
|
||||||
|
cha: 17,
|
||||||
|
passive: 21,
|
||||||
|
cr: "14",
|
||||||
|
action: [
|
||||||
|
{
|
||||||
|
name: "Bite",
|
||||||
|
entries: [
|
||||||
|
"{@atk mw} {@hit 11} to hit, reach 10 ft., one target. {@h}17 ({@damage 2d10 + 6}) piercing damage plus 4 ({@damage 1d8}) acid damage.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const bite = creatures[0].actions?.[0];
|
||||||
|
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", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Air Elemental",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["L"],
|
||||||
|
type: "elemental",
|
||||||
|
ac: [15],
|
||||||
|
hp: { average: 90, formula: "12d10 + 24" },
|
||||||
|
speed: {
|
||||||
|
walk: 10,
|
||||||
|
fly: { number: 90, condition: "(hover)" },
|
||||||
|
canHover: true,
|
||||||
|
},
|
||||||
|
str: 14,
|
||||||
|
dex: 20,
|
||||||
|
con: 14,
|
||||||
|
int: 6,
|
||||||
|
wis: 10,
|
||||||
|
cha: 6,
|
||||||
|
passive: 10,
|
||||||
|
cr: "5",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
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).toBeDefined();
|
||||||
|
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).toBeDefined();
|
||||||
|
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("dnd", "MM", "Monster Manual", creatures);
|
||||||
|
|
||||||
|
expect(await isSourceCached("dnd", "MM")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isSourceCached returns false for uncached source", async () => {
|
||||||
|
expect(await isSourceCached("dnd", "XGE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCachedSources returns sources from in-memory store", async () => {
|
||||||
|
await cacheSource("dnd", "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("dnd", "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("dnd", "MM", "Monster Manual", []);
|
||||||
|
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
|
await clearSource("dnd", "MM");
|
||||||
|
|
||||||
|
expect(await isSourceCached("dnd", "MM")).toBe(false);
|
||||||
|
expect(await isSourceCached("dnd", "VGM")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearAll removes all data from in-memory store", async () => {
|
||||||
|
await cacheSource("dnd", "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("dnd", "MM", "Monster Manual", creatures);
|
||||||
|
|
||||||
|
expect(fakeStore.has("dnd:MM")).toBe(true);
|
||||||
|
const record = fakeStore.get("dnd:MM") as {
|
||||||
|
sourceCode: string;
|
||||||
|
displayName: string;
|
||||||
|
creatures: Creature[];
|
||||||
|
creatureCount: number;
|
||||||
|
cachedAt: number;
|
||||||
|
};
|
||||||
|
expect(record.sourceCode).toBe("dnd: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("dnd", "XGE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true after caching", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
|
expect(await isSourceCached("dnd", "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("dnd", "MM", "Monster Manual", [
|
||||||
|
makeCreature("mm:goblin", "Goblin"),
|
||||||
|
makeCreature("mm:orc", "Orc"),
|
||||||
|
]);
|
||||||
|
await cacheSource("dnd", "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("dnd", "MM", "Monster Manual", [goblin, orc]);
|
||||||
|
await cacheSource("dnd", "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("dnd", "MM", "Monster Manual", []);
|
||||||
|
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
|
await clearSource("dnd", "MM");
|
||||||
|
|
||||||
|
expect(await isSourceCached("dnd", "MM")).toBe(false);
|
||||||
|
expect(await isSourceCached("dnd", "VGM")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearAll", () => {
|
||||||
|
it("removes all cached data", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
|
await cacheSource("dnd", "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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const PACK_DIR_PREFIX = /^pathfinder-monster-core\//;
|
||||||
|
const JSON_EXTENSION = /\.json$/;
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAllPf2eSourceCodes,
|
||||||
|
getCreaturePathsForSource,
|
||||||
|
getDefaultPf2eFetchUrl,
|
||||||
|
getPf2eSourceDisplayName,
|
||||||
|
loadPf2eBestiaryIndex,
|
||||||
|
} from "../pf2e-bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
describe("loadPf2eBestiaryIndex", () => {
|
||||||
|
it("returns an object with sources and creatures", () => {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
expect(index.sources).toBeDefined();
|
||||||
|
expect(index.creatures).toBeDefined();
|
||||||
|
expect(Array.isArray(index.creatures)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creatures have the expected PF2e shape", () => {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
expect(index.creatures.length).toBeGreaterThan(0);
|
||||||
|
const first = index.creatures[0];
|
||||||
|
expect(first).toHaveProperty("name");
|
||||||
|
expect(first).toHaveProperty("source");
|
||||||
|
expect(first).toHaveProperty("level");
|
||||||
|
expect(first).toHaveProperty("ac");
|
||||||
|
expect(first).toHaveProperty("hp");
|
||||||
|
expect(first).toHaveProperty("perception");
|
||||||
|
expect(first).toHaveProperty("size");
|
||||||
|
expect(first).toHaveProperty("type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains a substantial number of creatures", () => {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
expect(index.creatures.length).toBeGreaterThan(2500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creatures have size and type populated", () => {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
const withSize = index.creatures.filter((c) => c.size !== "");
|
||||||
|
const withType = index.creatures.filter((c) => c.type !== "");
|
||||||
|
expect(withSize.length).toBeGreaterThan(index.creatures.length * 0.9);
|
||||||
|
expect(withType.length).toBeGreaterThan(index.creatures.length * 0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same cached instance on subsequent calls", () => {
|
||||||
|
const a = loadPf2eBestiaryIndex();
|
||||||
|
const b = loadPf2eBestiaryIndex();
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllPf2eSourceCodes", () => {
|
||||||
|
it("returns all keys from the index sources", () => {
|
||||||
|
const codes = getAllPf2eSourceCodes();
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
expect(codes).toEqual(Object.keys(index.sources));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDefaultPf2eFetchUrl", () => {
|
||||||
|
it("returns Foundry VTT PF2e base URL", () => {
|
||||||
|
const url = getDefaultPf2eFetchUrl("pathfinder-monster-core");
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes custom base URL with trailing slash", () => {
|
||||||
|
const url = getDefaultPf2eFetchUrl(
|
||||||
|
"pathfinder-monster-core",
|
||||||
|
"https://example.com/pf2e",
|
||||||
|
);
|
||||||
|
expect(url).toBe("https://example.com/pf2e/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPf2eSourceDisplayName", () => {
|
||||||
|
it("returns display name for a known source", () => {
|
||||||
|
const name = getPf2eSourceDisplayName("pathfinder-monster-core");
|
||||||
|
expect(name).toBe("Monster Core");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to source code for unknown source", () => {
|
||||||
|
expect(getPf2eSourceDisplayName("UNKNOWN")).toBe("UNKNOWN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCreaturePathsForSource", () => {
|
||||||
|
it("returns file paths for a known source", () => {
|
||||||
|
const paths = getCreaturePathsForSource("pathfinder-monster-core");
|
||||||
|
expect(paths.length).toBeGreaterThan(100);
|
||||||
|
expect(paths[0]).toMatch(PACK_DIR_PREFIX);
|
||||||
|
expect(paths[0]).toMatch(JSON_EXTENSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for unknown source", () => {
|
||||||
|
expect(getCreaturePathsForSource("nonexistent")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { stripFoundryTags } from "../strip-foundry-tags.js";
|
||||||
|
|
||||||
|
describe("stripFoundryTags", () => {
|
||||||
|
describe("@Damage tags", () => {
|
||||||
|
it("formats damage with type bracket", () => {
|
||||||
|
expect(stripFoundryTags("@Damage[3d6+10[fire]]")).toBe("3d6+10 fire");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers display text when present", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@Damage[3d6+10[fire]]{3d6+10 fire damage}"),
|
||||||
|
).toBe("3d6+10 fire damage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple damage types", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@Damage[2d8+5[slashing]] plus @Damage[1d6[fire]]"),
|
||||||
|
).toBe("2d8+5 slashing plus 1d6 fire");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("@Check tags", () => {
|
||||||
|
it("formats basic saving throw", () => {
|
||||||
|
expect(stripFoundryTags("@Check[reflex|dc:33|basic]")).toBe(
|
||||||
|
"DC 33 basic Reflex",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats non-basic check", () => {
|
||||||
|
expect(stripFoundryTags("@Check[athletics|dc:25]")).toBe(
|
||||||
|
"DC 25 Athletics",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats check without DC", () => {
|
||||||
|
expect(stripFoundryTags("@Check[fortitude]")).toBe("Fortitude");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("@UUID tags", () => {
|
||||||
|
it("extracts display text", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags(
|
||||||
|
"@UUID[Compendium.pf2e.conditionitems.Item.Grabbed]{Grabbed}",
|
||||||
|
),
|
||||||
|
).toBe("Grabbed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts last segment when no display text", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@UUID[Compendium.pf2e.conditionitems.Item.Grabbed]"),
|
||||||
|
).toBe("Grabbed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("@Template tags", () => {
|
||||||
|
it("formats cone template", () => {
|
||||||
|
expect(stripFoundryTags("@Template[cone|distance:40]")).toBe(
|
||||||
|
"40-foot cone",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats emanation template", () => {
|
||||||
|
expect(stripFoundryTags("@Template[emanation|distance:10]")).toBe(
|
||||||
|
"10-foot emanation",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers display text", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@Template[cone|distance:40]{40-foot cone}"),
|
||||||
|
).toBe("40-foot cone");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unknown @Tag patterns", () => {
|
||||||
|
it("uses display text for unknown tags", () => {
|
||||||
|
expect(stripFoundryTags("@Localize[some.key]{Some Text}")).toBe(
|
||||||
|
"Some Text",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips unknown tags without display text", () => {
|
||||||
|
expect(stripFoundryTags("@Localize[some.key]")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML stripping", () => {
|
||||||
|
it("strips paragraph tags", () => {
|
||||||
|
expect(stripFoundryTags("<p>text</p>")).toBe("text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts br to newline", () => {
|
||||||
|
expect(stripFoundryTags("line1<br />line2")).toBe("line1\nline2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts hr to newline", () => {
|
||||||
|
expect(stripFoundryTags("before<hr />after")).toBe("before\nafter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves strong and em tags", () => {
|
||||||
|
expect(stripFoundryTags("<strong>bold</strong> <em>italic</em>")).toBe(
|
||||||
|
"<strong>bold</strong> <em>italic</em>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves list tags", () => {
|
||||||
|
expect(stripFoundryTags("<ul><li>first</li><li>second</li></ul>")).toBe(
|
||||||
|
"<ul><li>first</li><li>second</li></ul>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts p-to-p transitions to newlines", () => {
|
||||||
|
expect(stripFoundryTags("<p>first</p><p>second</p>")).toBe(
|
||||||
|
"first\nsecond",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips action-glyph spans", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags('<span class="action-glyph">1</span> Strike'),
|
||||||
|
).toBe("Strike");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML entities", () => {
|
||||||
|
it("decodes &", () => {
|
||||||
|
expect(stripFoundryTags("fire & ice")).toBe("fire & ice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes < and >", () => {
|
||||||
|
expect(stripFoundryTags("<tag>")).toBe("<tag>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes "", () => {
|
||||||
|
expect(stripFoundryTags(""hello"")).toBe('"hello"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("whitespace handling", () => {
|
||||||
|
it("collapses multiple spaces", () => {
|
||||||
|
expect(stripFoundryTags("a b c")).toBe("a b c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses multiple blank lines", () => {
|
||||||
|
expect(stripFoundryTags("a\n\n\nb")).toBe("a\nb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims leading and trailing whitespace", () => {
|
||||||
|
expect(stripFoundryTags(" hello ")).toBe("hello");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combined/edge cases", () => {
|
||||||
|
it("handles enrichment tags inside HTML", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags(
|
||||||
|
"<p>Deal @Damage[2d6[fire]] damage, @Check[reflex|dc:20|basic] save.</p>",
|
||||||
|
),
|
||||||
|
).toBe("Deal 2d6 fire damage, DC 20 basic Reflex save.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty string", () => {
|
||||||
|
expect(stripFoundryTags("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { stripTags } from "../strip-tags.js";
|
||||||
|
|
||||||
|
describe("stripTags", () => {
|
||||||
|
it("returns text unchanged when no tags present", () => {
|
||||||
|
expect(stripTags("Hello world")).toBe("Hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@spell Name|Source} to Name", () => {
|
||||||
|
expect(stripTags("{@spell Fireball|XPHB}")).toBe("Fireball");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@condition Name|Source} to Name", () => {
|
||||||
|
expect(stripTags("{@condition Frightened|XPHB}")).toBe("Frightened");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@damage dice} to dice", () => {
|
||||||
|
expect(stripTags("{@damage 2d10}")).toBe("2d10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@dice value} to value", () => {
|
||||||
|
expect(stripTags("{@dice 5d10}")).toBe("5d10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@dc N} to DC N", () => {
|
||||||
|
expect(stripTags("{@dc 15}")).toBe("DC 15");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@hit N} to +N", () => {
|
||||||
|
expect(stripTags("{@hit 5}")).toBe("+5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@h} to Hit:", () => {
|
||||||
|
expect(stripTags("{@h}")).toBe("Hit: ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@hom} to Hit or Miss:", () => {
|
||||||
|
expect(stripTags("{@hom}")).toBe("Hit or Miss: ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atkr m} to Melee Attack Roll:", () => {
|
||||||
|
expect(stripTags("{@atkr m}")).toBe("Melee Attack Roll:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atkr r} to Ranged Attack Roll:", () => {
|
||||||
|
expect(stripTags("{@atkr r}")).toBe("Ranged Attack Roll:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atkr m,r} to Melee or Ranged Attack Roll:", () => {
|
||||||
|
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk mw} to Melee Weapon Attack:", () => {
|
||||||
|
expect(stripTags("{@atk mw}")).toBe("Melee Weapon Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk rw} to Ranged Weapon Attack:", () => {
|
||||||
|
expect(stripTags("{@atk rw}")).toBe("Ranged Weapon Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk ms} to Melee Spell Attack:", () => {
|
||||||
|
expect(stripTags("{@atk ms}")).toBe("Melee Spell Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk rs} to Ranged Spell Attack:", () => {
|
||||||
|
expect(stripTags("{@atk rs}")).toBe("Ranged Spell Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk mw,rw} to Melee or Ranged Weapon Attack:", () => {
|
||||||
|
expect(stripTags("{@atk mw,rw}")).toBe("Melee or Ranged Weapon Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@recharge 5} to (Recharge 5-6)", () => {
|
||||||
|
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@recharge} to (Recharge 6)", () => {
|
||||||
|
expect(stripTags("{@recharge}")).toBe("(Recharge 6)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actSave wis} to Wisdom saving throw", () => {
|
||||||
|
expect(stripTags("{@actSave wis}")).toBe("Wisdom saving throw");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actSaveFail} to Failure:", () => {
|
||||||
|
expect(stripTags("{@actSaveFail}")).toBe("Failure:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actSaveFail 2} to Failure by 2 or More:", () => {
|
||||||
|
expect(stripTags("{@actSaveFail 2}")).toBe("Failure by 2 or More:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actSaveSuccess} to Success:", () => {
|
||||||
|
expect(stripTags("{@actSaveSuccess}")).toBe("Success:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actTrigger} to Trigger:", () => {
|
||||||
|
expect(stripTags("{@actTrigger}")).toBe("Trigger:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actResponse} to Response:", () => {
|
||||||
|
expect(stripTags("{@actResponse}")).toBe("Response:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@variantrule Name|Source|Display} to Display", () => {
|
||||||
|
expect(stripTags("{@variantrule Cone [Area of Effect]|XPHB|Cone}")).toBe(
|
||||||
|
"Cone",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@action Name|Source|Display} to Display", () => {
|
||||||
|
expect(stripTags("{@action Disengage|XPHB|Disengage}")).toBe("Disengage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@skill Name|Source} to Name", () => {
|
||||||
|
expect(stripTags("{@skill Perception|XPHB}")).toBe("Perception");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@creature Name|Source} to Name", () => {
|
||||||
|
expect(stripTags("{@creature Goblin|XPHB}")).toBe("Goblin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@hazard Name|Source} to Name", () => {
|
||||||
|
expect(stripTags("{@hazard Lava|XPHB}")).toBe("Lava");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@status Name|Source} to Name", () => {
|
||||||
|
expect(stripTags("{@status Bloodied|XPHB}")).toBe("Bloodied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles unknown tags by extracting first segment", () => {
|
||||||
|
expect(stripTags("{@unknown Something|else}")).toBe("Something");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple tags in the same string", () => {
|
||||||
|
expect(stripTags("{@hit 4}, reach 5 ft. {@h}5 ({@damage 1d6 + 2})")).toBe(
|
||||||
|
"+4, reach 5 ft. Hit: 5 (1d6 + 2)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles sibling tags in the same string", () => {
|
||||||
|
expect(
|
||||||
|
stripTags("The spell {@spell Fireball|XPHB} deals {@damage 8d6}."),
|
||||||
|
).toBe("The spell Fireball deals 8d6.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles nested tags (outer wrapping inner)", () => {
|
||||||
|
expect(
|
||||||
|
stripTags(
|
||||||
|
"{@b Arcane Innate Spells DC 24; 3rd {@spell fireball}, {@spell slow}}",
|
||||||
|
),
|
||||||
|
).toBe("Arcane Innate Spells DC 24; 3rd fireball, slow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles text with no tags", () => {
|
||||||
|
expect(stripTags("Just plain text.")).toBe("Just plain text.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actSaveSuccessOrFail} to Success or Failure:", () => {
|
||||||
|
expect(stripTags("{@actSaveSuccessOrFail}")).toBe("Success or Failure:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@action Name|Source} to Name when no display text", () => {
|
||||||
|
expect(stripTags("{@action Disengage|XPHB}")).toBe("Disengage");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
import type {
|
||||||
|
Creature,
|
||||||
|
CreatureId,
|
||||||
|
DailySpells,
|
||||||
|
LegendaryBlock,
|
||||||
|
SpellcastingBlock,
|
||||||
|
SpellReference,
|
||||||
|
TraitBlock,
|
||||||
|
TraitListItem,
|
||||||
|
TraitSegment,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
||||||
|
import { stripTags } from "./strip-tags.js";
|
||||||
|
|
||||||
|
const LEADING_DIGITS_REGEX = /^(\d+)/;
|
||||||
|
|
||||||
|
// --- Raw 5etools types (minimal, for parsing) ---
|
||||||
|
|
||||||
|
interface RawMonster {
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
size: string[];
|
||||||
|
type: string | { type: string; tags?: string[]; swarmSize?: string };
|
||||||
|
alignment?: string[];
|
||||||
|
ac: (number | { ac: number; from?: string[] } | { special: string })[];
|
||||||
|
hp: { average?: number; formula?: string; special?: string };
|
||||||
|
speed: Record<
|
||||||
|
string,
|
||||||
|
number | { number: number; condition?: string } | boolean
|
||||||
|
>;
|
||||||
|
str: number;
|
||||||
|
dex: number;
|
||||||
|
con: number;
|
||||||
|
int: number;
|
||||||
|
wis: number;
|
||||||
|
cha: number;
|
||||||
|
save?: Record<string, string>;
|
||||||
|
skill?: Record<string, string>;
|
||||||
|
senses?: string[];
|
||||||
|
passive: number;
|
||||||
|
resist?: (string | { special: string })[];
|
||||||
|
immune?: (string | { special: string })[];
|
||||||
|
vulnerable?: (string | { special: string })[];
|
||||||
|
conditionImmune?: string[];
|
||||||
|
languages?: string[];
|
||||||
|
cr?: string | { cr: string };
|
||||||
|
trait?: RawEntry[];
|
||||||
|
action?: RawEntry[];
|
||||||
|
bonus?: RawEntry[];
|
||||||
|
reaction?: RawEntry[];
|
||||||
|
legendary?: RawEntry[];
|
||||||
|
legendaryActions?: number;
|
||||||
|
legendaryActionsLair?: number;
|
||||||
|
legendaryHeader?: string[];
|
||||||
|
spellcasting?: RawSpellcasting[];
|
||||||
|
initiative?: { proficiency?: number };
|
||||||
|
_copy?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawEntry {
|
||||||
|
name: string;
|
||||||
|
entries: (string | RawEntryObject)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawEntryObject {
|
||||||
|
type: string;
|
||||||
|
items?: (
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
type: string;
|
||||||
|
name?: string;
|
||||||
|
entry?: string;
|
||||||
|
entries?: (string | RawEntryObject)[];
|
||||||
|
}
|
||||||
|
)[];
|
||||||
|
style?: string;
|
||||||
|
name?: string;
|
||||||
|
entries?: (string | RawEntryObject)[];
|
||||||
|
colLabels?: string[];
|
||||||
|
rows?: (string | RawEntryObject)[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawSpellcasting {
|
||||||
|
name: string;
|
||||||
|
headerEntries: string[];
|
||||||
|
will?: string[];
|
||||||
|
daily?: Record<string, string[]>;
|
||||||
|
rest?: Record<string, string[]>;
|
||||||
|
hidden?: string[];
|
||||||
|
ability?: string;
|
||||||
|
displayAs?: string;
|
||||||
|
legendary?: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Source mapping ---
|
||||||
|
|
||||||
|
let sourceDisplayNames: Record<string, string> = {};
|
||||||
|
|
||||||
|
export function setSourceDisplayNames(names: Record<string, string>): void {
|
||||||
|
sourceDisplayNames = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Size mapping ---
|
||||||
|
|
||||||
|
const SIZE_MAP: Record<string, string> = {
|
||||||
|
T: "Tiny",
|
||||||
|
S: "Small",
|
||||||
|
M: "Medium",
|
||||||
|
L: "Large",
|
||||||
|
H: "Huge",
|
||||||
|
G: "Gargantuan",
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Alignment mapping ---
|
||||||
|
|
||||||
|
const ALIGNMENT_MAP: Record<string, string> = {
|
||||||
|
L: "Lawful",
|
||||||
|
N: "Neutral",
|
||||||
|
C: "Chaotic",
|
||||||
|
G: "Good",
|
||||||
|
E: "Evil",
|
||||||
|
U: "Unaligned",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAlignment(codes?: string[]): string {
|
||||||
|
if (!codes || codes.length === 0) return "Unaligned";
|
||||||
|
if (codes.length === 1 && codes[0] === "U") return "Unaligned";
|
||||||
|
if (codes.length === 1 && codes[0] === "N") return "Neutral";
|
||||||
|
return codes.map((c) => ALIGNMENT_MAP[c] ?? c).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function formatSize(sizes: string[]): string {
|
||||||
|
return sizes.map((s) => SIZE_MAP[s] ?? s).join(" or ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatType(
|
||||||
|
type:
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
type: string | { choose: string[] };
|
||||||
|
tags?: string[];
|
||||||
|
swarmSize?: string;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
if (typeof type === "string") return capitalize(type);
|
||||||
|
|
||||||
|
const baseType =
|
||||||
|
typeof type.type === "string"
|
||||||
|
? capitalize(type.type)
|
||||||
|
: type.type.choose.map(capitalize).join(" or ");
|
||||||
|
|
||||||
|
let result = baseType;
|
||||||
|
if (type.tags && type.tags.length > 0) {
|
||||||
|
const tagStrs = type.tags
|
||||||
|
.filter((t): t is string => typeof t === "string")
|
||||||
|
.map(capitalize);
|
||||||
|
if (tagStrs.length > 0) {
|
||||||
|
result += ` (${tagStrs.join(", ")})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type.swarmSize) {
|
||||||
|
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
|
||||||
|
result = `Swarm of ${swarmSizeLabel} ${result}s`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAc(ac: RawMonster["ac"]): {
|
||||||
|
value: number;
|
||||||
|
source?: string;
|
||||||
|
} {
|
||||||
|
const first = ac[0];
|
||||||
|
if (typeof first === "number") {
|
||||||
|
return { value: first };
|
||||||
|
}
|
||||||
|
if ("special" in first) {
|
||||||
|
// Variable AC (e.g. spell summons) — parse leading number if possible
|
||||||
|
const match = LEADING_DIGITS_REGEX.exec(first.special);
|
||||||
|
return {
|
||||||
|
value: match ? Number(match[1]) : 0,
|
||||||
|
source: first.special,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: first.ac,
|
||||||
|
source: first.from ? stripTags(first.from.join(", ")) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(speed: RawMonster["speed"]): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const [mode, value] of Object.entries(speed)) {
|
||||||
|
if (mode === "canHover") continue;
|
||||||
|
if (typeof value === "boolean") continue;
|
||||||
|
|
||||||
|
let numStr: string;
|
||||||
|
let condition = "";
|
||||||
|
if (typeof value === "number") {
|
||||||
|
numStr = `${value} ft.`;
|
||||||
|
} else {
|
||||||
|
numStr = `${value.number} ft.`;
|
||||||
|
if (value.condition) {
|
||||||
|
condition = ` ${value.condition}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "walk") {
|
||||||
|
parts.push(`${numStr}${condition}`);
|
||||||
|
} else {
|
||||||
|
parts.push(`${mode} ${numStr}${condition}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSaves(save?: Record<string, string>): string | undefined {
|
||||||
|
if (!save) return undefined;
|
||||||
|
return Object.entries(save)
|
||||||
|
.map(([key, val]) => `${key.toUpperCase()} ${val}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSkills(skill?: Record<string, string>): string | undefined {
|
||||||
|
if (!skill) return undefined;
|
||||||
|
return Object.entries(skill)
|
||||||
|
.map(([key, val]) => `${capitalize(key)} ${val}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDamageList(
|
||||||
|
items?: (string | Record<string, unknown>)[],
|
||||||
|
): string | undefined {
|
||||||
|
if (!items || items.length === 0) return undefined;
|
||||||
|
return items
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === "string") return capitalize(stripTags(item));
|
||||||
|
if (typeof item.special === "string") return stripTags(item.special);
|
||||||
|
// Handle conditional entries like { vulnerable: [...], note: "..." }
|
||||||
|
const damageTypes = (Object.values(item).find((v) => Array.isArray(v)) ??
|
||||||
|
[]) as string[];
|
||||||
|
const note = typeof item.note === "string" ? ` ${item.note}` : "";
|
||||||
|
return damageTypes.map((d) => capitalize(stripTags(d))).join(", ") + note;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConditionImmunities(
|
||||||
|
items?: (string | { conditionImmune?: string[]; note?: string })[],
|
||||||
|
): string | undefined {
|
||||||
|
if (!items || items.length === 0) return undefined;
|
||||||
|
return items
|
||||||
|
.flatMap((c) => {
|
||||||
|
if (typeof c === "string") return [capitalize(stripTags(c))];
|
||||||
|
if (c.conditionImmune) {
|
||||||
|
const conds = c.conditionImmune.map((ci) => capitalize(stripTags(ci)));
|
||||||
|
const note = c.note ? ` ${c.note}` : "";
|
||||||
|
return conds.map((ci) => `${ci}${note}`);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toListItem(
|
||||||
|
item:
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
type: string;
|
||||||
|
name?: string;
|
||||||
|
entry?: string;
|
||||||
|
entries?: (string | RawEntryObject)[];
|
||||||
|
},
|
||||||
|
): TraitListItem | undefined {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
return { text: stripTags(item) };
|
||||||
|
}
|
||||||
|
if (item.name && 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" || entry.type === "table") {
|
||||||
|
// Handled structurally in segmentizeEntries
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEntries(entries: (string | RawEntryObject)[]): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
parts.push(stripTags(entry));
|
||||||
|
} else {
|
||||||
|
renderEntryObject(entry, parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
segments: segmentizeEntries(t.entries),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSpellcasting(
|
||||||
|
raw?: RawSpellcasting[],
|
||||||
|
): SpellcastingBlock[] | undefined {
|
||||||
|
if (!raw || raw.length === 0) return undefined;
|
||||||
|
|
||||||
|
return raw.map((sc) => {
|
||||||
|
const block: {
|
||||||
|
name: string;
|
||||||
|
headerText: string;
|
||||||
|
atWill?: SpellReference[];
|
||||||
|
daily?: DailySpells[];
|
||||||
|
restLong?: DailySpells[];
|
||||||
|
} = {
|
||||||
|
name: stripTags(sc.name),
|
||||||
|
headerText: sc.headerEntries.map((e) => stripTags(e)).join(" "),
|
||||||
|
};
|
||||||
|
|
||||||
|
const hidden = new Set(sc.hidden ?? []);
|
||||||
|
|
||||||
|
if (sc.will && !hidden.has("will")) {
|
||||||
|
block.atWill = sc.will.map((s) => ({ name: stripTags(s) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sc.daily) {
|
||||||
|
block.daily = parseDailyMap(sc.daily);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sc.rest) {
|
||||||
|
block.restLong = parseDailyMap(sc.rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return block;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
|
||||||
|
return Object.entries(map).map(([key, spells]) => {
|
||||||
|
const each = key.endsWith("e");
|
||||||
|
const uses = Number.parseInt(each ? key.slice(0, -1) : key, 10);
|
||||||
|
return {
|
||||||
|
uses,
|
||||||
|
each,
|
||||||
|
spells: spells.map((s) => ({ name: stripTags(s) })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLegendary(
|
||||||
|
raw?: RawEntry[],
|
||||||
|
monster?: RawMonster,
|
||||||
|
): LegendaryBlock | undefined {
|
||||||
|
if (!raw || raw.length === 0) return undefined;
|
||||||
|
|
||||||
|
const name = monster?.name ?? "creature";
|
||||||
|
const count = monster?.legendaryActions ?? 3;
|
||||||
|
const preamble = `${name} can take ${count} Legendary Actions, choosing from the options below. Only one Legendary Action can be used at a time and only at the end of another creature's turn. ${name} regains spent Legendary Actions at the start of its turn.`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
preamble,
|
||||||
|
entries: raw.map((e) => ({
|
||||||
|
name: stripTags(e.name),
|
||||||
|
segments: segmentizeEntries(e.entries),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCr(cr: string | { cr: string } | undefined): string {
|
||||||
|
if (cr === undefined) return "—";
|
||||||
|
return typeof cr === "string" ? cr : cr.cr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCreatureId(source: string, name: string): CreatureId {
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
|
return creatureId(`${source.toLowerCase()}:${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes raw 5etools bestiary JSON into domain Creature[].
|
||||||
|
*/
|
||||||
|
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
||||||
|
// Filter out _copy entries (reference another source's monster) and
|
||||||
|
// monsters missing required fields (ac, hp, size, type)
|
||||||
|
const monsters = raw.monster.filter((m) => {
|
||||||
|
if (m._copy) return false;
|
||||||
|
return (
|
||||||
|
Array.isArray(m.ac) &&
|
||||||
|
m.ac.length > 0 &&
|
||||||
|
m.hp !== undefined &&
|
||||||
|
Array.isArray(m.size) &&
|
||||||
|
m.size.length > 0 &&
|
||||||
|
m.type !== undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const creatures: Creature[] = [];
|
||||||
|
for (const m of monsters) {
|
||||||
|
try {
|
||||||
|
creatures.push(normalizeMonster(m));
|
||||||
|
} catch {
|
||||||
|
// Skip monsters with unexpected data shapes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return creatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMonster(m: RawMonster): Creature {
|
||||||
|
const crStr = extractCr(m.cr);
|
||||||
|
const ac = extractAc(m.ac);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: makeCreatureId(m.source, m.name),
|
||||||
|
name: m.name,
|
||||||
|
source: m.source,
|
||||||
|
sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
|
||||||
|
size: formatSize(m.size),
|
||||||
|
type: formatType(m.type),
|
||||||
|
alignment: formatAlignment(m.alignment),
|
||||||
|
ac: ac.value,
|
||||||
|
acSource: ac.source,
|
||||||
|
hp: {
|
||||||
|
average: m.hp.average ?? 0,
|
||||||
|
formula: m.hp.formula ?? m.hp.special ?? "",
|
||||||
|
},
|
||||||
|
speed: formatSpeed(m.speed),
|
||||||
|
abilities: {
|
||||||
|
str: m.str,
|
||||||
|
dex: m.dex,
|
||||||
|
con: m.con,
|
||||||
|
int: m.int,
|
||||||
|
wis: m.wis,
|
||||||
|
cha: m.cha,
|
||||||
|
},
|
||||||
|
cr: crStr,
|
||||||
|
initiativeProficiency: m.initiative?.proficiency ?? 0,
|
||||||
|
proficiencyBonus: proficiencyBonus(crStr),
|
||||||
|
passive: m.passive,
|
||||||
|
savingThrows: formatSaves(m.save),
|
||||||
|
skills: formatSkills(m.skill),
|
||||||
|
resist: formatDamageList(m.resist),
|
||||||
|
immune: formatDamageList(m.immune),
|
||||||
|
vulnerable: formatDamageList(m.vulnerable),
|
||||||
|
conditionImmune: formatConditionImmunities(m.conditionImmune),
|
||||||
|
senses:
|
||||||
|
m.senses && m.senses.length > 0
|
||||||
|
? m.senses.map((s) => stripTags(s)).join(", ")
|
||||||
|
: undefined,
|
||||||
|
languages:
|
||||||
|
m.languages && m.languages.length > 0
|
||||||
|
? m.languages.join(", ")
|
||||||
|
: undefined,
|
||||||
|
traits: normalizeTraits(m.trait),
|
||||||
|
actions: normalizeTraits(m.action),
|
||||||
|
bonusActions: normalizeTraits(m.bonus),
|
||||||
|
reactions: normalizeTraits(m.reaction),
|
||||||
|
legendaryActions: normalizeLegendary(m.legendary, m),
|
||||||
|
spellcasting: normalizeSpellcasting(m.spellcasting),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { AnyCreature, CreatureId } from "@initiative/domain";
|
||||||
|
import { type IDBPDatabase, openDB } from "idb";
|
||||||
|
|
||||||
|
const DB_NAME = "initiative-bestiary";
|
||||||
|
const STORE_NAME = "sources";
|
||||||
|
// v8 (2026-04-10): Attack effects, ability frequency, perception details added to PF2e creatures
|
||||||
|
const DB_VERSION = 8;
|
||||||
|
|
||||||
|
interface CachedSourceInfo {
|
||||||
|
readonly sourceCode: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly creatureCount: number;
|
||||||
|
readonly cachedAt: number;
|
||||||
|
readonly system?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedSourceRecord {
|
||||||
|
sourceCode: string;
|
||||||
|
displayName: string;
|
||||||
|
creatures: AnyCreature[];
|
||||||
|
cachedAt: number;
|
||||||
|
creatureCount: number;
|
||||||
|
system?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db: IDBPDatabase | null = null;
|
||||||
|
let dbFailed = false;
|
||||||
|
|
||||||
|
// In-memory fallback when IndexedDB is unavailable
|
||||||
|
const memoryStore = new Map<string, CachedSourceRecord>();
|
||||||
|
|
||||||
|
function scopedKey(system: string, sourceCode: string): string {
|
||||||
|
return `${system}:${sourceCode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDb(): Promise<IDBPDatabase | null> {
|
||||||
|
if (db) return db;
|
||||||
|
if (dbFailed) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
db = await openDB(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(database, oldVersion, _newVersion, transaction) {
|
||||||
|
if (oldVersion < 1) {
|
||||||
|
database.createObjectStore(STORE_NAME, {
|
||||||
|
keyPath: "sourceCode",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return db;
|
||||||
|
} catch {
|
||||||
|
dbFailed = true;
|
||||||
|
console.warn(
|
||||||
|
"IndexedDB unavailable — bestiary cache will not persist across sessions.",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cacheSource(
|
||||||
|
system: string,
|
||||||
|
sourceCode: string,
|
||||||
|
displayName: string,
|
||||||
|
creatures: AnyCreature[],
|
||||||
|
): Promise<void> {
|
||||||
|
const key = scopedKey(system, sourceCode);
|
||||||
|
const record: CachedSourceRecord = {
|
||||||
|
sourceCode: key,
|
||||||
|
displayName,
|
||||||
|
creatures,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
creatureCount: creatures.length,
|
||||||
|
system,
|
||||||
|
};
|
||||||
|
|
||||||
|
const database = await getDb();
|
||||||
|
if (database) {
|
||||||
|
await database.put(STORE_NAME, record);
|
||||||
|
} else {
|
||||||
|
memoryStore.set(key, record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isSourceCached(
|
||||||
|
system: string,
|
||||||
|
sourceCode: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const key = scopedKey(system, sourceCode);
|
||||||
|
const database = await getDb();
|
||||||
|
if (database) {
|
||||||
|
const record = await database.get(STORE_NAME, key);
|
||||||
|
return record !== undefined;
|
||||||
|
}
|
||||||
|
return memoryStore.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedSources(
|
||||||
|
system?: string,
|
||||||
|
): Promise<CachedSourceInfo[]> {
|
||||||
|
const database = await getDb();
|
||||||
|
let records: CachedSourceRecord[];
|
||||||
|
if (database) {
|
||||||
|
records = await database.getAll(STORE_NAME);
|
||||||
|
} else {
|
||||||
|
records = [...memoryStore.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = system
|
||||||
|
? records.filter((r) => r.system === system)
|
||||||
|
: records;
|
||||||
|
return filtered.map((r) => ({
|
||||||
|
sourceCode: r.system
|
||||||
|
? r.sourceCode.slice(r.system.length + 1)
|
||||||
|
: r.sourceCode,
|
||||||
|
displayName: r.displayName,
|
||||||
|
creatureCount: r.creatureCount,
|
||||||
|
cachedAt: r.cachedAt,
|
||||||
|
system: r.system,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSource(
|
||||||
|
system: string,
|
||||||
|
sourceCode: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const key = scopedKey(system, sourceCode);
|
||||||
|
const database = await getDb();
|
||||||
|
if (database) {
|
||||||
|
await database.delete(STORE_NAME, key);
|
||||||
|
} else {
|
||||||
|
memoryStore.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAll(): Promise<void> {
|
||||||
|
const database = await getDb();
|
||||||
|
if (database) {
|
||||||
|
await database.clear(STORE_NAME);
|
||||||
|
} else {
|
||||||
|
memoryStore.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAllCachedCreatures(): Promise<
|
||||||
|
Map<CreatureId, AnyCreature>
|
||||||
|
> {
|
||||||
|
const map = new Map<CreatureId, AnyCreature>();
|
||||||
|
const database = await getDb();
|
||||||
|
|
||||||
|
let records: CachedSourceRecord[];
|
||||||
|
if (database) {
|
||||||
|
records = await database.getAll(STORE_NAME);
|
||||||
|
} else {
|
||||||
|
records = [...memoryStore.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
for (const creature of record.creatures) {
|
||||||
|
map.set(creature.id, creature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain";
|
||||||
|
|
||||||
|
import rawIndex from "../../../../data/bestiary/index.json";
|
||||||
|
|
||||||
|
interface CompactCreature {
|
||||||
|
readonly n: string;
|
||||||
|
readonly s: string;
|
||||||
|
readonly ac: number;
|
||||||
|
readonly hp: number;
|
||||||
|
readonly dx: number;
|
||||||
|
readonly cr: string;
|
||||||
|
readonly ip: number;
|
||||||
|
readonly sz: string;
|
||||||
|
readonly tp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompactIndex {
|
||||||
|
readonly sources: Record<string, string>;
|
||||||
|
readonly creatures: readonly CompactCreature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCreature(c: CompactCreature): BestiaryIndexEntry {
|
||||||
|
return {
|
||||||
|
name: c.n,
|
||||||
|
source: c.s,
|
||||||
|
ac: c.ac,
|
||||||
|
hp: c.hp,
|
||||||
|
dex: c.dx,
|
||||||
|
cr: c.cr,
|
||||||
|
initiativeProficiency: c.ip,
|
||||||
|
size: c.sz,
|
||||||
|
type: c.tp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source codes whose filename on the remote differs from a simple lowercase.
|
||||||
|
// Plane Shift sources use a hyphen: PSA -> ps-a, etc.
|
||||||
|
const FILENAME_OVERRIDES: Record<string, string> = {
|
||||||
|
PSA: "ps-a",
|
||||||
|
PSD: "ps-d",
|
||||||
|
PSI: "ps-i",
|
||||||
|
PSK: "ps-k",
|
||||||
|
PSX: "ps-x",
|
||||||
|
PSZ: "ps-z",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Source codes with no corresponding remote bestiary file.
|
||||||
|
// Excluded from the index entirely so creatures aren't searchable
|
||||||
|
// without a fetchable source.
|
||||||
|
const EXCLUDED_SOURCES = new Set<string>([]);
|
||||||
|
|
||||||
|
let cachedIndex: BestiaryIndex | undefined;
|
||||||
|
|
||||||
|
export function loadBestiaryIndex(): BestiaryIndex {
|
||||||
|
if (cachedIndex) return cachedIndex;
|
||||||
|
|
||||||
|
const compact = rawIndex as unknown as CompactIndex;
|
||||||
|
const sources = Object.fromEntries(
|
||||||
|
Object.entries(compact.sources).filter(
|
||||||
|
([code]) => !EXCLUDED_SOURCES.has(code),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
cachedIndex = {
|
||||||
|
sources,
|
||||||
|
creatures: compact.creatures
|
||||||
|
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
|
||||||
|
.map(mapCreature),
|
||||||
|
};
|
||||||
|
return cachedIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllSourceCodes(): string[] {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
return Object.keys(index.sources).filter((c) => !EXCLUDED_SOURCES.has(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceCodeToFilename(sourceCode: string): string {
|
||||||
|
return FILENAME_OVERRIDES[sourceCode] ?? sourceCode.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultFetchUrl(
|
||||||
|
sourceCode: string,
|
||||||
|
baseUrl?: string,
|
||||||
|
): string {
|
||||||
|
const filename = `bestiary-${sourceCodeToFilename(sourceCode)}.json`;
|
||||||
|
if (baseUrl !== undefined) {
|
||||||
|
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||||
|
return `${normalized}${filename}`;
|
||||||
|
}
|
||||||
|
return `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSourceDisplayName(sourceCode: string): string {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
return index.sources[sourceCode] ?? sourceCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,774 @@
|
|||||||
|
import type {
|
||||||
|
CreatureId,
|
||||||
|
EquipmentItem,
|
||||||
|
Pf2eCreature,
|
||||||
|
SpellcastingBlock,
|
||||||
|
SpellReference,
|
||||||
|
TraitBlock,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { stripFoundryTags } from "./strip-foundry-tags.js";
|
||||||
|
|
||||||
|
// -- Raw Foundry VTT types (minimal, for parsing) --
|
||||||
|
|
||||||
|
interface RawFoundryCreature {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
system: {
|
||||||
|
abilities: Record<string, { mod: number }>;
|
||||||
|
attributes: {
|
||||||
|
ac: { value: number; details?: string };
|
||||||
|
hp: { max: number; details?: string };
|
||||||
|
speed: {
|
||||||
|
value: number;
|
||||||
|
otherSpeeds?: { type: string; value: number }[];
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
immunities?: { type: string; exceptions?: string[] }[];
|
||||||
|
resistances?: { type: string; value: number; exceptions?: string[] }[];
|
||||||
|
weaknesses?: { type: string; value: number }[];
|
||||||
|
allSaves?: { value: string };
|
||||||
|
};
|
||||||
|
details: {
|
||||||
|
level: { value: number };
|
||||||
|
languages: { value?: string[]; details?: string };
|
||||||
|
publication: { license: string; remaster: boolean; title: string };
|
||||||
|
};
|
||||||
|
perception: {
|
||||||
|
mod: number;
|
||||||
|
details?: string;
|
||||||
|
senses?: { type: string; acuity?: string; range?: number }[];
|
||||||
|
};
|
||||||
|
saves: {
|
||||||
|
fortitude: { value: number; saveDetail?: string };
|
||||||
|
reflex: { value: number; saveDetail?: string };
|
||||||
|
will: { value: number; saveDetail?: string };
|
||||||
|
};
|
||||||
|
skills: Record<string, { base: number; note?: string }>;
|
||||||
|
traits: { rarity: string; size: { value: string }; value: string[] };
|
||||||
|
};
|
||||||
|
items: RawFoundryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawFoundryItem {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
system: Record<string, unknown>;
|
||||||
|
sort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeleeSystem {
|
||||||
|
bonus?: { value: number };
|
||||||
|
damageRolls?: Record<string, { damage: string; damageType: string }>;
|
||||||
|
traits?: { value: string[] };
|
||||||
|
attackEffects?: { value: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionSystem {
|
||||||
|
category?: string;
|
||||||
|
actionType?: { value: string };
|
||||||
|
actions?: { value: number | null };
|
||||||
|
traits?: { value: string[] };
|
||||||
|
description?: { value: string };
|
||||||
|
frequency?: { max: number; per: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpellcastingEntrySystem {
|
||||||
|
tradition?: { value: string };
|
||||||
|
prepared?: { value: string };
|
||||||
|
spelldc?: { dc: number; value?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpellSystem {
|
||||||
|
slug?: string;
|
||||||
|
location?: {
|
||||||
|
value: string;
|
||||||
|
heightenedLevel?: number;
|
||||||
|
uses?: { max: number; value: number };
|
||||||
|
};
|
||||||
|
level?: { value: number };
|
||||||
|
traits?: { rarity?: string; value: string[]; traditions?: string[] };
|
||||||
|
description?: { value: string };
|
||||||
|
range?: { value: string };
|
||||||
|
target?: { value: string };
|
||||||
|
area?: { type?: string; value?: number; details?: string };
|
||||||
|
duration?: { value: string; sustained?: boolean };
|
||||||
|
time?: { value: string };
|
||||||
|
defense?: {
|
||||||
|
save?: { statistic: string; basic?: boolean };
|
||||||
|
passive?: { statistic: string };
|
||||||
|
};
|
||||||
|
heightening?:
|
||||||
|
| {
|
||||||
|
type: "fixed";
|
||||||
|
levels: Record<string, { text?: string }>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "interval";
|
||||||
|
interval: number;
|
||||||
|
damage?: { value: string };
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
overlays?: Record<
|
||||||
|
string,
|
||||||
|
{ name?: string; system?: { description?: { value: string } } }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConsumableSystem {
|
||||||
|
level?: { value: number };
|
||||||
|
traits?: { value: string[] };
|
||||||
|
description?: { value: string };
|
||||||
|
category?: string;
|
||||||
|
spell?: {
|
||||||
|
name: string;
|
||||||
|
system?: { level?: { value: number } };
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EQUIPMENT_TYPES = new Set(["weapon", "consumable", "equipment", "armor"]);
|
||||||
|
|
||||||
|
/** Items shown in the Equipment section with popovers. */
|
||||||
|
function isDetailedEquipment(item: RawFoundryItem): boolean {
|
||||||
|
if (!EQUIPMENT_TYPES.has(item.type)) return false;
|
||||||
|
const sys = item.system;
|
||||||
|
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
|
||||||
|
const traits = (sys.traits as { value: string[] } | undefined)?.value ?? [];
|
||||||
|
// All consumables are tactically relevant (potions, scrolls, poisons, etc.)
|
||||||
|
if (item.type === "consumable") return true;
|
||||||
|
// Magical/invested items
|
||||||
|
if (traits.includes("magical") || traits.includes("invested")) return true;
|
||||||
|
// Special material armor/equipment
|
||||||
|
const material = sys.material as { type: string | null } | undefined;
|
||||||
|
if (material?.type) return true;
|
||||||
|
// Higher-level items
|
||||||
|
if (level > 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Items shown on the "Items" line as plain names. */
|
||||||
|
function isMundaneItem(item: RawFoundryItem): boolean {
|
||||||
|
return EQUIPMENT_TYPES.has(item.type) && !isDetailedEquipment(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEquipmentItem(item: RawFoundryItem): EquipmentItem {
|
||||||
|
const sys = item.system;
|
||||||
|
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
|
||||||
|
const traits = (sys.traits as { value: string[] } | undefined)?.value;
|
||||||
|
const rawDesc = (sys.description as { value: string } | undefined)?.value;
|
||||||
|
const description = rawDesc
|
||||||
|
? stripFoundryTags(rawDesc) || undefined
|
||||||
|
: undefined;
|
||||||
|
const category = sys.category as string | undefined;
|
||||||
|
|
||||||
|
let spellName: string | undefined;
|
||||||
|
let spellRank: number | undefined;
|
||||||
|
if (item.type === "consumable") {
|
||||||
|
const spell = (sys as unknown as ConsumableSystem).spell;
|
||||||
|
if (spell) {
|
||||||
|
spellName = spell.name;
|
||||||
|
spellRank = spell.system?.level?.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
level,
|
||||||
|
category: category || undefined,
|
||||||
|
traits: traits && traits.length > 0 ? traits : undefined,
|
||||||
|
description,
|
||||||
|
spellName,
|
||||||
|
spellRank,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_MAP: Record<string, string> = {
|
||||||
|
tiny: "tiny",
|
||||||
|
sm: "small",
|
||||||
|
med: "medium",
|
||||||
|
lg: "large",
|
||||||
|
huge: "huge",
|
||||||
|
grg: "gargantuan",
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Helpers --
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCreatureId(source: string, name: string): CreatureId {
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
|
return creatureId(`${source}:${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NUMERIC_SLUG = /^(.+)-(\d+)$/;
|
||||||
|
const LETTER_SLUG = /^(.+)-([a-z])$/;
|
||||||
|
|
||||||
|
/** Format rules for traits with a numeric suffix: "reach-10" → "reach 10 feet" */
|
||||||
|
const NUMERIC_TRAIT_FORMATS: Record<string, (n: string) => string> = {
|
||||||
|
reach: (n) => `reach ${n} feet`,
|
||||||
|
range: (n) => `range ${n} feet`,
|
||||||
|
"range-increment": (n) => `range increment ${n} feet`,
|
||||||
|
versatile: (n) => `versatile ${n}`,
|
||||||
|
deadly: (n) => `deadly d${n}`,
|
||||||
|
fatal: (n) => `fatal d${n}`,
|
||||||
|
"fatal-aim": (n) => `fatal aim d${n}`,
|
||||||
|
reload: (n) => `reload ${n}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Format rules for traits with a letter suffix: "versatile-p" → "versatile P" */
|
||||||
|
const LETTER_TRAIT_FORMATS: Record<string, (l: string) => string> = {
|
||||||
|
versatile: (l) => `versatile ${l.toUpperCase()}`,
|
||||||
|
deadly: (l) => `deadly d${l}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Expand slugified trait names: "reach-10" → "reach 10 feet" */
|
||||||
|
function formatTrait(slug: string): string {
|
||||||
|
const numMatch = NUMERIC_SLUG.exec(slug);
|
||||||
|
if (numMatch) {
|
||||||
|
const [, base, num] = numMatch;
|
||||||
|
const fmt = NUMERIC_TRAIT_FORMATS[base];
|
||||||
|
return fmt ? fmt(num) : `${base} ${num}`;
|
||||||
|
}
|
||||||
|
const letterMatch = LETTER_SLUG.exec(slug);
|
||||||
|
if (letterMatch) {
|
||||||
|
const [, base, letter] = letterMatch;
|
||||||
|
const fmt = LETTER_TRAIT_FORMATS[base];
|
||||||
|
if (fmt) return fmt(letter);
|
||||||
|
}
|
||||||
|
return slug.replaceAll("-", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Formatting --
|
||||||
|
|
||||||
|
function formatSenses(
|
||||||
|
senses: { type: string; acuity?: string; range?: number }[] | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!senses || senses.length === 0) return undefined;
|
||||||
|
return senses
|
||||||
|
.map((s) => {
|
||||||
|
const parts = [capitalize(s.type.replaceAll("-", " "))];
|
||||||
|
if (s.acuity && s.acuity !== "precise") {
|
||||||
|
parts.push(`(${s.acuity})`);
|
||||||
|
}
|
||||||
|
if (s.range != null) parts.push(`${s.range} feet`);
|
||||||
|
return parts.join(" ");
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLanguages(
|
||||||
|
languages: { value?: string[]; details?: string } | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!languages?.value || languages.value.length === 0) return undefined;
|
||||||
|
const list = languages.value.map(capitalize).join(", ");
|
||||||
|
return languages.details ? `${list} (${languages.details})` : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSkills(
|
||||||
|
skills: Record<string, { base: number; note?: string }> | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!skills) return undefined;
|
||||||
|
const entries = Object.entries(skills);
|
||||||
|
if (entries.length === 0) return undefined;
|
||||||
|
return entries
|
||||||
|
.map(([name, val]) => {
|
||||||
|
const label = capitalize(name.replaceAll("-", " "));
|
||||||
|
return `${label} +${val.base}`;
|
||||||
|
})
|
||||||
|
.sort()
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImmunities(
|
||||||
|
immunities: { type: string; exceptions?: string[] }[] | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!immunities || immunities.length === 0) return undefined;
|
||||||
|
return immunities
|
||||||
|
.map((i) => {
|
||||||
|
const base = capitalize(i.type.replaceAll("-", " "));
|
||||||
|
if (i.exceptions && i.exceptions.length > 0) {
|
||||||
|
return `${base} (except ${i.exceptions.map((e) => capitalize(e.replaceAll("-", " "))).join(", ")})`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResistances(
|
||||||
|
resistances:
|
||||||
|
| { type: string; value: number; exceptions?: string[] }[]
|
||||||
|
| undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!resistances || resistances.length === 0) return undefined;
|
||||||
|
return resistances
|
||||||
|
.map((r) => {
|
||||||
|
const base = `${capitalize(r.type.replaceAll("-", " "))} ${r.value}`;
|
||||||
|
if (r.exceptions && r.exceptions.length > 0) {
|
||||||
|
return `${base} (except ${r.exceptions.map((e) => capitalize(e.replaceAll("-", " "))).join(", ")})`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWeaknesses(
|
||||||
|
weaknesses: { type: string; value: number }[] | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!weaknesses || weaknesses.length === 0) return undefined;
|
||||||
|
return weaknesses
|
||||||
|
.map((w) => `${capitalize(w.type.replaceAll("-", " "))} ${w.value}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(speed: {
|
||||||
|
value: number;
|
||||||
|
otherSpeeds?: { type: string; value: number }[];
|
||||||
|
details?: string;
|
||||||
|
}): string {
|
||||||
|
const parts = [`${speed.value} feet`];
|
||||||
|
if (speed.otherSpeeds) {
|
||||||
|
for (const s of speed.otherSpeeds) {
|
||||||
|
parts.push(`${capitalize(s.type)} ${s.value} feet`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const base = parts.join(", ");
|
||||||
|
return speed.details ? `${base} (${speed.details})` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Attack normalization --
|
||||||
|
|
||||||
|
/** Format an attack effect slug to display text: "grab" → "Grab", "lich-siphon-life" → "Siphon Life". */
|
||||||
|
function formatAttackEffect(slug: string, creatureName: string): string {
|
||||||
|
const prefix = `${creatureName.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-`;
|
||||||
|
const stripped = slug.startsWith(prefix) ? slug.slice(prefix.length) : slug;
|
||||||
|
return stripped.split("-").map(capitalize).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttack(
|
||||||
|
item: RawFoundryItem,
|
||||||
|
creatureName: string,
|
||||||
|
): TraitBlock {
|
||||||
|
const sys = item.system as unknown as MeleeSystem;
|
||||||
|
const bonus = sys.bonus?.value ?? 0;
|
||||||
|
const traits = sys.traits?.value ?? [];
|
||||||
|
const damageEntries = Object.values(sys.damageRolls ?? {});
|
||||||
|
const damage = damageEntries
|
||||||
|
.map((d) => `${d.damage} ${d.damageType}`)
|
||||||
|
.join(" plus ");
|
||||||
|
const traitStr =
|
||||||
|
traits.length > 0 ? ` (${traits.map(formatTrait).join(", ")})` : "";
|
||||||
|
const effects = sys.attackEffects?.value ?? [];
|
||||||
|
const effectStr =
|
||||||
|
effects.length > 0
|
||||||
|
? ` plus ${effects.map((e) => formatAttackEffect(e, creatureName)).join(" and ")}`
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
name: capitalize(item.name),
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: `+${bonus}${traitStr}, ${damage}${effectStr}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActivity(
|
||||||
|
actionType: string | undefined,
|
||||||
|
actionCount: number | null | undefined,
|
||||||
|
): { number: number; unit: "action" | "free" | "reaction" } | undefined {
|
||||||
|
if (actionType === "action") {
|
||||||
|
return { number: actionCount ?? 1, unit: "action" };
|
||||||
|
}
|
||||||
|
if (actionType === "reaction") {
|
||||||
|
return { number: 1, unit: "reaction" };
|
||||||
|
}
|
||||||
|
if (actionType === "free") {
|
||||||
|
return { number: 1, unit: "free" };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Ability normalization --
|
||||||
|
|
||||||
|
const FREQUENCY_LINE = /(<strong>)?Frequency(<\/strong>)?\s+[^\n]+\n*/i;
|
||||||
|
|
||||||
|
/** Strip the "Frequency once per day" line from ability descriptions when structured frequency data exists. */
|
||||||
|
function stripFrequencyLine(text: string): string {
|
||||||
|
return text.replace(FREQUENCY_LINE, "").trimStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
||||||
|
const sys = item.system as unknown as ActionSystem;
|
||||||
|
const actionType = sys.actionType?.value;
|
||||||
|
const actionCount = sys.actions?.value;
|
||||||
|
let description = stripFoundryTags(sys.description?.value ?? "");
|
||||||
|
const traits = sys.traits?.value ?? [];
|
||||||
|
|
||||||
|
const activity = parseActivity(actionType, actionCount);
|
||||||
|
|
||||||
|
const frequency =
|
||||||
|
sys.frequency?.max != null && sys.frequency.per
|
||||||
|
? `${sys.frequency.max}/${sys.frequency.per}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (frequency) {
|
||||||
|
description = stripFrequencyLine(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
const traitStr =
|
||||||
|
traits.length > 0
|
||||||
|
? `(${traits.map((t) => capitalize(formatTrait(t))).join(", ")}) `
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const text = traitStr ? `${traitStr}${description}` : description;
|
||||||
|
const segments: { type: "text"; value: string }[] = text
|
||||||
|
? [{ type: "text", value: text }]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { name: item.name, activity, frequency, segments };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Spellcasting normalization --
|
||||||
|
|
||||||
|
function formatRange(range: { value: string } | undefined): string | undefined {
|
||||||
|
if (!range?.value) return undefined;
|
||||||
|
return range.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatArea(
|
||||||
|
area: { type?: string; value?: number; details?: string } | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!area) return undefined;
|
||||||
|
if (area.value && area.type) return `${area.value}-foot ${area.type}`;
|
||||||
|
return area.details ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDefense(defense: SpellSystem["defense"]): string | undefined {
|
||||||
|
if (!defense) return undefined;
|
||||||
|
if (defense.save) {
|
||||||
|
const stat = capitalize(defense.save.statistic);
|
||||||
|
return defense.save.basic ? `basic ${stat}` : stat;
|
||||||
|
}
|
||||||
|
if (defense.passive) return capitalize(defense.passive.statistic);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeightening(
|
||||||
|
heightening: SpellSystem["heightening"],
|
||||||
|
): string | undefined {
|
||||||
|
if (!heightening) return undefined;
|
||||||
|
if (heightening.type === "fixed") {
|
||||||
|
const parts = Object.entries(heightening.levels)
|
||||||
|
.filter(([, lvl]) => lvl.text)
|
||||||
|
.map(
|
||||||
|
([rank, lvl]) =>
|
||||||
|
`Heightened (${rank}) ${stripFoundryTags(lvl.text as string)}`,
|
||||||
|
);
|
||||||
|
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||||
|
}
|
||||||
|
if (heightening.type === "interval") {
|
||||||
|
const dmg = heightening.damage?.value
|
||||||
|
? ` damage increases by ${heightening.damage.value}`
|
||||||
|
: "";
|
||||||
|
return `Heightened (+${heightening.interval})${dmg}`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOverlays(overlays: SpellSystem["overlays"]): string | undefined {
|
||||||
|
if (!overlays) return undefined;
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const overlay of Object.values(overlays)) {
|
||||||
|
const desc = overlay.system?.description?.value;
|
||||||
|
if (!desc) continue;
|
||||||
|
const label = overlay.name ? `${overlay.name}: ` : "";
|
||||||
|
parts.push(`${label}${stripFoundryTags(desc)}`);
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foundry descriptions often include heightening rules inline at the end.
|
||||||
|
* When we extract heightening into a structured field, strip that trailing
|
||||||
|
* text to avoid duplication.
|
||||||
|
*/
|
||||||
|
const HEIGHTENED_SUFFIX = /\s*Heightened\s*\([^)]*\)[\s\S]*$/;
|
||||||
|
|
||||||
|
function normalizeSpell(
|
||||||
|
item: RawFoundryItem,
|
||||||
|
creatureLevel: number,
|
||||||
|
): SpellReference {
|
||||||
|
const sys = item.system as unknown as SpellSystem;
|
||||||
|
const usesMax = sys.location?.uses?.max;
|
||||||
|
const isCantrip = sys.traits?.value?.includes("cantrip") ?? false;
|
||||||
|
const rank =
|
||||||
|
sys.location?.heightenedLevel ??
|
||||||
|
(isCantrip ? Math.ceil(creatureLevel / 2) : (sys.level?.value ?? 0));
|
||||||
|
const heightening =
|
||||||
|
formatHeightening(sys.heightening) ?? formatOverlays(sys.overlays);
|
||||||
|
|
||||||
|
let description: string | undefined;
|
||||||
|
if (sys.description?.value) {
|
||||||
|
let text = stripFoundryTags(sys.description.value);
|
||||||
|
// Resolve Foundry Roll formula references to the spell's actual rank.
|
||||||
|
// The parenthesized form (e.g., "(@item.level)d4") is most common.
|
||||||
|
text = text.replaceAll(/\(?@item\.(?:rank|level)\)?/g, String(rank));
|
||||||
|
if (heightening) {
|
||||||
|
text = text.replace(HEIGHTENED_SUFFIX, "").trim();
|
||||||
|
}
|
||||||
|
description = text || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
slug: sys.slug,
|
||||||
|
rank,
|
||||||
|
description,
|
||||||
|
traits: sys.traits?.value,
|
||||||
|
traditions: sys.traits?.traditions,
|
||||||
|
range: formatRange(sys.range),
|
||||||
|
target: sys.target?.value || undefined,
|
||||||
|
area: formatArea(sys.area),
|
||||||
|
duration: sys.duration?.value || undefined,
|
||||||
|
defense: formatDefense(sys.defense),
|
||||||
|
actionCost: sys.time?.value || undefined,
|
||||||
|
heightening,
|
||||||
|
usesPerDay: usesMax && usesMax > 1 ? usesMax : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSpellcastingEntry(
|
||||||
|
entry: RawFoundryItem,
|
||||||
|
allSpells: readonly RawFoundryItem[],
|
||||||
|
creatureLevel: number,
|
||||||
|
): SpellcastingBlock {
|
||||||
|
const sys = entry.system as unknown as SpellcastingEntrySystem;
|
||||||
|
const tradition = capitalize(sys.tradition?.value ?? "");
|
||||||
|
const prepared = sys.prepared?.value ?? "";
|
||||||
|
const dc = sys.spelldc?.dc ?? 0;
|
||||||
|
const attack = sys.spelldc?.value ?? 0;
|
||||||
|
|
||||||
|
const name = entry.name || `${tradition} ${capitalize(prepared)} Spells`;
|
||||||
|
const headerText = `DC ${dc}${attack ? `, attack +${attack}` : ""}`;
|
||||||
|
|
||||||
|
const linkedSpells = allSpells.filter(
|
||||||
|
(s) => (s.system as unknown as SpellSystem).location?.value === entry._id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const byRank = new Map<number, SpellReference[]>();
|
||||||
|
const cantrips: SpellReference[] = [];
|
||||||
|
|
||||||
|
for (const spell of linkedSpells) {
|
||||||
|
const ref = normalizeSpell(spell, creatureLevel);
|
||||||
|
const isCantrip =
|
||||||
|
(spell.system as unknown as SpellSystem).traits?.value?.includes(
|
||||||
|
"cantrip",
|
||||||
|
) ?? false;
|
||||||
|
if (isCantrip) {
|
||||||
|
cantrips.push(ref);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rank = ref.rank ?? 0;
|
||||||
|
const existing = byRank.get(rank) ?? [];
|
||||||
|
existing.push(ref);
|
||||||
|
byRank.set(rank, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const daily = [...byRank.entries()]
|
||||||
|
.sort(([a], [b]) => b - a)
|
||||||
|
.map(([rank, spells]) => ({
|
||||||
|
uses: rank,
|
||||||
|
each: true,
|
||||||
|
spells,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
headerText,
|
||||||
|
atWill: orUndefined(cantrips),
|
||||||
|
daily: orUndefined(daily),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSpellcasting(
|
||||||
|
items: readonly RawFoundryItem[],
|
||||||
|
creatureLevel: number,
|
||||||
|
): SpellcastingBlock[] {
|
||||||
|
const entries = items.filter((i) => i.type === "spellcastingEntry");
|
||||||
|
const spells = items.filter((i) => i.type === "spell");
|
||||||
|
return entries.map((entry) =>
|
||||||
|
normalizeSpellcastingEntry(entry, spells, creatureLevel),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Main normalization --
|
||||||
|
|
||||||
|
function orUndefined<T>(arr: T[]): T[] | undefined {
|
||||||
|
return arr.length > 0 ? arr : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build display traits: [rarity (if not common), size, ...type traits] */
|
||||||
|
function buildTraits(traits: {
|
||||||
|
rarity: string;
|
||||||
|
size: { value: string };
|
||||||
|
value: string[];
|
||||||
|
}): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
if (traits.rarity && traits.rarity !== "common") {
|
||||||
|
result.push(traits.rarity);
|
||||||
|
}
|
||||||
|
const size = SIZE_MAP[traits.size.value] ?? "medium";
|
||||||
|
result.push(size);
|
||||||
|
result.push(...traits.value);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEALING_GLOSSARY =
|
||||||
|
/^<p>@Localize\[PF2E\.NPC\.Abilities\.Glossary\.(FastHealing|Regeneration|NegativeHealing)\]/;
|
||||||
|
|
||||||
|
/** Glossary-only abilities that duplicate structured data shown elsewhere. */
|
||||||
|
const REDUNDANT_GLOSSARY =
|
||||||
|
/^<p>@Localize\[PF2E\.NPC\.Abilities\.Glossary\.(ConstantSpells|AtWillSpells)\]/;
|
||||||
|
|
||||||
|
const STRIP_GLOSSARY_AND_P = /<p>@Localize\[[^\]]+\]<\/p>|<\/?p>/g;
|
||||||
|
|
||||||
|
/** True when the description has no user-visible content beyond glossary tags. */
|
||||||
|
function isGlossaryOnly(desc: string | undefined): boolean {
|
||||||
|
if (!desc) return true;
|
||||||
|
return desc.replace(STRIP_GLOSSARY_AND_P, "").trim() === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRedundantAbility(
|
||||||
|
item: RawFoundryItem,
|
||||||
|
excludeName: string | undefined,
|
||||||
|
hpDetails: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
const sys = item.system as unknown as ActionSystem;
|
||||||
|
const desc = sys.description?.value;
|
||||||
|
// Ability duplicates the allSaves line — suppress only if glossary-only
|
||||||
|
if (excludeName && item.name.toLowerCase() === excludeName.toLowerCase()) {
|
||||||
|
return isGlossaryOnly(desc);
|
||||||
|
}
|
||||||
|
if (!desc) return false;
|
||||||
|
// Healing/regen glossary when hp.details already shows the info
|
||||||
|
if (hpDetails && HEALING_GLOSSARY.test(desc)) return true;
|
||||||
|
// Spell mechanic glossary reminders shown in the spellcasting section
|
||||||
|
if (REDUNDANT_GLOSSARY.test(desc)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionsByCategory(
|
||||||
|
items: readonly RawFoundryItem[],
|
||||||
|
category: string,
|
||||||
|
excludeName?: string,
|
||||||
|
hpDetails?: string,
|
||||||
|
): TraitBlock[] {
|
||||||
|
return items
|
||||||
|
.filter(
|
||||||
|
(a) =>
|
||||||
|
a.type === "action" &&
|
||||||
|
(a.system as unknown as ActionSystem).category === category &&
|
||||||
|
!isRedundantAbility(a, excludeName, hpDetails),
|
||||||
|
)
|
||||||
|
.map(normalizeAbility);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAbilityMods(
|
||||||
|
mods: Record<string, { mod: number }>,
|
||||||
|
): Pf2eCreature["abilityMods"] {
|
||||||
|
return {
|
||||||
|
str: mods.str?.mod ?? 0,
|
||||||
|
dex: mods.dex?.mod ?? 0,
|
||||||
|
con: mods.con?.mod ?? 0,
|
||||||
|
int: mods.int?.mod ?? 0,
|
||||||
|
wis: mods.wis?.mod ?? 0,
|
||||||
|
cha: mods.cha?.mod ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFoundryCreature(
|
||||||
|
raw: unknown,
|
||||||
|
sourceCode?: string,
|
||||||
|
sourceDisplayName?: string,
|
||||||
|
): Pf2eCreature {
|
||||||
|
const r = raw as RawFoundryCreature;
|
||||||
|
const sys = r.system;
|
||||||
|
const publication = sys.details?.publication;
|
||||||
|
|
||||||
|
const source = sourceCode ?? publication?.title ?? "";
|
||||||
|
const items = r.items ?? [];
|
||||||
|
const allSavesText = sys.attributes.allSaves?.value ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
system: "pf2e",
|
||||||
|
id: makeCreatureId(source, r.name),
|
||||||
|
name: r.name,
|
||||||
|
source,
|
||||||
|
sourceDisplayName: sourceDisplayName ?? publication?.title ?? "",
|
||||||
|
level: sys.details?.level?.value ?? 0,
|
||||||
|
traits: buildTraits(sys.traits),
|
||||||
|
perception: sys.perception?.mod ?? 0,
|
||||||
|
perceptionDetails: sys.perception?.details || undefined,
|
||||||
|
senses: formatSenses(sys.perception?.senses),
|
||||||
|
languages: formatLanguages(sys.details?.languages),
|
||||||
|
skills: formatSkills(sys.skills),
|
||||||
|
abilityMods: extractAbilityMods(sys.abilities ?? {}),
|
||||||
|
ac: sys.attributes.ac.value,
|
||||||
|
acConditional: sys.attributes.ac.details || undefined,
|
||||||
|
saveFort: sys.saves.fortitude.value,
|
||||||
|
saveRef: sys.saves.reflex.value,
|
||||||
|
saveWill: sys.saves.will.value,
|
||||||
|
saveConditional: allSavesText || undefined,
|
||||||
|
hp: sys.attributes.hp.max,
|
||||||
|
hpDetails: sys.attributes.hp.details || undefined,
|
||||||
|
immunities: formatImmunities(sys.attributes.immunities),
|
||||||
|
resistances: formatResistances(sys.attributes.resistances),
|
||||||
|
weaknesses: formatWeaknesses(sys.attributes.weaknesses),
|
||||||
|
speed: formatSpeed(sys.attributes.speed),
|
||||||
|
attacks: orUndefined(
|
||||||
|
items
|
||||||
|
.filter((i) => i.type === "melee")
|
||||||
|
.map((i) => normalizeAttack(i, r.name)),
|
||||||
|
),
|
||||||
|
abilitiesTop: orUndefined(actionsByCategory(items, "interaction")),
|
||||||
|
abilitiesMid: orUndefined(
|
||||||
|
actionsByCategory(
|
||||||
|
items,
|
||||||
|
"defensive",
|
||||||
|
allSavesText || undefined,
|
||||||
|
sys.attributes.hp.details || undefined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
|
||||||
|
spellcasting: orUndefined(
|
||||||
|
normalizeSpellcasting(items, sys.details?.level?.value ?? 0),
|
||||||
|
),
|
||||||
|
items:
|
||||||
|
items
|
||||||
|
.filter(isMundaneItem)
|
||||||
|
.map((i) => i.name)
|
||||||
|
.join(", ") || undefined,
|
||||||
|
equipment: orUndefined(
|
||||||
|
items.filter(isDetailedEquipment).map(normalizeEquipmentItem),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFoundryCreatures(
|
||||||
|
rawCreatures: unknown[],
|
||||||
|
sourceCode?: string,
|
||||||
|
sourceDisplayName?: string,
|
||||||
|
): Pf2eCreature[] {
|
||||||
|
return rawCreatures.map((raw) =>
|
||||||
|
normalizeFoundryCreature(raw, sourceCode, sourceDisplayName),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type {
|
||||||
|
Pf2eBestiaryIndex,
|
||||||
|
Pf2eBestiaryIndexEntry,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
|
||||||
|
import rawIndex from "../../../../data/bestiary/pf2e-index.json";
|
||||||
|
|
||||||
|
interface CompactCreature {
|
||||||
|
readonly n: string;
|
||||||
|
readonly s: string;
|
||||||
|
readonly lv: number;
|
||||||
|
readonly ac: number;
|
||||||
|
readonly hp: number;
|
||||||
|
readonly pc: number;
|
||||||
|
readonly sz: string;
|
||||||
|
readonly tp: string;
|
||||||
|
readonly f: string;
|
||||||
|
readonly li: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompactIndex {
|
||||||
|
readonly sources: Record<string, string>;
|
||||||
|
readonly creatures: readonly CompactCreature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCreature(c: CompactCreature): Pf2eBestiaryIndexEntry {
|
||||||
|
return {
|
||||||
|
name: c.n,
|
||||||
|
source: c.s,
|
||||||
|
level: c.lv,
|
||||||
|
ac: c.ac,
|
||||||
|
hp: c.hp,
|
||||||
|
perception: c.pc,
|
||||||
|
size: c.sz,
|
||||||
|
type: c.tp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedIndex: Pf2eBestiaryIndex | undefined;
|
||||||
|
|
||||||
|
export function loadPf2eBestiaryIndex(): Pf2eBestiaryIndex {
|
||||||
|
if (cachedIndex) return cachedIndex;
|
||||||
|
|
||||||
|
const compact = rawIndex as unknown as CompactIndex;
|
||||||
|
cachedIndex = {
|
||||||
|
sources: compact.sources,
|
||||||
|
creatures: compact.creatures.map(mapCreature),
|
||||||
|
};
|
||||||
|
return cachedIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllPf2eSourceCodes(): string[] {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
return Object.keys(index.sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultPf2eFetchUrl(
|
||||||
|
_sourceCode: string,
|
||||||
|
baseUrl?: string,
|
||||||
|
): string {
|
||||||
|
if (baseUrl !== undefined) {
|
||||||
|
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||||
|
}
|
||||||
|
return "https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCreaturePathsForSource(sourceCode: string): string[] {
|
||||||
|
const compact = rawIndex as unknown as CompactIndex;
|
||||||
|
return compact.creatures.filter((c) => c.s === sourceCode).map((c) => c.f);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCreatureNamesByPaths(paths: string[]): Map<string, string> {
|
||||||
|
const compact = rawIndex as unknown as CompactIndex;
|
||||||
|
const pathSet = new Set(paths);
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
for (const c of compact.creatures) {
|
||||||
|
if (pathSet.has(c.f)) {
|
||||||
|
result.set(c.f, c.n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPf2eSourceDisplayName(sourceCode: string): string {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
return index.sources[sourceCode] ?? sourceCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type {
|
||||||
|
AnyCreature,
|
||||||
|
BestiaryIndex,
|
||||||
|
CreatureId,
|
||||||
|
Encounter,
|
||||||
|
Pf2eBestiaryIndex,
|
||||||
|
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(
|
||||||
|
system: string,
|
||||||
|
sourceCode: string,
|
||||||
|
displayName: string,
|
||||||
|
creatures: AnyCreature[],
|
||||||
|
): Promise<void>;
|
||||||
|
isSourceCached(system: string, sourceCode: string): Promise<boolean>;
|
||||||
|
getCachedSources(system?: string): Promise<CachedSourceInfo[]>;
|
||||||
|
clearSource(system: string, sourceCode: string): Promise<void>;
|
||||||
|
clearAll(): Promise<void>;
|
||||||
|
loadAllCachedCreatures(): Promise<Map<CreatureId, AnyCreature>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BestiaryIndexPort {
|
||||||
|
loadIndex(): BestiaryIndex;
|
||||||
|
getAllSourceCodes(): string[];
|
||||||
|
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||||
|
getSourceDisplayName(sourceCode: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pf2eBestiaryIndexPort {
|
||||||
|
loadIndex(): Pf2eBestiaryIndex;
|
||||||
|
getAllSourceCodes(): string[];
|
||||||
|
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||||
|
getSourceDisplayName(sourceCode: string): string;
|
||||||
|
getCreaturePathsForSource(sourceCode: string): string[];
|
||||||
|
getCreatureNamesByPaths(paths: string[]): Map<string, string>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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";
|
||||||
|
import * as pf2eBestiaryIndex from "./pf2e-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,
|
||||||
|
},
|
||||||
|
pf2eBestiaryIndex: {
|
||||||
|
loadIndex: pf2eBestiaryIndex.loadPf2eBestiaryIndex,
|
||||||
|
getAllSourceCodes: pf2eBestiaryIndex.getAllPf2eSourceCodes,
|
||||||
|
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
||||||
|
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
||||||
|
getCreaturePathsForSource: pf2eBestiaryIndex.getCreaturePathsForSource,
|
||||||
|
getCreatureNamesByPaths: pf2eBestiaryIndex.getCreatureNamesByPaths,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Strips Foundry VTT HTML descriptions with enrichment syntax to plain
|
||||||
|
* readable text. Handles @Damage, @Check, @UUID, @Template and generic
|
||||||
|
* @Tag patterns as well as common HTML elements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// -- Enrichment-param helpers --
|
||||||
|
|
||||||
|
function formatDamage(params: string): string {
|
||||||
|
// "3d6+10[fire]" → "3d6+10 fire"
|
||||||
|
// "d4[persistent,fire]" → "d4 persistent fire"
|
||||||
|
return params
|
||||||
|
.replaceAll(
|
||||||
|
/\[([^\]]*)\]/g,
|
||||||
|
(_, type: string) => ` ${type.replaceAll(",", " ")}`,
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCheck(params: string): string {
|
||||||
|
// "reflex|dc:33|basic" → "DC 33 basic Reflex"
|
||||||
|
const parts = params.split("|");
|
||||||
|
const type = parts[0] ?? "";
|
||||||
|
let dc = "";
|
||||||
|
let basic = false;
|
||||||
|
for (const part of parts.slice(1)) {
|
||||||
|
if (part.startsWith("dc:")) {
|
||||||
|
dc = part.slice(3);
|
||||||
|
} else if (part === "basic") {
|
||||||
|
basic = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const label = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
const dcStr = dc ? `DC ${dc} ` : "";
|
||||||
|
const basicStr = basic ? "basic " : "";
|
||||||
|
return `${dcStr}${basicStr}${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTemplate(params: string): string {
|
||||||
|
// "cone|distance:40" → "40-foot cone"
|
||||||
|
const parts = params.split("|");
|
||||||
|
const shape = parts[0] ?? "";
|
||||||
|
let distance = "";
|
||||||
|
for (const part of parts.slice(1)) {
|
||||||
|
if (part.startsWith("distance:")) {
|
||||||
|
distance = part.slice(9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return distance ? `${distance}-foot ${shape}` : shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripFoundryTags(html: string): string {
|
||||||
|
if (typeof html !== "string") return String(html);
|
||||||
|
let result = html;
|
||||||
|
|
||||||
|
// Strip Foundry enrichment tags (with optional display text)
|
||||||
|
// @Tag[params]{display} → display (prefer display text)
|
||||||
|
// @Tag[params] → extracted content
|
||||||
|
|
||||||
|
// @Damage has nested brackets: @Damage[3d6+10[fire]]
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@Damage\[((?:[^[\]]|\[[^\]]*\])*)\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, params: string, display: string | undefined) =>
|
||||||
|
display ?? formatDamage(params),
|
||||||
|
);
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@Check\[([^\]]+)\](?:\{([^}]*)\})?/g,
|
||||||
|
(_, params: string) => formatCheck(params),
|
||||||
|
);
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@UUID\[[^\]]+?([^./\]]+)\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, lastSegment: string, display: string | undefined) =>
|
||||||
|
display ?? lastSegment,
|
||||||
|
);
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@Template\[([^\]]+)\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, params: string, display: string | undefined) =>
|
||||||
|
display ?? formatTemplate(params),
|
||||||
|
);
|
||||||
|
// Catch-all for unknown @Tag patterns
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@\w+\[[^\]]*\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, display: string | undefined) => display ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strip action-glyph spans (content is a number the renderer handles)
|
||||||
|
result = result.replaceAll(/<span class="action-glyph">[^<]*<\/span>/gi, "");
|
||||||
|
|
||||||
|
// Strip HTML tags (preserve <strong> for UI rendering)
|
||||||
|
result = result.replaceAll(/<br\s*\/?>/gi, "\n");
|
||||||
|
result = result.replaceAll(/<hr\s*\/?>/gi, "\n");
|
||||||
|
result = result.replaceAll(/<\/p>\s*<p[^>]*>/gi, "\n");
|
||||||
|
result = result.replaceAll(/<(?!\/?(?:strong|em|ul|ol|li)\b)[^>]+>/g, "");
|
||||||
|
|
||||||
|
// Decode common HTML entities
|
||||||
|
result = result.replaceAll("&", "&");
|
||||||
|
result = result.replaceAll("<", "<");
|
||||||
|
result = result.replaceAll(">", ">");
|
||||||
|
result = result.replaceAll(""", '"');
|
||||||
|
|
||||||
|
// Collapse whitespace around list tags so they don't create extra
|
||||||
|
// line breaks when rendered with whitespace-pre-line
|
||||||
|
result = result.replaceAll(/\s*(<\/?(?:ul|ol)>)\s*/g, "$1");
|
||||||
|
result = result.replaceAll(/\s*(<\/?li>)\s*/g, "$1");
|
||||||
|
|
||||||
|
// Collapse whitespace
|
||||||
|
result = result.replaceAll(/[ \t]+/g, " ");
|
||||||
|
result = result.replaceAll(/\n\s*\n/g, "\n");
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
const ABILITY_MAP: Record<string, string> = {
|
||||||
|
str: "Strength",
|
||||||
|
dex: "Dexterity",
|
||||||
|
con: "Constitution",
|
||||||
|
int: "Intelligence",
|
||||||
|
wis: "Wisdom",
|
||||||
|
cha: "Charisma",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ATKR_MAP: Record<string, string> = {
|
||||||
|
m: "Melee Attack Roll:",
|
||||||
|
r: "Ranged Attack Roll:",
|
||||||
|
"m,r": "Melee or Ranged Attack Roll:",
|
||||||
|
"r,m": "Melee or Ranged Attack Roll:",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ATK_MAP: Record<string, string> = {
|
||||||
|
mw: "Melee Weapon Attack:",
|
||||||
|
rw: "Ranged Weapon Attack:",
|
||||||
|
ms: "Melee Spell Attack:",
|
||||||
|
rs: "Ranged Spell Attack:",
|
||||||
|
"mw,rw": "Melee or Ranged Weapon Attack:",
|
||||||
|
"rw,mw": "Melee or Ranged Weapon Attack:",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
|
||||||
|
*
|
||||||
|
* Handles 15+ tag types per research.md R-002 tag resolution rules.
|
||||||
|
*/
|
||||||
|
export function stripTags(text: string): string {
|
||||||
|
if (typeof text !== "string") return String(text);
|
||||||
|
// Process special tags with specific output formats first
|
||||||
|
let result = text;
|
||||||
|
|
||||||
|
// {@h} → "Hit: "
|
||||||
|
result = result.replaceAll("{@h}", "Hit: ");
|
||||||
|
|
||||||
|
// {@hom} → "Hit or Miss: "
|
||||||
|
result = result.replaceAll("{@hom}", "Hit or Miss: ");
|
||||||
|
|
||||||
|
// {@actTrigger} → "Trigger:"
|
||||||
|
result = result.replaceAll("{@actTrigger}", "Trigger:");
|
||||||
|
|
||||||
|
// {@actResponse} → "Response:"
|
||||||
|
result = result.replaceAll("{@actResponse}", "Response:");
|
||||||
|
|
||||||
|
// {@actSaveSuccess} → "Success:"
|
||||||
|
result = result.replaceAll("{@actSaveSuccess}", "Success:");
|
||||||
|
|
||||||
|
// {@actSaveSuccessOrFail} → handled below as parameterized
|
||||||
|
|
||||||
|
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
|
||||||
|
result = result.replaceAll(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
|
||||||
|
result = result.replaceAll("{@recharge}", "(Recharge 6)");
|
||||||
|
|
||||||
|
// {@dc N} → "DC N"
|
||||||
|
result = result.replaceAll(/\{@dc\s+(\d+)\}/g, "DC $1");
|
||||||
|
|
||||||
|
// {@hit N} → "+N"
|
||||||
|
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||||
|
|
||||||
|
// {@atkr type} → mapped attack roll text (2024 rules)
|
||||||
|
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||||
|
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
|
||||||
|
});
|
||||||
|
|
||||||
|
// {@atk type} → mapped attack type text (pre-2024 data)
|
||||||
|
result = result.replaceAll(/\{@atk\s+([^}]+)\}/g, (_, type: string) => {
|
||||||
|
return ATK_MAP[type.trim()] ?? "Attack:";
|
||||||
|
});
|
||||||
|
|
||||||
|
// {@actSave ability} → "Ability saving throw"
|
||||||
|
result = result.replaceAll(
|
||||||
|
/\{@actSave\s+([^}]+)\}/g,
|
||||||
|
(_, ability: string) => {
|
||||||
|
const name = ABILITY_MAP[ability.trim().toLowerCase()];
|
||||||
|
return name ? `${name} saving throw` : `${ability} saving throw`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
|
||||||
|
result = result.replaceAll(
|
||||||
|
/\{@actSaveFail\s+(\d+)\}/g,
|
||||||
|
"Failure by $1 or More:",
|
||||||
|
);
|
||||||
|
result = result.replaceAll("{@actSaveFail}", "Failure:");
|
||||||
|
|
||||||
|
// {@actSaveSuccessOrFail} → keep as-is label
|
||||||
|
result = result.replaceAll("{@actSaveSuccessOrFail}", "Success or Failure:");
|
||||||
|
|
||||||
|
// {@actSaveFailBy N} → "Failure by N or More:"
|
||||||
|
result = result.replaceAll(
|
||||||
|
/\{@actSaveFailBy\s+(\d+)\}/g,
|
||||||
|
"Failure by $1 or More:",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
||||||
|
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
||||||
|
// creature, hazard, status, plus any unknown tags
|
||||||
|
// Run in a loop to resolve nested tags (e.g. {@b ... {@spell fireball} ...})
|
||||||
|
// from innermost to outermost.
|
||||||
|
const tagPattern = /\{@(\w+)\s+([^}]+)\}/g;
|
||||||
|
while (tagPattern.test(result)) {
|
||||||
|
result = result.replaceAll(
|
||||||
|
tagPattern,
|
||||||
|
(_, tag: string, content: string) => {
|
||||||
|
const segments = content.split("|");
|
||||||
|
|
||||||
|
if (
|
||||||
|
(tag === "variantrule" || tag === "action") &&
|
||||||
|
segments.length >= 3
|
||||||
|
) {
|
||||||
|
return segments[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments[0];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
// DOM API stubs — jsdom doesn't implement these
|
||||||
|
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(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
polyfillDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitting with a name adds a combatant", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Goblin");
|
||||||
|
const addButton = screen.getByRole("button", { name: "Add" });
|
||||||
|
await user.click(addButton);
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitting with empty name does nothing", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "{Enter}");
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
expect(screen.getByPlaceholderText("Init")).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText("AC")).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText("MaxHP")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Add button when name >= 2 chars and no suggestions", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
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(
|
||||||
|
screen.queryByRole("button", { name: "Roll all initiative" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows overflow menu items", () => {
|
||||||
|
renderBar({ onManagePlayers: vi.fn() });
|
||||||
|
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,166 @@
|
|||||||
|
// @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 { RulesEditionProvider } from "../../contexts/rules-edition-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(
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<AdapterProvider adapters={adapters}>
|
||||||
|
<BulkImportPrompt />
|
||||||
|
</AdapterProvider>
|
||||||
|
</RulesEditionProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { type CreatureId, combatantId } 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 { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { CombatantRow } from "../combatant-row.js";
|
||||||
|
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
||||||
|
|
||||||
|
const TEMP_HP_REGEX = /^\+\d/;
|
||||||
|
const CURRENT_HP_7_REGEX = /Current HP: 7/;
|
||||||
|
const CURRENT_HP_REGEX = /Current HP/;
|
||||||
|
|
||||||
|
// DOM API stubs
|
||||||
|
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);
|
||||||
|
|
||||||
|
function renderRow(
|
||||||
|
overrides: Partial<{
|
||||||
|
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
|
||||||
|
isActive: boolean;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const combatant = overrides.combatant ?? {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: 15,
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 10,
|
||||||
|
ac: 13,
|
||||||
|
};
|
||||||
|
return render(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={combatant}
|
||||||
|
isActive={overrides.isActive ?? false}
|
||||||
|
/>,
|
||||||
|
{ wrapper: AllProviders },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CombatantRow", () => {
|
||||||
|
it("renders combatant name", () => {
|
||||||
|
renderRow();
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders initiative value", () => {
|
||||||
|
renderRow();
|
||||||
|
expect(screen.getByText("15")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders current HP", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByText("7")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active combatant gets active border styling", () => {
|
||||||
|
const { container } = renderRow({ isActive: true });
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).toContain("border-active-row-border");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The name area should have opacity-50
|
||||||
|
const nameEl = screen.getByText("Goblin");
|
||||||
|
const nameContainer = nameEl.closest(".opacity-50");
|
||||||
|
expect(nameContainer).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Max' placeholder when no maxHp is set", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Max")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows concentration icon when isConcentrating is true", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
isConcentrating: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const concButton = screen.getByRole("button", {
|
||||||
|
name: "Toggle concentration",
|
||||||
|
});
|
||||||
|
expect(concButton.className).toContain("text-purple-400");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows player character icon and color when set", () => {
|
||||||
|
const { container } = renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Aragorn",
|
||||||
|
color: "red",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The icon should be rendered with the player color
|
||||||
|
const svgIcon = container.querySelector("svg[style]");
|
||||||
|
expect(svgIcon).not.toBeNull();
|
||||||
|
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove button removes after confirmation", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow();
|
||||||
|
const removeBtn = screen.getByRole("button", {
|
||||||
|
name: "Remove combatant",
|
||||||
|
});
|
||||||
|
// First click enters confirm state
|
||||||
|
await user.click(removeBtn);
|
||||||
|
// Second click confirms
|
||||||
|
const confirmBtn = screen.getByRole("button", {
|
||||||
|
name: "Confirm remove combatant",
|
||||||
|
});
|
||||||
|
await user.click(confirmBtn);
|
||||||
|
// After confirming, the button returns to its initial state
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Confirm remove combatant" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows d20 roll button when initiative is undefined and combatant has creatureId", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: "srd:goblin" as CreatureId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Roll initiative" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("concentration pulse", () => {
|
||||||
|
it("pulses when currentHp drops on a concentrating combatant", () => {
|
||||||
|
const combatant = {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
isConcentrating: true,
|
||||||
|
};
|
||||||
|
const { rerender, container } = renderRow({ combatant });
|
||||||
|
rerender(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={{ ...combatant, currentHp: 10 }}
|
||||||
|
isActive={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).toContain("animate-concentration-pulse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pulse when not concentrating", () => {
|
||||||
|
const combatant = {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
isConcentrating: false,
|
||||||
|
};
|
||||||
|
const { rerender, container } = renderRow({ combatant });
|
||||||
|
rerender(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={{ ...combatant, currentHp: 10 }}
|
||||||
|
isActive={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).not.toContain("animate-concentration-pulse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pulses when temp HP absorbs all damage on a concentrating combatant", () => {
|
||||||
|
const combatant = {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 8,
|
||||||
|
isConcentrating: true,
|
||||||
|
};
|
||||||
|
const { rerender, container } = renderRow({ combatant });
|
||||||
|
// Temp HP absorbs all damage, currentHp unchanged
|
||||||
|
rerender(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={{ ...combatant, tempHp: 3 }}
|
||||||
|
isActive={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).toContain("animate-concentration-pulse");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByText("+5")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show +N when combatant has no temp HP", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.queryByText(TEMP_HP_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("temp HP display uses cyan color", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tempHpEl = screen.getByText("+8");
|
||||||
|
expect(tempHpEl.className).toContain("text-cyan-400");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ConditionEntry,
|
||||||
|
type ConditionId,
|
||||||
|
getConditionsForEdition,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageType,
|
||||||
|
type RulesEdition,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { createRef, type ReactNode, type RefObject, useEffect } from "react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RulesEditionProvider } from "../../contexts/index.js";
|
||||||
|
import { useRulesEditionContext } from "../../contexts/rules-edition-context.js";
|
||||||
|
import { ConditionPicker } from "../condition-picker";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function EditionSetter({
|
||||||
|
edition,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
edition: RulesEdition;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { setEdition } = useRulesEditionContext();
|
||||||
|
useEffect(() => {
|
||||||
|
setEdition(edition);
|
||||||
|
}, [edition, setEdition]);
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPicker(
|
||||||
|
overrides: Partial<{
|
||||||
|
activeConditions: readonly ConditionEntry[];
|
||||||
|
activePersistentDamage: readonly PersistentDamageEntry[];
|
||||||
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
|
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||||
|
onAddPersistentDamage: (
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
formula: string,
|
||||||
|
) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
edition: RulesEdition;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const onToggle = overrides.onToggle ?? vi.fn();
|
||||||
|
const onSetValue = overrides.onSetValue ?? vi.fn();
|
||||||
|
const onAddPersistentDamage = overrides.onAddPersistentDamage ?? vi.fn();
|
||||||
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
|
const edition = overrides.edition ?? "5.5e";
|
||||||
|
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
||||||
|
const anchor = document.createElement("div");
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
(anchorRef as { current: HTMLElement }).current = anchor;
|
||||||
|
const result = render(
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<EditionSetter edition={edition}>
|
||||||
|
<ConditionPicker
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
activeConditions={overrides.activeConditions ?? []}
|
||||||
|
activePersistentDamage={overrides.activePersistentDamage}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onSetValue={onSetValue}
|
||||||
|
onAddPersistentDamage={onAddPersistentDamage}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
</EditionSetter>
|
||||||
|
</RulesEditionProvider>,
|
||||||
|
);
|
||||||
|
return { ...result, onToggle, onSetValue, onAddPersistentDamage, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ConditionPicker", () => {
|
||||||
|
it("renders edition-specific conditions from domain", () => {
|
||||||
|
renderPicker();
|
||||||
|
const editionConditions = getConditionsForEdition("5.5e");
|
||||||
|
for (const def of editionConditions) {
|
||||||
|
expect(screen.getByText(def.label)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active conditions are visually distinguished", () => {
|
||||||
|
renderPicker({ activeConditions: [{ id: "blinded" }] });
|
||||||
|
const row = screen.getByText("Blinded").closest("div[class]");
|
||||||
|
expect(row?.className).toContain("bg-card/50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a condition calls onToggle with that condition's ID", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onToggle } = renderPicker();
|
||||||
|
await user.click(screen.getByText("Poisoned"));
|
||||||
|
expect(onToggle).toHaveBeenCalledWith("poisoned");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-active conditions render with muted styling", () => {
|
||||||
|
renderPicker({ activeConditions: [] });
|
||||||
|
const label = screen.getByText("Charmed");
|
||||||
|
expect(label.className).toContain("text-muted-foreground");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active condition labels use foreground color", () => {
|
||||||
|
renderPicker({ activeConditions: [{ id: "charmed" }] });
|
||||||
|
const label = screen.getByText("Charmed");
|
||||||
|
expect(label.className).toContain("text-foreground");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Valued conditions (PF2e)", () => {
|
||||||
|
it("clicking a valued condition opens the counter editor", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPicker({ edition: "pf2e" });
|
||||||
|
await user.click(screen.getByText("Frightened"));
|
||||||
|
// Counter editor shows value badge and [-]/[+] buttons
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen
|
||||||
|
.getAllByRole("button")
|
||||||
|
.some((b) => b.querySelector(".lucide-minus")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increment and decrement adjust the counter value", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPicker({ edition: "pf2e" });
|
||||||
|
await user.click(screen.getByText("Frightened"));
|
||||||
|
// Value starts at 1; click [+] to go to 2
|
||||||
|
const plusButtons = screen.getAllByRole("button");
|
||||||
|
const plusButton = plusButtons.find((b) =>
|
||||||
|
b.querySelector(".lucide-plus"),
|
||||||
|
);
|
||||||
|
if (!plusButton) throw new Error("Plus button not found");
|
||||||
|
await user.click(plusButton);
|
||||||
|
expect(screen.getByText("2")).toBeInTheDocument();
|
||||||
|
// Click [-] to go back to 1
|
||||||
|
const minusButton = plusButtons.find((b) =>
|
||||||
|
b.querySelector(".lucide-minus"),
|
||||||
|
);
|
||||||
|
if (!minusButton) throw new Error("Minus button not found");
|
||||||
|
await user.click(minusButton);
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirm button calls onSetValue with condition and value", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSetValue } = renderPicker({ edition: "pf2e" });
|
||||||
|
await user.click(screen.getByText("Frightened"));
|
||||||
|
// Increment to 2, then confirm
|
||||||
|
const plusButton = screen
|
||||||
|
.getAllByRole("button")
|
||||||
|
.find((b) => b.querySelector(".lucide-plus"));
|
||||||
|
if (!plusButton) throw new Error("Plus button not found");
|
||||||
|
await user.click(plusButton);
|
||||||
|
const checkButton = screen
|
||||||
|
.getAllByRole("button")
|
||||||
|
.find((b) => b.querySelector(".lucide-check"));
|
||||||
|
if (!checkButton) throw new Error("Check button not found");
|
||||||
|
await user.click(checkButton);
|
||||||
|
expect(onSetValue).toHaveBeenCalledWith("frightened", 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows active value badge for existing valued condition", () => {
|
||||||
|
renderPicker({
|
||||||
|
edition: "pf2e",
|
||||||
|
activeConditions: [{ id: "frightened", value: 3 }],
|
||||||
|
});
|
||||||
|
expect(screen.getByText("3")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pre-fills counter with existing value when editing", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPicker({
|
||||||
|
edition: "pf2e",
|
||||||
|
activeConditions: [{ id: "frightened", value: 3 }],
|
||||||
|
});
|
||||||
|
await user.click(screen.getByText("Frightened"));
|
||||||
|
expect(screen.getByText("3")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables increment at maxValue", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPicker({
|
||||||
|
edition: "pf2e",
|
||||||
|
activeConditions: [{ id: "doomed", value: 3 }],
|
||||||
|
});
|
||||||
|
// Doomed has maxValue: 3, click to edit
|
||||||
|
await user.click(screen.getByText("Doomed"));
|
||||||
|
const plusButton = screen
|
||||||
|
.getAllByRole("button")
|
||||||
|
.find((b) => b.querySelector(".lucide-plus"));
|
||||||
|
expect(plusButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Persistent Damage (PF2e)", () => {
|
||||||
|
it("shows 'Persistent Damage' entry when edition is pf2e", () => {
|
||||||
|
renderPicker({ edition: "pf2e" });
|
||||||
|
expect(screen.getByText("Persistent Damage")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking 'Persistent Damage' opens sub-picker", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPicker({ edition: "pf2e" });
|
||||||
|
await user.click(screen.getByText("Persistent Damage"));
|
||||||
|
expect(screen.getByPlaceholderText("2d6")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Persistent Damage (D&D)", () => {
|
||||||
|
it("hides 'Persistent Damage' entry when edition is D&D", () => {
|
||||||
|
renderPicker({ edition: "5.5e" });
|
||||||
|
expect(screen.queryByText("Persistent Damage")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { ConditionEntry } 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 ?? (() => {})}
|
||||||
|
onDecrement={props.onDecrement ?? (() => {})}
|
||||||
|
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: ConditionEntry[] = [{ id: "blinded" }, { id: "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: [{ id: "blinded" }] as ConditionEntry[],
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays value badge for valued conditions", () => {
|
||||||
|
renderTags({ conditions: [{ id: "frightened", value: 3 }] });
|
||||||
|
expect(screen.getByText("3")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onDecrement for valued condition click", async () => {
|
||||||
|
const onDecrement = vi.fn();
|
||||||
|
renderTags({
|
||||||
|
conditions: [{ id: "frightened", value: 2 }],
|
||||||
|
onDecrement,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Remove Frightened" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onDecrement).toHaveBeenCalledWith("frightened");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onRemove for non-valued condition click", async () => {
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
renderTags({
|
||||||
|
conditions: [{ id: "blinded" }],
|
||||||
|
onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onRemove).toHaveBeenCalledWith("blinded");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,518 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AnyCreature,
|
||||||
|
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,
|
||||||
|
buildPf2eCreature,
|
||||||
|
} 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, AnyCreature>;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PF2e edition", () => {
|
||||||
|
const orcWarrior = buildPf2eCreature({
|
||||||
|
id: creatureId("pf2e:orc-warrior"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
level: 3,
|
||||||
|
source: "crb",
|
||||||
|
sourceDisplayName: "Core Rulebook",
|
||||||
|
});
|
||||||
|
|
||||||
|
function pf2eEncounter() {
|
||||||
|
return buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
creatureId: orcWarrior.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shows PF2e tier label", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Encounter Difficulty:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows party level", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Party Level: 5", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows creature level and level difference", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Orc Warrior level 3, party level 5 → diff −2
|
||||||
|
expect(
|
||||||
|
screen.getByText("Lv 3 (-2)", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 5 thresholds with short labels", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Triv:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Low:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Mod:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Sev:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Ext:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Net Creature XP label in PF2e mode", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Net Creature XP")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// @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,
|
||||||
|
TIER_LABELS_PF2E,
|
||||||
|
} 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,
|
||||||
|
partyLevel: 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders 4 bars when barCount is 4", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(2)}
|
||||||
|
labels={TIER_LABELS_PF2E}
|
||||||
|
barCount={4}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||||
|
expect(bars).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 0 filled bars for tier 0 with 4 bars", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(0)}
|
||||||
|
labels={TIER_LABELS_PF2E}
|
||||||
|
barCount={4}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||||
|
for (const bar of bars) {
|
||||||
|
expect(bar.className).toContain("bg-muted");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows correct PF2e tooltip for Severe tier", () => {
|
||||||
|
render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(3)}
|
||||||
|
labels={TIER_LABELS_PF2E}
|
||||||
|
barCount={4}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "Severe encounter difficulty" }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows correct PF2e tooltip for Extreme tier", () => {
|
||||||
|
render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(4)}
|
||||||
|
labels={TIER_LABELS_PF2E}
|
||||||
|
barCount={4}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "Extreme encounter difficulty" }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("D&D indicator still renders 3 bars (no regression)", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_5_5E} />,
|
||||||
|
);
|
||||||
|
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||||
|
expect(bars).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { EquipmentItem } from "@initiative/domain";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { EquipmentDetailPopover } from "../equipment-detail-popover.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const POISON: EquipmentItem = {
|
||||||
|
name: "Giant Wasp Venom",
|
||||||
|
level: 7,
|
||||||
|
category: "poison",
|
||||||
|
traits: ["consumable", "poison", "injury"],
|
||||||
|
description: "A deadly poison extracted from giant wasps.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCROLL: EquipmentItem = {
|
||||||
|
name: "Scroll of Teleport",
|
||||||
|
level: 11,
|
||||||
|
category: "scroll",
|
||||||
|
traits: ["consumable", "magical", "scroll"],
|
||||||
|
description: "A scroll containing Teleport.",
|
||||||
|
spellName: "Teleport",
|
||||||
|
spellRank: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANCHOR: DOMRect = new DOMRect(100, 100, 50, 20);
|
||||||
|
const SCROLL_SPELL_REGEX = /Teleport \(Rank 6\)/;
|
||||||
|
const DIALOG_LABEL_REGEX = /Equipment details: Giant Wasp Venom/;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
matches: true,
|
||||||
|
media: "(min-width: 1024px)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EquipmentDetailPopover", () => {
|
||||||
|
it("renders item name, level, traits, and description", () => {
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={POISON}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("7")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("consumable")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("poison")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("injury")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("A deadly poison extracted from giant wasps."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders scroll/wand spell info", () => {
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={SCROLL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(SCROLL_SPELL_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when Escape is pressed", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={POISON}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the dialog role with the item name as label", () => {
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={POISON}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", {
|
||||||
|
name: DIALOG_LABEL_REGEX,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,148 @@
|
|||||||
|
// @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 { HpAdjustPopover } from "../hp-adjust-popover";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderPopover(
|
||||||
|
overrides: Partial<{
|
||||||
|
onAdjust: (delta: number) => void;
|
||||||
|
onSetTempHp: (value: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const onAdjust = overrides.onAdjust ?? vi.fn();
|
||||||
|
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
|
||||||
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
|
const result = render(
|
||||||
|
<HpAdjustPopover
|
||||||
|
onAdjust={onAdjust}
|
||||||
|
onSetTempHp={onSetTempHp}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { ...result, onAdjust, onSetTempHp, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HpAdjustPopover", () => {
|
||||||
|
it("renders input with placeholder 'HP'", () => {
|
||||||
|
renderPopover();
|
||||||
|
expect(screen.getByPlaceholderText("HP")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("damage and heal buttons are disabled when input is empty", () => {
|
||||||
|
renderPopover();
|
||||||
|
expect(screen.getByRole("button", { name: "Apply damage" })).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Apply healing" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("damage and heal buttons are disabled when input is '0'", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "0");
|
||||||
|
expect(screen.getByRole("button", { name: "Apply damage" })).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Apply healing" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("typing a valid number enables both buttons", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "5");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Apply damage" }),
|
||||||
|
).not.toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Apply healing" }),
|
||||||
|
).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking damage button calls onAdjust with negative value and onClose", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdjust, onClose } = renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "7");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Apply damage" }));
|
||||||
|
expect(onAdjust).toHaveBeenCalledWith(-7);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking heal button calls onAdjust with positive value and onClose", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdjust, onClose } = renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "3");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Apply healing" }));
|
||||||
|
expect(onAdjust).toHaveBeenCalledWith(3);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter key applies damage (negative)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdjust, onClose } = renderPopover();
|
||||||
|
const input = screen.getByPlaceholderText("HP");
|
||||||
|
await user.type(input, "4");
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
expect(onAdjust).toHaveBeenCalledWith(-4);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Shift+Enter applies healing (positive)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdjust, onClose } = renderPopover();
|
||||||
|
const input = screen.getByPlaceholderText("HP");
|
||||||
|
await user.type(input, "6");
|
||||||
|
await user.keyboard("{Shift>}{Enter}{/Shift}");
|
||||||
|
expect(onAdjust).toHaveBeenCalledWith(6);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape key calls onClose", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onClose } = renderPopover();
|
||||||
|
const input = screen.getByPlaceholderText("HP");
|
||||||
|
await user.type(input, "2");
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only accepts digit characters in input", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPopover();
|
||||||
|
const input = screen.getByPlaceholderText("HP");
|
||||||
|
await user.type(input, "12abc34");
|
||||||
|
expect(input).toHaveValue("1234");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("temp HP", () => {
|
||||||
|
it("shield button calls onSetTempHp with entered value and closes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSetTempHp, onClose } = renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "8");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Set temp HP" }));
|
||||||
|
expect(onSetTempHp).toHaveBeenCalledWith(8);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shield button is disabled when input is empty", () => {
|
||||||
|
renderPopover();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Set temp HP" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shield button is disabled when input is '0'", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "0");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Set temp HP" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,76 @@
|
|||||||
|
// @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 { PersistentDamagePicker } from "../persistent-damage-picker.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderPicker(
|
||||||
|
overrides: Partial<{
|
||||||
|
activeEntries: { type: string; formula: string }[];
|
||||||
|
onAdd: (damageType: string, formula: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const onAdd = overrides.onAdd ?? vi.fn();
|
||||||
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
|
const result = render(
|
||||||
|
<PersistentDamagePicker
|
||||||
|
activeEntries={
|
||||||
|
(overrides.activeEntries as Parameters<
|
||||||
|
typeof PersistentDamagePicker
|
||||||
|
>[0]["activeEntries"]) ?? undefined
|
||||||
|
}
|
||||||
|
onAdd={onAdd as Parameters<typeof PersistentDamagePicker>[0]["onAdd"]}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { ...result, onAdd, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PersistentDamagePicker", () => {
|
||||||
|
it("renders damage type dropdown and formula input", () => {
|
||||||
|
renderPicker();
|
||||||
|
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText("2d6")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirm button is disabled when formula is empty", () => {
|
||||||
|
renderPicker();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Add persistent damage" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitting calls onAdd with selected type and formula", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdd } = renderPicker();
|
||||||
|
await user.type(screen.getByPlaceholderText("2d6"), "3d6");
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Add persistent damage" }),
|
||||||
|
);
|
||||||
|
expect(onAdd).toHaveBeenCalledWith("fire", "3d6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter in formula input confirms", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdd } = renderPicker();
|
||||||
|
await user.type(screen.getByPlaceholderText("2d6"), "2d6{Enter}");
|
||||||
|
expect(onAdd).toHaveBeenCalledWith("fire", "2d6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pre-fills formula for existing active entry", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPicker({
|
||||||
|
activeEntries: [{ type: "fire", formula: "2d6" }],
|
||||||
|
});
|
||||||
|
expect(screen.getByPlaceholderText("2d6")).toHaveValue("2d6");
|
||||||
|
|
||||||
|
// Change type to one without active entry
|
||||||
|
await user.selectOptions(screen.getByRole("combobox"), "bleed");
|
||||||
|
expect(screen.getByPlaceholderText("2d6")).toHaveValue("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PersistentDamageEntry,
|
||||||
|
PersistentDamageType,
|
||||||
|
} 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 { PersistentDamageTags } from "../persistent-damage-tags.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderTags(
|
||||||
|
entries: readonly PersistentDamageEntry[] | undefined,
|
||||||
|
onRemove = vi.fn(),
|
||||||
|
) {
|
||||||
|
const result = render(
|
||||||
|
<PersistentDamageTags entries={entries} onRemove={onRemove} />,
|
||||||
|
);
|
||||||
|
return { ...result, onRemove };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PersistentDamageTags", () => {
|
||||||
|
it("renders nothing when entries undefined", () => {
|
||||||
|
const { container } = renderTags(undefined);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when entries is empty array", () => {
|
||||||
|
const { container } = renderTags([]);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders tag per entry with icon and formula text", () => {
|
||||||
|
renderTags([
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
]);
|
||||||
|
expect(screen.getByText("2d6")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1d4")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("click calls onRemove with correct damage type", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onRemove } = renderTags([{ type: "fire", formula: "2d6" }]);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Remove persistent Fire damage",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onRemove).toHaveBeenCalledWith(
|
||||||
|
"fire" satisfies PersistentDamageType,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tooltip shows full description", () => {
|
||||||
|
renderTags([{ type: "fire", formula: "2d6" }]);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Remove persistent Fire damage",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
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 { Pf2eStatBlock } from "../pf2e-stat-block.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const USES_PER_DAY_REGEX = /×3/;
|
||||||
|
const HEAL_DESCRIPTION_REGEX = /channel positive energy/;
|
||||||
|
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
|
||||||
|
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
|
||||||
|
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
|
||||||
|
const SAVE_CONDITIONAL_ABSENT_REGEX = /status to all saves/;
|
||||||
|
const HP_DETAILS_REGEX = /115.*regeneration 20/;
|
||||||
|
const REGEN_REGEX = /regeneration/;
|
||||||
|
const ATTACK_NAME_REGEX = /Dogslicer/;
|
||||||
|
const ATTACK_DAMAGE_REGEX = /1d6 slashing/;
|
||||||
|
const SPELLCASTING_ENTRY_REGEX = /Divine Innate Spells\./;
|
||||||
|
const ABILITY_MID_NAME_REGEX = /Goblin Scuttle/;
|
||||||
|
const ABILITY_MID_DESC_REGEX = /The goblin Steps\./;
|
||||||
|
const CANTRIPS_REGEX = /Cantrips:/;
|
||||||
|
const AC_REGEX = /16/;
|
||||||
|
const RK_DC_13_REGEX = /DC 13/;
|
||||||
|
const RK_DC_15_REGEX = /DC 15/;
|
||||||
|
const RK_DC_25_REGEX = /DC 25/;
|
||||||
|
const RK_HUMANOID_SOCIETY_REGEX = /Humanoid \(Society\)/;
|
||||||
|
const RK_UNDEAD_RELIGION_REGEX = /Undead \(Religion\)/;
|
||||||
|
const RK_BEAST_SKILLS_REGEX = /Beast \(Arcana\/Nature\)/;
|
||||||
|
const SCROLL_NAME_REGEX = /Scroll of Teleport/;
|
||||||
|
|
||||||
|
const GOBLIN_WARRIOR: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("pathfinder-monster-core:goblin-warrior"),
|
||||||
|
name: "Goblin Warrior",
|
||||||
|
source: "pathfinder-monster-core",
|
||||||
|
sourceDisplayName: "Monster Core",
|
||||||
|
level: -1,
|
||||||
|
traits: ["small", "goblin", "humanoid"],
|
||||||
|
perception: 2,
|
||||||
|
senses: "Darkvision",
|
||||||
|
languages: "Common, Goblin",
|
||||||
|
skills: "Acrobatics +5, Athletics +2, Nature +1, Stealth +5",
|
||||||
|
abilityMods: { str: 0, dex: 3, con: 1, int: 0, wis: -1, cha: 1 },
|
||||||
|
ac: 16,
|
||||||
|
saveFort: 5,
|
||||||
|
saveRef: 7,
|
||||||
|
saveWill: 3,
|
||||||
|
hp: 6,
|
||||||
|
speed: "25 feet",
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Dogslicer",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: "+7 (agile, backstabber, finesse), 1d6 slashing",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
abilitiesMid: [
|
||||||
|
{
|
||||||
|
name: "Goblin Scuttle",
|
||||||
|
activity: { number: 1, unit: "reaction" },
|
||||||
|
segments: [{ type: "text", value: "The goblin Steps." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAUNET: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("pathfinder-monster-core-2:naunet"),
|
||||||
|
name: "Naunet",
|
||||||
|
source: "pathfinder-monster-core-2",
|
||||||
|
sourceDisplayName: "Monster Core 2",
|
||||||
|
level: 7,
|
||||||
|
traits: ["large", "monitor", "protean"],
|
||||||
|
perception: 14,
|
||||||
|
senses: "Darkvision",
|
||||||
|
languages: "Chthonian, Empyrean, Protean",
|
||||||
|
skills:
|
||||||
|
"Acrobatics +14, Athletics +16, Intimidation +16, Stealth +14, Survival +12",
|
||||||
|
abilityMods: { str: 5, dex: 3, con: 5, int: 0, wis: 3, cha: 3 },
|
||||||
|
ac: 24,
|
||||||
|
saveFort: 18,
|
||||||
|
saveRef: 14,
|
||||||
|
saveWill: 12,
|
||||||
|
saveConditional: "+1 status to all saves vs. magic",
|
||||||
|
hp: 120,
|
||||||
|
resistances: "Precision 5, Protean anatomy 10",
|
||||||
|
speed: "25 feet, Fly 30 feet, Swim 25 feet (unfettered movement)",
|
||||||
|
spellcasting: [
|
||||||
|
{
|
||||||
|
name: "Divine Innate Spells",
|
||||||
|
headerText: "DC 25, attack +17",
|
||||||
|
daily: [
|
||||||
|
{
|
||||||
|
uses: 4,
|
||||||
|
each: true,
|
||||||
|
spells: [{ name: "Unfettered Movement (Constant)" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
atWill: [{ name: "Detect Magic" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TROLL: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("pathfinder-monster-core:forest-troll"),
|
||||||
|
name: "Forest Troll",
|
||||||
|
source: "pathfinder-monster-core",
|
||||||
|
sourceDisplayName: "Monster Core",
|
||||||
|
level: 5,
|
||||||
|
traits: ["large", "giant", "troll"],
|
||||||
|
perception: 11,
|
||||||
|
senses: "Darkvision",
|
||||||
|
languages: "Jotun",
|
||||||
|
skills: "Athletics +12, Intimidation +12",
|
||||||
|
abilityMods: { str: 5, dex: 2, con: 6, int: -2, wis: 0, cha: -2 },
|
||||||
|
ac: 20,
|
||||||
|
saveFort: 17,
|
||||||
|
saveRef: 11,
|
||||||
|
saveWill: 7,
|
||||||
|
hp: 115,
|
||||||
|
hpDetails: "regeneration 20 (deactivated by acid or fire)",
|
||||||
|
weaknesses: "Fire 10",
|
||||||
|
speed: "30 feet",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderStatBlock(creature: Pf2eCreature) {
|
||||||
|
return render(<Pf2eStatBlock creature={creature} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Pf2eStatBlock", () => {
|
||||||
|
describe("header", () => {
|
||||||
|
it("renders creature name and level", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Goblin Warrior" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Level -1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders traits as tags", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Small")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Humanoid")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders source display name", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Monster Core")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recall knowledge", () => {
|
||||||
|
it("renders Recall Knowledge line for a creature with a recognized type trait", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Recall Knowledge")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(RK_DC_13_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(RK_HUMANOID_SOCIETY_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts DC for uncommon rarity", () => {
|
||||||
|
const uncommonCreature: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
traits: ["uncommon", "small", "humanoid"],
|
||||||
|
};
|
||||||
|
renderStatBlock(uncommonCreature);
|
||||||
|
expect(screen.getByText(RK_DC_15_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts DC for rare rarity", () => {
|
||||||
|
const rareCreature: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
level: 5,
|
||||||
|
traits: ["rare", "medium", "undead"],
|
||||||
|
};
|
||||||
|
renderStatBlock(rareCreature);
|
||||||
|
expect(screen.getByText(RK_DC_25_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(RK_UNDEAD_RELIGION_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows multiple skills for types with dual skill mapping", () => {
|
||||||
|
const beastCreature: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
traits: ["small", "beast"],
|
||||||
|
};
|
||||||
|
renderStatBlock(beastCreature);
|
||||||
|
expect(screen.getByText(RK_BEAST_SKILLS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits Recall Knowledge when no type trait is recognized", () => {
|
||||||
|
const noTypeCreature: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
traits: ["small", "goblin"],
|
||||||
|
};
|
||||||
|
renderStatBlock(noTypeCreature);
|
||||||
|
expect(screen.queryByText("Recall Knowledge")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("perception and senses", () => {
|
||||||
|
it("renders perception modifier and senses", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Perception")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(PERCEPTION_SENSES_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders languages", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Languages")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Common, Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders skills", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Skills")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(SKILLS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ability modifiers", () => {
|
||||||
|
it("renders all six ability labels", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
for (const label of ["Str", "Dex", "Con", "Int", "Wis", "Cha"]) {
|
||||||
|
expect(screen.getByText(label)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders positive and negative modifiers", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("+3")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("-1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defenses", () => {
|
||||||
|
it("renders AC and saves", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("AC")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(AC_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Fort")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Ref")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Will")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders HP", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("HP")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("6")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders saveConditional inline with saves", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText(SAVE_CONDITIONAL_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits saveConditional when absent", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(
|
||||||
|
screen.queryByText(SAVE_CONDITIONAL_ABSENT_REGEX),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hpDetails in parentheses after HP", () => {
|
||||||
|
renderStatBlock(TROLL);
|
||||||
|
expect(screen.getByText(HP_DETAILS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits hpDetails when absent", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.queryByText(REGEN_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders resistances and weaknesses", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText("Resistances")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Precision 5, Protean anatomy 10"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("abilities", () => {
|
||||||
|
it("renders mid (defensive) abilities", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText(ABILITY_MID_NAME_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(ABILITY_MID_DESC_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("speed and attacks", () => {
|
||||||
|
it("renders speed", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Speed")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("25 feet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders attacks", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText(ATTACK_NAME_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(ATTACK_DAMAGE_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spellcasting", () => {
|
||||||
|
it("renders spellcasting entry with header", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText(SPELLCASTING_ENTRY_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("DC 25, attack +17")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ranked spells", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText("Rank 4:")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Unfettered Movement (Constant)"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders cantrips", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText("Cantrips:")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Detect Magic")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits spellcasting when absent", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.queryByText(CANTRIPS_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("equipment section", () => {
|
||||||
|
const CREATURE_WITH_EQUIPMENT: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
id: creatureId("test:equipped"),
|
||||||
|
name: "Equipped NPC",
|
||||||
|
items: "longsword, leather armor",
|
||||||
|
equipment: [
|
||||||
|
{
|
||||||
|
name: "Giant Wasp Venom",
|
||||||
|
level: 7,
|
||||||
|
category: "poison",
|
||||||
|
traits: ["consumable", "poison"],
|
||||||
|
description: "A deadly poison extracted from giant wasps.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Scroll of Teleport",
|
||||||
|
level: 11,
|
||||||
|
category: "scroll",
|
||||||
|
traits: ["consumable", "magical", "scroll"],
|
||||||
|
description: "A scroll containing Teleport.",
|
||||||
|
spellName: "Teleport",
|
||||||
|
spellRank: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Plain Talisman",
|
||||||
|
level: 1,
|
||||||
|
traits: ["magical"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders Equipment section with item names", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Equipment" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders scroll name as-is from Foundry data", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(screen.getByText(SCROLL_NAME_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render Equipment section when creature has no equipment", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Equipment" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders equipment items with descriptions as clickable buttons", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Giant Wasp Venom" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders equipment items without descriptions as plain text", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Plain Talisman" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Plain Talisman")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Items line with mundane item names", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(screen.getByText("Items")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("longsword, leather armor")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clickable spells", () => {
|
||||||
|
const SPELLCASTER: Pf2eCreature = {
|
||||||
|
...NAUNET,
|
||||||
|
id: creatureId("test:spellcaster"),
|
||||||
|
name: "Spellcaster",
|
||||||
|
spellcasting: [
|
||||||
|
{
|
||||||
|
name: "Divine Innate Spells",
|
||||||
|
headerText: "DC 30, attack +20",
|
||||||
|
atWill: [{ name: "Detect Magic", rank: 1 }],
|
||||||
|
daily: [
|
||||||
|
{
|
||||||
|
uses: 4,
|
||||||
|
each: true,
|
||||||
|
spells: [
|
||||||
|
{
|
||||||
|
name: "Heal",
|
||||||
|
description: "You channel positive energy to heal.",
|
||||||
|
rank: 4,
|
||||||
|
usesPerDay: 3,
|
||||||
|
},
|
||||||
|
{ name: "Restoration", rank: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
matches: true,
|
||||||
|
media: "(min-width: 1024px)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a spell with a description as a clickable button", () => {
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
expect(screen.getByRole("button", { name: "Heal" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a spell without description as plain text (not a button)", () => {
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Restoration" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Restoration")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders usesPerDay as plain text alongside the spell button", () => {
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
expect(screen.getByText(USES_PER_DAY_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the spell popover when a spell button is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||||
|
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the popover when Escape is pressed", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||||
|
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(
|
||||||
|
screen.queryByText(HEAL_DESCRIPTION_REGEX),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,88 @@
|
|||||||
|
// @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 game system section with all three options", () => {
|
||||||
|
renderModal();
|
||||||
|
expect(screen.getByText("Game System")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "5e (2014)" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "5.5e (2024)" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Pathfinder 2e" }),
|
||||||
|
).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,133 @@
|
|||||||
|
// @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 { RulesEditionProvider } from "../../contexts/rules-edition-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(
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<AdapterProvider adapters={adapters}>
|
||||||
|
<SourceFetchPrompt
|
||||||
|
sourceCode={sourceCode}
|
||||||
|
onSourceLoaded={onSourceLoaded}
|
||||||
|
/>
|
||||||
|
</AdapterProvider>
|
||||||
|
</RulesEditionProvider>,
|
||||||
|
);
|
||||||
|
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({ skippedNames: [] });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSourceLoaded } = renderPrompt();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Load"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchAndCacheSource).toHaveBeenCalledWith(
|
||||||
|
"MM",
|
||||||
|
"https://example.com/bestiary/MM.json",
|
||||||
|
);
|
||||||
|
expect(onSourceLoaded).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetch error shows error message", async () => {
|
||||||
|
mockFetchAndCacheSource.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPrompt();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Load"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upload file calls uploadAndCacheSource and onSourceLoaded", async () => {
|
||||||
|
mockUploadAndCacheSource.mockResolvedValueOnce(undefined);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSourceLoaded } = renderPrompt();
|
||||||
|
|
||||||
|
const file = new File(['{"monster":[]}'], "bestiary-mm.json", {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const fileInput = document.querySelector(
|
||||||
|
'input[type="file"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
await user.upload(fileInput, file);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUploadAndCacheSource).toHaveBeenCalledWith("MM", {
|
||||||
|
monster: [],
|
||||||
|
});
|
||||||
|
expect(onSourceLoaded).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upload error shows error message", async () => {
|
||||||
|
mockUploadAndCacheSource.mockRejectedValueOnce(new Error("Invalid format"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPrompt();
|
||||||
|
|
||||||
|
const file = new File(['{"bad": true}'], "bad.json", {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const fileInput = document.querySelector(
|
||||||
|
'input[type="file"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
await user.upload(fileInput, file);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Invalid format")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// @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 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";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
function renderWithSources(sources: CachedSourceInfo[] = []): void {
|
||||||
|
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(_system, 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 () => {
|
||||||
|
renderWithSources([]);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists cached sources with display name and creature count", async () => {
|
||||||
|
renderWithSources([
|
||||||
|
{
|
||||||
|
sourceCode: "mm",
|
||||||
|
displayName: "Monster Manual",
|
||||||
|
creatureCount: 300,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceCode: "vgm",
|
||||||
|
displayName: "Volo's Guide",
|
||||||
|
creatureCount: 100,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("300 creatures")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Volo's Guide")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Clear All button removes all sources", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithSources([
|
||||||
|
{
|
||||||
|
sourceCode: "mm",
|
||||||
|
displayName: "Monster Manual",
|
||||||
|
creatureCount: 300,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Clear All" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("individual source delete button removes that source", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithSources([
|
||||||
|
{
|
||||||
|
sourceCode: "mm",
|
||||||
|
displayName: "Monster Manual",
|
||||||
|
creatureCount: 300,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceCode: "vgm",
|
||||||
|
displayName: "Volo's Guide",
|
||||||
|
creatureCount: 100,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Remove Monster Manual" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("Monster Manual")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Volo's Guide")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { SpellReference } from "@initiative/domain";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SpellDetailPopover } from "../spell-detail-popover.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const FIREBALL: SpellReference = {
|
||||||
|
name: "Fireball",
|
||||||
|
slug: "fireball",
|
||||||
|
rank: 3,
|
||||||
|
description: "A spark leaps from your fingertip to the target.",
|
||||||
|
traits: ["fire", "manipulate"],
|
||||||
|
traditions: ["arcane", "primal"],
|
||||||
|
range: "500 feet",
|
||||||
|
area: "20-foot burst",
|
||||||
|
defense: "basic Reflex",
|
||||||
|
actionCost: "2",
|
||||||
|
heightening: "Heightened (+1) The damage increases by 2d6.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANCHOR: DOMRect = new DOMRect(100, 100, 50, 20);
|
||||||
|
|
||||||
|
const SPARK_LEAPS_REGEX = /spark leaps/;
|
||||||
|
const HEIGHTENED_REGEX = /Heightened.*2d6/;
|
||||||
|
const RANGE_REGEX = /500 feet/;
|
||||||
|
const AREA_REGEX = /20-foot burst/;
|
||||||
|
const DEFENSE_REGEX = /basic Reflex/;
|
||||||
|
const NO_DESCRIPTION_REGEX = /No description available/;
|
||||||
|
const DIALOG_LABEL_REGEX = /Spell details: Fireball/;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Force desktop variant in jsdom
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
matches: true,
|
||||||
|
media: "(min-width: 1024px)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SpellDetailPopover", () => {
|
||||||
|
it("renders spell name, rank, traits, and description", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Fireball")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("3rd")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("fire")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("manipulate")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(SPARK_LEAPS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders heightening rules when present", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(HEIGHTENED_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders range, area, and defense", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(RANGE_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(AREA_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(DEFENSE_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when Escape is pressed", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows placeholder when description is missing", () => {
|
||||||
|
const spell: SpellReference = { name: "Mystery", rank: 1 };
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={spell}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(NO_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the action cost as an icon when it is a numeric action count", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Action cost "2" renders as an SVG ActivityIcon (portaled to body)
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
expect(dialog.querySelector("svg")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders non-numeric action cost as text", () => {
|
||||||
|
const spell: SpellReference = {
|
||||||
|
...FIREBALL,
|
||||||
|
actionCost: "1 minute",
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={spell}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("1 minute")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the dialog role with the spell name as label", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", { name: DIALOG_LABEL_REGEX }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
// @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 { DndStatBlock as StatBlock } from "../dnd-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: [{ name: "detect magic" }, { name: "suggestion" }],
|
||||||
|
daily: [
|
||||||
|
{
|
||||||
|
uses: 3,
|
||||||
|
each: true,
|
||||||
|
spells: [{ name: "fireball" }, { name: "wall of fire" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
restLong: [{ uses: 1, each: false, spells: [{ name: "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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { combatantId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
roundNumber: 3,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the combatant name separately from the round badge", () => {
|
||||||
|
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();
|
||||||
|
expect(name).toBeInTheDocument();
|
||||||
|
expect(badge).not.toBe(name);
|
||||||
|
expect(badge.closest("[class]")).not.toBe(name.closest("[class]"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render an em dash between round and name", () => {
|
||||||
|
const { container } = renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(container.textContent).not.toContain("\u2014");
|
||||||
|
});
|
||||||
|
|
||||||
|
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 and name are in separate grid cells to prevent layout shifts
|
||||||
|
expect(badge.parentElement).not.toBe(name.parentElement);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("US2: Layout robustness", () => {
|
||||||
|
it("applies truncation styles to long combatant names", () => {
|
||||||
|
const longName =
|
||||||
|
"Ancient Red Dragon Wyrm of the Northern Wastes and Beyond";
|
||||||
|
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(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "O" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("O")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Previous turn" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Next turn" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps all action buttons accessible regardless of name length", () => {
|
||||||
|
const longName = "A".repeat(60);
|
||||||
|
renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: longName })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Previous turn" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Next turn" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a 40-character name without truncation class issues", () => {
|
||||||
|
const name40 = "A".repeat(40);
|
||||||
|
renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: name40 })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const nameEl = screen.getByText(name40);
|
||||||
|
expect(nameEl).toBeInTheDocument();
|
||||||
|
expect(nameEl.className).toContain("truncate");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("US3: No combatants state", () => {
|
||||||
|
it("shows the round badge when there are no combatants", () => {
|
||||||
|
renderNav(buildEncounter({ combatants: [], roundNumber: 1 }));
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'No combatants' placeholder text", () => {
|
||||||
|
renderNav(buildEncounter({ combatants: [] }));
|
||||||
|
expect(screen.getByText("No combatants")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables navigation buttons when there are no combatants", () => {
|
||||||
|
renderNav(buildEncounter({ combatants: [] }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Previous turn" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "Next turn" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
interface AcShieldProps {
|
||||||
|
readonly value: number | undefined;
|
||||||
|
readonly onClick?: () => void;
|
||||||
|
readonly className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AcShield({ value, onClick, className }: AcShieldProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex items-center justify-center text-muted-foreground text-sm tabular-nums transition-colors hover:text-hover-neutral",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{ width: 28, height: 32 }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 28 32"
|
||||||
|
fill="var(--color-border)"
|
||||||
|
fillOpacity={0.5}
|
||||||
|
stroke="none"
|
||||||
|
className="absolute inset-0 h-full w-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
||||||
|
</svg>
|
||||||
|
<span className="relative -mt-0.5 font-medium text-xs leading-none">
|
||||||
|
{value == null ? "\u2014" : String(value)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Import,
|
||||||
|
Library,
|
||||||
|
Minus,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
Upload,
|
||||||
|
Users,
|
||||||
|
} from "lucide-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 {
|
||||||
|
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 { D20Icon } from "./d20-icon.js";
|
||||||
|
import { ExportMethodDialog } from "./export-method-dialog.js";
|
||||||
|
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
||||||
|
import { ImportMethodDialog } from "./import-method-dialog.js";
|
||||||
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||||
|
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||||
|
import { Toast } from "./toast.js";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Input } from "./ui/input.js";
|
||||||
|
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
||||||
|
|
||||||
|
interface ActionBarProps {
|
||||||
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
onManagePlayers?: () => void;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddModeSuggestionsProps {
|
||||||
|
nameInput: string;
|
||||||
|
suggestions: SearchResult[];
|
||||||
|
pcMatches: PlayerCharacter[];
|
||||||
|
suggestionIndex: number;
|
||||||
|
queued: QueuedCreature | null;
|
||||||
|
actions: SuggestionActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddModeSuggestions({
|
||||||
|
nameInput,
|
||||||
|
suggestions,
|
||||||
|
pcMatches,
|
||||||
|
suggestionIndex,
|
||||||
|
queued,
|
||||||
|
actions,
|
||||||
|
}: Readonly<AddModeSuggestionsProps>) {
|
||||||
|
return (
|
||||||
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={actions.dismiss}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
<span className="flex-1">Add "{nameInput}" as custom</span>
|
||||||
|
<kbd className="rounded border border-border px-1.5 py-0.5 text-muted-foreground text-xs">
|
||||||
|
Esc
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
<div className="max-h-48 overflow-y-auto py-1">
|
||||||
|
{pcMatches.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-1 font-medium text-muted-foreground text-xs">
|
||||||
|
Players
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{pcMatches.map((pc) => {
|
||||||
|
const PcIcon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||||
|
const pcColor = pc.color
|
||||||
|
? PLAYER_COLOR_HEX[pc.color]
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<li key={pc.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
actions.addFromPlayerCharacter?.(pc);
|
||||||
|
actions.clear();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!!PcIcon && (
|
||||||
|
<PcIcon size={14} style={{ color: pcColor }} />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 truncate">{pc.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Player
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<ul>
|
||||||
|
{suggestions.map((result, i) => {
|
||||||
|
const key = creatureKey(result);
|
||||||
|
const isQueued =
|
||||||
|
queued !== null && creatureKey(queued.result) === key;
|
||||||
|
return (
|
||||||
|
<li key={key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
|
||||||
|
isQueued && "bg-accent/30",
|
||||||
|
!isQueued && i === suggestionIndex && "bg-accent/20",
|
||||||
|
!isQueued &&
|
||||||
|
i !== suggestionIndex &&
|
||||||
|
"hover:bg-hover-neutral-bg",
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => actions.clickSuggestion(result)}
|
||||||
|
onMouseEnter={() => actions.setSuggestionIndex(i)}
|
||||||
|
>
|
||||||
|
<span>{result.name}</span>
|
||||||
|
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
||||||
|
{isQueued ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (queued.count <= 1) {
|
||||||
|
actions.setQueued(null);
|
||||||
|
} else {
|
||||||
|
actions.setQueued({
|
||||||
|
...queued,
|
||||||
|
count: queued.count - 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground">
|
||||||
|
{queued.count}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
actions.setQueued({
|
||||||
|
...queued,
|
||||||
|
count: queued.count + 1,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
actions.confirmQueued();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
result.sourceDisplayName
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowseSuggestionsProps {
|
||||||
|
suggestions: SearchResult[];
|
||||||
|
suggestionIndex: number;
|
||||||
|
onSelect: (result: SearchResult) => void;
|
||||||
|
onHover: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrowseSuggestions({
|
||||||
|
suggestions,
|
||||||
|
suggestionIndex,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
}: Readonly<BrowseSuggestionsProps>) {
|
||||||
|
if (suggestions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
||||||
|
<ul className="max-h-48 overflow-y-auto py-1">
|
||||||
|
{suggestions.map((result, i) => (
|
||||||
|
<li key={creatureKey(result)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
|
||||||
|
i === suggestionIndex
|
||||||
|
? "bg-accent/20 text-foreground"
|
||||||
|
: "text-foreground hover:bg-hover-neutral-bg",
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onSelect(result)}
|
||||||
|
onMouseEnter={() => onHover(i)}
|
||||||
|
>
|
||||||
|
<span>{result.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{result.sourceDisplayName}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomStatFieldsProps {
|
||||||
|
customInit: string;
|
||||||
|
customAc: string;
|
||||||
|
customMaxHp: string;
|
||||||
|
onInitChange: (v: string) => void;
|
||||||
|
onAcChange: (v: string) => void;
|
||||||
|
onMaxHpChange: (v: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomStatFields({
|
||||||
|
customInit,
|
||||||
|
customAc,
|
||||||
|
customMaxHp,
|
||||||
|
onInitChange,
|
||||||
|
onAcChange,
|
||||||
|
onMaxHpChange,
|
||||||
|
}: Readonly<CustomStatFieldsProps>) {
|
||||||
|
return (
|
||||||
|
<div className="hidden items-center gap-2 sm:flex">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customInit}
|
||||||
|
onChange={(e) => onInitChange(e.target.value)}
|
||||||
|
placeholder="Init"
|
||||||
|
className="w-16 text-center"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customAc}
|
||||||
|
onChange={(e) => onAcChange(e.target.value)}
|
||||||
|
placeholder="AC"
|
||||||
|
className="w-16 text-center"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customMaxHp}
|
||||||
|
onChange={(e) => onMaxHpChange(e.target.value)}
|
||||||
|
placeholder="MaxHP"
|
||||||
|
className="w-18 text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RollAllButton() {
|
||||||
|
const { hasCreatureCombatants, canRollAllInitiative } = useEncounterContext();
|
||||||
|
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||||
|
|
||||||
|
const [menuPos, setMenuPos] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const openMenu = useCallback((x: number, y: number) => {
|
||||||
|
setMenuPos({ x, y });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const longPress = useLongPress(
|
||||||
|
useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (touch) openMenu(touch.clientX, touch.clientY);
|
||||||
|
},
|
||||||
|
[openMenu],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasCreatureCombatants) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground hover:text-hover-action"
|
||||||
|
onClick={() => handleRollAllInitiative()}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openMenu(e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
{...longPress}
|
||||||
|
disabled={!canRollAllInitiative}
|
||||||
|
title="Roll all initiative"
|
||||||
|
aria-label="Roll all initiative"
|
||||||
|
>
|
||||||
|
<D20Icon className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
{!!menuPos && (
|
||||||
|
<RollModeMenu
|
||||||
|
position={menuPos}
|
||||||
|
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||||
|
onClose={() => setMenuPos(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverflowItems(opts: {
|
||||||
|
onManagePlayers?: () => void;
|
||||||
|
onOpenSourceManager?: () => void;
|
||||||
|
bestiaryLoaded: boolean;
|
||||||
|
onBulkImport?: () => void;
|
||||||
|
bulkImportDisabled?: boolean;
|
||||||
|
onExportEncounter: () => void;
|
||||||
|
onImportEncounter: () => void;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
|
}): OverflowMenuItem[] {
|
||||||
|
const items: OverflowMenuItem[] = [];
|
||||||
|
if (opts.onManagePlayers) {
|
||||||
|
items.push({
|
||||||
|
icon: <Users className="h-4 w-4" />,
|
||||||
|
label: "Player Characters",
|
||||||
|
onClick: opts.onManagePlayers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (opts.onOpenSourceManager) {
|
||||||
|
items.push({
|
||||||
|
icon: <Library className="h-4 w-4" />,
|
||||||
|
label: "Manage Sources",
|
||||||
|
onClick: opts.onOpenSourceManager,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
||||||
|
items.push({
|
||||||
|
icon: <Import className="h-4 w-4" />,
|
||||||
|
label: "Import All Sources",
|
||||||
|
onClick: opts.onBulkImport,
|
||||||
|
disabled: opts.bulkImportDisabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
icon: <Download className="h-4 w-4" />,
|
||||||
|
label: "Export Encounter",
|
||||||
|
onClick: opts.onExportEncounter,
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
icon: <Upload className="h-4 w-4" />,
|
||||||
|
label: "Import Encounter",
|
||||||
|
onClick: opts.onImportEncounter,
|
||||||
|
});
|
||||||
|
if (opts.onOpenSettings) {
|
||||||
|
items.push({
|
||||||
|
icon: <Settings className="h-4 w-4" />,
|
||||||
|
label: "Settings",
|
||||||
|
onClick: opts.onOpenSettings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionBar({
|
||||||
|
inputRef,
|
||||||
|
autoFocus,
|
||||||
|
onManagePlayers,
|
||||||
|
onOpenSettings,
|
||||||
|
}: Readonly<ActionBarProps>) {
|
||||||
|
const {
|
||||||
|
nameInput,
|
||||||
|
suggestions,
|
||||||
|
pcMatches,
|
||||||
|
suggestionIndex,
|
||||||
|
queued,
|
||||||
|
customInit,
|
||||||
|
customAc,
|
||||||
|
customMaxHp,
|
||||||
|
browseMode,
|
||||||
|
bestiaryLoaded,
|
||||||
|
hasSuggestions,
|
||||||
|
showBulkImport,
|
||||||
|
showSourceManager,
|
||||||
|
suggestionActions,
|
||||||
|
handleNameChange,
|
||||||
|
handleKeyDown,
|
||||||
|
handleBrowseKeyDown,
|
||||||
|
handleAdd,
|
||||||
|
handleBrowseSelect,
|
||||||
|
toggleBrowseMode,
|
||||||
|
setCustomInit,
|
||||||
|
setCustomAc,
|
||||||
|
setCustomMaxHp,
|
||||||
|
} = useActionBarState();
|
||||||
|
|
||||||
|
const { state: bulkImportState } = useBulkImportContext();
|
||||||
|
|
||||||
|
const {
|
||||||
|
importError,
|
||||||
|
showExportMethod,
|
||||||
|
showImportMethod,
|
||||||
|
showImportConfirm,
|
||||||
|
importFileRef,
|
||||||
|
setImportError,
|
||||||
|
setShowExportMethod,
|
||||||
|
setShowImportMethod,
|
||||||
|
handleExportDownload,
|
||||||
|
handleExportClipboard,
|
||||||
|
handleImportFile,
|
||||||
|
handleImportClipboard,
|
||||||
|
handleImportConfirm,
|
||||||
|
handleImportCancel,
|
||||||
|
} = useEncounterExportImport();
|
||||||
|
|
||||||
|
const overflowItems = buildOverflowItems({
|
||||||
|
onManagePlayers,
|
||||||
|
onOpenSourceManager: showSourceManager,
|
||||||
|
bestiaryLoaded,
|
||||||
|
onBulkImport: showBulkImport,
|
||||||
|
bulkImportDisabled: bulkImportState.status === "loading",
|
||||||
|
onExportEncounter: () => setShowExportMethod(true),
|
||||||
|
onImportEncounter: () => setShowImportMethod(true),
|
||||||
|
onOpenSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-glow flex items-center gap-3 border-border border-t bg-card px-4 py-3 sm:rounded-lg sm:border">
|
||||||
|
<form
|
||||||
|
onSubmit={handleAdd}
|
||||||
|
className="relative flex flex-1 flex-wrap items-center gap-3 sm:flex-nowrap"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative max-w-xs">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={nameInput}
|
||||||
|
onChange={(e) => handleNameChange(e.target.value)}
|
||||||
|
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
|
||||||
|
placeholder={
|
||||||
|
browseMode ? "Search stat blocks..." : "+ Add combatants"
|
||||||
|
}
|
||||||
|
className="pr-8"
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
/>
|
||||||
|
{!!bestiaryLoaded && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
||||||
|
browseMode && "text-accent",
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={toggleBrowseMode}
|
||||||
|
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
||||||
|
aria-label={
|
||||||
|
browseMode ? "Switch to add mode" : "Browse stat blocks"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{browseMode ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!!browseMode && (
|
||||||
|
<BrowseSuggestions
|
||||||
|
suggestions={suggestions}
|
||||||
|
suggestionIndex={suggestionIndex}
|
||||||
|
onSelect={handleBrowseSelect}
|
||||||
|
onHover={suggestionActions.setSuggestionIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!browseMode && hasSuggestions && (
|
||||||
|
<AddModeSuggestions
|
||||||
|
nameInput={nameInput}
|
||||||
|
suggestions={suggestions}
|
||||||
|
pcMatches={pcMatches}
|
||||||
|
suggestionIndex={suggestionIndex}
|
||||||
|
queued={queued}
|
||||||
|
actions={suggestionActions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
|
<CustomStatFields
|
||||||
|
customInit={customInit}
|
||||||
|
customAc={customAc}
|
||||||
|
customMaxHp={customMaxHp}
|
||||||
|
onInitChange={setCustomInit}
|
||||||
|
onAcChange={setCustomAc}
|
||||||
|
onMaxHpChange={setCustomMaxHp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
|
<Button type="submit">Add</Button>
|
||||||
|
)}
|
||||||
|
<RollAllButton />
|
||||||
|
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||||
|
</form>
|
||||||
|
<input
|
||||||
|
ref={importFileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImportFile}
|
||||||
|
/>
|
||||||
|
{!!importError && (
|
||||||
|
<Toast
|
||||||
|
message={importError}
|
||||||
|
onDismiss={() => setImportError(null)}
|
||||||
|
autoDismissMs={5000}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ExportMethodDialog
|
||||||
|
open={showExportMethod}
|
||||||
|
onDownload={handleExportDownload}
|
||||||
|
onCopyToClipboard={handleExportClipboard}
|
||||||
|
onClose={() => setShowExportMethod(false)}
|
||||||
|
/>
|
||||||
|
<ImportMethodDialog
|
||||||
|
open={showImportMethod}
|
||||||
|
onSelectFile={() => importFileRef.current?.click()}
|
||||||
|
onSubmitClipboard={handleImportClipboard}
|
||||||
|
onClose={() => setShowImportMethod(false)}
|
||||||
|
/>
|
||||||
|
<ImportConfirmDialog
|
||||||
|
open={showImportConfirm}
|
||||||
|
onConfirm={handleImportConfirm}
|
||||||
|
onCancel={handleImportCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useId, useState } from "react";
|
||||||
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
|
const DND_BASE_URL =
|
||||||
|
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||||
|
|
||||||
|
const PF2E_BASE_URL =
|
||||||
|
"https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/";
|
||||||
|
|
||||||
|
export function BulkImportPrompt() {
|
||||||
|
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||||
|
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
||||||
|
useBestiaryContext();
|
||||||
|
const { state: importState, startImport, reset } = useBulkImportContext();
|
||||||
|
const { dismissPanel } = useSidePanelContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
|
||||||
|
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
||||||
|
const defaultUrl = edition === "pf2e" ? PF2E_BASE_URL : DND_BASE_URL;
|
||||||
|
const [baseUrl, setBaseUrl] = useState(defaultUrl);
|
||||||
|
const baseUrlId = useId();
|
||||||
|
const totalSources = indexPort.getAllSourceCodes().length;
|
||||||
|
|
||||||
|
const handleStart = (url: string) => {
|
||||||
|
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
dismissPanel();
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (importState.status === "complete") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
|
||||||
|
All sources loaded
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleDone}>Done</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importState.status === "partial-failure") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-md border border-yellow-500/50 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-400">
|
||||||
|
Loaded {importState.completed}/{importState.total} sources (
|
||||||
|
{importState.failed} failed)
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleDone}>Done</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importState.status === "loading") {
|
||||||
|
const processed = importState.completed + importState.failed;
|
||||||
|
const pct =
|
||||||
|
importState.total > 0
|
||||||
|
? Math.round((processed / importState.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading sources... {processed}/{importState.total}
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// idle state
|
||||||
|
const isDisabled = !baseUrl.trim() || importState.status !== "idle";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-foreground text-sm">
|
||||||
|
Import All Sources
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
|
Load stat block data for all {totalSources} sources at once.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor={baseUrlId} className="text-muted-foreground text-xs">
|
||||||
|
Base URL
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id={baseUrlId}
|
||||||
|
type="url"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => handleStart(baseUrl)} disabled={isDisabled}>
|
||||||
|
Load All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
import { Toast } from "./toast.js";
|
||||||
|
|
||||||
|
export function BulkImportToasts() {
|
||||||
|
const { state, reset } = useBulkImportContext();
|
||||||
|
const { bulkImportMode, isRightPanelCollapsed } = useSidePanelContext();
|
||||||
|
const visible = !bulkImportMode || isRightPanelCollapsed;
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message={`Loading sources... ${state.completed + state.failed}/${state.total}`}
|
||||||
|
progress={
|
||||||
|
state.total > 0 ? (state.completed + state.failed) / state.total : 0
|
||||||
|
}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "complete") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message="All sources loaded"
|
||||||
|
onDismiss={reset}
|
||||||
|
autoDismissMs={3000}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "partial-failure") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
|
||||||
|
onDismiss={reset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { VALID_PLAYER_COLORS } from "@initiative/domain";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { PLAYER_COLOR_HEX } from "./player-icon-map";
|
||||||
|
|
||||||
|
interface ColorPaletteProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (color: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [...VALID_PLAYER_COLORS] as string[];
|
||||||
|
|
||||||
|
export function ColorPalette({ value, onChange }: Readonly<ColorPaletteProps>) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(value === color ? "" : color)}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 rounded-full transition-all",
|
||||||
|
value === color
|
||||||
|
? "scale-110 ring-2 ring-foreground ring-offset-2 ring-offset-background"
|
||||||
|
: "hover:scale-110",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
PLAYER_COLOR_HEX[color as keyof typeof PLAYER_COLOR_HEX],
|
||||||
|
}}
|
||||||
|
aria-label={color}
|
||||||
|
title={color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type ConditionEntry,
|
||||||
|
type CreatureId,
|
||||||
|
deriveHpStatus,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PlayerIcon,
|
||||||
|
type RollMode,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { Brain, Pencil, X } from "lucide-react";
|
||||||
|
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
import { useLongPress } from "../hooks/use-long-press.js";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
import { AcShield } from "./ac-shield.js";
|
||||||
|
import { ConditionPicker } from "./condition-picker.js";
|
||||||
|
import { ConditionTags } from "./condition-tags.js";
|
||||||
|
import { D20Icon } from "./d20-icon.js";
|
||||||
|
import { HpAdjustPopover } from "./hp-adjust-popover.js";
|
||||||
|
import { PersistentDamageTags } from "./persistent-damage-tags.js";
|
||||||
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||||
|
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||||
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||||
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
|
interface Combatant {
|
||||||
|
readonly id: CombatantId;
|
||||||
|
readonly name: string;
|
||||||
|
readonly initiative?: number;
|
||||||
|
readonly maxHp?: number;
|
||||||
|
readonly currentHp?: number;
|
||||||
|
readonly tempHp?: number;
|
||||||
|
readonly ac?: number;
|
||||||
|
readonly conditions?: readonly ConditionEntry[];
|
||||||
|
readonly persistentDamage?: readonly PersistentDamageEntry[];
|
||||||
|
readonly isConcentrating?: boolean;
|
||||||
|
readonly color?: string;
|
||||||
|
readonly icon?: string;
|
||||||
|
readonly creatureId?: CreatureId;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CombatantRowProps {
|
||||||
|
combatant: Combatant;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableName({
|
||||||
|
name,
|
||||||
|
combatantId,
|
||||||
|
onRename,
|
||||||
|
color,
|
||||||
|
onToggleStatBlock,
|
||||||
|
}: Readonly<{
|
||||||
|
name: string;
|
||||||
|
combatantId: CombatantId;
|
||||||
|
onRename: (id: CombatantId, newName: string) => void;
|
||||||
|
color?: string;
|
||||||
|
onToggleStatBlock?: () => void;
|
||||||
|
}>) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(name);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const commit = useCallback(() => {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (trimmed !== "" && trimmed !== name) {
|
||||||
|
onRename(combatantId, trimmed);
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
}, [draft, name, combatantId, onRename]);
|
||||||
|
|
||||||
|
const startEditing = useCallback(() => {
|
||||||
|
setDraft(name);
|
||||||
|
setEditing(true);
|
||||||
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
className="h-7 max-w-48 text-sm"
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commit();
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleStatBlock}
|
||||||
|
disabled={!onToggleStatBlock}
|
||||||
|
className={cn(
|
||||||
|
"truncate text-left text-sm transition-colors",
|
||||||
|
onToggleStatBlock
|
||||||
|
? "cursor-pointer text-foreground hover:text-hover-neutral"
|
||||||
|
: "cursor-default text-foreground",
|
||||||
|
)}
|
||||||
|
style={color ? { color } : undefined}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startEditing}
|
||||||
|
title="Rename"
|
||||||
|
aria-label="Rename"
|
||||||
|
className="inline-flex pointer-coarse:w-auto w-0 shrink-0 items-center overflow-hidden pointer-coarse:overflow-visible rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-all duration-150 hover:bg-hover-neutral-bg hover:text-hover-neutral focus:w-auto focus:overflow-visible focus:opacity-100 group-hover:w-auto group-hover:overflow-visible group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaxHpDisplay({
|
||||||
|
maxHp,
|
||||||
|
onCommit,
|
||||||
|
}: Readonly<{
|
||||||
|
maxHp: number | undefined;
|
||||||
|
onCommit: (value: number | undefined) => void;
|
||||||
|
}>) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const commit = useCallback(() => {
|
||||||
|
if (draft === "") {
|
||||||
|
onCommit(undefined);
|
||||||
|
} else {
|
||||||
|
const n = Number.parseInt(draft, 10);
|
||||||
|
if (!Number.isNaN(n) && n >= 1) {
|
||||||
|
onCommit(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
}, [draft, onCommit]);
|
||||||
|
|
||||||
|
const startEditing = useCallback(() => {
|
||||||
|
setDraft(maxHp?.toString() ?? "");
|
||||||
|
setEditing(true);
|
||||||
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
|
}, [maxHp]);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draft}
|
||||||
|
placeholder="Max"
|
||||||
|
className="h-7 w-[7ch] text-center tabular-nums"
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commit();
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startEditing}
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-7 min-w-[3ch] text-center leading-7 transition-colors hover:text-hover-neutral",
|
||||||
|
maxHp === undefined
|
||||||
|
? "text-muted-foreground text-sm"
|
||||||
|
: "text-muted-foreground text-xs",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{maxHp ?? "Max"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClickableHp({
|
||||||
|
currentHp,
|
||||||
|
maxHp,
|
||||||
|
tempHp,
|
||||||
|
onAdjust,
|
||||||
|
onSetTempHp,
|
||||||
|
}: Readonly<{
|
||||||
|
currentHp: number | undefined;
|
||||||
|
maxHp: number | undefined;
|
||||||
|
tempHp: number | undefined;
|
||||||
|
onAdjust: (delta: number) => void;
|
||||||
|
onSetTempHp: (value: number) => void;
|
||||||
|
}>) {
|
||||||
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
|
|
||||||
|
if (maxHp === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPopoverOpen(true)}
|
||||||
|
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm leading-7 transition-colors hover:text-hover-neutral",
|
||||||
|
status === "bloodied" && "text-amber-400",
|
||||||
|
status === "unconscious" && "text-red-400",
|
||||||
|
status === "healthy" && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{currentHp}
|
||||||
|
</button>
|
||||||
|
{!!tempHp && (
|
||||||
|
<span className="font-medium text-cyan-400 text-sm leading-7">
|
||||||
|
+{tempHp}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!!popoverOpen && (
|
||||||
|
<HpAdjustPopover
|
||||||
|
onAdjust={onAdjust}
|
||||||
|
onSetTempHp={onSetTempHp}
|
||||||
|
onClose={() => setPopoverOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AcDisplay({
|
||||||
|
ac,
|
||||||
|
onCommit,
|
||||||
|
}: Readonly<{
|
||||||
|
ac: number | undefined;
|
||||||
|
onCommit: (value: number | undefined) => void;
|
||||||
|
}>) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(ac?.toString() ?? "");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const commit = useCallback(() => {
|
||||||
|
if (draft === "") {
|
||||||
|
onCommit(undefined);
|
||||||
|
} else {
|
||||||
|
const n = Number.parseInt(draft, 10);
|
||||||
|
if (!Number.isNaN(n) && n >= 0) {
|
||||||
|
onCommit(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
}, [draft, onCommit]);
|
||||||
|
|
||||||
|
const startEditing = useCallback(() => {
|
||||||
|
setDraft(ac?.toString() ?? "");
|
||||||
|
setEditing(true);
|
||||||
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
|
}, [ac]);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draft}
|
||||||
|
placeholder="AC"
|
||||||
|
className="h-7 w-[6ch] text-center tabular-nums"
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commit();
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AcShield value={ac} onClick={startEditing} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InitiativeDisplay({
|
||||||
|
initiative,
|
||||||
|
combatantId,
|
||||||
|
dimmed,
|
||||||
|
onSetInitiative,
|
||||||
|
onRollInitiative,
|
||||||
|
}: Readonly<{
|
||||||
|
initiative: number | undefined;
|
||||||
|
combatantId: CombatantId;
|
||||||
|
dimmed: boolean;
|
||||||
|
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
||||||
|
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
|
||||||
|
}>) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(initiative?.toString() ?? "");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [menuPos, setMenuPos] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const openMenu = useCallback((x: number, y: number) => {
|
||||||
|
setMenuPos({ x, y });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const longPress = useLongPress(
|
||||||
|
useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (touch) openMenu(touch.clientX, touch.clientY);
|
||||||
|
},
|
||||||
|
[openMenu],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const commit = useCallback(() => {
|
||||||
|
if (draft === "") {
|
||||||
|
onSetInitiative(combatantId, undefined);
|
||||||
|
} else {
|
||||||
|
const n = Number.parseInt(draft, 10);
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
onSetInitiative(combatantId, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
}, [draft, combatantId, onSetInitiative]);
|
||||||
|
|
||||||
|
const startEditing = useCallback(() => {
|
||||||
|
setDraft(initiative?.toString() ?? "");
|
||||||
|
setEditing(true);
|
||||||
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
|
}, [initiative]);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draft}
|
||||||
|
placeholder="--"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-full text-center tabular-nums",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commit();
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty + bestiary creature -> d20 roll button
|
||||||
|
if (initiative === undefined && onRollInitiative) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRollInitiative(combatantId)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openMenu(e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
{...longPress}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
|
title="Roll initiative"
|
||||||
|
aria-label="Roll initiative"
|
||||||
|
>
|
||||||
|
<D20Icon className="h-7 w-7" />
|
||||||
|
</button>
|
||||||
|
{!!menuPos && (
|
||||||
|
<RollModeMenu
|
||||||
|
position={menuPos}
|
||||||
|
onSelect={(mode) => onRollInitiative(combatantId, mode)}
|
||||||
|
onClose={() => setMenuPos(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has value -> bold number, click to edit
|
||||||
|
// Empty + manual -> "--" placeholder, click to edit
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startEditing}
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-full text-center text-sm tabular-nums leading-7 transition-colors",
|
||||||
|
initiative === undefined
|
||||||
|
? "text-muted-foreground hover:text-hover-neutral"
|
||||||
|
: "font-medium text-foreground hover:text-hover-neutral",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initiative ?? "--"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowBorderClass(
|
||||||
|
isActive: boolean,
|
||||||
|
isConcentrating: boolean | undefined,
|
||||||
|
isPf2e: boolean,
|
||||||
|
): string {
|
||||||
|
const showConcentration = isConcentrating && !isPf2e;
|
||||||
|
if (isActive && showConcentration)
|
||||||
|
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
|
||||||
|
if (isActive)
|
||||||
|
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
|
||||||
|
if (showConcentration)
|
||||||
|
return "border border-l-2 border-transparent border-l-purple-400";
|
||||||
|
return "border border-l-2 border-transparent";
|
||||||
|
}
|
||||||
|
|
||||||
|
function concentrationIconClass(
|
||||||
|
isConcentrating: boolean | undefined,
|
||||||
|
dimmed: boolean,
|
||||||
|
): string {
|
||||||
|
if (!isConcentrating)
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CombatantRow({
|
||||||
|
ref,
|
||||||
|
combatant,
|
||||||
|
isActive,
|
||||||
|
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
||||||
|
const {
|
||||||
|
editCombatant,
|
||||||
|
setInitiative,
|
||||||
|
removeCombatant,
|
||||||
|
setHp,
|
||||||
|
adjustHp,
|
||||||
|
setTempHp,
|
||||||
|
setAc,
|
||||||
|
toggleCondition,
|
||||||
|
setConditionValue,
|
||||||
|
decrementCondition,
|
||||||
|
toggleConcentration,
|
||||||
|
addPersistentDamage,
|
||||||
|
removePersistentDamage,
|
||||||
|
} = useEncounterContext();
|
||||||
|
const {
|
||||||
|
selectedCreatureId,
|
||||||
|
selectedCombatantId,
|
||||||
|
showCreature,
|
||||||
|
toggleCollapse,
|
||||||
|
} = useSidePanelContext();
|
||||||
|
const { handleRollInitiative } = useInitiativeRollsContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
const isPf2e = edition === "pf2e";
|
||||||
|
|
||||||
|
// Derive what was previously conditional props
|
||||||
|
const isStatBlockOpen =
|
||||||
|
combatant.creatureId === selectedCreatureId &&
|
||||||
|
combatant.id === selectedCombatantId;
|
||||||
|
const { creatureId } = combatant;
|
||||||
|
const hasStatBlock = !!creatureId;
|
||||||
|
const onToggleStatBlock = hasStatBlock
|
||||||
|
? () => {
|
||||||
|
if (isStatBlockOpen) {
|
||||||
|
toggleCollapse();
|
||||||
|
} else {
|
||||||
|
showCreature(creatureId, combatant.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const onRollInitiative = combatant.creatureId
|
||||||
|
? handleRollInitiative
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||||
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
|
const dimmed = status === "unconscious";
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const conditionAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const prevHpRef = useRef(currentHp);
|
||||||
|
const prevTempHpRef = useRef(combatant.tempHp);
|
||||||
|
const [isPulsing, setIsPulsing] = useState(false);
|
||||||
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prevHp = prevHpRef.current;
|
||||||
|
const prevTempHp = prevTempHpRef.current;
|
||||||
|
prevHpRef.current = currentHp;
|
||||||
|
prevTempHpRef.current = combatant.tempHp;
|
||||||
|
|
||||||
|
const realHpDropped =
|
||||||
|
prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
|
||||||
|
const tempHpDropped =
|
||||||
|
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(realHpDropped || tempHpDropped) &&
|
||||||
|
combatant.isConcentrating &&
|
||||||
|
!isPf2e
|
||||||
|
) {
|
||||||
|
setIsPulsing(true);
|
||||||
|
clearTimeout(pulseTimerRef.current);
|
||||||
|
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||||
|
}
|
||||||
|
}, [currentHp, combatant.tempHp, combatant.isConcentrating, isPf2e]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!combatant.isConcentrating) {
|
||||||
|
clearTimeout(pulseTimerRef.current);
|
||||||
|
setIsPulsing(false);
|
||||||
|
}
|
||||||
|
}, [combatant.isConcentrating]);
|
||||||
|
|
||||||
|
const pcColor = combatant.color
|
||||||
|
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"group rounded-lg pr-3 transition-colors",
|
||||||
|
rowBorderClass(isActive, combatant.isConcentrating, isPf2e),
|
||||||
|
isPulsing && "animate-concentration-pulse",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid items-center gap-1.5 py-3 sm:gap-3 sm:py-2",
|
||||||
|
isPf2e
|
||||||
|
? "grid-cols-[3rem_auto_1fr_auto_2rem] pl-3 sm:grid-cols-[3.5rem_auto_1fr_auto_2rem]"
|
||||||
|
: "grid-cols-[2rem_3rem_auto_1fr_auto_2rem] sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Concentration — hidden in PF2e mode */}
|
||||||
|
{!isPf2e && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleConcentration(id)}
|
||||||
|
title="Concentrating"
|
||||||
|
aria-label="Toggle concentration"
|
||||||
|
className={cn(
|
||||||
|
"-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
||||||
|
concentrationIconClass(combatant.isConcentrating, dimmed),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Brain size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Initiative */}
|
||||||
|
<div className="rounded-md bg-muted/30 px-1">
|
||||||
|
<InitiativeDisplay
|
||||||
|
initiative={initiative}
|
||||||
|
combatantId={id}
|
||||||
|
dimmed={dimmed}
|
||||||
|
onSetInitiative={setInitiative}
|
||||||
|
onRollInitiative={onRollInitiative}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AC */}
|
||||||
|
<div className={cn(dimmed && "opacity-50")}>
|
||||||
|
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + Conditions */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex min-w-0 flex-wrap items-center gap-1",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!!combatant.icon &&
|
||||||
|
!!combatant.color &&
|
||||||
|
(() => {
|
||||||
|
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
|
||||||
|
const iconColor =
|
||||||
|
PLAYER_COLOR_HEX[
|
||||||
|
combatant.color as keyof typeof PLAYER_COLOR_HEX
|
||||||
|
];
|
||||||
|
return PcIcon ? (
|
||||||
|
<PcIcon
|
||||||
|
size={16}
|
||||||
|
style={{ color: iconColor }}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
<EditableName
|
||||||
|
name={name}
|
||||||
|
combatantId={id}
|
||||||
|
onRename={editCombatant}
|
||||||
|
color={pcColor}
|
||||||
|
onToggleStatBlock={onToggleStatBlock}
|
||||||
|
/>
|
||||||
|
<div ref={conditionAnchorRef}>
|
||||||
|
<ConditionTags
|
||||||
|
conditions={combatant.conditions}
|
||||||
|
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
|
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
||||||
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{isPf2e && (
|
||||||
|
<PersistentDamageTags
|
||||||
|
entries={combatant.persistentDamage}
|
||||||
|
onRemove={(damageType) =>
|
||||||
|
removePersistentDamage(id, damageType)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ConditionTags>
|
||||||
|
</div>
|
||||||
|
{!!pickerOpen && (
|
||||||
|
<ConditionPicker
|
||||||
|
anchorRef={conditionAnchorRef}
|
||||||
|
activeConditions={combatant.conditions}
|
||||||
|
activePersistentDamage={combatant.persistentDamage}
|
||||||
|
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
|
onSetValue={(conditionId, value) =>
|
||||||
|
setConditionValue(id, conditionId, value)
|
||||||
|
}
|
||||||
|
onAddPersistentDamage={(damageType, formula) =>
|
||||||
|
addPersistentDamage(id, damageType, formula)
|
||||||
|
}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HP */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-md tabular-nums",
|
||||||
|
maxHp === undefined
|
||||||
|
? ""
|
||||||
|
: "gap-0.5 border border-border/50 bg-muted/30 px-1.5",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ClickableHp
|
||||||
|
currentHp={currentHp}
|
||||||
|
maxHp={maxHp}
|
||||||
|
tempHp={combatant.tempHp}
|
||||||
|
onAdjust={(delta) => adjustHp(id, delta)}
|
||||||
|
onSetTempHp={(value) => setTempHp(id, value)}
|
||||||
|
/>
|
||||||
|
{maxHp !== undefined && (
|
||||||
|
<span className="text-muted-foreground/50 text-xs">/</span>
|
||||||
|
)}
|
||||||
|
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<ConfirmButton
|
||||||
|
icon={<X size={16} />}
|
||||||
|
label="Remove combatant"
|
||||||
|
onConfirm={() => removeCombatant(id)}
|
||||||
|
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import {
|
||||||
|
type ConditionEntry,
|
||||||
|
type ConditionId,
|
||||||
|
getConditionDescription,
|
||||||
|
getConditionsForEdition,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageType,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { Check, Flame, Minus, Plus } from "lucide-react";
|
||||||
|
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import {
|
||||||
|
CONDITION_COLOR_CLASSES,
|
||||||
|
CONDITION_ICON_MAP,
|
||||||
|
} from "./condition-styles.js";
|
||||||
|
import { PersistentDamagePicker } from "./persistent-damage-picker.js";
|
||||||
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
|
interface ConditionPickerProps {
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
|
activeConditions: readonly ConditionEntry[] | undefined;
|
||||||
|
activePersistentDamage?: readonly PersistentDamageEntry[];
|
||||||
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
|
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||||
|
onAddPersistentDamage?: (
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
formula: string,
|
||||||
|
) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConditionPicker({
|
||||||
|
anchorRef,
|
||||||
|
activeConditions,
|
||||||
|
activePersistentDamage,
|
||||||
|
onToggle,
|
||||||
|
onSetValue,
|
||||||
|
onAddPersistentDamage,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<ConditionPickerProps>) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [pos, setPos] = useState<{
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
maxHeight: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<{
|
||||||
|
id: ConditionId;
|
||||||
|
value: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [showPersistentDamage, setShowPersistentDamage] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const anchor = anchorRef.current;
|
||||||
|
const el = ref.current;
|
||||||
|
if (!anchor || !el) return;
|
||||||
|
|
||||||
|
const anchorRect = anchor.getBoundingClientRect();
|
||||||
|
const menuHeight = el.scrollHeight;
|
||||||
|
const pad = 8;
|
||||||
|
|
||||||
|
const spaceBelow = window.innerHeight - anchorRect.bottom - pad;
|
||||||
|
const spaceAbove = anchorRect.top - pad;
|
||||||
|
const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
|
||||||
|
|
||||||
|
const top = openBelow
|
||||||
|
? anchorRect.bottom + 4
|
||||||
|
: Math.max(pad, anchorRect.top - Math.min(menuHeight, spaceAbove) - 4);
|
||||||
|
const maxHeight = openBelow ? spaceBelow : Math.min(menuHeight, spaceAbove);
|
||||||
|
|
||||||
|
setPos({ top, left: anchorRect.left, maxHeight });
|
||||||
|
}, [anchorRef]);
|
||||||
|
|
||||||
|
useClickOutside(ref, onClose);
|
||||||
|
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
const conditions = getConditionsForEdition(edition);
|
||||||
|
const activeMap = new Map(
|
||||||
|
(activeConditions ?? []).map((e) => [e.id, e.value]),
|
||||||
|
);
|
||||||
|
const showPersistentDamageEntry =
|
||||||
|
edition === "pf2e" && !!onAddPersistentDamage;
|
||||||
|
const persistentDamageInsertIndex = showPersistentDamageEntry
|
||||||
|
? conditions.findIndex(
|
||||||
|
(d) => d.label.localeCompare("Persistent Damage") > 0,
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
const persistentDamageEntry = showPersistentDamageEntry ? (
|
||||||
|
<React.Fragment key="persistent-damage">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||||
|
showPersistentDamage && "bg-card/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex flex-1 items-center gap-2"
|
||||||
|
onClick={() => setShowPersistentDamage((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<Flame
|
||||||
|
size={14}
|
||||||
|
className={
|
||||||
|
showPersistentDamage ? "text-orange-400" : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
showPersistentDamage ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Persistent Damage
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!!showPersistentDamage && (
|
||||||
|
<PersistentDamagePicker
|
||||||
|
activeEntries={activePersistentDamage}
|
||||||
|
onAdd={onAddPersistentDamage}
|
||||||
|
onClose={() => setShowPersistentDamage(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="card-glow fixed z-50 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1"
|
||||||
|
style={
|
||||||
|
pos
|
||||||
|
? { top: pos.top, left: pos.left, maxHeight: pos.maxHeight }
|
||||||
|
: { visibility: "hidden" as const }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{conditions.map((def, index) => {
|
||||||
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||||
|
if (!Icon) return null;
|
||||||
|
const isActive = activeMap.has(def.id);
|
||||||
|
const activeValue = activeMap.get(def.id);
|
||||||
|
const isEditing = editing?.id === def.id;
|
||||||
|
const colorClass =
|
||||||
|
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (def.valued && edition === "pf2e") {
|
||||||
|
const current = activeMap.get(def.id);
|
||||||
|
setEditing({
|
||||||
|
id: def.id,
|
||||||
|
value: current ?? 1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onToggle(def.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={def.id}>
|
||||||
|
{index === persistentDamageInsertIndex && persistentDamageEntry}
|
||||||
|
<Tooltip
|
||||||
|
content={getConditionDescription(def, edition)}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||||
|
(isActive || isEditing) && "bg-card/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex flex-1 items-center gap-2"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={14}
|
||||||
|
className={
|
||||||
|
isActive || isEditing
|
||||||
|
? colorClass
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isActive || isEditing
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{def.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isActive && def.valued && edition === "pf2e" && !isEditing && (
|
||||||
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||||
|
{activeValue}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (editing.value > 1) {
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
value: editing.value - 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||||
|
{editing.value}
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const atMax =
|
||||||
|
def.maxValue !== undefined &&
|
||||||
|
editing.value >= def.maxValue;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"rounded p-0.5",
|
||||||
|
atMax
|
||||||
|
? "cursor-not-allowed text-muted-foreground opacity-50"
|
||||||
|
: "text-foreground hover:bg-accent/40",
|
||||||
|
)}
|
||||||
|
disabled={atMax}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!atMax) {
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
value: editing.value + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSetValue(editing.id, editing.value);
|
||||||
|
setEditing(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{persistentDamageInsertIndex === -1 && persistentDamageEntry}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user