import { fireEvent, render, screen, 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 { createInitialDirectorState, useDirectorStore } from "../store/directorStore"; import { DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT, getCameraLookRotationFromTarget } from "../schema/cameraGeometry"; import { CameraPanel } from "./CameraPanel"; function seedCameraCapture() { useDirectorStore.setState((state) => ({ ...state, project: { ...state.project, cameras: state.project.cameras.map((camera) => camera.id === "cam_1" ? { ...camera, lastCaptureUrl: "data:image/png;base64,camera-preview", captures: [ { id: "cam_1-capture-01", index: 1, name: "机位01-截图01", dataUrl: "data:image/png;base64,camera-preview", }, ], } : camera ), }, })); } function seedGroupedCameraCaptures() { const baseState = useDirectorStore.getState(); const firstCamera = baseState.project.cameras[0]; expect(firstCamera).toBeTruthy(); useDirectorStore.setState({ ...baseState, project: { ...baseState.project, cameras: [ { ...firstCamera!, captures: [ { id: "cam_1-capture-01", index: 1, name: "机位01-截图01", dataUrl: "data:image/png;base64,camera-1-a", }, { id: "cam_1-capture-02", index: 2, name: "机位01-截图02", dataUrl: "data:image/png;base64,camera-1-b", }, ], lastCaptureUrl: "data:image/png;base64,camera-1-b", }, { ...firstCamera!, id: "cam_2", name: "机位02", captures: [ { id: "cam_2-capture-01", index: 1, name: "机位02-截图01", dataUrl: "data:image/png;base64,camera-2-a", }, ], lastCaptureUrl: "data:image/png;base64,camera-2-a", }, ], }, }); } beforeEach(() => { useDirectorStore.setState({ ...useDirectorStore.getState(), ...createInitialDirectorState(), selectedObjectId: "cam_object_1", }); }); afterEach(() => { clearViewportCaptureHandler(); vi.restoreAllMocks(); }); it("renders the approved camera panel fields", () => { render(); expect(screen.getByText("摄像机")).toBeInTheDocument(); expect(screen.getByLabelText("机位名称")).toBeInTheDocument(); expect(screen.getByLabelText("切换机位")).toBeInTheDocument(); expect(screen.getByLabelText("机位位置 X")).toBeInTheDocument(); expect(screen.getByLabelText("注视目标模式")).toBeInTheDocument(); expect(screen.getByLabelText("机位水平角")).toBeInTheDocument(); expect(screen.getByLabelText("机位俯仰角")).toBeInTheDocument(); expect(screen.getByLabelText("机位横滚角")).toBeInTheDocument(); expect(screen.getByLabelText("机位 FOV")).toBeInTheDocument(); }); it("uses the provided right inspector layout for camera properties", () => { const { container } = render(); expect(screen.getByLabelText("摄像机右侧属性面板")).toHaveClass("right-inspector"); expect(container.querySelector(".right-inspector-tabs")).toBeInTheDocument(); expect(container.querySelector(".right-inspector-content")).toBeInTheDocument(); expect(screen.queryByLabelText("机位预览卡片")).not.toBeInTheDocument(); expect(screen.queryByText("FOV 50°")).not.toBeInTheDocument(); expect(container.querySelector("select.inspector-select-input")).not.toBeInTheDocument(); expect(screen.getByLabelText("切换机位")).toHaveClass("inspector-dropdown-trigger"); expect(screen.getByLabelText("注视目标模式")).toHaveClass("inspector-dropdown-trigger"); const positionY = screen.getByLabelText("机位位置 Y").closest(".inspector-axis-input"); const fovField = screen.getByLabelText("机位 FOV").closest(".inspector-range-row"); expect(positionY).toBeInTheDocument(); expect(within(positionY as HTMLElement).getByText("Y")).toHaveClass("inspector-axis-prefix"); expect(fovField).toBeInTheDocument(); }); it("shows the selected subject distance and focuses the camera onto it in one click", async () => { const user = userEvent.setup(); // 选中可对焦物体(角色)后,面板直接给出「机位 → 物体」的实测距离 useDirectorStore.setState({ ...useDirectorStore.getState(), selectedObjectId: "char_default_a", }); render(); // 默认机位 [0, 1.55, 5.4] → 角色焦点 [0, 0.99, 0] ≈ 5.4m expect(screen.getByLabelText("所选物体距离")).toHaveTextContent("所选物体 5.4 m"); await user.click(screen.getByRole("button", { name: "对焦到所选物体" })); const camera = useDirectorStore.getState().project.cameras[0]; expect(camera.focusDistance).toBeCloseTo(5.43, 1); }); it("keeps camera panel tab labels in fixed slots while switching tabs", async () => { const user = userEvent.setup(); const { container } = render(); const tabList = container.querySelector(".right-inspector-tabs"); const propertyTab = screen.getByRole("button", { name: "属性" }); const capturesTab = screen.getByRole("button", { name: "摄像机截图" }); expect(tabList).toHaveClass("right-inspector-tabs"); expect(propertyTab).toHaveClass("right-inspector-tab-button"); expect(capturesTab).toHaveClass("right-inspector-tab-button"); expect(propertyTab).toHaveAttribute("aria-pressed", "true"); await user.click(capturesTab); expect(propertyTab).toHaveClass("right-inspector-tab-button"); expect(capturesTab).toHaveClass("right-inspector-tab-button"); expect(capturesTab).toHaveAttribute("aria-pressed", "true"); }); it("updates the selected camera name and fov", async () => { const user = userEvent.setup(); render(); await user.clear(screen.getByLabelText("机位名称")); await user.type(screen.getByLabelText("机位名称"), "近景机位"); await user.clear(screen.getByLabelText("机位 FOV")); await user.type(screen.getByLabelText("机位 FOV"), "65"); const camera = useDirectorStore.getState().project.cameras[0]; expect(camera.name).toBe("近景机位"); expect(camera.fov).toBe(65); }); it("uses the custom dropdown menu to switch camera shots", async () => { const user = userEvent.setup(); useDirectorStore.getState().addCameraShot(); render(); await user.click(screen.getByLabelText("切换机位")); const menu = screen.getByRole("listbox", { name: "切换机位" }); expect(menu).toHaveClass("inspector-dropdown-menu"); await user.click(within(menu).getByRole("option", { name: "机位01" })); expect(useDirectorStore.getState().project.activeCameraId).toBe("cam_1"); expect(screen.queryByRole("listbox", { name: "切换机位" })).not.toBeInTheDocument(); }); it("renders target mode as the custom dropdown menu", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByLabelText("注视目标模式")); const menu = screen.getByRole("listbox", { name: "注视目标模式" }); const manualOption = within(menu).getByRole("option", { name: "手动角度" }); expect(menu).toHaveClass("inspector-dropdown-menu"); expect(manualOption).toHaveClass("is-selected"); expect(manualOption).toHaveAttribute("aria-selected", "true"); }); it("lists visible viewport models as camera focus targets and centers on the selected model", async () => { const user = userEvent.setup(); useDirectorStore.getState().addPresetCharacter("female"); useDirectorStore.getState().addGeometryPrimitive("box"); render(); await user.click(screen.getByLabelText("注视目标模式")); const menu = screen.getByRole("listbox", { name: "注视目标模式" }); const modelNames = within(menu).getAllByRole("option").map((option) => option.textContent); expect(modelNames).toEqual(["手动角度", "角色01", "角色02", "立方体"]); expect(within(menu).queryByRole("option", { name: "机位01" })).not.toBeInTheDocument(); await user.click(within(menu).getByRole("option", { name: "角色02" })); const state = useDirectorStore.getState(); const focusedObject = state.project.objects.find((item) => item.name === "角色02"); const camera = state.project.cameras.find((item) => item.id === state.project.activeCameraId); expect(focusedObject).toBeTruthy(); expect(camera?.targetMode).toBe("object"); expect(camera?.targetObjectId).toBe(focusedObject?.id); // 锁定物体 = 把「机位 → 物体」这条视线换算成注视朝向角 expect(camera?.lookRotation).toEqual( getCameraLookRotationFromTarget(camera!.transform.position, [-1.25, 0.99, 0]) ); expect(screen.getByLabelText("注视目标模式")).toHaveTextContent("角色02"); // rotation = [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)] expect(screen.getByLabelText("机位水平角")).toHaveValue(camera!.lookRotation[1]); expect(screen.getByLabelText("机位俯仰角")).toHaveValue(camera!.lookRotation[0]); expect(screen.getByLabelText("机位横滚角")).toHaveValue(camera!.lookRotation[2]); }); it("resets the camera position and the look angles from their own buttons", async () => { const user = userEvent.setup(); render(); const readCamera = () => useDirectorStore.getState().project.cameras.find((item) => item.id === "cam_1"); const seededCamera = readCamera(); expect(seededCamera).toBeTruthy(); const positionReset = within(screen.getByRole("group", { name: "位置" })).getByRole("button", { name: "重置位置" }); expect(positionReset).toHaveClass("inspector-axis-reset-button"); expect(positionReset.querySelector("svg")).toBeInTheDocument(); // 三个角度各自一行,行首带重置按钮 ["水平角", "俯仰角", "横滚角"].forEach((label) => { const button = within(screen.getByRole("group", { name: label })).getByRole("button", { name: `重置${label}` }); expect(button).toHaveClass("inspector-axis-reset-button"); expect(button.querySelector("svg")).toBeInTheDocument(); }); useDirectorStore.getState().updateCamera("cam_1", { transform: { ...seededCamera!.transform, position: [7, 3, 7] }, lookRotation: [12, 34, 56], }); await user.click(screen.getByRole("button", { name: "重置位置" })); // 机位回到默认机位位置(不是归零:相机压在世界原点会让视线方向退化) expect(readCamera()?.transform.position).toEqual([0, 1.55, 5.4]); // 只重置位置,注视朝向保持原样 expect(readCamera()?.lookRotation).toEqual([12, 34, 56]); // 单独重置「俯仰角」→ 归零(水平),不带动水平角 / 横滚角 await user.click(within(screen.getByRole("group", { name: "俯仰角" })).getByRole("button", { name: "重置俯仰角" })); expect(readCamera()?.lookRotation).toEqual([0, 34, 56]); await user.click(screen.getByRole("button", { name: "重置注视角度" })); // 俯仰角重置为水平 0°,水平角 / 横滚角回到默认机位 expect(readCamera()?.lookRotation).toEqual([ 0, DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation[1], DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation[2], ]); expect(readCamera()?.targetMode).toBe("manual"); }); it("updates camera position and look angles across all axes", async () => { const user = userEvent.setup(); render(); await user.clear(screen.getByLabelText("机位位置 Y")); await user.type(screen.getByLabelText("机位位置 Y"), "3.4"); await user.clear(screen.getByLabelText("机位位置 Z")); await user.type(screen.getByLabelText("机位位置 Z"), "7.5"); await user.clear(screen.getByLabelText("机位水平角")); await user.type(screen.getByLabelText("机位水平角"), "210"); await user.clear(screen.getByLabelText("机位俯仰角")); await user.type(screen.getByLabelText("机位俯仰角"), "12"); const camera = useDirectorStore.getState().project.cameras[0]; expect(camera.transform.position).toEqual([0, 3.4, 7.5]); // lookRotation = [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)] expect(camera.lookRotation).toEqual([12, 210, 0]); }); it("captures the current camera preview from the properties tab and shows it in the screenshots overview", async () => { const user = userEvent.setup(); setViewportCaptureHandler(async () => [ { label: "当前机位", dataUrl: "data:image/png;base64,camera-preview", meta: { mode: "camera", cameraId: "cam_1", fov: 50, position: [0, 2.2, 9], target: [0, 1.2, 0], }, }, ]); render(); await user.click(screen.getByRole("button", { name: "当前机位截图" })); await user.click(screen.getByRole("button", { name: "摄像机截图" })); expect(useDirectorStore.getState().project.cameras[0]?.lastCaptureUrl).toBe("data:image/png;base64,camera-preview"); expect(useDirectorStore.getState().project.cameras[0]?.captures).toEqual([ { id: "cam_1-capture-01", index: 1, name: "机位01-截图01", dataUrl: "data:image/png;base64,camera-preview", }, ]); expect(await screen.findByAltText("机位01-截图01 缩略图")).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "机位01截图" })).toBeInTheDocument(); expect(screen.getByText("机位01-截图01")).toBeInTheDocument(); }); it("keeps the camera capture section visible at the bottom of the properties tab", () => { render(); expect(screen.getByRole("heading", { name: "相机截图" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "当前机位截图" })).toHaveClass("camera-capture-current-button"); expect(screen.getByTestId("camera-current-capture-icon")).toBeInTheDocument(); }); it("renders the thumbnail actions in a bottom bar and opens the project-style viewer toolbar", async () => { const user = userEvent.setup(); seedCameraCapture(); render(); expect(screen.getByRole("group", { name: "机位01-截图01 缩略图操作" })).toHaveClass("camera-capture-actions"); await user.click(screen.getByLabelText("查看截图 机位01-截图01")); const viewer = screen.getByRole("dialog", { name: "相机截图查看器" }); const toolbar = within(viewer).getByRole("toolbar", { name: "相机截图查看器工具栏" }); expect(viewer).toBeInTheDocument(); expect(screen.getByAltText("机位01-截图01 查看大图")).toBeInTheDocument(); expect(within(toolbar).getByRole("button", { name: "放大图片" })).toBeInTheDocument(); expect(within(toolbar).getByRole("button", { name: "缩小图片" })).toBeInTheDocument(); expect(within(toolbar).getByRole("button", { name: "下载图片" })).toBeInTheDocument(); expect(within(toolbar).getByRole("button", { name: "关闭相机截图查看器" })).toBeInTheDocument(); }); it("sends a single camera capture to the host canvas when the thumbnail action is clicked", async () => { const user = userEvent.setup(); const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation(() => undefined); seedCameraCapture(); render(); await user.click(screen.getByRole("button", { name: "发送到画布 机位01-截图01" })); expect(postMessage).toHaveBeenCalledWith( { type: "storyai:director-desk-captures-sent", payload: { captures: [ { dataUrl: "data:image/png;base64,camera-preview", fileName: "机位01-截图01.png", }, ], }, }, window.location.origin ); }); it("shows all camera screenshots grouped by camera in the screenshots tab", async () => { const user = userEvent.setup(); seedGroupedCameraCaptures(); render(); await user.click(screen.getByRole("button", { name: "摄像机截图" })); const firstGroup = screen.getByRole("region", { name: "机位01截图" }); const secondGroup = screen.getByRole("region", { name: "机位02截图" }); expect(screen.queryByRole("heading", { name: "相机截图" })).not.toBeInTheDocument(); expect(within(firstGroup).getByRole("heading", { name: "机位01截图" })).toBeInTheDocument(); expect(within(firstGroup).getByText("机位01-截图01")).toBeInTheDocument(); expect(within(firstGroup).getByText("机位01-截图02")).toBeInTheDocument(); expect(within(secondGroup).getByRole("heading", { name: "机位02截图" })).toBeInTheDocument(); expect(within(secondGroup).getByText("机位02-截图01")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "清空全部" })).toHaveClass("camera-capture-clear-all"); expect(screen.getByRole("button", { name: "发送到画布" })).toHaveClass( "camera-capture-send-all", "viewport-toolbar-crowd-confirm" ); expect(screen.getByRole("button", { name: "发送到画布" })).not.toHaveClass("is-hover-state"); expect(screen.getByTestId("camera-capture-clear-icon")).toBeInTheDocument(); expect(screen.getByTestId("camera-capture-send-icon")).toBeInTheDocument(); const panel = screen.getByLabelText("摄像机右侧属性面板"); const content = panel.querySelector(".right-inspector-content"); const footer = panel.querySelector(".camera-capture-overview-footer"); expect(footer).toBeInTheDocument(); expect(content).toBeInTheDocument(); expect(content).not.toContainElement(footer as HTMLElement); }); it("sends all visible camera screenshots to the host canvas from the overview footer", async () => { const user = userEvent.setup(); const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation(() => undefined); seedGroupedCameraCaptures(); render(); await user.click(screen.getByRole("button", { name: "摄像机截图" })); await user.click(screen.getByRole("button", { name: "发送到画布" })); expect(postMessage).toHaveBeenCalledWith( { type: "storyai:director-desk-captures-sent", payload: { captures: [ { dataUrl: "data:image/png;base64,camera-1-a", fileName: "机位01-截图01.png", }, { dataUrl: "data:image/png;base64,camera-1-b", fileName: "机位01-截图02.png", }, { dataUrl: "data:image/png;base64,camera-2-a", fileName: "机位02-截图01.png", }, ], }, }, window.location.origin ); }); it("clears every camera screenshot from the screenshots tab and shows the empty state", async () => { const user = userEvent.setup(); seedGroupedCameraCaptures(); render(); await user.click(screen.getByRole("button", { name: "摄像机截图" })); await user.click(screen.getByRole("button", { name: "清空全部" })); expect(screen.queryByText("机位01-截图01")).not.toBeInTheDocument(); const emptyState = screen.getByRole("status", { name: "暂无摄像机截图" }); expect(emptyState).toHaveClass("camera-capture-empty", "object-search-empty-state"); expect(screen.getByTestId("camera-capture-empty-icon")).toHaveClass("object-search-empty-icon"); expect(screen.getByTestId("camera-capture-empty-icon").querySelector(".lucide-images")).toBeInTheDocument(); expect(screen.getByTestId("camera-capture-empty-icon").querySelector(".lucide-search")).not.toBeInTheDocument(); expect(screen.getByText("暂无摄像机截图")).toBeInTheDocument(); expect(useDirectorStore.getState().project.cameras.map((camera) => camera.captures ?? [])).toEqual([[], []]); expect(useDirectorStore.getState().project.cameras.map((camera) => camera.lastCaptureUrl ?? null)).toEqual([ null, null, ]); }); it("renders the capture viewer as a portal outside the side panel", async () => { const user = userEvent.setup(); seedCameraCapture(); const { container } = render(); await user.click(screen.getByLabelText("查看截图 机位01-截图01")); const viewer = screen.getByRole("dialog", { name: "相机截图查看器" }); // 侧栏卡片带 backdrop-filter,会成为 fixed 定位元素的包含块:查看器必须挂在 body 上, // 否则 position: fixed; inset: 0 会相对侧栏解析并被裁剪,看起来就不是弹出层了 expect(viewer.parentElement).toBe(document.body); expect(container.contains(viewer)).toBe(false); expect(viewer).toHaveAttribute("aria-modal", "true"); }); it("opens the capture viewer when the capture card itself is clicked", async () => { const user = userEvent.setup(); seedCameraCapture(); render(); // 点名称(缩略图外的区域)也应该弹出大图 await user.click(screen.getByText("机位01-截图01")); expect(screen.getByRole("dialog", { name: "相机截图查看器" })).toBeInTheDocument(); expect(screen.getByAltText("机位01-截图01 查看大图")).toBeInTheDocument(); }); it("closes the capture viewer when clicking outside the image", async () => { const user = userEvent.setup(); seedCameraCapture(); render(); await user.click(screen.getByLabelText("查看截图 机位01-截图01")); const previewImage = screen.getByAltText("机位01-截图01 查看大图"); // 查看器通过 portal 挂在 body 上,所以要直接从 document 查 const viewerStage = document.querySelector(".camera-capture-viewer-stage"); expect(viewerStage).toBeInTheDocument(); await user.click(previewImage); expect(screen.getByRole("dialog", { name: "相机截图查看器" })).toBeInTheDocument(); await user.click(viewerStage as HTMLElement); expect(screen.queryByRole("dialog", { name: "相机截图查看器" })).not.toBeInTheDocument(); }); it("zooms the capture preview through the viewer toolbar controls with the canvas image preview step", async () => { const user = userEvent.setup(); seedCameraCapture(); render(); await user.click(screen.getByLabelText("查看截图 机位01-截图01")); await user.click(screen.getByRole("button", { name: "放大图片" })); expect(screen.getByAltText("机位01-截图01 查看大图")).toHaveStyle({ transform: "translate(0px, 0px) scale(1.25)", }); }); it("supports wheel zooming and dragging like the canvas image preview", async () => { const user = userEvent.setup(); seedCameraCapture(); render(); await user.click(screen.getByLabelText("查看截图 机位01-截图01")); const previewImage = screen.getByAltText("机位01-截图01 查看大图"); fireEvent.wheel(previewImage, { deltaY: -100 }); expect(previewImage).toHaveStyle({ transform: "translate(0px, 0px) scale(1.25)" }); fireEvent.mouseDown(previewImage, { clientX: 100, clientY: 100 }); fireEvent.mouseMove(window, { clientX: 124, clientY: 132 }); fireEvent.mouseUp(window); expect(previewImage).toHaveStyle({ transform: "translate(24px, 32px) scale(1.25)" }); }); it("deletes a camera capture from the screenshot grid", async () => { const user = userEvent.setup(); seedCameraCapture(); render(); await user.click(screen.getByLabelText("删除截图 机位01-截图01")); expect(screen.queryByText("机位01-截图01")).not.toBeInTheDocument(); expect(useDirectorStore.getState().project.cameras[0]?.captures).toEqual([]); expect(useDirectorStore.getState().project.cameras[0]?.lastCaptureUrl).toBeNull(); });