Files
Genarrative/apps/ai-game-creator-shell/tests/resourceAssetDeleteFlow.test.tsx
suzmii d1581892d7 修复 PR #316 review(前端 TS/TSX):资源工作台与资源画布
- 素材重命名(跨范围契约):`rename_local_project_asset` 现在要求 `expectedProjectId` + `expectedProjectRevision`,前端按删除 / 分类保存同一套 CAS 口径补齐——先 `get_local_game_project_revision` 读当前 revision 再连同项目身份提交,不用可能过期的本地缓存值
- 失败文案统一:新增 `projectAssetCommandErrorMessage`,把 `project-identity-conflict` / `project-revision-conflict` 翻成用户可读中文,重命名 / 删除 / 分类保存三条链路共用一份映射,其余错误原样透出
- index.tsx:资源面板下载按 `resolveDownloadablePanelEntries` 过滤,版本卡这类合成 path 的条目不再进入落盘链路
- index.tsx:`characterAnimationPanel` 纳入画布浮层开关判据(抽 `resolveResourceCanvasFloatingPanelOpen`),「生成动画」面板与快速编辑 / 信息浮层共用同一条「点外部 / Esc 关闭」规则
- index.tsx:去掉 `ResourceBookScene` 上重复的 `onWheel`,滚轮只由 manager 上的原生 `passive:false` 监听处理,平移 / 缩放不再被翻倍
- index.tsx:快速编辑的源资源在层 id 命中不到时回落到已正规化的 `asset:<id>`,重试不再静默什么都不做;取不到时给出可见失败
- ResourceCanvasPanelView.tsx:下载按钮按可下载条数判可用、不再在上传中显示下载转圈;上传中禁掉 × / Esc / 遮罩三条关闭路径;预览占位文案抽 helper
- GameRunVersionPicker.tsx:portal 菜单在滚动 / 缩放后重算位置;外部点击判定沿用共享 `useImageCanvasFloatingOptionDismiss` + `menuRef` 边界(review 该条已修,保留现状并补位置用例)
- ResourceCanvasGenerationPanelView.tsx:`submitting` 改 `finally` 收回,成功路径不再永久锁住面板
- ResourceAssetDeleteDialog.tsx / ResourceRenameDialog.tsx / ResourceClassificationPanel.tsx:在飞时 `closeOnEscape` / `closeOnBackdrop` 与头部 × 一起挡住
- resourceEditModel.ts / resourceCanvasToolbarModel.ts / useProjectResourceCardPreviews.ts / projectResourceLiveUpdateModel.ts:抽 `resourceBaseName`、`canonicalProjectedResourceMediaType` 参数收窄去掉强转、删未使用参数、补「IO 失败会 reject」的签名说明
- resourceCanvasHistoryModel.ts:`resourceCanvasSnapshotsEqual` 不再比较不可恢复的 `manuallyPlaced`,消除「压进历史但撤销是空操作」
- resourceCanvasSectionMapping.ts:删掉从未接线、且注释与实现相矛盾的 `LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK`
- resourceCanvasChrome.css:`.game-resource-panel-grid` 补 `flex: 1 1 auto; min-height: 0`,卡片网格成为真正的滚动区
- 用例:重命名 CAS 载荷与读序、CAS 拒绝文案、版本卡下载门禁、上传中关闭路径、浮层判据、菜单跟随滚动、分区映射
2026-09-12 20:49:03 +08:00

