753 lines
25 KiB
TypeScript
753 lines
25 KiB
TypeScript
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<typeof import("@react-three/fiber")>("@react-three/fiber");
|
||
|
||
return {
|
||
...actual,
|
||
Canvas: ({
|
||
children,
|
||
className,
|
||
onPointerMissed,
|
||
}: {
|
||
children: React.ReactNode;
|
||
className?: string;
|
||
onPointerMissed?: () => void;
|
||
}) => (
|
||
<div className={className} data-testid="mock-r3f-canvas" onClick={() => onPointerMissed?.()}>
|
||
{children}
|
||
</div>
|
||
),
|
||
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<PerspectiveCamera["lookAt"]>) => {
|
||
mockCameraLookAt(...args);
|
||
const result = originalLookAt(...args);
|
||
originalUpdateMatrixWorld();
|
||
return result;
|
||
};
|
||
const updateMatrixWorld = (...args: Parameters<PerspectiveCamera["updateMatrixWorld"]>) => {
|
||
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<string, unknown>; visible: boolean }) => void) => {
|
||
[...mockCaptureExcludedObjects, mockCaptureVisibleObject].forEach(callback);
|
||
},
|
||
},
|
||
};
|
||
},
|
||
};
|
||
});
|
||
|
||
vi.mock("@react-three/drei", async () => {
|
||
const actual = await vi.importActual<typeof import("@react-three/drei")>("@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;
|
||
}) => (
|
||
<div
|
||
data-args={JSON.stringify(args)}
|
||
data-cell-color={cellColor}
|
||
data-cell-size={String(cellSize)}
|
||
data-cell-thickness={String(cellThickness)}
|
||
data-fade-distance={String(fadeDistance)}
|
||
data-infinite-grid={String(Boolean(infiniteGrid))}
|
||
data-position={JSON.stringify(position)}
|
||
data-section-color={sectionColor}
|
||
data-section-size={String(sectionSize)}
|
||
data-section-thickness={String(sectionThickness)}
|
||
data-side={String(side)}
|
||
data-testid="viewport-grid"
|
||
/>
|
||
),
|
||
GizmoHelper: ({
|
||
alignment,
|
||
children,
|
||
margin,
|
||
}: {
|
||
alignment?: string;
|
||
children?: React.ReactNode;
|
||
margin?: [number, number];
|
||
}) => (
|
||
<div data-alignment={alignment} data-margin={JSON.stringify(margin)} data-testid="native-gizmo-helper">
|
||
{children}
|
||
</div>
|
||
),
|
||
GizmoViewport: ({
|
||
axisColors,
|
||
disabled,
|
||
scale,
|
||
}: {
|
||
axisColors?: [string, string, string];
|
||
disabled?: boolean;
|
||
scale?: number;
|
||
}) => (
|
||
<button
|
||
data-axis-colors={JSON.stringify(axisColors)}
|
||
data-disabled={String(disabled)}
|
||
data-scale={String(scale)}
|
||
data-testid="native-gizmo-viewport"
|
||
type="button"
|
||
/>
|
||
),
|
||
OrbitControls: forwardRef(
|
||
({
|
||
enabled,
|
||
onChange,
|
||
zoomSpeed,
|
||
}: {
|
||
enabled?: boolean;
|
||
onChange?: (event: unknown) => void;
|
||
zoomSpeed?: number;
|
||
}) => (
|
||
<div data-enabled={String(enabled)} data-testid="orbit-controls" data-zoom-speed={String(zoomSpeed)}>
|
||
{/* 模拟用户直接操作控件(滚轮缩放 / 拖拽)时控件抛出的 change 事件载荷 */}
|
||
<button
|
||
aria-label="模拟操作视角控件"
|
||
type="button"
|
||
onClick={() =>
|
||
onChange?.({
|
||
target: {
|
||
object: { fov: 50, position: { x: 9, y: 2.2, z: 0 } },
|
||
target: { x: 0, y: 1.05, z: 0 },
|
||
},
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
)
|
||
),
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
// 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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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(<App />);
|
||
|
||
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);
|
||
});
|