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({ui}); 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; const mockApi = api as unknown as { searchByPhoto: ReturnType; estimateBreed: ReturnType; }; // 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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); }); });