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