Introduce adapter injection and migrate test suite
Replace direct adapter/persistence imports with context-based injection (AdapterContext + useAdapters) so tests use in-memory implementations instead of vi.mock. Migrate component tests from context mocking to AllProviders with real hooks. Extract export/import logic from ActionBar into useEncounterExportImport hook. Add bestiary-cache and bestiary-index-adapter test suites. Raise adapter coverage thresholds (68→80 lines, 56→62 branches). 77 test files, 891 tests, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
51
CLAUDE.md
51
CLAUDE.md
@@ -70,11 +70,60 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports.
|
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports.
|
||||||
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
||||||
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
- **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 from specs to individual `it()` blocks.
|
- **Tests** live in `__tests__/` directories adjacent to source. See the Testing section below for conventions on mocking, assertions, and per-layer approach.
|
||||||
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
|
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
|
||||||
|
|
||||||
For component prop rules, export format compatibility, and ADRs, see [`docs/conventions.md`](docs/conventions.md).
|
For component prop rules, export format compatibility, and ADRs, see [`docs/conventions.md`](docs/conventions.md).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Philosophy
|
||||||
|
|
||||||
|
Test **user-visible behavior**, not implementation details. A good test answers "does this feature work?" not "does this internal function get called?"
|
||||||
|
|
||||||
|
### Adapter Injection
|
||||||
|
|
||||||
|
Adapters (storage, cache, browser APIs) are provided via `AdapterContext`. Production wires real implementations; tests wire in-memory implementations. This means:
|
||||||
|
- No `vi.mock()` for adapter or persistence modules
|
||||||
|
- Tests control adapter behavior by configuring the in-memory implementation
|
||||||
|
- Type changes in adapter interfaces are caught at compile time
|
||||||
|
|
||||||
|
### Per-Layer Approach
|
||||||
|
|
||||||
|
| Layer | How to test |
|
||||||
|
|---|---|
|
||||||
|
| Domain (`packages/domain`) | Pure unit tests, no mocks, test invariants and acceptance scenarios |
|
||||||
|
| Application (`packages/application`) | Mock port interfaces only, use real domain logic |
|
||||||
|
| Hooks (context-wrapped) | Test via `renderHook` with `AllProviders` wrapping in-memory adapters |
|
||||||
|
| Hooks (component-specific) | Test through the component that uses them |
|
||||||
|
| Components | Render with `AllProviders`, use in-memory adapters, use `userEvent` for interactions |
|
||||||
|
|
||||||
|
### Test Data
|
||||||
|
|
||||||
|
Use factory functions from `apps/web/src/__tests__/factories/` to construct domain objects. Each factory provides sensible defaults overridden via `Partial<T>`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { buildEncounter } from "../../__tests__/factories/build-encounter.js";
|
||||||
|
import { buildCombatant } from "../../__tests__/factories/build-combatant.js";
|
||||||
|
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add new factory files as needed (one per domain type). Don't inline test data construction — use factories so type changes are caught at compile time.
|
||||||
|
|
||||||
|
### Anti-Patterns
|
||||||
|
|
||||||
|
- **`vi.mock()` for adapters**: Use in-memory adapter implementations via `AdapterContext` instead
|
||||||
|
- **Mocking contexts**: Use `AllProviders` and drive state through real hooks instead of `vi.mock("../../contexts/...")`. Exception: context mocks are acceptable when the component under test requires specific state machine states that cannot be reached through adapter configuration alone — document the reason in a comment at the top of the test file.
|
||||||
|
- **Stubbing child components**: Render real children; stub only if the child has heavy I/O that can't be mocked at the adapter level
|
||||||
|
- **Asserting mock call counts**: Prefer asserting what the user sees (`screen.getByText(...)`) over `expect(mockFn).toHaveBeenCalledWith(...)`
|
||||||
|
- **Testing internal state**: Don't assert `result.current.suggestionIndex === 0`; assert the first suggestion is highlighted
|
||||||
|
- **Assertion-free tests**: Every `it()` block must contain at least one `expect()`. Tests that render without asserting inflate coverage without catching bugs.
|
||||||
|
|
||||||
## Self-Review Checklist
|
## Self-Review Checklist
|
||||||
|
|
||||||
Before finishing a change, consider:
|
Before finishing a change, consider:
|
||||||
|
|||||||
108
apps/web/src/__tests__/adapters/in-memory-adapters.ts
Normal file
108
apps/web/src/__tests__/adapters/in-memory-adapters.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
type Creature,
|
||||||
|
type CreatureId,
|
||||||
|
EMPTY_UNDO_REDO_STATE,
|
||||||
|
type Encounter,
|
||||||
|
type PlayerCharacter,
|
||||||
|
type UndoRedoState,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { Adapters } from "../../contexts/adapter-context.js";
|
||||||
|
|
||||||
|
export function createTestAdapters(options?: {
|
||||||
|
encounter?: Encounter | null;
|
||||||
|
undoRedoState?: UndoRedoState;
|
||||||
|
playerCharacters?: PlayerCharacter[];
|
||||||
|
creatures?: Map<CreatureId, Creature>;
|
||||||
|
sources?: Map<
|
||||||
|
string,
|
||||||
|
{ displayName: string; creatures: Creature[]; cachedAt: number }
|
||||||
|
>;
|
||||||
|
}): Adapters {
|
||||||
|
let storedEncounter = options?.encounter ?? null;
|
||||||
|
let storedUndoRedo = options?.undoRedoState ?? EMPTY_UNDO_REDO_STATE;
|
||||||
|
let storedPCs = options?.playerCharacters ?? [];
|
||||||
|
const sourceStore =
|
||||||
|
options?.sources ??
|
||||||
|
new Map<
|
||||||
|
string,
|
||||||
|
{ displayName: string; creatures: Creature[]; cachedAt: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Pre-populate sourceStore from creatures map if provided
|
||||||
|
if (options?.creatures && !options?.sources) {
|
||||||
|
// No-op: creatures are accessed directly from the map
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatureMap = options?.creatures ?? new Map<CreatureId, Creature>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounterPersistence: {
|
||||||
|
load: () => storedEncounter,
|
||||||
|
save: (e) => {
|
||||||
|
storedEncounter = e;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undoRedoPersistence: {
|
||||||
|
load: () => storedUndoRedo,
|
||||||
|
save: (state) => {
|
||||||
|
storedUndoRedo = state;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
playerCharacterPersistence: {
|
||||||
|
load: () => [...storedPCs],
|
||||||
|
save: (pcs) => {
|
||||||
|
storedPCs = pcs;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bestiaryCache: {
|
||||||
|
cacheSource(sourceCode, displayName, creatures) {
|
||||||
|
sourceStore.set(sourceCode, {
|
||||||
|
displayName,
|
||||||
|
creatures,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
});
|
||||||
|
for (const c of creatures) {
|
||||||
|
creatureMap.set(c.id, c);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
isSourceCached(sourceCode) {
|
||||||
|
return Promise.resolve(sourceStore.has(sourceCode));
|
||||||
|
},
|
||||||
|
getCachedSources() {
|
||||||
|
return Promise.resolve(
|
||||||
|
[...sourceStore.entries()].map(([sourceCode, info]) => ({
|
||||||
|
sourceCode,
|
||||||
|
displayName: info.displayName,
|
||||||
|
creatureCount: info.creatures.length,
|
||||||
|
cachedAt: info.cachedAt,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
clearSource(sourceCode) {
|
||||||
|
sourceStore.delete(sourceCode);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
clearAll() {
|
||||||
|
sourceStore.clear();
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
loadAllCachedCreatures() {
|
||||||
|
return Promise.resolve(new Map(creatureMap));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bestiaryIndex: {
|
||||||
|
loadIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: (sourceCode, baseUrl) => {
|
||||||
|
const filename = `bestiary-${sourceCode.toLowerCase()}.json`;
|
||||||
|
if (baseUrl !== undefined) {
|
||||||
|
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||||
|
return `${normalized}${filename}`;
|
||||||
|
}
|
||||||
|
return `https://example.com/${filename}`;
|
||||||
|
},
|
||||||
|
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,34 +7,6 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|||||||
import { App } from "../App.js";
|
import { App } from "../App.js";
|
||||||
import { AllProviders } from "./test-providers.js";
|
import { AllProviders } from "./test-providers.js";
|
||||||
|
|
||||||
// Mock persistence — no localStorage interaction
|
|
||||||
vi.mock("../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: () => null,
|
|
||||||
saveEncounter: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../persistence/player-character-storage.js", () => ({
|
|
||||||
loadPlayerCharacters: () => [],
|
|
||||||
savePlayerCharacters: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock bestiary — no IndexedDB or JSON index
|
|
||||||
vi.mock("../adapters/bestiary-cache.js", () => ({
|
|
||||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
|
||||||
isSourceCached: () => Promise.resolve(false),
|
|
||||||
cacheSource: () => Promise.resolve(),
|
|
||||||
getCachedSources: () => Promise.resolve([]),
|
|
||||||
clearSource: () => Promise.resolve(),
|
|
||||||
clearAll: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// DOM API stubs — jsdom doesn't implement these
|
// DOM API stubs — jsdom doesn't implement these
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(globalThis, "matchMedia", {
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
getAllSourceCodes,
|
|
||||||
getDefaultFetchUrl,
|
|
||||||
} from "../adapters/bestiary-index-adapter.js";
|
|
||||||
|
|
||||||
describe("getAllSourceCodes", () => {
|
|
||||||
it("returns all keys from the index sources object", () => {
|
|
||||||
const codes = getAllSourceCodes();
|
|
||||||
expect(codes.length).toBeGreaterThan(0);
|
|
||||||
expect(Array.isArray(codes)).toBe(true);
|
|
||||||
for (const code of codes) {
|
|
||||||
expect(typeof code).toBe("string");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getDefaultFetchUrl", () => {
|
|
||||||
it("returns the default URL when no baseUrl is provided", () => {
|
|
||||||
const url = getDefaultFetchUrl("XMM");
|
|
||||||
expect(url).toBe(
|
|
||||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-xmm.json",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("constructs URL from baseUrl with trailing slash", () => {
|
|
||||||
const url = getDefaultFetchUrl("PHB", "https://example.com/data/");
|
|
||||||
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes baseUrl without trailing slash", () => {
|
|
||||||
const url = getDefaultFetchUrl("PHB", "https://example.com/data");
|
|
||||||
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lowercases the source code in the filename", () => {
|
|
||||||
const url = getDefaultFetchUrl("MM", "https://example.com/");
|
|
||||||
expect(url).toBe("https://example.com/bestiary-mm.json");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
12
apps/web/src/__tests__/factories/build-combatant.ts
Normal file
12
apps/web/src/__tests__/factories/build-combatant.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Combatant } from "@initiative/domain";
|
||||||
|
import { combatantId } from "@initiative/domain";
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
export function buildCombatant(overrides?: Partial<Combatant>): Combatant {
|
||||||
|
return {
|
||||||
|
id: combatantId(`c-${++counter}`),
|
||||||
|
name: "Combatant",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
apps/web/src/__tests__/factories/build-encounter.ts
Normal file
10
apps/web/src/__tests__/factories/build-encounter.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Encounter } from "@initiative/domain";
|
||||||
|
|
||||||
|
export function buildEncounter(overrides?: Partial<Encounter>): Encounter {
|
||||||
|
return {
|
||||||
|
combatants: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
2
apps/web/src/__tests__/factories/index.ts
Normal file
2
apps/web/src/__tests__/factories/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { buildCombatant } from "./build-combatant.js";
|
||||||
|
export { buildEncounter } from "./build-encounter.js";
|
||||||
@@ -5,7 +5,9 @@ import type { Creature, CreatureId } from "@initiative/domain";
|
|||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
// Mock the context modules
|
// Uses context mocks because StatBlockPanel requires fine-grained control over
|
||||||
|
// panel state (collapsed/expanded, pinned/unpinned, wide/narrow desktop) that
|
||||||
|
// would need extensive setup to drive through real providers.
|
||||||
vi.mock("../contexts/side-panel-context.js", () => ({
|
vi.mock("../contexts/side-panel-context.js", () => ({
|
||||||
useSidePanelContext: vi.fn(),
|
useSidePanelContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -14,14 +16,6 @@ vi.mock("../contexts/bestiary-context.js", () => ({
|
|||||||
useBestiaryContext: vi.fn(),
|
useBestiaryContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock adapters to avoid IndexedDB
|
|
||||||
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import type { Adapters } from "../contexts/adapter-context.js";
|
||||||
|
import { AdapterProvider } from "../contexts/adapter-context.js";
|
||||||
import {
|
import {
|
||||||
BestiaryProvider,
|
BestiaryProvider,
|
||||||
BulkImportProvider,
|
BulkImportProvider,
|
||||||
@@ -9,9 +11,18 @@ import {
|
|||||||
SidePanelProvider,
|
SidePanelProvider,
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
} from "../contexts/index.js";
|
} from "../contexts/index.js";
|
||||||
|
import { createTestAdapters } from "./adapters/in-memory-adapters.js";
|
||||||
|
|
||||||
export function AllProviders({ children }: { children: ReactNode }) {
|
export function AllProviders({
|
||||||
|
adapters,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
adapters?: Adapters;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const resolved = adapters ?? createTestAdapters();
|
||||||
return (
|
return (
|
||||||
|
<AdapterProvider adapters={resolved}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<RulesEditionProvider>
|
<RulesEditionProvider>
|
||||||
<EncounterProvider>
|
<EncounterProvider>
|
||||||
@@ -19,7 +30,9 @@ export function AllProviders({ children }: { children: ReactNode }) {
|
|||||||
<PlayerCharactersProvider>
|
<PlayerCharactersProvider>
|
||||||
<BulkImportProvider>
|
<BulkImportProvider>
|
||||||
<SidePanelProvider>
|
<SidePanelProvider>
|
||||||
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
|
<InitiativeRollsProvider>
|
||||||
|
{children}
|
||||||
|
</InitiativeRollsProvider>
|
||||||
</SidePanelProvider>
|
</SidePanelProvider>
|
||||||
</BulkImportProvider>
|
</BulkImportProvider>
|
||||||
</PlayerCharactersProvider>
|
</PlayerCharactersProvider>
|
||||||
@@ -27,5 +40,6 @@ export function AllProviders({ children }: { children: ReactNode }) {
|
|||||||
</EncounterProvider>
|
</EncounterProvider>
|
||||||
</RulesEditionProvider>
|
</RulesEditionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</AdapterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { Creature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock idb to reject — simulates IndexedDB unavailable.
|
||||||
|
// This must be a separate file from bestiary-cache.test.ts because the
|
||||||
|
// module caches the db connection in a singleton; once openDB succeeds
|
||||||
|
// in one test, the fallback path is unreachable.
|
||||||
|
vi.mock("idb", () => ({
|
||||||
|
openDB: vi.fn().mockRejectedValue(new Error("IndexedDB unavailable")),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
cacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
getCachedSources,
|
||||||
|
clearSource,
|
||||||
|
clearAll,
|
||||||
|
loadAllCachedCreatures,
|
||||||
|
} = await import("../bestiary-cache.js");
|
||||||
|
|
||||||
|
function makeCreature(id: string, name: string): Creature {
|
||||||
|
return {
|
||||||
|
id: creatureId(id),
|
||||||
|
name,
|
||||||
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
alignment: "neutral evil",
|
||||||
|
ac: 15,
|
||||||
|
hp: { average: 7, formula: "2d6" },
|
||||||
|
speed: "30 ft.",
|
||||||
|
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
proficiencyBonus: 2,
|
||||||
|
passive: 9,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bestiary-cache fallback (IndexedDB unavailable)", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cacheSource falls back to in-memory store", async () => {
|
||||||
|
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||||
|
await cacheSource("MM", "Monster Manual", creatures);
|
||||||
|
|
||||||
|
expect(await isSourceCached("MM")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isSourceCached returns false for uncached source", async () => {
|
||||||
|
expect(await isSourceCached("XGE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCachedSources returns sources from in-memory store", async () => {
|
||||||
|
await cacheSource("MM", "Monster Manual", [
|
||||||
|
makeCreature("mm:goblin", "Goblin"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toHaveLength(1);
|
||||||
|
expect(sources[0].sourceCode).toBe("MM");
|
||||||
|
expect(sources[0].creatureCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loadAllCachedCreatures assembles creatures from in-memory store", async () => {
|
||||||
|
const goblin = makeCreature("mm:goblin", "Goblin");
|
||||||
|
await cacheSource("MM", "Monster Manual", [goblin]);
|
||||||
|
|
||||||
|
const map = await loadAllCachedCreatures();
|
||||||
|
expect(map.size).toBe(1);
|
||||||
|
expect(map.get(creatureId("mm:goblin"))?.name).toBe("Goblin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearSource removes a single source from in-memory store", async () => {
|
||||||
|
await cacheSource("MM", "Monster Manual", []);
|
||||||
|
await cacheSource("VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
|
await clearSource("MM");
|
||||||
|
|
||||||
|
expect(await isSourceCached("MM")).toBe(false);
|
||||||
|
expect(await isSourceCached("VGM")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearAll removes all data from in-memory store", async () => {
|
||||||
|
await cacheSource("MM", "Monster Manual", []);
|
||||||
|
await clearAll();
|
||||||
|
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
174
apps/web/src/adapters/__tests__/bestiary-cache.test.ts
Normal file
174
apps/web/src/adapters/__tests__/bestiary-cache.test.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import type { Creature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock idb — the one legitimate use of vi.mock for a third-party I/O library.
|
||||||
|
// We can't use real IndexedDB in jsdom; this tests the cache logic through
|
||||||
|
// all public API methods with an in-memory backing store.
|
||||||
|
const fakeStore = new Map<string, unknown>();
|
||||||
|
|
||||||
|
vi.mock("idb", () => ({
|
||||||
|
openDB: vi.fn().mockResolvedValue({
|
||||||
|
put: vi.fn((_storeName: string, value: unknown) => {
|
||||||
|
const record = value as { sourceCode: string };
|
||||||
|
fakeStore.set(record.sourceCode, value);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
get: vi.fn((_storeName: string, key: string) =>
|
||||||
|
Promise.resolve(fakeStore.get(key)),
|
||||||
|
),
|
||||||
|
getAll: vi.fn((_storeName: string) =>
|
||||||
|
Promise.resolve([...fakeStore.values()]),
|
||||||
|
),
|
||||||
|
delete: vi.fn((_storeName: string, key: string) => {
|
||||||
|
fakeStore.delete(key);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
clear: vi.fn((_storeName: string) => {
|
||||||
|
fakeStore.clear();
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
const {
|
||||||
|
cacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
getCachedSources,
|
||||||
|
clearSource,
|
||||||
|
clearAll,
|
||||||
|
loadAllCachedCreatures,
|
||||||
|
} = await import("../bestiary-cache.js");
|
||||||
|
|
||||||
|
function makeCreature(id: string, name: string): Creature {
|
||||||
|
return {
|
||||||
|
id: creatureId(id),
|
||||||
|
name,
|
||||||
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
alignment: "neutral evil",
|
||||||
|
ac: 15,
|
||||||
|
hp: { average: 7, formula: "2d6" },
|
||||||
|
speed: "30 ft.",
|
||||||
|
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
proficiencyBonus: 2,
|
||||||
|
passive: 9,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bestiary-cache", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fakeStore.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cacheSource", () => {
|
||||||
|
it("stores creatures and metadata", async () => {
|
||||||
|
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||||
|
await cacheSource("MM", "Monster Manual", creatures);
|
||||||
|
|
||||||
|
expect(fakeStore.has("MM")).toBe(true);
|
||||||
|
const record = fakeStore.get("MM") as {
|
||||||
|
sourceCode: string;
|
||||||
|
displayName: string;
|
||||||
|
creatures: Creature[];
|
||||||
|
creatureCount: number;
|
||||||
|
cachedAt: number;
|
||||||
|
};
|
||||||
|
expect(record.sourceCode).toBe("MM");
|
||||||
|
expect(record.displayName).toBe("Monster Manual");
|
||||||
|
expect(record.creatures).toHaveLength(1);
|
||||||
|
expect(record.creatureCount).toBe(1);
|
||||||
|
expect(record.cachedAt).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isSourceCached", () => {
|
||||||
|
it("returns false for uncached source", async () => {
|
||||||
|
expect(await isSourceCached("XGE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true after caching", async () => {
|
||||||
|
await cacheSource("MM", "Monster Manual", []);
|
||||||
|
expect(await isSourceCached("MM")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCachedSources", () => {
|
||||||
|
it("returns empty array when no sources cached", async () => {
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns source info with creature counts", async () => {
|
||||||
|
await cacheSource("MM", "Monster Manual", [
|
||||||
|
makeCreature("mm:goblin", "Goblin"),
|
||||||
|
makeCreature("mm:orc", "Orc"),
|
||||||
|
]);
|
||||||
|
await cacheSource("VGM", "Volo's Guide", [
|
||||||
|
makeCreature("vgm:flind", "Flind"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toHaveLength(2);
|
||||||
|
|
||||||
|
const mm = sources.find((s) => s.sourceCode === "MM");
|
||||||
|
expect(mm).toBeDefined();
|
||||||
|
expect(mm?.displayName).toBe("Monster Manual");
|
||||||
|
expect(mm?.creatureCount).toBe(2);
|
||||||
|
|
||||||
|
const vgm = sources.find((s) => s.sourceCode === "VGM");
|
||||||
|
expect(vgm?.creatureCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadAllCachedCreatures", () => {
|
||||||
|
it("returns empty map when nothing cached", async () => {
|
||||||
|
const map = await loadAllCachedCreatures();
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assembles creatures from all cached sources", async () => {
|
||||||
|
const goblin = makeCreature("mm:goblin", "Goblin");
|
||||||
|
const orc = makeCreature("mm:orc", "Orc");
|
||||||
|
const flind = makeCreature("vgm:flind", "Flind");
|
||||||
|
|
||||||
|
await cacheSource("MM", "Monster Manual", [goblin, orc]);
|
||||||
|
await cacheSource("VGM", "Volo's Guide", [flind]);
|
||||||
|
|
||||||
|
const map = await loadAllCachedCreatures();
|
||||||
|
expect(map.size).toBe(3);
|
||||||
|
expect(map.get(creatureId("mm:goblin"))?.name).toBe("Goblin");
|
||||||
|
expect(map.get(creatureId("mm:orc"))?.name).toBe("Orc");
|
||||||
|
expect(map.get(creatureId("vgm:flind"))?.name).toBe("Flind");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearSource", () => {
|
||||||
|
it("removes a single source", async () => {
|
||||||
|
await cacheSource("MM", "Monster Manual", []);
|
||||||
|
await cacheSource("VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
|
await clearSource("MM");
|
||||||
|
|
||||||
|
expect(await isSourceCached("MM")).toBe(false);
|
||||||
|
expect(await isSourceCached("VGM")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearAll", () => {
|
||||||
|
it("removes all cached data", async () => {
|
||||||
|
await cacheSource("MM", "Monster Manual", []);
|
||||||
|
await cacheSource("VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
|
await clearAll();
|
||||||
|
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
107
apps/web/src/adapters/__tests__/bestiary-index-adapter.test.ts
Normal file
107
apps/web/src/adapters/__tests__/bestiary-index-adapter.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getAllSourceCodes,
|
||||||
|
getDefaultFetchUrl,
|
||||||
|
getSourceDisplayName,
|
||||||
|
loadBestiaryIndex,
|
||||||
|
} from "../bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
describe("loadBestiaryIndex", () => {
|
||||||
|
it("returns an object with sources and creatures", () => {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
expect(index.sources).toBeDefined();
|
||||||
|
expect(index.creatures).toBeDefined();
|
||||||
|
expect(Array.isArray(index.creatures)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creatures have the expected shape", () => {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
expect(index.creatures.length).toBeGreaterThan(0);
|
||||||
|
const first = index.creatures[0];
|
||||||
|
expect(first).toHaveProperty("name");
|
||||||
|
expect(first).toHaveProperty("source");
|
||||||
|
expect(first).toHaveProperty("ac");
|
||||||
|
expect(first).toHaveProperty("hp");
|
||||||
|
expect(first).toHaveProperty("dex");
|
||||||
|
expect(first).toHaveProperty("cr");
|
||||||
|
expect(first).toHaveProperty("initiativeProficiency");
|
||||||
|
expect(first).toHaveProperty("size");
|
||||||
|
expect(first).toHaveProperty("type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same cached instance on subsequent calls", () => {
|
||||||
|
const a = loadBestiaryIndex();
|
||||||
|
const b = loadBestiaryIndex();
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sources is a record of source code to display name", () => {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
const entries = Object.entries(index.sources);
|
||||||
|
expect(entries.length).toBeGreaterThan(0);
|
||||||
|
for (const [code, name] of entries) {
|
||||||
|
expect(typeof code).toBe("string");
|
||||||
|
expect(typeof name).toBe("string");
|
||||||
|
expect(code.length).toBeGreaterThan(0);
|
||||||
|
expect(name.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllSourceCodes", () => {
|
||||||
|
it("returns all keys from the index sources", () => {
|
||||||
|
const codes = getAllSourceCodes();
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
expect(codes).toEqual(Object.keys(index.sources));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only strings", () => {
|
||||||
|
for (const code of getAllSourceCodes()) {
|
||||||
|
expect(typeof code).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDefaultFetchUrl", () => {
|
||||||
|
it("returns default GitHub URL when no baseUrl provided", () => {
|
||||||
|
const url = getDefaultFetchUrl("MM");
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-mm.json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constructs URL from baseUrl with trailing slash", () => {
|
||||||
|
const url = getDefaultFetchUrl("PHB", "https://example.com/data/");
|
||||||
|
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes baseUrl without trailing slash", () => {
|
||||||
|
const url = getDefaultFetchUrl("PHB", "https://example.com/data");
|
||||||
|
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lowercases the source code in the filename", () => {
|
||||||
|
const url = getDefaultFetchUrl("XMM");
|
||||||
|
expect(url).toContain("bestiary-xmm.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies filename override for Plane Shift sources", () => {
|
||||||
|
expect(getDefaultFetchUrl("PSA")).toContain("bestiary-ps-a.json");
|
||||||
|
expect(getDefaultFetchUrl("PSD")).toContain("bestiary-ps-d.json");
|
||||||
|
expect(getDefaultFetchUrl("PSK")).toContain("bestiary-ps-k.json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSourceDisplayName", () => {
|
||||||
|
it("returns display name for a known source", () => {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
const [code, expectedName] = Object.entries(index.sources)[0];
|
||||||
|
expect(getSourceDisplayName(code)).toBe(expectedName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to source code for unknown source", () => {
|
||||||
|
expect(getSourceDisplayName("UNKNOWN_SOURCE_XYZ")).toBe(
|
||||||
|
"UNKNOWN_SOURCE_XYZ",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ const DB_NAME = "initiative-bestiary";
|
|||||||
const STORE_NAME = "sources";
|
const STORE_NAME = "sources";
|
||||||
const DB_VERSION = 2;
|
const DB_VERSION = 2;
|
||||||
|
|
||||||
export interface CachedSourceInfo {
|
interface CachedSourceInfo {
|
||||||
readonly sourceCode: string;
|
readonly sourceCode: string;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
readonly creatureCount: number;
|
readonly creatureCount: number;
|
||||||
|
|||||||
50
apps/web/src/adapters/ports.ts
Normal file
50
apps/web/src/adapters/ports.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type {
|
||||||
|
BestiaryIndex,
|
||||||
|
Creature,
|
||||||
|
CreatureId,
|
||||||
|
Encounter,
|
||||||
|
PlayerCharacter,
|
||||||
|
UndoRedoState,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
|
||||||
|
export interface EncounterPersistence {
|
||||||
|
load(): Encounter | null;
|
||||||
|
save(encounter: Encounter): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UndoRedoPersistence {
|
||||||
|
load(): UndoRedoState;
|
||||||
|
save(state: UndoRedoState): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerCharacterPersistence {
|
||||||
|
load(): PlayerCharacter[];
|
||||||
|
save(characters: PlayerCharacter[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CachedSourceInfo {
|
||||||
|
readonly sourceCode: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly creatureCount: number;
|
||||||
|
readonly cachedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BestiaryCachePort {
|
||||||
|
cacheSource(
|
||||||
|
sourceCode: string,
|
||||||
|
displayName: string,
|
||||||
|
creatures: Creature[],
|
||||||
|
): Promise<void>;
|
||||||
|
isSourceCached(sourceCode: string): Promise<boolean>;
|
||||||
|
getCachedSources(): Promise<CachedSourceInfo[]>;
|
||||||
|
clearSource(sourceCode: string): Promise<void>;
|
||||||
|
clearAll(): Promise<void>;
|
||||||
|
loadAllCachedCreatures(): Promise<Map<CreatureId, Creature>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BestiaryIndexPort {
|
||||||
|
loadIndex(): BestiaryIndex;
|
||||||
|
getAllSourceCodes(): string[];
|
||||||
|
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||||
|
getSourceDisplayName(sourceCode: string): string;
|
||||||
|
}
|
||||||
44
apps/web/src/adapters/production-adapters.ts
Normal file
44
apps/web/src/adapters/production-adapters.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Adapters } from "../contexts/adapter-context.js";
|
||||||
|
import {
|
||||||
|
loadEncounter,
|
||||||
|
saveEncounter,
|
||||||
|
} from "../persistence/encounter-storage.js";
|
||||||
|
import {
|
||||||
|
loadPlayerCharacters,
|
||||||
|
savePlayerCharacters,
|
||||||
|
} from "../persistence/player-character-storage.js";
|
||||||
|
import {
|
||||||
|
loadUndoRedoStacks,
|
||||||
|
saveUndoRedoStacks,
|
||||||
|
} from "../persistence/undo-redo-storage.js";
|
||||||
|
import * as bestiaryCache from "./bestiary-cache.js";
|
||||||
|
import * as bestiaryIndex from "./bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
export const productionAdapters: Adapters = {
|
||||||
|
encounterPersistence: {
|
||||||
|
load: loadEncounter,
|
||||||
|
save: saveEncounter,
|
||||||
|
},
|
||||||
|
undoRedoPersistence: {
|
||||||
|
load: loadUndoRedoStacks,
|
||||||
|
save: saveUndoRedoStacks,
|
||||||
|
},
|
||||||
|
playerCharacterPersistence: {
|
||||||
|
load: loadPlayerCharacters,
|
||||||
|
save: savePlayerCharacters,
|
||||||
|
},
|
||||||
|
bestiaryCache: {
|
||||||
|
cacheSource: bestiaryCache.cacheSource,
|
||||||
|
isSourceCached: bestiaryCache.isSourceCached,
|
||||||
|
getCachedSources: bestiaryCache.getCachedSources,
|
||||||
|
clearSource: bestiaryCache.clearSource,
|
||||||
|
clearAll: bestiaryCache.clearAll,
|
||||||
|
loadAllCachedCreatures: bestiaryCache.loadAllCachedCreatures,
|
||||||
|
},
|
||||||
|
bestiaryIndex: {
|
||||||
|
loadIndex: bestiaryIndex.loadBestiaryIndex,
|
||||||
|
getAllSourceCodes: bestiaryIndex.getAllSourceCodes,
|
||||||
|
getDefaultFetchUrl: bestiaryIndex.getDefaultFetchUrl,
|
||||||
|
getSourceDisplayName: bestiaryIndex.getSourceDisplayName,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,41 +1,16 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { playerCharacterId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { ActionBar } from "../action-bar.js";
|
import { ActionBar } from "../action-bar.js";
|
||||||
|
|
||||||
// Mock persistence — no localStorage interaction
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: () => null,
|
|
||||||
saveEncounter: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
|
||||||
loadPlayerCharacters: () => [],
|
|
||||||
savePlayerCharacters: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock bestiary — no IndexedDB or JSON index
|
|
||||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
|
||||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
|
||||||
isSourceCached: () => Promise.resolve(false),
|
|
||||||
cacheSource: () => Promise.resolve(),
|
|
||||||
getCachedSources: () => Promise.resolve([]),
|
|
||||||
clearSource: () => Promise.resolve(),
|
|
||||||
clearAll: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// DOM API stubs — jsdom doesn't implement these
|
// DOM API stubs — jsdom doesn't implement these
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(globalThis, "matchMedia", {
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
@@ -60,10 +35,97 @@ function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
|||||||
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderBarWithBestiary(
|
||||||
|
props: Partial<Parameters<typeof ActionBar>[0]> = {},
|
||||||
|
) {
|
||||||
|
const adapters = createTestAdapters();
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
|
loadIndex: () => ({
|
||||||
|
sources: { MM: "Monster Manual" },
|
||||||
|
creatures: [
|
||||||
|
{
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Golem, Iron",
|
||||||
|
source: "MM",
|
||||||
|
ac: 20,
|
||||||
|
hp: 210,
|
||||||
|
dex: 9,
|
||||||
|
cr: "16",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Large",
|
||||||
|
type: "construct",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
getSourceDisplayName: (code: string) =>
|
||||||
|
code === "MM" ? "Monster Manual" : code,
|
||||||
|
};
|
||||||
|
return render(<ActionBar {...props} />, {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBarWithPCs(
|
||||||
|
props: Partial<Parameters<typeof ActionBar>[0]> = {},
|
||||||
|
) {
|
||||||
|
const adapters = createTestAdapters({
|
||||||
|
playerCharacters: [
|
||||||
|
{
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Gandalf",
|
||||||
|
ac: 15,
|
||||||
|
maxHp: 40,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
|
loadIndex: () => ({
|
||||||
|
sources: { MM: "Monster Manual" },
|
||||||
|
creatures: [
|
||||||
|
{
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
getSourceDisplayName: (code: string) =>
|
||||||
|
code === "MM" ? "Monster Manual" : code,
|
||||||
|
};
|
||||||
|
return render(<ActionBar {...props} />, {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("ActionBar", () => {
|
describe("ActionBar", () => {
|
||||||
|
describe("basic rendering and custom add", () => {
|
||||||
it("renders input with placeholder '+ Add combatants'", () => {
|
it("renders input with placeholder '+ Add combatants'", () => {
|
||||||
renderBar();
|
renderBar();
|
||||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByPlaceholderText("+ Add combatants"),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submitting with a name adds a combatant", async () => {
|
it("submitting with a name adds a combatant", async () => {
|
||||||
@@ -71,20 +133,16 @@ describe("ActionBar", () => {
|
|||||||
renderBar();
|
renderBar();
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
await user.type(input, "Goblin");
|
await user.type(input, "Goblin");
|
||||||
// The Add button appears when name >= 2 chars and no suggestions
|
|
||||||
const addButton = screen.getByRole("button", { name: "Add" });
|
const addButton = screen.getByRole("button", { name: "Add" });
|
||||||
await user.click(addButton);
|
await user.click(addButton);
|
||||||
// Input is cleared after adding (context handles the state)
|
|
||||||
expect(input).toHaveValue("");
|
expect(input).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submitting with empty name does nothing", async () => {
|
it("submitting with empty name does nothing", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderBar();
|
renderBar();
|
||||||
// Submit the form directly (Enter on empty input)
|
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
await user.type(input, "{Enter}");
|
await user.type(input, "{Enter}");
|
||||||
// Input stays empty, no error
|
|
||||||
expect(input).toHaveValue("");
|
expect(input).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,6 +164,160 @@ describe("ActionBar", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("submits custom stats with combatant", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Fighter");
|
||||||
|
await user.type(screen.getByPlaceholderText("Init"), "15");
|
||||||
|
await user.type(screen.getByPlaceholderText("AC"), "18");
|
||||||
|
await user.type(screen.getByPlaceholderText("MaxHP"), "45");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bestiary suggestions and queuing", () => {
|
||||||
|
it("shows bestiary suggestions when typing a matching name", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Golem, Iron")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a suggestion queues it with count badge", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the Goblin suggestion
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
|
||||||
|
// Should show count badge "1"
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking same suggestion again increments count", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
|
||||||
|
expect(screen.getByText("2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirming queued creatures adds them to the encounter", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue 1 Goblin
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
|
||||||
|
// Press Enter to confirm the queued creature
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
|
||||||
|
// Input should be cleared after confirming
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears queued when search text no longer matches", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Change search to something with no matches
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "xyz");
|
||||||
|
|
||||||
|
// Count badge should be gone
|
||||||
|
expect(screen.queryByText("1")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("player character matching", () => {
|
||||||
|
it("shows matching player characters in suggestions", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithPCs();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Gan");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Gandalf")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Player")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("browse mode", () => {
|
||||||
|
it("toggles browse mode via eye icon button", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
|
||||||
|
const browseButton = screen.getByRole("button", {
|
||||||
|
name: "Browse stat blocks",
|
||||||
|
});
|
||||||
|
await user.click(browseButton);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText("Search stat blocks..."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Switch to add mode" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("browse mode shows suggestions without add UI", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Browse stat blocks" }),
|
||||||
|
);
|
||||||
|
const input = screen.getByPlaceholderText("Search stat blocks...");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
// No Add button in browse mode
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Add" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("overflow menu", () => {
|
||||||
it("does not show roll all initiative button when no creature combatants", () => {
|
it("does not show roll all initiative button when no creature combatants", () => {
|
||||||
renderBar();
|
renderBar();
|
||||||
expect(
|
expect(
|
||||||
@@ -115,7 +327,6 @@ describe("ActionBar", () => {
|
|||||||
|
|
||||||
it("shows overflow menu items", () => {
|
it("shows overflow menu items", () => {
|
||||||
renderBar({ onManagePlayers: vi.fn() });
|
renderBar({ onManagePlayers: vi.fn() });
|
||||||
// The overflow menu should be present (it contains Player Characters etc.)
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "More actions" }),
|
screen.getByRole("button", { name: "More actions" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -125,10 +336,8 @@ describe("ActionBar", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderBar();
|
renderBar();
|
||||||
await user.click(screen.getByRole("button", { name: "More actions" }));
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
// Click the menu item
|
|
||||||
const items = screen.getAllByText("Export Encounter");
|
const items = screen.getAllByText("Export Encounter");
|
||||||
await user.click(items[0]);
|
await user.click(items[0]);
|
||||||
// Dialog should now be open — it renders a second "Export Encounter" as heading
|
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByText("Export Encounter").length,
|
screen.getAllByText("Export Encounter").length,
|
||||||
).toBeGreaterThanOrEqual(1);
|
).toBeGreaterThanOrEqual(1);
|
||||||
@@ -162,19 +371,5 @@ describe("ActionBar", () => {
|
|||||||
await user.click(screen.getByText("Settings"));
|
await user.click(screen.getByText("Settings"));
|
||||||
expect(onOpenSettings).toHaveBeenCalledOnce();
|
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("");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||||
import { BulkImportPrompt } from "../bulk-import-prompt.js";
|
import { BulkImportPrompt } from "../bulk-import-prompt.js";
|
||||||
|
|
||||||
const THREE_SOURCES_REGEX = /3 sources/;
|
const THREE_SOURCES_REGEX = /3 sources/;
|
||||||
@@ -28,6 +30,10 @@ let mockImportState = {
|
|||||||
failed: 0,
|
failed: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Uses context mocks because the bulk import state machine (idle → loading →
|
||||||
|
// complete → partial-failure) is impractical to drive through user interactions
|
||||||
|
// without real network calls. Consider migrating if adapter injection expands
|
||||||
|
// to cover these state transitions.
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||||
useBestiaryContext: () => ({
|
useBestiaryContext: () => ({
|
||||||
fetchAndCacheSource: mockFetchAndCacheSource,
|
fetchAndCacheSource: mockFetchAndCacheSource,
|
||||||
@@ -50,12 +56,23 @@ vi.mock("../../contexts/side-panel-context.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
function createAdaptersWithSources() {
|
||||||
|
const adapters = createTestAdapters();
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||||
getDefaultFetchUrl: () => "",
|
};
|
||||||
getSourceDisplayName: (code: string) => code,
|
return adapters;
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
}
|
||||||
}));
|
|
||||||
|
function renderWithAdapters() {
|
||||||
|
const adapters = createAdaptersWithSources();
|
||||||
|
return render(
|
||||||
|
<AdapterProvider adapters={adapters}>
|
||||||
|
<BulkImportPrompt />
|
||||||
|
</AdapterProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("BulkImportPrompt", () => {
|
describe("BulkImportPrompt", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -64,7 +81,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("idle: shows base URL input, source count, Load All button", () => {
|
it("idle: shows base URL input, source count, Load All button", () => {
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
expect(screen.getByText(THREE_SOURCES_REGEX)).toBeInTheDocument();
|
expect(screen.getByText(THREE_SOURCES_REGEX)).toBeInTheDocument();
|
||||||
expect(screen.getByDisplayValue(GITHUB_URL_REGEX)).toBeInTheDocument();
|
expect(screen.getByDisplayValue(GITHUB_URL_REGEX)).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
@@ -74,7 +91,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
|
|
||||||
it("idle: clearing URL disables the button", async () => {
|
it("idle: clearing URL disables the button", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
|
|
||||||
const input = screen.getByDisplayValue(GITHUB_URL_REGEX);
|
const input = screen.getByDisplayValue(GITHUB_URL_REGEX);
|
||||||
await user.clear(input);
|
await user.clear(input);
|
||||||
@@ -83,7 +100,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
|
|
||||||
it("idle: clicking Load All calls startImport with URL", async () => {
|
it("idle: clicking Load All calls startImport with URL", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Load All" }));
|
await user.click(screen.getByRole("button", { name: "Load All" }));
|
||||||
expect(mockStartImport).toHaveBeenCalledWith(
|
expect(mockStartImport).toHaveBeenCalledWith(
|
||||||
@@ -101,7 +118,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
completed: 3,
|
completed: 3,
|
||||||
failed: 1,
|
failed: 1,
|
||||||
};
|
};
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
expect(screen.getByText(LOADING_PROGRESS_REGEX)).toBeInTheDocument();
|
expect(screen.getByText(LOADING_PROGRESS_REGEX)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,7 +129,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
completed: 10,
|
completed: 10,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
};
|
};
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
expect(screen.getByText("All sources loaded")).toBeInTheDocument();
|
expect(screen.getByText("All sources loaded")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -125,7 +142,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
failed: 0,
|
failed: 0,
|
||||||
};
|
};
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Done" }));
|
await user.click(screen.getByRole("button", { name: "Done" }));
|
||||||
expect(mockDismissPanel).toHaveBeenCalled();
|
expect(mockDismissPanel).toHaveBeenCalled();
|
||||||
@@ -139,7 +156,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
completed: 7,
|
completed: 7,
|
||||||
failed: 3,
|
failed: 3,
|
||||||
};
|
};
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
expect(screen.getByText(SEVEN_OF_TEN_REGEX)).toBeInTheDocument();
|
expect(screen.getByText(SEVEN_OF_TEN_REGEX)).toBeInTheDocument();
|
||||||
expect(screen.getByText(THREE_FAILED_REGEX)).toBeInTheDocument();
|
expect(screen.getByText(THREE_FAILED_REGEX)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,34 +13,6 @@ const TEMP_HP_REGEX = /^\+\d/;
|
|||||||
const CURRENT_HP_7_REGEX = /Current HP: 7/;
|
const CURRENT_HP_7_REGEX = /Current HP: 7/;
|
||||||
const CURRENT_HP_REGEX = /Current HP/;
|
const CURRENT_HP_REGEX = /Current HP/;
|
||||||
|
|
||||||
// Mock persistence — no localStorage interaction
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: () => null,
|
|
||||||
saveEncounter: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
|
||||||
loadPlayerCharacters: () => [],
|
|
||||||
savePlayerCharacters: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock bestiary — no IndexedDB or JSON index
|
|
||||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
|
||||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
|
||||||
isSourceCached: () => Promise.resolve(false),
|
|
||||||
cacheSource: () => Promise.resolve(),
|
|
||||||
getCachedSources: () => Promise.resolve([]),
|
|
||||||
clearSource: () => Promise.resolve(),
|
|
||||||
clearAll: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// DOM API stubs
|
// DOM API stubs
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(globalThis, "matchMedia", {
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
|||||||
@@ -3,36 +3,33 @@ import type { ConditionId } from "@initiative/domain";
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||||
import { ConditionTags } from "../condition-tags.js";
|
import { ConditionTags } from "../condition-tags.js";
|
||||||
|
|
||||||
vi.mock("../../contexts/rules-edition-context.js", () => ({
|
|
||||||
useRulesEditionContext: () => ({ edition: "5.5e" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderTags(props: Partial<Parameters<typeof ConditionTags>[0]> = {}) {
|
||||||
|
return render(
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<ConditionTags
|
||||||
|
conditions={props.conditions}
|
||||||
|
onRemove={props.onRemove ?? (() => {})}
|
||||||
|
onOpenPicker={props.onOpenPicker ?? (() => {})}
|
||||||
|
/>
|
||||||
|
</RulesEditionProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("ConditionTags", () => {
|
describe("ConditionTags", () => {
|
||||||
it("renders nothing when conditions is undefined", () => {
|
it("renders nothing when conditions is undefined", () => {
|
||||||
const { container } = render(
|
const { container } = renderTags();
|
||||||
<ConditionTags
|
|
||||||
conditions={undefined}
|
|
||||||
onRemove={() => {}}
|
|
||||||
onOpenPicker={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
// Only the add button should be present
|
// Only the add button should be present
|
||||||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a button per condition", () => {
|
it("renders a button per condition", () => {
|
||||||
const conditions: ConditionId[] = ["blinded", "prone"];
|
const conditions: ConditionId[] = ["blinded", "prone"];
|
||||||
render(
|
renderTags({ conditions });
|
||||||
<ConditionTags
|
|
||||||
conditions={conditions}
|
|
||||||
onRemove={() => {}}
|
|
||||||
onOpenPicker={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
@@ -41,13 +38,10 @@ describe("ConditionTags", () => {
|
|||||||
|
|
||||||
it("calls onRemove with condition id when clicked", async () => {
|
it("calls onRemove with condition id when clicked", async () => {
|
||||||
const onRemove = vi.fn();
|
const onRemove = vi.fn();
|
||||||
render(
|
renderTags({
|
||||||
<ConditionTags
|
conditions: ["blinded"] as ConditionId[],
|
||||||
conditions={["blinded"] as ConditionId[]}
|
onRemove,
|
||||||
onRemove={onRemove}
|
});
|
||||||
onOpenPicker={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await userEvent.click(
|
await userEvent.click(
|
||||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
@@ -58,13 +52,7 @@ describe("ConditionTags", () => {
|
|||||||
|
|
||||||
it("calls onOpenPicker when add button is clicked", async () => {
|
it("calls onOpenPicker when add button is clicked", async () => {
|
||||||
const onOpenPicker = vi.fn();
|
const onOpenPicker = vi.fn();
|
||||||
render(
|
renderTags({ conditions: [], onOpenPicker });
|
||||||
<ConditionTags
|
|
||||||
conditions={[]}
|
|
||||||
onRemove={() => {}}
|
|
||||||
onOpenPicker={onOpenPicker}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await userEvent.click(
|
await userEvent.click(
|
||||||
screen.getByRole("button", { name: "Add condition" }),
|
screen.getByRole("button", { name: "Add condition" }),
|
||||||
@@ -74,13 +62,7 @@ describe("ConditionTags", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders empty conditions array without errors", () => {
|
it("renders empty conditions array without errors", () => {
|
||||||
render(
|
renderTags({ conditions: [] });
|
||||||
<ConditionTags
|
|
||||||
conditions={[]}
|
|
||||||
onRemove={() => {}}
|
|
||||||
onOpenPicker={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
// Only add button
|
// Only add button
|
||||||
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
|
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,32 +33,6 @@ beforeAll(() => {
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: () => null,
|
|
||||||
saveEncounter: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
|
||||||
loadPlayerCharacters: () => [],
|
|
||||||
savePlayerCharacters: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
|
||||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
|
||||||
isSourceCached: () => Promise.resolve(false),
|
|
||||||
cacheSource: () => Promise.resolve(),
|
|
||||||
getCachedSources: () => Promise.resolve([]),
|
|
||||||
clearSource: () => Promise.resolve(),
|
|
||||||
clearAll: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function renderSection() {
|
function renderSection() {
|
||||||
const ref = createRef<PlayerCharacterSectionHandle>();
|
const ref = createRef<PlayerCharacterSectionHandle>();
|
||||||
const result = render(<PlayerCharacterSection ref={ref} />, {
|
const result = render(<PlayerCharacterSection ref={ref} />, {
|
||||||
|
|||||||
@@ -28,32 +28,6 @@ beforeAll(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: () => null,
|
|
||||||
saveEncounter: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
|
||||||
loadPlayerCharacters: () => [],
|
|
||||||
savePlayerCharacters: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
|
||||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
|
||||||
isSourceCached: () => Promise.resolve(false),
|
|
||||||
cacheSource: () => Promise.resolve(),
|
|
||||||
getCachedSources: () => Promise.resolve([]),
|
|
||||||
clearSource: () => Promise.resolve(),
|
|
||||||
clearAll: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function renderModal(open = true) {
|
function renderModal(open = true) {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const result = render(<SettingsModal open={open} onClose={onClose} />, {
|
const result = render(<SettingsModal open={open} onClose={onClose} />, {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||||
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
||||||
|
|
||||||
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
||||||
@@ -13,6 +15,9 @@ afterEach(cleanup);
|
|||||||
const mockFetchAndCacheSource = vi.fn();
|
const mockFetchAndCacheSource = vi.fn();
|
||||||
const mockUploadAndCacheSource = vi.fn();
|
const mockUploadAndCacheSource = vi.fn();
|
||||||
|
|
||||||
|
// Uses context mock because fetchAndCacheSource/uploadAndCacheSource involve
|
||||||
|
// real fetch() calls. The test controls success/failure to verify the
|
||||||
|
// component's loading and error UI, not the fetching logic itself.
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||||
useBestiaryContext: () => ({
|
useBestiaryContext: () => ({
|
||||||
fetchAndCacheSource: mockFetchAndCacheSource,
|
fetchAndCacheSource: mockFetchAndCacheSource,
|
||||||
@@ -20,22 +25,23 @@ vi.mock("../../contexts/bestiary-context.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
function renderPrompt(sourceCode = "MM") {
|
||||||
|
const onSourceLoaded = vi.fn();
|
||||||
|
const adapters = createTestAdapters();
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
getDefaultFetchUrl: (code: string) =>
|
getDefaultFetchUrl: (code: string) =>
|
||||||
`https://example.com/bestiary/${code}.json`,
|
`https://example.com/bestiary/${code}.json`,
|
||||||
getSourceDisplayName: (code: string) =>
|
getSourceDisplayName: (code: string) =>
|
||||||
code === "MM" ? "Monster Manual" : code,
|
code === "MM" ? "Monster Manual" : code,
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
};
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
function renderPrompt(sourceCode = "MM") {
|
|
||||||
const onSourceLoaded = vi.fn();
|
|
||||||
const result = render(
|
const result = render(
|
||||||
|
<AdapterProvider adapters={adapters}>
|
||||||
<SourceFetchPrompt
|
<SourceFetchPrompt
|
||||||
sourceCode={sourceCode}
|
sourceCode={sourceCode}
|
||||||
onSourceLoaded={onSourceLoaded}
|
onSourceLoaded={onSourceLoaded}
|
||||||
/>,
|
/>
|
||||||
|
</AdapterProvider>,
|
||||||
);
|
);
|
||||||
return { ...result, onSourceLoaded };
|
return { ...result, onSourceLoaded };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,60 +3,68 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
|
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import type { ReactNode } from "react";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
getCachedSources: vi.fn(),
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
clearSource: vi.fn(),
|
import type { CachedSourceInfo } from "../../adapters/ports.js";
|
||||||
clearAll: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the context module
|
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
|
||||||
useBestiaryContext: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
|
|
||||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
|
||||||
import { SourceManager } from "../source-manager.js";
|
import { SourceManager } from "../source-manager.js";
|
||||||
|
|
||||||
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
|
beforeAll(() => {
|
||||||
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
writable: true,
|
||||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
afterEach(() => {
|
media: query,
|
||||||
cleanup();
|
onchange: null,
|
||||||
vi.clearAllMocks();
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupMockContext() {
|
afterEach(cleanup);
|
||||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
|
||||||
mockUseBestiaryContext.mockReturnValue({
|
function renderWithSources(sources: CachedSourceInfo[] = []) {
|
||||||
refreshCache,
|
const adapters = createTestAdapters();
|
||||||
search: vi.fn().mockReturnValue([]),
|
// Wire getCachedSources to return the provided sources initially,
|
||||||
getCreature: vi.fn(),
|
// then empty after clear operations
|
||||||
isLoaded: true,
|
let currentSources = [...sources];
|
||||||
isSourceCached: vi.fn().mockResolvedValue(false),
|
adapters.bestiaryCache = {
|
||||||
fetchAndCacheSource: vi.fn(),
|
...adapters.bestiaryCache,
|
||||||
uploadAndCacheSource: vi.fn(),
|
getCachedSources: () => Promise.resolve(currentSources),
|
||||||
} as ReturnType<typeof useBestiaryContext>);
|
clearSource(sourceCode) {
|
||||||
return { refreshCache };
|
currentSources = currentSources.filter(
|
||||||
|
(s) => s.sourceCode !== sourceCode,
|
||||||
|
);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
clearAll() {
|
||||||
|
currentSources = [];
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<SourceManager />, {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("SourceManager", () => {
|
describe("SourceManager", () => {
|
||||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||||
setupMockContext();
|
void renderWithSources([]);
|
||||||
mockGetCachedSources.mockResolvedValue([]);
|
|
||||||
render(<SourceManager />);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists cached sources with display name and creature count", async () => {
|
it("lists cached sources with display name and creature count", async () => {
|
||||||
setupMockContext();
|
void renderWithSources([
|
||||||
mockGetCachedSources.mockResolvedValue([
|
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
@@ -70,7 +78,6 @@ describe("SourceManager", () => {
|
|||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
render(<SourceManager />);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -79,38 +86,31 @@ describe("SourceManager", () => {
|
|||||||
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Clear All button calls cache clear and refreshCache", async () => {
|
it("Clear All button removes all sources", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { refreshCache } = setupMockContext();
|
void renderWithSources([
|
||||||
mockGetCachedSources
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
creatureCount: 300,
|
creatureCount: 300,
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
.mockResolvedValue([]);
|
|
||||||
mockClearAll.mockResolvedValue(undefined);
|
|
||||||
render(<SourceManager />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Clear All" }));
|
await user.click(screen.getByRole("button", { name: "Clear All" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClearAll).toHaveBeenCalled();
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(refreshCache).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("individual source delete button calls clear for that source", async () => {
|
it("individual source delete button removes that source", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { refreshCache } = setupMockContext();
|
void renderWithSources([
|
||||||
mockGetCachedSources
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
@@ -123,18 +123,8 @@ describe("SourceManager", () => {
|
|||||||
creatureCount: 100,
|
creatureCount: 100,
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
},
|
},
|
||||||
])
|
|
||||||
.mockResolvedValue([
|
|
||||||
{
|
|
||||||
sourceCode: "vgm",
|
|
||||||
displayName: "Volo's Guide",
|
|
||||||
creatureCount: 100,
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
mockClearSource.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
render(<SourceManager />);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -142,9 +132,10 @@ describe("SourceManager", () => {
|
|||||||
await user.click(
|
await user.click(
|
||||||
screen.getByRole("button", { name: "Remove Monster Manual" }),
|
screen.getByRole("button", { name: "Remove Monster Manual" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClearSource).toHaveBeenCalledWith("mm");
|
expect(screen.queryByText("Monster Manual")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(refreshCache).toHaveBeenCalled();
|
expect(screen.getByText("Volo's Guide")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,100 +1,68 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import type { Encounter } from "@initiative/domain";
|
|
||||||
import { combatantId } from "@initiative/domain";
|
import { combatantId } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import type { ReactNode } from "react";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
// Mock the context modules
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
import {
|
||||||
useEncounterContext: vi.fn(),
|
buildCombatant,
|
||||||
}));
|
buildEncounter,
|
||||||
|
} from "../../__tests__/factories/index.js";
|
||||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
usePlayerCharactersContext: vi.fn().mockReturnValue({ characters: [] }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
|
||||||
useBestiaryContext: vi.fn().mockReturnValue({ getCreature: () => undefined }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
|
||||||
import { TurnNavigation } from "../turn-navigation.js";
|
import { TurnNavigation } from "../turn-navigation.js";
|
||||||
|
|
||||||
const mockUseEncounterContext = vi.mocked(useEncounterContext);
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
afterEach(() => {
|
writable: true,
|
||||||
cleanup();
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
vi.clearAllMocks();
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockContext(overrides: Partial<Encounter> = {}) {
|
afterEach(cleanup);
|
||||||
const encounter: Encounter = {
|
|
||||||
combatants: [
|
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
|
||||||
{ id: combatantId("2"), name: "Conjurer" },
|
|
||||||
],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
|
|
||||||
const value = {
|
function renderNav(encounter = buildEncounter()) {
|
||||||
encounter,
|
const adapters = createTestAdapters({ encounter });
|
||||||
advanceTurn: vi.fn(),
|
return render(<TurnNavigation />, {
|
||||||
retreatTurn: vi.fn(),
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
clearEncounter: vi.fn(),
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
isEmpty: encounter.combatants.length === 0,
|
),
|
||||||
hasCreatureCombatants: false,
|
});
|
||||||
canRollAllInitiative: false,
|
|
||||||
addCombatant: vi.fn(),
|
|
||||||
removeCombatant: vi.fn(),
|
|
||||||
editCombatant: vi.fn(),
|
|
||||||
setInitiative: vi.fn(),
|
|
||||||
setHp: vi.fn(),
|
|
||||||
adjustHp: vi.fn(),
|
|
||||||
setTempHp: vi.fn(),
|
|
||||||
hasTempHp: false,
|
|
||||||
setAc: vi.fn(),
|
|
||||||
toggleCondition: vi.fn(),
|
|
||||||
toggleConcentration: vi.fn(),
|
|
||||||
addFromBestiary: vi.fn(),
|
|
||||||
addMultipleFromBestiary: vi.fn(),
|
|
||||||
addFromPlayerCharacter: vi.fn(),
|
|
||||||
makeStore: vi.fn(),
|
|
||||||
withUndo: vi.fn((action: () => unknown) => action()),
|
|
||||||
undo: vi.fn(),
|
|
||||||
redo: vi.fn(),
|
|
||||||
canUndo: false,
|
|
||||||
canRedo: false,
|
|
||||||
undoRedoState: { undoStack: [], redoStack: [] },
|
|
||||||
setEncounter: vi.fn(),
|
|
||||||
setUndoRedoState: vi.fn(),
|
|
||||||
events: [],
|
|
||||||
lastCreatureId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
mockUseEncounterContext.mockReturnValue(
|
|
||||||
value as ReturnType<typeof useEncounterContext>,
|
|
||||||
);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNav(overrides: Partial<Encounter> = {}) {
|
|
||||||
mockContext(overrides);
|
|
||||||
return render(<TurnNavigation />);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TurnNavigation", () => {
|
describe("TurnNavigation", () => {
|
||||||
describe("US1: Round badge and combatant name", () => {
|
describe("US1: Round badge and combatant name", () => {
|
||||||
it("renders the round badge with correct round number", () => {
|
it("renders the round badge with correct round number", () => {
|
||||||
renderNav({ roundNumber: 3 });
|
renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
roundNumber: 3,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the combatant name separately from the round badge", () => {
|
it("renders the combatant name separately from the round badge", () => {
|
||||||
renderNav();
|
renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Goblin" }),
|
||||||
|
buildCombatant({ id: combatantId("c-2"), name: "Conjurer" }),
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const badge = screen.getByText("R1");
|
const badge = screen.getByText("R1");
|
||||||
const name = screen.getByText("Goblin");
|
const name = screen.getByText("Goblin");
|
||||||
expect(badge).toBeInTheDocument();
|
expect(badge).toBeInTheDocument();
|
||||||
@@ -104,59 +72,45 @@ describe("TurnNavigation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not render an em dash between round and name", () => {
|
it("does not render an em dash between round and name", () => {
|
||||||
const { container } = renderNav();
|
const { container } = renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(container.textContent).not.toContain("\u2014");
|
expect(container.textContent).not.toContain("\u2014");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round badge and combatant name are siblings in the center area", () => {
|
it("round badge and combatant name are siblings in the center area", () => {
|
||||||
renderNav();
|
renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
const badge = screen.getByText("R1");
|
const badge = screen.getByText("R1");
|
||||||
const name = screen.getByText("Goblin");
|
const name = screen.getByText("Goblin");
|
||||||
// badge text is inside inner span > outer span, name is a direct child
|
|
||||||
expect(badge.closest(".flex")).toBe(name.parentElement);
|
expect(badge.closest(".flex")).toBe(name.parentElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the round badge when round changes", () => {
|
|
||||||
mockContext({ roundNumber: 2 });
|
|
||||||
const { rerender } = render(<TurnNavigation />);
|
|
||||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
|
||||||
|
|
||||||
mockContext({ roundNumber: 3 });
|
|
||||||
rerender(<TurnNavigation />);
|
|
||||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the next combatant name when turn advances", () => {
|
|
||||||
const combatants = [
|
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
|
||||||
{ id: combatantId("2"), name: "Conjurer" },
|
|
||||||
];
|
|
||||||
mockContext({ combatants, activeIndex: 0 });
|
|
||||||
const { rerender } = render(<TurnNavigation />);
|
|
||||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
|
||||||
|
|
||||||
mockContext({ combatants, activeIndex: 1 });
|
|
||||||
rerender(<TurnNavigation />);
|
|
||||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("US2: Layout robustness", () => {
|
describe("US2: Layout robustness", () => {
|
||||||
it("applies truncation styles to long combatant names", () => {
|
it("applies truncation styles to long combatant names", () => {
|
||||||
const longName =
|
const longName =
|
||||||
"Ancient Red Dragon Wyrm of the Northern Wastes and Beyond";
|
"Ancient Red Dragon Wyrm of the Northern Wastes and Beyond";
|
||||||
renderNav({
|
renderNav(
|
||||||
combatants: [{ id: combatantId("1"), name: longName }],
|
buildEncounter({
|
||||||
});
|
combatants: [buildCombatant({ name: longName })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
const nameEl = screen.getByText(longName);
|
const nameEl = screen.getByText(longName);
|
||||||
expect(nameEl.className).toContain("truncate");
|
expect(nameEl.className).toContain("truncate");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders three-zone layout with a single-character name", () => {
|
it("renders three-zone layout with a single-character name", () => {
|
||||||
renderNav({
|
renderNav(
|
||||||
combatants: [{ id: combatantId("1"), name: "O" }],
|
buildEncounter({
|
||||||
});
|
combatants: [buildCombatant({ name: "O" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
expect(screen.getByText("O")).toBeInTheDocument();
|
expect(screen.getByText("O")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
@@ -169,9 +123,11 @@ describe("TurnNavigation", () => {
|
|||||||
|
|
||||||
it("keeps all action buttons accessible regardless of name length", () => {
|
it("keeps all action buttons accessible regardless of name length", () => {
|
||||||
const longName = "A".repeat(60);
|
const longName = "A".repeat(60);
|
||||||
renderNav({
|
renderNav(
|
||||||
combatants: [{ id: combatantId("1"), name: longName }],
|
buildEncounter({
|
||||||
});
|
combatants: [buildCombatant({ name: longName })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Previous turn" }),
|
screen.getByRole("button", { name: "Previous turn" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -182,29 +138,30 @@ describe("TurnNavigation", () => {
|
|||||||
|
|
||||||
it("renders a 40-character name without truncation class issues", () => {
|
it("renders a 40-character name without truncation class issues", () => {
|
||||||
const name40 = "A".repeat(40);
|
const name40 = "A".repeat(40);
|
||||||
renderNav({
|
renderNav(
|
||||||
combatants: [{ id: combatantId("1"), name: name40 }],
|
buildEncounter({
|
||||||
});
|
combatants: [buildCombatant({ name: name40 })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
const nameEl = screen.getByText(name40);
|
const nameEl = screen.getByText(name40);
|
||||||
expect(nameEl).toBeInTheDocument();
|
expect(nameEl).toBeInTheDocument();
|
||||||
// The truncate class is applied but CSS only visually truncates if content overflows
|
|
||||||
expect(nameEl.className).toContain("truncate");
|
expect(nameEl.className).toContain("truncate");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("US3: No combatants state", () => {
|
describe("US3: No combatants state", () => {
|
||||||
it("shows the round badge when there are no combatants", () => {
|
it("shows the round badge when there are no combatants", () => {
|
||||||
renderNav({ combatants: [], roundNumber: 1 });
|
renderNav(buildEncounter({ combatants: [], roundNumber: 1 }));
|
||||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows 'No combatants' placeholder text", () => {
|
it("shows 'No combatants' placeholder text", () => {
|
||||||
renderNav({ combatants: [] });
|
renderNav(buildEncounter({ combatants: [] }));
|
||||||
expect(screen.getByText("No combatants")).toBeInTheDocument();
|
expect(screen.getByText("No combatants")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables navigation buttons when there are no combatants", () => {
|
it("disables navigation buttons when there are no combatants", () => {
|
||||||
renderNav({ combatants: [] });
|
renderNav(buildEncounter({ combatants: [] }));
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Previous turn" }),
|
screen.getByRole("button", { name: "Previous turn" }),
|
||||||
).toBeDisabled();
|
).toBeDisabled();
|
||||||
|
|||||||
@@ -12,27 +12,20 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { type RefObject, useCallback, useRef, useState } from "react";
|
import React, { type RefObject, useCallback, useState } from "react";
|
||||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
|
||||||
import {
|
import {
|
||||||
creatureKey,
|
creatureKey,
|
||||||
type QueuedCreature,
|
type QueuedCreature,
|
||||||
type SuggestionActions,
|
type SuggestionActions,
|
||||||
useActionBarState,
|
useActionBarState,
|
||||||
} from "../hooks/use-action-bar-state.js";
|
} from "../hooks/use-action-bar-state.js";
|
||||||
|
import { useEncounterExportImport } from "../hooks/use-encounter-export-import.js";
|
||||||
import { useLongPress } from "../hooks/use-long-press.js";
|
import { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import {
|
|
||||||
assembleExportBundle,
|
|
||||||
bundleToJson,
|
|
||||||
readImportFile,
|
|
||||||
triggerDownload,
|
|
||||||
validateImportBundle,
|
|
||||||
} from "../persistence/export-import.js";
|
|
||||||
import { D20Icon } from "./d20-icon.js";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
import { ExportMethodDialog } from "./export-method-dialog.js";
|
import { ExportMethodDialog } from "./export-method-dialog.js";
|
||||||
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
||||||
@@ -439,116 +432,23 @@ export function ActionBar({
|
|||||||
} = useActionBarState();
|
} = useActionBarState();
|
||||||
|
|
||||||
const { state: bulkImportState } = useBulkImportContext();
|
const { state: bulkImportState } = useBulkImportContext();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
encounter,
|
importError,
|
||||||
undoRedoState,
|
showExportMethod,
|
||||||
isEmpty: encounterIsEmpty,
|
showImportMethod,
|
||||||
setEncounter,
|
showImportConfirm,
|
||||||
setUndoRedoState,
|
importFileRef,
|
||||||
} = useEncounterContext();
|
setImportError,
|
||||||
const { characters: playerCharacters, replacePlayerCharacters } =
|
setShowExportMethod,
|
||||||
usePlayerCharactersContext();
|
setShowImportMethod,
|
||||||
|
handleExportDownload,
|
||||||
const importFileRef = useRef<HTMLInputElement>(null);
|
handleExportClipboard,
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
handleImportFile,
|
||||||
const [showExportMethod, setShowExportMethod] = useState(false);
|
handleImportClipboard,
|
||||||
const [showImportMethod, setShowImportMethod] = useState(false);
|
handleImportConfirm,
|
||||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
handleImportCancel,
|
||||||
const pendingBundleRef = useRef<
|
} = useEncounterExportImport();
|
||||||
import("@initiative/domain").ExportBundle | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const handleExportDownload = useCallback(
|
|
||||||
(includeHistory: boolean, filename: string) => {
|
|
||||||
const bundle = assembleExportBundle(
|
|
||||||
encounter,
|
|
||||||
undoRedoState,
|
|
||||||
playerCharacters,
|
|
||||||
includeHistory,
|
|
||||||
);
|
|
||||||
triggerDownload(bundle, filename);
|
|
||||||
},
|
|
||||||
[encounter, undoRedoState, playerCharacters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleExportClipboard = useCallback(
|
|
||||||
(includeHistory: boolean) => {
|
|
||||||
const bundle = assembleExportBundle(
|
|
||||||
encounter,
|
|
||||||
undoRedoState,
|
|
||||||
playerCharacters,
|
|
||||||
includeHistory,
|
|
||||||
);
|
|
||||||
void navigator.clipboard.writeText(bundleToJson(bundle));
|
|
||||||
},
|
|
||||||
[encounter, undoRedoState, playerCharacters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const applyImport = useCallback(
|
|
||||||
(bundle: import("@initiative/domain").ExportBundle) => {
|
|
||||||
setEncounter(bundle.encounter);
|
|
||||||
setUndoRedoState({
|
|
||||||
undoStack: bundle.undoStack,
|
|
||||||
redoStack: bundle.redoStack,
|
|
||||||
});
|
|
||||||
replacePlayerCharacters([...bundle.playerCharacters]);
|
|
||||||
},
|
|
||||||
[setEncounter, setUndoRedoState, replacePlayerCharacters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleValidatedBundle = useCallback(
|
|
||||||
(result: import("@initiative/domain").ExportBundle | string) => {
|
|
||||||
if (typeof result === "string") {
|
|
||||||
setImportError(result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (encounterIsEmpty) {
|
|
||||||
applyImport(result);
|
|
||||||
} else {
|
|
||||||
pendingBundleRef.current = result;
|
|
||||||
setShowImportConfirm(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[encounterIsEmpty, applyImport],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleImportFile = useCallback(
|
|
||||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (importFileRef.current) importFileRef.current.value = "";
|
|
||||||
|
|
||||||
setImportError(null);
|
|
||||||
handleValidatedBundle(await readImportFile(file));
|
|
||||||
},
|
|
||||||
[handleValidatedBundle],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleImportClipboard = useCallback(
|
|
||||||
(text: string) => {
|
|
||||||
setImportError(null);
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(text);
|
|
||||||
handleValidatedBundle(validateImportBundle(parsed));
|
|
||||||
} catch {
|
|
||||||
setImportError("Invalid file format");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleValidatedBundle],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleImportConfirm = useCallback(() => {
|
|
||||||
if (pendingBundleRef.current) {
|
|
||||||
applyImport(pendingBundleRef.current);
|
|
||||||
pendingBundleRef.current = null;
|
|
||||||
}
|
|
||||||
setShowImportConfirm(false);
|
|
||||||
}, [applyImport]);
|
|
||||||
|
|
||||||
const handleImportCancel = useCallback(() => {
|
|
||||||
pendingBundleRef.current = null;
|
|
||||||
setShowImportConfirm(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const overflowItems = buildOverflowItems({
|
const overflowItems = buildOverflowItems({
|
||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
@@ -11,6 +11,7 @@ const DEFAULT_BASE_URL =
|
|||||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||||
|
|
||||||
export function BulkImportPrompt() {
|
export function BulkImportPrompt() {
|
||||||
|
const { bestiaryIndex } = useAdapters();
|
||||||
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
||||||
useBestiaryContext();
|
useBestiaryContext();
|
||||||
const { state: importState, startImport, reset } = useBulkImportContext();
|
const { state: importState, startImport, reset } = useBulkImportContext();
|
||||||
@@ -18,7 +19,7 @@ export function BulkImportPrompt() {
|
|||||||
|
|
||||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||||
const baseUrlId = useId();
|
const baseUrlId = useId();
|
||||||
const totalSources = getAllSourceCodes().length;
|
const totalSources = bestiaryIndex.getAllSourceCodes().length;
|
||||||
|
|
||||||
const handleStart = (url: string) => {
|
const handleStart = (url: string) => {
|
||||||
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useId, useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import {
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
getDefaultFetchUrl,
|
|
||||||
getSourceDisplayName,
|
|
||||||
} from "../adapters/bestiary-index-adapter.js";
|
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
@@ -17,9 +14,12 @@ export function SourceFetchPrompt({
|
|||||||
sourceCode,
|
sourceCode,
|
||||||
onSourceLoaded,
|
onSourceLoaded,
|
||||||
}: Readonly<SourceFetchPromptProps>) {
|
}: Readonly<SourceFetchPromptProps>) {
|
||||||
|
const { bestiaryIndex } = useAdapters();
|
||||||
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
||||||
const sourceDisplayName = getSourceDisplayName(sourceCode);
|
const sourceDisplayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||||
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
const [url, setUrl] = useState(() =>
|
||||||
|
bestiaryIndex.getDefaultFetchUrl(sourceCode),
|
||||||
|
);
|
||||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import {
|
|||||||
useOptimistic,
|
useOptimistic,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
import type { CachedSourceInfo } from "../adapters/ports.js";
|
||||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
export function SourceManager() {
|
export function SourceManager() {
|
||||||
|
const { bestiaryCache } = useAdapters();
|
||||||
const { refreshCache } = useBestiaryContext();
|
const { refreshCache } = useBestiaryContext();
|
||||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
@@ -30,7 +31,7 @@ export function SourceManager() {
|
|||||||
const loadSources = useCallback(async () => {
|
const loadSources = useCallback(async () => {
|
||||||
const cached = await bestiaryCache.getCachedSources();
|
const cached = await bestiaryCache.getCachedSources();
|
||||||
setSources(cached);
|
setSources(cached);
|
||||||
}, []);
|
}, [bestiaryCache]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSources();
|
void loadSources();
|
||||||
|
|||||||
38
apps/web/src/contexts/adapter-context.tsx
Normal file
38
apps/web/src/contexts/adapter-context.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import type {
|
||||||
|
BestiaryCachePort,
|
||||||
|
BestiaryIndexPort,
|
||||||
|
EncounterPersistence,
|
||||||
|
PlayerCharacterPersistence,
|
||||||
|
UndoRedoPersistence,
|
||||||
|
} from "../adapters/ports.js";
|
||||||
|
|
||||||
|
export interface Adapters {
|
||||||
|
encounterPersistence: EncounterPersistence;
|
||||||
|
undoRedoPersistence: UndoRedoPersistence;
|
||||||
|
playerCharacterPersistence: PlayerCharacterPersistence;
|
||||||
|
bestiaryCache: BestiaryCachePort;
|
||||||
|
bestiaryIndex: BestiaryIndexPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdapterContext = createContext<Adapters | null>(null);
|
||||||
|
|
||||||
|
export function AdapterProvider({
|
||||||
|
adapters,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
adapters: Adapters;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AdapterContext.Provider value={adapters}>
|
||||||
|
{children}
|
||||||
|
</AdapterContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdapters(): Adapters {
|
||||||
|
const ctx = useContext(AdapterContext);
|
||||||
|
if (!ctx) throw new Error("useAdapters requires AdapterProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -10,19 +10,9 @@ import {
|
|||||||
isDomainError,
|
isDomainError,
|
||||||
playerCharacterId,
|
playerCharacterId,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
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 {
|
function emptyState(): EncounterState {
|
||||||
return {
|
return {
|
||||||
encounter: {
|
encounter: {
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
// @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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,15 +1,38 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { useBulkImport } from "../use-bulk-import.js";
|
import { useBulkImport } from "../use-bulk-import.js";
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const adapters = createTestAdapters();
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||||
getDefaultFetchUrl: (code: string, baseUrl: string) =>
|
getDefaultFetchUrl: (code: string, baseUrl?: string) =>
|
||||||
`${baseUrl}${code}.json`,
|
`${baseUrl}${code}.json`,
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
};
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AllProviders adapters={adapters}>{children}</AllProviders>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Flush microtasks so the internal async IIFE inside startImport settles. */
|
/** Flush microtasks so the internal async IIFE inside startImport settles. */
|
||||||
function flushMicrotasks(): Promise<void> {
|
function flushMicrotasks(): Promise<void> {
|
||||||
@@ -20,7 +43,7 @@ function flushMicrotasks(): Promise<void> {
|
|||||||
|
|
||||||
describe("useBulkImport", () => {
|
describe("useBulkImport", () => {
|
||||||
it("starts in idle state with all counters at 0", () => {
|
it("starts in idle state with all counters at 0", () => {
|
||||||
const { result } = renderHook(() => useBulkImport());
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
expect(result.current.state).toEqual({
|
expect(result.current.state).toEqual({
|
||||||
status: "idle",
|
status: "idle",
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -30,7 +53,7 @@ describe("useBulkImport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reset returns to idle state", async () => {
|
it("reset returns to idle state", async () => {
|
||||||
const { result } = renderHook(() => useBulkImport());
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
const isSourceCached = vi.fn().mockResolvedValue(true);
|
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||||
const fetchAndCacheSource = vi.fn();
|
const fetchAndCacheSource = vi.fn();
|
||||||
@@ -51,7 +74,7 @@ describe("useBulkImport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("goes straight to complete when all sources are cached", async () => {
|
it("goes straight to complete when all sources are cached", async () => {
|
||||||
const { result } = renderHook(() => useBulkImport());
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
const isSourceCached = vi.fn().mockResolvedValue(true);
|
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||||
const fetchAndCacheSource = vi.fn();
|
const fetchAndCacheSource = vi.fn();
|
||||||
@@ -73,7 +96,7 @@ describe("useBulkImport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fetches uncached sources and completes", async () => {
|
it("fetches uncached sources and completes", async () => {
|
||||||
const { result } = renderHook(() => useBulkImport());
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
||||||
@@ -97,7 +120,7 @@ describe("useBulkImport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reports partial-failure when some sources fail", async () => {
|
it("reports partial-failure when some sources fail", async () => {
|
||||||
const { result } = renderHook(() => useBulkImport());
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
const fetchAndCacheSource = vi
|
const fetchAndCacheSource = vi
|
||||||
@@ -124,7 +147,7 @@ describe("useBulkImport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls refreshCache after all batches complete", async () => {
|
it("calls refreshCache after all batches complete", async () => {
|
||||||
const { result } = renderHook(() => useBulkImport());
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { ExportBundle } from "@initiative/domain";
|
||||||
|
import { combatantId, playerCharacterId } from "@initiative/domain";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import {
|
||||||
|
buildCombatant,
|
||||||
|
buildEncounter,
|
||||||
|
} from "../../__tests__/factories/index.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||||
|
import { useEncounterExportImport } from "../use-encounter-export-import.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AllProviders>{children}</AllProviders>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapperWithEncounter(encounter: ReturnType<typeof buildEncounter>) {
|
||||||
|
const adapters = createTestAdapters({ encounter });
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AllProviders adapters={adapters}>{children}</AllProviders>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_BUNDLE: ExportBundle = {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [buildCombatant({ id: combatantId("c-1"), name: "Imported" })],
|
||||||
|
}),
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
playerCharacters: [
|
||||||
|
{
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Hero",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useEncounterExportImport", () => {
|
||||||
|
describe("import via clipboard", () => {
|
||||||
|
it("imports valid JSON into empty encounter without error", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard(JSON.stringify(VALID_BUNDLE));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import should succeed without error and not show confirm
|
||||||
|
expect(result.current.importError).toBeNull();
|
||||||
|
expect(result.current.showImportConfirm).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets error for invalid JSON", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard("not json{{{");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.importError).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets error for valid JSON that fails validation", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard(JSON.stringify({ version: 999 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.importError).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows confirm dialog when encounter is not empty", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Existing" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper: wrapperWithEncounter(encounter),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard(JSON.stringify(VALID_BUNDLE));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showImportConfirm).toBe(true);
|
||||||
|
expect(result.current.importError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleImportConfirm clears confirm dialog", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Existing" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper: wrapperWithEncounter(encounter),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard(JSON.stringify(VALID_BUNDLE));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showImportConfirm).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showImportConfirm).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleImportCancel clears pending without applying", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Existing" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => ({
|
||||||
|
exportImport: useEncounterExportImport(),
|
||||||
|
encounter: useEncounterContext(),
|
||||||
|
}),
|
||||||
|
{ wrapper: wrapperWithEncounter(encounter) },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.exportImport.handleImportClipboard(
|
||||||
|
JSON.stringify(VALID_BUNDLE),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.exportImport.handleImportCancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.exportImport.showImportConfirm).toBe(false);
|
||||||
|
expect(result.current.encounter.encounter.combatants[0].name).toBe(
|
||||||
|
"Existing",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("export", () => {
|
||||||
|
it("handleExportDownload calls triggerDownload", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Fighter" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper: wrapperWithEncounter(encounter),
|
||||||
|
});
|
||||||
|
|
||||||
|
// triggerDownload creates a blob URL and clicks an anchor — just verify it doesn't throw
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
result.current.handleExportDownload(false, "test-export.json");
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dialog state", () => {
|
||||||
|
it("toggles export method dialog", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showExportMethod).toBe(false);
|
||||||
|
act(() => result.current.setShowExportMethod(true));
|
||||||
|
expect(result.current.showExportMethod).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles import method dialog", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showImportMethod).toBe(false);
|
||||||
|
act(() => result.current.setShowImportMethod(true));
|
||||||
|
expect(result.current.showImportMethod).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears import error", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard("bad json");
|
||||||
|
});
|
||||||
|
expect(result.current.importError).toBe("Invalid file format");
|
||||||
|
|
||||||
|
act(() => result.current.setImportError(null));
|
||||||
|
expect(result.current.importError).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,27 +2,35 @@
|
|||||||
import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain";
|
import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain";
|
||||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { useEncounter } from "../use-encounter.js";
|
import { useEncounter } from "../use-encounter.js";
|
||||||
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
beforeAll(() => {
|
||||||
loadEncounter: vi.fn().mockReturnValue(null),
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
saveEncounter: vi.fn(),
|
writable: true,
|
||||||
}));
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
const { loadEncounter: mockLoad, saveEncounter: mockSave } =
|
media: query,
|
||||||
await vi.importMock<typeof import("../../persistence/encounter-storage.js")>(
|
onchange: null,
|
||||||
"../../persistence/encounter-storage.js",
|
addListener: vi.fn(),
|
||||||
);
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
describe("useEncounter", () => {
|
removeEventListener: vi.fn(),
|
||||||
beforeEach(() => {
|
dispatchEvent: vi.fn(),
|
||||||
vi.clearAllMocks();
|
})),
|
||||||
mockLoad.mockReturnValue(null);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AllProviders>{children}</AllProviders>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useEncounter", () => {
|
||||||
it("initializes with empty encounter when persistence returns null", () => {
|
it("initializes with empty encounter when persistence returns null", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
expect(result.current.encounter.combatants).toEqual([]);
|
expect(result.current.encounter.combatants).toEqual([]);
|
||||||
expect(result.current.encounter.activeIndex).toBe(0);
|
expect(result.current.encounter.activeIndex).toBe(0);
|
||||||
@@ -32,13 +40,33 @@ describe("useEncounter", () => {
|
|||||||
|
|
||||||
it("initializes from stored encounter", () => {
|
it("initializes from stored encounter", () => {
|
||||||
const stored = {
|
const stored = {
|
||||||
combatants: [{ id: combatantId("c-1"), name: "Goblin" }],
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: undefined,
|
||||||
|
maxHp: undefined,
|
||||||
|
currentHp: undefined,
|
||||||
|
tempHp: undefined,
|
||||||
|
ac: undefined,
|
||||||
|
conditions: [],
|
||||||
|
concentrating: false,
|
||||||
|
creatureId: undefined,
|
||||||
|
playerCharacterId: undefined,
|
||||||
|
color: undefined,
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
activeIndex: 0,
|
activeIndex: 0,
|
||||||
roundNumber: 2,
|
roundNumber: 2,
|
||||||
};
|
};
|
||||||
mockLoad.mockReturnValue(stored);
|
const adapters = createTestAdapters({ encounter: stored });
|
||||||
|
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.encounter.combatants).toHaveLength(1);
|
expect(result.current.encounter.combatants).toHaveLength(1);
|
||||||
expect(result.current.encounter.roundNumber).toBe(2);
|
expect(result.current.encounter.roundNumber).toBe(2);
|
||||||
@@ -46,7 +74,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addCombatant adds a combatant with incremental IDs and persists", () => {
|
it("addCombatant adds a combatant with incremental IDs and persists", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() => result.current.addCombatant("Goblin"));
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
act(() => result.current.addCombatant("Orc"));
|
act(() => result.current.addCombatant("Orc"));
|
||||||
@@ -55,11 +83,10 @@ describe("useEncounter", () => {
|
|||||||
expect(result.current.encounter.combatants[0].name).toBe("Goblin");
|
expect(result.current.encounter.combatants[0].name).toBe("Goblin");
|
||||||
expect(result.current.encounter.combatants[1].name).toBe("Orc");
|
expect(result.current.encounter.combatants[1].name).toBe("Orc");
|
||||||
expect(result.current.isEmpty).toBe(false);
|
expect(result.current.isEmpty).toBe(false);
|
||||||
expect(mockSave).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removeCombatant removes a combatant and persists", () => {
|
it("removeCombatant removes a combatant and persists", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() => result.current.addCombatant("Goblin"));
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
const id = result.current.encounter.combatants[0].id;
|
const id = result.current.encounter.combatants[0].id;
|
||||||
@@ -71,7 +98,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("advanceTurn and retreatTurn update encounter state", () => {
|
it("advanceTurn and retreatTurn update encounter state", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() => result.current.addCombatant("Goblin"));
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
act(() => result.current.addCombatant("Orc"));
|
act(() => result.current.addCombatant("Orc"));
|
||||||
@@ -86,7 +113,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("clearEncounter resets to empty and resets ID counter", () => {
|
it("clearEncounter resets to empty and resets ID counter", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() => result.current.addCombatant("Goblin"));
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
act(() => result.current.clearEncounter());
|
act(() => result.current.clearEncounter());
|
||||||
@@ -100,7 +127,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addCombatant with opts applies initiative, ac, maxHp", () => {
|
it("addCombatant with opts applies initiative, ac, maxHp", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() =>
|
act(() =>
|
||||||
result.current.addCombatant("Goblin", {
|
result.current.addCombatant("Goblin", {
|
||||||
@@ -118,7 +145,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("derived flags: hasCreatureCombatants and canRollAllInitiative", () => {
|
it("derived flags: hasCreatureCombatants and canRollAllInitiative", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
// No creatures yet
|
// No creatures yet
|
||||||
expect(result.current.hasCreatureCombatants).toBe(false);
|
expect(result.current.hasCreatureCombatants).toBe(false);
|
||||||
@@ -146,7 +173,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
|
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
const entry: BestiaryIndexEntry = {
|
const entry: BestiaryIndexEntry = {
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
@@ -173,7 +200,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addFromBestiary auto-numbers duplicate names", () => {
|
it("addFromBestiary auto-numbers duplicate names", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
const entry: BestiaryIndexEntry = {
|
const entry: BestiaryIndexEntry = {
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
@@ -200,7 +227,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addFromPlayerCharacter adds combatant with HP, AC, color, icon", () => {
|
it("addFromPlayerCharacter adds combatant with HP, AC, color, icon", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
const pc: PlayerCharacter = {
|
const pc: PlayerCharacter = {
|
||||||
id: playerCharacterId("pc-1"),
|
id: playerCharacterId("pc-1"),
|
||||||
@@ -1,25 +1,33 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { playerCharacterId } from "@initiative/domain";
|
import { playerCharacterId } from "@initiative/domain";
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { usePlayerCharacters } from "../use-player-characters.js";
|
import { usePlayerCharacters } from "../use-player-characters.js";
|
||||||
|
|
||||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
beforeAll(() => {
|
||||||
loadPlayerCharacters: vi.fn().mockReturnValue([]),
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
savePlayerCharacters: vi.fn(),
|
writable: true,
|
||||||
}));
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
const { loadPlayerCharacters: mockLoad, savePlayerCharacters: mockSave } =
|
media: query,
|
||||||
await vi.importMock<
|
onchange: null,
|
||||||
typeof import("../../persistence/player-character-storage.js")
|
addListener: vi.fn(),
|
||||||
>("../../persistence/player-character-storage.js");
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
describe("usePlayerCharacters", () => {
|
removeEventListener: vi.fn(),
|
||||||
beforeEach(() => {
|
dispatchEvent: vi.fn(),
|
||||||
vi.clearAllMocks();
|
})),
|
||||||
mockLoad.mockReturnValue([]);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AllProviders>{children}</AllProviders>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("usePlayerCharacters", () => {
|
||||||
it("initializes with characters from persistence", () => {
|
it("initializes with characters from persistence", () => {
|
||||||
const stored = [
|
const stored = [
|
||||||
{
|
{
|
||||||
@@ -31,15 +39,19 @@ describe("usePlayerCharacters", () => {
|
|||||||
icon: undefined,
|
icon: undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
mockLoad.mockReturnValue(stored);
|
const adapters = createTestAdapters({ playerCharacters: stored });
|
||||||
|
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.characters).toEqual(stored);
|
expect(result.current.characters).toEqual(stored);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("createCharacter adds a character and persists", () => {
|
it("createCharacter adds a character and persists", () => {
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.createCharacter(
|
result.current.createCharacter(
|
||||||
@@ -56,11 +68,10 @@ describe("usePlayerCharacters", () => {
|
|||||||
expect(result.current.characters[0].name).toBe("Vex");
|
expect(result.current.characters[0].name).toBe("Vex");
|
||||||
expect(result.current.characters[0].ac).toBe(15);
|
expect(result.current.characters[0].ac).toBe(15);
|
||||||
expect(result.current.characters[0].maxHp).toBe(28);
|
expect(result.current.characters[0].maxHp).toBe(28);
|
||||||
expect(mockSave).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("createCharacter returns domain error for empty name", () => {
|
it("createCharacter returns domain error for empty name", () => {
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||||
|
|
||||||
let error: unknown;
|
let error: unknown;
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -79,7 +90,7 @@ describe("usePlayerCharacters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("editCharacter updates character and persists", () => {
|
it("editCharacter updates character and persists", () => {
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.createCharacter(
|
result.current.createCharacter(
|
||||||
@@ -99,11 +110,10 @@ describe("usePlayerCharacters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.characters[0].name).toBe("Vex'ahlia");
|
expect(result.current.characters[0].name).toBe("Vex'ahlia");
|
||||||
expect(mockSave).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deleteCharacter removes character and persists", () => {
|
it("deleteCharacter removes character and persists", () => {
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.createCharacter(
|
result.current.createCharacter(
|
||||||
@@ -123,6 +133,5 @@ describe("usePlayerCharacters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.characters).toHaveLength(0);
|
expect(result.current.characters).toHaveLength(0);
|
||||||
expect(mockSave).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -8,11 +8,7 @@ import {
|
|||||||
normalizeBestiary,
|
normalizeBestiary,
|
||||||
setSourceDisplayNames,
|
setSourceDisplayNames,
|
||||||
} from "../adapters/bestiary-adapter.js";
|
} from "../adapters/bestiary-adapter.js";
|
||||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
import {
|
|
||||||
getSourceDisplayName,
|
|
||||||
loadBestiaryIndex,
|
|
||||||
} from "../adapters/bestiary-index-adapter.js";
|
|
||||||
|
|
||||||
export interface SearchResult extends BestiaryIndexEntry {
|
export interface SearchResult extends BestiaryIndexEntry {
|
||||||
readonly sourceDisplayName: string;
|
readonly sourceDisplayName: string;
|
||||||
@@ -32,13 +28,14 @@ interface BestiaryHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBestiary(): BestiaryHook {
|
export function useBestiary(): BestiaryHook {
|
||||||
|
const { bestiaryCache, bestiaryIndex } = useAdapters();
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [creatureMap, setCreatureMap] = useState(
|
const [creatureMap, setCreatureMap] = useState(
|
||||||
() => new Map<CreatureId, Creature>(),
|
() => new Map<CreatureId, Creature>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const index = loadBestiaryIndex();
|
const index = bestiaryIndex.loadIndex();
|
||||||
setSourceDisplayNames(index.sources as Record<string, string>);
|
setSourceDisplayNames(index.sources as Record<string, string>);
|
||||||
if (index.creatures.length > 0) {
|
if (index.creatures.length > 0) {
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
@@ -47,21 +44,24 @@ export function useBestiary(): BestiaryHook {
|
|||||||
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||||
setCreatureMap(map);
|
setCreatureMap(map);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [bestiaryCache, bestiaryIndex]);
|
||||||
|
|
||||||
const search = useCallback((query: string): SearchResult[] => {
|
const search = useCallback(
|
||||||
|
(query: string): SearchResult[] => {
|
||||||
if (query.length < 2) return [];
|
if (query.length < 2) return [];
|
||||||
const lower = query.toLowerCase();
|
const lower = query.toLowerCase();
|
||||||
const index = loadBestiaryIndex();
|
const index = bestiaryIndex.loadIndex();
|
||||||
return index.creatures
|
return index.creatures
|
||||||
.filter((c) => c.name.toLowerCase().includes(lower))
|
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
sourceDisplayName: getSourceDisplayName(c.source),
|
sourceDisplayName: bestiaryIndex.getSourceDisplayName(c.source),
|
||||||
}));
|
}));
|
||||||
}, []);
|
},
|
||||||
|
[bestiaryIndex],
|
||||||
|
);
|
||||||
|
|
||||||
const getCreature = useCallback(
|
const getCreature = useCallback(
|
||||||
(id: CreatureId): Creature | undefined => {
|
(id: CreatureId): Creature | undefined => {
|
||||||
@@ -74,7 +74,7 @@ export function useBestiary(): BestiaryHook {
|
|||||||
(sourceCode: string): Promise<boolean> => {
|
(sourceCode: string): Promise<boolean> => {
|
||||||
return bestiaryCache.isSourceCached(sourceCode);
|
return bestiaryCache.isSourceCached(sourceCode);
|
||||||
},
|
},
|
||||||
[],
|
[bestiaryCache],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchAndCacheSource = useCallback(
|
const fetchAndCacheSource = useCallback(
|
||||||
@@ -87,7 +87,7 @@ export function useBestiary(): BestiaryHook {
|
|||||||
}
|
}
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
const creatures = normalizeBestiary(json);
|
const creatures = normalizeBestiary(json);
|
||||||
const displayName = getSourceDisplayName(sourceCode);
|
const displayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||||
setCreatureMap((prev) => {
|
setCreatureMap((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -97,14 +97,14 @@ export function useBestiary(): BestiaryHook {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[bestiaryCache, bestiaryIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadAndCacheSource = useCallback(
|
const uploadAndCacheSource = useCallback(
|
||||||
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies
|
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies
|
||||||
const creatures = normalizeBestiary(jsonData as any);
|
const creatures = normalizeBestiary(jsonData as any);
|
||||||
const displayName = getSourceDisplayName(sourceCode);
|
const displayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||||
setCreatureMap((prev) => {
|
setCreatureMap((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -114,13 +114,13 @@ export function useBestiary(): BestiaryHook {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[bestiaryCache, bestiaryIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshCache = useCallback(async (): Promise<void> => {
|
const refreshCache = useCallback(async (): Promise<void> => {
|
||||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||||
setCreatureMap(map);
|
setCreatureMap(map);
|
||||||
}, []);
|
}, [bestiaryCache]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
search,
|
search,
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import {
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
getAllSourceCodes,
|
|
||||||
getDefaultFetchUrl,
|
|
||||||
} from "../adapters/bestiary-index-adapter.js";
|
|
||||||
|
|
||||||
const BATCH_SIZE = 6;
|
const BATCH_SIZE = 6;
|
||||||
|
|
||||||
@@ -32,6 +29,7 @@ interface BulkImportHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBulkImport(): BulkImportHook {
|
export function useBulkImport(): BulkImportHook {
|
||||||
|
const { bestiaryIndex } = useAdapters();
|
||||||
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
|
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
|
||||||
const countersRef = useRef({ completed: 0, failed: 0 });
|
const countersRef = useRef({ completed: 0, failed: 0 });
|
||||||
|
|
||||||
@@ -42,7 +40,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||||
refreshCache: () => Promise<void>,
|
refreshCache: () => Promise<void>,
|
||||||
) => {
|
) => {
|
||||||
const allCodes = getAllSourceCodes();
|
const allCodes = bestiaryIndex.getAllSourceCodes();
|
||||||
const total = allCodes.length;
|
const total = allCodes.length;
|
||||||
|
|
||||||
countersRef.current = { completed: 0, failed: 0 };
|
countersRef.current = { completed: 0, failed: 0 };
|
||||||
@@ -83,7 +81,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
chain.then(() =>
|
chain.then(() =>
|
||||||
Promise.allSettled(
|
Promise.allSettled(
|
||||||
batch.map(async ({ code }) => {
|
batch.map(async ({ code }) => {
|
||||||
const url = getDefaultFetchUrl(code, baseUrl);
|
const url = bestiaryIndex.getDefaultFetchUrl(code, baseUrl);
|
||||||
try {
|
try {
|
||||||
await fetchAndCacheSource(code, url);
|
await fetchAndCacheSource(code, url);
|
||||||
countersRef.current.completed++;
|
countersRef.current.completed++;
|
||||||
@@ -117,7 +115,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
[],
|
[bestiaryIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
|
|||||||
139
apps/web/src/hooks/use-encounter-export-import.ts
Normal file
139
apps/web/src/hooks/use-encounter-export-import.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import type { ExportBundle } from "@initiative/domain";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
|
import {
|
||||||
|
assembleExportBundle,
|
||||||
|
bundleToJson,
|
||||||
|
readImportFile,
|
||||||
|
triggerDownload,
|
||||||
|
validateImportBundle,
|
||||||
|
} from "../persistence/export-import.js";
|
||||||
|
|
||||||
|
export function useEncounterExportImport() {
|
||||||
|
const {
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
isEmpty: encounterIsEmpty,
|
||||||
|
setEncounter,
|
||||||
|
setUndoRedoState,
|
||||||
|
} = useEncounterContext();
|
||||||
|
const { characters: playerCharacters, replacePlayerCharacters } =
|
||||||
|
usePlayerCharactersContext();
|
||||||
|
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
const [showExportMethod, setShowExportMethod] = useState(false);
|
||||||
|
const [showImportMethod, setShowImportMethod] = useState(false);
|
||||||
|
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||||
|
const pendingBundleRef = useRef<ExportBundle | null>(null);
|
||||||
|
const importFileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleExportDownload = useCallback(
|
||||||
|
(includeHistory: boolean, filename: string) => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
includeHistory,
|
||||||
|
);
|
||||||
|
triggerDownload(bundle, filename);
|
||||||
|
},
|
||||||
|
[encounter, undoRedoState, playerCharacters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExportClipboard = useCallback(
|
||||||
|
(includeHistory: boolean) => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
includeHistory,
|
||||||
|
);
|
||||||
|
void navigator.clipboard.writeText(bundleToJson(bundle));
|
||||||
|
},
|
||||||
|
[encounter, undoRedoState, playerCharacters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyImport = useCallback(
|
||||||
|
(bundle: ExportBundle) => {
|
||||||
|
setEncounter(bundle.encounter);
|
||||||
|
setUndoRedoState({
|
||||||
|
undoStack: bundle.undoStack,
|
||||||
|
redoStack: bundle.redoStack,
|
||||||
|
});
|
||||||
|
replacePlayerCharacters([...bundle.playerCharacters]);
|
||||||
|
},
|
||||||
|
[setEncounter, setUndoRedoState, replacePlayerCharacters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleValidatedBundle = useCallback(
|
||||||
|
(result: ExportBundle | string) => {
|
||||||
|
if (typeof result === "string") {
|
||||||
|
setImportError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (encounterIsEmpty) {
|
||||||
|
applyImport(result);
|
||||||
|
} else {
|
||||||
|
pendingBundleRef.current = result;
|
||||||
|
setShowImportConfirm(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[encounterIsEmpty, applyImport],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImportFile = useCallback(
|
||||||
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (importFileRef.current) importFileRef.current.value = "";
|
||||||
|
|
||||||
|
setImportError(null);
|
||||||
|
handleValidatedBundle(await readImportFile(file));
|
||||||
|
},
|
||||||
|
[handleValidatedBundle],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImportClipboard = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
setImportError(null);
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(text);
|
||||||
|
handleValidatedBundle(validateImportBundle(parsed));
|
||||||
|
} catch {
|
||||||
|
setImportError("Invalid file format");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleValidatedBundle],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImportConfirm = useCallback(() => {
|
||||||
|
if (pendingBundleRef.current) {
|
||||||
|
applyImport(pendingBundleRef.current);
|
||||||
|
pendingBundleRef.current = null;
|
||||||
|
}
|
||||||
|
setShowImportConfirm(false);
|
||||||
|
}, [applyImport]);
|
||||||
|
|
||||||
|
const handleImportCancel = useCallback(() => {
|
||||||
|
pendingBundleRef.current = null;
|
||||||
|
setShowImportConfirm(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
importError,
|
||||||
|
showExportMethod,
|
||||||
|
showImportMethod,
|
||||||
|
showImportConfirm,
|
||||||
|
importFileRef,
|
||||||
|
setImportError,
|
||||||
|
setShowExportMethod,
|
||||||
|
setShowImportMethod,
|
||||||
|
handleExportDownload,
|
||||||
|
handleExportClipboard,
|
||||||
|
handleImportFile,
|
||||||
|
handleImportClipboard,
|
||||||
|
handleImportConfirm,
|
||||||
|
handleImportCancel,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
@@ -37,14 +37,7 @@ import {
|
|||||||
resolveCreatureName,
|
resolveCreatureName,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||||
import {
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
loadEncounter,
|
|
||||||
saveEncounter,
|
|
||||||
} from "../persistence/encounter-storage.js";
|
|
||||||
import {
|
|
||||||
loadUndoRedoStacks,
|
|
||||||
saveUndoRedoStacks,
|
|
||||||
} from "../persistence/undo-redo-storage.js";
|
|
||||||
|
|
||||||
// -- Types --
|
// -- Types --
|
||||||
|
|
||||||
@@ -111,11 +104,14 @@ function deriveNextId(encounter: Encounter): number {
|
|||||||
return max;
|
return max;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeState(): EncounterState {
|
function initializeState(
|
||||||
const encounter = loadEncounter() ?? EMPTY_ENCOUNTER;
|
loadEncounterFn: () => Encounter | null,
|
||||||
|
loadUndoRedoFn: () => UndoRedoState,
|
||||||
|
): EncounterState {
|
||||||
|
const encounter = loadEncounterFn() ?? EMPTY_ENCOUNTER;
|
||||||
return {
|
return {
|
||||||
encounter,
|
encounter,
|
||||||
undoRedoState: loadUndoRedoStacks(),
|
undoRedoState: loadUndoRedoFn(),
|
||||||
events: [],
|
events: [],
|
||||||
nextId: deriveNextId(encounter),
|
nextId: deriveNextId(encounter),
|
||||||
lastCreatureId: null,
|
lastCreatureId: null,
|
||||||
@@ -385,7 +381,10 @@ function dispatchEncounterAction(
|
|||||||
// -- Hook --
|
// -- Hook --
|
||||||
|
|
||||||
export function useEncounter() {
|
export function useEncounter() {
|
||||||
const [state, dispatch] = useReducer(encounterReducer, null, initializeState);
|
const { encounterPersistence, undoRedoPersistence } = useAdapters();
|
||||||
|
const [state, dispatch] = useReducer(encounterReducer, null, () =>
|
||||||
|
initializeState(encounterPersistence.load, undoRedoPersistence.load),
|
||||||
|
);
|
||||||
const { encounter, undoRedoState, events } = state;
|
const { encounter, undoRedoState, events } = state;
|
||||||
|
|
||||||
const encounterRef = useRef(encounter);
|
const encounterRef = useRef(encounter);
|
||||||
@@ -394,12 +393,12 @@ export function useEncounter() {
|
|||||||
undoRedoRef.current = undoRedoState;
|
undoRedoRef.current = undoRedoState;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveEncounter(encounter);
|
encounterPersistence.save(encounter);
|
||||||
}, [encounter]);
|
}, [encounter, encounterPersistence]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveUndoRedoStacks(undoRedoState);
|
undoRedoPersistence.save(undoRedoState);
|
||||||
}, [undoRedoState]);
|
}, [undoRedoState, undoRedoPersistence]);
|
||||||
|
|
||||||
// Escape hatches for useInitiativeRolls (needs raw port access)
|
// Escape hatches for useInitiativeRolls (needs raw port access)
|
||||||
const makeStore = useCallback((): EncounterStore => {
|
const makeStore = useCallback((): EncounterStore => {
|
||||||
|
|||||||
@@ -7,14 +7,7 @@ import {
|
|||||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
import { isDomainError, playerCharacterId } from "@initiative/domain";
|
import { isDomainError, playerCharacterId } from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
loadPlayerCharacters,
|
|
||||||
savePlayerCharacters,
|
|
||||||
} from "../persistence/player-character-storage.js";
|
|
||||||
|
|
||||||
function initializeCharacters(): PlayerCharacter[] {
|
|
||||||
return loadPlayerCharacters();
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextPcId = 0;
|
let nextPcId = 0;
|
||||||
|
|
||||||
@@ -32,14 +25,16 @@ interface EditFields {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePlayerCharacters() {
|
export function usePlayerCharacters() {
|
||||||
const [characters, setCharacters] =
|
const { playerCharacterPersistence } = useAdapters();
|
||||||
useState<PlayerCharacter[]>(initializeCharacters);
|
const [characters, setCharacters] = useState<PlayerCharacter[]>(() =>
|
||||||
|
playerCharacterPersistence.load(),
|
||||||
|
);
|
||||||
const charactersRef = useRef(characters);
|
const charactersRef = useRef(characters);
|
||||||
charactersRef.current = characters;
|
charactersRef.current = characters;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
savePlayerCharacters(characters);
|
playerCharacterPersistence.save(characters);
|
||||||
}, [characters]);
|
}, [characters, playerCharacterPersistence]);
|
||||||
|
|
||||||
const makeStore = useCallback((): PlayerCharacterStore => {
|
const makeStore = useCallback((): PlayerCharacterStore => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App.js";
|
import { App } from "./App.js";
|
||||||
|
import { productionAdapters } from "./adapters/production-adapters.js";
|
||||||
|
import { AdapterProvider } from "./contexts/adapter-context.js";
|
||||||
import {
|
import {
|
||||||
BestiaryProvider,
|
BestiaryProvider,
|
||||||
BulkImportProvider,
|
BulkImportProvider,
|
||||||
@@ -17,6 +19,7 @@ const root = document.getElementById("root");
|
|||||||
if (root) {
|
if (root) {
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<AdapterProvider adapters={productionAdapters}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<RulesEditionProvider>
|
<RulesEditionProvider>
|
||||||
<EncounterProvider>
|
<EncounterProvider>
|
||||||
@@ -34,6 +37,7 @@ if (root) {
|
|||||||
</EncounterProvider>
|
</EncounterProvider>
|
||||||
</RulesEditionProvider>
|
</RulesEditionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</AdapterProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export default defineConfig({
|
|||||||
branches: 90,
|
branches: 90,
|
||||||
},
|
},
|
||||||
"apps/web/src/adapters": {
|
"apps/web/src/adapters": {
|
||||||
lines: 68,
|
lines: 80,
|
||||||
branches: 56,
|
branches: 62,
|
||||||
},
|
},
|
||||||
"apps/web/src/persistence": {
|
"apps/web/src/persistence": {
|
||||||
lines: 85,
|
lines: 85,
|
||||||
|
|||||||
Reference in New Issue
Block a user