3bf499ddfd
- 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:新增真宿主用例,依赖模式下打开「所有资源」,读渲染出的卡片世界矩形做逐对相交检查
2060 lines
76 KiB
TypeScript
2060 lines
76 KiB
TypeScript
/** @vitest-environment jsdom */
|
||
import React, { useState } from 'react';
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||
|
||
const canvasFixture = vi.hoisted(() => ({
|
||
manifest: null as GameCreationAppManifest | null,
|
||
revision: 0,
|
||
}));
|
||
|
||
import {
|
||
calculateCharacterAnimationPrice,
|
||
CHARACTER_ANIMATION_MODEL,
|
||
} from '../../../src/components/image-editor/ImageCanvasGenerationModel';
|
||
import { 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([]);
|
||
});
|
||
});
|