import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
applyScrollableStyle,
applyTableTouchScroll,
findScrollableBox,
updateTableFit,
shouldTableScrollConsumeTouch,
shouldTableScrollConsumeWheel,
SCROLL_WRAPPER_CLASS,
SCROLL_WRAPPER_FIT_CLASS,
} from '@/utils/scrollable';
describe('applyScrollableStyle', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('wraps a table in a horizontal scroll container', () => {
document.body.innerHTML = `
`;
applyScrollableStyle(document);
const table = document.querySelector('table')!;
const wrapper = table.parentElement;
expect(wrapper?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
expect(table.style.transform).toBe('');
});
it('wraps multiple tables independently', () => {
document.body.innerHTML = `
`;
applyScrollableStyle(document);
const wrappers = document.querySelectorAll(`.${SCROLL_WRAPPER_CLASS}`);
expect(wrappers).toHaveLength(2);
});
it('does not double-wrap when applyScrollableStyle runs twice', () => {
document.body.innerHTML = `
`;
applyScrollableStyle(document);
applyScrollableStyle(document);
expect(document.querySelectorAll(`.${SCROLL_WRAPPER_CLASS}`)).toHaveLength(1);
});
it('does not crash on a table whose parent is body', () => {
document.body.innerHTML = `
`;
applyScrollableStyle(document);
const table = document.querySelector('table')!;
expect(table.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
});
it('hoists a negative table margin onto the wrapper so it is not clipped (#4439)', () => {
// A decorative TOC lays out as nested tables; the inner table pulls itself
// up with a negative top margin. Inside an overflow:auto wrapper that margin
// would bleed the table (and its heading) past the box edge and get clipped.
document.body.innerHTML = `
`;
applyScrollableStyle(document);
const table = document.querySelector('table')!;
const wrapper = table.parentElement!;
expect(wrapper.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
// The negative top margin moves to the wrapper (box stays in place)...
expect(wrapper.style.marginTop).toBe('-1em');
// ...and is zeroed on the table so it sits flush inside the clip box.
expect(table.style.marginTop).toBe('0px');
// The positive left margin is left alone (still counts toward overflow).
expect(wrapper.style.marginLeft).toBe('');
expect(table.style.marginLeft).toBe('1em');
});
it('leaves a table with only non-negative margins untouched', () => {
document.body.innerHTML = `
`;
applyScrollableStyle(document);
const table = document.querySelector('table')!;
const wrapper = table.parentElement!;
expect(wrapper.style.marginTop).toBe('');
expect(table.style.marginTop).toBe('1em');
expect(table.style.marginLeft).toBe('auto');
});
it('wraps a display equation (math that is the sole content of its container)', () => {
document.body.innerHTML = `
`;
applyScrollableStyle(document);
const math = document.querySelector('math')!;
expect(math.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
});
it('does not wrap inline math that flows with surrounding text', () => {
document.body.innerHTML = `tokens , then more text
`;
applyScrollableStyle(document);
const math = document.querySelector('math')!;
expect(math.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(false);
expect(math.parentElement?.tagName.toLowerCase()).toBe('p');
});
it('wraps math[display="block"] even when it has a sibling (e.g. an equation number)', () => {
document.body.innerHTML = `(1)
`;
applyScrollableStyle(document);
const math = document.querySelector('math')!;
expect(math.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
});
});
describe('updateTableFit', () => {
const makeWrapper = (scrollWidth: number, clientWidth: number) => {
const wrapper = document.createElement('div');
wrapper.className = SCROLL_WRAPPER_CLASS;
Object.defineProperty(wrapper, 'scrollWidth', { value: scrollWidth, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: clientWidth, configurable: true });
return wrapper;
};
it('marks a table that fits its column as not-a-scroll-container', () => {
const wrapper = makeWrapper(200, 200);
updateTableFit(wrapper);
expect(wrapper.classList.contains(SCROLL_WRAPPER_FIT_CLASS)).toBe(true);
});
it('leaves a table wider than its column scrollable', () => {
const wrapper = makeWrapper(400, 200);
updateTableFit(wrapper);
expect(wrapper.classList.contains(SCROLL_WRAPPER_FIT_CLASS)).toBe(false);
});
it('treats overflow within tolerance as fitting', () => {
const wrapper = makeWrapper(203, 200); // 3px slop ≤ tolerance
updateTableFit(wrapper);
expect(wrapper.classList.contains(SCROLL_WRAPPER_FIT_CLASS)).toBe(true);
});
});
describe('shouldTableScrollConsumeTouch', () => {
it('returns false when the table is not wider than its container', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 100, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 100, configurable: true });
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(false);
});
it('consumes a horizontal swipe when more content is available to the right', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(true);
});
it('still consumes at the right edge so it never chains to a page turn', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollLeft', { value: 200, configurable: true });
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(true);
});
it('ignores a vertical swipe on a box that only scrolls horizontally', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
expect(shouldTableScrollConsumeTouch(wrapper, -5, -40)).toBe(false);
});
it('consumes a vertical swipe when more content is available below (tall code block)', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollTop', { value: 0, configurable: true });
// Finger up (dy < 0) scrolls down; content remains below the viewport.
expect(shouldTableScrollConsumeTouch(wrapper, 0, -40)).toBe(true);
});
it('still consumes a vertical swipe at the bottom edge (never turns the page)', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollTop', { value: 200, configurable: true });
expect(shouldTableScrollConsumeTouch(wrapper, 0, -40)).toBe(true);
});
});
describe('shouldTableScrollConsumeWheel', () => {
it('returns false when the table is not wider than its container', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 100, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 100, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(false);
});
it('consumes a horizontal wheel when more content is available to the right', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(true);
});
it('still consumes at the right edge so it never chains to a page turn', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
// scrolled fully to the right edge
Object.defineProperty(wrapper, 'scrollLeft', { value: 200, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(true);
});
it('still consumes at the left edge so it never chains to a page turn', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, -40, 0)).toBe(true);
});
it('ignores a vertical wheel on a box that only scrolls horizontally', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, 5, 40)).toBe(false);
});
it('consumes a vertical wheel down while a tall box can still scroll down', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollTop', { value: 0, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, 0, 40)).toBe(true);
});
it('still consumes a vertical wheel at the bottom edge (never turns the page)', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollTop', { value: 200, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, 0, 40)).toBe(true);
});
});
describe('findScrollableBox', () => {
const setScroll = (el: HTMLElement, scrollWidth: number, clientWidth: number) => {
Object.defineProperty(el, 'scrollWidth', { value: scrollWidth, configurable: true });
Object.defineProperty(el, 'clientWidth', { value: clientWidth, configurable: true });
};
const makeWrapper = () => {
const wrapper = document.createElement('div');
wrapper.className = SCROLL_WRAPPER_CLASS;
const inner = document.createElement('span');
wrapper.appendChild(inner);
document.body.appendChild(wrapper);
return { wrapper, inner };
};
it('routes a scrollable wrapper (table / equation) from a target inside it', () => {
const { wrapper, inner } = makeWrapper();
setScroll(wrapper, 400, 200);
expect(findScrollableBox(inner)).toBe(wrapper);
wrapper.remove();
});
it('only routes the scroll-wrapper — bare pre / code / math do not capture gestures', () => {
// pre/code/math rely on native overflow scrolling; only the wrapper that
// applyScrollableStyle adds (tables, display equations) is routed.
for (const tag of ['pre', 'code', 'math'] as const) {
const box = document.createElement(tag);
const inner = document.createElement('span');
box.appendChild(inner);
document.body.appendChild(box);
setScroll(box, 400, 200);
expect(findScrollableBox(inner), `${tag} should NOT route`).toBeNull();
box.remove();
}
});
it('routes a wrapper that only overflows vertically (tall block)', () => {
const { wrapper, inner } = makeWrapper();
setScroll(wrapper, 200, 200); // fits horizontally
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
expect(findScrollableBox(inner)).toBe(wrapper);
wrapper.remove();
});
it('ignores a wrapper that fits (overflow within tolerance)', () => {
const { wrapper, inner } = makeWrapper();
setScroll(wrapper, 202, 200); // 2px slop ≤ tolerance
expect(findScrollableBox(inner)).toBeNull();
wrapper.remove();
});
it('returns null for targets outside any scroll box', () => {
const p = document.createElement('p');
document.body.appendChild(p);
expect(findScrollableBox(p)).toBeNull();
expect(findScrollableBox(null)).toBeNull();
p.remove();
});
});
describe('applyTableTouchScroll', () => {
it('attaches capture-phase touch and wheel listeners once per document', () => {
document.documentElement.removeAttribute('data-readest-table-touch-scroll');
const addSpy = vi.spyOn(document, 'addEventListener');
applyTableTouchScroll(document);
applyTableTouchScroll(document);
const touchMoves = addSpy.mock.calls.filter(([type]) => type === 'touchmove');
expect(touchMoves).toHaveLength(1);
expect(touchMoves[0]?.[2]).toEqual({ capture: true, passive: false });
const wheels = addSpy.mock.calls.filter(([type]) => type === 'wheel');
expect(wheels).toHaveLength(1);
expect(wheels[0]?.[2]).toEqual({ capture: true, passive: true });
addSpy.mockRestore();
});
});