576786324f
- 新增 `gameCreationAppAssetPersistedCategory`:写回口径,只做「缺失或非法 → 按 kind 派生」兜底,不套用读时自愈;它必然等于 Rust 反序列化后的落盘 `category`
- `ResourceClassificationPanel` 的分类初值从 `gameCreationAppAssetCategory`(读显示口径、会把落盘 `unclassified` 自愈成派生值)改为 `gameCreationAppAssetPersistedCategory`,保存时回传落盘原值
- 修掉真机上「同一 `kind:"ui"` 资产同时存在 55 条 `unclassified` 与 2 条 `ui-interaction`」的漂移签名——后者正是自愈值被回写持久化的结果
- 面板注释更正:原注释写「保证『只改标签』不会顺带改动分类」,但那行读的是自愈后的值,这个保证是假的
- `gameCreationAppAssetCategory` 的文档补上「这是读显示口径,回写必须用 `gameCreationAppAssetPersistedCategory`」,避免下次再混用
- 新增面板用例「保存标签不改动落盘 category:自愈值不得被回写」:夹具 `{kind:'ui', category:'unclassified'}`,断言写入命令收到的 `category` 仍是 `unclassified`
- 既有面板夹具 `{kind:'character', category:'character'}` 是自愈 no-op,永远测不出漂移,因此保留它作为对照、另加自愈夹具
- 新增契约用例「写回口径保留落盘 unclassified,读显示口径才自愈」,把两个口径的差集钉死
596 lines
20 KiB
TypeScript
596 lines
20 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
|
||
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 type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||
import { ResourceClassificationPanel } from '../src/view/project-development/ResourceClassificationPanel';
|
||
|
||
const asset: GameCreationAppAssetManifestEntry = {
|
||
id: 'asset-hero',
|
||
kind: 'character',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/hero.png',
|
||
source: { kind: 'uploaded' },
|
||
category: 'character',
|
||
tags: ['主角'],
|
||
};
|
||
|
||
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 renderPanel(
|
||
overrides: {
|
||
asset?: GameCreationAppAssetManifestEntry;
|
||
onClose?: () => void;
|
||
onSaved?: (result: unknown) => void;
|
||
onDeleted?: (result: unknown) => void;
|
||
} = {},
|
||
) {
|
||
render(
|
||
<ResourceClassificationPanel
|
||
projectPath="C:/project"
|
||
projectId="project-1"
|
||
asset={overrides.asset ?? asset}
|
||
onClose={overrides.onClose ?? vi.fn()}
|
||
onSaved={overrides.onSaved ?? vi.fn()}
|
||
onDeleted={overrides.onDeleted ?? vi.fn()}
|
||
/>,
|
||
);
|
||
}
|
||
|
||
/** 已有标签按 pill 文本读出(pill 内的删除按钮文本是 `×`)。 */
|
||
function pillLabels() {
|
||
const list = screen.queryByRole('list', { name: '已有标签' });
|
||
if (!list) return [];
|
||
return within(list)
|
||
.getAllByRole('listitem')
|
||
.map((item) => item.textContent?.replace('×', '').trim());
|
||
}
|
||
|
||
function classificationWrites(invoke: ReturnType<typeof vi.fn>) {
|
||
return invoke.mock.calls.filter(
|
||
([command]) => command === 'update_local_project_resource_classification',
|
||
);
|
||
}
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
removeInvoke();
|
||
});
|
||
|
||
describe('ResourceClassificationPanel 编辑素材标签', () => {
|
||
test('对齐用户截图:标题、素材名副标题、标签占位与底部按钮', () => {
|
||
installInvoke(async () => undefined);
|
||
renderPanel({
|
||
asset: { ...asset, localPath: 'assets/UI Assets/生成UI设计图.png' },
|
||
});
|
||
|
||
expect(
|
||
screen.getByRole('heading', { name: '编辑素材标签' }),
|
||
).not.toBeNull();
|
||
// 副标题是素材名(localPath 的 basename),不再是资源路径。
|
||
expect(screen.getByText('生成UI设计图.png')).not.toBeNull();
|
||
expect(screen.queryByText('assets/UI Assets/生成UI设计图.png')).toBeNull();
|
||
expect(
|
||
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
|
||
).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: '保存标签' })).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: '取消' })).not.toBeNull();
|
||
// 「管理全部标签」没有实现,分类也不再由这个面板编辑。
|
||
expect(screen.queryByRole('button', { name: '管理全部标签' })).toBeNull();
|
||
expect(screen.queryByRole('button', { name: '角色与对象' })).toBeNull();
|
||
expect(screen.queryByRole('button', { name: '场景与环境' })).toBeNull();
|
||
});
|
||
|
||
test('每个已有标签渲染成自带删除按钮的 pill,且 aria-label 可区分', () => {
|
||
installInvoke(async () => undefined);
|
||
renderPanel({ asset: { ...asset, tags: ['主页', '节日'] } });
|
||
|
||
expect(pillLabels()).toEqual(['主页', '节日']);
|
||
// 删除按钮在 pill 内部(不是飘在外面的独立图标)。
|
||
const pill = screen.getByRole('button', { name: '删除标签 主页' });
|
||
expect(pill.closest('.game-resource-tag-pill')).not.toBeNull();
|
||
expect(
|
||
screen.getByRole('button', { name: '删除标签 节日' }),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
test('点某个 pill 的删除按钮只删掉那一个标签', async () => {
|
||
const user = userEvent.setup();
|
||
installInvoke(async () => undefined);
|
||
renderPanel({ asset: { ...asset, tags: ['主页', '节日'] } });
|
||
|
||
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
|
||
|
||
expect(pillLabels()).toEqual(['节日']);
|
||
expect(screen.queryByRole('button', { name: '删除标签 主页' })).toBeNull();
|
||
expect(
|
||
screen.getByRole('button', { name: '删除标签 节日' }),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
test('删除在保存前可撤销:点取消不写盘', async () => {
|
||
const user = userEvent.setup();
|
||
const onClose = vi.fn();
|
||
const invoke = installInvoke(async () => undefined);
|
||
renderPanel({
|
||
asset: { ...asset, tags: ['主页', '节日'] },
|
||
onClose,
|
||
});
|
||
|
||
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
|
||
expect(pillLabels()).toEqual(['节日']);
|
||
|
||
await user.click(screen.getByRole('button', { name: '取消' }));
|
||
|
||
expect(onClose).toHaveBeenCalledTimes(1);
|
||
expect(classificationWrites(invoke)).toHaveLength(0);
|
||
});
|
||
|
||
test('新增标签沿用同一套提交方式:回车 / 中英文逗号 / 顿号都切分', async () => {
|
||
const user = userEvent.setup();
|
||
installInvoke(async () => undefined);
|
||
renderPanel({ asset: { ...asset, tags: [] } });
|
||
|
||
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
|
||
await user.type(field, '主页,');
|
||
expect(pillLabels()).toEqual(['主页']);
|
||
|
||
await user.type(field, '节日,');
|
||
expect(pillLabels()).toEqual(['主页', '节日']);
|
||
|
||
await user.type(field, '新春{Enter}');
|
||
expect(pillLabels()).toEqual(['主页', '节日', '新春']);
|
||
|
||
// 纯空白草稿不会落成空标签。
|
||
await user.type(field, ' {Enter}');
|
||
expect(pillLabels()).toEqual(['主页', '节日', '新春']);
|
||
});
|
||
|
||
test('保存时把剩余标签写成数组,并原样回传读到的分类', async () => {
|
||
const user = userEvent.setup();
|
||
const onSaved = vi.fn();
|
||
const invoke = installInvoke(async (command) => {
|
||
if (command === 'get_local_game_project_revision') {
|
||
return { revision: 7 };
|
||
}
|
||
if (command === 'update_local_project_resource_classification') {
|
||
return {
|
||
asset: { ...asset, tags: ['节日', '新春'] },
|
||
committedProjectRevision: 8,
|
||
};
|
||
}
|
||
throw new Error(`unexpected command: ${command}`);
|
||
});
|
||
renderPanel({ asset: { ...asset, tags: ['主页', '节日'] }, onSaved });
|
||
|
||
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
|
||
await user.type(
|
||
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
|
||
'新春',
|
||
);
|
||
await user.click(screen.getByRole('button', { name: '保存标签' }));
|
||
|
||
await waitFor(() => {
|
||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||
});
|
||
const [, args] = classificationWrites(invoke)[0]!;
|
||
expect(args).toEqual({
|
||
input: {
|
||
projectPath: 'C:/project',
|
||
expectedProjectId: 'project-1',
|
||
expectedProjectRevision: 7,
|
||
assetId: 'asset-hero',
|
||
// 本面板不编辑分类:落盘原值原样回传,不因为只改标签而漂移。
|
||
category: 'character',
|
||
tags: ['节日', '新春'],
|
||
},
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 漂移防线:落盘 `unclassified` 而 `kind` 能派生出明确分类时,读显示口径会自愈成
|
||
* `ui-interaction`,但**回写必须是落盘原值** `unclassified`。否则「编辑标签」面板
|
||
* 会在用户只改标签时静默改掉分类——这正是真机上同一条 `kind:"ui"` 资产同时出现
|
||
* `unclassified` 与 `ui-interaction` 两种落盘值的成因。
|
||
*
|
||
* 变异验证:把面板改回 `gameCreationAppAssetCategory(asset)`,本用例必须失败。
|
||
*/
|
||
test('保存标签不改动落盘 category:自愈值不得被回写', async () => {
|
||
const user = userEvent.setup();
|
||
const invoke = installInvoke(async (command) => {
|
||
if (command === 'get_local_game_project_revision') {
|
||
return { revision: 7 };
|
||
}
|
||
return {
|
||
asset: { ...asset, kind: 'ui', category: 'unclassified' },
|
||
committedProjectRevision: 8,
|
||
};
|
||
});
|
||
renderPanel({
|
||
asset: {
|
||
...asset,
|
||
kind: 'ui',
|
||
category: 'unclassified',
|
||
tags: [],
|
||
},
|
||
});
|
||
|
||
await user.type(
|
||
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
|
||
'界面',
|
||
);
|
||
await user.click(screen.getByRole('button', { name: '保存标签' }));
|
||
|
||
await waitFor(() => {
|
||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||
});
|
||
const [, args] = classificationWrites(invoke)[0]!;
|
||
// 读显示口径下该资产是 ui-interaction;回写口径必须是落盘值 unclassified。
|
||
expect(args).toEqual({
|
||
input: {
|
||
projectPath: 'C:/project',
|
||
expectedProjectId: 'project-1',
|
||
expectedProjectRevision: 7,
|
||
assetId: 'asset-hero',
|
||
category: 'unclassified',
|
||
tags: ['界面'],
|
||
},
|
||
});
|
||
});
|
||
|
||
test('归一化口径不变:重复与空白标签在保存前被收敛', async () => {
|
||
const user = userEvent.setup();
|
||
const invoke = installInvoke(async (command) => {
|
||
if (command === 'get_local_game_project_revision') {
|
||
return { revision: 7 };
|
||
}
|
||
return { asset, committedProjectRevision: 8 };
|
||
});
|
||
renderPanel({ asset: { ...asset, tags: [] } });
|
||
|
||
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
|
||
await user.type(field, ' 主舞台 ,日夜,主舞台、');
|
||
await user.click(screen.getByRole('button', { name: '保存标签' }));
|
||
|
||
await waitFor(() => {
|
||
expect(classificationWrites(invoke)).toHaveLength(1);
|
||
});
|
||
const [, args] = classificationWrites(invoke)[0]!;
|
||
expect((args as { input: { tags: string[] } }).input.tags).toEqual([
|
||
'主舞台',
|
||
'日夜',
|
||
]);
|
||
});
|
||
|
||
test('surfaces the native rejection without reporting a save', async () => {
|
||
const user = userEvent.setup();
|
||
const onSaved = vi.fn();
|
||
installInvoke(async (command) => {
|
||
if (command === 'get_local_game_project_revision') {
|
||
return { revision: 7 };
|
||
}
|
||
throw '非法资源分类:future-category';
|
||
});
|
||
|
||
renderPanel({ onSaved });
|
||
|
||
await user.click(screen.getByRole('button', { name: '保存标签' }));
|
||
|
||
await screen.findByRole('alert');
|
||
expect(screen.getByRole('alert').textContent).toContain('非法资源分类');
|
||
expect(onSaved).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('does not attempt a write outside the native client', async () => {
|
||
const user = userEvent.setup();
|
||
const onSaved = vi.fn();
|
||
renderPanel({ onSaved });
|
||
|
||
await user.click(screen.getByRole('button', { name: '保存标签' }));
|
||
|
||
await screen.findByRole('alert');
|
||
expect(screen.getByRole('alert').textContent).toContain('客户端');
|
||
expect(onSaved).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('资源卡工具条入口 label 与面板标题一致,不再是「分类与标签」', () => {
|
||
const viewSource = readFileSync(
|
||
resolve(
|
||
process.cwd(),
|
||
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
|
||
),
|
||
'utf8',
|
||
);
|
||
|
||
// 工具条写着「分类与标签」却打开纯标签面板会误导用户,入口必须与面板同名。
|
||
expect(viewSource).toContain('label="编辑标签"');
|
||
expect(viewSource).toContain('title="编辑标签"');
|
||
expect(viewSource).toContain('<span>编辑标签</span>');
|
||
expect(viewSource).not.toContain('label="分类与标签"');
|
||
});
|
||
|
||
test('标签 pill 的删除按钮热区不小于 32px', () => {
|
||
const panelStyles = readFileSync(
|
||
resolve(
|
||
process.cwd(),
|
||
'apps/ai-game-creator-shell/src/features/project-workspace/resourceClassificationTagPanel.css',
|
||
),
|
||
'utf8',
|
||
);
|
||
|
||
const rule = panelStyles.match(
|
||
/\.game-resource-tag-remove\s*\{([^}]*)\}/s,
|
||
)?.[1];
|
||
expect(rule).toBeDefined();
|
||
// 视觉图标可以小,热区不能小:这是"看得到却点不中"的防线。
|
||
expect(rule).toMatch(/width:\s*32px/);
|
||
expect(rule).toMatch(/height:\s*32px/);
|
||
expect(rule).toMatch(/flex:\s*0 0 32px/);
|
||
});
|
||
});
|
||
|
||
describe('ResourceClassificationPanel 删除资源', () => {
|
||
test('deletes the asset registration only after the confirmation panel is submitted', async () => {
|
||
const user = userEvent.setup();
|
||
const onDeleted = vi.fn();
|
||
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}`);
|
||
});
|
||
|
||
render(
|
||
<ResourceClassificationPanel
|
||
projectPath="C:/project"
|
||
projectId="project-1"
|
||
asset={asset}
|
||
onClose={vi.fn()}
|
||
onSaved={vi.fn()}
|
||
onDeleted={onDeleted}
|
||
/>,
|
||
);
|
||
|
||
// 删除入口只打开确认面板,不得直接发起删除。
|
||
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(
|
||
invoke.mock.calls.some(
|
||
([command]) => command === 'delete_local_project_asset',
|
||
),
|
||
).toBe(false);
|
||
// 未被任何版本引用时不出现连带删除勾选。
|
||
expect(
|
||
screen.queryByRole('checkbox', { name: '把相关游戏版本一并删除' }),
|
||
).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,
|
||
});
|
||
});
|
||
|
||
test('lists the versions using the asset and keeps the cascade option unchecked', async () => {
|
||
const user = userEvent.setup();
|
||
const onDeleted = vi.fn();
|
||
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 };
|
||
}
|
||
if (command === 'delete_local_project_asset') {
|
||
return {
|
||
assetId: 'asset-hero',
|
||
localPath: 'assets/hero.png',
|
||
committedProjectRevision: 10,
|
||
fileRetained: true,
|
||
};
|
||
}
|
||
throw new Error(`unexpected command: ${command}`);
|
||
});
|
||
|
||
render(
|
||
<ResourceClassificationPanel
|
||
projectPath="C:/project"
|
||
projectId="project-1"
|
||
asset={asset}
|
||
onClose={vi.fn()}
|
||
onSaved={vi.fn()}
|
||
onDeleted={onDeleted}
|
||
/>,
|
||
);
|
||
|
||
await user.click(screen.getByRole('button', { name: '删除资源' }));
|
||
await screen.findByRole('dialog', { name: '确认删除资源' });
|
||
expect(screen.getByText('被 2 个游戏版本使用')).toBeTruthy();
|
||
expect(screen.getByText('initial-1')).toBeTruthy();
|
||
expect(screen.getByText('agent-4')).toBeTruthy();
|
||
|
||
const cascade = screen.getByRole('checkbox', {
|
||
name: '把相关游戏版本一并删除',
|
||
});
|
||
expect((cascade as HTMLInputElement).checked).toBe(false);
|
||
|
||
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
|
||
|
||
await waitFor(() => {
|
||
expect(onDeleted).toHaveBeenCalledTimes(1);
|
||
});
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'delete_local_project_asset',
|
||
expect.objectContaining({
|
||
input: expect.objectContaining({ deleteReferencedVersions: false }),
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('requests the cascade delete only when the user checks the option', async () => {
|
||
const user = userEvent.setup();
|
||
const onDeleted = vi.fn();
|
||
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 };
|
||
}
|
||
if (command === 'delete_local_project_asset') {
|
||
return {
|
||
assetId: 'asset-hero',
|
||
localPath: 'assets/hero.png',
|
||
committedProjectRevision: 10,
|
||
fileRetained: true,
|
||
};
|
||
}
|
||
throw new Error(`unexpected command: ${command}`);
|
||
});
|
||
|
||
render(
|
||
<ResourceClassificationPanel
|
||
projectPath="C:/project"
|
||
projectId="project-1"
|
||
asset={asset}
|
||
onClose={vi.fn()}
|
||
onSaved={vi.fn()}
|
||
onDeleted={onDeleted}
|
||
/>,
|
||
);
|
||
|
||
await user.click(screen.getByRole('button', { name: '删除资源' }));
|
||
await screen.findByRole('dialog', { name: '确认删除资源' });
|
||
await user.click(
|
||
screen.getByRole('checkbox', { name: '把相关游戏版本一并删除' }),
|
||
);
|
||
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
|
||
|
||
await waitFor(() => {
|
||
expect(onDeleted).toHaveBeenCalledTimes(1);
|
||
});
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'delete_local_project_asset',
|
||
expect.objectContaining({
|
||
input: expect.objectContaining({ deleteReferencedVersions: true }),
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('surfaces the native rejection without reporting a delete', async () => {
|
||
const user = userEvent.setup();
|
||
const onDeleted = vi.fn();
|
||
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';
|
||
});
|
||
|
||
render(
|
||
<ResourceClassificationPanel
|
||
projectPath="C:/project"
|
||
projectId="project-1"
|
||
asset={asset}
|
||
onClose={vi.fn()}
|
||
onSaved={vi.fn()}
|
||
onDeleted={onDeleted}
|
||
/>,
|
||
);
|
||
|
||
await user.click(screen.getByRole('button', { name: '删除资源' }));
|
||
await screen.findByRole('dialog', { name: '确认删除资源' });
|
||
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
|
||
|
||
await screen.findByRole('alert');
|
||
expect(screen.getByRole('alert').textContent).toContain(
|
||
'project-revision-conflict',
|
||
);
|
||
expect(onDeleted).not.toHaveBeenCalled();
|
||
});
|
||
});
|