Compare commits
185 Commits
94d125d9c4
...
0.9.34
| Author | SHA1 | Date | |
|---|---|---|---|
| e44e56b09b | |||
| 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 |
@@ -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
|
||||
- Skip if project is purely internal (build scripts, one-off tools, etc.)
|
||||
|
||||
3. **Agent context update**:
|
||||
- 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
|
||||
**Output**: data-model.md, /contracts/*, quickstart.md
|
||||
|
||||
## 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/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
docs/agents/plans/
|
||||
docs/agents/research/
|
||||
.agent-tests/
|
||||
.rodney/
|
||||
|
||||
@@ -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
|
||||
───────────────────
|
||||
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:
|
||||
- Development Workflow: added automated-checks merge gate
|
||||
Templates requiring updates:
|
||||
- .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
|
||||
- Development Workflow: added artifact lifecycle rules (spec.md living, plan/tasks bounded, tests authoritative)
|
||||
Templates requiring updates: none
|
||||
-->
|
||||
# Encounter Console Constitution
|
||||
|
||||
@@ -29,7 +25,7 @@ be injected at the boundary, never sourced inside the domain layer.
|
||||
|
||||
### 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:
|
||||
|
||||
1. **Domain** — pure types, state transitions, validation rules.
|
||||
@@ -39,34 +35,37 @@ dependency direction:
|
||||
interfaces that Adapters implement. May import Domain only.
|
||||
3. **Adapters** — I/O, persistence, UI rendering, external APIs.
|
||||
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.
|
||||
|
||||
### III. Agent Boundary
|
||||
### II-A. Context-Based State Flow
|
||||
|
||||
The agent layer MAY read domain events and current state. The agent
|
||||
MAY produce suggestions, annotations, or recommendations. The agent
|
||||
MUST NOT mutate domain state directly. All agent-originated changes
|
||||
MUST flow through the Application layer as explicit user-confirmed
|
||||
commands.
|
||||
UI components MUST consume shared application state via React context
|
||||
providers, not prop drilling. Props are reserved for per-instance
|
||||
configuration (e.g., a specific data item, a layout variant, a ref).
|
||||
|
||||
- Agent output MUST be clearly labeled as suggestions.
|
||||
- No silent or automatic application of agent recommendations.
|
||||
- Components MUST NOT declare more than 8 explicit props in their
|
||||
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,
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
### V. Escalation Gates
|
||||
### IV. Escalation Gates
|
||||
|
||||
Any feature, requirement, or scope change not present in the current
|
||||
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`.
|
||||
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
|
||||
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;
|
||||
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,
|
||||
rule-system specifics, or encounter resolution logic. Such details
|
||||
@@ -96,9 +95,9 @@ architecture, and quality — not product behavior.
|
||||
## Scope Constraints
|
||||
|
||||
- The Encounter Console's primary focus is initiative tracking and
|
||||
encounter state management. Adjacent capabilities (e.g., richer
|
||||
game-engine features) are not in the MVP baseline but may be
|
||||
added via spec updates in future iterations.
|
||||
encounter state management. Adjacent capabilities (e.g., bestiary
|
||||
integration, richer game-engine features) may be added via spec
|
||||
updates.
|
||||
- Technology choices, UI framework, and storage mechanism are
|
||||
spec-level decisions, not constitutional mandates.
|
||||
- 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
|
||||
static analysis as defined by the project) pass.
|
||||
- Every feature begins with a spec (`/speckit.specify`).
|
||||
- Implementation follows the plan → tasks → implement pipeline.
|
||||
- Specs describe **features**, not individual changes. Each spec is
|
||||
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.
|
||||
- Long-running or multi-step state transitions SHOULD be verifiable
|
||||
through reproducible event logs or snapshot-style tests.
|
||||
- Commits SHOULD be atomic and map to individual tasks where
|
||||
practical.
|
||||
- Layer boundary compliance MUST be verified by automated import
|
||||
rules or architectural tests. Agent-assisted or manual review MAY
|
||||
supplement but not replace automated checks.
|
||||
rules or architectural tests.
|
||||
- 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
|
||||
|
||||
@@ -142,4 +168,4 @@ MUST comply with its principles.
|
||||
**Compliance review**: Every spec and plan MUST include a
|
||||
Constitution Check section validating adherence to all principles.
|
||||
|
||||
**Version**: 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
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
**Initiative** is a browser-based combat encounter tracker for tabletop RPGs (D&D 5.5e, Pathfinder 2e). It runs entirely client-side — no backend, no accounts — with localStorage and IndexedDB for persistence.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm check # Merge gate — must pass before every commit (knip + format + lint + typecheck + test)
|
||||
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
|
||||
```
|
||||
@@ -27,63 +29,123 @@ apps/web (React 19 + Vite) → packages/application (use cases) → packages
|
||||
```
|
||||
|
||||
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
|
||||
- **Application** — Orchestrates domain calls via port interfaces (e.g., `EncounterStore`). No business logic here.
|
||||
- **Web** — React adapter. Implements ports using hooks/state.
|
||||
- **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.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
||||
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
|
||||
- **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 `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||
- **Feature specs** live in `specs/<feature>/` with spec.md, plan.md, tasks.md. The project constitution is at `.specify/memory/constitution.md`.
|
||||
- **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.
|
||||
|
||||
## Constitution (key principles)
|
||||
For component prop rules, export format compatibility, and ADRs, see [`docs/conventions.md`](docs/conventions.md).
|
||||
|
||||
The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
||||
## Testing
|
||||
|
||||
1. **Deterministic Domain Core** — Pure state transitions only; no I/O, randomness, or clocks in domain.
|
||||
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
|
||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
||||
5. **Every feature begins with a spec** — Spec → Plan → Tasks → Implementation.
|
||||
### Philosophy
|
||||
|
||||
## Active Technologies
|
||||
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite (003-remove-combatant)
|
||||
- In-memory React state (local-first, single-user MVP) (003-remove-combatant)
|
||||
- TypeScript 5.x (project), Go binary via npm (Lefthook) + `lefthook` (npm devDependency) (006-pre-commit-gate)
|
||||
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + Knip v5 (new), Biome 2.0, Vitest, Vite 6, React 19 (007-add-knip)
|
||||
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Biome 2.0, existing domain/application packages (008-persist-encounter)
|
||||
- Browser localStorage (adapter layer only) (008-persist-encounter)
|
||||
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Biome 2.0, Vites (009-combatant-hp)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui, Lucide React (icons) (010-ui-baseline)
|
||||
- N/A (no storage changes — localStorage persistence unchanged) (010-ui-baseline)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons) (011-quick-hp-input)
|
||||
- N/A (no storage changes -- existing localStorage persistence handles HP via `adjustHpUseCase`) (011-quick-hp-input)
|
||||
- N/A (no storage changes -- existing localStorage persistence unchanged) (012-turn-navigation)
|
||||
- N/A (no storage changes -- purely derived state, existing localStorage persistence unchanged) (013-hp-status-indicators)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + jscpd (new dev dependency), Lefthook (existing), Biome 2.0 (existing), Knip (existing) (015-add-jscpd-gate)
|
||||
- N/A (no storage changes) (015-add-jscpd-gate)
|
||||
- Browser localStorage (existing adapter, transparent JSON serialization) (016-combatant-ac)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, Lucide React (icons) (017-combat-conditions)
|
||||
- N/A (no storage changes — existing localStorage persistence unchanged) (019-combatant-row-declutter)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Lucide React (icons) (020-fix-zero-hp-opacity)
|
||||
- Browser localStorage (existing adapter, extended for creatureId) (021-bestiary-statblock)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4 + React 19, Tailwind CSS v4, Vite 6 (022-fixed-layout-bars)
|
||||
- N/A (no storage changes -- purely presentational) (022-fixed-layout-bars)
|
||||
- Browser localStorage (existing adapter, updated to handle empty encounters) (023-clear-encounter)
|
||||
- N/A (no storage changes — purely presentational fix) (024-fix-hp-popover-overflow)
|
||||
- N/A (no storage changes — purely derived from existing bestiary data) (025-display-initiative)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Lucide React (icons), Vite 6 (026-roll-initiative)
|
||||
- N/A (no storage changes — existing localStorage persistence handles initiative via `setInitiativeUseCase`) (026-roll-initiative)
|
||||
- N/A (no storage changes — purely presentational) (027-ui-polish)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Vite 6 + Tailwind CSS v4 (CSS-native `@theme` theming), Lucide React (icons) (028-semantic-hover-tokens)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, Lucide React (icons), idb (IndexedDB wrapper) (029-on-demand-bestiary)
|
||||
- IndexedDB for cached source data (new); localStorage for encounter persistence (existing, unchanged) (029-on-demand-bestiary)
|
||||
- IndexedDB for cached source data (existing via `bestiary-cache.ts`); localStorage for encounter persistence (existing, unchanged) (030-bulk-import-sources)
|
||||
Test **user-visible behavior**, not implementation details. A good test answers "does this feature work?" not "does this internal function get called?"
|
||||
|
||||
## Recent Changes
|
||||
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
|
||||
### 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
|
||||
@@ -1,10 +1,21 @@
|
||||
# Initiative Tracker
|
||||
# Initiative
|
||||
|
||||
A turn-based initiative tracker for tabletop RPG encounters. Click "Next Turn" to cycle through combatants and advance rounds.
|
||||
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
|
||||
- Node.js 22+
|
||||
- pnpm 10.6+
|
||||
|
||||
## Getting Started
|
||||
@@ -14,9 +25,7 @@ pnpm install
|
||||
pnpm --filter web dev
|
||||
```
|
||||
|
||||
Open the URL printed in your terminal (typically `http://localhost:5173`).
|
||||
|
||||
The app starts with a 3-combatant demo encounter (Aria, Brak, Cael). Click **Next Turn** to advance through the initiative order. When the last combatant finishes their turn, the round number increments and the cycle restarts.
|
||||
Open `http://localhost:5173`.
|
||||
|
||||
## Scripts
|
||||
|
||||
@@ -24,5 +33,93 @@ The app starts with a 3-combatant demo encounter (Aria, Brak, Cael). Click **Nex
|
||||
|---------|-------------|
|
||||
| `pnpm --filter web dev` | Start the dev server |
|
||||
| `pnpm --filter web build` | Production build |
|
||||
| `pnpm test` | Run all tests |
|
||||
| `pnpm check` | Full merge gate (format, lint, typecheck, test) |
|
||||
| `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">
|
||||
<head>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -20,11 +20,15 @@
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@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-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vite": "^6.2.0"
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+120
-224
@@ -1,251 +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 {
|
||||
rollAllInitiativeUseCase,
|
||||
rollInitiativeUseCase,
|
||||
} from "@initiative/application";
|
||||
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActionBar } from "./components/action-bar";
|
||||
import { CombatantRow } from "./components/combatant-row";
|
||||
import { SourceManager } from "./components/source-manager";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||
import { Toast } from "./components/toast";
|
||||
import { TurnNavigation } from "./components/turn-navigation";
|
||||
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
||||
import { useEncounter } from "./hooks/use-encounter";
|
||||
|
||||
function rollDice(): number {
|
||||
return Math.floor(Math.random() * 20) + 1;
|
||||
}
|
||||
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() {
|
||||
const {
|
||||
encounter,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
clearEncounter,
|
||||
removeCombatant,
|
||||
editCombatant,
|
||||
setInitiative,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
makeStore,
|
||||
} = useEncounter();
|
||||
const { encounter, isEmpty } = useEncounterContext();
|
||||
const sidePanel = useSidePanelContext();
|
||||
const rolls = useInitiativeRollsContext();
|
||||
|
||||
const {
|
||||
search,
|
||||
getCreature,
|
||||
isLoaded,
|
||||
isSourceCached,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
} = useBestiary();
|
||||
useAutoStatBlock();
|
||||
|
||||
const bulkImport = useBulkImport();
|
||||
|
||||
const [selectedCreatureId, setSelectedCreatureId] =
|
||||
useState<CreatureId | null>(null);
|
||||
const [bulkImportMode, setBulkImportMode] = useState(false);
|
||||
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
|
||||
|
||||
const selectedCreature: Creature | null = selectedCreatureId
|
||||
? (getCreature(selectedCreatureId) ?? null)
|
||||
: null;
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
addFromBestiary(result);
|
||||
// Derive the creature ID so stat block panel can try to show it
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
setSelectedCreatureId(
|
||||
`${result.source.toLowerCase()}:${slug}` as CreatureId,
|
||||
);
|
||||
},
|
||||
[addFromBestiary],
|
||||
);
|
||||
|
||||
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
||||
setSelectedCreatureId(creatureId as CreatureId);
|
||||
}, []);
|
||||
|
||||
const handleRollInitiative = useCallback(
|
||||
(id: CombatantId) => {
|
||||
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
|
||||
},
|
||||
[makeStore, getCreature],
|
||||
);
|
||||
|
||||
const handleRollAllInitiative = useCallback(() => {
|
||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||
}, [makeStore, getCreature]);
|
||||
|
||||
const handleBulkImport = useCallback(() => {
|
||||
setBulkImportMode(true);
|
||||
setSelectedCreatureId(null);
|
||||
}, []);
|
||||
|
||||
const handleStartBulkImport = useCallback(
|
||||
(baseUrl: string) => {
|
||||
bulkImport.startImport(
|
||||
baseUrl,
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
},
|
||||
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
|
||||
);
|
||||
|
||||
const handleBulkImportDone = useCallback(() => {
|
||||
setBulkImportMode(false);
|
||||
bulkImport.reset();
|
||||
}, [bulkImport.reset]);
|
||||
|
||||
// Auto-scroll to the active combatant when the turn changes
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
activeRowRef.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [encounter.activeIndex]);
|
||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||
|
||||
// Auto-show stat block for the active combatant when turn changes,
|
||||
// but only when the viewport is wide enough to show it alongside the tracker.
|
||||
// Only react to activeIndex changes — not combatant reordering (e.g. Roll All).
|
||||
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
||||
// Close the side panel when the encounter becomes empty
|
||||
useEffect(() => {
|
||||
if (prevActiveIndexRef.current === encounter.activeIndex) return;
|
||||
prevActiveIndexRef.current = encounter.activeIndex;
|
||||
if (!window.matchMedia("(min-width: 1024px)").matches) return;
|
||||
const active = encounter.combatants[encounter.activeIndex];
|
||||
if (!active?.creatureId || !isLoaded) return;
|
||||
setSelectedCreatureId(active.creatureId as CreatureId);
|
||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
||||
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-screen flex-col">
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
|
||||
{/* Turn Navigation — fixed at top */}
|
||||
<div className="shrink-0 pt-8">
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={advanceTurn}
|
||||
onRetreatTurn={retreatTurn}
|
||||
onClearEncounter={clearEncounter}
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sourceManagerOpen && (
|
||||
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
|
||||
<SourceManager onCacheCleared={refreshCache} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Scrollable area — combatant list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="flex flex-col pb-2">
|
||||
{encounter.combatants.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
No combatants yet — add one to get started
|
||||
</p>
|
||||
) : (
|
||||
encounter.combatants.map((c, i) => (
|
||||
<CombatantRow
|
||||
key={c.id}
|
||||
ref={i === encounter.activeIndex ? activeRowRef : null}
|
||||
combatant={c}
|
||||
isActive={i === encounter.activeIndex}
|
||||
onRename={editCombatant}
|
||||
onSetInitiative={setInitiative}
|
||||
onRemove={removeCombatant}
|
||||
onSetHp={setHp}
|
||||
onAdjustHp={adjustHp}
|
||||
onSetAc={setAc}
|
||||
onToggleCondition={toggleCondition}
|
||||
onToggleConcentration={toggleConcentration}
|
||||
onShowStatBlock={
|
||||
c.creatureId
|
||||
? () => handleCombatantStatBlock(c.creatureId as string)
|
||||
: undefined
|
||||
}
|
||||
onRollInitiative={
|
||||
c.creatureId ? handleRollInitiative : undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* Action Bar — fixed at bottom */}
|
||||
<div className="shrink-0 pb-8">
|
||||
<ActionBar
|
||||
onAddCombatant={addCombatant}
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onBulkImport={handleBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Stat Block Panel */}
|
||||
<StatBlockPanel
|
||||
creatureId={selectedCreatureId}
|
||||
creature={selectedCreature}
|
||||
isSourceCached={isSourceCached}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
uploadAndCacheSource={uploadAndCacheSource}
|
||||
refreshCache={refreshCache}
|
||||
onClose={() => {
|
||||
setSelectedCreatureId(null);
|
||||
setBulkImportMode(false);
|
||||
}}
|
||||
bulkImportMode={bulkImportMode}
|
||||
bulkImportState={bulkImport.state}
|
||||
onStartBulkImport={handleStartBulkImport}
|
||||
onBulkImportDone={handleBulkImportDone}
|
||||
/>
|
||||
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||
<StatBlockPanel panelRole="pinned" side="left" />
|
||||
)}
|
||||
|
||||
{/* Toast for bulk import progress when panel is closed */}
|
||||
{bulkImport.state.status === "loading" && !bulkImportMode && (
|
||||
<StatBlockPanel panelRole="browse" side="right" />
|
||||
|
||||
<BulkImportToasts />
|
||||
|
||||
{rolls.rollSkippedCount > 0 && (
|
||||
<Toast
|
||||
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
|
||||
progress={
|
||||
bulkImport.state.total > 0
|
||||
? (bulkImport.state.completed + bulkImport.state.failed) /
|
||||
bulkImport.state.total
|
||||
: 0
|
||||
}
|
||||
onDismiss={() => {}}
|
||||
message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`}
|
||||
onDismiss={rolls.dismissRollSkipped}
|
||||
autoDismissMs={4000}
|
||||
/>
|
||||
)}
|
||||
{bulkImport.state.status === "complete" && !bulkImportMode && (
|
||||
|
||||
{!!rolls.rollSingleSkipped && (
|
||||
<Toast
|
||||
message="All sources loaded"
|
||||
onDismiss={bulkImport.reset}
|
||||
autoDismissMs={3000}
|
||||
/>
|
||||
)}
|
||||
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
|
||||
<Toast
|
||||
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
||||
onDismiss={bulkImport.reset}
|
||||
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,121 @@
|
||||
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: () => [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getAllSourceCodes,
|
||||
getDefaultFetchUrl,
|
||||
} from "../adapters/bestiary-index-adapter.js";
|
||||
|
||||
describe("getAllSourceCodes", () => {
|
||||
it("returns all keys from the index sources object", () => {
|
||||
const codes = getAllSourceCodes();
|
||||
expect(codes.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(codes)).toBe(true);
|
||||
for (const code of codes) {
|
||||
expect(typeof code).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultFetchUrl", () => {
|
||||
it("returns the default URL when no baseUrl is provided", () => {
|
||||
const url = getDefaultFetchUrl("XMM");
|
||||
expect(url).toBe(
|
||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-xmm.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("constructs URL from baseUrl with trailing slash", () => {
|
||||
const url = getDefaultFetchUrl("PHB", "https://example.com/data/");
|
||||
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||
});
|
||||
|
||||
it("normalizes baseUrl without trailing slash", () => {
|
||||
const url = getDefaultFetchUrl("PHB", "https://example.com/data");
|
||||
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||
});
|
||||
|
||||
it("lowercases the source code in the filename", () => {
|
||||
const url = getDefaultFetchUrl("MM", "https://example.com/");
|
||||
expect(url).toBe("https://example.com/bestiary-mm.json");
|
||||
});
|
||||
});
|
||||
@@ -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,3 @@
|
||||
export { buildCombatant } from "./build-combatant.js";
|
||||
export { buildCreature } from "./build-creature.js";
|
||||
export { buildEncounter } from "./build-encounter.js";
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* jsdom doesn't implement HTMLDialogElement.showModal/close.
|
||||
* Call this in beforeAll() for tests that render <Dialog>.
|
||||
*/
|
||||
export function polyfillDialog(): void {
|
||||
if (typeof HTMLDialogElement.prototype.showModal !== "function") {
|
||||
HTMLDialogElement.prototype.showModal = function showModal() {
|
||||
this.setAttribute("open", "");
|
||||
};
|
||||
}
|
||||
if (typeof HTMLDialogElement.prototype.close !== "function") {
|
||||
HTMLDialogElement.prototype.close = function close() {
|
||||
this.removeAttribute("open");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// @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(),
|
||||
}));
|
||||
|
||||
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
|
||||
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,
|
||||
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>);
|
||||
|
||||
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,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);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,24 @@
|
||||
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" });
|
||||
});
|
||||
@@ -74,11 +89,11 @@ describe("normalizeBestiary", () => {
|
||||
expect(c.senses).toBe("Darkvision 60 ft.");
|
||||
expect(c.languages).toBe("Common, Goblin");
|
||||
expect(c.actions).toHaveLength(1);
|
||||
expect(c.actions?.[0].text).toContain("Melee Attack Roll:");
|
||||
expect(c.actions?.[0].text).not.toContain("{@");
|
||||
expect(flatText(c.actions?.[0])).toContain("Melee Attack Roll:");
|
||||
expect(flatText(c.actions?.[0])).not.toContain("{@");
|
||||
expect(c.bonusActions).toHaveLength(1);
|
||||
expect(c.bonusActions?.[0].text).toContain("Disengage");
|
||||
expect(c.bonusActions?.[0].text).not.toContain("{@");
|
||||
expect(flatText(c.bonusActions?.[0])).toContain("Disengage");
|
||||
expect(flatText(c.bonusActions?.[0])).not.toContain("{@");
|
||||
});
|
||||
|
||||
it("normalizes a creature with legendary actions", () => {
|
||||
@@ -166,17 +181,20 @@ describe("normalizeBestiary", () => {
|
||||
expect(sc?.name).toBe("Spellcasting");
|
||||
expect(sc?.headerText).toContain("DC 15");
|
||||
expect(sc?.headerText).not.toContain("{@");
|
||||
expect(sc?.atWill).toEqual(["Detect Magic", "Mage Hand"]);
|
||||
expect(sc?.atWill).toEqual([
|
||||
{ name: "Detect Magic" },
|
||||
{ name: "Mage Hand" },
|
||||
]);
|
||||
expect(sc?.daily).toHaveLength(2);
|
||||
expect(sc?.daily).toContainEqual({
|
||||
uses: 2,
|
||||
each: true,
|
||||
spells: ["Fireball"],
|
||||
spells: [{ name: "Fireball" }],
|
||||
});
|
||||
expect(sc?.daily).toContainEqual({
|
||||
uses: 1,
|
||||
each: false,
|
||||
spells: ["Dimension Door"],
|
||||
spells: [{ name: "Dimension Door" }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,6 +318,44 @@ describe("normalizeBestiary", () => {
|
||||
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: [
|
||||
@@ -330,4 +386,131 @@ describe("normalizeBestiary", () => {
|
||||
const creatures = normalizeBestiary(raw);
|
||||
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
|
||||
});
|
||||
|
||||
it("renders list items with singular entry field (e.g. Confusing Burble d4 effects)", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Jabberwock",
|
||||
source: "WBtW",
|
||||
size: ["H"],
|
||||
type: "dragon",
|
||||
ac: [18],
|
||||
hp: { average: 115, formula: "10d12 + 50" },
|
||||
speed: { walk: 30 },
|
||||
str: 22,
|
||||
dex: 15,
|
||||
con: 20,
|
||||
int: 8,
|
||||
wis: 14,
|
||||
cha: 16,
|
||||
passive: 12,
|
||||
cr: "13",
|
||||
trait: [
|
||||
{
|
||||
name: "Confusing Burble",
|
||||
entries: [
|
||||
"The jabberwock burbles unless {@condition incapacitated}. Roll a {@dice d4}:",
|
||||
{
|
||||
type: "list",
|
||||
style: "list-hang-notitle",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
name: "1-2",
|
||||
entry: "The creature does nothing.",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
name: "3",
|
||||
entry:
|
||||
"The creature uses all its movement to move in a random direction.",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
name: "4",
|
||||
entry:
|
||||
"The creature makes one melee attack against a random creature.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
const trait = creatures[0].traits?.[0];
|
||||
expect(trait).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,162 @@
|
||||
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("strips strong and em tags", () => {
|
||||
expect(stripFoundryTags("<strong>bold</strong> <em>italic</em>")).toBe(
|
||||
"bold italic",
|
||||
);
|
||||
});
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,11 +30,11 @@ describe("stripTags", () => {
|
||||
expect(stripTags("{@hit 5}")).toBe("+5");
|
||||
});
|
||||
|
||||
it("strips {@h} to Hit: ", () => {
|
||||
it("strips {@h} to Hit:", () => {
|
||||
expect(stripTags("{@h}")).toBe("Hit: ");
|
||||
});
|
||||
|
||||
it("strips {@hom} to Hit or Miss: ", () => {
|
||||
it("strips {@hom} to Hit or Miss:", () => {
|
||||
expect(stripTags("{@hom}")).toBe("Hit or Miss: ");
|
||||
});
|
||||
|
||||
@@ -50,6 +50,26 @@ describe("stripTags", () => {
|
||||
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)");
|
||||
});
|
||||
@@ -118,12 +138,20 @@ describe("stripTags", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("handles nested tags gracefully", () => {
|
||||
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.");
|
||||
});
|
||||
|
||||
@@ -4,11 +4,16 @@ import type {
|
||||
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 {
|
||||
@@ -49,6 +54,7 @@ interface RawMonster {
|
||||
legendaryHeader?: string[];
|
||||
spellcasting?: RawSpellcasting[];
|
||||
initiative?: { proficiency?: number };
|
||||
_copy?: unknown;
|
||||
}
|
||||
|
||||
interface RawEntry {
|
||||
@@ -60,11 +66,18 @@ interface RawEntryObject {
|
||||
type: string;
|
||||
items?: (
|
||||
| string
|
||||
| { type: string; name?: string; entries?: (string | RawEntryObject)[] }
|
||||
| {
|
||||
type: string;
|
||||
name?: string;
|
||||
entry?: string;
|
||||
entries?: (string | RawEntryObject)[];
|
||||
}
|
||||
)[];
|
||||
style?: string;
|
||||
name?: string;
|
||||
entries?: (string | RawEntryObject)[];
|
||||
colLabels?: string[];
|
||||
rows?: (string | RawEntryObject)[][];
|
||||
}
|
||||
|
||||
interface RawSpellcasting {
|
||||
@@ -168,7 +181,7 @@ function extractAc(ac: RawMonster["ac"]): {
|
||||
}
|
||||
if ("special" in first) {
|
||||
// Variable AC (e.g. spell summons) — parse leading number if possible
|
||||
const match = first.special.match(/^(\d+)/);
|
||||
const match = LEADING_DIGITS_REGEX.exec(first.special);
|
||||
return {
|
||||
value: match ? Number(match[1]) : 0,
|
||||
source: first.special,
|
||||
@@ -254,35 +267,113 @@ function formatConditionImmunities(
|
||||
.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 if (entry.type === "list") {
|
||||
for (const item of entry.items ?? []) {
|
||||
if (typeof item === "string") {
|
||||
parts.push(`• ${stripTags(item)}`);
|
||||
} else if (item.name && item.entries) {
|
||||
parts.push(
|
||||
`• ${stripTags(item.name)}: ${renderEntries(item.entries)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else 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));
|
||||
} 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),
|
||||
text: renderEntries(t.entries),
|
||||
segments: segmentizeEntries(t.entries),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -295,7 +386,7 @@ function normalizeSpellcasting(
|
||||
const block: {
|
||||
name: string;
|
||||
headerText: string;
|
||||
atWill?: string[];
|
||||
atWill?: SpellReference[];
|
||||
daily?: DailySpells[];
|
||||
restLong?: DailySpells[];
|
||||
} = {
|
||||
@@ -306,7 +397,7 @@ function normalizeSpellcasting(
|
||||
const hidden = new Set(sc.hidden ?? []);
|
||||
|
||||
if (sc.will && !hidden.has("will")) {
|
||||
block.atWill = sc.will.map((s) => stripTags(s));
|
||||
block.atWill = sc.will.map((s) => ({ name: stripTags(s) }));
|
||||
}
|
||||
|
||||
if (sc.daily) {
|
||||
@@ -328,7 +419,7 @@ function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
|
||||
return {
|
||||
uses,
|
||||
each,
|
||||
spells: spells.map((s) => stripTags(s)),
|
||||
spells: spells.map((s) => ({ name: stripTags(s) })),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -347,7 +438,7 @@ function normalizeLegendary(
|
||||
preamble,
|
||||
entries: raw.map((e) => ({
|
||||
name: stripTags(e.name),
|
||||
text: renderEntries(e.entries),
|
||||
segments: segmentizeEntries(e.entries),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -360,8 +451,8 @@ function extractCr(cr: string | { cr: string } | undefined): string {
|
||||
function makeCreatureId(source: string, name: string): CreatureId {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
return creatureId(`${source.toLowerCase()}:${slug}`);
|
||||
}
|
||||
|
||||
@@ -372,8 +463,7 @@ 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) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
|
||||
if ((m as any)._copy) return false;
|
||||
if (m._copy) return false;
|
||||
return (
|
||||
Array.isArray(m.ac) &&
|
||||
m.ac.length > 0 &&
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import type { AnyCreature, CreatureId } from "@initiative/domain";
|
||||
import { type IDBPDatabase, openDB } from "idb";
|
||||
|
||||
const DB_NAME = "initiative-bestiary";
|
||||
const STORE_NAME = "sources";
|
||||
const DB_VERSION = 1;
|
||||
// v7 (2026-04-10): Equipment items added to PF2e creatures; old caches are cleared
|
||||
const DB_VERSION = 7;
|
||||
|
||||
export interface CachedSourceInfo {
|
||||
interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
readonly displayName: string;
|
||||
readonly creatureCount: number;
|
||||
readonly cachedAt: number;
|
||||
readonly system?: string;
|
||||
}
|
||||
|
||||
interface CachedSourceRecord {
|
||||
sourceCode: string;
|
||||
displayName: string;
|
||||
creatures: Creature[];
|
||||
creatures: AnyCreature[];
|
||||
cachedAt: number;
|
||||
creatureCount: number;
|
||||
system?: string;
|
||||
}
|
||||
|
||||
let db: IDBPDatabase | null = null;
|
||||
@@ -26,18 +29,29 @@ 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) {
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
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;
|
||||
@@ -51,60 +65,77 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
||||
}
|
||||
|
||||
export async function cacheSource(
|
||||
system: string,
|
||||
sourceCode: string,
|
||||
displayName: string,
|
||||
creatures: Creature[],
|
||||
creatures: AnyCreature[],
|
||||
): Promise<void> {
|
||||
const key = scopedKey(system, sourceCode);
|
||||
const record: CachedSourceRecord = {
|
||||
sourceCode,
|
||||
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(sourceCode, record);
|
||||
memoryStore.set(key, record);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isSourceCached(sourceCode: string): Promise<boolean> {
|
||||
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, sourceCode);
|
||||
const record = await database.get(STORE_NAME, key);
|
||||
return record !== undefined;
|
||||
}
|
||||
return memoryStore.has(sourceCode);
|
||||
return memoryStore.has(key);
|
||||
}
|
||||
|
||||
export async function getCachedSources(): Promise<CachedSourceInfo[]> {
|
||||
export async function getCachedSources(
|
||||
system?: string,
|
||||
): Promise<CachedSourceInfo[]> {
|
||||
const database = await getDb();
|
||||
let records: CachedSourceRecord[];
|
||||
if (database) {
|
||||
const all: CachedSourceRecord[] = await database.getAll(STORE_NAME);
|
||||
return all.map((r) => ({
|
||||
sourceCode: r.sourceCode,
|
||||
displayName: r.displayName,
|
||||
creatureCount: r.creatureCount,
|
||||
cachedAt: r.cachedAt,
|
||||
}));
|
||||
records = await database.getAll(STORE_NAME);
|
||||
} else {
|
||||
records = [...memoryStore.values()];
|
||||
}
|
||||
return [...memoryStore.values()].map((r) => ({
|
||||
sourceCode: r.sourceCode,
|
||||
|
||||
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(sourceCode: string): Promise<void> {
|
||||
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, sourceCode);
|
||||
await database.delete(STORE_NAME, key);
|
||||
} else {
|
||||
memoryStore.delete(sourceCode);
|
||||
memoryStore.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,9 +149,9 @@ export async function clearAll(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function loadAllCachedCreatures(): Promise<
|
||||
Map<CreatureId, Creature>
|
||||
Map<CreatureId, AnyCreature>
|
||||
> {
|
||||
const map = new Map<CreatureId, Creature>();
|
||||
const map = new Map<CreatureId, AnyCreature>();
|
||||
const database = await getDb();
|
||||
|
||||
let records: CachedSourceRecord[];
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
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[] };
|
||||
}
|
||||
|
||||
interface ActionSystem {
|
||||
category?: string;
|
||||
actionType?: { value: string };
|
||||
actions?: { value: number | null };
|
||||
traits?: { value: string[] };
|
||||
description?: { value: 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 --
|
||||
|
||||
function normalizeAttack(item: RawFoundryItem): 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(", ")})` : "";
|
||||
return {
|
||||
name: capitalize(item.name),
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [
|
||||
{
|
||||
type: "text",
|
||||
value: `+${bonus}${traitStr}, ${damage}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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 --
|
||||
|
||||
function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
||||
const sys = item.system as unknown as ActionSystem;
|
||||
const actionType = sys.actionType?.value;
|
||||
const actionCount = sys.actions?.value;
|
||||
const description = stripFoundryTags(sys.description?.value ?? "");
|
||||
const traits = sys.traits?.value ?? [];
|
||||
|
||||
const activity = parseActivity(actionType, actionCount);
|
||||
|
||||
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, 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): SpellReference {
|
||||
const sys = item.system as unknown as SpellSystem;
|
||||
const usesMax = sys.location?.uses?.max;
|
||||
const rank = sys.location?.heightenedLevel ?? 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);
|
||||
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[],
|
||||
): 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);
|
||||
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[],
|
||||
): SpellcastingBlock[] {
|
||||
const entries = items.filter((i) => i.type === "spellcastingEntry");
|
||||
const spells = items.filter((i) => i.type === "spell");
|
||||
return entries.map((entry) => normalizeSpellcastingEntry(entry, spells));
|
||||
}
|
||||
|
||||
// -- 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,
|
||||
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(normalizeAttack),
|
||||
),
|
||||
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)),
|
||||
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,75 @@
|
||||
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 getPf2eSourceDisplayName(sourceCode: string): string {
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
return index.sources[sourceCode] ?? sourceCode;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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[];
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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"
|
||||
return params.replaceAll(/\[([^\]]*)\]/g, " $1").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
|
||||
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(/<[^>]+>/g, "");
|
||||
|
||||
// Decode common HTML entities
|
||||
result = result.replaceAll("&", "&");
|
||||
result = result.replaceAll("<", "<");
|
||||
result = result.replaceAll(">", ">");
|
||||
result = result.replaceAll(""", '"');
|
||||
|
||||
// Collapse whitespace
|
||||
result = result.replaceAll(/[ \t]+/g, " ");
|
||||
result = result.replaceAll(/\n\s*\n/g, "\n");
|
||||
return result.trim();
|
||||
}
|
||||
@@ -14,6 +14,15 @@ const ATKR_MAP: Record<string, string> = {
|
||||
"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.
|
||||
*
|
||||
@@ -25,55 +34,63 @@ export function stripTags(text: string): string {
|
||||
let result = text;
|
||||
|
||||
// {@h} → "Hit: "
|
||||
result = result.replace(/\{@h\}/g, "Hit: ");
|
||||
result = result.replaceAll("{@h}", "Hit: ");
|
||||
|
||||
// {@hom} → "Hit or Miss: "
|
||||
result = result.replace(/\{@hom\}/g, "Hit or Miss: ");
|
||||
result = result.replaceAll("{@hom}", "Hit or Miss: ");
|
||||
|
||||
// {@actTrigger} → "Trigger:"
|
||||
result = result.replace(/\{@actTrigger\}/g, "Trigger:");
|
||||
result = result.replaceAll("{@actTrigger}", "Trigger:");
|
||||
|
||||
// {@actResponse} → "Response:"
|
||||
result = result.replace(/\{@actResponse\}/g, "Response:");
|
||||
result = result.replaceAll("{@actResponse}", "Response:");
|
||||
|
||||
// {@actSaveSuccess} → "Success:"
|
||||
result = result.replace(/\{@actSaveSuccess\}/g, "Success:");
|
||||
result = result.replaceAll("{@actSaveSuccess}", "Success:");
|
||||
|
||||
// {@actSaveSuccessOrFail} → handled below as parameterized
|
||||
|
||||
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
|
||||
result = result.replace(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
|
||||
result = result.replace(/\{@recharge\}/g, "(Recharge 6)");
|
||||
result = result.replaceAll(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
|
||||
result = result.replaceAll("{@recharge}", "(Recharge 6)");
|
||||
|
||||
// {@dc N} → "DC N"
|
||||
result = result.replace(/\{@dc\s+(\d+)\}/g, "DC $1");
|
||||
result = result.replaceAll(/\{@dc\s+(\d+)\}/g, "DC $1");
|
||||
|
||||
// {@hit N} → "+N"
|
||||
result = result.replace(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||
|
||||
// {@atkr type} → mapped attack roll text
|
||||
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||
return ATKR_MAP[type.trim()] ?? `Attack Roll:`;
|
||||
// {@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.replace(/\{@actSave\s+([^}]+)\}/g, (_, ability: string) => {
|
||||
const name = ABILITY_MAP[ability.trim().toLowerCase()];
|
||||
return name ? `${name} saving throw` : `${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.replace(
|
||||
result = result.replaceAll(
|
||||
/\{@actSaveFail\s+(\d+)\}/g,
|
||||
"Failure by $1 or More:",
|
||||
);
|
||||
result = result.replace(/\{@actSaveFail\}/g, "Failure:");
|
||||
result = result.replaceAll("{@actSaveFail}", "Failure:");
|
||||
|
||||
// {@actSaveSuccessOrFail} → keep as-is label
|
||||
result = result.replace(/\{@actSaveSuccessOrFail\}/g, "Success or Failure:");
|
||||
result = result.replaceAll("{@actSaveSuccessOrFail}", "Success or Failure:");
|
||||
|
||||
// {@actSaveFailBy N} → "Failure by N or More:"
|
||||
result = result.replace(
|
||||
result = result.replaceAll(
|
||||
/\{@actSaveFailBy\s+(\d+)\}/g,
|
||||
"Failure by $1 or More:",
|
||||
);
|
||||
@@ -81,20 +98,26 @@ export function stripTags(text: string): string {
|
||||
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
||||
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
||||
// creature, hazard, status, plus any unknown tags
|
||||
result = result.replace(
|
||||
/\{@(\w+)\s+([^}]+)\}/g,
|
||||
(_, tag: string, content: string) => {
|
||||
// For tags with Display|Source format, extract first segment
|
||||
const segments = content.split("|");
|
||||
// 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("|");
|
||||
|
||||
// Some tags have a third segment as display text: {@variantrule Name|Source|Display}
|
||||
if ((tag === "variantrule" || tag === "action") && segments.length >= 3) {
|
||||
return segments[2];
|
||||
}
|
||||
if (
|
||||
(tag === "variantrule" || tag === "action") &&
|
||||
segments.length >= 3
|
||||
) {
|
||||
return segments[2];
|
||||
}
|
||||
|
||||
return segments[0];
|
||||
},
|
||||
);
|
||||
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,80 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import {
|
||||
type ConditionEntry,
|
||||
type ConditionId,
|
||||
getConditionsForEdition,
|
||||
} from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRef, type RefObject } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RulesEditionProvider } from "../../contexts/index.js";
|
||||
import { ConditionPicker } from "../condition-picker";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderPicker(
|
||||
overrides: Partial<{
|
||||
activeConditions: readonly ConditionEntry[];
|
||||
onToggle: (conditionId: ConditionId) => void;
|
||||
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||
onClose: () => void;
|
||||
}> = {},
|
||||
) {
|
||||
const onToggle = overrides.onToggle ?? vi.fn();
|
||||
const onSetValue = overrides.onSetValue ?? vi.fn();
|
||||
const onClose = overrides.onClose ?? vi.fn();
|
||||
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>
|
||||
<ConditionPicker
|
||||
anchorRef={anchorRef}
|
||||
activeConditions={overrides.activeConditions ?? []}
|
||||
onToggle={onToggle}
|
||||
onSetValue={onSetValue}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
return { ...result, onToggle, onSetValue, 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");
|
||||
});
|
||||
});
|
||||
@@ -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,360 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useRulesEdition } from "../../hooks/use-rules-edition.js";
|
||||
import { DifficultyBreakdownPanel } from "../difficulty-breakdown-panel.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const pcId1 = playerCharacterId("pc-1");
|
||||
const goblinCreature = buildCreature({
|
||||
id: creatureId("srd:goblin"),
|
||||
name: "Goblin",
|
||||
cr: "1/4",
|
||||
source: "srd",
|
||||
sourceDisplayName: "SRD",
|
||||
});
|
||||
|
||||
function renderPanel(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
encounter: options.encounter,
|
||||
playerCharacters: options.playerCharacters ?? [],
|
||||
creatures: options.creatures,
|
||||
});
|
||||
return render(
|
||||
<AllProviders adapters={adapters}>
|
||||
<DifficultyBreakdownPanel onClose={options.onClose ?? (() => {})} />
|
||||
</AllProviders>,
|
||||
);
|
||||
}
|
||||
|
||||
function defaultEncounter() {
|
||||
return buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
creatureId: goblinCreature.id,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-3"),
|
||||
name: "Custom Thug",
|
||||
cr: "2",
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-4"),
|
||||
name: "Bandit",
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const defaultPCs: PlayerCharacter[] = [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
];
|
||||
|
||||
describe("DifficultyBreakdownPanel", () => {
|
||||
it("renders party budget section", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Party Budget", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("1 PC", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText("Low:", { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders tier label", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Encounter Difficulty:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows PC in party column with level", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Hero")).toBeInTheDocument();
|
||||
expect(screen.getByText("Lv 5")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows monsters in enemy column", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("CR 1/4").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders explanation text", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Allied NPC XP is subtracted from encounter difficulty",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders Net Monster XP footer", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Net Monster XP")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders custom combatant with CR picker in enemy column", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||
expect(pickers).toHaveLength(2);
|
||||
expect(pickers[0]).toHaveValue("2");
|
||||
});
|
||||
});
|
||||
|
||||
it("selecting a CR updates the visible XP value", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByLabelText("Challenge rating")).toHaveLength(2);
|
||||
});
|
||||
|
||||
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||
await user.selectOptions(pickers[1], "5");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("1,800")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("non-PC combatants show toggle button", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Each non-PC enemy combatant has a toggle button
|
||||
expect(
|
||||
screen.getByLabelText("Move Goblin to party side"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("Move Custom Thug to party side"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("PC combatants do not show side toggle", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Hero")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText("Move Hero to enemy side"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("side toggle moves combatant between sections", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Toggle goblin to party side
|
||||
const toggleBtn = screen.getByLabelText("Move Goblin to party side");
|
||||
await user.click(toggleBtn);
|
||||
|
||||
// After toggle, the aria-label should change to "Move Goblin to enemy side"
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByLabelText("Move Goblin to enemy side"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing when breakdown data is insufficient", () => {
|
||||
const { container } = renderPanel({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({ id: combatantId("c-1"), name: "Custom" }),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("shows 4 threshold columns for 2014 edition", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("5e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Easy:", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText("Med:", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText("Hard:", { exact: false })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Deadly:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows multiplier and adjusted XP for 2014 edition", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("5e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster XP")).toBeInTheDocument();
|
||||
// 1 PC (<3) triggers party size adjustment
|
||||
expect(screen.getByText("Adjusted for 1 PC")).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Net Monster XP for 5.5e edition (no multiplier)", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Net Monster XP")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onClose when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
onClose,
|
||||
});
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { DifficultyResult } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DifficultyIndicator,
|
||||
TIER_LABELS_5_5E,
|
||||
TIER_LABELS_2014,
|
||||
} from "../difficulty-indicator.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
||||
return {
|
||||
tier,
|
||||
totalMonsterXp: 100,
|
||||
thresholds: [
|
||||
{ label: "Low", value: 50 },
|
||||
{ label: "Moderate", value: 100 },
|
||||
{ label: "High", value: 200 },
|
||||
],
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DifficultyIndicator", () => {
|
||||
it("renders 3 bars", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||
expect(bars).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("shows 'Trivial encounter difficulty' for 5.5e tier 0", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(0)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Trivial encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Low encounter difficulty' for 5.5e tier 1", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(1)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Low encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Moderate encounter difficulty' for 5.5e tier 2", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Moderate encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'High encounter difficulty' for 5.5e tier 3", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "High encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Easy encounter difficulty' for 2014 tier 0", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(0)} labels={TIER_LABELS_2014} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Easy encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Deadly encounter difficulty' for 2014 tier 3", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_2014} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Deadly encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onClick when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult(2)}
|
||||
labels={TIER_LABELS_5_5E}
|
||||
onClick={handleClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("img", { name: "Moderate encounter difficulty" }),
|
||||
);
|
||||
expect(handleClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders as div when onClick not provided", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
const element = container.querySelector("[role='img']");
|
||||
expect(element?.tagName).toBe("DIV");
|
||||
});
|
||||
|
||||
it("renders as button when onClick provided", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult(2)}
|
||||
labels={TIER_LABELS_5_5E}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
const element = container.querySelector("[role='img']");
|
||||
expect(element?.tagName).toBe("BUTTON");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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,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(undefined);
|
||||
const user = userEvent.setup();
|
||||
const { onSourceLoaded } = renderPrompt();
|
||||
|
||||
await user.click(screen.getByText("Load"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAndCacheSource).toHaveBeenCalledWith(
|
||||
"MM",
|
||||
"https://example.com/bestiary/MM.json",
|
||||
);
|
||||
expect(onSourceLoaded).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("fetch error shows error message", async () => {
|
||||
mockFetchAndCacheSource.mockRejectedValueOnce(new Error("Network error"));
|
||||
const user = userEvent.setup();
|
||||
renderPrompt();
|
||||
|
||||
await user.click(screen.getByText("Load"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("upload file calls uploadAndCacheSource and onSourceLoaded", async () => {
|
||||
mockUploadAndCacheSource.mockResolvedValueOnce(undefined);
|
||||
const user = userEvent.setup();
|
||||
const { onSourceLoaded } = renderPrompt();
|
||||
|
||||
const file = new File(['{"monster":[]}'], "bestiary-mm.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
const fileInput = document.querySelector(
|
||||
'input[type="file"]',
|
||||
) as HTMLInputElement;
|
||||
await user.upload(fileInput, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUploadAndCacheSource).toHaveBeenCalledWith("MM", {
|
||||
monster: [],
|
||||
});
|
||||
expect(onSourceLoaded).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("upload error shows error message", async () => {
|
||||
mockUploadAndCacheSource.mockRejectedValueOnce(new Error("Invalid format"));
|
||||
const user = userEvent.setup();
|
||||
renderPrompt();
|
||||
|
||||
const file = new File(['{"bad": true}'], "bad.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
const fileInput = document.querySelector(
|
||||
'input[type="file"]',
|
||||
) as HTMLInputElement;
|
||||
await user.upload(fileInput, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Invalid format")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,25 +12,23 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center text-sm tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral",
|
||||
"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="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
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 text-xs font-medium leading-none">
|
||||
{value !== undefined ? value : "\u2014"}
|
||||
<span className="relative -mt-0.5 font-medium text-xs leading-none">
|
||||
{value == null ? "\u2014" : String(value)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,157 +1,575 @@
|
||||
import { Import, Search } from "lucide-react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
import { BestiarySearch } from "./bestiary-search.js";
|
||||
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 {
|
||||
onAddCombatant: (name: string) => void;
|
||||
onAddFromBestiary: (result: SearchResult) => void;
|
||||
bestiarySearch: (query: string) => SearchResult[];
|
||||
bestiaryLoaded: boolean;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
autoFocus?: boolean;
|
||||
onManagePlayers?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
export function ActionBar({
|
||||
onAddCombatant,
|
||||
onAddFromBestiary,
|
||||
bestiarySearch,
|
||||
bestiaryLoaded,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
}: ActionBarProps) {
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||
|
||||
const handleAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (nameInput.trim() === "") return;
|
||||
onAddCombatant(nameInput);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNameInput(value);
|
||||
setSuggestionIndex(-1);
|
||||
if (value.length >= 2) {
|
||||
setSuggestions(bestiarySearch(value));
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectCreature = (result: SearchResult) => {
|
||||
onAddFromBestiary(result);
|
||||
setSearchOpen(false);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (result: SearchResult) => {
|
||||
onAddFromBestiary(result);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (suggestions.length === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
handleSelectSuggestion(suggestions[suggestionIndex]);
|
||||
} else if (e.key === "Escape") {
|
||||
setSuggestionIndex(-1);
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
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="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||
{searchOpen ? (
|
||||
<BestiarySearch
|
||||
onSelectCreature={handleSelectCreature}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
searchFn={bestiarySearch}
|
||||
/>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleAdd}
|
||||
className="relative flex flex-1 items-center gap-2"
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Combatant name"
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{suggestions.length > 0 && (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||
<ul className="max-h-48 overflow-y-auto py-1">
|
||||
{suggestions.map((result, i) => (
|
||||
<li key={`${result.source}:${result.name}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`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"
|
||||
}`}
|
||||
onClick={() => handleSelectSuggestion(result)}
|
||||
onMouseEnter={() => setSuggestionIndex(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{result.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
{bestiaryLoaded && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
{onBulkImport && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onBulkImport}
|
||||
disabled={bulkImportDisabled}
|
||||
>
|
||||
<Import className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { Search, X } from "lucide-react";
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface BestiarySearchProps {
|
||||
onSelectCreature: (result: SearchResult) => void;
|
||||
onClose: () => void;
|
||||
searchFn: (query: string) => SearchResult[];
|
||||
}
|
||||
|
||||
export function BestiarySearch({
|
||||
onSelectCreature,
|
||||
onClose,
|
||||
searchFn,
|
||||
}: BestiarySearchProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const results = query.length >= 2 ? searchFn(query) : [];
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightIndex(-1);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && highlightIndex >= 0) {
|
||||
e.preventDefault();
|
||||
onSelectCreature(results[highlightIndex]);
|
||||
}
|
||||
},
|
||||
[results, highlightIndex, onClose, onSelectCreature],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full max-w-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search bestiary..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{query.length >= 2 && (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
||||
{results.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
No creatures found
|
||||
</div>
|
||||
) : (
|
||||
<ul className="max-h-60 overflow-y-auto py-1">
|
||||
{results.map((result, i) => (
|
||||
<li key={`${result.source}:${result.name}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||
i === highlightIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-hover-neutral-bg"
|
||||
}`}
|
||||
onClick={() => onSelectCreature(result)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{result.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,49 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
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 DEFAULT_BASE_URL =
|
||||
const DND_BASE_URL =
|
||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||
|
||||
interface BulkImportPromptProps {
|
||||
importState: BulkImportState;
|
||||
onStartImport: (baseUrl: string) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
const PF2E_BASE_URL =
|
||||
"https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/";
|
||||
|
||||
export function BulkImportPrompt({
|
||||
importState,
|
||||
onStartImport,
|
||||
onDone,
|
||||
}: BulkImportPromptProps) {
|
||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||
const totalSources = getAllSourceCodes().length;
|
||||
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-sm text-green-400">
|
||||
<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 size="sm" onClick={onDone}>
|
||||
Done
|
||||
</Button>
|
||||
<Button onClick={handleDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,9 +55,7 @@ export function BulkImportPrompt({
|
||||
Loaded {importState.completed}/{importState.total} sources (
|
||||
{importState.failed} failed)
|
||||
</div>
|
||||
<Button size="sm" onClick={onDone}>
|
||||
Done
|
||||
</Button>
|
||||
<Button onClick={handleDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +69,7 @@ export function BulkImportPrompt({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<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>
|
||||
@@ -78,24 +89,20 @@ export function BulkImportPrompt({
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Bulk Import Sources
|
||||
<h3 className="font-semibold text-foreground text-sm">
|
||||
Import All Sources
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Load stat block data for all {totalSources} sources at once. This will
|
||||
download approximately 12.5 MB of data.
|
||||
<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="bulk-base-url"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<label htmlFor={baseUrlId} className="text-muted-foreground text-xs">
|
||||
Base URL
|
||||
</label>
|
||||
<Input
|
||||
id="bulk-base-url"
|
||||
id={baseUrlId}
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
@@ -103,11 +110,7 @@ export function BulkImportPrompt({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onStartImport(baseUrl)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type ConditionId,
|
||||
type ConditionEntry,
|
||||
type CreatureId,
|
||||
deriveHpStatus,
|
||||
type PlayerIcon,
|
||||
type RollMode,
|
||||
} from "@initiative/domain";
|
||||
import { Brain, X } from "lucide-react";
|
||||
import { Brain, Pencil, X } from "lucide-react";
|
||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { AcShield } from "./ac-shield";
|
||||
import { ConditionPicker } from "./condition-picker";
|
||||
import { ConditionTags } from "./condition-tags";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
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 { 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;
|
||||
@@ -20,35 +30,33 @@ interface Combatant {
|
||||
readonly initiative?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly currentHp?: number;
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly conditions?: readonly ConditionEntry[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly creatureId?: CreatureId;
|
||||
}
|
||||
|
||||
interface CombatantRowProps {
|
||||
combatant: Combatant;
|
||||
isActive: boolean;
|
||||
onRename: (id: CombatantId, newName: string) => void;
|
||||
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
||||
onRemove: (id: CombatantId) => void;
|
||||
onSetHp: (id: CombatantId, maxHp: number | undefined) => void;
|
||||
onAdjustHp: (id: CombatantId, delta: number) => void;
|
||||
onSetAc: (id: CombatantId, value: number | undefined) => void;
|
||||
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
||||
onToggleConcentration: (id: CombatantId) => void;
|
||||
onShowStatBlock?: () => void;
|
||||
onRollInitiative?: (id: CombatantId) => void;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -73,7 +81,7 @@ function EditableName({
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={draft}
|
||||
className="h-7 text-sm"
|
||||
className="h-7 max-w-48 text-sm"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
@@ -85,26 +93,41 @@ function EditableName({
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditing();
|
||||
}}
|
||||
className="truncate text-left text-sm text-foreground hover:text-hover-neutral transition-colors"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
<>
|
||||
<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);
|
||||
@@ -135,7 +158,7 @@ function MaxHpDisplay({
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
placeholder="Max"
|
||||
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
||||
className="h-7 w-[7ch] text-center tabular-nums"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
@@ -150,7 +173,12 @@ function MaxHpDisplay({
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
className="inline-block h-7 min-w-[3ch] text-center text-sm leading-7 tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral"
|
||||
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>
|
||||
@@ -160,48 +188,47 @@ function MaxHpDisplay({
|
||||
function ClickableHp({
|
||||
currentHp,
|
||||
maxHp,
|
||||
tempHp,
|
||||
onAdjust,
|
||||
dimmed,
|
||||
}: {
|
||||
onSetTempHp,
|
||||
}: Readonly<{
|
||||
currentHp: number | undefined;
|
||||
maxHp: number | undefined;
|
||||
tempHp: number | undefined;
|
||||
onAdjust: (delta: number) => void;
|
||||
dimmed?: boolean;
|
||||
}) {
|
||||
onSetTempHp: (value: number) => void;
|
||||
}>) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
|
||||
if (maxHp === undefined) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
--
|
||||
</span>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<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 text-sm font-medium leading-7 tabular-nums transition-colors hover:text-hover-neutral",
|
||||
"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",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{currentHp}
|
||||
</button>
|
||||
{popoverOpen && (
|
||||
{!!tempHp && (
|
||||
<span className="font-medium text-cyan-400 text-sm leading-7">
|
||||
+{tempHp}
|
||||
</span>
|
||||
)}
|
||||
{!!popoverOpen && (
|
||||
<HpAdjustPopover
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={() => setPopoverOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -212,10 +239,10 @@ function ClickableHp({
|
||||
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);
|
||||
@@ -246,7 +273,7 @@ function AcDisplay({
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
placeholder="AC"
|
||||
className="h-7 w-[6ch] text-center text-sm tabular-nums"
|
||||
className="h-7 w-[6ch] text-center tabular-nums"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
@@ -266,16 +293,34 @@ function InitiativeDisplay({
|
||||
dimmed,
|
||||
onSetInitiative,
|
||||
onRollInitiative,
|
||||
}: {
|
||||
}: Readonly<{
|
||||
initiative: number | undefined;
|
||||
combatantId: CombatantId;
|
||||
dimmed: boolean;
|
||||
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
||||
onRollInitiative?: (id: CombatantId) => 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 === "") {
|
||||
@@ -304,7 +349,7 @@ function InitiativeDisplay({
|
||||
value={draft}
|
||||
placeholder="--"
|
||||
className={cn(
|
||||
"h-7 w-[6ch] text-center text-sm tabular-nums",
|
||||
"h-7 w-full text-center tabular-nums",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
@@ -317,35 +362,49 @@ function InitiativeDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
// Empty + bestiary creature → d20 roll button
|
||||
// Empty + bestiary creature -> d20 roll button
|
||||
if (initiative === undefined && onRollInitiative) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRollInitiative(combatantId)}
|
||||
className={cn(
|
||||
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
||||
dimmed && "opacity-50",
|
||||
<>
|
||||
<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)}
|
||||
/>
|
||||
)}
|
||||
title="Roll initiative"
|
||||
aria-label="Roll initiative"
|
||||
>
|
||||
<D20Icon className="h-7 w-7" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Has value → bold number, click to edit
|
||||
// Empty + manual → "--" placeholder, click to edit
|
||||
// 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 leading-7 tabular-nums transition-colors",
|
||||
initiative !== undefined
|
||||
? "font-medium text-foreground hover:text-hover-neutral"
|
||||
: "text-muted-foreground hover:text-hover-neutral",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
@@ -354,45 +413,103 @@ function InitiativeDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
onRename,
|
||||
onSetInitiative,
|
||||
onRemove,
|
||||
onSetHp,
|
||||
onAdjustHp,
|
||||
onSetAc,
|
||||
onToggleCondition,
|
||||
onToggleConcentration,
|
||||
onShowStatBlock,
|
||||
onRollInitiative,
|
||||
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
||||
const {
|
||||
editCombatant,
|
||||
setInitiative,
|
||||
removeCombatant,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setTempHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
setConditionValue,
|
||||
decrementCondition,
|
||||
toggleConcentration,
|
||||
} = useEncounterContext();
|
||||
const { selectedCreatureId, showCreature, toggleCollapse } =
|
||||
useSidePanelContext();
|
||||
const { handleRollInitiative } = useInitiativeRollsContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
const isPf2e = edition === "pf2e";
|
||||
|
||||
// Derive what was previously conditional props
|
||||
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
|
||||
const { creatureId } = combatant;
|
||||
const hasStatBlock = !!creatureId;
|
||||
const onToggleStatBlock = hasStatBlock
|
||||
? () => {
|
||||
if (isStatBlockOpen) {
|
||||
toggleCollapse();
|
||||
} else {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
}
|
||||
: 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 (
|
||||
prevHp !== undefined &&
|
||||
currentHp !== undefined &&
|
||||
currentHp < prevHp &&
|
||||
combatant.isConcentrating
|
||||
(realHpDropped || tempHpDropped) &&
|
||||
combatant.isConcentrating &&
|
||||
!isPf2e
|
||||
) {
|
||||
setIsPulsing(true);
|
||||
clearTimeout(pulseTimerRef.current);
|
||||
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||
}
|
||||
}, [currentHp, combatant.isConcentrating]);
|
||||
}, [currentHp, combatant.tempHp, combatant.isConcentrating, isPf2e]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!combatant.isConcentrating) {
|
||||
@@ -401,134 +518,140 @@ export function CombatantRow({
|
||||
}
|
||||
}, [combatant.isConcentrating]);
|
||||
|
||||
const pcColor = combatant.color
|
||||
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
/* biome-ignore lint/a11y/useKeyWithClickEvents: row click opens stat block */
|
||||
/* biome-ignore lint/a11y/noStaticElementInteractions: row click opens stat block */
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group rounded-md pr-3 transition-colors",
|
||||
isActive
|
||||
? "border-l-2 border-l-accent bg-accent/10"
|
||||
: combatant.isConcentrating
|
||||
? "border-l-2 border-l-purple-400"
|
||||
: "border-l-2 border-l-transparent",
|
||||
"group rounded-lg pr-3 transition-colors",
|
||||
rowBorderClass(isActive, combatant.isConcentrating, isPf2e),
|
||||
isPulsing && "animate-concentration-pulse",
|
||||
onShowStatBlock && "cursor-pointer",
|
||||
)}
|
||||
onClick={onShowStatBlock}
|
||||
>
|
||||
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
||||
{/* Concentration */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleConcentration(id);
|
||||
}}
|
||||
title="Concentrating"
|
||||
aria-label="Toggle concentration"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
||||
combatant.isConcentrating
|
||||
? dimmed
|
||||
? "opacity-50 text-purple-400"
|
||||
: "opacity-100 text-purple-400"
|
||||
: "opacity-0 group-hover:opacity-50 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Brain size={16} />
|
||||
</button>
|
||||
<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 */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="rounded-md bg-muted/30 px-1">
|
||||
<InitiativeDisplay
|
||||
initiative={initiative}
|
||||
combatantId={id}
|
||||
dimmed={dimmed}
|
||||
onSetInitiative={onSetInitiative}
|
||||
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 flex-wrap items-center gap-1 min-w-0",
|
||||
"relative flex min-w-0 flex-wrap items-center gap-1",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<EditableName name={name} combatantId={id} onRename={onRename} />
|
||||
</span>
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
{!!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}
|
||||
/>
|
||||
{pickerOpen && (
|
||||
<div ref={conditionAnchorRef}>
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
{!!pickerOpen && (
|
||||
<ConditionPicker
|
||||
anchorRef={conditionAnchorRef}
|
||||
activeConditions={combatant.conditions}
|
||||
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onSetValue={(conditionId, value) =>
|
||||
setConditionValue(id, conditionId, value)
|
||||
}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AC */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
|
||||
<div
|
||||
className={cn(dimmed && "opacity-50")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* HP */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
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}
|
||||
onAdjust={(delta) => onAdjustHp(id, delta)}
|
||||
dimmed={dimmed}
|
||||
tempHp={combatant.tempHp}
|
||||
onAdjust={(delta) => adjustHp(id, delta)}
|
||||
onSetTempHp={(value) => setTempHp(id, value)}
|
||||
/>
|
||||
{maxHp !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm tabular-nums text-muted-foreground",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
<span className="text-muted-foreground/50 text-xs">/</span>
|
||||
)}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
|
||||
</div>
|
||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-hover-destructive opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(id);
|
||||
}}
|
||||
title="Remove combatant"
|
||||
aria-label="Remove combatant"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,124 +1,215 @@
|
||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
Droplet,
|
||||
EarOff,
|
||||
EyeOff,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
Link,
|
||||
Moon,
|
||||
Siren,
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
type ConditionEntry,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
} from "@initiative/domain";
|
||||
import { Check, Minus, Plus } from "lucide-react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
Heart,
|
||||
EarOff,
|
||||
BatteryLow,
|
||||
Siren,
|
||||
Hand,
|
||||
Ban,
|
||||
Ghost,
|
||||
ZapOff,
|
||||
Gem,
|
||||
Droplet,
|
||||
ArrowDown,
|
||||
Link,
|
||||
Sparkles,
|
||||
Moon,
|
||||
};
|
||||
|
||||
const COLOR_CLASSES: Record<string, string> = {
|
||||
neutral: "text-muted-foreground",
|
||||
pink: "text-pink-400",
|
||||
amber: "text-amber-400",
|
||||
orange: "text-orange-400",
|
||||
gray: "text-gray-400",
|
||||
violet: "text-violet-400",
|
||||
yellow: "text-yellow-400",
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
};
|
||||
import {
|
||||
CONDITION_COLOR_CLASSES,
|
||||
CONDITION_ICON_MAP,
|
||||
} from "./condition-styles.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
interface ConditionPickerProps {
|
||||
activeConditions: readonly ConditionId[] | undefined;
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
activeConditions: readonly ConditionEntry[] | undefined;
|
||||
onToggle: (conditionId: ConditionId) => void;
|
||||
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConditionPicker({
|
||||
anchorRef,
|
||||
activeConditions,
|
||||
onToggle,
|
||||
onSetValue,
|
||||
onClose,
|
||||
}: ConditionPickerProps) {
|
||||
}: Readonly<ConditionPickerProps>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [pos, setPos] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
|
||||
const [editing, setEditing] = useState<{
|
||||
id: ConditionId;
|
||||
value: number;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setFlipped(rect.bottom > window.innerHeight);
|
||||
}, []);
|
||||
if (!anchor || !el) return;
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const menuHeight = el.scrollHeight;
|
||||
const pad = 8;
|
||||
|
||||
const active = new Set(activeConditions ?? []);
|
||||
const spaceBelow = window.innerHeight - anchorRect.bottom - pad;
|
||||
const spaceAbove = anchorRect.top - pad;
|
||||
const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
|
||||
|
||||
return (
|
||||
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]),
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute z-10 w-fit rounded-md border border-border bg-background p-1 shadow-lg",
|
||||
flipped ? "bottom-full mb-1" : "mt-1",
|
||||
)}
|
||||
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 }
|
||||
}
|
||||
>
|
||||
{CONDITION_DEFINITIONS.map((def) => {
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
{conditions.map((def) => {
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const isActive = active.has(def.id);
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
const 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 (
|
||||
<button
|
||||
<Tooltip
|
||||
key={def.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
||||
isActive && "bg-card/50",
|
||||
)}
|
||||
onClick={() => onToggle(def.id)}
|
||||
content={getConditionDescription(def, edition)}
|
||||
className="block"
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={isActive ? colorClass : "text-muted-foreground"}
|
||||
/>
|
||||
<span
|
||||
className={isActive ? "text-foreground" : "text-muted-foreground"}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||
(isActive || isEditing) && "bg-card/50",
|
||||
)}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Anchor,
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
BrainCog,
|
||||
CircleHelp,
|
||||
CloudFog,
|
||||
Drama,
|
||||
Droplet,
|
||||
Droplets,
|
||||
EarOff,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Footprints,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
HeartCrack,
|
||||
HeartPulse,
|
||||
Link,
|
||||
Moon,
|
||||
PersonStanding,
|
||||
ShieldMinus,
|
||||
ShieldOff,
|
||||
Siren,
|
||||
Skull,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Sun,
|
||||
TrendingDown,
|
||||
Zap,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
|
||||
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
Anchor,
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
BrainCog,
|
||||
CircleHelp,
|
||||
CloudFog,
|
||||
Drama,
|
||||
Droplet,
|
||||
Droplets,
|
||||
EarOff,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Footprints,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
HeartCrack,
|
||||
HeartPulse,
|
||||
Link,
|
||||
Moon,
|
||||
PersonStanding,
|
||||
ShieldMinus,
|
||||
ShieldOff,
|
||||
Siren,
|
||||
Skull,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Sun,
|
||||
TrendingDown,
|
||||
Zap,
|
||||
ZapOff,
|
||||
};
|
||||
|
||||
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||
neutral: "text-muted-foreground",
|
||||
pink: "text-pink-400",
|
||||
amber: "text-amber-400",
|
||||
orange: "text-orange-400",
|
||||
gray: "text-gray-400",
|
||||
violet: "text-violet-400",
|
||||
yellow: "text-yellow-400",
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
red: "text-red-400",
|
||||
};
|
||||
@@ -1,95 +1,79 @@
|
||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
Droplet,
|
||||
EarOff,
|
||||
EyeOff,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
Link,
|
||||
Moon,
|
||||
Plus,
|
||||
Siren,
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
Heart,
|
||||
EarOff,
|
||||
BatteryLow,
|
||||
Siren,
|
||||
Hand,
|
||||
Ban,
|
||||
Ghost,
|
||||
ZapOff,
|
||||
Gem,
|
||||
Droplet,
|
||||
ArrowDown,
|
||||
Link,
|
||||
Sparkles,
|
||||
Moon,
|
||||
};
|
||||
|
||||
const COLOR_CLASSES: Record<string, string> = {
|
||||
neutral: "text-muted-foreground",
|
||||
pink: "text-pink-400",
|
||||
amber: "text-amber-400",
|
||||
orange: "text-orange-400",
|
||||
gray: "text-gray-400",
|
||||
violet: "text-violet-400",
|
||||
yellow: "text-yellow-400",
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
};
|
||||
CONDITION_DEFINITIONS,
|
||||
type ConditionEntry,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
} from "@initiative/domain";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
CONDITION_COLOR_CLASSES,
|
||||
CONDITION_ICON_MAP,
|
||||
} from "./condition-styles.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
interface ConditionTagsProps {
|
||||
conditions: readonly ConditionId[] | undefined;
|
||||
conditions: readonly ConditionEntry[] | undefined;
|
||||
onRemove: (conditionId: ConditionId) => void;
|
||||
onDecrement: (conditionId: ConditionId) => void;
|
||||
onOpenPicker: () => void;
|
||||
}
|
||||
|
||||
export function ConditionTags({
|
||||
conditions,
|
||||
onRemove,
|
||||
onDecrement,
|
||||
onOpenPicker,
|
||||
}: ConditionTagsProps) {
|
||||
}: Readonly<ConditionTagsProps>) {
|
||||
const { edition } = useRulesEditionContext();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{conditions?.map((condId) => {
|
||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
|
||||
{conditions?.map((entry) => {
|
||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === entry.id);
|
||||
if (!def) return null;
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
const colorClass =
|
||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
const tooltipLabel =
|
||||
entry.value === undefined ? def.label : `${def.label} ${entry.value}`;
|
||||
return (
|
||||
<button
|
||||
key={condId}
|
||||
type="button"
|
||||
title={def.label}
|
||||
aria-label={`Remove ${def.label}`}
|
||||
className={`inline-flex items-center rounded p-0.5 hover:bg-hover-neutral-bg transition-colors ${colorClass}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(condId);
|
||||
}}
|
||||
<Tooltip
|
||||
key={entry.id}
|
||||
content={`${tooltipLabel}:\n${getConditionDescription(def, edition)}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${def.label}`}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
colorClass,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (entry.value === undefined) {
|
||||
onRemove(entry.id);
|
||||
} else {
|
||||
onDecrement(entry.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{entry.value !== undefined && (
|
||||
<span className="font-medium text-xs leading-none">
|
||||
{entry.value}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
title="Add condition"
|
||||
aria-label="Add condition"
|
||||
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
|
||||
className="inline-flex pointer-coarse:w-auto w-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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenPicker();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { VALID_CR_VALUES } from "@initiative/domain";
|
||||
|
||||
const CR_LABELS: Record<string, string> = {
|
||||
"0": "CR 0",
|
||||
"1/8": "CR 1/8",
|
||||
"1/4": "CR 1/4",
|
||||
"1/2": "CR 1/2",
|
||||
};
|
||||
|
||||
function formatCr(cr: string): string {
|
||||
return CR_LABELS[cr] ?? `CR ${cr}`;
|
||||
}
|
||||
|
||||
export function CrPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (cr: string | undefined) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className="rounded border border-border bg-card px-1.5 py-0.5 text-xs"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
aria-label="Challenge rating"
|
||||
>
|
||||
<option value="">Assign</option>
|
||||
{VALID_CR_VALUES.map((cr) => (
|
||||
<option key={cr} value={cr}>
|
||||
{formatCr(cr)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ColorPalette } from "./color-palette";
|
||||
import { IconGrid } from "./icon-grid";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog } from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
function parseLevel(value: string): number | undefined | "invalid" {
|
||||
if (value.trim() === "") return undefined;
|
||||
const n = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > 20) return "invalid";
|
||||
return n;
|
||||
}
|
||||
|
||||
interface CreatePlayerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
level: number | undefined,
|
||||
) => void;
|
||||
playerCharacter?: PlayerCharacter;
|
||||
}
|
||||
|
||||
export function CreatePlayerModal({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
playerCharacter,
|
||||
}: Readonly<CreatePlayerModalProps>) {
|
||||
const [name, setName] = useState("");
|
||||
const [ac, setAc] = useState("10");
|
||||
const [maxHp, setMaxHp] = useState("10");
|
||||
const [color, setColor] = useState("blue");
|
||||
const [icon, setIcon] = useState("sword");
|
||||
const [level, setLevel] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isEdit = !!playerCharacter;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (playerCharacter) {
|
||||
setName(playerCharacter.name);
|
||||
setAc(String(playerCharacter.ac));
|
||||
setMaxHp(String(playerCharacter.maxHp));
|
||||
setColor(playerCharacter.color ?? "");
|
||||
setIcon(playerCharacter.icon ?? "");
|
||||
setLevel(
|
||||
playerCharacter.level === undefined
|
||||
? ""
|
||||
: String(playerCharacter.level),
|
||||
);
|
||||
} else {
|
||||
setName("");
|
||||
setAc("10");
|
||||
setMaxHp("10");
|
||||
setColor("");
|
||||
setIcon("");
|
||||
setLevel("");
|
||||
}
|
||||
setError("");
|
||||
}
|
||||
}, [open, playerCharacter]);
|
||||
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed === "") {
|
||||
setError("Name is required");
|
||||
return;
|
||||
}
|
||||
const acNum = Number.parseInt(ac, 10);
|
||||
if (Number.isNaN(acNum) || acNum < 0) {
|
||||
setError("AC must be a non-negative number");
|
||||
return;
|
||||
}
|
||||
const hpNum = Number.parseInt(maxHp, 10);
|
||||
if (Number.isNaN(hpNum) || hpNum < 1) {
|
||||
setError("Max HP must be at least 1");
|
||||
return;
|
||||
}
|
||||
const levelNum = parseLevel(level);
|
||||
if (levelNum === "invalid") {
|
||||
setError("Level must be between 1 and 20");
|
||||
return;
|
||||
}
|
||||
onSave(
|
||||
trimmed,
|
||||
acNum,
|
||||
hpNum,
|
||||
color || undefined,
|
||||
icon || undefined,
|
||||
levelNum,
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
{isEdit ? "Edit Player" : "Create Player"}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<span className="mb-1 block text-muted-foreground text-sm">Name</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder="Character name"
|
||||
aria-label="Name"
|
||||
autoFocus
|
||||
/>
|
||||
{!!error && <p className="mt-1 text-destructive text-sm">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-muted-foreground text-sm">AC</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={ac}
|
||||
onChange={(e) => setAc(e.target.value)}
|
||||
placeholder="AC"
|
||||
aria-label="AC"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-muted-foreground text-sm">
|
||||
Max HP
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={maxHp}
|
||||
onChange={(e) => setMaxHp(e.target.value)}
|
||||
placeholder="Max HP"
|
||||
aria-label="Max HP"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-muted-foreground text-sm">
|
||||
Level
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value)}
|
||||
placeholder="1-20"
|
||||
aria-label="Level"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-muted-foreground text-sm">
|
||||
Color
|
||||
</span>
|
||||
<ColorPalette value={color} onChange={setColor} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-muted-foreground text-sm">Icon</span>
|
||||
<IconGrid value={icon} onChange={setIcon} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import { useSwipeToDismissDown } from "../hooks/use-swipe-to-dismiss.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
|
||||
interface DetailPopoverProps {
|
||||
readonly anchorRect: DOMRect;
|
||||
readonly onClose: () => void;
|
||||
readonly ariaLabel: string;
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
|
||||
function DesktopPanel({
|
||||
anchorRect,
|
||||
onClose,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: Readonly<DetailPopoverProps>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const popover = el.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const vh = document.documentElement.clientHeight;
|
||||
// Prefer placement to the LEFT of the anchor (panel is on the right edge)
|
||||
let left = anchorRect.left - popover.width - 8;
|
||||
if (left < 8) {
|
||||
left = anchorRect.right + 8;
|
||||
}
|
||||
if (left + popover.width > vw - 8) {
|
||||
left = vw - popover.width - 8;
|
||||
}
|
||||
let top = anchorRect.top;
|
||||
if (top + popover.height > vh - 8) {
|
||||
top = vh - popover.height - 8;
|
||||
}
|
||||
if (top < 8) top = 8;
|
||||
setPos({ top, left });
|
||||
}, [anchorRect]);
|
||||
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="card-glow fixed z-50 max-h-[calc(100vh-16px)] w-80 max-w-[calc(100vw-16px)] overflow-y-auto rounded-lg border border-border bg-card p-4 shadow-lg"
|
||||
style={pos ? { top: pos.top, left: pos.left } : { visibility: "hidden" }}
|
||||
role="dialog"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSheet({
|
||||
onClose,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: Readonly<Omit<DetailPopoverProps, "anchorRect">>) {
|
||||
const { offsetY, isSwiping, handlers } = useSwipeToDismissDown(onClose);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
className="fade-in absolute inset-0 animate-in bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-label="Close details"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"panel-glow absolute right-0 bottom-0 left-0 max-h-[80vh] rounded-t-2xl border-border border-t bg-card",
|
||||
!isSwiping && "animate-slide-in-bottom",
|
||||
)}
|
||||
style={
|
||||
isSwiping ? { transform: `translateY(${offsetY}px)` } : undefined
|
||||
}
|
||||
{...handlers}
|
||||
role="dialog"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="flex justify-center pt-2 pb-1">
|
||||
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
|
||||
</div>
|
||||
<div className="max-h-[calc(80vh-24px)] overflow-y-auto p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailPopover({
|
||||
anchorRect,
|
||||
onClose,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: Readonly<DetailPopoverProps>) {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
// Portal to document.body to escape any CSS transforms on ancestors
|
||||
// (the side panel uses translate-x for collapse animation, which would
|
||||
// otherwise become the containing block for fixed-positioned children).
|
||||
const content = isDesktop ? (
|
||||
<DesktopPanel
|
||||
anchorRect={anchorRect}
|
||||
onClose={onClose}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</DesktopPanel>
|
||||
) : (
|
||||
<MobileSheet onClose={onClose} ariaLabel={ariaLabel}>
|
||||
{children}
|
||||
</MobileSheet>
|
||||
);
|
||||
return createPortal(content, 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