Files
Genarrative/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
T
suzmii 3bf499ddfd
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 2m59s
Project CI / Native shell tests (pull_request) Successful in 19m29s
修复「所有资源」展开态文档卡与图片卡初始自动重叠
- resourceBookLayout:新增分带几何缓存键 resourceBookAllLayoutKey 与取几何的 resolveResourceBookAllLayout,键纳入布局是否就绪 / 卡片真实尺寸 / 排序模式,拖动只改坐标时键保持不变
- resourceBookLayout:抽出 ResourceBookAllLayoutInput,生产组件与回归用例共用同一份「键 + 缓存」口径
- index.tsx:展开态分带几何改走 resolveResourceBookAllLayout,并传入当前布局 hook 的 ready;此前只按项目 / 排序 / 可见资源 id 冻结,依赖模式关系图就绪前算出的单行带(144 高)被永久固化,图片带仍挤在 144 + 48 处,整排压住文档卡
- tests/resourceBookLayout.test.ts:新增混合类型(12 文档 + 12 图片)第一版坐标到位后分带重算、逐对矩形不相交的主用例
- tests/resourceBookLayout.test.ts:新增同一栏混排不同尺寸卡(类型 / 依赖两种模式)不相交、sidecar 手放坐标保留并让开下一带的对照用例
- tests/resourceBookLayout.test.ts:新增缓存键的拖动稳定性、就绪翻转、卡片尺寸与排序模式变化用例
- tests/projectResourceLiveIntegration.test.tsx:新增真宿主用例,依赖模式下打开「所有资源」,读渲染出的卡片世界矩形做逐对相交检查
2026-09-13 17:06:09 +08:00

