import { describe, expect, it } from 'vitest'; import type { CanvasLayer } from '../../../src/components/image-editor/ImageCanvasEditorTypes'; import type { ChatReference } from '../src/features/project-workspace/resourceReferences'; import { createEmptyResourceQuickEditDraftStore, createResourceQuickEditPanelDraft, dropResourceQuickEditDraft, isResourceQuickEditDraftEmpty, listResourceQuickEditDraftEntries, readResourceQuickEditDraft, resourceQuickEditDraftPromptExcerpt, restoreResourceQuickEditPanelDraft, writeResourceQuickEditDraft, } from '../src/features/resource-canvas/resourceCanvasQuickEditModel'; const SOURCE_LAYER: CanvasLayer = { id: 'asset:source-art', resourceId: 'asset:source-art', title: 'source-art.png', src: '/source-art.png', x: 0, y: 0, width: 512, height: 512, originalWidth: 1024, originalHeight: 1024, zIndex: 1, sourceType: 'upload', }; const RULES_REFERENCE: ChatReference = { type: 'resource', resourceId: 'rules-resource', kind: 'game-rules', mediaType: 'text/markdown', label: 'rules', category: 'document', tags: [], source: 'asset-picker', }; describe('快速编辑草稿表', () => { it('提示词与引用各自上报时互相补齐,不改动原表', () => { const withPrompt = writeResourceQuickEditDraft( createEmptyResourceQuickEditDraftStore(), { key: 'asset:source-art', prompt: '把夜色改成星空', updatedAt: 10 }, ); const withReferences = writeResourceQuickEditDraft(withPrompt, { key: 'asset:source-art', references: [RULES_REFERENCE], updatedAt: 20, }); // 只补引用那一次不能把提示词清掉,反之亦然:输入区两条状态是分别上报的。 expect( readResourceQuickEditDraft(withReferences, 'asset:source-art'), ).toEqual({ prompt: '把夜色改成星空', references: [RULES_REFERENCE], updatedAt: 20, }); expect(readResourceQuickEditDraft(withPrompt, 'asset:source-art')).toEqual({ prompt: '把夜色改成星空', references: [], updatedAt: 10, }); // 只读语义:写操作返回新表、源表不动;空表走工厂,每次都是独立的一张。 expect(withPrompt.size).toBe(1); expect(createEmptyResourceQuickEditDraftStore().size).toBe(0); expect(createEmptyResourceQuickEditDraftStore()).not.toBe( createEmptyResourceQuickEditDraftStore(), ); }); /** * 键是资源**路径**,不是投影 id:正规化把卡片从 `task:…` 换成 `asset:…` 之后, * 同一张卡的草稿必须在恢复入口里照样对得上号,否则用户刚写的提示词会静默消失。 */ it('草稿按路径归属,卡片换成 asset 投影后仍在入口里', () => { const store = writeResourceQuickEditDraft( createEmptyResourceQuickEditDraftStore(), { key: 'assets/generated/hero.png', prompt: '改成红色头发', references: [RULES_REFERENCE], updatedAt: 10, }, ); // 正规化前后是同一路径的两张卡;入口只认路径,落在哪一张上都该列出来。 expect( listResourceQuickEditDraftEntries(store, [ { path: 'assets/generated/hero.png', label: 'hero.png(正式素材)' }, ]), ).toEqual([ { key: 'assets/generated/hero.png', label: 'hero.png(正式素材)', prompt: '改成红色头发', updatedAt: 10, }, ]); // 资源真的不在项目里时(删掉了)才不入列表,记录本身不在这一步删。 expect(listResourceQuickEditDraftEntries(store, [])).toEqual([]); expect(store.size).toBe(1); }); it('恢复入口按最后改动时间从新到旧', () => { const store = writeResourceQuickEditDraft( writeResourceQuickEditDraft(createEmptyResourceQuickEditDraftStore(), { key: 'assets/older.png', prompt: '早写的', updatedAt: 10, }), { key: 'assets/newer.png', prompt: '后写的', updatedAt: 30 }, ); expect( listResourceQuickEditDraftEntries(store, [ { path: 'assets/older.png', label: 'older.png' }, { path: 'assets/newer.png', label: 'newer.png' }, ]).map((entry) => entry.key), ).toEqual(['assets/newer.png', 'assets/older.png']); }); it('提示词与引用都清空后不留条目', () => { const store = writeResourceQuickEditDraft( createEmptyResourceQuickEditDraftStore(), { key: 'asset:source-art', prompt: '把夜色改成星空', updatedAt: 10, }, ); const cleared = writeResourceQuickEditDraft(store, { key: 'asset:source-art', prompt: '', updatedAt: 20, }); expect( isResourceQuickEditDraftEmpty({ prompt: ' ', references: [] }), ).toBe(true); expect( readResourceQuickEditDraft(cleared, 'asset:source-art'), ).toBeUndefined(); expect(cleared.size).toBe(0); }); it('丢弃只删目标键,未记录过时原样返回', () => { const store = writeResourceQuickEditDraft( createEmptyResourceQuickEditDraftStore(), { key: 'asset:a', prompt: 'A 的草稿', updatedAt: 10, }, ); const withTwo = writeResourceQuickEditDraft(store, { key: 'asset:b', prompt: 'B 的草稿', updatedAt: 20, }); const dropped = dropResourceQuickEditDraft(withTwo, 'asset:a'); expect(readResourceQuickEditDraft(dropped, 'asset:a')).toBeUndefined(); expect(readResourceQuickEditDraft(dropped, 'asset:b')?.prompt).toBe( 'B 的草稿', ); expect(dropResourceQuickEditDraft(dropped, 'asset:missing')).toBe(dropped); expect(dropResourceQuickEditDraft(dropped, null)).toBe(dropped); expect(readResourceQuickEditDraft(dropped, null)).toBeUndefined(); }); it('回填只覆盖提示词,模型与比例仍取当下默认值', () => { const panelDraft = createResourceQuickEditPanelDraft(SOURCE_LAYER); const restored = restoreResourceQuickEditPanelDraft(panelDraft, { prompt: '把夜色改成星空', references: [], updatedAt: 10, }); expect(restored.prompt).toBe('把夜色改成星空'); expect(restored.model).toBe(panelDraft.model); expect(restored.aspectRatio).toBe(panelDraft.aspectRatio); expect(restored.size).toBe(panelDraft.size); expect(restored.status).toBe('idle'); // 没有草稿时面板原样返回:默认面板不该被这一步改写。 expect(restoreResourceQuickEditPanelDraft(panelDraft, undefined)).toBe( panelDraft, ); }); it('摘要压缩空白并按上限截断', () => { expect(resourceQuickEditDraftPromptExcerpt('')).toBe('(尚未填写提示词)'); expect(resourceQuickEditDraftPromptExcerpt(' \n ')).toBe( '(尚未填写提示词)', ); expect(resourceQuickEditDraftPromptExcerpt('把夜色\n改成 星空')).toBe( '把夜色 改成 星空', ); expect(resourceQuickEditDraftPromptExcerpt('一二三四五', 3)).toBe( '一二三…', ); expect(resourceQuickEditDraftPromptExcerpt('一二三', 3)).toBe('一二三'); }); });