Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
206
.claude/skills/browser-interactive-testing/SKILL.md
Normal file
206
.claude/skills/browser-interactive-testing/SKILL.md
Normal file
@@ -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.
|
||||
160
.claude/skills/browser-interactive-testing/scripts/setup.py
Normal file
160
.claude/skills/browser-interactive-testing/scripts/setup.py
Normal file
@@ -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()
|
||||
75
.claude/skills/commit/SKILL.md
Normal file
75
.claude/skills/commit/SKILL.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: commit
|
||||
description: Create a git commit with pre-commit hooks (bypasses sandbox restrictions).
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(git *), Bash(pnpm *)
|
||||
---
|
||||
|
||||
## Instructions
|
||||
|
||||
Create a git commit for the current staged and/or unstaged changes.
|
||||
|
||||
### Step 1 — Assess changes
|
||||
|
||||
Run these in parallel:
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
```bash
|
||||
git diff
|
||||
```
|
||||
|
||||
```bash
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
### Step 2 — Draft commit message
|
||||
|
||||
- Summarize the nature of the changes (new feature, enhancement, bug fix, refactor, test, docs, etc.)
|
||||
- Keep the first line concise (under 72 chars), use imperative mood
|
||||
- Add a blank line and a short body if the "why" isn't obvious from the first line
|
||||
- Match the style of recent commits in the log
|
||||
- Do not commit files that likely contain secrets (.env, credentials, etc.)
|
||||
|
||||
### Step 3 — Stage and commit
|
||||
|
||||
Stage relevant files by name (avoid `git add -A` or `git add .`). Then commit.
|
||||
|
||||
**CRITICAL:** Always use `dangerouslyDisableSandbox: true` for the commit command. Lefthook pre-commit hooks spawn subprocesses (biome, oxlint, vitest, etc.) that require filesystem access beyond what the sandbox allows. They will always fail with "operation not permitted" in sandbox mode.
|
||||
|
||||
Append the co-author trailer:
|
||||
|
||||
```
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
Use a HEREDOC for the commit message:
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
<first line>
|
||||
|
||||
<optional body>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### Step 4 — Verify
|
||||
|
||||
Run `git status` after the commit to confirm success.
|
||||
|
||||
### If the commit fails
|
||||
|
||||
If a pre-commit hook fails, fix the issue, re-stage, and create a **new** commit. Never amend unless explicitly asked — amending after a hook failure would modify the previous commit.
|
||||
|
||||
## User arguments
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
If the user provided arguments, treat them as the commit message or guidance for what to commit.
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,3 +12,6 @@ Thumbs.db
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
docs/agents/plans/
|
||||
docs/agents/research/
|
||||
.agent-tests/
|
||||
.rodney/
|
||||
|
||||
9
.jsinspectrc
Normal file
9
.jsinspectrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"threshold": 50,
|
||||
"minInstances": 3,
|
||||
"identifiers": false,
|
||||
"literals": false,
|
||||
"ignore": "dist|__tests__|node_modules",
|
||||
"reporter": "default",
|
||||
"truncate": 100
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
───────────────────
|
||||
Version change: 2.2.1 → 3.0.0 (MAJOR — specs describe features not changes, proportional workflow)
|
||||
Version change: 3.1.0 → 3.2.0 (MINOR — artifact lifecycle guidance)
|
||||
Modified sections:
|
||||
- Development Workflow: specs are living feature documents; full pipeline for new features only
|
||||
- Development Workflow: added artifact lifecycle rules (spec.md living, plan/tasks bounded, tests authoritative)
|
||||
Templates requiring updates: none
|
||||
-->
|
||||
# Encounter Console Constitution
|
||||
@@ -38,6 +38,22 @@ dependency direction:
|
||||
|
||||
A module in an inner layer MUST NOT import from an outer layer.
|
||||
|
||||
### II-A. Context-Based State Flow
|
||||
|
||||
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).
|
||||
|
||||
- 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.
|
||||
|
||||
### III. Clarification-First
|
||||
|
||||
Before making any non-trivial assumption during specification,
|
||||
@@ -97,6 +113,18 @@ architecture, and quality — not product behavior.
|
||||
(which creates a feature branch for the full speckit pipeline);
|
||||
changes to existing features update the existing spec via
|
||||
`/integrate-issue`.
|
||||
- **Artifact lifecycles differ by type**:
|
||||
- `spec.md` is a **living capability document** — it describes what
|
||||
the feature does and is updated whenever the feature meaningfully
|
||||
changes. It survives across multiple rounds of work.
|
||||
- `plan.md` and `tasks.md` are **bounded work packages** — they
|
||||
describe what to do for a specific increment of work. After
|
||||
completion they become historical records. The next round of work
|
||||
on the same feature gets a new plan, not an update to the old one.
|
||||
- Tests are the **executable ground truth**. When a spec's
|
||||
acceptance criteria and the tests disagree, the tests are
|
||||
authoritative. Spec prose captures intent and context; tests
|
||||
capture actual behavior.
|
||||
- The full pipeline (spec → plan → tasks → implement) applies to new
|
||||
features and significant additions. Bug fixes, tooling changes,
|
||||
and trivial UI adjustments do not require specs.
|
||||
@@ -140,4 +168,4 @@ MUST comply with its principles.
|
||||
**Compliance review**: Every spec and plan MUST include a
|
||||
Constitution Check section validating adherence to all principles.
|
||||
|
||||
**Version**: 3.0.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-11
|
||||
**Version**: 3.2.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-30
|
||||
|
||||
60
CLAUDE.md
60
CLAUDE.md
@@ -1,11 +1,11 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
**Initiative** is a browser-based combat encounter tracker for tabletop RPGs (D&D 5.5e, Pathfinder 2e). It runs entirely client-side — no backend, no accounts — with localStorage and IndexedDB for persistence.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd)
|
||||
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd + jsinspect)
|
||||
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
|
||||
pnpm knip # Unused code detection (Knip)
|
||||
pnpm test # Run all tests (Vitest)
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
```
|
||||
@@ -29,7 +30,7 @@ apps/web (React 19 + Vite) → packages/application (use cases) → packages
|
||||
|
||||
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
|
||||
- **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
|
||||
- **Web** — React adapter. Implements ports using hooks/state. All UI components, routing, and user interaction live here.
|
||||
- **Web** — React adapter. Implements ports using hooks/state. All UI components and user interaction live here.
|
||||
|
||||
Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which runs as a Vitest test. Domain and application must never import from React, Vite, or upper layers.
|
||||
|
||||
@@ -59,19 +60,20 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
||||
- React 19, Vite 6, Tailwind CSS v4
|
||||
- Lucide React (icons)
|
||||
- `idb` (IndexedDB wrapper for bestiary cache)
|
||||
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection)
|
||||
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection), jsinspect-plus (structural duplication)
|
||||
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
||||
- **oxlint** for type-aware linting that Biome can't do (unnecessary type assertions, deprecated APIs, `replaceAll` preference, `String.raw`). Configured in `.oxlintrc.json`.
|
||||
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
|
||||
- **oxlint** for type-aware linting that Biome can't do. Configured in `.oxlintrc.json`.
|
||||
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports.
|
||||
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
||||
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
||||
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios from specs to individual `it()` blocks.
|
||||
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
|
||||
|
||||
For component prop rules, export format compatibility, and ADRs, see [`docs/conventions.md`](docs/conventions.md).
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
@@ -83,19 +85,7 @@ Before finishing a change, consider:
|
||||
|
||||
## Speckit Workflow
|
||||
|
||||
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
||||
|
||||
### Issue-driven workflow
|
||||
- `/write-issue` — create a well-structured Gitea issue via interactive interview
|
||||
- `/integrate-issue <number>` — fetch an issue, route it to the right spec, and update the spec with the new/changed requirements. Then implement directly.
|
||||
- `/sync-issue <number>` — push acceptance criteria from the spec back to the Gitea issue
|
||||
|
||||
### RPI skills (Research → Plan → Implement)
|
||||
- `rpi-research` — deep codebase research producing a written report in `docs/agents/research/`
|
||||
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
|
||||
- `rpi-implement` — execute a plan file phase by phase with automated + manual verification
|
||||
|
||||
### Choosing the right workflow by scope
|
||||
Specs are **living documents** in `specs/NNN-feature-name/` that describe features, not individual changes. Use `/speckit.*` and RPI skills (`rpi-research`, `rpi-plan`, `rpi-implement`) to manage them — skill descriptions have full usage details.
|
||||
|
||||
| Scope | Workflow |
|
||||
|---|---|
|
||||
@@ -104,28 +94,8 @@ Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Spec
|
||||
| Larger addition to existing feature | `/integrate-issue` → `rpi-research` → `rpi-plan` → `rpi-implement` |
|
||||
| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` |
|
||||
|
||||
Speckit manages **what** to build (specs as living documents). RPI manages **how** to build it (research, planning, execution). The full speckit pipeline is for new features. For changes to existing features, update the spec via `/integrate-issue`, then use RPI skills if the change is non-trivial.
|
||||
**Research scope**: Always scan for existing patterns similar to what the feature needs. Identify extraction and consolidation opportunities before implementation, not during.
|
||||
|
||||
### Current feature specs
|
||||
- `specs/001-combatant-management/` — CRUD, persistence, clear, batch add, confirm buttons
|
||||
- `specs/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar
|
||||
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
||||
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
|
||||
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
|
||||
## Constitution
|
||||
|
||||
## Constitution (key principles)
|
||||
|
||||
The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
||||
|
||||
1. **Deterministic Domain Core** — Pure state transitions only; no I/O, randomness, or clocks in domain.
|
||||
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
|
||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
||||
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
|
||||
|
||||
## Active Technologies
|
||||
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac (005-player-characters)
|
||||
- localStorage (new key `"initiative:player-characters"`) (005-player-characters)
|
||||
|
||||
## Recent Changes
|
||||
- 005-player-characters: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac
|
||||
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.
|
||||
|
||||
83
README.md
83
README.md
@@ -1,4 +1,4 @@
|
||||
# Encounter Console
|
||||
# Initiative
|
||||
|
||||
A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e / 2024). Runs entirely in the browser — no server, no account, no data leaves your machine.
|
||||
|
||||
@@ -7,7 +7,10 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
|
||||
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
|
||||
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
||||
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
||||
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||
- **Player characters** — create reusable player character templates with name, AC, HP, level, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||
- **Encounter difficulty** — live 3-bar indicator in the top bar showing encounter difficulty (Trivial/Low/Moderate/High) based on the 2024 5.5e XP budget system; automatically derived from PC levels and bestiary creature CRs
|
||||
- **Undo/redo** — reverse any encounter action with Undo/Redo buttons or keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac); history persists across page reloads
|
||||
- **Import/export** — export the full encounter state (combatants, undo/redo history, player characters) as a JSON file or copy to clipboard; import from file upload or pasted JSON with validation and confirmation
|
||||
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
||||
|
||||
## Prerequisites
|
||||
@@ -31,16 +34,42 @@ Open `http://localhost:5173`.
|
||||
| `pnpm --filter web dev` | Start the dev server |
|
||||
| `pnpm --filter web build` | Production build |
|
||||
| `pnpm test` | Run all tests (Vitest) |
|
||||
| `pnpm check` | Full merge gate (knip, biome, typecheck, test, jscpd) |
|
||||
| `pnpm test:watch` | Tests in watch mode |
|
||||
| `pnpm vitest run path/to/test.ts` | Run a single test file |
|
||||
| `pnpm typecheck` | TypeScript type checking |
|
||||
| `pnpm lint` | Biome lint |
|
||||
| `pnpm format` | Biome format (writes changes) |
|
||||
| `pnpm check` | Full merge gate (see below) |
|
||||
|
||||
### Merge gate (`pnpm check`)
|
||||
|
||||
All of these run at pre-commit via Lefthook (in parallel where possible):
|
||||
|
||||
- `pnpm audit` — security audit
|
||||
- `knip` — unused code detection
|
||||
- `biome check` — formatting + linting
|
||||
- `oxlint` — type-aware linting (complements Biome)
|
||||
- Custom scripts — lint-ignore caps, className enforcement, component prop limits
|
||||
- `tsc --build` — TypeScript strict mode
|
||||
- `vitest run` — tests with per-path coverage thresholds
|
||||
- `jscpd` + `jsinspect` — copy-paste and structural duplication detection
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- TypeScript 5.8 (strict mode), React 19, Vite 6
|
||||
- Tailwind CSS v4 (dark/light theme)
|
||||
- Biome 2.4 (formatting + linting), oxlint (type-aware linting)
|
||||
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||
- Knip (unused code), jscpd + jsinspect (duplication detection)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/web/ React 19 + Vite — UI components, hooks, adapters
|
||||
packages/domain/ Pure functions — state transitions, types, validation
|
||||
packages/app/ Use cases — orchestrates domain via port interfaces
|
||||
data/bestiary/ Bestiary index for creature search
|
||||
scripts/ Build tooling (layer boundary checks, index generation)
|
||||
packages/application/ Use cases — orchestrates domain via port interfaces
|
||||
data/bestiary/ Pre-built bestiary search index (~10k creatures)
|
||||
scripts/ Build tooling (layer checks, index generation)
|
||||
specs/ Feature specifications (spec → plan → tasks)
|
||||
```
|
||||
|
||||
@@ -52,5 +81,45 @@ Strict layered architecture with enforced dependency direction:
|
||||
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
|
||||
```
|
||||
|
||||
Domain is pure — no I/O, no randomness, no framework imports. Layer boundaries are enforced by automated import checks that run as part of the test suite. See [CLAUDE.md](./CLAUDE.md) for full conventions.
|
||||
- **Domain** — pure functions, no I/O, no randomness, no framework imports. Errors returned as values (`DomainError`), never thrown.
|
||||
- **Application** — orchestrates domain calls via port interfaces (`EncounterStore`, `PlayerCharacterStore`, etc.). No business logic.
|
||||
- **Web** — React adapter. Implements ports using hooks/state. All UI components, persistence, and external data access live here.
|
||||
|
||||
Layer boundaries are enforced by automated import checks that run as part of the test suite.
|
||||
|
||||
## Contributing
|
||||
|
||||
### Workflow
|
||||
|
||||
Development is spec-driven. Feature specs live in `specs/NNN-feature-name/` and are managed through Claude Code skills (see [CLAUDE.md](./CLAUDE.md) for full details).
|
||||
|
||||
| Scope | What to do |
|
||||
|-------|-----------|
|
||||
| Bug fix / CSS tweak | Fix it, run `pnpm check`, commit. Optionally use `/browser-interactive-testing` for visual verification. |
|
||||
| Change to existing feature | Update the feature spec, then implement |
|
||||
| Larger change to existing feature | Update the spec → `/rpi-research` → `/rpi-plan` → `/rpi-implement` |
|
||||
| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` |
|
||||
|
||||
Use `/write-issue` to create well-structured Gitea issues, and `/integrate-issue` to pull an existing issue's requirements into the relevant feature spec.
|
||||
|
||||
### Before committing
|
||||
|
||||
Run `pnpm check` — Lefthook runs this automatically at pre-commit, but running it manually first saves time. All checks must pass.
|
||||
|
||||
### Conventions
|
||||
|
||||
- **Biome** for formatting and linting — tab indentation, 80-char lines
|
||||
- **TypeScript strict mode** with `verbatimModuleSyntax` (type-only imports must use `import type`)
|
||||
- **Max 8 props** per component interface — use React context for shared state
|
||||
- **Tests** in `__tests__/` directories — test pure functions directly, use `renderHook` for hooks
|
||||
|
||||
See [CLAUDE.md](./CLAUDE.md) for the full conventions and project constitution.
|
||||
|
||||
## Bestiary Index
|
||||
|
||||
The bestiary search index (`data/bestiary/index.json`) is pre-built and checked into the repo. To regenerate it (e.g., after a new source book release):
|
||||
|
||||
1. Clone [5etools-mirror-3/5etools-src](https://github.com/5etools-mirror-3/5etools-src) locally
|
||||
2. Run `node scripts/generate-bestiary-index.mjs /path/to/5etools-src`
|
||||
|
||||
The script extracts creature names, stats, and source info into a compact search index.
|
||||
|
||||
@@ -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,15 +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",
|
||||
"jsdom": "^28.1.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.1"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/web/public/apple-touch-icon.png
Normal file
BIN
apps/web/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
21
apps/web/public/favicon.svg
Normal file
21
apps/web/public/favicon.svg
Normal file
@@ -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 |
BIN
apps/web/public/icon-192.png
Normal file
BIN
apps/web/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
31
apps/web/public/icon.svg
Normal file
31
apps/web/public/icon.svg
Normal file
@@ -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 |
21
apps/web/public/manifest.json
Normal file
21
apps/web/public/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,249 +1,86 @@
|
||||
import {
|
||||
rollAllInitiativeUseCase,
|
||||
rollInitiativeUseCase,
|
||||
} from "@initiative/application";
|
||||
import {
|
||||
type CombatantId,
|
||||
type Creature,
|
||||
type CreatureId,
|
||||
isDomainError,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ActionBar } from "./components/action-bar";
|
||||
import { BulkImportToasts } from "./components/bulk-import-toasts";
|
||||
import { CombatantRow } from "./components/combatant-row";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActionBar } from "./components/action-bar.js";
|
||||
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
|
||||
import { CombatantRow } from "./components/combatant-row.js";
|
||||
import {
|
||||
PlayerCharacterSection,
|
||||
type PlayerCharacterSectionHandle,
|
||||
} from "./components/player-character-section";
|
||||
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";
|
||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
||||
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
||||
|
||||
function rollDice(): number {
|
||||
return Math.floor(Math.random() * 20) + 1;
|
||||
}
|
||||
|
||||
function useActionBarAnimation(combatantCount: number) {
|
||||
const wasEmptyRef = useRef(combatantCount === 0);
|
||||
const [settling, setSettling] = useState(false);
|
||||
const [rising, setRising] = useState(false);
|
||||
const [topBarExiting, setTopBarExiting] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const nowEmpty = combatantCount === 0;
|
||||
if (wasEmptyRef.current && !nowEmpty) {
|
||||
setSettling(true);
|
||||
} else if (!wasEmptyRef.current && nowEmpty) {
|
||||
setRising(true);
|
||||
setTopBarExiting(true);
|
||||
}
|
||||
wasEmptyRef.current = nowEmpty;
|
||||
}, [combatantCount]);
|
||||
|
||||
const empty = combatantCount === 0;
|
||||
const risingClass = rising ? " animate-rise-to-center" : "";
|
||||
const settlingClass = settling ? " animate-settle-to-bottom" : "";
|
||||
const exitingClass = topBarExiting
|
||||
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
||||
: "";
|
||||
const topBarClass = settling ? " animate-slide-down-in" : exitingClass;
|
||||
const showTopBar = !empty || topBarExiting;
|
||||
|
||||
return {
|
||||
risingClass,
|
||||
settlingClass,
|
||||
topBarClass,
|
||||
showTopBar,
|
||||
onSettleEnd: () => setSettling(false),
|
||||
onRiseEnd: () => setRising(false),
|
||||
onTopBarExitEnd: () => setTopBarExiting(false),
|
||||
};
|
||||
}
|
||||
} 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,
|
||||
isEmpty,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
clearEncounter,
|
||||
removeCombatant,
|
||||
editCombatant,
|
||||
setInitiative,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
makeStore,
|
||||
} = useEncounter();
|
||||
const { encounter, isEmpty } = useEncounterContext();
|
||||
const sidePanel = useSidePanelContext();
|
||||
const rolls = useInitiativeRollsContext();
|
||||
|
||||
const {
|
||||
characters: playerCharacters,
|
||||
createCharacter: createPlayerCharacter,
|
||||
editCharacter: editPlayerCharacter,
|
||||
deleteCharacter: deletePlayerCharacter,
|
||||
} = usePlayerCharacters();
|
||||
useAutoStatBlock();
|
||||
|
||||
const {
|
||||
search,
|
||||
getCreature,
|
||||
isLoaded,
|
||||
isSourceCached,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
} = useBestiary();
|
||||
|
||||
const bulkImport = useBulkImport();
|
||||
const sidePanel = useSidePanelState();
|
||||
|
||||
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||
|
||||
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
|
||||
? (getCreature(sidePanel.selectedCreatureId) ?? null)
|
||||
: null;
|
||||
|
||||
const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
|
||||
? (getCreature(sidePanel.pinnedCreatureId) ?? null)
|
||||
: null;
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
addFromBestiary(result);
|
||||
},
|
||||
[addFromBestiary],
|
||||
);
|
||||
|
||||
const handleCombatantStatBlock = useCallback(
|
||||
(creatureId: string) => {
|
||||
sidePanel.showCreature(creatureId as CreatureId);
|
||||
},
|
||||
[sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleRollInitiative = useCallback(
|
||||
(id: CombatantId) => {
|
||||
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
|
||||
},
|
||||
[makeStore, getCreature],
|
||||
);
|
||||
|
||||
const handleRollAllInitiative = useCallback(() => {
|
||||
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||
setRollSkippedCount(result.skippedNoSource);
|
||||
}
|
||||
}, [makeStore, getCreature]);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
sidePanel.showCreature(cId);
|
||||
},
|
||||
[sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleStartBulkImport = useCallback(
|
||||
(baseUrl: string) => {
|
||||
bulkImport.startImport(
|
||||
baseUrl,
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
},
|
||||
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
|
||||
);
|
||||
|
||||
const handleBulkImportDone = useCallback(() => {
|
||||
sidePanel.dismissPanel();
|
||||
bulkImport.reset();
|
||||
}, [sidePanel.dismissPanel, bulkImport.reset]);
|
||||
|
||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||
|
||||
// Auto-scroll to the active combatant when the turn changes
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
// Close the side panel when the encounter becomes empty
|
||||
useEffect(() => {
|
||||
if (isEmpty) {
|
||||
sidePanel.dismissPanel();
|
||||
}
|
||||
}, [isEmpty, sidePanel.dismissPanel]);
|
||||
|
||||
// Auto-scroll to active combatant when turn changes
|
||||
const activeIndex = encounter.activeIndex;
|
||||
useEffect(() => {
|
||||
if (activeIndex >= 0) {
|
||||
activeRowRef.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col">
|
||||
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
|
||||
<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={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
|
||||
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
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={advanceTurn}
|
||||
onRetreatTurn={retreatTurn}
|
||||
onClearEncounter={clearEncounter}
|
||||
/>
|
||||
<TurnNavigation />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
/* Empty state — ActionBar centered */
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
||||
<div
|
||||
className={`w-full${actionBarAnim.risingClass}`}
|
||||
className={cn("w-full", actionBarAnim.risingClass)}
|
||||
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||
>
|
||||
<ActionBar
|
||||
onAddCombatant={addCombatant}
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={sidePanel.showBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
inputRef={actionBarInputRef}
|
||||
playerCharacters={playerCharacters}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
onManagePlayers={() =>
|
||||
playerCharacterRef.current?.openManagement()
|
||||
}
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
showRollAllInitiative={hasCreatureCombatants}
|
||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||
onOpenSourceManager={sidePanel.showSourceManager}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Scrollable area — combatant list */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col px-2 py-2">
|
||||
{encounter.combatants.map((c, i) => (
|
||||
@@ -252,120 +89,59 @@ export function App() {
|
||||
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
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Bar — fixed at bottom */}
|
||||
<div
|
||||
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
|
||||
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
|
||||
onAddCombatant={addCombatant}
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={sidePanel.showBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
inputRef={actionBarInputRef}
|
||||
playerCharacters={playerCharacters}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
onManagePlayers={() =>
|
||||
playerCharacterRef.current?.openManagement()
|
||||
}
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
showRollAllInitiative={hasCreatureCombatants}
|
||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||
onOpenSourceManager={sidePanel.showSourceManager}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pinned Stat Block Panel (left) */}
|
||||
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||
<StatBlockPanel
|
||||
creatureId={sidePanel.pinnedCreatureId}
|
||||
creature={pinnedCreature}
|
||||
isSourceCached={isSourceCached}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
uploadAndCacheSource={uploadAndCacheSource}
|
||||
refreshCache={refreshCache}
|
||||
panelRole="pinned"
|
||||
isCollapsed={false}
|
||||
onToggleCollapse={() => {}}
|
||||
onPin={() => {}}
|
||||
onUnpin={sidePanel.unpin}
|
||||
showPinButton={false}
|
||||
side="left"
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
<StatBlockPanel panelRole="pinned" side="left" />
|
||||
)}
|
||||
|
||||
{/* Browse Stat Block Panel (right) */}
|
||||
<StatBlockPanel
|
||||
creatureId={sidePanel.selectedCreatureId}
|
||||
creature={selectedCreature}
|
||||
isSourceCached={isSourceCached}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
uploadAndCacheSource={uploadAndCacheSource}
|
||||
refreshCache={refreshCache}
|
||||
panelRole="browse"
|
||||
isCollapsed={sidePanel.isRightPanelCollapsed}
|
||||
onToggleCollapse={sidePanel.toggleCollapse}
|
||||
onPin={sidePanel.togglePin}
|
||||
onUnpin={() => {}}
|
||||
showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
|
||||
side="right"
|
||||
onDismiss={sidePanel.dismissPanel}
|
||||
bulkImportMode={sidePanel.bulkImportMode}
|
||||
bulkImportState={bulkImport.state}
|
||||
onStartBulkImport={handleStartBulkImport}
|
||||
onBulkImportDone={handleBulkImportDone}
|
||||
sourceManagerMode={sidePanel.sourceManagerMode}
|
||||
/>
|
||||
<StatBlockPanel panelRole="browse" side="right" />
|
||||
|
||||
<BulkImportToasts
|
||||
state={bulkImport.state}
|
||||
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
|
||||
onReset={bulkImport.reset}
|
||||
/>
|
||||
<BulkImportToasts />
|
||||
|
||||
{rollSkippedCount > 0 && (
|
||||
{rolls.rollSkippedCount > 0 && (
|
||||
<Toast
|
||||
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
||||
onDismiss={() => setRollSkippedCount(0)}
|
||||
message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`}
|
||||
onDismiss={rolls.dismissRollSkipped}
|
||||
autoDismissMs={4000}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PlayerCharacterSection
|
||||
ref={playerCharacterRef}
|
||||
characters={playerCharacters}
|
||||
onCreateCharacter={createPlayerCharacter}
|
||||
onEditCharacter={editPlayerCharacter}
|
||||
onDeleteCharacter={deletePlayerCharacter}
|
||||
{!!rolls.rollSingleSkipped && (
|
||||
<Toast
|
||||
message="Can't roll — bestiary source not loaded"
|
||||
onDismiss={rolls.dismissRollSingleSkipped}
|
||||
autoDismissMs={4000}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
<PlayerCharacterSection ref={playerCharacterRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ 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";
|
||||
import { App } from "../App.js";
|
||||
import { AllProviders } from "./test-providers.js";
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../persistence/encounter-storage.js", () => ({
|
||||
@@ -76,7 +77,7 @@ async function addCombatant(
|
||||
describe("App integration", () => {
|
||||
it("adds a combatant and removes it, returning to empty state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
render(<App />, { wrapper: AllProviders });
|
||||
|
||||
// Empty state: centered input visible, no TurnNavigation
|
||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||
@@ -109,7 +110,7 @@ describe("App integration", () => {
|
||||
|
||||
it("advances and retreats turns across two combatants", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
render(<App />, { wrapper: AllProviders });
|
||||
|
||||
await addCombatant(user, "Fighter");
|
||||
await addCombatant(user, "Wizard");
|
||||
@@ -137,12 +138,11 @@ describe("App integration", () => {
|
||||
|
||||
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
render(<App />, { wrapper: AllProviders });
|
||||
|
||||
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||
|
||||
// Verify HP displays — currentHp and maxHp both show "59"
|
||||
expect(screen.getByText("/")).toBeInTheDocument();
|
||||
const hpButton = screen.getByRole("button", {
|
||||
name: "Current HP: 59 (healthy)",
|
||||
});
|
||||
|
||||
233
apps/web/src/__tests__/export-import.test.ts
Normal file
233
apps/web/src/__tests__/export-import.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
16
apps/web/src/__tests__/polyfill-dialog.ts
Normal file
16
apps/web/src/__tests__/polyfill-dialog.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* jsdom doesn't implement HTMLDialogElement.showModal/close.
|
||||
* Call this in beforeAll() for tests that render <Dialog>.
|
||||
*/
|
||||
export function polyfillDialog(): void {
|
||||
if (typeof HTMLDialogElement.prototype.showModal !== "function") {
|
||||
HTMLDialogElement.prototype.showModal = function showModal() {
|
||||
this.setAttribute("open", "");
|
||||
};
|
||||
}
|
||||
if (typeof HTMLDialogElement.prototype.close !== "function") {
|
||||
HTMLDialogElement.prototype.close = function close() {
|
||||
this.removeAttribute("open");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,33 @@ 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";
|
||||
import { StatBlockPanel } from "../components/stat-block-panel";
|
||||
|
||||
// Mock the context modules
|
||||
vi.mock("../contexts/side-panel-context.js", () => ({
|
||||
useSidePanelContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock adapters to avoid IndexedDB
|
||||
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
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,
|
||||
@@ -44,41 +66,65 @@ function mockMatchMedia(matches: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
interface PanelOverrides {
|
||||
creatureId?: CreatureId | null;
|
||||
creature?: Creature | null;
|
||||
panelRole?: "browse" | "pinned";
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onPin?: () => void;
|
||||
onUnpin?: () => void;
|
||||
showPinButton?: boolean;
|
||||
side?: "left" | "right";
|
||||
onDismiss?: () => void;
|
||||
bulkImportMode?: boolean;
|
||||
}
|
||||
|
||||
function renderPanel(overrides: PanelProps = {}) {
|
||||
const props = {
|
||||
creatureId: CREATURE_ID,
|
||||
creature: CREATURE,
|
||||
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(),
|
||||
panelRole: "browse" as const,
|
||||
isCollapsed: false,
|
||||
onToggleCollapse: vi.fn(),
|
||||
onPin: vi.fn(),
|
||||
onUnpin: vi.fn(),
|
||||
showPinButton: false,
|
||||
side: "right" as const,
|
||||
onDismiss: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
|
||||
render(<StatBlockPanel {...props} />);
|
||||
return props;
|
||||
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", () => {
|
||||
@@ -113,19 +159,19 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("calls onToggleCollapse when collapse button is clicked", () => {
|
||||
const props = renderPanel();
|
||||
const callbacks = renderPanel();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||
);
|
||||
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
||||
const props = renderPanel({ isCollapsed: true });
|
||||
const callbacks = renderPanel({ isCollapsed: true });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||
);
|
||||
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies translate-x class when collapsed (right side)", () => {
|
||||
@@ -163,53 +209,58 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("calls onDismiss when backdrop is clicked on mobile", () => {
|
||||
const props = renderPanel();
|
||||
const callbacks = renderPanel();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
|
||||
expect(props.onDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not render pinned panel on mobile", () => {
|
||||
const { container } = render(
|
||||
<StatBlockPanel
|
||||
creatureId={CREATURE_ID}
|
||||
creature={CREATURE}
|
||||
isSourceCached={vi.fn().mockResolvedValue(true)}
|
||||
fetchAndCacheSource={vi.fn()}
|
||||
uploadAndCacheSource={vi.fn()}
|
||||
refreshCache={vi.fn()}
|
||||
panelRole="pinned"
|
||||
isCollapsed={false}
|
||||
onToggleCollapse={vi.fn()}
|
||||
onPin={vi.fn()}
|
||||
onUnpin={vi.fn()}
|
||||
showPinButton={false}
|
||||
side="left"
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
(() => {
|
||||
setupMocks({ panelRole: "pinned" });
|
||||
return <StatBlockPanel panelRole="pinned" side="left" />;
|
||||
})(),
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("US2: Pin and Unpin", () => {
|
||||
it("shows pin button when showPinButton is true on desktop", () => {
|
||||
renderPanel({ showPinButton: true });
|
||||
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 showPinButton is false", () => {
|
||||
renderPanel({ showPinButton: false });
|
||||
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", () => {
|
||||
const props = renderPanel({ showPinButton: true });
|
||||
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(props.onPin).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.togglePin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows unpin button for pinned role", () => {
|
||||
@@ -220,9 +271,9 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("calls onUnpin when unpin button is clicked", () => {
|
||||
const props = renderPanel({ panelRole: "pinned", side: "left" });
|
||||
const callbacks = renderPanel({ panelRole: "pinned", side: "left" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
|
||||
expect(props.onUnpin).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onUnpin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("positions pinned panel on the left side", () => {
|
||||
@@ -255,7 +306,7 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("pinned panel is always expanded (no translate offset)", () => {
|
||||
renderPanel({ panelRole: "pinned", side: "left", isCollapsed: false });
|
||||
renderPanel({ panelRole: "pinned", side: "left" });
|
||||
const unpinBtn = screen.getByRole("button", {
|
||||
name: "Unpin creature",
|
||||
});
|
||||
|
||||
31
apps/web/src/__tests__/test-providers.tsx
Normal file
31
apps/web/src/__tests__/test-providers.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
BestiaryProvider,
|
||||
BulkImportProvider,
|
||||
EncounterProvider,
|
||||
InitiativeRollsProvider,
|
||||
PlayerCharactersProvider,
|
||||
RulesEditionProvider,
|
||||
SidePanelProvider,
|
||||
ThemeProvider,
|
||||
} from "../contexts/index.js";
|
||||
|
||||
export function AllProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<RulesEditionProvider>
|
||||
<EncounterProvider>
|
||||
<BestiaryProvider>
|
||||
<PlayerCharactersProvider>
|
||||
<BulkImportProvider>
|
||||
<SidePanelProvider>
|
||||
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
|
||||
</SidePanelProvider>
|
||||
</BulkImportProvider>
|
||||
</PlayerCharactersProvider>
|
||||
</BestiaryProvider>
|
||||
</EncounterProvider>
|
||||
</RulesEditionProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
249
apps/web/src/__tests__/validate-import-bundle.test.ts
Normal file
249
apps/web/src/__tests__/validate-import-bundle.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import type { ExportBundle } from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateImportBundle } from "../persistence/export-import.js";
|
||||
|
||||
function validBundle(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: "2026-03-27T12:00:00.000Z",
|
||||
encounter: {
|
||||
combatants: [{ id: "c-1", name: "Goblin", initiative: 15 }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
playerCharacters: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("validateImportBundle", () => {
|
||||
it("accepts a valid bundle", () => {
|
||||
const result = validateImportBundle(validBundle());
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.version).toBe(1);
|
||||
expect(bundle.encounter.combatants).toHaveLength(1);
|
||||
expect(bundle.encounter.combatants[0].name).toBe("Goblin");
|
||||
});
|
||||
|
||||
it("accepts a valid bundle with empty encounter", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.encounter.combatants).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts a bundle with undo/redo stacks", () => {
|
||||
const enc = {
|
||||
combatants: [{ id: "c-1", name: "Orc" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const input = {
|
||||
...validBundle(),
|
||||
undoStack: [enc],
|
||||
redoStack: [enc],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.undoStack).toHaveLength(1);
|
||||
expect(bundle.redoStack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accepts a bundle with player characters", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(1);
|
||||
expect(bundle.playerCharacters[0].name).toBe("Aria");
|
||||
});
|
||||
|
||||
it("rejects non-object input", () => {
|
||||
expect(validateImportBundle(null)).toBe("Invalid file format");
|
||||
expect(validateImportBundle(42)).toBe("Invalid file format");
|
||||
expect(validateImportBundle("string")).toBe("Invalid file format");
|
||||
expect(validateImportBundle([])).toBe("Invalid file format");
|
||||
expect(validateImportBundle(undefined)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing version field", () => {
|
||||
const input = validBundle();
|
||||
delete input.version;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects version 0 or negative", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), version: 0 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
expect(validateImportBundle({ ...validBundle(), version: -1 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown version", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), version: 99 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing encounter field", () => {
|
||||
const input = validBundle();
|
||||
delete input.encounter;
|
||||
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("rejects invalid encounter data", () => {
|
||||
expect(
|
||||
validateImportBundle({ ...validBundle(), encounter: "not an object" }),
|
||||
).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("rejects missing undoStack", () => {
|
||||
const input = validBundle();
|
||||
delete input.undoStack;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing redoStack", () => {
|
||||
const input = validBundle();
|
||||
delete input.redoStack;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing playerCharacters", () => {
|
||||
const input = validBundle();
|
||||
delete input.playerCharacters;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects non-string exportedAt", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), exportedAt: 12345 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops invalid entries from undo stack", () => {
|
||||
const valid = {
|
||||
combatants: [{ id: "c-1", name: "Orc" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const input = {
|
||||
...validBundle(),
|
||||
undoStack: [valid, "invalid", { bad: true }, valid],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.undoStack).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops invalid player characters", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{ id: "pc-1", name: "Valid", ac: 10, maxHp: 20 },
|
||||
{ id: "", name: "Bad ID" },
|
||||
"not an object",
|
||||
{ id: "pc-3", name: "Also Valid", ac: 15, maxHp: 30 },
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects JSON array instead of object", () => {
|
||||
expect(validateImportBundle([1, 2, 3])).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects encounter that fails rehydration (missing combatant fields)", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
encounter: {
|
||||
combatants: [{ noId: true }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
};
|
||||
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("strips invalid color/icon from player characters but keeps the character", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 20,
|
||||
color: "neon-pink",
|
||||
icon: "bazooka",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
// rehydrateCharacter rejects characters with invalid color/icon members
|
||||
// that are not in the valid sets, so this character is dropped
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps player characters with valid optional color and icon", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(1);
|
||||
expect(bundle.playerCharacters[0].color).toBe("blue");
|
||||
expect(bundle.playerCharacters[0].icon).toBe("sword");
|
||||
});
|
||||
|
||||
it("ignores unknown extra fields on the bundle", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
unknownField: "should be ignored",
|
||||
anotherExtra: 42,
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.version).toBe(1);
|
||||
expect("unknownField" in bundle).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -300,6 +300,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(bite?.text).toContain("Melee Weapon Attack:");
|
||||
expect(bite?.text).not.toContain("mw");
|
||||
expect(bite?.text).not.toContain("{@");
|
||||
});
|
||||
|
||||
it("handles fly speed with hover condition", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type IDBPDatabase, openDB } from "idb";
|
||||
|
||||
const DB_NAME = "initiative-bestiary";
|
||||
const STORE_NAME = "sources";
|
||||
const DB_VERSION = 1;
|
||||
const DB_VERSION = 2;
|
||||
|
||||
export interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
@@ -32,12 +32,16 @@ async function getDb(): Promise<IDBPDatabase | 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 < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
||||
// Clear cached creatures to pick up improved tag processing
|
||||
void transaction.objectStore(STORE_NAME).clear();
|
||||
}
|
||||
},
|
||||
});
|
||||
return db;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -51,11 +60,16 @@ export function stripTags(text: string): string {
|
||||
// {@hit N} → "+N"
|
||||
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||
|
||||
// {@atkr type} → mapped attack roll text
|
||||
// {@atkr type} → mapped attack roll text (2024 rules)
|
||||
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
|
||||
});
|
||||
|
||||
// {@atk type} → mapped attack type text (pre-2024 data)
|
||||
result = result.replaceAll(/\{@atk\s+([^}]+)\}/g, (_, type: string) => {
|
||||
return ATK_MAP[type.trim()] ?? "Attack:";
|
||||
});
|
||||
|
||||
// {@actSave ability} → "Ability saving throw"
|
||||
result = result.replaceAll(
|
||||
/\{@actSave\s+([^}]+)\}/g,
|
||||
|
||||
@@ -3,21 +3,61 @@ 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 { ActionBar } from "../action-bar";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { ActionBar } from "../action-bar.js";
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
// Mock bestiary — no IndexedDB or JSON index
|
||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||
isSourceCached: () => Promise.resolve(false),
|
||||
cacheSource: () => Promise.resolve(),
|
||||
getCachedSources: () => Promise.resolve([]),
|
||||
clearSource: () => Promise.resolve(),
|
||||
clearAll: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
// DOM API stubs — jsdom doesn't implement these
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
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);
|
||||
|
||||
const defaultProps = {
|
||||
onAddCombatant: vi.fn(),
|
||||
onAddFromBestiary: vi.fn(),
|
||||
bestiarySearch: () => [],
|
||||
bestiaryLoaded: false,
|
||||
};
|
||||
|
||||
function renderBar(overrides: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
||||
const props = { ...defaultProps, ...overrides };
|
||||
return render(<ActionBar {...props} />);
|
||||
function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
||||
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
||||
}
|
||||
|
||||
describe("ActionBar", () => {
|
||||
@@ -26,26 +66,26 @@ describe("ActionBar", () => {
|
||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submitting with a name calls onAddCombatant", async () => {
|
||||
it("submitting with a name adds a combatant", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddCombatant = vi.fn();
|
||||
renderBar({ onAddCombatant });
|
||||
renderBar();
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "Goblin");
|
||||
// The Add button appears when name >= 2 chars and no suggestions
|
||||
const addButton = screen.getByRole("button", { name: "Add" });
|
||||
await user.click(addButton);
|
||||
expect(onAddCombatant).toHaveBeenCalledWith("Goblin", undefined);
|
||||
// Input is cleared after adding (context handles the state)
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("submitting with empty name does nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddCombatant = vi.fn();
|
||||
renderBar({ onAddCombatant });
|
||||
renderBar();
|
||||
// Submit the form directly (Enter on empty input)
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "{Enter}");
|
||||
expect(onAddCombatant).not.toHaveBeenCalled();
|
||||
// Input stays empty, no error
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => {
|
||||
@@ -66,23 +106,75 @@ describe("ActionBar", () => {
|
||||
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows roll all initiative button when showRollAllInitiative is true", () => {
|
||||
const onRollAllInitiative = vi.fn();
|
||||
renderBar({ showRollAllInitiative: true, onRollAllInitiative });
|
||||
it("does not show roll all initiative button when no creature combatants", () => {
|
||||
renderBar();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Roll all initiative" }),
|
||||
screen.queryByRole("button", { name: "Roll all initiative" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows overflow menu items", () => {
|
||||
renderBar({ onManagePlayers: vi.fn() });
|
||||
// The overflow menu should be present (it contains Player Characters etc.)
|
||||
expect(
|
||||
screen.getByRole("button", { name: "More actions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("roll all initiative button is disabled when rollAllInitiativeDisabled is true", () => {
|
||||
const onRollAllInitiative = vi.fn();
|
||||
renderBar({
|
||||
showRollAllInitiative: true,
|
||||
onRollAllInitiative,
|
||||
rollAllInitiativeDisabled: true,
|
||||
});
|
||||
it("opens export method dialog via overflow menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBar();
|
||||
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||
// Click the menu item
|
||||
const items = screen.getAllByText("Export Encounter");
|
||||
await user.click(items[0]);
|
||||
// Dialog should now be open — it renders a second "Export Encounter" as heading
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Roll all initiative" }),
|
||||
).toBeDisabled();
|
||||
screen.getAllByText("Export Encounter").length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("opens import method dialog via overflow menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBar();
|
||||
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||
const items = screen.getAllByText("Import Encounter");
|
||||
await user.click(items[0]);
|
||||
expect(
|
||||
screen.getAllByText("Import Encounter").length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("calls onManagePlayers from overflow menu", async () => {
|
||||
const onManagePlayers = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderBar({ onManagePlayers });
|
||||
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||
await user.click(screen.getByText("Player Characters"));
|
||||
expect(onManagePlayers).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onOpenSettings from overflow menu", async () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderBar({ onOpenSettings });
|
||||
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||
await user.click(screen.getByText("Settings"));
|
||||
expect(onOpenSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("submits custom stats with combatant", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBar();
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "Fighter");
|
||||
const initInput = screen.getByPlaceholderText("Init");
|
||||
const acInput = screen.getByPlaceholderText("AC");
|
||||
const hpInput = screen.getByPlaceholderText("MaxHP");
|
||||
await user.type(initInput, "15");
|
||||
await user.type(acInput, "18");
|
||||
await user.type(hpInput, "45");
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
146
apps/web/src/components/__tests__/bulk-import-prompt.test.tsx
Normal file
146
apps/web/src/components/__tests__/bulk-import-prompt.test.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { BulkImportPrompt } from "../bulk-import-prompt.js";
|
||||
|
||||
const THREE_SOURCES_REGEX = /3 sources/;
|
||||
const GITHUB_URL_REGEX = /raw\.githubusercontent/;
|
||||
const LOADING_PROGRESS_REGEX = /Loading sources\.\.\. 4\/10/;
|
||||
const SEVEN_OF_TEN_REGEX = /7\/10 sources/;
|
||||
const THREE_FAILED_REGEX = /3 failed/;
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const mockFetchAndCacheSource = vi.fn();
|
||||
const mockIsSourceCached = vi.fn().mockResolvedValue(false);
|
||||
const mockRefreshCache = vi.fn();
|
||||
const mockStartImport = vi.fn();
|
||||
const mockReset = vi.fn();
|
||||
const mockDismissPanel = vi.fn();
|
||||
|
||||
let mockImportState = {
|
||||
status: "idle" as string,
|
||||
total: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: () => ({
|
||||
fetchAndCacheSource: mockFetchAndCacheSource,
|
||||
isSourceCached: mockIsSourceCached,
|
||||
refreshCache: mockRefreshCache,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bulk-import-context.js", () => ({
|
||||
useBulkImportContext: () => ({
|
||||
state: mockImportState,
|
||||
startImport: mockStartImport,
|
||||
reset: mockReset,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/side-panel-context.js", () => ({
|
||||
useSidePanelContext: () => ({
|
||||
dismissPanel: mockDismissPanel,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
}));
|
||||
|
||||
describe("BulkImportPrompt", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockImportState = { status: "idle", total: 0, completed: 0, failed: 0 };
|
||||
});
|
||||
|
||||
it("idle: shows base URL input, source count, Load All button", () => {
|
||||
render(<BulkImportPrompt />);
|
||||
expect(screen.getByText(THREE_SOURCES_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue(GITHUB_URL_REGEX)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Load All" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("idle: clearing URL disables the button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BulkImportPrompt />);
|
||||
|
||||
const input = screen.getByDisplayValue(GITHUB_URL_REGEX);
|
||||
await user.clear(input);
|
||||
expect(screen.getByRole("button", { name: "Load All" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("idle: clicking Load All calls startImport with URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BulkImportPrompt />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Load All" }));
|
||||
expect(mockStartImport).toHaveBeenCalledWith(
|
||||
expect.stringContaining("raw.githubusercontent"),
|
||||
mockFetchAndCacheSource,
|
||||
mockIsSourceCached,
|
||||
mockRefreshCache,
|
||||
);
|
||||
});
|
||||
|
||||
it("loading: shows progress text and progress bar", () => {
|
||||
mockImportState = {
|
||||
status: "loading",
|
||||
total: 10,
|
||||
completed: 3,
|
||||
failed: 1,
|
||||
};
|
||||
render(<BulkImportPrompt />);
|
||||
expect(screen.getByText(LOADING_PROGRESS_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("complete: shows success message and Done button", () => {
|
||||
mockImportState = {
|
||||
status: "complete",
|
||||
total: 10,
|
||||
completed: 10,
|
||||
failed: 0,
|
||||
};
|
||||
render(<BulkImportPrompt />);
|
||||
expect(screen.getByText("All sources loaded")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("complete: Done calls dismissPanel and reset", async () => {
|
||||
mockImportState = {
|
||||
status: "complete",
|
||||
total: 10,
|
||||
completed: 10,
|
||||
failed: 0,
|
||||
};
|
||||
const user = userEvent.setup();
|
||||
render(<BulkImportPrompt />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Done" }));
|
||||
expect(mockDismissPanel).toHaveBeenCalled();
|
||||
expect(mockReset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("partial-failure: shows loaded/failed counts", () => {
|
||||
mockImportState = {
|
||||
status: "partial-failure",
|
||||
total: 10,
|
||||
completed: 7,
|
||||
failed: 3,
|
||||
};
|
||||
render(<BulkImportPrompt />);
|
||||
expect(screen.getByText(SEVEN_OF_TEN_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(THREE_FAILED_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
56
apps/web/src/components/__tests__/color-palette.test.tsx
Normal file
56
apps/web/src/components/__tests__/color-palette.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { VALID_PLAYER_COLORS } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
import { ColorPalette } from "../color-palette.js";
|
||||
|
||||
describe("ColorPalette", () => {
|
||||
it("renders a button for each valid color", () => {
|
||||
render(<ColorPalette value="" onChange={() => {}} />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(VALID_PLAYER_COLORS.size);
|
||||
});
|
||||
|
||||
it("each button has an aria-label matching the color name", () => {
|
||||
render(<ColorPalette value="" onChange={() => {}} />);
|
||||
for (const color of VALID_PLAYER_COLORS) {
|
||||
expect(screen.getByRole("button", { name: color })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("clicking a color calls onChange with that color", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<ColorPalette value="" onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "blue" }));
|
||||
expect(onChange).toHaveBeenCalledWith("blue");
|
||||
});
|
||||
|
||||
it("clicking the selected color deselects it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<ColorPalette value="red" onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "red" }));
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("selected color has ring styling", () => {
|
||||
render(<ColorPalette value="green" onChange={() => {}} />);
|
||||
const selected = screen.getByRole("button", { name: "green" });
|
||||
expect(selected.className).toContain("ring-2");
|
||||
});
|
||||
|
||||
it("non-selected colors do not have ring styling", () => {
|
||||
render(<ColorPalette value="green" onChange={() => {}} />);
|
||||
const other = screen.getByRole("button", { name: "blue" });
|
||||
expect(other.className).not.toContain("ring-2");
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { combatantId } from "@initiative/domain";
|
||||
import { type CreatureId, combatantId } 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 { CombatantRow } from "../combatant-row";
|
||||
import { PLAYER_COLOR_HEX } from "../player-icon-map";
|
||||
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/;
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
// Mock bestiary — no IndexedDB or JSON index
|
||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||
isSourceCached: () => Promise.resolve(false),
|
||||
cacheSource: () => Promise.resolve(),
|
||||
getCachedSources: () => Promise.resolve([]),
|
||||
clearSource: () => Promise.resolve(),
|
||||
clearAll: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
// DOM API stubs
|
||||
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 defaultProps = {
|
||||
onRename: vi.fn(),
|
||||
onSetInitiative: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
onSetHp: vi.fn(),
|
||||
onAdjustHp: vi.fn(),
|
||||
onSetAc: vi.fn(),
|
||||
onToggleCondition: vi.fn(),
|
||||
onToggleConcentration: vi.fn(),
|
||||
};
|
||||
|
||||
function renderRow(
|
||||
overrides: Partial<{
|
||||
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
|
||||
isActive: boolean;
|
||||
onRollInitiative: (id: ReturnType<typeof combatantId>) => void;
|
||||
onRemove: (id: ReturnType<typeof combatantId>) => void;
|
||||
onShowStatBlock: () => void;
|
||||
}> = {},
|
||||
) {
|
||||
const combatant = overrides.combatant ?? {
|
||||
@@ -38,15 +74,13 @@ function renderRow(
|
||||
currentHp: 10,
|
||||
ac: 13,
|
||||
};
|
||||
const props = {
|
||||
...defaultProps,
|
||||
combatant,
|
||||
isActive: overrides.isActive ?? false,
|
||||
onRollInitiative: overrides.onRollInitiative,
|
||||
onShowStatBlock: overrides.onShowStatBlock,
|
||||
onRemove: overrides.onRemove ?? defaultProps.onRemove,
|
||||
};
|
||||
return render(<CombatantRow {...props} />);
|
||||
return render(
|
||||
<CombatantRow
|
||||
combatant={combatant}
|
||||
isActive={overrides.isActive ?? false}
|
||||
/>,
|
||||
{ wrapper: AllProviders },
|
||||
);
|
||||
}
|
||||
|
||||
describe("CombatantRow", () => {
|
||||
@@ -75,7 +109,7 @@ describe("CombatantRow", () => {
|
||||
it("active combatant gets active border styling", () => {
|
||||
const { container } = renderRow({ isActive: true });
|
||||
const row = container.firstElementChild;
|
||||
expect(row?.className).toContain("border-l-accent");
|
||||
expect(row?.className).toContain("border-active-row-border");
|
||||
});
|
||||
|
||||
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
|
||||
@@ -93,14 +127,14 @@ describe("CombatantRow", () => {
|
||||
expect(nameContainer).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows '--' for current HP when no maxHp is set", () => {
|
||||
it("shows 'Max' placeholder when no maxHp is set", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
},
|
||||
});
|
||||
expect(screen.getByLabelText("No HP set")).toBeInTheDocument();
|
||||
expect(screen.getByText("Max")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows concentration icon when isConcentrating is true", () => {
|
||||
@@ -132,10 +166,9 @@ describe("CombatantRow", () => {
|
||||
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
|
||||
});
|
||||
|
||||
it("remove button calls onRemove after confirmation", async () => {
|
||||
it("remove button removes after confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRemove = vi.fn();
|
||||
renderRow({ onRemove });
|
||||
renderRow();
|
||||
const removeBtn = screen.getByRole("button", {
|
||||
name: "Remove combatant",
|
||||
});
|
||||
@@ -146,19 +179,290 @@ describe("CombatantRow", () => {
|
||||
name: "Confirm remove combatant",
|
||||
});
|
||||
await user.click(confirmBtn);
|
||||
expect(onRemove).toHaveBeenCalledWith(combatantId("1"));
|
||||
// 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 onRollInitiative is provided", () => {
|
||||
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,
|
||||
},
|
||||
onRollInitiative: vi.fn(),
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { CONDITION_DEFINITIONS, type ConditionId } 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);
|
||||
@@ -18,12 +20,19 @@ function renderPicker(
|
||||
) {
|
||||
const onToggle = overrides.onToggle ?? 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}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
/>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
return { ...result, onToggle, onClose };
|
||||
}
|
||||
|
||||
87
apps/web/src/components/__tests__/condition-tags.test.tsx
Normal file
87
apps/web/src/components/__tests__/condition-tags.test.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { ConditionId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConditionTags } from "../condition-tags.js";
|
||||
|
||||
vi.mock("../../contexts/rules-edition-context.js", () => ({
|
||||
useRulesEditionContext: () => ({ edition: "5.5e" }),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("ConditionTags", () => {
|
||||
it("renders nothing when conditions is undefined", () => {
|
||||
const { container } = render(
|
||||
<ConditionTags
|
||||
conditions={undefined}
|
||||
onRemove={() => {}}
|
||||
onOpenPicker={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Only the add button should be present
|
||||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders a button per condition", () => {
|
||||
const conditions: ConditionId[] = ["blinded", "prone"];
|
||||
render(
|
||||
<ConditionTags
|
||||
conditions={conditions}
|
||||
onRemove={() => {}}
|
||||
onOpenPicker={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||
).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Remove Prone" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onRemove with condition id when clicked", async () => {
|
||||
const onRemove = vi.fn();
|
||||
render(
|
||||
<ConditionTags
|
||||
conditions={["blinded"] as ConditionId[]}
|
||||
onRemove={onRemove}
|
||||
onOpenPicker={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||
);
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith("blinded");
|
||||
});
|
||||
|
||||
it("calls onOpenPicker when add button is clicked", async () => {
|
||||
const onOpenPicker = vi.fn();
|
||||
render(
|
||||
<ConditionTags
|
||||
conditions={[]}
|
||||
onRemove={() => {}}
|
||||
onOpenPicker={onOpenPicker}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Add condition" }),
|
||||
);
|
||||
|
||||
expect(onOpenPicker).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders empty conditions array without errors", () => {
|
||||
render(
|
||||
<ConditionTags
|
||||
conditions={[]}
|
||||
onRemove={() => {}}
|
||||
onOpenPicker={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Only add button
|
||||
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
|
||||
});
|
||||
});
|
||||
165
apps/web/src/components/__tests__/create-player-modal.test.tsx
Normal file
165
apps/web/src/components/__tests__/create-player-modal.test.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { playerCharacterId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { CreatePlayerModal } from "../create-player-modal.js";
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderModal(
|
||||
overrides: Partial<Parameters<typeof CreatePlayerModal>[0]> = {},
|
||||
) {
|
||||
const defaults = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
};
|
||||
const props = { ...defaults, ...overrides };
|
||||
return { ...render(<CreatePlayerModal {...props} />), ...props };
|
||||
}
|
||||
|
||||
describe("CreatePlayerModal", () => {
|
||||
it("renders create form with defaults", () => {
|
||||
renderModal();
|
||||
expect(screen.getByText("Create Player")).toBeDefined();
|
||||
expect(screen.getByLabelText("Name")).toBeDefined();
|
||||
expect(screen.getByLabelText("AC")).toBeDefined();
|
||||
expect(screen.getByLabelText("Max HP")).toBeDefined();
|
||||
expect(screen.getByLabelText("Level")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders edit form when playerCharacter is provided", () => {
|
||||
const pc: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Gandalf",
|
||||
ac: 15,
|
||||
maxHp: 40,
|
||||
color: "blue",
|
||||
icon: "wand",
|
||||
level: 10,
|
||||
};
|
||||
renderModal({ playerCharacter: pc });
|
||||
expect(screen.getByText("Edit Player")).toBeDefined();
|
||||
expect(screen.getByLabelText("Name")).toHaveProperty("value", "Gandalf");
|
||||
expect(screen.getByLabelText("AC")).toHaveProperty("value", "15");
|
||||
expect(screen.getByLabelText("Max HP")).toHaveProperty("value", "40");
|
||||
expect(screen.getByLabelText("Level")).toHaveProperty("value", "10");
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onSave with valid data", async () => {
|
||||
const { onSave, onClose } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Aria");
|
||||
await user.clear(screen.getByLabelText("AC"));
|
||||
await user.type(screen.getByLabelText("AC"), "16");
|
||||
await user.clear(screen.getByLabelText("Max HP"));
|
||||
await user.type(screen.getByLabelText("Max HP"), "30");
|
||||
await user.type(screen.getByLabelText("Level"), "5");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
"Aria",
|
||||
16,
|
||||
30,
|
||||
undefined,
|
||||
undefined,
|
||||
5,
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for empty name", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(screen.getByText("Name is required")).toBeDefined();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for invalid AC", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Test");
|
||||
await user.clear(screen.getByLabelText("AC"));
|
||||
await user.type(screen.getByLabelText("AC"), "abc");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(screen.getByText("AC must be a non-negative number")).toBeDefined();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for invalid Max HP", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Test");
|
||||
await user.clear(screen.getByLabelText("Max HP"));
|
||||
await user.type(screen.getByLabelText("Max HP"), "0");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(screen.getByText("Max HP must be at least 1")).toBeDefined();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for invalid level", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Test");
|
||||
await user.type(screen.getByLabelText("Level"), "25");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(screen.getByText("Level must be between 1 and 20")).toBeDefined();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears error when name is edited", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
expect(screen.getByText("Name is required")).toBeDefined();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "A");
|
||||
expect(screen.queryByText("Name is required")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onClose when cancel is clicked", async () => {
|
||||
const { onClose } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("omits level when field is empty", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Aria");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
"Aria",
|
||||
10,
|
||||
10,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
63
apps/web/src/components/__tests__/dialog.test.tsx
Normal file
63
apps/web/src/components/__tests__/dialog.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { Dialog, DialogHeader } from "../ui/dialog.js";
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Dialog", () => {
|
||||
it("opens when open=true", () => {
|
||||
render(
|
||||
<Dialog open={true} onClose={() => {}}>
|
||||
Content
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("Content")).toBeDefined();
|
||||
});
|
||||
|
||||
it("closes when open changes from true to false", () => {
|
||||
const { rerender } = render(
|
||||
<Dialog open={true} onClose={() => {}}>
|
||||
Content
|
||||
</Dialog>,
|
||||
);
|
||||
const dialog = document.querySelector("dialog");
|
||||
expect(dialog?.hasAttribute("open")).toBe(true);
|
||||
|
||||
rerender(
|
||||
<Dialog open={false} onClose={() => {}}>
|
||||
Content
|
||||
</Dialog>,
|
||||
);
|
||||
expect(dialog?.hasAttribute("open")).toBe(false);
|
||||
});
|
||||
|
||||
it("calls onClose on cancel event", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Dialog open={true} onClose={onClose}>
|
||||
Content
|
||||
</Dialog>,
|
||||
);
|
||||
const dialog = document.querySelector("dialog");
|
||||
dialog?.dispatchEvent(new Event("cancel"));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DialogHeader", () => {
|
||||
it("renders title and close button", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<DialogHeader title="Test Title" onClose={onClose} />);
|
||||
|
||||
expect(screen.getByText("Test Title")).toBeDefined();
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { DifficultyResult } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DifficultyIndicator } from "../difficulty-indicator.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
||||
return {
|
||||
tier,
|
||||
totalMonsterXp: 100,
|
||||
partyBudget: { low: 50, moderate: 100, high: 200 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("DifficultyIndicator", () => {
|
||||
it("renders 3 bars", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator result={makeResult("moderate")} />,
|
||||
);
|
||||
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||
expect(bars).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("shows 'Trivial encounter difficulty' label for trivial tier", () => {
|
||||
render(<DifficultyIndicator result={makeResult("trivial")} />);
|
||||
expect(
|
||||
screen.getByRole("img", {
|
||||
name: "Trivial encounter difficulty",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Low encounter difficulty' label for low tier", () => {
|
||||
render(<DifficultyIndicator result={makeResult("low")} />);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Low encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Moderate encounter difficulty' label for moderate tier", () => {
|
||||
render(<DifficultyIndicator result={makeResult("moderate")} />);
|
||||
expect(
|
||||
screen.getByRole("img", {
|
||||
name: "Moderate encounter difficulty",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'High encounter difficulty' label for high tier", () => {
|
||||
render(<DifficultyIndicator result={makeResult("high")} />);
|
||||
expect(
|
||||
screen.getByRole("img", {
|
||||
name: "High encounter difficulty",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { ExportMethodDialog } from "../export-method-dialog.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
});
|
||||
|
||||
function renderDialog(open = true) {
|
||||
const onDownload = vi.fn();
|
||||
const onCopyToClipboard = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const result = render(
|
||||
<ExportMethodDialog
|
||||
open={open}
|
||||
onDownload={onDownload}
|
||||
onCopyToClipboard={onCopyToClipboard}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return { ...result, onDownload, onCopyToClipboard, onClose };
|
||||
}
|
||||
|
||||
describe("ExportMethodDialog", () => {
|
||||
it("renders filename input and unchecked history checkbox", () => {
|
||||
renderDialog();
|
||||
expect(
|
||||
screen.getByPlaceholderText("Filename (optional)"),
|
||||
).toBeInTheDocument();
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("download button calls onDownload with defaults", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onDownload } = renderDialog();
|
||||
|
||||
await user.click(screen.getByText("Download file"));
|
||||
expect(onDownload).toHaveBeenCalledWith(false, "");
|
||||
});
|
||||
|
||||
it("download with filename and history checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onDownload } = renderDialog();
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Filename (optional)"),
|
||||
"my-encounter",
|
||||
);
|
||||
await user.click(screen.getByRole("checkbox"));
|
||||
await user.click(screen.getByText("Download file"));
|
||||
expect(onDownload).toHaveBeenCalledWith(true, "my-encounter");
|
||||
});
|
||||
|
||||
it("copy to clipboard calls onCopyToClipboard and shows Copied", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onCopyToClipboard } = renderDialog();
|
||||
|
||||
await user.click(screen.getByText("Copy to clipboard"));
|
||||
expect(onCopyToClipboard).toHaveBeenCalledWith(false);
|
||||
expect(screen.getByText("Copied!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Copied! reverts after 2 seconds", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByText("Copy to clipboard"));
|
||||
expect(screen.getByText("Copied!")).toBeInTheDocument();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByText("Copied!")).not.toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
expect(screen.getByText("Copy to clipboard")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,15 +11,21 @@ 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} onClose={onClose} />,
|
||||
<HpAdjustPopover
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return { ...result, onAdjust, onClose };
|
||||
return { ...result, onAdjust, onSetTempHp, onClose };
|
||||
}
|
||||
|
||||
describe("HpAdjustPopover", () => {
|
||||
@@ -112,4 +118,31 @@ describe("HpAdjustPopover", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
89
apps/web/src/components/__tests__/overflow-menu.test.tsx
Normal file
89
apps/web/src/components/__tests__/overflow-menu.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { Circle } from "lucide-react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { OverflowMenu } from "../ui/overflow-menu.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const items = [
|
||||
{ icon: <Circle />, label: "Action A", onClick: vi.fn() },
|
||||
{ icon: <Circle />, label: "Action B", onClick: vi.fn() },
|
||||
];
|
||||
|
||||
describe("OverflowMenu", () => {
|
||||
it("renders toggle button", () => {
|
||||
render(<OverflowMenu items={items} />);
|
||||
expect(screen.getByRole("button", { name: "More actions" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show menu items when closed", () => {
|
||||
render(<OverflowMenu items={items} />);
|
||||
expect(screen.queryByText("Action A")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows menu items when toggled open", async () => {
|
||||
render(<OverflowMenu items={items} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
|
||||
|
||||
expect(screen.getByText("Action A")).toBeDefined();
|
||||
expect(screen.getByText("Action B")).toBeDefined();
|
||||
});
|
||||
|
||||
it("closes menu after clicking an item", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<OverflowMenu items={[{ icon: <Circle />, label: "Do it", onClick }]} />,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
|
||||
await userEvent.click(screen.getByText("Do it"));
|
||||
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByText("Do it")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps menu open when keepOpen is true", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<OverflowMenu
|
||||
items={[
|
||||
{
|
||||
icon: <Circle />,
|
||||
label: "Stay",
|
||||
onClick,
|
||||
keepOpen: true,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
|
||||
await userEvent.click(screen.getByText("Stay"));
|
||||
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
expect(screen.getByText("Stay")).toBeDefined();
|
||||
});
|
||||
|
||||
it("disables items when disabled is true", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<OverflowMenu
|
||||
items={[
|
||||
{
|
||||
icon: <Circle />,
|
||||
label: "Nope",
|
||||
onClick,
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
|
||||
const item = screen.getByText("Nope");
|
||||
expect(item.closest("button")?.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRef } from "react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import {
|
||||
PlayerCharacterSection,
|
||||
type PlayerCharacterSectionHandle,
|
||||
} from "../player-character-section.js";
|
||||
|
||||
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||
isSourceCached: () => Promise.resolve(false),
|
||||
cacheSource: () => Promise.resolve(),
|
||||
getCachedSources: () => Promise.resolve([]),
|
||||
clearSource: () => Promise.resolve(),
|
||||
clearAll: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
function renderSection() {
|
||||
const ref = createRef<PlayerCharacterSectionHandle>();
|
||||
const result = render(<PlayerCharacterSection ref={ref} />, {
|
||||
wrapper: AllProviders,
|
||||
});
|
||||
return { ...result, ref };
|
||||
}
|
||||
|
||||
describe("PlayerCharacterSection", () => {
|
||||
it("openManagement ref handle opens the management dialog", async () => {
|
||||
const { ref } = renderSection();
|
||||
|
||||
const handle = ref.current;
|
||||
if (!handle) throw new Error("ref not set");
|
||||
act(() => handle.openManagement());
|
||||
|
||||
// Management dialog should now be open with its title visible
|
||||
await waitFor(() => {
|
||||
const dialogs = document.querySelectorAll("dialog");
|
||||
const managementDialog = Array.from(dialogs).find((d) =>
|
||||
d.textContent?.includes("Player Characters"),
|
||||
);
|
||||
expect(managementDialog).toHaveAttribute("open");
|
||||
});
|
||||
});
|
||||
|
||||
it("creating a character from management opens create modal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { ref } = renderSection();
|
||||
|
||||
const handle = ref.current;
|
||||
if (!handle) throw new Error("ref not set");
|
||||
act(() => handle.openManagement());
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: CREATE_FIRST_PC_REGEX,
|
||||
}),
|
||||
);
|
||||
|
||||
// Create modal should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Character name")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("saving a new character and returning to management", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { ref } = renderSection();
|
||||
|
||||
const handle = ref.current;
|
||||
if (!handle) throw new Error("ref not set");
|
||||
act(() => handle.openManagement());
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: CREATE_FIRST_PC_REGEX,
|
||||
}),
|
||||
);
|
||||
|
||||
// Fill in the create form
|
||||
await user.type(screen.getByPlaceholderText("Character name"), "Aria");
|
||||
await user.type(screen.getByPlaceholderText("AC"), "16");
|
||||
await user.type(screen.getByPlaceholderText("Max HP"), "30");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
// Should return to management dialog showing the new character
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Aria")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
120
apps/web/src/components/__tests__/player-management.test.tsx
Normal file
120
apps/web/src/components/__tests__/player-management.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { type PlayerCharacter, playerCharacterId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||
const LEVEL_REGEX = /^Lv /;
|
||||
|
||||
import { PlayerManagement } from "../player-management.js";
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
});
|
||||
|
||||
const PC_WARRIOR: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Thorin",
|
||||
ac: 18,
|
||||
maxHp: 45,
|
||||
color: "red",
|
||||
icon: "sword",
|
||||
};
|
||||
|
||||
const PC_WIZARD: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-2"),
|
||||
name: "Gandalf",
|
||||
ac: 12,
|
||||
maxHp: 30,
|
||||
color: "blue",
|
||||
icon: "wand",
|
||||
level: 10,
|
||||
};
|
||||
|
||||
function renderManagement(
|
||||
overrides: Partial<Parameters<typeof PlayerManagement>[0]> = {},
|
||||
) {
|
||||
const props = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
characters: [] as readonly PlayerCharacter[],
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onCreate: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return { ...render(<PlayerManagement {...props} />), props };
|
||||
}
|
||||
|
||||
describe("PlayerManagement", () => {
|
||||
it("shows empty state when no characters", () => {
|
||||
renderManagement();
|
||||
expect(screen.getByText("No player characters yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows create button in empty state that calls onCreate", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderManagement();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: CREATE_FIRST_PC_REGEX,
|
||||
}),
|
||||
);
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders each character with name, AC, HP", () => {
|
||||
renderManagement({ characters: [PC_WARRIOR, PC_WIZARD] });
|
||||
expect(screen.getByText("Thorin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Gandalf")).toBeInTheDocument();
|
||||
expect(screen.getByText("AC 18")).toBeInTheDocument();
|
||||
expect(screen.getByText("HP 45")).toBeInTheDocument();
|
||||
expect(screen.getByText("AC 12")).toBeInTheDocument();
|
||||
expect(screen.getByText("HP 30")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows level when present, omits when undefined", () => {
|
||||
renderManagement({ characters: [PC_WARRIOR, PC_WIZARD] });
|
||||
expect(screen.getByText("Lv 10")).toBeInTheDocument();
|
||||
// Thorin has no level — there should be only one "Lv" text
|
||||
expect(screen.queryAllByText(LEVEL_REGEX)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("edit button calls onEdit with the character", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
expect(props.onEdit).toHaveBeenCalledWith(PC_WARRIOR);
|
||||
});
|
||||
|
||||
it("delete button calls onDelete after confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||
|
||||
const deleteBtn = screen.getByRole("button", {
|
||||
name: "Delete player character",
|
||||
});
|
||||
await user.click(deleteBtn);
|
||||
const confirmBtn = screen.getByRole("button", {
|
||||
name: "Confirm delete player character",
|
||||
});
|
||||
await user.click(confirmBtn);
|
||||
expect(props.onDelete).toHaveBeenCalledWith(PC_WARRIOR.id);
|
||||
});
|
||||
|
||||
it("add button calls onCreate", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
55
apps/web/src/components/__tests__/roll-mode-menu.test.tsx
Normal file
55
apps/web/src/components/__tests__/roll-mode-menu.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RollModeMenu } from "../roll-mode-menu.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("RollModeMenu", () => {
|
||||
it("renders advantage and disadvantage buttons", () => {
|
||||
render(
|
||||
<RollModeMenu
|
||||
position={{ x: 100, y: 100 }}
|
||||
onSelect={() => {}}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Advantage")).toBeDefined();
|
||||
expect(screen.getByText("Disadvantage")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onSelect with 'advantage' and onClose when clicked", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<RollModeMenu
|
||||
position={{ x: 100, y: 100 }}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Advantage"));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("advantage");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSelect with 'disadvantage' and onClose when clicked", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<RollModeMenu
|
||||
position={{ x: 100, y: 100 }}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Disadvantage"));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("disadvantage");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
110
apps/web/src/components/__tests__/settings-modal.test.tsx
Normal file
110
apps/web/src/components/__tests__/settings-modal.test.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { SettingsModal } from "../settings-modal.js";
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||
isSourceCached: () => Promise.resolve(false),
|
||||
cacheSource: () => Promise.resolve(),
|
||||
getCachedSources: () => Promise.resolve([]),
|
||||
clearSource: () => Promise.resolve(),
|
||||
clearAll: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
function renderModal(open = true) {
|
||||
const onClose = vi.fn();
|
||||
const result = render(<SettingsModal open={open} onClose={onClose} />, {
|
||||
wrapper: AllProviders,
|
||||
});
|
||||
return { ...result, onClose };
|
||||
}
|
||||
|
||||
describe("SettingsModal", () => {
|
||||
it("renders edition toggle buttons", () => {
|
||||
renderModal();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "5e (2014)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "5.5e (2024)" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders theme toggle buttons", () => {
|
||||
renderModal();
|
||||
expect(screen.getByRole("button", { name: "System" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Light" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking an edition button switches the active edition", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
const btn5e = screen.getByRole("button", { name: "5e (2014)" });
|
||||
await user.click(btn5e);
|
||||
// After clicking 5e, it should have the active style
|
||||
expect(btn5e.className).toContain("bg-accent");
|
||||
});
|
||||
|
||||
it("clicking a theme button switches the active theme", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
const darkBtn = screen.getByRole("button", { name: "Dark" });
|
||||
await user.click(darkBtn);
|
||||
expect(darkBtn.className).toContain("bg-accent");
|
||||
});
|
||||
|
||||
it("close button calls onClose", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = renderModal();
|
||||
// DialogHeader renders an X button
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const closeBtn = buttons.find((b) => b.querySelector(".h-4.w-4") !== null);
|
||||
expect(closeBtn).toBeDefined();
|
||||
await user.click(closeBtn as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
124
apps/web/src/components/__tests__/source-fetch-prompt.test.tsx
Normal file
124
apps/web/src/components/__tests__/source-fetch-prompt.test.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
||||
|
||||
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const mockFetchAndCacheSource = vi.fn();
|
||||
const mockUploadAndCacheSource = vi.fn();
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: () => ({
|
||||
fetchAndCacheSource: mockFetchAndCacheSource,
|
||||
uploadAndCacheSource: mockUploadAndCacheSource,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
getDefaultFetchUrl: (code: string) =>
|
||||
`https://example.com/bestiary/${code}.json`,
|
||||
getSourceDisplayName: (code: string) =>
|
||||
code === "MM" ? "Monster Manual" : code,
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
}));
|
||||
|
||||
function renderPrompt(sourceCode = "MM") {
|
||||
const onSourceLoaded = vi.fn();
|
||||
const result = render(
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
onSourceLoaded={onSourceLoaded}
|
||||
/>,
|
||||
);
|
||||
return { ...result, onSourceLoaded };
|
||||
}
|
||||
|
||||
describe("SourceFetchPrompt", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders source name, URL input, Load and Upload buttons", () => {
|
||||
renderPrompt();
|
||||
expect(screen.getByText(MONSTER_MANUAL_REGEX)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByDisplayValue("https://example.com/bestiary/MM.json"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Load")).toBeInTheDocument();
|
||||
expect(screen.getByText("Upload file")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => {
|
||||
mockFetchAndCacheSource.mockResolvedValueOnce(undefined);
|
||||
const user = userEvent.setup();
|
||||
const { onSourceLoaded } = renderPrompt();
|
||||
|
||||
await user.click(screen.getByText("Load"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAndCacheSource).toHaveBeenCalledWith(
|
||||
"MM",
|
||||
"https://example.com/bestiary/MM.json",
|
||||
);
|
||||
expect(onSourceLoaded).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("fetch error shows error message", async () => {
|
||||
mockFetchAndCacheSource.mockRejectedValueOnce(new Error("Network error"));
|
||||
const user = userEvent.setup();
|
||||
renderPrompt();
|
||||
|
||||
await user.click(screen.getByText("Load"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("upload file calls uploadAndCacheSource and onSourceLoaded", async () => {
|
||||
mockUploadAndCacheSource.mockResolvedValueOnce(undefined);
|
||||
const user = userEvent.setup();
|
||||
const { onSourceLoaded } = renderPrompt();
|
||||
|
||||
const file = new File(['{"monster":[]}'], "bestiary-mm.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
const fileInput = document.querySelector(
|
||||
'input[type="file"]',
|
||||
) as HTMLInputElement;
|
||||
await user.upload(fileInput, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUploadAndCacheSource).toHaveBeenCalledWith("MM", {
|
||||
monster: [],
|
||||
});
|
||||
expect(onSourceLoaded).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("upload error shows error message", async () => {
|
||||
mockUploadAndCacheSource.mockRejectedValueOnce(new Error("Invalid format"));
|
||||
const user = userEvent.setup();
|
||||
renderPrompt();
|
||||
|
||||
const file = new File(['{"bad": true}'], "bad.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
const fileInput = document.querySelector(
|
||||
'input[type="file"]',
|
||||
) as HTMLInputElement;
|
||||
await user.upload(fileInput, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Invalid format")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,28 +11,51 @@ vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
clearAll: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the context module
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
|
||||
import { SourceManager } from "../source-manager";
|
||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
||||
import { SourceManager } from "../source-manager.js";
|
||||
|
||||
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
|
||||
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
||||
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function setupMockContext() {
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockUseBestiaryContext.mockReturnValue({
|
||||
refreshCache,
|
||||
search: vi.fn().mockReturnValue([]),
|
||||
getCreature: vi.fn(),
|
||||
isLoaded: true,
|
||||
isSourceCached: vi.fn().mockResolvedValue(false),
|
||||
fetchAndCacheSource: vi.fn(),
|
||||
uploadAndCacheSource: vi.fn(),
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
return { refreshCache };
|
||||
}
|
||||
|
||||
describe("SourceManager", () => {
|
||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||
setupMockContext();
|
||||
mockGetCachedSources.mockResolvedValue([]);
|
||||
render(<SourceManager onCacheCleared={vi.fn()} />);
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("lists cached sources with display name and creature count", async () => {
|
||||
setupMockContext();
|
||||
mockGetCachedSources.mockResolvedValue([
|
||||
{
|
||||
sourceCode: "mm",
|
||||
@@ -47,7 +70,7 @@ describe("SourceManager", () => {
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
render(<SourceManager onCacheCleared={vi.fn()} />);
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
@@ -56,9 +79,9 @@ describe("SourceManager", () => {
|
||||
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Clear All button calls cache clear and onCacheCleared", async () => {
|
||||
it("Clear All button calls cache clear and refreshCache", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCacheCleared = vi.fn();
|
||||
const { refreshCache } = setupMockContext();
|
||||
mockGetCachedSources
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -70,7 +93,7 @@ describe("SourceManager", () => {
|
||||
])
|
||||
.mockResolvedValue([]);
|
||||
mockClearAll.mockResolvedValue(undefined);
|
||||
render(<SourceManager onCacheCleared={onCacheCleared} />);
|
||||
render(<SourceManager />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
@@ -80,12 +103,12 @@ describe("SourceManager", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockClearAll).toHaveBeenCalled();
|
||||
});
|
||||
expect(onCacheCleared).toHaveBeenCalled();
|
||||
expect(refreshCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("individual source delete button calls clear for that source", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCacheCleared = vi.fn();
|
||||
const { refreshCache } = setupMockContext();
|
||||
mockGetCachedSources
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -111,7 +134,7 @@ describe("SourceManager", () => {
|
||||
]);
|
||||
mockClearSource.mockResolvedValue(undefined);
|
||||
|
||||
render(<SourceManager onCacheCleared={onCacheCleared} />);
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
@@ -122,6 +145,6 @@ describe("SourceManager", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockClearSource).toHaveBeenCalledWith("mm");
|
||||
});
|
||||
expect(onCacheCleared).toHaveBeenCalled();
|
||||
expect(refreshCache).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
273
apps/web/src/components/__tests__/stat-block.test.tsx
Normal file
273
apps/web/src/components/__tests__/stat-block.test.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { StatBlock } from "../stat-block.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const ARMOR_CLASS_REGEX = /Armor Class/;
|
||||
const DEX_PLUS_4_REGEX = /Dex \+4/;
|
||||
const CR_QUARTER_REGEX = /1\/4/;
|
||||
const PROF_BONUS_2_REGEX = /Proficiency Bonus \+2/;
|
||||
const NIMBLE_ESCAPE_REGEX = /Nimble Escape\./;
|
||||
const SCIMITAR_REGEX = /Scimitar\./;
|
||||
const DETECT_REGEX = /Detect\./;
|
||||
const TAIL_ATTACK_REGEX = /Tail Attack\./;
|
||||
const INNATE_SPELLCASTING_REGEX = /Innate Spellcasting\./;
|
||||
const AT_WILL_REGEX = /At Will:/;
|
||||
const DETECT_MAGIC_REGEX = /detect magic, suggestion/;
|
||||
const DAILY_REGEX = /3\/day each:/;
|
||||
const FIREBALL_REGEX = /fireball, wall of fire/;
|
||||
const LONG_REST_REGEX = /1\/long rest:/;
|
||||
const WISH_REGEX = /wish/;
|
||||
|
||||
const GOBLIN: Creature = {
|
||||
id: creatureId("srd:goblin"),
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
size: "Small",
|
||||
type: "humanoid",
|
||||
alignment: "neutral evil",
|
||||
ac: 15,
|
||||
acSource: "leather armor, shield",
|
||||
hp: { average: 7, formula: "2d6" },
|
||||
speed: "30 ft.",
|
||||
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
|
||||
cr: "1/4",
|
||||
initiativeProficiency: 0,
|
||||
proficiencyBonus: 2,
|
||||
passive: 9,
|
||||
savingThrows: "Dex +4",
|
||||
skills: "Stealth +6",
|
||||
senses: "darkvision 60 ft., passive Perception 9",
|
||||
languages: "Common, Goblin",
|
||||
traits: [{ name: "Nimble Escape", text: "Disengage or Hide as bonus." }],
|
||||
actions: [{ name: "Scimitar", text: "Melee: +4 to hit, 5 slashing." }],
|
||||
bonusActions: [{ name: "Nimble", text: "Disengage or Hide." }],
|
||||
reactions: [{ name: "Redirect", text: "Redirect attack to ally." }],
|
||||
};
|
||||
|
||||
const DRAGON: Creature = {
|
||||
id: creatureId("srd:dragon"),
|
||||
name: "Ancient Red Dragon",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
size: "Gargantuan",
|
||||
type: "dragon",
|
||||
alignment: "chaotic evil",
|
||||
ac: 22,
|
||||
hp: { average: 546, formula: "28d20 + 252" },
|
||||
speed: "40 ft., climb 40 ft., fly 80 ft.",
|
||||
abilities: { str: 30, dex: 10, con: 29, int: 18, wis: 15, cha: 23 },
|
||||
cr: "24",
|
||||
initiativeProficiency: 0,
|
||||
proficiencyBonus: 7,
|
||||
passive: 26,
|
||||
resist: "fire",
|
||||
immune: "fire",
|
||||
vulnerable: "cold",
|
||||
conditionImmune: "frightened",
|
||||
legendaryActions: {
|
||||
preamble: "The dragon can take 3 legendary actions.",
|
||||
entries: [
|
||||
{ name: "Detect", text: "Wisdom (Perception) check." },
|
||||
{ name: "Tail Attack", text: "Tail attack." },
|
||||
],
|
||||
},
|
||||
spellcasting: [
|
||||
{
|
||||
name: "Innate Spellcasting",
|
||||
headerText: "The dragon's spellcasting ability is Charisma.",
|
||||
atWill: ["detect magic", "suggestion"],
|
||||
daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
|
||||
restLong: [{ uses: 1, each: false, spells: ["wish"] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function renderStatBlock(creature: Creature) {
|
||||
return render(<StatBlock creature={creature} />);
|
||||
}
|
||||
|
||||
describe("StatBlock", () => {
|
||||
describe("header", () => {
|
||||
it("renders creature name", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Goblin" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders size, type, alignment", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByText("Small humanoid, neutral evil"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders source display name", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stats bar", () => {
|
||||
it("renders AC with source", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText(ARMOR_CLASS_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText("15")).toBeInTheDocument();
|
||||
expect(screen.getByText("(leather armor, shield)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders AC without source when acSource is undefined", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText("22")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders HP average and formula", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("7")).toBeInTheDocument();
|
||||
expect(screen.getByText("(2d6)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders speed", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("30 ft.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ability scores", () => {
|
||||
it("renders all 6 ability labels", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
for (const label of ["STR", "DEX", "CON", "INT", "WIS", "CHA"]) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders ability scores with modifier notation", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("(+2)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("properties", () => {
|
||||
it("renders saving throws when present", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("Saving Throws")).toBeInTheDocument();
|
||||
expect(screen.getByText(DEX_PLUS_4_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders skills when present", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("Skills")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders damage resistances, immunities, vulnerabilities", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText("Damage Resistances")).toBeInTheDocument();
|
||||
expect(screen.getByText("Damage Immunities")).toBeInTheDocument();
|
||||
expect(screen.getByText("Damage Vulnerabilities")).toBeInTheDocument();
|
||||
expect(screen.getByText("Condition Immunities")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits properties when undefined", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.queryByText("Damage Resistances")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Damage Immunities")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders CR and proficiency bonus", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText("Challenge")).toBeInTheDocument();
|
||||
expect(screen.getByText(CR_QUARTER_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(PROF_BONUS_2_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("traits", () => {
|
||||
it("renders trait entries", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.getByText(NIMBLE_ESCAPE_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("actions / bonus actions / reactions", () => {
|
||||
it("renders actions heading and entries", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Actions" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(SCIMITAR_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders bonus actions heading and entries", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Bonus Actions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders reactions heading and entries", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Reactions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("legendary actions", () => {
|
||||
it("renders legendary actions with preamble", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Legendary Actions" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("The dragon can take 3 legendary actions."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(DETECT_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(TAIL_ATTACK_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits legendary actions when undefined", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Legendary Actions" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spellcasting", () => {
|
||||
it("renders spellcasting block with header", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText(INNATE_SPELLCASTING_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders at-will spells", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText(AT_WILL_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(DETECT_MAGIC_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders daily spells", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText(DAILY_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(FIREBALL_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders long rest spells", () => {
|
||||
renderStatBlock(DRAGON);
|
||||
expect(screen.getByText(LONG_REST_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(WISH_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits spellcasting when undefined", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(screen.queryByText(AT_WILL_REGEX)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
68
apps/web/src/components/__tests__/toast.test.tsx
Normal file
68
apps/web/src/components/__tests__/toast.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Toast } from "../toast.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Toast", () => {
|
||||
it("renders message text", () => {
|
||||
render(<Toast message="Hello" onDismiss={() => {}} />);
|
||||
expect(screen.getByText("Hello")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders progress bar when progress is provided", () => {
|
||||
render(<Toast message="Loading" progress={0.5} onDismiss={() => {}} />);
|
||||
const bar = document.body.querySelector("[style*='width']") as HTMLElement;
|
||||
expect(bar).not.toBeNull();
|
||||
expect(bar.style.width).toBe("50%");
|
||||
});
|
||||
|
||||
it("does not render progress bar when progress is omitted", () => {
|
||||
render(<Toast message="Done" onDismiss={() => {}} />);
|
||||
const bar = document.body.querySelector("[style*='width']");
|
||||
expect(bar).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onDismiss when close button is clicked", async () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<Toast message="Hi" onDismiss={onDismiss} />);
|
||||
|
||||
const toast = screen.getByText("Hi").closest("div");
|
||||
const button = toast?.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
await userEvent.click(button as HTMLElement);
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe("auto-dismiss", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("auto-dismisses after specified timeout", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<Toast message="Auto" onDismiss={onDismiss} autoDismissMs={3000} />,
|
||||
);
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not auto-dismiss when autoDismissMs is omitted", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<Toast message="Stay" onDismiss={onDismiss} />);
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
42
apps/web/src/components/__tests__/tooltip.test.tsx
Normal file
42
apps/web/src/components/__tests__/tooltip.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Tooltip } from "../ui/tooltip.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Tooltip", () => {
|
||||
it("renders children", () => {
|
||||
render(
|
||||
<Tooltip content="Hint">
|
||||
<button type="button">Hover me</button>
|
||||
</Tooltip>,
|
||||
);
|
||||
expect(screen.getByText("Hover me")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show tooltip initially", () => {
|
||||
render(
|
||||
<Tooltip content="Hint">
|
||||
<span>Target</span>
|
||||
</Tooltip>,
|
||||
);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows tooltip on pointer enter and hides on pointer leave", () => {
|
||||
render(
|
||||
<Tooltip content="Hint text">
|
||||
<span>Target</span>
|
||||
</Tooltip>,
|
||||
);
|
||||
|
||||
const wrapper = screen.getByText("Target").closest("span");
|
||||
fireEvent.pointerEnter(wrapper as HTMLElement);
|
||||
expect(screen.getByRole("tooltip")).toBeDefined();
|
||||
expect(screen.getByText("Hint text")).toBeDefined();
|
||||
|
||||
fireEvent.pointerLeave(wrapper as HTMLElement);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,31 @@ import type { Encounter } from "@initiative/domain";
|
||||
import { combatantId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TurnNavigation } from "../turn-navigation";
|
||||
|
||||
afterEach(cleanup);
|
||||
// Mock the context modules
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
||||
usePlayerCharactersContext: vi.fn().mockReturnValue({ characters: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn().mockReturnValue({ getCreature: () => undefined }),
|
||||
}));
|
||||
|
||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||
import { TurnNavigation } from "../turn-navigation.js";
|
||||
|
||||
const mockUseEncounterContext = vi.mocked(useEncounterContext);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function mockContext(overrides: Partial<Encounter> = {}) {
|
||||
const encounter: Encounter = {
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
@@ -20,14 +40,50 @@ function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
const value = {
|
||||
encounter,
|
||||
advanceTurn: vi.fn(),
|
||||
retreatTurn: vi.fn(),
|
||||
clearEncounter: vi.fn(),
|
||||
isEmpty: encounter.combatants.length === 0,
|
||||
hasCreatureCombatants: false,
|
||||
canRollAllInitiative: false,
|
||||
addCombatant: vi.fn(),
|
||||
removeCombatant: vi.fn(),
|
||||
editCombatant: vi.fn(),
|
||||
setInitiative: vi.fn(),
|
||||
setHp: vi.fn(),
|
||||
adjustHp: vi.fn(),
|
||||
setTempHp: vi.fn(),
|
||||
hasTempHp: false,
|
||||
setAc: vi.fn(),
|
||||
toggleCondition: vi.fn(),
|
||||
toggleConcentration: vi.fn(),
|
||||
addFromBestiary: vi.fn(),
|
||||
addMultipleFromBestiary: vi.fn(),
|
||||
addFromPlayerCharacter: vi.fn(),
|
||||
makeStore: vi.fn(),
|
||||
withUndo: vi.fn((action: () => unknown) => action()),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
undoRedoState: { undoStack: [], redoStack: [] },
|
||||
setEncounter: vi.fn(),
|
||||
setUndoRedoState: vi.fn(),
|
||||
events: [],
|
||||
lastCreatureId: null,
|
||||
};
|
||||
|
||||
mockUseEncounterContext.mockReturnValue(
|
||||
value as ReturnType<typeof useEncounterContext>,
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
mockContext(overrides);
|
||||
return render(<TurnNavigation />);
|
||||
}
|
||||
|
||||
describe("TurnNavigation", () => {
|
||||
@@ -49,80 +105,39 @@ describe("TurnNavigation", () => {
|
||||
|
||||
it("does not render an em dash between round and name", () => {
|
||||
const { container } = renderNav();
|
||||
expect(container.textContent).not.toContain("—");
|
||||
expect(container.textContent).not.toContain("\u2014");
|
||||
});
|
||||
|
||||
it("round badge and combatant name are siblings in the center area", () => {
|
||||
renderNav();
|
||||
const badge = screen.getByText("R1");
|
||||
const name = screen.getByText("Goblin");
|
||||
expect(badge.parentElement).toBe(name.parentElement);
|
||||
// badge text is inside inner span > outer span, name is a direct child
|
||||
expect(badge.closest(".flex")).toBe(name.parentElement);
|
||||
});
|
||||
|
||||
it("updates the round badge when round changes", () => {
|
||||
const { rerender } = render(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 2,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
mockContext({ roundNumber: 2 });
|
||||
const { rerender } = render(<TurnNavigation />);
|
||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 3,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
mockContext({ roundNumber: 3 });
|
||||
rerender(<TurnNavigation />);
|
||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the next combatant name when turn advances", () => {
|
||||
const { rerender } = render(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [
|
||||
const combatants = [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
];
|
||||
mockContext({ combatants, activeIndex: 0 });
|
||||
const { rerender } = render(<TurnNavigation />);
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 1,
|
||||
roundNumber: 1,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
mockContext({ combatants, activeIndex: 1 });
|
||||
rerender(<TurnNavigation />);
|
||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,17 +19,15 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
||||
>
|
||||
<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 font-medium text-xs leading-none">
|
||||
<span className="relative -mt-0.5 font-medium text-xs leading-none">
|
||||
{value == null ? "\u2014" : String(value)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,52 +1,63 @@
|
||||
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, useDeferredValue, useState } from "react";
|
||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
import React, { type RefObject, useCallback, useRef, useState } from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import {
|
||||
creatureKey,
|
||||
type QueuedCreature,
|
||||
type SuggestionActions,
|
||||
useActionBarState,
|
||||
} from "../hooks/use-action-bar-state.js";
|
||||
import { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
assembleExportBundle,
|
||||
bundleToJson,
|
||||
readImportFile,
|
||||
triggerDownload,
|
||||
validateImportBundle,
|
||||
} from "../persistence/export-import.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
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 QueuedCreature {
|
||||
result: SearchResult;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ActionBarProps {
|
||||
onAddCombatant: (
|
||||
name: string,
|
||||
opts?: { initiative?: number; ac?: number; maxHp?: number },
|
||||
) => void;
|
||||
onAddFromBestiary: (result: SearchResult) => void;
|
||||
bestiarySearch: (query: string) => SearchResult[];
|
||||
bestiaryLoaded: boolean;
|
||||
onViewStatBlock?: (result: SearchResult) => void;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
playerCharacters?: readonly PlayerCharacter[];
|
||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
onManagePlayers?: () => void;
|
||||
onRollAllInitiative?: () => void;
|
||||
showRollAllInitiative?: boolean;
|
||||
rollAllInitiativeDisabled?: boolean;
|
||||
onOpenSourceManager?: () => void;
|
||||
autoFocus?: boolean;
|
||||
onManagePlayers?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
function creatureKey(r: SearchResult): string {
|
||||
return `${r.source}:${r.name}`;
|
||||
interface AddModeSuggestionsProps {
|
||||
nameInput: string;
|
||||
suggestions: SearchResult[];
|
||||
pcMatches: PlayerCharacter[];
|
||||
suggestionIndex: number;
|
||||
queued: QueuedCreature | null;
|
||||
actions: SuggestionActions;
|
||||
}
|
||||
|
||||
function AddModeSuggestions({
|
||||
@@ -55,34 +66,15 @@ function AddModeSuggestions({
|
||||
pcMatches,
|
||||
suggestionIndex,
|
||||
queued,
|
||||
onDismiss,
|
||||
onClickSuggestion,
|
||||
onSetSuggestionIndex,
|
||||
onSetQueued,
|
||||
onConfirmQueued,
|
||||
onAddFromPlayerCharacter,
|
||||
onClear,
|
||||
}: Readonly<{
|
||||
nameInput: string;
|
||||
suggestions: SearchResult[];
|
||||
pcMatches: PlayerCharacter[];
|
||||
suggestionIndex: number;
|
||||
queued: QueuedCreature | null;
|
||||
onDismiss: () => void;
|
||||
onClear: () => void;
|
||||
onClickSuggestion: (result: SearchResult) => void;
|
||||
onSetSuggestionIndex: (i: number) => void;
|
||||
onSetQueued: (q: QueuedCreature | null) => void;
|
||||
onConfirmQueued: () => void;
|
||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
}>) {
|
||||
actions,
|
||||
}: Readonly<AddModeSuggestionsProps>) {
|
||||
return (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||
<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={onDismiss}
|
||||
onClick={actions.dismiss}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span className="flex-1">Add "{nameInput}" as custom</span>
|
||||
@@ -109,8 +101,8 @@ function AddModeSuggestions({
|
||||
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={() => {
|
||||
onAddFromPlayerCharacter?.(pc);
|
||||
onClear();
|
||||
actions.addFromPlayerCharacter?.(pc);
|
||||
actions.clear();
|
||||
}}
|
||||
>
|
||||
{!!PcIcon && (
|
||||
@@ -137,15 +129,17 @@ function AddModeSuggestions({
|
||||
<li key={key}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${(() => {
|
||||
if (isQueued) return "bg-accent/30 text-foreground";
|
||||
if (i === suggestionIndex)
|
||||
return "bg-accent/20 text-foreground";
|
||||
return "text-foreground hover:bg-hover-neutral-bg";
|
||||
})()}`}
|
||||
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={() => onClickSuggestion(result)}
|
||||
onMouseEnter={() => onSetSuggestionIndex(i)}
|
||||
onClick={() => actions.clickSuggestion(result)}
|
||||
onMouseEnter={() => actions.setSuggestionIndex(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
||||
@@ -158,9 +152,9 @@ function AddModeSuggestions({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (queued.count <= 1) {
|
||||
onSetQueued(null);
|
||||
actions.setQueued(null);
|
||||
} else {
|
||||
onSetQueued({
|
||||
actions.setQueued({
|
||||
...queued,
|
||||
count: queued.count - 1,
|
||||
});
|
||||
@@ -169,7 +163,7 @@ function AddModeSuggestions({
|
||||
>
|
||||
<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-foreground">
|
||||
<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
|
||||
@@ -178,7 +172,7 @@ function AddModeSuggestions({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetQueued({
|
||||
actions.setQueued({
|
||||
...queued,
|
||||
count: queued.count + 1,
|
||||
});
|
||||
@@ -192,7 +186,7 @@ function AddModeSuggestions({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfirmQueued();
|
||||
actions.confirmQueued();
|
||||
}}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
@@ -213,12 +207,161 @@ function AddModeSuggestions({
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -243,222 +386,186 @@ function buildOverflowItems(opts: {
|
||||
disabled: opts.bulkImportDisabled,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
label: "Export Encounter",
|
||||
onClick: opts.onExportEncounter,
|
||||
});
|
||||
items.push({
|
||||
icon: <Upload className="h-4 w-4" />,
|
||||
label: "Import Encounter",
|
||||
onClick: opts.onImportEncounter,
|
||||
});
|
||||
if (opts.onOpenSettings) {
|
||||
items.push({
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
label: "Settings",
|
||||
onClick: opts.onOpenSettings,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function ActionBar({
|
||||
onAddCombatant,
|
||||
onAddFromBestiary,
|
||||
bestiarySearch,
|
||||
bestiaryLoaded,
|
||||
onViewStatBlock,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
inputRef,
|
||||
playerCharacters,
|
||||
onAddFromPlayerCharacter,
|
||||
onManagePlayers,
|
||||
onRollAllInitiative,
|
||||
showRollAllInitiative,
|
||||
rollAllInitiativeDisabled,
|
||||
onOpenSourceManager,
|
||||
autoFocus,
|
||||
onManagePlayers,
|
||||
onOpenSettings,
|
||||
}: Readonly<ActionBarProps>) {
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
const deferredSuggestions = useDeferredValue(suggestions);
|
||||
const deferredPcMatches = useDeferredValue(pcMatches);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
||||
const [customInit, setCustomInit] = useState("");
|
||||
const [customAc, setCustomAc] = useState("");
|
||||
const [customMaxHp, setCustomMaxHp] = useState("");
|
||||
const [browseMode, setBrowseMode] = useState(false);
|
||||
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 clearCustomFields = () => {
|
||||
setCustomInit("");
|
||||
setCustomAc("");
|
||||
setCustomMaxHp("");
|
||||
};
|
||||
const { state: bulkImportState } = useBulkImportContext();
|
||||
const {
|
||||
encounter,
|
||||
undoRedoState,
|
||||
isEmpty: encounterIsEmpty,
|
||||
setEncounter,
|
||||
setUndoRedoState,
|
||||
} = useEncounterContext();
|
||||
const { characters: playerCharacters, replacePlayerCharacters } =
|
||||
usePlayerCharactersContext();
|
||||
|
||||
const clearInput = () => {
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
};
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [showExportMethod, setShowExportMethod] = useState(false);
|
||||
const [showImportMethod, setShowImportMethod] = useState(false);
|
||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||
const pendingBundleRef = useRef<
|
||||
import("@initiative/domain").ExportBundle | null
|
||||
>(null);
|
||||
|
||||
const dismissSuggestions = () => {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
const confirmQueued = () => {
|
||||
if (!queued) return;
|
||||
for (let i = 0; i < queued.count; i++) {
|
||||
onAddFromBestiary(queued.result);
|
||||
}
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const parseNum = (v: string): number | undefined => {
|
||||
if (v.trim() === "") return undefined;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
};
|
||||
|
||||
const handleAdd = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (browseMode) return;
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
return;
|
||||
}
|
||||
if (nameInput.trim() === "") return;
|
||||
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
|
||||
const init = parseNum(customInit);
|
||||
const ac = parseNum(customAc);
|
||||
const maxHp = parseNum(customMaxHp);
|
||||
if (init !== undefined) opts.initiative = init;
|
||||
if (ac !== undefined) opts.ac = ac;
|
||||
if (maxHp !== undefined) opts.maxHp = maxHp;
|
||||
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const handleBrowseSearch = (value: string) => {
|
||||
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
|
||||
};
|
||||
|
||||
const handleAddSearch = (value: string) => {
|
||||
let newSuggestions: SearchResult[] = [];
|
||||
let newPcMatches: PlayerCharacter[] = [];
|
||||
if (value.length >= 2) {
|
||||
newSuggestions = bestiarySearch(value);
|
||||
setSuggestions(newSuggestions);
|
||||
if (playerCharacters && playerCharacters.length > 0) {
|
||||
const lower = value.toLowerCase();
|
||||
newPcMatches = playerCharacters.filter((pc) =>
|
||||
pc.name.toLowerCase().includes(lower),
|
||||
const handleExportDownload = useCallback(
|
||||
(includeHistory: boolean, filename: string) => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
includeHistory,
|
||||
);
|
||||
triggerDownload(bundle, filename);
|
||||
},
|
||||
[encounter, undoRedoState, playerCharacters],
|
||||
);
|
||||
}
|
||||
setPcMatches(newPcMatches);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
}
|
||||
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
||||
clearCustomFields();
|
||||
}
|
||||
if (queued) {
|
||||
const qKey = creatureKey(queued.result);
|
||||
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
|
||||
if (!stillVisible) {
|
||||
setQueued(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNameInput(value);
|
||||
setSuggestionIndex(-1);
|
||||
if (browseMode) {
|
||||
handleBrowseSearch(value);
|
||||
} else {
|
||||
handleAddSearch(value);
|
||||
}
|
||||
};
|
||||
const handleExportClipboard = useCallback(
|
||||
(includeHistory: boolean) => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
includeHistory,
|
||||
);
|
||||
void navigator.clipboard.writeText(bundleToJson(bundle));
|
||||
},
|
||||
[encounter, undoRedoState, playerCharacters],
|
||||
);
|
||||
|
||||
const handleClickSuggestion = (result: SearchResult) => {
|
||||
const key = creatureKey(result);
|
||||
if (queued && creatureKey(queued.result) === key) {
|
||||
setQueued({ ...queued, count: queued.count + 1 });
|
||||
} else {
|
||||
setQueued({ result, count: 1 });
|
||||
}
|
||||
};
|
||||
const applyImport = useCallback(
|
||||
(bundle: import("@initiative/domain").ExportBundle) => {
|
||||
setEncounter(bundle.encounter);
|
||||
setUndoRedoState({
|
||||
undoStack: bundle.undoStack,
|
||||
redoStack: bundle.redoStack,
|
||||
});
|
||||
replacePlayerCharacters([...bundle.playerCharacters]);
|
||||
},
|
||||
[setEncounter, setUndoRedoState, replacePlayerCharacters],
|
||||
);
|
||||
|
||||
const handleEnter = () => {
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
} else if (suggestionIndex >= 0) {
|
||||
handleClickSuggestion(suggestions[suggestionIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSuggestions =
|
||||
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!hasSuggestions) 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") {
|
||||
e.preventDefault();
|
||||
handleEnter();
|
||||
} else if (e.key === "Escape") {
|
||||
dismissSuggestions();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
const handleValidatedBundle = useCallback(
|
||||
(result: import("@initiative/domain").ExportBundle | string) => {
|
||||
if (typeof result === "string") {
|
||||
setImportError(result);
|
||||
return;
|
||||
}
|
||||
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();
|
||||
onViewStatBlock?.(suggestions[suggestionIndex]);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
if (encounterIsEmpty) {
|
||||
applyImport(result);
|
||||
} else {
|
||||
pendingBundleRef.current = result;
|
||||
setShowImportConfirm(true);
|
||||
}
|
||||
};
|
||||
},
|
||||
[encounterIsEmpty, applyImport],
|
||||
);
|
||||
|
||||
const handleBrowseSelect = (result: SearchResult) => {
|
||||
onViewStatBlock?.(result);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
};
|
||||
const handleImportFile = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (importFileRef.current) importFileRef.current.value = "";
|
||||
|
||||
const toggleBrowseMode = () => {
|
||||
setBrowseMode((m) => !m);
|
||||
clearInput();
|
||||
clearCustomFields();
|
||||
};
|
||||
setImportError(null);
|
||||
handleValidatedBundle(await readImportFile(file));
|
||||
},
|
||||
[handleValidatedBundle],
|
||||
);
|
||||
|
||||
const handleImportClipboard = useCallback(
|
||||
(text: string) => {
|
||||
setImportError(null);
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
handleValidatedBundle(validateImportBundle(parsed));
|
||||
} catch {
|
||||
setImportError("Invalid file format");
|
||||
}
|
||||
},
|
||||
[handleValidatedBundle],
|
||||
);
|
||||
|
||||
const handleImportConfirm = useCallback(() => {
|
||||
if (pendingBundleRef.current) {
|
||||
applyImport(pendingBundleRef.current);
|
||||
pendingBundleRef.current = null;
|
||||
}
|
||||
setShowImportConfirm(false);
|
||||
}, [applyImport]);
|
||||
|
||||
const handleImportCancel = useCallback(() => {
|
||||
pendingBundleRef.current = null;
|
||||
setShowImportConfirm(false);
|
||||
}, []);
|
||||
|
||||
const overflowItems = buildOverflowItems({
|
||||
onManagePlayers,
|
||||
onOpenSourceManager,
|
||||
onOpenSourceManager: showSourceManager,
|
||||
bestiaryLoaded,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
onBulkImport: showBulkImport,
|
||||
bulkImportDisabled: bulkImportState.status === "loading",
|
||||
onExportEncounter: () => setShowExportMethod(true),
|
||||
onImportEncounter: () => setShowImportMethod(true),
|
||||
onOpenSettings,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||
<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 items-center gap-2"
|
||||
className="relative flex flex-1 flex-wrap items-center gap-3 sm:flex-nowrap"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="relative max-w-xs">
|
||||
@@ -474,7 +581,7 @@ export function ActionBar({
|
||||
className="pr-8"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{bestiaryLoaded && !!onViewStatBlock && (
|
||||
{!!bestiaryLoaded && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
@@ -482,6 +589,7 @@ export function ActionBar({
|
||||
"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={
|
||||
@@ -495,97 +603,73 @@ export function ActionBar({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{browseMode && deferredSuggestions.length > 0 && (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
||||
<ul className="max-h-48 overflow-y-auto py-1">
|
||||
{deferredSuggestions.map((result, i) => (
|
||||
<li key={creatureKey(result)}>
|
||||
<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"
|
||||
}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleBrowseSelect(result)}
|
||||
onMouseEnter={() => setSuggestionIndex(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{result.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{!!browseMode && (
|
||||
<BrowseSuggestions
|
||||
suggestions={suggestions}
|
||||
suggestionIndex={suggestionIndex}
|
||||
onSelect={handleBrowseSelect}
|
||||
onHover={suggestionActions.setSuggestionIndex}
|
||||
/>
|
||||
)}
|
||||
{!browseMode && hasSuggestions && (
|
||||
<AddModeSuggestions
|
||||
nameInput={nameInput}
|
||||
suggestions={deferredSuggestions}
|
||||
pcMatches={deferredPcMatches}
|
||||
suggestions={suggestions}
|
||||
pcMatches={pcMatches}
|
||||
suggestionIndex={suggestionIndex}
|
||||
queued={queued}
|
||||
onDismiss={dismissSuggestions}
|
||||
onClear={clearInput}
|
||||
onClickSuggestion={handleClickSuggestion}
|
||||
onSetSuggestionIndex={setSuggestionIndex}
|
||||
onSetQueued={setQueued}
|
||||
onConfirmQueued={confirmQueued}
|
||||
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
|
||||
actions={suggestionActions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customInit}
|
||||
onChange={(e) => setCustomInit(e.target.value)}
|
||||
placeholder="Init"
|
||||
className="w-16 text-center"
|
||||
<CustomStatFields
|
||||
customInit={customInit}
|
||||
customAc={customAc}
|
||||
customMaxHp={customMaxHp}
|
||||
onInitChange={setCustomInit}
|
||||
onAcChange={setCustomAc}
|
||||
onMaxHpChange={setCustomMaxHp}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customAc}
|
||||
onChange={(e) => setCustomAc(e.target.value)}
|
||||
placeholder="AC"
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customMaxHp}
|
||||
onChange={(e) => setCustomMaxHp(e.target.value)}
|
||||
placeholder="MaxHP"
|
||||
className="w-18 text-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<Button type="submit">Add</Button>
|
||||
)}
|
||||
{showRollAllInitiative && !!onRollAllInitiative && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={onRollAllInitiative}
|
||||
disabled={rollAllInitiativeDisabled}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</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,35 +1,41 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-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 =
|
||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||
|
||||
interface BulkImportPromptProps {
|
||||
importState: BulkImportState;
|
||||
onStartImport: (baseUrl: string) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
export function BulkImportPrompt() {
|
||||
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
||||
useBestiaryContext();
|
||||
const { state: importState, startImport, reset } = useBulkImportContext();
|
||||
const { dismissPanel } = useSidePanelContext();
|
||||
|
||||
export function BulkImportPrompt({
|
||||
importState,
|
||||
onStartImport,
|
||||
onDone,
|
||||
}: Readonly<BulkImportPromptProps>) {
|
||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||
const baseUrlId = useId();
|
||||
const totalSources = getAllSourceCodes().length;
|
||||
|
||||
const handleStart = (url: string) => {
|
||||
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
dismissPanel();
|
||||
reset();
|
||||
};
|
||||
|
||||
if (importState.status === "complete") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
|
||||
All sources loaded
|
||||
</div>
|
||||
<Button onClick={onDone}>Done</Button>
|
||||
<Button onClick={handleDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +47,7 @@ export function BulkImportPrompt({
|
||||
Loaded {importState.completed}/{importState.total} sources (
|
||||
{importState.failed} failed)
|
||||
</div>
|
||||
<Button onClick={onDone}>Done</Button>
|
||||
<Button onClick={handleDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +102,7 @@ export function BulkImportPrompt({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
|
||||
<Button onClick={() => handleStart(baseUrl)} disabled={isDisabled}>
|
||||
Load All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { Toast } from "./toast.js";
|
||||
|
||||
interface BulkImportToastsProps {
|
||||
state: BulkImportState;
|
||||
visible: boolean;
|
||||
onReset: () => void;
|
||||
}
|
||||
export function BulkImportToasts() {
|
||||
const { state, reset } = useBulkImportContext();
|
||||
const { bulkImportMode, isRightPanelCollapsed } = useSidePanelContext();
|
||||
const visible = !bulkImportMode || isRightPanelCollapsed;
|
||||
|
||||
export function BulkImportToasts({
|
||||
state,
|
||||
visible,
|
||||
onReset,
|
||||
}: Readonly<BulkImportToastsProps>) {
|
||||
if (!visible) return null;
|
||||
|
||||
if (state.status === "loading") {
|
||||
@@ -30,7 +25,7 @@ export function BulkImportToasts({
|
||||
return (
|
||||
<Toast
|
||||
message="All sources loaded"
|
||||
onDismiss={onReset}
|
||||
onDismiss={reset}
|
||||
autoDismissMs={3000}
|
||||
/>
|
||||
);
|
||||
@@ -40,7 +35,7 @@ export function BulkImportToasts({
|
||||
return (
|
||||
<Toast
|
||||
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
|
||||
onDismiss={onReset}
|
||||
onDismiss={reset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type ConditionId,
|
||||
type CreatureId,
|
||||
deriveHpStatus,
|
||||
type PlayerIcon,
|
||||
type RollMode,
|
||||
} from "@initiative/domain";
|
||||
import { BookOpen, 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 { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Input } from "./ui/input";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-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;
|
||||
@@ -22,26 +29,18 @@ interface Combatant {
|
||||
readonly initiative?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly currentHp?: number;
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
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({
|
||||
@@ -49,11 +48,13 @@ function EditableName({
|
||||
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);
|
||||
@@ -79,7 +80,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) => {
|
||||
@@ -91,14 +92,31 @@ function EditableName({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,7 +157,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) => {
|
||||
@@ -154,7 +172,12 @@ function MaxHpDisplay({
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
className="inline-block h-7 min-w-[3ch] text-center text-muted-foreground text-sm tabular-nums leading-7 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>
|
||||
@@ -164,51 +187,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-muted-foreground text-sm tabular-nums leading-7",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
role="status"
|
||||
aria-label="No HP set"
|
||||
>
|
||||
--
|
||||
</span>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPopoverOpen(true)}
|
||||
aria-label={`Current HP: ${currentHp} (${status})`}
|
||||
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
|
||||
className={cn(
|
||||
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 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>
|
||||
{!!tempHp && (
|
||||
<span className="font-medium text-cyan-400 text-sm leading-7">
|
||||
+{tempHp}
|
||||
</span>
|
||||
)}
|
||||
{!!popoverOpen && (
|
||||
<HpAdjustPopover
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={() => setPopoverOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -253,7 +272,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) => {
|
||||
@@ -278,11 +297,29 @@ function InitiativeDisplay({
|
||||
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 === "") {
|
||||
@@ -311,7 +348,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)}
|
||||
@@ -324,12 +361,18 @@ 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)}
|
||||
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",
|
||||
@@ -339,11 +382,19 @@ function InitiativeDisplay({
|
||||
>
|
||||
<D20Icon className="h-7 w-7" />
|
||||
</button>
|
||||
{!!menuPos && (
|
||||
<RollModeMenu
|
||||
position={menuPos}
|
||||
onSelect={(mode) => onRollInitiative(combatantId, mode)}
|
||||
onClose={() => setMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Has value → bold number, click to edit
|
||||
// Empty + manual → "--" placeholder, click to edit
|
||||
// Has value -> bold number, click to edit
|
||||
// Empty + manual -> "--" placeholder, click to edit
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -365,9 +416,13 @@ function rowBorderClass(
|
||||
isActive: boolean,
|
||||
isConcentrating: boolean | undefined,
|
||||
): string {
|
||||
if (isActive) return "border-l-2 border-l-accent bg-accent/10";
|
||||
if (isConcentrating) return "border-l-2 border-l-purple-400";
|
||||
return "border-l-2 border-l-transparent";
|
||||
if (isActive && isConcentrating)
|
||||
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 (isConcentrating)
|
||||
return "border border-l-2 border-transparent border-l-purple-400";
|
||||
return "border border-l-2 border-transparent";
|
||||
}
|
||||
|
||||
function concentrationIconClass(
|
||||
@@ -383,41 +438,67 @@ 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,
|
||||
toggleConcentration,
|
||||
} = useEncounterContext();
|
||||
const { selectedCreatureId, showCreature, toggleCollapse } =
|
||||
useSidePanelContext();
|
||||
const { handleRollInitiative } = useInitiativeRollsContext();
|
||||
|
||||
// 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;
|
||||
|
||||
if (
|
||||
prevHp !== undefined &&
|
||||
currentHp !== undefined &&
|
||||
currentHp < prevHp &&
|
||||
combatant.isConcentrating
|
||||
) {
|
||||
const realHpDropped =
|
||||
prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
|
||||
const tempHpDropped =
|
||||
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
||||
|
||||
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
|
||||
setIsPulsing(true);
|
||||
clearTimeout(pulseTimerRef.current);
|
||||
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||
}
|
||||
}, [currentHp, combatant.isConcentrating]);
|
||||
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!combatant.isConcentrating) {
|
||||
@@ -434,16 +515,16 @@ export function CombatantRow({
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group rounded-md pr-3 transition-colors",
|
||||
"group rounded-lg pr-3 transition-colors",
|
||||
rowBorderClass(isActive, combatant.isConcentrating),
|
||||
isPulsing && "animate-concentration-pulse",
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
||||
<div className="grid grid-cols-[2rem_3rem_auto_1fr_auto_2rem] items-center gap-1.5 py-3 sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] sm:gap-3 sm:py-2">
|
||||
{/* Concentration */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleConcentration(id)}
|
||||
onClick={() => toggleConcentration(id)}
|
||||
title="Concentrating"
|
||||
aria-label="Toggle concentration"
|
||||
className={cn(
|
||||
@@ -455,13 +536,20 @@ export function CombatantRow({
|
||||
</button>
|
||||
|
||||
{/* Initiative */}
|
||||
<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
|
||||
@@ -470,17 +558,6 @@ export function CombatantRow({
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{!!onShowStatBlock && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowStatBlock}
|
||||
title="View stat block"
|
||||
aria-label="View stat block"
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-hover-neutral"
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
)}
|
||||
{!!combatant.icon &&
|
||||
!!combatant.color &&
|
||||
(() => {
|
||||
@@ -491,7 +568,7 @@ export function CombatantRow({
|
||||
];
|
||||
return PcIcon ? (
|
||||
<PcIcon
|
||||
size={14}
|
||||
size={16}
|
||||
style={{ color: iconColor }}
|
||||
className="shrink-0"
|
||||
/>
|
||||
@@ -500,56 +577,55 @@ export function CombatantRow({
|
||||
<EditableName
|
||||
name={name}
|
||||
combatantId={id}
|
||||
onRename={onRename}
|
||||
onRename={editCombatant}
|
||||
color={pcColor}
|
||||
onToggleStatBlock={onToggleStatBlock}
|
||||
/>
|
||||
<div ref={conditionAnchorRef}>
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
{!!pickerOpen && (
|
||||
<ConditionPicker
|
||||
anchorRef={conditionAnchorRef}
|
||||
activeConditions={combatant.conditions}
|
||||
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AC */}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* HP */}
|
||||
<div className="flex items-center gap-1">
|
||||
<ClickableHp
|
||||
currentHp={currentHp}
|
||||
maxHp={maxHp}
|
||||
onAdjust={(delta) => onAdjustHp(id, delta)}
|
||||
dimmed={dimmed}
|
||||
/>
|
||||
{maxHp !== undefined && (
|
||||
<span
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm tabular-nums",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
<ClickableHp
|
||||
currentHp={currentHp}
|
||||
maxHp={maxHp}
|
||||
tempHp={combatant.tempHp}
|
||||
onAdjust={(delta) => adjustHp(id, delta)}
|
||||
onSetTempHp={(value) => setTempHp(id, value)}
|
||||
/>
|
||||
{maxHp !== undefined && (
|
||||
<span className="text-muted-foreground/50 text-xs">/</span>
|
||||
)}
|
||||
<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 */}
|
||||
<ConfirmButton
|
||||
icon={<X size={16} />}
|
||||
label="Remove combatant"
|
||||
onConfirm={() => onRemove(id)}
|
||||
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>
|
||||
|
||||
@@ -1,115 +1,89 @@
|
||||
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 ConditionId,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
} from "@initiative/domain";
|
||||
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 {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
activeConditions: readonly ConditionId[] | undefined;
|
||||
onToggle: (conditionId: ConditionId) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConditionPicker({
|
||||
anchorRef,
|
||||
activeConditions,
|
||||
onToggle,
|
||||
onClose,
|
||||
}: Readonly<ConditionPickerProps>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
||||
const [pos, setPos] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.top;
|
||||
const spaceAbove = rect.bottom;
|
||||
const shouldFlip =
|
||||
rect.bottom > window.innerHeight && spaceAbove > spaceBelow;
|
||||
setFlipped(shouldFlip);
|
||||
const available = shouldFlip ? spaceAbove : spaceBelow;
|
||||
if (rect.height > available) {
|
||||
setMaxHeight(available - 16);
|
||||
}
|
||||
}, []);
|
||||
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 spaceBelow = window.innerHeight - anchorRect.bottom - pad;
|
||||
const spaceAbove = anchorRect.top - pad;
|
||||
const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
|
||||
|
||||
const top = openBelow
|
||||
? anchorRect.bottom + 4
|
||||
: Math.max(pad, anchorRect.top - Math.min(menuHeight, spaceAbove) - 4);
|
||||
const maxHeight = openBelow ? spaceBelow : Math.min(menuHeight, spaceAbove);
|
||||
|
||||
setPos({ top, left: anchorRect.left, maxHeight });
|
||||
}, [anchorRef]);
|
||||
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
const { edition } = useRulesEditionContext();
|
||||
const conditions = getConditionsForEdition(edition);
|
||||
const active = new Set(activeConditions ?? []);
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
|
||||
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
||||
)}
|
||||
style={maxHeight ? { maxHeight } : undefined}
|
||||
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 colorClass =
|
||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<button
|
||||
<Tooltip
|
||||
key={def.id}
|
||||
content={getConditionDescription(def, edition)}
|
||||
className="block"
|
||||
>
|
||||
<button
|
||||
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",
|
||||
@@ -122,13 +96,17 @@ export function ConditionPicker({
|
||||
className={isActive ? colorClass : "text-muted-foreground"}
|
||||
/>
|
||||
<span
|
||||
className={isActive ? "text-foreground" : "text-muted-foreground"}
|
||||
className={
|
||||
isActive ? "text-foreground" : "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
54
apps/web/src/components/condition-styles.ts
Normal file
54
apps/web/src/components/condition-styles.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
Droplet,
|
||||
EarOff,
|
||||
EyeOff,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
Link,
|
||||
Moon,
|
||||
ShieldMinus,
|
||||
Siren,
|
||||
Snail,
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
|
||||
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
Heart,
|
||||
EarOff,
|
||||
BatteryLow,
|
||||
Siren,
|
||||
Hand,
|
||||
Ban,
|
||||
Ghost,
|
||||
ZapOff,
|
||||
Gem,
|
||||
Droplet,
|
||||
ArrowDown,
|
||||
Link,
|
||||
ShieldMinus,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Moon,
|
||||
};
|
||||
|
||||
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||
neutral: "text-muted-foreground",
|
||||
pink: "text-pink-400",
|
||||
amber: "text-amber-400",
|
||||
orange: "text-orange-400",
|
||||
gray: "text-gray-400",
|
||||
violet: "text-violet-400",
|
||||
yellow: "text-yellow-400",
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
};
|
||||
@@ -1,54 +1,16 @@
|
||||
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 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;
|
||||
@@ -61,21 +23,28 @@ export function ConditionTags({
|
||||
onRemove,
|
||||
onOpenPicker,
|
||||
}: 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);
|
||||
if (!def) return null;
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
const colorClass =
|
||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<button
|
||||
<Tooltip
|
||||
key={condId}
|
||||
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={def.label}
|
||||
aria-label={`Remove ${def.label}`}
|
||||
className={`inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg ${colorClass}`}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
colorClass,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(condId);
|
||||
@@ -83,13 +52,14 @@ export function ConditionTags({
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
title="Add condition"
|
||||
aria-label="Add condition"
|
||||
className="inline-flex items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
|
||||
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();
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ColorPalette } from "./color-palette";
|
||||
import { IconGrid } from "./icon-grid";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog } from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
function parseLevel(value: string): number | undefined | "invalid" {
|
||||
if (value.trim() === "") return undefined;
|
||||
const n = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > 20) return "invalid";
|
||||
return n;
|
||||
}
|
||||
|
||||
interface CreatePlayerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -15,6 +23,7 @@ interface CreatePlayerModalProps {
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
level: number | undefined,
|
||||
) => void;
|
||||
playerCharacter?: PlayerCharacter;
|
||||
}
|
||||
@@ -25,12 +34,12 @@ export function CreatePlayerModal({
|
||||
onSave,
|
||||
playerCharacter,
|
||||
}: Readonly<CreatePlayerModalProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [ac, setAc] = useState("10");
|
||||
const [maxHp, setMaxHp] = useState("10");
|
||||
const [color, setColor] = useState("blue");
|
||||
const [icon, setIcon] = useState("sword");
|
||||
const [level, setLevel] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isEdit = !!playerCharacter;
|
||||
@@ -43,45 +52,23 @@ export function CreatePlayerModal({
|
||||
setMaxHp(String(playerCharacter.maxHp));
|
||||
setColor(playerCharacter.color ?? "");
|
||||
setIcon(playerCharacter.icon ?? "");
|
||||
setLevel(
|
||||
playerCharacter.level === undefined
|
||||
? ""
|
||||
: String(playerCharacter.level),
|
||||
);
|
||||
} else {
|
||||
setName("");
|
||||
setAc("10");
|
||||
setMaxHp("10");
|
||||
setColor("");
|
||||
setIcon("");
|
||||
setLevel("");
|
||||
}
|
||||
setError("");
|
||||
}
|
||||
}, [open, playerCharacter]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
@@ -99,15 +86,24 @@ export function CreatePlayerModal({
|
||||
setError("Max HP must be at least 1");
|
||||
return;
|
||||
}
|
||||
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
|
||||
const levelNum = parseLevel(level);
|
||||
if (levelNum === "invalid") {
|
||||
setError("Level must be between 1 and 20");
|
||||
return;
|
||||
}
|
||||
onSave(
|
||||
trimmed,
|
||||
acNum,
|
||||
hpNum,
|
||||
color || undefined,
|
||||
icon || undefined,
|
||||
levelNum,
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
|
||||
>
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
{isEdit ? "Edit Player" : "Create Player"}
|
||||
@@ -166,6 +162,20 @@ export function CreatePlayerModal({
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-muted-foreground text-sm">
|
||||
Level
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value)}
|
||||
placeholder="1-20"
|
||||
aria-label="Level"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -187,6 +197,6 @@ export function CreatePlayerModal({
|
||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
39
apps/web/src/components/difficulty-indicator.tsx
Normal file
39
apps/web/src/components/difficulty-indicator.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { DifficultyResult, DifficultyTier } from "@initiative/domain";
|
||||
import { cn } from "../lib/utils.js";
|
||||
|
||||
const TIER_CONFIG: Record<
|
||||
DifficultyTier,
|
||||
{ filledBars: number; color: string; label: string }
|
||||
> = {
|
||||
trivial: { filledBars: 0, color: "", label: "Trivial" },
|
||||
low: { filledBars: 1, color: "bg-green-500", label: "Low" },
|
||||
moderate: { filledBars: 2, color: "bg-yellow-500", label: "Moderate" },
|
||||
high: { filledBars: 3, color: "bg-red-500", label: "High" },
|
||||
};
|
||||
|
||||
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
||||
|
||||
export function DifficultyIndicator({ result }: { result: DifficultyResult }) {
|
||||
const config = TIER_CONFIG[result.tier];
|
||||
const tooltip = `${config.label} encounter difficulty`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-end gap-0.5"
|
||||
title={tooltip}
|
||||
role="img"
|
||||
aria-label={tooltip}
|
||||
>
|
||||
{BAR_HEIGHTS.map((height, i) => (
|
||||
<div
|
||||
key={height}
|
||||
className={cn(
|
||||
"w-1 rounded-sm",
|
||||
height,
|
||||
i < config.filledBars ? config.color : "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
apps/web/src/components/export-method-dialog.tsx
Normal file
93
apps/web/src/components/export-method-dialog.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Check, ClipboardCopy, Download } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface ExportMethodDialogProps {
|
||||
open: boolean;
|
||||
onDownload: (includeHistory: boolean, filename: string) => void;
|
||||
onCopyToClipboard: (includeHistory: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExportMethodDialog({
|
||||
open,
|
||||
onDownload,
|
||||
onCopyToClipboard,
|
||||
onClose,
|
||||
}: Readonly<ExportMethodDialogProps>) {
|
||||
const [includeHistory, setIncludeHistory] = useState(false);
|
||||
const [filename, setFilename] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIncludeHistory(false);
|
||||
setFilename("");
|
||||
setCopied(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||
<DialogHeader title="Export Encounter" onClose={handleClose} />
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
type="text"
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
placeholder="Filename (optional)"
|
||||
/>
|
||||
</div>
|
||||
<label className="mb-4 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeHistory}
|
||||
onChange={(e) => setIncludeHistory(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span className="text-foreground">Include undo/redo history</span>
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onDownload(includeHistory, filename);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Download className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Download file</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Save as a JSON file
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onCopyToClipboard(includeHistory);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
) : (
|
||||
<ClipboardCopy className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{copied ? "Copied!" : "Copy to clipboard"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Copy JSON to your clipboard
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Heart, Sword } from "lucide-react";
|
||||
import { Heart, ShieldPlus, Sword } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -6,16 +6,22 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||
|
||||
interface HpAdjustPopoverProps {
|
||||
readonly onAdjust: (delta: number) => void;
|
||||
readonly onSetTempHp: (value: number) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
export function HpAdjustPopover({
|
||||
onAdjust,
|
||||
onSetTempHp,
|
||||
onClose,
|
||||
}: HpAdjustPopoverProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -43,15 +49,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
const parsedValue =
|
||||
inputValue === "" ? null : Number.parseInt(inputValue, 10);
|
||||
@@ -87,7 +85,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-10 rounded-md border border-border bg-background p-2 shadow-lg"
|
||||
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
|
||||
style={
|
||||
pos
|
||||
? { top: pos.top, left: pos.left }
|
||||
@@ -101,7 +99,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
inputMode="numeric"
|
||||
value={inputValue}
|
||||
placeholder="HP"
|
||||
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
||||
className="h-7 w-[7ch] text-center tabular-nums"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
|
||||
@@ -113,7 +111,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isValid}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-red-950 hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-hp-damage-hover-bg hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => applyDelta(-1)}
|
||||
title="Apply damage"
|
||||
aria-label="Apply damage"
|
||||
@@ -123,13 +121,28 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isValid}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-emerald-950 hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-hp-heal-hover-bg hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => applyDelta(1)}
|
||||
title="Apply healing"
|
||||
aria-label="Apply healing"
|
||||
>
|
||||
<Heart size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isValid}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-cyan-400 transition-colors hover:bg-cyan-400/10 hover:text-cyan-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => {
|
||||
if (isValid && parsedValue) {
|
||||
onSetTempHp(parsedValue);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Set temp HP"
|
||||
aria-label="Set temp HP"
|
||||
>
|
||||
<ShieldPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
32
apps/web/src/components/import-confirm-prompt.tsx
Normal file
32
apps/web/src/components/import-confirm-prompt.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
|
||||
interface ImportConfirmDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ImportConfirmDialog({
|
||||
open,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Readonly<ImportConfirmDialogProps>) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel}>
|
||||
<h2 className="mb-2 font-semibold text-lg">Replace current encounter?</h2>
|
||||
<p className="mb-4 text-muted-foreground text-sm">
|
||||
Importing will replace your current encounter, undo/redo history, and
|
||||
player characters. This cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm}>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/components/import-method-dialog.tsx
Normal file
114
apps/web/src/components/import-method-dialog.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ClipboardPaste, FileUp } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||
|
||||
interface ImportMethodDialogProps {
|
||||
open: boolean;
|
||||
onSelectFile: () => void;
|
||||
onSubmitClipboard: (text: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportMethodDialog({
|
||||
open,
|
||||
onSelectFile,
|
||||
onSubmitClipboard,
|
||||
onClose,
|
||||
}: Readonly<ImportMethodDialogProps>) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [mode, setMode] = useState<"pick" | "paste">("pick");
|
||||
const [pasteText, setPasteText] = useState("");
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setMode("pick");
|
||||
setPasteText("");
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMode("pick");
|
||||
setPasteText("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "paste") {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||
<DialogHeader title="Import Encounter" onClose={handleClose} />
|
||||
{mode === "pick" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onSelectFile();
|
||||
}}
|
||||
>
|
||||
<FileUp className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">From file</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Upload a JSON file
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => setMode("paste")}
|
||||
>
|
||||
<ClipboardPaste className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Paste content</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Paste JSON content directly
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{mode === "paste" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
placeholder="Paste exported JSON here..."
|
||||
className="h-32 w-full resize-none rounded-md border border-border bg-background px-3 py-2 font-mono text-foreground text-xs placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setMode("pick");
|
||||
setPasteText("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={pasteText.trim().length === 0}
|
||||
onClick={() => {
|
||||
const text = pasteText;
|
||||
handleClose();
|
||||
onSubmitClipboard(text);
|
||||
}}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { type RefObject, useImperativeHandle, useState } from "react";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||
import { PlayerManagement } from "./player-management.js";
|
||||
|
||||
@@ -7,37 +8,14 @@ export interface PlayerCharacterSectionHandle {
|
||||
openManagement: () => void;
|
||||
}
|
||||
|
||||
interface PlayerCharacterSectionProps {
|
||||
characters: readonly PlayerCharacter[];
|
||||
onCreateCharacter: (
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
) => void;
|
||||
onEditCharacter: (
|
||||
id: PlayerCharacterId,
|
||||
fields: {
|
||||
name?: string;
|
||||
ac?: number;
|
||||
maxHp?: number;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
},
|
||||
) => void;
|
||||
onDeleteCharacter: (id: PlayerCharacterId) => void;
|
||||
}
|
||||
|
||||
export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
characters,
|
||||
onCreateCharacter,
|
||||
onEditCharacter,
|
||||
onDeleteCharacter,
|
||||
ref,
|
||||
}: PlayerCharacterSectionProps & {
|
||||
}: {
|
||||
ref?: RefObject<PlayerCharacterSectionHandle | null>;
|
||||
}) {
|
||||
const { characters, createCharacter, editCharacter, deleteCharacter } =
|
||||
usePlayerCharactersContext();
|
||||
|
||||
const [managementOpen, setManagementOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingPlayer, setEditingPlayer] = useState<
|
||||
@@ -57,17 +35,18 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
setEditingPlayer(undefined);
|
||||
setManagementOpen(true);
|
||||
}}
|
||||
onSave={(name, ac, maxHp, color, icon) => {
|
||||
onSave={(name, ac, maxHp, color, icon, level) => {
|
||||
if (editingPlayer) {
|
||||
onEditCharacter(editingPlayer.id, {
|
||||
editCharacter(editingPlayer.id, {
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color: color ?? null,
|
||||
icon: icon ?? null,
|
||||
level: level ?? null,
|
||||
});
|
||||
} else {
|
||||
onCreateCharacter(name, ac, maxHp, color, icon);
|
||||
createCharacter(name, ac, maxHp, color, icon, level);
|
||||
}
|
||||
}}
|
||||
playerCharacter={editingPlayer}
|
||||
@@ -81,7 +60,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
setCreateOpen(true);
|
||||
setManagementOpen(false);
|
||||
}}
|
||||
onDelete={(id) => onDeleteCharacter(id)}
|
||||
onDelete={(id) => deleteCharacter(id)}
|
||||
onCreate={() => {
|
||||
setEditingPlayer(undefined);
|
||||
setCreateOpen(true);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog";
|
||||
|
||||
interface PlayerManagementProps {
|
||||
open: boolean;
|
||||
@@ -22,54 +22,9 @@ export function PlayerManagement({
|
||||
onDelete,
|
||||
onCreate,
|
||||
}: Readonly<PlayerManagementProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
Player Characters
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||
<DialogHeader title="Player Characters" onClose={onClose} />
|
||||
|
||||
{characters.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
@@ -101,6 +56,11 @@ export function PlayerManagement({
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
HP {pc.maxHp}
|
||||
</span>
|
||||
{pc.level !== undefined && (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
Lv {pc.level}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -128,6 +88,6 @@ export function PlayerManagement({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
74
apps/web/src/components/roll-mode-menu.tsx
Normal file
74
apps/web/src/components/roll-mode-menu.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { RollMode } from "@initiative/domain";
|
||||
import { ChevronsDown, ChevronsUp } from "lucide-react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
|
||||
interface RollModeMenuProps {
|
||||
readonly position: { x: number; y: number };
|
||||
readonly onSelect: (mode: RollMode) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function RollModeMenu({
|
||||
position,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: RollModeMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const vh = document.documentElement.clientHeight;
|
||||
|
||||
let left = position.x;
|
||||
let top = position.y;
|
||||
|
||||
if (left + rect.width > vw) left = vw - rect.width - 8;
|
||||
if (left < 8) left = 8;
|
||||
if (top + rect.height > vh) top = position.y - rect.height;
|
||||
if (top < 8) top = 8;
|
||||
|
||||
setPos({ top, left });
|
||||
}, [position.x, position.y]);
|
||||
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="card-glow fixed z-50 min-w-40 rounded-lg border border-border bg-card py-1"
|
||||
style={
|
||||
pos
|
||||
? { top: pos.top, left: pos.left }
|
||||
: { visibility: "hidden" as const }
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-emerald-400 text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onSelect("advantage");
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ChevronsUp className="h-4 w-4" />
|
||||
Advantage
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-red-400 text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onSelect("disadvantage");
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ChevronsDown className="h-4 w-4" />
|
||||
Disadvantage
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/web/src/components/settings-modal.tsx
Normal file
89
apps/web/src/components/settings-modal.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { RulesEdition } from "@initiative/domain";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useThemeContext } from "../contexts/theme-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
|
||||
{ value: "5e", label: "5e (2014)" },
|
||||
{ value: "5.5e", label: "5.5e (2024)" },
|
||||
];
|
||||
|
||||
const THEME_OPTIONS: {
|
||||
value: "system" | "light" | "dark";
|
||||
label: string;
|
||||
icon: typeof Sun;
|
||||
}[] = [
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
];
|
||||
|
||||
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
const { edition, setEdition } = useRulesEditionContext();
|
||||
const { preference, setPreference } = useThemeContext();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
|
||||
<DialogHeader title="Settings" onClose={onClose} />
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
Conditions
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{EDITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||
edition === opt.value
|
||||
? "bg-accent text-primary-foreground"
|
||||
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setEdition(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
Theme
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{THEME_OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||
preference === opt.value
|
||||
? "bg-accent text-primary-foreground"
|
||||
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setPreference(opt.value)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
|
||||
import {
|
||||
getDefaultFetchUrl,
|
||||
getSourceDisplayName,
|
||||
} from "../adapters/bestiary-index-adapter.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface SourceFetchPromptProps {
|
||||
sourceCode: string;
|
||||
sourceDisplayName: string;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
onSourceLoaded: () => void;
|
||||
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
export function SourceFetchPrompt({
|
||||
sourceCode,
|
||||
sourceDisplayName,
|
||||
fetchAndCacheSource,
|
||||
onSourceLoaded,
|
||||
onUploadSource,
|
||||
}: Readonly<SourceFetchPromptProps>) {
|
||||
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
||||
const sourceDisplayName = getSourceDisplayName(sourceCode);
|
||||
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||
const [error, setError] = useState<string>("");
|
||||
@@ -47,7 +47,7 @@ export function SourceFetchPrompt({
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
await onUploadSource(sourceCode, json);
|
||||
await uploadAndCacheSource(sourceCode, json);
|
||||
onSourceLoaded();
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
|
||||
@@ -8,16 +8,12 @@ import {
|
||||
} from "react";
|
||||
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface SourceManagerProps {
|
||||
onCacheCleared: () => void;
|
||||
}
|
||||
|
||||
export function SourceManager({
|
||||
onCacheCleared,
|
||||
}: Readonly<SourceManagerProps>) {
|
||||
export function SourceManager() {
|
||||
const { refreshCache } = useBestiaryContext();
|
||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||
@@ -44,14 +40,14 @@ export function SourceManager({
|
||||
applyOptimistic({ type: "remove", sourceCode });
|
||||
await bestiaryCache.clearSource(sourceCode);
|
||||
await loadSources();
|
||||
onCacheCleared();
|
||||
void refreshCache();
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
applyOptimistic({ type: "clear" });
|
||||
await bestiaryCache.clearAll();
|
||||
await loadSources();
|
||||
onCacheCleared();
|
||||
void refreshCache();
|
||||
};
|
||||
|
||||
const filteredSources = useMemo(() => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import type { CreatureId } from "@initiative/domain";
|
||||
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||
import { SourceManager } from "./source-manager.js";
|
||||
@@ -12,28 +13,8 @@ import { StatBlock } from "./stat-block.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
interface StatBlockPanelProps {
|
||||
creatureId: CreatureId | null;
|
||||
creature: Creature | null;
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
uploadAndCacheSource: (
|
||||
sourceCode: string,
|
||||
jsonData: unknown,
|
||||
) => Promise<void>;
|
||||
refreshCache: () => Promise<void>;
|
||||
panelRole: "browse" | "pinned";
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onPin: () => void;
|
||||
onUnpin: () => void;
|
||||
showPinButton: boolean;
|
||||
side: "left" | "right";
|
||||
onDismiss: () => void;
|
||||
bulkImportMode?: boolean;
|
||||
bulkImportState?: BulkImportState;
|
||||
onStartBulkImport?: (baseUrl: string) => void;
|
||||
onBulkImportDone?: () => void;
|
||||
sourceManagerMode?: boolean;
|
||||
}
|
||||
|
||||
function extractSourceCode(cId: CreatureId): string {
|
||||
@@ -55,9 +36,10 @@ function CollapsedTab({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
|
||||
side === "right" ? "self-start" : "self-end"
|
||||
}`}
|
||||
className={cn(
|
||||
"flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral",
|
||||
side === "right" ? "self-start" : "self-end",
|
||||
)}
|
||||
aria-label="Expand stat block panel"
|
||||
>
|
||||
<span className="writing-vertical-rl font-medium text-sm">
|
||||
@@ -152,7 +134,11 @@ function DesktopPanel({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isCollapsed ? collapsedTranslate : "translate-x-0"}`}
|
||||
className={cn(
|
||||
"panel-glow fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel",
|
||||
sideClasses,
|
||||
isCollapsed ? collapsedTranslate : "translate-x-0",
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<CollapsedTab
|
||||
@@ -194,7 +180,10 @@ function MobileDrawer({
|
||||
aria-label="Close stat block"
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
|
||||
className={cn(
|
||||
"panel-glow absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card",
|
||||
!isSwiping && "animate-slide-in-right",
|
||||
)}
|
||||
style={
|
||||
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
||||
}
|
||||
@@ -219,27 +208,49 @@ function MobileDrawer({
|
||||
);
|
||||
}
|
||||
|
||||
export function StatBlockPanel({
|
||||
function usePanelRole(panelRole: "browse" | "pinned") {
|
||||
const sidePanel = useSidePanelContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
|
||||
const creatureId =
|
||||
panelRole === "browse"
|
||||
? sidePanel.selectedCreatureId
|
||||
: sidePanel.pinnedCreatureId;
|
||||
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
|
||||
|
||||
const isBrowse = panelRole === "browse";
|
||||
return {
|
||||
creatureId,
|
||||
creature,
|
||||
isSourceCached,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
||||
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
||||
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
||||
onPin: isBrowse ? sidePanel.togglePin : () => {},
|
||||
onUnpin: panelRole === "pinned" ? sidePanel.unpin : () => {},
|
||||
showPinButton: isBrowse && sidePanel.isWideDesktop && !!creature,
|
||||
bulkImportMode: isBrowse && sidePanel.bulkImportMode,
|
||||
sourceManagerMode: isBrowse && sidePanel.sourceManagerMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function StatBlockPanel({
|
||||
panelRole,
|
||||
side,
|
||||
}: Readonly<StatBlockPanelProps>) {
|
||||
const { isSourceCached } = useBestiaryContext();
|
||||
const {
|
||||
creatureId,
|
||||
creature,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onDismiss,
|
||||
onPin,
|
||||
onUnpin,
|
||||
showPinButton,
|
||||
side,
|
||||
onDismiss,
|
||||
bulkImportMode,
|
||||
bulkImportState,
|
||||
onStartBulkImport,
|
||||
onBulkImportDone,
|
||||
sourceManagerMode,
|
||||
}: Readonly<StatBlockPanelProps>) {
|
||||
} = usePanelRole(panelRole);
|
||||
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||
);
|
||||
@@ -276,29 +287,17 @@ export function StatBlockPanel({
|
||||
|
||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||
|
||||
const handleSourceLoaded = async () => {
|
||||
await refreshCache();
|
||||
const handleSourceLoaded = () => {
|
||||
setNeedsFetch(false);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (sourceManagerMode) {
|
||||
return <SourceManager onCacheCleared={refreshCache} />;
|
||||
return <SourceManager />;
|
||||
}
|
||||
|
||||
if (
|
||||
bulkImportMode &&
|
||||
bulkImportState &&
|
||||
onStartBulkImport &&
|
||||
onBulkImportDone
|
||||
) {
|
||||
return (
|
||||
<BulkImportPrompt
|
||||
importState={bulkImportState}
|
||||
onStartImport={onStartBulkImport}
|
||||
onDone={onBulkImportDone}
|
||||
/>
|
||||
);
|
||||
if (bulkImportMode) {
|
||||
return <BulkImportPrompt />;
|
||||
}
|
||||
|
||||
if (checkingCache) {
|
||||
@@ -315,10 +314,7 @@ export function StatBlockPanel({
|
||||
return (
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
sourceDisplayName={getSourceDisplayName(sourceCode)}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
onSourceLoaded={handleSourceLoaded}
|
||||
onUploadSource={uploadAndCacheSource}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,32 @@ function PropertyLine({
|
||||
|
||||
function SectionDivider() {
|
||||
return (
|
||||
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" />
|
||||
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
|
||||
);
|
||||
}
|
||||
|
||||
function TraitSection({
|
||||
entries,
|
||||
heading,
|
||||
}: Readonly<{
|
||||
entries: readonly { name: string; text: string }[] | undefined;
|
||||
heading?: string;
|
||||
}>) {
|
||||
if (!entries || entries.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<SectionDivider />
|
||||
{heading ? (
|
||||
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{entries.map((e) => (
|
||||
<div key={e.name} className="text-sm">
|
||||
<span className="font-semibold italic">{e.name}.</span> {e.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +79,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
<div className="space-y-1 text-foreground">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="font-bold text-amber-400 text-xl">{creature.name}</h2>
|
||||
<h2 className="font-bold text-stat-heading text-xl">{creature.name}</h2>
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
{creature.size} {creature.type}, {creature.alignment}
|
||||
</p>
|
||||
@@ -134,19 +159,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traits */}
|
||||
{creature.traits && creature.traits.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<div className="space-y-2">
|
||||
{creature.traits.map((t) => (
|
||||
<div key={t.name} className="text-sm">
|
||||
<span className="font-semibold italic">{t.name}.</span> {t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TraitSection entries={creature.traits} />
|
||||
|
||||
{/* Spellcasting */}
|
||||
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
||||
@@ -190,56 +203,15 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{creature.actions && creature.actions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-amber-400 text-base">Actions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.actions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bonus Actions */}
|
||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-amber-400 text-base">Bonus Actions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.bonusActions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
{creature.reactions && creature.reactions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-amber-400 text-base">Reactions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.reactions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TraitSection entries={creature.actions} heading="Actions" />
|
||||
<TraitSection entries={creature.bonusActions} heading="Bonus Actions" />
|
||||
<TraitSection entries={creature.reactions} heading="Reactions" />
|
||||
|
||||
{/* Legendary Actions */}
|
||||
{!!creature.legendaryActions && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-amber-400 text-base">
|
||||
<h3 className="font-bold text-base text-stat-heading">
|
||||
Legendary Actions
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
|
||||
@@ -24,7 +24,7 @@ export function Toast({
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed bottom-4 left-4 z-50">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
||||
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||
<span className="text-foreground text-sm">{message}</span>
|
||||
{progress !== undefined && (
|
||||
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useDifficulty } from "../hooks/use-difficulty.js";
|
||||
import { DifficultyIndicator } from "./difficulty-indicator.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
|
||||
interface TurnNavigationProps {
|
||||
encounter: Encounter;
|
||||
onAdvanceTurn: () => void;
|
||||
onRetreatTurn: () => void;
|
||||
onClearEncounter: () => void;
|
||||
}
|
||||
|
||||
export function TurnNavigation({
|
||||
export function TurnNavigation() {
|
||||
const {
|
||||
encounter,
|
||||
onAdvanceTurn,
|
||||
onRetreatTurn,
|
||||
onClearEncounter,
|
||||
}: Readonly<TurnNavigationProps>) {
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
clearEncounter,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
} = useEncounterContext();
|
||||
|
||||
const difficulty = useDifficulty();
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||
<div className="card-glow flex items-center gap-3 border-border border-b bg-card px-4 py-3 sm:rounded-lg sm:border">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRetreatTurn}
|
||||
onClick={retreatTurn}
|
||||
disabled={!hasCombatants || isAtStart}
|
||||
title="Previous turn"
|
||||
aria-label="Previous turn"
|
||||
@@ -33,29 +35,55 @@ export function TurnNavigation({
|
||||
<StepBack className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
title="Undo"
|
||||
aria-label="Undo"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
title="Redo"
|
||||
aria-label="Redo"
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||
<span className="-mt-[3px] inline-block">
|
||||
R{encounter.roundNumber}
|
||||
</span>
|
||||
</span>
|
||||
{activeCombatant ? (
|
||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No combatants</span>
|
||||
)}
|
||||
{difficulty && <DifficultyIndicator result={difficulty} />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
<ConfirmButton
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
label="Clear encounter"
|
||||
onConfirm={onClearEncounter}
|
||||
onConfirm={clearEncounter}
|
||||
disabled={!hasCombatants}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onAdvanceTurn}
|
||||
onClick={advanceTurn}
|
||||
disabled={!hasCombatants}
|
||||
title="Next turn"
|
||||
aria-label="Next turn"
|
||||
|
||||
@@ -9,7 +9,7 @@ const buttonVariants = cva(
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
outline:
|
||||
"border border-border bg-transparent text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||
"border border-border bg-background/50 text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||
ghost:
|
||||
"text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useClickOutside } from "../../hooks/use-click-outside.js";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
@@ -42,32 +43,7 @@ export function ConfirmButton({
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
// Click-outside listener when confirming
|
||||
useEffect(() => {
|
||||
if (!isConfirming) return;
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(e.target as Node)
|
||||
) {
|
||||
revert();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscapeKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
revert();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleEscapeKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleEscapeKey);
|
||||
};
|
||||
}, [isConfirming, revert]);
|
||||
useClickOutside(wrapperRef, revert, isConfirming);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
|
||||
71
apps/web/src/components/ui/dialog.tsx
Normal file
71
apps/web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { X } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
import { Button } from "./button.js";
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Dialog({ open, onClose, className, children }: DialogProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={cn(
|
||||
"m-auto rounded-lg border border-border bg-card text-foreground shadow-xl backdrop:bg-black/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="p-6">{children}</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogHeader({
|
||||
title,
|
||||
onClose,
|
||||
}: Readonly<{ title: string; onClose: () => void }>) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">{title}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export const Input = ({
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-foreground text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EllipsisVertical } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { type ReactNode, useRef, useState } from "react";
|
||||
import { useClickOutside } from "../../hooks/use-click-outside.js";
|
||||
import { Button } from "./button";
|
||||
|
||||
export interface OverflowMenuItem {
|
||||
@@ -7,6 +8,7 @@ export interface OverflowMenuItem {
|
||||
readonly label: string;
|
||||
readonly onClick: () => void;
|
||||
readonly disabled?: boolean;
|
||||
readonly keepOpen?: boolean;
|
||||
}
|
||||
|
||||
interface OverflowMenuProps {
|
||||
@@ -17,23 +19,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
useClickOutside(ref, () => setOpen(false), open);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
@@ -49,7 +35,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
||||
<EllipsisVertical className="h-5 w-5" />
|
||||
</Button>
|
||||
{!!open && (
|
||||
<div className="absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
|
||||
<div className="card-glow absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-lg border border-border bg-card py-1">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
@@ -58,7 +44,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
setOpen(false);
|
||||
if (!item.keepOpen) setOpen(false);
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
|
||||
55
apps/web/src/components/ui/tooltip.tsx
Normal file
55
apps/web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { type ReactNode, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface TooltipProps {
|
||||
content: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
content,
|
||||
children,
|
||||
className,
|
||||
}: Readonly<TooltipProps>) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
function show() {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setPos({
|
||||
top: rect.top - 4,
|
||||
left: rect.left + rect.width / 2,
|
||||
});
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setPos(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={ref}
|
||||
onPointerEnter={show}
|
||||
onPointerLeave={hide}
|
||||
className={className ?? "inline-flex"}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{pos !== null &&
|
||||
createPortal(
|
||||
<div
|
||||
role="tooltip"
|
||||
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full whitespace-pre-line rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/contexts/bestiary-context.tsx
Normal file
23
apps/web/src/contexts/bestiary-context.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useBestiary } from "../hooks/use-bestiary.js";
|
||||
|
||||
export type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
|
||||
type BestiaryContextValue = ReturnType<typeof useBestiary>;
|
||||
|
||||
const BestiaryContext = createContext<BestiaryContextValue | null>(null);
|
||||
|
||||
export function BestiaryProvider({ children }: { children: ReactNode }) {
|
||||
const value = useBestiary();
|
||||
return (
|
||||
<BestiaryContext.Provider value={value}>
|
||||
{children}
|
||||
</BestiaryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBestiaryContext(): BestiaryContextValue {
|
||||
const ctx = useContext(BestiaryContext);
|
||||
if (!ctx) throw new Error("useBestiaryContext requires BestiaryProvider");
|
||||
return ctx;
|
||||
}
|
||||
21
apps/web/src/contexts/bulk-import-context.tsx
Normal file
21
apps/web/src/contexts/bulk-import-context.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useBulkImport } from "../hooks/use-bulk-import.js";
|
||||
|
||||
type BulkImportContextValue = ReturnType<typeof useBulkImport>;
|
||||
|
||||
const BulkImportContext = createContext<BulkImportContextValue | null>(null);
|
||||
|
||||
export function BulkImportProvider({ children }: { children: ReactNode }) {
|
||||
const value = useBulkImport();
|
||||
return (
|
||||
<BulkImportContext.Provider value={value}>
|
||||
{children}
|
||||
</BulkImportContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBulkImportContext(): BulkImportContextValue {
|
||||
const ctx = useContext(BulkImportContext);
|
||||
if (!ctx) throw new Error("useBulkImportContext requires BulkImportProvider");
|
||||
return ctx;
|
||||
}
|
||||
23
apps/web/src/contexts/encounter-context.tsx
Normal file
23
apps/web/src/contexts/encounter-context.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useEncounter } from "../hooks/use-encounter.js";
|
||||
import { useUndoRedoShortcuts } from "../hooks/use-undo-redo-shortcuts.js";
|
||||
|
||||
type EncounterContextValue = ReturnType<typeof useEncounter>;
|
||||
|
||||
const EncounterContext = createContext<EncounterContextValue | null>(null);
|
||||
|
||||
export function EncounterProvider({ children }: { children: ReactNode }) {
|
||||
const value = useEncounter();
|
||||
useUndoRedoShortcuts(value.undo, value.redo, value.canUndo, value.canRedo);
|
||||
return (
|
||||
<EncounterContext.Provider value={value}>
|
||||
{children}
|
||||
</EncounterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useEncounterContext(): EncounterContextValue {
|
||||
const ctx = useContext(EncounterContext);
|
||||
if (!ctx) throw new Error("useEncounterContext requires EncounterProvider");
|
||||
return ctx;
|
||||
}
|
||||
8
apps/web/src/contexts/index.ts
Normal file
8
apps/web/src/contexts/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { BestiaryProvider } from "./bestiary-context.js";
|
||||
export { BulkImportProvider } from "./bulk-import-context.js";
|
||||
export { EncounterProvider } from "./encounter-context.js";
|
||||
export { InitiativeRollsProvider } from "./initiative-rolls-context.js";
|
||||
export { PlayerCharactersProvider } from "./player-characters-context.js";
|
||||
export { RulesEditionProvider } from "./rules-edition-context.js";
|
||||
export { SidePanelProvider } from "./side-panel-context.js";
|
||||
export { ThemeProvider } from "./theme-context.js";
|
||||
25
apps/web/src/contexts/initiative-rolls-context.tsx
Normal file
25
apps/web/src/contexts/initiative-rolls-context.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useInitiativeRolls } from "../hooks/use-initiative-rolls.js";
|
||||
|
||||
type InitiativeRollsContextValue = ReturnType<typeof useInitiativeRolls>;
|
||||
|
||||
const InitiativeRollsContext =
|
||||
createContext<InitiativeRollsContextValue | null>(null);
|
||||
|
||||
export function InitiativeRollsProvider({ children }: { children: ReactNode }) {
|
||||
const value = useInitiativeRolls();
|
||||
return (
|
||||
<InitiativeRollsContext.Provider value={value}>
|
||||
{children}
|
||||
</InitiativeRollsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useInitiativeRollsContext(): InitiativeRollsContextValue {
|
||||
const ctx = useContext(InitiativeRollsContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"useInitiativeRollsContext requires InitiativeRollsProvider",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
29
apps/web/src/contexts/player-characters-context.tsx
Normal file
29
apps/web/src/contexts/player-characters-context.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { usePlayerCharacters } from "../hooks/use-player-characters.js";
|
||||
|
||||
type PlayerCharactersContextValue = ReturnType<typeof usePlayerCharacters>;
|
||||
|
||||
const PlayerCharactersContext =
|
||||
createContext<PlayerCharactersContextValue | null>(null);
|
||||
|
||||
export function PlayerCharactersProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const value = usePlayerCharacters();
|
||||
return (
|
||||
<PlayerCharactersContext.Provider value={value}>
|
||||
{children}
|
||||
</PlayerCharactersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlayerCharactersContext(): PlayerCharactersContextValue {
|
||||
const ctx = useContext(PlayerCharactersContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"usePlayerCharactersContext requires PlayerCharactersProvider",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
24
apps/web/src/contexts/rules-edition-context.tsx
Normal file
24
apps/web/src/contexts/rules-edition-context.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useRulesEdition } from "../hooks/use-rules-edition.js";
|
||||
|
||||
type RulesEditionContextValue = ReturnType<typeof useRulesEdition>;
|
||||
|
||||
const RulesEditionContext = createContext<RulesEditionContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function RulesEditionProvider({ children }: { children: ReactNode }) {
|
||||
const value = useRulesEdition();
|
||||
return (
|
||||
<RulesEditionContext.Provider value={value}>
|
||||
{children}
|
||||
</RulesEditionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRulesEditionContext(): RulesEditionContextValue {
|
||||
const ctx = useContext(RulesEditionContext);
|
||||
if (!ctx)
|
||||
throw new Error("useRulesEditionContext requires RulesEditionProvider");
|
||||
return ctx;
|
||||
}
|
||||
21
apps/web/src/contexts/side-panel-context.tsx
Normal file
21
apps/web/src/contexts/side-panel-context.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useSidePanelState } from "../hooks/use-side-panel-state.js";
|
||||
|
||||
type SidePanelContextValue = ReturnType<typeof useSidePanelState>;
|
||||
|
||||
const SidePanelContext = createContext<SidePanelContextValue | null>(null);
|
||||
|
||||
export function SidePanelProvider({ children }: { children: ReactNode }) {
|
||||
const value = useSidePanelState();
|
||||
return (
|
||||
<SidePanelContext.Provider value={value}>
|
||||
{children}
|
||||
</SidePanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidePanelContext(): SidePanelContextValue {
|
||||
const ctx = useContext(SidePanelContext);
|
||||
if (!ctx) throw new Error("useSidePanelContext requires SidePanelProvider");
|
||||
return ctx;
|
||||
}
|
||||
19
apps/web/src/contexts/theme-context.tsx
Normal file
19
apps/web/src/contexts/theme-context.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useTheme } from "../hooks/use-theme.js";
|
||||
|
||||
type ThemeContextValue = ReturnType<typeof useTheme>;
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const value = useTheme();
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useThemeContext(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useThemeContext requires ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
416
apps/web/src/hooks/__tests__/encounter-reducer.test.ts
Normal file
416
apps/web/src/hooks/__tests__/encounter-reducer.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
ConditionId,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
combatantId,
|
||||
createEncounter,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
isDomainError,
|
||||
playerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
||||
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: vi.fn().mockReturnValue(null),
|
||||
saveEncounter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/undo-redo-storage.js", () => ({
|
||||
loadUndoRedoStacks: vi.fn().mockReturnValue(EMPTY_UNDO_REDO_STATE),
|
||||
saveUndoRedoStacks: vi.fn(),
|
||||
}));
|
||||
|
||||
function emptyState(): EncounterState {
|
||||
return {
|
||||
encounter: {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
||||
events: [],
|
||||
nextId: 0,
|
||||
lastCreatureId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function stateWith(...names: string[]): EncounterState {
|
||||
let state = emptyState();
|
||||
for (const name of names) {
|
||||
state = encounterReducer(state, { type: "add-combatant", name });
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function stateWithHp(name: string, maxHp: number): EncounterState {
|
||||
const state = stateWith(name);
|
||||
const id = state.encounter.combatants[0].id;
|
||||
return encounterReducer(state, {
|
||||
type: "set-hp",
|
||||
id,
|
||||
maxHp,
|
||||
});
|
||||
}
|
||||
|
||||
const BESTIARY_ENTRY: BestiaryIndexEntry = {
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
cr: "1/4",
|
||||
initiativeProficiency: 0,
|
||||
size: "Small",
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
describe("encounterReducer", () => {
|
||||
describe("add-combatant", () => {
|
||||
it("adds a combatant and pushes undo", () => {
|
||||
const next = encounterReducer(emptyState(), {
|
||||
type: "add-combatant",
|
||||
name: "Goblin",
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants).toHaveLength(1);
|
||||
expect(next.encounter.combatants[0].name).toBe("Goblin");
|
||||
expect(next.undoRedoState.undoStack).toHaveLength(1);
|
||||
expect(next.nextId).toBe(1);
|
||||
});
|
||||
|
||||
it("applies optional init values", () => {
|
||||
const next = encounterReducer(emptyState(), {
|
||||
type: "add-combatant",
|
||||
name: "Goblin",
|
||||
init: { initiative: 15, ac: 13, maxHp: 7 },
|
||||
});
|
||||
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.initiative).toBe(15);
|
||||
expect(c.ac).toBe(13);
|
||||
expect(c.maxHp).toBe(7);
|
||||
expect(c.currentHp).toBe(7);
|
||||
});
|
||||
|
||||
it("increments IDs", () => {
|
||||
const s1 = encounterReducer(emptyState(), {
|
||||
type: "add-combatant",
|
||||
name: "A",
|
||||
});
|
||||
const s2 = encounterReducer(s1, {
|
||||
type: "add-combatant",
|
||||
name: "B",
|
||||
});
|
||||
|
||||
expect(s2.encounter.combatants[0].id).toBe("c-1");
|
||||
expect(s2.encounter.combatants[1].id).toBe("c-2");
|
||||
});
|
||||
|
||||
it("returns unchanged state for invalid name", () => {
|
||||
const state = emptyState();
|
||||
const next = encounterReducer(state, {
|
||||
type: "add-combatant",
|
||||
name: "",
|
||||
});
|
||||
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove-combatant", () => {
|
||||
it("removes combatant and pushes undo", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "remove-combatant",
|
||||
id,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants).toHaveLength(0);
|
||||
expect(next.undoRedoState.undoStack).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit-combatant", () => {
|
||||
it("renames combatant", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "edit-combatant",
|
||||
id,
|
||||
newName: "Hobgoblin",
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].name).toBe("Hobgoblin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("advance-turn / retreat-turn", () => {
|
||||
it("advances and retreats turn", () => {
|
||||
const state = stateWith("A", "B");
|
||||
const advanced = encounterReducer(state, {
|
||||
type: "advance-turn",
|
||||
});
|
||||
expect(advanced.encounter.activeIndex).toBe(1);
|
||||
|
||||
const retreated = encounterReducer(advanced, {
|
||||
type: "retreat-turn",
|
||||
});
|
||||
expect(retreated.encounter.activeIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("returns unchanged state on empty encounter", () => {
|
||||
const state = emptyState();
|
||||
const next = encounterReducer(state, { type: "advance-turn" });
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("set-hp / adjust-hp / set-temp-hp", () => {
|
||||
it("sets max HP", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-hp",
|
||||
id,
|
||||
maxHp: 20,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].maxHp).toBe(20);
|
||||
expect(next.encounter.combatants[0].currentHp).toBe(20);
|
||||
});
|
||||
|
||||
it("adjusts HP", () => {
|
||||
const state = stateWithHp("Goblin", 20);
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "adjust-hp",
|
||||
id,
|
||||
delta: -5,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].currentHp).toBe(15);
|
||||
});
|
||||
|
||||
it("sets temp HP", () => {
|
||||
const state = stateWithHp("Goblin", 20);
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-temp-hp",
|
||||
id,
|
||||
tempHp: 5,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].tempHp).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("set-ac", () => {
|
||||
it("sets AC", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-ac",
|
||||
id,
|
||||
value: 15,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].ac).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("set-initiative", () => {
|
||||
it("sets initiative", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-initiative",
|
||||
id,
|
||||
value: 18,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].initiative).toBe(18);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle-condition / toggle-concentration", () => {
|
||||
it("toggles condition", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "toggle-condition",
|
||||
id,
|
||||
conditionId: "blinded" as ConditionId,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].conditions).toContain("blinded");
|
||||
});
|
||||
|
||||
it("toggles concentration", () => {
|
||||
const state = stateWith("Wizard");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "toggle-concentration",
|
||||
id,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].isConcentrating).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear-encounter", () => {
|
||||
it("clears combatants, resets history and nextId", () => {
|
||||
const state = stateWith("A", "B");
|
||||
const next = encounterReducer(state, {
|
||||
type: "clear-encounter",
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants).toHaveLength(0);
|
||||
expect(next.undoRedoState.undoStack).toHaveLength(0);
|
||||
expect(next.undoRedoState.redoStack).toHaveLength(0);
|
||||
expect(next.nextId).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo / redo", () => {
|
||||
it("undo restores previous state", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const next = encounterReducer(state, { type: "undo" });
|
||||
|
||||
expect(next.encounter.combatants).toHaveLength(0);
|
||||
expect(next.undoRedoState.undoStack).toHaveLength(0);
|
||||
expect(next.undoRedoState.redoStack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("redo restores undone state", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const undone = encounterReducer(state, { type: "undo" });
|
||||
const redone = encounterReducer(undone, { type: "redo" });
|
||||
|
||||
expect(redone.encounter.combatants).toHaveLength(1);
|
||||
expect(redone.encounter.combatants[0].name).toBe("Goblin");
|
||||
});
|
||||
|
||||
it("undo returns unchanged state when stack is empty", () => {
|
||||
const state = emptyState();
|
||||
const next = encounterReducer(state, { type: "undo" });
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
|
||||
it("redo returns unchanged state when stack is empty", () => {
|
||||
const state = emptyState();
|
||||
const next = encounterReducer(state, { type: "redo" });
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-from-bestiary", () => {
|
||||
it("adds creature with HP, AC, and creatureId", () => {
|
||||
const next = encounterReducer(emptyState(), {
|
||||
type: "add-from-bestiary",
|
||||
entry: BESTIARY_ENTRY,
|
||||
});
|
||||
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.name).toBe("Goblin");
|
||||
expect(c.maxHp).toBe(7);
|
||||
expect(c.ac).toBe(15);
|
||||
expect(c.creatureId).toBe("mm:goblin");
|
||||
expect(next.lastCreatureId).toBe("mm:goblin");
|
||||
expect(next.undoRedoState.undoStack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("auto-numbers duplicate names", () => {
|
||||
const s1 = encounterReducer(emptyState(), {
|
||||
type: "add-from-bestiary",
|
||||
entry: BESTIARY_ENTRY,
|
||||
});
|
||||
const s2 = encounterReducer(s1, {
|
||||
type: "add-from-bestiary",
|
||||
entry: BESTIARY_ENTRY,
|
||||
});
|
||||
|
||||
const names = s2.encounter.combatants.map((c) => c.name);
|
||||
expect(names).toContain("Goblin 1");
|
||||
expect(names).toContain("Goblin 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-multiple-from-bestiary", () => {
|
||||
it("adds multiple creatures in one action", () => {
|
||||
const next = encounterReducer(emptyState(), {
|
||||
type: "add-multiple-from-bestiary",
|
||||
entry: BESTIARY_ENTRY,
|
||||
count: 3,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants).toHaveLength(3);
|
||||
expect(next.undoRedoState.undoStack).toHaveLength(1);
|
||||
expect(next.lastCreatureId).toBe("mm:goblin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-from-player-character", () => {
|
||||
it("adds combatant with PC attributes", () => {
|
||||
const pc: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 30,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
};
|
||||
const next = encounterReducer(emptyState(), {
|
||||
type: "add-from-player-character",
|
||||
pc,
|
||||
});
|
||||
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.name).toBe("Aria");
|
||||
expect(c.maxHp).toBe(30);
|
||||
expect(c.ac).toBe(16);
|
||||
expect(c.color).toBe("blue");
|
||||
expect(c.icon).toBe("sword");
|
||||
expect(c.playerCharacterId).toBe("pc-1");
|
||||
expect(next.lastCreatureId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("import", () => {
|
||||
it("replaces encounter and undo/redo state", () => {
|
||||
const state = stateWith("A", "B");
|
||||
const enc = createEncounter([
|
||||
{ id: combatantId("c-5"), name: "Imported" },
|
||||
]);
|
||||
if (isDomainError(enc)) throw new Error("Setup failed");
|
||||
|
||||
const next = encounterReducer(state, {
|
||||
type: "import",
|
||||
encounter: enc,
|
||||
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants).toHaveLength(1);
|
||||
expect(next.encounter.combatants[0].name).toBe("Imported");
|
||||
expect(next.nextId).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("events accumulation", () => {
|
||||
it("accumulates events across actions", () => {
|
||||
const s1 = encounterReducer(emptyState(), {
|
||||
type: "add-combatant",
|
||||
name: "A",
|
||||
});
|
||||
const s2 = encounterReducer(s1, {
|
||||
type: "add-combatant",
|
||||
name: "B",
|
||||
});
|
||||
|
||||
expect(s2.events.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
328
apps/web/src/hooks/__tests__/use-action-bar-state.test.ts
Normal file
328
apps/web/src/hooks/__tests__/use-action-bar-state.test.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { playerCharacterId } from "@initiative/domain";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SearchResult } from "../../contexts/bestiary-context.js";
|
||||
import { useActionBarState } from "../use-action-bar-state.js";
|
||||
|
||||
const mockAddCombatant = vi.fn();
|
||||
const mockAddFromBestiary = vi.fn();
|
||||
const mockAddMultipleFromBestiary = vi.fn();
|
||||
const mockAddFromPlayerCharacter = vi.fn();
|
||||
const mockBestiarySearch = vi.fn<(q: string) => SearchResult[]>();
|
||||
const mockShowCreature = vi.fn();
|
||||
const mockShowBulkImport = vi.fn();
|
||||
const mockShowSourceManager = vi.fn();
|
||||
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: () => ({
|
||||
addCombatant: mockAddCombatant,
|
||||
addFromBestiary: mockAddFromBestiary,
|
||||
addMultipleFromBestiary: mockAddMultipleFromBestiary,
|
||||
addFromPlayerCharacter: mockAddFromPlayerCharacter,
|
||||
lastCreatureId: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: () => ({
|
||||
search: mockBestiarySearch,
|
||||
isLoaded: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
||||
usePlayerCharactersContext: () => ({
|
||||
characters: mockPlayerCharacters,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/side-panel-context.js", () => ({
|
||||
useSidePanelContext: () => ({
|
||||
showCreature: mockShowCreature,
|
||||
showBulkImport: mockShowBulkImport,
|
||||
showSourceManager: mockShowSourceManager,
|
||||
panelView: { mode: "closed" },
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockPlayerCharacters: PlayerCharacter[] = [];
|
||||
|
||||
const GOBLIN: SearchResult = {
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
cr: "1/4",
|
||||
initiativeProficiency: 0,
|
||||
size: "Small",
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
const ORC: SearchResult = {
|
||||
name: "Orc",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
ac: 13,
|
||||
hp: 15,
|
||||
dex: 12,
|
||||
cr: "1/2",
|
||||
initiativeProficiency: 0,
|
||||
size: "Medium",
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
function renderActionBar() {
|
||||
return renderHook(() => useActionBarState());
|
||||
}
|
||||
|
||||
describe("useActionBarState", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockBestiarySearch.mockReturnValue([]);
|
||||
mockPlayerCharacters = [];
|
||||
});
|
||||
|
||||
describe("search and suggestions", () => {
|
||||
it("starts with empty state", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
expect(result.current.nameInput).toBe("");
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
expect(result.current.queued).toBeNull();
|
||||
expect(result.current.browseMode).toBe(false);
|
||||
});
|
||||
|
||||
it("searches bestiary when input >= 2 chars", () => {
|
||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("go"));
|
||||
|
||||
expect(mockBestiarySearch).toHaveBeenCalledWith("go");
|
||||
expect(result.current.nameInput).toBe("go");
|
||||
});
|
||||
|
||||
it("does not search when input < 2 chars", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("g"));
|
||||
|
||||
expect(mockBestiarySearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches player characters by name", () => {
|
||||
mockPlayerCharacters = [
|
||||
{
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Gandalf",
|
||||
ac: 15,
|
||||
maxHp: 40,
|
||||
},
|
||||
];
|
||||
mockBestiarySearch.mockReturnValue([]);
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("gan"));
|
||||
|
||||
expect(result.current.pcMatches).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queued creatures", () => {
|
||||
it("queues a creature on click", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
|
||||
expect(result.current.queued).toEqual({
|
||||
result: GOBLIN,
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("increments count when same creature clicked again", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
|
||||
expect(result.current.queued?.count).toBe(2);
|
||||
});
|
||||
|
||||
it("resets queue when different creature clicked", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(ORC));
|
||||
|
||||
expect(result.current.queued).toEqual({
|
||||
result: ORC,
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("confirmQueued calls addFromBestiary for count=1", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.confirmQueued());
|
||||
|
||||
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
|
||||
expect(result.current.queued).toBeNull();
|
||||
});
|
||||
|
||||
it("confirmQueued calls addMultipleFromBestiary for count>1", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.confirmQueued());
|
||||
|
||||
expect(mockAddMultipleFromBestiary).toHaveBeenCalledWith(GOBLIN, 3);
|
||||
});
|
||||
|
||||
it("clears queued when search text changes and creature no longer visible", () => {
|
||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("go"));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
|
||||
// Change search to something that won't match
|
||||
mockBestiarySearch.mockReturnValue([]);
|
||||
act(() => result.current.handleNameChange("xyz"));
|
||||
|
||||
expect(result.current.queued).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("form submission", () => {
|
||||
it("adds custom combatant on submit", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("Fighter"));
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||
act(() => result.current.handleAdd(event));
|
||||
|
||||
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", undefined);
|
||||
expect(result.current.nameInput).toBe("");
|
||||
});
|
||||
|
||||
it("does not add when name is empty", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||
act(() => result.current.handleAdd(event));
|
||||
|
||||
expect(mockAddCombatant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes custom init/ac/maxHp when set", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("Fighter"));
|
||||
act(() => result.current.setCustomInit("15"));
|
||||
act(() => result.current.setCustomAc("18"));
|
||||
act(() => result.current.setCustomMaxHp("45"));
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||
act(() => result.current.handleAdd(event));
|
||||
|
||||
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", {
|
||||
initiative: 15,
|
||||
ac: 18,
|
||||
maxHp: 45,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not submit in browse mode", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.toggleBrowseMode());
|
||||
act(() => result.current.handleNameChange("Fighter"));
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||
act(() => result.current.handleAdd(event));
|
||||
|
||||
expect(mockAddCombatant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirms queued on submit instead of adding by name", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||
act(() => result.current.handleAdd(event));
|
||||
|
||||
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
|
||||
expect(mockAddCombatant).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("browse mode", () => {
|
||||
it("toggles browse mode", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.toggleBrowseMode());
|
||||
expect(result.current.browseMode).toBe(true);
|
||||
|
||||
act(() => result.current.toggleBrowseMode());
|
||||
expect(result.current.browseMode).toBe(false);
|
||||
});
|
||||
|
||||
it("handleBrowseSelect shows creature and exits browse mode", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.toggleBrowseMode());
|
||||
act(() => result.current.handleBrowseSelect(GOBLIN));
|
||||
|
||||
expect(mockShowCreature).toHaveBeenCalledWith("mm:goblin");
|
||||
expect(result.current.browseMode).toBe(false);
|
||||
expect(result.current.nameInput).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dismiss and clear", () => {
|
||||
it("dismissSuggestions clears suggestions and queued", () => {
|
||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("go"));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.dismiss());
|
||||
|
||||
expect(result.current.queued).toBeNull();
|
||||
expect(result.current.suggestionIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it("clear resets everything", () => {
|
||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("go"));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.clear());
|
||||
|
||||
expect(result.current.nameInput).toBe("");
|
||||
expect(result.current.queued).toBeNull();
|
||||
expect(result.current.suggestionIndex).toBe(-1);
|
||||
});
|
||||
});
|
||||
});
|
||||
145
apps/web/src/hooks/__tests__/use-bulk-import.test.ts
Normal file
145
apps/web/src/hooks/__tests__/use-bulk-import.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useBulkImport } from "../use-bulk-import.js";
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||
getDefaultFetchUrl: (code: string, baseUrl: string) =>
|
||||
`${baseUrl}${code}.json`,
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
/** Flush microtasks so the internal async IIFE inside startImport settles. */
|
||||
function flushMicrotasks(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
describe("useBulkImport", () => {
|
||||
it("starts in idle state with all counters at 0", () => {
|
||||
const { result } = renderHook(() => useBulkImport());
|
||||
expect(result.current.state).toEqual({
|
||||
status: "idle",
|
||||
total: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("reset returns to idle state", async () => {
|
||||
const { result } = renderHook(() => useBulkImport());
|
||||
|
||||
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||
const fetchAndCacheSource = vi.fn();
|
||||
const refreshCache = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
result.current.startImport(
|
||||
"https://example.com/",
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.state.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("goes straight to complete when all sources are cached", async () => {
|
||||
const { result } = renderHook(() => useBulkImport());
|
||||
|
||||
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||
const fetchAndCacheSource = vi.fn();
|
||||
const refreshCache = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
result.current.startImport(
|
||||
"https://example.com/",
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(result.current.state.status).toBe("complete");
|
||||
expect(result.current.state.completed).toBe(3);
|
||||
expect(fetchAndCacheSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches uncached sources and completes", async () => {
|
||||
const { result } = renderHook(() => useBulkImport());
|
||||
|
||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await act(async () => {
|
||||
result.current.startImport(
|
||||
"https://example.com/",
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(result.current.state.status).toBe("complete");
|
||||
expect(result.current.state.completed).toBe(3);
|
||||
expect(result.current.state.failed).toBe(0);
|
||||
expect(fetchAndCacheSource).toHaveBeenCalledTimes(3);
|
||||
expect(refreshCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports partial-failure when some sources fail", async () => {
|
||||
const { result } = renderHook(() => useBulkImport());
|
||||
|
||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||
const fetchAndCacheSource = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("fail"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await act(async () => {
|
||||
result.current.startImport(
|
||||
"https://example.com/",
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(result.current.state.status).toBe("partial-failure");
|
||||
expect(result.current.state.completed).toBe(2);
|
||||
expect(result.current.state.failed).toBe(1);
|
||||
expect(refreshCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls refreshCache after all batches complete", async () => {
|
||||
const { result } = renderHook(() => useBulkImport());
|
||||
|
||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await act(async () => {
|
||||
result.current.startImport(
|
||||
"https://example.com/",
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(refreshCache).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
220
apps/web/src/hooks/__tests__/use-difficulty.test.ts
Normal file
220
apps/web/src/hooks/__tests__/use-difficulty.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
// @vitest-environment jsdom
|
||||
import type {
|
||||
Combatant,
|
||||
CreatureId,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
||||
usePlayerCharactersContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../../contexts/player-characters-context.js";
|
||||
import { useDifficulty } from "../use-difficulty.js";
|
||||
|
||||
const mockEncounterContext = vi.mocked(useEncounterContext);
|
||||
const mockPlayerCharactersContext = vi.mocked(usePlayerCharactersContext);
|
||||
const mockBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
|
||||
const pcId1 = playerCharacterId("pc-1");
|
||||
const pcId2 = playerCharacterId("pc-2");
|
||||
const crId1 = creatureId("creature-1");
|
||||
const _crId2 = creatureId("creature-2");
|
||||
|
||||
function setup(options: {
|
||||
combatants: Combatant[];
|
||||
characters: PlayerCharacter[];
|
||||
creatures: Map<CreatureId, { cr: string }>;
|
||||
}) {
|
||||
const encounter = {
|
||||
combatants: options.combatants,
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
} as Encounter;
|
||||
|
||||
mockEncounterContext.mockReturnValue({
|
||||
encounter,
|
||||
} as ReturnType<typeof useEncounterContext>);
|
||||
|
||||
mockPlayerCharactersContext.mockReturnValue({
|
||||
characters: options.characters,
|
||||
} as ReturnType<typeof usePlayerCharactersContext>);
|
||||
|
||||
mockBestiaryContext.mockReturnValue({
|
||||
getCreature: (id: CreatureId) => options.creatures.get(id),
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useDifficulty", () => {
|
||||
it("returns difficulty result for leveled PCs and bestiary monsters", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1 },
|
||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
||||
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
expect(result.current?.tier).toBe("low");
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
});
|
||||
|
||||
describe("returns null when data is insufficient (ED-2)", () => {
|
||||
it("returns null when encounter has no combatants", () => {
|
||||
setup({ combatants: [], characters: [], creatures: new Map() });
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when only custom combatants (no creatureId)", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Custom",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }],
|
||||
creatures: new Map(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when bestiary monsters present but no PC combatants", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{ id: combatantId("c1"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when PC combatants have no level", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when PC combatant references unknown player character", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId2,
|
||||
},
|
||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 }],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles mixed combatants: only leveled PCs and bestiary monsters contribute", () => {
|
||||
// Party: one leveled PC, one without level (excluded)
|
||||
// Monsters: one bestiary creature, one custom (excluded)
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Leveled",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{
|
||||
id: combatantId("c2"),
|
||||
name: "No Level",
|
||||
playerCharacterId: pcId2,
|
||||
},
|
||||
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
||||
{ id: combatantId("c4"), name: "Custom Monster" },
|
||||
],
|
||||
characters: [
|
||||
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
|
||||
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
|
||||
],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
// 1 level-1 PC: budget low=50, mod=75, high=100
|
||||
// 1 CR 1 monster: 200 XP → high (200 >= 100)
|
||||
expect(result.current?.tier).toBe("high");
|
||||
expect(result.current?.totalMonsterXp).toBe(200);
|
||||
expect(result.current?.partyBudget.low).toBe(50);
|
||||
});
|
||||
|
||||
it("includes duplicate PC combatants in budget", () => {
|
||||
// Same PC added twice → counts twice
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Hero 1",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{
|
||||
id: combatantId("c2"),
|
||||
name: "Hero 2",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
||||
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
// 2x level 1: budget low=100
|
||||
expect(result.current?.partyBudget.low).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -137,7 +137,9 @@ describe("useEncounter", () => {
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
act(() => result.current.addFromBestiary(entry));
|
||||
act(() => {
|
||||
result.current.addFromBestiary(entry);
|
||||
});
|
||||
|
||||
expect(result.current.hasCreatureCombatants).toBe(true);
|
||||
expect(result.current.canRollAllInitiative).toBe(true);
|
||||
@@ -158,7 +160,9 @@ describe("useEncounter", () => {
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
act(() => result.current.addFromBestiary(entry));
|
||||
act(() => {
|
||||
result.current.addFromBestiary(entry);
|
||||
});
|
||||
|
||||
const combatant = result.current.encounter.combatants[0];
|
||||
expect(combatant.name).toBe("Goblin");
|
||||
@@ -183,8 +187,12 @@ describe("useEncounter", () => {
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
act(() => result.current.addFromBestiary(entry));
|
||||
act(() => result.current.addFromBestiary(entry));
|
||||
act(() => {
|
||||
result.current.addFromBestiary(entry);
|
||||
});
|
||||
act(() => {
|
||||
result.current.addFromBestiary(entry);
|
||||
});
|
||||
|
||||
const names = result.current.encounter.combatants.map((c) => c.name);
|
||||
expect(names).toContain("Goblin 1");
|
||||
|
||||
118
apps/web/src/hooks/__tests__/use-initiative-rolls.test.ts
Normal file
118
apps/web/src/hooks/__tests__/use-initiative-rolls.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { type CreatureId, combatantId } from "@initiative/domain";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useInitiativeRolls } from "../use-initiative-rolls.js";
|
||||
|
||||
const mockMakeStore = vi.fn(() => ({}));
|
||||
const mockWithUndo = vi.fn((fn: () => unknown) => fn());
|
||||
const mockGetCreature = vi.fn();
|
||||
const mockShowCreature = vi.fn();
|
||||
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: () => ({
|
||||
encounter: {
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Goblin",
|
||||
creatureId: "srd:goblin" as CreatureId,
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
makeStore: mockMakeStore,
|
||||
withUndo: mockWithUndo,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: () => ({
|
||||
getCreature: mockGetCreature,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/side-panel-context.js", () => ({
|
||||
useSidePanelContext: () => ({
|
||||
showCreature: mockShowCreature,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockRollInitiativeUseCase = vi.fn();
|
||||
const mockRollAllInitiativeUseCase = vi.fn();
|
||||
|
||||
vi.mock("@initiative/application", () => ({
|
||||
rollInitiativeUseCase: (...args: unknown[]) =>
|
||||
mockRollInitiativeUseCase(...args),
|
||||
rollAllInitiativeUseCase: (...args: unknown[]) =>
|
||||
mockRollAllInitiativeUseCase(...args),
|
||||
}));
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
describe("useInitiativeRolls", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("handleRollInitiative calls rollInitiativeUseCase via withUndo", () => {
|
||||
mockRollInitiativeUseCase.mockReturnValue({ initiative: 15 });
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||
|
||||
expect(mockWithUndo).toHaveBeenCalled();
|
||||
expect(mockRollInitiativeUseCase).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets rollSingleSkipped on domain error", () => {
|
||||
mockRollInitiativeUseCase.mockReturnValue({
|
||||
kind: "domain-error",
|
||||
code: "missing-source",
|
||||
message: "no source",
|
||||
});
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||
expect(result.current.rollSingleSkipped).toBe(true);
|
||||
expect(mockShowCreature).toHaveBeenCalledWith("srd:goblin");
|
||||
});
|
||||
|
||||
it("dismissRollSingleSkipped resets the flag", () => {
|
||||
mockRollInitiativeUseCase.mockReturnValue({
|
||||
kind: "domain-error",
|
||||
code: "missing-source",
|
||||
message: "no source",
|
||||
});
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||
expect(result.current.rollSingleSkipped).toBe(true);
|
||||
|
||||
act(() => result.current.dismissRollSingleSkipped());
|
||||
expect(result.current.rollSingleSkipped).toBe(false);
|
||||
});
|
||||
|
||||
it("handleRollAllInitiative sets rollSkippedCount when sources missing", () => {
|
||||
mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 3 });
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollAllInitiative());
|
||||
expect(result.current.rollSkippedCount).toBe(3);
|
||||
});
|
||||
|
||||
it("dismissRollSkipped resets the count", () => {
|
||||
mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 2 });
|
||||
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||
|
||||
act(() => result.current.handleRollAllInitiative());
|
||||
act(() => result.current.dismissRollSkipped());
|
||||
expect(result.current.rollSkippedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
104
apps/web/src/hooks/__tests__/use-long-press.test.ts
Normal file
104
apps/web/src/hooks/__tests__/use-long-press.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useLongPress } from "../use-long-press.js";
|
||||
|
||||
function touchEvent(overrides?: Partial<React.TouchEvent>): React.TouchEvent {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as React.TouchEvent;
|
||||
}
|
||||
|
||||
describe("useLongPress", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns onTouchStart, onTouchEnd, onTouchMove handlers", () => {
|
||||
const { result } = renderHook(() => useLongPress(vi.fn()));
|
||||
expect(result.current.onTouchStart).toBeInstanceOf(Function);
|
||||
expect(result.current.onTouchEnd).toBeInstanceOf(Function);
|
||||
expect(result.current.onTouchMove).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it("fires onLongPress after 500ms hold", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
const e = touchEvent();
|
||||
act(() => result.current.onTouchStart(e));
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(onLongPress).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not fire if released before 500ms", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
act(() => result.current.onTouchStart(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
act(() => result.current.onTouchEnd(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels on touch move", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
act(() => result.current.onTouchStart(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
act(() => result.current.onTouchMove());
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onTouchEnd calls preventDefault after long press fires", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
act(() => result.current.onTouchStart(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
const preventDefaultSpy = vi.fn();
|
||||
const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
|
||||
act(() => result.current.onTouchEnd(endEvent));
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onTouchEnd does not preventDefault when long press did not fire", () => {
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||
|
||||
act(() => result.current.onTouchStart(touchEvent()));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
const preventDefaultSpy = vi.fn();
|
||||
const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
|
||||
act(() => result.current.onTouchEnd(endEvent));
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,14 @@ describe("usePlayerCharacters", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||
result.current.createCharacter(
|
||||
"Vex",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.characters).toHaveLength(1);
|
||||
@@ -57,7 +64,14 @@ describe("usePlayerCharacters", () => {
|
||||
|
||||
let error: unknown;
|
||||
act(() => {
|
||||
error = result.current.createCharacter("", 15, 28, undefined, undefined);
|
||||
error = result.current.createCharacter(
|
||||
"",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
expect(error).toMatchObject({ kind: "domain-error" });
|
||||
@@ -68,7 +82,14 @@ describe("usePlayerCharacters", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||
result.current.createCharacter(
|
||||
"Vex",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const id = result.current.characters[0].id;
|
||||
@@ -85,7 +106,14 @@ describe("usePlayerCharacters", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||
result.current.createCharacter(
|
||||
"Vex",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const id = result.current.characters[0].id;
|
||||
|
||||
45
apps/web/src/hooks/__tests__/use-rules-edition.test.ts
Normal file
45
apps/web/src/hooks/__tests__/use-rules-edition.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useRulesEdition } from "../use-rules-edition.js";
|
||||
|
||||
const STORAGE_KEY = "initiative:rules-edition";
|
||||
|
||||
describe("useRulesEdition", () => {
|
||||
afterEach(() => {
|
||||
// Reset to default
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
act(() => result.current.setEdition("5.5e"));
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("defaults to 5.5e", () => {
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
expect(result.current.edition).toBe("5.5e");
|
||||
});
|
||||
|
||||
it("setEdition updates value", () => {
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
|
||||
act(() => result.current.setEdition("5e"));
|
||||
|
||||
expect(result.current.edition).toBe("5e");
|
||||
});
|
||||
|
||||
it("setEdition persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
|
||||
act(() => result.current.setEdition("5e"));
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("5e");
|
||||
});
|
||||
|
||||
it("multiple hooks stay in sync", () => {
|
||||
const { result: r1 } = renderHook(() => useRulesEdition());
|
||||
const { result: r2 } = renderHook(() => useRulesEdition());
|
||||
|
||||
act(() => r1.current.setEdition("5e"));
|
||||
|
||||
expect(r2.current.edition).toBe("5e");
|
||||
});
|
||||
});
|
||||
116
apps/web/src/hooks/__tests__/use-swipe-to-dismiss.test.ts
Normal file
116
apps/web/src/hooks/__tests__/use-swipe-to-dismiss.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSwipeToDismiss } from "../use-swipe-to-dismiss.js";
|
||||
|
||||
const PANEL_WIDTH = 300;
|
||||
|
||||
function makeTouchEvent(clientX: number, clientY = 0): React.TouchEvent {
|
||||
return {
|
||||
touches: [{ clientX, clientY }],
|
||||
currentTarget: {
|
||||
getBoundingClientRect: () => ({ width: PANEL_WIDTH }),
|
||||
},
|
||||
} as unknown as React.TouchEvent;
|
||||
}
|
||||
|
||||
describe("useSwipeToDismiss", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("starts with offsetX 0 and isSwiping false", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
expect(result.current.isSwiping).toBe(false);
|
||||
});
|
||||
|
||||
it("horizontal drag updates offsetX and sets isSwiping", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
|
||||
|
||||
expect(result.current.offsetX).toBe(50);
|
||||
expect(result.current.isSwiping).toBe(true);
|
||||
});
|
||||
|
||||
it("vertical drag is ignored after direction lock", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0, 0)));
|
||||
// Move vertically > 10px to lock vertical
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(0, 20)));
|
||||
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
});
|
||||
|
||||
it("small movement does not lock direction", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(5)));
|
||||
|
||||
// No direction locked yet, no update
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
expect(result.current.isSwiping).toBe(false);
|
||||
});
|
||||
|
||||
it("leftward drag is clamped to 0", () => {
|
||||
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(100)));
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
|
||||
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
});
|
||||
|
||||
it("calls onDismiss when ratio exceeds threshold", () => {
|
||||
const onDismiss = vi.fn();
|
||||
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
// Move > 35% of panel width (300 * 0.35 = 105)
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(120)));
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(5000); // slow swipe
|
||||
act(() => result.current.handlers.onTouchEnd());
|
||||
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onDismiss with fast velocity", () => {
|
||||
const onDismiss = vi.fn();
|
||||
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
// Small distance but fast
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(30)));
|
||||
|
||||
// Very fast: 30px in 0.1s = 300px/s, velocity = 300/300 = 1.0 > 0.5
|
||||
vi.spyOn(Date, "now").mockReturnValue(100);
|
||||
act(() => result.current.handlers.onTouchEnd());
|
||||
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not dismiss when below thresholds", () => {
|
||||
const onDismiss = vi.fn();
|
||||
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||
|
||||
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||
// Small distance, slow speed
|
||||
act(() => result.current.handlers.onTouchMove(makeTouchEvent(20)));
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(5000);
|
||||
act(() => result.current.handlers.onTouchEnd());
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
expect(result.current.isSwiping).toBe(false);
|
||||
});
|
||||
});
|
||||
63
apps/web/src/hooks/__tests__/use-theme.test.ts
Normal file
63
apps/web/src/hooks/__tests__/use-theme.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useTheme } from "../use-theme.js";
|
||||
|
||||
const STORAGE_KEY = "initiative:theme";
|
||||
|
||||
describe("useTheme", () => {
|
||||
afterEach(() => {
|
||||
// Reset to default
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setPreference("system"));
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("defaults to system preference", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.preference).toBe("system");
|
||||
});
|
||||
|
||||
it("setPreference updates to light", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => result.current.setPreference("light"));
|
||||
|
||||
expect(result.current.preference).toBe("light");
|
||||
expect(result.current.resolved).toBe("light");
|
||||
});
|
||||
|
||||
it("setPreference updates to dark", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => result.current.setPreference("dark"));
|
||||
|
||||
expect(result.current.preference).toBe("dark");
|
||||
expect(result.current.resolved).toBe("dark");
|
||||
});
|
||||
|
||||
it("persists preference to localStorage", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => result.current.setPreference("light"));
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("light");
|
||||
});
|
||||
|
||||
it("applies theme to document element", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => result.current.setPreference("light"));
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("multiple hooks stay in sync", () => {
|
||||
const { result: r1 } = renderHook(() => useTheme());
|
||||
const { result: r2 } = renderHook(() => useTheme());
|
||||
|
||||
act(() => r1.current.setPreference("dark"));
|
||||
|
||||
expect(r2.current.preference).toBe("dark");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user