import { TestBed } from '@angular/core/testing'; import { ChatService } from './chat.service'; import { FmodeApi } from './api/fmode-api'; import { LiveService } from './live.service'; import { MatrixClientService, MxMessage } from './matrix-client.service'; import { SessionService } from './session.service'; import { UnreadService } from './unread.service'; import { Agent, MatrixProvision } from './api/models'; import { environment } from '../../environments/environment'; /** * ChatService 单测 —— 会话状态机(SPEC FR-04) * * 覆盖:provision → 连接 → 房间 → 收发 → 分页 的编排与失败分环节报错。 * 依赖(FmodeApi / LiveService / MatrixClientService)全部用桩替换, * **不触网、不加载 matrix-js-sdk**。 * * 🔴 重点回归: * ① 切会话时 `seq` 防串扰(过期回包必须被丢弃) * ② `close()` **不断开**会话级连接(未读依赖 sync 常驻) * ③ 失败时**分环节**报错(FMODE 开通 / Matrix 登录 / 房间创建) * ④ `paginate()` 防重入(滚动事件高频触发) */ describe('ChatService', () => { let svc: ChatService; /** 桩:FmodeApi */ const apiStub = { provisionMatrix: jasmine.createSpy('provisionMatrix'), validateMatrix: jasmine.createSpy('validateMatrix'), fetchAgents: jasmine.createSpy('fetchAgents'), uploadFile: jasmine.createSpy('uploadFile'), requestUploadUrl: jasmine.createSpy('requestUploadUrl'), }; /** 桩:LiveService */ const liveStub = { ensureLoaded: jasmine.createSpy('ensureLoaded').and.returnValue(Promise.resolve()), agentById: jasmine.createSpy('agentById'), agents: jasmine.createSpy('agents').and.returnValue([] as Agent[]), /** 加载状态(chat.open 用它区分「加载中」与「确实不存在」) */ state: jasmine.createSpy('state').and.returnValue('ready'), error: jasmine.createSpy('error').and.returnValue(null), }; /** 桩:MatrixClientService */ const mxStub = { connectSession: jasmine.createSpy('connectSession').and.returnValue(Promise.resolve()), disconnect: jasmine.createSpy('disconnect'), ensureRoom: jasmine.createSpy('ensureRoom'), syncState: jasmine.createSpy('syncState').and.returnValue('connected'), hasInitialSync: true, paginationToken: jasmine.createSpy('paginationToken').and.returnValue('tok-b'), timelineToMessages: jasmine.createSpy('timelineToMessages').and.returnValue([] as MxMessage[]), onMessage: jasmine.createSpy('onMessage').and.returnValue(() => undefined), onAnyMessage: jasmine.createSpy('onAnyMessage').and.returnValue(() => undefined), onTyping: jasmine.createSpy('onTyping').and.returnValue(() => undefined), sendText: jasmine.createSpy('sendText').and.returnValue(Promise.resolve({ event_id: '$e' })), setTyping: jasmine.createSpy('setTyping').and.returnValue(Promise.resolve()), paginateBack: jasmine.createSpy('paginateBack').and.returnValue(Promise.resolve(true)), }; const PROV: MatrixProvision = { cached: true, bindingId: 'b1', status: 'active', userMxId: '@sr2wipsdyq:fmode.cn', agentMxId: '@zgdcmgnqcd:fmode.cn', homeserver: 'https://matrix.fmode.cn', accessToken: 'tok', deviceId: 'DEV1', roomId: '!room1:fmode.cn', }; const AGENT: Agent = { id: 'agent-node-lj-test', name: 'lj-test', status: 'online' as Agent['status'], matrixReady: true, unread: 0, }; beforeEach(() => { localStorage.clear(); for (const k of Object.keys(apiStub)) { (apiStub as Record)[k].calls.reset(); } liveStub.ensureLoaded.calls.reset(); liveStub.agentById.calls.reset(); for (const k of Object.keys(mxStub)) { const v = (mxStub as Record)[k]; if (v && typeof (v as jasmine.Spy).calls === 'object') (v as jasmine.Spy).calls.reset(); } mxStub.connectSession.and.returnValue(Promise.resolve()); mxStub.ensureRoom.and.returnValue(Promise.resolve('!room1:fmode.cn')); mxStub.syncState.and.returnValue('connected'); mxStub.paginationToken.and.returnValue('tok-b'); mxStub.timelineToMessages.and.returnValue([]); mxStub.onMessage.and.returnValue(() => undefined); mxStub.onAnyMessage.and.returnValue(() => undefined); mxStub.onTyping.and.returnValue(() => undefined); mxStub.sendText.and.returnValue(Promise.resolve({ event_id: '$e' })); mxStub.paginateBack.and.returnValue(Promise.resolve(true)); liveStub.agentById.and.returnValue(AGENT); apiStub.provisionMatrix.and.returnValue(Promise.resolve(PROV)); TestBed.configureTestingModule({ providers: [ ChatService, { provide: FmodeApi, useValue: apiStub }, { provide: LiveService, useValue: liveStub }, { provide: MatrixClientService, useValue: mxStub }, { provide: SessionService, useValue: { me: () => ({ id: 'u1' }), token: () => 'r:t' } }, { provide: UnreadService, useClass: UnreadService }, ], }); svc = TestBed.inject(ChatService); }); afterEach(() => localStorage.clear()); /** 测试统一走真实态(演示态由 MockApi 覆盖,另测) */ function realMode(): void { (environment as { useMockData: boolean }).useMockData = false; } describe('open() 真实态编排', () => { it('provision → connectSession → ensureRoom 顺序执行', async () => { realMode(); const order: string[] = []; apiStub.provisionMatrix.and.callFake(() => { order.push('provision'); return Promise.resolve(PROV); }); mxStub.connectSession.and.callFake(() => { order.push('connect'); return Promise.resolve(); }); mxStub.ensureRoom.and.callFake(() => { order.push('room'); return Promise.resolve('!room1:fmode.cn'); }); await svc.open('agent-node-lj-test'); expect(order).toEqual(['provision', 'connect', 'room']); expect(svc.agentId()).toBe('agent-node-lj-test'); expect(svc.connection()).toBe('connected'); }); it('空房间 → state=empty(不是 error)', async () => { realMode(); mxStub.timelineToMessages.and.returnValue([]); await svc.open('agent-node-lj-test'); expect(svc.state()).toBe('empty'); }); it('有消息 → state=ready 且按 sender 区分 me/agent', async () => { realMode(); mxStub.timelineToMessages.and.returnValue([ { roomId: 'r', eventId: '$1', sender: '@sr2wipsdyq:fmode.cn', body: '我发的', ts: 1, kind: 'text', live: false }, { roomId: 'r', eventId: '$2', sender: '@zgdcmgnqcd:fmode.cn', body: '它回的', ts: 2, kind: 'text', live: false }, ]); await svc.open('agent-node-lj-test'); expect(svc.state()).toBe('ready'); expect(svc.messages().map((m) => m.sender)).toEqual(['me', 'agent']); expect(svc.messages()[0].text).toBe('我发的'); }); it('数字生命不存在 → 报明确错误', async () => { realMode(); liveStub.agentById.and.returnValue(undefined); await svc.open('nope'); expect(svc.state()).toBe('error'); expect(svc.error()).toContain('不存在'); }); it('🔴 数据仍在加载 → 不报「不存在」(刷新页面的真实场景)', async () => { realMode(); liveStub.agentById.and.returnValue(undefined); liveStub.state.and.returnValue('loading'); await svc.open('agent-node-lj-test'); expect(svc.error()).not.toContain('不存在'); expect(svc.error()).toContain('加载'); liveStub.state.and.returnValue('ready'); // 复位 }); it('🔴 数据加载失败 → 报加载失败而非「不存在」', async () => { realMode(); liveStub.agentById.and.returnValue(undefined); liveStub.state.and.returnValue('error'); liveStub.error.and.returnValue('网络异常'); await svc.open('agent-node-lj-test'); expect(svc.error()).toContain('加载失败'); expect(svc.error()).toContain('网络异常'); liveStub.state.and.returnValue('ready'); liveStub.error.and.returnValue(null); }); }); describe('失败分环节报错(FR-04:不得笼统)', () => { it('provision 失败 → 文案含「FMODE 开通失败」', async () => { realMode(); apiStub.provisionMatrix.and.returnValue(Promise.reject(new Error('SessionToken 无效'))); await svc.open('a'); expect(svc.error()).toContain('FMODE 开通失败'); expect(svc.error()).toContain('SessionToken 无效'); }); it('SDK 登录失败 → 文案含「Matrix 登录失败」', async () => { realMode(); mxStub.connectSession.and.returnValue(Promise.reject(new Error('凭据不完整'))); await svc.open('a'); expect(svc.error()).toContain('Matrix 登录失败'); }); it('房间创建失败 → 文案含「房间创建失败」', async () => { realMode(); mxStub.ensureRoom.and.returnValue(Promise.reject(new Error('403'))); await svc.open('a'); expect(svc.error()).toContain('房间创建失败'); }); it('失败时 connection=disconnected(不假装连着)', async () => { realMode(); apiStub.provisionMatrix.and.returnValue(Promise.reject(new Error('x'))); await svc.open('a'); expect(svc.connection()).toBe('disconnected'); }); }); describe('seq 防串扰(切会话丢弃过期回包)', () => { it('慢的旧请求返回时不覆盖新会话', async () => { realMode(); let resolveSlow: (v: MatrixProvision) => void = () => undefined; apiStub.provisionMatrix.and.callFake((id: string) => id === 'slow' ? new Promise((res) => (resolveSlow = res)) : Promise.resolve(PROV), ); mxStub.ensureRoom.and.callFake(() => Promise.resolve('!room1:fmode.cn')); const slowOpen = svc.open('slow'); // 悬而未决 await svc.open('fast'); // 后发先至,成为当前会话 resolveSlow(PROV); // 旧请求此刻才返回 await slowOpen; // 当前会话仍是 fast,未被 slow 的迟到回包覆盖 expect(svc.agentId()).toBe('fast'); }); }); describe('close() 必须**不断开**会话级连接', () => { it('close 后 sync 仍常驻(未读计数依赖)', async () => { realMode(); await svc.open('agent-node-lj-test'); svc.close(); // 🔴 关键:close 不得调用 disconnect —— 否则离开会话就收不到消息 expect(mxStub.disconnect).not.toHaveBeenCalled(); expect(svc.agentId()).toBeNull(); expect(svc.messages()).toEqual([]); expect(svc.connection()).toBe('disconnected'); // 仅 UI 状态复位 }); it('close 会解绑本房间的事件订阅(防串扰)', async () => { realMode(); const unbindMsg = jasmine.createSpy('unbindMsg'); const unbindTyping = jasmine.createSpy('unbindTyping'); mxStub.onMessage.and.returnValue(unbindMsg); mxStub.onTyping.and.returnValue(unbindTyping); await svc.open('a'); svc.close(); expect(unbindMsg).toHaveBeenCalled(); expect(unbindTyping).toHaveBeenCalled(); }); }); describe('sendText(AC-04.2 / AC-04.8)', () => { beforeEach(async () => { realMode(); await svc.open('agent-node-lj-test'); }); it('乐观插入 → 调用 SDK 发送 → 标记 sent', async () => { await svc.sendText(' 你好 '); const last = svc.messages().at(-1)!; expect(last.text).toBe('你好'); // 已 trim expect(last.state).toBe('sent'); expect(mxStub.sendText).toHaveBeenCalledWith('!room1:fmode.cn', '你好'); }); it('发送后清空草稿', async () => { svc.setDraft('待发'); await svc.sendText('待发'); expect(svc.draft()).toBe(''); }); it('空文本不发送(也不插气泡)', async () => { const before = svc.messages().length; await svc.sendText(' '); expect(svc.messages().length).toBe(before); expect(mxStub.sendText).not.toHaveBeenCalled(); }); it('失败 → 留痕 failed 且带错误原因(不丢消息)', async () => { mxStub.sendText.and.returnValue(Promise.reject(new Error('网络错误'))); await svc.sendText('会失败'); const last = svc.messages().at(-1)!; expect(last.state).toBe('failed'); expect(last.error).toContain('网络错误'); }); it('retry 把 failed 消息重发并复位为 sent', async () => { mxStub.sendText.and.returnValue(Promise.reject(new Error('临时故障'))); await svc.sendText('重试我'); const failed = svc.messages().at(-1)!; expect(failed.state).toBe('failed'); mxStub.sendText.and.returnValue(Promise.resolve({ event_id: '$ok' })); await svc.retry(failed.id); const after = svc.messages().find((m) => m.id === failed.id)!; expect(after.state).toBe('sent'); expect(after.error).toBeUndefined(); }); it('非 failed 状态的消息 retry 不做事', async () => { await svc.sendText('已成功的'); const ok = svc.messages().at(-1)!; mxStub.sendText.calls.reset(); await svc.retry(ok.id); expect(mxStub.sendText).not.toHaveBeenCalled(); }); }); describe('paginate(AC-04.4 翻历史)', () => { beforeEach(async () => { realMode(); await svc.open('agent-node-lj-test'); }); it('分页后条数增长 → hasMoreHistory=true', async () => { // 注:open() 已调用过一次 timelineToMessages(返回 []); // paginate() 只再调用一次,故此处用 returnValue(单值)而非 returnValues。 mxStub.timelineToMessages.and.returnValue( Array.from({ length: 5 }, (_, i) => ({ roomId: 'r', eventId: `$h${i}`, sender: '@zgdcmgnqcd:fmode.cn', body: `历史${i}`, ts: i, kind: 'text' as const, live: false, })), ); const grew = await svc.paginate(); expect(grew).toBeTrue(); expect(svc.messages().length).toBe(5); expect(svc.hasMoreHistory()).toBeTrue(); }); it('条数未增长 → hasMoreHistory=false(到最早了,停止请求)', async () => { mxStub.timelineToMessages.and.returnValue([]); const grew = await svc.paginate(); expect(grew).toBeFalse(); expect(svc.hasMoreHistory()).toBeFalse(); }); it('🔴 防重入:并发调用只触发一次 SDK 分页', async () => { let resolveIt: (v: boolean) => void = () => undefined; mxStub.paginateBack.and.callFake(() => new Promise((res) => (resolveIt = res))); const a = svc.paginate(); const b = svc.paginate(); // 立刻重入 resolveIt(true); await Promise.all([a, b]); expect(mxStub.paginateBack.calls.count()).toBe(1); }); it('paginating 在完成后复位', async () => { await svc.paginate(); expect(svc.paginating()).toBeFalse(); }); }); describe('setDraft 触发 typing 通知', () => { it('有内容 → typing=true,清空 → typing=false', async () => { realMode(); await svc.open('a'); svc.setDraft('在输入'); expect(mxStub.setTyping).toHaveBeenCalledWith('!room1:fmode.cn', true); svc.setDraft(''); expect(mxStub.setTyping).toHaveBeenCalledWith('!room1:fmode.cn', false); }); }); describe('sendFiles(AC-04.3)', () => { it('上传成功后追加文件卡片消息', async () => { realMode(); await svc.open('a'); apiStub.uploadFile.and.returnValue( Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' }), ); const f = new File(['x'], 'doc.txt', { type: 'text/plain' }); await svc.sendFiles([f]); const last = svc.messages().at(-1)!; expect(last.file?.name).toBe('doc.txt'); expect(last.file?.url).toBe('https://s3.fmode.cn/k'); expect(svc.uploading()).toBeFalse(); }); it('上传失败 → 记录错误且不上传中', async () => { realMode(); await svc.open('a'); apiStub.uploadFile.and.returnValue(Promise.reject(new Error('签名获取失败'))); await svc.sendFiles([new File(['x'], 'a.txt', { type: 'text/plain' })]); expect(svc.error()).toContain('签名获取失败'); expect(svc.uploading()).toBeFalse(); }); }); describe('进入会话即已读(清未读计数)', () => { it('open 成功后该 agent 未读归零', async () => { realMode(); const unread = TestBed.inject(UnreadService); // 先造出未读 unread.onIncoming('agent-node-lj-test', 1000, true); unread.onIncoming('agent-node-lj-test', 2000, false); expect(unread.countOf('agent-node-lj-test')).toBe(1); await svc.open('agent-node-lj-test'); expect(unread.countOf('agent-node-lj-test')).toBe(0); }); }); });