Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions tests/audio/tts-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading