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

739 lines
19 KiB
TypeScript

import {
test,
describe,
afterEach,
beforeAll,
afterAll,
expect,
vi
} from "vitest";
import {
cleanup,
render,
waitFor,
mock_client,
upload_file,
drop_file,
download_file,
TEST_GLTF,
TEST_PLY,
TEST_SPLAT
} from "@self/tootils/render";
import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
import event from "@testing-library/user-event";
import Model3D from "./Index.svelte";
// BabylonJS and gsplat perform async initialization (canvas setup, model loading)
// that can complete after component cleanup, causing unhandled rejections.
// These are harmless race conditions in the 3D rendering libraries, not test bugs.
function suppress_3d_library_errors(e: PromiseRejectionEvent): void {
const msg = e.reason?.message ?? "";
if (
msg.includes("addEventListener") ||
msg.includes("Viewer is disposed") ||
msg.includes("Invalid URL") ||
msg.includes("Unsupported property type") ||
msg.includes("postProcessManager")
) {
e.preventDefault();
}
}
beforeAll(() => {
window.addEventListener("unhandledrejection", suppress_3d_library_errors);
});
afterAll(() => {
window.removeEventListener("unhandledrejection", suppress_3d_library_errors);
});
const base_props = {
label: "3D Model",
show_label: true,
interactive: false,
value: null as any,
display_mode: "solid" as "solid" | "point_cloud" | "wireframe",
clear_color: [0, 0, 0, 0] as [number, number, number, number],
zoom_speed: 1,
camera_position: [null, null, null] as [
number | null,
number | null,
number | null
],
has_change_history: false,
buttons: null as any,
height: undefined
};
const interactive_props = {
...base_props,
interactive: true,
client: mock_client()
};
// Model3D uses BlockLabel (floating label) instead of the standard block-info label,
// so the shared label tests don't apply — label behaviour is tested manually below.
run_shared_prop_tests({
component: Model3D,
name: "Model3D",
base_props,
has_label: false
});
describe("Model3D", () => {
afterEach(() => cleanup());
test("renders empty placeholder when value is null (static)", async () => {
const { queryByTestId } = await render(Model3D, {
...base_props,
value: null
});
expect(queryByTestId("model3d")).not.toBeInTheDocument();
});
test("renders viewer when value is set (static)", async () => {
const { getByTestId } = await render(Model3D, {
...base_props,
value: TEST_GLTF
});
await waitFor(() => {
expect(getByTestId("model3d")).toBeInTheDocument();
});
});
test("default label is '3D Model' when label is empty", async () => {
const { getByText } = await render(Model3D, {
...base_props,
label: "",
show_label: true
});
expect(getByText("3D Model")).toBeInTheDocument();
});
test("custom label is rendered", async () => {
const { getByText } = await render(Model3D, {
...base_props,
label: "My Fancy Model",
show_label: true
});
expect(getByText("My Fancy Model")).toBeVisible();
});
test("show_label: false hides the label visually", async () => {
const { getByText } = await render(Model3D, {
...base_props,
label: "Hidden Label",
show_label: false
});
expect(getByText("Hidden Label")).not.toBeVisible();
});
});
describe("Static mode", () => {
afterEach(() => cleanup());
test("download produces file with orig_name as filename", async () => {
await render(Model3D, {
...base_props,
value: TEST_GLTF
});
const { suggested_filename } = await download_file(
"[data-testid='model3d-download-link']"
);
expect(suggested_filename).toBe(TEST_GLTF.orig_name);
});
test("download falls back to path when orig_name is missing", async () => {
const value_no_orig = {
path: "mymodel.gltf",
url: "/test/test_files/Box.gltf",
orig_name: undefined,
size: 100,
mime_type: "model/gltf+json"
};
await render(Model3D, {
...base_props,
value: value_no_orig as any
});
const { suggested_filename } = await download_file(
"[data-testid='model3d-download-link']"
);
expect(suggested_filename).toBe("mymodel.gltf");
});
test("undo button is disabled when has_change_history is false", async () => {
const { getByLabelText } = await render(Model3D, {
...base_props,
value: TEST_GLTF,
has_change_history: false
});
await waitFor(() => {
expect(getByLabelText("Undo")).toBeDisabled();
});
});
test(".ply value skips undo button (Canvas3DGS branch)", async () => {
const { queryByLabelText, getByTestId } = await render(Model3D, {
...base_props,
value: TEST_PLY
});
await waitFor(() => {
expect(getByTestId("model3d")).toBeInTheDocument();
});
expect(queryByLabelText("Undo")).not.toBeInTheDocument();
});
test(".splat value skips undo button (Canvas3DGS branch)", async () => {
const { queryByLabelText, getByTestId } = await render(Model3D, {
...base_props,
value: TEST_SPLAT
});
await waitFor(() => {
expect(getByTestId("model3d")).toBeInTheDocument();
});
expect(queryByLabelText("Undo")).not.toBeInTheDocument();
});
test(".gltf value shows undo button (Canvas3D branch)", async () => {
const { getByLabelText } = await render(Model3D, {
...base_props,
value: TEST_GLTF
});
await waitFor(() => {
expect(getByLabelText("Undo")).toBeInTheDocument();
});
});
});
describe("Interactive mode", () => {
afterEach(() => cleanup());
test("renders upload dropzone when value is null", async () => {
const { getByLabelText } = await render(Model3D, {
...interactive_props,
value: null
});
expect(getByLabelText("model3d.drop_to_upload")).toBeInTheDocument();
});
test("renders viewer with clear button when value is set", async () => {
const { getByLabelText, queryByLabelText } = await render(Model3D, {
...interactive_props,
value: TEST_GLTF
});
expect(getByLabelText("common.clear")).toBeInTheDocument();
expect(queryByLabelText("model3d.drop_to_upload")).not.toBeInTheDocument();
});
test("clicking clear transitions back to upload dropzone", async () => {
const { getByLabelText } = await render(Model3D, {
...interactive_props,
value: TEST_GLTF
});
const clear_btn = await waitFor(() => getByLabelText("common.clear"));
await event.click(clear_btn);
await waitFor(() => {
expect(getByLabelText("model3d.drop_to_upload")).toBeInTheDocument();
});
});
test("undo is available for .gltf files", async () => {
const { getByLabelText } = await render(Model3D, {
...interactive_props,
value: TEST_GLTF
});
expect(getByLabelText("common.undo")).toBeInTheDocument();
});
test("undo is not available for .ply files", async () => {
const { getByLabelText, queryByLabelText } = await render(Model3D, {
...interactive_props,
value: TEST_PLY
});
expect(getByLabelText("common.clear")).toBeInTheDocument();
expect(queryByLabelText("common.undo")).not.toBeInTheDocument();
});
test("undo is not available for .splat files", async () => {
const { getByLabelText, queryByLabelText } = await render(Model3D, {
...interactive_props,
value: TEST_SPLAT
});
expect(getByLabelText("common.clear")).toBeInTheDocument();
expect(queryByLabelText("common.undo")).not.toBeInTheDocument();
});
test("clicking undo does not clear the value", async () => {
const { getByLabelText, get_data } = await render(Model3D, {
...interactive_props,
value: TEST_GLTF
});
await event.click(getByLabelText("common.undo"));
const data = await get_data();
expect(data.value).toEqual(TEST_GLTF);
});
test("set_data to null shows upload dropzone", async () => {
const { set_data, getByLabelText } = await render(Model3D, {
...interactive_props,
value: TEST_GLTF
});
await set_data({ value: null });
await waitFor(() => {
expect(getByLabelText("model3d.drop_to_upload")).toBeInTheDocument();
});
});
test("set_data with value shows viewer and clear button", async () => {
const { set_data, getByLabelText, queryByLabelText } = await render(
Model3D,
{
...interactive_props,
value: null
}
);
await set_data({ value: TEST_GLTF });
await waitFor(() => {
expect(getByLabelText("common.clear")).toBeInTheDocument();
});
expect(queryByLabelText("model3d.drop_to_upload")).not.toBeInTheDocument();
});
});
describe("Events", () => {
afterEach(() => cleanup());
test("mounting with a null value does not fire change", async () => {
const { listen } = await render(Model3D, {
...base_props,
value: null
});
const change = listen("change", { retrospective: true });
expect(change).not.toHaveBeenCalled();
});
test("mounting with an initial value does not fire change", async () => {
const { listen } = await render(Model3D, {
...base_props,
value: TEST_GLTF
});
const change = listen("change", { retrospective: true });
expect(change).not.toHaveBeenCalled();
});
test("change fires when value changes via set_data", async () => {
const { listen, set_data } = await render(Model3D, {
...base_props,
value: null
});
const change = listen("change");
await set_data({ value: TEST_GLTF });
expect(change).toHaveBeenCalledTimes(1);
});
test("change deduplicates when set_data called with same value", async () => {
const { listen, set_data } = await render(Model3D, {
...base_props,
value: TEST_GLTF
});
const change = listen("change");
await set_data({ value: TEST_GLTF });
await set_data({ value: TEST_GLTF });
expect(change).not.toHaveBeenCalled();
});
test("change fires when set_data clears the value", async () => {
const { listen, set_data } = await render(Model3D, {
...base_props,
value: TEST_GLTF
});
const change = listen("change");
await set_data({ value: null });
expect(change).toHaveBeenCalledTimes(1);
});
test("clear event fires when clear button clicked (interactive)", async () => {
const { getByLabelText, listen } = await render(Model3D, {
...interactive_props,
value: TEST_GLTF
});
const clear = listen("clear");
const change = listen("change");
const clear_btn = await waitFor(() => getByLabelText("common.clear"));
await event.click(clear_btn);
expect(clear).toHaveBeenCalledTimes(1);
expect(change).toHaveBeenCalled();
});
test("custom_button_click fires with id when toolbar button clicked", async () => {
const { getByLabelText, listen } = await render(Model3D, {
...base_props,
value: null,
show_label: true,
buttons: [{ value: "Action", id: 42, icon: null }] as any
});
const custom_click = listen("custom_button_click");
await event.click(getByLabelText("Action"));
expect(custom_click).toHaveBeenCalledTimes(1);
expect(custom_click).toHaveBeenCalledWith({ id: 42 });
});
test("change fires when switching file types via set_data", async () => {
const { listen, set_data } = await render(Model3D, {
...base_props,
value: TEST_GLTF
});
const change = listen("change");
await set_data({ value: TEST_PLY });
expect(change).toHaveBeenCalledTimes(1);
});
test("upload event fires when file is uploaded", async () => {
const { listen } = await render(Model3D, {
...interactive_props,
value: null
});
const upload = listen("upload");
const change = listen("change");
await upload_file(TEST_GLTF);
await waitFor(() => {
expect(upload).toHaveBeenCalledTimes(1);
});
expect(change).toHaveBeenCalled();
});
test("drag and drop triggers upload and change events", async () => {
const { listen } = await render(Model3D, {
...interactive_props,
value: null
});
const upload = listen("upload");
const change = listen("change");
await drop_file(TEST_GLTF, "[aria-label='model3d.drop_to_upload']");
await waitFor(() => {
expect(upload).toHaveBeenCalledTimes(1);
});
expect(change).toHaveBeenCalled();
});
test("upload failure dispatches error event", async () => {
const failing_upload = vi
.fn()
.mockRejectedValue(new Error("File too large"));
const { listen } = await render(Model3D, {
...interactive_props,
client: {
upload: failing_upload,
stream: async () => ({ onmessage: null, close: () => {} })
}
});
const error = listen("error");
await upload_file(TEST_GLTF);
await waitFor(() => {
expect(failing_upload).toHaveBeenCalled();
});
await waitFor(() => {
expect(error).toHaveBeenCalledTimes(1);
});
expect(error).toHaveBeenCalledWith("File too large");
});
});
describe("Props: buttons", () => {
afterEach(() => cleanup());
test("renders buttons when show_label is true and buttons is non-empty", async () => {
const { getByLabelText } = await render(Model3D, {
...base_props,
value: null,
show_label: true,
buttons: [{ value: "MyBtn", id: 1, icon: null }] as any
});
expect(getByLabelText("MyBtn")).toBeInTheDocument();
});
test("does not render buttons when show_label is false", async () => {
const { queryByLabelText } = await render(Model3D, {
...base_props,
value: null,
show_label: false,
buttons: [{ value: "HiddenBtn", id: 1, icon: null }] as any
});
expect(queryByLabelText("HiddenBtn")).not.toBeInTheDocument();
});
test("does not render custom button when buttons is null", async () => {
const { queryByLabelText } = await render(Model3D, {
...base_props,
value: null,
show_label: true,
buttons: null
});
expect(queryByLabelText("Custom action")).not.toBeInTheDocument();
});
test("does not render custom button when buttons is empty array", async () => {
const { queryByLabelText } = await render(Model3D, {
...base_props,
value: null,
show_label: true,
buttons: []
});
expect(queryByLabelText("Custom action")).not.toBeInTheDocument();
});
test("renders multiple custom buttons", async () => {
const { getByLabelText } = await render(Model3D, {
...base_props,
value: null,
show_label: true,
buttons: [
{ value: "First", id: 1, icon: null },
{ value: "Second", id: 2, icon: null }
] as any
});
expect(getByLabelText("First")).toBeInTheDocument();
expect(getByLabelText("Second")).toBeInTheDocument();
});
});
describe("get_data / set_data", () => {
afterEach(() => cleanup());
test("get_data returns null when value is null", async () => {
const { get_data } = await render(Model3D, {
...base_props,
value: null
});
const data = await get_data();
expect(data.value).toBeNull();
});
test("get_data returns FileData when value is set", async () => {
const { get_data } = await render(Model3D, {
...base_props,
value: TEST_GLTF
});
const data = await get_data();
expect(data.value).toEqual(TEST_GLTF);
});
test("set_data populates the viewer", async () => {
const { set_data, getByTestId } = await render(Model3D, {
...base_props,
value: null
});
await set_data({ value: TEST_GLTF });
await waitFor(() => {
expect(getByTestId("model3d")).toBeInTheDocument();
});
});
test("set_data to null removes the viewer (static)", async () => {
const { set_data, queryByTestId } = await render(Model3D, {
...base_props,
value: TEST_GLTF
});
await set_data({ value: null });
await waitFor(() => {
expect(queryByTestId("model3d")).not.toBeInTheDocument();
});
});
test("round-trip: set_data then get_data returns same value", async () => {
const { set_data, get_data } = await render(Model3D, {
...base_props,
value: null
});
await set_data({ value: TEST_GLTF });
const data = await get_data();
expect(data.value).toEqual(TEST_GLTF);
});
test("user clear reflected in get_data (interactive)", async () => {
const { getByLabelText, get_data } = await render(Model3D, {
...interactive_props,
value: TEST_GLTF
});
const clear_btn = await waitFor(() => getByLabelText("common.clear"));
await event.click(clear_btn);
const data = await get_data();
expect(data.value).toBeNull();
});
test("set_data switches file type and get_data reflects it", async () => {
const { set_data, get_data } = await render(Model3D, {
...base_props,
value: TEST_GLTF
});
await set_data({ value: TEST_PLY });
const data = await get_data();
expect(data.value).toEqual(TEST_PLY);
});
test("set_data in interactive mode reflects in get_data", async () => {
const { set_data, get_data } = await render(Model3D, {
...interactive_props,
value: null
});
await set_data({ value: TEST_GLTF });
const data = await get_data();
expect(data.value).toEqual(TEST_GLTF);
});
});
describe("Edge cases", () => {
afterEach(() => cleanup());
test("value transition null → set → null renders correctly", async () => {
const { set_data, getByTestId, queryByTestId } = await render(Model3D, {
...base_props,
value: null
});
await set_data({ value: TEST_GLTF });
await waitFor(() => {
expect(getByTestId("model3d")).toBeInTheDocument();
});
await set_data({ value: null });
await waitFor(() => {
expect(queryByTestId("model3d")).not.toBeInTheDocument();
});
});
test("switching from gltf to ply removes undo button", async () => {
const { set_data, getByTestId, getByLabelText, queryByLabelText } =
await render(Model3D, {
...base_props,
value: TEST_GLTF
});
await waitFor(() => {
expect(getByLabelText("Undo")).toBeInTheDocument();
});
await set_data({ value: TEST_PLY });
await waitFor(() => {
expect(getByTestId("model3d")).toBeInTheDocument();
});
expect(queryByLabelText("Undo")).not.toBeInTheDocument();
});
test("switching from ply to gltf restores undo button", async () => {
const { set_data, getByTestId, getByLabelText, queryByLabelText } =
await render(Model3D, {
...base_props,
value: TEST_PLY
});
await waitFor(() => {
expect(getByTestId("model3d")).toBeInTheDocument();
});
expect(queryByLabelText("Undo")).not.toBeInTheDocument();
await set_data({ value: TEST_GLTF });
await waitFor(() => {
expect(getByLabelText("Undo")).toBeInTheDocument();
});
});
});
test.todo(
"VISUAL: display_mode='solid' renders a solid-shaded mesh — needs Playwright visual regression screenshot comparison"
);
test.todo(
"VISUAL: display_mode='point_cloud' renders the mesh as points — needs Playwright visual regression screenshot comparison"
);
test.todo(
"VISUAL: display_mode='wireframe' renders the mesh as wireframe — needs Playwright visual regression screenshot comparison"
);
test.todo(
"VISUAL: clear_color sets the scene background colour — needs Playwright visual regression screenshot comparison"
);
test.todo(
"VISUAL: zoom_speed affects wheel zoom sensitivity — needs Playwright interaction + screenshot comparison"
);
test.todo(
"VISUAL: camera_position sets initial alpha/beta/radius of the camera — needs Playwright visual regression screenshot comparison"
);
test.todo(
"VISUAL: height prop resizes the Block containing the viewer — needs Playwright visual regression screenshot comparison"
);
test.todo(
"VISUAL: pan_speed affects drag pan sensitivity — needs Playwright interaction + screenshot comparison"
);