import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CanvasLayer, EditorAssetFolder, GenerateDialogState, } from './ImageCanvasEditorTypes'; import { applyGenerationReferenceUpload, applyMeasuredUploadAssetSize, applyPersistedUploadAsset, applyUploadAssetCreateFailure, applyUploadAssetCreatePending, applyUploadAssetReadFailure, applyUploadAssetReadSuccess, bindUploadLayerToPersistedAsset, createUploadCanvasLayer, createUploadedGenerationReference, createUploadingAssetPlaceholder, expandUploadFolder, resizeUploadCanvasLayerToImage, resolveUploadFolderId, setFailedGenerationIdle, } from './ImageCanvasUploadModel'; function createFolder( overrides: Partial = {}, ): EditorAssetFolder { return { id: 'project', label: '项目素材', collapsed: false, systemDefault: true, persisted: true, ...overrides, }; } function createLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-upload-1', resourceId: 'local-resource-upload-1', title: '上传图片', src: 'data:image/png;base64,upload', x: -160, y: -107.5, width: 420, height: 315, originalWidth: 420, originalHeight: 315, zIndex: 11, sourceType: 'uploaded', sourceAssetId: 'upload-1', ...overrides, }; } function createUploadingAsset( overrides: Partial> = {}, ) { return { ...createUploadingAssetPlaceholder({ uploadIndex: 1, fileName: '上传图片.png', folderId: 'project', }), ...overrides, }; } function createDialog( overrides: Partial = {}, ): GenerateDialogState { return { mode: 'character', prompt: '', status: 'failed', errorMessage: '旧错误', ...overrides, }; } describe('ImageCanvasUploadModel', () => { afterEach(() => { vi.useRealTimers(); }); it('resolves upload folder ids with project fallback', () => { const folders = [ createFolder(), createFolder({ id: 'characters', label: '角色素材' }), ]; expect( resolveUploadFolderId({ assetFolders: folders, requestedFolderId: 'characters', activeUploadFolderId: 'project', }), ).toBe('characters'); expect( resolveUploadFolderId({ assetFolders: folders, requestedFolderId: 'missing', activeUploadFolderId: 'characters', }), ).toBe('project'); }); it('creates uploading asset placeholders', () => { expect( createUploadingAssetPlaceholder({ uploadIndex: 7, fileName: 'hero.png', folderId: 'characters', }), ).toEqual({ id: 'upload-7', label: 'hero.png', src: '', width: 420, height: 315, folderId: 'characters', sourceKind: 'uploaded', sourceType: 'uploaded', persisted: false, uploadStatus: 'uploading', uploadProgress: 8, uploadMessage: '准备上传', }); }); it('opens the target upload folder without changing other folders', () => { expect( expandUploadFolder({ folders: [ createFolder({ id: 'project', collapsed: true }), createFolder({ id: 'characters', label: '角色素材', collapsed: true }), ], folderId: 'characters', }), ).toEqual([ createFolder({ id: 'project', collapsed: true }), createFolder({ id: 'characters', label: '角色素材', collapsed: false }), ]); }); it('applies upload asset lifecycle patches', () => { const asset = createUploadingAsset(); const otherAsset = createUploadingAsset({ id: 'upload-other', label: '其他图片.png', }); expect( applyUploadAssetReadSuccess({ assets: [asset, otherAsset], uploadAssetId: asset.id, imageSrc: 'data:image/png;base64,read', }), ).toMatchObject([ { id: 'upload-1', src: 'data:image/png;base64,read', uploadProgress: 42, uploadMessage: '读取图片', }, { id: 'upload-other', label: '其他图片.png' }, ]); expect( applyUploadAssetCreatePending({ assets: [asset], uploadAssetId: asset.id, }), ).toMatchObject([ { id: 'upload-1', uploadStatus: 'uploading', uploadProgress: 68, uploadMessage: '上传中', }, ]); expect( applyUploadAssetReadFailure({ assets: [asset], uploadAssetId: asset.id, }), ).toMatchObject([ { id: 'upload-1', uploadStatus: 'failed', uploadProgress: 100, uploadMessage: '读取失败', }, ]); expect( applyUploadAssetCreateFailure({ assets: [asset], uploadAssetId: asset.id, message: '请先登录', }), ).toMatchObject([ { id: 'upload-1', uploadStatus: 'failed', uploadProgress: 100, uploadMessage: '请先登录', }, ]); }); it('replaces upload placeholders with persisted assets and clears upload state', () => { expect( applyPersistedUploadAsset({ assets: [createUploadingAsset()], uploadAssetId: 'upload-1', persistedAsset: { assetId: 'asset-1', folderId: 'characters', label: '角色.png', imageSrc: 'data:image/png;base64,persisted', width: 1024, height: 768, objectKey: 'object-key', assetObjectId: 'asset-object', }, }), ).toEqual([ { id: 'asset-1', label: '角色.png', src: 'data:image/png;base64,persisted', width: 1024, height: 768, folderId: 'characters', sourceKind: 'uploaded', sourceType: 'uploaded', persisted: true, objectKey: 'object-key', assetObjectId: 'asset-object', uploadStatus: undefined, uploadProgress: undefined, uploadMessage: undefined, }, ]); }); it('updates measured asset size after the browser loads the uploaded image', () => { expect( applyMeasuredUploadAssetSize({ assets: [createUploadingAsset()], uploadAssetId: 'upload-1', originalWidth: 1600, originalHeight: 900, }), ).toMatchObject([ { id: 'upload-1', width: 1600, height: 900, }, ]); }); it('binds uploaded canvas layers to persisted asset metadata', () => { expect( bindUploadLayerToPersistedAsset({ layers: [createLayer({ objectKey: 'local-object' })], layerId: 'layer-upload-1', persistedAsset: { assetId: 'asset-1', folderId: 'project', label: '上传图片.png', imageSrc: 'data:image/png;base64,persisted', width: 420, height: 315, objectKey: 'object-key', assetObjectId: 'asset-object', }, }), ).toMatchObject([ { id: 'layer-upload-1', sourceAssetId: 'asset-1', objectKey: 'object-key', assetObjectId: 'asset-object', }, ]); }); it('creates uploaded generation references with fallback labels', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-06-17T12:00:00.000Z')); expect( createUploadedGenerationReference({ idPrefix: 'upload-character-reference', index: 2, fileName: '', fallbackLabel: '参考图3', imageSrc: 'data:image/png;base64,ref', }), ).toEqual({ id: 'upload-character-reference-1781697600000-2', label: '参考图3', src: 'data:image/png;base64,ref', }); }); it('resets failed generation dialogs when reference uploads are applied', () => { expect( setFailedGenerationIdle( createDialog({ status: 'failed', errorMessage: '旧错误' }), ), ).toMatchObject({ status: 'idle', errorMessage: undefined }); expect( setFailedGenerationIdle(createDialog({ status: 'generating' })), ).toMatchObject({ status: 'generating', errorMessage: '旧错误' }); }); it('applies uploaded references to matching generation dialogs', () => { const reference = { id: 'ref-a', label: '参考图', src: 'data:image/png;base64,ref', }; expect( applyGenerationReferenceUpload({ dialog: createDialog({ mode: 'spec' }), target: 'spec-reference', references: [reference], }), ).toMatchObject({ mode: 'spec', status: 'idle', specReference: reference, errorMessage: undefined, }); expect( applyGenerationReferenceUpload({ dialog: createDialog({ mode: 'character' }), target: 'character-spec', references: [reference], }), ).toMatchObject({ mode: 'character', status: 'idle', characterSpecReference: reference, }); expect( applyGenerationReferenceUpload({ dialog: createDialog({ mode: 'icon' }), target: 'icon-spec', references: [reference], }), ).toMatchObject({ mode: 'icon', status: 'idle', iconSpecReference: reference, }); }); it('appends character reference uploads without changing unmatched dialogs', () => { const previousReference = { id: 'ref-old', label: '旧参考图', src: 'data:image/png;base64,old', }; const nextReference = { id: 'ref-new', label: '新参考图', src: 'data:image/png;base64,new', }; expect( applyGenerationReferenceUpload({ dialog: createDialog({ mode: 'character', characterReferences: [previousReference], }), target: 'character-reference', references: [nextReference], }), ).toMatchObject({ mode: 'character', status: 'idle', characterReferences: [previousReference, nextReference], }); const iconDialog = createDialog({ mode: 'icon' }); expect( applyGenerationReferenceUpload({ dialog: iconDialog, target: 'character-reference', references: [nextReference], }), ).toBe(iconDialog); }); it('creates upload canvas layers centered on the target canvas point', () => { const layer = createUploadCanvasLayer({ uploadIndex: 1, fileName: '画布素材.png', imageSrc: 'data:image/png;base64,canvas', canvasPoint: { x: 110, y: 120 }, canvasSize: { width: 900, height: 640 }, viewport: { x: 10, y: 20, scale: 2 }, }); expect(layer).toMatchObject({ id: 'layer-upload-1', resourceId: 'local-resource-upload-1', title: '画布素材.png', x: -160, y: -107.5, width: 420, height: 315, originalWidth: 420, originalHeight: 315, zIndex: 11, sourceAssetId: 'upload-1', }); }); it('uses viewport center fallback for invalid upload points', () => { const layer = createUploadCanvasLayer({ uploadIndex: 2, fileName: '', imageSrc: 'data:image/png;base64,canvas', canvasPoint: { x: Number.NaN, y: Number.POSITIVE_INFINITY }, canvasSize: { width: 900, height: 640 }, viewport: { x: 10, y: 20, scale: 2 }, }); expect(layer.title).toBe('上传图片'); expect(layer.x).toBe(10); expect(layer.y).toBe(-7.5); }); it('resizes upload canvas layers around the same canvas point', () => { const resizedLayer = resizeUploadCanvasLayerToImage({ layer: createLayer(), originalWidth: 1536, originalHeight: 1024, canvasPoint: { x: 110, y: 120 }, canvasSize: { width: 900, height: 640 }, viewport: { x: 10, y: 20, scale: 2 }, }); expect(resizedLayer).toMatchObject({ width: 1536, height: 1024, originalWidth: 1536, originalHeight: 1024, x: -718, y: -462, }); }); });