测试骨架的历史切片按 limit 与锚点分页

- harness.ts 的 read_direct_project_history_slice 按生产口径从队尾取一屏、锚点条目不进窗口
- 按本屏最老一条算 firstItemId,收满一屏后再有条目才算 hasMore
- project-development.suite.ts 补一条用例:26 条历史只显示尾部 20 条,点「显示更早的对话」按锚点取回前 6 条后按钮消失
This commit is contained in:
2026-09-16 23:52:02 +08:00
parent 6a4cb2bc18
commit 64bd006981
2 changed files with 107 additions and 3 deletions
@@ -1023,10 +1023,33 @@ function createProjectSupervisorRuntimeHarness({
return { events };
}
if (command === 'read_direct_project_history_slice') {
// 生产口径:从文件尾反向取一屏可显示条目,锚点条目本身不进窗口;收满一屏之后
// 再见一条才算 `hasMore``firstItemId` 是本屏最老一条的 itemId。
const requestedLimit = Number(args?.limit ?? 20);
const limit = Math.min(
Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1),
200,
);
const beforeItemId =
typeof args?.beforeItemId === 'string' && args.beforeItemId
? args.beforeItemId
: null;
let end = directThreadHistoryItems.length;
if (beforeItemId) {
const anchorIndex = directThreadHistoryItems.findIndex(
(item) => String(item.itemId ?? '') === beforeItemId,
);
end =
anchorIndex >= 0 ? anchorIndex : directThreadHistoryItems.length;
}
const start = Math.max(0, end - limit);
const window = directThreadHistoryItems.slice(start, end);
const oldest = window[0];
return {
items: [...directThreadHistoryItems],
hasMore: false,
firstItemId: null,
items: [...window],
hasMore: start > 0,
firstItemId:
oldest && typeof oldest.itemId === 'string' ? oldest.itemId : null,
};
}
if (command === 'start_game_creator_supervisor_runtime_task') {
@@ -8342,6 +8342,87 @@ export function registerProjectSupervisorSurfaceTests() {
).toHaveLength(2);
});
it('loads the earlier direct history page from the first-item anchor', async () => {
// 分页锚点走的是首屏切片给出的 `firstItemId`:切片本身从队尾取,老的一屏靠锚点继续向前。
const projectPath = '/tmp/launcher-direct-history-pagination-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-direct-history-pagination-game',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
// 26 条 > 首屏 20 条:首屏只显示最后 20 条,前面 6 条要靠"显示更早"翻页取回。
supervisorHarness.setDirectThreadHistory(
Array.from({ length: 26 }, (_, index) => {
const label = `历史对话 ${String(index + 1).padStart(2, '0')}`;
return {
itemType: 'message',
itemId: `direct-codex:turn-${label}:user`,
role: 'user',
text: label,
at: 1000 + index,
};
}),
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_design_agent_runtime_mode') return null;
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-direct-history-pagination-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
expect(
await within(supervisorSurface).findByText('历史对话 26'),
).not.toBeNull();
// 首屏是尾部的 20 条:第 7 条起可见,更早的 6 条还没读进来。
expect(within(supervisorSurface).getByText('历史对话 07')).not.toBeNull();
expect(within(supervisorSurface).queryByText('历史对话 06')).toBeNull();
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
projectPath,
limit: 20,
});
fireEvent.click(screen.getByRole('button', { name: '显示更早的对话' }));
// 翻页按首屏最老一条的锚点向前取;取到文件头后按钮消失。
expect(
await within(supervisorSurface).findByText('历史对话 01'),
).not.toBeNull();
expect(within(supervisorSurface).getByText('历史对话 06')).not.toBeNull();
expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull();
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
projectPath,
beforeItemId: 'direct-codex:turn-历史对话 07:user',
limit: 20,
});
});
it('drains a notify that lands before the subscribe receipt so the running turn is not stranded', async () => {
// 真实竞态:`subscribe` 在 Rust 侧已经注册好 subscriber 并开始通知,但前端还没拿到
// subscriptionId。此时的通知不能白丢——拿到回执后必须补一次 consume,否则整个回合会卡在队列里。