Files
ttstd-3d-director/src/editor/canvas/ViewportToolbar.test.tsx
2026-09-18 00:52:28 +08:00

823 lines
34 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, vi } from "vitest";
import { clearViewportCaptureHandler, setViewportCaptureHandler } from "../io/captureBridge";
import { BODY_TYPE_OPTIONS } from "../runtime/mannequin/bodyTypes";
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { getCameraRigPositionFromViewSnapshot, getCameraViewSnapshotFromShot } from "../schema/cameraGeometry";
import { ViewportToolbar } from "./ViewportToolbar";
import { getModelLibraryItems } from "../modelLibrary/modelLibraryCatalog";
// 模型库是仓库外的外部资产(../模型库),不随仓库分发。
// 目录缺失时 catalog 解析为空数组,依赖具体模型的用例应跳过而不是失败。
const hasModelLibrary = getModelLibraryItems().length > 0;
const itWithModelLibrary = it.skipIf(!hasModelLibrary);
const mockReadLocalModelFile = vi.fn();
vi.mock("../loaders/localModelImport", () => ({
readLocalModelFile: (...args: unknown[]) => mockReadLocalModelFile(...args),
}));
function createMemoryStorage(): Storage {
const storage = new Map<string, string>();
return {
get length() {
return storage.size;
},
clear: () => storage.clear(),
getItem: (key) => storage.get(key) ?? null,
key: (index) => Array.from(storage.keys())[index] ?? null,
removeItem: (key) => {
storage.delete(key);
},
setItem: (key, value) => {
storage.set(key, String(value));
},
};
}
beforeEach(() => {
vi.stubGlobal("localStorage", createMemoryStorage());
useDirectorStore.setState({
...useDirectorStore.getState(),
...createInitialDirectorState(),
});
mockReadLocalModelFile.mockReset();
});
afterEach(() => {
clearViewportCaptureHandler();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("renders the viewport capsule as project icon-system buttons", () => {
render(<ViewportToolbar />);
const toolbar = screen.getByRole("group", { name: "3D视口快捷工具" });
const expectedActions = [
"移动",
"旋转",
"缩放",
"添加角色",
"导入全景图",
"导入本地模型",
"模型库",
"添加机位",
"选择画幅比例",
"当前视角截图",
"四方位截图",
"十二方位截图",
"全屏",
"添加轨迹",
"录制",
];
expectedActions.forEach((label) => {
const button = within(toolbar).getByRole("button", { name: label });
expect(button.querySelector("svg")).toBeInTheDocument();
expect(button).toHaveClass("viewport-toolbar-button");
});
expect(toolbar).toHaveClass("viewport-toolbar");
const toolbarButtonLabels = Array.from(toolbar.querySelectorAll("button[aria-label]")).map((button) =>
button.getAttribute("aria-label")
);
expect(toolbarButtonLabels.indexOf("模型库")).toBe(toolbarButtonLabels.indexOf("导入本地模型") + 1);
// 打点 / 录制固定在工具条最右侧
expect(toolbarButtonLabels.slice(-2)).toEqual(["添加轨迹", "录制"]);
});
it("renders custom hover labels instead of native title tooltips", () => {
render(<ViewportToolbar />);
const toolbar = screen.getByRole("group", { name: "3D视口快捷工具" });
const button = within(toolbar).getByRole("button", { name: "导入本地模型" });
const label = within(button).getByText("导入本地模型");
expect(button).not.toHaveAttribute("title");
expect(label).toHaveClass("viewport-toolbar-label");
});
it("uses the requested viewport toolbar SVG icons for camera and capture actions", () => {
render(<ViewportToolbar />);
expect(screen.getByRole("button", { name: "添加机位" }).querySelector("svg")).toHaveClass("lucide-video");
expect(screen.getByRole("button", { name: "当前视角截图" }).querySelector("svg")).toHaveClass("lucide-camera");
expect(screen.getByRole("button", { name: "四方位截图" }).querySelector("svg")).toHaveClass("lucide-grid2x2");
expect(screen.getByRole("button", { name: "十二方位截图" }).querySelector("svg")).toHaveClass("lucide-grid3x3");
});
it("uses the fullscreen button to collapse the side panels instead of entering browser fullscreen", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
expect((useDirectorStore.getState() as { viewportPanelsCollapsed?: boolean }).viewportPanelsCollapsed ?? false).toBe(
false
);
await user.click(screen.getByRole("button", { name: "全屏" }));
expect((useDirectorStore.getState() as { viewportPanelsCollapsed?: boolean }).viewportPanelsCollapsed ?? false).toBe(
true
);
});
it("stores viewport capsule screenshots on the active camera without creating a new one from director view", async () => {
const user = userEvent.setup();
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
const snapshot = {
fov: 64,
position: [3, 2, 1] as [number, number, number],
target: [0, 1, -2] as [number, number, number],
};
const handler = vi.fn(async () => [
{
label: "当前机位",
dataUrl: "data:image/png;base64,current-camera",
meta: {
mode: "camera" as const,
cameraId: "cam_1",
fov: 64,
position: [3, 2, 1] as [number, number, number],
target: [0, 1, -2] as [number, number, number],
},
},
]);
setViewportCaptureHandler(handler);
render(<ViewportToolbar getViewportCameraSnapshot={() => snapshot} />);
await user.click(screen.getByRole("button", { name: "当前视角截图" }));
await waitFor(() => {
expect(handler).toHaveBeenCalledWith({ preset: "current", source: "camera-panel", cameraId: "cam_1" });
});
const state = useDirectorStore.getState();
const activeCamera = state.project.cameras[0];
expect(anchorClick).not.toHaveBeenCalled();
expect(state.viewMode).toBe("camera");
expect(state.project.cameras).toHaveLength(1);
expect(state.project.activeCameraId).toBe("cam_1");
expect(activeCamera?.captures).toEqual([
{
id: "cam_1-capture-01",
index: 1,
name: "机位01-截图01",
dataUrl: "data:image/png;base64,current-camera",
},
]);
expect(activeCamera?.lastCaptureUrl).toBe("data:image/png;base64,current-camera");
});
it("stores viewport capsule screenshots in the current camera while already in camera view", async () => {
const user = userEvent.setup();
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
const secondCameraSnapshot = {
fov: 58,
position: [1, 2, 6] as [number, number, number],
target: [0, 1, 0] as [number, number, number],
};
const handler = vi.fn(async ({ preset }: { preset: "current" | "four" | "twelve" }) =>
Array.from({ length: preset === "four" ? 4 : 1 }, (_, index) => ({
label: `机位截图-${index + 1}`,
dataUrl: `data:image/png;base64,camera-view-${index + 1}`,
meta: {
mode: "camera" as const,
cameraId: "cam_2",
fov: 58,
position: [1, 2, 6] as [number, number, number],
target: [0, 1, 0] as [number, number, number],
},
}))
);
useDirectorStore.getState().addCameraShot(secondCameraSnapshot);
useDirectorStore.getState().setViewMode("camera");
setViewportCaptureHandler(handler);
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "四方位截图" }));
await waitFor(() => {
expect(handler).toHaveBeenCalledWith({ preset: "four", source: "camera-panel", cameraId: "cam_2" });
});
const state = useDirectorStore.getState();
const originalCamera = state.project.cameras[0];
const activeCamera = state.project.cameras[1];
expect(anchorClick).not.toHaveBeenCalled();
expect(state.project.cameras).toHaveLength(2);
expect(state.viewMode).toBe("camera");
expect(state.project.activeCameraId).toBe("cam_2");
expect(originalCamera?.captures).toEqual([]);
expect(activeCamera?.captures).toHaveLength(4);
expect(activeCamera?.captures?.map((item) => item.name)).toEqual([
"机位02-截图01",
"机位02-截图02",
"机位02-截图03",
"机位02-截图04",
]);
expect(activeCamera?.lastCaptureUrl).toBe("data:image/png;base64,camera-view-4");
});
it("disables the add-trajectory action until a character is selected", () => {
render(<ViewportToolbar />);
const button = screen.getByRole("button", { name: "添加轨迹" });
expect(button).toBeDisabled();
expect(button).toHaveClass("viewport-toolbar-button");
expect(button.querySelector("svg")).toBeInTheDocument();
});
it("keeps the add-trajectory action disabled for non-character objects", () => {
useDirectorStore.getState().selectObject("cam_object_1");
render(<ViewportToolbar />);
expect(screen.getByRole("button", { name: "添加轨迹" })).toBeDisabled();
});
it("enters and leaves the canvas placement mode from the add-trajectory action", async () => {
const user = userEvent.setup();
useDirectorStore.getState().selectObject("char_default_a");
render(<ViewportToolbar />);
const button = screen.getByRole("button", { name: "添加轨迹" });
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute("aria-pressed", "false");
await user.click(button);
expect(useDirectorStore.getState().trajectoryPlacementObjectId).toBe("char_default_a");
expect(button).toHaveAttribute("aria-pressed", "true");
await user.click(button);
expect(useDirectorStore.getState().trajectoryPlacementObjectId).toBeNull();
expect(button).toHaveAttribute("aria-pressed", "false");
});
it("stops placing trajectory points when Escape is pressed", async () => {
const user = userEvent.setup();
useDirectorStore.getState().selectObject("char_default_a");
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "添加轨迹" }));
expect(useDirectorStore.getState().trajectoryPlacementObjectId).toBe("char_default_a");
await user.keyboard("{Escape}");
expect(useDirectorStore.getState().trajectoryPlacementObjectId).toBeNull();
});
it("hides and shows the floating timeline from the record action", async () => {
const user = userEvent.setup();
useDirectorStore.getState().setTimelineVisible(true);
render(<ViewportToolbar />);
const recordButton = screen.getByRole("button", { name: "录制" });
expect(recordButton).toHaveAttribute("aria-pressed", "true");
expect(recordButton).toHaveClass("is-recording");
await user.click(recordButton);
expect(useDirectorStore.getState().timelineVisible).toBe(false);
expect(recordButton).toHaveAttribute("aria-pressed", "false");
expect(recordButton).not.toHaveClass("is-recording");
await user.click(screen.getByRole("button", { name: "录制" }));
expect(useDirectorStore.getState().timelineVisible).toBe(true);
expect(screen.getByRole("button", { name: "录制" })).toHaveAttribute("aria-pressed", "true");
});
it("switches the active transform control mode from the viewport capsule", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
expect(screen.getByRole("button", { name: "移动" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "移动" })).toHaveClass("is-active");
expect(screen.getByRole("button", { name: "旋转" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "缩放" })).toHaveAttribute("aria-pressed", "false");
await user.click(screen.getByRole("button", { name: "旋转" }));
expect(useDirectorStore.getState().transformMode).toBe("rotate");
expect(screen.getByRole("button", { name: "移动" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "旋转" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "旋转" })).toHaveClass("is-active");
await user.click(screen.getByRole("button", { name: "缩放" }));
expect(useDirectorStore.getState().transformMode).toBe("scale");
expect(screen.getByRole("button", { name: "旋转" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "缩放" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "缩放" })).toHaveClass("is-active");
await user.click(screen.getByRole("button", { name: "移动" }));
expect(useDirectorStore.getState().transformMode).toBe("translate");
expect(screen.getByRole("button", { name: "移动" })).toHaveAttribute("aria-pressed", "true");
});
it("keeps add role and add camera actions available from the viewport capsule", async () => {
const user = userEvent.setup();
const snapshot = { fov: 64, position: [3, 2, 1] as [number, number, number], target: [0, 1, -2] as [number, number, number] };
render(<ViewportToolbar getViewportCameraSnapshot={() => snapshot} />);
await user.click(screen.getByRole("button", { name: "添加角色" }));
await user.click(screen.getByRole("menuitem", { name: "男性素体" }));
await user.click(screen.getByRole("button", { name: "添加机位" }));
const state = useDirectorStore.getState();
const characterCount = state.project.objects.filter((item) => item.kind === "character").length;
const cameraCount = state.project.cameras.length;
expect(characterCount).toBe(2);
expect(cameraCount).toBe(2);
expect(state.selectedObjectId).toBe("cam_object_2");
expect(state.project.cameras[1].fov).toBe(64);
expect(state.project.cameras[1].transform.position).toEqual(getCameraRigPositionFromViewSnapshot(snapshot));
expect(getCameraViewSnapshotFromShot(state.project.cameras[1]).position).toEqual(snapshot.position);
expect(state.project.cameras[1].target).toEqual([0, 1, -2]);
});
it("does not show operation feedback text on the right side of the viewport capsule", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "旋转" }));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.queryByText("已切换到旋转工具")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "添加角色" }));
await user.click(screen.getByRole("menuitem", { name: "男性素体" }));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.queryByText("已添加男性素体")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "选择画幅比例" }));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.queryByText("画幅比例入口已就绪")).not.toBeInTheDocument();
});
it("adds a selected procedural body type from the add-character menu", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "添加角色" }));
BODY_TYPE_OPTIONS.forEach((option) => {
expect(screen.getByRole("menuitem", { name: option.label })).toBeInTheDocument();
});
await user.click(screen.getByRole("menuitem", { name: "宽厚素体" }));
const characters = useDirectorStore.getState().project.objects.filter((item) => item.kind === "character");
const added = characters[characters.length - 1];
expect(added?.bodyType).toBe("broad");
expect(useDirectorStore.getState().selectedObjectId).toBe(added?.id);
});
it("adds geometry primitives from the add-character submenu", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "添加角色" }));
const geometryMenuItem = screen.getByRole("menuitem", { name: "几何模型" });
expect(geometryMenuItem.querySelector("svg")).toBeInTheDocument();
await user.hover(geometryMenuItem);
["立方体", "球体", "圆柱体", "环状体", "圆锥", "棱锥"].forEach((label) => {
expect(screen.getByRole("menuitem", { name: label })).toBeInTheDocument();
});
await user.click(screen.getByRole("menuitem", { name: "立方体" }));
const prop = useDirectorStore.getState().project.objects.find((item) => item.kind === "prop");
expect(prop?.name).toBe("立方体");
expect(prop?.geometryType).toBe("box");
expect(prop?.color).toBe("#d7e7ff");
expect(useDirectorStore.getState().selectedObjectId).toBe(prop?.id);
});
it("opens a crowd panel from the add-character menu hover row and adds a 3x3 character array", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "添加角色" }));
const crowdMenuItem = screen.getByRole("menuitem", { name: "群众 (3x3)" });
expect(crowdMenuItem.querySelector(".lucide-users")).not.toBeInTheDocument();
expect(crowdMenuItem.querySelector(".lucide-chevron-right")).toBeInTheDocument();
fireEvent.click(crowdMenuItem);
expect(screen.queryByRole("dialog", { name: "添加群众阵列" })).not.toBeInTheDocument();
await user.hover(crowdMenuItem);
const crowdDialog = screen.getByRole("dialog", { name: "添加群众阵列" });
expect(crowdDialog).toBeInTheDocument();
expect(within(crowdDialog).getByText("共9人")).toBeInTheDocument();
expect(within(crowdDialog).getByLabelText("群众行数")).toHaveValue(3);
expect(within(crowdDialog).getByLabelText("群众列数")).toHaveValue(3);
expect(within(crowdDialog).getByLabelText("群众间距")).toHaveValue(1.2);
expect(within(crowdDialog).getByRole("button", { name: "取消" })).toHaveClass("camera-capture-clear-all");
expect(within(crowdDialog).getByRole("button", { name: "添加群众" })).toHaveClass("camera-capture-send-all");
await user.click(screen.getByRole("button", { name: "添加群众" }));
await waitFor(() => {
expect(useDirectorStore.getState().project.objects.filter((item) => item.kind === "character")).toHaveLength(10);
});
const state = useDirectorStore.getState();
const crowdCharacters = state.project.objects.filter((item) => item.kind === "character" && item.id !== "char_default_a");
expect(crowdCharacters).toHaveLength(9);
expect(screen.queryByRole("dialog", { name: "添加群众阵列" })).not.toBeInTheDocument();
expect(state.selectedObjectIds).toHaveLength(9);
expect(state.selectedObjectId).toBe(crowdCharacters[crowdCharacters.length - 1]?.id ?? null);
});
it("opens the model library panel from the viewport capsule", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "模型库" }));
expect(screen.getByRole("dialog", { name: "模型库" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "便利生活" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "居家生活" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "户外出行" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "工具配件" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "我的模型" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "关闭模型库" })).toBeInTheDocument();
});
itWithModelLibrary("shows Chinese model names and thumbnails for convenience items", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "模型库" }));
expect(screen.getByRole("button", { name: "添加模型 自动取款机" })).toBeInTheDocument();
expect(screen.getByText("自动取款机")).toBeInTheDocument();
expect(screen.queryByText("ATM")).not.toBeInTheDocument();
expect(screen.queryByText("2 Liter")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "添加模型 自动取款机" }).querySelector("img")).toBeInTheDocument();
});
itWithModelLibrary("uses category thumbnail folders for outdoor and tools model library items", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "模型库" }));
await user.click(screen.getByRole("tab", { name: "户外出行" }));
expect(screen.getByRole("button", { name: "添加模型 背包" }).querySelector("img")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "添加模型 保温瓶" }).querySelector("img")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "添加模型 鹿头骨" }).querySelector("img")).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "工具配件" }));
expect(screen.getByRole("button", { name: "添加模型 扳手" }).querySelector("img")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "添加模型 台钻" }).querySelector("img")).toBeInTheDocument();
});
it("renders floating viewport menus and model library outside the frosted toolbar shell", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
const toolbar = screen.getByRole("group", { name: "3D视口快捷工具" });
await user.click(screen.getByRole("button", { name: "添加角色" }));
const characterMenu = screen.getByRole("menu", { name: "选择角色体型" });
expect(toolbar.contains(characterMenu)).toBe(false);
await user.hover(screen.getByRole("menuitem", { name: "几何模型" }));
const geometryMenu = screen.getByRole("menu", { name: "选择几何模型" });
expect(toolbar.contains(geometryMenu)).toBe(false);
await user.click(screen.getByRole("button", { name: "模型库" }));
const modelLibrary = screen.getByRole("dialog", { name: "模型库" });
expect(toolbar.contains(modelLibrary)).toBe(false);
});
it("closes the model library panel from its close button", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "模型库" }));
await user.click(screen.getByRole("button", { name: "关闭模型库" }));
expect(screen.queryByRole("dialog", { name: "模型库" })).not.toBeInTheDocument();
});
itWithModelLibrary("adds a selected model library item into the viewport scene", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "模型库" }));
await user.click(screen.getByRole("button", { name: "添加模型 自动取款机" }));
const state = useDirectorStore.getState();
const asset = state.project.assets.find((item) => item.fileName === "ATM_low.fbx");
const prop = state.project.objects.find((item) => item.name === "自动取款机");
expect(asset?.sourceType).toBe("model");
expect(asset?.kind).toBe("prop");
expect(asset?.url).toContain("ATM_low");
expect(prop?.kind).toBe("prop");
expect(prop?.assetRefId).toBe(asset?.id);
expect(state.selectedObjectId).toBe(prop?.id);
});
it("shows a centered empty state with a local import action inside the my-models tab", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "模型库" }));
await user.click(screen.getByRole("tab", { name: "我的模型" }));
const emptyState = screen.getByRole("status", { name: "暂无任何模型" });
expect(emptyState).toBeInTheDocument();
expect(within(emptyState).getByRole("button", { name: "本地导入" })).toBeInTheDocument();
});
it("imports a local model into the my-models tab without adding it to the scene immediately", async () => {
const user = userEvent.setup();
mockReadLocalModelFile.mockResolvedValue({
id: "local-model-1",
fileName: "chair.obj",
name: "本地椅子",
url: "blob:local-chair",
});
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "模型库" }));
await user.click(screen.getByRole("tab", { name: "我的模型" }));
await user.click(screen.getByRole("button", { name: "本地导入" }));
const fileInput = screen.getByTestId("library-local-model-input") as HTMLInputElement | null;
expect(fileInput).not.toBeNull();
await user.upload(fileInput!, new File(["chair"], "chair.obj", { type: "model/obj" }));
await waitFor(() => {
expect(useDirectorStore.getState().project.assets.some((item) => item.fileName === "chair.obj")).toBe(true);
});
const state = useDirectorStore.getState();
expect(state.project.assets.some((item) => item.fileName === "chair.obj")).toBe(true);
expect(state.project.objects.some((item) => item.name === "本地椅子")).toBe(false);
expect(screen.queryByRole("status", { name: "暂无任何模型" })).not.toBeInTheDocument();
expect(screen.getByText("本地椅子")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "本地导入" })).toBeInTheDocument();
});
it("imports multiple local model files into the my-models tab at once", async () => {
const user = userEvent.setup();
mockReadLocalModelFile.mockImplementation(async (file: File) => ({
id: `local-${file.name}`,
fileName: file.name,
name: file.name.replace(/\.(fbx|obj)$/i, ""),
url: `data:model/plain;base64,${file.name}`,
}));
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "模型库" }));
await user.click(screen.getByRole("tab", { name: "我的模型" }));
await user.click(screen.getByRole("button", { name: "本地导入" }));
const fileInput = screen.getByTestId("library-local-model-input") as HTMLInputElement | null;
expect(fileInput).not.toBeNull();
expect(fileInput).toHaveAttribute("multiple");
await user.upload(fileInput!, [
new File(["chair"], "本地椅子.obj", { type: "model/obj" }),
new File(["table"], "本地桌子.fbx", { type: "model/fbx" }),
]);
await waitFor(() => {
expect(useDirectorStore.getState().project.assets.filter((item) => item.assetSource === "local")).toHaveLength(2);
});
const state = useDirectorStore.getState();
expect(state.project.objects.some((item) => item.name === "本地椅子")).toBe(false);
expect(state.project.objects.some((item) => item.name === "本地桌子")).toBe(false);
expect(screen.getByText("本地椅子")).toBeInTheDocument();
expect(screen.getByText("本地桌子")).toBeInTheDocument();
expect(mockReadLocalModelFile).toHaveBeenCalledTimes(2);
});
it("restores imported my-models assets after browser refresh initialization", async () => {
const user = userEvent.setup();
mockReadLocalModelFile.mockResolvedValue({
id: "local-model-persisted",
fileName: "chair.obj",
name: "本地椅子",
url: "data:model/plain;base64,cGERSISTED",
});
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "模型库" }));
await user.click(screen.getByRole("tab", { name: "我的模型" }));
await user.click(screen.getByRole("button", { name: "本地导入" }));
await user.upload(
screen.getByTestId("library-local-model-input") as HTMLInputElement,
new File(["chair"], "chair.obj", { type: "model/obj" })
);
await waitFor(() => {
expect(useDirectorStore.getState().project.assets.some((item) => item.fileName === "chair.obj")).toBe(true);
});
await act(async () => {
useDirectorStore.setState({
...useDirectorStore.getState(),
...createInitialDirectorState({ includePersistedLocalAssets: true }),
});
});
await waitFor(() => {
expect(screen.getByText("本地椅子")).toBeInTheDocument();
});
const restoredAsset = useDirectorStore.getState().project.assets.find((item) => item.fileName === "chair.obj");
expect(restoredAsset?.assetSource).toBe("local");
expect(restoredAsset?.url).toBe("data:model/plain;base64,cGERSISTED");
expect(useDirectorStore.getState().project.objects.some((item) => item.name === "本地椅子")).toBe(false);
});
it("still imports a local model directly into the scene from the viewport capsule action", async () => {
const user = userEvent.setup();
mockReadLocalModelFile.mockResolvedValue({
id: "local-model-2",
fileName: "lamp.obj",
name: "本地台灯",
url: "blob:local-lamp",
});
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "导入本地模型" }));
const fileInput = screen.getByTestId("scene-local-model-input") as HTMLInputElement | null;
expect(fileInput).not.toBeNull();
await user.upload(fileInput!, new File(["lamp"], "lamp.obj", { type: "model/obj" }));
await waitFor(() => {
expect(useDirectorStore.getState().project.objects.some((item) => item.name === "本地台灯")).toBe(true);
});
const state = useDirectorStore.getState();
expect(state.project.assets.some((item) => item.fileName === "lamp.obj")).toBe(true);
expect(state.project.objects.some((item) => item.name === "本地台灯")).toBe(true);
});
it("shows a delete action on my-models cards and removes the asset plus its scene instances", async () => {
const user = userEvent.setup();
mockReadLocalModelFile.mockResolvedValue({
id: "local-model-3",
fileName: "chair.obj",
name: "本地椅子",
url: "blob:local-chair",
});
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "模型库" }));
await user.click(screen.getByRole("tab", { name: "我的模型" }));
await user.click(screen.getByRole("button", { name: "本地导入" }));
const fileInput = screen.getByTestId("library-local-model-input") as HTMLInputElement | null;
expect(fileInput).not.toBeNull();
await user.upload(fileInput!, new File(["chair"], "chair.obj", { type: "model/obj" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "添加模型 本地椅子" })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "添加模型 本地椅子" }));
await waitFor(() => {
expect(useDirectorStore.getState().project.objects.some((item) => item.name === "本地椅子")).toBe(true);
});
await user.click(screen.getByRole("button", { name: "模型库" }));
await user.click(screen.getByRole("tab", { name: "我的模型" }));
await user.hover(screen.getByRole("button", { name: "添加模型 本地椅子" }));
await user.click(screen.getByRole("button", { name: "删除模型 本地椅子" }));
await waitFor(() => {
expect(useDirectorStore.getState().project.assets.some((item) => item.fileName === "chair.obj")).toBe(false);
});
const state = useDirectorStore.getState();
expect(state.project.assets.some((item) => item.fileName === "chair.obj")).toBe(false);
expect(state.project.objects.some((item) => item.name === "本地椅子")).toBe(false);
expect(screen.getByRole("status", { name: "暂无任何模型" })).toBeInTheDocument();
});
it("opens the geometry submenu only after hover every time the add-character menu is opened", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "添加角色" }));
const geometryMenuItem = screen.getByRole("menuitem", { name: "几何模型" });
fireEvent.click(geometryMenuItem);
expect(screen.queryByRole("menu", { name: "选择几何模型" })).not.toBeInTheDocument();
await user.hover(geometryMenuItem);
expect(screen.getByRole("menu", { name: "选择几何模型" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "添加角色" }));
expect(screen.queryByRole("menu", { name: "选择角色体型" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "添加角色" }));
expect(screen.getByRole("menu", { name: "选择角色体型" })).toBeInTheDocument();
expect(screen.queryByRole("menu", { name: "选择几何模型" })).not.toBeInTheDocument();
await user.hover(screen.getByRole("menuitem", { name: "几何模型" }));
expect(screen.getByRole("menu", { name: "选择几何模型" })).toBeInTheDocument();
});
it("closes the geometry submenu when users hover another character menu item", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "添加角色" }));
await user.hover(screen.getByRole("menuitem", { name: "几何模型" }));
expect(screen.getByRole("menu", { name: "选择几何模型" })).toBeInTheDocument();
await user.hover(screen.getByRole("menuitem", { name: "女性素体" }));
expect(screen.queryByRole("menu", { name: "选择几何模型" })).not.toBeInTheDocument();
expect(screen.getByRole("menu", { name: "选择角色体型" })).toBeInTheDocument();
});
it("closes open viewport toolbar menus when users click outside", async () => {
const user = userEvent.setup();
render(
<>
<button type="button">画布空白</button>
<ViewportToolbar />
</>
);
await user.click(screen.getByRole("button", { name: "添加角色" }));
await user.hover(screen.getByRole("menuitem", { name: "几何模型" }));
expect(screen.getByRole("menu", { name: "选择角色体型" })).toBeInTheDocument();
expect(screen.getByRole("menu", { name: "选择几何模型" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "画布空白" }));
expect(screen.queryByRole("menu", { name: "选择角色体型" })).not.toBeInTheDocument();
expect(screen.queryByRole("menu", { name: "选择几何模型" })).not.toBeInTheDocument();
});
it("opens the aspect ratio panel from the viewport capsule with the supported presets", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "选择画幅比例" }));
const toolbar = screen.getByRole("group", { name: "3D视口快捷工具" });
const dialog = screen.getByRole("dialog", { name: "比例" });
expect(dialog).toBeInTheDocument();
expect(toolbar.contains(dialog)).toBe(false);
expect(screen.getByRole("button", { name: "自动" })).toHaveAttribute("aria-pressed", "true");
["1:1", "2:1", "3:4", "4:3", "16:9", "21:9", "9:16"].forEach((label) => {
expect(screen.getByRole("button", { name: label })).toBeInTheDocument();
});
});
it("updates the viewport aspect ratio from the ratio panel", async () => {
const user = userEvent.setup();
render(<ViewportToolbar />);
await user.click(screen.getByRole("button", { name: "选择画幅比例" }));
await user.click(screen.getByRole("button", { name: "9:16" }));
expect(useDirectorStore.getState().viewportAspectRatio).toBe("9:16");
expect(screen.queryByRole("dialog", { name: "比例" })).not.toBeInTheDocument();
});