/** * TitleGenerator 测试(v0.7.3 P4-1) * * T1 sanitizeTitle 清洗规则矩阵(围栏/引号/自述前缀/空白/截断/空值); * T2 maybeGenerateTitle 幂等(每会话仅一次); * T3 已有自定义标题的会话不覆盖; * T4 LLM 失败/超时静默回退(不抛错,返回 null); * T5 空输入门控。 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { TitleGenerator, sanitizeTitle } from '../title-generator.service'; import type { IMetonaProviderAdapter, MetonaResponse } from '../../harness/types'; describe('sanitizeTitle', () => { it('T1a: 干净文本原样通过', () => { expect(sanitizeTitle('修复登录超时')).toBe('修复登录超时'); }); it('T1b: markdown 围栏与列表标记剥离', () => { expect(sanitizeTitle('```json\n"标题"\n```')).toBe('标题'); expect(sanitizeTitle('- 修复登录超时')).toBe('修复登录超时'); expect(sanitizeTitle('# 修复登录超时')).toBe('修复登录超时'); }); it('T1c: 成对包裹引号剥离', () => { expect(sanitizeTitle('"修复登录超时"')).toBe('修复登录超时'); expect(sanitizeTitle('“修复登录超时”')).toBe('修复登录超时'); }); it('T1d: 自述前缀剥离(标题:/Title:)', () => { expect(sanitizeTitle('标题: 修复登录超时')).toBe('修复登录超时'); expect(sanitizeTitle('Title: Fix login timeout')).toBe('Fix login timeout'); }); it('T1e: 换行折叠为空格、首尾空白去除', () => { expect(sanitizeTitle('修复\n登录 超时\n')).toBe('修复 登录 超时'); }); it('T1f: 超长截断到 maxLen', () => { const out = sanitizeTitle('a'.repeat(100), 40); expect(out?.length).toBe(40); }); it('T1g: 空值/纯符号 → null', () => { expect(sanitizeTitle('')).toBeNull(); expect(sanitizeTitle(' ')).toBeNull(); expect(sanitizeTitle('"""')).toBeNull(); expect(sanitizeTitle('###')).toBeNull(); }); }); function makeAdapter(content: string | Error): IMetonaProviderAdapter { return { providerId: 'mock', supportedModels: ['m'], supportsToolCalling: true, supportsThinking: false, getContextWindow: () => 128_000, send: vi.fn(async (): Promise => { if (content instanceof Error) throw content; return { meta: { requestId: 'r', provider: 'mock', model: 'm', latencyMs: 1, timestamp: Date.now() }, content, usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, finishReason: 'stop' as never, }; }), sendStream: vi.fn(), setAbortSignal: vi.fn(), healthCheck: async () => true, } as unknown as IMetonaProviderAdapter; } function makeSessionService(existing: Array<{ id: string; title: string }> = []) { // 注意:真实 SessionService.list() 是同步方法(better-sqlite3), // mock 必须保持同步返回,否则 generate 内的 .find() 拿到 Promise return { list: vi.fn(() => existing) as unknown as () => Array<{ id: string; title: string }>, rename: vi.fn(() => true), }; } describe('TitleGenerator.maybeGenerateTitle', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('T2: 首个 run 生成标题并调用 rename;同会话幂等', async () => { const svc = makeSessionService([{ id: 's1', title: '新会话' }]); const gen = new TitleGenerator(() => makeAdapter('修复登录超时'), svc as never); const title = await gen.maybeGenerateTitle( 's1', '帮我看看为什么登录会超时', '已定位为 token 过期…', ); expect(title).toBe('修复登录超时'); expect(svc.rename).toHaveBeenCalledWith('s1', '修复登录超时'); // 第二次调用(同会话)幂等 —— 不再调用 LLM const second = await gen.maybeGenerateTitle('s1', '再问一次', '再答一次'); expect(second).toBeNull(); }); it('T3: 已有自定义标题的会话不覆盖', async () => { const svc = makeSessionService([{ id: 's1', title: '用户手动命名' }]); const adapterSend = vi.fn(); const adapter = makeAdapter('不应被使用'); (adapter.send as ReturnType).mockImplementation(async (...args: unknown[]) => { adapterSend(...args); throw new Error('should not be called'); }); const gen = new TitleGenerator(() => adapter, svc as never); const title = await gen.maybeGenerateTitle('s1', '内容', '回答'); expect(title).toBeNull(); expect(adapterSend).not.toHaveBeenCalled(); }); it('T4: LLM 失败静默回退(返回 null,不抛错),且同会话不重试', async () => { const svc = makeSessionService([{ id: 's1', title: '新会话' }]); const gen = new TitleGenerator(() => makeAdapter(new Error('network down')), svc as never); await expect(gen.maybeGenerateTitle('s1', 'q', 'a')).resolves.toBeNull(); await expect(gen.maybeGenerateTitle('s1', 'q2', 'a2')).resolves.toBeNull(); expect(svc.rename).not.toHaveBeenCalled(); }); it('T5: 空 sessionId / 双向空内容 → 不生成', async () => { const svc = makeSessionService(); const adapter = makeAdapter('x'); const gen = new TitleGenerator(() => adapter, svc as never); expect(await gen.maybeGenerateTitle('', 'q', 'a')).toBeNull(); expect(await gen.maybeGenerateTitle('s1', ' ', ' ')).toBeNull(); }); it('T6: 并发重入复用同一 Promise(不重复调用 LLM)', async () => { const svc = makeSessionService([{ id: 's1', title: '新会话' }]); const gen = new TitleGenerator(() => makeAdapter('并发标题'), svc as never); // 使用真实定时器场景下并发触发 vi.useRealTimers(); const [a, b] = await Promise.all([ gen.maybeGenerateTitle('s2', 'q', 'a'), gen.maybeGenerateTitle('s2', 'q', 'a'), ]); expect(a).toBe('并发标题'); expect(b).toBe('并发标题'); // rename 仅一次(并发重入复用) expect(svc.rename).toHaveBeenCalledTimes(1); }); });