Files
Genarrative/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts
k88936 098693636a 分页切片开头的前导条目并进后面那个回合
- buildDirectChatTurns 先攒住第一个用户条目之前的过程条目,交给后面那个回合
- 不再渲染没有用户气泡的孤儿回合;整份历史都没有用户条目时仍保留一个回合
- directTurnPresentation 单测补一条分页切片从半截回合开头的情形
2026-09-17 00:02:11 +08:00

242 lines
7.5 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { ChatMessage } from '../src/app/types';
import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat';
import {
buildDirectChatTurns,
directChatEntriesToMessages,
directMessageTimestamp,
mergeDirectChatMessages,
normalizeDirectTimestamp,
} from '../src/features/project-workspace/directTurnPresentation';
const userEntry = (
itemId: string,
at = 1_800_000_000_000,
): DirectChatEntry => ({
itemId,
kind: 'message',
role: 'user',
text: `问题 ${itemId}`,
at,
});
const assistantEntry = (
itemId: string,
text: string,
at = 1_800_000_001_000,
): DirectChatEntry => ({
itemId,
kind: 'message',
role: 'assistant',
text,
at,
});
const toolEntry = (
itemId: string,
at = 1_800_000_000_500,
): DirectChatEntry => ({
itemId,
kind: 'tool',
role: null,
text: null,
at,
toolCall: {
schemaVersion: 'agc-tool-call.v1',
id: itemId,
kind: 'command',
title: '执行命令',
summary: 'npm run build',
status: 'completed',
detail: { command: 'npm run build' },
startedAt: at,
updatedAt: at,
},
});
const reasoningEntry = (
itemId: string,
text = '先看目录',
at = 1_800_000_000_200,
): DirectChatEntry => ({
itemId,
kind: 'reasoning',
role: null,
text,
at,
});
const localUser = (text: string, messageId?: string): ChatMessage => ({
role: 'user',
text,
...(messageId ? { messageId } : {}),
updatedAt: 1_800_000_002_000,
});
const localNotice = (text: string, messageId?: string): ChatMessage => ({
role: 'assistant',
text,
...(messageId ? { messageId } : {}),
updatedAt: 1_800_000_002_500,
});
describe('DirectProject 聊天分区', () => {
it('把 Unix 秒时间戳归一化为毫秒,已有毫秒值保持不变', () => {
expect(normalizeDirectTimestamp(1_800_000_000)).toBe(1_800_000_000_000);
expect(normalizeDirectTimestamp(1_800_000_000_000)).toBe(1_800_000_000_000);
expect(directMessageTimestamp(undefined)).toBe(0);
expect(directMessageTimestamp(Number.MAX_VALUE)).toBe(0);
expect(directMessageTimestamp(1_800_000_000_001)).toBe(1_800_000_000_001);
});
it('每个用户条目开一个新回合,顺序就是条目顺序', () => {
const turns = buildDirectChatTurns({
entries: [
userEntry('u1'),
assistantEntry('a1', '第一轮答复'),
userEntry('u2', 1_800_000_010_000),
assistantEntry('a2', '第二轮答复', 1_800_000_011_000),
],
});
expect(turns.map((turn) => turn.key)).toEqual(['u1', 'u2']);
expect(turns[0]?.users[0]).toMatchObject({ text: '问题 u1' });
expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' });
expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' });
expect(turns[0]?.startedAt).toBe(1_800_000_000_000);
expect(turns[0]?.endedAt).toBe(1_800_000_001_000);
});
it('已结束的回合只把最后一条助手正文当最终回复,中间正文与工具进过程', () => {
const turns = buildDirectChatTurns({
entries: [
userEntry('u1'),
reasoningEntry('r1'),
assistantEntry('a-mid', '我先看一下项目'),
toolEntry('t1'),
assistantEntry('a-final', '改好了', 1_800_000_002_000),
],
});
expect(turns[0]?.process.map((block) => block.key)).toEqual([
'u1:r1',
'u1:a-mid',
'u1:t1',
]);
expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u1:a-final']);
});
it('连续工具合成一块,夹了正文就另起一块', () => {
const turns = buildDirectChatTurns({
entries: [
userEntry('u1'),
toolEntry('t1'),
toolEntry('t2', 1_800_000_000_600),
assistantEntry('a-mid', '继续', 1_800_000_000_700),
toolEntry('t3', 1_800_000_000_800),
],
turnRunning: true,
});
const process = turns[0]?.process ?? [];
expect(process.map((block) => block.kind)).toEqual([
'tools',
'assistant',
'tools',
]);
expect(process[0]).toMatchObject({
kind: 'tools',
calls: [{ id: 't1' }, { id: 't2' }],
});
expect(process[2]).toMatchObject({ kind: 'tools', calls: [{ id: 't3' }] });
});
it('运行中的回合:过程不折叠,最后的助手正文仍在过程里流式显示', () => {
const turns = buildDirectChatTurns({
entries: [
userEntry('u1'),
assistantEntry('a-live', '正在写'),
toolEntry('t1', 1_800_000_001_100),
],
turnRunning: true,
});
expect(turns[0]?.active).toBe(true);
expect(turns[0]?.finals).toEqual([]);
expect(turns[0]?.process.map((block) => block.kind)).toEqual([
'assistant',
'tools',
]);
});
it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => {
const turns = buildDirectChatTurns({
entries: [userEntry('u1'), assistantEntry('a1', '正文')],
localMessages: [localNotice('后台任务失败:端口被占用')],
});
expect(turns).toHaveLength(1);
expect(turns[0]?.finals.map((block) => block.kind)).toEqual([
'assistant',
'assistant',
]);
expect(turns[0]?.finals[1]).toMatchObject({
notice: true,
text: '后台任务失败:端口被占用',
});
});
it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => {
const turns = buildDirectChatTurns({
entries: [userEntry('u1'), assistantEntry('a1', '答复')],
localMessages: [localUser('问题 u1', 'u1'), localUser('第二条')],
turnRunning: true,
});
expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']);
expect(turns[1]?.users).toHaveLength(1);
expect(turns[1]?.active).toBe(true);
});
it('历史无用户条目时也保留一个回合承载正文', () => {
const turns = buildDirectChatTurns({
entries: [assistantEntry('a1', '只有回复')],
});
expect(turns).toHaveLength(1);
expect(turns[0]?.users).toEqual([]);
expect(turns[0]?.finals[0]).toMatchObject({ text: '只有回复' });
});
it('分页切片开头落在半截回合里时并进后面那个回合,不生成没有用户气泡的孤儿回合', () => {
// 更早的一屏把上一条用户条目切在外面:这三条前导条目属于上一轮,但不能自成回合。
const turns = buildDirectChatTurns({
entries: [
reasoningEntry('r-prev'),
toolEntry('t-prev'),
assistantEntry('a-prev', '上一轮的答复', 1_800_000_000_900),
userEntry('u2', 1_800_000_010_000),
assistantEntry('a2', '这一轮的答复', 1_800_000_011_000),
],
});
expect(turns.map((turn) => turn.key)).toEqual(['u2']);
expect(turns[0]?.users.map((block) => block.key)).toEqual(['u2:u2']);
expect(turns[0]?.process.map((block) => block.key)).toEqual([
'u2:r-prev',
'u2:t-prev',
'u2:a-prev',
]);
expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u2:a2']);
});
it('条目转消息只保留用户 / 助手正文,并按身份去重', () => {
const messages = directChatEntriesToMessages([
userEntry('u1'),
reasoningEntry('r1'),
toolEntry('t1'),
assistantEntry('a1', '答复'),
]);
expect(messages.map((message) => message.messageId)).toEqual(['u1', 'a1']);
expect(
mergeDirectChatMessages(messages, [
{ role: 'assistant', text: '答复', messageId: 'a1' },
localNotice('只在运行期的失败说明', 'local-1'),
]).map((message) => message.messageId),
).toEqual(['u1', 'a1', 'local-1']);
});
});