Files
Genarrative/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
T
suzmii e765c9f8a6 资源卡工具条接通「生成动画」:只放行真能跑通的角色图派生
- resourceCanvasToolbarModel:只有在「已登记的栅格角色图」上放行 character-animation
  (非角色 / 未登记为正式素材 / 非栅格三类都不放行;Rust 侧角色动画要解码源图取宽高,SVG 必失败);其余动作仍按 opt-in 不渲染,并写明不做它们各自的阻塞证据。
- index.tsx:把源解析与 revision 缓存抽成 resolveResourceDeriveSource,快速编辑与生成动画共用,避免同一个源在两条链路上得到两份 manifest 视图;新增生成动画的源/浮层状态、请求身份(与快速编辑同一套「提示词变了才重铸」口径)与打开/提交/关闭时机。
- 面板复用共享 ImageCanvasCharacterAnimationPanelView,参数入口显式关闭(比例/时长/清晰度与模型由客户端 Rust 固定为 same/720p/32 帧/4 秒/seedance2.0-fast,改它不改变请求);档位常量与 Rust 常量同值,泥点价按同一算法算,避免按钮上出现与实际计费不符的数字。
- 「改造」(redraw) 不再另起入口:其语义由既有「快速编辑」承担,接独立按钮只是同链路重复入口。
- 断言改写(四不写):角色资源那条由「只放行快速编辑与下载」改写为「放行生成动画」,并新增三条反向守卫(非角色图片 / 角色图未登记 / 非栅格角色图均不放行)。
- 端到端新增「从角色资源卡生成动画」:断言 editKind/generationMode/prompt/assetName/sourceAssetId 与泥点价档位,并断言参数入口不存在。
2026-09-11 20:08:22 +08:00

1478 lines
54 KiB
TypeScript

