@gradio/annotatedimage 0.11.5 → 0.11.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AnnotatedImage.test.ts +595 -0
- package/CHANGELOG.md +21 -0
- package/Index.svelte +1 -7
- package/dist/Index.svelte +1 -7
- package/package.json +7 -7
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { test, describe, afterEach, expect } from "vitest";
|
|
2
|
+
import { cleanup, render, fireEvent } from "@self/tootils/render";
|
|
3
|
+
import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
|
|
4
|
+
|
|
5
|
+
import AnnotatedImage from "./Index.svelte";
|
|
6
|
+
|
|
7
|
+
const fake_image = {
|
|
8
|
+
path: "test.png",
|
|
9
|
+
url: "https://example.com/test.png",
|
|
10
|
+
orig_name: "test.png",
|
|
11
|
+
size: 1024,
|
|
12
|
+
mime_type: "image/png",
|
|
13
|
+
is_stream: false
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const fake_mask_1 = {
|
|
17
|
+
path: "mask1.png",
|
|
18
|
+
url: "https://example.com/mask1.png",
|
|
19
|
+
orig_name: "mask1.png",
|
|
20
|
+
size: 512,
|
|
21
|
+
mime_type: "image/png",
|
|
22
|
+
is_stream: false
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const fake_mask_2 = {
|
|
26
|
+
path: "mask2.png",
|
|
27
|
+
url: "https://example.com/mask2.png",
|
|
28
|
+
orig_name: "mask2.png",
|
|
29
|
+
size: 512,
|
|
30
|
+
mime_type: "image/png",
|
|
31
|
+
is_stream: false
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const fake_value = {
|
|
35
|
+
image: fake_image,
|
|
36
|
+
annotations: [
|
|
37
|
+
{ image: fake_mask_1, label: "cat" },
|
|
38
|
+
{ image: fake_mask_2, label: "dog" }
|
|
39
|
+
]
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const single_annotation_value = {
|
|
43
|
+
image: fake_image,
|
|
44
|
+
annotations: [{ image: fake_mask_1, label: "cat" }]
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const default_props = {
|
|
48
|
+
value: null as any,
|
|
49
|
+
label: "Annotated Image",
|
|
50
|
+
show_label: true,
|
|
51
|
+
show_legend: true,
|
|
52
|
+
color_map: {} as Record<string, string>,
|
|
53
|
+
buttons: ["fullscreen"] as (
|
|
54
|
+
| string
|
|
55
|
+
| { value: string; id: number; icon: null }
|
|
56
|
+
)[]
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// BlockLabel in AnnotatedImage doesn't use data-testid='block-info',
|
|
60
|
+
// so the shared show_label: false test fails. Disable has_label and
|
|
61
|
+
// write custom label tests below.
|
|
62
|
+
run_shared_prop_tests({
|
|
63
|
+
component: AnnotatedImage,
|
|
64
|
+
name: "AnnotatedImage",
|
|
65
|
+
base_props: {
|
|
66
|
+
...default_props
|
|
67
|
+
},
|
|
68
|
+
has_label: false,
|
|
69
|
+
has_validation_error: false
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("Label", () => {
|
|
73
|
+
afterEach(() => cleanup());
|
|
74
|
+
|
|
75
|
+
test("label text is rendered", async () => {
|
|
76
|
+
const { getByText } = await render(AnnotatedImage, {
|
|
77
|
+
...default_props,
|
|
78
|
+
label: "My Image",
|
|
79
|
+
show_label: true
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(getByText("My Image")).toBeTruthy();
|
|
83
|
+
expect(getByText("My Image")).toBeVisible();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("show_label=false hides the label visually", async () => {
|
|
87
|
+
const { getByText } = await render(AnnotatedImage, {
|
|
88
|
+
...default_props,
|
|
89
|
+
label: "My Image",
|
|
90
|
+
show_label: false
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// BlockLabel hides via CSS when show_label is false
|
|
94
|
+
expect(getByText("My Image")).not.toBeVisible();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("AnnotatedImage", () => {
|
|
99
|
+
afterEach(() => cleanup());
|
|
100
|
+
|
|
101
|
+
test("renders empty state when value is null", async () => {
|
|
102
|
+
const { queryByAltText } = await render(AnnotatedImage, {
|
|
103
|
+
...default_props,
|
|
104
|
+
value: null
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(queryByAltText("the base file that is annotated")).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("renders base image when value is set", async () => {
|
|
111
|
+
const { getByAltText } = await render(AnnotatedImage, {
|
|
112
|
+
...default_props,
|
|
113
|
+
value: fake_value
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const img = getByAltText("the base file that is annotated");
|
|
117
|
+
expect(img).toBeTruthy();
|
|
118
|
+
expect(img.getAttribute("src")).toBe("https://example.com/test.png");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("renders annotation masks when value has annotations", async () => {
|
|
122
|
+
const { container } = await render(AnnotatedImage, {
|
|
123
|
+
...default_props,
|
|
124
|
+
value: fake_value
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const masks = container.querySelectorAll("img.mask");
|
|
128
|
+
expect(masks.length).toBe(2);
|
|
129
|
+
expect(masks[0].getAttribute("src")).toBe("https://example.com/mask1.png");
|
|
130
|
+
expect(masks[1].getAttribute("src")).toBe("https://example.com/mask2.png");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("Props: show_legend", () => {
|
|
135
|
+
afterEach(() => cleanup());
|
|
136
|
+
|
|
137
|
+
test("show_legend=true renders legend buttons for each annotation", async () => {
|
|
138
|
+
const { getByRole } = await render(AnnotatedImage, {
|
|
139
|
+
...default_props,
|
|
140
|
+
value: fake_value,
|
|
141
|
+
show_legend: true
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(getByRole("button", { name: "cat" })).toBeTruthy();
|
|
145
|
+
expect(getByRole("button", { name: "dog" })).toBeTruthy();
|
|
146
|
+
expect(getByRole("button", { name: "cat" })).toBeVisible();
|
|
147
|
+
expect(getByRole("button", { name: "dog" })).toBeVisible();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("show_legend=false hides legend", async () => {
|
|
151
|
+
const { queryByRole } = await render(AnnotatedImage, {
|
|
152
|
+
...default_props,
|
|
153
|
+
value: fake_value,
|
|
154
|
+
show_legend: false
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(queryByRole("button", { name: "cat" })).toBeNull();
|
|
158
|
+
expect(queryByRole("button", { name: "dog" })).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("legend is not rendered when value is null", async () => {
|
|
162
|
+
const { queryByRole } = await render(AnnotatedImage, {
|
|
163
|
+
...default_props,
|
|
164
|
+
value: null,
|
|
165
|
+
show_legend: true
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(queryByRole("button", { name: "cat" })).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("Props: color_map", () => {
|
|
173
|
+
afterEach(() => cleanup());
|
|
174
|
+
|
|
175
|
+
test("color_map applies custom background colors to legend items", async () => {
|
|
176
|
+
const { getByRole } = await render(AnnotatedImage, {
|
|
177
|
+
...default_props,
|
|
178
|
+
value: fake_value,
|
|
179
|
+
color_map: { cat: "#ff0000", dog: "#00ff00" }
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const catBtn = getByRole("button", { name: "cat" });
|
|
183
|
+
const dogBtn = getByRole("button", { name: "dog" });
|
|
184
|
+
|
|
185
|
+
// color_map colors get '88' (0x88/0xff ≈ 0.533) appended for semi-transparency
|
|
186
|
+
// The browser normalizes to rgba
|
|
187
|
+
expect(catBtn.style.backgroundColor).toContain("rgba(255, 0, 0");
|
|
188
|
+
expect(dogBtn.style.backgroundColor).toContain("rgba(0, 255, 0");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("without color_map, masks have hue-rotate filter style", async () => {
|
|
192
|
+
const { container } = await render(AnnotatedImage, {
|
|
193
|
+
...default_props,
|
|
194
|
+
value: fake_value,
|
|
195
|
+
color_map: {}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const masks = container.querySelectorAll("img.mask");
|
|
199
|
+
// First mask: hue-rotate(0deg), second: hue-rotate(180deg)
|
|
200
|
+
expect(masks[0].getAttribute("style")).toContain("hue-rotate(0deg)");
|
|
201
|
+
expect(masks[1].getAttribute("style")).toContain("hue-rotate(180deg)");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("with color_map, masks have no hue-rotate filter", async () => {
|
|
205
|
+
const { container } = await render(AnnotatedImage, {
|
|
206
|
+
...default_props,
|
|
207
|
+
value: fake_value,
|
|
208
|
+
color_map: { cat: "#ff0000", dog: "#00ff00" }
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const masks = container.querySelectorAll("img.mask");
|
|
212
|
+
for (const mask of masks) {
|
|
213
|
+
const style = mask.getAttribute("style");
|
|
214
|
+
// Should be null or not contain hue-rotate
|
|
215
|
+
if (style) {
|
|
216
|
+
expect(style).not.toContain("hue-rotate");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("Props: buttons", () => {
|
|
223
|
+
afterEach(() => cleanup());
|
|
224
|
+
|
|
225
|
+
test("fullscreen button renders when 'fullscreen' is in buttons", async () => {
|
|
226
|
+
const { getByLabelText } = await render(AnnotatedImage, {
|
|
227
|
+
...default_props,
|
|
228
|
+
value: fake_value,
|
|
229
|
+
buttons: ["fullscreen"]
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(getByLabelText("Fullscreen")).toBeTruthy();
|
|
233
|
+
expect(getByLabelText("Fullscreen")).toBeVisible();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("empty buttons array shows no fullscreen button", async () => {
|
|
237
|
+
const { queryByLabelText } = await render(AnnotatedImage, {
|
|
238
|
+
...default_props,
|
|
239
|
+
value: fake_value,
|
|
240
|
+
buttons: []
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(queryByLabelText("Fullscreen")).toBeNull();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("custom button renders and dispatches custom_button_click", async () => {
|
|
247
|
+
const { listen, getByLabelText } = await render(AnnotatedImage, {
|
|
248
|
+
...default_props,
|
|
249
|
+
value: fake_value,
|
|
250
|
+
buttons: [{ value: "Analyze", id: 5, icon: null }]
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const custom = listen("custom_button_click");
|
|
254
|
+
const btn = getByLabelText("Analyze");
|
|
255
|
+
|
|
256
|
+
await fireEvent.click(btn);
|
|
257
|
+
|
|
258
|
+
expect(custom).toHaveBeenCalledTimes(1);
|
|
259
|
+
expect(custom).toHaveBeenCalledWith({ id: 5 });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("buttons not rendered when value is null (empty state)", async () => {
|
|
263
|
+
const { queryByLabelText } = await render(AnnotatedImage, {
|
|
264
|
+
...default_props,
|
|
265
|
+
value: null,
|
|
266
|
+
buttons: ["fullscreen"]
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(queryByLabelText("Fullscreen")).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("Events: change", () => {
|
|
274
|
+
afterEach(() => cleanup());
|
|
275
|
+
|
|
276
|
+
test("setting value triggers change event", async () => {
|
|
277
|
+
const { listen, set_data } = await render(AnnotatedImage, {
|
|
278
|
+
...default_props,
|
|
279
|
+
value: null
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const change = listen("change");
|
|
283
|
+
|
|
284
|
+
await set_data({ value: fake_value });
|
|
285
|
+
|
|
286
|
+
expect(change).toHaveBeenCalledTimes(1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("change event is not triggered on mount with null value", async () => {
|
|
290
|
+
const { listen } = await render(AnnotatedImage, {
|
|
291
|
+
...default_props,
|
|
292
|
+
value: null
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const change = listen("change", { retrospective: true });
|
|
296
|
+
|
|
297
|
+
expect(change).not.toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("change event does not fire once on mount when initial value is set", async () => {
|
|
301
|
+
// The component's $effect fires change when old_value != value on mount
|
|
302
|
+
const { listen } = await render(AnnotatedImage, {
|
|
303
|
+
...default_props,
|
|
304
|
+
value: fake_value
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const change = listen("change", { retrospective: true });
|
|
308
|
+
|
|
309
|
+
expect(change).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("changing value multiple times triggers change each time", async () => {
|
|
313
|
+
const { listen, set_data } = await render(AnnotatedImage, {
|
|
314
|
+
...default_props,
|
|
315
|
+
value: null
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const change = listen("change");
|
|
319
|
+
|
|
320
|
+
await set_data({ value: fake_value });
|
|
321
|
+
expect(change).toHaveBeenCalledTimes(1);
|
|
322
|
+
await set_data({ value: single_annotation_value });
|
|
323
|
+
|
|
324
|
+
expect(change).toHaveBeenCalledTimes(2);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("setting value to null after a value triggers change", async () => {
|
|
328
|
+
const { listen, set_data } = await render(AnnotatedImage, {
|
|
329
|
+
...default_props,
|
|
330
|
+
value: fake_value
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const change = listen("change");
|
|
334
|
+
|
|
335
|
+
await set_data({ value: null });
|
|
336
|
+
|
|
337
|
+
expect(change).toHaveBeenCalledTimes(1);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("Events: select", () => {
|
|
342
|
+
afterEach(() => cleanup());
|
|
343
|
+
|
|
344
|
+
test("clicking legend item dispatches select with value and index", async () => {
|
|
345
|
+
const { listen, getByRole } = await render(AnnotatedImage, {
|
|
346
|
+
...default_props,
|
|
347
|
+
value: fake_value
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const select = listen("select");
|
|
351
|
+
|
|
352
|
+
await fireEvent.click(getByRole("button", { name: "cat" }));
|
|
353
|
+
|
|
354
|
+
expect(select).toHaveBeenCalledTimes(1);
|
|
355
|
+
expect(select).toHaveBeenCalledWith({ value: "cat", index: 0 });
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("clicking second legend item dispatches correct index", async () => {
|
|
359
|
+
const { listen, getByRole } = await render(AnnotatedImage, {
|
|
360
|
+
...default_props,
|
|
361
|
+
value: fake_value
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const select = listen("select");
|
|
365
|
+
|
|
366
|
+
await fireEvent.click(getByRole("button", { name: "dog" }));
|
|
367
|
+
|
|
368
|
+
expect(select).toHaveBeenCalledTimes(1);
|
|
369
|
+
expect(select).toHaveBeenCalledWith({ value: "dog", index: 1 });
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("clicking multiple legend items dispatches select for each", async () => {
|
|
373
|
+
const { listen, getByRole } = await render(AnnotatedImage, {
|
|
374
|
+
...default_props,
|
|
375
|
+
value: fake_value
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const select = listen("select");
|
|
379
|
+
|
|
380
|
+
await fireEvent.click(getByRole("button", { name: "cat" }));
|
|
381
|
+
await fireEvent.click(getByRole("button", { name: "dog" }));
|
|
382
|
+
|
|
383
|
+
expect(select).toHaveBeenCalledTimes(2);
|
|
384
|
+
expect(select).toHaveBeenNthCalledWith(1, { value: "cat", index: 0 });
|
|
385
|
+
expect(select).toHaveBeenNthCalledWith(2, { value: "dog", index: 1 });
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe("Legend hover interactions", () => {
|
|
390
|
+
afterEach(() => cleanup());
|
|
391
|
+
|
|
392
|
+
test("hovering over legend item sets active class on corresponding mask", async () => {
|
|
393
|
+
const { container, getByRole } = await render(AnnotatedImage, {
|
|
394
|
+
...default_props,
|
|
395
|
+
value: fake_value
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await fireEvent.mouseOver(getByRole("button", { name: "cat" }));
|
|
399
|
+
|
|
400
|
+
const masks = container.querySelectorAll("img.mask");
|
|
401
|
+
expect(masks[0].classList.contains("active")).toBe(true);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("hovering over legend item sets inactive class on other masks", async () => {
|
|
405
|
+
const { container, getByRole } = await render(AnnotatedImage, {
|
|
406
|
+
...default_props,
|
|
407
|
+
value: fake_value
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
await fireEvent.mouseOver(getByRole("button", { name: "cat" }));
|
|
411
|
+
|
|
412
|
+
const masks = container.querySelectorAll("img.mask");
|
|
413
|
+
expect(masks[0].classList.contains("active")).toBe(true);
|
|
414
|
+
expect(masks[1].classList.contains("inactive")).toBe(true);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("mouseout resets all masks to default state", async () => {
|
|
418
|
+
const { container, getByRole } = await render(AnnotatedImage, {
|
|
419
|
+
...default_props,
|
|
420
|
+
value: fake_value
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await fireEvent.mouseOver(getByRole("button", { name: "cat" }));
|
|
424
|
+
await fireEvent.mouseOut(getByRole("button", { name: "cat" }));
|
|
425
|
+
|
|
426
|
+
const masks = container.querySelectorAll("img.mask");
|
|
427
|
+
expect(masks[0].classList.contains("active")).toBe(false);
|
|
428
|
+
expect(masks[0].classList.contains("inactive")).toBe(false);
|
|
429
|
+
expect(masks[1].classList.contains("active")).toBe(false);
|
|
430
|
+
expect(masks[1].classList.contains("inactive")).toBe(false);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("focus on legend item activates mask (keyboard accessibility)", async () => {
|
|
434
|
+
const { container, getByRole } = await render(AnnotatedImage, {
|
|
435
|
+
...default_props,
|
|
436
|
+
value: fake_value
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await fireEvent.focus(getByRole("button", { name: "dog" }));
|
|
440
|
+
|
|
441
|
+
const masks = container.querySelectorAll("img.mask");
|
|
442
|
+
expect(masks[1].classList.contains("active")).toBe(true);
|
|
443
|
+
expect(masks[0].classList.contains("inactive")).toBe(true);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("blur on legend item resets masks (keyboard accessibility)", async () => {
|
|
447
|
+
const { container, getByRole } = await render(AnnotatedImage, {
|
|
448
|
+
...default_props,
|
|
449
|
+
value: fake_value
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
await fireEvent.focus(getByRole("button", { name: "dog" }));
|
|
453
|
+
await fireEvent.blur(getByRole("button", { name: "dog" }));
|
|
454
|
+
|
|
455
|
+
const masks = container.querySelectorAll("img.mask");
|
|
456
|
+
expect(masks[0].classList.contains("active")).toBe(false);
|
|
457
|
+
expect(masks[0].classList.contains("inactive")).toBe(false);
|
|
458
|
+
expect(masks[1].classList.contains("active")).toBe(false);
|
|
459
|
+
expect(masks[1].classList.contains("inactive")).toBe(false);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe("get_data / set_data", () => {
|
|
464
|
+
afterEach(() => cleanup());
|
|
465
|
+
|
|
466
|
+
test("get_data returns null when no value", async () => {
|
|
467
|
+
const { get_data } = await render(AnnotatedImage, {
|
|
468
|
+
...default_props,
|
|
469
|
+
value: null
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const data = await get_data();
|
|
473
|
+
expect(data.value).toBeNull();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("set_data updates the displayed image", async () => {
|
|
477
|
+
const { set_data, getByAltText } = await render(AnnotatedImage, {
|
|
478
|
+
...default_props,
|
|
479
|
+
value: null
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
await set_data({ value: fake_value });
|
|
483
|
+
|
|
484
|
+
const img = getByAltText("the base file that is annotated");
|
|
485
|
+
expect(img).toBeTruthy();
|
|
486
|
+
expect(img.getAttribute("src")).toBe("https://example.com/test.png");
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("get_data reflects set_data value (round-trip)", async () => {
|
|
490
|
+
const { get_data, set_data } = await render(AnnotatedImage, {
|
|
491
|
+
...default_props,
|
|
492
|
+
value: null
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
await set_data({ value: fake_value });
|
|
496
|
+
|
|
497
|
+
const data = await get_data();
|
|
498
|
+
expect(data.value).toEqual(fake_value);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("set_data to null shows empty state", async () => {
|
|
502
|
+
const { set_data, queryByAltText } = await render(AnnotatedImage, {
|
|
503
|
+
...default_props,
|
|
504
|
+
value: fake_value
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
await set_data({ value: null });
|
|
508
|
+
|
|
509
|
+
expect(queryByAltText("the base file that is annotated")).toBeNull();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("set_data updates legend items", async () => {
|
|
513
|
+
const { set_data, getByRole, queryByRole } = await render(AnnotatedImage, {
|
|
514
|
+
...default_props,
|
|
515
|
+
value: null
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
await set_data({ value: fake_value });
|
|
519
|
+
|
|
520
|
+
expect(getByRole("button", { name: "cat" })).toBeTruthy();
|
|
521
|
+
expect(getByRole("button", { name: "dog" })).toBeTruthy();
|
|
522
|
+
|
|
523
|
+
await set_data({ value: single_annotation_value });
|
|
524
|
+
|
|
525
|
+
expect(getByRole("button", { name: "cat" })).toBeTruthy();
|
|
526
|
+
expect(queryByRole("button", { name: "dog" })).toBeNull();
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe("Edge cases", () => {
|
|
531
|
+
afterEach(() => cleanup());
|
|
532
|
+
|
|
533
|
+
test("single annotation renders without errors in hue calculation", async () => {
|
|
534
|
+
const { container } = await render(AnnotatedImage, {
|
|
535
|
+
...default_props,
|
|
536
|
+
value: single_annotation_value,
|
|
537
|
+
color_map: {}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const masks = container.querySelectorAll("img.mask");
|
|
541
|
+
expect(masks.length).toBe(1);
|
|
542
|
+
// With 1 annotation: hue-rotate(0 * 360 / 1) = hue-rotate(0deg)
|
|
543
|
+
expect(masks[0].getAttribute("style")).toContain("hue-rotate(0deg)");
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("value with empty annotations array renders base image but no masks or legend", async () => {
|
|
547
|
+
const { container, getByAltText, queryByRole } = await render(
|
|
548
|
+
AnnotatedImage,
|
|
549
|
+
{
|
|
550
|
+
...default_props,
|
|
551
|
+
value: {
|
|
552
|
+
image: fake_image,
|
|
553
|
+
annotations: []
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
expect(getByAltText("the base file that is annotated")).toBeTruthy();
|
|
559
|
+
|
|
560
|
+
const masks = container.querySelectorAll("img.mask");
|
|
561
|
+
expect(masks.length).toBe(0);
|
|
562
|
+
|
|
563
|
+
// No legend buttons since there are no annotations
|
|
564
|
+
expect(queryByRole("button", { name: "cat" })).toBeNull();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("color_map with only some labels falls back to hue-rotate for unmapped labels", async () => {
|
|
568
|
+
const { container } = await render(AnnotatedImage, {
|
|
569
|
+
...default_props,
|
|
570
|
+
value: fake_value,
|
|
571
|
+
color_map: { cat: "#ff0000" } // only cat is mapped, dog is not
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
const masks = container.querySelectorAll("img.mask");
|
|
575
|
+
// cat mask: has color_map entry, no hue-rotate
|
|
576
|
+
const catStyle = masks[0].getAttribute("style");
|
|
577
|
+
if (catStyle) {
|
|
578
|
+
expect(catStyle).not.toContain("hue-rotate");
|
|
579
|
+
}
|
|
580
|
+
// dog mask: no color_map entry, should have hue-rotate
|
|
581
|
+
expect(masks[1].getAttribute("style")).toContain("hue-rotate");
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test.todo(
|
|
586
|
+
"VISUAL: mask opacity transitions on hover — masks should fade to 0.3 opacity on container hover, active mask at 1.0, inactive at 0 — needs Playwright visual regression screenshot comparison"
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
test.todo(
|
|
590
|
+
"VISUAL: height/width props control component dimensions — needs Playwright visual regression screenshot comparison"
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
test.todo(
|
|
594
|
+
"VISUAL: default hue-rotate coloring distributes colors evenly across the color wheel — needs Playwright visual regression screenshot comparison"
|
|
595
|
+
);
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# @gradio/annotatedimage
|
|
2
2
|
|
|
3
|
+
## 0.11.7
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- [#13185](https://github.com/gradio-app/gradio/pull/13185) [`ffc00ff`](https://github.com/gradio-app/gradio/commit/ffc00ff4cf23641e90f0963cec6ed52f85ed511c) - Annotated image unit tests. Thanks @freddyaboulton!
|
|
8
|
+
|
|
9
|
+
### Dependency updates
|
|
10
|
+
|
|
11
|
+
- @gradio/atoms@0.23.1
|
|
12
|
+
- @gradio/statustracker@0.14.0
|
|
13
|
+
- @gradio/client@2.2.0
|
|
14
|
+
|
|
15
|
+
## 0.11.6
|
|
16
|
+
|
|
17
|
+
### Dependency updates
|
|
18
|
+
|
|
19
|
+
- @gradio/utils@0.12.2
|
|
20
|
+
- @gradio/atoms@0.23.0
|
|
21
|
+
- @gradio/statustracker@0.13.1
|
|
22
|
+
- @gradio/upload@0.17.8
|
|
23
|
+
|
|
3
24
|
## 0.11.5
|
|
4
25
|
|
|
5
26
|
### Dependency updates
|
package/Index.svelte
CHANGED
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
const props = $props();
|
|
16
16
|
const gradio = new Gradio<AnnotatedImageEvents, AnnotatedImageProps>(props);
|
|
17
17
|
|
|
18
|
-
let old_value = $state(gradio.props.value);
|
|
19
18
|
let active: string | null = $state(null);
|
|
20
19
|
let image_container: HTMLElement;
|
|
21
20
|
let fullscreen = $state(false);
|
|
@@ -23,12 +22,7 @@
|
|
|
23
22
|
gradio.shared.label || gradio.i18n("annotated_image.annotated_image")
|
|
24
23
|
);
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
if (old_value != gradio.props.value) {
|
|
28
|
-
old_value = gradio.props.value;
|
|
29
|
-
gradio.dispatch("change");
|
|
30
|
-
}
|
|
31
|
-
});
|
|
25
|
+
gradio.watch_for_change();
|
|
32
26
|
|
|
33
27
|
function handle_mouseover(_label: string): void {
|
|
34
28
|
active = _label;
|
package/dist/Index.svelte
CHANGED
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
const props = $props();
|
|
16
16
|
const gradio = new Gradio<AnnotatedImageEvents, AnnotatedImageProps>(props);
|
|
17
17
|
|
|
18
|
-
let old_value = $state(gradio.props.value);
|
|
19
18
|
let active: string | null = $state(null);
|
|
20
19
|
let image_container: HTMLElement;
|
|
21
20
|
let fullscreen = $state(false);
|
|
@@ -23,12 +22,7 @@
|
|
|
23
22
|
gradio.shared.label || gradio.i18n("annotated_image.annotated_image")
|
|
24
23
|
);
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
if (old_value != gradio.props.value) {
|
|
28
|
-
old_value = gradio.props.value;
|
|
29
|
-
gradio.dispatch("change");
|
|
30
|
-
}
|
|
31
|
-
});
|
|
25
|
+
gradio.watch_for_change();
|
|
32
26
|
|
|
33
27
|
function handle_mouseover(_label: string): void {
|
|
34
28
|
active = _label;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gradio/annotatedimage",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.7",
|
|
4
4
|
"description": "Gradio UI packages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "",
|
|
@@ -16,18 +16,18 @@
|
|
|
16
16
|
"./package.json": "./package.json"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@gradio/preview": "^0.16.
|
|
19
|
+
"@gradio/preview": "^0.16.2"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"svelte": "^5.48.0"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@gradio/atoms": "^0.
|
|
25
|
+
"@gradio/atoms": "^0.23.1",
|
|
26
|
+
"@gradio/statustracker": "^0.14.0",
|
|
26
27
|
"@gradio/icons": "^0.15.1",
|
|
27
|
-
"@gradio/upload": "^0.17.
|
|
28
|
-
"@gradio/
|
|
29
|
-
"@gradio/
|
|
30
|
-
"@gradio/client": "^2.1.0"
|
|
28
|
+
"@gradio/upload": "^0.17.8",
|
|
29
|
+
"@gradio/utils": "^0.12.2",
|
|
30
|
+
"@gradio/client": "^2.2.0"
|
|
31
31
|
},
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|