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>
134 lines
4.1 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|