Files
Genarrative/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
suzmii 928a0ea13e 素材类型选项改成纵向单选列表,不再横排成一条
- ResourceTypePanel:选项区从 PlatformSegmentedTabs(3~6 列网格,6 项正好挤成一行)换成纵向单选列表,复用共享的 PlatformNavigableListItem 作行骨架
- ResourceTypePanel:容器改为 role="radiogroup" + aria-label,每项 role="radio" + aria-checked,选中态与读屏读的是同一个属性
- ResourceTypePanel:roving tabindex(只有选中项 tabIndex=0)与方向键在选项间移动焦点,走到头不越界;方向键不顺手选中,Enter/Space 才落盘(选中=一次 CAS 写盘,浏览选项不该连环写盘)
- ResourceTypePanel:选中行额外渲染勾选图标作为第二视觉线索,图标 aria-hidden 不污染可读名字
- resourceTypePanel.css:选项容器改成单列网格(grid-template-columns: minmax(0, 1fr) + grid-auto-flow: row + align-content: start),一行一个选项
- resourceTypePanel.css:选项行补 width/min-width/min-height: 44px 与 overflow-wrap,移动端热区与窄屏换行都不依赖共享件的工具类
- resourceTypePanel.css:选中态由 [aria-checked='true'] 驱动并显式提权到 (0,3,0) 以上(含覆盖 :hover:not(:disabled)),避免被共享列表行的悬停底色顶掉
- resourceTypePanel.css:滚动从 body 挪到选项列表(列表现有 max-height: min(320px, 40dvh) + overflow-y: auto),body 改为 auto/minmax(0,1fr)/auto 三段,标题、素材名与错误提示不跟着滚
- 测试:新增「选项区是纵向单列列表」用例,按 styleCascade 解析真实生效声明(360/390/1440 三档宽度都是单列、行满宽、热区 44px),并断言 6 项都是 radiogroup 的直接子元素
- 测试:新增「单选语义与键盘」用例(radiogroup/radio + aria-checked + roving tabindex,方向键只移焦点不落盘,Enter 才写盘)
- 测试:新增「选中态由 aria-checked 驱动并压过共享列表行悬停底色」用例
- 测试:把「面板有高度上界且中间行可滚动」改成「选项区有上界并独立滚动,标题与错误提示不滚」
- 测试:集成用例的类型选项查询从 button/aria-pressed 改为 radio/aria-checked
2026-09-14 18:16:56 +08:00

