openreader/tests/unit/tts-segments-manifest.spec.ts
Richard R 8488ad37e1 refactor(tts,db,manifest): split tts_segments into normalized entry and variant tables
Redesign TTS segment storage by decomposing the `tts_segments` table into two normalized tables: `tts_segment_entries` for unique segment identity and locator projection, and `tts_segment_variants` for per-settings audio variants. Update schema, queries, and API routes to use the new structure, including manifest pagination and segment resolution logic. Refactor segment locator handling to use stable projections and manifest cursors. Migrate tests and data cleanup utilities to support the new model.

BREAKING CHANGE: Database schema for TTS segments is now split into entry and variant tables; all code and consumers must use the new structure. Persisted data and APIs relying on the old `tts_segments` table are incompatible.
2026-05-12 17:44:57 -06:00

184 lines
7.7 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 = {
locatorReaderRank: 0,
locatorSpineIndex: 2,
locatorCharOffset: 128,
locatorSpineHref: 'OEBPS/ch02.xhtml',
locatorPage: -1,
locatorLocation: '',
segmentIndex: 4,
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
segmentEntryId: 'entry-4',
};
const encoded = encodeManifestCursor(raw);
expect(decodeManifestCursor(encoded)).toEqual(raw);
expect(decodeManifestCursor('not-base64')).toBeNull();
const malformed = Buffer.from(JSON.stringify({ bad: true }), 'utf8').toString('base64url');
expect(decodeManifestCursor(malformed)).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);
});
});