Files
wehub-resource-sync adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:17:32 +08:00

614 lines
17 KiB
TypeScript

import { test, describe, afterEach, expect } from "vitest";
import { cleanup, render, fireEvent, waitFor } from "@self/tootils/render";
import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
import AnnotatedImage from "./Index.svelte";
const fake_image = {
path: "test.png",
url: "https://example.com/test.png",
orig_name: "test.png",
size: 1024,
mime_type: "image/png",
is_stream: false
};
const fake_mask_1 = {
path: "mask1.png",
url: "https://example.com/mask1.png",
orig_name: "mask1.png",
size: 512,
mime_type: "image/png",
is_stream: false
};
const fake_mask_2 = {
path: "mask2.png",
url: "https://example.com/mask2.png",
orig_name: "mask2.png",
size: 512,
mime_type: "image/png",
is_stream: false
};
const fake_value = {
image: fake_image,
annotations: [
{ image: fake_mask_1, label: "cat" },
{ image: fake_mask_2, label: "dog" }
]
};
const single_annotation_value = {
image: fake_image,
annotations: [{ image: fake_mask_1, label: "cat" }]
};
const default_props = {
value: null as any,
label: "Annotated Image",
show_label: true,
show_legend: true,
color_map: {} as Record<string, string>,
buttons: ["fullscreen"] as (
| string
| { value: string; id: number; icon: null }
)[]
};
// BlockLabel in AnnotatedImage doesn't use data-testid='block-info',
// so the shared show_label: false test fails. Disable has_label and
// write custom label tests below.
run_shared_prop_tests({
component: AnnotatedImage,
name: "AnnotatedImage",
base_props: {
...default_props
},
has_label: false,
has_validation_error: false
});
describe("Label", () => {
afterEach(() => cleanup());
test("label text is rendered", async () => {
const { getByText } = await render(AnnotatedImage, {
...default_props,
label: "My Image",
show_label: true
});
expect(getByText("My Image")).toBeTruthy();
expect(getByText("My Image")).toBeVisible();
});
test("show_label=false hides the label visually", async () => {
const { getByText } = await render(AnnotatedImage, {
...default_props,
label: "My Image",
show_label: false
});
// BlockLabel hides via CSS when show_label is false
expect(getByText("My Image")).not.toBeVisible();
});
});
describe("AnnotatedImage", () => {
afterEach(() => cleanup());
test("renders empty state when value is null", async () => {
const { queryByAltText } = await render(AnnotatedImage, {
...default_props,
value: null
});
expect(queryByAltText("the base file that is annotated")).toBeNull();
});
test("renders base image when value is set", async () => {
const { getByAltText } = await render(AnnotatedImage, {
...default_props,
value: fake_value
});
const img = getByAltText("the base file that is annotated");
expect(img).toBeTruthy();
expect(img.getAttribute("src")).toBe("https://example.com/test.png");
});
test("renders annotation masks when value has annotations", async () => {
const { container } = await render(AnnotatedImage, {
...default_props,
value: fake_value
});
const masks = container.querySelectorAll("img.mask");
expect(masks.length).toBe(2);
expect(masks[0].getAttribute("src")).toBe("https://example.com/mask1.png");
expect(masks[1].getAttribute("src")).toBe("https://example.com/mask2.png");
});
});
describe("Props: show_legend", () => {
afterEach(() => cleanup());
test("show_legend=true renders legend buttons for each annotation", async () => {
const { getByRole } = await render(AnnotatedImage, {
...default_props,
value: fake_value,
show_legend: true
});
expect(getByRole("button", { name: "cat" })).toBeTruthy();
expect(getByRole("button", { name: "dog" })).toBeTruthy();
expect(getByRole("button", { name: "cat" })).toBeVisible();
expect(getByRole("button", { name: "dog" })).toBeVisible();
});
test("show_legend=false hides legend", async () => {
const { queryByRole } = await render(AnnotatedImage, {
...default_props,
value: fake_value,
show_legend: false
});
expect(queryByRole("button", { name: "cat" })).toBeNull();
expect(queryByRole("button", { name: "dog" })).toBeNull();
});
test("legend is not rendered when value is null", async () => {
const { queryByRole } = await render(AnnotatedImage, {
...default_props,
value: null,
show_legend: true
});
expect(queryByRole("button", { name: "cat" })).toBeNull();
});
});
describe("Props: color_map", () => {
afterEach(() => cleanup());
test("color_map applies custom background colors to legend items", async () => {
const { getByRole } = await render(AnnotatedImage, {
...default_props,
value: fake_value,
color_map: { cat: "#ff0000", dog: "#00ff00" }
});
const catBtn = getByRole("button", { name: "cat" });
const dogBtn = getByRole("button", { name: "dog" });
// color_map colors get '88' (0x88/0xff ≈ 0.533) appended for semi-transparency
// The browser normalizes to rgba
expect(catBtn.style.backgroundColor).toContain("rgba(255, 0, 0");
expect(dogBtn.style.backgroundColor).toContain("rgba(0, 255, 0");
});
test("without color_map, masks have hue-rotate filter style", async () => {
const { container } = await render(AnnotatedImage, {
...default_props,
value: fake_value,
color_map: {}
});
const masks = container.querySelectorAll("img.mask");
// First mask: hue-rotate(0deg), second: hue-rotate(180deg)
expect(masks[0].getAttribute("style")).toContain("hue-rotate(0deg)");
expect(masks[1].getAttribute("style")).toContain("hue-rotate(180deg)");
});
test("with color_map, masks have no hue-rotate filter", async () => {
const { container } = await render(AnnotatedImage, {
...default_props,
value: fake_value,
color_map: { cat: "#ff0000", dog: "#00ff00" }
});
const masks = container.querySelectorAll("img.mask");
for (const mask of masks) {
const style = mask.getAttribute("style");
// Should be null or not contain hue-rotate
if (style) {
expect(style).not.toContain("hue-rotate");
}
}
});
});
describe("Props: buttons", () => {
afterEach(() => cleanup());
test("fullscreen button renders when 'fullscreen' is in buttons", async () => {
const { getByLabelText } = await render(AnnotatedImage, {
...default_props,
value: fake_value,
buttons: ["fullscreen"]
});
expect(getByLabelText("Fullscreen")).toBeTruthy();
expect(getByLabelText("Fullscreen")).toBeVisible();
});
test("clicking the fullscreen button toggles fullscreen mode", async () => {
const { getByLabelText } = await render(AnnotatedImage, {
...default_props,
value: fake_value,
buttons: ["fullscreen"]
});
await fireEvent.click(getByLabelText("Fullscreen"));
await waitFor(() => {
expect(getByLabelText("Exit fullscreen mode")).toBeVisible();
});
await fireEvent.click(getByLabelText("Exit fullscreen mode"));
await waitFor(() => {
expect(getByLabelText("Fullscreen")).toBeVisible();
});
});
test("empty buttons array shows no fullscreen button", async () => {
const { queryByLabelText } = await render(AnnotatedImage, {
...default_props,
value: fake_value,
buttons: []
});
expect(queryByLabelText("Fullscreen")).toBeNull();
});
test("custom button renders and dispatches custom_button_click", async () => {
const { listen, getByLabelText } = await render(AnnotatedImage, {
...default_props,
value: fake_value,
buttons: [{ value: "Analyze", id: 5, icon: null }]
});
const custom = listen("custom_button_click");
const btn = getByLabelText("Analyze");
await fireEvent.click(btn);
expect(custom).toHaveBeenCalledTimes(1);
expect(custom).toHaveBeenCalledWith({ id: 5 });
});
test("buttons not rendered when value is null (empty state)", async () => {
const { queryByLabelText } = await render(AnnotatedImage, {
...default_props,
value: null,
buttons: ["fullscreen"]
});
expect(queryByLabelText("Fullscreen")).toBeNull();
});
});
describe("Events: change", () => {
afterEach(() => cleanup());
test("setting value triggers change event", async () => {
const { listen, set_data } = await render(AnnotatedImage, {
...default_props,
value: null
});
const change = listen("change");
await set_data({ value: fake_value });
expect(change).toHaveBeenCalledTimes(1);
});
test("change event is not triggered on mount with null value", async () => {
const { listen } = await render(AnnotatedImage, {
...default_props,
value: null
});
const change = listen("change", { retrospective: true });
expect(change).not.toHaveBeenCalled();
});
test("change event does not fire once on mount when initial value is set", async () => {
// The component's $effect fires change when old_value != value on mount
const { listen } = await render(AnnotatedImage, {
...default_props,
value: fake_value
});
const change = listen("change", { retrospective: true });
expect(change).not.toHaveBeenCalled();
});
test("changing value multiple times triggers change each time", async () => {
const { listen, set_data } = await render(AnnotatedImage, {
...default_props,
value: null
});
const change = listen("change");
await set_data({ value: fake_value });
expect(change).toHaveBeenCalledTimes(1);
await set_data({ value: single_annotation_value });
expect(change).toHaveBeenCalledTimes(2);
});
test("setting value to null after a value triggers change", async () => {
const { listen, set_data } = await render(AnnotatedImage, {
...default_props,
value: fake_value
});
const change = listen("change");
await set_data({ value: null });
expect(change).toHaveBeenCalledTimes(1);
});
});
describe("Events: select", () => {
afterEach(() => cleanup());
test("clicking legend item dispatches select with value and index", async () => {
const { listen, getByRole } = await render(AnnotatedImage, {
...default_props,
value: fake_value
});
const select = listen("select");
await fireEvent.click(getByRole("button", { name: "cat" }));
expect(select).toHaveBeenCalledTimes(1);
expect(select).toHaveBeenCalledWith({ value: "cat", index: 0 });
});
test("clicking second legend item dispatches correct index", async () => {
const { listen, getByRole } = await render(AnnotatedImage, {
...default_props,
value: fake_value
});
const select = listen("select");
await fireEvent.click(getByRole("button", { name: "dog" }));
expect(select).toHaveBeenCalledTimes(1);
expect(select).toHaveBeenCalledWith({ value: "dog", index: 1 });
});
test("clicking multiple legend items dispatches select for each", async () => {
const { listen, getByRole } = await render(AnnotatedImage, {
...default_props,
value: fake_value
});
const select = listen("select");
await fireEvent.click(getByRole("button", { name: "cat" }));
await fireEvent.click(getByRole("button", { name: "dog" }));
expect(select).toHaveBeenCalledTimes(2);
expect(select).toHaveBeenNthCalledWith(1, { value: "cat", index: 0 });
expect(select).toHaveBeenNthCalledWith(2, { value: "dog", index: 1 });
});
});
describe("Legend hover interactions", () => {
afterEach(() => cleanup());
test("hovering over legend item sets active class on corresponding mask", async () => {
const { container, getByRole } = await render(AnnotatedImage, {
...default_props,
value: fake_value
});
await fireEvent.mouseOver(getByRole("button", { name: "cat" }));
const masks = container.querySelectorAll("img.mask");
expect(masks[0].classList.contains("active")).toBe(true);
});
test("hovering over legend item sets inactive class on other masks", async () => {
const { container, getByRole } = await render(AnnotatedImage, {
...default_props,
value: fake_value
});
await fireEvent.mouseOver(getByRole("button", { name: "cat" }));
const masks = container.querySelectorAll("img.mask");
expect(masks[0].classList.contains("active")).toBe(true);
expect(masks[1].classList.contains("inactive")).toBe(true);
});
test("mouseout resets all masks to default state", async () => {
const { container, getByRole } = await render(AnnotatedImage, {
...default_props,
value: fake_value
});
await fireEvent.mouseOver(getByRole("button", { name: "cat" }));
await fireEvent.mouseOut(getByRole("button", { name: "cat" }));
const masks = container.querySelectorAll("img.mask");
expect(masks[0].classList.contains("active")).toBe(false);
expect(masks[0].classList.contains("inactive")).toBe(false);
expect(masks[1].classList.contains("active")).toBe(false);
expect(masks[1].classList.contains("inactive")).toBe(false);
});
test("focus on legend item activates mask (keyboard accessibility)", async () => {
const { container, getByRole } = await render(AnnotatedImage, {
...default_props,
value: fake_value
});
await fireEvent.focus(getByRole("button", { name: "dog" }));
const masks = container.querySelectorAll("img.mask");
expect(masks[1].classList.contains("active")).toBe(true);
expect(masks[0].classList.contains("inactive")).toBe(true);
});
test("blur on legend item resets masks (keyboard accessibility)", async () => {
const { container, getByRole } = await render(AnnotatedImage, {
...default_props,
value: fake_value
});
await fireEvent.focus(getByRole("button", { name: "dog" }));
await fireEvent.blur(getByRole("button", { name: "dog" }));
const masks = container.querySelectorAll("img.mask");
expect(masks[0].classList.contains("active")).toBe(false);
expect(masks[0].classList.contains("inactive")).toBe(false);
expect(masks[1].classList.contains("active")).toBe(false);
expect(masks[1].classList.contains("inactive")).toBe(false);
});
});
describe("get_data / set_data", () => {
afterEach(() => cleanup());
test("get_data returns null when no value", async () => {
const { get_data } = await render(AnnotatedImage, {
...default_props,
value: null
});
const data = await get_data();
expect(data.value).toBeNull();
});
test("set_data updates the displayed image", async () => {
const { set_data, getByAltText } = await render(AnnotatedImage, {
...default_props,
value: null
});
await set_data({ value: fake_value });
const img = getByAltText("the base file that is annotated");
expect(img).toBeTruthy();
expect(img.getAttribute("src")).toBe("https://example.com/test.png");
});
test("get_data reflects set_data value (round-trip)", async () => {
const { get_data, set_data } = await render(AnnotatedImage, {
...default_props,
value: null
});
await set_data({ value: fake_value });
const data = await get_data();
expect(data.value).toEqual(fake_value);
});
test("set_data to null shows empty state", async () => {
const { set_data, queryByAltText } = await render(AnnotatedImage, {
...default_props,
value: fake_value
});
await set_data({ value: null });
expect(queryByAltText("the base file that is annotated")).toBeNull();
});
test("set_data updates legend items", async () => {
const { set_data, getByRole, queryByRole } = await render(AnnotatedImage, {
...default_props,
value: null
});
await set_data({ value: fake_value });
expect(getByRole("button", { name: "cat" })).toBeTruthy();
expect(getByRole("button", { name: "dog" })).toBeTruthy();
await set_data({ value: single_annotation_value });
expect(getByRole("button", { name: "cat" })).toBeTruthy();
expect(queryByRole("button", { name: "dog" })).toBeNull();
});
});
describe("Edge cases", () => {
afterEach(() => cleanup());
test("single annotation renders without errors in hue calculation", async () => {
const { container } = await render(AnnotatedImage, {
...default_props,
value: single_annotation_value,
color_map: {}
});
const masks = container.querySelectorAll("img.mask");
expect(masks.length).toBe(1);
// With 1 annotation: hue-rotate(0 * 360 / 1) = hue-rotate(0deg)
expect(masks[0].getAttribute("style")).toContain("hue-rotate(0deg)");
});
test("value with empty annotations array renders base image but no masks or legend", async () => {
const { container, getByAltText, queryByRole } = await render(
AnnotatedImage,
{
...default_props,
value: {
image: fake_image,
annotations: []
}
}
);
expect(getByAltText("the base file that is annotated")).toBeTruthy();
const masks = container.querySelectorAll("img.mask");
expect(masks.length).toBe(0);
// No legend buttons since there are no annotations
expect(queryByRole("button", { name: "cat" })).toBeNull();
});
test("color_map with only some labels falls back to hue-rotate for unmapped labels", async () => {
const { container } = await render(AnnotatedImage, {
...default_props,
value: fake_value,
color_map: { cat: "#ff0000" } // only cat is mapped, dog is not
});
const masks = container.querySelectorAll("img.mask");
// cat mask: has color_map entry, no hue-rotate
const catStyle = masks[0].getAttribute("style");
if (catStyle) {
expect(catStyle).not.toContain("hue-rotate");
}
// dog mask: no color_map entry, should have hue-rotate
expect(masks[1].getAttribute("style")).toContain("hue-rotate");
});
});
test.todo(
"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"
);
test.todo(
"VISUAL: height/width props control component dimensions — needs Playwright visual regression screenshot comparison"
);
test.todo(
"VISUAL: default hue-rotate coloring distributes colors evenly across the color wheel — needs Playwright visual regression screenshot comparison"
);