主视口机位视角按传感器画幅加 letterbox 遮幅,使主视口、镜头监视器与视频导出的取景逐像素同源,虚化按传感器画幅归一化。镜头监视器新增全屏查看(Esc 关闭),支持拖拽瞄准与单击对焦并显示景深诊断读数,像素比上限改为 2 以消除机位视角的放大模糊。监视器与主视口统一按自动切机位口径解析当前机位。相机面板新增所选物体距离显示与一键对焦。
591 lines
23 KiB
TypeScript
591 lines
23 KiB
TypeScript
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
// 默认机位 [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(<CameraPanel />);
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
// 点名称(缩略图外的区域)也应该弹出大图
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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(<CameraPanel />);
|
||
|
||
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();
|
||
});
|