AGC 资源画布两条过期断言跟上新合同,并补项目切换的预览缓存清理行为断言

- 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 干净。
This commit is contained in:
2026-09-11 01:33:10 +08:00
parent 1ac8cc3eca
commit 7fa61231ae
@@ -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<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: 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', () => {