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 { getCameraLookRotationFromTarget, 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(); 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(); 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(); 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(); 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(); 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( 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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( 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].lookRotation).toEqual(getCameraLookRotationFromTarget(snapshot.position, snapshot.target)); }); it("does not show operation feedback text on the right side of the viewport capsule", async () => { const user = userEvent.setup(); render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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( <> ); 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(); 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(); 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(); });