Introduce adapter injection and migrate test suite
All checks were successful
CI / check (push) Successful in 2m13s
CI / build-image (push) Has been skipped

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:
Lukas
2026-04-01 23:55:45 +02:00
parent 228c1c667f
commit 2c643cc98b
42 changed files with 1879 additions and 1190 deletions

View File

@@ -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([]);
});
});

View 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([]);
});
});
});

View 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",
);
});
});