group-store.service.spec.ts 13 KB

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