/** @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) { 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; /** 写入桩;吃 IPC 入参,便于用例按"这次换的是谁"返回对应的 `replacement` 记录。 */ replacementWrite?: (args?: Record) => Promise; /** 未被 manifest 登记的附件:用来验证「只有 manifest 资产才有删除素材入口」这一判据。 */ attachments?: Array<{ fileName: string; mediaType: string; localPath: string; status: 'imported' | 'failed'; }>; /** 引用这个素材的游戏版本;非空时确认面板要列出它们并给出连带删除勾选。 */ referencedVersions?: Array<{ versionId: string; projectRevision: number; createdAt: number; }>; deletion?: () => Promise; }; 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) => { 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 }; } 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', ); } /** 点选态的提示条;不在点选态时为 `null`。 */ function pickHint() { return document.querySelector('.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; observer: IntersectionObserver; }> = []; class ResourceCardIntersectionObserver { readonly root = null; readonly rootMargin = '160px'; readonly thresholds = [0]; readonly observed = new Set(); 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`,候选卡里也没有 ``)。 */ 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: '选择替换素材', }); 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(toolbarAction(toolbar, '替换素材')); 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(toolbarAction(toolbar, '替换素材')); 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(toolbarAction(toolbar, '替换素材')); 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(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(); }); });