mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 21:41:02 +08:00
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
// @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 `<a>`, and an `<a>` 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);
|
||
});
|
||
});
|