Files
Genarrative/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx
T
suzmii 46788edbb8
Project CI / Repository checks (pull_request) Successful in 2m44s
Project CI / Frontend tests (pull_request) Successful in 3m37s
Project CI / Backend tests (pull_request) Successful in 7m5s
Project CI / Native shell tests (pull_request) Successful in 17m30s
修复「编辑素材标签」保存后关窗:点「添加」成功后面板保持打开,可连续添加
- 定位关窗来源:面板的 saveResourceClassification 只调 onSaved、从不调 onClose;真正卸载面板的是宿主 index.tsx 的 setResourceClassificationAssetId(null)(在 reloadManifestAfterAssetCommand 里,由 handleResourceClassificationSaved 触发)
- 在宿主层给 reloadManifestAfterAssetCommand 加 keepClassificationPanelOpen 开关:默认仍然收起,删除素材 / 版本替换 / 重命名的既有行为不变,只有标签保存显式传 true
- 标签保存后不再清 resourceClassificationAssetId:面板保持打开,新标签已落成 pill、输入框已清空,用户可以接着加下一个标签
- 关闭面板仍只走头部 ×;标签 pill 内的「删除标签」按钮与删除素材流程都不受影响
- 面板用例:添加后不得调用 onClose、面板仍在、新标签出现在已有标签列表、输入框清空;连续两次「添加」各自写盘且第二次带上累计标签、category 仍原样回传落盘值
- 工台用例(resourceVersionReplacement.test.tsx)补真宿主链路:经工具条「编辑标签」打开面板 → 添加 → 断言写入载荷与面板仍在 → 再添加一次 → 两次写入各自发生且面板依然打开(只测面板组件看不出宿主把面板卸载了)
2026-09-12 15:59:44 +08:00

990 lines
34 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 === 'update_local_project_resource_classification') {
const input = args?.input as
| { assetId?: string; category?: string; tags?: string[] }
| undefined;
return {
asset: {
id: input?.assetId,
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/legacy.png',
source: { kind: 'generated' as const },
category: input?.category,
tags: input?.tags,
},
committedProjectRevision: 6,
};
}
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: '图片工具栏' });
}
/** 标签写入的调用明细:`(command, args)`,用来断言"点了几次、每次写什么"。 */
function classificationWrites(invoke: { mock: { calls: unknown[][] } }) {
return invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_classification',
);
}
/**
* 资源卡预览用的 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();
});
/**
* 「添加」保存成功后「编辑素材标签」面板**必须保持打开**(连续添加),关窗只能走头部 ×。
*
* 关窗发生在宿主层:面板自己的 `saveResourceClassification` 只调 `onSaved`、不调 `onClose`
* 真正清掉 `resourceClassificationAssetId` 的是宿主的 `reloadManifestAfterAssetCommand`。
* 所以这条判据必须走真宿主链路才钉得住(只测面板组件看不出宿主把面板卸载了)。
*
* 变异验证:去掉 `handleResourceClassificationSaved` 上的 `keepClassificationPanelOpen`
* (即恢复"保存后关窗"),本用例在第一次「添加」后就会失败。
*/
it('标签保存成功后编辑素材标签面板保持打开,可以接着连续添加', async () => {
const { invoke } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' }));
const dialog = await screen.findByRole('dialog', {
name: '编辑素材标签',
});
const tagField = () =>
screen.getByPlaceholderText('新增标签,多个用逗号分隔');
// 第一次添加:草稿只是填进去,刻意不按回车也不失焦 —— 「添加」必须自己把尾巴并进来。
fireEvent.change(tagField(), { target: { value: '主角' } });
fireEvent.click(within(dialog).getByRole('button', { name: '添加' }));
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1));
expect(classificationWrites(invoke)[0]?.[1]).toEqual({
input: {
projectPath: PROJECT_PATH,
expectedProjectId: PROJECT_ID,
expectedProjectRevision: EXPECTED_REVISION,
assetId: 'asset-legacy',
// 分类不由这个面板编辑:回传的就是 manifest 里的落盘原值。
category: 'character',
tags: ['主角'],
},
});
// 面板仍在(保存不得关窗),新标签已落成 pill,输入框已清空。
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
expect(
within(dialog).getByRole('list', { name: '已有标签' }).textContent,
).toContain('主角');
expect((tagField() as HTMLInputElement).value).toBe('');
// 第二次添加:各自保存一次,且累计标签一起去写;面板依然开着。
fireEvent.change(tagField(), { target: { value: '待定稿' } });
fireEvent.click(within(dialog).getByRole('button', { name: '添加' }));
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(2));
expect(
(
classificationWrites(invoke)[1]?.[1] as {
input: { tags: string[]; category: string };
}
).input.tags,
).toEqual(['主角', '待定稿']);
expect(
(
classificationWrites(invoke)[1]?.[1] as {
input: { tags: string[]; category: string };
}
).input.category,
).toBe('character');
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
});
});