Files
Genarrative/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx
lhk229 86c92280cf
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
修正 Windows 平台测试兼容性
按当前发送按钮和画布工具条结构更新契约。

Windows 无法创建符号链接时跳过依赖符号链接权限语义的测试。
2026-09-16 07:09:12 +08:00

1414 lines
51 KiB
TypeScript
Raw Permalink 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,
getResourceSelectButton,
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>;
/** 写入桩;吃 IPC 入参,便于用例按"这次换的是谁"返回对应的 `replacement` 记录。 */
replacementWrite?: (args?: Record<string, unknown>) => 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(args)
: 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',
);
}
/** 替换写入的调用明细:合法点选必须恰好一次,非法点选必须零次。 */
function replacementWrites(invoke: { mock: { calls: unknown[][] } }) {
return invoke.mock.calls.filter(
([command]) => command === 'replace_local_project_version_resource',
);
}
/** 点选态的提示条;不在点选态时为 `null`。 */
function pickHint() {
return document.querySelector<HTMLElement>('.game-resource-canvas-pick-hint');
}
/**
* 在点选态下点一张资源卡:按下(这一步就是点选)+ 真实浏览器里紧随其后的那一次 `click`。
*
* 之所以把 `click` 也补上:点选态要求"卡片单击不参与选中",而那正是靠**消费掉紧随 pointerdown
* 的那一次 click** 实现的 —— 不补这一下,用例就漏掉了抑制残留这条最可能的回归。
*/
async function pickResourceCard(label: string) {
const selectButton = await findResourceSelectButton(label);
const card = selectButton.closest('.game-resource-card');
if (!card) throw new Error(`资源卡未渲染:${label}`);
fireEvent.pointerDown(selectButton, {
pointerId: 7,
button: 0,
clientX: 40,
clientY: 50,
});
fireEvent.pointerUp(selectButton, { pointerId: 7, clientX: 40, clientY: 50 });
fireEvent.click(card);
return card;
}
/** 资源卡的卡面元素;找不到说明这张卡没渲染。 */
function resourceCardOf(label: string) {
const card = getResourceSelectButton(label).closest('.game-resource-card');
if (!card) throw new Error(`资源卡未渲染:${label}`);
return card;
}
/** 切栏目页:点选会话要跨栏目活着,所以用例走用户那条路(收起资源 → 打开目标栏目)。 */
async function openResourceBookCategory(label: string) {
if (document.querySelector('[data-resource-book-view="child"]')) {
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
}
fireEvent.click(await screen.findByRole('button', { name: `打开${label}` }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="child"]'),
).not.toBeNull(),
);
}
/**
* 资源卡预览用的 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('删除素材')).toBeGreaterThan(
toolbarLabels.indexOf('导出'),
);
// 与前面隔开:紧邻的前一个兄弟就是共享工具条那套分隔线,不是新造的分隔符。
const deleteButton = within(toolbar).getByRole('button', {
name: '删除素材',
});
const divider = deleteButton.previousElementSibling;
expect(divider?.getAttribute('class')).toMatch(
/(?:image-canvas-editor__floating-toolbar-divider|genarrative-image-canvas__chrome-button)/,
);
// 点删除先读引用信息(不直接删),再开二次确认面板。
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();
});
/**
* 替换弹窗的「点选替换」入口(PRD §5.3):关掉弹窗、改在画布上点目标素材。
*
* 进入点选必须**卸载弹窗**:弹窗外壳是全屏遮罩,留着它画布上的卡根本点不到。
* 入口本身不改数据:候选读取是打开弹窗时的事,这里一次替换写入都不该有。
*/
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: '选择替换素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull();
const hint = pickHint();
if (!hint) throw new Error('点选提示条未渲染');
expect(hint.textContent).toContain('在画布上点选要替换成的素材');
// 空白处点击不退出这件事必须写在提示条上:用户按直觉点空白才不会以为点坏了。
expect(hint.textContent).toContain('点击空白处不会退出');
expect(within(hint).getByRole('button', { name: '取消' })).not.toBeNull();
expect(replacementWrites(invoke)).toHaveLength(0);
});
/**
* 点选态下合法目标直接提交:写入路径与弹窗确认**完全同一条**(同一个 `confirm` 函数),
* 载荷必须逐字一致,且成功后自动退出点选态。
*
* 同时钉住"一次性抑制"的收尾:退出点选后点**同一张**刚被点选过的卡,单击语义必须完好
* (换选中)。抑制残留就会在这一步被吞掉 —— 这是最容易被写错的一处。
*/
it('点选替换:点中合法候选提交一次且载荷一致,成功后自动退出点选态', async () => {
const { invoke, onManifestChange } = renderReplacementWorkbench();
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('button', { name: '点选替换' }));
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('final.png');
await waitFor(() => expect(replacementWrites(invoke)).toHaveLength(1));
expect(replacementWrites(invoke)[0]?.[1]).toEqual({
input: {
projectPath: PROJECT_PATH,
expectedProjectId: PROJECT_ID,
expectedProjectRevision: EXPECTED_REVISION,
sourceVersionId: SOURCE_VERSION_ID,
sourceResourceId: 'asset-legacy',
replacementResourceId: 'asset-final',
},
});
await waitFor(() =>
expect(onManifestChange).toHaveBeenCalledWith(
PROJECT_PATH,
expect.objectContaining({ projectId: PROJECT_ID }),
expect.objectContaining({ revision: 6, source: 'asset-command' }),
),
);
// 点选那一下自带的 click 不得换选中:源素材仍是选中的那一个。
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
await waitFor(() => expect(pickHint()).toBeNull());
// 退出点选后单击语义不变:点同一张卡照常选中自己,抑制没有残留。
const finalCard = (await findResourceSelectButton('final.png')).closest(
'.game-resource-card',
);
if (!finalCard) throw new Error('资源卡未渲染:final.png');
fireEvent.click(finalCard);
await waitFor(() =>
expect(
getResourceSelectButton('final.png').getAttribute('aria-pressed'),
).toBe('true'),
);
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('false');
});
/**
* 点选态下点非法目标:说明原因、**留在**点选态、零写入,且点空白既不退出也不清选中。
*
* 四种非法各点一次:源素材本身、不在权威候选里、分类不同的候选(跨栏目点)、
* manifest 里没有的身份(未登记附件)。判据全部来自同一条 `resolveResourceReplacementPick`
* 文案与候选弹窗同源。
*/
it('点选替换:非法目标不提交、留在点选态并说明原因,点空白不退出', async () => {
const { invoke } = renderReplacementWorkbench({
attachments: [
{
fileName: '草稿.png',
mediaType: 'image/png',
localPath: 'uploads/draft.png',
status: 'imported',
},
],
});
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('button', { name: '点选替换' }));
// ① 点的就是源素材本身。
await pickResourceCard('legacy.png');
expect(screen.getByRole('alert').textContent).toBe('替换素材与源素材相同');
expect(pickHint()).not.toBeNull();
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
// 空白处左键:既不退出点选,也不清画布选中(清选中会连带收起选中工具条)。
const canvas = screen.getByLabelText('资源依赖视图');
// 指针捕获在 jsdom 里没有实现;这里补桩是为了让"这条按下到底走到哪一步"只由行为断言
// 判定,而不是被一个缺失的 DOM API 提前打断。
Object.defineProperties(canvas, {
setPointerCapture: { configurable: true, value: vi.fn() },
hasPointerCapture: { configurable: true, value: vi.fn(() => true) },
releasePointerCapture: { configurable: true, value: vi.fn() },
});
fireEvent.pointerDown(canvas, {
pointerId: 9,
button: 0,
clientX: 5,
clientY: 5,
});
expect(pickHint()).not.toBeNull();
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
// ② 不在权威候选里的素材:候选读取之后才登记的资源不属于这次替换目标,一律不放行
// (判据是"候选里有没有",不在前端另算一遍兼容性)。
await pickResourceCard('late.png');
expect(screen.getByRole('alert').textContent).toBe(
'替换素材未登记或已被删除',
);
expect(pickHint()).not.toBeNull();
// ③ 分类不同的候选:目标在别的栏目,点选会话要跨栏目活着。
await openResourceBookCategory('场景与环境');
expect(pickHint()).not.toBeNull();
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('scene.png');
expect(screen.getByRole('alert').textContent).toBe(
'替换素材不兼容:分类不同',
);
expect(pickHint()).not.toBeNull();
// 非法目标同样不换选中:点选态里卡片单击只用来点选。
expect(
getResourceSelectButton('scene.png').getAttribute('aria-pressed'),
).toBe('false');
// ④ 未登记资源:附件没有 manifest 身份,永远不是合法替换目标。
await openResourceBookCategory('待归类');
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('草稿.png');
expect(screen.getByRole('alert').textContent).toBe(
'替换素材未登记或已被删除',
);
expect(pickHint()).not.toBeNull();
expect(replacementWrites(invoke)).toHaveLength(0);
});
/**
* 点选态的 Esc 只退出点选:资源画布自己的 Esc 挂在 window 上(清画布焦点 = 清选中 +
* 收浮层),提示条这条线必须在 document 上截断它 —— 否则用户按一次 Esc 会连正在替换的
* 选中一起丢。
*
* 判据分两层:window 上的监听器收不到这次 Escape(截断生效),且选中工具条仍在。
* 变异验证:去掉 `event.stopPropagation()`,本用例必须失败。
*/
it('点选替换:Esc 退出点选态且不清画布选中', async () => {
renderReplacementWorkbench();
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('button', { name: '点选替换' }));
expect(pickHint()).not.toBeNull();
const windowEsc = vi.fn();
window.addEventListener('keydown', windowEsc);
try {
fireEvent.keyDown(document, { key: 'Escape' });
} finally {
window.removeEventListener('keydown', windowEsc);
}
expect(windowEsc).not.toHaveBeenCalled();
expect(pickHint()).toBeNull();
// 选中没有跟着被清掉:源素材仍是选中的那一个。
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
});
/**
* 替换血缘标注(PRD §5.3 / §7.8):替换成功后,被替换掉的源素材(它已经失去「当前版本」
* 光环,光环不再能说明关系)与替换素材必须互相标明关系,且都给出稳定 id 判据。
*
* 判据分两层:DOM 属性值=两个 manifest 资产 id(不是显示名),卡面文字=可读关系。
* 未参与替换的资源卡两个属性都不带、也没有血缘角标。
*
* 变异验证:只把血缘记进宿主状态、不接到卡面,本用例必须失败。
*/
it('替换成功后两张卡互相标注血缘:稳定 id 判据 + 可读文案', async () => {
const { invoke } = renderReplacementWorkbench();
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(replacementWrites(invoke)).toHaveLength(1));
await waitFor(() => {
// 源素材 A:标「已被 B 替换」,判据值是 B 的稳定 id。
const sourceCard = resourceCardOf('legacy.png');
expect(sourceCard.getAttribute('data-resource-replaced-by')).toBe(
'asset-final',
);
expect(sourceCard.textContent).toContain('已被 final.png 替换');
expect(
sourceCard.querySelector('[data-resource-lineage="replaced-by"]'),
).not.toBeNull();
// 替换素材 B:标「替换自 A」,判据值是 A 的稳定 id。
const replacementCard = resourceCardOf('final.png');
expect(replacementCard.getAttribute('data-resource-replacement-of')).toBe(
'asset-legacy',
);
expect(replacementCard.textContent).toContain('替换自 legacy.png');
expect(
replacementCard.querySelector(
'[data-resource-lineage="replacement-of"]',
),
).not.toBeNull();
});
// 没参与替换的资源卡:两个属性都不带,也没有血缘角标。
const untouchedCard = resourceCardOf('late.png');
expect(untouchedCard.getAttribute('data-resource-replaced-by')).toBeNull();
expect(
untouchedCard.getAttribute('data-resource-replacement-of'),
).toBeNull();
expect(
untouchedCard.querySelector('.game-resource-card-lineage-badge'),
).toBeNull();
});
/**
* 同会话内再次替换:血缘按最新一次覆盖,只保留当前有效的那一对。
*
* 第二次把刚上位的 `final.png` 换成 `final.webp`:旧关系(legacy → final)必须整条消失
* (legacy 卡上不再有任何替换标注),新关系(final → final.webp)落到卡上。
*
* 变异验证:把血缘累加而不是覆盖(例如存成数组),旧标注会留在 legacy 卡上,本用例失败。
*/
it('再次替换覆盖上一条血缘:旧标注消失,只保留当前有效关系', async () => {
const { invoke } = renderReplacementWorkbench({
// 写入桩按本次请求回填血缘,才能造出"第二次换了别人"这一场景。
replacementWrite: async (args) => {
const input = (args?.input ?? {}) as {
sourceResourceId?: string;
replacementResourceId?: string;
};
return {
versionId: SOURCE_VERSION_ID,
committedProjectRevision: 6,
replacement: {
versionId: SOURCE_VERSION_ID,
sourceResourceId: input.sourceResourceId,
replacementResourceId: input.replacementResourceId,
compatibility: {
categoryEqual: true,
subtypeEqual: true,
sizeSpecEqual: true,
},
warning: null,
},
};
},
});
// 第一次:legacy → final。
const legacyToolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(
within(legacyToolbar).getByRole('button', { name: '替换素材' }),
);
let 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(
resourceCardOf('legacy.png').getAttribute('data-resource-replaced-by'),
).toBe('asset-final'),
);
// 第二次:final → final.webp(同一个工作台会话内)。
const finalToolbar = await selectCardAndOpenToolbar('final.png');
fireEvent.click(
within(finalToolbar).getByRole('button', { name: '替换素材' }),
);
dialog = await screen.findByRole('dialog', { name: '选择替换素材' });
fireEvent.click(
within(dialog).getByRole('option', { name: '选择替换素材final.webp' }),
);
fireEvent.click(
within(dialog).getByRole('button', { name: '确认选择替换素材' }),
);
await waitFor(() => expect(replacementWrites(invoke)).toHaveLength(2));
await waitFor(() => {
// 新关系:final 被 final.webp 替换。
expect(
resourceCardOf('final.png').getAttribute('data-resource-replaced-by'),
).toBe('asset-webp');
expect(resourceCardOf('final.png').textContent).toContain(
'已被 final.webp 替换',
);
expect(
resourceCardOf('final.webp').getAttribute(
'data-resource-replacement-of',
),
).toBe('asset-final');
});
// 旧关系整条消失:legacy 卡上不再有任何替换标注。
const legacyCard = resourceCardOf('legacy.png');
expect(legacyCard.getAttribute('data-resource-replaced-by')).toBeNull();
expect(legacyCard.getAttribute('data-resource-replacement-of')).toBeNull();
expect(
legacyCard.querySelector('.game-resource-card-lineage-badge'),
).toBeNull();
});
});