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