332 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import {
cleanup,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { ResourceAssetDeleteDialog } from '../src/view/project-development/ResourceAssetDeleteDialog';
import type { DeleteLocalProjectAssetResult } from '../src/view/project-development/useResourceAssetDeleteFlow';
import { useResourceAssetDeleteFlow } from '../src/view/project-development/useResourceAssetDeleteFlow';
/**
* 素材删除流程的定向用例。
*
* 这条流程从「编辑素材标签」面板抽成了 `useResourceAssetDeleteFlow`,改由资源卡选中工具条
* 的「删除素材」触发;判据与实现在抽前后必须逐字不变 —— 所以这里钉住的是三件事:
* 1. 点入口只读引用、只开二次确认面板,不直接删;
* 2. `deleteReferencedVersions` 的三分支(不勾 / 勾 / 未被引用);
* 3. 失败时既不改状态也不静默:原因经宿主的 `onError` 出口出去。
*
* 工具条上的入口渲染判据(只有 `manifestAssetId` 存在才渲染)与真链路 IPC 载荷由
* `resourceVersionReplacement.test.tsx` 的工台用例覆盖,这里只测流程本身。
*/
const TARGET = { assetId: 'asset-hero', localPath: 'assets/hero.png' };
type InvokeMock = ReturnType<typeof vi.fn>;
function installInvoke(
implementation: (command: string, args?: unknown) => Promise<unknown>,
) {
const invoke = vi.fn(implementation);
(
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof invoke } };
}
).__TAURI__ = { core: { invoke } };
return invoke;
}
function removeInvoke() {
delete (
window as unknown as {
__TAURI__?: { core?: { invoke?: unknown } };
}
).__TAURI__;
}
function DeleteFlowHarness({
onDeleted,
onError,
}: {
onDeleted: (result: DeleteLocalProjectAssetResult) => void;
onError: (message: string) => void;
}) {
const flow = useResourceAssetDeleteFlow({
projectPath: 'C:/project',
projectId: 'project-1',
onError,
onDeleted,
});
return (
<div>
<button
type="button"
disabled={flow.preparing || flow.deleting}
onClick={() => void flow.requestDelete(TARGET)}
>
删除素材
</button>
{flow.deleteDialogProps ? (
<ResourceAssetDeleteDialog {...flow.deleteDialogProps} />
) : null}
</div>
);
}
function renderDeleteFlow() {
const onDeleted = vi.fn();
const onError = vi.fn();
render(<DeleteFlowHarness onDeleted={onDeleted} onError={onError} />);
return { onDeleted, onError };
}
function deleteCalls(invoke: InvokeMock) {
return invoke.mock.calls.filter(
([command]) => command === 'delete_local_project_asset',
);
}
afterEach(() => {
cleanup();
removeInvoke();
});
describe('useResourceAssetDeleteFlow 删除素材', () => {
test('入口只开确认面板不直接删;未被引用时不出现连带删除勾选', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return { assetId: 'asset-hero', versions: [] };
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
if (command === 'delete_local_project_asset') {
return {
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
};
}
throw new Error(`unexpected command: ${command}`);
});
const { onDeleted, onError } = renderDeleteFlow();
await user.click(screen.getByRole('button', { name: '删除素材' }));
await screen.findByRole('dialog', { name: '确认删除资源' });
expect(onDeleted).not.toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith('read_local_project_asset_references', {
input: { projectPath: 'C:/project', assetId: 'asset-hero' },
});
expect(deleteCalls(invoke)).toHaveLength(0);
expect(
screen.queryByRole('checkbox', { name: '把相关游戏版本一并删除' }),
).toBeNull();
// 面板副标题用的是落盘 `localPath`,不是素材 id。
expect(screen.getByText('assets/hero.png')).not.toBeNull();
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
await waitFor(() => {
expect(onDeleted).toHaveBeenCalledTimes(1);
});
expect(invoke).toHaveBeenCalledWith('delete_local_project_asset', {
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 9,
assetId: 'asset-hero',
deleteReferencedVersions: false,
},
});
expect(onDeleted.mock.calls[0]?.[0]).toEqual({
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
});
expect(onError).not.toHaveBeenCalled();
// 删完面板收起,不留一个"点不动"的确认面板。
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '确认删除资源' })).toBeNull(),
);
});
test('列出引用该素材的版本,并默认不连带删除', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return {
assetId: 'asset-hero',
versions: [
{
versionId: 'initial-1',
projectRevision: 1,
createdAt: 1_760_000_000,
},
{
versionId: 'agent-4',
projectRevision: 4,
createdAt: 1_760_003_600,
},
],
};
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
return {
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
};
});
renderDeleteFlow();
await user.click(screen.getByRole('button', { name: '删除素材' }));
const dialog = await screen.findByRole('dialog', {
name: '确认删除资源',
});
expect(within(dialog).getByText('被 2 个游戏版本使用')).toBeTruthy();
expect(within(dialog).getByText('initial-1')).toBeTruthy();
expect(within(dialog).getByText('agent-4')).toBeTruthy();
expect(
(
within(dialog).getByRole('checkbox', {
name: '把相关游戏版本一并删除',
}) as HTMLInputElement
).checked,
).toBe(false);
await user.click(
within(dialog).getByRole('button', { name: '确认删除资源' }),
);
await waitFor(() => expect(deleteCalls(invoke)).toHaveLength(1));
expect(invoke).toHaveBeenCalledWith(
'delete_local_project_asset',
expect.objectContaining({
input: expect.objectContaining({ deleteReferencedVersions: false }),
}),
);
});
test('只有勾上连带删除才按 true 走同一分支', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return {
assetId: 'asset-hero',
versions: [
{
versionId: 'initial-1',
projectRevision: 1,
createdAt: 1_760_000_000,
},
],
};
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
return {
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
};
});
renderDeleteFlow();
await user.click(screen.getByRole('button', { name: '删除素材' }));
const dialog = await screen.findByRole('dialog', {
name: '确认删除资源',
});
await user.click(
within(dialog).getByRole('checkbox', {
name: '把相关游戏版本一并删除',
}),
);
await user.click(
within(dialog).getByRole('button', { name: '确认删除资源' }),
);
await waitFor(() => expect(deleteCalls(invoke)).toHaveLength(1));
expect(invoke).toHaveBeenCalledWith(
'delete_local_project_asset',
expect.objectContaining({
input: expect.objectContaining({ deleteReferencedVersions: true }),
}),
);
});
test('读引用失败时不弹空壳确认面板,原因经宿主出口说明白', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
throw '素材已被删除';
}
throw new Error(`unexpected command: ${command}`);
});
const { onDeleted, onError } = renderDeleteFlow();
await user.click(screen.getByRole('button', { name: '删除素材' }));
await waitFor(() => expect(onError).toHaveBeenCalledWith('素材已被删除'));
expect(screen.queryByRole('dialog', { name: '确认删除资源' })).toBeNull();
expect(deleteCalls(invoke)).toHaveLength(0);
expect(onDeleted).not.toHaveBeenCalled();
});
test('删除失败时保留确认面板、透出原生拒绝,且不报删除成功', async () => {
const user = userEvent.setup();
installInvoke(async (command) => {
if (command === 'read_local_project_asset_references') {
return { assetId: 'asset-hero', versions: [] };
}
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
throw 'project-revision-conflict';
});
const { onDeleted, onError } = renderDeleteFlow();
await user.click(screen.getByRole('button', { name: '删除素材' }));
await screen.findByRole('dialog', { name: '确认删除资源' });
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
await waitFor(() =>
// 结构化 CAS 错误码翻成用户可读中文:与重命名、分类保存同一份映射。
expect(onError).toHaveBeenCalledWith(
'项目已被其它操作改动,请刷新后重试',
),
);
expect(screen.getByRole('dialog', { name: '确认删除资源' })).not.toBeNull();
expect(onDeleted).not.toHaveBeenCalled();
});
test('不在客户端内时不发任何 IPC,直接说明原因', async () => {
const user = userEvent.setup();
const { onDeleted, onError } = renderDeleteFlow();
await user.click(screen.getByRole('button', { name: '删除素材' }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith('删除资源需要在客户端内执行'),
);
expect(screen.queryByRole('dialog', { name: '确认删除资源' })).toBeNull();
expect(onDeleted).not.toHaveBeenCalled();
});
});