Files
Genarrative/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
suzmii 532c6d51fd
Project CI / Repository checks (push) Successful in 4m5s
Project CI / Frontend tests (push) Successful in 4m48s
Project CI / Backend tests (push) Successful in 6m6s
Project CI / Native shell tests (push) Successful in 15m24s
完善 Game Agent 资源画布与图片精修生成事务 (#181)
## 背景

本合并请求整合 Game Agent 资源管理的栏目分页自由画布,以及图片持续精修、候选生成和本地恢复事务。

当前远端比较基线:

- base:`master` @ `b00a3f80576949567bf3320f0c6c92b4ed0a649d`
- head:`codex/game-agent-resource-section-pages` @ `5795d2f9b8797c342dcf2920ccefec2a3ff3f472`
- 相对 base:28 commits、33 files(`+3746 / -1333`)

## 主要改动

### 资源管理分页自由画布

- 固定展示“设计文档 → 美术资源 → 音乐音效 → 游戏代码 → 项目版本”五个栏目;空栏目仍可从悬浮 Dock 打开空画布。
- `按依赖` 与 `按类型` 共用栏目分页画布;每个“排序模式 + 栏目”组合独立保存 viewport。
- 普通滚轮使用有界意图队列切换栏目;Ctrl/Meta + 滚轮以指针为锚点缩放;空白拖拽可沿 x/y 无限平移,不以资源 extent 作为导航边界。
- 资源卡支持拖拽布局与一次 CAS 持久化;切页、排序切换和卸载会统一取消拖拽及 pointer capture。
- 资源详情为非模态独立卡片:打开、切换或关闭详情不卸载背景画布、资源卡或依赖连线,也不重置 viewport、搜索或排序状态。
- 顶部已移除“生成视频 / 生成音效 / 生成背景音乐 / 新增 UI 设计”的手动入口;保留播放、未完成编辑恢复、排序和画布复位,以及资源详情中的既有编辑动作。

### 图片持续精修与生成事务

- 图片资源可进入唯一活动精修草稿,支持原生批量导入、图片下方快速编辑卡、候选图层、任务侧栏和“设为最终图”。
- 生成成功只合并候选图层和 generation 权威事实,不以全量 hydrate 覆盖生成期间的本地编辑。
- 候选媒体、图层和公开 generation 记录写入并回读成功后,才推进私有 `candidate-ready`。
- 候选确认接入 Surface 保存 FIFO 与重新打开 hydrate;revision-sensitive 操作等待确认屏障。
- 失败归档和正式图提交采用可恢复中间态,避免中断后出现幽灵任务、重复 revision 或不一致的正式资产。

### 合同与文档

- `acknowledgeCandidateLayers`、`importLocalImages`、`archiveFailedGeneration` 为必选 Host Port;不支持的宿主返回结构化 `unsupported-capability`。
- 同步更新 TypeScript / Rust 合同、Tauri command 可达性、PRD、技术方案与项目共享记忆。
- 明确资源栏目无限画布合同:普通平移不夹取;资源 extent、图片测量、布局变更和 resize 不得重置用户 viewport;仅首次可测量布局与显式复位按真实卡片包围盒适配内容。

## 验证

CI run [#1290](https://git.genarrative.world/git/GenarrativeAI/Genarrative/actions/runs/1290) 针对当前 head 已完成且四项均成功:

- [x] Repository checks
- [x] Frontend tests(235 test files、3166 tests passed;App Surface 410 tests)
- [x] Backend tests
- [x] Native shell tests

另有本地定向检查:

- [x] `npm run agc:typecheck`
- [x] 资源画布、App Surface、Asset Canvas 与资源实时集成定向回归
- [x] Rust 候选确认、候选中断恢复、失败归档与恢复定向测试
- [x] `cargo fmt --check`
- [x] `npm run check:encoding`
- [x] `git diff --check`

## 评审 provenance

- review #247、#248、#252 都针对旧 head,当前在 Gitea 中均标记为 stale;不将其写作“已处理 #247”或当前 head 的评审结论。
- review #253 针对旧 `5456a1d…` head,提出 PRD 退役入口、共享 pitfalls 的 extent 旧合同和 PR 正文事实更新三项 P2;对应文档已在 `5795d2f…` head 修复并推送。
- 早期由 PR #176 引入的候选持久化、归档、Host Port 与快速编辑卡问题,当前已在 base/head 中具备对应修复,不作为本轮重复阻断项。
- 当前 head 仍需新的、针对最新提交的审批;历史 stale review 不能替代当前 head approval。

## 影响范围

- 修改 AI 游戏创作 App 的资源管理、图片精修、Tauri 本地持久化与恢复语义,以及共享合同和权威文档。
- 会调整本地资源布局、精修 draft 和私有 generation ledger 的状态机。
- 不修改 SpacetimeDB schema,不新增或变更 `/api/external/v1` 路由。

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/181
Co-authored-by: suzmii <suzmii@qq.com>
Co-committed-by: suzmii <suzmii@qq.com>
2026-08-24 20:42:58 +08:00

1589 lines
56 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,
sequence: 0,
recoveredDraftIds: [] as string[],
}));
vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
const ReactModule = await import('react');
return {
AssetCanvasSurface: (props: {
scope: {
projectId: string;
draftId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
};
sessionId: string;
initialAssetName?: string;
initialAssetKind?: string;
onCancel?: (result: {
draftId: string;
disposition: 'kept' | 'discarded';
}) => void;
onSaveAttempt?: (attempt: {
saveAttemptId: string;
sessionId: string;
projectId: string;
draftId: string;
commitId: string;
}) => void;
onCommitted?: (notification: Record<string, unknown>) => void;
host: {
generation: {
recoverImages: (input: {
scope: {
projectId: string;
draftId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
};
}) => Promise<unknown>;
};
};
}) => {
ReactModule.useEffect(() => {
void props.host.generation.recoverImages({ scope: props.scope });
}, [props.host, props.scope]);
return ReactModule.createElement(
'section',
{ 'aria-label': '测试素材创作画布' },
ReactModule.createElement(
'span',
null,
`${props.scope.intent}:${props.scope.sourceAssetId ?? 'none'}:${props.initialAssetName ?? 'unset'}:${props.initialAssetKind ?? 'unset'}`,
),
ReactModule.createElement(
'button',
{
type: 'button',
onClick: () =>
props.onCancel?.({
draftId: props.scope.draftId,
disposition: 'kept',
}),
},
'取消并返回',
),
ReactModule.createElement(
'button',
{
type: 'button',
onClick: () => {
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
canvasFixture.sequence += 1;
canvasFixture.revision += 1;
const assetId =
props.scope.intent === 'refine' && props.scope.sourceAssetId
? props.scope.sourceAssetId
: `canvas-output-${canvasFixture.sequence}`;
const commitId = `commit-${canvasFixture.sequence}`;
const existingAsset = base.assets.find(
(asset) => asset.id === assetId,
);
const committedAsset = {
id: assetId,
kind: existingAsset?.kind ?? 'art-image',
mediaType: 'image/png',
localPath:
props.scope.intent === 'refine'
? `assets/canvas/${assetId}--${canvasFixture.sequence}.png`
: `assets/${assetId}.png`,
source:
existingAsset?.source ??
({
kind: 'canvas' as const,
taskId: null,
resourceId: `canvas-resource-${canvasFixture.sequence}`,
referenceResourceIds: [],
} as const),
};
const nextManifest = {
...base,
assets: existingAsset
? base.assets.map((asset) =>
asset.id === assetId ? committedAsset : asset,
)
: [...base.assets, committedAsset],
};
canvasFixture.manifest = nextManifest;
props.onSaveAttempt?.({
saveAttemptId: `save-${canvasFixture.sequence}`,
sessionId: props.sessionId,
projectId: props.scope.projectId,
draftId: props.scope.draftId,
commitId,
});
props.onCommitted?.({
source: 'command',
projectPath: '/tmp/live-canvas-integration',
projectId: props.scope.projectId,
draftId: props.scope.draftId,
commitId,
assetId,
manifest: nextManifest,
projectRevision: canvasFixture.revision,
committedProjectRevision: canvasFixture.revision,
eventId: `event-${canvasFixture.sequence}`,
});
},
},
'完成测试保存',
),
);
},
};
});
import ProjectDevelopmentView from '../src/view/project-development';
import {
act,
createGameCreationAppManifest,
fireEvent,
render,
screen,
waitFor,
} 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() {
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' },
},
];
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)
}
/>
</>
);
}
function TaskImageWorkbench() {
const initial = createGameCreationAppManifest(
'live-canvas-project',
'实时画布项目',
);
const task = initial.tasks.find(
(candidate) => candidate.id === 'art-asset-plan',
);
if (!task) throw new Error('missing art task fixture');
task.status = 'completed';
task.artifacts = ['assets/task-hero.png'];
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)}
/>
);
}
describe('project resource live canvas integration', () => {
afterEach(() => {
delete window.__TAURI__;
canvasFixture.manifest = null;
canvasFixture.revision = 0;
canvasFixture.sequence = 0;
canvasFixture.recoveredDraftIds = [];
});
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>>;
} = {},
) {
const layoutWrites: Array<Record<string, unknown>> = [];
const graphReads: Array<Record<string, unknown>> = [];
const deriveCalls: Array<Record<string, unknown>> = [];
const normalizeCalls: 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 discoverableDraftId: string | null = null;
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 === 'discover_local_project_asset_canvas_draft') {
const input = (args?.input ?? {}) as Record<string, unknown>;
return discoverableDraftId
? {
status: 'found',
draft: {
draftId: discoverableDraftId,
projectId: String(input.expectedProjectId),
intent: 'refine',
sourceAssetId: String(input.sourceAssetId),
},
}
: { status: 'not-found', draft: null };
}
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 === 'recover_local_project_asset_canvas_generations') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
discoverableDraftId = String(input.draftId);
canvasFixture.recoveredDraftIds.push(String(input.draftId));
return {
resumedGenerationIds: [`generation-for-${String(input.draftId)}`],
serviceIdentityConfirmations: [],
};
}
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 === 'normalize_local_project_raster_resource') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
normalizeCalls.push(input);
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
const asset = {
id: 'normalized-task-hero',
kind: 'art-image',
mediaType: 'image/png',
localPath: String(input.sourcePath),
source: {
kind: 'generated' as const,
taskId: String(input.producerTaskId),
resourceId: String(input.sourceResourceId),
},
};
canvasFixture.revision += 1;
const nextManifest = { ...base, assets: [...base.assets, asset] };
canvasFixture.manifest = nextManifest;
return {
committedProjectRevision: canvasFixture.revision,
asset,
manifest: nextManifest,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: async () => () => undefined },
};
return {
deriveCalls,
graphReads,
layoutWrites,
normalizeCalls,
pendingReadCalls,
resumeCalls,
archiveCalls,
serviceIdentityRequestCalls,
serviceIdentityConfirmCalls,
};
}
it('enters refine in the central view, keeps the draft on return, and preserves the source after a durable edit', async () => {
const { graphReads, layoutWrites } = installTauri();
render(<LiveWorkbench />);
const sourceCard = await screen.findByRole('button', {
name: /source-art\.png/,
});
for (const label of [
'生成视频',
'生成音效',
'生成背景音乐',
'新增 UI 设计',
]) {
expect(screen.queryByRole('button', { name: label })).toBeNull();
}
fireEvent.click(sourceCard);
const sourceDetails = screen.getByRole('dialog', {
name: /source-art\.png/,
});
expect(sourceDetails.closest('.game-workbench-stage')).toBe(
screen.getByLabelText('项目主视窗'),
);
expect(sourceDetails.hasAttribute('aria-modal')).toBe(false);
expect(document.querySelector('.game-resource-focus-backdrop')).toBeNull();
for (const label of [
'生成视频',
'生成音效',
'生成背景音乐',
'新增 UI 设计',
]) {
expect(screen.queryByRole('button', { name: label })).toBeNull();
}
expect(
screen
.getByRole('button', { name: '按依赖' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.getByRole('button', { name: '按类型' })).not.toBeNull();
expect(screen.getByRole('button', { name: '复位资源画布' })).not.toBeNull();
expect(screen.getByLabelText('资源依赖视图').hasAttribute('inert')).toBe(
false,
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(
(await screen.findByLabelText('测试素材创作画布')).textContent,
).toContain('refine:source-art:source-art:art-image');
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
expect(
await screen.findByRole('dialog', { name: /source-art\.png/ }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
fireEvent.click(
await screen.findByRole('button', { name: '完成测试保存' }),
);
expect(await screen.findByLabelText('测试素材创作画布')).not.toBeNull();
expect(screen.queryByRole('dialog', { name: /canvas-output/ })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
const updatedSource = await screen.findByRole('button', {
name: /source-art--1.png/,
});
fireEvent.click(updatedSource);
expect(
await screen.findByRole('dialog', { name: /source-art--1.png/ }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
expect(
layoutWrites.every(
(write) =>
!(write.positions as Array<{ resourceId: string }>).some(
(position) => position.resourceId === 'asset:canvas-output-1',
),
),
).toBe(true);
expect(
graphReads.every((read) => {
const resourceIds = (
read.resources as Array<{ resourceId: string }>
).map((resource) => resource.resourceId);
return (
resourceIds.includes('asset:source-art') &&
!resourceIds.includes('asset:canvas-output-1')
);
}),
).toBe(true);
});
it('reuses a kept refine draft and recovers its generation after a real workbench remount', async () => {
installTauri();
const firstWorkbench = render(<LiveWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: /source-art\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
await waitFor(() => {
expect(canvasFixture.recoveredDraftIds).toHaveLength(1);
});
const keptDraftId = canvasFixture.recoveredDraftIds[0];
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
firstWorkbench.unmount();
render(<LiveWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: /source-art\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
await waitFor(() => {
expect(canvasFixture.recoveredDraftIds).toEqual([
keptDraftId,
keptDraftId,
]);
});
});
it('keeps an existing search while replacing the same final-image resource', async () => {
installTauri();
render(<LiveWorkbench />);
const search = await screen.findByLabelText('搜索项目资源');
fireEvent.change(search, { target: { value: 'source-art' } });
fireEvent.click(
await screen.findByRole('button', { name: /source-art\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
fireEvent.click(
await screen.findByRole('button', { name: '完成测试保存' }),
);
expect(await screen.findByLabelText('测试素材创作画布')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
const restoredSearch = (await screen.findByLabelText(
'搜索项目资源',
)) as HTMLInputElement;
expect(restoredSearch.value).toBe('source-art');
expect(
await screen.findByRole('button', { name: /source-art--1.png/ }),
).not.toBeNull();
expect(screen.queryByText('新资源已保存,但被当前搜索条件隐藏')).toBeNull();
});
it('derives a document non-destructively and retries with the original operation identity', async () => {
const { deriveCalls } = installTauri({ failFirstDerive: true });
render(<DerivedWorkbench />);
fireEvent.click(await screen.findByRole('button', { name: /rules\.md/ }));
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(await screen.findByText('编辑现有资源')).not.toBeNull();
fireEvent.change(screen.getByLabelText('编辑提示词'), {
target: { value: '把角色头发设定改为红色' },
});
fireEvent.click(screen.getByRole('button', { name: '生成派生资源' }));
expect(await screen.findByRole('alert')).not.toBeNull();
fireEvent.click(screen.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]).not.toHaveProperty('accessToken');
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
expect(deriveCalls[1]).not.toHaveProperty('accessToken');
expect(deriveCalls[1]).not.toHaveProperty('apiKey');
const operationId = String(deriveCalls[1]?.operationId);
expect(
await screen.findByRole('dialog', {
name: new RegExp(operationId, 'u'),
}),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
expect(
await screen.findByRole('button', {
name: '打开资源详情:设计文档 rules.md',
}),
).not.toBeNull();
});
it('normalizes a completed task image before opening the existing refine canvas', async () => {
const { normalizeCalls } = installTauri();
render(<TaskImageWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: /task-hero\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(
(await screen.findByLabelText('测试素材创作画布')).textContent,
).toContain('refine:normalized-task-hero');
expect(normalizeCalls).toHaveLength(1);
expect(normalizeCalls[0]?.sourcePath).toBe('assets/task-hero.png');
expect(normalizeCalls[0]?.producerTaskId).toBe('art-asset-plan');
});
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('dialog', {
name: /recovered-rules\.md/,
}),
).not.toBeNull();
});
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: '收起资源' }));
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(
'焦点测试恢复失败',
);
});
});