openreader/tests/unit/epub-spine-coordinates.vitest.spec.ts

408 lines
16 KiB
TypeScript

import { describe, expect, test } from 'vitest';
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 };
}
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);
});
});
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();
});
});
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);
});
});
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
});
});
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);
});
});
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);
});
});
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]);
});
});
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('');
});
});