/** @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { composerValue, createGameCreationAppManifest, findResourceSelectButton, fireEvent, installResizeObserverStub, ProjectDevelopmentView, React, render, screen, setComposerText, waitFor, within, } from './appSurface/harness'; vi.mock('@tauri-apps/api/core', async () => ({ ...(await vi.importActual( '@tauri-apps/api/core', )), invoke: (command: string, args?: Record) => window.__TAURI__!.core.invoke(command, args), })); const PROJECT_PATH = '/tmp/quick-edit-draft'; const PROJECT_ID = 'quick-edit-draft-project'; const CANVAS_TASK_ART_PATH = 'assets/generated/task-art.png'; /** * 两张同栏目图片 + 一份文档素材。 * * 两张图片落在同一个「待归类」栏目里,才测得出**换素材卡**时草稿各归各的;文档素材 * 供快速编辑的 `@` 引用选择器取用——引用的回填与提示词一样属于这份草稿。 */ function createDraftManifest(): GameCreationAppManifest { const manifest = createGameCreationAppManifest( PROJECT_ID, '快速编辑草稿测试', ); manifest.assets = [ { id: 'source-art', kind: 'art-image', mediaType: 'image/png', localPath: 'assets/source-art.png', source: { kind: 'generated', resourceId: 'art-resource' }, }, { id: 'source-art-2', kind: 'art-image', mediaType: 'image/png', localPath: 'assets/source-art-2.png', source: { kind: 'generated', resourceId: 'art-resource-2' }, }, { id: 'source-rules', kind: 'game-rules', mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, }, ]; return manifest; } /** * 画布产物(还没登记成正式素材的任务产物)那一份清单。 * * 卡片 id 是 `task:canvas-task:assets/generated/task-art.png`,提交快速编辑时先走 * `normalize_local_project_raster_resource` 正规化成 `asset:`——卡片被重投影走、 * 面板的 `sourceLayerId` 仍停在旧 id 上,这正是草稿键必须跟着投影走的那条路。 */ function createCanvasTaskArtManifest(): GameCreationAppManifest { const manifest = createGameCreationAppManifest( PROJECT_ID, '快速编辑草稿测试', ); manifest.assets = [ { id: 'source-rules', kind: 'game-rules', mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, }, ]; manifest.tasks = [ ...manifest.tasks, { id: 'canvas-task', title: '画布生成', group: 'art', role: 'Artisan', status: 'completed', dependencies: [], artifacts: [CANVAS_TASK_ART_PATH], acceptanceCriteria: ['画布产物落在画布上'], }, ]; return manifest; } type PendingResourceEditFixture = { operationId: string; editKind: string; sourceResourceId: string; assetName: string; phase: string; createdAt: number; }; /** * 工作台跑真实链路所需的最小原生桩:资源图、画布布局、预览读取与恢复队列读取。 * 派生走成功分支,用于验证「提交成功后这一笔草稿作废」。 */ function installInvoke( options: { pendingResourceEdits?: PendingResourceEditFixture[]; /** 与工作台同一份清单工厂:两边必须是同一份初始 manifest。 */ createManifest?: () => GameCreationAppManifest; /** 派生一律失败:用于把面板留在失败态、只验证草稿的去向。 */ failDerive?: boolean; /** * 派生挂起门:测试拿住它就能停在「已经提交、结果还没回来」那一段,验证提交期间的界面。 * 不传时派生立即返回,行为与从前一致。 */ deriveGate?: Promise; } = {}, ) { const createManifest = options.createManifest ?? createDraftManifest; let revision = 0; let manifest = createManifest(); const deriveCalls: Array> = []; const normalizeCalls: Array> = []; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_project_revision') { return { revision }; } if (command === 'read_local_project_resource_graph') { const resourceIds = ( args?.resources as Array<{ resourceId: string }> ).map((item) => item.resourceId); return { resourceIds, referenceEdges: [], taskFlows: [], producerAssignments: [], dependencyDepths: resourceIds.map((resourceId) => ({ resourceId, dependencyDepth: 0, })), connectionIndex: resourceIds.map((resourceId) => ({ resourceId, upstreamReferenceResourceIds: [], downstreamReferenceResourceIds: [], referenceEdgeIds: [], taskFlowIds: [], })), unresolvedReferenceResourceIds: [], cyclicResourceIds: [], cyclicTaskIds: [], producerMappingTruncated: false, }; } if ( command === 'read_local_project_resource_canvas_layout' || command === 'update_local_project_resource_canvas_layout' ) { const layout = { schemaVersion: 'game-creator-resource-layout.v1', projectId: PROJECT_ID, mode: args?.mode, revision: Number(args?.expectedRevision ?? 0) + 1, positions: args?.positions ?? [], updatedAt: 1, }; return command.startsWith('update_') ? { status: 'updated', layout } : layout; } if (command === 'read_local_project_image_preview') { return { path: String(args?.relativePath ?? ''), mediaType: 'image/png', byteLen: 12, dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', }; } if (command === 'read_local_project_text_preview') { return { path: String(args?.relativePath ?? ''), mediaType: 'text/markdown', byteLen: 5, content: '# 规则', }; } if (command === 'list_pending_local_project_resource_edits') { return structuredClone(options.pendingResourceEdits ?? []); } if (command === 'list_local_project_asset_generations') { return []; } if (command === 'get_local_game_manifest') { return manifest; } if (command === 'normalize_local_project_raster_resource') { const input = structuredClone( (args?.input ?? {}) as Record, ); normalizeCalls.push(input); const asset = { id: 'normalized-task-art', kind: 'art-image', mediaType: 'image/png', localPath: CANVAS_TASK_ART_PATH, source: { kind: 'canvas' as const, resourceId: String(input.sourceResourceId), taskId: 'canvas-task', }, }; revision += 1; manifest = { ...manifest, assets: [...manifest.assets, asset] }; return { committedProjectRevision: revision, asset, manifest, }; } if (command === 'derive_local_project_resource') { const input = structuredClone( (args?.input ?? {}) as Record, ); deriveCalls.push(input); if (options.failDerive) { throw new Error('result-unknown: 测试网络中断'); } if (options.deriveGate) { await options.deriveGate; } const operationId = String(input.operationId); const assetId = `edit-${operationId}`; const asset = { id: assetId, kind: 'art-image', mediaType: 'image/png', localPath: `assets/edits/${operationId}-art.png`, source: { kind: 'generated' as const, resourceId: `local-asset:${assetId}`, referenceResourceIds: ['art-resource'], }, }; revision += 1; manifest = { ...manifest, assets: [...manifest.assets, asset] }; return { operationId, editKind: input.editKind, sourceResourceId: 'art-resource', committedProjectRevision: revision, asset, version: null, manifest, }; } throw new Error(`unexpected command: ${command}`); }, ); ( window as unknown as { __TAURI__?: { core?: { invoke?: typeof invoke } }; } ).__TAURI__ = { core: { invoke } }; return { invoke, deriveCalls, normalizeCalls }; } /** 宿主由状态持有 manifest,派生结果才能真的回写并重投影(不是断言桩)。 */ function DraftWorkbench({ createManifest = createDraftManifest, externalAssetPath = null, }: { createManifest?: () => GameCreationAppManifest; /** * 打开一枚测试用的「外部登记素材」按钮:模拟 Agent / 外部编辑器把某个路径登记成 * 正式素材。走的是同一条投影链路(manifest 变更 → 卡片重投影),与快速编辑自己 * 触发的正规化无关——这正是「别的流程换了卡片 id」的那条旁路。 */ externalAssetPath?: string | null; } = {}) { const [manifest, setManifest] = React.useState(createManifest); return React.createElement( React.Fragment, null, externalAssetPath ? React.createElement( 'button', { type: 'button', onClick: () => setManifest((current) => ({ ...current, assets: [ ...current.assets, { id: 'external-art', kind: 'art-image', mediaType: 'image/png', localPath: externalAssetPath, source: { kind: 'generated' as const, resourceId: 'external-art-resource', }, }, ], })), }, '外部登记素材', ) : null, React.createElement(ProjectDevelopmentView, { projectName: manifest.name, projectPath: PROJECT_PATH, manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, supervisor: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), onManifestChange: ( _path: string, nextManifest: GameCreationAppManifest, ) => setManifest(nextManifest), }), ); } /** 切栏目:左侧大纲导航已删除,一律走「资源总览」的栏目缩略卡片。 */ async function openResourceBookCategory(label: string) { if (document.querySelector('[data-resource-book-view="child"]')) { fireEvent.click(await screen.findByRole('button', { name: '收起资源' })); await waitFor(() => expect( document.querySelector('[data-resource-book-view="main"]'), ).not.toBeNull(), ); } fireEvent.click(await screen.findByRole('button', { name: `打开${label}` })); await waitFor(() => expect( document.querySelector('[data-resource-book-view="child"]'), ).not.toBeNull(), ); } async function openQuickEditPanel(fileName: string) { fireEvent.click(await findResourceSelectButton(fileName)); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); return await screen.findByRole('dialog', { name: '快速编辑图片' }); } /** 面板里的提示词文本(`@` 引用只活在 chip 里,不重复出现在文本里)。 */ function panelPromptText(panel: HTMLElement) { return within(panel).getByLabelText('快速编辑提示词').textContent ?? ''; } /** * 「重开面板后提示词应当回填」不能同步读。 * * 面板(dialog)先出现,Lexical 的编辑器值随后才落;同步读 `textContent` 在并发跑全量时 * 会偶发读到空串——CI run 2702 与本地一次全量各命中过一次(同一条用例、`expected '' to * include '把角色头发改成红色'`)。这里等值真的落位,不再赌时序。 */ async function expectPanelPromptContains(panel: HTMLElement, expected: string) { await waitFor(() => expect(panelPromptText(panel)).toContain(expected)); } /** 关掉画布浮层:Esc 与点外部同一条既有链路(`clearResourceCanvasFocus`)。 */ async function dismissPanel() { fireEvent.keyDown(document, { key: 'Escape' }); await waitFor(() => expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(), ); } afterEach(() => { delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; }); describe('快速编辑草稿的保留与恢复', () => { it('Esc 收起面板后重开:提示词与 @ 引用都回到输入区', async () => { installResizeObserverStub(); installInvoke(); render(React.createElement(DraftWorkbench)); await openResourceBookCategory('待归类'); const panel = await openQuickEditPanel('source-art.png'); await setComposerText( within(panel).getByLabelText('快速编辑提示词'), '把夜色改成星空', ); // `@` 引用也是这一笔草稿的一部分:插入一枚再收起面板。 fireEvent.click( within(panel).getByRole('button', { name: '插入素材引用' }), ); const picker = await screen.findByRole('dialog', { name: '选择素材' }); fireEvent.click(within(picker).getByRole('option', { name: /^rules/u })); fireEvent.click(within(picker).getByRole('button', { name: '插入引用' })); await waitFor(() => expect( document.querySelector('[data-resource-reference-id="source-rules"]'), ).not.toBeNull(), ); await dismissPanel(); const reopened = await openQuickEditPanel('source-art.png'); await expectPanelPromptContains(reopened, '把夜色改成星空'); expect( document.querySelector('[data-resource-reference-id="source-rules"]'), ).not.toBeNull(); }); it('换素材卡时两笔草稿各归各的,切回来不串也不丢', async () => { installResizeObserverStub(); installInvoke(); render(React.createElement(DraftWorkbench)); await openResourceBookCategory('待归类'); const firstPanel = await openQuickEditPanel('source-art.png'); await setComposerText( within(firstPanel).getByLabelText('快速编辑提示词'), '第一张的草稿', ); // 面板开着直接换卡:宿主换选中时收起面板,草稿必须留在第一张名下。 const secondPanel = await openQuickEditPanel('source-art-2.png'); expect(secondPanel).not.toBe(firstPanel); await waitFor(() => expect(panelPromptText(secondPanel)).not.toContain('第一张的草稿'), ); await setComposerText( within(secondPanel).getByLabelText('快速编辑提示词'), '第二张的草稿', ); await dismissPanel(); await expectPanelPromptContains( await openQuickEditPanel('source-art.png'), '第一张的草稿', ); await dismissPanel(); await expectPanelPromptContains( await openQuickEditPanel('source-art-2.png'), '第二张的草稿', ); }); it('恢复入口按草稿计数,「继续编辑」重开面板并回填,丢弃后入口消失', async () => { installResizeObserverStub(); installInvoke(); render(React.createElement(DraftWorkbench)); await openResourceBookCategory('待归类'); const panel = await openQuickEditPanel('source-art.png'); await setComposerText( within(panel).getByLabelText('快速编辑提示词'), '把角色头发改成红色', ); await dismissPanel(); // 未提交草稿同样要有继续入口:入口计数把本地草稿算进去。 fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); const recovery = await screen.findByRole('dialog', { name: '未完成的资源编辑', }); expect(within(recovery).getByText('source-art.png')).not.toBeNull(); // 语义标注:草稿只在这次打开客户端期间存在,不能和账本里正式的可恢复事实看起来一样。 expect(within(recovery).getByText(/^快速编辑草稿/u).textContent).toContain( '快速编辑草稿 · 本会话未提交,关闭客户端不保留 · ', ); // 时间戳口径:草稿存的是 `Date.now()` 毫秒,标签复用账本那条秒级格式化器, // 少一次换算就会把毫秒当成秒、显示成完全无关的日期——这里按「就是今天」钉住。 expect(within(recovery).getByText(/^快速编辑草稿/u).textContent).toContain( new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', }).format(new Date()), ); expect(within(recovery).getByText('把角色头发改成红色')).not.toBeNull(); fireEvent.click(within(recovery).getByRole('button', { name: '继续编辑' })); const resumed = await screen.findByRole('dialog', { name: '快速编辑图片', }); await expectPanelPromptContains(resumed, '把角色头发改成红色'); await dismissPanel(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); fireEvent.click( within( await screen.findByRole('dialog', { name: '未完成的资源编辑' }), ).getByRole('button', { name: '丢弃草稿' }), ); await waitFor(() => expect( screen.queryByRole('button', { name: /管理未完成编辑/u }), ).toBeNull(), ); }); it('草稿所在的素材不在当前栏目时:先切栏目定位,再从工具栏重开继续', async () => { installResizeObserverStub(); installInvoke(); render(React.createElement(DraftWorkbench)); await openResourceBookCategory('待归类'); const panel = await openQuickEditPanel('source-art.png'); await setComposerText( within(panel).getByLabelText('快速编辑提示词'), '把夜色改成星空', ); await dismissPanel(); // 人在别的栏目:草稿条目仍在入口里,点「继续编辑」先把栏目切回去。 await openResourceBookCategory('文档'); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); fireEvent.click( within( await screen.findByRole('dialog', { name: '未完成的资源编辑' }), ).getByRole('button', { name: '继续编辑' }), ); await waitFor(() => expect( document.querySelector( '[data-resource-book-view="child"] .game-resource-book-scene-titlebar.is-active[data-resource-book-category="unclassified"]', ), ).not.toBeNull(), ); expect( await screen.findByText(/已定位到「source-art\.png」/u), ).not.toBeNull(); const resumed = await openQuickEditPanel('source-art.png'); await expectPanelPromptContains(resumed, '把夜色改成星空'); }); it('正规化换掉卡片投影后,草稿跟着资源走、重开在正式素材上继续', async () => { installResizeObserverStub(); const { deriveCalls, normalizeCalls } = installInvoke({ createManifest: createCanvasTaskArtManifest, failDerive: true, }); render( React.createElement(DraftWorkbench, { createManifest: createCanvasTaskArtManifest, }), ); await openResourceBookCategory('待归类'); const panel = await openQuickEditPanel('task-art.png'); await setComposerText( within(panel).getByLabelText('快速编辑提示词'), '把夜色改成星空', ); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); // 提交即关面板:面板只负责把任务交出去,失败也不再把它拉回来(回落成草稿 + 提示条)。 await waitFor(() => expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(), ); // 先正规化成正式素材、再派生失败:卡片已经重投影。 await waitFor(() => expect(normalizeCalls).toHaveLength(1)); await waitFor(() => expect(deriveCalls).toHaveLength(1)); await waitFor(() => expect( document.querySelector( '[data-resource-card-id="asset:normalized-task-art"]', ), ).not.toBeNull(), ); expect( document.querySelector( '[data-resource-card-id="task:canvas-task:assets/generated/task-art.png"]', ), ).toBeNull(); // 失败可见:这一笔在「生成任务」侧栏里收口为失败并带上原因(面板已经关了,结果只在这里)。 fireEvent.click(await screen.findByRole('button', { name: '生成任务' })); const taskSidebar = await screen.findByRole('region', { name: '生成任务' }); expect(within(taskSidebar).getByText('失败')).not.toBeNull(); expect( within(taskSidebar).getAllByText(/result-unknown: 测试网络中断/u).length, ).toBeGreaterThan(0); // 草稿回到「未完成编辑」等重开。 fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); expect( within( await screen.findByRole('dialog', { name: '未完成的资源编辑' }), ).getByRole('button', { name: '继续编辑' }), ).not.toBeNull(); fireEvent.click( within( screen.getByRole('dialog', { name: '未完成的资源编辑' }), ).getByRole('button', { name: '关闭资源编辑恢复面板' }), ); // 旧卡片已经不在画布上:草稿按**路径**归属,新投影的正式素材卡照样命中,否则这一笔 // 就再也点不到。 const reopened = await openQuickEditPanel('task-art.png'); await expectPanelPromptContains(reopened, '把夜色改成星空'); }); /** * 旁路重投影:换卡片 id 的不是「打开中这一笔快速编辑」自己,而是 Agent / 外部编辑器把 * 同一路径登记成正式素材。草稿是会话内存态、面板已经收起,没有任何「跟着投影搬家」的机会, * 只有键本身就是稳定身份(路径)时才不会变成查不到的孤儿草稿。 */ it('面板收起后卡片被旁路重投影:草稿仍在入口里,重开照样回填', async () => { installResizeObserverStub(); installInvoke({ createManifest: createCanvasTaskArtManifest, failDerive: true, }); render( React.createElement(DraftWorkbench, { createManifest: createCanvasTaskArtManifest, externalAssetPath: CANVAS_TASK_ART_PATH, }), ); await openResourceBookCategory('待归类'); const panel = await openQuickEditPanel('task-art.png'); await setComposerText( within(panel).getByLabelText('快速编辑提示词'), '把夜色改成星空', ); // 收起面板:这一笔草稿只留在宿主内存里,没有任何打开中的面板替它盯投影。 await dismissPanel(); fireEvent.click( await screen.findByRole('button', { name: '外部登记素材' }), ); await waitFor(() => expect( document.querySelector( '[data-resource-card-id="task:canvas-task:' + CANVAS_TASK_ART_PATH + '"]', ), ).toBeNull(), ); expect( document.querySelector('[data-resource-card-id="asset:external-art"]'), ).not.toBeNull(); // 入口里那一笔还在(旧实现按投影 id 过滤,这里会整条消失)。 fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); const recovery = await screen.findByRole('dialog', { name: '未完成的资源编辑', }); expect(within(recovery).getByText('task-art.png')).not.toBeNull(); fireEvent.click( within(recovery).getByRole('button', { name: '关闭资源编辑恢复面板' }), ); const reopened = await openQuickEditPanel('task-art.png'); await expectPanelPromptContains(reopened, '把夜色改成星空'); }); it('原生恢复队列与本地草稿共用同一枚入口,两边都能继续', async () => { installResizeObserverStub(); installInvoke({ pendingResourceEdits: [ { operationId: '99999999-9999-4999-8999-999999999999', editKind: 'image', sourceResourceId: 'art-resource', assetName: '未完成的派生图.png', phase: 'media-downloaded', createdAt: 1, }, ], }); render(React.createElement(DraftWorkbench)); await openResourceBookCategory('待归类'); const panel = await openQuickEditPanel('source-art.png'); await setComposerText( within(panel).getByLabelText('快速编辑提示词'), '把夜色改成星空', ); await dismissPanel(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (2)' }), ); const recovery = await screen.findByRole('dialog', { name: '未完成的资源编辑', }); // 两边语义不同:本地草稿是「继续编辑」,原生账本是「继续原编辑」。 expect( within(recovery).getByRole('button', { name: '继续编辑' }), ).not.toBeNull(); expect( within(recovery).getByRole('button', { name: '继续原编辑' }), ).not.toBeNull(); }); it('提交成功后这一笔草稿作废,重开是空面板', async () => { installResizeObserverStub(); const { deriveCalls } = installInvoke(); render(React.createElement(DraftWorkbench)); await openResourceBookCategory('待归类'); const panel = await openQuickEditPanel('source-art.png'); await setComposerText( within(panel).getByLabelText('快速编辑提示词'), '把夜色改成星空', ); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); await waitFor(() => expect(deriveCalls).toHaveLength(1)); // 先证明这条草稿真的写进了出站请求,下面的「重开是空面板」才不是空断言。 expect(deriveCalls[0]?.prompt).toBe('把夜色改成星空'); await waitFor(() => expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(), ); // 正式的可恢复事实已经由原 operation 账本接管,本地草稿不再占着恢复入口。 await waitFor(() => expect( screen.queryByRole('button', { name: /管理未完成编辑/u }), ).toBeNull(), ); const reopened = await openQuickEditPanel('source-art.png'); // 用 `composerValue` 读(它内部先让 Lexical 落值):同步读会在值未落时拿到空串, // 让这条否定断言空过——那正是上面那条回填断言的同一类竞态。 expect( await composerValue(within(reopened).getByLabelText('快速编辑提示词')), ).not.toContain('把夜色改成星空'); }); }); /** * 验收现场那条「点了生成,生成任务里什么都没有」:派生链路既没进生成任务侧栏,又让恢复入口 * 继续列着同一条草稿,用户据此判断「这次没提交」。这里钉住三件事——提交当帧就进侧栏、提交 * 期间那笔草稿让位、有结果之后收口为已完成。 */ describe('提交中的快速编辑进「生成任务」侧栏', () => { it('提交当帧进侧栏并显示阶段与提示词,结果回来后收口为已完成', async () => { installResizeObserverStub(); let releaseDerive = () => {}; const deriveGate = new Promise((resolve) => { releaseDerive = resolve; }); const { deriveCalls } = installInvoke({ deriveGate }); render(React.createElement(DraftWorkbench)); await openResourceBookCategory('待归类'); const panel = await openQuickEditPanel('source-art.png'); await setComposerText( within(panel).getByLabelText('快速编辑提示词'), '把嘴改小一点', ); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); await waitFor(() => expect(deriveCalls).toHaveLength(1)); // 在途计数把这一笔算进来:入口按钮带上数量,侧栏里能看到素材名、入口文案与提示词。 const entry = await screen.findByRole('button', { name: '生成任务' }); await waitFor(() => expect(entry.getAttribute('data-resource-generation-task-count')).toBe( '1', ), ); fireEvent.click(entry); const sidebar = await screen.findByRole('region', { name: '生成任务' }); expect(within(sidebar).getByText('source-art-编辑版')).not.toBeNull(); expect(within(sidebar).getByText('快速编辑')).not.toBeNull(); expect(within(sidebar).getByText('把嘴改小一点')).not.toBeNull(); expect(within(sidebar).getByText('生成中')).not.toBeNull(); expect(within(sidebar).queryByText('还没有生成任务')).toBeNull(); releaseDerive(); await waitFor(() => expect(within(sidebar).getByText('已完成')).not.toBeNull(), ); expect(within(sidebar).getByText('已产出新素材')).not.toBeNull(); }); it('同一张素材还在提交中时换卡再回来提交:拦下且不重复派生', async () => { installResizeObserverStub(); let releaseDerive = () => {}; const deriveGate = new Promise((resolve) => { releaseDerive = resolve; }); const { deriveCalls } = installInvoke({ deriveGate }); render(React.createElement(DraftWorkbench)); await openResourceBookCategory('待归类'); const first = await openQuickEditPanel('source-art.png'); await setComposerText( within(first).getByLabelText('快速编辑提示词'), '把嘴改小一点', ); fireEvent.click(within(first).getByRole('button', { name: '修改' })); await waitFor(() => expect(deriveCalls).toHaveLength(1)); // 换到另一张卡:浮层互斥把第一张的面板收起来,而第一笔提交仍在后台跑。工具条这时才露出来, // 所以「已经交出去的那一笔不再算未完成编辑」这条判据只能在这里验(面板开着时工具条被盖住, // 在那个位置断言会变成永远为真的空断言)。 fireEvent.click(await findResourceSelectButton('source-art-2.png')); await waitFor(() => expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(), ); await waitFor(() => expect( screen.queryByRole('button', { name: /管理未完成编辑/u }), ).toBeNull(), ); // 回到第一张:草稿照旧回填,但这一笔已经在跑——再点提交必须被拦下。 const reopened = await openQuickEditPanel('source-art.png'); await expectPanelPromptContains(reopened, '把嘴改小一点'); fireEvent.click(within(reopened).getByRole('button', { name: '修改' })); expect( await within(reopened).findByText( '这张素材正在修改中,请等它出结果后再提交', ), ).not.toBeNull(); expect(deriveCalls).toHaveLength(1); releaseDerive(); }); });