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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user