Files
Genarrative/src/components/image-editor/ImageCanvasContextMenusView.test.tsx
T
kdletters 3cbc33bd69 优化画布快速编辑参数与替换规则
快速编辑继承并支持模型、比例和尺寸参数,旧模型回落当前默认模型
仅禁止图标与图标图集快速编辑,图标规范继续按普通图片处理
按用户选定分辨率替换当前图层并保持图层中心位置
补齐后端真实图层校验、移动端避让、定向测试与项目文档
2026-07-13 18:48:38 +08:00

295 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-1',
resourceId: 'resource-1',
title: '角色图',
src: 'data:image/png;base64,layer',
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'uploaded',
...overrides,
};
}
function renderContextMenus(
overrides: Partial<Parameters<typeof ImageCanvasContextMenusView>[0]> = {},
) {
const props: Parameters<typeof ImageCanvasContextMenusView>[0] = {
viewport: { x: 0, y: 0, scale: 1 },
contextMenu: null,
canvasClipboard: null,
imageContextMenu: null,
imageContextMenuLayer: null,
contextMenuLayer: null,
contextShouldShowLayer: false,
contextShouldUnlockLayer: false,
onPasteCanvasClipboard: vi.fn(),
onCopyContextLayers: vi.fn(),
onDuplicateContextLayers: vi.fn(),
onMoveContextLayers: vi.fn(),
onGroupContextLayers: vi.fn(),
onUngroupContextLayers: vi.fn(),
onToggleContextLayerVisibility: vi.fn(),
onToggleContextLayerLock: vi.fn(),
onFlipContextLayers: vi.fn(),
onExportContextLayer: vi.fn(),
onDeleteContextLayers: vi.fn(),
onDeleteLayerById: vi.fn(),
onCloseContextMenu: vi.fn(),
onCloseImageContextMenu: vi.fn(),
onUpdateScaleFromCenter: vi.fn(),
onFitLayers: vi.fn(),
onOpenQuickEditPanel: vi.fn(),
onOpenLayerMetadata: vi.fn(),
onOpenCharacterAnimationPanel: vi.fn(),
...overrides,
};
render(<ImageCanvasContextMenusView {...props} />);
return props;
}
function createDomRect(
width: number,
height: number,
overrides: Partial<DOMRect> = {},
): DOMRect {
const left = overrides.left ?? 0;
const top = overrides.top ?? 0;
return {
x: 0,
y: 0,
width,
height,
top,
left,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
...overrides,
} as DOMRect;
}
describe('ImageCanvasContextMenusView', () => {
it('renders blank canvas commands and forwards the canvas point', () => {
const canvasPoint = { x: 18, y: 24 };
const layer = createLayer();
const props = renderContextMenus({
contextMenu: { kind: 'blank', x: 10, y: 12, canvasPoint },
canvasClipboard: { layers: [layer], mode: 'copy' },
});
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
fireEvent.click(screen.getByRole('menuitem', { name: '放大' }));
fireEvent.click(screen.getByRole('menuitem', { name: '显示画布所有元素' }));
expect(props.onPasteCanvasClipboard).toHaveBeenCalledWith(canvasPoint);
expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(1.16);
expect(props.onFitLayers).toHaveBeenCalledTimes(1);
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(2);
});
it('closes canvas context menus when another operation starts outside', () => {
const props = renderContextMenus({
contextMenu: {
kind: 'blank',
x: 10,
y: 12,
canvasPoint: { x: 18, y: 24 },
},
});
fireEvent.pointerDown(document.body);
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
});
it('renders layer context commands and forwards layer operations', () => {
const layer = createLayer({
assetKind: 'character',
mediaType: 'image-sequence',
});
const props = renderContextMenus({
contextMenu: {
kind: 'layer',
x: 16,
y: 18,
layerId: layer.id,
canvasPoint: { x: 40, y: 42 },
},
canvasClipboard: { layers: [layer], mode: 'copy' },
imageContextMenuLayer: layer,
contextMenuLayer: layer,
contextShouldShowLayer: true,
contextShouldUnlockLayer: true,
});
fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));
fireEvent.click(screen.getByRole('menuitem', { name: '剪切' }));
fireEvent.click(screen.getByRole('menuitem', { name: '创建副本' }));
fireEvent.click(screen.getByRole('menuitem', { name: '上移一层' }));
fireEvent.click(screen.getByRole('menuitem', { name: '显示' }));
fireEvent.click(screen.getByRole('menuitem', { name: '解锁' }));
fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' }));
expect(
screen
.getByRole('menuitem', { name: '导出为' })
.getAttribute('aria-haspopup'),
).toBe('menu');
fireEvent.click(
screen.getByRole('menuitem', { name: '序列帧导出(zip' }),
);
fireEvent.click(
screen.getByRole('menuitem', { name: 'Spine 导出(zip' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '快速编辑' }));
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
expect(props.onCopyContextLayers).toHaveBeenNthCalledWith(1);
expect(props.onCopyContextLayers).toHaveBeenNthCalledWith(2, { cut: true });
expect(props.onDuplicateContextLayers).toHaveBeenCalledTimes(1);
expect(props.onMoveContextLayers).toHaveBeenCalledWith('up');
expect(props.onToggleContextLayerVisibility).toHaveBeenCalledTimes(1);
expect(props.onToggleContextLayerLock).toHaveBeenCalledTimes(1);
expect(props.onFlipContextLayers).toHaveBeenCalledWith('x');
expect(props.onExportContextLayer).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ mode: 'sequence-with-preview' }),
);
expect(props.onExportContextLayer).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ mode: 'spine-json' }),
);
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer);
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(2);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
});
it('keeps measured layer menus and submenus inside the viewport', async () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
window.innerWidth = 500;
window.innerHeight = 640;
const getBoundingClientRectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getBoundingClientRect(this: HTMLElement) {
if (this.classList.contains('image-canvas-editor__context-menu')) {
return createDomRect(188, 620);
}
if (
this.classList.contains('image-canvas-editor__context-submenu-panel')
) {
return createDomRect(160, 72);
}
if (this.classList.contains('image-canvas-editor__context-submenu')) {
return createDomRect(188, 29, { top: 586 });
}
return createDomRect(0, 0);
});
try {
const layer = createLayer({ mediaType: 'image-sequence' });
renderContextMenus({
contextMenu: {
kind: 'layer',
x: 490,
y: 500,
layerId: layer.id,
canvasPoint: { x: 40, y: 42 },
},
contextMenuLayer: layer,
});
const menu = screen.getByRole('menu', { name: '图片功能面板' });
await waitFor(() => {
expect(menu.style.left).toBe('304px');
expect(menu.style.top).toBe('12px');
expect(menu.className).toContain(
'image-canvas-editor__context-menu--submenu-left',
);
expect(menu.className).toContain(
'image-canvas-editor__context-menu--submenu-up',
);
});
} finally {
getBoundingClientRectSpy.mockRestore();
window.innerWidth = originalInnerWidth;
window.innerHeight = originalInnerHeight;
}
});
it('closes the standalone image menu after opening character animation', () => {
const layer = createLayer({ assetKind: 'character' });
const props = renderContextMenus({
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
imageContextMenuLayer: layer,
});
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
});
it('renders the image context menu when no canvas context menu is open', () => {
const layer = createLayer({ assetKind: 'character' });
const props = renderContextMenus({
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
imageContextMenuLayer: layer,
});
fireEvent.click(screen.getByRole('menuitem', { name: '快速编辑' }));
fireEvent.click(screen.getByRole('menuitem', { name: '查看图片信息' }));
fireEvent.click(screen.getByRole('menuitem', { name: '删除图片' }));
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer);
expect(props.onOpenLayerMetadata).toHaveBeenCalledWith(layer);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
expect(props.onDeleteLayerById).toHaveBeenCalledWith(layer.id);
});
it.each(['icon', 'icon-spritesheet'] as const)(
'hides quick edit from the standalone menu for %s assets',
(assetKind) => {
const layer = createLayer({ assetKind });
renderContextMenus({
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
imageContextMenuLayer: layer,
});
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
expect(
screen.getByRole('menuitem', { name: '查看图片信息' }),
).toBeTruthy();
},
);
it('keeps quick edit in the standalone menu for icon specs', () => {
const layer = createLayer({ assetKind: 'icon-spec' });
renderContextMenus({
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
imageContextMenuLayer: layer,
});
expect(screen.getByRole('menuitem', { name: '快速编辑' })).toBeTruthy();
});
});