Transition all TTS-related logic, types, and UI to use the new providerRef/providerType model in place of legacy ttsProvider fields. Introduce a centralized tts-provider-policy module to encapsulate provider/model capability checks, default value resolution, and compatibility logic. Update all API routes, contexts, hooks, components, and tests to use providerRef and providerType, ensuring consistent handling of built-in and shared TTS providers. Remove legacy defaultTtsModel config in favor of per-provider defaults and shared provider admin control. Add the showAllProviderModels runtime flag to restrict users to provider default models when desired. BREAKING CHANGE: ttsProvider fields are replaced by providerRef/providerType throughout the codebase; defaultTtsModel config is removed in favor of per-provider defaults.
198 lines
6.1 KiB
TypeScript
198 lines
6.1 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import {
|
|
buildProportionalAlignment,
|
|
buildTtsSegmentEntryId,
|
|
buildTtsSegmentId,
|
|
buildTtsSegmentSettingsHash,
|
|
buildTtsSegmentTextHash,
|
|
locatorFingerprint,
|
|
normalizeLocator,
|
|
normalizeSegmentText,
|
|
projectSegmentLocator,
|
|
} from '../../src/lib/server/tts/segments';
|
|
|
|
test.describe('tts segment helpers', () => {
|
|
test('builds stable settings hash', () => {
|
|
const a = buildTtsSegmentSettingsHash({
|
|
providerRef: 'openai',
|
|
providerType: 'openai',
|
|
ttsModel: 'gpt-4o-mini-tts',
|
|
voice: 'alloy',
|
|
nativeSpeed: 1,
|
|
ttsInstructions: 'calm',
|
|
});
|
|
const b = buildTtsSegmentSettingsHash({
|
|
providerRef: 'openai',
|
|
providerType: 'openai',
|
|
ttsModel: 'gpt-4o-mini-tts',
|
|
voice: 'alloy',
|
|
nativeSpeed: 1,
|
|
ttsInstructions: 'calm',
|
|
});
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
test('builds deterministic segment id', () => {
|
|
const id1 = buildTtsSegmentId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
settingsHash: 'abc',
|
|
segmentIndex: 2,
|
|
normalizedText: 'hello world',
|
|
locatorFingerprint: 'loc',
|
|
});
|
|
const id2 = buildTtsSegmentId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
settingsHash: 'abc',
|
|
segmentIndex: 2,
|
|
normalizedText: 'hello world',
|
|
locatorFingerprint: 'loc',
|
|
});
|
|
const id3 = buildTtsSegmentId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
settingsHash: 'abc',
|
|
segmentIndex: 3,
|
|
normalizedText: 'hello world',
|
|
locatorFingerprint: 'loc',
|
|
});
|
|
expect(id1).toBe(id2);
|
|
expect(id1).not.toBe(id3);
|
|
});
|
|
|
|
test('builds deterministic segment entry id independent of settings', () => {
|
|
const entry1 = buildTtsSegmentEntryId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
segmentIndex: 2,
|
|
segmentKey: 'doc:v1:segment-a',
|
|
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
|
|
textHash: 'abc123',
|
|
});
|
|
const entry2 = buildTtsSegmentEntryId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
segmentIndex: 2,
|
|
segmentKey: 'doc:v1:segment-a',
|
|
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
|
|
textHash: 'abc123',
|
|
});
|
|
const entry3 = buildTtsSegmentEntryId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
segmentIndex: 2,
|
|
segmentKey: 'doc:v1:segment-b',
|
|
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
|
|
textHash: 'abc123',
|
|
});
|
|
expect(entry1).toBe(entry2);
|
|
expect(entry1).not.toBe(entry3);
|
|
});
|
|
|
|
test('canonical segment key makes id independent of locator and index', () => {
|
|
const id1 = buildTtsSegmentId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
settingsHash: 'abc',
|
|
segmentIndex: 2,
|
|
segmentKey: 'doc:v1:segment-a',
|
|
normalizedText: 'hello world',
|
|
locatorFingerprint: 'loc-a',
|
|
});
|
|
const id2 = buildTtsSegmentId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
settingsHash: 'abc',
|
|
segmentIndex: 99,
|
|
segmentKey: 'doc:v1:segment-a',
|
|
normalizedText: 'hello world',
|
|
locatorFingerprint: 'loc-b',
|
|
});
|
|
const id3 = buildTtsSegmentId({
|
|
documentId: 'doc',
|
|
documentVersion: 1,
|
|
settingsHash: 'abc',
|
|
segmentIndex: 2,
|
|
segmentKey: 'doc:v1:segment-b',
|
|
normalizedText: 'hello world',
|
|
locatorFingerprint: 'loc-a',
|
|
});
|
|
expect(id1).toBe(id2);
|
|
expect(id1).not.toBe(id3);
|
|
});
|
|
|
|
test('does not leak plaintext via text hash', () => {
|
|
const hash = buildTtsSegmentTextHash('plain sentence', 'secret');
|
|
expect(hash).not.toContain('plain');
|
|
expect(hash).toHaveLength(64);
|
|
});
|
|
|
|
test('normalizes PDF locators and creates fingerprints', () => {
|
|
const locator = normalizeLocator({ readerType: 'pdf', page: 2.9 });
|
|
if (!locator) throw new Error('expected normalized pdf locator');
|
|
expect(locator).toEqual({ readerType: 'pdf', page: 2 });
|
|
expect(locatorFingerprint(locator)).toHaveLength(64);
|
|
expect(projectSegmentLocator(locator)).toMatchObject({
|
|
locatorReaderRank: 1,
|
|
locatorReaderType: 'pdf',
|
|
locatorPage: 2,
|
|
locatorIdentityKey: 'pdf:2',
|
|
});
|
|
});
|
|
|
|
test('normalizes stable EPUB locators and rejects legacy CFI-only drafts', () => {
|
|
// Stable shape: passes through with floors and trims applied.
|
|
const stable = normalizeLocator({
|
|
readerType: 'epub',
|
|
spineHref: ' OEBPS/ch02.xhtml ',
|
|
spineIndex: 2.7,
|
|
charOffset: 128.4,
|
|
cfi: ' epubcfi(/6/4!/4:0) ',
|
|
});
|
|
if (!stable) throw new Error('expected normalized epub locator');
|
|
expect(stable).toEqual({
|
|
readerType: 'epub',
|
|
spineHref: 'OEBPS/ch02.xhtml',
|
|
spineIndex: 2,
|
|
charOffset: 128,
|
|
cfi: 'epubcfi(/6/4!/4:0)',
|
|
});
|
|
expect(locatorFingerprint(stable)).toHaveLength(64);
|
|
expect(projectSegmentLocator(stable)).toMatchObject({
|
|
locatorReaderRank: 0,
|
|
locatorReaderType: 'epub',
|
|
locatorSpineIndex: 2,
|
|
locatorCharOffset: 128,
|
|
locatorSpineHref: 'OEBPS/ch02.xhtml',
|
|
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
|
|
});
|
|
|
|
// Legacy/draft EPUB shape (CFI in `location` but no spine coords) is
|
|
// rejected so we never persist viewport-dependent locators.
|
|
const legacy = normalizeLocator({ readerType: 'epub', location: 'epubcfi(/6/4!/4:0)' });
|
|
expect(legacy).toBeNull();
|
|
});
|
|
|
|
test('projects html locators into stable manifest sort fields', () => {
|
|
expect(projectSegmentLocator({ readerType: 'html', location: '#intro' })).toMatchObject({
|
|
locatorReaderRank: 2,
|
|
locatorReaderType: 'html',
|
|
locatorLocation: '#intro',
|
|
locatorIdentityKey: 'html:#intro',
|
|
});
|
|
});
|
|
|
|
test('builds proportional alignment preserving order', () => {
|
|
const alignment = buildProportionalAlignment({
|
|
sentence: normalizeSegmentText('Hello world again'),
|
|
sentenceIndex: 5,
|
|
durationMs: 1500,
|
|
});
|
|
expect(alignment.sentenceIndex).toBe(5);
|
|
expect(alignment.words.length).toBe(3);
|
|
expect(alignment.words[0].startSec).toBe(0);
|
|
expect(alignment.words[2].endSec).toBeGreaterThan(1.4);
|
|
expect(alignment.words[1].charStart).toBeGreaterThan(alignment.words[0].charStart);
|
|
});
|
|
});
|