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:
40
apps/web/src/__tests__/bestiary-index-helpers.test.ts
Normal file
40
apps/web/src/__tests__/bestiary-index-helpers.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
235
apps/web/src/__tests__/use-bulk-import.test.ts
Normal file
235
apps/web/src/__tests__/use-bulk-import.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user