openreader/tests/unit/epub-spine-coordinates.spec.ts
Richard R f9adba791d feat(tts,epub,db): enforce stable EPUB segment locators and add segmentKey identity
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.
2026-05-12 16:18:36 -06:00

408 lines
16 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Book } from 'epubjs';
import {
buildEpubLocator,
buildEpubLocatorFromChunk,
findSegmentOffset,
getSpineItemPlainText,
invalidateSpinePlainTextCache,
resolveMonotonicSentenceOffsets,
resolveSpineFromCfi,
} from '../../src/lib/client/epub/spine-coordinates';
import { isStableEpubLocator } from '../../src/types/client';
interface FakeSection {
index: number;
href: string;
cfiBase: string;
__text: string;
// epubjs's real `section.load()` resolves to an `<html>` Element, but
// historically the fake returned a Document. We accept both so the helper's
// defensive shape-handling can be tested.
load: (request?: unknown) => Promise<Element | Document>;
unload: () => void;
}
function makeFakeBook(items: Array<{ index: number; href: string; cfiBase: string; text: string }>) {
// Mirror what epubjs's `Section.load()` actually does: resolve to the
// spine item's `<html>` Element (NOT a Document). The helper has to query
// `<body>` inside it — that's the contract this test fixture pins down.
const sections: FakeSection[] = items.map((item) => ({
index: item.index,
href: item.href,
cfiBase: item.cfiBase,
__text: item.text,
load: async () => {
if (typeof document === 'undefined') {
// Node fallback: hand back a shape with a `body` getter so the
// helper's body-aware branch still works.
return {
querySelector: (sel: string) => (sel === 'body' ? { textContent: item.text } : null),
textContent: item.text,
} as unknown as Element;
}
const html = document.createElement('html');
const body = document.createElement('body');
body.textContent = item.text;
html.appendChild(body);
return html as unknown as Document;
},
unload: () => {},
}));
const get = (target: unknown) => {
if (typeof target === 'number') {
return sections.find((s) => s.index === target) ?? null;
}
if (typeof target === 'string') {
// Match by href substring or by cfiBase substring (epubcfi(/6/4!...) → '/6/4').
const byHref = sections.find((s) => s.href === target || target.includes(s.href));
if (byHref) return byHref;
const byCfiBase = sections.find((s) => target.includes(s.cfiBase));
if (byCfiBase) return byCfiBase;
}
return null;
};
const book = {
spine: { get, spineItems: sections },
load: () => Promise.resolve(undefined),
} as unknown as Book;
return { book, sections };
}
test.describe('findSegmentOffset', () => {
test('finds an offset for matching text', () => {
const spineText = 'Hello world. This is a test paragraph for offsets.';
const offset = findSegmentOffset(spineText, 'this is a test');
expect(offset).toBeGreaterThanOrEqual(0);
});
test('uses the hint to disambiguate repeated phrases', () => {
const spineText = 'apple banana apple banana apple banana';
// First occurrence
const first = findSegmentOffset(spineText, 'apple banana', 0);
// Hint past the first occurrence
const second = findSegmentOffset(spineText, 'apple banana', first + 1);
expect(second).toBeGreaterThan(first);
});
test('returns -1 when the segment text is not present', () => {
expect(findSegmentOffset('hello world', 'goodbye')).toBe(-1);
});
test('falls back to a from-start search when the hint overshoots', () => {
const spineText = 'apple banana cherry';
const offset = findSegmentOffset(spineText, 'apple', 100);
expect(offset).toBe(0);
});
});
test.describe('resolveSpineFromCfi', () => {
test('returns spine identity for a CFI inside a known spine', () => {
const { book } = makeFakeBook([
{ index: 2, href: 'OEBPS/ch02.xhtml', cfiBase: '/6/4', text: 'chapter 2 contents' },
{ index: 3, href: 'OEBPS/ch03.xhtml', cfiBase: '/6/6', text: 'chapter 3 contents' },
]);
const resolved = resolveSpineFromCfi(book, 'epubcfi(/6/4!/4:0)');
expect(resolved).toEqual({ href: 'OEBPS/ch02.xhtml', index: 2 });
});
test('returns null when the spine.get call throws', () => {
const broken = {
spine: {
get: () => { throw new Error('boom'); },
spineItems: [],
},
} as unknown as Book;
expect(resolveSpineFromCfi(broken, 'epubcfi(/6/4!/4:0)')).toBeNull();
});
test('returns null for null/undefined input', () => {
const { book } = makeFakeBook([]);
expect(resolveSpineFromCfi(book, null)).toBeNull();
expect(resolveSpineFromCfi(book, undefined)).toBeNull();
});
});
test.describe('buildEpubLocator', () => {
test('produces a stable locator with charOffset within the spine item', async () => {
const { book } = makeFakeBook([
{
index: 2,
href: 'OEBPS/ch02.xhtml',
cfiBase: '/6/4',
text: 'First paragraph. The quick brown fox jumps over the lazy dog. Final words here.',
},
]);
invalidateSpinePlainTextCache(book);
const locator = await buildEpubLocator(
book,
'epubcfi(/6/4!/4:0)',
'The quick brown fox',
);
expect(locator).not.toBeNull();
expect(isStableEpubLocator(locator)).toBe(true);
if (!locator || !isStableEpubLocator(locator)) return; // narrow for TS
expect(locator.spineHref).toBe('OEBPS/ch02.xhtml');
expect(locator.spineIndex).toBe(2);
expect(locator.charOffset).toBeGreaterThan(0);
expect(locator.cfi).toBe('epubcfi(/6/4!/4:0)');
});
test('returns null when the CFI does not resolve to any spine item', async () => {
const { book } = makeFakeBook([
{ index: 0, href: 'OEBPS/ch00.xhtml', cfiBase: '/6/2', text: 'only chapter' },
]);
const locator = await buildEpubLocator(book, 'epubcfi(/99/99!/4:0)', 'nope');
expect(locator).toBeNull();
});
test('same content text yields identical locators across two calls (stability)', async () => {
const { book } = makeFakeBook([
{
index: 4,
href: 'OEBPS/ch04.xhtml',
cfiBase: '/6/8',
text: 'Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu.',
},
]);
const a = await buildEpubLocator(book, 'epubcfi(/6/8!/4:0)', 'gamma delta epsilon');
const b = await buildEpubLocator(book, 'epubcfi(/6/8!/4:0)', 'gamma delta epsilon');
expect(a).toEqual(b);
});
});
test.describe('buildEpubLocatorFromChunk', () => {
test('uses the chunk anchor offset as a hint to disambiguate repeated text', () => {
const spineText = 'foo bar foo bar foo bar';
const anchorEarly = {
spineHref: 'a.xhtml',
spineIndex: 0,
charOffset: 0,
spineText,
};
const anchorLate = {
spineHref: 'a.xhtml',
spineIndex: 0,
charOffset: 8, // past the first "foo bar"
spineText,
};
const early = buildEpubLocatorFromChunk(anchorEarly, 'foo bar');
const late = buildEpubLocatorFromChunk(anchorLate, 'foo bar');
expect(early.charOffset).toBeLessThan(late.charOffset ?? Infinity);
});
test('NEVER returns an offset before the chunk anchor (forward-only)', () => {
// Regression: a segment text that recurs earlier in the chapter must not
// get persisted with the earlier offset. The forward-only contract is
// what keeps later-page rows from interleaving with earlier pages in the
// sidebar's sort.
const spineText = 'Yes I said. ' + 'Filler. '.repeat(20) + 'Yes I said. The actual page sentence.';
const earlyOccurrence = spineText.indexOf('Yes I said.');
const lateOccurrence = spineText.indexOf('Yes I said.', earlyOccurrence + 1);
expect(earlyOccurrence).toBeLessThan(lateOccurrence); // sanity
const anchor = {
spineHref: 'a.xhtml',
spineIndex: 0,
// Anchored ~at the actual page (past the early occurrence).
charOffset: lateOccurrence - 5,
spineText,
};
const locator = buildEpubLocatorFromChunk(anchor, 'Yes I said.');
expect(locator.charOffset).toBeGreaterThanOrEqual(anchor.charOffset);
// Specifically: must be at the late occurrence, NOT the early one.
expect(locator.charOffset).toBeGreaterThan(earlyOccurrence);
});
test('holds the anchor offset when segment text is not found ahead', () => {
const anchor = {
spineHref: 'a.xhtml',
spineIndex: 0,
charOffset: 100,
spineText: 'short text here',
};
const locator = buildEpubLocatorFromChunk(anchor, 'definitely not present');
expect(locator.charOffset).toBe(100); // holds at anchor, never goes earlier
});
});
test.describe('buildEpubLocator anchored-search regression', () => {
// Pins the contract for the server-side resolver path: when called with
// a `chunkText` argument representing the current rendered page, the
// returned locator's `charOffset` is **at or after** the chunk's position
// in the spine — never the segment's earliest occurrence in the chapter.
test('locator for a recurring sentence sits at the page anchor, not the chapter-early echo', async () => {
if (typeof document === 'undefined') return; // DOM-only via fixture
const earlyEcho = 'The Lighthouse.';
const pageText = 'On Tuesday, the visitors arrived. The Lighthouse. They marveled at the height.';
const chapterText = `${earlyEcho} ${'Filler sentence. '.repeat(40)}${pageText}`;
const { book } = makeFakeBook([
{ index: 0, href: 'ch.xhtml', cfiBase: '/6/2', text: chapterText },
]);
invalidateSpinePlainTextCache(book);
// Search the SAME segment text. The resolver passes the page text as
// chunkText, so the anchor sits at the page's start — not at the
// earlier `earlyEcho` occurrence.
const locator = await buildEpubLocator(
book,
'epubcfi(/6/2!/4:0)',
'The Lighthouse.',
pageText,
);
expect(locator).not.toBeNull();
if (!locator || !isStableEpubLocator(locator)) return;
// The returned offset must be past where the early echo lives.
const earlyEchoIndex = chapterText.toLowerCase().indexOf(earlyEcho.toLowerCase());
expect(locator.charOffset).toBeGreaterThan(earlyEchoIndex);
});
});
test.describe('findSegmentOffset fallback contract', () => {
// These tests pin the documented behavior of `findSegmentOffset`. The
// from-start fallback is correct for single-shot lookups but **wrong** in
// a monotonic per-sentence walk — `resolveMonotonicSentenceOffsets` exists
// precisely to avoid this. Keep these tests so the two helpers' contracts
// stay distinct.
test('returns the earliest occurrence when the hint overshoots', () => {
expect(findSegmentOffset('Yes. No. Maybe.', 'yes.', /* hint */ 50)).toBe(0);
});
test('falls back to from-start when the forward search misses', () => {
// "echo" appears at index 0. Hint past the only occurrence should still
// find it via the from-start fallback.
expect(findSegmentOffset('echo and silence', 'echo', /* hint */ 10)).toBe(0);
});
});
test.describe('resolveMonotonicSentenceOffsets', () => {
test('returns monotonically non-decreasing offsets for sentences in order', () => {
const spineText = 'The cat sat on the mat. The dog barked loudly. Then it was quiet.';
const sentences = [
'The cat sat on the mat.',
'The dog barked loudly.',
'Then it was quiet.',
];
const offsets = resolveMonotonicSentenceOffsets(spineText, sentences);
expect(offsets).toHaveLength(3);
for (let i = 1; i < offsets.length; i += 1) {
expect(offsets[i]).toBeGreaterThanOrEqual(offsets[i - 1]);
}
});
test('does NOT jump backwards when a later sentence echoes earlier text', () => {
// Regression: a sentence on the current page that happens to contain a
// phrase also present earlier in the chapter (chapter title, refrain,
// etc.) used to take the earlier occurrence's offset via
// `findSegmentOffset`'s from-start fallback, which silently reordered
// the sidebar's synth rows. The monotonic helper must never do that.
const spineText = 'The Lighthouse. Several pages of narrative go here. The Lighthouse stood tall.';
const sentences = [
'Several pages of narrative go here.', // ~at offset 16
'The Lighthouse stood tall.', // recurs — must NOT jump back to offset 0
];
const offsets = resolveMonotonicSentenceOffsets(spineText, sentences);
expect(offsets[1]).toBeGreaterThan(offsets[0]);
});
test('holds the cursor for a sentence that cannot be found ahead', () => {
const spineText = 'alpha beta gamma delta';
const sentences = ['gamma', 'something not present', 'delta'];
const offsets = resolveMonotonicSentenceOffsets(spineText, sentences);
// gamma found, missing sentence holds cursor, delta found ahead of it.
expect(offsets[0]).toBeGreaterThan(0);
expect(offsets[1]).toBeGreaterThanOrEqual(offsets[0]); // monotonic
expect(offsets[2]).toBeGreaterThan(offsets[1]); // delta is after gamma
});
test('handles empty inputs gracefully', () => {
expect(resolveMonotonicSentenceOffsets('', ['anything'])).toEqual([0]);
expect(resolveMonotonicSentenceOffsets('hello world', [])).toEqual([]);
expect(resolveMonotonicSentenceOffsets('hello world', ['', '', ''])).toEqual([0, 0, 0]);
});
test('repeated phrase across the page is resolved at successive occurrences', () => {
// Three "yes." sentences in a row should each pick the next occurrence,
// not all pile onto the first one.
const spineText = 'yes. yes. yes. end.';
const sentences = ['yes.', 'yes.', 'yes.'];
const offsets = resolveMonotonicSentenceOffsets(spineText, sentences);
expect(new Set(offsets).size).toBe(3); // all distinct
expect(offsets[0]).toBeLessThan(offsets[1]);
expect(offsets[1]).toBeLessThan(offsets[2]);
});
test('normalizes whitespace and casing the same as segmentKey identity', () => {
// The helper uses `normalizeSegmentIdentityText` internally so it agrees
// with the segmentKey hash space. Different casing / whitespace must
// still locate the sentence.
const spineText = 'Hello World. More content here.';
const sentences = ['hello world.', 'MORE content here.'];
const offsets = resolveMonotonicSentenceOffsets(spineText, sentences);
expect(offsets[0]).toBe(0);
expect(offsets[1]).toBeGreaterThan(offsets[0]);
});
});
test.describe('getSpineItemPlainText shape handling', () => {
// Regression: epubjs's `Section.load()` resolves to the spine item's
// `<html>` Element (NOT a Document). Reading `loaded.body` directly was
// returning undefined for every spine item, so spineText came back ''
// and every `findSegmentOffset` returned -1 → every persisted charOffset
// ended up at 0. These tests pin both shapes so a future epubjs upgrade
// (or a defensive rewrite) doesn't quietly break extraction again.
test('extracts text when section.load resolves to an <html> Element', async () => {
// Use the harness fixture which already returns a real <html> element
// when DOM is available.
if (typeof document === 'undefined') {
// Skip in non-DOM environments.
return;
}
const { book } = makeFakeBook([
{ index: 0, href: 'ch.xhtml', cfiBase: '/6/2', text: 'real text from html element' },
]);
invalidateSpinePlainTextCache(book);
const text = await getSpineItemPlainText(book, 'ch.xhtml');
expect(text).toContain('real text');
});
test('extracts text when section.load resolves to a Document-shaped object', async () => {
// Defensive: if a future epubjs version (or a custom shim) returns a
// Document instead of an Element, the helper should still work.
const fakeBook = {
spine: {
get: () => ({
href: 'ch.xhtml',
load: async () => ({
body: { textContent: 'doc-shaped content' },
documentElement: { textContent: 'doc-shaped content' },
}),
unload: () => {},
}),
spineItems: [],
},
load: () => Promise.resolve(undefined),
} as unknown as Parameters<typeof getSpineItemPlainText>[0];
const text = await getSpineItemPlainText(fakeBook, 'ch.xhtml');
expect(text).toBe('doc-shaped content');
});
test('returns empty string when section.load resolves to null/undefined', async () => {
const fakeBook = {
spine: {
get: () => ({
href: 'ch.xhtml',
load: async () => null,
unload: () => {},
}),
spineItems: [],
},
load: () => Promise.resolve(undefined),
} as unknown as Parameters<typeof getSpineItemPlainText>[0];
const text = await getSpineItemPlainText(fakeBook, 'ch.xhtml');
expect(text).toBe('');
});
});