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.
172 lines
7.3 KiB
TypeScript
172 lines
7.3 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import {
|
|
compareManifestSegments,
|
|
decodeManifestCursor,
|
|
dedupeManifestVariants,
|
|
encodeManifestCursor,
|
|
parseManifestPageSize,
|
|
} from '../../src/lib/server/tts/segments-manifest';
|
|
|
|
test.describe('tts segments manifest helpers', () => {
|
|
test('dedupe prefers completed variant over newer pending for same settings key', () => {
|
|
const variants = dedupeManifestVariants([
|
|
{
|
|
dedupeKey: 'settings:abc',
|
|
variant: {
|
|
segmentId: 'old-completed',
|
|
settings: null,
|
|
audioPresignUrl: '/audio/old',
|
|
audioFallbackUrl: '/audio/old/fallback',
|
|
durationMs: 1100,
|
|
status: 'completed',
|
|
textLength: 12,
|
|
alignmentWordCount: 2,
|
|
audioKey: 'old',
|
|
updatedAt: 100,
|
|
},
|
|
},
|
|
{
|
|
dedupeKey: 'settings:abc',
|
|
variant: {
|
|
segmentId: 'new-pending',
|
|
settings: null,
|
|
audioPresignUrl: null,
|
|
audioFallbackUrl: null,
|
|
durationMs: null,
|
|
status: 'pending',
|
|
textLength: 15,
|
|
alignmentWordCount: 0,
|
|
audioKey: null,
|
|
updatedAt: 200,
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(variants).toHaveLength(1);
|
|
expect(variants[0].segmentId).toBe('old-completed');
|
|
expect(variants[0].status).toBe('completed');
|
|
});
|
|
|
|
test('dedupe uses status rank as tiebreaker when updatedAt is equal', () => {
|
|
const variants = dedupeManifestVariants([
|
|
{
|
|
dedupeKey: 'settings:abc',
|
|
variant: {
|
|
segmentId: 'pending',
|
|
settings: null,
|
|
audioPresignUrl: null,
|
|
audioFallbackUrl: null,
|
|
durationMs: null,
|
|
status: 'pending',
|
|
textLength: 12,
|
|
alignmentWordCount: 0,
|
|
audioKey: null,
|
|
updatedAt: 123,
|
|
},
|
|
},
|
|
{
|
|
dedupeKey: 'settings:abc',
|
|
variant: {
|
|
segmentId: 'completed',
|
|
settings: null,
|
|
audioPresignUrl: '/audio/new',
|
|
audioFallbackUrl: '/audio/new/fallback',
|
|
durationMs: 1200,
|
|
status: 'completed',
|
|
textLength: 12,
|
|
alignmentWordCount: 2,
|
|
audioKey: 'new',
|
|
updatedAt: 123,
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(variants).toHaveLength(1);
|
|
expect(variants[0].segmentId).toBe('completed');
|
|
expect(variants[0].status).toBe('completed');
|
|
});
|
|
|
|
test('sorts PDF segments deterministically by page and segment index', () => {
|
|
const rows = [
|
|
{ groupKey: 'p2-i4', segmentIndex: 4, locator: { readerType: 'pdf' as const, page: 2 } },
|
|
{ groupKey: 'p1-i1', segmentIndex: 1, locator: { readerType: 'pdf' as const, page: 1 } },
|
|
{ groupKey: 'p1-i0', segmentIndex: 0, locator: { readerType: 'pdf' as const, page: 1 } },
|
|
{ groupKey: 'p1-i2', segmentIndex: 2, locator: { readerType: 'pdf' as const, page: 1 } },
|
|
];
|
|
|
|
const sorted = rows.sort(compareManifestSegments);
|
|
expect(sorted.map((row) => row.groupKey)).toEqual(['p1-i0', 'p1-i1', 'p1-i2', 'p2-i4']);
|
|
});
|
|
|
|
test('sorts EPUB locators by spineIndex then charOffset (numeric, viewport-stable)', () => {
|
|
// The order below is intentionally jumbled, with spineIndex 10 placed
|
|
// first so that a lexicographic compare on the spine string would put
|
|
// "10" before "2". A correct numeric compare must surface spine 2 first.
|
|
const rows = [
|
|
{ groupKey: 'spine-10-off-0', segmentIndex: 0, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch10.xhtml', spineIndex: 10, charOffset: 0 } },
|
|
{ groupKey: 'spine-2-off-200', segmentIndex: 1, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 200 } },
|
|
{ groupKey: 'spine-2-off-50', segmentIndex: 0, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 50 } },
|
|
];
|
|
|
|
const sorted = rows.sort(compareManifestSegments);
|
|
expect(sorted.map((row) => row.groupKey)).toEqual(['spine-2-off-50', 'spine-2-off-200', 'spine-10-off-0']);
|
|
});
|
|
|
|
test('groups EPUB rows by spine identity, not by raw CFI', () => {
|
|
// Two rows in the same spine item but at different char offsets must share
|
|
// a groupKey (so the sidebar buckets them together as one chapter), while
|
|
// a row in a different spine sits in its own group.
|
|
const rows = [
|
|
{ groupKey: 'a', segmentIndex: 0, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 0, cfi: 'epubcfi(/6/4!/2:0)' } },
|
|
{ groupKey: 'b', segmentIndex: 1, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 1024, cfi: 'epubcfi(/6/4!/4:0)' } },
|
|
{ groupKey: 'c', segmentIndex: 0, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch03.xhtml', spineIndex: 3, charOffset: 0, cfi: 'epubcfi(/6/6!/2:0)' } },
|
|
];
|
|
|
|
const sorted = rows.sort(compareManifestSegments);
|
|
// Recompute groupKey using the production helper to assert the actual
|
|
// grouping behavior rather than the test's hand-labeled groupKey.
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { locatorGroupKey } = require('../../src/lib/shared/tts-locator');
|
|
const groupKeys = sorted.map((row) => locatorGroupKey(row.locator));
|
|
expect(groupKeys[0]).toBe(groupKeys[1]); // both ch02
|
|
expect(groupKeys[2]).not.toBe(groupKeys[0]); // ch03 is its own group
|
|
});
|
|
|
|
test('manifest aggregator distinguishes rows by identity (regression: per-page rows collapsed by chapter bucket)', () => {
|
|
// Bug reproduction: previously the server-side aggregator keyed rows by
|
|
// `${segmentIndex}|${locatorGroupKey(locator)}`. Because the new EPUB
|
|
// groupKey is chapter-coarse, two persisted rows in different *pages* of
|
|
// the same chapter that happened to share segmentIndex (which is
|
|
// page-relative) collapsed into a single entry — making earlier pages'
|
|
// rows visually "disappear" when later pages were generated.
|
|
//
|
|
// The fix uses `locatorIdentityKey` which includes charOffset for EPUB.
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { locatorIdentityKey, locatorGroupKey } = require('../../src/lib/shared/tts-locator');
|
|
|
|
const page1Row0 = { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 0 };
|
|
const page2Row0 = { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 2048 };
|
|
|
|
// Same chapter bucket (sidebar grouping should put them under one chapter header).
|
|
expect(locatorGroupKey(page1Row0)).toBe(locatorGroupKey(page2Row0));
|
|
// Different storage identity (server aggregator must NOT collapse them).
|
|
const segmentIndex = 0;
|
|
const k1 = `${segmentIndex}|${locatorIdentityKey(page1Row0)}`;
|
|
const k2 = `${segmentIndex}|${locatorIdentityKey(page2Row0)}`;
|
|
expect(k1).not.toBe(k2);
|
|
});
|
|
|
|
test('encodes and decodes cursors', () => {
|
|
const raw = '3|p:2|l:epubcfi(/6/2)|r:epub';
|
|
const encoded = encodeManifestCursor(raw);
|
|
expect(decodeManifestCursor(encoded)).toBe(raw);
|
|
expect(decodeManifestCursor('not-base64')).toBeNull();
|
|
});
|
|
|
|
test('clamps page size bounds', () => {
|
|
expect(parseManifestPageSize(null)).toBe(150);
|
|
expect(parseManifestPageSize('10')).toBe(25);
|
|
expect(parseManifestPageSize('900')).toBe(500);
|
|
expect(parseManifestPageSize('200')).toBe(200);
|
|
});
|
|
});
|