import { Message } from './api/models'; import { GROUP_QUIET_MS, GroupEntry, blockDuration, buildGroupTurns, } from './group-turns'; const T0 = Date.UTC(2026, 10, 5, 10, 0, 0); function msg(id: string, sender: 'me' | 'agent', ts: number, text = ''): Message { return { id, conversationId: sender === 'me' ? 'group' : 'a', sender, kind: 'text', text, ts: new Date(ts).toISOString(), state: 'sent', }; } /** 用户消息条目 */ function mine(turnId: string, ts: number, text = '派活'): GroupEntry { return { id: turnId, turnId, fromAgentId: null, message: msg(turnId, 'me', ts, text), at: ts, }; } /** 成员回复条目 */ function reply(turnId: string, agentId: string, id: string, ts: number, text = ''): GroupEntry { return { id, turnId, fromAgentId: agentId, message: msg(id, 'agent', ts, text), at: ts, }; } /** 构造输入:targets 一律返回给定成员 */ function input( entries: GroupEntry[], targets: string[], typing: Record = {}, now = T0 + 60_000, ) { return { entries, targetsOf: () => targets, typingOf: (id: string) => typing[id] ?? false, now, }; } describe('group-turns', () => { describe('buildGroupTurns', () => { it('两个成员各自回复 → 同一回合内两块,且不交错', () => { const turns = buildGroupTurns( input( [ mine('t1', T0), reply('t1', 'A', 'a1', T0 + 1000, 'A 第一句'), reply('t1', 'B', 'b1', T0 + 1500, 'B 第一句'), reply('t1', 'A', 'a2', T0 + 2000, 'A 第二句'), reply('t1', 'B', 'b2', T0 + 2500, 'B 第二句'), ], ['A', 'B'], ), ); expect(turns.length).toBe(1); expect(turns[0].blocks.length).toBe(2); const [blockA, blockB] = turns[0].blocks; expect(blockA.agentId).toBe('A'); expect(blockA.messages.map((m) => m.text)).toEqual(['A 第一句', 'A 第二句']); expect(blockB.agentId).toBe('B'); expect(blockB.messages.map((m) => m.text)).toEqual(['B 第一句', 'B 第二句']); }); it('块顺序 = 目标声明顺序,而不是到达顺序', () => { const turns = buildGroupTurns( input( [ mine('t1', T0), reply('t1', 'B', 'b1', T0 + 1000, 'B 先回'), reply('t1', 'A', 'a1', T0 + 2000, 'A 后回'), ], ['A', 'B'], ), ); expect(turns[0].blocks.map((b) => b.agentId)).toEqual(['A', 'B']); }); it('尚未回复的成员保留空块(能看到「已派给它」)', () => { const turns = buildGroupTurns( input([mine('t1', T0), reply('t1', 'A', 'a1', T0 + 1000)], ['A', 'B']), ); expect(turns[0].blocks.length).toBe(2); const b = turns[0].blocks.find((x) => x.agentId === 'B')!; expect(b.messages.length).toBe(0); expect(blockDuration(b)).toBe(0); }); it('🔴 跨天的脏数据不再产生超大耗时(189848s 回归)', () => { const day = 24 * 3600 * 1000; const turns = buildGroupTurns( input( [ mine('t1', T0), reply('t1', 'A', 'a-old', T0 - day * 2, '两天前的历史'), reply('t1', 'A', 'a-new', T0 + 1000, '刚回的'), ], ['A'], ), ); // 关键:只有一个回合、一块(不会被拆成跨天的大回合) expect(turns.length).toBe(1); expect(turns[0].blocks.length).toBe(1); expect(turns[0].blocks[0].messages.length).toBe(2); // 真正的回归断言:块末时间就是最新那条,不会被历史拉走 expect(turns[0].blocks[0].updatedAt).toBe(T0 + 1000); }); it('🔴 迟到历史会把块起点拉早 —— 入库层必须过滤历史(记录当前行为)', () => { const day = 24 * 3600 * 1000; const turns = buildGroupTurns( input( [ mine('t1', T0), reply('t1', 'A', 'a1', T0 + 1000), reply('t1', 'A', 'a-stale', T0 - day, '过期历史'), ], ['A'], ), ); // 这是**已知边界**:聚合层不做时间过滤,脏历史混进来依然会拉长耗时。 // 正确做法是在 GroupStore 入库时就拒绝历史事件(见 isFreshReply)。 expect(turns[0].blocks[0].startedAt).toBe(T0 - day); expect(turns[0].blocks[0].updatedAt).toBe(T0 + 1000); }); it('不同回合的块互不干扰,各自耗时独立', () => { const turns = buildGroupTurns( input( [ mine('t1', T0), reply('t1', 'A', 'a1', T0 + 1000), reply('t1', 'A', 'a2', T0 + 3000), mine('t2', T0 + 100_000), reply('t2', 'A', 'a3', T0 + 101_000), reply('t2', 'A', 'a4', T0 + 104_000), ], ['A'], {}, T0 + 200_000, ), ); expect(turns.length).toBe(2); expect(blockDuration(turns[0].blocks[0])).toBe(2000); expect(blockDuration(turns[1].blocks[0])).toBe(3000); }); it('typing 未结束 → 该块保持打开', () => { const turns = buildGroupTurns( input([mine('t1', T0), reply('t1', 'A', 'a1', T0 + 1000)], ['A', 'B'], { A: true }), ); const a = turns[0].blocks.find((x) => x.agentId === 'A')!; expect(a.typing).toBe(true); expect(a.closed).toBe(false); expect(turns[0].closed).toBe(false); }); it('静默超过 quietMs 且不在 typing → 关闭', () => { const turns = buildGroupTurns({ entries: [mine('t1', T0), reply('t1', 'A', 'a1', T0 + 1000)], targetsOf: () => ['A'], typingOf: () => false, now: T0 + 1000 + GROUP_QUIET_MS + 1, }); expect(turns[0].blocks[0].closed).toBe(true); expect(turns[0].closed).toBe(true); }); it('静默未到 quietMs → 仍打开', () => { const turns = buildGroupTurns({ entries: [mine('t1', T0), reply('t1', 'A', 'a1', T0 + 1000)], targetsOf: () => ['A'], typingOf: () => false, now: T0 + 2000, }); expect(turns[0].blocks[0].closed).toBe(false); }); it('找不到锚点回合的回复被丢弃(不凭空造回合)', () => { const turns = buildGroupTurns(input([reply('ghost', 'A', 'x1', T0 + 1000)], ['A'])); expect(turns.length).toBe(0); }); it('重复的用户消息只认第一条', () => { const turns = buildGroupTurns( input([mine('t1', T0, '第一次'), mine('t1', T0 + 1, '重复')], ['A']), ); expect(turns.length).toBe(1); expect(turns[0].userMessage.text).toBe('第一次'); }); it('条目乱序传入也能正确聚合', () => { const turns = buildGroupTurns( input( [ reply('t1', 'A', 'a1', T0 + 2000), mine('t1', T0), reply('t1', 'A', 'a0', T0 + 1000), ], ['A'], ), ); expect(turns[0].blocks[0].messages.map((m) => m.id)).toEqual(['a0', 'a1']); }); it('空输入 → 空结果', () => { expect(buildGroupTurns(input([], ['A']))).toEqual([]); }); it('回合按用户消息时间顺序返回', () => { const turns = buildGroupTurns( input([mine('t2', T0 + 50_000), mine('t1', T0)], ['A'], {}, T0 + 100_000), ); expect(turns.map((t) => t.id)).toEqual(['t1', 't2']); }); }); describe('blockDuration', () => { it('空块为 0', () => { expect( blockDuration({ agentId: 'A', messages: [], startedAt: 0, updatedAt: 0, typing: false, closed: true }), ).toBe(0); }); it('单条消息耗时为 0', () => { const turns = buildGroupTurns(input([mine('t1', T0), reply('t1', 'A', 'a1', T0 + 1000)], ['A'])); expect(blockDuration(turns[0].blocks[0])).toBe(0); }); }); });