bd36587e0a
新增 features/resource-canvas:资源卡到美术画布 CanvasLayer 的适配、工具条动作可用性判定、C5 当前版本绑定判定、快速编辑草稿工厂与 AGC 宿主 chrome 样式 资源卡点击改为选中(Shift/Ctrl/Meta 多选),选中后在卡片旁复用 ImageCanvasSelectedLayerToolbarView 浮出工具条 快速编辑复用 ImageCanvasQuickEditPanelView 浮层面板,先调 normalize_local_project_raster_resource 正规化任务产物,再走 derive_local_project_resource(editKind=image-reference) 产出新素材,失败重试复用同一 operationId/idempotencyKey 删除 resourceEditorRoute 全链路(ResourceEditSurface 路由、openResourceEditor、handleResourceCardOpenEditor、cancelResourceEditor、submitResourceEdit)与资源详情面板(focusedResource 状态/引用/浮层 JSX/CSS 断言) 分类与标签入口搬到选中工具条;UI 资源保留「UI 编辑器」入口并从工具条打开 移除 apps/ai-game-creator-shell/scripts/check-config.mjs 中 normalize_local_project_raster_resource 的过渡 allowlist 条目 为共享工具条组件新增 opt-in 的 supportedActions/extraActions(默认 null/undefined,网页端行为不变) AGC vite-env.d.ts 补 wx/ReactNativeWebView/WeixinJSBridge 跨端全局声明;hostBridge fail 回调补显式 unknown 类型 测试:资源卡查询 helper 由资源详情按钮改为选中按钮,清理详情面板断言,画布空白左键改为框选后平移用例改用中键
1212 lines
42 KiB
TypeScript
1212 lines
42 KiB
TypeScript
/** @vitest-environment jsdom */
|
|
import React, { useState } from 'react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
|
|
|
const canvasFixture = vi.hoisted(() => ({
|
|
manifest: null as GameCreationAppManifest | null,
|
|
revision: 0,
|
|
}));
|
|
|
|
import ProjectDevelopmentView from '../src/view/project-development';
|
|
import {
|
|
act,
|
|
createGameCreationAppManifest,
|
|
findResourceSelectButton,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
setComposerText,
|
|
waitFor,
|
|
within,
|
|
} from './appSurface/harness';
|
|
|
|
const projectPath = '/tmp/live-canvas-integration';
|
|
|
|
type PendingResourceEditFixture = {
|
|
operationId: string;
|
|
editKind: string;
|
|
sourceResourceId: string;
|
|
assetName: string;
|
|
phase: string;
|
|
createdAt: number;
|
|
};
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, reject, resolve };
|
|
}
|
|
|
|
function graphFor(
|
|
resources: Array<{ resourceId: string }>,
|
|
manifest: GameCreationAppManifest,
|
|
) {
|
|
const resourceIds = resources.map((resource) => resource.resourceId);
|
|
const referenceEdges = manifest.assets.flatMap((asset) =>
|
|
(asset.source.referenceResourceIds ?? []).map((sourceResourceId) => ({
|
|
id: `reference:${sourceResourceId}:${asset.id}`,
|
|
kind: 'asset-reference' as const,
|
|
sourceResourceId,
|
|
targetResourceId: `asset:${asset.id}`,
|
|
cyclic: false,
|
|
})),
|
|
);
|
|
return {
|
|
resourceIds,
|
|
referenceEdges,
|
|
taskFlows: [],
|
|
connectionIndex: resourceIds.map((resourceId) => ({
|
|
resourceId,
|
|
upstreamReferenceResourceIds: referenceEdges
|
|
.filter((edge) => edge.targetResourceId === resourceId)
|
|
.map((edge) => edge.sourceResourceId),
|
|
downstreamReferenceResourceIds: [],
|
|
referenceEdgeIds: referenceEdges
|
|
.filter(
|
|
(edge) =>
|
|
edge.targetResourceId === resourceId ||
|
|
edge.sourceResourceId === resourceId,
|
|
)
|
|
.map((edge) => edge.id),
|
|
taskFlowIds: [],
|
|
})),
|
|
producerAssignments: [],
|
|
dependencyDepths: resourceIds.map((resourceId) => ({
|
|
resourceId,
|
|
dependencyDepth: referenceEdges.some(
|
|
(edge) => edge.targetResourceId === resourceId,
|
|
)
|
|
? 1
|
|
: 0,
|
|
})),
|
|
unresolvedReferenceResourceIds: [],
|
|
cyclicResourceIds: [],
|
|
cyclicTaskIds: [],
|
|
producerMappingTruncated: false,
|
|
};
|
|
}
|
|
|
|
function LiveWorkbench() {
|
|
const initial = createGameCreationAppManifest(
|
|
'live-canvas-project',
|
|
'实时画布项目',
|
|
);
|
|
initial.assets = [
|
|
{
|
|
id: 'source-art',
|
|
kind: 'art-image',
|
|
mediaType: 'image/png',
|
|
localPath: 'assets/source-art.png',
|
|
source: { kind: 'canvas', taskId: null, resourceId: 'source-resource' },
|
|
},
|
|
];
|
|
const [manifest, setManifest] = useState(initial);
|
|
canvasFixture.manifest = manifest;
|
|
|
|
return (
|
|
<ProjectDevelopmentView
|
|
projectName={manifest.name}
|
|
projectPath={projectPath}
|
|
manifest={manifest}
|
|
attachments={[]}
|
|
recentRunStatus={null}
|
|
recentRunStopReason={null}
|
|
supervisor={<div>Supervisor</div>}
|
|
onHomeOpen={() => undefined}
|
|
onProjectsOpen={() => undefined}
|
|
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function DerivedWorkbench() {
|
|
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)
|
|
}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
describe('project resource live canvas integration', () => {
|
|
afterEach(() => {
|
|
delete window.__TAURI__;
|
|
canvasFixture.manifest = null;
|
|
canvasFixture.revision = 0;
|
|
});
|
|
|
|
function installTauri(
|
|
options: {
|
|
failFirstDerive?: boolean;
|
|
pendingResourceEdit?: boolean;
|
|
pendingResourceEdits?: PendingResourceEditFixture[];
|
|
failFirstPendingRead?: boolean;
|
|
readPendingResourceEdits?: (
|
|
input: Record<string, unknown>,
|
|
readCount: number,
|
|
) => PendingResourceEditFixture[] | Promise<PendingResourceEditFixture[]>;
|
|
resumeResourceEdit?: (
|
|
input: Record<string, unknown>,
|
|
) => Promise<Record<string, unknown>>;
|
|
requestResourceEditServiceIdentityConfirmation?: (
|
|
input: Record<string, unknown>,
|
|
) => Promise<Record<string, unknown>>;
|
|
confirmResourceEditServiceIdentity?: (
|
|
input: Record<string, unknown>,
|
|
) => Promise<Record<string, unknown>>;
|
|
} = {},
|
|
) {
|
|
const layoutWrites: Array<Record<string, unknown>> = [];
|
|
const graphReads: Array<Record<string, unknown>> = [];
|
|
const deriveCalls: Array<Record<string, unknown>> = [];
|
|
const resumeCalls: Array<Record<string, unknown>> = [];
|
|
const serviceIdentityRequestCalls: Array<Record<string, unknown>> = [];
|
|
const serviceIdentityConfirmCalls: Array<Record<string, unknown>> = [];
|
|
const archiveCalls: Array<Record<string, unknown>> = [];
|
|
const pendingReadCalls: Array<Record<string, unknown>> = [];
|
|
let pendingReadCount = 0;
|
|
let pendingEdits =
|
|
options.pendingResourceEdits ??
|
|
(options.pendingResourceEdit
|
|
? [
|
|
{
|
|
operationId: '99999999-9999-4999-8999-999999999999',
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '恢复的规则编辑版',
|
|
phase: 'media-downloaded',
|
|
createdAt: 1,
|
|
},
|
|
]
|
|
: []);
|
|
const invoke = vi.fn(
|
|
async (command: string, args?: Record<string, unknown>) => {
|
|
if (command === 'get_local_game_project_revision') {
|
|
return { revision: canvasFixture.revision };
|
|
}
|
|
if (command === 'read_local_project_resource_graph') {
|
|
graphReads.push(structuredClone(args ?? {}));
|
|
return graphFor(
|
|
(args?.resources ?? []) as Array<{ resourceId: string }>,
|
|
canvasFixture.manifest!,
|
|
);
|
|
}
|
|
if (command === 'read_local_project_resource_document') {
|
|
return {
|
|
path: String(args?.relativePath ?? 'docs/rules.md'),
|
|
mediaType: 'text/markdown',
|
|
byteLen: 1,
|
|
content: '# rules',
|
|
};
|
|
}
|
|
if (command === 'read_local_project_resource_canvas_layout') {
|
|
return {
|
|
schemaVersion: 'game-creator-resource-layout.v1',
|
|
projectId: 'live-canvas-project',
|
|
mode: args?.mode,
|
|
revision: 0,
|
|
positions: [],
|
|
updatedAt: 0,
|
|
};
|
|
}
|
|
if (command === 'update_local_project_resource_canvas_layout') {
|
|
layoutWrites.push(structuredClone(args ?? {}));
|
|
return {
|
|
status: 'updated',
|
|
layout: {
|
|
schemaVersion: 'game-creator-resource-layout.v1',
|
|
projectId: 'live-canvas-project',
|
|
mode: args?.mode,
|
|
revision: Number(args?.expectedRevision ?? 0) + 1,
|
|
positions: args?.positions,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
}
|
|
if (command === 'read_local_project_image_preview') {
|
|
return {
|
|
path: String(args?.relativePath ?? ''),
|
|
mediaType: 'image/png',
|
|
byteLen: 1,
|
|
dataUrl: 'data:image/png;base64,AA==',
|
|
};
|
|
}
|
|
if (command === 'read_local_project_text_preview') {
|
|
return {
|
|
path: String(args?.relativePath ?? ''),
|
|
mediaType: 'text/markdown',
|
|
byteLen: 8,
|
|
content: '# 玩法规则',
|
|
};
|
|
}
|
|
if (command === 'list_pending_local_project_resource_edits') {
|
|
const input = structuredClone(
|
|
(args?.input ?? {}) as Record<string, unknown>,
|
|
);
|
|
pendingReadCount += 1;
|
|
pendingReadCalls.push(input);
|
|
if (options.failFirstPendingRead && pendingReadCount === 1) {
|
|
throw new Error('恢复队列测试读取失败');
|
|
}
|
|
if (options.readPendingResourceEdits) {
|
|
return structuredClone(
|
|
await options.readPendingResourceEdits(input, pendingReadCount),
|
|
);
|
|
}
|
|
return structuredClone(pendingEdits);
|
|
}
|
|
if (command === 'resume_local_project_resource_edit') {
|
|
const input = structuredClone(
|
|
(args?.input ?? {}) as Record<string, unknown>,
|
|
);
|
|
resumeCalls.push(input);
|
|
if (options.resumeResourceEdit) {
|
|
return await options.resumeResourceEdit(input);
|
|
}
|
|
const base = canvasFixture.manifest;
|
|
if (!base) throw new Error('missing manifest fixture');
|
|
const operationId = String(input.operationId);
|
|
pendingEdits = pendingEdits.filter(
|
|
(pending) => pending.operationId !== operationId,
|
|
);
|
|
const asset = {
|
|
id: `edit-${operationId}`,
|
|
kind: 'game-rules',
|
|
mediaType: 'text/markdown',
|
|
localPath: `assets/edits/${operationId}-recovered-rules.md`,
|
|
source: {
|
|
kind: 'generated' as const,
|
|
resourceId: `local-asset:edit-${operationId}`,
|
|
referenceResourceIds: ['rules-resource'],
|
|
},
|
|
};
|
|
canvasFixture.revision += 1;
|
|
const nextManifest = { ...base, assets: [...base.assets, asset] };
|
|
canvasFixture.manifest = nextManifest;
|
|
return {
|
|
operationId,
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
committedProjectRevision: canvasFixture.revision,
|
|
asset,
|
|
version: null,
|
|
manifest: nextManifest,
|
|
};
|
|
}
|
|
if (
|
|
command ===
|
|
'request_local_project_resource_edit_service_identity_confirmation'
|
|
) {
|
|
const input = structuredClone(
|
|
(args?.input ?? {}) as Record<string, unknown>,
|
|
);
|
|
serviceIdentityRequestCalls.push(input);
|
|
if (options.requestResourceEditServiceIdentityConfirmation) {
|
|
return await options.requestResourceEditServiceIdentityConfirmation(
|
|
input,
|
|
);
|
|
}
|
|
throw new Error('unexpected resource edit service identity request');
|
|
}
|
|
if (
|
|
command === 'confirm_local_project_resource_edit_service_identity'
|
|
) {
|
|
const input = structuredClone(
|
|
(args?.input ?? {}) as Record<string, unknown>,
|
|
);
|
|
serviceIdentityConfirmCalls.push(input);
|
|
if (options.confirmResourceEditServiceIdentity) {
|
|
return await options.confirmResourceEditServiceIdentity(input);
|
|
}
|
|
throw new Error('unexpected resource edit service identity confirm');
|
|
}
|
|
if (command === 'archive_failed_local_project_resource_edit') {
|
|
const input = structuredClone(
|
|
(args?.input ?? {}) as Record<string, unknown>,
|
|
);
|
|
archiveCalls.push(input);
|
|
pendingEdits = pendingEdits.filter(
|
|
(pending) => pending.operationId !== input.operationId,
|
|
);
|
|
return {
|
|
operationId: input.operationId,
|
|
phase: 'archived',
|
|
};
|
|
}
|
|
if (command === 'derive_local_project_resource') {
|
|
const input = structuredClone(
|
|
(args?.input ?? {}) as Record<string, unknown>,
|
|
);
|
|
deriveCalls.push(input);
|
|
if (options.failFirstDerive && deriveCalls.length === 1) {
|
|
throw new Error('result-unknown: 测试网络中断');
|
|
}
|
|
const base = canvasFixture.manifest;
|
|
if (!base) throw new Error('missing manifest fixture');
|
|
const operationId = String(input.operationId);
|
|
const assetId = `edit-${operationId}`;
|
|
const asset = {
|
|
id: assetId,
|
|
kind: 'game-rules',
|
|
mediaType: 'text/markdown',
|
|
localPath: `assets/edits/${operationId}-rules.md`,
|
|
source: {
|
|
kind: 'generated' as const,
|
|
resourceId: `local-asset:${assetId}`,
|
|
referenceResourceIds: ['rules-resource'],
|
|
},
|
|
};
|
|
canvasFixture.revision += 1;
|
|
const nextManifest = {
|
|
...base,
|
|
assets: [...base.assets, asset],
|
|
};
|
|
canvasFixture.manifest = nextManifest;
|
|
return {
|
|
operationId,
|
|
editKind: input.editKind,
|
|
sourceResourceId: 'rules-resource',
|
|
committedProjectRevision: canvasFixture.revision,
|
|
asset,
|
|
version: null,
|
|
manifest: nextManifest,
|
|
};
|
|
}
|
|
throw new Error(`unexpected invoke ${command}`);
|
|
},
|
|
);
|
|
window.__TAURI__ = {
|
|
core: { invoke },
|
|
event: { listen: async () => () => undefined },
|
|
};
|
|
return {
|
|
deriveCalls,
|
|
graphReads,
|
|
layoutWrites,
|
|
pendingReadCalls,
|
|
resumeCalls,
|
|
archiveCalls,
|
|
serviceIdentityRequestCalls,
|
|
serviceIdentityConfirmCalls,
|
|
};
|
|
}
|
|
|
|
it('derives a new art asset non-destructively from the quick edit panel and reuses the original operation identity', async () => {
|
|
const { deriveCalls } = installTauri({ failFirstDerive: true });
|
|
render(<DerivedWorkbench />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开美术资源' }));
|
|
fireEvent.click(await findResourceSelectButton('source-art.png'));
|
|
const toolbar = await screen.findByRole('toolbar', {
|
|
name: '图片工具栏',
|
|
});
|
|
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
|
|
const panel = await screen.findByRole('dialog', {
|
|
name: '快速编辑图片',
|
|
});
|
|
await setComposerText(
|
|
within(panel).getByLabelText('快速编辑提示词'),
|
|
'把角色头发设定改为红色',
|
|
);
|
|
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
|
|
expect(await screen.findByRole('alert')).not.toBeNull();
|
|
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
|
|
|
|
await waitFor(() => expect(deriveCalls).toHaveLength(2));
|
|
expect(deriveCalls[0]?.operationId).toBe(deriveCalls[1]?.operationId);
|
|
expect(deriveCalls[0]?.idempotencyKey).toBe(deriveCalls[1]?.idempotencyKey);
|
|
expect(deriveCalls[0]?.editKind).toBe('image-reference');
|
|
expect(deriveCalls[0]?.generationMode).toBe('derive');
|
|
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
|
|
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
|
|
expect(deriveCalls[1]).not.toHaveProperty('accessToken');
|
|
expect(deriveCalls[1]).not.toHaveProperty('apiKey');
|
|
});
|
|
|
|
it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => {
|
|
const { resumeCalls } = installTauri({ pendingResourceEdit: true });
|
|
render(<DerivedWorkbench />);
|
|
|
|
const manage = await screen.findByRole('button', {
|
|
name: '管理未完成编辑 (1)',
|
|
});
|
|
fireEvent.click(manage);
|
|
const resume = await screen.findByRole('button', {
|
|
name: '继续原编辑',
|
|
});
|
|
fireEvent.click(resume);
|
|
await waitFor(() => expect(resumeCalls).toHaveLength(1));
|
|
expect(resumeCalls[0]).toEqual({
|
|
projectPath,
|
|
expectedProjectId: 'live-canvas-project',
|
|
operationId: '99999999-9999-4999-8999-999999999999',
|
|
});
|
|
expect(resumeCalls[0]).not.toHaveProperty('prompt');
|
|
expect(resumeCalls[0]).not.toHaveProperty('endpoint');
|
|
expect(resumeCalls[0]).not.toHaveProperty('idempotencyKey');
|
|
// 恢复完成后新素材被选中;不再有"资源详情"面板。
|
|
expect(
|
|
(
|
|
await screen.findByRole('button', {
|
|
name: /选中资源:\S+ \S*recovered-rules\.md$/,
|
|
})
|
|
).getAttribute('aria-pressed'),
|
|
).toBe('true');
|
|
});
|
|
|
|
it('显式确认旧 Key-bound 服务后只用原 operation 继续恢复', async () => {
|
|
const operation: PendingResourceEditFixture = {
|
|
operationId: 'abababab-abab-4bab-8bab-abababababab',
|
|
editKind: 'video',
|
|
sourceResourceId: 'source-video',
|
|
assetName: '旧 Key 视频编辑',
|
|
phase: 'accepted',
|
|
createdAt: 1,
|
|
};
|
|
let identityConfirmed = false;
|
|
let committed = false;
|
|
const {
|
|
resumeCalls,
|
|
serviceIdentityRequestCalls,
|
|
serviceIdentityConfirmCalls,
|
|
} = installTauri({
|
|
readPendingResourceEdits: () => (committed ? [] : [operation]),
|
|
resumeResourceEdit: async () => {
|
|
if (!identityConfirmed) {
|
|
throw new Error(
|
|
'service-identity-confirmation-required: 当前服务地址需要用户确认',
|
|
);
|
|
}
|
|
const base = canvasFixture.manifest!;
|
|
const asset = {
|
|
id: `edit-${operation.operationId}`,
|
|
kind: 'video',
|
|
mediaType: 'video/mp4',
|
|
localPath: `assets/edits/${operation.operationId}.mp4`,
|
|
source: {
|
|
kind: 'canvas' as const,
|
|
resourceId: 'remote-video-resource',
|
|
referenceResourceIds: ['source-video'],
|
|
},
|
|
};
|
|
committed = true;
|
|
canvasFixture.revision += 1;
|
|
const manifest = { ...base, assets: [...base.assets, asset] };
|
|
canvasFixture.manifest = manifest;
|
|
return {
|
|
operationId: operation.operationId,
|
|
editKind: 'video',
|
|
sourceResourceId: 'source-video',
|
|
committedProjectRevision: canvasFixture.revision,
|
|
asset,
|
|
version: null,
|
|
manifest,
|
|
};
|
|
},
|
|
requestResourceEditServiceIdentityConfirmation: async (input) => ({
|
|
operationId: input.operationId,
|
|
remoteOperationId: 'remote-existing-operation',
|
|
operationState: 'accepted',
|
|
serviceOrigin: 'https://editor.example.test',
|
|
challenge: 'challenge-bound-to-the-current-ledger-snapshot',
|
|
expiresAt: 9_999_999_999,
|
|
}),
|
|
confirmResourceEditServiceIdentity: async (input) => {
|
|
expect(input).toMatchObject({
|
|
operationId: operation.operationId,
|
|
remoteOperationId: 'remote-existing-operation',
|
|
challenge: 'challenge-bound-to-the-current-ledger-snapshot',
|
|
});
|
|
identityConfirmed = true;
|
|
return {
|
|
operationId: operation.operationId,
|
|
remoteOperationId: 'remote-existing-operation',
|
|
operationState: 'accepted',
|
|
serviceOrigin: 'https://editor.example.test',
|
|
identityScheme: 'service-origin-v1',
|
|
};
|
|
},
|
|
});
|
|
render(<DerivedWorkbench />);
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
|
|
|
|
expect(
|
|
await screen.findByText('当前服务:https://editor.example.test'),
|
|
).not.toBeNull();
|
|
expect(serviceIdentityRequestCalls).toHaveLength(1);
|
|
expect(resumeCalls).toHaveLength(1);
|
|
fireEvent.click(screen.getByRole('button', { name: '确认当前服务并继续' }));
|
|
|
|
await waitFor(() => expect(resumeCalls).toHaveLength(2));
|
|
expect(serviceIdentityConfirmCalls).toHaveLength(1);
|
|
expect(resumeCalls[0]).toEqual(resumeCalls[1]);
|
|
expect(resumeCalls[1]).toEqual({
|
|
projectPath,
|
|
expectedProjectId: 'live-canvas-project',
|
|
operationId: operation.operationId,
|
|
});
|
|
});
|
|
|
|
it('恢复面板可跳过首条失败任务继续任意 operation,且对账项不会被重放', async () => {
|
|
const failedOperationId = '11111111-1111-4111-8111-111111111111';
|
|
const resumableOperationId = '22222222-2222-4222-8222-222222222222';
|
|
const reconciliationOperationId = '33333333-3333-4333-8333-333333333333';
|
|
const { archiveCalls, pendingReadCalls, resumeCalls } = installTauri({
|
|
pendingResourceEdits: [
|
|
{
|
|
operationId: failedOperationId,
|
|
editKind: 'image',
|
|
sourceResourceId: 'source-art',
|
|
assetName: '已失败图片编辑',
|
|
phase: 'remote-failed',
|
|
createdAt: 1,
|
|
},
|
|
{
|
|
operationId: resumableOperationId,
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '可继续规则编辑',
|
|
phase: 'media-downloaded',
|
|
createdAt: 2,
|
|
},
|
|
{
|
|
operationId: reconciliationOperationId,
|
|
editKind: 'agent-result',
|
|
sourceResourceId: 'agent-result',
|
|
assetName: '需要对账的结果',
|
|
phase: 'reconciliation-required',
|
|
createdAt: 3,
|
|
},
|
|
],
|
|
});
|
|
render(<DerivedWorkbench />);
|
|
fireEvent.click(
|
|
await screen.findByRole('button', {
|
|
name: '管理未完成编辑 (3)',
|
|
}),
|
|
);
|
|
const dialog = await screen.findByRole('dialog', {
|
|
name: '未完成的资源编辑',
|
|
});
|
|
expect(dialog.textContent).toContain('远程明确失败');
|
|
expect(dialog.textContent).toContain('需要人工对账');
|
|
expect(screen.getByText('只读对账')).not.toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
|
|
await waitFor(() => expect(resumeCalls).toHaveLength(1));
|
|
await waitFor(() => expect(pendingReadCalls).toHaveLength(2));
|
|
expect(resumeCalls[0]?.operationId).toBe(resumableOperationId);
|
|
expect(resumeCalls[0]?.operationId).not.toBe(reconciliationOperationId);
|
|
|
|
fireEvent.click(
|
|
await screen.findByRole('button', {
|
|
name: '管理未完成编辑 (2)',
|
|
}),
|
|
);
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '移出恢复队列' }),
|
|
);
|
|
await waitFor(() => expect(archiveCalls).toHaveLength(1));
|
|
await waitFor(() => expect(pendingReadCalls).toHaveLength(3));
|
|
expect(archiveCalls[0]?.operationId).toBe(failedOperationId);
|
|
expect(screen.getByText('只读对账')).not.toBeNull();
|
|
expect(
|
|
await screen.findByRole('button', {
|
|
name: '管理未完成编辑 (1)',
|
|
}),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it('项目切换后丢弃旧项目迟到的恢复队列读取结果', async () => {
|
|
const oldRead = createDeferred<PendingResourceEditFixture[]>();
|
|
const oldOperation: PendingResourceEditFixture = {
|
|
operationId: '44444444-4444-4444-8444-444444444444',
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '旧项目迟到任务',
|
|
phase: 'media-downloaded',
|
|
createdAt: 1,
|
|
};
|
|
const newOperation: PendingResourceEditFixture = {
|
|
operationId: '55555555-5555-4555-8555-555555555555',
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '新项目权威任务',
|
|
phase: 'media-downloaded',
|
|
createdAt: 2,
|
|
};
|
|
const { pendingReadCalls } = installTauri({
|
|
readPendingResourceEdits: (input) =>
|
|
input.projectPath === '/tmp/recovery-project-a'
|
|
? oldRead.promise
|
|
: [newOperation],
|
|
});
|
|
render(<SwitchableDerivedWorkbench onManifestChange={() => undefined} />);
|
|
await waitFor(() => expect(pendingReadCalls).toHaveLength(1));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '切换到恢复项目 B' }));
|
|
await waitFor(() => expect(pendingReadCalls).toHaveLength(2));
|
|
expect(pendingReadCalls[1]).toMatchObject({
|
|
projectPath: '/tmp/recovery-project-b',
|
|
expectedProjectId: 'recovery-project-b',
|
|
});
|
|
expect(
|
|
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
|
|
).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
oldRead.resolve([oldOperation]);
|
|
await oldRead.promise;
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '管理未完成编辑 (1)' }));
|
|
expect(await screen.findByText('新项目权威任务')).not.toBeNull();
|
|
expect(screen.queryByText('旧项目迟到任务')).toBeNull();
|
|
});
|
|
|
|
it('项目切换后丢弃旧项目迟到的恢复结果', async () => {
|
|
const staleResume = createDeferred<Record<string, unknown>>();
|
|
const staleOperation: PendingResourceEditFixture = {
|
|
operationId: '66666666-6666-4666-8666-666666666666',
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '旧项目恢复任务',
|
|
phase: 'media-downloaded',
|
|
createdAt: 1,
|
|
};
|
|
const currentOperation: PendingResourceEditFixture = {
|
|
operationId: '77777777-7777-4777-8777-777777777777',
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '新项目保留任务',
|
|
phase: 'media-downloaded',
|
|
createdAt: 2,
|
|
};
|
|
const onManifestChange = vi.fn();
|
|
const { resumeCalls } = installTauri({
|
|
readPendingResourceEdits: (input) =>
|
|
input.projectPath === '/tmp/recovery-project-a'
|
|
? [staleOperation]
|
|
: [currentOperation],
|
|
resumeResourceEdit: () => staleResume.promise,
|
|
});
|
|
render(<SwitchableDerivedWorkbench onManifestChange={onManifestChange} />);
|
|
fireEvent.click(
|
|
await screen.findByRole('button', {
|
|
name: '管理未完成编辑 (1)',
|
|
}),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
|
|
await waitFor(() => expect(resumeCalls).toHaveLength(1));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '切换到恢复项目 B' }));
|
|
const currentTrigger = await screen.findByRole('button', {
|
|
name: '管理未完成编辑 (1)',
|
|
});
|
|
expect(screen.queryByText('旧项目恢复任务')).toBeNull();
|
|
|
|
const staleManifest = createGameCreationAppManifest(
|
|
'recovery-project-a',
|
|
'恢复项目 A',
|
|
);
|
|
const staleAsset = {
|
|
id: 'stale-recovered-asset',
|
|
kind: 'game-rules',
|
|
mediaType: 'text/markdown',
|
|
localPath: 'assets/stale-recovered-rules.md',
|
|
source: {
|
|
kind: 'generated' as const,
|
|
resourceId: 'local-asset:stale-recovered-asset',
|
|
referenceResourceIds: ['rules-resource'],
|
|
},
|
|
};
|
|
staleManifest.assets = [...staleManifest.assets, staleAsset];
|
|
await act(async () => {
|
|
staleResume.resolve({
|
|
operationId: staleOperation.operationId,
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
committedProjectRevision: 1,
|
|
asset: staleAsset,
|
|
version: null,
|
|
manifest: staleManifest,
|
|
});
|
|
await staleResume.promise;
|
|
});
|
|
|
|
expect(onManifestChange).not.toHaveBeenCalled();
|
|
fireEvent.click(currentTrigger);
|
|
expect(await screen.findByText('新项目保留任务')).not.toBeNull();
|
|
expect(screen.queryByText('旧项目恢复任务')).toBeNull();
|
|
expect(
|
|
screen.queryByRole('button', { name: /stale-recovered/u }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('恢复成功后的权威队列重读迟到时不会关闭新项目面板', async () => {
|
|
const postActionRead = createDeferred<PendingResourceEditFixture[]>();
|
|
const oldOperation: PendingResourceEditFixture = {
|
|
operationId: '88888888-8888-4888-8888-888888888888',
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '旧项目已恢复任务',
|
|
phase: 'media-downloaded',
|
|
createdAt: 1,
|
|
};
|
|
const newOperation: PendingResourceEditFixture = {
|
|
operationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '新项目不能被关闭的任务',
|
|
phase: 'media-downloaded',
|
|
createdAt: 2,
|
|
};
|
|
const { pendingReadCalls, resumeCalls } = installTauri({
|
|
readPendingResourceEdits: (input, readCount) => {
|
|
if (input.projectPath === '/tmp/recovery-project-b') {
|
|
return [newOperation];
|
|
}
|
|
return readCount === 1 ? [oldOperation] : postActionRead.promise;
|
|
},
|
|
});
|
|
render(<SwitchableDerivedWorkbench onManifestChange={() => undefined} />);
|
|
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
|
|
await waitFor(() => expect(resumeCalls).toHaveLength(1));
|
|
await waitFor(() => expect(pendingReadCalls).toHaveLength(2));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '切换到恢复项目 B' }));
|
|
await waitFor(() => expect(pendingReadCalls).toHaveLength(3));
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
|
|
);
|
|
expect(await screen.findByText('新项目不能被关闭的任务')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
postActionRead.resolve([]);
|
|
await postActionRead.promise;
|
|
});
|
|
|
|
expect(
|
|
screen.getByRole('dialog', { name: '未完成的资源编辑' }),
|
|
).not.toBeNull();
|
|
expect(screen.getByText('新项目不能被关闭的任务')).not.toBeNull();
|
|
});
|
|
|
|
it('恢复失败后重读权威队列并展示后端迁移后的失败阶段', async () => {
|
|
const operation: PendingResourceEditFixture = {
|
|
operationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '阶段迁移任务',
|
|
phase: 'media-downloaded',
|
|
createdAt: 1,
|
|
};
|
|
const { pendingReadCalls } = installTauri({
|
|
readPendingResourceEdits: (_input, readCount) => [
|
|
{
|
|
...operation,
|
|
phase: readCount === 1 ? 'media-downloaded' : 'remote-failed',
|
|
},
|
|
],
|
|
resumeResourceEdit: async () => {
|
|
throw new Error('远程恢复明确失败');
|
|
},
|
|
});
|
|
render(<DerivedWorkbench />);
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
|
|
|
|
await waitFor(() => expect(pendingReadCalls).toHaveLength(2));
|
|
expect((await screen.findByRole('alert')).textContent).toContain(
|
|
'远程恢复明确失败',
|
|
);
|
|
expect(screen.getByText('远程明确失败')).not.toBeNull();
|
|
expect(screen.getByRole('button', { name: '移出恢复队列' })).not.toBeNull();
|
|
});
|
|
|
|
it('仅锁定正在操作的 operation,同项防双击且其他项可并行', async () => {
|
|
const firstResume = createDeferred<Record<string, unknown>>();
|
|
const secondResume = createDeferred<Record<string, unknown>>();
|
|
const firstOperationId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
|
const secondOperationId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
|
|
const { resumeCalls } = installTauri({
|
|
pendingResourceEdits: [
|
|
{
|
|
operationId: firstOperationId,
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '并行任务一',
|
|
phase: 'media-downloaded',
|
|
createdAt: 1,
|
|
},
|
|
{
|
|
operationId: secondOperationId,
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '并行任务二',
|
|
phase: 'media-downloaded',
|
|
createdAt: 2,
|
|
},
|
|
],
|
|
resumeResourceEdit: (input) =>
|
|
input.operationId === firstOperationId
|
|
? firstResume.promise
|
|
: secondResume.promise,
|
|
});
|
|
render(<DerivedWorkbench />);
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '管理未完成编辑 (2)' }),
|
|
);
|
|
const actions = screen.getAllByRole('button', { name: '继续原编辑' });
|
|
|
|
fireEvent.click(actions[0]);
|
|
fireEvent.click(actions[0]);
|
|
expect(actions[0].hasAttribute('disabled')).toBe(true);
|
|
expect(actions[1].hasAttribute('disabled')).toBe(false);
|
|
fireEvent.click(actions[1]);
|
|
|
|
await waitFor(() => expect(resumeCalls).toHaveLength(2));
|
|
expect(resumeCalls.map((call) => call.operationId)).toEqual([
|
|
firstOperationId,
|
|
secondOperationId,
|
|
]);
|
|
});
|
|
|
|
it('并行 operation 的权威重读互相判旧时仍分别保留原始失败', async () => {
|
|
const firstResume = createDeferred<Record<string, unknown>>();
|
|
const secondResume = createDeferred<Record<string, unknown>>();
|
|
const firstRead = createDeferred<PendingResourceEditFixture[]>();
|
|
const secondRead = createDeferred<PendingResourceEditFixture[]>();
|
|
const firstOperation: PendingResourceEditFixture = {
|
|
operationId: 'f1111111-1111-4111-8111-111111111111',
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '并行失败任务一',
|
|
phase: 'media-downloaded',
|
|
createdAt: 1,
|
|
};
|
|
const secondOperation: PendingResourceEditFixture = {
|
|
operationId: 'f2222222-2222-4222-8222-222222222222',
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '并行失败任务二',
|
|
phase: 'media-downloaded',
|
|
createdAt: 2,
|
|
};
|
|
const operations = [firstOperation, secondOperation];
|
|
const { pendingReadCalls } = installTauri({
|
|
readPendingResourceEdits: (_input, readCount) => {
|
|
if (readCount === 1) return operations;
|
|
return readCount === 2 ? firstRead.promise : secondRead.promise;
|
|
},
|
|
resumeResourceEdit: (input) =>
|
|
input.operationId === firstOperation.operationId
|
|
? firstResume.promise
|
|
: secondResume.promise,
|
|
});
|
|
render(<DerivedWorkbench />);
|
|
fireEvent.click(
|
|
await screen.findByRole('button', {
|
|
name: '管理未完成编辑 (2)',
|
|
}),
|
|
);
|
|
const actions = screen.getAllByRole('button', { name: '继续原编辑' });
|
|
fireEvent.click(actions[0]);
|
|
fireEvent.click(actions[1]);
|
|
|
|
await act(async () => {
|
|
firstResume.reject(new Error('并行恢复一失败'));
|
|
await firstResume.promise.catch(() => undefined);
|
|
});
|
|
await waitFor(() => expect(pendingReadCalls).toHaveLength(2));
|
|
await act(async () => {
|
|
secondResume.reject(new Error('并行恢复二失败'));
|
|
await secondResume.promise.catch(() => undefined);
|
|
});
|
|
await waitFor(() => expect(pendingReadCalls).toHaveLength(3));
|
|
await act(async () => {
|
|
firstRead.resolve(operations);
|
|
await firstRead.promise;
|
|
});
|
|
expect((await screen.findByRole('alert')).textContent).toContain(
|
|
'并行恢复一失败',
|
|
);
|
|
|
|
await act(async () => {
|
|
secondRead.resolve(operations);
|
|
await secondRead.promise;
|
|
});
|
|
await waitFor(() => {
|
|
const alert = screen.getByRole('alert');
|
|
expect(alert.textContent).toContain('并行恢复一失败');
|
|
expect(alert.textContent).toContain('并行恢复二失败');
|
|
});
|
|
});
|
|
|
|
it('旧弹窗动作完成时不会关闭用户后来重新打开的同项目弹窗', async () => {
|
|
const pendingResume = createDeferred<Record<string, unknown>>();
|
|
const operation: PendingResourceEditFixture = {
|
|
operationId: 'f3333333-3333-4333-8333-333333333333',
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '跨弹窗恢复任务',
|
|
phase: 'media-downloaded',
|
|
createdAt: 1,
|
|
};
|
|
const { pendingReadCalls } = installTauri({
|
|
readPendingResourceEdits: (_input, readCount) =>
|
|
readCount === 1 ? [operation] : [],
|
|
resumeResourceEdit: () => pendingResume.promise,
|
|
});
|
|
render(<DerivedWorkbench />);
|
|
const trigger = await screen.findByRole('button', {
|
|
name: '管理未完成编辑 (1)',
|
|
});
|
|
fireEvent.click(trigger);
|
|
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
|
|
fireEvent.click(
|
|
screen.getByRole('button', { name: '关闭资源编辑恢复面板' }),
|
|
);
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.queryByRole('dialog', { name: '未完成的资源编辑' }),
|
|
).toBeNull(),
|
|
);
|
|
fireEvent.click(trigger);
|
|
expect(
|
|
await screen.findByRole('dialog', { name: '未完成的资源编辑' }),
|
|
).not.toBeNull();
|
|
|
|
const base = canvasFixture.manifest!;
|
|
const asset = {
|
|
id: 'reopened-dialog-output',
|
|
kind: 'game-rules',
|
|
mediaType: 'text/markdown',
|
|
localPath: 'assets/reopened-dialog-output.md',
|
|
source: {
|
|
kind: 'generated' as const,
|
|
resourceId: 'local-asset:reopened-dialog-output',
|
|
referenceResourceIds: ['rules-resource'],
|
|
},
|
|
};
|
|
const nextManifest = { ...base, assets: [...base.assets, asset] };
|
|
canvasFixture.manifest = nextManifest;
|
|
await act(async () => {
|
|
pendingResume.resolve({
|
|
operationId: operation.operationId,
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
committedProjectRevision: 1,
|
|
asset,
|
|
version: null,
|
|
manifest: nextManifest,
|
|
});
|
|
await pendingResume.promise;
|
|
});
|
|
await waitFor(() => expect(pendingReadCalls).toHaveLength(2));
|
|
expect(
|
|
screen.getByRole('dialog', { name: '未完成的资源编辑' }),
|
|
).not.toBeNull();
|
|
expect(screen.getByText('当前没有未完成的资源编辑')).not.toBeNull();
|
|
});
|
|
|
|
it('恢复已提交但权威队列重读失败时保留面板并显式报错', async () => {
|
|
const operation: PendingResourceEditFixture = {
|
|
operationId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
|
|
editKind: 'text',
|
|
sourceResourceId: 'rules-resource',
|
|
assetName: '已提交待确认任务',
|
|
phase: 'media-downloaded',
|
|
createdAt: 1,
|
|
};
|
|
installTauri({
|
|
readPendingResourceEdits: (_input, readCount) => {
|
|
if (readCount > 1) throw new Error('提交后权威队列读取失败');
|
|
return [operation];
|
|
},
|
|
});
|
|
render(<DerivedWorkbench />);
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
|
|
|
|
expect((await screen.findByRole('alert')).textContent).toContain(
|
|
'提交后权威队列读取失败',
|
|
);
|
|
expect(
|
|
screen.getByRole('dialog', { name: '未完成的资源编辑' }),
|
|
).not.toBeNull();
|
|
expect(screen.getByText('已提交待确认任务')).not.toBeNull();
|
|
});
|
|
|
|
it('恢复队列读取失败时显式提供重试,不伪装成空队列', async () => {
|
|
installTauri({
|
|
failFirstPendingRead: true,
|
|
pendingResourceEdit: true,
|
|
});
|
|
render(<DerivedWorkbench />);
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '恢复队列读取失败' }),
|
|
);
|
|
expect((await screen.findByRole('alert')).textContent).toContain(
|
|
'恢复队列测试读取失败',
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '重试读取' }));
|
|
expect(await screen.findByText('恢复的规则编辑版')).not.toBeNull();
|
|
});
|
|
|
|
it('恢复面板循环约束 Tab 焦点,Escape 关闭后归还触发器聚焦', async () => {
|
|
installTauri({ pendingResourceEdit: true });
|
|
render(<DerivedWorkbench />);
|
|
const trigger = await screen.findByRole('button', {
|
|
name: '管理未完成编辑 (1)',
|
|
});
|
|
fireEvent.click(trigger);
|
|
const close = await screen.findByRole('button', {
|
|
name: '关闭资源编辑恢复面板',
|
|
});
|
|
expect(document.activeElement).toBe(close);
|
|
const resume = screen.getByRole('button', { name: '继续原编辑' });
|
|
fireEvent.keyDown(window, { key: 'Tab', shiftKey: true });
|
|
expect(document.activeElement).toBe(resume);
|
|
fireEvent.keyDown(window, { key: 'Tab' });
|
|
expect(document.activeElement).toBe(close);
|
|
trigger.focus();
|
|
fireEvent.keyDown(window, { key: 'Tab' });
|
|
expect(document.activeElement).toBe(close);
|
|
fireEvent.keyDown(window, { key: 'Escape' });
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.queryByRole('dialog', { name: '未完成的资源编辑' }),
|
|
).toBeNull(),
|
|
);
|
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
|
});
|
|
|
|
it('恢复动作使当前按钮动态禁用后仍将 Tab 焦点约束在弹窗内', async () => {
|
|
const pendingResume = createDeferred<Record<string, unknown>>();
|
|
installTauri({
|
|
pendingResourceEdit: true,
|
|
resumeResourceEdit: () => pendingResume.promise,
|
|
});
|
|
render(<DerivedWorkbench />);
|
|
fireEvent.click(
|
|
await screen.findByRole('button', {
|
|
name: '管理未完成编辑 (1)',
|
|
}),
|
|
);
|
|
const close = await screen.findByRole('button', {
|
|
name: '关闭资源编辑恢复面板',
|
|
});
|
|
const resume = screen.getByRole('button', { name: '继续原编辑' });
|
|
resume.focus();
|
|
fireEvent.click(resume);
|
|
await waitFor(() => expect(resume.hasAttribute('disabled')).toBe(true));
|
|
expect(document.activeElement).toBe(resume);
|
|
|
|
fireEvent.keyDown(window, { key: 'Tab' });
|
|
expect(document.activeElement).toBe(close);
|
|
|
|
await act(async () => {
|
|
pendingResume.reject(new Error('焦点测试恢复失败'));
|
|
await pendingResume.promise.catch(() => undefined);
|
|
});
|
|
expect((await screen.findByRole('alert')).textContent).toContain(
|
|
'焦点测试恢复失败',
|
|
);
|
|
});
|
|
});
|