| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150 |
- /**
- * on-demand 章节 TTS 单元测试
- *
- * 覆盖:
- * 1. content 第一段 ≥50 字 → 取第一段
- * 2. content 第一段 <50 字 → 取前 500 字
- * 3. content < 5 字 → 抛 CHAPTER_CONTENT_EMPTY
- * 4. 标题前缀拼接
- * 5. prefix + body 整体超 500 → 截断到 500
- * 6. 二次校验:fresh.audioSource='full' → 直接返回,不调 TTS
- * 7. 写回 updateMany 调用参数正确(条件 audioSource=null)
- * 8. 写回失败不影响响应返回
- * 9. synthesizeSync 抛异常 → 外层捕获抛给 controller
- */
- import { describe, it, expect, vi, beforeEach } from 'vitest';
- // === 用 vi.hoisted 把 mock 引用提升到顶部,让 vi.mock 工厂能拿到 ===
- const mocks = vi.hoisted(() => ({
- findUnique: vi.fn(),
- updateMany: vi.fn(),
- stripMarkdown: vi.fn((t: string) => (t == null ? '' : t)),
- synthesizeSync: vi.fn(),
- }));
- vi.mock('@/models', () => ({
- prisma: {
- bookChapter: {
- findUnique: mocks.findUnique,
- updateMany: mocks.updateMany,
- },
- },
- }));
- vi.mock('@/modules/tts/tts.service', () => ({
- stripMarkdown: mocks.stripMarkdown,
- synthesizeSync: mocks.synthesizeSync,
- }));
- import { onDemandChapterTts } from '@/modules/book-generator/chapter-tts';
- describe('onDemandChapterTts', () => {
- beforeEach(() => {
- // 注意:不能用 vi.clearAllMocks(),会清掉 mock implementation
- mocks.findUnique.mockReset().mockResolvedValue(null);
- mocks.updateMany.mockReset().mockResolvedValue({ count: 1 });
- mocks.synthesizeSync.mockReset().mockResolvedValue({
- audioId: 'aid-1',
- audioUrl: 'https://oss.example.com/a.mp3',
- duration: 30,
- });
- // mockReset 会清掉默认 implementation,需要重新设置
- mocks.stripMarkdown.mockReset().mockImplementation((t: string) => (t == null ? '' : t));
- });
- it('第一段 ≥50 字 → 取第一段', async () => {
- const content = '第一段很长很长的内容。' + '啊'.repeat(60) + '\n\n第二段内容';
- const result = await onDemandChapterTts({ id: 1, content, title: '标题' });
- expect(mocks.synthesizeSync).toHaveBeenCalledTimes(1);
- const calledText = mocks.synthesizeSync.mock.calls[0][0];
- expect(calledText.startsWith('标题。第一段很长很长的内容')).toBe(true);
- expect(calledText.length).toBeLessThanOrEqual(500);
- expect(result.audioUrl).toContain('https://');
- });
- it('第一段 <50 字 → 取前 500 字(含第二段)', async () => {
- const content = '短段\n\n' + '中'.repeat(200);
- await onDemandChapterTts({ id: 1, content, title: 'T' });
- const calledText = mocks.synthesizeSync.mock.calls[0][0];
- expect(calledText.includes('短段')).toBe(true);
- expect(calledText.includes('中')).toBe(true);
- });
- it('content < 5 字 → 抛 CHAPTER_CONTENT_EMPTY', async () => {
- await expect(onDemandChapterTts({ id: 1, content: '短', title: '' })).rejects.toThrow('CHAPTER_CONTENT_EMPTY');
- await expect(onDemandChapterTts({ id: 1, content: '', title: '' })).rejects.toThrow('CHAPTER_CONTENT_EMPTY');
- expect(mocks.synthesizeSync).not.toHaveBeenCalled();
- });
- it('标题前缀拼接正确', async () => {
- await onDemandChapterTts({ id: 1, content: '内容内容内容内容内容内容', title: '第一回' });
- const calledText = mocks.synthesizeSync.mock.calls[0][0];
- expect(calledText.startsWith('第一回。')).toBe(true);
- });
- it('prefix + body 整体超 500 → 截断到 500', async () => {
- const longTitle = 'X'.repeat(100);
- const content = 'A'.repeat(600);
- await onDemandChapterTts({ id: 1, content, title: longTitle });
- const calledText = mocks.synthesizeSync.mock.calls[0][0];
- expect(calledText.length).toBe(500);
- });
- it('二次校验:fresh.audioSource=full → 直接返回,不调 TTS', async () => {
- mocks.findUnique.mockResolvedValue({
- id: 1,
- audioUrl: 'https://oss/full.mp3',
- audioSource: 'full',
- audioDuration: 999,
- });
- const result = await onDemandChapterTts({ id: 1, content: '随便', title: 'T' });
- expect(mocks.synthesizeSync).not.toHaveBeenCalled();
- expect(result.audioUrl).toBe('https://oss/full.mp3');
- expect(result.duration).toBe(999);
- });
- it('写回调用 updateMany,条件 audioSource=null', async () => {
- await onDemandChapterTts({ id: 42, content: '内容内容内容内容内容内容内容内容', title: 'T' });
- expect(mocks.updateMany).toHaveBeenCalledWith({
- where: { id: 42, audioSource: null },
- data: expect.objectContaining({
- audioSource: 'on_demand',
- audioUrl: expect.any(String),
- audioDuration: expect.any(Number),
- }),
- });
- });
- it('写回失败不影响响应返回(fire-and-forget)', async () => {
- mocks.updateMany.mockRejectedValue(new Error('write fail'));
- const result = await onDemandChapterTts({ id: 1, content: '内容内容内容内容内容内容', title: 'T' });
- expect(result.audioUrl).toBeDefined();
- });
- it('synthesizeSync 抛异常 → 外层向上抛', async () => {
- mocks.synthesizeSync.mockRejectedValue(new Error('所有 Provider 都已尝试'));
- await expect(
- onDemandChapterTts({ id: 1, content: '内容内容内容内容内容内容', title: 'T' }),
- ).rejects.toThrow('所有 Provider');
- });
- it('自定义 voiceId 与 speed 透传', async () => {
- await onDemandChapterTts(
- { id: 1, content: '内容内容内容内容内容内容', title: 'T' },
- { voiceId: 'male-yujie', speed: 1.5 },
- );
- expect(mocks.synthesizeSync).toHaveBeenCalledWith(expect.any(String), 'male-yujie', { speed: 1.5 });
- });
- it('audioUrl 是 /uploads/ 路径时二次校验不通过,继续走 TTS', async () => {
- mocks.findUnique.mockResolvedValue({
- id: 1,
- audioUrl: '/uploads/audio/abc.mp3',
- audioSource: 'full',
- audioDuration: 999,
- });
- const result = await onDemandChapterTts({ id: 1, content: '内容内容内容内容内容内容', title: 'T' });
- expect(mocks.synthesizeSync).toHaveBeenCalled();
- expect(result.audioUrl).toBe('https://oss.example.com/a.mp3');
- });
- });
|