PawTrace / frontend /src /components /__tests__ /PhotoTools.test.tsx
Elliott Duke
Site polish: hero illustration, About rewrite, copy and styling pass
9122959
Raw
History Blame Contribute Delete
8.48 kB
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render as rtlRender, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import PhotoTools from "../PhotoTools";
const render = (ui: React.ReactElement) => rtlRender(<MemoryRouter>{ui}</MemoryRouter>);
import { api } from "../../api";
vi.mock("../../api", () => ({
api: { searchByPhoto: vi.fn(), estimateBreed: vi.fn() },
ApiError: class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
}));
// jsdom has no canvas, so the real browser-side downscale/HEIC conversion can't run here.
// Pass files through by default; individual tests can make it reject to exercise the error path.
vi.mock("../../lib/prepareImage", () => ({
prepareImage: vi.fn(async (f: File) => f),
UnreadableImageError: class UnreadableImageError extends Error {},
}));
import { prepareImage } from "../../lib/prepareImage";
const mockPrepare = prepareImage as unknown as ReturnType<typeof vi.fn>;
const mockApi = api as unknown as {
searchByPhoto: ReturnType<typeof vi.fn>;
estimateBreed: ReturnType<typeof vi.fn>;
};
// jsdom doesn't implement object URLs; the component uses them for the thumbnail previews.
beforeEach(() => {
vi.clearAllMocks();
mockPrepare.mockImplementation(async (f: File) => f);
globalThis.URL.createObjectURL = vi.fn(() => "blob:preview");
globalThis.URL.revokeObjectURL = vi.fn();
mockApi.searchByPhoto.mockResolvedValue({
results: [
{
dog: {
kind: "unknown", id: 9, name: "Found dog", breed: "Lab", color: "black", size: null,
status: "at_shelter", zip: "77002", dataset_id: 2, dataset_name: "U",
picture_count: 2, thumb_url: null, predicted_breeds: [],
},
score: 0.94,
distance_miles: 3.2,
photos: [
{ id: 1, url: "/media/a.jpg", thumb_url: "/media/a.jpg", is_primary: true },
{ id: 2, url: "/media/b.jpg", thumb_url: "/media/b.jpg", is_primary: false },
],
},
],
model: "hf-embed/v1",
candidate_count: 5,
zip: null,
radius_miles: -1,
pool: "found",
});
mockApi.estimateBreed.mockResolvedValue({
model: "hf-breed/Dogs-Breed-Image-Classification-V1",
breeds: [
{ label: "border collie", score: 0.62 },
{ label: "australian shepherd", score: 0.21 },
{ label: "kelpie", score: 0.08 },
],
});
});
function file(name = "dog.jpg") {
return new File([new Uint8Array([1, 2, 3])], name, { type: "image/jpeg" });
}
describe("PhotoTools", () => {
it("opens on the matching tab and finds matches for one image", async () => {
const user = userEvent.setup();
render(<PhotoTools />);
expect(screen.getByRole("tab", { name: /Image matching/i })).toHaveAttribute(
"aria-selected",
"true"
);
await user.upload(screen.getByLabelText(/Upload images of your dog/i), file());
expect(screen.getByAltText(/Your dog, image 1/i)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Find matches/i }));
await waitFor(() =>
expect(mockApi.searchByPhoto).toHaveBeenCalledWith([expect.any(File)], undefined, "found")
);
// The component holds results back for a minimum display time (MIN_PROGRESS_MS) so the staged
// progress steps are readable — give the wait a longer timeout than the default 1000ms.
expect(await screen.findByText("Found dog", {}, { timeout: 3000 })).toBeInTheDocument();
expect(screen.getByText("94.0% match")).toBeInTheDocument();
expect(mockApi.estimateBreed).not.toHaveBeenCalled();
});
it("accepts multiple images (up to 6) and sends them all", async () => {
const user = userEvent.setup();
render(<PhotoTools />);
const input = screen.getByLabelText(/Upload images of your dog/i);
await user.upload(input, [file("a.jpg"), file("b.jpg"), file("c.jpg")]);
expect(screen.getByAltText(/Your dog, image 1/i)).toBeInTheDocument();
expect(screen.getByAltText(/Your dog, image 3/i)).toBeInTheDocument();
expect(screen.getByText(/3 of 6 images/i)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Find matches/i }));
await waitFor(() =>
expect(mockApi.searchByPhoto).toHaveBeenCalledWith(
[expect.any(File), expect.any(File), expect.any(File)],
undefined,
"found"
)
);
});
it("removes an image via its remove button", async () => {
const user = userEvent.setup();
render(<PhotoTools />);
await user.upload(screen.getByLabelText(/Upload images of your dog/i), [file("a.jpg"), file("b.jpg")]);
expect(screen.getByText(/2 of 6 images/i)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Remove image 1/i }));
expect(screen.getByText(/1 of 6 images/i)).toBeInTheDocument();
});
it("searches the lost pool when the 'I found a dog' toggle is chosen", async () => {
const user = userEvent.setup();
render(<PhotoTools />);
await user.upload(screen.getByLabelText(/Upload images of your dog/i), file());
await user.click(screen.getByRole("button", { name: /I found a dog/i }));
await user.click(screen.getByRole("button", { name: /Find matches/i }));
await waitFor(() =>
expect(mockApi.searchByPhoto).toHaveBeenCalledWith([expect.any(File)], undefined, "lost")
);
});
it("still uploads an image this browser can't preview (e.g. HEIC outside Safari)", async () => {
const user = userEvent.setup();
mockPrepare.mockRejectedValue(new Error("undecodable"));
render(<PhotoTools />);
await user.upload(screen.getByLabelText(/Upload images of your dog/i), file("holiday.heic"));
// Falls back to a labelled placeholder instead of a broken thumbnail...
expect(await screen.findByText(/No preview/i)).toBeInTheDocument();
expect(screen.getByText(/holiday\.heic/i)).toBeInTheDocument();
expect(screen.getByText(/will still be searched/i)).toBeInTheDocument();
// ...and the original file is still sent to the server, which can decode HEIC itself.
await user.click(screen.getByRole("button", { name: /Find matches/i }));
await waitFor(() =>
expect(mockApi.searchByPhoto).toHaveBeenCalledWith([expect.any(File)], undefined, "found")
);
});
it("has no ZIP code field", () => {
render(<PhotoTools />);
expect(screen.queryByLabelText(/ZIP code/i)).not.toBeInTheDocument();
});
it("estimates breed across all uploaded images, always asking for the top 10", async () => {
const user = userEvent.setup();
render(<PhotoTools />);
await user.upload(screen.getByLabelText(/Upload images of your dog/i), [file("a.jpg"), file("b.jpg")]);
await user.click(screen.getByRole("tab", { name: /Breed estimation/i }));
// No count picker any more — the tool always requests the model's full top 10.
expect(screen.queryByLabelText(/Breeds to estimate/i)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Estimate breed/i }));
await waitFor(() =>
expect(mockApi.estimateBreed).toHaveBeenCalledWith([expect.any(File), expect.any(File)], 10)
);
expect(await screen.findByText("border collie", {}, { timeout: 3000 })).toBeInTheDocument();
expect(screen.getByText("62%")).toBeInTheDocument();
expect(mockApi.searchByPhoto).not.toHaveBeenCalled();
});
it("compares your images side by side with a match when its card is clicked", async () => {
const user = userEvent.setup();
render(<PhotoTools />);
await user.upload(screen.getByLabelText(/Upload images of your dog/i), [file("a.jpg"), file("b.jpg")]);
await user.click(screen.getByRole("button", { name: /Find matches/i }));
await screen.findByText("Found dog", {}, { timeout: 3000 });
await user.click(screen.getByRole("button", { name: /Found dog/i }));
expect(await screen.findByRole("dialog")).toBeInTheDocument();
// Left column = the visitor's uploads, right column = the candidate's photos.
expect(screen.getByText(/Your images/i)).toBeInTheDocument();
expect(screen.getByText(/Found dog \(match\)/i)).toBeInTheDocument();
expect(screen.getAllByAltText(/Your images/i)).toHaveLength(2);
expect(screen.getAllByAltText(/Found dog \(match\)/i)).toHaveLength(2);
});
});