Implement strict validation for EPUB TTS segment locators, requiring stable spine coordinates (`spineHref`, `spineIndex`, `charOffset`) and rejecting legacy CFI-only locators. Introduce `segmentKey` as a normalized identity derived from segment text, used for robust merging and deduplication of synthesized and persisted segments. Update database schemas, API routes, and manifest logic to support the new locator format and segmentKey. Add helpers for locator resolution and segmentKey construction, with comprehensive unit tests for identity, normalization, and manifest grouping. BREAKING CHANGE: Persisted EPUB TTS segment locators must now include stable spine coordinates; legacy CFI-only locators are no longer accepted. Consumers must handle the new `segmentKey` field for segment identity and merging.
150 lines
6.2 KiB
TypeScript
150 lines
6.2 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import {
|
|
compareSegmentLocators,
|
|
locatorGroupKey,
|
|
locatorIdentityKey,
|
|
normalizeEpubLocationToken,
|
|
} from '../../src/lib/shared/tts-locator';
|
|
import {
|
|
isHtmlLocator,
|
|
isPdfLocator,
|
|
isStableEpubLocator,
|
|
type TTSSegmentLocator,
|
|
} from '../../src/types/client';
|
|
|
|
const epubLocator = (
|
|
spineHref: string,
|
|
spineIndex: number,
|
|
charOffset: number,
|
|
cfi?: string,
|
|
): TTSSegmentLocator => ({
|
|
readerType: 'epub',
|
|
spineHref,
|
|
spineIndex,
|
|
charOffset,
|
|
...(cfi ? { cfi } : {}),
|
|
});
|
|
|
|
test.describe('locatorGroupKey', () => {
|
|
test('groups stable EPUB locators by spine index and href', () => {
|
|
expect(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 0))).toBe('epub:2:OEBPS/ch02.xhtml');
|
|
// Different charOffset, same spine → same group (sidebar bucket is chapter-sized).
|
|
expect(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 1024)))
|
|
.toBe(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 0)));
|
|
// Different spine → different group.
|
|
expect(locatorGroupKey(epubLocator('OEBPS/ch03.xhtml', 3, 0)))
|
|
.not.toBe(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 0)));
|
|
});
|
|
|
|
test('groups PDF locators by page', () => {
|
|
expect(locatorGroupKey({ readerType: 'pdf', page: 7 })).toBe('pdf:7');
|
|
});
|
|
|
|
test('groups HTML locators by location', () => {
|
|
expect(locatorGroupKey({ readerType: 'html', location: '#anchor' })).toBe('html:#anchor');
|
|
});
|
|
|
|
test('returns "none" for null locators', () => {
|
|
expect(locatorGroupKey(null)).toBe('none');
|
|
});
|
|
|
|
test('falls back to a legacy group key for EPUB drafts missing spine coords', () => {
|
|
// A draft EPUB locator (just a CFI) should not co-group with stable rows.
|
|
const draft: TTSSegmentLocator = { readerType: 'epub', location: 'epubcfi(/6/8!/4:0)' };
|
|
expect(locatorGroupKey(draft)).toContain('legacy');
|
|
});
|
|
});
|
|
|
|
test.describe('locatorIdentityKey', () => {
|
|
test('two EPUB rows in the same chapter at different charOffsets get distinct identity keys', () => {
|
|
// This is the bug that caused page-N rows to swallow page-(N+1) rows in
|
|
// the server-side manifest aggregator: the previous code keyed by the
|
|
// chapter-coarse groupKey, collapsing distinct rows into a single bucket.
|
|
const a = epubLocator('OEBPS/ch02.xhtml', 2, 0);
|
|
const b = epubLocator('OEBPS/ch02.xhtml', 2, 1024);
|
|
expect(locatorIdentityKey(a)).not.toBe(locatorIdentityKey(b));
|
|
});
|
|
|
|
test('two EPUB rows at the same coordinate get equal identity keys', () => {
|
|
// Identity is content-of-locator only; the optional cfi jump hint is
|
|
// intentionally not part of identity.
|
|
const a = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:0)');
|
|
const b = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:8)');
|
|
expect(locatorIdentityKey(a)).toBe(locatorIdentityKey(b));
|
|
});
|
|
|
|
test('chapter-coarse group key collides across rows that identity-key keeps distinct', () => {
|
|
// Sanity-check the relationship between the two helpers.
|
|
const a = epubLocator('OEBPS/ch02.xhtml', 2, 0);
|
|
const b = epubLocator('OEBPS/ch02.xhtml', 2, 1024);
|
|
expect(locatorGroupKey(a)).toBe(locatorGroupKey(b)); // same chapter bucket
|
|
expect(locatorIdentityKey(a)).not.toBe(locatorIdentityKey(b)); // distinct rows
|
|
});
|
|
});
|
|
|
|
test.describe('compareSegmentLocators', () => {
|
|
test('orders EPUB rows by spineIndex then charOffset numerically', () => {
|
|
// Spine 10 must come AFTER spine 2 — a lexicographic compare would
|
|
// wrongly place "10" before "2".
|
|
const a = epubLocator('a.xhtml', 10, 0);
|
|
const b = epubLocator('b.xhtml', 2, 0);
|
|
expect(compareSegmentLocators(a, b)).toBeGreaterThan(0);
|
|
expect(compareSegmentLocators(b, a)).toBeLessThan(0);
|
|
});
|
|
|
|
test('within the same spine, orders by charOffset', () => {
|
|
const a = epubLocator('OEBPS/ch02.xhtml', 2, 50);
|
|
const b = epubLocator('OEBPS/ch02.xhtml', 2, 1000);
|
|
expect(compareSegmentLocators(a, b)).toBeLessThan(0);
|
|
expect(compareSegmentLocators(b, a)).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('treats equal stable EPUB locators as equal regardless of optional cfi hint', () => {
|
|
const a = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:0)');
|
|
const b = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:8)');
|
|
// cfi is a soft jump hint, not part of identity/sort.
|
|
expect(compareSegmentLocators(a, b)).toBe(0);
|
|
});
|
|
|
|
test('orders PDF locators by page numerically', () => {
|
|
const a: TTSSegmentLocator = { readerType: 'pdf', page: 2 };
|
|
const b: TTSSegmentLocator = { readerType: 'pdf', page: 10 };
|
|
expect(compareSegmentLocators(a, b)).toBeLessThan(0);
|
|
});
|
|
|
|
test('null locators sort last', () => {
|
|
expect(compareSegmentLocators(null, { readerType: 'pdf', page: 1 })).toBeGreaterThan(0);
|
|
expect(compareSegmentLocators({ readerType: 'pdf', page: 1 }, null)).toBeLessThan(0);
|
|
expect(compareSegmentLocators(null, null)).toBe(0);
|
|
});
|
|
});
|
|
|
|
test.describe('type guards', () => {
|
|
test('isStableEpubLocator requires all spine fields', () => {
|
|
expect(isStableEpubLocator(epubLocator('a.xhtml', 0, 0))).toBe(true);
|
|
expect(isStableEpubLocator({ readerType: 'epub', spineHref: 'a.xhtml' })).toBe(false);
|
|
expect(isStableEpubLocator({ readerType: 'epub', location: 'epubcfi(...)' })).toBe(false);
|
|
expect(isStableEpubLocator(null)).toBe(false);
|
|
expect(isStableEpubLocator({ readerType: 'pdf', page: 1 })).toBe(false);
|
|
});
|
|
|
|
test('isPdfLocator narrows PDF rows', () => {
|
|
expect(isPdfLocator({ readerType: 'pdf', page: 3 })).toBe(true);
|
|
expect(isPdfLocator({ readerType: 'pdf' })).toBe(false);
|
|
expect(isPdfLocator(epubLocator('a.xhtml', 0, 0))).toBe(false);
|
|
});
|
|
|
|
test('isHtmlLocator narrows HTML rows', () => {
|
|
expect(isHtmlLocator({ readerType: 'html', location: '#x' })).toBe(true);
|
|
expect(isHtmlLocator({ readerType: 'html', location: '' })).toBe(false);
|
|
expect(isHtmlLocator({ readerType: 'pdf', page: 1 })).toBe(false);
|
|
});
|
|
});
|
|
|
|
test.describe('normalizeEpubLocationToken', () => {
|
|
test('strips whitespace and step markers but preserves identity', () => {
|
|
const a = normalizeEpubLocationToken('epubcfi(/6/8!/4:0)');
|
|
const b = normalizeEpubLocationToken(' epubcfi(/6/8[;s=a]!/4:0) ');
|
|
expect(a).toBe(b);
|
|
});
|
|
});
|