/** @vitest-environment jsdom */
import React, { useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
const canvasFixture = vi.hoisted(() => ({
manifest: null as GameCreationAppManifest | null,
revision: 0,
}));
import {
calculateCharacterAnimationPrice,
CHARACTER_ANIMATION_MODEL,
} from '../../../src/components/image-editor/ImageCanvasGenerationModel';
import ProjectDevelopmentView from '../src/view/project-development';
import {
act,
createGameCreationAppManifest,
findResourceSelectButton,
fireEvent,
openResourceSearch,
render,
screen,
setComposerText,
waitFor,
within,
} from './appSurface/harness';
const projectPath = '/tmp/live-canvas-integration';
type PendingResourceEditFixture = {
operationId: string;
editKind: string;
sourceResourceId: string;
assetName: string;
phase: string;
createdAt: number;
};
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
function graphFor(
resources: Array<{ resourceId: string }>,
manifest: GameCreationAppManifest,
) {
const resourceIds = resources.map((resource) => resource.resourceId);
const referenceEdges = manifest.assets.flatMap((asset) =>
(asset.source.referenceResourceIds ?? []).map((sourceResourceId) => ({
id: `reference:${sourceResourceId}:${asset.id}`,
kind: 'asset-reference' as const,
sourceResourceId,
targetResourceId: `asset:${asset.id}`,
cyclic: false,
})),
);
return {
resourceIds,
referenceEdges,
taskFlows: [],
connectionIndex: resourceIds.map((resourceId) => ({
resourceId,
upstreamReferenceResourceIds: referenceEdges
.filter((edge) => edge.targetResourceId === resourceId)
.map((edge) => edge.sourceResourceId),
downstreamReferenceResourceIds: [],
referenceEdgeIds: referenceEdges
.filter(
(edge) =>
edge.targetResourceId === resourceId ||
edge.sourceResourceId === resourceId,
)
.map((edge) => edge.id),
taskFlowIds: [],
})),
producerAssignments: [],
dependencyDepths: resourceIds.map((resourceId) => ({
resourceId,
dependencyDepth: referenceEdges.some(
(edge) => edge.targetResourceId === resourceId,
)
? 1
: 0,
})),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
function LiveWorkbench() {
const initial = createGameCreationAppManifest(
'live-canvas-project',
'实时画布项目',
);
initial.assets = [
{
id: 'source-art',
kind: 'art-image',
mediaType: 'image/png',
localPath: 'assets/source-art.png',
source: { kind: 'canvas', taskId: null, resourceId: 'source-resource' },
},
];
const [manifest, setManifest] = useState(initial);
canvasFixture.manifest = manifest;
return (
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={projectPath}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
supervisor={<div>Supervisor</div>}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
/>
);
}
function DerivedWorkbench({
includeArt = false,
includeCharacter = false,
}: {
includeArt?: boolean;
includeCharacter?: boolean;
}) {
const initial = createGameCreationAppManifest(
'live-canvas-project',
'实时画布项目',
);
initial.assets = [
{
id: 'source-rules',
kind: 'game-rules',
mediaType: 'text/markdown',
localPath: 'docs/rules.md',
source: { kind: 'generated', resourceId: 'rules-resource' },
},
...(includeArt
? [
{
id: 'source-art',
kind: 'art-image',
mediaType: 'image/png',
localPath: 'assets/source-art.png',
source: { kind: 'generated', resourceId: 'art-resource' },
},
]
: []),
...(includeCharacter
? [
{
id: 'source-character',
kind: 'character',
category: 'character' as const,
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'generated', resourceId: 'hero-resource' },
},
]
: []),
];
const [manifest, setManifest] = useState(initial);
canvasFixture.manifest = manifest;
return (
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={projectPath}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
supervisor={<div>Supervisor</div>}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
/>
);
}
function SwitchableDerivedWorkbench({
onManifestChange,
}: {
onManifestChange: (projectPath: string) => void;
}) {
const [project, setProject] = useState({
id: 'recovery-project-a',
name: '恢复项目 A',
path: '/tmp/recovery-project-a',
});
const manifest = createGameCreationAppManifest(project.id, project.name);
manifest.assets = [
{
id: 'source-rules',
kind: 'game-rules',
mediaType: 'text/markdown',
localPath: 'docs/rules.md',
source: { kind: 'generated', resourceId: 'rules-resource' },
},
];
canvasFixture.manifest = manifest;
return (
<>
<button
type="button"
onClick={() =>
setProject({
id: 'recovery-project-b',
name: '恢复项目 B',
path: '/tmp/recovery-project-b',
})
}
>
切换到恢复项目 B
</button>
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={project.path}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
supervisor={<div>Supervisor</div>}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
onManifestChange={(changedProjectPath) =>
onManifestChange(changedProjectPath)
}
/>
</>
);
}
describe('project resource live canvas integration', () => {
afterEach(() => {
delete window.__TAURI__;
canvasFixture.manifest = null;
canvasFixture.revision = 0;
});
function installTauri(
options: {
failFirstDerive?: boolean;
pendingResourceEdit?: boolean;
pendingResourceEdits?: PendingResourceEditFixture[];
failFirstPendingRead?: boolean;
readPendingResourceEdits?: (
input: Record<string, unknown>,
readCount: number,
) => PendingResourceEditFixture[] | Promise<PendingResourceEditFixture[]>;
resumeResourceEdit?: (
input: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
requestResourceEditServiceIdentityConfirmation?: (
input: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
confirmResourceEditServiceIdentity?: (
input: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
/** 配了才应答 `polish_local_project_prompt`;不配时该命令直接抛错。 */
polishResult?: string;
} = {},
) {
const layoutWrites: Array<Record<string, unknown>> = [];
const graphReads: Array<Record<string, unknown>> = [];
const deriveCalls: Array<Record<string, unknown>> = [];
const polishCalls: Array<Record<string, unknown>> = [];
const resumeCalls: Array<Record<string, unknown>> = [];
const serviceIdentityRequestCalls: Array<Record<string, unknown>> = [];
const serviceIdentityConfirmCalls: Array<Record<string, unknown>> = [];
const archiveCalls: Array<Record<string, unknown>> = [];
const pendingReadCalls: Array<Record<string, unknown>> = [];
let pendingReadCount = 0;
let pendingEdits =
options.pendingResourceEdits ??
(options.pendingResourceEdit
? [
{
operationId: '99999999-9999-4999-8999-999999999999',
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '恢复的规则编辑版',
phase: 'media-downloaded',
createdAt: 1,
},
]
: []);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_project_revision') {
return { revision: canvasFixture.revision };
}
if (command === 'read_local_project_resource_graph') {
graphReads.push(structuredClone(args ?? {}));
return graphFor(
(args?.resources ?? []) as Array<{ resourceId: string }>,
canvasFixture.manifest!,
);
}
if (command === 'read_local_project_resource_document') {
return {
path: String(args?.relativePath ?? 'docs/rules.md'),
mediaType: 'text/markdown',
byteLen: 1,
content: '# rules',
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'live-canvas-project',
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutWrites.push(structuredClone(args ?? {}));
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'live-canvas-project',
mode: args?.mode,
revision: Number(args?.expectedRevision ?? 0) + 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'read_local_project_image_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'image/png',
byteLen: 1,
dataUrl: 'data:image/png;base64,AA==',
};
}
if (command === 'read_local_project_text_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'text/markdown',
byteLen: 8,
content: '# 玩法规则',
};
}
if (command === 'list_pending_local_project_resource_edits') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
pendingReadCount += 1;
pendingReadCalls.push(input);
if (options.failFirstPendingRead && pendingReadCount === 1) {
throw new Error('恢复队列测试读取失败');
}
if (options.readPendingResourceEdits) {
return structuredClone(
await options.readPendingResourceEdits(input, pendingReadCount),
);
}
return structuredClone(pendingEdits);
}
if (command === 'resume_local_project_resource_edit') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
resumeCalls.push(input);
if (options.resumeResourceEdit) {
return await options.resumeResourceEdit(input);
}
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
const operationId = String(input.operationId);
pendingEdits = pendingEdits.filter(
(pending) => pending.operationId !== operationId,
);
const asset = {
id: `edit-${operationId}`,
kind: 'game-rules',
mediaType: 'text/markdown',
localPath: `assets/edits/${operationId}-recovered-rules.md`,
source: {
kind: 'generated' as const,
resourceId: `local-asset:edit-${operationId}`,
referenceResourceIds: ['rules-resource'],
},
};
canvasFixture.revision += 1;
const nextManifest = { ...base, assets: [...base.assets, asset] };
canvasFixture.manifest = nextManifest;
return {
operationId,
editKind: 'text',
sourceResourceId: 'rules-resource',
committedProjectRevision: canvasFixture.revision,
asset,
version: null,
manifest: nextManifest,
};
}
if (
command ===
'request_local_project_resource_edit_service_identity_confirmation'
) {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
serviceIdentityRequestCalls.push(input);
if (options.requestResourceEditServiceIdentityConfirmation) {
return await options.requestResourceEditServiceIdentityConfirmation(
input,
);
}
throw new Error('unexpected resource edit service identity request');
}
if (
command === 'confirm_local_project_resource_edit_service_identity'
) {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
serviceIdentityConfirmCalls.push(input);
if (options.confirmResourceEditServiceIdentity) {
return await options.confirmResourceEditServiceIdentity(input);
}
throw new Error('unexpected resource edit service identity confirm');
}
if (command === 'archive_failed_local_project_resource_edit') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
archiveCalls.push(input);
pendingEdits = pendingEdits.filter(
(pending) => pending.operationId !== input.operationId,
);
return {
operationId: input.operationId,
phase: 'archived',
};
}
if (command === 'derive_local_project_resource') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
deriveCalls.push(input);
if (options.failFirstDerive && deriveCalls.length === 1) {
throw new Error('result-unknown: 测试网络中断');
}
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
const operationId = String(input.operationId);
const assetId = `edit-${operationId}`;
const asset = {
id: assetId,
kind: 'game-rules',
mediaType: 'text/markdown',
localPath: `assets/edits/${operationId}-rules.md`,
source: {
kind: 'generated' as const,
resourceId: `local-asset:${assetId}`,
referenceResourceIds: ['rules-resource'],
},
};
canvasFixture.revision += 1;
const nextManifest = {
...base,
assets: [...base.assets, asset],
};
canvasFixture.manifest = nextManifest;
return {
operationId,
editKind: input.editKind,
sourceResourceId: 'rules-resource',
committedProjectRevision: canvasFixture.revision,
asset,
version: null,
manifest: nextManifest,
};
}
if (command === 'polish_local_project_prompt') {
polishCalls.push(structuredClone(args ?? {}));
if (options.polishResult) {
return options.polishResult;
}
throw new Error('polish unavailable in this fixture');
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: async () => () => undefined },
};
return {
invoke,
deriveCalls,
polishCalls,
graphReads,
layoutWrites,
pendingReadCalls,
resumeCalls,
archiveCalls,
serviceIdentityRequestCalls,
serviceIdentityConfirmCalls,
};
}
it('derives a new art asset non-destructively from the quick edit panel and reuses the original operation identity', async () => {
const { deriveCalls } = installTauri({ failFirstDerive: true });
render(<DerivedWorkbench includeArt />);
fireEvent.click(screen.getByRole('button', { name: '打开待归类' }));
fireEvent.click(await findResourceSelectButton('source-art.png'));
const toolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
const panel = await screen.findByRole('dialog', {
name: '快速编辑图片',
});
await setComposerText(
within(panel).getByLabelText('快速编辑提示词'),
'把角色头发设定改为红色',
);
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
expect(await screen.findByRole('alert')).not.toBeNull();
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
await waitFor(() => expect(deriveCalls).toHaveLength(2));
expect(deriveCalls[0]?.operationId).toBe(deriveCalls[1]?.operationId);
expect(deriveCalls[0]?.idempotencyKey).toBe(deriveCalls[1]?.idempotencyKey);
expect(deriveCalls[0]?.editKind).toBe('image-reference');
expect(deriveCalls[0]?.generationMode).toBe('derive');
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
expect(deriveCalls[1]).not.toHaveProperty('accessToken');
expect(deriveCalls[1]).not.toHaveProperty('apiKey');
});
it('从角色资源卡生成动画:走 character-animation 派生,且不渲染会被后端忽略的参数入口', async () => {
const { deriveCalls } = installTauri();
render(<DerivedWorkbench includeCharacter />);
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(within(toolbar).getByRole('button', { name: '生成动画' }));
const panel = await screen.findByRole('dialog', {
name: '角色动画生成面板',
});
// 参数入口必须不存在:Rust 固定 720p / same / 32 帧 / 4 秒,改它不会改变请求。
expect(
within(panel).queryByRole('button', { name: /动画参数/ }),
).toBeNull();
await setComposerText(
within(panel).getByLabelText('动画描述'),
'角色挥手打招呼',
);
// 泥点价必须按 Rust 实际固定的 720p×4 秒算,而不是共享 draft 工厂的 480p 默认档:
// 档位与 Rust 常量不一致,按钮上就是一个与实际计费不符的数字(内置价表 20 vs 10 每秒)。
const submitButton = within(panel).getByRole('button', {
name: /生成[\d.]+泥点/,
});
expect(submitButton.textContent).toContain(
`${calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 4)}泥点`,
);
expect(submitButton.textContent).not.toContain(
`${calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '480p', 4)}泥点`,
);
fireEvent.click(submitButton);
await waitFor(() => expect(deriveCalls).toHaveLength(1));
expect(deriveCalls[0]?.editKind).toBe('character-animation');
expect(deriveCalls[0]?.generationMode).toBe('derive');
expect(deriveCalls[0]?.prompt).toBe('角色挥手打招呼');
expect(deriveCalls[0]?.assetName).toBe('hero-角色动画');
expect(deriveCalls[0]?.sourceAssetId).toBe('source-character');
expect(deriveCalls[0]?.sourceMediaType).toBe('image/png');
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
});
it('快速编辑里润色提示词:带场景约束回填,提示词变了就换请求身份', async () => {
const { deriveCalls, polishCalls } = installTauri({
failFirstDerive: true,
polishResult: '把角色头发设定改为亮红色',
});
render(<DerivedWorkbench includeArt />);
fireEvent.click(screen.getByRole('button', { name: '打开待归类' }));
fireEvent.click(await findResourceSelectButton('source-art.png'));
const toolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
const panel = await screen.findByRole('dialog', {
name: '快速编辑图片',
});
await setComposerText(
within(panel).getByLabelText('快速编辑提示词'),
'把角色头发改成红色',
);
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
expect(await screen.findByRole('alert')).not.toBeNull();
// 失败后提示词仍可改:润色一次,回填的是润色结果,而不是原文。
fireEvent.click(within(panel).getByRole('button', { name: 'AI 润色' }));
await waitFor(() => {
expect(
within(panel).getByLabelText('快速编辑提示词').textContent,
).toContain('亮红色');
});
expect(polishCalls[0]).toMatchObject({ prompt: '把角色头发改成红色' });
expect(String(polishCalls[0]?.context)).toContain(
'图片素材的快速编辑提示词',
);
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
await waitFor(() => expect(deriveCalls).toHaveLength(2));
// Rust 的 request_fingerprint 含 prompt:提示词换过就必须换 operationId / 幂等键,
// 否则后端会判「已绑定到不同资源编辑请求」。
expect(deriveCalls[1]?.prompt).toBe('把角色头发设定改为亮红色');
expect(deriveCalls[1]?.operationId).not.toBe(deriveCalls[0]?.operationId);
expect(deriveCalls[1]?.idempotencyKey).not.toBe(
deriveCalls[0]?.idempotencyKey,
);
});
it('快速编辑里润色失败:保留原文、给出可重试提示,也不换请求身份', async () => {
const { deriveCalls } = installTauri({ failFirstDerive: true });
render(<DerivedWorkbench includeArt />);
fireEvent.click(screen.getByRole('button', { name: '打开待归类' }));
fireEvent.click(await findResourceSelectButton('source-art.png'));
const toolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
const panel = await screen.findByRole('dialog', {
name: '快速编辑图片',
});
await setComposerText(
within(panel).getByLabelText('快速编辑提示词'),
'把角色头发改成红色',
);
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
expect(await screen.findByRole('alert')).not.toBeNull();
fireEvent.click(within(panel).getByRole('button', { name: 'AI 润色' }));
expect(
await within(panel).findByText('AI 润色失败,可重试'),
).not.toBeNull();
expect(
within(panel).getByLabelText('快速编辑提示词').textContent,
).toContain('把角色头发改成红色');
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
await waitFor(() => expect(deriveCalls).toHaveLength(2));
// 提示词没变,身份也不该变:重试仍然命中同一 operation 账本。
expect(deriveCalls[1]?.operationId).toBe(deriveCalls[0]?.operationId);
expect(deriveCalls[1]?.idempotencyKey).toBe(deriveCalls[0]?.idempotencyKey);
});
it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => {
const { deriveCalls } = installTauri({ failFirstDerive: true });
render(<DerivedWorkbench />);
fireEvent.click(await screen.findByRole('button', { name: '生成素材' }));
const panel = await screen.findByRole('dialog', { name: '生成素材' });
fireEvent.change(within(panel).getByLabelText('生成提示词'), {
target: { value: '一段片头动画,镜头缓慢推进' },
});
fireEvent.click(within(panel).getByRole('button', { name: '生成视频' }));
expect(await within(panel).findByRole('alert')).not.toBeNull();
fireEvent.click(
within(panel).getByRole('button', { name: '使用原请求重试' }),
);
await waitFor(() => expect(deriveCalls).toHaveLength(2));
const operationId = String(deriveCalls[0]?.operationId);
expect(deriveCalls[0]).toMatchObject({
projectPath,
expectedProjectId: 'live-canvas-project',
editKind: 'video',
generationMode: 'create',
sourceResourceId: `create:${operationId}`,
sourceAssetId: null,
sourcePath: null,
sourceMediaType: 'video/mp4',
sourceSubtype: null,
producerTaskId: null,
sourceVersionId: null,
prompt: '一段片头动画,镜头缓慢推进',
assetName: '新视频',
});
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
expect(deriveCalls[1]?.operationId).toBe(operationId);
expect(deriveCalls[1]?.idempotencyKey).toBe(deriveCalls[0]?.idempotencyKey);
expect(screen.queryByRole('dialog', { name: '生成素材' })).toBeNull();
// 产出物是新素材:画布定位并选中新卡片。
expect(
(await findResourceSelectButton(`${operationId}-rules.md`)).getAttribute(
'aria-pressed',
),
).toBe('true');
});
it('keeps the search condition, offers an explicit clear-and-locate, then locates the new asset', async () => {
const { deriveCalls } = installTauri();
render(<DerivedWorkbench includeArt />);
fireEvent.click(screen.getByRole('button', { name: '打开待归类' }));
fireEvent.click(await findResourceSelectButton('source-art.png'));
const toolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
const panel = await screen.findByRole('dialog', {
name: '快速编辑图片',
});
await setComposerText(
within(panel).getByLabelText('快速编辑提示词'),
'把角色头发设定改为红色',
);
// 搜索条件在提交前就存在,新素材(文档分类)不会命中它:走"被当前搜索隐藏"分支。
const search = openResourceSearch();
fireEvent.change(search, { target: { value: 'source-art' } });
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
await waitFor(() => expect(deriveCalls).toHaveLength(1));
// 保留条件并明确提示,只通过显式动作清除条件并定位,不静默改搜索。
expect(
await screen.findByText('新资源已保存,但被当前搜索条件隐藏'),
).not.toBeNull();
// 提交时点了快速编辑面板,搜索浮层按「点外部」收起;重新叫出来读到的仍是被保留的条件。
expect(openResourceSearch().value).toBe('source-art');
fireEvent.click(screen.getByRole('button', { name: '清除搜索并定位' }));
// 清空只由这个显式动作发起:再叫出浮层读到的已经是空值。
expect(openResourceSearch().value).toBe('');
const operationId = String(deriveCalls[0]?.operationId);
expect(
(await findResourceSelectButton(`${operationId}-rules.md`)).getAttribute(
'aria-pressed',
),
).toBe('true');
expect(screen.queryByText('新资源已保存,但被当前搜索条件隐藏')).toBeNull();
});
it('hides the canvas generation entry when the client bridge is unavailable', async () => {
render(<DerivedWorkbench />);
expect(
await screen.findByRole('button', { name: '资源面板' }),
).not.toBeNull();
expect(screen.queryByRole('button', { name: '生成素材' })).toBeNull();
});
it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => {
const { resumeCalls } = installTauri({ pendingResourceEdit: true });
render(<DerivedWorkbench />);
const manage = await screen.findByRole('button', {
name: '管理未完成编辑 (1)',
});
fireEvent.click(manage);
const resume = await screen.findByRole('button', {
name: '继续原编辑',
});
fireEvent.click(resume);
await waitFor(() => expect(resumeCalls).toHaveLength(1));
expect(resumeCalls[0]).toEqual({
projectPath,
expectedProjectId: 'live-canvas-project',
operationId: '99999999-9999-4999-8999-999999999999',
});
expect(resumeCalls[0]).not.toHaveProperty('prompt');
expect(resumeCalls[0]).not.toHaveProperty('endpoint');
expect(resumeCalls[0]).not.toHaveProperty('idempotencyKey');
// 恢复完成后新素材被选中;不再有"资源详情"面板。
expect(
(
await screen.findByRole('button', {
name: /选中资源:\S+ \S*recovered-rules\.md$/,
})
).getAttribute('aria-pressed'),
).toBe('true');
});
it('显式确认旧 Key-bound 服务后只用原 operation 继续恢复', async () => {
const operation: PendingResourceEditFixture = {
operationId: 'abababab-abab-4bab-8bab-abababababab',
editKind: 'video',
sourceResourceId: 'source-video',
assetName: '旧 Key 视频编辑',
phase: 'accepted',
createdAt: 1,
};
let identityConfirmed = false;
let committed = false;
const {
resumeCalls,
serviceIdentityRequestCalls,
serviceIdentityConfirmCalls,
} = installTauri({
readPendingResourceEdits: () => (committed ? [] : [operation]),
resumeResourceEdit: async () => {
if (!identityConfirmed) {
throw new Error(
'service-identity-confirmation-required: 当前服务地址需要用户确认',
);
}
const base = canvasFixture.manifest!;
const asset = {
id: `edit-${operation.operationId}`,
kind: 'video',
mediaType: 'video/mp4',
localPath: `assets/edits/${operation.operationId}.mp4`,
source: {
kind: 'canvas' as const,
resourceId: 'remote-video-resource',
referenceResourceIds: ['source-video'],
},
};
committed = true;
canvasFixture.revision += 1;
const manifest = { ...base, assets: [...base.assets, asset] };
canvasFixture.manifest = manifest;
return {
operationId: operation.operationId,
editKind: 'video',
sourceResourceId: 'source-video',
committedProjectRevision: canvasFixture.revision,
asset,
version: null,
manifest,
};
},
requestResourceEditServiceIdentityConfirmation: async (input) => ({
operationId: input.operationId,
remoteOperationId: 'remote-existing-operation',
operationState: 'accepted',
serviceOrigin: 'https://editor.example.test',
challenge: 'challenge-bound-to-the-current-ledger-snapshot',
expiresAt: 9_999_999_999,
}),
confirmResourceEditServiceIdentity: async (input) => {
expect(input).toMatchObject({
operationId: operation.operationId,
remoteOperationId: 'remote-existing-operation',
challenge: 'challenge-bound-to-the-current-ledger-snapshot',
});
identityConfirmed = true;
return {
operationId: operation.operationId,
remoteOperationId: 'remote-existing-operation',
operationState: 'accepted',
serviceOrigin: 'https://editor.example.test',
identityScheme: 'service-origin-v1',
};
},
});
render(<DerivedWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
);
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
expect(
await screen.findByText('当前服务:https://editor.example.test'),
).not.toBeNull();
expect(serviceIdentityRequestCalls).toHaveLength(1);
expect(resumeCalls).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '确认当前服务并继续' }));
await waitFor(() => expect(resumeCalls).toHaveLength(2));
expect(serviceIdentityConfirmCalls).toHaveLength(1);
expect(resumeCalls[0]).toEqual(resumeCalls[1]);
expect(resumeCalls[1]).toEqual({
projectPath,
expectedProjectId: 'live-canvas-project',
operationId: operation.operationId,
});
});
it('恢复面板可跳过首条失败任务继续任意 operation,且对账项不会被重放', async () => {
const failedOperationId = '11111111-1111-4111-8111-111111111111';
const resumableOperationId = '22222222-2222-4222-8222-222222222222';
const reconciliationOperationId = '33333333-3333-4333-8333-333333333333';
const { archiveCalls, pendingReadCalls, resumeCalls } = installTauri({
pendingResourceEdits: [
{
operationId: failedOperationId,
editKind: 'image',
sourceResourceId: 'source-art',
assetName: '已失败图片编辑',
phase: 'remote-failed',
createdAt: 1,
},
{
operationId: resumableOperationId,
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '可继续规则编辑',
phase: 'media-downloaded',
createdAt: 2,
},
{
operationId: reconciliationOperationId,
editKind: 'agent-result',
sourceResourceId: 'agent-result',
assetName: '需要对账的结果',
phase: 'reconciliation-required',
createdAt: 3,
},
],
});
render(<DerivedWorkbench />);
fireEvent.click(
await screen.findByRole('button', {
name: '管理未完成编辑 (3)',
}),
);
const dialog = await screen.findByRole('dialog', {
name: '未完成的资源编辑',
});
expect(dialog.textContent).toContain('远程明确失败');
expect(dialog.textContent).toContain('需要人工对账');
expect(screen.getByText('只读对账')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
await waitFor(() => expect(resumeCalls).toHaveLength(1));
await waitFor(() => expect(pendingReadCalls).toHaveLength(2));
expect(resumeCalls[0]?.operationId).toBe(resumableOperationId);
expect(resumeCalls[0]?.operationId).not.toBe(reconciliationOperationId);
fireEvent.click(
await screen.findByRole('button', {
name: '管理未完成编辑 (2)',
}),
);
fireEvent.click(
await screen.findByRole('button', { name: '移出恢复队列' }),
);
await waitFor(() => expect(archiveCalls).toHaveLength(1));
await waitFor(() => expect(pendingReadCalls).toHaveLength(3));
expect(archiveCalls[0]?.operationId).toBe(failedOperationId);
expect(screen.getByText('只读对账')).not.toBeNull();
expect(
await screen.findByRole('button', {
name: '管理未完成编辑 (1)',
}),
).not.toBeNull();
});
it('项目切换后丢弃旧项目迟到的恢复队列读取结果', async () => {
const oldRead = createDeferred<PendingResourceEditFixture[]>();
const oldOperation: PendingResourceEditFixture = {
operationId: '44444444-4444-4444-8444-444444444444',
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '旧项目迟到任务',
phase: 'media-downloaded',
createdAt: 1,
};
const newOperation: PendingResourceEditFixture = {
operationId: '55555555-5555-4555-8555-555555555555',
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '新项目权威任务',
phase: 'media-downloaded',
createdAt: 2,
};
const { pendingReadCalls } = installTauri({
readPendingResourceEdits: (input) =>
input.projectPath === '/tmp/recovery-project-a'
? oldRead.promise
: [newOperation],
});
render(<SwitchableDerivedWorkbench onManifestChange={() => undefined} />);
await waitFor(() => expect(pendingReadCalls).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: '切换到恢复项目 B' }));
await waitFor(() => expect(pendingReadCalls).toHaveLength(2));
expect(pendingReadCalls[1]).toMatchObject({
projectPath: '/tmp/recovery-project-b',
expectedProjectId: 'recovery-project-b',
});
expect(
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
).not.toBeNull();
await act(async () => {
oldRead.resolve([oldOperation]);
await oldRead.promise;
});
fireEvent.click(screen.getByRole('button', { name: '管理未完成编辑 (1)' }));
expect(await screen.findByText('新项目权威任务')).not.toBeNull();
expect(screen.queryByText('旧项目迟到任务')).toBeNull();
});
it('项目切换后丢弃旧项目迟到的恢复结果', async () => {
const staleResume = createDeferred<Record<string, unknown>>();
const staleOperation: PendingResourceEditFixture = {
operationId: '66666666-6666-4666-8666-666666666666',
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '旧项目恢复任务',
phase: 'media-downloaded',
createdAt: 1,
};
const currentOperation: PendingResourceEditFixture = {
operationId: '77777777-7777-4777-8777-777777777777',
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '新项目保留任务',
phase: 'media-downloaded',
createdAt: 2,
};
const onManifestChange = vi.fn();
const { resumeCalls } = installTauri({
readPendingResourceEdits: (input) =>
input.projectPath === '/tmp/recovery-project-a'
? [staleOperation]
: [currentOperation],
resumeResourceEdit: () => staleResume.promise,
});
render(<SwitchableDerivedWorkbench onManifestChange={onManifestChange} />);
fireEvent.click(
await screen.findByRole('button', {
name: '管理未完成编辑 (1)',
}),
);
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
await waitFor(() => expect(resumeCalls).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: '切换到恢复项目 B' }));
const currentTrigger = await screen.findByRole('button', {
name: '管理未完成编辑 (1)',
});
expect(screen.queryByText('旧项目恢复任务')).toBeNull();
const staleManifest = createGameCreationAppManifest(
'recovery-project-a',
'恢复项目 A',
);
const staleAsset = {
id: 'stale-recovered-asset',
kind: 'game-rules',
mediaType: 'text/markdown',
localPath: 'assets/stale-recovered-rules.md',
source: {
kind: 'generated' as const,
resourceId: 'local-asset:stale-recovered-asset',
referenceResourceIds: ['rules-resource'],
},
};
staleManifest.assets = [...staleManifest.assets, staleAsset];
await act(async () => {
staleResume.resolve({
operationId: staleOperation.operationId,
editKind: 'text',
sourceResourceId: 'rules-resource',
committedProjectRevision: 1,
asset: staleAsset,
version: null,
manifest: staleManifest,
});
await staleResume.promise;
});
expect(onManifestChange).not.toHaveBeenCalled();
fireEvent.click(currentTrigger);
expect(await screen.findByText('新项目保留任务')).not.toBeNull();
expect(screen.queryByText('旧项目恢复任务')).toBeNull();
expect(
screen.queryByRole('button', { name: /stale-recovered/u }),
).toBeNull();
});
it('恢复成功后的权威队列重读迟到时不会关闭新项目面板', async () => {
const postActionRead = createDeferred<PendingResourceEditFixture[]>();
const oldOperation: PendingResourceEditFixture = {
operationId: '88888888-8888-4888-8888-888888888888',
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '旧项目已恢复任务',
phase: 'media-downloaded',
createdAt: 1,
};
const newOperation: PendingResourceEditFixture = {
operationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '新项目不能被关闭的任务',
phase: 'media-downloaded',
createdAt: 2,
};
const { pendingReadCalls, resumeCalls } = installTauri({
readPendingResourceEdits: (input, readCount) => {
if (input.projectPath === '/tmp/recovery-project-b') {
return [newOperation];
}
return readCount === 1 ? [oldOperation] : postActionRead.promise;
},
});
render(<SwitchableDerivedWorkbench onManifestChange={() => undefined} />);
fireEvent.click(
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
);
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
await waitFor(() => expect(resumeCalls).toHaveLength(1));
await waitFor(() => expect(pendingReadCalls).toHaveLength(2));
fireEvent.click(screen.getByRole('button', { name: '切换到恢复项目 B' }));
await waitFor(() => expect(pendingReadCalls).toHaveLength(3));
fireEvent.click(
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
);
expect(await screen.findByText('新项目不能被关闭的任务')).not.toBeNull();
await act(async () => {
postActionRead.resolve([]);
await postActionRead.promise;
});
expect(
screen.getByRole('dialog', { name: '未完成的资源编辑' }),
).not.toBeNull();
expect(screen.getByText('新项目不能被关闭的任务')).not.toBeNull();
});
it('恢复失败后重读权威队列并展示后端迁移后的失败阶段', async () => {
const operation: PendingResourceEditFixture = {
operationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '阶段迁移任务',
phase: 'media-downloaded',
createdAt: 1,
};
const { pendingReadCalls } = installTauri({
readPendingResourceEdits: (_input, readCount) => [
{
...operation,
phase: readCount === 1 ? 'media-downloaded' : 'remote-failed',
},
],
resumeResourceEdit: async () => {
throw new Error('远程恢复明确失败');
},
});
render(<DerivedWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
);
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
await waitFor(() => expect(pendingReadCalls).toHaveLength(2));
expect((await screen.findByRole('alert')).textContent).toContain(
'远程恢复明确失败',
);
expect(screen.getByText('远程明确失败')).not.toBeNull();
expect(screen.getByRole('button', { name: '移出恢复队列' })).not.toBeNull();
});
it('仅锁定正在操作的 operation,同项防双击且其他项可并行', async () => {
const firstResume = createDeferred<Record<string, unknown>>();
const secondResume = createDeferred<Record<string, unknown>>();
const firstOperationId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
const secondOperationId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
const { resumeCalls } = installTauri({
pendingResourceEdits: [
{
operationId: firstOperationId,
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '并行任务一',
phase: 'media-downloaded',
createdAt: 1,
},
{
operationId: secondOperationId,
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '并行任务二',
phase: 'media-downloaded',
createdAt: 2,
},
],
resumeResourceEdit: (input) =>
input.operationId === firstOperationId
? firstResume.promise
: secondResume.promise,
});
render(<DerivedWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: '管理未完成编辑 (2)' }),
);
const actions = screen.getAllByRole('button', { name: '继续原编辑' });
fireEvent.click(actions[0]);
fireEvent.click(actions[0]);
expect(actions[0].hasAttribute('disabled')).toBe(true);
expect(actions[1].hasAttribute('disabled')).toBe(false);
fireEvent.click(actions[1]);
await waitFor(() => expect(resumeCalls).toHaveLength(2));
expect(resumeCalls.map((call) => call.operationId)).toEqual([
firstOperationId,
secondOperationId,
]);
});
it('并行 operation 的权威重读互相判旧时仍分别保留原始失败', async () => {
const firstResume = createDeferred<Record<string, unknown>>();
const secondResume = createDeferred<Record<string, unknown>>();
const firstRead = createDeferred<PendingResourceEditFixture[]>();
const secondRead = createDeferred<PendingResourceEditFixture[]>();
const firstOperation: PendingResourceEditFixture = {
operationId: 'f1111111-1111-4111-8111-111111111111',
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '并行失败任务一',
phase: 'media-downloaded',
createdAt: 1,
};
const secondOperation: PendingResourceEditFixture = {
operationId: 'f2222222-2222-4222-8222-222222222222',
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '并行失败任务二',
phase: 'media-downloaded',
createdAt: 2,
};
const operations = [firstOperation, secondOperation];
const { pendingReadCalls } = installTauri({
readPendingResourceEdits: (_input, readCount) => {
if (readCount === 1) return operations;
return readCount === 2 ? firstRead.promise : secondRead.promise;
},
resumeResourceEdit: (input) =>
input.operationId === firstOperation.operationId
? firstResume.promise
: secondResume.promise,
});
render(<DerivedWorkbench />);
fireEvent.click(
await screen.findByRole('button', {
name: '管理未完成编辑 (2)',
}),
);
const actions = screen.getAllByRole('button', { name: '继续原编辑' });
fireEvent.click(actions[0]);
fireEvent.click(actions[1]);
await act(async () => {
firstResume.reject(new Error('并行恢复一失败'));
await firstResume.promise.catch(() => undefined);
});
await waitFor(() => expect(pendingReadCalls).toHaveLength(2));
await act(async () => {
secondResume.reject(new Error('并行恢复二失败'));
await secondResume.promise.catch(() => undefined);
});
await waitFor(() => expect(pendingReadCalls).toHaveLength(3));
await act(async () => {
firstRead.resolve(operations);
await firstRead.promise;
});
expect((await screen.findByRole('alert')).textContent).toContain(
'并行恢复一失败',
);
await act(async () => {
secondRead.resolve(operations);
await secondRead.promise;
});
await waitFor(() => {
const alert = screen.getByRole('alert');
expect(alert.textContent).toContain('并行恢复一失败');
expect(alert.textContent).toContain('并行恢复二失败');
});
});
it('旧弹窗动作完成时不会关闭用户后来重新打开的同项目弹窗', async () => {
const pendingResume = createDeferred<Record<string, unknown>>();
const operation: PendingResourceEditFixture = {
operationId: 'f3333333-3333-4333-8333-333333333333',
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '跨弹窗恢复任务',
phase: 'media-downloaded',
createdAt: 1,
};
const { pendingReadCalls } = installTauri({
readPendingResourceEdits: (_input, readCount) =>
readCount === 1 ? [operation] : [],
resumeResourceEdit: () => pendingResume.promise,
});
render(<DerivedWorkbench />);
const trigger = await screen.findByRole('button', {
name: '管理未完成编辑 (1)',
});
fireEvent.click(trigger);
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
fireEvent.click(
screen.getByRole('button', { name: '关闭资源编辑恢复面板' }),
);
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '未完成的资源编辑' }),
).toBeNull(),
);
fireEvent.click(trigger);
expect(
await screen.findByRole('dialog', { name: '未完成的资源编辑' }),
).not.toBeNull();
const base = canvasFixture.manifest!;
const asset = {
id: 'reopened-dialog-output',
kind: 'game-rules',
mediaType: 'text/markdown',
localPath: 'assets/reopened-dialog-output.md',
source: {
kind: 'generated' as const,
resourceId: 'local-asset:reopened-dialog-output',
referenceResourceIds: ['rules-resource'],
},
};
const nextManifest = { ...base, assets: [...base.assets, asset] };
canvasFixture.manifest = nextManifest;
await act(async () => {
pendingResume.resolve({
operationId: operation.operationId,
editKind: 'text',
sourceResourceId: 'rules-resource',
committedProjectRevision: 1,
asset,
version: null,
manifest: nextManifest,
});
await pendingResume.promise;
});
await waitFor(() => expect(pendingReadCalls).toHaveLength(2));
expect(
screen.getByRole('dialog', { name: '未完成的资源编辑' }),
).not.toBeNull();
expect(screen.getByText('当前没有未完成的资源编辑')).not.toBeNull();
});
it('恢复已提交但权威队列重读失败时保留面板并显式报错', async () => {
const operation: PendingResourceEditFixture = {
operationId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '已提交待确认任务',
phase: 'media-downloaded',
createdAt: 1,
};
installTauri({
readPendingResourceEdits: (_input, readCount) => {
if (readCount > 1) throw new Error('提交后权威队列读取失败');
return [operation];
},
});
render(<DerivedWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
);
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
expect((await screen.findByRole('alert')).textContent).toContain(
'提交后权威队列读取失败',
);
expect(
screen.getByRole('dialog', { name: '未完成的资源编辑' }),
).not.toBeNull();
expect(screen.getByText('已提交待确认任务')).not.toBeNull();
});
it('恢复队列读取失败时显式提供重试,不伪装成空队列', async () => {
installTauri({
failFirstPendingRead: true,
pendingResourceEdit: true,
});
render(<DerivedWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: '恢复队列读取失败' }),
);
expect((await screen.findByRole('alert')).textContent).toContain(
'恢复队列测试读取失败',
);
fireEvent.click(screen.getByRole('button', { name: '重试读取' }));
expect(await screen.findByText('恢复的规则编辑版')).not.toBeNull();
});
it('恢复面板循环约束 Tab 焦点,Escape 关闭后归还触发器聚焦', async () => {
installTauri({ pendingResourceEdit: true });
render(<DerivedWorkbench />);
const trigger = await screen.findByRole('button', {
name: '管理未完成编辑 (1)',
});
fireEvent.click(trigger);
const close = await screen.findByRole('button', {
name: '关闭资源编辑恢复面板',
});
expect(document.activeElement).toBe(close);
const resume = screen.getByRole('button', { name: '继续原编辑' });
fireEvent.keyDown(window, { key: 'Tab', shiftKey: true });
expect(document.activeElement).toBe(resume);
fireEvent.keyDown(window, { key: 'Tab' });
expect(document.activeElement).toBe(close);
trigger.focus();
fireEvent.keyDown(window, { key: 'Tab' });
expect(document.activeElement).toBe(close);
fireEvent.keyDown(window, { key: 'Escape' });
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '未完成的资源编辑' }),
).toBeNull(),
);
await waitFor(() => expect(document.activeElement).toBe(trigger));
});
it('恢复动作使当前按钮动态禁用后仍将 Tab 焦点约束在弹窗内', async () => {
const pendingResume = createDeferred<Record<string, unknown>>();
installTauri({
pendingResourceEdit: true,
resumeResourceEdit: () => pendingResume.promise,
});
render(<DerivedWorkbench />);
fireEvent.click(
await screen.findByRole('button', {
name: '管理未完成编辑 (1)',
}),
);
const close = await screen.findByRole('button', {
name: '关闭资源编辑恢复面板',
});
const resume = screen.getByRole('button', { name: '继续原编辑' });
resume.focus();
fireEvent.click(resume);
await waitFor(() => expect(resume.hasAttribute('disabled')).toBe(true));
expect(document.activeElement).toBe(resume);
fireEvent.keyDown(window, { key: 'Tab' });
expect(document.activeElement).toBe(close);
await act(async () => {
pendingResume.reject(new Error('焦点测试恢复失败'));
await pendingResume.promise.catch(() => undefined);
});
expect((await screen.findByRole('alert')).textContent).toContain(
'焦点测试恢复失败',
);
});
});