import { TestBed } from '@angular/core/testing'; import { Message } from './api/models'; import { ChatSessionService } from './chat-session.service'; import { GroupStoreService, MAX_ENTRIES_PER_GROUP, isFreshReply, trimByTurn, } from './group-store.service'; import { GroupEntry } from './group-turns'; import { SessionService } from './session.service'; function replyMsg(id: string, text = ''): Message { return { id, conversationId: 'a', sender: 'agent', kind: 'text', text, ts: new Date(Date.now()).toISOString(), state: 'sent', }; } describe('GroupStoreService', () => { let store: GroupStoreService; beforeEach(() => { localStorage.clear(); TestBed.configureTestingModule({ providers: [SessionService, GroupStoreService], }); TestBed.inject(SessionService).setSession('r:test', { id: 'u1', name: '用户', } as never); store = TestBed.inject(GroupStoreService); store.resetForTest(); // 模拟真实时序:页面先 load() 再写入(未加载时 persist 会拒绝写盘) store.load(); }); afterEach(() => localStorage.clear()); it('记录用户消息后能读到', () => { const turnId = store.appendMine('g1', '派活', ['a', 'b']); const list = store.entriesOf('g1'); expect(list.length).toBe(1); expect(list[0].turnId).toBe(turnId); expect(list[0].fromAgentId).toBeNull(); expect(store.targetsOf(turnId)).toEqual(['a', 'b']); }); it('用户消息 id 唯一(同毫秒也不冲突)', () => { const t = Date.now(); const id1 = store.appendMine('g1', '一', ['a'], t); const id2 = store.appendMine('g1', '二', ['a'], t); expect(id1).not.toBe(id2); expect(store.entriesOf('g1').length).toBe(2); }); it('回复归入最近一次提问', () => { const turnId = store.appendMine('g1', '派活', ['a']); const got = store.appendReply('g1', 'a', replyMsg('e1'), Date.now()); expect(got).toBe(turnId); expect(store.entriesOf('g1').length).toBe(2); }); it('回复可显式指定回合', () => { const t1 = store.appendMine('g1', '第一次', ['a']); const t2 = store.appendMine('g1', '第二次', ['a']); // 显式指定归到第一次提问(默认会归到最近的 t2) const got = store.appendReply('g1', 'a', replyMsg('e1'), Date.now(), t1); expect(got).toBe(t1); expect(store.entriesOf('g1').at(-1)!.turnId).toBe(t1); expect(t2).not.toBe(t1); }); it('🔴 同一 eventId 只入群一次(重复回复回归)', () => { store.appendMine('g1', '派活', ['a']); const now = Date.now(); expect(store.appendReply('g1', 'a', replyMsg('e1'), now)).not.toBeNull(); // 第二次同 id(resubscribe 后重新订阅、或翻页重复到达) expect(store.appendReply('g1', 'a', replyMsg('e1'), now + 100)).toBeNull(); expect(store.entriesOf('g1').length).toBe(2); }); it('没有前置提问时,回复被丢弃(不凭空造回合)', () => { expect(store.appendReply('g1', 'a', replyMsg('e1'), Date.now())).toBeNull(); expect(store.entriesOf('g1').length).toBe(0); }); it('按时间升序存储', () => { const t = Date.now(); store.appendMine('g1', '一', ['a'], t + 1000); store.appendMine('g1', '二', ['a'], t); expect(store.entriesOf('g1').map((e) => e.message.text)).toEqual(['二', '一']); }); it('不同会话互不干扰', () => { store.appendMine('g1', '群一', ['a']); store.appendMine('g2', '群二', ['b']); expect(store.entriesOf('g1').length).toBe(1); expect(store.entriesOf('g2').length).toBe(1); expect(store.entriesOf('g1')[0].message.text).toBe('群一'); }); it('超过上限时只裁剪**落盘副本**,内存表保持完整', () => { /* * 🔴 2026-11-07 语义变更:Matrix 是群历史真源,本地裁剪内存表会导致 * 「重建出来又被裁回去」。现在只在 persist 时裁写盘的那份。 */ const t = Date.now(); for (let i = 0; i < MAX_ENTRIES_PER_GROUP + 10; i++) { store.appendMine('g1', `m${i}`, ['a'], t + i); } const list = store.entriesOf('g1'); expect(list.length).toBe(MAX_ENTRIES_PER_GROUP + 10); expect(list[list.length - 1].message.text).toBe(`m${MAX_ENTRIES_PER_GROUP + 9}`); // 写盘的那份被裁剪 const raw = localStorage.getItem( Object.keys(localStorage).find((k) => k.startsWith('fmode_group_entries_v1_'))!, )!; const saved = JSON.parse(raw).entries.g1.length; expect(saved).toBe(MAX_ENTRIES_PER_GROUP); }); it('turnId 可复现:同一毫秒只追加序号,不再是内存自增', () => { const t = 1793952987639; const a = store.appendMine('g1', '一', ['a'], t); const b = store.appendMine('g1', '二', ['a'], t); expect(a).toBe(`me-${t}`); expect(b).toBe(`me-${t}-2`); }); it('Matrix 重建写回:与本地同 id 的条目不会重复', () => { const t = 1793952987639; const id = store.appendMine('g1', '问题', ['a'], t); const matrixEntries = [ { id, turnId: id, fromAgentId: null, message: { id, text: '问题' } as never, at: t }, { id: '$ev1', turnId: id, fromAgentId: 'a', message: { id: '$ev1' } as never, at: t + 100 }, ]; store.replaceFromMatrix('g1', [{ ...matrixEntries[0] }], { [id]: ['a'] }); store.appendReply('g1', 'a', matrixEntries[1].message, t + 100, id); store.appendReply('g1', 'a', matrixEntries[1].message, t + 100, id); const list = store.entriesOf('g1'); expect(list.filter((e) => e.fromAgentId === null).length).toBe(1); expect(list.filter((e) => e.id === '$ev1').length).toBe(1); }); it('appendUserFile 同名同大小只记一次', () => { const id = store.appendMine('g1', '看图', ['a', 'b'], 1000); const card = { kind: 'file' as const, name: 'a.png', size: 10, mimeType: 'image/png', url: 'blob:x' }; store.appendUserFile('g1', id, card, 1000, '$f1'); store.appendUserFile('g1', id, card, 1000, '$f2'); expect(store.entriesOf('g1').filter((e) => e.message.file).length).toBe(1); }); it('clear 清空指定会话', () => { store.appendMine('g1', '一', ['a']); store.appendMine('g2', '二', ['b']); store.clear('g1'); expect(store.entriesOf('g1').length).toBe(0); expect(store.entriesOf('g2').length).toBe(1); }); describe('持久化', () => { it('刷新后能恢复', () => { store.appendMine('g1', '派活', ['a', 'b']); store.appendReply('g1', 'a', replyMsg('e1', '收到'), Date.now()); const fresh = TestBed.inject(GroupStoreService); fresh.resetForTest(); fresh.load(); const list = fresh.entriesOf('g1'); expect(list.length).toBe(2); expect(list[1].message.text).toBe('收到'); }); it('坏 JSON 不抛错,退回空列表', () => { localStorage.setItem('fmode_group_entries_v1_u1', '{坏掉的'); expect(() => store.load()).not.toThrow(); expect(store.entriesOf('g1').length).toBe(0); }); it('未登录不落盘(避免下一个账号读到上一个账号的群聊)', () => { localStorage.clear(); TestBed.inject(SessionService).setSession(null as never, null as never); store.resetForTest(); store.appendMine('g1', '派活', ['a']); // 内存里有了,但群聊的存储键不该出现 expect(store.entriesOf('g1').length).toBe(1); const groupKeys = Object.keys(localStorage).filter((k) => k.startsWith('fmode_group_entries_v1_')); expect(groupKeys).toEqual([]); }); }); describe('isFreshReply', () => { it('非实时(历史回填)→ 拒绝', () => { expect(isFreshReply(false, 1000, 0)).toBe(false); }); it('实时且晚于水位 → 接受', () => { expect(isFreshReply(true, 2000, 1000)).toBe(true); }); it('🔴 实时但早于水位(向上翻页的旧事件)→ 拒绝', () => { expect(isFreshReply(true, 500, 1000)).toBe(false); }); it('无水位约束时只看 live', () => { expect(isFreshReply(true, 1, 0)).toBe(true); }); }); }); describe('trimByTurn', () => { const mk = (turnId: string, from: string | null, at: number): GroupEntry => ({ id: `${turnId}-${from ?? 'me'}-${at}`, turnId, fromAgentId: from, at, message: { id: `${turnId}-${from ?? 'me'}-${at}`, conversationId: 'g', sender: from ? 'agent' : 'me', kind: 'text', text: 'x', ts: new Date(at).toISOString(), state: 'sent', }, }); it('不超限原样返回', () => { const list = [mk('t1', null, 1), mk('t1', 'a', 2)]; expect(trimByTurn(list, 10)).toBe(list); }); it('🔴 按回合整组裁剪,不留孤儿回复', () => { const list = [ mk('t1', null, 1), mk('t1', 'a', 2), mk('t1', 'b', 3), mk('t2', null, 4), mk('t2', 'a', 5), ]; // 上限 3:t2 整组(2 条)保留,t1 整组丢弃 —— 不会剩半组 const out = trimByTurn(list, 3); expect(out.every((e) => e.turnId === 't2')).toBe(true); expect(out.length).toBe(2); }); it('保留最近的回合', () => { const list = [ mk('t1', null, 1), mk('t1', 'a', 2), mk('t2', null, 3), mk('t2', 'a', 4), ]; const out = trimByTurn(list, 2); expect(out.map((e) => e.turnId)).toEqual(['t2', 't2']); }); it('单个回合超上限时仍保留(不能整组删空)', () => { const list = [mk('t1', null, 1), mk('t1', 'a', 2), mk('t1', 'b', 3)]; expect(trimByTurn(list, 1).length).toBe(3); }); }); describe('未加载时不覆盖已有数据(评审 🔴1 回归)', () => { it('未调用 load 就写入 → 不落盘,旧数据保留', () => { localStorage.clear(); TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [SessionService, GroupStoreService] }); TestBed.inject(SessionService).setSession('r:t', { id: 'u9', name: 'x' } as never); const s = TestBed.inject(GroupStoreService); s.resetForTest(); // 先手工放一份「上次的历史」 const old = { entries: { g1: [ { id: 'me-1', turnId: 'me-1', fromAgentId: null, at: 1, sessionId: 'g1', message: { id: 'me-1', conversationId: 'group', sender: 'me', kind: 'text', text: '旧消息', ts: new Date(1).toISOString(), state: 'sent', }, }, ], }, targets: { 'me-1': ['a'] }, }; localStorage.setItem('fmode_group_entries_v1_u9', JSON.stringify(old)); // 未 load 直接写 —— 必须被拒绝 s.appendMine('g2', '新消息', ['b']); const raw = JSON.parse(localStorage.getItem('fmode_group_entries_v1_u9')!); expect(Object.keys(raw.entries)).toEqual(['g1']); expect(raw.entries.g1[0].message.text).toBe('旧消息'); }); }); /** * 群会话的成果链接写入(2026-11-10) * * 原来群会话的 `deliverableUrls` 永远是空的,右栏「成果」只能退化成 * 「按成员容器拉全量」,成员在别的单聊里产出的文件也会混进来。 */ describe('GroupStoreService · 成果链接写入群会话', () => { let store: GroupStoreService; beforeEach(() => { TestBed.configureTestingModule({ providers: [SessionService, GroupStoreService] }); store = TestBed.inject(GroupStoreService); }); function fileReply(id: string, url: string, cloudUrl?: string): Message { return { ...replyMsg(id), file: { kind: 'file', name: 'r.md', size: 1, mimeType: 'text/markdown', url, cloudUrl }, }; } it('🔴 容器产出(/deliverables/)→ 写进群会话成果', () => { const spy = spyOn(TestBed.inject(ChatSessionService), 'addDeliverableUrl'); store.appendMine('g1', '做个报告', ['a']); store.appendReply('g1', 'a', fileReply('e1', 'https://s3.fmode.cn/deliverables/a/202611/r.md'), Date.now()); expect(spy).toHaveBeenCalledWith('g1', 'https://s3.fmode.cn/deliverables/a/202611/r.md'); }); it('🔴 用户上传的链接(/user/…/chat/…)→ 不写入成果', () => { const spy = spyOn(TestBed.inject(ChatSessionService), 'addDeliverableUrl'); store.appendMine('g1', '看这个', ['a']); store.appendReply('g1', 'a', fileReply('e1', 'https://s3.fmode.cn/user/u/chat/202611/x.png'), Date.now()); expect(spy).not.toHaveBeenCalled(); }); it('优先使用云盘地址(cloudUrl)', () => { const spy = spyOn(TestBed.inject(ChatSessionService), 'addDeliverableUrl'); store.appendMine('g1', '做个报告', ['a']); store.appendReply( 'g1', 'a', fileReply('e1', 'https://matrix.fmode.cn/_matrix/media/x', 'https://s3.fmode.cn/deliverables/a/b.md'), Date.now(), ); expect(spy).toHaveBeenCalledWith('g1', 'https://s3.fmode.cn/deliverables/a/b.md'); }); it('没有文件 / 没有 url 的回复不写入', () => { const spy = spyOn(TestBed.inject(ChatSessionService), 'addDeliverableUrl'); store.appendMine('g1', '你好', ['a']); store.appendReply('g1', 'a', replyMsg('e1', '纯文本'), Date.now()); expect(spy).not.toHaveBeenCalled(); }); });