diff --git a/tests/audio/tts-utils.test.ts b/tests/audio/tts-utils.test.ts new file mode 100644 index 000000000..64c5ef231 --- /dev/null +++ b/tests/audio/tts-utils.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { + splitLongSpeechText, + splitLongSpeechActions, + TTS_MAX_TEXT_LENGTH, +} from '@/lib/audio/tts-utils'; +import type { Action, SpeechAction } from '@/lib/types/action'; + +describe('splitLongSpeechText', () => { + it('returns the trimmed text unchanged when within the limit', () => { + expect(splitLongSpeechText(' hello world ', 100)).toEqual(['hello world']); + }); + + it('splits at sentence boundaries, keeping the punctuation', () => { + expect(splitLongSpeechText('句子一。句子二!句子三?', 6)).toEqual([ + '句子一。', + '句子二!', + '句子三?', + ]); + }); + + it('packs consecutive short sentences up to the limit', () => { + const out = splitLongSpeechText('aa。bb。cc。dd。', 6); + expect(out).toEqual(['aa。bb。', 'cc。dd。']); + expect(out.join('')).toBe('aa。bb。cc。dd。'); + }); + + it('falls back to clause punctuation for an over-long sentence', () => { + const out = splitLongSpeechText('一,二,三,四,五', 4); + expect(out.every((c) => c.length <= 4)).toBe(true); + expect(out.join('').replace(/,/g, '')).toContain('一'); + }); + + it('hard-splits a long run with no punctuation', () => { + expect(splitLongSpeechText('x'.repeat(25), 10)).toEqual(['xxxxxxxxxx', 'xxxxxxxxxx', 'xxxxx']); + }); + + it('never emits a chunk longer than maxLength, nor an empty chunk (invariant)', () => { + const text = '这是一个很长的句子,包含很多子句、标点。'.repeat(50) + 'z'.repeat(120); + for (const max of [8, 16, 64, 200]) { + const out = splitLongSpeechText(text, max); + expect( + out.every((c) => c.length <= max), + `max=${max}`, + ).toBe(true); + expect( + out.some((c) => c.length === 0), + `max=${max}`, + ).toBe(false); + } + }); +}); + +describe('splitLongSpeechActions', () => { + const speech = (id: string, text: string): SpeechAction => + ({ id, type: 'speech', text }) as SpeechAction; + + it('returns actions untouched for a provider with no length limit', () => { + const actions: Action[] = [speech('a', 'x'.repeat(5000))]; + expect(splitLongSpeechActions(actions, 'openai-tts')).toBe(actions); + }); + + it('leaves short speech and non-speech actions unchanged', () => { + const actions: Action[] = [speech('s', 'short'), { id: 'sp', type: 'spotlight' } as Action]; + expect(splitLongSpeechActions(actions, 'glm-tts')).toBe(actions); + }); + + it('splits an over-limit speech action into chunked sub-actions', () => { + const max = TTS_MAX_TEXT_LENGTH['glm-tts']!; // 1024 + const long = '句子。'.repeat(400); // 1200 chars > 1024 + const out = splitLongSpeechActions([speech('a', long)], 'glm-tts') as SpeechAction[]; + + expect(out.length).toBeGreaterThan(1); + expect(out.every((a) => a.type === 'speech' && a.text.length <= max)).toBe(true); + // deterministic sub-action ids… + expect(out.map((a) => a.id)).toEqual(out.map((_, i) => `a_tts_${i + 1}`)); + // …each gets its own audio (the parent audioId is dropped)… + expect(out.every((a) => a.audioId === undefined)).toBe(true); + // …and the text is preserved across the split. + expect(out.map((a) => a.text).join('')).toBe(long); + }); +});