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();
});