From 7fa61231aef0b59f2c928636345aaa605379623c Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 01:33:10 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E8=B5=84=E6=BA=90=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=E8=BF=87=E6=9C=9F=E6=96=AD=E8=A8=80=E8=B7=9F?= =?UTF-8?q?=E4=B8=8A=E6=96=B0=E5=90=88=E5=90=8C=EF=BC=8C=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=88=87=E6=8D=A2=E7=9A=84=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=B8=85=E7=90=86=E8=A1=8C=E4=B8=BA=E6=96=AD?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - appSurface > renders one body-first card system:把原先钉「全局只读 1 次」的断言收窄成「同一身份不因重复请求新增物理读取」——热预取之后先让 IntersectionObserver 再报一次可见、再打开一次详情,读取次数必须与快照一致;并先钉住快照非空且读取目标都是 assets/hero.png,避免空集恒真。60d8b8fbb 把热预取改成只喂当前栏目后,用例里每进出一次「角色与对象」都会重读该栏目的可见卡(该 commit 已明确接受这个代价),全局总次数会随进出次数漂移;「同一身份同时只读一次」是 PRD 里真实存在的合同,比总次数更精确,没有放宽。 - appSurface > keeps the resource preview version cache wired and cleared on project switch:60d8b8fbb 删掉了从未被写入的 resourcePreviewVersionByResourceId 死接线(只被 new Map() 初始化与清空,previewVersionByResourceId 恒为空串),断言守的 prop 与其清空语句都已不存在,按「四不写」删除这条形态断言;用例改名 clears the resource preview cache and cancels the old preview scope on project switch。 - 同用例把意图从源码正则换成可执行行为断言(原断言只证明接线存在,不证明缓存真被清):渲染 A 项目并等卡片预览落到 Blob URL 后切到 B 项目,断言 ① 旧卡片预览的 Blob URL 被撤销、② 旧 scope 的在途读取按旧 scopeId 取消、③ 同一资源以新项目身份与新 scopeId 重新读取。 - 变异验证在 %TEMP% 下的独立 worktree 沙箱里做(未改本工作树产品代码):去掉预览 Hook 里 loaded 早退 → 第二条用例失败(同身份读取 6→7);去掉 cancelLocalProjectResourcePreviewScope(previousScopeId) → 第三条 ② 失败;切项目不复用新 scopeId → ③ 失败;拆掉两条撤销 Blob URL 的路径 → ① 失败。 - 未改 uses one full-page canvas per resource section with dependency-only guide lines:沙箱实验证明只把预览 Hook 入参从 activePageResources 换回 canvasResources,该用例即整体通过。它守的「总览逐栏绘制各栏目真实卡片」是被 60d8b8fbb 意外收窄的(renderResourceBookCard 在 identityByResourceId 缺该资源时返回 null,而该 map 从该 commit 起只覆盖当前栏目;其 commit message 只声明了预取口径与重读代价,未声明总览不再绘制其它栏目卡片),属产品回归,已上报,不以改断言掩盖。 - 门禁:ai-game-creator-shell:typecheck exit 0;apps/ai-game-creator-shell/tests 1 failed / 1170 passed / 4 skipped(唯一失败即上面上报的回归;改前 3 failed / 1168 passed);src/components/image-editor 1385 passed;check:encoding 4374 文件;git diff --check 干净。 --- .../appSurface/project-development.suite.ts | 150 ++++++++++++++++-- 1 file changed, 138 insertions(+), 12 deletions(-) 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', () => {