Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx
T
suzmii 973e965f4f 卡片浮层改为提交即关:快速编辑 / 生成动画只负责交任务
- 快速编辑与生成动画点提交当帧即关面板(不等 IPC、不留等待态),进度、阶段与结果只由「生成任务」侧栏承载,与图片类生成面板同一口径
- 受理之后的失败不再重开面板:侧栏那条收口为失败 + 提示条,草稿回落成「未完成编辑」等用户重开;只有点击瞬间就失败(名称预检、源素材不在画布、无客户端)留在面板里
- 派生请求身份改为按「入口 + 资源路径」记忆:重开面板再提交时提示词没变就沿用同一 operationId / 幂等键,命中同一 operation 账本;派生成成功后清除
- 删除 canDismissResourceCanvasQuickEdit 与宿主里只为它服务的两块面板状态 ref,clearResourceCanvasFocus 回到「清选中 + 关两块浮层」的直线逻辑
- 三条既有用例按新口径改写为「重开面板再重试,身份不变」;PRD §3.10 与 decision-log 同步
2026-09-21 00:19:42 +08:00

830 lines
30 KiB
TypeScript

/** @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
createGameCreationAppManifest,
findResourceSelectButton,
fireEvent,
installResizeObserverStub,
ProjectDevelopmentView,
React,
render,
screen,
setComposerText,
waitFor,
within,
} from './appSurface/harness';
vi.mock('@tauri-apps/api/core', async () => ({
...(await vi.importActual<typeof import('@tauri-apps/api/core')>(
'@tauri-apps/api/core',
)),
invoke: (command: string, args?: Record<string, unknown>) =>
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:<id>`——卡片被重投影走、
* 面板的 `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<void>;
} = {},
) {
const createManifest = options.createManifest ?? createDraftManifest;
let revision = 0;
let manifest = createManifest();
const deriveCalls: Array<Record<string, unknown>> = [];
const normalizeCalls: Array<Record<string, unknown>> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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<string, unknown>,
);
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<string, unknown>,
);
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 ?? '';
}
/** 关掉画布浮层: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');
expect(panelPromptText(reopened)).toContain('把夜色改成星空');
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();
expect(
panelPromptText(await openQuickEditPanel('source-art.png')),
).toContain('第一张的草稿');
await dismissPanel();
expect(
panelPromptText(await openQuickEditPanel('source-art-2.png')),
).toContain('第二张的草稿');
});
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: '快速编辑图片',
});
expect(panelPromptText(resumed)).toContain('把角色头发改成红色');
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');
expect(panelPromptText(resumed)).toContain('把夜色改成星空');
});
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');
expect(panelPromptText(reopened)).toContain('把夜色改成星空');
});
/**
* 旁路重投影:换卡片 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');
expect(panelPromptText(reopened)).toContain('把夜色改成星空');
});
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');
expect(panelPromptText(reopened)).not.toContain('把夜色改成星空');
});
});
/**
* 验收现场那条「点了生成,生成任务里什么都没有」:派生链路既没进生成任务侧栏,又让恢复入口
* 继续列着同一条草稿,用户据此判断「这次没提交」。这里钉住三件事——提交当帧就进侧栏、提交
* 期间那笔草稿让位、有结果之后收口为已完成。
*/
describe('提交中的快速编辑进「生成任务」侧栏', () => {
it('提交当帧进侧栏并显示阶段与提示词,结果回来后收口为已完成', async () => {
installResizeObserverStub();
let releaseDerive = () => {};
const deriveGate = new Promise<void>((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<void>((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');
expect(panelPromptText(reopened)).toContain('把嘴改小一点');
fireEvent.click(within(reopened).getByRole('button', { name: '修改' }));
expect(
await within(reopened).findByText(
'这张素材正在修改中,请等它出结果后再提交',
),
).not.toBeNull();
expect(deriveCalls).toHaveLength(1);
releaseDerive();
});
});