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

900 lines
30 KiB
TypeScript
Raw 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 { afterEach } from 'vitest';
import {
act,
createGameCreationAppManifest,
describe,
expect,
findResourceSelectButton,
fireEvent,
it,
ProjectDevelopmentView,
React,
render,
screen,
vi,
waitFor,
within,
} from './appSurface/harness';
/**
* 版本级资源替换的真链路用例(PRD §3.2 / §5.3)。
*
* 覆盖:入口只在"素材被当前版本绑定"时渲染(不留假按钮)、候选弹窗的禁用与原因、写入的
* 精确 IPC 载荷、成功后切版本但不重载预览、失败时保留弹窗并显示原因且零副作用。
*
* 用例独立成文件,不往 `project-development.suite.ts` 里插,避免与工具条那条线互相踩。
*/
const PROJECT_PATH = '/tmp/agc-version-resource-replacement';
const PROJECT_ID = 'agc-version-resource-replacement';
const SOURCE_VERSION_ID = 'initial-1';
const EXPECTED_REVISION = 5;
function resourceGraphForInputs(args?: Record<string, unknown>) {
const resources =
(args?.resources as Array<{ resourceId: string }> | undefined) ?? [];
return {
resourceIds: resources.map(({ resourceId }) => resourceId),
referenceEdges: [],
taskFlows: [],
connectionIndex: resources.map(({ resourceId }) => ({
resourceId,
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [],
taskFlowIds: [],
})),
producerAssignments: [],
dependencyDepths: resources.map(({ resourceId }) => ({
resourceId,
dependencyDepth: 0,
})),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
function replacementManifest() {
const manifest = createGameCreationAppManifest(PROJECT_ID, '替换素材测试');
manifest.assets = [
{
id: 'asset-legacy',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/legacy.png',
source: { kind: 'generated' as const },
},
{
id: 'asset-final',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/final.png',
source: { kind: 'generated' as const },
},
{
id: 'asset-scene',
kind: 'scene',
mediaType: 'image/png',
localPath: 'assets/scene.png',
source: { kind: 'generated' as const },
},
{
id: 'asset-late',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/late.png',
source: { kind: 'generated' as const },
},
{
id: 'asset-webp',
kind: 'character',
mediaType: 'image/webp',
localPath: 'assets/final.webp',
source: { kind: 'generated' as const },
},
];
manifest.versions = [
{
versionId: SOURCE_VERSION_ID,
parentVersionId: null,
projectRevision: 1,
resourceBindings: [
{ slotId: 'asset:asset-legacy', resourceId: 'asset-legacy' },
{ slotId: 'asset:asset-final', resourceId: 'asset-final' },
{ slotId: 'asset:asset-scene', resourceId: 'asset-scene' },
],
createdReason: 'initial',
createdAt: 1_700_000_000,
},
];
return manifest;
}
const REPLACEMENT_CANDIDATES = {
sourceVersionId: SOURCE_VERSION_ID,
sourceResourceId: 'asset-legacy',
// 后端给的名次里**不可替换项排在最前**(真实后端按资源名次给,不保证可替换项在前):
// 这样"候选排序"用例才有可失败的判据。
candidates: [
{
resourceId: 'asset-scene',
compatible: false,
compatibility: {
categoryEqual: false,
subtypeEqual: false,
sizeSpecEqual: true,
},
blockedReason: '分类不同',
warning: null,
},
{
resourceId: 'asset-final',
compatible: true,
compatibility: {
categoryEqual: true,
subtypeEqual: true,
sizeSpecEqual: true,
},
blockedReason: null,
warning: null,
},
{
// 同分类同类型、只有媒体格式不同:可选,但带一条提示。
resourceId: 'asset-webp',
compatible: true,
compatibility: {
categoryEqual: true,
subtypeEqual: true,
sizeSpecEqual: false,
},
blockedReason: null,
warning: '格式与源素材不同',
},
],
};
// 直接替换:不产生新版本,返回的就是被改的那个版本。
const REPLACEMENT_RESULT = {
versionId: SOURCE_VERSION_ID,
committedProjectRevision: 6,
replacement: {
versionId: SOURCE_VERSION_ID,
sourceResourceId: 'asset-legacy',
replacementResourceId: 'asset-final',
compatibility: {
categoryEqual: true,
subtypeEqual: true,
sizeSpecEqual: true,
},
warning: null,
},
};
type RenderOptions = {
replacementCandidates?: () => Promise<unknown>;
replacementWrite?: () => Promise<unknown>;
/** 未被 manifest 登记的附件:用来验证「只有 manifest 资产才有删除素材入口」这一判据。 */
attachments?: Array<{
fileName: string;
mediaType: string;
localPath: string;
status: 'imported' | 'failed';
}>;
/** 引用这个素材的游戏版本;非空时确认面板要列出它们并给出连带删除勾选。 */
referencedVersions?: Array<{
versionId: string;
projectRevision: number;
createdAt: number;
}>;
deletion?: () => Promise<unknown>;
};
let observer: ReturnType<
typeof installResourceCardIntersectionObserver
> | null = null;
type RectOverride = { restore: () => void };
let rectOverride: RectOverride | null = null;
afterEach(() => {
rectOverride?.restore();
rectOverride = null;
});
/**
* 候选弹窗缩略图的可见性判据是**视口几何**(`viewportBandOfElement`:视口 ± 160px),
* jsdom 里所有元素量出来都是 0×0 ⇒ 一律判"不在放行范围内",缩略图永远不会被请求。
*
* 这里只给候选弹窗自己的媒体元素一个真实矩形,其它元素保持原样:既不动画布布局计算,
* 也不改判据本身 —— 用例钉的还是线上那条几何放行路径。
*/
function installVisibleCandidateMediaRects() {
const original = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function (this: Element) {
if (this.classList?.contains('game-resource-replacement-media')) {
return {
x: 0,
y: 0,
top: 0,
left: 0,
right: 120,
bottom: 80,
width: 120,
height: 80,
toJSON: () => ({}),
} as DOMRect;
}
return original.call(this);
};
rectOverride = {
restore: () => {
Element.prototype.getBoundingClientRect = original;
},
};
}
function renderReplacementWorkbench(options: RenderOptions = {}) {
observer = installResourceCardIntersectionObserver();
const manifest = replacementManifest();
// 直接替换后的 manifest:版本数量不变,只有该版本的绑定被改写。
const nextManifest = {
...manifest,
versions: manifest.versions.map((version) => ({
...version,
resourceBindings: [
{ slotId: 'asset:asset-final', resourceId: 'asset-final' },
{ slotId: 'asset:asset-scene', resourceId: 'asset-scene' },
],
})),
};
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: layoutRevision,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_image_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'image/png',
byteLen: 12,
dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB',
};
}
if (command === 'get_local_game_project_revision') {
return { revision: EXPECTED_REVISION };
}
if (
command === 'read_local_project_version_resource_replacement_candidates'
) {
return options.replacementCandidates
? options.replacementCandidates()
: REPLACEMENT_CANDIDATES;
}
if (command === 'replace_local_project_version_resource') {
return options.replacementWrite
? options.replacementWrite()
: REPLACEMENT_RESULT;
}
if (command === 'read_local_project_asset_references') {
const input = args?.input as { assetId?: string } | undefined;
return {
assetId: input?.assetId,
versions: options.referencedVersions ?? [],
};
}
if (command === 'delete_local_project_asset') {
if (options.deletion) {
return options.deletion();
}
const input = args?.input as { assetId?: string } | undefined;
return {
assetId: input?.assetId,
localPath: 'assets/legacy.png',
committedProjectRevision: 6,
fileRetained: true,
};
}
if (command === 'get_local_game_manifest') {
return nextManifest;
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const onActiveVersionChange = vi.fn();
const onPlay = vi.fn();
const onManifestChange = vi.fn();
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: PROJECT_PATH,
manifest,
attachments: options.attachments ?? [],
recentRunStatus: null,
recentRunStopReason: null,
activeVersionId: null,
onActiveVersionChange,
onPlay,
onManifestChange,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
return { invoke, onActiveVersionChange, onPlay, onManifestChange };
}
async function selectCardAndOpenToolbar(label: string) {
await waitFor(() =>
expect(
document.querySelector('.game-resource-book-thumbnail'),
).not.toBeNull(),
);
// 浮出工具条只在栏目页(`resourceBookView !== 'main'`)上挂载:先打开素材所在的栏目页。
if (!document.querySelector('[data-resource-book-view="child"]')) {
fireEvent.click(
await screen.findByRole('button', { name: '打开角色与对象' }),
);
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="child"]'),
).not.toBeNull(),
);
}
// 卡片必须先被 IntersectionObserver 报为可见,选中后才会浮出工具条;
// 这段 stub 与 `project-development.suite.ts` 的同名 helper 同形,本文件独立成文件后才复制过来。
act(() => {
if (!observer) throw new Error('IntersectionObserver stub 未安装');
observer.triggerVisible();
});
fireEvent.click(await findResourceSelectButton(label));
return screen.findByRole('toolbar', { name: '图片工具栏' });
}
/**
* 资源卡预览用的 IntersectionObserver stub。
*
* jsdom 没有 IntersectionObserver,而卡片浮出工具条依赖"可见"这一步,所以用例自己提供它。
*/
function installResourceCardIntersectionObserver() {
const instances: Array<{
callback: IntersectionObserverCallback;
observed: Set<Element>;
observer: IntersectionObserver;
}> = [];
class ResourceCardIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
readonly observed = new Set<Element>();
constructor(readonly callback: IntersectionObserverCallback) {
instances.push({
callback,
observed: this.observed,
observer: this as unknown as IntersectionObserver,
});
}
observe(element: Element) {
this.observed.add(element);
}
unobserve(element: Element) {
this.observed.delete(element);
}
disconnect() {
this.observed.clear();
}
takeRecords() {
return [];
}
}
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: ResourceCardIntersectionObserver,
});
return {
triggerVisible(elements?: Element[]) {
const instance = instances.at(-1);
if (!instance) {
throw new Error('resource card IntersectionObserver was not created');
}
const targets = elements ?? Array.from(instance.observed);
instance.callback(
targets.map(
(target) =>
({
target,
isIntersecting: true,
intersectionRatio: 1,
}) as IntersectionObserverEntry,
),
instance.observer,
);
},
};
}
describe('版本级资源替换', () => {
it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => {
const { invoke } = renderReplacementWorkbench();
// 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。
const lateToolbar = await selectCardAndOpenToolbar('late.png');
expect(
within(lateToolbar).queryByRole('button', { name: '替换素材' }),
).toBeNull();
expect(
within(lateToolbar).getByRole('button', { name: '快速编辑' }),
).not.toBeNull();
expect(
invoke.mock.calls.some(
([command]) =>
command ===
'read_local_project_version_resource_replacement_candidates',
),
).toBe(false);
// 被当前版本绑定的素材:入口出现。
const sourceToolbar = await selectCardAndOpenToolbar('legacy.png');
expect(
within(sourceToolbar).getByRole('button', { name: '替换素材' }),
).not.toBeNull();
});
it('从入口一路走到写入:候选弹窗禁用硬门禁项、给出格式提示、直接替换且不产生新版本', async () => {
const { invoke, onActiveVersionChange, onPlay, onManifestChange } =
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
await waitFor(() =>
expect(
invoke.mock.calls.some(
([command]) =>
command ===
'read_local_project_version_resource_replacement_candidates',
),
).toBe(true),
);
expect(invoke).toHaveBeenCalledWith(
'read_local_project_version_resource_replacement_candidates',
{
input: {
projectPath: PROJECT_PATH,
sourceVersionId: SOURCE_VERSION_ID,
sourceResourceId: 'asset-legacy',
},
},
);
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
// 候选排序:可替换的排在最前,不可替换的(分类不同)按后端原名次跟随 ——
// 后端给的名次里它本来排在第一位。
expect(
within(dialog)
.getAllByRole('option')
.map((option) => option.getAttribute('aria-label')),
).toEqual([
'选择替换素材final.png',
'选择替换素材final.webp',
'选择替换素材scene.png',
]);
// 候选只列后端给出的素材:未绑定/未登记的素材不合成条目。
expect(
within(dialog).queryByRole('option', { name: '选择替换素材late.png' }),
).toBeNull();
const blockedOption = within(dialog).getByRole('option', {
name: '选择替换素材scene.png',
}) as HTMLButtonElement;
expect(blockedOption.disabled).toBe(true);
expect(within(dialog).getByText('分类不同')).not.toBeNull();
const compatibleOption = within(dialog).getByRole('option', {
name: '选择替换素材final.png',
}) as HTMLButtonElement;
expect(compatibleOption.disabled).toBe(false);
// 尺寸规格降级为提示:格式不同的候选仍可选,但把差异说清楚。
const hintedOption = within(dialog).getByRole('option', {
name: '选择替换素材final.webp',
}) as HTMLButtonElement;
expect(hintedOption.disabled).toBe(false);
expect(within(dialog).getByText('格式与源素材不同')).not.toBeNull();
fireEvent.click(compatibleOption);
fireEvent.click(
within(dialog).getByRole('button', { name: '确认选择替换素材' }),
);
await waitFor(() =>
expect(
invoke.mock.calls.some(
([command]) => command === 'replace_local_project_version_resource',
),
).toBe(true),
);
expect(invoke).toHaveBeenCalledWith(
'replace_local_project_version_resource',
{
input: {
projectPath: PROJECT_PATH,
expectedProjectId: PROJECT_ID,
expectedProjectRevision: EXPECTED_REVISION,
sourceVersionId: SOURCE_VERSION_ID,
sourceResourceId: 'asset-legacy',
replacementResourceId: 'asset-final',
},
},
);
// 直接替换:重读 manifest 并按新 revision 提交,但**不切版本**(没有新版本可切)。
await waitFor(() =>
expect(onManifestChange).toHaveBeenCalledWith(
PROJECT_PATH,
expect.objectContaining({ projectId: PROJECT_ID }),
expect.objectContaining({ revision: 6, source: 'asset-command' }),
),
);
expect(onActiveVersionChange).not.toHaveBeenCalled();
expect(onPlay).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(),
);
});
it('后端拒绝时保留弹窗、显示原因,且不切版本、不重读 manifest', async () => {
const { invoke, onActiveVersionChange, onManifestChange } =
renderReplacementWorkbench({
replacementWrite: async () => {
throw new Error('resource-replacement-incompatible:分类不同');
},
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
fireEvent.click(
within(dialog).getByRole('option', { name: '选择替换素材final.png' }),
);
fireEvent.click(
within(dialog).getByRole('button', { name: '确认选择替换素材' }),
);
await waitFor(() =>
expect(within(dialog).getByRole('alert').textContent).toBe(
'替换素材不兼容:分类不同',
),
);
expect(screen.getByRole('dialog', { name: '选择替换素材' })).not.toBeNull();
expect(onActiveVersionChange).not.toHaveBeenCalled();
expect(onManifestChange).not.toHaveBeenCalled();
expect(
invoke.mock.calls.some(
([command]) => command === 'get_local_game_manifest',
),
).toBe(false);
});
it('候选读取失败时不弹空壳弹窗,直接把原因说明白', async () => {
renderReplacementWorkbench({
replacementCandidates: async () => {
throw new Error('源项目版本不存在:initial-1');
},
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
await waitFor(() =>
expect(
screen.getByText('当前版本已不存在,请刷新项目后重试'),
).not.toBeNull(),
);
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull();
});
/**
* 候选弹窗的缩略图必须走**资源卡同一条预览管线**:候选媒体元素登记进同一张可见性登记表、
* 由同一条几何判据(视口 ± 160px)放行、读到的 Blob URL 直接渲染出来。
*
* 判据要点:弹窗内容挂在 portal 下,**不在资源画本 observer 的 root 子树里**,所以这里
* 不能指望 observer 回调 —— 走的是注册与几何扫描这条同源通路(用例只给候选媒体元素
* 一个真实矩形,不动判据本身)。
*
* 变异验证:把 `renderAssetMedia` 改回"只渲染类型占位图标",本用例必须失败
* (既不请求 `read_local_project_image_preview`,候选卡里也没有 `<img>`)。
*/
it('候选卡缩略图走资源卡同一条预览管线:可见候选读到图并渲染出来', async () => {
installVisibleCandidateMediaRects();
const { invoke } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
await waitFor(() => {
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_project_image_preview' &&
(args as { relativePath?: string } | undefined)?.relativePath ===
'assets/final.png',
),
).toBe(true);
});
const option = within(dialog).getByRole('option', {
name: '选择替换素材final.png',
});
await waitFor(() => {
expect(option.querySelector('img')?.getAttribute('src')).toBe(
'blob:mock-attachment-preview',
);
});
// 预览状态与资源卡一样暴露在 DOM 上:占位图标到底是"还没读"还是"读失败",排障不用猜。
expect(
option
.querySelector('.game-resource-replacement-media')
?.getAttribute('data-resource-preview-status'),
).toBe('loaded');
});
/**
* portal 里的候选卡**不在资源画本 observer 的 root 子树里**,observer 永远不会把它们报成
* 相交 —— 所以"可见才读"只能由几何兜底扫描承担,而扫描要在**几何变化点**被显式触发。
*
* 这里把候选媒体元素的矩形推迟到弹窗打开之后再给:打开时的注册扫描量不出尺寸、不请求,
* 只有滚动那一次显式扫描才放行。判据用的还是同一条几何口径(视口 ± 160px)。
*
* 变异验证:去掉弹窗打开时挂的滚动/定时兜底扫描,本用例必须失败。
*/
it('候选弹窗滚动时靠几何扫描补放行:portal 内容不在 observer 的 root 里', async () => {
const { invoke } = renderReplacementWorkbench();
const readsOf = (relativePath: string) =>
invoke.mock.calls.filter(
([command, args]) =>
command === 'read_local_project_image_preview' &&
(args as { relativePath?: string } | undefined)?.relativePath ===
relativePath,
).length;
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
// 等打开时那一次扫描(含 0ms 兜底)跑完:量不出尺寸 ⇒ 一个候选都不请求。
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
// 场景候选的卡不在当前栏目里,所以这条路径的读取只可能来自弹窗。
expect(readsOf('assets/scene.png')).toBe(0);
installVisibleCandidateMediaRects();
fireEvent.scroll(document);
await waitFor(() => {
expect(readsOf('assets/scene.png')).toBeGreaterThan(0);
});
const sceneOption = within(dialog).getByRole('option', {
name: '选择替换素材scene.png',
});
await waitFor(() => {
expect(sceneOption.querySelector('img')?.getAttribute('src')).toBe(
'blob:mock-attachment-preview',
);
});
// 不可替换的候选也照样出图:禁用说的是"不能替换",不是"看不到素材"。
expect((sceneOption as HTMLButtonElement).disabled).toBe(true);
});
/**
* 工具条「删除素材」:破坏性动作放在末位 + 共享分隔线,复用同一套删除流程
* (读引用 → 二次确认 → `deleteReferencedVersions` 分支 → 重读 manifest)。
*
* 变异验证:
* - 把按钮挪到「替换素材」之前、或删掉前置分隔线,位置断言必须失败;
* - 把 `deleteReferencedVersions` 写死成 `false`,勾选用例(另一条分支)必须失败。
*/
it('工具条末位是「删除素材」:前置分隔线,且复用二次确认与 deleteReferencedVersions 分支', async () => {
const { invoke, onManifestChange } = renderReplacementWorkbench({
referencedVersions: [
{
versionId: SOURCE_VERSION_ID,
projectRevision: 1,
createdAt: 1_700_000_000,
},
],
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
const toolbarLabels = within(toolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label') ?? '');
// 末位:在最后一个非破坏性动作(替换素材)之后、共享下载按钮之前。
expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan(
toolbarLabels.indexOf('替换素材'),
);
expect(toolbarLabels.indexOf('删除素材')).toBeLessThan(
toolbarLabels.indexOf('下载按钮'),
);
// 与前面隔开:紧邻的前一个兄弟就是共享工具条那套分隔线,不是新造的分隔符。
const deleteButton = within(toolbar).getByRole('button', {
name: '删除素材',
});
const divider = deleteButton.previousElementSibling;
expect(divider?.getAttribute('aria-hidden')).toBe('true');
expect(divider?.getAttribute('class')).toContain(
'image-canvas-editor__floating-toolbar-divider',
);
// 点删除先读引用信息(不直接删),再开二次确认面板。
fireEvent.click(deleteButton);
const dialog = await screen.findByRole('dialog', { name: '确认删除资源' });
expect(invoke).toHaveBeenCalledWith('read_local_project_asset_references', {
input: { projectPath: PROJECT_PATH, assetId: 'asset-legacy' },
});
expect(
invoke.mock.calls.some(
([command]) => command === 'delete_local_project_asset',
),
).toBe(false);
// 被版本引用时列出引用版本;连带删除默认不勾(默认只摘登记,版本保留悬空绑定)。
expect(within(dialog).getByText('被 1 个游戏版本使用')).not.toBeNull();
expect(
(
within(dialog).getByRole('checkbox', {
name: '把相关游戏版本一并删除',
}) as HTMLInputElement
).checked,
).toBe(false);
fireEvent.click(
within(dialog).getByRole('button', { name: '确认删除资源' }),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('delete_local_project_asset', {
input: {
projectPath: PROJECT_PATH,
expectedProjectId: PROJECT_ID,
expectedProjectRevision: EXPECTED_REVISION,
assetId: 'asset-legacy',
deleteReferencedVersions: false,
},
});
});
// 删除成功后走与标签保存同一条 manifest 重载路径。
await waitFor(() =>
expect(onManifestChange).toHaveBeenCalledWith(
PROJECT_PATH,
expect.objectContaining({ projectId: PROJECT_ID }),
expect.objectContaining({ revision: 6, source: 'asset-command' }),
),
);
});
it('勾上连带删除时按 true 走同一分支', async () => {
const { invoke } = renderReplacementWorkbench({
referencedVersions: [
{
versionId: SOURCE_VERSION_ID,
projectRevision: 1,
createdAt: 1_700_000_000,
},
],
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '删除素材' }));
const dialog = await screen.findByRole('dialog', { name: '确认删除资源' });
fireEvent.click(
within(dialog).getByRole('checkbox', {
name: '把相关游戏版本一并删除',
}),
);
fireEvent.click(
within(dialog).getByRole('button', { name: '确认删除资源' }),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'delete_local_project_asset',
expect.objectContaining({
input: expect.objectContaining({ deleteReferencedVersions: true }),
}),
);
});
});
/**
* 入口判据与「编辑标签」同口径:只有 `manifestAssetId` 存在才渲染「删除素材」。
*
* 附件(`attachments`)没有 manifest 登记,删不了 —— 给它一个"点了报错"的按钮比不渲染更糟。
*
* 变异验证:把判据换成"只要选中就渲染",本用例必须失败。
*/
it('没有 manifest 身份的资源不渲染「删除素材」:与「编辑标签」同口径', async () => {
renderReplacementWorkbench({
attachments: [
{
fileName: '草稿.png',
mediaType: 'image/png',
localPath: 'uploads/draft.png',
status: 'imported',
},
],
});
fireEvent.click(await screen.findByRole('button', { name: '打开待归类' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="child"]'),
).not.toBeNull(),
);
act(() => {
observer?.triggerVisible();
});
fireEvent.click(await findResourceSelectButton('草稿.png'));
const toolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
expect(
within(toolbar).queryByRole('button', { name: '删除素材' }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '编辑标签' }),
).toBeNull();
// 同一条工具条仍在(只读动作不受 manifest 身份影响),证明不是"整条工具条没渲染"。
expect(
within(toolbar).getByRole('button', { name: '信息' }),
).not.toBeNull();
});
});