Compare commits
12 Commits
1de00e3d8e
...
c295840b7b
| Author | SHA1 | Date | |
|---|---|---|---|
| c295840b7b | |||
| d13641152f | |||
| 110f4726ae | |||
| 2bc22369ce | |||
| 2971d32f45 | |||
| a97044ec3e | |||
| a77db0eeee | |||
| d8c8a0c44d | |||
| 80dd68752e | |||
| 896fd427ed | |||
| 01b1bba6d6 | |||
| b7a97c3d88 |
@@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd)
|
||||
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd + jsinspect)
|
||||
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
|
||||
pnpm knip # Unused code detection (Knip)
|
||||
pnpm test # Run all tests (Vitest)
|
||||
@@ -30,7 +30,7 @@ apps/web (React 19 + Vite) → packages/application (use cases) → packages
|
||||
|
||||
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
|
||||
- **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
|
||||
- **Web** — React adapter. Implements ports using hooks/state. All UI components, routing, and user interaction live here.
|
||||
- **Web** — React adapter. Implements ports using hooks/state. All UI components and user interaction live here.
|
||||
|
||||
Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which runs as a Vitest test. Domain and application must never import from React, Vite, or upper layers.
|
||||
|
||||
@@ -60,7 +60,7 @@ 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
|
||||
@@ -72,9 +72,9 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
||||
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
||||
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
|
||||
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs` using the TypeScript compiler API). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
|
||||
- **Export format compatibility** — When changing `Encounter`, `Combatant`, `PlayerCharacter`, or `UndoRedoState` types, verify that previously exported JSON files (version 1) still import correctly. If not, bump the `ExportBundle` version and add migration logic in `validateImportBundle()`.
|
||||
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
||||
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Encounter Console
|
||||
# Initiative
|
||||
|
||||
A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e / 2024). Runs entirely in the browser — no server, no account, no data leaves your machine.
|
||||
|
||||
@@ -34,16 +34,42 @@ Open `http://localhost:5173`.
|
||||
| `pnpm --filter web dev` | Start the dev server |
|
||||
| `pnpm --filter web build` | Production build |
|
||||
| `pnpm test` | Run all tests (Vitest) |
|
||||
| `pnpm check` | Full merge gate (knip, biome, typecheck, test, jscpd) |
|
||||
| `pnpm test:watch` | Tests in watch mode |
|
||||
| `pnpm vitest run path/to/test.ts` | Run a single test file |
|
||||
| `pnpm typecheck` | TypeScript type checking |
|
||||
| `pnpm lint` | Biome lint |
|
||||
| `pnpm format` | Biome format (writes changes) |
|
||||
| `pnpm check` | Full merge gate (see below) |
|
||||
|
||||
### Merge gate (`pnpm check`)
|
||||
|
||||
All of these run at pre-commit via Lefthook (in parallel where possible):
|
||||
|
||||
- `pnpm audit` — security audit
|
||||
- `knip` — unused code detection
|
||||
- `biome check` — formatting + linting
|
||||
- `oxlint` — type-aware linting (complements Biome)
|
||||
- Custom scripts — lint-ignore caps, className enforcement, component prop limits
|
||||
- `tsc --build` — TypeScript strict mode
|
||||
- `vitest run` — tests with per-path coverage thresholds
|
||||
- `jscpd` + `jsinspect` — copy-paste and structural duplication detection
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- TypeScript 5.8 (strict mode), React 19, Vite 6
|
||||
- Tailwind CSS v4 (dark/light theme)
|
||||
- Biome 2.4 (formatting + linting), oxlint (type-aware linting)
|
||||
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||
- Knip (unused code), jscpd + jsinspect (duplication detection)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/web/ React 19 + Vite — UI components, hooks, adapters
|
||||
packages/domain/ Pure functions — state transitions, types, validation
|
||||
packages/app/ Use cases — orchestrates domain via port interfaces
|
||||
data/bestiary/ Bestiary index for creature search
|
||||
scripts/ Build tooling (layer boundary checks, index generation)
|
||||
packages/application/ Use cases — orchestrates domain via port interfaces
|
||||
data/bestiary/ Pre-built bestiary search index (~10k creatures)
|
||||
scripts/ Build tooling (layer checks, index generation)
|
||||
specs/ Feature specifications (spec → plan → tasks)
|
||||
```
|
||||
|
||||
@@ -55,5 +81,45 @@ Strict layered architecture with enforced dependency direction:
|
||||
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
|
||||
```
|
||||
|
||||
Domain is pure — no I/O, no randomness, no framework imports. Layer boundaries are enforced by automated import checks that run as part of the test suite. See [CLAUDE.md](./CLAUDE.md) for full conventions.
|
||||
- **Domain** — pure functions, no I/O, no randomness, no framework imports. Errors returned as values (`DomainError`), never thrown.
|
||||
- **Application** — orchestrates domain calls via port interfaces (`EncounterStore`, `PlayerCharacterStore`, etc.). No business logic.
|
||||
- **Web** — React adapter. Implements ports using hooks/state. All UI components, persistence, and external data access live here.
|
||||
|
||||
Layer boundaries are enforced by automated import checks that run as part of the test suite.
|
||||
|
||||
## Contributing
|
||||
|
||||
### Workflow
|
||||
|
||||
Development is spec-driven. Feature specs live in `specs/NNN-feature-name/` and are managed through Claude Code skills (see [CLAUDE.md](./CLAUDE.md) for full details).
|
||||
|
||||
| Scope | What to do |
|
||||
|-------|-----------|
|
||||
| Bug fix / CSS tweak | Fix it, run `pnpm check`, commit. Optionally use `/browser-interactive-testing` for visual verification. |
|
||||
| Change to existing feature | Update the feature spec, then implement |
|
||||
| Larger change to existing feature | Update the spec → `/rpi-research` → `/rpi-plan` → `/rpi-implement` |
|
||||
| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` |
|
||||
|
||||
Use `/write-issue` to create well-structured Gitea issues, and `/integrate-issue` to pull an existing issue's requirements into the relevant feature spec.
|
||||
|
||||
### Before committing
|
||||
|
||||
Run `pnpm check` — Lefthook runs this automatically at pre-commit, but running it manually first saves time. All checks must pass.
|
||||
|
||||
### Conventions
|
||||
|
||||
- **Biome** for formatting and linting — tab indentation, 80-char lines
|
||||
- **TypeScript strict mode** with `verbatimModuleSyntax` (type-only imports must use `import type`)
|
||||
- **Max 8 props** per component interface — use React context for shared state
|
||||
- **Tests** in `__tests__/` directories — test pure functions directly, use `renderHook` for hooks
|
||||
|
||||
See [CLAUDE.md](./CLAUDE.md) for the full conventions and project constitution.
|
||||
|
||||
## Bestiary Index
|
||||
|
||||
The bestiary search index (`data/bestiary/index.json`) is pre-built and checked into the repo. To regenerate it (e.g., after a new source book release):
|
||||
|
||||
1. Clone [5etools-mirror-3/5etools-src](https://github.com/5etools-mirror-3/5etools-src) locally
|
||||
2. Run `node scripts/generate-bestiary-index.mjs /path/to/5etools-src`
|
||||
|
||||
The script extracts creature names, stats, and source info into a compact search index.
|
||||
|
||||
@@ -50,6 +50,16 @@ beforeAll(() => {
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
HTMLDialogElement.prototype.showModal =
|
||||
HTMLDialogElement.prototype.showModal ||
|
||||
function showModal(this: HTMLDialogElement) {
|
||||
this.setAttribute("open", "");
|
||||
};
|
||||
HTMLDialogElement.prototype.close =
|
||||
HTMLDialogElement.prototype.close ||
|
||||
function close(this: HTMLDialogElement) {
|
||||
this.removeAttribute("open");
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -118,4 +128,61 @@ describe("ActionBar", () => {
|
||||
screen.getByRole("button", { name: "More actions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens export method dialog via overflow menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBar();
|
||||
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||
// 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.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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
// @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 { CreatePlayerModal } from "../create-player-modal.js";
|
||||
|
||||
beforeAll(() => {
|
||||
HTMLDialogElement.prototype.showModal =
|
||||
HTMLDialogElement.prototype.showModal ||
|
||||
function showModal(this: HTMLDialogElement) {
|
||||
this.setAttribute("open", "");
|
||||
};
|
||||
HTMLDialogElement.prototype.close =
|
||||
HTMLDialogElement.prototype.close ||
|
||||
function close(this: HTMLDialogElement) {
|
||||
this.removeAttribute("open");
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderModal(
|
||||
overrides: Partial<Parameters<typeof CreatePlayerModal>[0]> = {},
|
||||
) {
|
||||
const defaults = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
};
|
||||
const props = { ...defaults, ...overrides };
|
||||
return { ...render(<CreatePlayerModal {...props} />), ...props };
|
||||
}
|
||||
|
||||
describe("CreatePlayerModal", () => {
|
||||
it("renders create form with defaults", () => {
|
||||
renderModal();
|
||||
expect(screen.getByText("Create Player")).toBeDefined();
|
||||
expect(screen.getByLabelText("Name")).toBeDefined();
|
||||
expect(screen.getByLabelText("AC")).toBeDefined();
|
||||
expect(screen.getByLabelText("Max HP")).toBeDefined();
|
||||
expect(screen.getByLabelText("Level")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders edit form when playerCharacter is provided", () => {
|
||||
const pc: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Gandalf",
|
||||
ac: 15,
|
||||
maxHp: 40,
|
||||
color: "blue",
|
||||
icon: "wand",
|
||||
level: 10,
|
||||
};
|
||||
renderModal({ playerCharacter: pc });
|
||||
expect(screen.getByText("Edit Player")).toBeDefined();
|
||||
expect(screen.getByLabelText("Name")).toHaveProperty("value", "Gandalf");
|
||||
expect(screen.getByLabelText("AC")).toHaveProperty("value", "15");
|
||||
expect(screen.getByLabelText("Max HP")).toHaveProperty("value", "40");
|
||||
expect(screen.getByLabelText("Level")).toHaveProperty("value", "10");
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onSave with valid data", async () => {
|
||||
const { onSave, onClose } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Aria");
|
||||
await user.clear(screen.getByLabelText("AC"));
|
||||
await user.type(screen.getByLabelText("AC"), "16");
|
||||
await user.clear(screen.getByLabelText("Max HP"));
|
||||
await user.type(screen.getByLabelText("Max HP"), "30");
|
||||
await user.type(screen.getByLabelText("Level"), "5");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
"Aria",
|
||||
16,
|
||||
30,
|
||||
undefined,
|
||||
undefined,
|
||||
5,
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for empty name", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(screen.getByText("Name is required")).toBeDefined();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for invalid AC", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Test");
|
||||
await user.clear(screen.getByLabelText("AC"));
|
||||
await user.type(screen.getByLabelText("AC"), "abc");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(screen.getByText("AC must be a non-negative number")).toBeDefined();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for invalid Max HP", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Test");
|
||||
await user.clear(screen.getByLabelText("Max HP"));
|
||||
await user.type(screen.getByLabelText("Max HP"), "0");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(screen.getByText("Max HP must be at least 1")).toBeDefined();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for invalid level", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Test");
|
||||
await user.type(screen.getByLabelText("Level"), "25");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(screen.getByText("Level must be between 1 and 20")).toBeDefined();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears error when name is edited", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
expect(screen.getByText("Name is required")).toBeDefined();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "A");
|
||||
expect(screen.queryByText("Name is required")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onClose when cancel is clicked", async () => {
|
||||
const { onClose } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("omits level when field is empty", async () => {
|
||||
const { onSave } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Aria");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
"Aria",
|
||||
10,
|
||||
10,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// @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 { Dialog, DialogHeader } from "../ui/dialog.js";
|
||||
|
||||
beforeAll(() => {
|
||||
HTMLDialogElement.prototype.showModal =
|
||||
HTMLDialogElement.prototype.showModal ||
|
||||
function showModal(this: HTMLDialogElement) {
|
||||
this.setAttribute("open", "");
|
||||
};
|
||||
HTMLDialogElement.prototype.close =
|
||||
HTMLDialogElement.prototype.close ||
|
||||
function close(this: HTMLDialogElement) {
|
||||
this.removeAttribute("open");
|
||||
};
|
||||
});
|
||||
|
||||
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,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,55 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RollModeMenu } from "../roll-mode-menu.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("RollModeMenu", () => {
|
||||
it("renders advantage and disadvantage buttons", () => {
|
||||
render(
|
||||
<RollModeMenu
|
||||
position={{ x: 100, y: 100 }}
|
||||
onSelect={() => {}}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Advantage")).toBeDefined();
|
||||
expect(screen.getByText("Disadvantage")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onSelect with 'advantage' and onClose when clicked", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<RollModeMenu
|
||||
position={{ x: 100, y: 100 }}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Advantage"));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("advantage");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSelect with 'disadvantage' and onClose when clicked", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<RollModeMenu
|
||||
position={{ x: 100, y: 100 }}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Disadvantage"));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("disadvantage");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Toast } from "../toast.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Toast", () => {
|
||||
it("renders message text", () => {
|
||||
render(<Toast message="Hello" onDismiss={() => {}} />);
|
||||
expect(screen.getByText("Hello")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders progress bar when progress is provided", () => {
|
||||
render(<Toast message="Loading" progress={0.5} onDismiss={() => {}} />);
|
||||
const bar = document.body.querySelector("[style*='width']") as HTMLElement;
|
||||
expect(bar).not.toBeNull();
|
||||
expect(bar.style.width).toBe("50%");
|
||||
});
|
||||
|
||||
it("does not render progress bar when progress is omitted", () => {
|
||||
render(<Toast message="Done" onDismiss={() => {}} />);
|
||||
const bar = document.body.querySelector("[style*='width']");
|
||||
expect(bar).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onDismiss when close button is clicked", async () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<Toast message="Hi" onDismiss={onDismiss} />);
|
||||
|
||||
const toast = screen.getByText("Hi").closest("div");
|
||||
const button = toast?.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
await userEvent.click(button as HTMLElement);
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe("auto-dismiss", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("auto-dismisses after specified timeout", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<Toast message="Auto" onDismiss={onDismiss} autoDismissMs={3000} />,
|
||||
);
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not auto-dismiss when autoDismissMs is omitted", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<Toast message="Stay" onDismiss={onDismiss} />);
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Tooltip } from "../ui/tooltip.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Tooltip", () => {
|
||||
it("renders children", () => {
|
||||
render(
|
||||
<Tooltip content="Hint">
|
||||
<button type="button">Hover me</button>
|
||||
</Tooltip>,
|
||||
);
|
||||
expect(screen.getByText("Hover me")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show tooltip initially", () => {
|
||||
render(
|
||||
<Tooltip content="Hint">
|
||||
<span>Target</span>
|
||||
</Tooltip>,
|
||||
);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows tooltip on pointer enter and hides on pointer leave", () => {
|
||||
render(
|
||||
<Tooltip content="Hint text">
|
||||
<span>Target</span>
|
||||
</Tooltip>,
|
||||
);
|
||||
|
||||
const wrapper = screen.getByText("Target").closest("span");
|
||||
fireEvent.pointerEnter(wrapper as HTMLElement);
|
||||
expect(screen.getByRole("tooltip")).toBeDefined();
|
||||
expect(screen.getByText("Hint text")).toBeDefined();
|
||||
|
||||
fireEvent.pointerLeave(wrapper as HTMLElement);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,7 @@ function mockContext(overrides: Partial<Encounter> = {}) {
|
||||
setEncounter: vi.fn(),
|
||||
setUndoRedoState: vi.fn(),
|
||||
events: [],
|
||||
lastCreatureId: null,
|
||||
};
|
||||
|
||||
mockUseEncounterContext.mockReturnValue(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useRulesEdition } from "../use-rules-edition.js";
|
||||
|
||||
const STORAGE_KEY = "initiative:rules-edition";
|
||||
|
||||
describe("useRulesEdition", () => {
|
||||
afterEach(() => {
|
||||
// Reset to default
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
act(() => result.current.setEdition("5.5e"));
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("defaults to 5.5e", () => {
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
expect(result.current.edition).toBe("5.5e");
|
||||
});
|
||||
|
||||
it("setEdition updates value", () => {
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
|
||||
act(() => result.current.setEdition("5e"));
|
||||
|
||||
expect(result.current.edition).toBe("5e");
|
||||
});
|
||||
|
||||
it("setEdition persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
|
||||
act(() => result.current.setEdition("5e"));
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("5e");
|
||||
});
|
||||
|
||||
it("multiple hooks stay in sync", () => {
|
||||
const { result: r1 } = renderHook(() => useRulesEdition());
|
||||
const { result: r2 } = renderHook(() => useRulesEdition());
|
||||
|
||||
act(() => r1.current.setEdition("5e"));
|
||||
|
||||
expect(r2.current.edition).toBe("5e");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useTheme } from "../use-theme.js";
|
||||
|
||||
const STORAGE_KEY = "initiative:theme";
|
||||
|
||||
describe("useTheme", () => {
|
||||
afterEach(() => {
|
||||
// Reset to default
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setPreference("system"));
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("defaults to system preference", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.preference).toBe("system");
|
||||
});
|
||||
|
||||
it("setPreference updates to light", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => result.current.setPreference("light"));
|
||||
|
||||
expect(result.current.preference).toBe("light");
|
||||
expect(result.current.resolved).toBe("light");
|
||||
});
|
||||
|
||||
it("setPreference updates to dark", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => result.current.setPreference("dark"));
|
||||
|
||||
expect(result.current.preference).toBe("dark");
|
||||
expect(result.current.resolved).toBe("dark");
|
||||
});
|
||||
|
||||
it("persists preference to localStorage", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => result.current.setPreference("light"));
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("light");
|
||||
});
|
||||
|
||||
it("applies theme to document element", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => result.current.setPreference("light"));
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("multiple hooks stay in sync", () => {
|
||||
const { result: r1 } = renderHook(() => useTheme());
|
||||
const { result: r2 } = renderHook(() => useTheme());
|
||||
|
||||
act(() => r1.current.setPreference("dark"));
|
||||
|
||||
expect(r2.current.preference).toBe("dark");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import { useCallback, useDeferredValue, useMemo, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
@@ -31,6 +38,7 @@ export function useActionBarState() {
|
||||
addFromBestiary,
|
||||
addMultipleFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
lastCreatureId,
|
||||
} = useEncounterContext();
|
||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||
useBestiaryContext();
|
||||
@@ -38,6 +46,20 @@ export function useActionBarState() {
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
useSidePanelContext();
|
||||
|
||||
// Auto-show stat block when a bestiary creature is added on desktop
|
||||
const prevCreatureIdRef = useRef(lastCreatureId);
|
||||
useEffect(() => {
|
||||
if (
|
||||
lastCreatureId &&
|
||||
lastCreatureId !== prevCreatureIdRef.current &&
|
||||
panelView.mode === "closed" &&
|
||||
globalThis.matchMedia("(min-width: 1024px)").matches
|
||||
) {
|
||||
showCreature(lastCreatureId);
|
||||
}
|
||||
prevCreatureIdRef.current = lastCreatureId;
|
||||
}, [lastCreatureId, panelView.mode, showCreature]);
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
@@ -73,13 +95,9 @@ export function useActionBarState() {
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
addFromBestiary(result);
|
||||
},
|
||||
[addFromBestiary, panelView.mode, showCreature],
|
||||
[addFromBestiary],
|
||||
);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
@@ -99,21 +117,10 @@ export function useActionBarState() {
|
||||
if (queued.count === 1) {
|
||||
handleAddFromBestiary(queued.result);
|
||||
} else {
|
||||
const creatureId = addMultipleFromBestiary(queued.result, queued.count);
|
||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
addMultipleFromBestiary(queued.result, queued.count);
|
||||
}
|
||||
clearInput();
|
||||
}, [
|
||||
queued,
|
||||
handleAddFromBestiary,
|
||||
addMultipleFromBestiary,
|
||||
panelView.mode,
|
||||
showCreature,
|
||||
clearInput,
|
||||
]);
|
||||
}, [queued, handleAddFromBestiary, addMultipleFromBestiary, clearInput]);
|
||||
|
||||
const parseNum = (v: string): number | undefined => {
|
||||
if (v.trim() === "") return undefined;
|
||||
|
||||
+432
-272
@@ -36,7 +36,7 @@ import {
|
||||
pushUndo,
|
||||
resolveCreatureName,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import {
|
||||
loadEncounter,
|
||||
saveEncounter,
|
||||
@@ -46,6 +46,51 @@ import {
|
||||
saveUndoRedoStacks,
|
||||
} from "../persistence/undo-redo-storage.js";
|
||||
|
||||
// -- Types --
|
||||
|
||||
type EncounterAction =
|
||||
| { type: "advance-turn" }
|
||||
| { type: "retreat-turn" }
|
||||
| { type: "add-combatant"; name: string; init?: CombatantInit }
|
||||
| { type: "remove-combatant"; id: CombatantId }
|
||||
| { type: "edit-combatant"; id: CombatantId; newName: string }
|
||||
| { type: "set-initiative"; id: CombatantId; value: number | undefined }
|
||||
| { type: "set-hp"; id: CombatantId; maxHp: number | undefined }
|
||||
| { type: "adjust-hp"; id: CombatantId; delta: number }
|
||||
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
||||
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
||||
| {
|
||||
type: "toggle-condition";
|
||||
id: CombatantId;
|
||||
conditionId: ConditionId;
|
||||
}
|
||||
| { type: "toggle-concentration"; id: CombatantId }
|
||||
| { type: "clear-encounter" }
|
||||
| { type: "undo" }
|
||||
| { type: "redo" }
|
||||
| { type: "add-from-bestiary"; entry: BestiaryIndexEntry }
|
||||
| {
|
||||
type: "add-multiple-from-bestiary";
|
||||
entry: BestiaryIndexEntry;
|
||||
count: number;
|
||||
}
|
||||
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
||||
| {
|
||||
type: "import";
|
||||
encounter: Encounter;
|
||||
undoRedoState: UndoRedoState;
|
||||
};
|
||||
|
||||
export interface EncounterState {
|
||||
readonly encounter: Encounter;
|
||||
readonly undoRedoState: UndoRedoState;
|
||||
readonly events: readonly DomainEvent[];
|
||||
readonly nextId: number;
|
||||
readonly lastCreatureId: CreatureId | null;
|
||||
}
|
||||
|
||||
// -- Initialization --
|
||||
|
||||
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||
|
||||
const EMPTY_ENCOUNTER: Encounter = {
|
||||
@@ -54,12 +99,6 @@ const EMPTY_ENCOUNTER: Encounter = {
|
||||
roundNumber: 1,
|
||||
};
|
||||
|
||||
function initializeEncounter(): Encounter {
|
||||
const stored = loadEncounter();
|
||||
if (stored !== null) return stored;
|
||||
return EMPTY_ENCOUNTER;
|
||||
}
|
||||
|
||||
function deriveNextId(encounter: Encounter): number {
|
||||
let max = 0;
|
||||
for (const c of encounter.combatants) {
|
||||
@@ -72,11 +111,283 @@ function deriveNextId(encounter: Encounter): number {
|
||||
return max;
|
||||
}
|
||||
|
||||
function initializeState(): EncounterState {
|
||||
const encounter = loadEncounter() ?? EMPTY_ENCOUNTER;
|
||||
return {
|
||||
encounter,
|
||||
undoRedoState: loadUndoRedoStacks(),
|
||||
events: [],
|
||||
nextId: deriveNextId(encounter),
|
||||
lastCreatureId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
function makeStoreFromState(state: EncounterState): {
|
||||
store: EncounterStore;
|
||||
getEncounter: () => Encounter;
|
||||
} {
|
||||
let current = state.encounter;
|
||||
return {
|
||||
store: {
|
||||
get: () => current,
|
||||
save: (e) => {
|
||||
current = e;
|
||||
},
|
||||
},
|
||||
getEncounter: () => current,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAndRename(store: EncounterStore, name: string): string {
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(name, existingNames);
|
||||
|
||||
for (const { from, to } of renames) {
|
||||
const target = store.get().combatants.find((c) => c.name === from);
|
||||
if (target) {
|
||||
editCombatantUseCase(store, target.id, to);
|
||||
}
|
||||
}
|
||||
|
||||
return newName;
|
||||
}
|
||||
|
||||
function addOneFromBestiary(
|
||||
store: EncounterStore,
|
||||
entry: BestiaryIndexEntry,
|
||||
nextId: number,
|
||||
): {
|
||||
cId: CreatureId;
|
||||
events: DomainEvent[];
|
||||
nextId: number;
|
||||
} | null {
|
||||
const newName = resolveAndRename(store, entry.name);
|
||||
|
||||
const slug = entry.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||
|
||||
const id = combatantId(`c-${nextId + 1}`);
|
||||
const result = addCombatantUseCase(store, id, newName, {
|
||||
maxHp: entry.hp,
|
||||
ac: entry.ac > 0 ? entry.ac : undefined,
|
||||
creatureId: cId,
|
||||
});
|
||||
|
||||
if (isDomainError(result)) return null;
|
||||
|
||||
return { cId, events: result, nextId: nextId + 1 };
|
||||
}
|
||||
|
||||
// -- Reducer case handlers --
|
||||
|
||||
function handleUndoRedo(
|
||||
state: EncounterState,
|
||||
direction: "undo" | "redo",
|
||||
): EncounterState {
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
const undoRedoStore: UndoRedoStore = {
|
||||
get: () => state.undoRedoState,
|
||||
save: () => {},
|
||||
};
|
||||
const applyFn = direction === "undo" ? undoUseCase : redoUseCase;
|
||||
const result = applyFn(store, undoRedoStore);
|
||||
if (isDomainError(result)) return state;
|
||||
|
||||
const isUndo = direction === "undo";
|
||||
return {
|
||||
...state,
|
||||
encounter: getEncounter(),
|
||||
undoRedoState: {
|
||||
undoStack: isUndo
|
||||
? state.undoRedoState.undoStack.slice(0, -1)
|
||||
: [...state.undoRedoState.undoStack, state.encounter],
|
||||
redoStack: isUndo
|
||||
? [...state.undoRedoState.redoStack, state.encounter]
|
||||
: state.undoRedoState.redoStack.slice(0, -1),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function handleAddFromBestiary(
|
||||
state: EncounterState,
|
||||
entry: BestiaryIndexEntry,
|
||||
count: number,
|
||||
): EncounterState {
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
const allEvents: DomainEvent[] = [];
|
||||
let nextId = state.nextId;
|
||||
let lastCId: CreatureId | null = null;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const added = addOneFromBestiary(store, entry, nextId);
|
||||
if (!added) return state;
|
||||
allEvents.push(...added.events);
|
||||
nextId = added.nextId;
|
||||
lastCId = added.cId;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
encounter: getEncounter(),
|
||||
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||
events: [...state.events, ...allEvents],
|
||||
nextId,
|
||||
lastCreatureId: lastCId,
|
||||
};
|
||||
}
|
||||
|
||||
function handleAddFromPlayerCharacter(
|
||||
state: EncounterState,
|
||||
pc: PlayerCharacter,
|
||||
): EncounterState {
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
const newName = resolveAndRename(store, pc.name);
|
||||
const id = combatantId(`c-${state.nextId + 1}`);
|
||||
const result = addCombatantUseCase(store, id, newName, {
|
||||
maxHp: pc.maxHp,
|
||||
ac: pc.ac > 0 ? pc.ac : undefined,
|
||||
color: pc.color,
|
||||
icon: pc.icon,
|
||||
playerCharacterId: pc.id,
|
||||
});
|
||||
if (isDomainError(result)) return state;
|
||||
return {
|
||||
...state,
|
||||
encounter: getEncounter(),
|
||||
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||
events: [...state.events, ...result],
|
||||
nextId: state.nextId + 1,
|
||||
lastCreatureId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Reducer --
|
||||
|
||||
export function encounterReducer(
|
||||
state: EncounterState,
|
||||
action: EncounterAction,
|
||||
): EncounterState {
|
||||
switch (action.type) {
|
||||
case "import":
|
||||
return {
|
||||
...state,
|
||||
encounter: action.encounter,
|
||||
undoRedoState: action.undoRedoState,
|
||||
nextId: deriveNextId(action.encounter),
|
||||
lastCreatureId: null,
|
||||
};
|
||||
case "undo":
|
||||
case "redo":
|
||||
return handleUndoRedo(state, action.type);
|
||||
case "clear-encounter": {
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
const result = clearEncounterUseCase(store);
|
||||
if (isDomainError(result)) return state;
|
||||
return {
|
||||
...state,
|
||||
encounter: getEncounter(),
|
||||
undoRedoState: clearHistory(),
|
||||
events: [...state.events, ...result],
|
||||
nextId: 0,
|
||||
lastCreatureId: null,
|
||||
};
|
||||
}
|
||||
case "add-from-bestiary":
|
||||
return handleAddFromBestiary(state, action.entry, 1);
|
||||
case "add-multiple-from-bestiary":
|
||||
return handleAddFromBestiary(state, action.entry, action.count);
|
||||
case "add-from-player-character":
|
||||
return handleAddFromPlayerCharacter(state, action.pc);
|
||||
default:
|
||||
return dispatchEncounterAction(state, action);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchEncounterAction(
|
||||
state: EncounterState,
|
||||
action: Extract<
|
||||
EncounterAction,
|
||||
| { type: "advance-turn" }
|
||||
| { type: "retreat-turn" }
|
||||
| { type: "add-combatant" }
|
||||
| { type: "remove-combatant" }
|
||||
| { type: "edit-combatant" }
|
||||
| { type: "set-initiative" }
|
||||
| { type: "set-hp" }
|
||||
| { type: "adjust-hp" }
|
||||
| { type: "set-temp-hp" }
|
||||
| { type: "set-ac" }
|
||||
| { type: "toggle-condition" }
|
||||
| { type: "toggle-concentration" }
|
||||
>,
|
||||
): EncounterState {
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
let result: DomainEvent[] | DomainError;
|
||||
|
||||
switch (action.type) {
|
||||
case "advance-turn":
|
||||
result = advanceTurnUseCase(store);
|
||||
break;
|
||||
case "retreat-turn":
|
||||
result = retreatTurnUseCase(store);
|
||||
break;
|
||||
case "add-combatant": {
|
||||
const id = combatantId(`c-${state.nextId + 1}`);
|
||||
result = addCombatantUseCase(store, id, action.name, action.init);
|
||||
break;
|
||||
}
|
||||
case "remove-combatant":
|
||||
result = removeCombatantUseCase(store, action.id);
|
||||
break;
|
||||
case "edit-combatant":
|
||||
result = editCombatantUseCase(store, action.id, action.newName);
|
||||
break;
|
||||
case "set-initiative":
|
||||
result = setInitiativeUseCase(store, action.id, action.value);
|
||||
break;
|
||||
case "set-hp":
|
||||
result = setHpUseCase(store, action.id, action.maxHp);
|
||||
break;
|
||||
case "adjust-hp":
|
||||
result = adjustHpUseCase(store, action.id, action.delta);
|
||||
break;
|
||||
case "set-temp-hp":
|
||||
result = setTempHpUseCase(store, action.id, action.tempHp);
|
||||
break;
|
||||
case "set-ac":
|
||||
result = setAcUseCase(store, action.id, action.value);
|
||||
break;
|
||||
case "toggle-condition":
|
||||
result = toggleConditionUseCase(store, action.id, action.conditionId);
|
||||
break;
|
||||
case "toggle-concentration":
|
||||
result = toggleConcentrationUseCase(store, action.id);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isDomainError(result)) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
encounter: getEncounter(),
|
||||
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||
events: [...state.events, ...result],
|
||||
nextId: action.type === "add-combatant" ? state.nextId + 1 : state.nextId,
|
||||
lastCreatureId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Hook --
|
||||
|
||||
export function useEncounter() {
|
||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||
const [undoRedoState, setUndoRedoState] =
|
||||
useState<UndoRedoState>(loadUndoRedoStacks);
|
||||
const [state, dispatch] = useReducer(encounterReducer, null, initializeState);
|
||||
const { encounter, undoRedoState, events } = state;
|
||||
|
||||
const encounterRef = useRef(encounter);
|
||||
encounterRef.current = encounter;
|
||||
const undoRedoRef = useRef(undoRedoState);
|
||||
@@ -90,22 +401,17 @@ export function useEncounter() {
|
||||
saveUndoRedoStacks(undoRedoState);
|
||||
}, [undoRedoState]);
|
||||
|
||||
// Escape hatches for useInitiativeRolls (needs raw port access)
|
||||
const makeStore = useCallback((): EncounterStore => {
|
||||
return {
|
||||
get: () => encounterRef.current,
|
||||
save: (e) => {
|
||||
encounterRef.current = e;
|
||||
setEncounter(e);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
|
||||
return {
|
||||
get: () => undoRedoRef.current,
|
||||
save: (s) => {
|
||||
undoRedoRef.current = s;
|
||||
setUndoRedoState(s);
|
||||
dispatch({
|
||||
type: "import",
|
||||
encounter: e,
|
||||
undoRedoState: undoRedoRef.current,
|
||||
});
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
@@ -116,245 +422,21 @@ export function useEncounter() {
|
||||
if (!isDomainError(result)) {
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
dispatch({
|
||||
type: "import",
|
||||
encounter: encounterRef.current,
|
||||
undoRedoState: newState,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const dispatchAction = useCallback(
|
||||
(action: () => DomainEvent[] | DomainError) => {
|
||||
const result = withUndo(action);
|
||||
if (!isDomainError(result)) {
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}
|
||||
},
|
||||
[withUndo],
|
||||
);
|
||||
|
||||
const nextId = useRef(deriveNextId(encounter));
|
||||
|
||||
const advanceTurn = useCallback(
|
||||
() => dispatchAction(() => advanceTurnUseCase(makeStore())),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const retreatTurn = useCallback(
|
||||
() => dispatchAction(() => retreatTurnUseCase(makeStore())),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const addCombatant = useCallback(
|
||||
(name: string, init?: CombatantInit) => {
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
dispatchAction(() => addCombatantUseCase(makeStore(), id, name, init));
|
||||
},
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const removeCombatant = useCallback(
|
||||
(id: CombatantId) =>
|
||||
dispatchAction(() => removeCombatantUseCase(makeStore(), id)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const editCombatant = useCallback(
|
||||
(id: CombatantId, newName: string) =>
|
||||
dispatchAction(() => editCombatantUseCase(makeStore(), id, newName)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const setInitiative = useCallback(
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatchAction(() => setInitiativeUseCase(makeStore(), id, value)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const setHp = useCallback(
|
||||
(id: CombatantId, maxHp: number | undefined) =>
|
||||
dispatchAction(() => setHpUseCase(makeStore(), id, maxHp)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const adjustHp = useCallback(
|
||||
(id: CombatantId, delta: number) =>
|
||||
dispatchAction(() => adjustHpUseCase(makeStore(), id, delta)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const setTempHp = useCallback(
|
||||
(id: CombatantId, tempHp: number | undefined) =>
|
||||
dispatchAction(() => setTempHpUseCase(makeStore(), id, tempHp)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const setAc = useCallback(
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatchAction(() => setAcUseCase(makeStore(), id, value)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const toggleCondition = useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId) =>
|
||||
dispatchAction(() =>
|
||||
toggleConditionUseCase(makeStore(), id, conditionId),
|
||||
),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const toggleConcentration = useCallback(
|
||||
(id: CombatantId) =>
|
||||
dispatchAction(() => toggleConcentrationUseCase(makeStore(), id)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const clearEncounter = useCallback(() => {
|
||||
const result = clearEncounterUseCase(makeStore());
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleared = clearHistory();
|
||||
undoRedoRef.current = cleared;
|
||||
setUndoRedoState(cleared);
|
||||
|
||||
nextId.current = 0;
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore]);
|
||||
|
||||
const resolveAndRename = useCallback(
|
||||
(name: string): string => {
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(name, existingNames);
|
||||
|
||||
for (const { from, to } of renames) {
|
||||
const target = store.get().combatants.find((c) => c.name === from);
|
||||
if (target) {
|
||||
editCombatantUseCase(makeStore(), target.id, to);
|
||||
}
|
||||
}
|
||||
|
||||
return newName;
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const addOneFromBestiary = useCallback(
|
||||
(
|
||||
entry: BestiaryIndexEntry,
|
||||
): { cId: CreatureId; events: DomainEvent[] } | null => {
|
||||
const newName = resolveAndRename(entry.name);
|
||||
|
||||
const slug = entry.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = addCombatantUseCase(makeStore(), id, newName, {
|
||||
maxHp: entry.hp,
|
||||
ac: entry.ac > 0 ? entry.ac : undefined,
|
||||
creatureId: cId,
|
||||
});
|
||||
|
||||
if (isDomainError(result)) return null;
|
||||
|
||||
return { cId, events: result };
|
||||
},
|
||||
[makeStore, resolveAndRename],
|
||||
);
|
||||
|
||||
const addFromBestiary = useCallback(
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
const snapshot = encounterRef.current;
|
||||
const added = addOneFromBestiary(entry);
|
||||
|
||||
if (!added) {
|
||||
makeStore().save(snapshot);
|
||||
return null;
|
||||
}
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...added.events]);
|
||||
return added.cId;
|
||||
},
|
||||
[makeStore, addOneFromBestiary],
|
||||
);
|
||||
|
||||
const addMultipleFromBestiary = useCallback(
|
||||
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
||||
const snapshot = encounterRef.current;
|
||||
const allEvents: DomainEvent[] = [];
|
||||
let lastCId: CreatureId | null = null;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const added = addOneFromBestiary(entry);
|
||||
if (!added) {
|
||||
makeStore().save(snapshot);
|
||||
return null;
|
||||
}
|
||||
allEvents.push(...added.events);
|
||||
lastCId = added.cId;
|
||||
}
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...allEvents]);
|
||||
return lastCId;
|
||||
},
|
||||
[makeStore, addOneFromBestiary],
|
||||
);
|
||||
|
||||
const addFromPlayerCharacter = useCallback(
|
||||
(pc: PlayerCharacter) => {
|
||||
const snapshot = encounterRef.current;
|
||||
const newName = resolveAndRename(pc.name);
|
||||
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = addCombatantUseCase(makeStore(), id, newName, {
|
||||
maxHp: pc.maxHp,
|
||||
ac: pc.ac > 0 ? pc.ac : undefined,
|
||||
color: pc.color,
|
||||
icon: pc.icon,
|
||||
playerCharacterId: pc.id,
|
||||
});
|
||||
|
||||
if (isDomainError(result)) {
|
||||
makeStore().save(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, resolveAndRename],
|
||||
);
|
||||
|
||||
const undoAction = useCallback(() => {
|
||||
undoUseCase(makeStore(), makeUndoRedoStore());
|
||||
}, [makeStore, makeUndoRedoStore]);
|
||||
|
||||
const redoAction = useCallback(() => {
|
||||
redoUseCase(makeStore(), makeUndoRedoStore());
|
||||
}, [makeStore, makeUndoRedoStore]);
|
||||
|
||||
// Derived state
|
||||
const canUndo = undoRedoState.undoStack.length > 0;
|
||||
const canRedo = undoRedoState.redoStack.length > 0;
|
||||
|
||||
const hasTempHp = encounter.combatants.some(
|
||||
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||
);
|
||||
|
||||
const isEmpty = encounter.combatants.length === 0;
|
||||
const hasCreatureCombatants = encounter.combatants.some(
|
||||
(c) => c.creatureId != null,
|
||||
@@ -373,27 +455,105 @@ export function useEncounter() {
|
||||
canRollAllInitiative,
|
||||
canUndo,
|
||||
canRedo,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
clearEncounter,
|
||||
removeCombatant,
|
||||
editCombatant,
|
||||
setInitiative,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setTempHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addMultipleFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
undo: undoAction,
|
||||
redo: redoAction,
|
||||
setEncounter,
|
||||
setUndoRedoState,
|
||||
advanceTurn: useCallback(() => dispatch({ type: "advance-turn" }), []),
|
||||
retreatTurn: useCallback(() => dispatch({ type: "retreat-turn" }), []),
|
||||
addCombatant: useCallback(
|
||||
(name: string, init?: CombatantInit) =>
|
||||
dispatch({ type: "add-combatant", name, init }),
|
||||
[],
|
||||
),
|
||||
removeCombatant: useCallback(
|
||||
(id: CombatantId) => dispatch({ type: "remove-combatant", id }),
|
||||
[],
|
||||
),
|
||||
editCombatant: useCallback(
|
||||
(id: CombatantId, newName: string) =>
|
||||
dispatch({ type: "edit-combatant", id, newName }),
|
||||
[],
|
||||
),
|
||||
setInitiative: useCallback(
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatch({ type: "set-initiative", id, value }),
|
||||
[],
|
||||
),
|
||||
setHp: useCallback(
|
||||
(id: CombatantId, maxHp: number | undefined) =>
|
||||
dispatch({ type: "set-hp", id, maxHp }),
|
||||
[],
|
||||
),
|
||||
adjustHp: useCallback(
|
||||
(id: CombatantId, delta: number) =>
|
||||
dispatch({ type: "adjust-hp", id, delta }),
|
||||
[],
|
||||
),
|
||||
setTempHp: useCallback(
|
||||
(id: CombatantId, tempHp: number | undefined) =>
|
||||
dispatch({ type: "set-temp-hp", id, tempHp }),
|
||||
[],
|
||||
),
|
||||
setAc: useCallback(
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatch({ type: "set-ac", id, value }),
|
||||
[],
|
||||
),
|
||||
toggleCondition: useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId) =>
|
||||
dispatch({ type: "toggle-condition", id, conditionId }),
|
||||
[],
|
||||
),
|
||||
toggleConcentration: useCallback(
|
||||
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
||||
[],
|
||||
),
|
||||
clearEncounter: useCallback(
|
||||
() => dispatch({ type: "clear-encounter" }),
|
||||
[],
|
||||
),
|
||||
addFromBestiary: useCallback(
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
dispatch({ type: "add-from-bestiary", entry });
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
),
|
||||
addMultipleFromBestiary: useCallback(
|
||||
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
||||
dispatch({
|
||||
type: "add-multiple-from-bestiary",
|
||||
entry,
|
||||
count,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
),
|
||||
addFromPlayerCharacter: useCallback(
|
||||
(pc: PlayerCharacter) =>
|
||||
dispatch({ type: "add-from-player-character", pc }),
|
||||
[],
|
||||
),
|
||||
undo: useCallback(() => dispatch({ type: "undo" }), []),
|
||||
redo: useCallback(() => dispatch({ type: "redo" }), []),
|
||||
setEncounter: useCallback(
|
||||
(enc: Encounter) =>
|
||||
dispatch({
|
||||
type: "import",
|
||||
encounter: enc,
|
||||
undoRedoState: undoRedoRef.current,
|
||||
}),
|
||||
[],
|
||||
),
|
||||
setUndoRedoState: useCallback(
|
||||
(urs: UndoRedoState) =>
|
||||
dispatch({
|
||||
type: "import",
|
||||
encounter: encounterRef.current,
|
||||
undoRedoState: urs,
|
||||
}),
|
||||
[],
|
||||
),
|
||||
makeStore,
|
||||
withUndo,
|
||||
lastCreatureId: state.lastCreatureId,
|
||||
} as const;
|
||||
}
|
||||
|
||||
+27
-2
@@ -1,4 +1,29 @@
|
||||
pre-commit:
|
||||
parallel: true
|
||||
jobs:
|
||||
- name: check
|
||||
run: pnpm check
|
||||
- name: audit
|
||||
run: pnpm audit --audit-level=high
|
||||
- name: knip
|
||||
run: pnpm exec knip
|
||||
- name: biome
|
||||
run: pnpm exec biome check .
|
||||
- name: check-ignores
|
||||
run: node scripts/check-lint-ignores.mjs
|
||||
- name: check-classnames
|
||||
run: node scripts/check-cn-classnames.mjs
|
||||
- name: check-props
|
||||
run: node scripts/check-component-props.mjs
|
||||
- name: jscpd
|
||||
run: pnpm exec jscpd
|
||||
- name: jsinspect
|
||||
run: pnpm jsinspect
|
||||
- name: typecheck-oxlint-test
|
||||
group:
|
||||
piped: true
|
||||
jobs:
|
||||
- name: typecheck
|
||||
run: pnpm exec tsc --build
|
||||
- name: oxlint
|
||||
run: pnpm oxlint
|
||||
- name: test
|
||||
run: pnpm test
|
||||
|
||||
+1
-1
@@ -35,6 +35,6 @@
|
||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||
"check:props": "node scripts/check-component-props.mjs",
|
||||
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && tsc --build && vitest run && jscpd && pnpm jsinspect"
|
||||
"check": "pnpm audit --audit-level=high && knip && biome check . && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && jscpd && pnpm jsinspect && tsc --build && oxlint --tsconfig apps/web/tsconfig.json --type-aware && vitest run"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { Encounter, PlayerCharacter } from "@initiative/domain";
|
||||
import { isDomainError } from "@initiative/domain";
|
||||
import type { EncounterStore, PlayerCharacterStore } from "../ports.js";
|
||||
import type {
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import { EMPTY_UNDO_REDO_STATE, isDomainError } from "@initiative/domain";
|
||||
import type {
|
||||
EncounterStore,
|
||||
PlayerCharacterStore,
|
||||
UndoRedoStore,
|
||||
} from "../ports.js";
|
||||
|
||||
export function requireSaved<T>(value: T | null): T {
|
||||
if (value === null) throw new Error("Expected store.saved to be non-null");
|
||||
@@ -52,3 +60,17 @@ export function stubPlayerCharacterStore(
|
||||
};
|
||||
return stub;
|
||||
}
|
||||
|
||||
export function stubUndoRedoStore(
|
||||
initial: UndoRedoState = EMPTY_UNDO_REDO_STATE,
|
||||
): UndoRedoStore & { saved: UndoRedoState | null } {
|
||||
const stub = {
|
||||
saved: null as UndoRedoState | null,
|
||||
get: () => initial,
|
||||
save: (state: UndoRedoState) => {
|
||||
stub.saved = state;
|
||||
stub.get = () => state;
|
||||
},
|
||||
};
|
||||
return stub;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
type ConditionId,
|
||||
combatantId,
|
||||
createEncounter,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
isDomainError,
|
||||
playerCharacterId,
|
||||
pushUndo,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addCombatantUseCase } from "../add-combatant-use-case.js";
|
||||
@@ -14,17 +16,21 @@ import { createPlayerCharacterUseCase } from "../create-player-character-use-cas
|
||||
import { deletePlayerCharacterUseCase } from "../delete-player-character-use-case.js";
|
||||
import { editCombatantUseCase } from "../edit-combatant-use-case.js";
|
||||
import { editPlayerCharacterUseCase } from "../edit-player-character-use-case.js";
|
||||
import { redoUseCase } from "../redo-use-case.js";
|
||||
import { removeCombatantUseCase } from "../remove-combatant-use-case.js";
|
||||
import { retreatTurnUseCase } from "../retreat-turn-use-case.js";
|
||||
import { setAcUseCase } from "../set-ac-use-case.js";
|
||||
import { setHpUseCase } from "../set-hp-use-case.js";
|
||||
import { setInitiativeUseCase } from "../set-initiative-use-case.js";
|
||||
import { setTempHpUseCase } from "../set-temp-hp-use-case.js";
|
||||
import { toggleConcentrationUseCase } from "../toggle-concentration-use-case.js";
|
||||
import { toggleConditionUseCase } from "../toggle-condition-use-case.js";
|
||||
import { undoUseCase } from "../undo-use-case.js";
|
||||
import {
|
||||
requireSaved,
|
||||
stubEncounterStore,
|
||||
stubPlayerCharacterStore,
|
||||
stubUndoRedoStore,
|
||||
} from "./helpers.js";
|
||||
|
||||
const ID_A = combatantId("a");
|
||||
@@ -386,3 +392,80 @@ describe("editPlayerCharacterUseCase", () => {
|
||||
expect(store.saved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTempHpUseCase", () => {
|
||||
it("sets temp HP and saves", () => {
|
||||
const enc = encounterWithHp("Goblin", 10);
|
||||
const store = stubEncounterStore(enc);
|
||||
const result = setTempHpUseCase(store, combatantId("Goblin"), 5);
|
||||
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
expect(requireSaved(store.saved).combatants[0].tempHp).toBe(5);
|
||||
});
|
||||
|
||||
it("returns domain error for unknown combatant", () => {
|
||||
const store = stubEncounterStore(emptyEncounter());
|
||||
const result = setTempHpUseCase(store, ID_A, 5);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
expect(store.saved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("undoUseCase", () => {
|
||||
it("restores previous encounter and saves both stores", () => {
|
||||
const previous = encounterWith("A");
|
||||
const current = encounterWith("A", "B");
|
||||
const undoRedoState = pushUndo(EMPTY_UNDO_REDO_STATE, previous);
|
||||
const encounterStore = stubEncounterStore(current);
|
||||
const undoRedoStore = stubUndoRedoStore(undoRedoState);
|
||||
|
||||
const result = undoUseCase(encounterStore, undoRedoStore);
|
||||
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
expect(requireSaved(encounterStore.saved).combatants).toHaveLength(1);
|
||||
expect(undoRedoStore.saved).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns domain error when nothing to undo", () => {
|
||||
const encounterStore = stubEncounterStore(emptyEncounter());
|
||||
const undoRedoStore = stubUndoRedoStore();
|
||||
|
||||
const result = undoUseCase(encounterStore, undoRedoStore);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
expect(encounterStore.saved).toBeNull();
|
||||
expect(undoRedoStore.saved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("redoUseCase", () => {
|
||||
it("restores next encounter and saves both stores", () => {
|
||||
const previous = encounterWith("A");
|
||||
const current = encounterWith("A", "B");
|
||||
// Simulate: undo pushed current to redoStack
|
||||
const undoRedoState = {
|
||||
undoStack: [],
|
||||
redoStack: [current],
|
||||
};
|
||||
const encounterStore = stubEncounterStore(previous);
|
||||
const undoRedoStore = stubUndoRedoStore(undoRedoState);
|
||||
|
||||
const result = redoUseCase(encounterStore, undoRedoStore);
|
||||
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
expect(requireSaved(encounterStore.saved).combatants).toHaveLength(2);
|
||||
expect(undoRedoStore.saved).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns domain error when nothing to redo", () => {
|
||||
const encounterStore = stubEncounterStore(emptyEncounter());
|
||||
const undoRedoStore = stubUndoRedoStore();
|
||||
|
||||
const result = redoUseCase(encounterStore, undoRedoStore);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
expect(encounterStore.saved).toBeNull();
|
||||
expect(undoRedoStore.saved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
* Only scans component files (not hooks, adapters, etc.) and only
|
||||
* counts properties declared directly in *Props interfaces — inherited
|
||||
* or extended HTML attributes are not counted.
|
||||
*
|
||||
* Uses the TypeScript compiler API for accurate AST-based counting,
|
||||
* immune to comments, strings, and complex type syntax.
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { relative } from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
const MAX_PROPS = 8;
|
||||
|
||||
@@ -25,66 +28,38 @@ const files = execSync(
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
|
||||
const program = ts.createProgram(files, {
|
||||
target: ts.ScriptTarget.ESNext,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
strict: true,
|
||||
noEmit: true,
|
||||
skipLibCheck: true,
|
||||
});
|
||||
|
||||
let errors = 0;
|
||||
|
||||
const propsRegex = /^(?:export\s+)?interface\s+(\w+Props)\s*\{/;
|
||||
|
||||
for (const file of files) {
|
||||
const content = readFileSync(file, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const sourceFile = program.getSourceFile(file);
|
||||
if (!sourceFile) continue;
|
||||
|
||||
let inInterface = false;
|
||||
let interfaceName = "";
|
||||
let braceDepth = 0;
|
||||
let parenDepth = 0;
|
||||
let propCount = 0;
|
||||
let startLine = 0;
|
||||
ts.forEachChild(sourceFile, (node) => {
|
||||
if (!ts.isInterfaceDeclaration(node)) return;
|
||||
if (!node.name.text.endsWith("Props")) return;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const propCount = node.members.filter((m) =>
|
||||
ts.isPropertySignature(m),
|
||||
).length;
|
||||
|
||||
if (!inInterface) {
|
||||
const match = propsRegex.exec(line);
|
||||
if (match) {
|
||||
inInterface = true;
|
||||
interfaceName = match[1];
|
||||
braceDepth = 0;
|
||||
parenDepth = 0;
|
||||
propCount = 0;
|
||||
startLine = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (inInterface) {
|
||||
for (const ch of line) {
|
||||
if (ch === "{") braceDepth++;
|
||||
if (ch === "}") braceDepth--;
|
||||
if (ch === "(") parenDepth++;
|
||||
if (ch === ")") parenDepth--;
|
||||
}
|
||||
|
||||
// Count prop lines at brace depth 1 and not inside function params:
|
||||
// Matches " propName?: type" and " readonly propName: type"
|
||||
if (
|
||||
braceDepth === 1 &&
|
||||
parenDepth === 0 &&
|
||||
/^\s+(?:readonly\s+)?\w+\??\s*:/.test(line)
|
||||
) {
|
||||
propCount++;
|
||||
}
|
||||
|
||||
if (braceDepth === 0) {
|
||||
if (propCount > MAX_PROPS) {
|
||||
const rel = relative(process.cwd(), file);
|
||||
const { line } = sourceFile.getLineAndCharacterOfPosition(node.name.pos);
|
||||
console.error(
|
||||
`${rel}:${startLine}: ${interfaceName} has ${propCount} props (max ${MAX_PROPS})`,
|
||||
`${rel}:${line + 1}: ${node.name.text} has ${propCount} props (max ${MAX_PROPS})`,
|
||||
);
|
||||
errors++;
|
||||
}
|
||||
inInterface = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (errors > 0) {
|
||||
|
||||
+14
-15
@@ -9,34 +9,33 @@ export default defineConfig({
|
||||
enabled: true,
|
||||
exclude: ["**/dist/**"],
|
||||
thresholds: {
|
||||
autoUpdate: true,
|
||||
"packages/domain/src": {
|
||||
lines: 99,
|
||||
branches: 97,
|
||||
lines: 98,
|
||||
branches: 96,
|
||||
},
|
||||
"packages/application/src": {
|
||||
lines: 97,
|
||||
branches: 94,
|
||||
lines: 96,
|
||||
branches: 90,
|
||||
},
|
||||
"apps/web/src/adapters": {
|
||||
lines: 72,
|
||||
branches: 78,
|
||||
lines: 68,
|
||||
branches: 56,
|
||||
},
|
||||
"apps/web/src/persistence": {
|
||||
lines: 90,
|
||||
branches: 71,
|
||||
lines: 85,
|
||||
branches: 70,
|
||||
},
|
||||
"apps/web/src/hooks": {
|
||||
lines: 59,
|
||||
branches: 85,
|
||||
lines: 72,
|
||||
branches: 55,
|
||||
},
|
||||
"apps/web/src/components": {
|
||||
lines: 52,
|
||||
branches: 64,
|
||||
lines: 59,
|
||||
branches: 55,
|
||||
},
|
||||
"apps/web/src/components/ui": {
|
||||
lines: 73,
|
||||
branches: 96,
|
||||
lines: 93,
|
||||
branches: 90,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user