/* @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 { 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[0]> = {}, ) { const props: Parameters[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(); return props; } function createDomRect( width: number, height: number, overrides: Partial = {}, ): 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(); }); });