Files
Genarrative/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts
k88936 30c377d0f0
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m21s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m51s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m3s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 5m54s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m28s
Project CI / AI game creator shell Rust crates (push) Successful in 1m53s
Project CI / Repository checks (push) Successful in 5m33s
Project CI / Frontend tests (push) Successful in 7m20s
Project CI / Native shell tests (push) Successful in 8m29s
Project CI / Backend tests (push) Successful in 8m57s
Project CI / AI game creator shell web tests (push) Successful in 3m16s
保留唯一的thread manager作为direct project的状态来源 (#384)
说明: 在把工作交给段哥前还没有实现direct project聊天页面的迁移, 导致在 #375 里用很复杂的实现又做了一套事件流, 实测还有会话丢失的bug, 我在这里把数据获取的部分迁移到 #367 上

---------

Co-authored-by: 孔令弘 <ink29535@proton.me>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/384
Reviewed-by: 孔令弘 <ink29535@proton.me>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-18 02:13:50 +08:00

254 lines
8.1 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../src/app/constants';
import { readDirectHistoryPages } from '../src/features/project-workspace/directHistoryPaging';
import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat';
import {
emptyDirectThreadChatState,
mergeDirectHistoryItems,
selectDirectChatEntries,
} from '../src/features/project-workspace/directThreadChat';
import type {
DirectThreadHistorySlice,
DirectThreadItem,
} from '../src/features/project-workspace/directThreadEvents';
function userItem(itemId: string): DirectThreadItem {
return { itemType: 'message', itemId, role: 'user', text: itemId, at: 1000 };
}
function assistantItem(itemId: string): DirectThreadItem {
return {
itemType: 'message',
itemId,
role: 'assistant',
text: `答复 ${itemId}`,
at: 2000,
};
}
function toolItem(itemId: string): DirectThreadItem {
return {
itemType: 'function_call',
itemId,
name: 'exec_command',
arguments: '{"cmd": "ls"}',
at: 1500,
};
}
function reasoningItem(itemId: string): DirectThreadItem {
return { itemType: 'reasoning', itemId, text: '想想', at: 1400 };
}
function slice(
items: DirectThreadItem[],
hasMore: boolean,
firstItemId: string | null,
): DirectThreadHistorySlice {
return { items, hasMore, firstItemId };
}
/** 用生产投影把条目拉成聊天条目:判据必须和视图看到的是同一份。 */
function entriesOf(items: DirectThreadItem[]): DirectChatEntry[] {
return selectDirectChatEntries(
mergeDirectHistoryItems(emptyDirectThreadChatState(), items),
);
}
function readerOf(pages: DirectThreadHistorySlice[]) {
const remaining = [...pages];
return vi.fn(async (_beforeItemId: string | null) => {
const next = remaining.shift();
if (!next) throw new Error('测试桩:不该再取页');
return next;
});
}
describe('DirectProject 历史分页连拉', () => {
it('整页工具与思考条目落进已渲染回合时继续取页,直到出现新的用户气泡', async () => {
// 已渲染的回合是「turn-2-user」,后面跟着它的折叠执行过程。
const existing = entriesOf([userItem('turn-2-user'), toolItem('call-2')]);
const readSlice = readerOf([
slice([toolItem('call-3'), reasoningItem('reason-3')], true, 'reason-3'),
slice(
[userItem('turn-1-user'), assistantItem('turn-1-assistant')],
false,
'turn-1-user',
),
]);
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
// 第二页才带来新回合:锚点按上一页最老一条推进,条目按文件顺序拼回去。
expect(readSlice.mock.calls).toEqual([['turn-2-user'], ['reason-3']]);
expect(result.items.map((item) => item.itemId)).toEqual([
'turn-1-user',
'turn-1-assistant',
'call-3',
'reason-3',
]);
expect(result.hasMore).toBe(false);
expect(result.firstItemId).toBe('turn-1-user');
expect(result.error).toBeNull();
const merged = selectDirectChatEntries(
mergeDirectHistoryItems(
{ turnRunning: false, history: existing, live: [] },
result.items,
),
);
expect(merged.some((entry) => entry.itemId === 'turn-1-user')).toBe(true);
});
it('第一页就出现新的用户气泡时只取一页', async () => {
const existing = entriesOf([userItem('turn-2-user')]);
const readSlice = readerOf([
slice([userItem('turn-1-user')], true, 'turn-1-user'),
]);
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(1);
// 后端说还有更早的:按钮继续留在界面上,用户可以接着往前翻。
expect(result.hasMore).toBe(true);
expect(result.firstItemId).toBe('turn-1-user');
});
it('首屏没有已渲染回合时,第一页本身就构成可见反馈', async () => {
const readSlice = readerOf([slice([toolItem('call-1')], true, 'call-1')]);
const result = await readDirectHistoryPages({
existingEntries: [],
beforeItemId: null,
readSlice,
});
expect(readSlice.mock.calls).toEqual([[null]]);
expect(result.items.map((item) => item.itemId)).toEqual(['call-1']);
});
it('hasMore=false 时即使没有新回合也立即停止', async () => {
const existing = entriesOf([userItem('turn-2-user')]);
const readSlice = readerOf([slice([toolItem('call-3')], false, 'call-3')]);
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(1);
expect(result.hasMore).toBe(false);
});
it('一直没有新回合时最多连拉上限页数就停手', async () => {
const existing = entriesOf([userItem('turn-2-user')]);
const readSlice = readerOf(
Array.from(
{ length: DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2 },
(_, index) =>
slice([toolItem(`call-${index + 3}`)], true, `call-${index + 3}`),
),
);
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(
DIRECT_HISTORY_MAX_PAGES_PER_ACTION,
);
// 用满上限但文件里还有更早的历史:按钮留在界面上,用户可再点一次。
expect(result.hasMore).toBe(true);
expect(result.items).toHaveLength(DIRECT_HISTORY_MAX_PAGES_PER_ACTION);
expect(result.firstItemId).toBe(
`call-${DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2}`,
);
});
it('锚点不前进时立即停止,不空转也不留按钮', async () => {
const existing = entriesOf([userItem('turn-2-user')]);
const readSlice = readerOf([slice([], true, null)]);
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(1);
expect(result.items).toEqual([]);
expect(result.hasMore).toBe(false);
expect(result.firstItemId).toBe('turn-2-user');
});
it('锚点原地打转时立即停止,不重复取同一页', async () => {
const existing = entriesOf([userItem('turn-2-user')]);
const readSlice = readerOf([
slice([toolItem('call-3')], true, 'turn-2-user'),
]);
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(1);
expect(result.hasMore).toBe(false);
});
it('某一页读取失败时保留已经取到的页', async () => {
const existing = entriesOf([userItem('turn-2-user')]);
const failure = new Error('读取失败');
const readSlice = vi.fn(async (beforeItemId: string | null) => {
if (beforeItemId === 'turn-2-user') {
return slice([toolItem('call-3')], true, 'call-3');
}
throw failure;
});
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(2);
expect(result.items.map((item) => item.itemId)).toEqual(['call-3']);
// 失败的页没有推进锚点:下一次点击会重取这一页。
expect(result.firstItemId).toBe('call-3');
expect(result.hasMore).toBe(true);
expect(result.error).toBe(failure);
});
it('首页读取失败时保留首屏锚点并返回错误', async () => {
const failure = new Error('首页读取失败');
const readSlice = vi.fn(async (_beforeItemId: string | null) => {
throw failure;
});
const result = await readDirectHistoryPages({
existingEntries: [],
beforeItemId: null,
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(1);
expect(result.items).toEqual([]);
expect(result.firstItemId).toBeNull();
expect(result.hasMore).toBe(false);
expect(result.error).toBe(failure);
});
});