/* @vitest-environment jsdom */ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useState } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CanvasContextMenuState, CanvasGenerationDialogState, CanvasHistoryAction, CanvasLayer, } from './ImageCanvasEditorTypes'; import { readLayerImageBlob } from './ImageCanvasExportModel'; import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; vi.mock('./ImageCanvasExportModel', () => ({ readLayerImageBlob: vi.fn(async (layer: CanvasLayer) => { const blob = new Blob([layer.id], { type: 'image/png' }); Object.defineProperty(blob, 'layerId', { configurable: true, value: layer.id, }); return blob; }), })); function createLayer(id: string, x: number, zIndex: number): CanvasLayer { return { id, resourceId: `resource-${id}`, title: id, src: `data:image/png,${encodeURIComponent(id)}`, x, y: 20, width: 100, height: 80, originalWidth: 100, originalHeight: 80, zIndex, sourceType: 'uploaded', }; } function createDurablePerfectPixelDialog( id: string, status: 'generating' | 'pending-confirmation' | 'failed', ): CanvasGenerationDialogState { const submittedAt = 1_700_000_000_000; return { id, mode: 'quick-edit', prompt: '完美像素', status, perfectPixelOperation: { version: 1, kind: 'perfect-pixel', operationId: id, taskId: `pixel-art-snap-${id}`, request: { sourceImageSrc: 'generated-images/editor/source.png', projectId: 'project-1', canvasCompletion: { dialogId: id, title: '源图 · 完美像素', placeholder: { x: 20, y: 20, width: 100, height: 80, originalWidth: 100, originalHeight: 80, }, }, }, submittedAt, reconcileUntil: submittedAt + 75_000, }, }; } const originalClipboard = navigator.clipboard; const originalClipboardItem = globalThis.ClipboardItem; const originalCreateImageBitmap = globalThis.createImageBitmap; type ClipboardImageItems = Record>; function installImageClipboardMock() { class ClipboardItemMock { readonly items: ClipboardImageItems; constructor(items: ClipboardImageItems) { this.items = items; } } const write = vi.fn( async (_items: Array<{ items: ClipboardImageItems }>) => undefined, ); Object.defineProperty(globalThis, 'ClipboardItem', { configurable: true, value: ClipboardItemMock, }); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { write }, }); return write; } function LayerCommandsHarness({ exportLayerImage = vi.fn(), onDeleteLayerSideEffects = vi.fn(), onDeleteGenerationDialogSideEffects = vi.fn(), onRequestDeleteGenerationDialog, onCaptureCanvasHistory = vi.fn(), onCanvasLayerCopyBlocked = vi.fn(), canDeleteLayers, initialLayers, initialContextMenu, initialSelectedLayerId = 'first', initialSelectedLayerIds = ['first', 'second'], initialCanvasGenerationDialogs, }: { exportLayerImage?: ( layer: CanvasLayer | null, options?: { mode?: 'spine-json' | 'sequence-with-preview' }, ) => void; onDeleteLayerSideEffects?: (layerId: string) => void; onDeleteGenerationDialogSideEffects?: (dialogId: string) => void; onRequestDeleteGenerationDialog?: ( dialog: CanvasGenerationDialogState, ) => void; onCaptureCanvasHistory?: (action: CanvasHistoryAction) => void; onCanvasLayerCopyBlocked?: ( reason: | 'pending-resource' | 'self-contained-local-unsupported' | 'unresolved-resource', ) => void; canDeleteLayers?: (targetLayerIds: string[]) => boolean; initialLayers?: CanvasLayer[]; initialContextMenu?: CanvasContextMenuState; initialSelectedLayerId?: string | null; initialSelectedLayerIds?: string[]; initialCanvasGenerationDialogs?: CanvasGenerationDialogState[]; }) { const [layers, setLayers] = useState( initialLayers ?? [ createLayer('first', 10, 1), createLayer('second', 160, 2), createLayer('third', 310, 3), ], ); const [selectedLayerId, setSelectedLayerId] = useState( initialSelectedLayerId, ); const [selectedLayerIds, setSelectedLayerIds] = useState( initialSelectedLayerIds, ); const [canvasGenerationDialogs] = useState( initialCanvasGenerationDialogs ?? [ { id: 'dialog-1', mode: 'generate', prompt: '生成', status: 'idle', placeholder: { x: 20, y: 20, width: 100, height: 80, originalWidth: 100, originalHeight: 80, }, }, ], ); const [metadataLayer, setMetadataLayer] = useState( layers[0] ?? null, ); const [hoveredLayerId, setHoveredLayerId] = useState('first'); const [contextMenu, setContextMenu] = useState( initialContextMenu ?? { kind: 'layer', layerId: 'first', x: 0, y: 0, canvasPoint: { x: 0, y: 0 }, }, ); const [historyCount, setHistoryCount] = useState(0); const [imageContextClosedCount, setImageContextClosedCount] = useState(0); const [activeTool, setActiveTool] = useState('select'); const selectSingleLayer = (layerId: string | null) => { setSelectedLayerId(layerId); setSelectedLayerIds(layerId ? [layerId] : []); }; const commands = useImageCanvasLayerCommands({ layers, contextMenu, selectedLayerId, selectedLayerIds, canvasGenerationDialogs, setLayers, appendCanvasLayersWithResources: (nextLayers) => setLayers((currentLayers) => [...currentLayers, ...nextLayers]), setSelectedLayerId, setSelectedLayerIds, setHoveredLayerId, setMetadataLayer, setContextMenu, setImageContextMenu: () => setImageContextClosedCount((currentCount) => currentCount + 1), setActiveTool, captureCanvasHistory: (action) => { setHistoryCount((currentCount) => currentCount + 1); onCaptureCanvasHistory(action); }, selectSingleLayer, onDeleteLayerSideEffects, onDeleteGenerationDialogSideEffects, onRequestDeleteGenerationDialog, exportLayerImage, onCanvasLayerCopyBlocked, canDeleteLayers, }); return (
{layers .map( (layer) => `${layer.id}:${layer.title}:${layer.x}:${layer.zIndex}:${layer.groupId ?? '-'}:${layer.hidden ? 'hidden' : 'shown'}:${layer.locked ? 'locked' : 'open'}:${layer.flipX ? 'flipX' : '-'}:${layer.flipY ? 'flipY' : '-'}`, ) .join('|')} {selectedLayerId ?? '-'}:{selectedLayerIds.join(',')} {metadataLayer?.id ?? '-'} {hoveredLayerId ?? '-'} {contextMenu ? 'open' : 'closed'} {historyCount} {imageContextClosedCount} {activeTool} {commands.canvasClipboard ? `${commands.canvasClipboard.mode}:${commands.canvasClipboard.layers.length}` : '-'}
); } afterEach(() => { vi.restoreAllMocks(); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: originalClipboard, }); Object.defineProperty(globalThis, 'ClipboardItem', { configurable: true, value: originalClipboardItem, }); Object.defineProperty(globalThis, 'createImageBitmap', { configurable: true, value: originalCreateImageBitmap, }); }); describe('useImageCanvasLayerCommands', () => { it('writes the last copied image layer to the system clipboard', async () => { const write = installImageClipboardMock(); render(); fireEvent.click(screen.getByRole('button', { name: '复制选中' })); await waitFor(() => expect(write).toHaveBeenCalledTimes(1)); const clipboardItems = write.mock.calls[0]?.[0] as | Array<{ items: ClipboardImageItems }> | undefined; await expect( clipboardItems?.[0]?.items['image/png'], ).resolves.toBeInstanceOf(Blob); expect(readLayerImageBlob).toHaveBeenCalledWith( expect.objectContaining({ id: 'second' }), ); expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); }); it('starts system clipboard write before image bytes finish loading', async () => { const write = installImageClipboardMock(); let resolveBlob: (blob: Blob) => void = () => undefined; vi.mocked(readLayerImageBlob).mockReturnValueOnce( new Promise((resolve) => { resolveBlob = resolve; }), ); render(); fireEvent.click(screen.getByRole('button', { name: '复制选中' })); await waitFor(() => expect(write).toHaveBeenCalledTimes(1)); const clipboardItems = write.mock.calls[0]?.[0] as | Array<{ items: ClipboardImageItems }> | undefined; const pngBlobPromise = clipboardItems?.[0]?.items['image/png']; expect(pngBlobPromise).toBeInstanceOf(Promise); resolveBlob(new Blob(['late-png'], { type: 'image/png' })); await expect(pngBlobPromise).resolves.toBeInstanceOf(Blob); }); it('normalizes copied images to png for the system clipboard', async () => { const write = installImageClipboardMock(); const drawImage = vi.fn(); const toBlob = vi.fn((resolve: (blob: Blob) => void) => resolve(new Blob(['png'], { type: 'image/png' })), ); vi.mocked(readLayerImageBlob).mockResolvedValueOnce( new Blob(['webp'], { type: 'image/webp' }), ); Object.defineProperty(globalThis, 'createImageBitmap', { configurable: true, value: vi.fn(async () => ({ width: 16, height: 12 })), }); const originalCreateElement = document.createElement.bind(document); vi.spyOn(document, 'createElement').mockImplementation((tagName) => { if (tagName === 'canvas') { return { width: 0, height: 0, getContext: () => ({ drawImage }), toBlob, } as unknown as HTMLCanvasElement; } return originalCreateElement(tagName); }); render(); fireEvent.click(screen.getByRole('button', { name: '复制选中' })); await waitFor(() => expect(write).toHaveBeenCalledTimes(1)); const clipboardItems = write.mock.calls[0]?.[0] as | Array<{ items: ClipboardImageItems }> | undefined; await expect( Promise.resolve(clipboardItems?.[0]?.items['image/png']).then( (blob) => blob?.type, ), ).resolves.toBe('image/png'); expect(drawImage).toHaveBeenCalledTimes(1); }); it('keeps canvas copy when system image clipboard is unavailable', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: {}, }); Object.defineProperty(globalThis, 'ClipboardItem', { configurable: true, value: undefined, }); render(); fireEvent.click(screen.getByRole('button', { name: '复制选中' })); expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); }); it('skips non-image layers when choosing the system clipboard image', async () => { const write = installImageClipboardMock(); render( , ); fireEvent.click(screen.getByRole('button', { name: '复制选中' })); await waitFor(() => expect(write).toHaveBeenCalledTimes(1)); expect(readLayerImageBlob).toHaveBeenCalledWith( expect.objectContaining({ id: 'second' }), ); expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); }); it('copies, cuts, and pastes context layers with history and selection updates', async () => { const write = installImageClipboardMock(); const onDeleteLayerSideEffects = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: '复制' })); await waitFor(() => expect(write).toHaveBeenCalledTimes(1)); expect(readLayerImageBlob).toHaveBeenCalledWith( expect.objectContaining({ id: 'second' }), ); expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); expect(screen.getByTestId('context').textContent).toBe('closed'); expect(screen.getByTestId('image-context-closed').textContent).toBe('1'); fireEvent.click(screen.getByRole('button', { name: '粘贴' })); expect(screen.getByTestId('layers').textContent).toContain( 'first 副本:500:4', ); expect(screen.getByTestId('layers').textContent).toContain( 'second 副本:650:5', ); expect(screen.getByTestId('selection').textContent).toContain( 'layer-copy-', ); expect(screen.getByTestId('tool').textContent).toBe('select'); expect(screen.getByTestId('image-context-closed').textContent).toBe('2'); fireEvent.click(screen.getByRole('button', { name: '右键第三层' })); fireEvent.click(screen.getByRole('button', { name: '只选第三层' })); fireEvent.click(screen.getByRole('button', { name: '剪切' })); expect(screen.getByTestId('clipboard').textContent).toBe('cut:1'); expect(screen.getByTestId('layers').textContent).not.toContain('third'); expect(screen.getByTestId('selection').textContent).toBe('-:'); expect(screen.getByTestId('image-context-closed').textContent).toBe('3'); expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('third'); expect(Number(screen.getByTestId('history').textContent)).toBeGreaterThan( 1, ); }); it('copies, cuts, duplicates, and moves selected layers for keyboard shortcuts', () => { const onDeleteLayerSideEffects = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: '复制选中' })); expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); expect(screen.getByTestId('image-context-closed').textContent).toBe('1'); fireEvent.click(screen.getByRole('button', { name: '创建选中副本' })); expect(screen.getByTestId('layers').textContent).toContain( 'first 副本:42:4', ); expect(screen.getByTestId('layers').textContent).toContain( 'second 副本:192:5', ); expect(screen.getByTestId('selection').textContent).toContain( 'layer-copy-', ); fireEvent.click(screen.getByRole('button', { name: '创建选中副本' })); const layerIds = (screen.getByTestId('layers').textContent ?? '') .split('|') .map((entry) => entry.split(':')[0]); expect(new Set(layerIds).size).toBe(layerIds.length); fireEvent.click(screen.getByRole('button', { name: '只选第三层' })); fireEvent.click(screen.getByRole('button', { name: '选中置顶' })); expect(screen.getByTestId('layers').textContent).toContain('third:third'); fireEvent.click(screen.getByRole('button', { name: '剪切选中' })); expect(screen.getByTestId('clipboard').textContent).toBe('cut:1'); expect(screen.getByTestId('layers').textContent).not.toContain('third'); expect(screen.getByTestId('selection').textContent).toBe('-:'); expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('third'); }); it('blocks context and keyboard cuts before changing clipboard or layers', () => { const onDeleteLayerSideEffects = vi.fn(); const canDeleteLayers = vi.fn().mockReturnValue(false); render( , ); fireEvent.click(screen.getByRole('button', { name: '剪切' })); fireEvent.click(screen.getByRole('button', { name: '剪切选中' })); expect(canDeleteLayers).toHaveBeenNthCalledWith(1, ['first', 'second']); expect(canDeleteLayers).toHaveBeenNthCalledWith(2, ['first', 'second']); expect(screen.getByTestId('clipboard').textContent).toBe('-'); expect(screen.getByTestId('layers').textContent).toContain('first'); expect(screen.getByTestId('layers').textContent).toContain('second'); expect(screen.getByTestId('history').textContent).toBe('0'); expect(onDeleteLayerSideEffects).not.toHaveBeenCalled(); }); it('blocks the whole copy operation until every selected resource is registered', () => { const onCanvasLayerCopyBlocked = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: '复制选中' })); fireEvent.click(screen.getByRole('button', { name: '剪切选中' })); fireEvent.click(screen.getByRole('button', { name: '创建选中副本' })); expect(onCanvasLayerCopyBlocked).toHaveBeenCalledTimes(3); expect(onCanvasLayerCopyBlocked).toHaveBeenLastCalledWith( 'pending-resource', ); expect(screen.getByTestId('clipboard').textContent).toBe('-'); expect(screen.getByTestId('layers').textContent).not.toContain('副本'); expect(screen.getByTestId('layers').textContent).toContain('second'); fireEvent.click(screen.getByRole('button', { name: '完成素材保存' })); fireEvent.click(screen.getByRole('button', { name: '复制选中' })); expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); expect(onCanvasLayerCopyBlocked).toHaveBeenCalledTimes(3); }); it('reports persisted self-contained sequences as unsupported instead of pending', () => { const onCanvasLayerCopyBlocked = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: '复制' })); expect(onCanvasLayerCopyBlocked).toHaveBeenCalledWith( 'self-contained-local-unsupported', ); expect(screen.getByTestId('clipboard').textContent).toBe('-'); }); it('reports unresolved local resources without claiming a save is in progress', () => { const onCanvasLayerCopyBlocked = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: '复制' })); expect(onCanvasLayerCopyBlocked).toHaveBeenCalledWith( 'unresolved-resource', ); expect(screen.getByTestId('clipboard').textContent).toBe('-'); }); it('applies layer commands and clears menus without owning menu positioning', () => { const exportLayerImage = vi.fn(); render(); fireEvent.click(screen.getByRole('button', { name: '创建组' })); expect(screen.getByTestId('layers').textContent).toContain('layer-group-'); fireEvent.click(screen.getByRole('button', { name: '右键第三层' })); fireEvent.click(screen.getByRole('button', { name: '显隐' })); expect(screen.getByTestId('layers').textContent).toContain(':hidden:'); fireEvent.click(screen.getByRole('button', { name: '右键第三层' })); fireEvent.click(screen.getByRole('button', { name: '锁定' })); expect(screen.getByTestId('layers').textContent).toContain(':locked:'); fireEvent.click(screen.getByRole('button', { name: '右键第三层' })); fireEvent.click(screen.getByRole('button', { name: '翻转' })); expect(screen.getByTestId('layers').textContent).toContain(':flipX:'); fireEvent.click(screen.getByRole('button', { name: '右键第三层' })); fireEvent.click(screen.getByRole('button', { name: '导出' })); expect(exportLayerImage).toHaveBeenCalledWith( expect.objectContaining({ id: 'third' }), expect.any(Object), ); expect(screen.getByTestId('context').textContent).toBe('closed'); expect(Number(screen.getByTestId('image-context-closed').textContent)).toBe( 5, ); }); it('deletes selected and direct layers while running delete side effects', () => { const onDeleteLayerSideEffects = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: '删除选中' })); expect(screen.getByTestId('layers').textContent).not.toContain('first'); expect(screen.getByTestId('layers').textContent).not.toContain('second'); expect(screen.getByTestId('selection').textContent).toBe('third:third'); expect(screen.getByTestId('metadata').textContent).toBe('-'); expect(screen.getByTestId('image-context-closed').textContent).toBe('1'); expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('first'); expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('second'); fireEvent.click(screen.getByRole('button', { name: '删除单图' })); expect(screen.getByTestId('image-context-closed').textContent).toBe('2'); }); it('leaves layers and side effects untouched when deletion is blocked', () => { const onDeleteLayerSideEffects = vi.fn(); const canDeleteLayers = vi.fn().mockReturnValue(false); render( , ); fireEvent.click(screen.getByRole('button', { name: '删除选中' })); expect(canDeleteLayers).toHaveBeenCalledWith(['first', 'second']); expect(screen.getByTestId('layers').textContent).toContain('first'); expect(screen.getByTestId('layers').textContent).toContain('second'); expect(screen.getByTestId('history').textContent).toBe('0'); expect(onDeleteLayerSideEffects).not.toHaveBeenCalled(); }); it('deletes selected generation dialogs together with layer targets', () => { const onDeleteLayerSideEffects = vi.fn(); const onDeleteGenerationDialogSideEffects = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: '混合选择' })); fireEvent.click(screen.getByRole('button', { name: '删除选中' })); expect(screen.getByTestId('layers').textContent).not.toContain('first'); expect(screen.getByTestId('layers').textContent).toContain('second'); expect(screen.getByTestId('selection').textContent).toBe('-:'); expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('first'); expect(onDeleteGenerationDialogSideEffects).toHaveBeenCalledWith( 'dialog-1', ); }); // 中文注释:未收口的完美像素 operation 不再豁免删除。删除占位不撤销任何在途请求,结果照常 // 落库并进素材库;封锁换来的只是「结果自动回填画布」这一便利,代价是用户画布上出现删不掉 // 的对象。快捷键删除因此与其它占位同等对待。 it.each(['generating', 'pending-confirmation', 'failed'] as const)( 'deletes a selected durable perfect-pixel dialog while %s', (status) => { const dialog = createDurablePerfectPixelDialog( `perfect-pixel-${status}`, status, ); const selectionId = `generation-dialog:${dialog.id}`; const onCaptureCanvasHistory = vi.fn(); const onDeleteLayerSideEffects = vi.fn(); const onDeleteGenerationDialogSideEffects = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: '删除选中' })); expect(screen.getByTestId('history').textContent).toBe('1'); expect(onCaptureCanvasHistory).toHaveBeenCalledWith({ type: 'delete-generation-result', count: 1, }); expect(screen.getByTestId('selection').textContent).toBe('-:'); expect(screen.getByTestId('context').textContent).toBe('closed'); expect(screen.getByTestId('image-context-closed').textContent).toBe('1'); expect(onDeleteLayerSideEffects).not.toHaveBeenCalled(); expect(onDeleteGenerationDialogSideEffects).toHaveBeenCalledWith( dialog.id, ); }, ); it('delegates a generation-dialog context delete before history and selection side effects', () => { const dialog = createDurablePerfectPixelDialog( 'perfect-pixel-context', 'pending-confirmation', ); const selectionId = `generation-dialog:${dialog.id}`; const onCaptureCanvasHistory = vi.fn(); const onDeleteGenerationDialogSideEffects = vi.fn(); const onRequestDeleteGenerationDialog = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: '删除右键目标' })); expect(onRequestDeleteGenerationDialog).toHaveBeenCalledTimes(1); expect(onRequestDeleteGenerationDialog).toHaveBeenCalledWith(dialog); expect(screen.getByTestId('history').textContent).toBe('0'); expect(screen.getByTestId('selection').textContent).toBe( `${selectionId}:${selectionId}`, ); expect(screen.getByTestId('context').textContent).toBe('closed'); expect(onCaptureCanvasHistory).not.toHaveBeenCalled(); expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled(); }); it('deletes both layer and durable-dialog targets in a mixed selection', () => { const dialog = createDurablePerfectPixelDialog( 'perfect-pixel-pending', 'pending-confirmation', ); const dialogSelectionId = `generation-dialog:${dialog.id}`; const onCaptureCanvasHistory = vi.fn(); const onDeleteLayerSideEffects = vi.fn(); const onDeleteGenerationDialogSideEffects = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: '删除选中' })); expect(screen.getByTestId('layers').textContent).not.toContain('first'); // 中文注释:混合选择里存在占位目标时统一清空选择,计数沿用既有口径——只数图层。 expect(screen.getByTestId('selection').textContent).toBe('-:'); expect(screen.getByTestId('history').textContent).toBe('1'); expect(onCaptureCanvasHistory).toHaveBeenCalledWith({ type: 'delete-image', count: 1, }); expect(onDeleteLayerSideEffects).toHaveBeenCalledTimes(1); expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('first'); expect(onDeleteGenerationDialogSideEffects).toHaveBeenCalledTimes(1); expect(onDeleteGenerationDialogSideEffects).toHaveBeenCalledWith(dialog.id); }); });