group-turns.spec.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import { Message } from './api/models';
  2. import {
  3. GROUP_QUIET_MS,
  4. GroupEntry,
  5. blockDuration,
  6. buildGroupTurns,
  7. } from './group-turns';
  8. const T0 = Date.UTC(2026, 10, 5, 10, 0, 0);
  9. function msg(id: string, sender: 'me' | 'agent', ts: number, text = ''): Message {
  10. return {
  11. id,
  12. conversationId: sender === 'me' ? 'group' : 'a',
  13. sender,
  14. kind: 'text',
  15. text,
  16. ts: new Date(ts).toISOString(),
  17. state: 'sent',
  18. };
  19. }
  20. /** 用户消息条目 */
  21. function mine(turnId: string, ts: number, text = '派活'): GroupEntry {
  22. return {
  23. id: turnId,
  24. turnId,
  25. fromAgentId: null,
  26. message: msg(turnId, 'me', ts, text),
  27. at: ts,
  28. };
  29. }
  30. /** 成员回复条目 */
  31. function reply(turnId: string, agentId: string, id: string, ts: number, text = ''): GroupEntry {
  32. return {
  33. id,
  34. turnId,
  35. fromAgentId: agentId,
  36. message: msg(id, 'agent', ts, text),
  37. at: ts,
  38. };
  39. }
  40. /** 构造输入:targets 一律返回给定成员 */
  41. function input(
  42. entries: GroupEntry[],
  43. targets: string[],
  44. typing: Record<string, boolean> = {},
  45. now = T0 + 60_000,
  46. ) {
  47. return {
  48. entries,
  49. targetsOf: () => targets,
  50. typingOf: (id: string) => typing[id] ?? false,
  51. now,
  52. };
  53. }
  54. describe('group-turns', () => {
  55. describe('buildGroupTurns', () => {
  56. it('两个成员各自回复 → 同一回合内两块,且不交错', () => {
  57. const turns = buildGroupTurns(
  58. input(
  59. [
  60. mine('t1', T0),
  61. reply('t1', 'A', 'a1', T0 + 1000, 'A 第一句'),
  62. reply('t1', 'B', 'b1', T0 + 1500, 'B 第一句'),
  63. reply('t1', 'A', 'a2', T0 + 2000, 'A 第二句'),
  64. reply('t1', 'B', 'b2', T0 + 2500, 'B 第二句'),
  65. ],
  66. ['A', 'B'],
  67. ),
  68. );
  69. expect(turns.length).toBe(1);
  70. expect(turns[0].blocks.length).toBe(2);
  71. const [blockA, blockB] = turns[0].blocks;
  72. expect(blockA.agentId).toBe('A');
  73. expect(blockA.messages.map((m) => m.text)).toEqual(['A 第一句', 'A 第二句']);
  74. expect(blockB.agentId).toBe('B');
  75. expect(blockB.messages.map((m) => m.text)).toEqual(['B 第一句', 'B 第二句']);
  76. });
  77. it('块顺序 = 目标声明顺序,而不是到达顺序', () => {
  78. const turns = buildGroupTurns(
  79. input(
  80. [
  81. mine('t1', T0),
  82. reply('t1', 'B', 'b1', T0 + 1000, 'B 先回'),
  83. reply('t1', 'A', 'a1', T0 + 2000, 'A 后回'),
  84. ],
  85. ['A', 'B'],
  86. ),
  87. );
  88. expect(turns[0].blocks.map((b) => b.agentId)).toEqual(['A', 'B']);
  89. });
  90. it('尚未回复的成员保留空块(能看到「已派给它」)', () => {
  91. const turns = buildGroupTurns(
  92. input([mine('t1', T0), reply('t1', 'A', 'a1', T0 + 1000)], ['A', 'B']),
  93. );
  94. expect(turns[0].blocks.length).toBe(2);
  95. const b = turns[0].blocks.find((x) => x.agentId === 'B')!;
  96. expect(b.messages.length).toBe(0);
  97. expect(blockDuration(b)).toBe(0);
  98. });
  99. it('🔴 跨天的脏数据不再产生超大耗时(189848s 回归)', () => {
  100. const day = 24 * 3600 * 1000;
  101. const turns = buildGroupTurns(
  102. input(
  103. [
  104. mine('t1', T0),
  105. reply('t1', 'A', 'a-old', T0 - day * 2, '两天前的历史'),
  106. reply('t1', 'A', 'a-new', T0 + 1000, '刚回的'),
  107. ],
  108. ['A'],
  109. ),
  110. );
  111. // 关键:只有一个回合、一块(不会被拆成跨天的大回合)
  112. expect(turns.length).toBe(1);
  113. expect(turns[0].blocks.length).toBe(1);
  114. expect(turns[0].blocks[0].messages.length).toBe(2);
  115. // 真正的回归断言:块末时间就是最新那条,不会被历史拉走
  116. expect(turns[0].blocks[0].updatedAt).toBe(T0 + 1000);
  117. });
  118. it('🔴 迟到历史会把块起点拉早 —— 入库层必须过滤历史(记录当前行为)', () => {
  119. const day = 24 * 3600 * 1000;
  120. const turns = buildGroupTurns(
  121. input(
  122. [
  123. mine('t1', T0),
  124. reply('t1', 'A', 'a1', T0 + 1000),
  125. reply('t1', 'A', 'a-stale', T0 - day, '过期历史'),
  126. ],
  127. ['A'],
  128. ),
  129. );
  130. // 这是**已知边界**:聚合层不做时间过滤,脏历史混进来依然会拉长耗时。
  131. // 正确做法是在 GroupStore 入库时就拒绝历史事件(见 isFreshReply)。
  132. expect(turns[0].blocks[0].startedAt).toBe(T0 - day);
  133. expect(turns[0].blocks[0].updatedAt).toBe(T0 + 1000);
  134. });
  135. it('不同回合的块互不干扰,各自耗时独立', () => {
  136. const turns = buildGroupTurns(
  137. input(
  138. [
  139. mine('t1', T0),
  140. reply('t1', 'A', 'a1', T0 + 1000),
  141. reply('t1', 'A', 'a2', T0 + 3000),
  142. mine('t2', T0 + 100_000),
  143. reply('t2', 'A', 'a3', T0 + 101_000),
  144. reply('t2', 'A', 'a4', T0 + 104_000),
  145. ],
  146. ['A'],
  147. {},
  148. T0 + 200_000,
  149. ),
  150. );
  151. expect(turns.length).toBe(2);
  152. expect(blockDuration(turns[0].blocks[0])).toBe(2000);
  153. expect(blockDuration(turns[1].blocks[0])).toBe(3000);
  154. });
  155. it('typing 未结束 → 该块保持打开', () => {
  156. const turns = buildGroupTurns(
  157. input([mine('t1', T0), reply('t1', 'A', 'a1', T0 + 1000)], ['A', 'B'], { A: true }),
  158. );
  159. const a = turns[0].blocks.find((x) => x.agentId === 'A')!;
  160. expect(a.typing).toBe(true);
  161. expect(a.closed).toBe(false);
  162. expect(turns[0].closed).toBe(false);
  163. });
  164. it('静默超过 quietMs 且不在 typing → 关闭', () => {
  165. const turns = buildGroupTurns({
  166. entries: [mine('t1', T0), reply('t1', 'A', 'a1', T0 + 1000)],
  167. targetsOf: () => ['A'],
  168. typingOf: () => false,
  169. now: T0 + 1000 + GROUP_QUIET_MS + 1,
  170. });
  171. expect(turns[0].blocks[0].closed).toBe(true);
  172. expect(turns[0].closed).toBe(true);
  173. });
  174. it('静默未到 quietMs → 仍打开', () => {
  175. const turns = buildGroupTurns({
  176. entries: [mine('t1', T0), reply('t1', 'A', 'a1', T0 + 1000)],
  177. targetsOf: () => ['A'],
  178. typingOf: () => false,
  179. now: T0 + 2000,
  180. });
  181. expect(turns[0].blocks[0].closed).toBe(false);
  182. });
  183. it('找不到锚点回合的回复被丢弃(不凭空造回合)', () => {
  184. const turns = buildGroupTurns(input([reply('ghost', 'A', 'x1', T0 + 1000)], ['A']));
  185. expect(turns.length).toBe(0);
  186. });
  187. it('重复的用户消息只认第一条', () => {
  188. const turns = buildGroupTurns(
  189. input([mine('t1', T0, '第一次'), mine('t1', T0 + 1, '重复')], ['A']),
  190. );
  191. expect(turns.length).toBe(1);
  192. expect(turns[0].userMessage.text).toBe('第一次');
  193. });
  194. it('条目乱序传入也能正确聚合', () => {
  195. const turns = buildGroupTurns(
  196. input(
  197. [
  198. reply('t1', 'A', 'a1', T0 + 2000),
  199. mine('t1', T0),
  200. reply('t1', 'A', 'a0', T0 + 1000),
  201. ],
  202. ['A'],
  203. ),
  204. );
  205. expect(turns[0].blocks[0].messages.map((m) => m.id)).toEqual(['a0', 'a1']);
  206. });
  207. it('空输入 → 空结果', () => {
  208. expect(buildGroupTurns(input([], ['A']))).toEqual([]);
  209. });
  210. it('回合按用户消息时间顺序返回', () => {
  211. const turns = buildGroupTurns(
  212. input([mine('t2', T0 + 50_000), mine('t1', T0)], ['A'], {}, T0 + 100_000),
  213. );
  214. expect(turns.map((t) => t.id)).toEqual(['t1', 't2']);
  215. });
  216. });
  217. describe('blockDuration', () => {
  218. it('空块为 0', () => {
  219. expect(
  220. blockDuration({ agentId: 'A', messages: [], startedAt: 0, updatedAt: 0, typing: false, closed: true }),
  221. ).toBe(0);
  222. });
  223. it('单条消息耗时为 0', () => {
  224. const turns = buildGroupTurns(input([mine('t1', T0), reply('t1', 'A', 'a1', T0 + 1000)], ['A']));
  225. expect(blockDuration(turns[0].blocks[0])).toBe(0);
  226. });
  227. });
  228. });