200 lines
6.8 KiB
TypeScript
200 lines
6.8 KiB
TypeScript
import { act, render, screen } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { vi } from "vitest";
|
|
import { createInitialDirectorState, useDirectorStore } from "./editor/store/directorStore";
|
|
import { usePlaybackStore } from "./editor/runtime/playbackStore";
|
|
|
|
vi.mock("./editor/canvas/DirectorCanvas", () => ({
|
|
DirectorCanvas: () => <div data-testid="mock-director-canvas" />,
|
|
}));
|
|
|
|
import App from "./App";
|
|
|
|
beforeEach(() => {
|
|
useDirectorStore.setState({
|
|
...useDirectorStore.getState(),
|
|
...createInitialDirectorState(),
|
|
});
|
|
});
|
|
|
|
it("renders the director desk header and view mode switch", () => {
|
|
const { container } = render(<App />);
|
|
|
|
expect(screen.queryByText("3D导演台")).not.toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "导演视角" })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "机位视角" })).toBeInTheDocument();
|
|
expect(container.querySelector(".top-bar-center .mode-toggle")).toBeInTheDocument();
|
|
expect(screen.queryByLabelText("帮助")).not.toBeInTheDocument();
|
|
// 右上角的「关闭」按钮已去掉
|
|
expect(screen.queryByLabelText("关闭")).not.toBeInTheDocument();
|
|
expect(container.querySelector(".top-bar-actions")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("notifies the host canvas when the director desk app is ready", () => {
|
|
const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation(() => undefined);
|
|
|
|
render(<App />);
|
|
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{ type: "storyai:director-desk-ready" },
|
|
window.location.origin
|
|
);
|
|
|
|
postMessage.mockRestore();
|
|
});
|
|
|
|
it("uses a full-width director desk frame instead of floating card columns", () => {
|
|
const { container } = render(<App />);
|
|
const shell = container.querySelector(".director-shell.director-shell-fullbleed");
|
|
|
|
expect(shell).toBeInTheDocument();
|
|
expect(shell?.firstElementChild).toHaveClass("viewport-column");
|
|
expect(screen.getByLabelText("场景")).toHaveClass("left-sidebar");
|
|
expect(screen.getByLabelText("3D视口")).toHaveClass("viewport-column");
|
|
expect(screen.getByLabelText("属性")).toHaveClass("right-sidebar");
|
|
});
|
|
|
|
it("collapses both side panels from the fullscreen toolbar action", async () => {
|
|
const { container, rerender } = render(<App />);
|
|
|
|
expect(container.querySelector(".director-shell-fullbleed.is-sidebars-collapsed")).not.toBeInTheDocument();
|
|
|
|
act(() => {
|
|
useDirectorStore.setState({
|
|
...useDirectorStore.getState(),
|
|
viewportPanelsCollapsed: true,
|
|
} as ReturnType<typeof useDirectorStore.getState>);
|
|
});
|
|
rerender(<App />);
|
|
|
|
expect(container.querySelector(".director-shell-fullbleed.is-sidebars-collapsed")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("场景")).toHaveAttribute("aria-hidden", "true");
|
|
expect(screen.getByLabelText("属性")).toHaveAttribute("aria-hidden", "true");
|
|
});
|
|
|
|
it("shows the floating timeline by default and hides it from the record action", () => {
|
|
const { container } = render(<App />);
|
|
|
|
expect(container.querySelector(".timeline-panel")).toBeInTheDocument();
|
|
|
|
act(() => {
|
|
useDirectorStore.getState().toggleTimelineVisible();
|
|
});
|
|
|
|
expect(container.querySelector(".timeline-panel")).not.toBeInTheDocument();
|
|
|
|
act(() => {
|
|
useDirectorStore.getState().toggleTimelineVisible();
|
|
});
|
|
|
|
expect(container.querySelector(".timeline-panel")).toBeInTheDocument();
|
|
});
|
|
|
|
it("switches from director mode to camera mode", async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
const directorButton = screen.getByRole("button", { name: "导演视角" });
|
|
const cameraButton = screen.getByRole("button", { name: "机位视角" });
|
|
|
|
expect(directorButton).toHaveAttribute("aria-pressed", "true");
|
|
expect(cameraButton).toHaveAttribute("aria-pressed", "false");
|
|
|
|
await user.click(cameraButton);
|
|
|
|
expect(directorButton).toHaveAttribute("aria-pressed", "false");
|
|
expect(cameraButton).toHaveAttribute("aria-pressed", "true");
|
|
});
|
|
|
|
it("adds a keyframe at the playhead when K is pressed while the recording panel is open", async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
act(() => {
|
|
useDirectorStore.getState().selectObject("char_default_a");
|
|
usePlaybackStore.getState().setPlayhead(1.5);
|
|
});
|
|
|
|
await user.keyboard("k");
|
|
|
|
const track = useDirectorStore
|
|
.getState()
|
|
.project.timeline.tracks.find((item) => item.objectId === "char_default_a");
|
|
|
|
expect(track?.keyframes).toHaveLength(1);
|
|
expect(track?.keyframes[0]?.time).toBe(1.5);
|
|
});
|
|
|
|
it("records a camera keyframe with K when the selected target is a camera object", async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
act(() => {
|
|
useDirectorStore.getState().selectObject("cam_object_1");
|
|
usePlaybackStore.getState().setPlayhead(2);
|
|
});
|
|
|
|
await user.keyboard("k");
|
|
|
|
const track = useDirectorStore
|
|
.getState()
|
|
.project.timeline.cameraTracks.find((item) => item.cameraId === "cam_1");
|
|
|
|
expect(track?.keyframes).toHaveLength(1);
|
|
expect(track?.keyframes[0]?.time).toBe(2);
|
|
});
|
|
|
|
it("ignores K while the recording panel is hidden and while typing in a field", async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
act(() => {
|
|
useDirectorStore.getState().selectObject("char_default_a");
|
|
// 录制面板隐藏 → K 不再是快捷键
|
|
useDirectorStore.getState().toggleTimelineVisible();
|
|
});
|
|
|
|
await user.keyboard("k");
|
|
expect(useDirectorStore.getState().project.timeline.tracks).toHaveLength(0);
|
|
|
|
act(() => {
|
|
useDirectorStore.getState().toggleTimelineVisible();
|
|
});
|
|
|
|
// 在输入框里敲 k 只应该输入文字,不能打点
|
|
await user.click(screen.getByLabelText("搜索场景内容"));
|
|
await user.keyboard("k");
|
|
|
|
expect(useDirectorStore.getState().project.timeline.tracks).toHaveLength(0);
|
|
});
|
|
|
|
it("supports Cmd/Ctrl+C and Cmd/Ctrl+V to duplicate the selected object", async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
await user.click(screen.getByRole("button", { name: "角色01" }));
|
|
await user.keyboard("{Control>}c{/Control}");
|
|
await user.keyboard("{Control>}v{/Control}");
|
|
|
|
const state = useDirectorStore.getState();
|
|
const characters = state.project.objects.filter((item) => item.kind === "character");
|
|
|
|
expect(characters).toHaveLength(2);
|
|
expect(characters[1]?.id).not.toBe("char_default_a");
|
|
expect(state.selectedObjectId).toBe(characters[1]?.id ?? null);
|
|
});
|
|
|
|
it("supports Cmd/Ctrl+Z to undo the latest scene edit", async () => {
|
|
const user = userEvent.setup();
|
|
render(<App />);
|
|
|
|
act(() => {
|
|
useDirectorStore.getState().addPresetCharacter("female");
|
|
});
|
|
expect(useDirectorStore.getState().project.objects.some((item) => item.name === "角色02")).toBe(true);
|
|
|
|
await user.keyboard("{Control>}z{/Control}");
|
|
|
|
expect(useDirectorStore.getState().project.objects.some((item) => item.name === "角色02")).toBe(false);
|
|
});
|