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 无法创建符号链接时跳过依赖符号链接权限语义的测试。
1414 lines
51 KiB
TypeScript
1414 lines
51 KiB
TypeScript
/** @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();
|
||
});
|
||
});
|