Files
Genarrative/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx
T
suzmii 5467ed6d7b 自查修正:拖到对话落点逐值比较、替换面板锚进画布容器并与任务面板互斥
- 拖动落点改逐值比较 sameResourceCardReferenceDrop(条数 + 对话栏矩形四值),指针在对话栏上移动不再每帧 setState、不再每帧重渲染工作台。
- 替换面板从 stage 顶层移进画布容器 .game-resource-book-manager(本身 position: relative),top 3.2rem 排在任务开关下方;相对 stage 写死的偏移会在工具条换行时压上去,与刚修的任务开关是同一类隐患。
- 打开替换会话时收起「生成任务」面板:画布右上角同一时刻只留一块浮层(任务照旧在账本里推进)。
- 面板上方补一行「替换源素材:<显示名>」:非模态之后画布上的源素材光环会被空白点击清掉,源身份得在面板这一侧留一份可读说法。
- 测试:替换用例新增「面板锚在画布容器里、写明源素材、并与任务面板互斥」(20 passed)、落点模型新增逐值比较用例(6 passed)。
- 文档:decision-log 新增本批自查条目、待办文档补登记、验收用例 C9 注脚改为 nonModal 形态。
2026-09-21 06:21:19 +08:00

1563 lines
57 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,
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;
}
// 「生成任务」面板与未完成编辑账本(本文件的互斥用例会开一次任务面板)。
if (command === 'list_local_project_asset_generations') {
return [];
}
if (command === 'list_pending_local_project_resource_edits') {
return [];
}
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 };
}
function toolbarAction(toolbar: HTMLElement, name: string) {
const visible = within(toolbar).queryByRole('button', { name });
if (visible) return visible;
fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' }));
return within(screen.getByRole('group', { name: '更多操作' })).getByRole(
'button',
{ name },
);
}
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',
);
}
/**
* 在替换面板开着时点一张资源卡:按下(这一步就是点选)+ 真实浏览器里紧随其后的那一次 `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('更多浮层滚轮不平移画布,Escape 只收菜单且换选不残留', async () => {
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' }));
const menu = screen.getByRole('group', { name: '更多操作' });
const viewport = () =>
document
.querySelector('[data-resource-viewport]')
?.getAttribute('data-resource-viewport');
const before = viewport();
expect(before).toBeTruthy();
const wheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
});
act(() => {
menu.dispatchEvent(wheel);
});
expect(wheel.defaultPrevented).toBe(false);
expect(viewport()).toBe(before);
fireEvent.keyDown(document.body, { key: 'Escape' });
expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull();
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBe(toolbar);
fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' }));
fireEvent.click(await findResourceSelectButton('late.png'));
expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull();
const scene = document.querySelector('.game-resource-book-scene')!;
act(() => {
scene.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
clientX: 90,
clientY: 70,
}),
);
});
expect(viewport()).not.toBe(before);
});
it('信息从未选中卡打开并跟随资源身份,普通换选会关闭', async () => {
renderReplacementWorkbench();
await selectCardAndOpenToolbar('legacy.png');
const lateInfo = screen.getByRole('button', {
name: '查看late.png资源信息',
});
fireEvent.pointerDown(lateInfo, { button: 0 });
fireEvent.click(lateInfo);
const panel = screen.getByRole('dialog', { name: '资源信息' });
expect(within(panel).getByText('late.png')).toBeTruthy();
expect(lateInfo.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(
screen.getByRole('button', { name: '查看legacy.png资源信息' }),
);
const switched = screen.getByRole('dialog', { name: '资源信息' });
expect(within(switched).getByText('legacy.png')).toBeTruthy();
expect(within(switched).queryByText('late.png')).toBeNull();
fireEvent.click(await findResourceSelectButton('late.png'));
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
});
it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => {
const { invoke } = renderReplacementWorkbench();
// 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。
const lateToolbar = await selectCardAndOpenToolbar('late.png');
fireEvent.mouseEnter(
within(lateToolbar).getByRole('button', { name: '更多' }),
);
expect(screen.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(toolbarAction(sourceToolbar, '替换素材')).not.toBeNull();
});
it('从入口一路走到写入:候选弹窗禁用硬门禁项、给出格式提示、直接替换且不产生新版本', async () => {
const { invoke, onActiveVersionChange, onPlay, onManifestChange } =
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(toolbarAction(toolbar, '替换素材'));
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(toolbarAction(toolbar, '替换素材'));
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(toolbarAction(toolbar, '替换素材'));
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(toolbarAction(toolbar, '替换素材'));
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(toolbarAction(toolbar, '替换素材'));
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 deleteButton = toolbarAction(toolbar, '删除素材');
const toolbarLabels = [
...within(toolbar).getAllByRole('button'),
...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole(
'button',
),
].map((button) => button.getAttribute('aria-label') ?? '');
// 末位:在最后一个非破坏性动作(替换素材)之后、共享导出按钮之前。
expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan(
toolbarLabels.indexOf('替换素材'),
);
expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan(
toolbarLabels.indexOf('导出'),
);
// 与前面隔开:紧邻的前一个兄弟就是共享工具条那套分隔线,不是新造的分隔符。
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(toolbarAction(toolbar, '删除素材'));
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(
screen.getByRole('button', { name: '查看草稿.png资源信息' }),
).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(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
// 非模态判据:没有全屏遮罩,面板自己是画布上的浮层(否则画布上的卡根本点不到)。
expect(dialog.className).toContain(
'image-canvas-editor__project-asset-picker--floating',
);
expect(document.querySelector('.platform-overlay')).toBeNull();
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('final.png');
const option = within(dialog).getByRole('option', {
name: '选择替换素材final.png',
});
await waitFor(() =>
expect(option.getAttribute('aria-selected')).toBe('true'),
);
// 点选只改面板里的当前选择:写入还是得等「确认」。
expect(replacementWrites(invoke)).toHaveLength(0);
// 点选那一下自带的 click 不得换画布选中:源素材仍是选中的那一个。
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
});
/**
* 在画布上点目标素材只同步「当前选择」这一项:面板里的搜索词与分类筛选不能被重置 —— 用户
* 常常是先筛到一半、才想起目标就在画布上(这正是这次的入口动机)。同步点由宿主的显式序号给
* `initialSelectionRevision`),不是「props 变了就重置」。
*/
it('画布点选同步当前选择,但不清掉面板里的搜索与分类', async () => {
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
const search = within(dialog).getByLabelText('搜索参考图素材');
fireEvent.change(search, { target: { value: 'final' } });
expect((search as HTMLInputElement).value).toBe('final');
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('final.png');
await waitFor(() =>
expect(
within(dialog)
.getByRole('option', { name: '选择替换素材final.png' })
.getAttribute('aria-selected'),
).toBe('true'),
);
// 搜索词还在:同步选择没有顺手把面板筛回「全部 / 空关键词」。
expect(
(within(dialog).getByLabelText('搜索参考图素材') as HTMLInputElement)
.value,
).toBe('final');
});
/**
* 面板锚在**画布容器**里(不是 stage 顶层):相对 stage 写死的偏移会在工具条换行变高时压上去
* —— 与任务开关同一类问题。同时钉住「同一个角只留一块浮层」:打开替换面板会收起任务面板
* (任务本身仍在账本里推进),并显示这次替换的源素材名(非模态之后源身份不再只靠画布光环)。
*/
it('面板锚在画布容器里、写明源素材,并与任务面板互斥', async () => {
renderReplacementWorkbench();
// 先开「生成任务」面板:替换入口与它抢同一个角。
fireEvent.click(screen.getByRole('button', { name: '生成任务' }));
await screen.findByRole('region', { name: '生成任务' });
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
const anchor = dialog.closest('.game-resource-replacement-panel');
expect(anchor).not.toBeNull();
expect(
document
.querySelector('.game-resource-book-manager')
?.contains(anchor as Element),
).toBe(true);
expect(
within(anchor as HTMLElement).getByText(/替换源素材:legacy/u),
).not.toBeNull();
// 同一个角只留一块浮层:任务面板被收起。
await waitFor(() =>
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(),
);
});
/**
* 面板里按下「确认」才写入:写入路径与旧的弹窗确认**完全同一条**(同一个 `confirm` 函数),
* 载荷必须逐字一致,成功后关闭面板并标注血缘。
*
* 同时钉住"一次性抑制"的收尾:关闭面板后点**同一张**刚被点选过的卡,单击语义必须完好
* (换选中)。抑制残留就会在这一步被吞掉 —— 这是最容易被写错的一处。
*/
it('面板里确认才写入:载荷一致、成功后关闭面板并标注血缘', async () => {
const { invoke, onManifestChange } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('final.png');
await waitFor(() =>
expect(
within(dialog)
.getByRole('option', { name: '选择替换素材final.png' })
.getAttribute('aria-selected'),
).toBe('true'),
);
fireEvent.click(
within(dialog).getByRole('button', { name: '确认选择替换素材' }),
);
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' }),
),
);
// 写入成功即结束这次会话:面板收掉,画布选中不动。
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(),
);
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
// 会话结束后单击语义不变:点同一张卡照常选中自己,抑制没有残留。
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(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
const reason = () => within(dialog).getByRole('alert').textContent;
// ① 点的就是源素材本身。
await pickResourceCard('legacy.png');
await waitFor(() => expect(reason()).toBe('替换素材与源素材相同'));
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(
screen.queryByRole('dialog', { name: '选择替换素材' }),
).not.toBeNull();
// ② 不在权威候选里的素材:候选读取之后才登记的资源不属于这次替换目标,一律不放行
// (判据是"候选里有没有",不在前端另算一遍兼容性)。
await pickResourceCard('late.png');
await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除'));
// ③ 分类不同的候选:目标在别的栏目,替换会话要跨栏目活着。
await openResourceBookCategory('场景与环境');
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('scene.png');
await waitFor(() => expect(reason()).toBe('替换素材不兼容:分类不同'));
// 非法目标同样不换选中:面板开着时卡片单击只用来点选。
expect(
getResourceSelectButton('scene.png').getAttribute('aria-pressed'),
).toBe('false');
// ④ 未登记资源:附件没有 manifest 身份,永远不是合法替换目标。
await openResourceBookCategory('待归类');
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('草稿.png');
await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除'));
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(toolbarAction(toolbar, '替换素材'));
await screen.findByRole('dialog', { name: '选择替换素材' });
const windowEsc = vi.fn();
window.addEventListener('keydown', windowEsc);
try {
fireEvent.keyDown(document, { key: 'Escape' });
} finally {
window.removeEventListener('keydown', windowEsc);
}
expect(windowEsc).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).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(toolbarAction(toolbar, '替换素材'));
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(toolbarAction(legacyToolbar, '替换素材'));
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(toolbarAction(finalToolbar, '替换素材'));
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();
});
});