Files
Genarrative/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx
T
kdletters 06c12f40b6 修复画板 Portal 菜单主题继承
新增画板 Portal 主题桥接并同步亮暗主题
让编辑器根节点与 Portal 菜单共享完整品牌 Token
补充静音开关、角色动画与样式契约回归测试
同步图片画布技术方案和 Portal 排障记忆
2026-07-20 20:56:36 +08:00

503 lines
16 KiB
TypeScript

/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { useRef, useState } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasTool,
ImageContextMenuState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface';
const { mockUseAuthUi } = vi.hoisted(() => ({
mockUseAuthUi: vi.fn(() => ({
platformTheme: 'light' as 'light' | 'dark',
})),
}));
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: mockUseAuthUi,
}));
vi.mock('../../services/image-editor/editorImageReference', () => ({
resolveEditorImageReferenceDataUrl: vi.fn(async (src: string) => src),
}));
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorProjectClient')
>('../../services/image-editor/editorProjectClient');
return {
...actual,
editEditorImage: vi.fn(),
generateEditorCharacterAnimation: vi.fn(),
generateEditorIconSpritesheet: vi.fn(),
generateEditorImage: vi.fn(),
};
});
let mockedComposerRect: DOMRect | null = null;
let originalGetBoundingClientRect: typeof Element.prototype.getBoundingClientRect;
beforeEach(() => {
mockUseAuthUi.mockReturnValue({ platformTheme: 'light' });
originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function getBoundingClientRect() {
if (
mockedComposerRect &&
this instanceof HTMLElement &&
this.classList.contains('image-canvas-editor__generation-composer')
) {
return mockedComposerRect;
}
return originalGetBoundingClientRect.call(this);
};
});
afterEach(() => {
mockedComposerRect = null;
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
});
function createDomRect({
left,
top,
right,
bottom,
}: {
left: number;
top: number;
right: number;
bottom: number;
}) {
return {
x: left,
y: top,
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
toJSON: () => ({}),
} as DOMRect;
}
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
const id = overrides.id ?? 'layer-a';
return {
id,
resourceId: `resource-${id}`,
title: id,
src: `data:image/png;base64,${id}`,
x: 80,
y: 90,
width: 240,
height: 180,
originalWidth: 240,
originalHeight: 180,
zIndex: 1,
sourceType: 'uploaded',
...overrides,
};
}
function GenerationSurfaceHarness() {
const [layers, setLayers] = useState<CanvasLayer[]>([createLayer()]);
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
const [activeSidebarPanel, setActiveSidebarPanel] =
useState<SidebarPanel | null>('assets');
const [, setMetadataLayer] = useState<CanvasLayer | null>(null);
const [, setImageContextMenu] = useState<ImageContextMenuState | null>(null);
const layerCounterRef = useRef(0);
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const musicToolWrapRef = useRef<HTMLSpanElement | null>(null);
const publicationToolWrapRef = useRef<HTMLSpanElement | null>(null);
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const [viewport, setViewport] = useState({ x: 10, y: 20, scale: 2 });
const dialogs = useCanvasGenerationDialogs();
const activeDialog = dialogs.generateDialog;
const activeCanvasDialog =
activeDialog && 'id' in activeDialog
? (activeDialog as CanvasGenerationDialogState)
: null;
const surface = useImageCanvasGenerationSurface({
layers,
canvasSize: { width: 900, height: 640 },
viewport,
setViewport,
canvasViewportRef,
setLayers,
layerCounterRef,
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
characterSpecButtonRef,
characterReferenceButtonRef,
iconSpecButtonRef,
generationReferenceButtonRef,
publicationReferenceButtonRef,
generateDialog: dialogs.generateDialog,
setGenerateDialog: dialogs.setGenerateDialog,
activeCanvasGenerationDialog: activeCanvasDialog,
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog:
dialogs.archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
selectSingleLayer: () => {},
fitLayers: () => {},
captureCanvasHistory: () => {},
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
requestUpload: () => {},
});
return (
<div>
<span ref={specToolWrapRef}>规范入口</span>
<span
ref={musicToolWrapRef}
onPointerEnter={() => surface.openToolbarOptionMenu('music')}
onPointerLeave={() => surface.closeToolbarOptionMenu('music')}
>
音乐入口
</span>
<span ref={publicationToolWrapRef}>宣发入口</span>
<span data-testid="tool">{activeTool}</span>
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
<span data-testid="dialog">
{activeDialog
? `${activeDialog.mode}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.placeholder ? 'placeholder' : '-'}`
: '-'}
</span>
<span data-testid="composer-left">
{surface.generationComposerStyle?.left ?? '-'}
</span>
<span data-testid="composer-top">
{surface.generationComposerStyle?.top ?? '-'}
</span>
<span data-testid="viewport-x">{viewport.x}</span>
<span data-testid="viewport-y">{viewport.y}</span>
<button
type="button"
onClick={() => surface.switchGenerationTool('generate')}
>
切换生成
</button>
<button
type="button"
onClick={() => surface.switchGenerationTool('video')}
>
切换视频
</button>
<button
type="button"
onClick={() => surface.switchGenerationTool('music')}
>
切换音乐
</button>
<button
type="button"
onClick={() => surface.switchGenerationTool('spec')}
>
切换规范
</button>
<button
type="button"
onClick={() => surface.switchGenerationTool('character')}
>
切换角色
</button>
<button
type="button"
onClick={() => surface.switchGenerationTool('icon')}
>
切换图标
</button>
<button
type="button"
onClick={() => surface.switchGenerationTool('ui-design')}
>
切换UI设计
</button>
<button
type="button"
onClick={() => surface.switchGenerationTool('publication')}
>
切换宣发素材
</button>
<button
type="button"
onClick={() =>
surface.pickCharacterSpecFromLayer(
createLayer({
id: 'layer-character-image',
title: '角色形象 2',
assetKind: 'character',
}),
)
}
>
选择非规范角色图
</button>
<button
type="button"
onClick={() => surface.switchGenerationTool('select')}
>
切换选择
</button>
<div ref={canvasViewportRef} data-testid="viewport">
<div className="image-canvas-editor__bottom-toolbar">底部工具栏</div>
{surface.generationComposerNode}
</div>
</div>
);
}
describe('useImageCanvasGenerationSurface', () => {
it('switches generation tools while leaving non-generation tools untouched', () => {
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换选择' }));
expect(screen.getByTestId('tool').textContent).toBe('select');
fireEvent.click(screen.getByRole('button', { name: '切换生成' }));
expect(screen.getByTestId('tool').textContent).toBe('generate');
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:open:placeholder',
);
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
});
it('derives composer position without rendering a standalone close button', () => {
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换生成' }));
expect(screen.getByTestId('composer-left').textContent).toBe('450');
expect(screen.getByTestId('composer-top').textContent).toBe('1354');
expect(
screen.queryByRole('button', { name: '关闭生成图片' }),
).toBeNull();
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:open:placeholder',
);
});
it('opens the spec menu through the generation surface', () => {
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换规范' }));
expect(screen.getByTestId('tool').textContent).toBe('spec');
const menu = screen.getByRole('menu', { name: '生成规范类型' });
expect(
within(menu).getByRole('menuitem', { name: '角色规范' }),
).toBeTruthy();
});
it('bridges the current platform theme into body-level portal menus', () => {
mockUseAuthUi.mockReturnValue({ platformTheme: 'dark' });
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换规范' }));
const menu = screen.getByRole('menu', { name: '生成规范类型' });
const themeBridge = menu.closest('.image-canvas-editor__portal-theme');
expect(themeBridge?.parentElement).toBe(document.body);
expect(themeBridge?.classList.contains('platform-theme')).toBe(true);
expect(themeBridge?.classList.contains('platform-theme--dark')).toBe(true);
});
it('renders a warning toast when a non-spec layer is picked as character spec', () => {
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换角色' }));
fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' }));
expect(screen.getByRole('alert').textContent).toBe(
'选择的图片不是角色规范图,请选择生成规范里的角色规范。',
);
fireEvent.click(screen.getByRole('button', { name: '关闭提示' }));
expect(screen.queryByRole('alert')).toBeNull();
});
it('anchors the music menu to the music tool and closes after hover leaves', () => {
vi.useFakeTimers();
try {
render(<GenerationSurfaceHarness />);
const musicAnchor = screen.getByText('音乐入口');
Object.defineProperty(musicAnchor, 'getBoundingClientRect', {
configurable: true,
value: () => ({
x: 220,
y: 360,
left: 220,
top: 360,
right: 256,
bottom: 396,
width: 36,
height: 36,
toJSON: () => ({}),
}),
});
fireEvent.pointerEnter(musicAnchor);
const menu = screen.getByRole('menu', { name: '生成音乐类型' });
expect(menu.style.left).toBe('220px');
expect(menu.style.top).toBe('360px');
expect(menu.style.transform).toBe('translateY(calc(-100% - 0.45rem))');
expect(menu.getAttribute('style') ?? '').not.toContain('left: 50%');
fireEvent.pointerLeave(musicAnchor);
fireEvent.pointerEnter(menu);
act(() => {
vi.advanceTimersByTime(200);
});
expect(screen.getByRole('menu', { name: '生成音乐类型' })).toBeTruthy();
fireEvent.pointerLeave(menu);
act(() => {
vi.advanceTimersByTime(200);
});
expect(screen.queryByRole('menu', { name: '生成音乐类型' })).toBeNull();
} finally {
vi.useRealTimers();
}
});
it('opens video and UI design generation dialogs through the generation surface', () => {
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换视频' }));
expect(screen.getByTestId('tool').textContent).toBe('video');
expect(screen.getByTestId('dialog').textContent).toBe(
'video:open:placeholder',
);
expect(screen.getByRole('dialog', { name: '生成视频' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '切换UI设计' }));
expect(screen.getByTestId('tool').textContent).toBe('ui-design');
expect(screen.getByTestId('dialog').textContent).toBe(
'ui-design:open:placeholder',
);
expect(screen.getByRole('dialog', { name: '生成UI设计图' })).toBeTruthy();
});
it('opens the music choice menu before creating audio generation dialogs', () => {
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换音乐' }));
expect(screen.getByTestId('tool').textContent).toBe('music');
expect(screen.getByTestId('dialog').textContent).toBe('-');
const menu = screen.getByRole('menu', { name: '生成音乐类型' });
expect(
within(menu).getByRole('menuitem', { name: '生成游戏音效' }),
).toBeTruthy();
expect(
within(menu).getByRole('menuitem', { name: '生成游戏背景音乐' }),
).toBeTruthy();
fireEvent.click(
within(menu).getByRole('menuitem', { name: '生成游戏音效' }),
);
expect(screen.getByTestId('dialog').textContent).toBe(
'audio-sound-effect:open:placeholder',
);
expect(screen.getByRole('dialog', { name: '生成游戏音效' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '切换音乐' }));
fireEvent.click(
screen.getByRole('menuitem', { name: '生成游戏背景音乐' }),
);
expect(screen.getByTestId('dialog').textContent).toBe(
'audio-background-music:open:placeholder',
);
expect(
screen.getByRole('dialog', { name: '生成游戏背景音乐' }),
).toBeTruthy();
});
it('does not pan the canvas again when a focused composer leaves the visible viewport', async () => {
render(<GenerationSurfaceHarness />);
const viewportElement = screen.getByTestId('viewport');
const toolbarElement = viewportElement.querySelector(
'.image-canvas-editor__bottom-toolbar',
) as HTMLElement;
Object.defineProperty(viewportElement, 'getBoundingClientRect', {
configurable: true,
value: () => ({
x: 0,
y: 0,
left: 0,
top: 0,
right: 900,
bottom: 640,
width: 900,
height: 640,
toJSON: () => ({}),
}),
});
Object.defineProperty(toolbarElement, 'getBoundingClientRect', {
configurable: true,
value: () => ({
x: 250,
y: 570,
left: 250,
top: 570,
right: 650,
bottom: 620,
width: 400,
height: 50,
toJSON: () => ({}),
}),
});
mockedComposerRect = createDomRect({
left: 114,
top: 520,
right: 786,
bottom: 700,
});
fireEvent.click(screen.getByRole('button', { name: '切换生成' }));
await screen.findByRole('dialog', { name: '生成图片' });
const focusedViewportX = Number(
screen.getByTestId('viewport-x').textContent,
);
const focusedViewportY = Number(
screen.getByTestId('viewport-y').textContent,
);
await act(async () => {});
expect(focusedViewportX).toBe(10);
expect(focusedViewportY).toBe(1228);
expect(Number(screen.getByTestId('viewport-x').textContent)).toBe(
focusedViewportX,
);
expect(Number(screen.getByTestId('viewport-y').textContent)).toBe(
focusedViewportY,
);
});
});