File size: 8,484 Bytes
de1e3fc 9122959 de1e3fc 9122959 de1e3fc f0f5bef de1e3fc f0f5bef de1e3fc a270696 de1e3fc f0f5bef de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc f0f5bef a270696 f0f5bef de1e3fc a270696 f0f5bef de1e3fc a270696 f0f5bef a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | 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);
});
});
|