1671d35995
让画布 Agent 消息统一走后端规划与 LLM 回复 修复生成图片在聊天、画布和素材库中的展示与恢复 补齐右键删除、画布焦点、聊天滚轮和面板避让交互 同步 Agent 事件契约、文档和定向回归测试
1232 lines
40 KiB
TypeScript
1232 lines
40 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type {
|
|
CanvasGenerationDialogState,
|
|
CanvasLayer,
|
|
GenerateDialogState,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { ImageCanvasWorldView } from './ImageCanvasWorldView';
|
|
|
|
const useResolvedAssetReadUrlMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
|
useResolvedAssetReadUrl: useResolvedAssetReadUrlMock,
|
|
}));
|
|
|
|
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(),
|
|
onSelectLayer: vi.fn(),
|
|
onLayerContextMenu: vi.fn(),
|
|
onLayerMouseEnter: vi.fn(),
|
|
onLayerMouseLeave: vi.fn(),
|
|
onOpenLayerMetadata: vi.fn(),
|
|
onUpdateLayerAssetKind: vi.fn(),
|
|
onGenerationFramePointerDown: vi.fn(),
|
|
onGenerationFrameContextMenu: vi.fn(),
|
|
onActivateGenerationDialog: vi.fn(),
|
|
onCropExpandHandlePointerDown: vi.fn(),
|
|
...overrides,
|
|
};
|
|
|
|
const result = render(<ImageCanvasWorldView {...props} />);
|
|
|
|
return { props, ...result };
|
|
}
|
|
|
|
describe('ImageCanvasWorldView', () => {
|
|
beforeEach(() => {
|
|
useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({
|
|
resolvedUrl: source,
|
|
isResolving: false,
|
|
shouldResolve: false,
|
|
}));
|
|
});
|
|
|
|
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('keeps the image layer frame visible while the image is loading', () => {
|
|
useResolvedAssetReadUrlMock.mockImplementation(() => ({
|
|
resolvedUrl: '',
|
|
isResolving: true,
|
|
shouldResolve: true,
|
|
}));
|
|
|
|
renderWorldView();
|
|
|
|
const layerButton = screen.getByRole('button', { name: '选择角色主图' });
|
|
|
|
expect(
|
|
layerButton.querySelector('.image-canvas-editor__layer-image-loading'),
|
|
).toBeTruthy();
|
|
expect(within(layerButton).queryByAltText('画布图片:角色主图')).toBeNull();
|
|
});
|
|
|
|
it('marks an already decoded cached image as loaded', async () => {
|
|
const completeDescriptor = Object.getOwnPropertyDescriptor(
|
|
HTMLImageElement.prototype,
|
|
'complete',
|
|
);
|
|
const naturalWidthDescriptor = Object.getOwnPropertyDescriptor(
|
|
HTMLImageElement.prototype,
|
|
'naturalWidth',
|
|
);
|
|
|
|
Object.defineProperty(HTMLImageElement.prototype, 'complete', {
|
|
configurable: true,
|
|
get: () => true,
|
|
});
|
|
Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', {
|
|
configurable: true,
|
|
get: () => 128,
|
|
});
|
|
|
|
try {
|
|
renderWorldView();
|
|
|
|
const image = screen.getByAltText('画布图片:角色主图');
|
|
|
|
await waitFor(() => {
|
|
expect(image.className).toContain(
|
|
'image-canvas-editor__layer-image--loaded',
|
|
);
|
|
});
|
|
} finally {
|
|
if (completeDescriptor) {
|
|
Object.defineProperty(
|
|
HTMLImageElement.prototype,
|
|
'complete',
|
|
completeDescriptor,
|
|
);
|
|
} else {
|
|
Reflect.deleteProperty(HTMLImageElement.prototype, 'complete');
|
|
}
|
|
if (naturalWidthDescriptor) {
|
|
Object.defineProperty(
|
|
HTMLImageElement.prototype,
|
|
'naturalWidth',
|
|
naturalWidthDescriptor,
|
|
);
|
|
} else {
|
|
Reflect.deleteProperty(HTMLImageElement.prototype, 'naturalWidth');
|
|
}
|
|
}
|
|
});
|
|
|
|
it('resolves image layers from objectKey before falling back to src', () => {
|
|
useResolvedAssetReadUrlMock.mockImplementation(
|
|
(_source: string, options?: { objectKey?: string | null }) => ({
|
|
resolvedUrl:
|
|
options?.objectKey === 'generated-character-drafts/editor/uploaded.png'
|
|
? 'https://oss.example.com/uploaded.png?signature=1'
|
|
: _source,
|
|
isResolving: false,
|
|
shouldResolve: true,
|
|
}),
|
|
);
|
|
|
|
renderWorldView({
|
|
layers: [
|
|
createLayer({
|
|
objectKey: 'generated-character-drafts/editor/uploaded.png',
|
|
src: '/generated-character-drafts/editor/stale.png',
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(useResolvedAssetReadUrlMock).toHaveBeenCalledWith(
|
|
'/generated-character-drafts/editor/stale.png',
|
|
expect.objectContaining({
|
|
objectKey: 'generated-character-drafts/editor/uploaded.png',
|
|
refreshKey: 'resource-1',
|
|
}),
|
|
);
|
|
expect(screen.getByAltText('画布图片:角色主图').getAttribute('src')).toBe(
|
|
'https://oss.example.com/uploaded.png?signature=1',
|
|
);
|
|
});
|
|
|
|
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('shows editable kind tags for tagged and untagged layers', () => {
|
|
const layer = createLayer({ assetKind: null });
|
|
renderWorldView({
|
|
layers: [
|
|
layer,
|
|
createLayer({
|
|
id: 'layer-character',
|
|
resourceId: 'resource-character',
|
|
title: '角色标签图层',
|
|
assetKind: 'character',
|
|
}),
|
|
],
|
|
});
|
|
|
|
const layerButton = screen.getByRole('button', { name: '选择角色主图' });
|
|
const characterButton = screen.getByRole('button', {
|
|
name: '选择角色标签图层',
|
|
});
|
|
|
|
expect(within(layerButton).getByText('未知')).toBeTruthy();
|
|
expect(within(characterButton).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(
|
|
within(frame).queryByRole('button', { name: '删除图标素材生成占位图' }),
|
|
).toBeNull();
|
|
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: 'publication',
|
|
ariaLabel: '宣发素材生成占位图',
|
|
generatorLabel: 'Home Image Generator',
|
|
badgeLabel: '宣发',
|
|
frameClassName: 'image-canvas-editor__generation-frame--publication',
|
|
iconClassName: 'lucide-megaphone',
|
|
},
|
|
{
|
|
mode: 'spec',
|
|
ariaLabel: '规范生成占位图',
|
|
generatorLabel: 'Spec Generator',
|
|
badgeLabel: '规范',
|
|
frameClassName: 'image-canvas-editor__generation-frame--spec',
|
|
iconClassName: 'lucide-clipboard-list',
|
|
},
|
|
{
|
|
mode: 'ui-design',
|
|
ariaLabel: 'UI设计图生成占位图',
|
|
generatorLabel: 'UI Design Generator',
|
|
badgeLabel: 'UI设计',
|
|
frameClassName: 'image-canvas-editor__generation-frame--ui-design',
|
|
iconClassName: 'lucide-app-window',
|
|
},
|
|
{
|
|
mode: 'quick-edit',
|
|
ariaLabel: '快速编辑生成占位图',
|
|
generatorLabel: 'Quick Edit Generator',
|
|
badgeLabel: '快编',
|
|
frameClassName: 'image-canvas-editor__generation-frame--quick-edit',
|
|
iconClassName: 'lucide-sparkles',
|
|
},
|
|
{
|
|
mode: 'video',
|
|
ariaLabel: '视频生成占位图',
|
|
generatorLabel: 'Video Generator',
|
|
badgeLabel: '视频',
|
|
frameClassName: 'image-canvas-editor__generation-frame--video',
|
|
iconClassName: 'lucide-clapperboard',
|
|
},
|
|
{
|
|
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',
|
|
},
|
|
])(
|
|
'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: 'publication',
|
|
ariaLabel: '宣发素材生成占位图',
|
|
generatorLabel: 'Home Image 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 generation title size and badge readable when zoomed out',
|
|
({ mode, ariaLabel, generatorLabel, badgeLabel }) => {
|
|
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(
|
|
mode === 'audio-sound-effect' || mode === 'audio-background-music'
|
|
? within(frame).queryByText('1024 x 768')
|
|
: (
|
|
within(frame).getByText('1024 x 768') as HTMLElement
|
|
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
|
).toBe(
|
|
mode === 'audio-sound-effect' || mode === 'audio-background-music'
|
|
? null
|
|
: 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: 'publication',
|
|
ariaLabel: '宣发素材生成占位图',
|
|
generatorLabel: 'Home Image 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('keeps blank generation placeholder icons readable when the canvas is zoomed out', () => {
|
|
const dialog = createGenerationDialog({ id: 'dialog-icon-scale' });
|
|
|
|
renderWorldView({
|
|
viewport: { x: 0, y: 0, scale: 0.25 },
|
|
canvasGenerationDialogs: [dialog],
|
|
generateDialog: null,
|
|
});
|
|
|
|
const frame = screen.getByRole('button', { name: '图像生成占位图' });
|
|
expect(
|
|
(frame.querySelector('.image-canvas-editor__generation-frame-icon') as HTMLElement)
|
|
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
|
).toBe('4');
|
|
});
|
|
|
|
it('does not inverse-scale the icon generator decorative tile when zoomed out', () => {
|
|
const dialog = createGenerationDialog({
|
|
id: 'dialog-icon-decorative-scale',
|
|
mode: 'icon',
|
|
});
|
|
|
|
renderWorldView({
|
|
viewport: { x: 0, y: 0, scale: 0.25 },
|
|
canvasGenerationDialogs: [dialog],
|
|
generateDialog: null,
|
|
});
|
|
|
|
const frame = screen.getByRole('button', { name: '图标素材生成占位图' });
|
|
const iconTile = frame.querySelector(
|
|
'.image-canvas-editor__generation-frame-icon',
|
|
) as HTMLElement;
|
|
|
|
expect(
|
|
iconTile.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
|
).toBe('');
|
|
});
|
|
|
|
it('uses the shared generating icon scale for icon placeholders', () => {
|
|
const dialog = createGenerationDialog({
|
|
id: 'dialog-icon-generating-scale',
|
|
mode: 'icon',
|
|
status: 'generating',
|
|
});
|
|
|
|
renderWorldView({
|
|
viewport: { x: 0, y: 0, scale: 0.25 },
|
|
canvasGenerationDialogs: [dialog],
|
|
generateDialog: dialog,
|
|
});
|
|
|
|
const frame = screen.getByRole('button', { name: '图标素材生成占位图' });
|
|
const iconTile = frame.querySelector(
|
|
'.image-canvas-editor__generation-frame-icon',
|
|
) as HTMLElement;
|
|
|
|
expect(
|
|
iconTile.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
|
).toBe('4');
|
|
});
|
|
|
|
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('画布音频:游戏音效');
|
|
const preview = layerButton.querySelector(
|
|
'.image-canvas-editor__media-preview--audio',
|
|
);
|
|
expect(audio.tagName.toLowerCase()).toBe('audio');
|
|
expect(preview?.className).toContain(
|
|
'image-canvas-editor__media-preview--variant-',
|
|
);
|
|
expect(audio.hasAttribute('controls')).toBe(false);
|
|
expect(within(layerButton).queryByAltText('画布图片:游戏音效')).toBeNull();
|
|
expect(within(layerButton).getByText('音效')).toBeTruthy();
|
|
expect(within(layerButton).queryByText(/时长/u)).toBeNull();
|
|
expect(
|
|
within(layerButton).getAllByRole('button', { name: /播放|暂停/u }),
|
|
).toHaveLength(1);
|
|
expect(
|
|
within(layerButton).getByRole('button', { name: '播放游戏音效' })
|
|
.querySelector('svg')
|
|
?.getAttribute('class'),
|
|
).toContain('lucide-volume-2');
|
|
expect(
|
|
layerButton
|
|
.querySelector('.image-canvas-editor__audio-card-controls')
|
|
?.className.includes(
|
|
'image-canvas-editor__audio-card-controls--integrated',
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
layerButton
|
|
.querySelector('.image-canvas-editor__audio-card')
|
|
?.className.includes(
|
|
'image-canvas-editor__audio-card--controls-visible',
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
within(layerButton).getByLabelText('调整游戏音效播放进度'),
|
|
).toBeTruthy();
|
|
expect(within(layerButton).getByLabelText('静音游戏音效')).toBeTruthy();
|
|
expect(within(layerButton).getByLabelText('调整游戏音效音量')).toBeTruthy();
|
|
expect(
|
|
within(layerButton).queryByText('420 x 120 px'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('keeps audio controls interactive and switches icon, play and pause states', () => {
|
|
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, rerender } = renderWorldView({
|
|
layers: [layer],
|
|
selectedLayerIds: [layer.id],
|
|
});
|
|
|
|
const layerButton = screen.getByRole('button', { name: '选择游戏背景音乐' });
|
|
const audio = within(layerButton).getByLabelText('画布音频:游戏背景音乐');
|
|
const playbackButton = within(layerButton).getByRole('button', {
|
|
name: '播放游戏背景音乐',
|
|
});
|
|
|
|
expect(within(layerButton).getByText('紧张的地下城循环音乐')).toBeTruthy();
|
|
expect(within(layerButton).queryByText(/时长/u)).toBeNull();
|
|
expect(
|
|
playbackButton.querySelector('svg')?.getAttribute('class'),
|
|
).toContain('lucide-music');
|
|
|
|
rerender(
|
|
<ImageCanvasWorldView
|
|
{...props}
|
|
layers={[layer]}
|
|
hoveredLayerId={layer.id}
|
|
selectedLayerIds={[layer.id]}
|
|
/>,
|
|
);
|
|
const hoveredLayerButton = screen.getByRole('button', {
|
|
name: '选择游戏背景音乐',
|
|
});
|
|
expect(
|
|
hoveredLayerButton
|
|
.querySelector('.image-canvas-editor__audio-card')
|
|
?.className.includes(
|
|
'image-canvas-editor__audio-card--controls-visible',
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
within(hoveredLayerButton)
|
|
.getByRole('button', { name: '播放游戏背景音乐' })
|
|
.querySelector('svg')
|
|
?.getAttribute('class'),
|
|
).toContain('lucide-play');
|
|
|
|
const hoveredAudio = within(hoveredLayerButton).getByLabelText(
|
|
'画布音频:游戏背景音乐',
|
|
);
|
|
fireEvent.play(hoveredAudio);
|
|
expect(
|
|
within(hoveredLayerButton)
|
|
.getByRole('button', { name: '暂停游戏背景音乐' })
|
|
.querySelector('svg')
|
|
?.getAttribute('class'),
|
|
).toContain('lucide-pause');
|
|
|
|
fireEvent.pointerDown(hoveredAudio);
|
|
fireEvent.click(hoveredAudio);
|
|
|
|
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('keeps the layer type menu scrollable and closes it when clicking outside', () => {
|
|
const layer = createLayer({ assetKind: 'character' });
|
|
const documentWheel = vi.fn();
|
|
document.addEventListener('wheel', documentWheel);
|
|
|
|
try {
|
|
renderWorldView({ layers: [layer] });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '素材标签:角色' }));
|
|
|
|
const menu = screen.getByRole('menu', { name: '选择素材标签' });
|
|
fireEvent.wheel(menu, { deltaY: 120 });
|
|
|
|
expect(documentWheel).not.toHaveBeenCalled();
|
|
expect(screen.getByRole('menu', { name: '选择素材标签' })).toBeTruthy();
|
|
|
|
fireEvent.pointerDown(menu);
|
|
expect(screen.getByRole('menu', { name: '选择素材标签' })).toBeTruthy();
|
|
|
|
fireEvent.pointerDown(document.body);
|
|
expect(
|
|
screen.queryByRole('menu', { name: '选择素材标签' }),
|
|
).toBeNull();
|
|
} finally {
|
|
document.removeEventListener('wheel', documentWheel);
|
|
}
|
|
});
|
|
|
|
it('hides resolution and duration text 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).queryByText('时长 --:--')).toBeNull();
|
|
expect(within(frame).queryByText('1024 x 768')).toBeNull();
|
|
});
|
|
|
|
it('renders video layers with a playable video element instead of an image', () => {
|
|
useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({
|
|
resolvedUrl:
|
|
source === '/generated-character-drafts/editor-videos/video-7.mp4'
|
|
? 'https://oss.example.com/video-7.mp4?signature=1'
|
|
: source === '/generated-character-drafts/editor/video-7-poster.png'
|
|
? 'https://oss.example.com/video-7-poster.png?signature=1'
|
|
: source,
|
|
isResolving: false,
|
|
shouldResolve: true,
|
|
}));
|
|
const layer = createLayer({
|
|
title: '生成视频 7',
|
|
src: '/generated-character-drafts/editor-videos/video-7.mp4',
|
|
mediaType: 'video',
|
|
assetKind: 'video',
|
|
thumbnailSrc: '/generated-character-drafts/editor/video-7-poster.png',
|
|
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(
|
|
'https://oss.example.com/video-7.mp4?signature=1',
|
|
);
|
|
expect(video.getAttribute('poster')).toBe(
|
|
'https://oss.example.com/video-7-poster.png?signature=1',
|
|
);
|
|
expect(video.hasAttribute('controls')).toBe(true);
|
|
expect(video.hasAttribute('playsinline')).toBe(true);
|
|
expect(video.getAttribute('preload')).toBe('metadata');
|
|
expect(
|
|
layerButton.querySelector('.image-canvas-editor__media-preview--video')
|
|
?.className,
|
|
).toContain('image-canvas-editor__media-preview--variant-');
|
|
expect(within(layerButton).queryByAltText('画布视频:生成视频 7')).toBeNull();
|
|
expect(within(layerButton).getByText('视频')).toBeTruthy();
|
|
expect(props.onLayerPointerDown).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
layer,
|
|
);
|
|
expect(props.onLayerClick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps private video src empty while signed url resolution fails', () => {
|
|
useResolvedAssetReadUrlMock.mockImplementation(() => ({
|
|
resolvedUrl: '',
|
|
isResolving: false,
|
|
shouldResolve: true,
|
|
}));
|
|
const layer = createLayer({
|
|
title: '私有视频',
|
|
src: '/generated-character-drafts/editor-videos/private.mp4',
|
|
mediaType: 'video',
|
|
objectKey: 'generated-character-drafts/editor-videos/private.mp4',
|
|
assetKind: 'video',
|
|
});
|
|
|
|
renderWorldView({ layers: [layer] });
|
|
|
|
const layerButton = screen.getByRole('button', { name: '选择私有视频' });
|
|
const video = within(layerButton).getByLabelText('画布视频:私有视频');
|
|
expect(video.hasAttribute('src')).toBe(false);
|
|
expect(useResolvedAssetReadUrlMock).toHaveBeenCalledWith(
|
|
'/generated-character-drafts/editor-videos/private.mp4',
|
|
expect.objectContaining({
|
|
objectKey: 'generated-character-drafts/editor-videos/private.mp4',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('renders character animation layers as an image sequence player', () => {
|
|
useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({
|
|
resolvedUrl:
|
|
source === '/generated-character-drafts/editor/frame01.png'
|
|
? 'https://oss.example.com/frame01.png?signature=1'
|
|
: source,
|
|
isResolving: false,
|
|
shouldResolve: true,
|
|
}));
|
|
const layer = createLayer({
|
|
title: '角色动作',
|
|
src: '/generated-character-drafts/editor/frame01.png',
|
|
mediaType: 'image-sequence',
|
|
assetKind: 'character-animation',
|
|
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
|
imageSequenceFrames: [
|
|
{
|
|
frameIndex: 1,
|
|
imageSrc: '/generated-character-drafts/editor/frame01.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
{
|
|
frameIndex: 2,
|
|
imageSrc: '/generated-character-drafts/editor/frame02.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
],
|
|
durationSeconds: 4,
|
|
});
|
|
|
|
renderWorldView({ layers: [layer] });
|
|
|
|
const layerButton = screen.getByRole('button', { name: '选择角色动作' });
|
|
const frame = within(layerButton).getByAltText('画布序列帧:角色动作');
|
|
const toggle = within(layerButton).getByRole('button', {
|
|
name: '暂停角色动作序列帧播放',
|
|
});
|
|
|
|
expect(frame.getAttribute('src')).toBe(
|
|
'https://oss.example.com/frame01.png?signature=1',
|
|
);
|
|
expect(frame.className).toBe(
|
|
'image-canvas-editor__layer-image-sequence-frame',
|
|
);
|
|
expect(frame.style.opacity).toBe('0');
|
|
expect(frame.style.transition).toBe('none');
|
|
fireEvent.load(frame);
|
|
expect(frame.style.opacity).toBe('1');
|
|
expect(frame.style.transition).toBe('none');
|
|
expect(within(layerButton).getByText('1/2')).toBeTruthy();
|
|
expect(within(layerButton).getByText('动作')).toBeTruthy();
|
|
|
|
fireEvent.click(toggle);
|
|
expect(
|
|
within(layerButton).getByRole('button', {
|
|
name: '播放角色动作序列帧',
|
|
}),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('keeps the last loaded sequence frame visible while the next frame is resolving', () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
useResolvedAssetReadUrlMock.mockImplementation(
|
|
(source: string) => ({
|
|
resolvedUrl:
|
|
source === '/generated-character-drafts/editor/frame01.png'
|
|
? 'https://oss.example.com/frame01.png?signature=1'
|
|
: '',
|
|
isResolving:
|
|
source === '/generated-character-drafts/editor/frame02.png',
|
|
shouldResolve: true,
|
|
}),
|
|
);
|
|
const layer = createLayer({
|
|
title: '角色动作',
|
|
src: '/generated-character-drafts/editor/frame01.png',
|
|
mediaType: 'image-sequence',
|
|
assetKind: 'character-animation',
|
|
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
|
imageSequenceFrames: [
|
|
{
|
|
frameIndex: 1,
|
|
imageSrc: '/generated-character-drafts/editor/frame01.png',
|
|
objectKey: 'generated-character-drafts/editor/frame01.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
{
|
|
frameIndex: 2,
|
|
imageSrc: '/generated-character-drafts/editor/frame02.png',
|
|
objectKey: 'generated-character-drafts/editor/frame02.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
],
|
|
durationSeconds: 0.1,
|
|
});
|
|
|
|
renderWorldView({ layers: [layer] });
|
|
|
|
const layerButton = screen.getByRole('button', { name: '选择角色动作' });
|
|
const firstFrame = within(layerButton).getByAltText(
|
|
'画布序列帧:角色动作',
|
|
);
|
|
|
|
fireEvent.load(firstFrame);
|
|
expect(
|
|
layerButton.querySelector('.image-canvas-editor__layer-image-loading'),
|
|
).toBeNull();
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(60);
|
|
});
|
|
|
|
expect(within(layerButton).getByText('2/2')).toBeTruthy();
|
|
expect(
|
|
within(layerButton)
|
|
.getByAltText('画布序列帧:角色动作')
|
|
.getAttribute('src'),
|
|
).toBe('https://oss.example.com/frame01.png?signature=1');
|
|
expect(
|
|
layerButton.querySelector('.image-canvas-editor__layer-image-loading'),
|
|
).toBeNull();
|
|
expect(useResolvedAssetReadUrlMock).toHaveBeenCalledWith(
|
|
'/generated-character-drafts/editor/frame02.png',
|
|
expect.objectContaining({
|
|
objectKey: 'generated-character-drafts/editor/frame02.png',
|
|
}),
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|