| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419 |
- 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[]),
- };
- /** 桩: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<string, jasmine.Spy>)[k].calls.reset();
- }
- liveStub.ensureLoaded.calls.reset();
- liveStub.agentById.calls.reset();
- for (const k of Object.keys(mxStub)) {
- const v = (mxStub as Record<string, unknown>)[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('不存在');
- });
- });
- 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<MatrixProvision>((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<boolean>((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);
- });
- });
- });
|