180 lines
7.5 KiB
TypeScript
180 lines
7.5 KiB
TypeScript
import { describe, expect, test } from 'vitest';
|
|
import {
|
|
compareManifestSegments,
|
|
decodeManifestCursor,
|
|
dedupeManifestVariants,
|
|
encodeManifestCursor,
|
|
parseManifestPageSize,
|
|
} from '../../src/lib/server/tts/segments-manifest';
|
|
import { locatorGroupKey, locatorIdentityKey } from '../../src/lib/shared/tts-locator';
|
|
|
|
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.
|
|
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.
|
|
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);
|
|
});
|
|
});
|