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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user