diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 38c3d28b3..d6f7cc924 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -2439,6 +2439,21 @@ export function registerProjectWorkbenchFoundationTests() { await showResourcePage('角色与对象'); heroDetailButton = await findResourceSelectButton('hero.png'); + // 同一身份去重:热预取已经把 hero.png 读过一次,再让 IntersectionObserver 报一次 + // 可见、再打开一次详情,都不得新增物理读取。 + // 不能钉全局总次数:60d8b8fbb 之后热预取只喂当前栏目,用例里每进出一次「角色与对象」 + // 都会重读该栏目的可见卡(该 commit 已明确接受这个代价),全局次数会随进出漂移, + // 而「同一身份同时只读一次」这条合同与进出次数无关。 + const heroImageReadsBeforeRepeat = invoke.mock.calls.filter( + ([command]) => command === 'read_local_project_image_preview', + ); + expect(heroImageReadsBeforeRepeat.length).toBeGreaterThan(0); + expect( + heroImageReadsBeforeRepeat.every( + ([, args]) => args?.relativePath === 'assets/hero.png', + ), + ).toBe(true); + act(() => observer.triggerVisible()); fireEvent.click(heroDetailButton); const heroToolbar = await screen.findByRole('toolbar', { name: '图片工具栏', @@ -2451,7 +2466,7 @@ export function registerProjectWorkbenchFoundationTests() { invoke.mock.calls.filter( ([command]) => command === 'read_local_project_image_preview', ), - ).toHaveLength(1); + ).toHaveLength(heroImageReadsBeforeRepeat.length); fireEvent.click(screen.getByRole('button', { name: '按类型' })); expect(observer.observedCount()).toBeGreaterThanOrEqual(2); @@ -4869,21 +4884,132 @@ export function registerProjectWorkbenchFoundationTests() { ); }); - it('keeps the resource preview version cache wired and cleared on project switch', () => { - const projectDevelopmentSource = readFileSync( - resolve( - process.cwd(), - 'apps/ai-game-creator-shell/src/view/project-development/index.tsx', - ), - 'utf8', + it('clears the resource preview cache and cancels the old preview scope on project switch', async () => { + // 60d8b8fbb 删掉了从未被写入的 `resourcePreviewVersionByResourceId` 死接线,原先钉 + // 那条 prop 与它的清空语句的断言随之取消;用例真正要守的意图不变——项目切换必须把 + // 预览缓存和旧 scope 的在途读取一起清掉,所以这里改成直接观察外部可见行为。 + const manifest = createGameCreationAppManifest( + 'workbench-preview-scope-switch', + '预览缓存清理项目', + ); + manifest.assets = [ + { + id: 'hero-image', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/hero.png', + source: { kind: 'generated', taskId: 'art-asset-plan' }, + }, + ]; + 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: manifest.projectId, + 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: manifest.projectId, + 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 === 'cancel_local_project_resource_preview_scope') { + return null; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const viewProps = { + projectName: manifest.name, + projectPath: '/tmp/workbench-preview-scope-switch-a', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }; + const rendered = render( + React.createElement(ProjectDevelopmentView, viewProps), ); - expect(projectDevelopmentSource).toMatch( - /previewVersionByResourceId:\s*resourcePreviewVersionByResourceId/, + await openResourceBookCategory('角色与对象'); + await waitFor(() => + expect( + document + .querySelector('.game-resource-card-visual > img') + ?.getAttribute('src'), + ).toBe('blob:mock-attachment-preview'), ); - expect(projectDevelopmentSource).toMatch( - /setResourcePreviewVersionByResourceId\(new Map\(\)\)/, + const firstPreviewCall = invoke.mock.calls.find( + ([command]) => command === 'read_local_project_image_preview', ); + const firstScopeId = firstPreviewCall?.[1]?.scopeId; + expect(typeof firstScopeId).toBe('string'); + // 只关心切换之后发生了什么,先把切换前的撤销调用清掉。 + const revokeObjectUrl = URL.revokeObjectURL as unknown as ReturnType< + typeof vi.fn + >; + revokeObjectUrl.mockClear(); + + rendered.rerender( + React.createElement(ProjectDevelopmentView, { + ...viewProps, + projectPath: '/tmp/workbench-preview-scope-switch-b', + }), + ); + + // ① 旧项目的预览缓存必须清掉:卡片预览的 Blob URL 要撤销,不能留在 WebView 里。 + await waitFor(() => + expect(revokeObjectUrl).toHaveBeenCalledWith( + 'blob:mock-attachment-preview', + ), + ); + // ② 旧项目在途的读取要按旧 scopeId 取消,结果不得落进新项目的卡片。 + await waitFor(() => + expect(invoke).toHaveBeenCalledWith( + 'cancel_local_project_resource_preview_scope', + { scopeId: firstScopeId }, + ), + ); + // ③ 新项目不复用旧缓存:同一资源以新项目身份、新 scopeId 重新读取。 + await waitFor(() => { + const switchedRead = invoke.mock.calls.find( + ([command, args]) => + command === 'read_local_project_image_preview' && + args?.projectPath === '/tmp/workbench-preview-scope-switch-b', + ); + expect(switchedRead).toBeDefined(); + expect(switchedRead?.[1]?.scopeId).not.toBe(firstScopeId); + }); }); it('keeps resource sort tab keyboard focus inside the clipped segmented control', () => {