import { TestBed } from '@angular/core/testing'; import { ChatSessionService } from './chat-session.service'; import { FmodeApi } from './api/fmode-api'; import { SessionService } from './session.service'; import { legacySessionId } from './chat-session'; /** * ChatSessionService —— 会话目录的唯一 owner。 * * 覆盖:新建 / 归档 / 重命名 / 移除黑名单 / 云端单向同步 / 持久化。 * 这些是「多会话」的数据底座,错一个字段用户就会看到串号的对话名。 */ /** * 以「带 FmodeApi 桩」的方式构造服务实例。 * * 文件级声明:多个 describe 都要用它构造独立实例, * 每次重建都拿到干净的 signal 状态(`resetTestingModule`)。 */ function withApiStub(api: Record): ChatSessionService { TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ ChatSessionService, { provide: SessionService, useValue: { me: () => ({ id: 'u1' }) } }, { provide: FmodeApi, useValue: api }, ], }); const s2 = TestBed.inject(ChatSessionService); s2.load(); return s2; } describe('ChatSessionService', () => { let svc: ChatSessionService; beforeEach(() => { localStorage.clear(); TestBed.configureTestingModule({ providers: [ ChatSessionService, { provide: SessionService, useValue: { me: () => ({ id: 'u1' }) } }, ], }); svc = TestBed.inject(ChatSessionService); svc.reset(); }); afterEach(() => localStorage.clear()); const ROOM = '!room1:fmode.cn'; const AGENT = 'agent-a'; it('新建会话:标题默认「新对话」、未归档、计数 0', () => { const s = svc.create(ROOM, AGENT); expect(s.title).toBe('新对话'); expect(s.archived).toBeFalse(); expect(s.messageCount).toBe(0); expect(s.roomId).toBe(ROOM); expect(s.agentId).toBe(AGENT); }); it('create 生成独立线程根、同容器可并存多条', () => { const a = svc.create(ROOM, AGENT); const b = svc.create(ROOM, AGENT); expect(b.id).not.toBe(a.id); expect(svc.listFor(AGENT).length).toBe(2); }); it('为什么服务端认为是同一个 agent 的不同容器 —— 会话互不可见', () => { svc.create(ROOM, 'agent-a'); svc.create(ROOM, 'agent-b'); expect(svc.listFor('agent-a').length).toBe(1); expect(svc.listFor('agent-b').length).toBe(1); }); it('归档后默认列表不含它,但可按 id 取到(链接可打开)', () => { const s = svc.create(ROOM, AGENT); svc.archive(s.id); expect(svc.listFor(AGENT)).toEqual([]); expect(svc.listFor(AGENT, { includeArchived: true }).length).toBe(1); expect(svc.byId(s.id)?.archived).toBeTrue(); }); it('取消归档后回到列表', () => { const s = svc.create(ROOM, AGENT); svc.archive(s.id); svc.archive(s.id, false); expect(svc.listFor(AGENT).length).toBe(1); }); it('重命名会锁定标题,之后消息不再覆盖', () => { const s = svc.create(ROOM, AGENT); svc.rename(s.id, ' 我的任务 '); const renamed = svc.byId(s.id)!; expect(renamed.title).toBe('我的任务'); expect(renamed.titleLocked).toBeTrue(); svc.noteMessage(s.id, { text: '新消息内容', ts: Date.now(), fromMe: true }); expect(svc.byId(s.id)!.title).toBe('我的任务'); }); it('重命名空字符串被忽略', () => { const s = svc.create(ROOM, AGENT); svc.rename(s.id, ' '); expect(svc.byId(s.id)!.title).toBe('新对话'); }); it('用户首条消息推导标题', () => { const s = svc.create(ROOM, AGENT); svc.noteMessage(s.id, { text: '帮我做个登录页', ts: 2000, fromMe: true }); expect(svc.byId(s.id)!.title).toBe('做个登录页'); }); describe('remove(用户主动移除)', () => { it('移除后从列表消失', () => { const s = svc.create(ROOM, AGENT); svc.remove(s.id); expect(svc.byId(s.id)).toBeUndefined(); }); it('🔴 移除的线程不会被下一次云端拉取复活', async () => { const svc2 = withApiStub({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => o?.includeDeleted ? [] : [ { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$gone', title: '被移除的对话', titleLocked: false, messageCount: 5, lastMessageAt: 100, archived: false, lastPreview: '', }, ], upsertChatThreads: async () => undefined, removeChatThread: async () => undefined, }); svc2.adopt({ id: '$gone', roomId: ROOM, agentId: AGENT }); svc2.remove('$gone'); await svc2.hydrateFromCloud(AGENT); expect(svc2.byId('$gone')).withContext('被移除的线程又回来了').toBeUndefined(); }); it('isRemoved 能查到移除记录', () => { const s = svc.create(ROOM, AGENT); expect(svc.isRemoved(s.id)).toBeFalse(); svc.remove(s.id); expect(svc.isRemoved(s.id)).toBeTrue(); }); it('移除记录会持久化', () => { const s = svc.create(ROOM, AGENT); svc.remove(s.id); svc.reset(); svc.load(); expect(svc.isRemoved(s.id)).withContext('重载后黑名单丢了').toBeTrue(); }); }); describe('历史对话(承接无 thread 旧消息)', () => { it('首次创建返回 created=true', () => { const r = svc.adoptLegacy(ROOM, AGENT, '旧消息预览'); expect(r.created).toBeTrue(); expect(r.session.id).toBe(legacySessionId(ROOM)); expect(r.session.title).toBe('历史对话'); expect(r.session.legacy).toBeTrue(); expect(r.session.titleLocked).withContext('历史会话不改名').toBeTrue(); }); it('幂等:重复调用返回 created=false 且不重复添加', () => { svc.adoptLegacy(ROOM, AGENT); const again = svc.adoptLegacy(ROOM, AGENT); expect(again.created).toBeFalse(); expect(svc.listFor(AGENT).length).toBe(1); }); it('不同房间的历史会话 id 不同', () => { expect(legacySessionId('!a:x')).not.toBe(legacySessionId('!b:x')); }); }); describe('持久化', () => { it('新建后写入 localStorage(v2 缓存键,按用户隔离)', () => { const s = svc.create(ROOM, AGENT); const raw = localStorage.getItem('fmode_chat_sessions_v2_u1'); expect(raw).withContext('未按用户隔离存储').toBeTruthy(); expect(JSON.parse(raw!)[0].id).toBe(s.id); }); it('重新载入能恢复会话', () => { const s = svc.create(ROOM, AGENT); svc.reset(); svc.load(); expect(svc.byId(s.id)).toBeDefined(); }); // 换账号不能看到上一个账号的会话 it('🔴 换用户后不串号', () => { svc.create(ROOM, AGENT); expect(svc.all().length).toBe(1); // 切换成另一个用户 TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ ChatSessionService, { provide: SessionService, useValue: { me: () => ({ id: 'u2' }) } }, ], }); const other = TestBed.inject(ChatSessionService); other.load(); expect(other.all()).withContext('读到了别人的会话').toEqual([]); }); it('坏 JSON 不影响后续使用(降级为空列表)', () => { localStorage.setItem('fmode_chat_sessions_u1', '{{{ 不是 json'); svc.reset(); expect(() => svc.load()).not.toThrow(); expect(svc.all()).toEqual([]); }); it('缺字段的脏数据被过滤', () => { localStorage.setItem( 'fmode_chat_sessions_v2_u1', JSON.stringify([{ id: '$x' }, { id: '$ok', roomId: '!r:x', agentId: 'a', title: 't', createdAt: 1, updatedAt: 1 }]), ); svc.reset(); svc.load(); expect(svc.all().length).toBe(1); expect(svc.all()[0].id).toBe('$ok'); }); }); describe('threadIdsOf', () => { it('返回该房间下所有会话的线程根', () => { const a = svc.create(ROOM, AGENT); const b = svc.create(ROOM, 'agent-b'); svc.create('!other:x', AGENT); const ids = svc.threadIdsOf(ROOM); expect(ids.has(a.id)).toBeTrue(); expect(ids.has(b.id)).toBeTrue(); expect(ids.size).toBe(2); }); }); describe('消息推进必须同步云端(2026-11-01)', () => { const ROOM = '!room1:fmode.cn'; const AGENT = 'agent-a'; beforeEach(() => localStorage.clear()); /* * 🔴 回归:历史会话名称加载不对,显示成「新对话」。 * * 根因:`noteMessage` 只写本地,标题/计数/时间从不推云端。 * 云端那条记录永远停在「新对话 / 0 条」,换设备或重新拉列表就退回默认名。 */ it('🔴 首条消息确定标题后必须推送到云端', async () => { const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve()); const svc2 = withApiStub({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []), upsertChatThreads: upsert, }); await svc2.hydrateFromCloud(AGENT); const s = svc2.create(ROOM, AGENT); upsert.calls.reset(); svc2.noteMessage(s.id, { text: '帮我做个登录页', ts: Date.now(), fromMe: true }); expect(upsert).withContext('标题变了必须推云端,否则换设备退回「新对话」').toHaveBeenCalled(); const pushed = upsert.calls.mostRecent().args[0][0]; expect(pushed.title).toBe('做个登录页'); expect(pushed.threadRoot).toBe(s.id); }); it('同一会话短时间内重复消息不重复打接口(节流)', async () => { const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve()); const svc2 = withApiStub({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []), upsertChatThreads: upsert, }); await svc2.hydrateFromCloud(AGENT); const s = svc2.create(ROOM, AGENT); upsert.calls.reset(); // 首条定标题(触发一次) svc2.noteMessage(s.id, { text: '第一条', ts: 1000, fromMe: true }); const afterFirst = upsert.calls.count(); // 紧随其后的几条只改计数,不应再推 svc2.noteMessage(s.id, { text: '第二条', ts: 1001, fromMe: true }); svc2.noteMessage(s.id, { text: '第三条', ts: 1002, fromMe: true }); expect(upsert.calls.count()).withContext('计数变化不该每条都打接口').toBe(afterFirst); }); }); describe('工作区归属(2026-11-01 上云)', () => { const ROOM = '!room1:fmode.cn'; const AGENT = 'agent-a'; const WS = 'ws-1'; beforeEach(() => localStorage.clear()); /* * 🔴 回归:用户反馈「在工作区里新建对话,结果跑到下面日常对话里去了」。 * * 根因:归属只存本地,`hydrateFromCloud` 重建列表时靠本地缓存「捡」, * 捡不到就回落 null(日常)。现在云端是权威来源。 */ it('🔴 云端返回的 workspaceId 必须覆盖本地为空的归属', async () => { const svc2 = withApiStub({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => o?.includeDeleted ? [] : [ { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$w', title: 't', titleLocked: false, messageCount: 2, lastMessageAt: 9, archived: false, lastPreview: '', workspaceId: WS }, ], upsertChatThreads: async () => undefined, }); await svc2.hydrateFromCloud(AGENT); expect(svc2.byId('$w')?.workspaceId).withContext('归属丢了会掉进日常对话').toBe(WS); }); it('moveTo 会把归属推到云端', async () => { const patch = jasmine.createSpy('patch').and.returnValue(Promise.resolve()); const svc2 = withApiStub({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => o?.includeDeleted ? [] : [ { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$w', title: 't', titleLocked: false, messageCount: 2, lastMessageAt: 9, archived: false, lastPreview: '' }, ], upsertChatThreads: async () => undefined, patchChatThread: patch, }); await svc2.hydrateFromCloud(AGENT); svc2.moveTo('$w', WS); expect(patch).toHaveBeenCalledWith({ threadRoot: '$w', workspaceId: WS }); expect(svc2.byId('$w')?.workspaceId).toBe(WS); }); it('移回日常对话时云端收到空字符串(不是不传)', async () => { const patch = jasmine.createSpy('patch').and.returnValue(Promise.resolve()); const svc2 = withApiStub({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => o?.includeDeleted ? [] : [ { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$w', title: 't', titleLocked: false, messageCount: 2, lastMessageAt: 9, archived: false, lastPreview: '', workspaceId: WS }, ], upsertChatThreads: async () => undefined, patchChatThread: patch, }); await svc2.hydrateFromCloud(AGENT); svc2.moveTo('$w', null); expect(patch).toHaveBeenCalledWith({ threadRoot: '$w', workspaceId: '' }); expect(svc2.byId('$w')?.workspaceId).toBeNull(); }); }); describe('云端会话目录(2026-11-01 单向同步)', () => { const ROOM = '!room1:fmode.cn'; const AGENT = 'agent-a'; beforeEach(() => localStorage.clear()); /** 以「带 FmodeApi 桩」的方式重建服务(内部用;原 `withApi` 已改为文件级 `withApiStub`) */ function withApi(api: Record): ChatSessionService { return withApiStub(api); } it('云端有新会话、本地为空 → 重建到本地(换浏览器场景)', async () => { const svc2 = withApi({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => o?.includeDeleted ? [] : [ { objectId: 'x1', agentId: AGENT, roomId: ROOM, threadRoot: '$cloud1', title: '云端会话', titleLocked: false, messageCount: 3, lastMessageAt: 1000, archived: false, lastPreview: 'hi', }, ], upsertChatThreads: async () => undefined, }); await svc2.hydrateFromCloud(AGENT); expect(svc2.byId('$cloud1')?.title).toBe('云端会话'); expect(svc2.byId('$cloud1')?.messageCount).toBe(3); }); it('已移除的线程不会被云端复活', async () => { const svc2 = withApi({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => o?.includeDeleted ? [] : [ { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$gone', title: 'a', titleLocked: false, messageCount: 1, lastMessageAt: 1, archived: false, lastPreview: '' }, ], upsertChatThreads: async () => undefined, removeChatThread: async () => undefined, }); const s0 = svc2.create(ROOM, AGENT); svc2.remove(s0.id); await svc2.hydrateFromCloud(AGENT); expect(svc2.byId(s0.id)).toBeUndefined(); }); /* * 🔴 行为变更(2026-11-01):单向同步下**云端标题才是权威**。 * * 旧断言「本地锁定标题不被云端覆盖」在合并式同步下成立,但正是它允许了 * 「本地与云端各说各话」——改名推云端失败后,本地会永远保留一个云端不知道的标题。 * 现在:改名会立刻 `patchChatThread` 推云端,云端成功后自然以云端为准; * `titleLocked` 只用于防止「首条消息自动命名」覆盖用户手动改过的名字。 */ it('改名成功后标题以云端为准', async () => { let remoteTitle = '云端标题'; const svc2 = withApi({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => o?.includeDeleted ? [] : [ { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$t', title: remoteTitle, titleLocked: true, messageCount: 1, lastMessageAt: 5, archived: false, lastPreview: '' }, ], upsertChatThreads: async () => undefined, patchChatThread: async (i: { title?: string }) => { if (i.title) remoteTitle = i.title; }, }); svc2.adopt({ id: '$t', roomId: ROOM, agentId: AGENT }); svc2.rename('$t', '我改的标题'); await svc2.hydrateFromCloud(AGENT); expect(svc2.byId('$t')?.title).withContext('云端已更新,应展示新标题').toBe('我改的标题'); }); it('归档状态以云端为准', async () => { const svc2 = withApi({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => o?.includeDeleted ? [] : [ { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$a', title: 't', titleLocked: false, messageCount: 1, lastMessageAt: 9, archived: true, lastPreview: '' }, ], upsertChatThreads: async () => undefined, }); svc2.adopt({ id: '$a', roomId: ROOM, agentId: AGENT }); await svc2.hydrateFromCloud(AGENT); expect(svc2.byId('$a')?.archived).toBeTrue(); }); it('🔴 云端明确为空 → 本机同容器会话一并清掉(删除后不再复活)', async () => { const svc2 = withApi({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []), upsertChatThreads: async () => undefined, }); svc2.create(ROOM, AGENT); await svc2.hydrateFromCloud(AGENT); expect(svc2.listFor(AGENT, { includeArchived: true }).length).toBe(0); }); it('云端明确为空时 pushAll 不得把本地会话推回去', async () => { const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve()); const svc2 = withApi({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []), upsertChatThreads: upsert }); svc2.create(ROOM, AGENT); // create 自身会推一次(正常行为) await svc2.hydrateFromCloud(AGENT); // 云端为空 → 本地清空 const callsAfterCreate = upsert.calls.count(); await svc2.pushAll(AGENT); // 清空后不应再推 expect(upsert.calls.count()).toBe(callsAfterCreate); }); /* * 🔴 行为变更(2026-11-01 用户裁决):云端不可达时**列表不可信**。 * * 旧行为「保留本地缓存照常展示」看着更"稳",实际是两端互相覆盖的根源: * 用户看到的可能是几小时前的列表,在上面做的删除/归档又会回写云端。 */ it('🔴 云端不可达 → ready=false,界面据此显示「加载失败」', async () => { const svc2 = withApi({ fetchChatThreads: async () => { throw new Error('network'); }, upsertChatThreads: async () => { throw new Error('network'); }, }); await svc2.hydrateFromCloud(AGENT); expect(svc2.ready()).toBeFalse(); expect(svc2.error()).toBe('network'); }); it('云端不可达时 pushAll 不推送(列表本身就不可信)', async () => { const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve()); const svc2 = withApi({ fetchChatThreads: async () => { throw new Error('network'); }, upsertChatThreads: upsert, }); svc2.create(ROOM, AGENT); const before = upsert.calls.count(); await svc2.hydrateFromCloud(AGENT); await svc2.pushAll(AGENT); expect(upsert.calls.count()).withContext('云端不可达时不应回写').toBe(before); }); it('云端成功返回后 ready=true', async () => { const svc2 = withApi({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []), upsertChatThreads: async () => undefined, }); expect(svc2.ready()).toBeFalse(); await svc2.hydrateFromCloud(AGENT); expect(svc2.ready()).toBeTrue(); }); it('🔴 云端删掉的会话,本地下一次拉取即消失', async () => { let live = [ { objectId: 'a', agentId: AGENT, roomId: ROOM, threadRoot: '$a', title: 'A', titleLocked: false, messageCount: 1, lastMessageAt: 1, archived: false, lastPreview: '' }, { objectId: 'b', agentId: AGENT, roomId: ROOM, threadRoot: '$b', title: 'B', titleLocked: false, messageCount: 1, lastMessageAt: 2, archived: false, lastPreview: '' }, ]; const svc2 = withApi({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : live), upsertChatThreads: async () => undefined, }); await svc2.hydrateFromCloud(AGENT); expect(svc2.listFor(AGENT).length).toBe(2); live = [live[0]]; await svc2.hydrateFromCloud(AGENT); expect(svc2.listFor(AGENT).map((s) => s.id)).withContext('云端没有的应被移除').toEqual(['$a']); }); }); });