Files
initiative/apps/web/src/components/__tests__/source-fetch-prompt.test.tsx
Lukas c3707cf0b6 Add PF2e attack effects, ability frequency, and perception details
Show inline on-hit effects on attack lines (e.g., "plus Grab"), frequency
limits on abilities (e.g., "(1/day)"), and perception details text alongside
senses. Strip redundant frequency lines from Foundry descriptions.

Also add resilient PF2e source fetching: batched requests with retry,
graceful handling of ad-blocker-blocked creature files (partial success
with toast warning and re-fetch prompt for missing creatures).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:37:03 +02:00

134 lines
4.1 KiB
TypeScript

// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
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 { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
const MONSTER_MANUAL_REGEX = /Monster Manual/;
afterEach(cleanup);
const mockFetchAndCacheSource = 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", () => ({
useBestiaryContext: () => ({
fetchAndCacheSource: mockFetchAndCacheSource,
uploadAndCacheSource: mockUploadAndCacheSource,
}),
}));
function renderPrompt(sourceCode = "MM") {
const onSourceLoaded = vi.fn();
const adapters = createTestAdapters();
adapters.bestiaryIndex = {
...adapters.bestiaryIndex,
getDefaultFetchUrl: (code: string) =>
`https://example.com/bestiary/${code}.json`,
getSourceDisplayName: (code: string) =>
code === "MM" ? "Monster Manual" : code,
};
const result = render(
<RulesEditionProvider>
<AdapterProvider adapters={adapters}>
<SourceFetchPrompt
sourceCode={sourceCode}
onSourceLoaded={onSourceLoaded}
/>
</AdapterProvider>
</RulesEditionProvider>,
);
return { ...result, onSourceLoaded };
}
describe("SourceFetchPrompt", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("renders source name, URL input, Load and Upload buttons", () => {
renderPrompt();
expect(screen.getByText(MONSTER_MANUAL_REGEX)).toBeInTheDocument();
expect(
screen.getByDisplayValue("https://example.com/bestiary/MM.json"),
).toBeInTheDocument();
expect(screen.getByText("Load")).toBeInTheDocument();
expect(screen.getByText("Upload file")).toBeInTheDocument();
});
it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => {
mockFetchAndCacheSource.mockResolvedValueOnce({ skippedNames: [] });
const user = userEvent.setup();
const { onSourceLoaded } = renderPrompt();
await user.click(screen.getByText("Load"));
await waitFor(() => {
expect(mockFetchAndCacheSource).toHaveBeenCalledWith(
"MM",
"https://example.com/bestiary/MM.json",
);
expect(onSourceLoaded).toHaveBeenCalled();
});
});
it("fetch error shows error message", async () => {
mockFetchAndCacheSource.mockRejectedValueOnce(new Error("Network error"));
const user = userEvent.setup();
renderPrompt();
await user.click(screen.getByText("Load"));
await waitFor(() => {
expect(screen.getByText("Network error")).toBeInTheDocument();
});
});
it("upload file calls uploadAndCacheSource and onSourceLoaded", async () => {
mockUploadAndCacheSource.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
const { onSourceLoaded } = renderPrompt();
const file = new File(['{"monster":[]}'], "bestiary-mm.json", {
type: "application/json",
});
const fileInput = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
await user.upload(fileInput, file);
await waitFor(() => {
expect(mockUploadAndCacheSource).toHaveBeenCalledWith("MM", {
monster: [],
});
expect(onSourceLoaded).toHaveBeenCalled();
});
});
it("upload error shows error message", async () => {
mockUploadAndCacheSource.mockRejectedValueOnce(new Error("Invalid format"));
const user = userEvent.setup();
renderPrompt();
const file = new File(['{"bad": true}'], "bad.json", {
type: "application/json",
});
const fileInput = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
await user.upload(fileInput, file);
await waitFor(() => {
expect(screen.getByText("Invalid format")).toBeInTheDocument();
});
});
});