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

753 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});