forked from akai/readest
ed7cfc31fc
The `pages` getter used Math.ceil(viewSize / containerSize) which inflates the count by 1 when floating-point drift makes the ratio slightly above an integer (e.g. 4.00000001 → 5). Use Math.round to absorb sub-pixel drift, matching the approach already used in #getPagesBeforeView. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
361 lines
15 KiB
TypeScript
361 lines
15 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
// Mock the paginator module to avoid custom element registration conflicts
|
|
vi.mock('foliate-js/paginator.js', () => ({}));
|
|
|
|
describe('Paginator multi-view architecture', () => {
|
|
describe('View padding configuration', () => {
|
|
it('should have default padding of {before:1, after:1}', () => {
|
|
// The View class has #padding defaulting to {before:1, after:1}
|
|
// This is verified by checking that paginator.pages includes 2 padding pages
|
|
// when there's a single view (pages = contentPages + 2)
|
|
expect(true).toBe(true); // placeholder - tested via integration
|
|
});
|
|
});
|
|
|
|
describe('Paginator primaryIndex getter', () => {
|
|
it('should expose primaryIndex on the paginator element', async () => {
|
|
// Register a stub custom element if not already registered
|
|
if (!customElements.get('foliate-paginator')) {
|
|
// Use a simplified stub that mirrors the real Paginator's public interface
|
|
customElements.define(
|
|
'foliate-paginator',
|
|
class extends HTMLElement {
|
|
#primaryIndex = -1;
|
|
get primaryIndex() {
|
|
return this.#primaryIndex;
|
|
}
|
|
},
|
|
);
|
|
}
|
|
const el = document.createElement('foliate-paginator') as HTMLElement & {
|
|
primaryIndex: number;
|
|
};
|
|
expect(el.primaryIndex).toBe(-1);
|
|
});
|
|
});
|
|
|
|
describe('getContents returns all loaded views', () => {
|
|
it('should return contents sorted by section index', () => {
|
|
// With multi-view, getContents() returns entries from all loaded views
|
|
// sorted by section index. Each entry has { doc, index, overlayer }.
|
|
// This is verified by the fact that consumers can .find() by index.
|
|
const contents = [
|
|
{ doc: {} as Document, index: 3, overlayer: null },
|
|
{ doc: {} as Document, index: 4, overlayer: null },
|
|
{ doc: {} as Document, index: 5, overlayer: null },
|
|
];
|
|
const primaryIndex = 4;
|
|
const primary = contents.find((x) => x.index === primaryIndex) ?? contents[0];
|
|
expect(primary?.index).toBe(4);
|
|
});
|
|
|
|
it('should fall back to first entry when primaryIndex not found', () => {
|
|
const contents = [{ doc: {} as Document, index: 3, overlayer: null }];
|
|
const primaryIndex = 99; // not in contents
|
|
const primary = contents.find((x) => x.index === primaryIndex) ?? contents[0];
|
|
expect(primary?.index).toBe(3);
|
|
});
|
|
|
|
it('should handle empty contents gracefully', () => {
|
|
const contents: { doc: Document; index: number; overlayer: unknown }[] = [];
|
|
const primaryIndex = 0;
|
|
const primary = contents.find((x) => x.index === primaryIndex) ?? contents[0];
|
|
expect(primary).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('View padding assignment logic', () => {
|
|
it('should assign {before:1, after:1} for single view', () => {
|
|
const views = [{ index: 5, padding: { before: 0, after: 0 } }];
|
|
// Simulate #updateViewPadding
|
|
if (views.length === 1) {
|
|
views[0]!.padding = { before: 1, after: 1 };
|
|
}
|
|
expect(views[0]!.padding).toEqual({ before: 1, after: 1 });
|
|
});
|
|
|
|
it('should assign correct padding for multiple views', () => {
|
|
const views = [
|
|
{ index: 3, padding: { before: 0, after: 0 } },
|
|
{ index: 4, padding: { before: 0, after: 0 } },
|
|
{ index: 5, padding: { before: 0, after: 0 } },
|
|
];
|
|
// Simulate #updateViewPadding
|
|
for (let i = 0; i < views.length; i++) {
|
|
const before = i === 0 ? 1 : 0;
|
|
const after = i === views.length - 1 ? 1 : 0;
|
|
views[i]!.padding = { before, after };
|
|
}
|
|
expect(views[0]!.padding).toEqual({ before: 1, after: 0 });
|
|
expect(views[1]!.padding).toEqual({ before: 0, after: 0 });
|
|
expect(views[2]!.padding).toEqual({ before: 0, after: 1 });
|
|
});
|
|
|
|
it('should assign correct padding for two views', () => {
|
|
const views = [
|
|
{ index: 3, padding: { before: 0, after: 0 } },
|
|
{ index: 4, padding: { before: 0, after: 0 } },
|
|
];
|
|
for (let i = 0; i < views.length; i++) {
|
|
const before = i === 0 ? 1 : 0;
|
|
const after = i === views.length - 1 ? 1 : 0;
|
|
views[i]!.padding = { before, after };
|
|
}
|
|
expect(views[0]!.padding).toEqual({ before: 1, after: 0 });
|
|
expect(views[1]!.padding).toEqual({ before: 0, after: 1 });
|
|
});
|
|
});
|
|
|
|
describe('Page counting across views (column-level sizing)', () => {
|
|
// contentPages is now in column units. View element sizes are:
|
|
// contentColumns * columnSize + (padding.before + padding.after) * spreadSize
|
|
// where columnSize = spreadSize / columnCount
|
|
const spreadSize = 800;
|
|
const columnCount = 2;
|
|
const columnSize = spreadSize / columnCount; // 400
|
|
|
|
type ViewInfo = {
|
|
index: number;
|
|
contentPages: number;
|
|
padding: { before: number; after: number };
|
|
};
|
|
|
|
const getViewElementSize = (view: ViewInfo) => {
|
|
return (
|
|
view.contentPages * columnSize + (view.padding.before + view.padding.after) * spreadSize
|
|
);
|
|
};
|
|
|
|
const getViewOffset = (views: ViewInfo[], targetIndex: number) => {
|
|
let offset = 0;
|
|
for (const view of views) {
|
|
if (view.index === targetIndex) return offset;
|
|
offset += getViewElementSize(view);
|
|
}
|
|
return offset;
|
|
};
|
|
|
|
// #getPagesBeforeView uses pixel offsets divided by spread size (Math.floor)
|
|
const getPagesBeforeView = (views: ViewInfo[], targetIndex: number) => {
|
|
return Math.floor(getViewOffset(views, targetIndex) / spreadSize);
|
|
};
|
|
|
|
it('should count spread pages before a given view using pixel offsets', () => {
|
|
const views: ViewInfo[] = [
|
|
{ index: 3, contentPages: 5, padding: { before: 1, after: 0 } },
|
|
{ index: 4, contentPages: 2, padding: { before: 0, after: 0 } },
|
|
{ index: 5, contentPages: 8, padding: { before: 0, after: 1 } },
|
|
];
|
|
expect(getPagesBeforeView(views, 3)).toBe(0);
|
|
// View 3: 5*400 + 800 = 2800px → floor(2800/800) = 3
|
|
expect(getPagesBeforeView(views, 4)).toBe(3);
|
|
// View 3+4: 2800 + 2*400 = 3600 → floor(3600/800) = 4
|
|
expect(getPagesBeforeView(views, 5)).toBe(4);
|
|
});
|
|
|
|
it('should compute total pages using Math.ceil of viewSize / spreadSize', () => {
|
|
const views: ViewInfo[] = [
|
|
{ index: 0, contentPages: 5, padding: { before: 1, after: 0 } },
|
|
{ index: 1, contentPages: 2, padding: { before: 0, after: 0 } },
|
|
{ index: 2, contentPages: 8, padding: { before: 0, after: 1 } },
|
|
];
|
|
const totalViewSize = views.reduce((sum, v) => sum + getViewElementSize(v), 0);
|
|
// 5*400+800 + 2*400 + 8*400+800 = 2800 + 800 + 4000 = 7600
|
|
const totalPages = Math.ceil(totalViewSize / spreadSize);
|
|
expect(totalPages).toBe(Math.ceil(7600 / 800)); // 10
|
|
});
|
|
|
|
it('should place 1-column section at first column with next section after', () => {
|
|
// Image page (1 col) + normal section (10 cols)
|
|
const views: ViewInfo[] = [
|
|
{ index: 0, contentPages: 1, padding: { before: 1, after: 0 } },
|
|
{ index: 1, contentPages: 10, padding: { before: 0, after: 1 } },
|
|
];
|
|
// View 0: 1*400 + 800 = 1200. View 1: 10*400 + 800 = 4800
|
|
const totalViewSize = views.reduce((sum, v) => sum + getViewElementSize(v), 0);
|
|
expect(totalViewSize).toBe(6000);
|
|
expect(Math.ceil(totalViewSize / spreadSize)).toBe(8);
|
|
// Pages before view 1: floor(1200/800) = 1
|
|
expect(getPagesBeforeView(views, 1)).toBe(1);
|
|
});
|
|
|
|
it('should handle single-column layout (columnCount=1) same as before', () => {
|
|
const singleColSpread = 800;
|
|
const singleColSize = singleColSpread; // columnCount=1
|
|
// contentPages=3 means 3 columns, but with columnCount=1, each column IS a spread
|
|
const viewSize = 3 * singleColSize + 2 * singleColSpread;
|
|
expect(viewSize).toBe(4000);
|
|
expect(Math.ceil(viewSize / singleColSpread)).toBe(5); // 3 content + 2 padding
|
|
});
|
|
});
|
|
|
|
describe('Fraction and anchor calculation with column-level sizing', () => {
|
|
const columnCount = 2;
|
|
|
|
it('should compute fraction as localColumn / textPages', () => {
|
|
// Simulate #afterScroll fraction calculation
|
|
const textPages = 6; // 6 content columns
|
|
// At spread 0 (first content spread)
|
|
const localPage0 = 0;
|
|
const localColumn0 = localPage0 * columnCount; // 0
|
|
const fraction0 = Math.max(0, Math.min(1, localColumn0 / textPages));
|
|
expect(fraction0).toBe(0);
|
|
|
|
// At spread 1 (second content spread)
|
|
const localPage1 = 1;
|
|
const localColumn1 = localPage1 * columnCount; // 2
|
|
const fraction1 = Math.max(0, Math.min(1, localColumn1 / textPages));
|
|
expect(fraction1).toBeCloseTo(1 / 3);
|
|
|
|
// At spread 2 (third content spread, last)
|
|
const localPage2 = 2;
|
|
const localColumn2 = localPage2 * columnCount; // 4
|
|
const fraction2 = Math.max(0, Math.min(1, localColumn2 / textPages));
|
|
expect(fraction2).toBeCloseTo(2 / 3);
|
|
});
|
|
|
|
it('should convert anchor fraction to spread page for scrolling', () => {
|
|
const textPages = 5; // 5 content columns
|
|
// anchor=0 → column 0 → spread 0
|
|
expect(Math.floor(Math.round(0 * (textPages - 1)) / columnCount)).toBe(0);
|
|
// anchor=0.5 → column 2 → spread 1
|
|
expect(Math.floor(Math.round(0.5 * (textPages - 1)) / columnCount)).toBe(1);
|
|
// anchor=1.0 → column 4 → spread 2
|
|
expect(Math.floor(Math.round(1.0 * (textPages - 1)) / columnCount)).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('Primary section detection', () => {
|
|
it('should detect primary view as first visible view', () => {
|
|
// Simulate #detectPrimaryView — uses visibleStart, not midpoint
|
|
const views = [
|
|
{ index: 3, offset: 0, size: 100 },
|
|
{ index: 4, offset: 100, size: 50 },
|
|
{ index: 5, offset: 150, size: 200 },
|
|
];
|
|
const detectPrimary = (visibleStart: number) => {
|
|
let offset = 0;
|
|
for (const view of views) {
|
|
if (visibleStart < offset + view.size - 1) {
|
|
return view.index;
|
|
}
|
|
offset += view.size;
|
|
}
|
|
return views[views.length - 1]?.index;
|
|
};
|
|
expect(detectPrimary(0)).toBe(3); // start in first view
|
|
expect(detectPrimary(50)).toBe(3); // still in first view
|
|
expect(detectPrimary(100)).toBe(4); // at boundary → second view
|
|
expect(detectPrimary(125)).toBe(4); // in second view
|
|
expect(detectPrimary(150)).toBe(5); // at boundary → third view
|
|
expect(detectPrimary(300)).toBe(5); // in third view
|
|
});
|
|
|
|
it('should handle floating-point precision at view boundaries (Android DPR)', () => {
|
|
// On Android with non-integer DPR, container scrollLeft and accumulated
|
|
// getBoundingClientRect() sizes diverge by tiny amounts (~0.0001px).
|
|
// Real trace: visibleStart=1570.9090576171875, accumulated offset+viewSize=1570.9091796875
|
|
// Fix: 1px tolerance so sub-pixel drift doesn't pick the wrong view.
|
|
const viewSize = 785.45458984375;
|
|
const detectPrimary = (visibleStart: number) => {
|
|
let offset = 0;
|
|
const views = [
|
|
{ index: 1, size: viewSize },
|
|
{ index: 2, size: viewSize },
|
|
{ index: 3, size: viewSize },
|
|
];
|
|
for (const view of views) {
|
|
if (visibleStart < offset + view.size - 1) {
|
|
return view.index;
|
|
}
|
|
offset += view.size;
|
|
}
|
|
return views[views.length - 1]?.index;
|
|
};
|
|
// visibleStart from scrollLeft: 2 * viewSize computed differently than sum of rects
|
|
const visibleStart = 1570.9090576171875; // container.scrollLeft
|
|
// With tolerance, this should detect index 3 (not 2)
|
|
expect(detectPrimary(visibleStart)).toBe(3);
|
|
});
|
|
|
|
it('should not inflate page count due to fractional DPR rounding (pages getter)', () => {
|
|
// On fractional DPR devices, getBoundingClientRect() on the view element
|
|
// returns a width that is a near-integer multiple of the container size,
|
|
// but with tiny floating-point drift. Math.ceil inflates the count by 1.
|
|
// Real scenario: containerSize=785.45458984375, viewSize should be
|
|
// exactly 4*containerSize but getBoundingClientRect() returns a value
|
|
// with sub-pixel error.
|
|
const containerSize = 785.45458984375;
|
|
const viewSize = containerSize * 4 + 0.0001; // tiny FP drift
|
|
|
|
// Bug: Math.ceil gives 5 instead of 4
|
|
const buggyPages = Math.ceil(viewSize / containerSize);
|
|
expect(buggyPages).toBe(5); // confirms the bug exists
|
|
|
|
// Fix: Math.round absorbs sub-pixel drift
|
|
const fixedPages = Math.round(viewSize / containerSize);
|
|
expect(fixedPages).toBe(4);
|
|
});
|
|
|
|
it('should not inflate rendered page count due to fractional DPR rounding', () => {
|
|
// Same issue for #renderedPages which sums multiple view sizes
|
|
const containerSize = 785.45458984375;
|
|
const viewSizes = [containerSize * 3 + 0.00005, containerSize * 2 + 0.00008];
|
|
const totalViewSize = viewSizes.reduce((a, b) => a + b, 0);
|
|
|
|
// Bug: Math.ceil over-counts
|
|
const buggyPages = Math.ceil(totalViewSize / containerSize);
|
|
expect(buggyPages).toBe(6); // should be 5 but ceil rounds up
|
|
|
|
// Fix: Math.round
|
|
const fixedPages = Math.round(totalViewSize / containerSize);
|
|
expect(fixedPages).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('Adjacent index with fromIndex parameter', () => {
|
|
it('should find next linear section from a given index', () => {
|
|
const sections = [
|
|
{ linear: 'yes' }, // 0
|
|
{ linear: 'no' }, // 1 (non-linear)
|
|
{ linear: 'yes' }, // 2
|
|
{ linear: 'yes' }, // 3
|
|
{ linear: 'no' }, // 4 (non-linear)
|
|
{ linear: 'yes' }, // 5
|
|
];
|
|
const adjacentIndex = (dir: number, fromIndex: number) => {
|
|
for (let index = fromIndex + dir; index >= 0 && index < sections.length; index += dir) {
|
|
if (sections[index]?.linear !== 'no') return index;
|
|
}
|
|
return undefined;
|
|
};
|
|
expect(adjacentIndex(1, 0)).toBe(2); // skip non-linear 1
|
|
expect(adjacentIndex(1, 2)).toBe(3);
|
|
expect(adjacentIndex(1, 3)).toBe(5); // skip non-linear 4
|
|
expect(adjacentIndex(-1, 5)).toBe(3); // skip non-linear 4
|
|
expect(adjacentIndex(-1, 2)).toBe(0); // skip non-linear 1
|
|
expect(adjacentIndex(1, 5)).toBeUndefined(); // end of book
|
|
expect(adjacentIndex(-1, 0)).toBeUndefined(); // start of book
|
|
});
|
|
});
|
|
|
|
describe('Trim distant views', () => {
|
|
it('should keep max 4 views (1 before + primary + 2 after)', () => {
|
|
// Simulate #trimDistantViews
|
|
const viewIndices = [1, 3, 5, 7, 9, 11];
|
|
const primaryIndex = 5;
|
|
const sorted = [...viewIndices].sort((a, b) => a - b);
|
|
const primaryPos = sorted.indexOf(primaryIndex);
|
|
const keep = new Set([primaryIndex]);
|
|
if (primaryPos > 0) keep.add(sorted[primaryPos - 1]!);
|
|
for (let d = 1; d <= 2; d++) {
|
|
if (primaryPos + d < sorted.length) keep.add(sorted[primaryPos + d]!);
|
|
}
|
|
const remaining = viewIndices.filter((i) => keep.has(i));
|
|
expect(remaining).toEqual([3, 5, 7, 9]);
|
|
expect(remaining.length).toBeLessThanOrEqual(4);
|
|
});
|
|
});
|
|
});
|