group-store.service.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import { TestBed } from '@angular/core/testing';
  2. import { Message } from './api/models';
  3. import {
  4. GroupStoreService,
  5. MAX_ENTRIES_PER_GROUP,
  6. isFreshReply,
  7. trimByTurn,
  8. } from './group-store.service';
  9. import { GroupEntry } from './group-turns';
  10. import { SessionService } from './session.service';
  11. function replyMsg(id: string, text = ''): Message {
  12. return {
  13. id,
  14. conversationId: 'a',
  15. sender: 'agent',
  16. kind: 'text',
  17. text,
  18. ts: new Date(Date.now()).toISOString(),
  19. state: 'sent',
  20. };
  21. }
  22. describe('GroupStoreService', () => {
  23. let store: GroupStoreService;
  24. beforeEach(() => {
  25. localStorage.clear();
  26. TestBed.configureTestingModule({
  27. providers: [SessionService, GroupStoreService],
  28. });
  29. TestBed.inject(SessionService).setSession('r:test', {
  30. id: 'u1',
  31. name: '用户',
  32. } as never);
  33. store = TestBed.inject(GroupStoreService);
  34. store.resetForTest();
  35. // 模拟真实时序:页面先 load() 再写入(未加载时 persist 会拒绝写盘)
  36. store.load();
  37. });
  38. afterEach(() => localStorage.clear());
  39. it('记录用户消息后能读到', () => {
  40. const turnId = store.appendMine('g1', '派活', ['a', 'b']);
  41. const list = store.entriesOf('g1');
  42. expect(list.length).toBe(1);
  43. expect(list[0].turnId).toBe(turnId);
  44. expect(list[0].fromAgentId).toBeNull();
  45. expect(store.targetsOf(turnId)).toEqual(['a', 'b']);
  46. });
  47. it('用户消息 id 唯一(同毫秒也不冲突)', () => {
  48. const t = Date.now();
  49. const id1 = store.appendMine('g1', '一', ['a'], t);
  50. const id2 = store.appendMine('g1', '二', ['a'], t);
  51. expect(id1).not.toBe(id2);
  52. expect(store.entriesOf('g1').length).toBe(2);
  53. });
  54. it('回复归入最近一次提问', () => {
  55. const turnId = store.appendMine('g1', '派活', ['a']);
  56. const got = store.appendReply('g1', 'a', replyMsg('e1'), Date.now());
  57. expect(got).toBe(turnId);
  58. expect(store.entriesOf('g1').length).toBe(2);
  59. });
  60. it('回复可显式指定回合', () => {
  61. const t1 = store.appendMine('g1', '第一次', ['a']);
  62. const t2 = store.appendMine('g1', '第二次', ['a']);
  63. // 显式指定归到第一次提问(默认会归到最近的 t2)
  64. const got = store.appendReply('g1', 'a', replyMsg('e1'), Date.now(), t1);
  65. expect(got).toBe(t1);
  66. expect(store.entriesOf('g1').at(-1)!.turnId).toBe(t1);
  67. expect(t2).not.toBe(t1);
  68. });
  69. it('🔴 同一 eventId 只入群一次(重复回复回归)', () => {
  70. store.appendMine('g1', '派活', ['a']);
  71. const now = Date.now();
  72. expect(store.appendReply('g1', 'a', replyMsg('e1'), now)).not.toBeNull();
  73. // 第二次同 id(resubscribe 后重新订阅、或翻页重复到达)
  74. expect(store.appendReply('g1', 'a', replyMsg('e1'), now + 100)).toBeNull();
  75. expect(store.entriesOf('g1').length).toBe(2);
  76. });
  77. it('没有前置提问时,回复被丢弃(不凭空造回合)', () => {
  78. expect(store.appendReply('g1', 'a', replyMsg('e1'), Date.now())).toBeNull();
  79. expect(store.entriesOf('g1').length).toBe(0);
  80. });
  81. it('按时间升序存储', () => {
  82. const t = Date.now();
  83. store.appendMine('g1', '一', ['a'], t + 1000);
  84. store.appendMine('g1', '二', ['a'], t);
  85. expect(store.entriesOf('g1').map((e) => e.message.text)).toEqual(['二', '一']);
  86. });
  87. it('不同会话互不干扰', () => {
  88. store.appendMine('g1', '群一', ['a']);
  89. store.appendMine('g2', '群二', ['b']);
  90. expect(store.entriesOf('g1').length).toBe(1);
  91. expect(store.entriesOf('g2').length).toBe(1);
  92. expect(store.entriesOf('g1')[0].message.text).toBe('群一');
  93. });
  94. it('超过上限时只裁剪**落盘副本**,内存表保持完整', () => {
  95. /*
  96. * 🔴 2026-11-07 语义变更:Matrix 是群历史真源,本地裁剪内存表会导致
  97. * 「重建出来又被裁回去」。现在只在 persist 时裁写盘的那份。
  98. */
  99. const t = Date.now();
  100. for (let i = 0; i < MAX_ENTRIES_PER_GROUP + 10; i++) {
  101. store.appendMine('g1', `m${i}`, ['a'], t + i);
  102. }
  103. const list = store.entriesOf('g1');
  104. expect(list.length).toBe(MAX_ENTRIES_PER_GROUP + 10);
  105. expect(list[list.length - 1].message.text).toBe(`m${MAX_ENTRIES_PER_GROUP + 9}`);
  106. // 写盘的那份被裁剪
  107. const raw = localStorage.getItem(
  108. Object.keys(localStorage).find((k) => k.startsWith('fmode_group_entries_v1_'))!,
  109. )!;
  110. const saved = JSON.parse(raw).entries.g1.length;
  111. expect(saved).toBe(MAX_ENTRIES_PER_GROUP);
  112. });
  113. it('turnId 可复现:同一毫秒只追加序号,不再是内存自增', () => {
  114. const t = 1793952987639;
  115. const a = store.appendMine('g1', '一', ['a'], t);
  116. const b = store.appendMine('g1', '二', ['a'], t);
  117. expect(a).toBe(`me-${t}`);
  118. expect(b).toBe(`me-${t}-2`);
  119. });
  120. it('Matrix 重建写回:与本地同 id 的条目不会重复', () => {
  121. const t = 1793952987639;
  122. const id = store.appendMine('g1', '问题', ['a'], t);
  123. const matrixEntries = [
  124. { id, turnId: id, fromAgentId: null, message: { id, text: '问题' } as never, at: t },
  125. { id: '$ev1', turnId: id, fromAgentId: 'a', message: { id: '$ev1' } as never, at: t + 100 },
  126. ];
  127. store.replaceFromMatrix('g1', [{ ...matrixEntries[0] }], { [id]: ['a'] });
  128. store.appendReply('g1', 'a', matrixEntries[1].message, t + 100, id);
  129. store.appendReply('g1', 'a', matrixEntries[1].message, t + 100, id);
  130. const list = store.entriesOf('g1');
  131. expect(list.filter((e) => e.fromAgentId === null).length).toBe(1);
  132. expect(list.filter((e) => e.id === '$ev1').length).toBe(1);
  133. });
  134. it('appendUserFile 同名同大小只记一次', () => {
  135. const id = store.appendMine('g1', '看图', ['a', 'b'], 1000);
  136. const card = { kind: 'file' as const, name: 'a.png', size: 10, mimeType: 'image/png', url: 'blob:x' };
  137. store.appendUserFile('g1', id, card, 1000, '$f1');
  138. store.appendUserFile('g1', id, card, 1000, '$f2');
  139. expect(store.entriesOf('g1').filter((e) => e.message.file).length).toBe(1);
  140. });
  141. it('clear 清空指定会话', () => {
  142. store.appendMine('g1', '一', ['a']);
  143. store.appendMine('g2', '二', ['b']);
  144. store.clear('g1');
  145. expect(store.entriesOf('g1').length).toBe(0);
  146. expect(store.entriesOf('g2').length).toBe(1);
  147. });
  148. describe('持久化', () => {
  149. it('刷新后能恢复', () => {
  150. store.appendMine('g1', '派活', ['a', 'b']);
  151. store.appendReply('g1', 'a', replyMsg('e1', '收到'), Date.now());
  152. const fresh = TestBed.inject(GroupStoreService);
  153. fresh.resetForTest();
  154. fresh.load();
  155. const list = fresh.entriesOf('g1');
  156. expect(list.length).toBe(2);
  157. expect(list[1].message.text).toBe('收到');
  158. });
  159. it('坏 JSON 不抛错,退回空列表', () => {
  160. localStorage.setItem('fmode_group_entries_v1_u1', '{坏掉的');
  161. expect(() => store.load()).not.toThrow();
  162. expect(store.entriesOf('g1').length).toBe(0);
  163. });
  164. it('未登录不落盘(避免下一个账号读到上一个账号的群聊)', () => {
  165. localStorage.clear();
  166. TestBed.inject(SessionService).setSession(null as never, null as never);
  167. store.resetForTest();
  168. store.appendMine('g1', '派活', ['a']);
  169. // 内存里有了,但群聊的存储键不该出现
  170. expect(store.entriesOf('g1').length).toBe(1);
  171. const groupKeys = Object.keys(localStorage).filter((k) => k.startsWith('fmode_group_entries_v1_'));
  172. expect(groupKeys).toEqual([]);
  173. });
  174. });
  175. describe('isFreshReply', () => {
  176. it('非实时(历史回填)→ 拒绝', () => {
  177. expect(isFreshReply(false, 1000, 0)).toBe(false);
  178. });
  179. it('实时且晚于水位 → 接受', () => {
  180. expect(isFreshReply(true, 2000, 1000)).toBe(true);
  181. });
  182. it('🔴 实时但早于水位(向上翻页的旧事件)→ 拒绝', () => {
  183. expect(isFreshReply(true, 500, 1000)).toBe(false);
  184. });
  185. it('无水位约束时只看 live', () => {
  186. expect(isFreshReply(true, 1, 0)).toBe(true);
  187. });
  188. });
  189. });
  190. describe('trimByTurn', () => {
  191. const mk = (turnId: string, from: string | null, at: number): GroupEntry => ({
  192. id: `${turnId}-${from ?? 'me'}-${at}`,
  193. turnId,
  194. fromAgentId: from,
  195. at,
  196. message: {
  197. id: `${turnId}-${from ?? 'me'}-${at}`,
  198. conversationId: 'g',
  199. sender: from ? 'agent' : 'me',
  200. kind: 'text',
  201. text: 'x',
  202. ts: new Date(at).toISOString(),
  203. state: 'sent',
  204. },
  205. });
  206. it('不超限原样返回', () => {
  207. const list = [mk('t1', null, 1), mk('t1', 'a', 2)];
  208. expect(trimByTurn(list, 10)).toBe(list);
  209. });
  210. it('🔴 按回合整组裁剪,不留孤儿回复', () => {
  211. const list = [
  212. mk('t1', null, 1),
  213. mk('t1', 'a', 2),
  214. mk('t1', 'b', 3),
  215. mk('t2', null, 4),
  216. mk('t2', 'a', 5),
  217. ];
  218. // 上限 3:t2 整组(2 条)保留,t1 整组丢弃 —— 不会剩半组
  219. const out = trimByTurn(list, 3);
  220. expect(out.every((e) => e.turnId === 't2')).toBe(true);
  221. expect(out.length).toBe(2);
  222. });
  223. it('保留最近的回合', () => {
  224. const list = [
  225. mk('t1', null, 1),
  226. mk('t1', 'a', 2),
  227. mk('t2', null, 3),
  228. mk('t2', 'a', 4),
  229. ];
  230. const out = trimByTurn(list, 2);
  231. expect(out.map((e) => e.turnId)).toEqual(['t2', 't2']);
  232. });
  233. it('单个回合超上限时仍保留(不能整组删空)', () => {
  234. const list = [mk('t1', null, 1), mk('t1', 'a', 2), mk('t1', 'b', 3)];
  235. expect(trimByTurn(list, 1).length).toBe(3);
  236. });
  237. });
  238. describe('未加载时不覆盖已有数据(评审 🔴1 回归)', () => {
  239. it('未调用 load 就写入 → 不落盘,旧数据保留', () => {
  240. localStorage.clear();
  241. TestBed.resetTestingModule();
  242. TestBed.configureTestingModule({ providers: [SessionService, GroupStoreService] });
  243. TestBed.inject(SessionService).setSession('r:t', { id: 'u9', name: 'x' } as never);
  244. const s = TestBed.inject(GroupStoreService);
  245. s.resetForTest();
  246. // 先手工放一份「上次的历史」
  247. const old = {
  248. entries: {
  249. g1: [
  250. {
  251. id: 'me-1',
  252. turnId: 'me-1',
  253. fromAgentId: null,
  254. at: 1,
  255. sessionId: 'g1',
  256. message: {
  257. id: 'me-1',
  258. conversationId: 'group',
  259. sender: 'me',
  260. kind: 'text',
  261. text: '旧消息',
  262. ts: new Date(1).toISOString(),
  263. state: 'sent',
  264. },
  265. },
  266. ],
  267. },
  268. targets: { 'me-1': ['a'] },
  269. };
  270. localStorage.setItem('fmode_group_entries_v1_u9', JSON.stringify(old));
  271. // 未 load 直接写 —— 必须被拒绝
  272. s.appendMine('g2', '新消息', ['b']);
  273. const raw = JSON.parse(localStorage.getItem('fmode_group_entries_v1_u9')!);
  274. expect(Object.keys(raw.entries)).toEqual(['g1']);
  275. expect(raw.entries.g1[0].message.text).toBe('旧消息');
  276. });
  277. });