openreader/tests/unit/tts-segment-plan.spec.ts
Richard R e8d4c11434 refactor(tts,epub,locator): introduce canonical segment keys and locator comparison utilities
Refactor TTS segment identification to support canonical segment keys, decoupling segment IDs from locator and index for more robust deduplication and handoff. Add `segmentKey` to segment types, manifest, and ensure route. Implement natural sorting for EPUB CFI locations and segment locators using new comparison utilities. Update sidebar and manifest helpers to use locator-aware sorting. Add foundational modules for EPUB word highlighting, TTS segment planning, and EPUB handoff logic. Expand types and tests to cover new canonical segment and locator behaviors.

BREAKING CHANGE: TTS segment IDs now support canonical segment keys; APIs and manifest consumers must handle `segmentKey` and updated locator comparison logic.
2026-05-11 05:17:41 -06:00

167 lines
6.1 KiB
TypeScript

import { expect, test } from '@playwright/test';
import { planCanonicalTtsSegments } from '../../src/lib/shared/tts-segment-plan';
test.describe('planCanonicalTtsSegments', () => {
test('emits a cross-boundary segment once and assigns it to the source where it starts', () => {
const plan = planCanonicalTtsSegments([
{
sourceKey: 'page:1',
locator: { page: 1, readerType: 'pdf' },
text: 'The boundary sentence begins on page one and',
},
{
sourceKey: 'page:2',
locator: { page: 2, readerType: 'pdf' },
text: 'finishes on page two with enough words to stand alone. A short follow up.',
},
], {
readerType: 'pdf',
maxBlockLength: 60,
keyPrefix: 'doc:v1',
});
expect(plan.segments.length).toBeGreaterThanOrEqual(2);
expect(plan.segments[0]).toMatchObject({
ownerSourceKey: 'page:1',
ownerLocator: { page: 1, readerType: 'pdf' },
spansSourceBoundary: true,
});
expect(plan.segments[0].text).toContain('begins on page one and finishes on page two');
expect(plan.segments[1]).toMatchObject({
ownerSourceKey: 'page:2',
spansSourceBoundary: false,
});
expect(plan.segments[1].text).toContain('A short follow up.');
});
test('returns the same segment keys for the same canonical source anchors', () => {
const options = {
readerType: 'epub' as const,
maxBlockLength: 70,
keyPrefix: 'book:v1',
};
const firstPlan = planCanonicalTtsSegments([
{
sourceKey: 'chapter:1',
text: 'First sentence with enough words to be its own block. Second sentence with enough words to be its own block.',
},
], options);
const secondPlan = planCanonicalTtsSegments([
{
sourceKey: 'chapter:1',
text: 'First sentence with enough words to be its own block. Second sentence with enough words to be its own block.',
},
], options);
expect(secondPlan.segments.map((segment) => segment.text)).toEqual(
firstPlan.segments.map((segment) => segment.text),
);
expect(secondPlan.segments.map((segment) => segment.key)).toEqual(
firstPlan.segments.map((segment) => segment.key),
);
});
test('repeated identical text produces the same content-addressed key', () => {
const repeated = 'Repeat this exact sentence with enough filler words here.';
const plan = planCanonicalTtsSegments([
{ sourceKey: 'chapter:1', text: `${repeated} ${repeated}` },
], {
readerType: 'epub',
maxBlockLength: 50,
keyPrefix: 'book:v1',
});
expect(plan.segments).toHaveLength(2);
expect(plan.segments[0].text).toBe(repeated);
expect(plan.segments[1].text).toBe(repeated);
// Content-addressed keys: same text → same key (viewport-independent).
// Segments remain distinct via ordinal and anchors for client-side use.
expect(plan.segments[0].key).toBe(plan.segments[1].key);
expect(plan.segments[0].ordinal).not.toBe(plan.segments[1].ordinal);
});
test('does not let locator changes alter canonical keys', () => {
const text = 'Locator changes should not alter this stable segment identity.';
const a = planCanonicalTtsSegments([
{ sourceKey: 'page:1', locator: { page: 1, readerType: 'pdf' }, text },
], {
readerType: 'pdf',
keyPrefix: 'doc:v1',
});
const b = planCanonicalTtsSegments([
{ sourceKey: 'page:1', locator: { page: 99, readerType: 'pdf' }, text },
], {
readerType: 'pdf',
keyPrefix: 'doc:v1',
});
expect(a.segments.map((segment) => segment.key)).toEqual(
b.segments.map((segment) => segment.key),
);
expect(a.segments[0].ownerLocator).toEqual({ page: 1, readerType: 'pdf' });
expect(b.segments[0].ownerLocator).toEqual({ page: 99, readerType: 'pdf' });
});
test('generates identical segment keys for the same text from different viewports (sourceKeys)', () => {
const sentence = 'This sentence is rendered on different viewports.';
// Viewport A (e.g. narrow device)
const planA = planCanonicalTtsSegments([
{ sourceKey: 'epubcfi(/6/10!/4/2)', text: sentence },
], {
readerType: 'epub',
keyPrefix: 'book:v1',
});
// Viewport B (e.g. wide device)
const planB = planCanonicalTtsSegments([
{ sourceKey: 'epubcfi(/6/12!/4/4)', text: sentence },
], {
readerType: 'epub',
keyPrefix: 'book:v1',
});
// Content-addressed keys should match perfectly because the text is the same
expect(planA.segments[0].key).toBe(planB.segments[0].key);
// But the client-side anchoring accurately reflects the different viewport source
expect(planA.segments[0].ownerSourceKey).toBe('epubcfi(/6/10!/4/2)');
expect(planB.segments[0].ownerSourceKey).toBe('epubcfi(/6/12!/4/4)');
expect(planA.segments[0].startAnchor.sourceKey).not.toBe(planB.segments[0].startAnchor.sourceKey);
});
test('anchors segment offsets back to normalized source text', () => {
const plan = planCanonicalTtsSegments([
{ sourceKey: 'page:1', text: 'First sentence. ' },
{ sourceKey: 'page:2', text: ' Second sentence with enough words to be separate.' },
], {
readerType: 'epub',
maxBlockLength: 50,
keyPrefix: 'book:v1',
});
expect(plan.segments).toHaveLength(2);
expect(plan.segments[0].startAnchor).toEqual({ sourceKey: 'page:1', offset: 0 });
expect(plan.segments[0].endAnchor.sourceKey).toBe('page:1');
expect(plan.segments[1].startAnchor).toEqual({ sourceKey: 'page:2', offset: 0 });
expect(plan.text).toBe('First sentence. Second sentence with enough words to be separate.');
});
test('ignores empty source units', () => {
const plan = planCanonicalTtsSegments([
{ sourceKey: 'empty:1', text: ' ' },
{ sourceKey: 'page:1', text: 'Only useful text remains.' },
{ sourceKey: 'empty:2', text: '\n\n' },
], {
readerType: 'pdf',
keyPrefix: 'doc:v1',
});
expect(plan.text).toBe('Only useful text remains.');
expect(plan.segments).toHaveLength(1);
expect(plan.segments[0].ownerSourceKey).toBe('page:1');
});
});