openreader/tests/unit/tts-segments.spec.ts
Richard R b4f4d43d6a feat(tts,config,types): migrate to providerRef/providerType model and add centralized TTS provider policy
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.
2026-05-13 10:11:05 -06:00

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);
});
});