2060 lines
76 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,
openResourceFilterPanel,
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)}
/>
);
}
type ExpandedSceneCard = {
resourceId: string;
categoryBadge: string;
left: number;
top: number;
right: number;
bottom: number;
};
/**
* 展开态画本里每张卡的世界矩形。
*
* `--resource-x/y` 与卡片宽高就是渲染层按画本计划写上的几何(见资源卡的宿主样式),
* 所以这里读到的是"卡片被画在哪儿",而不是测试自己重算的一份平行几何。
*/
function readExpandedSceneCards(): ExpandedSceneCard[] {
const hosts = Array.from(
document.querySelectorAll(
'[data-resource-book-view="child"] .game-resource-book-scene-world .game-resource-book-scene-card.is-expanded .game-resource-card',
),
);
return hosts.flatMap((host) => {
const resourceId = host.getAttribute('data-resource-card-id');
const style = (host as HTMLElement).style;
const left = Number.parseFloat(
style.getPropertyValue('--resource-x').replace('px', ''),
);
const top = Number.parseFloat(
style.getPropertyValue('--resource-y').replace('px', ''),
);
const width = Number.parseFloat(
style.getPropertyValue('--resource-card-width').replace('px', ''),
);
const height = Number.parseFloat(
style.getPropertyValue('--resource-card-height').replace('px', ''),
);
if (!resourceId || !Number.isFinite(left + top + width + height)) {
return [];
}
return [
{
resourceId,
categoryBadge:
host.querySelector('.game-resource-card-type-badge')?.textContent ??
'',
left,
top,
right: left + width,
bottom: top + height,
},
];
});
}
/** 逐对矩形相交检查:红的时候直接列出撞在一起的那两张卡的矩形。 */
function intersectingSceneCards(cards: readonly ExpandedSceneCard[]): string[] {
const intersections: string[] = [];
for (let left = 0; left < cards.length; left += 1) {
for (let right = left + 1; right < cards.length; right += 1) {
const a = cards[left]!;
const b = cards[right]!;
if (
a.left < b.right &&
b.left < a.right &&
a.top < b.bottom &&
b.top < a.bottom
) {
intersections.push(
`${a.resourceId}[${a.left},${a.top},${a.right},${a.bottom}] ∩ ` +
`${b.resourceId}[${b.left},${b.top},${b.right},${b.bottom}]`,
);
}
}
}
return intersections;
}
/**
* 文档 + 图片混排的宿主:6 份文档(`category: 'document'` → 文档栏)与 6 张图片
* `kind: 'art-image'` → 待归类栏)。两种卡在依赖模式下都是 180 宽的卡,但落在两条
* 不同的栏目带上 —— 重叠只可能来自带几何,不可能是同一栏里的排布。
*/
function MixedCategoryWorkbench() {
const initial = createGameCreationAppManifest(
'live-canvas-project',
'实时画布项目',
);
initial.assets = [
...Array.from({ length: 6 }, (_, index) => ({
id: `asset-doc-${index + 1}`,
kind: 'document',
category: 'document' as const,
mediaType: 'text/markdown',
localPath: `docs/note-0${index + 1}.md`,
source: {
kind: 'generated' as const,
resourceId: `doc-resource-${index + 1}`,
},
})),
...Array.from({ length: 6 }, (_, index) => ({
id: `asset-image-${index + 1}`,
kind: 'art-image',
mediaType: 'image/png',
localPath: `assets/img-0${index + 1}.png`,
source: {
kind: 'generated' as const,
resourceId: `image-resource-${index + 1}`,
},
})),
];
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);
});
/**
* 用户报的原始现象:在「快速编辑 → 插入素材引用」开出的选择器列表上滚鼠标滚轮,
* 滚的不是列表,而是背后的资源画布(画布跟着缩放 / 平移)。
*
* 链路:选择器 portal 到 `document.body`,而 React 的 portal 事件沿 **React 树** 冒泡
* React 把委托监听挂在 portal 容器上),所以它的 wheel 照样走到画布场景根的
* `onWheel`;修复前那一下会被画布消费掉。这里用真实事件序列钉住「浮层里的滚轮归浮层、
* 画布视口一格不动」,同时用对照用例钉住「画布本体的滚轮照旧」。
*/
it('在选择素材浮层里滚轮:浮层自己收到、画布视口不动,画布本体滚轮照旧', async () => {
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: '快速编辑图片',
});
fireEvent.click(
within(panel).getByRole('button', { name: '插入素材引用' }),
);
const picker = await screen.findByRole('dialog', { name: '选择素材' });
// 前提自检:选择器 DOM 上确实不在资源画本里(这正是 React 事件仍会冒泡到画布的原因)。
const manager = document.querySelector('.game-resource-book-manager');
expect(manager?.contains(picker)).toBe(false);
expect(picker.parentElement).toBe(document.body);
const list = picker.querySelector('.resource-reference-picker-list');
if (!list) throw new Error('missing picker list');
const overlayWheelCalls = vi.fn();
list.addEventListener('wheel', overlayWheelCalls);
const readViewport = () =>
document
.querySelector('[data-resource-viewport]')
?.getAttribute('data-resource-viewport');
const readSceneWorldTransform = () =>
document
.querySelector('.game-resource-book-scene-world')
?.getAttribute('style');
const viewportBefore = readViewport();
const sceneWorldBefore = readSceneWorldTransform();
expect(viewportBefore).toBeTruthy();
// 真实事件序列:从浮层内部元素派发滚轮(等同用户在选择器列表上滚)。
const overlayWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 240,
clientX: 80,
clientY: 60,
});
act(() => {
list.dispatchEvent(overlayWheel);
});
// 浮层自己收到该事件、且没被画布消费(列表按原生行为滚动)。
expect(overlayWheelCalls).toHaveBeenCalledTimes(1);
expect(overlayWheel.defaultPrevented).toBe(false);
// 画布视口一格不动。
expect(readViewport()).toBe(viewportBefore);
expect(readSceneWorldTransform()).toBe(sceneWorldBefore);
list.removeEventListener('wheel', overlayWheelCalls);
// 对照用例:画布本体(场景根)上的滚轮必须照旧平移视口——修复没把画布交互一起关掉。
const sceneRoot = document.querySelector('.game-resource-book-scene');
if (!sceneRoot) throw new Error('missing scene root');
const canvasWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
clientX: 90,
clientY: 70,
});
act(() => {
sceneRoot.dispatchEvent(canvasWheel);
});
expect(canvasWheel.defaultPrevented).toBe(true);
expect(readViewport()).not.toBe(viewportBefore);
});
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 = openResourceFilterPanel();
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(openResourceFilterPanel().value).toBe('source-art');
fireEvent.click(screen.getByRole('button', { name: '清除搜索并定位' }));
// 清空只由这个显式动作发起:再叫出浮层读到的已经是空值。
expect(openResourceFilterPanel().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();
});
/**
* 用户报的原始现象(PR #316 反馈):在「所有资源」一栏里,文档卡与图片卡**一开始**就自动
* 重叠 —— 文档那一排的第 2、3 行被图片栏的卡片整排压住。
*
* 真宿主链路:依赖排序下,关系图读回之前布局 hook 给的是"没有坐标"的布局;展开态的分带
* 几何当时按"所有卡都在原点"算出来(文档带只有单行 144 高),坐标到位后若带几何不重算,
* 图片栏就仍被摆在 144 + 48 处,而文档卡已经铺到 y = 168 / 336。
*
* 判据取渲染出来的卡片世界矩形(`--resource-x/y` 与卡片尺寸就是画本按计划写上的几何),
* 逐对做矩形相交检查:jsdom 没有排版引擎,这是"画出来的位置"在测试环境里能拿到的最强证据。
*/
it('「所有资源」展开态:文档卡与图片卡初始自动布局逐对不相交', async () => {
installTauri();
render(<MixedCategoryWorkbench />);
// 依赖排序:坐标等关系图读回(这就是用户看到的"一开始")。
fireEvent.click(await screen.findByRole('button', { name: '按依赖' }));
fireEvent.click(
await screen.findByRole('button', { name: '打开所有资源' }),
);
const cards = await waitFor(() => {
const rendered = readExpandedSceneCards();
expect(rendered.length).toBe(12);
return rendered;
});
// 前提自检:两个栏目都真的有卡(不是"只有一种类型的卡"导致用例空转)。
expect(new Set(cards.map((card) => card.categoryBadge))).toEqual(
new Set(['文档', '待归类']),
);
const documentTops = new Set(
cards
.filter((card) => card.categoryBadge === '文档')
.map((card) => card.top),
);
// 文档栏至少两行:单行时"带高压住下一带"的现象根本构造不出来,用例会失去意义。
expect(documentTops.size).toBeGreaterThan(1);
expect(intersectingSceneCards(cards)).toEqual([]);
});
});