| 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; |
| } |
| }, |
| })); |
|
|
| |
| |
| 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>; |
| }; |
|
|
| |
| 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") |
| ); |
| |
| |
| 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")); |
|
|
| |
| expect(await screen.findByText(/No preview/i)).toBeInTheDocument(); |
| expect(screen.getByText(/holiday\.heic/i)).toBeInTheDocument(); |
| expect(screen.getByText(/will still be searched/i)).toBeInTheDocument(); |
|
|
| |
| 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 })); |
| |
| 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(); |
|
|
| |
| 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); |
| }); |
| }); |
|
|