dc243b0d57
- 「替换素材」候选卡缩略图接入资源卡同一条预览管线:新增 ResourcePreviewMedia,复用同一份预览身份、同一条队列与 LRU 缓存,不再只渲染类型占位图标 - 预览 hook 暴露 sweepVisiblePreviews:弹窗内容挂在 portal 下、不在资源画本 observer 的 root 子树内,改为在「弹窗打开」与「候选区滚动」两个几何变化点显式按同一套视口几何判据兜底放行,不新增第二条加载通路 - 候选排序抽出纯函数 sortResourceReplacementCandidates:可替换(无硬门禁失败)的排最前,其余按后端原名次跟随且仍渲染但禁用;判据与弹窗禁用状态同源,不会出现「排最前却点不动」 - 抽出纯函数 projectResourcesByManifestAssetId,让弹窗素材映射与候选缩略图共用同一份资源投影口径 - 共享选择弹窗的禁用态不再整卡压透明度:变灰只压在缩略图与名称上,「分类不同」这类禁用原因保持全对比度可读(网页端美术画布不传禁用原因,视觉逐字不变) - 资源卡选中工具条末位新增「删除素材」,前置共享工具条同一套分隔线与前面的非破坏性动作隔开,只在 manifestAssetId 存在时才渲染 - 删除流程抽成 useResourceAssetDeleteFlow(读引用信息 → ResourceAssetDeleteDialog 二次确认 → delete_local_project_asset 的 deleteReferencedVersions 三分支),工具条与面板共用同一份实现,没有第二套删除 - 「编辑素材标签」面板底部只留右下角一个「添加」:点它把输入框内容(含未按回车的尾巴)落成 pill 并按现有写入路径保存,分类仍原样回传落盘原值;删除素材/取消/保存标签三个按钮移除,关闭仍走头部 × - 标签 pill 内的删除标签按钮保持不变 - 更新与补充用例:候选排序、排序与禁用同源、候选缩略图真链路与滚动兜底放行、工具条删除入口位置与 IPC 载荷、连带删除分支、面板无删除入口与「添加」语义、禁用态可读性类名契约、工具条动作清单
329 lines
11 KiB
TypeScript
329 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(() =>
|
|
expect(onError).toHaveBeenCalledWith('project-revision-conflict'),
|
|
);
|
|
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();
|
|
});
|
|
});
|