| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311 |
- import { TestBed } from '@angular/core/testing';
- import { Message } from './api/models';
- 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('旧消息');
- });
- });
|