2204 lines
83 KiB
TypeScript
Raw Permalink 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 categoryOption = within(dialog).getByRole('radio', {
name: '角色与对象',
});
expect(categoryOption.getAttribute('aria-checked')).toBe('true');
// 「选中即落盘」:这一次点击本身就是完整动作,不需要任何标签动作。
fireEvent.click(within(dialog).getByRole('radio', { 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: [],
},
});
// 保存成功后由宿主收起面板(关窗不是用户的第二个动作):用户马上就能在画布上
// 看到卡片落进新栏目,而不是隔着一块挡画布的浮层去猜。
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();
});
/**
* 两块分类浮层的关闭时机互不串台:类型面板与标签面板都是 portal 到 body 的模态浮层,
* 开着时「点外部清画布焦点」与画布自己的 Esc 都必须让位 ——
* 关掉的只能是浮层本身,画布选中与工具条都得留着。
*
* 变异验证(已实测):把 `resourceTypeAssetId` 从 `isClassificationPanelOpen` 判据里去掉
* (只留标签面板那一半),Esc 那一步会被画布的 Esc 抢走:类型面板关不掉、工具条一起消失,
* 本用例必须失败。
*/
it('类型面板与标签面板:Esc / 点外部都只关浮层,不动画布选中', async () => {
const { invoke } = installTauri();
render(<ClassificationWorkbench />);
await openResourceBookCategory('角色与对象');
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
// 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。
fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' }));
await screen.findByRole('dialog', { name: '设置素材类型' });
fireEvent.click(document.body);
expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull();
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
// Esc 只关类型面板,选中(工具条)留着 —— 用户接着还能做别的动作。
fireEvent.keyDown(document.body, { key: 'Escape' });
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '设置素材类型' })).toBeNull(),
);
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
// 标签面板走同一份判据:Esc 关面板,不清选中。
fireEvent.click(
within(screen.getByRole('toolbar', { name: '图片工具栏' })).getByRole(
'button',
{ name: '编辑标签' },
),
);
await screen.findByRole('dialog', { name: '编辑素材标签' });
fireEvent.keyDown(document.body, { key: 'Escape' });
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '编辑素材标签' })).toBeNull(),
);
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
// 收尾自检:没有写入发生 —— 这两个动作都不该改数据。
expect(classificationWrites(invoke)).toHaveLength(0);
});
/**
* 只改标签不许动分类(验收判据里的"逐字不变"):落盘 `unclassified` + `kind:"ui"` 的资产
* 在读显示口径下自愈成「UI 交互」,用户在标签面板里加一个标签后,manifest 上的 `category`
* 必须还是 `unclassified` —— 真机上这条资产正因回写自愈值同时出现过两种落盘值。
*
* 变异验证(已实测):把标签面板的载荷改回读显示口径
* `gameCreationAppAssetCategory(asset)`),manifest 上的值会变成 `ui-interaction`
* 本用例必须失败。
*/
it('只改标签后 manifest 的 category 逐字不变(落盘 unclassified + kind ui 的资产)', async () => {
const { invoke } = installTauri();
render(<ClassificationWorkbench />);
await openResourceBookCategory('UI 交互');
fireEvent.click(await findResourceSelectButton('panel.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' }));
const dialog = await screen.findByRole('dialog', { name: '编辑素材标签' });
fireEvent.change(
within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'),
{ target: { value: '界面' } },
);
fireEvent.click(within(dialog).getByRole('button', { name: '添加' }));
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1));
const persisted = canvasFixture.manifest?.assets.find(
(entry) => entry.id === 'asset-ui',
);
// 落盘原值逐字不变(不是自愈出来的 ui-interaction),标签写进去了。
expect(persisted?.category).toBe('unclassified');
expect(persisted?.tags).toEqual(['界面']);
});
/**
* 第二入口:信息浮层的「分类」行点进「设置素材类型」。
*
* 信息浮层是只读事实卡,新增的可点字段只有「分类」——点它等同于点工具条的「素材类型」:
* 同一块面板、同一条写入命令,不另开第二条写路径。
*
* 变异验证:浮层不传 `onEditCategory`(或按钮渲染进 `dd`),本用例必须失败。
*/
it('信息浮层的「分类」行可以进类型设置,落盘走同一条链路', async () => {
const { invoke } = installTauri();
render(<ClassificationWorkbench />);
await openResourceBookCategory('角色与对象');
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
fireEvent.click(within(toolbar).getByRole('button', { name: '信息' }));
const infoPanel = await screen.findByRole('dialog', { name: '资源信息' });
// 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。
expect(
Array.from(infoPanel.querySelectorAll('dt')).map(
(node) => node.textContent,
),
).toContain('分类');
expect(
Array.from(infoPanel.querySelectorAll('dd')).map(
(node) => node.textContent,
),
).toContain('角色与对象');
fireEvent.click(
within(infoPanel).getByRole('button', { name: '设置素材类型' }),
);
// 信息浮层先收起(它锚在卡片位置,而卡片马上要换栏目),类型面板接着打开。
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
const dialog = await screen.findByRole('dialog', { name: '设置素材类型' });
expect(
within(dialog)
.getByRole('radio', { name: '角色与对象' })
.getAttribute('aria-checked'),
).toBe('true');
fireEvent.click(within(dialog).getByRole('radio', { name: '音频' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
expect(classificationWrites(invoke)[0]?.[1]).toEqual({
input: {
projectPath,
expectedProjectId: 'live-canvas-project',
expectedProjectRevision: expect.any(Number),
assetId: 'asset-hero',
category: 'audio',
tags: [],
},
});
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '设置素材类型' })).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([]);
});
});