import { expect, test } from '@playwright/test'; import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey, normalizeEpubLocationToken, } from '../../src/lib/shared/tts-locator'; import { isHtmlLocator, isPdfLocator, isStableEpubLocator, type TTSSegmentLocator, } from '../../src/types/client'; const epubLocator = ( spineHref: string, spineIndex: number, charOffset: number, cfi?: string, ): TTSSegmentLocator => ({ readerType: 'epub', spineHref, spineIndex, charOffset, ...(cfi ? { cfi } : {}), }); test.describe('locatorGroupKey', () => { test('groups stable EPUB locators by spine index and href', () => { expect(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 0))).toBe('epub:2:OEBPS/ch02.xhtml'); // Different charOffset, same spine → same group (sidebar bucket is chapter-sized). expect(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 1024))) .toBe(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 0))); // Different spine → different group. expect(locatorGroupKey(epubLocator('OEBPS/ch03.xhtml', 3, 0))) .not.toBe(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 0))); }); test('groups PDF locators by page', () => { expect(locatorGroupKey({ readerType: 'pdf', page: 7 })).toBe('pdf:7'); }); test('groups HTML locators by location', () => { expect(locatorGroupKey({ readerType: 'html', location: '#anchor' })).toBe('html:#anchor'); }); test('returns "none" for null locators', () => { expect(locatorGroupKey(null)).toBe('none'); }); test('falls back to a legacy group key for EPUB drafts missing spine coords', () => { // A draft EPUB locator (just a CFI) should not co-group with stable rows. const draft: TTSSegmentLocator = { readerType: 'epub', location: 'epubcfi(/6/8!/4:0)' }; expect(locatorGroupKey(draft)).toContain('legacy'); }); }); test.describe('locatorIdentityKey', () => { test('two EPUB rows in the same chapter at different charOffsets get distinct identity keys', () => { // This is the bug that caused page-N rows to swallow page-(N+1) rows in // the server-side manifest aggregator: the previous code keyed by the // chapter-coarse groupKey, collapsing distinct rows into a single bucket. const a = epubLocator('OEBPS/ch02.xhtml', 2, 0); const b = epubLocator('OEBPS/ch02.xhtml', 2, 1024); expect(locatorIdentityKey(a)).not.toBe(locatorIdentityKey(b)); }); test('two EPUB rows at the same coordinate get equal identity keys', () => { // Identity is content-of-locator only; the optional cfi jump hint is // intentionally not part of identity. const a = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:0)'); const b = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:8)'); expect(locatorIdentityKey(a)).toBe(locatorIdentityKey(b)); }); test('chapter-coarse group key collides across rows that identity-key keeps distinct', () => { // Sanity-check the relationship between the two helpers. const a = epubLocator('OEBPS/ch02.xhtml', 2, 0); const b = epubLocator('OEBPS/ch02.xhtml', 2, 1024); expect(locatorGroupKey(a)).toBe(locatorGroupKey(b)); // same chapter bucket expect(locatorIdentityKey(a)).not.toBe(locatorIdentityKey(b)); // distinct rows }); }); test.describe('compareSegmentLocators', () => { test('orders EPUB rows by spineIndex then charOffset numerically', () => { // Spine 10 must come AFTER spine 2 — a lexicographic compare would // wrongly place "10" before "2". const a = epubLocator('a.xhtml', 10, 0); const b = epubLocator('b.xhtml', 2, 0); expect(compareSegmentLocators(a, b)).toBeGreaterThan(0); expect(compareSegmentLocators(b, a)).toBeLessThan(0); }); test('within the same spine, orders by charOffset', () => { const a = epubLocator('OEBPS/ch02.xhtml', 2, 50); const b = epubLocator('OEBPS/ch02.xhtml', 2, 1000); expect(compareSegmentLocators(a, b)).toBeLessThan(0); expect(compareSegmentLocators(b, a)).toBeGreaterThan(0); }); test('treats equal stable EPUB locators as equal regardless of optional cfi hint', () => { const a = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:0)'); const b = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:8)'); // cfi is a soft jump hint, not part of identity/sort. expect(compareSegmentLocators(a, b)).toBe(0); }); test('orders PDF locators by page numerically', () => { const a: TTSSegmentLocator = { readerType: 'pdf', page: 2 }; const b: TTSSegmentLocator = { readerType: 'pdf', page: 10 }; expect(compareSegmentLocators(a, b)).toBeLessThan(0); }); test('null locators sort last', () => { expect(compareSegmentLocators(null, { readerType: 'pdf', page: 1 })).toBeGreaterThan(0); expect(compareSegmentLocators({ readerType: 'pdf', page: 1 }, null)).toBeLessThan(0); expect(compareSegmentLocators(null, null)).toBe(0); }); }); test.describe('type guards', () => { test('isStableEpubLocator requires all spine fields', () => { expect(isStableEpubLocator(epubLocator('a.xhtml', 0, 0))).toBe(true); expect(isStableEpubLocator({ readerType: 'epub', spineHref: 'a.xhtml' })).toBe(false); expect(isStableEpubLocator({ readerType: 'epub', location: 'epubcfi(...)' })).toBe(false); expect(isStableEpubLocator(null)).toBe(false); expect(isStableEpubLocator({ readerType: 'pdf', page: 1 })).toBe(false); }); test('isPdfLocator narrows PDF rows', () => { expect(isPdfLocator({ readerType: 'pdf', page: 3 })).toBe(true); expect(isPdfLocator({ readerType: 'pdf' })).toBe(false); expect(isPdfLocator(epubLocator('a.xhtml', 0, 0))).toBe(false); }); test('isHtmlLocator narrows HTML rows', () => { expect(isHtmlLocator({ readerType: 'html', location: '#x' })).toBe(true); expect(isHtmlLocator({ readerType: 'html', location: '' })).toBe(false); expect(isHtmlLocator({ readerType: 'pdf', page: 1 })).toBe(false); }); }); test.describe('normalizeEpubLocationToken', () => { test('strips whitespace and step markers but preserves identity', () => { const a = normalizeEpubLocationToken('epubcfi(/6/8!/4:0)'); const b = normalizeEpubLocationToken(' epubcfi(/6/8[;s=a]!/4:0) '); expect(a).toBe(b); }); });