chapter-tts.test.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. /**
  2. * on-demand 章节 TTS 单元测试
  3. *
  4. * 覆盖:
  5. * 1. content 第一段 ≥50 字 → 取第一段
  6. * 2. content 第一段 <50 字 → 取前 500 字
  7. * 3. content < 5 字 → 抛 CHAPTER_CONTENT_EMPTY
  8. * 4. 标题前缀拼接
  9. * 5. prefix + body 整体超 500 → 截断到 500
  10. * 6. 二次校验:fresh.audioSource='full' → 直接返回,不调 TTS
  11. * 7. 写回 updateMany 调用参数正确(条件 audioSource=null)
  12. * 8. 写回失败不影响响应返回
  13. * 9. synthesizeSync 抛异常 → 外层捕获抛给 controller
  14. */
  15. import { describe, it, expect, vi, beforeEach } from 'vitest';
  16. // === 用 vi.hoisted 把 mock 引用提升到顶部,让 vi.mock 工厂能拿到 ===
  17. const mocks = vi.hoisted(() => ({
  18. findUnique: vi.fn(),
  19. updateMany: vi.fn(),
  20. stripMarkdown: vi.fn((t: string) => (t == null ? '' : t)),
  21. synthesizeSync: vi.fn(),
  22. }));
  23. vi.mock('@/models', () => ({
  24. prisma: {
  25. bookChapter: {
  26. findUnique: mocks.findUnique,
  27. updateMany: mocks.updateMany,
  28. },
  29. },
  30. }));
  31. vi.mock('@/modules/tts/tts.service', () => ({
  32. stripMarkdown: mocks.stripMarkdown,
  33. synthesizeSync: mocks.synthesizeSync,
  34. }));
  35. import { onDemandChapterTts } from '@/modules/book-generator/chapter-tts';
  36. describe('onDemandChapterTts', () => {
  37. beforeEach(() => {
  38. // 注意:不能用 vi.clearAllMocks(),会清掉 mock implementation
  39. mocks.findUnique.mockReset().mockResolvedValue(null);
  40. mocks.updateMany.mockReset().mockResolvedValue({ count: 1 });
  41. mocks.synthesizeSync.mockReset().mockResolvedValue({
  42. audioId: 'aid-1',
  43. audioUrl: 'https://oss.example.com/a.mp3',
  44. duration: 30,
  45. });
  46. // mockReset 会清掉默认 implementation,需要重新设置
  47. mocks.stripMarkdown.mockReset().mockImplementation((t: string) => (t == null ? '' : t));
  48. });
  49. it('第一段 ≥50 字 → 取第一段', async () => {
  50. const content = '第一段很长很长的内容。' + '啊'.repeat(60) + '\n\n第二段内容';
  51. const result = await onDemandChapterTts({ id: 1, content, title: '标题' });
  52. expect(mocks.synthesizeSync).toHaveBeenCalledTimes(1);
  53. const calledText = mocks.synthesizeSync.mock.calls[0][0];
  54. expect(calledText.startsWith('标题。第一段很长很长的内容')).toBe(true);
  55. expect(calledText.length).toBeLessThanOrEqual(500);
  56. expect(result.audioUrl).toContain('https://');
  57. });
  58. it('第一段 <50 字 → 取前 500 字(含第二段)', async () => {
  59. const content = '短段\n\n' + '中'.repeat(200);
  60. await onDemandChapterTts({ id: 1, content, title: 'T' });
  61. const calledText = mocks.synthesizeSync.mock.calls[0][0];
  62. expect(calledText.includes('短段')).toBe(true);
  63. expect(calledText.includes('中')).toBe(true);
  64. });
  65. it('content < 5 字 → 抛 CHAPTER_CONTENT_EMPTY', async () => {
  66. await expect(onDemandChapterTts({ id: 1, content: '短', title: '' })).rejects.toThrow('CHAPTER_CONTENT_EMPTY');
  67. await expect(onDemandChapterTts({ id: 1, content: '', title: '' })).rejects.toThrow('CHAPTER_CONTENT_EMPTY');
  68. expect(mocks.synthesizeSync).not.toHaveBeenCalled();
  69. });
  70. it('标题前缀拼接正确', async () => {
  71. await onDemandChapterTts({ id: 1, content: '内容内容内容内容内容内容', title: '第一回' });
  72. const calledText = mocks.synthesizeSync.mock.calls[0][0];
  73. expect(calledText.startsWith('第一回。')).toBe(true);
  74. });
  75. it('prefix + body 整体超 500 → 截断到 500', async () => {
  76. const longTitle = 'X'.repeat(100);
  77. const content = 'A'.repeat(600);
  78. await onDemandChapterTts({ id: 1, content, title: longTitle });
  79. const calledText = mocks.synthesizeSync.mock.calls[0][0];
  80. expect(calledText.length).toBe(500);
  81. });
  82. it('二次校验:fresh.audioSource=full → 直接返回,不调 TTS', async () => {
  83. mocks.findUnique.mockResolvedValue({
  84. id: 1,
  85. audioUrl: 'https://oss/full.mp3',
  86. audioSource: 'full',
  87. audioDuration: 999,
  88. });
  89. const result = await onDemandChapterTts({ id: 1, content: '随便', title: 'T' });
  90. expect(mocks.synthesizeSync).not.toHaveBeenCalled();
  91. expect(result.audioUrl).toBe('https://oss/full.mp3');
  92. expect(result.duration).toBe(999);
  93. });
  94. it('写回调用 updateMany,条件 audioSource=null', async () => {
  95. await onDemandChapterTts({ id: 42, content: '内容内容内容内容内容内容内容内容', title: 'T' });
  96. expect(mocks.updateMany).toHaveBeenCalledWith({
  97. where: { id: 42, audioSource: null },
  98. data: expect.objectContaining({
  99. audioSource: 'on_demand',
  100. audioUrl: expect.any(String),
  101. audioDuration: expect.any(Number),
  102. }),
  103. });
  104. });
  105. it('写回失败不影响响应返回(fire-and-forget)', async () => {
  106. mocks.updateMany.mockRejectedValue(new Error('write fail'));
  107. const result = await onDemandChapterTts({ id: 1, content: '内容内容内容内容内容内容', title: 'T' });
  108. expect(result.audioUrl).toBeDefined();
  109. });
  110. it('synthesizeSync 抛异常 → 外层向上抛', async () => {
  111. mocks.synthesizeSync.mockRejectedValue(new Error('所有 Provider 都已尝试'));
  112. await expect(
  113. onDemandChapterTts({ id: 1, content: '内容内容内容内容内容内容', title: 'T' }),
  114. ).rejects.toThrow('所有 Provider');
  115. });
  116. it('自定义 voiceId 与 speed 透传', async () => {
  117. await onDemandChapterTts(
  118. { id: 1, content: '内容内容内容内容内容内容', title: 'T' },
  119. { voiceId: 'male-yujie', speed: 1.5 },
  120. );
  121. expect(mocks.synthesizeSync).toHaveBeenCalledWith(expect.any(String), 'male-yujie', { speed: 1.5 });
  122. });
  123. it('audioUrl 是 /uploads/ 路径时二次校验不通过,继续走 TTS', async () => {
  124. mocks.findUnique.mockResolvedValue({
  125. id: 1,
  126. audioUrl: '/uploads/audio/abc.mp3',
  127. audioSource: 'full',
  128. audioDuration: 999,
  129. });
  130. const result = await onDemandChapterTts({ id: 1, content: '内容内容内容内容内容内容', title: 'T' });
  131. expect(mocks.synthesizeSync).toHaveBeenCalled();
  132. expect(result.audioUrl).toBe('https://oss.example.com/a.mp3');
  133. });
  134. });