Implement the 030-bulk-import-sources feature that adds a one-click bulk import button to load all bestiary sources at once, with real-time progress feedback in the side panel and a toast notification when the panel is closed, plus completion/failure reporting with auto-dismiss on success and persistent display on partial failure, while also hardening the bestiary normalizer to handle variable stat blocks (spell summons with special AC/HP/CR) and skip malformed monster entries gracefully

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-10 23:29:34 +01:00
parent c323adc343
commit 94d125d9c4
14 changed files with 850 additions and 106 deletions

View File

@@ -0,0 +1,40 @@
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");
});
});

View File

@@ -0,0 +1,235 @@
import { describe, expect, it, vi } from "vitest";
import * as indexAdapter from "../adapters/bestiary-index-adapter.js";
// We test the bulk import logic by extracting and exercising the async flow.
// Since useBulkImport is a thin React wrapper around async logic,
// we test the core behavior via a direct simulation.
vi.mock("../adapters/bestiary-index-adapter.js", async () => {
const actual = await vi.importActual<
typeof import("../adapters/bestiary-index-adapter.js")
>("../adapters/bestiary-index-adapter.js");
return {
...actual,
getAllSourceCodes: vi.fn(),
};
});
const mockGetAllSourceCodes = vi.mocked(indexAdapter.getAllSourceCodes);
interface BulkImportState {
status: "idle" | "loading" | "complete" | "partial-failure";
total: number;
completed: number;
failed: number;
}
/** Simulate the core bulk import logic extracted from the hook */
async function runBulkImport(
baseUrl: string,
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
isSourceCached: (sourceCode: string) => Promise<boolean>,
refreshCache: () => Promise<void>,
): Promise<BulkImportState> {
const allCodes = indexAdapter.getAllSourceCodes();
const total = allCodes.length;
const cacheChecks = await Promise.all(
allCodes.map(async (code) => ({
code,
cached: await isSourceCached(code),
})),
);
const alreadyCached = cacheChecks.filter((c) => c.cached).length;
const uncached = cacheChecks.filter((c) => !c.cached);
if (uncached.length === 0) {
return { status: "complete", total, completed: total, failed: 0 };
}
let completed = alreadyCached;
let failed = 0;
await Promise.allSettled(
uncached.map(async ({ code }) => {
const url = indexAdapter.getDefaultFetchUrl(code, baseUrl);
try {
await fetchAndCacheSource(code, url);
completed++;
} catch {
failed++;
}
}),
);
await refreshCache();
return {
status: failed > 0 ? "partial-failure" : "complete",
total,
completed,
failed,
};
}
describe("bulk import logic", () => {
it("skips already-cached sources and counts them into completed", async () => {
mockGetAllSourceCodes.mockReturnValue(["SRC1", "SRC2", "SRC3"]);
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
const isSourceCached = vi
.fn()
.mockImplementation((code: string) =>
Promise.resolve(code === "SRC1" || code === "SRC3"),
);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(fetchAndCache).toHaveBeenCalledTimes(1);
expect(fetchAndCache).toHaveBeenCalledWith(
"SRC2",
"https://example.com/bestiary-src2.json",
);
expect(result.completed).toBe(3);
expect(result.status).toBe("complete");
});
it("increments completed on successful fetch", async () => {
mockGetAllSourceCodes.mockReturnValue(["SRC1"]);
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.completed).toBe(1);
expect(result.failed).toBe(0);
expect(result.status).toBe("complete");
});
it("increments failed on rejected fetch", async () => {
mockGetAllSourceCodes.mockReturnValue(["SRC1"]);
const fetchAndCache = vi.fn().mockRejectedValue(new Error("fail"));
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.failed).toBe(1);
expect(result.completed).toBe(0);
expect(result.status).toBe("partial-failure");
});
it("transitions to complete when all succeed", async () => {
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.status).toBe("complete");
expect(result.completed).toBe(2);
expect(result.failed).toBe(0);
});
it("transitions to partial-failure when any fetch fails", async () => {
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
const fetchAndCache = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("fail"));
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.status).toBe("partial-failure");
expect(result.failed).toBe(1);
});
it("immediately transitions to complete when all sources are cached", async () => {
mockGetAllSourceCodes.mockReturnValue(["A", "B", "C"]);
const fetchAndCache = vi.fn();
const isSourceCached = vi.fn().mockResolvedValue(true);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.status).toBe("complete");
expect(result.total).toBe(3);
expect(result.completed).toBe(3);
expect(fetchAndCache).not.toHaveBeenCalled();
});
it("calls refreshCache exactly once when all settle", async () => {
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(refreshCache).toHaveBeenCalledTimes(1);
});
it("does not call refreshCache when all sources are already cached", async () => {
mockGetAllSourceCodes.mockReturnValue(["A"]);
const fetchAndCache = vi.fn();
const isSourceCached = vi.fn().mockResolvedValue(true);
const refreshCache = vi.fn().mockResolvedValue(undefined);
await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(refreshCache).not.toHaveBeenCalled();
});
});