46788edbb8
- 定位关窗来源:面板的 saveResourceClassification 只调 onSaved、从不调 onClose;真正卸载面板的是宿主 index.tsx 的 setResourceClassificationAssetId(null)(在 reloadManifestAfterAssetCommand 里,由 handleResourceClassificationSaved 触发) - 在宿主层给 reloadManifestAfterAssetCommand 加 keepClassificationPanelOpen 开关:默认仍然收起,删除素材 / 版本替换 / 重命名的既有行为不变,只有标签保存显式传 true - 标签保存后不再清 resourceClassificationAssetId:面板保持打开,新标签已落成 pill、输入框已清空,用户可以接着加下一个标签 - 关闭面板仍只走头部 ×;标签 pill 内的「删除标签」按钮与删除素材流程都不受影响 - 面板用例:添加后不得调用 onClose、面板仍在、新标签出现在已有标签列表、输入框清空;连续两次「添加」各自写盘且第二次带上累计标签、category 仍原样回传落盘值 - 工台用例(resourceVersionReplacement.test.tsx)补真宿主链路:经工具条「编辑标签」打开面板 → 添加 → 断言写入载荷与面板仍在 → 再添加一次 → 两次写入各自发生且面板依然打开(只测面板组件看不出宿主把面板卸载了)
990 lines
34 KiB
TypeScript
990 lines
34 KiB
TypeScript
/** @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();
|
||
});
|
||
});
|