Files
Genarrative/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
T
suzmii 2d40f02286
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m0s
Project CI / Native shell tests (pull_request) Successful in 17m12s
合并资源工作台分支:素材类型可由用户变更与角标显示资源类型并入
- 把 v3 顶端 ace31a5f1(快速编辑 @ 引用素材 + @ 面板标签筛选)合入本分支,与本分支 1079f6c91(用户可变更素材类型、角标改显示资源类型)并存
- 冲突零:index.tsx 两侧改动落点不同(快速编辑 promptInputSlot 接线 vs 角标取值点),projectResourceLiveIntegration 与验收文档自动合并
- 验证:npm run typecheck exit 0;resourceClassificationPanel + projectResourceLiveIntegration 共 45 tests 全绿;check:encoding 4409 文件通过;git diff --check 干净
2026-09-12 16:57:28 +08:00

1801 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** @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 { resourceReferenceFromAsset } from '../src/features/project-workspace/resourceReferences';
import ProjectDevelopmentView from '../src/view/project-development';
import {
act,
createGameCreationAppManifest,
findResourceSelectButton,
fireEvent,
openResourceSearch,
queryResourceSelectButton,
render,
screen,
setComposerText,
waitFor,
within,
} from './appSurface/harness';
const projectPath = '/tmp/live-canvas-integration';
/**
* 切栏目走「资源总览」的栏目缩略卡片(左侧大纲导航已删除)。
* 与两个 appSurface suite 里的同名助手同口径:切完必须落到目标栏目的子画布。
*/
async function openResourceBookCategory(label: string) {
const categoryByLabel: Record<string, string> = {
'UI 交互': 'ui-interaction',
角色与对象: 'character',
场景与环境: 'scene',
音频: 'audio',
文档: 'document',
待归类: 'unclassified',
项目版本: 'version',
};
if (document.querySelector('[data-resource-book-view="child"]')) {
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
}
fireEvent.click(await screen.findByRole('button', { name: `打开${label}` }));
await waitFor(() => {
const manager = document.querySelector('[data-resource-book-view="child"]');
expect(manager).not.toBeNull();
expect(
manager?.querySelector(
`.game-resource-book-scene-titlebar.is-active[data-resource-book-category="${categoryByLabel[label]}"]`,
),
).not.toBeNull();
});
}
/** 资源卡右上角角标的文本(找不到卡片或角标时返回 null)。 */
async function cardBadgeText(fileName: string) {
const select = await findResourceSelectButton(fileName);
return (
select
.closest('.game-resource-card')
?.querySelector('.game-resource-card-type-badge')?.textContent ?? null
);
}
/**
* 总览态「所有资源」摞里那张预览卡的角标文本。
*
* 覆盖用户看到的另一个面(总览缩略摞铺在上面的真实卡片本体),它与栏目卡共用
* `ResourceCard` 里那一处角标渲染,所以角标口径一处改、两处都跟着走。
*/
function overviewStackBadgeText(fileName: string) {
const cards = Array.from(
document.querySelectorAll(
'.game-resource-book-preview-card[data-resource-book-category="all"] .game-resource-card',
),
);
const card = cards.find((entry) =>
entry
.querySelector('.game-resource-card-select')
?.getAttribute('aria-label')
?.includes(fileName),
);
return (
card?.querySelector('.game-resource-card-type-badge')?.textContent ?? null
);
}
function classificationWrites(invoke: ReturnType<typeof vi.fn>) {
return invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_classification',
);
}
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)}
/>
);
}
/**
* 素材类型用例的宿主:manifest 由状态持有、`onManifestChange` 真的回写,
* 「改完类型后卡片进目标栏目、角标跟着变」走的才是真链路
* (命令 → 重读 manifest → 重投影 → 栏目分组 → 渲染),而不是断言桩。
*/
function ClassificationWorkbench() {
const initial = createGameCreationAppManifest(
'live-canvas-project',
'实时画布项目',
);
initial.assets = [
{
id: 'asset-hero',
kind: 'character',
category: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'generated', resourceId: 'hero-resource' },
},
{
id: 'asset-ui',
// `ui` 不在 canonical 目录里:`kind` 派生值经别名落到 `ui-interaction`
// 而落盘值仍是 `unclassified`。用来钉住"角标与栏目都走读显示口径"。
kind: 'ui',
category: 'unclassified',
mediaType: 'image/png',
localPath: 'assets/panel.png',
source: { kind: 'generated', resourceId: 'ui-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 === 'update_local_project_resource_classification') {
const input = (args?.input ?? {}) as {
assetId?: string;
category?: string;
tags?: string[];
};
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
// 与 Rust `update_manifest_asset_classification_at` 同语义:只改目标条目的
// `category` / `tags`,并推进一次项目 revision。
canvasFixture.revision += 1;
const nextManifest = {
...base,
assets: base.assets.map((asset) =>
asset.id === input.assetId
? ({
...asset,
category: input.category,
tags: input.tags ?? [],
} as (typeof base.assets)[number])
: asset,
),
};
canvasFixture.manifest = nextManifest;
const asset = nextManifest.assets.find(
(entry) => entry.id === input.assetId,
);
if (!asset) throw new Error(`missing asset ${input.assetId}`);
return {
asset,
committedProjectRevision: canvasFixture.revision,
};
}
if (command === 'get_local_game_manifest') {
if (!canvasFixture.manifest) {
throw new Error('missing manifest fixture');
}
return canvasFixture.manifest;
}
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('快速编辑提示词里能 @ 出资源选择器,插入的引用与聊天同字面量进入派生请求', 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('快速编辑提示词'),
'把夜色改成星空',
);
// 提示词输入区自带 `@` 入口:面板里的「插入素材引用」开出的是资源选择器本身,
// 不是第二个面板或另一套引用格式。
fireEvent.click(
within(panel).getByRole('button', { name: '插入素材引用' }),
);
const picker = await screen.findByRole('dialog', { name: '选择素材' });
fireEvent.click(within(picker).getByRole('option', { name: /^rules/u }));
fireEvent.click(within(picker).getByRole('button', { name: '插入引用' }));
// 回填:chip 与文本都落在快速编辑提示词里。
await waitFor(() => {
expect(
document.querySelector('[data-resource-reference-id="source-rules"]'),
).not.toBeNull();
});
expect(
within(panel).getByLabelText('快速编辑提示词').textContent,
).toContain('把夜色改成星空@rules');
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
await waitFor(() => expect(deriveCalls).toHaveLength(1));
// 出站负载:派生请求里的提示词就是聊天 `@` 拼出来的那一串(`@显示名`),
// 期望值直接由共享引用模型算,钉住"快速编辑没有第二种引用格式"。
const mentionedAsset = canvasFixture.manifest?.assets.find(
(entry) => entry.id === 'source-rules',
);
if (!mentionedAsset) throw new Error('missing source-rules fixture');
const expectedText = `把夜色改成星空@${
resourceReferenceFromAsset(mentionedAsset, 'asset-picker').label
}`;
expect(deriveCalls[0]?.prompt).toBe(expectedText);
});
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(
'焦点测试恢复失败',
);
});
/**
* 卡片右上角角标显示的是**资源类型**(功能分类中文名),等于这张卡所在的画布栏目。
*
* 两条判据缺一不可:
* - `asset-hero`(落盘 `character`):角标「角色与对象」,不可能是媒体类型「图片」;
* - `asset-ui`(落盘 `unclassified` + `kind:"ui"`):读显示口径自愈成 `ui-interaction`
* 所以角标与栏目都是「UI 交互」——不是落盘值推出的「待归类」。
*
* 变异验证:把角标改回媒体类型口径(`projectResourceTypeLabel(resource)`),
* 两条都会变红(分别是「图片」与「图片」)。
*/
it('角标显示资源类型(与所在栏目同名),落盘 unclassified 的 UI 资产按自愈口径显示', async () => {
installTauri();
render(<ClassificationWorkbench />);
await openResourceBookCategory('角色与对象');
expect(await cardBadgeText('hero.png')).toBe('角色与对象');
await openResourceBookCategory('UI 交互');
expect(await cardBadgeText('panel.png')).toBe('UI 交互');
});
/**
* 用户改素材类型后的**整条跟随链**(真宿主:命令 → 重读 manifest → 重投影 → 栏目分组):
* 1. 总览「所有资源」摞里那张预览卡的角标跟着变;
* 2. 卡片挪到新类型对应的栏目,角标等于新栏目名;
* 3. 原栏目里不再有它。
*
* 变异验证(已实测):
* - 把写入改回"永远回传落盘原值" → 载荷 category 不是 `scene`,第一步就红;
* - 把角标改回媒体类型口径(`projectResourceTypeLabel(resource)`)→ 角标恒为「图片」,红。
*
* 已实测**不可构造**的变异:「角标只在挂载时算一次」(`useState(() => …)`)在本用例下仍然绿。
* 原因是选中一张资源卡本身就会把画布切进它所在栏目的子画布,而改类型又让这张卡换到另一个
* 栏目的分组里 —— React 每次都会卸载重建卡片宿主,因此挂载快照与实时读数在 DOM 上同形。
* 换句话说:这条链路在当前结构下不存在"卡片留在原地、值却过期"的窗口,
* 跟随用例能钉住的是**取值来源**(分类 vs 媒体类型),不是缓存时机。
*/
it('改素材类型后卡片进目标栏目、角标跟着变', async () => {
const { invoke } = installTauri();
render(<ClassificationWorkbench />);
await openResourceBookCategory('角色与对象');
expect(await cardBadgeText('hero.png')).toBe('角色与对象');
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' }));
const dialog = await screen.findByRole('dialog', {
name: '编辑素材标签',
});
// 面板里的类型选择器显示的就是卡片当前所在栏目。
const categoryTab = within(dialog).getByRole('button', {
name: '角色与对象',
});
expect(categoryTab.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(within(dialog).getByRole('button', { name: '场景与环境' }));
fireEvent.click(within(dialog).getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
const [, args] = classificationWrites(invoke)[0]!;
expect(args).toEqual({
input: {
projectPath,
expectedProjectId: 'live-canvas-project',
expectedProjectRevision: expect.any(Number),
assetId: 'asset-hero',
category: 'scene',
tags: [],
},
});
// 关掉面板再看栏目(保存本身不关窗,关窗是头部 × 的职责)。
fireEvent.click(
within(dialog).getByRole('button', { name: '关闭编辑素材标签' }),
);
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '编辑素材标签' })).toBeNull(),
);
// 跟随一:总览「所有资源」摞里那张预览卡的角标**就地**跟着变(同一个卡片宿主,
// 不是卸载重建)—— 这一条才钉得住"角标取值来自当前投影、不是挂载时算一次的旧值"。
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
expect(overviewStackBadgeText('hero.png')).toBe('场景与环境');
// 跟随二:卡片出现在「场景与环境」栏,且角标同步成新类型名。
await openResourceBookCategory('场景与环境');
const select = await findResourceSelectButton('hero.png');
expect(
select
.closest('[data-resource-book-category]')
?.getAttribute('data-resource-book-category'),
).toBe('scene');
expect(await cardBadgeText('hero.png')).toBe('场景与环境');
// 跟随三:原栏目里没有它了。
await openResourceBookCategory('角色与对象');
expect(queryResourceSelectButton('hero.png')).toBeNull();
});
});