532c6d51fd
## 背景 本合并请求整合 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>
1589 lines
56 KiB
TypeScript
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(
|
|
'焦点测试恢复失败',
|
|
);
|
|
});
|
|
});
|