// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { attachGestures, type GestureHandlers } from '../src/ui/gestures.ts';
/**
* The click/gesture layering.
*
* This is the layer that decides what a touch *means*, and the reported symptom
* was the one every reader hits first: tapping a control also turned the page. The
* contract it has to keep is a priority order, and the tests below are written as
* that order rather than as a list of cases:
*
* 1. a control the reader actually touched owns the gesture, whatever it is;
* 2. otherwise a drag is a swipe;
* 3. otherwise a short press is a tap in one of the three zones.
*
* Everything here is a *pointer* sequence, because the layer tracks pointers: a
* `click` arrives after a drag that ended on a link, which is exactly why a tap
* cannot be recognised from it.
*/
/**
* A pointer event jsdom will accept and the layer can read.
*
* `composed: true` is not optional. The book's markup lives in a shadow root, and a
* real browser composes every pointer event across that boundary — which is the
* only reason the stage's own listener sees a tap on a paragraph at all. A test
* event without it simply never arrives, and the suite would then be asserting
* about a gesture layer that was never called.
*/
function pointer(type: string, init: PointerEventInit): PointerEvent {
return new window.PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
...init,
});
}
interface Harness {
stage: HTMLDivElement;
saw: {
zones: string[];
swipes: string[];
drags: number;
longPresses: number;
};
handlers: GestureHandlers;
}
/** A stage 300px wide with a 300px-wide, 3-zone tap model. */
function harness(options: { selection?: boolean; width?: number } = {}): Harness {
const width = options.width ?? 300;
const stage = document.createElement('div');
document.body.append(stage);
// jsdom has no layout, so the zone arithmetic reads a stubbed width.
Object.defineProperty(stage, 'clientWidth', { get: () => width, configurable: true });
stage.getBoundingClientRect = () => ({ left: 0, top: 0, width, height: 600, right: width, bottom: 600, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
const saw = { zones: [] as string[], swipes: [] as string[], drags: 0, longPresses: 0 };
const handlers: GestureHandlers = {
onTapZone: (zone) => saw.zones.push(zone),
onSwipe: (direction) => saw.swipes.push(direction),
onDragDelta: () => { saw.drags += 1; },
onLongPress: () => { saw.longPresses += 1; },
hasSelection: () => options.selection === true,
};
attachGestures(stage, handlers);
return { stage, saw, handlers };
}
/** Presses, moves if told to, and releases. */
function press(target: EventTarget, from: [number, number], to?: [number, number], holdMs = 40): void {
const start = Date.now();
target.dispatchEvent(pointer('pointerdown', { clientX: from[0], clientY: from[1] }));
if (to) target.dispatchEvent(pointer('pointermove', { clientX: to[0], clientY: to[1] }));
// The layer measures duration with `Date.now()`, so the clock has to move by the
// intended hold — otherwise a "tap" is however long the suite took to get here.
vi.setSystemTime(start + holdMs);
target.dispatchEvent(pointer('pointerup', { clientX: to?.[0] ?? from[0], clientY: to?.[1] ?? from[1] }));
}
beforeEach(() => {
// `Date` is faked as well as the timers, because the layer measures duration with
// `Date.now()`: with only the timer queue faked, a "tap" in a slow suite is
// hundreds of milliseconds long and stops being a tap.
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
document.body.replaceChildren();
});
describe('tap zones', () => {
it('turns the page on the outer thirds and toggles the chrome in the middle', () => {
const h = harness();
press(h.stage, [40, 300]);
press(h.stage, [150, 300]);
press(h.stage, [260, 300]);
expect(h.saw.zones).toEqual(['previous', 'toggle-chrome', 'next']);
});
it('reads the zones from layout geometry, so a scaled page still taps where the finger is', () => {
// The failure this closes is a *half* of the tap area, and it is invisible at
// 100% zoom: `getBoundingClientRect()` and `event.clientX` are both in visual
// viewport units, so under a pinch zoom the border box and the finger disagree
// while the zone arithmetic happily accepts the tap. The reader taps the middle
// third to hide the toolbar and turns a page instead.
//
// Stated as a *scaled* rect: the layout box is 300px and on screen it is
// reported as 600px wide, which is what a page at 2× looks like to this layer.
// A tap at the visual centre of the screen must be the *middle* zone, and the
// two taps at the outer quarters must be the outer zones — with the old
// arithmetic, which compared a visual offset against a layout width, the first
// of the three landed in the middle and the whole row shifted by one.
const h = harness();
// `offsetWidth` is what the zones divide; the rect is where the element *is*.
Object.defineProperty(h.stage, 'offsetWidth', { get: () => 300, configurable: true });
h.stage.getBoundingClientRect = () =>
({ left: 0, top: 0, width: 600, height: 1200, right: 600, bottom: 1200, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
// The tap coordinates are in *visual* units and the zones are in layout units.
// A third of the 300px layout box is 100px of *layout* and 200px of screen, so
// 150 is inside the first third, 300 (the visible centre) is the middle, and 450
// is the last.
press(h.stage, [150, 300]);
press(h.stage, [300, 300]);
press(h.stage, [450, 300]);
expect(h.saw.zones).toEqual(['previous', 'toggle-chrome', 'next']);
});
it('does not turn a page when the tap lands on a control inside the stage', () => {
// The bug: the gesture layer treats *every* pointer it sees on the stage as a
// page turn, so a tap on a button that lives inside the reading surface — a
// footnote link, a control in an injected panel — also turned the page behind
// it. The reader pressed one thing and two happened.
const h = harness();
const button = document.createElement('button');
h.stage.append(button);
press(button, [40, 300]);
expect(h.saw.zones).toEqual([]);
});
it('does not turn a page when the tap lands on a link inside the book', () => {
// A footnote is an ``, and an `` inside the shadow root is still the
// reader's target. The check therefore has to cross the shadow boundary.
const h = harness();
const host = document.createElement('div');
h.stage.append(host);
const shadow = host.attachShadow({ mode: 'open' });
const link = document.createElement('a');
link.href = '#note1';
shadow.append(link);
press(link, [260, 300]);
expect(h.saw.zones).toEqual([]);
});
it('does not turn a page when the tap lands on scrollable book text', () => {
// Plain text is *not* a control: a tap on a paragraph is a page turn, which is
// the whole model. Stating it keeps the "controls win" rule from being
// over-applied into "nothing inside the stage ever turns a page".
const h = harness();
const host = document.createElement('div');
h.stage.append(host);
const shadow = host.attachShadow({ mode: 'open' });
const paragraph = document.createElement('p');
paragraph.textContent = '正文';
shadow.append(paragraph);
press(paragraph, [260, 300]);
expect(h.saw.zones).toEqual(['next']);
});
it('leaves a range input alone, which is how the panels are built', () => {
const h = harness();
const input = document.createElement('input');
input.type = 'range';
h.stage.append(input);
press(input, [150, 300]);
expect(h.saw.zones).toEqual([]);
expect(h.saw.swipes).toEqual([]);
});
it('honours the reader’s handedness by leaving the mapping to the caller', () => {
// The layer reports *sides*, not directions. Handedness is the screen's
// business; a layer that reversed them itself would have to know a setting it
// has no access to, and would be wrong for a left-handed reader who never
// opened the settings panel.
const h = harness();
press(h.stage, [40, 300]);
expect(h.saw.zones).toEqual(['previous']);
});
});
describe('swipes', () => {
it('reports a mostly-horizontal drag as a horizontal swipe', () => {
const h = harness();
press(h.stage, [250, 300], [50, 320]);
expect(h.saw.swipes).toEqual(['left']);
});
it('reports a mostly-vertical drag as a vertical swipe', () => {
// A diagonal drag is almost always an attempt to scroll; treating it as a page
// turn makes a reflowable book feel like it is fighting back.
const h = harness();
press(h.stage, [150, 500], [180, 200]);
expect(h.saw.swipes).toEqual(['up']);
});
it('ignores a drag too short to be a swipe', () => {
const h = harness();
press(h.stage, [150, 300], [170, 300]);
expect(h.saw.swipes).toEqual([]);
expect(h.saw.zones).toEqual([]);
});
it('suppresses the click that follows a swipe', () => {
// The browser fires `click` after a drag that ended on a link. Without the
// suppression a swipe that happens to end on a footnote opens it.
const h = harness();
const link = document.createElement('a');
h.stage.append(link);
press(link, [250, 300], [50, 300]);
const click = new MouseEvent('click', { bubbles: true, cancelable: true });
link.dispatchEvent(click);
expect(click.defaultPrevented).toBe(true);
expect(h.saw.swipes).toEqual(['left']);
});
});
describe('selection and long press', () => {
it('stands aside while a selection is active', () => {
// Long-press selection is how a reader copies a sentence; a page turn on the
// release would lose the selection they just made.
const h = harness({ selection: true });
press(h.stage, [260, 300]);
expect(h.saw.zones).toEqual([]);
expect(h.saw.swipes).toEqual([]);
});
it('turns a long press into a long press, not a page turn', () => {
const h = harness();
const start = Date.now();
h.stage.dispatchEvent(pointer('pointerdown', { clientX: 260, clientY: 300 }));
vi.setSystemTime(start + 600);
vi.advanceTimersByTime(600);
expect(h.saw.longPresses).toBe(1);
h.stage.dispatchEvent(pointer('pointerup', { clientX: 260, clientY: 300 }));
expect(h.saw.zones).toEqual([]);
});
it('lets a control keep its own click when the reader tapped it', () => {
// The other half of "controls win": the gesture layer suppresses the click that
// follows a page turn, and it must not suppress the click a *button* is waiting
// for. Otherwise the toolbar is visible, tappable, and does nothing.
const h = harness();
const button = document.createElement('button');
h.stage.append(button);
let clicks = 0;
button.addEventListener('click', () => { clicks += 1; });
press(button, [260, 300]);
button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(clicks).toBe(1);
expect(h.saw.zones).toEqual([]);
});
it('cancels a pending long press once the finger moves', () => {
const h = harness();
h.stage.dispatchEvent(pointer('pointerdown', { clientX: 260, clientY: 300 }));
h.stage.dispatchEvent(pointer('pointermove', { clientX: 260, clientY: 400 }));
vi.advanceTimersByTime(600);
expect(h.saw.longPresses).toBe(0);
});
});