Files
Genarrative/src/components/image-editor/ImageCanvasWorldView.test.tsx
T
JenkenB 097170836d Add UI-design asset extraction & spritesheet support
Introduce a new UI design asset extraction flow: add POST /api/editor/ui-designs/assets/extractions handler that always uses gpt-image-2 with the fixed prompt "提取画面中的所有独立并整理成spritesheet"; parse Data URL references, call OpenAI image edits, return a spritesheet and sliced icon assets. Increase request body limit for image Data URLs and wire the route with bearer auth. Add platform-image slicing: slice_generated_icon_spritesheet_all_by_connected_components (with auto-naming "素材 1..N", foreground estimation and heuristics) and export it. Add Rust tests for the handler, prompt constant, and slicing behavior. Update frontend client and integration tests to call the new extractEditorUiDesignAssets API and verify spritesheet + icon layers are added to the canvas and persisted. Update multiple docs to describe the extraction UI, toolbar placement, and related audio/playback pitfalls. Minor related updates: toolbar button ordering and audio duration / signed read-url notes.
2026-06-19 10:09:19 +08:00

680 lines
22 KiB
TypeScript

/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type {
CanvasGenerationDialogState,
CanvasLayer,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import { ImageCanvasWorldView } from './ImageCanvasWorldView';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-1',
resourceId: 'resource-1',
title: '角色主图',
src: 'data:image/png;base64,layer',
x: 120,
y: 160,
width: 320,
height: 240,
originalWidth: 640,
originalHeight: 480,
zIndex: 1,
sourceType: 'uploaded',
...overrides,
};
}
function createGenerationDialog(
overrides: Partial<CanvasGenerationDialogState> = {},
): CanvasGenerationDialogState {
return {
id: 'dialog-1',
mode: 'generate',
prompt: '生成一张图',
status: 'idle',
placeholder: {
x: 480,
y: 320,
width: 360,
height: 240,
originalWidth: 1024,
originalHeight: 768,
},
...overrides,
};
}
function renderWorldView(
overrides: Partial<Parameters<typeof ImageCanvasWorldView>[0]> = {},
) {
const props: Parameters<typeof ImageCanvasWorldView>[0] = {
viewport: { x: 10, y: 20, scale: 1.5 },
snapGuide: null,
layers: [createLayer()],
selectedLayerIds: [],
hoveredLayerId: null,
canvasMarquee: null,
canvasGenerationDialogs: [],
generateDialog: null,
cropExpandPanel: null,
cropExpandSourceLayer: null,
generationComposerStyle: null,
onLayerPointerDown: vi.fn(),
onLayerClick: vi.fn(),
onLayerContextMenu: vi.fn(),
onLayerMouseEnter: vi.fn(),
onLayerMouseLeave: vi.fn(),
onOpenLayerMetadata: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
...overrides,
};
const result = render(<ImageCanvasWorldView {...props} />);
return { props, ...result };
}
describe('ImageCanvasWorldView', () => {
it('renders visible layers and filters hidden layers', () => {
const visibleLayer = createLayer({ title: '可见图层', zIndex: 2 });
const hiddenLayer = createLayer({
id: 'layer-hidden',
resourceId: 'resource-hidden',
title: '隐藏图层',
hidden: true,
});
renderWorldView({
layers: [hiddenLayer, visibleLayer],
selectedLayerIds: [visibleLayer.id],
hoveredLayerId: visibleLayer.id,
});
const visibleButton = screen.getByRole('button', { name: '选择可见图层' });
expect(screen.queryByRole('button', { name: '选择隐藏图层' })).toBeNull();
expect(visibleButton.className).toContain(
'image-canvas-editor__layer--selected',
);
expect(visibleButton.className).toContain(
'image-canvas-editor__layer--hovered',
);
expect(visibleButton.className).not.toContain(
'image-canvas-editor__layer--generating',
);
expect(visibleButton.style.left).toBe('120px');
expect(visibleButton.style.top).toBe('160px');
expect(screen.getByText('640 x 480 px')).toBeTruthy();
expect(screen.queryByRole('status', { name: '' })).toBeNull();
});
it('forwards layer pointer, hover, context menu and metadata actions', () => {
const layer = createLayer({ assetKind: 'character' });
const { props } = renderWorldView({ layers: [layer] });
const layerButton = screen.getByRole('button', { name: '选择角色主图' });
const metadataButton = within(layerButton).getByRole('button', {
name: '查看角色主图图片信息',
});
fireEvent.pointerDown(layerButton);
fireEvent.click(layerButton);
fireEvent.contextMenu(layerButton);
fireEvent.mouseEnter(layerButton);
fireEvent.mouseLeave(layerButton);
fireEvent.click(metadataButton);
expect(props.onLayerPointerDown).toHaveBeenCalledWith(
expect.any(Object),
layer,
);
expect(props.onLayerClick).toHaveBeenCalledWith(expect.any(Object), layer);
expect(props.onLayerContextMenu).toHaveBeenCalledWith(
expect.any(Object),
layer,
);
expect(props.onLayerMouseEnter).toHaveBeenCalledWith(layer.id);
expect(props.onLayerMouseLeave).toHaveBeenCalledWith(layer.id);
expect(props.onOpenLayerMetadata).toHaveBeenCalledWith(layer);
expect(screen.getByText('角色')).toBeTruthy();
});
it('renders snap guides, marquee and floating generation status', () => {
renderWorldView({
snapGuide: { vertical: 288, horizontal: 344 },
canvasMarquee: {
pointerId: 1,
startX: 40,
startY: 50,
currentX: 190,
currentY: 230,
},
generateDialog: {
id: 'dialog-active',
mode: 'generate',
prompt: '生成中',
status: 'generating',
},
generationComposerStyle: { left: 12, top: 24 },
});
expect(
screen.getByTestId('image-canvas-editor-snap-guide-vertical').style.left,
).toBe('288px');
expect(
screen.getByTestId('image-canvas-editor-snap-guide-horizontal').style.top,
).toBe('344px');
expect(screen.getByText('生成中')).toBeTruthy();
const world = document.querySelector('.image-canvas-editor__world');
const marquee = world?.querySelector('.image-canvas-editor__canvas-marquee');
expect(world?.getAttribute('style')).toContain(
'transform: translate(10px, 20px) scale(1.5)',
);
expect((marquee as HTMLElement | null)?.style.width).toBe('100px');
expect((marquee as HTMLElement | null)?.style.height).toBe('120px');
});
it('renders generation placeholders and forwards frame actions', () => {
const dialog = createGenerationDialog({
mode: 'icon',
status: 'generating',
});
const { props } = renderWorldView({
canvasGenerationDialogs: [
dialog,
createGenerationDialog({
id: 'dialog-without-placeholder',
placeholder: undefined,
}),
],
generateDialog: dialog,
});
const frame = screen.getByRole('button', { name: '图标素材生成占位图' });
expect(frame.className).toContain(
'image-canvas-editor__generation-frame--focused',
);
expect(within(frame).getByText('Icon Generator')).toBeTruthy();
expect(within(frame).getByText('图标')).toBeTruthy();
expect(within(frame).getByText('1024 x 768')).toBeTruthy();
expect(within(frame).getByRole('status').textContent).toBe('生成中');
fireEvent.pointerDown(frame);
fireEvent.doubleClick(frame);
expect(props.onGenerationFramePointerDown).toHaveBeenCalledWith(
expect.any(Object),
dialog,
);
expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog);
expect(screen.queryByText('dialog-without-placeholder')).toBeNull();
});
it('renders crop-expand frame handles and forwards drag actions', () => {
const layer = createLayer();
const { props } = renderWorldView({
layers: [layer],
cropExpandSourceLayer: layer,
cropExpandPanel: {
sourceLayerId: layer.id,
frame: { x: 100, y: 140, width: 360, height: 300 },
ratio: 'free',
status: 'idle',
},
});
const frame = screen.getByRole('group', { name: '裁扩边界' });
const rightHandle = within(frame).getByRole('button', {
name: '拖拽裁扩右边',
});
expect(frame.style.left).toBe('100px');
expect(frame.style.width).toBe('360px');
fireEvent.pointerDown(rightHandle);
expect(props.onCropExpandHandlePointerDown).toHaveBeenCalledWith(
expect.any(Object),
'right',
);
});
it('hides idle placeholder chrome after blur and restores it when focused', () => {
const dialog = createGenerationDialog({
id: 'dialog-idle',
mode: 'generate',
status: 'idle',
});
const { rerender, props } = renderWorldView({
canvasGenerationDialogs: [dialog],
generateDialog: null,
});
const blurredFrame = screen.getByRole('button', { name: '图像生成占位图' });
expect(blurredFrame.className).not.toContain(
'image-canvas-editor__generation-frame--focused',
);
expect(within(blurredFrame).queryByText('Image Generator')).toBeNull();
expect(within(blurredFrame).queryByText('1024 x 768')).toBeNull();
rerender(<ImageCanvasWorldView {...props} generateDialog={dialog} />);
const focusedFrame = screen.getByRole('button', { name: '图像生成占位图' });
expect(focusedFrame.className).toContain(
'image-canvas-editor__generation-frame--focused',
);
expect(within(focusedFrame).getByText('Image Generator')).toBeTruthy();
expect(within(focusedFrame).getByText('1024 x 768')).toBeTruthy();
});
it.each([
{
mode: 'character',
ariaLabel: '角色生成占位图',
generatorLabel: 'Character Generator',
badgeLabel: '角色',
frameClassName: 'image-canvas-editor__generation-frame--character',
iconClassName: 'lucide-user-round',
},
{
mode: 'character-animation',
ariaLabel: '角色动作生成占位图',
generatorLabel: 'Action Generator',
badgeLabel: '动作',
frameClassName:
'image-canvas-editor__generation-frame--character-animation',
iconClassName: 'lucide-person-standing',
},
{
mode: 'video',
ariaLabel: '视频生成占位图',
generatorLabel: 'Video Generator',
badgeLabel: '视频',
frameClassName: 'image-canvas-editor__generation-frame--video',
iconClassName: 'lucide-film',
},
{
mode: 'audio-sound-effect',
ariaLabel: '音效生成占位图',
generatorLabel: 'Sound Generator',
badgeLabel: '音效',
frameClassName:
'image-canvas-editor__generation-frame--audio-sound-effect',
iconClassName: 'lucide-volume-2',
},
{
mode: 'audio-background-music',
ariaLabel: '背景音乐生成占位图',
generatorLabel: 'Music Generator',
badgeLabel: '背景音乐',
frameClassName:
'image-canvas-editor__generation-frame--audio-background-music',
iconClassName: 'lucide-music-2',
},
])(
'renders $mode generation placeholder with its own icon and badge',
({ mode, ariaLabel, generatorLabel, badgeLabel, frameClassName, iconClassName }) => {
const dialog = createGenerationDialog({
id: `dialog-${mode}`,
mode: mode as CanvasGenerationDialogState['mode'],
});
renderWorldView({
canvasGenerationDialogs: [dialog],
generateDialog: dialog as GenerateDialogState,
});
const frame = screen.getByRole('button', { name: ariaLabel });
const generatorLabelNode = within(frame).getByText(
generatorLabel,
) as HTMLElement;
expect(frame.className).toContain(frameClassName);
expect(generatorLabelNode).toBeTruthy();
expect(
generatorLabelNode.querySelector('svg')?.getAttribute('class'),
).toContain(iconClassName);
expect(within(frame).getByText(badgeLabel)).toBeTruthy();
expect(
frame
.querySelector('.image-canvas-editor__generation-frame-icon svg')
?.getAttribute('class'),
).toContain(iconClassName);
},
);
it.each([
{
mode: 'character',
ariaLabel: '角色生成占位图',
generatorLabel: 'Character Generator',
badgeLabel: '角色',
},
{
mode: 'character-animation',
ariaLabel: '角色动作生成占位图',
generatorLabel: 'Action Generator',
badgeLabel: '动作',
},
{
mode: 'video',
ariaLabel: '视频生成占位图',
generatorLabel: 'Video Generator',
badgeLabel: '视频',
},
{
mode: 'audio-sound-effect',
ariaLabel: '音效生成占位图',
generatorLabel: 'Sound Generator',
badgeLabel: '音效',
metricLabel: '时长 --:--',
},
{
mode: 'audio-background-music',
ariaLabel: '背景音乐生成占位图',
generatorLabel: 'Music Generator',
badgeLabel: '背景音乐',
metricLabel: '时长 --:--',
},
])(
'keeps $mode generation title size and badge readable when zoomed out',
({ mode, ariaLabel, generatorLabel, badgeLabel, metricLabel }) => {
const dialog = createGenerationDialog({
id: `dialog-zoom-${mode}`,
mode: mode as CanvasGenerationDialogState['mode'],
});
renderWorldView({
viewport: { x: 0, y: 0, scale: 0.25 },
canvasGenerationDialogs: [dialog],
generateDialog: dialog as GenerateDialogState,
});
const frame = screen.getByRole('button', { name: ariaLabel });
const inverseScale = '4';
expect(
(within(frame).getByText(generatorLabel) as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
).toBe(inverseScale);
expect(
(
within(frame).getByText(metricLabel ?? '1024 x 768') as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
(within(frame).getByText(badgeLabel) as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
).toBe(inverseScale);
},
);
it.each([
{
mode: 'character',
ariaLabel: '角色生成占位图',
generatorLabel: 'Character Generator',
badgeLabel: '角色',
},
{
mode: 'character-animation',
ariaLabel: '角色动作生成占位图',
generatorLabel: 'Action Generator',
badgeLabel: '动作',
},
{
mode: 'video',
ariaLabel: '视频生成占位图',
generatorLabel: 'Video Generator',
badgeLabel: '视频',
},
{
mode: 'audio-sound-effect',
ariaLabel: '音效生成占位图',
generatorLabel: 'Sound Generator',
badgeLabel: '音效',
},
{
mode: 'audio-background-music',
ariaLabel: '背景音乐生成占位图',
generatorLabel: 'Music Generator',
badgeLabel: '背景音乐',
},
])(
'keeps $mode placeholder icon and badge visible after blur without helper chrome',
({ mode, ariaLabel, generatorLabel, badgeLabel }) => {
const dialog = createGenerationDialog({
id: `dialog-blurred-${mode}`,
mode: mode as CanvasGenerationDialogState['mode'],
status: 'idle',
});
renderWorldView({
generateDialog: null,
canvasGenerationDialogs: [dialog],
});
const frame = screen.getByRole('button', { name: ariaLabel });
expect(frame.className).not.toContain(
'image-canvas-editor__generation-frame--focused',
);
expect(within(frame).queryByText(generatorLabel)).toBeNull();
expect(within(frame).queryByText('1024 x 768')).toBeNull();
expect(within(frame).getByText(badgeLabel)).toBeTruthy();
expect(
frame.querySelector('.image-canvas-editor__generation-frame-icon svg'),
).toBeTruthy();
},
);
it('keeps layer and generation chrome readable when the canvas is zoomed out', () => {
const layer = createLayer({ assetKind: 'character' });
const dialog = createGenerationDialog({ id: 'dialog-focused' });
renderWorldView({
viewport: { x: 0, y: 0, scale: 0.25 },
layers: [layer],
hoveredLayerId: layer.id,
canvasGenerationDialogs: [dialog],
generateDialog: dialog,
});
const layerButton = screen.getByRole('button', { name: '选择角色主图' });
const frame = screen.getByRole('button', { name: '图像生成占位图' });
const inverseScale = '4';
expect(
(within(layerButton).getByText('角色') as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
).toBe(inverseScale);
expect(
(within(layerButton).getByText('640 x 480 px') as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
).toBe(inverseScale);
expect(
within(layerButton)
.getByRole('button', { name: '查看角色主图图片信息' })
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
(within(frame).getByText('Image Generator') as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
).toBe(inverseScale);
expect(
(within(frame).getByText('1024 x 768') as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
).toBe(inverseScale);
});
it('uses a plain information icon for metadata corner actions', () => {
const layer = createLayer();
const { props } = renderWorldView({ layers: [layer] });
const metadataButton = screen.getByRole('button', {
name: '查看角色主图图片信息',
});
expect(metadataButton.querySelector('svg')?.getAttribute('class')).toContain(
'lucide-info',
);
});
it('renders audio layers with an audio control card instead of an image', () => {
const layer = createLayer({
title: '游戏音效',
src: '/generated-character-drafts/editor-audios/sfx.mp3',
mediaType: 'audio',
assetKind: 'sound-effect',
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
});
const { props } = renderWorldView({ layers: [layer] });
const layerButton = screen.getByRole('button', { name: '选择游戏音效' });
const audio = within(layerButton).getByLabelText('画布音频:游戏音效');
expect(audio.tagName.toLowerCase()).toBe('audio');
expect(within(layerButton).queryByAltText('画布图片:游戏音效')).toBeNull();
expect(within(layerButton).getByText('音效')).toBeTruthy();
expect(within(layerButton).getByText('时长 --:--')).toBeTruthy();
});
it('keeps audio controls interactive and shows the generated prompt', () => {
const layer = createLayer({
title: '游戏背景音乐',
src: 'data:audio/mpeg;base64,audio',
mediaType: 'audio',
assetKind: 'background-music',
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
prompt: '紧张的地下城循环音乐',
durationSeconds: 75,
});
const { props } = renderWorldView({
layers: [layer],
selectedLayerIds: [layer.id],
});
const layerButton = screen.getByRole('button', { name: '选择游戏背景音乐' });
const audio = within(layerButton).getByLabelText('画布音频:游戏背景音乐');
expect(within(layerButton).getByText('紧张的地下城循环音乐')).toBeTruthy();
expect(within(layerButton).getByText('时长 1:15')).toBeTruthy();
fireEvent.pointerDown(audio);
fireEvent.click(audio);
expect(props.onLayerPointerDown).not.toHaveBeenCalled();
expect(props.onLayerClick).not.toHaveBeenCalled();
});
it('places layer type labels to the left of the information action', () => {
const layer = createLayer({ assetKind: 'character' });
const { props } = renderWorldView({ layers: [layer] });
const layerButton = screen.getByRole('button', { name: '选择角色主图' });
const badge = within(layerButton).getByText('角色');
const metadataButton = within(layerButton).getByRole('button', {
name: '查看角色主图图片信息',
});
expect(badge.className).toContain(
'image-canvas-editor__kind-badge--beside-info',
);
expect(metadataButton.className).not.toContain(
'image-canvas-editor__metadata-corner--beside-kind',
);
});
it('shows duration instead of resolution for focused audio generation frames', () => {
const dialog = createGenerationDialog({
id: 'dialog-audio',
mode: 'audio-sound-effect',
});
renderWorldView({
canvasGenerationDialogs: [dialog],
generateDialog: dialog,
});
const frame = screen.getByRole('button', { name: '音效生成占位图' });
expect(within(frame).getByText('时长 --:--')).toBeTruthy();
expect(within(frame).queryByText('1024 x 768')).toBeNull();
});
it('renders video layers with a playable video element instead of an image', () => {
const layer = createLayer({
title: '生成视频 7',
src: '/generated-character-drafts/editor-videos/video-7.mp4',
mediaType: 'video',
assetKind: 'video',
width: 854,
height: 480,
originalWidth: 854,
originalHeight: 480,
});
const { props } = renderWorldView({ layers: [layer] });
const layerButton = screen.getByRole('button', { name: '选择生成视频 7' });
const video = within(layerButton).getByLabelText('画布视频:生成视频 7');
fireEvent.pointerDown(video);
fireEvent.click(video);
expect(video.tagName.toLowerCase()).toBe('video');
expect(video.getAttribute('src')).toBe(
'/generated-character-drafts/editor-videos/video-7.mp4',
);
expect(video.hasAttribute('controls')).toBe(true);
expect(video.hasAttribute('playsinline')).toBe(true);
expect(video.getAttribute('preload')).toBe('metadata');
expect(within(layerButton).queryByAltText('画布视频:生成视频 7')).toBeNull();
expect(within(layerButton).getByText('视频')).toBeTruthy();
expect(props.onLayerPointerDown).not.toHaveBeenCalled();
expect(props.onLayerClick).not.toHaveBeenCalled();
});
it('keeps saved generator placeholders hidden after a result layer exists', () => {
renderWorldView({
layers: [createLayer({ id: 'layer-generated', title: '生成图片' })],
canvasGenerationDialogs: [
createGenerationDialog({
generatedLayerId: 'layer-generated',
placeholder: {
x: 80,
y: 90,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
}),
],
});
expect(screen.getByRole('button', { name: '选择生成图片' })).toBeTruthy();
expect(
screen.queryByRole('button', { name: '图像生成占位图' }),
).toBeNull();
});
});