/* @vitest-environment jsdom */ import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react'; 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 { 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 { 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[0]> = {}, ) { const props: Parameters[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(); 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('shows the atlas splitting treatment on the source layer', () => { const layer = createLayer({ assetKind: 'icon-spritesheet' }); renderWorldView({ layers: [layer], splittingIconSpritesheetLayerIds: new Set([layer.id]), }); const sourceLayer = screen.getByRole('button', { name: `选择${layer.title}`, }); expect(sourceLayer.className).toContain( 'image-canvas-editor__layer--generating', ); expect(within(sourceLayer).getByRole('status').textContent).toBe('拆图中'); }); 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('only rerenders the moved image layer when other layer objects stay stable', () => { const firstLayer = createLayer({ id: 'layer-first', resourceId: 'resource-first', title: '第一张图片', zIndex: 1, }); const movedLayer = createLayer({ id: 'layer-moved', resourceId: 'resource-moved', title: '移动图片', zIndex: 2, }); const lastLayer = createLayer({ id: 'layer-last', resourceId: 'resource-last', title: '最后一张图片', zIndex: 3, }); const { props, rerender } = renderWorldView({ layers: [firstLayer, movedLayer, lastLayer], }); const initialReadUrlCallCount = useResolvedAssetReadUrlMock.mock.calls.length; rerender( , ); expect(useResolvedAssetReadUrlMock).toHaveBeenCalledTimes( initialReadUrlCallCount + 1, ); }); it('calls the latest layer callback without rerendering a stable layer', () => { const layer = createLayer(); const firstOnLayerClick = vi.fn(); const latestOnLayerClick = vi.fn(); const { props, rerender } = renderWorldView({ layers: [layer], onLayerClick: firstOnLayerClick, }); rerender( , ); fireEvent.click(screen.getByRole('button', { name: '选择角色主图' })); expect(latestOnLayerClick).toHaveBeenCalledWith(expect.any(Object), layer); expect(firstOnLayerClick).not.toHaveBeenCalled(); }); 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', }), createLayer({ id: 'layer-scene', resourceId: 'resource-scene', title: '游戏场景', assetKind: 'scene', }), createLayer({ id: 'layer-source', resourceId: 'resource-source', title: '角色原图', assetKind: 'character', }), ], }); const layerButton = screen.getByRole('button', { name: '选择角色主图' }); const characterButton = screen.getByRole('button', { name: '选择角色标签图层', }); const sceneButton = screen.getByRole('button', { name: '选择游戏场景' }); const sourceButton = screen.getByRole('button', { name: '选择角色原图' }); expect(within(layerButton).getByText('未知')).toBeTruthy(); expect(within(characterButton).getByText('角色')).toBeTruthy(); expect(within(sceneButton).getByText('场景')).toBeTruthy(); expect(within(sourceButton).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?.style.transform).toBe( canvasViewportToWorldTransform({ x: 10, y: 20, 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(); const generatingStatus = within(frame).getByRole('status'); expect(generatingStatus.textContent).toBe('生成中'); expect(generatingStatus.className).not.toContain( 'image-canvas-editor__generation-frame-progress--pending-confirmation', ); 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('keeps a pending perfect-pixel placeholder visible with a confirmation verdict', () => { const dialog = createGenerationDialog({ id: 'dialog-perfect-pixel-pending', status: 'pending-confirmation', }); renderWorldView({ canvasGenerationDialogs: [dialog], generateDialog: null, }); const frame = screen.getByRole('button', { name: '图像生成占位图' }); expect(frame.className).toContain( 'image-canvas-editor__generation-frame--pending-confirmation', ); expect(within(frame).getByText('Image Generator')).toBeTruthy(); const pendingStatus = within(frame).getByRole('status'); expect(pendingStatus.textContent).toBe('结果待确认'); expect(pendingStatus.className).toContain( 'image-canvas-editor__generation-frame-progress--pending-confirmation', ); expect(within(frame).queryByText('生成中')).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(); 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', }, { mode: 'character-animation', ariaLabel: '角色动作生成占位图', generatorLabel: 'Action Generator', badgeLabel: '动作', frameClassName: 'image-canvas-editor__generation-frame--character-animation', }, { mode: 'publication', ariaLabel: '宣发素材生成占位图', generatorLabel: 'Home Image Generator', badgeLabel: '宣发', frameClassName: 'image-canvas-editor__generation-frame--publication', }, { mode: 'spec', ariaLabel: '规范生成占位图', generatorLabel: 'Spec Generator', badgeLabel: '规范', frameClassName: 'image-canvas-editor__generation-frame--spec', }, { mode: 'ui-design', ariaLabel: 'UI设计图生成占位图', generatorLabel: 'UI Design Generator', badgeLabel: 'UI设计', frameClassName: 'image-canvas-editor__generation-frame--ui-design', }, { mode: 'scene', ariaLabel: '游戏场景生成占位图', generatorLabel: 'Scene Generator', badgeLabel: '场景', frameClassName: 'image-canvas-editor__generation-frame--scene', }, { mode: 'quick-edit', ariaLabel: '快速编辑生成占位图', generatorLabel: 'Quick Edit Generator', badgeLabel: '快编', frameClassName: 'image-canvas-editor__generation-frame--quick-edit', }, { mode: 'video', ariaLabel: '视频生成占位图', generatorLabel: 'Video Generator', badgeLabel: '视频', frameClassName: 'image-canvas-editor__generation-frame--video', }, { mode: 'audio-sound-effect', ariaLabel: '音效生成占位图', generatorLabel: 'Sound Generator', badgeLabel: '音效', frameClassName: 'image-canvas-editor__generation-frame--audio-sound-effect', }, { mode: 'audio-background-music', ariaLabel: '背景音乐生成占位图', generatorLabel: 'Music Generator', badgeLabel: '背景音乐', frameClassName: 'image-canvas-editor__generation-frame--audio-background-music', }, ])( 'renders $mode generation placeholder with its own visual treatment and badge', ({ mode, ariaLabel, generatorLabel, badgeLabel, frameClassName }) => { 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(within(frame).getByText(badgeLabel)).toBeTruthy(); }, ); 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('角色').parentElement 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: '查看角色主图图片信息' }) .parentElement!.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: '紧张的地下城循环音乐', generationInputs: { fields: [{ title: '时长', value: '75秒' }], references: [], }, }); 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( , ); 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('画布音频:游戏背景音乐'); const progress = within(hoveredLayerButton).getByLabelText( '调整游戏背景音乐播放进度', ) as HTMLInputElement; expect(progress.disabled).toBe(true); expect(progress.max).toBe('0'); Object.defineProperty(hoveredAudio, 'duration', { configurable: true, value: 90, }); fireEvent.loadedMetadata(hoveredAudio); expect(progress.disabled).toBe(false); expect(progress.max).toBe('90'); 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('starts layer dragging from the audio card outside playback controls', () => { const layer = createLayer({ title: '拖拽音效', src: 'data:audio/mpeg;base64,audio', mediaType: 'audio', assetKind: 'sound-effect', width: 420, height: 120, originalWidth: 420, originalHeight: 120, }); const { props } = renderWorldView({ layers: [layer], hoveredLayerId: layer.id, }); const layerButton = screen.getByRole('button', { name: '选择拖拽音效' }); const audioCard = layerButton.querySelector( '.image-canvas-editor__audio-card', ); const playbackButton = within(layerButton).getByRole('button', { name: '播放拖拽音效', }); const controls = layerButton.querySelector( '.image-canvas-editor__audio-card-controls', ); expect(audioCard).toBeTruthy(); expect(controls).toBeTruthy(); fireEvent.pointerDown(audioCard as HTMLElement); expect(props.onLayerPointerDown).toHaveBeenCalledWith( expect.any(Object), layer, ); fireEvent.pointerDown(playbackButton); fireEvent.pointerDown(controls as HTMLElement); expect(props.onLayerPointerDown).toHaveBeenCalledTimes(1); }); 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.parentElement?.className).toBe('shared-canvas-card-corners'); expect(badge.nextElementSibling).toBe(metadataButton); }); 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('disables labels from other media families', () => { const imageLayer = createLayer({ resourceAssetKind: null, assetKind: 'character', }); renderWorldView({ layers: [imageLayer] }); fireEvent.click(screen.getByRole('button', { name: '素材标签:角色' })); const menu = screen.getByRole('menu', { name: '选择素材标签' }); expect( within(menu) .getByRole('menuitem', { name: '图标' }) .hasAttribute('disabled'), ).toBe(false); for (const label of ['动作', '视频', '音效', '背景音乐']) { const item = within(menu).getByRole('menuitem', { name: label }); expect(item.hasAttribute('disabled')).toBe(true); expect(item.title).toBe('该标签与素材的媒体类型不兼容'); } }); it('offers scene as a user-selectable label for image layers', () => { const imageLayer = createLayer({ resourceAssetKind: null, assetKind: 'character', }); const { props } = renderWorldView({ layers: [imageLayer] }); fireEvent.click(screen.getByRole('button', { name: '素材标签:角色' })); const sceneItem = within( screen.getByRole('menu', { name: '选择素材标签' }), ).getByRole('menuitem', { name: '场景' }); expect(sceneItem.hasAttribute('disabled')).toBe(false); fireEvent.click(sceneItem); expect(props.onUpdateLayerAssetKind).toHaveBeenCalledWith( imageLayer.id, 'scene', ); }); it('keeps the 3D model label inside its own media family', () => { const imageLayer = createLayer({ resourceAssetKind: null, assetKind: 'character', }); const { unmount } = renderWorldView({ layers: [imageLayer] }); fireEvent.click(screen.getByRole('button', { name: '素材标签:角色' })); const modelItem = within( screen.getByRole('menu', { name: '选择素材标签' }), ).getByRole('menuitem', { name: '3D模型' }); expect(modelItem.hasAttribute('disabled')).toBe(true); expect(modelItem.title).toBe('该标签与素材的媒体类型不兼容'); unmount(); const modelLayer = createLayer({ resourceAssetKind: 'model3d', assetKind: 'model3d', src: 'generated/editor/model3d/task-1/preview.webp', objectKey: 'generated/editor/model3d/task-1/model.glb', }); renderWorldView({ layers: [modelLayer] }); fireEvent.click(screen.getByRole('button', { name: '素材标签:3D模型' })); const modelMenu = screen.getByRole('menu', { name: '选择素材标签' }); expect( within(modelMenu) .getByRole('menuitem', { name: '3D模型' }) .hasAttribute('disabled'), ).toBe(false); expect( within(modelMenu) .getByRole('menuitem', { name: '角色' }) .hasAttribute('disabled'), ).toBe(true); expect( within(modelMenu) .getByRole('menuitem', { name: '动作' }) .hasAttribute('disabled'), ).toBe(true); }); it('draws a 3D placeholder instead of loading the model file as an image', () => { const layer = createLayer({ assetKind: 'model3d', title: '三维角色', src: 'generated/editor/model3d/task-1/model.glb', objectKey: 'generated/editor/model3d/task-1/model.glb', }); renderWorldView({ layers: [layer] }); expect(screen.queryByAltText('画布图片:三维角色')).toBeNull(); expect( screen.getByRole('button', { name: '素材标签:3D模型' }), ).toBeTruthy(); }); it('renders the model3d preview image without resolving the model object key', () => { const layer = createLayer({ assetKind: 'model3d', title: '三维角色', src: 'generated/editor/model3d/task-1/preview.webp', objectKey: 'generated/editor/model3d/task-1/model.glb', }); renderWorldView({ layers: [layer] }); expect(screen.getByAltText('画布图片:三维角色')).toBeTruthy(); expect(useResolvedAssetReadUrlMock).toHaveBeenCalledWith( 'generated/editor/model3d/task-1/preview.webp', expect.objectContaining({ objectKey: null }), ); }); 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: [ { imageSrc: '/generated-character-drafts/editor/frame01.png', width: 1024, height: 1024, }, { imageSrc: '/generated-character-drafts/editor/frame02.png', width: 1024, height: 1024, }, ], imageSequenceDurationMs: 4_000, }); 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: [ { imageSrc: '/generated-character-drafts/editor/frame01.png', objectKey: 'generated-character-drafts/editor/frame01.png', width: 1024, height: 1024, }, { imageSrc: '/generated-character-drafts/editor/frame02.png', objectKey: 'generated-character-drafts/editor/frame02.png', width: 1024, height: 1024, }, ], imageSequenceDurationMs: 100, }); 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(); }); });