import { forwardRef } from "react"; import { fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, beforeEach, vi } from "vitest"; import { DoubleSide, PerspectiveCamera, Vector3 } from "three"; import { clearViewportCaptureHandler, requestViewportCapture } from "../io/captureBridge"; import { DEFAULT_DIRECTOR_VIEW_SNAPSHOT, getViewportGizmoHitButtonStyle, VIEWPORT_WHEEL_ZOOM_SPEED, } from "./DirectorCanvas"; import { GROUND_PLANE_SIZE } from "./sceneGround"; import { getViewportAspectFrameRect } from "./viewportAspectFrame"; import { degreesRotationToRadians } from "../schema/directorProject"; const mockCameraPositionSet = vi.hoisted(() => vi.fn()); const mockCameraLookAt = vi.hoisted(() => vi.fn()); const mockCameraUpdateMatrixWorld = vi.hoisted(() => vi.fn()); const mockCameraUpdateProjectionMatrix = vi.hoisted(() => vi.fn()); const mockCaptureExcludedObjects = vi.hoisted(() => [ { name: "viewport-grid", userData: { hideFromViewportCapture: true }, visible: true }, { name: "viewport-camera-wireframe", userData: { hideFromViewportCapture: true }, visible: true }, ]); const mockCaptureVisibleObject = vi.hoisted(() => ({ name: "role-model", userData: {}, visible: true, })); const mockRenderVisibilitySnapshots = vi.hoisted(() => [] as boolean[][]); beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => null); mockCameraPositionSet.mockClear(); mockCameraLookAt.mockClear(); mockCameraUpdateMatrixWorld.mockClear(); mockCameraUpdateProjectionMatrix.mockClear(); mockRenderVisibilitySnapshots.length = 0; mockCaptureExcludedObjects.forEach((object) => { object.visible = true; }); mockCaptureVisibleObject.visible = true; useDirectorStore.setState({ ...useDirectorStore.getState(), ...createInitialDirectorState(), }); }); afterEach(() => { clearViewportCaptureHandler(); vi.restoreAllMocks(); }); vi.mock("@react-three/fiber", async () => { const actual = await vi.importActual("@react-three/fiber"); return { ...actual, Canvas: ({ children, className, onPointerMissed, }: { children: React.ReactNode; className?: string; onPointerMissed?: () => void; }) => (
onPointerMissed?.()}> {children}
), useFrame: () => undefined, useThree: () => { const testCamera = new PerspectiveCamera(50, 1000 / 700, 0.1, 1000); testCamera.position.set(0, 2.2, 9); testCamera.lookAt(0, 1.2, 0); testCamera.updateProjectionMatrix(); testCamera.updateMatrixWorld(); const position = testCamera.position; const originalLookAt = testCamera.lookAt.bind(testCamera); const originalUpdateMatrixWorld = testCamera.updateMatrixWorld.bind(testCamera); const originalUpdateProjectionMatrix = testCamera.updateProjectionMatrix.bind(testCamera); const originalPositionSet = position.set.bind(position); const originalPositionCopy = position.copy.bind(position); position.set = ((x: number, y: number, z: number) => { mockCameraPositionSet(x, y, z); const result = originalPositionSet(x, y, z); testCamera.updateMatrixWorld(); return result; }) as typeof position.set; position.copy = ((vector: Vector3) => { const result = originalPositionCopy(vector); testCamera.updateMatrixWorld(); return result; }) as typeof position.copy; const lookAt = (...args: Parameters) => { mockCameraLookAt(...args); const result = originalLookAt(...args); originalUpdateMatrixWorld(); return result; }; const updateMatrixWorld = (...args: Parameters) => { mockCameraUpdateMatrixWorld(...args); return originalUpdateMatrixWorld(...args); }; const updateProjectionMatrix = () => { mockCameraUpdateProjectionMatrix(); return originalUpdateProjectionMatrix(); }; testCamera.lookAt = lookAt as PerspectiveCamera["lookAt"]; testCamera.updateMatrixWorld = updateMatrixWorld as PerspectiveCamera["updateMatrixWorld"]; testCamera.updateProjectionMatrix = updateProjectionMatrix as PerspectiveCamera["updateProjectionMatrix"]; return { camera: testCamera, gl: { render: () => { mockRenderVisibilitySnapshots.push([ ...mockCaptureExcludedObjects.map((object) => object.visible), mockCaptureVisibleObject.visible, ]); }, setClearColor: () => undefined, domElement: { width: 1000, height: 700, clientWidth: 1000, clientHeight: 700, toDataURL: () => "data:image/png;base64,mock", }, }, scene: { background: null, backgroundRotation: { clone: () => ({ copy: () => undefined, }), copy: () => undefined, set: () => undefined, }, backgroundBlurriness: 0, backgroundIntensity: 1, traverse: (callback: (object: { userData?: Record; visible: boolean }) => void) => { [...mockCaptureExcludedObjects, mockCaptureVisibleObject].forEach(callback); }, }, }; }, }; }); vi.mock("@react-three/drei", async () => { const actual = await vi.importActual("@react-three/drei"); return { ...actual, Grid: ({ args, cellColor, cellSize, cellThickness, fadeDistance, infiniteGrid, position, sectionColor, sectionSize, sectionThickness, side, }: { args?: [number, number]; cellColor?: string; cellSize?: number; cellThickness?: number; fadeDistance?: number; infiniteGrid?: boolean; position?: [number, number, number]; sectionColor?: string; sectionSize?: number; sectionThickness?: number; side?: number; }) => (
), GizmoHelper: ({ alignment, children, margin, }: { alignment?: string; children?: React.ReactNode; margin?: [number, number]; }) => (
{children}
), GizmoViewport: ({ axisColors, disabled, scale, }: { axisColors?: [string, string, string]; disabled?: boolean; scale?: number; }) => (
) ), PerspectiveCamera: () => null, Html: ({ children }: { children?: React.ReactNode }) => <>{children}, Line: () => null, useTexture: () => ({ isTexture: true }), }; }); vi.mock("./SceneRoot", () => ({ SceneRoot: () => null, })); import App from "../../App"; import { createInitialDirectorState, useDirectorStore } from "../store/directorStore"; it("renders a live R3F viewport and director scene controls", () => { render(); expect(screen.getByTestId("director-canvas")).toBeInTheDocument(); expect(screen.getByLabelText("场景缩放")).toBeInTheDocument(); expect(screen.getByText("全景背景")).toBeInTheDocument(); expect(screen.getByText("全景球")).toBeInTheDocument(); expect(screen.getByTestId("orbit-controls")).toHaveAttribute("data-enabled", "true"); }); it("keeps orbit controls available when a transformable object is selected but no handle is being dragged", () => { useDirectorStore.setState({ ...useDirectorStore.getState(), selectedObjectId: "char_default_a", }); render(); expect(screen.getByTestId("orbit-controls")).toHaveAttribute("data-enabled", "true"); }); it("does not render a full-viewport transform drag layer over the 3D viewport", () => { useDirectorStore.setState({ ...useDirectorStore.getState(), selectedObjectId: "char_default_a", }); render(); expect(screen.queryByRole("application", { name: "3D视口移动拖拽层" })).not.toBeInTheDocument(); expect(screen.queryByRole("application", { name: "3D视口旋转拖拽层" })).not.toBeInTheDocument(); expect(screen.queryByRole("application", { name: "3D视口缩放拖拽层" })).not.toBeInTheDocument(); }); it("renders a fine viewport grid with thicker, darker major lines every 5 cells on the same footprint as the ground", () => { render(); const grid = screen.getByTestId("viewport-grid"); expect(grid).toHaveAttribute("data-position", "[0,0.002,0]"); expect(grid).toHaveAttribute("data-cell-size", "1"); expect(grid).toHaveAttribute("data-section-size", "5"); // 每 5 格一条更粗的分割线 expect(grid).toHaveAttribute("data-section-thickness", "1.4"); // 细分割线比主分割线更细 expect(grid).toHaveAttribute("data-cell-thickness", "0.5"); // 主分割线颜色更浅(中性灰,突出 RGB 坐标轴) expect(grid).toHaveAttribute("data-section-color", "#6b7280"); expect(grid).toHaveAttribute("data-fade-distance", "80"); // 网格不能是"无限网格":默认尺寸(1×1 × (1+fadeDistance))比地面小得多, // 边缘不重合就会被看成另一层地面。这里必须和地面同尺寸。 expect(grid).toHaveAttribute("data-infinite-grid", "false"); expect(grid).toHaveAttribute("data-args", `[${GROUND_PLANE_SIZE},${GROUND_PLANE_SIZE}]`); }); it("does not bounce the camera back while the user is driving OrbitControls", () => { render(); mockCameraPositionSet.mockClear(); // 用户滚轮 / 拖拽时控件每帧都会抛 change;模拟其中一次 fireEvent.click(screen.getByRole("button", { name: "模拟操作视角控件" })); // 相机归 OrbitControls 逐帧驱动,不能再把回调里那份(上一帧的)快照写回去, // 否则缩放时会出现"缩小时突然又放大一点"的抖动 expect(mockCameraPositionSet).not.toHaveBeenCalled(); // 快照本身仍照常同步给 UI:坐标控件命中按钮跟着这次视角变化重算 const expectedStyle = getViewportGizmoHitButtonStyle( { fov: 50, position: [9, 2.2, 0], target: [0, 1.05, 0] }, [1, 0, 0] ); expect(screen.getByRole("button", { name: "切换到 X 正向视图" })).toHaveStyle({ left: expectedStyle.left, top: expectedStyle.top, }); }); it("speeds up wheel zoom beyond the three.js default so scrolling feels responsive", () => { render(); const controls = screen.getByTestId("orbit-controls"); const zoomSpeed = Number(controls.getAttribute("data-zoom-speed")); expect(zoomSpeed).toBe(VIEWPORT_WHEEL_ZOOM_SPEED); // three 的 zoomSpeed 默认 1(每格滚轮只推拉约 5%),必须大于它才是"变快" expect(zoomSpeed).toBeGreaterThan(1); }); it("keeps the viewport grid visible when the view drops below the ground plane", () => { render(); // drei 的 Grid 默认 BackSide:视角转到地面以下时会因背面剔除整个消失 expect(screen.getByTestId("viewport-grid")).toHaveAttribute("data-side", String(DoubleSide)); }); it("keeps the viewport grid slightly above the configured ground plane", () => { useDirectorStore.setState({ ...useDirectorStore.getState(), project: { ...useDirectorStore.getState().project, scene: { ...useDirectorStore.getState().project.scene, groundHeight: 1.5, }, }, }); render(); expect(screen.getByTestId("viewport-grid")).toHaveAttribute("data-position", "[0,1.502,0]"); }); it("shares the scene transform with the ground plane so the grid never becomes a second floor", () => { useDirectorStore.setState({ ...useDirectorStore.getState(), project: { ...useDirectorStore.getState().project, scene: { ...useDirectorStore.getState().project.scene, scale: 2, position: [1, 3, -2], rotation: [0, 90, 0], groundHeight: 1.5, }, }, }); render(); const grid = screen.getByTestId("viewport-grid"); // 网格被包在「导出时隐藏」的 group 里,再外层才是与地面共享变换的场景 group const gridWrapper = grid.closest("group"); const sceneGroup = gridWrapper?.parentElement?.closest("group"); // 网格与地面同在场景组内:共享场景平移/旋转/缩放,不会各自留在不同坐标空间 expect(sceneGroup).not.toBeNull(); expect(sceneGroup).toHaveAttribute("position", "1,3,-2"); // 场景旋转存的是度数,写进 Three.js group 前会转成弧度(90° → π/2) expect(sceneGroup).toHaveAttribute("rotation", degreesRotationToRadians([0, 90, 0]).join(",")); expect(sceneGroup).toHaveAttribute("scale", "2,2,2"); // 网格自身的 y 相对场景原点,仍是「地面高度 + 微小抬升」 expect(grid).toHaveAttribute("data-position", "[0,1.502,0]"); }); it("opens the scene inspector when users click empty 3D viewport space", () => { useDirectorStore.setState({ ...useDirectorStore.getState(), selectedObjectId: "char_default_a", }); render(); expect(screen.getByLabelText("角色名称")).toBeInTheDocument(); fireEvent.click(within(screen.getByTestId("director-canvas")).getByTestId("mock-r3f-canvas")); expect(useDirectorStore.getState().selectedObjectId).toBeNull(); expect(screen.getByLabelText("3D场景右侧属性面板")).toBeInTheDocument(); expect(screen.getByLabelText("场景缩放")).toBeInTheDocument(); }); it("renders the viewport aspect ratio overlay when a non-auto frame is selected", () => { useDirectorStore.setState({ ...useDirectorStore.getState(), viewportAspectRatio: "9:16", }); render(); expect(screen.getByLabelText("视口画幅框")).toBeInTheDocument(); // 遮罩已由「四块矩形」改为单元素 + CSS mask-composite 挖洞(见 index.css 契约测试)。 expect(screen.getAllByLabelText("视口画幅遮罩")).toHaveLength(1); expect(screen.getByLabelText("视口画幅框")).toHaveAttribute("data-aspect-ratio", "9:16"); }); it("toggles the viewport rule-of-thirds guide from the aspect frame button", () => { useDirectorStore.setState({ ...useDirectorStore.getState(), viewportAspectRatio: "16:9", viewportRuleOfThirdsEnabled: false, }); render(); const guideToggle = screen.getByRole("button", { name: "开启九宫格辅助线" }); fireEvent.click(guideToggle); expect(useDirectorStore.getState().viewportRuleOfThirdsEnabled).toBe(true); expect(screen.getByLabelText("九宫格辅助线")).toBeInTheDocument(); }); it("renders the native 3D viewport gizmo in an overlay canvas above the aspect mask", () => { render(); expect(screen.getAllByTestId("mock-r3f-canvas")).toHaveLength(2); expect(screen.getByLabelText("3D视口原生坐标控件")).toContainElement(screen.getByTestId("native-gizmo-helper")); expect(screen.getByTestId("native-gizmo-helper")).toHaveAttribute("data-alignment", "center-center"); expect(screen.getByTestId("native-gizmo-helper")).toHaveAttribute("data-margin", "[0,0]"); expect(screen.getByTestId("native-gizmo-viewport")).toHaveAttribute( "data-axis-colors", "[\"#FF4D4F\",\"#52C41A\",\"#1890FF\"]" ); expect(screen.getByTestId("native-gizmo-viewport")).toHaveAttribute("data-disabled", "true"); expect(screen.getByTestId("native-gizmo-viewport")).toHaveAttribute("data-scale", "25"); expect(screen.getByTestId("native-gizmo-viewport").closest(".director-canvas")).not.toBeInTheDocument(); expect(screen.queryByLabelText("3D视口坐标指示")).not.toBeInTheDocument(); }); it("offsets the native viewport gizmo inward when overlay side panels are open", () => { render(); const gizmo = screen.getByLabelText("3D视口原生坐标控件"); // 右侧悬浮卡片宽 300px,内缩 16px,再加 16px 间距 → 坐标控件贴在其左侧 expect(gizmo).toHaveStyle({ right: "332px", }); }); it("resets the director view from the button below the native gizmo", () => { useDirectorStore.getState().setViewMode("camera"); render(); const resetButton = screen.getByRole("button", { name: "重置视角" }); expect(resetButton).toHaveClass("viewport-view-reset-button"); // 文字按钮:不再用图标 + 悬停提示 expect(resetButton).toHaveTextContent("重置视角"); expect(resetButton.querySelector("svg")).not.toBeInTheDocument(); mockCameraPositionSet.mockClear(); fireEvent.click(resetButton); expect(useDirectorStore.getState().viewMode).toBe("director"); expect(mockCameraPositionSet).toHaveBeenCalledWith(...DEFAULT_DIRECTOR_VIEW_SNAPSHOT.position); }); it("syncs native viewport gizmo axis clicks back to the main director view", () => { render(); mockCameraPositionSet.mockClear(); const xAxisHitTarget = screen.getByRole("button", { name: "切换到 X 正向视图" }); expect(xAxisHitTarget).toHaveClass("viewport-gizmo-hit-button"); fireEvent.click(xAxisHitTarget); expect(mockCameraPositionSet).toHaveBeenCalledWith(expect.closeTo(5.423099, 5), 1.05, 0); }); it("captures screenshots using the selected viewport aspect ratio crop", async () => { useDirectorStore.setState({ ...useDirectorStore.getState(), viewportAspectRatio: "9:16", }); const originalCreateElement = document.createElement.bind(document); const drawImage = vi.fn(); const cropCanvas = { width: 0, height: 0, getContext: vi.fn(() => ({ drawImage, })), toDataURL: vi.fn(() => "data:image/png;base64,cropped"), }; vi.spyOn(document, "createElement").mockImplementation(((tagName: string) => { if (tagName === "canvas") { return cropCanvas as unknown as HTMLCanvasElement; } return originalCreateElement(tagName); }) as typeof document.createElement); render(); const results = await requestViewportCapture({ preset: "current", source: "capture-panel", }); expect(results[0]?.dataUrl).toBe("data:image/png;base64,cropped"); expect(drawImage).toHaveBeenCalledTimes(1); expect(cropCanvas.width / cropCanvas.height).toBeCloseTo(9 / 16, 2); }); it("draws visible character name labels into viewport screenshots", async () => { useDirectorStore.setState({ ...useDirectorStore.getState(), viewportAspectRatio: "16:9", }); const originalCreateElement = document.createElement.bind(document); const fillText = vi.fn(); const cropCanvas = { width: 0, height: 0, getContext: vi.fn(() => ({ beginPath: vi.fn(), closePath: vi.fn(), drawImage: vi.fn(), fill: vi.fn(), fillText, measureText: vi.fn(() => ({ width: 32 })), quadraticCurveTo: vi.fn(), lineTo: vi.fn(), moveTo: vi.fn(), })), toDataURL: vi.fn(() => "data:image/png;base64,labelled"), }; vi.spyOn(document, "createElement").mockImplementation(((tagName: string) => { if (tagName === "canvas") { return cropCanvas as unknown as HTMLCanvasElement; } return originalCreateElement(tagName); }) as typeof document.createElement); render(); await requestViewportCapture({ preset: "current", source: "capture-panel", }); expect(fillText).toHaveBeenCalledWith("角色01", expect.any(Number), expect.any(Number)); }); it("does not draw character name labels into screenshots when scene labels are hidden", async () => { useDirectorStore.setState({ ...useDirectorStore.getState(), project: { ...useDirectorStore.getState().project, scene: { ...useDirectorStore.getState().project.scene, showLabels: false, }, }, viewportAspectRatio: "16:9", }); const originalCreateElement = document.createElement.bind(document); const fillText = vi.fn(); const cropCanvas = { width: 0, height: 0, getContext: vi.fn(() => ({ beginPath: vi.fn(), closePath: vi.fn(), drawImage: vi.fn(), fill: vi.fn(), fillText, measureText: vi.fn(() => ({ width: 32 })), quadraticCurveTo: vi.fn(), lineTo: vi.fn(), moveTo: vi.fn(), })), toDataURL: vi.fn(() => "data:image/png;base64-unlabelled"), }; vi.spyOn(document, "createElement").mockImplementation(((tagName: string) => { if (tagName === "canvas") { return cropCanvas as unknown as HTMLCanvasElement; } return originalCreateElement(tagName); }) as typeof document.createElement); render(); await requestViewportCapture({ preset: "current", source: "capture-panel", }); expect(fillText).not.toHaveBeenCalled(); }); it("hides viewport grid and camera helper models only while rendering screenshots", async () => { render(); await requestViewportCapture({ preset: "current", source: "capture-panel", }); expect(mockRenderVisibilitySnapshots).toContainEqual([false, false, true]); expect(mockCaptureExcludedObjects.map((object) => object.visible)).toEqual([true, true]); expect(mockCaptureVisibleObject.visible).toBe(true); }); it("captures screenshots from the same safe-area frame shown by the aspect overlay", async () => { useDirectorStore.setState({ ...useDirectorStore.getState(), viewportAspectRatio: "16:9", viewportPanelsCollapsed: false, }); const originalCreateElement = document.createElement.bind(document); const drawImage = vi.fn(); const cropCanvas = { width: 0, height: 0, getContext: vi.fn(() => ({ drawImage, })), toDataURL: vi.fn(() => "data:image/png;base64,cropped-safe-area"), }; vi.spyOn(document, "createElement").mockImplementation(((tagName: string) => { if (tagName === "canvas") { return cropCanvas as unknown as HTMLCanvasElement; } return originalCreateElement(tagName); }) as typeof document.createElement); render(); await requestViewportCapture({ preset: "current", source: "capture-panel", }); // 悬浮卡片左右等宽:300 + 16 + 16,与 DirectorCanvas 的安全区保持一致 const expectedFrame = getViewportAspectFrameRect("16:9", 1000, 700, 124, { left: 332, right: 332, top: 0, bottom: 0, }); expect(expectedFrame).not.toBeNull(); expect(drawImage).toHaveBeenCalledWith( expect.anything(), Math.round(expectedFrame!.left), Math.round(expectedFrame!.top), Math.round(expectedFrame!.width), Math.round(expectedFrame!.height), 0, 0, Math.round(expectedFrame!.width), Math.round(expectedFrame!.height) ); }); it("captures every four-view screenshot using the selected viewport aspect ratio crop", async () => { useDirectorStore.setState({ ...useDirectorStore.getState(), viewportAspectRatio: "4:3", }); const originalCreateElement = document.createElement.bind(document); const drawImage = vi.fn(); const cropCanvas = { width: 0, height: 0, getContext: vi.fn(() => ({ drawImage, })), toDataURL: vi.fn(() => "data:image/png;base64,cropped-four"), }; vi.spyOn(document, "createElement").mockImplementation(((tagName: string) => { if (tagName === "canvas") { return cropCanvas as unknown as HTMLCanvasElement; } return originalCreateElement(tagName); }) as typeof document.createElement); render(); const results = await requestViewportCapture({ preset: "four", source: "capture-panel", }); expect(results).toHaveLength(4); results.forEach((result) => { expect(result.dataUrl).toBe("data:image/png;base64,cropped-four"); }); expect(drawImage).toHaveBeenCalledTimes(4); expect(cropCanvas.width / cropCanvas.height).toBeCloseTo(4 / 3, 2); });