Files
reader/web/test/gestures.test.ts
T
hectorqin cdc95d8d27 fix(web,server): 阅读页可读性与面板、翻页、同步频率、事件层级
用户报的八条里,这一提交覆盖了除图标对齐之外的七条。每一条都先做成能复现的
测试或量出来的数字,再改。

## 2. txt 没有处理就渲染,没有默认样式,也无法调整

三层都缺,所以修了三层。

服务端新增 `chapter-html:<n>`,把纯文本渲染成带 p 的片段
(`indexer/formats/text-html.ts`)。空行断段;「上一行以句末标点结尾、下一行以
句子开头」也断段——抓取来的网文常常每行一段且没有空行,少了这条整本书是一块。
章节标题行提升成 h3,否则正文会变成「第二章 落雨雨来了。」。引用仍是
`chapter:<n>`(它同时是阅读位置的标识,改它会把现有读者打回第一章),新增
`format` 字段说明取哪一份;`chapter:` 的 256KB 截断也不再作用于这份渲染结果——
那个截断会把长章节切一半。

客户端新增 `TXT_STYLESHEET`,只在 `format === 'txt'` 时注入 shadow root,作用域
`.txt-body`,所以 EPUB 作者自己的样式一点不受影响。默认两字符首行缩进,用
`2em` 而不是 `2rem`:缩进要跟字号缩放。

阅读设置新增「正文」一节(段落缩进 / 段间距 / 编码),只在 TXT 上出现,前两项
是写在 stage 上的自定义属性,所以调整不需要重新注入章节。

## 3. 翻页异常,往前翻页还会来回跳

`previous()` 用 `scrollTop > 8` 判断「还有没有地方可退」,`next()` 用
`scrollTop < limit - 8` 判断「还有没有地方可进」,然后两边都以 0.9 屏步进。
这三个数字互不相容:forward 落到的位置,back 不认为那是边界。读者看到的就是
来回跳。

改成按**页序号**算术:整屏步进,页边界由滚动**范围**(`scrollHeight -
clientHeight`)而不是内容高度推出。这一个区别是页数和可翻次数对不上的原因——
2.31 屏的一章,按内容高度算 3 页并请求 offset 0/723/1446/2169,而 2169 会被
夹到 1674,也就是第 2 个边界,于是第四次按下的位置计数器不认。

`stepSection` 改成 await:它在跨章时是 fire-and-forget,而 `turnPage` 在
`previous()` 返回后就播放动画——动画演的是读者刚离开的那一章。

## 4. sync 接口会不停的重复渲染,sync 接口调用太过频繁

两处。`onPosition` 每个位置调三次 `patch()`,而 `patch` 会重绘整棵 chrome
树,滚动时每帧三次;合并成一次。

`flushProgress` 无条件 `syncNow()`,于是 1.5 秒一轮 POST+GET,读者只要在读就
一直发。`SyncEngine.schedule()` 去抖并**保证尾次**——没有尾次的话,读者翻完
最后一页就关书,那一页永远发不出去。

## 5/6. 布局样式,以及目录/设置改半屏

面板从 `inset: 0` 改成底部半屏 + 遮罩 + 把手,上半屏正文仍然看得见:选章节、
调字号都是对下面那一页的调整,全屏让读者为了做调整而离开那一页。

状态行从 flex 列里的一行改成浮在正文上的药丸。横条每出现一次就把正文推下去
一行再收回来,也就是把读者正在读的那句挪走。`ui:review` 有一条断言盯着它。

页脚改成两行不同职责:控制行居中、读数是三个固定位置的等宽数字。目录行改成
「序号 + 标题」,当前章高亮一次而不是每行都划线;当前窗口到不了的行标记出来
而不是静静地没反应。

## 7. 点击页面的事件处理层次

一次触摸只属于一个目标,最里面那个赢。两个真缺陷:

- 必须用 `composedPath()` 而不是 `event.target`。书的正文在 shadow root 里,
  shadow 边界会**重定向**事件,所以 target 是 shadow host——用 target 写不出
  「段落」和「同一个 shadow 树里的按钮」的区别。
- **阅读面自己是个滚动容器**。滚动模式就是让 shadow host 滚起来,于是「可滚动
  的祖先拿走手势」这条规则让它吃掉了每一个手势:翻页层级完全正确,但永远
  到不了。这是「点击页面没反应」的真正原因。

另外,按到控件的触摸不再被吞掉 click——之前工具栏按钮可见、可点、没反应。

面板打开时它拥有点击:旁边露出的那一条正文不翻页。

## 8. 端到端测试要做 UI 评审

新增 `web/tools/ui-review/`:真实 Chromium(390×844、2x)加载**生产产物**,
对着替身 API 逐屏截图并测量。截图给人看(「只是丑」不是测试失败,但是缺陷),
尺寸给流水线断言(面板不遮住整页、留出 ≥120px 正文、有遮罩和把手、状态提示
不推动正文、控件 ≥28px 且有名字、没有横向溢出)。接进 `client-ui-review`
流水线,只在界面相关文件变化时触发。

这套工具第一次跑就发现了四个真缺陷:`api.toc()` 返回形状不符时 `openStaged`
用 TypeError 打死整个 open(现在回退到 manifest 自己的条目);状态行一旦进入
loading 就再也清不掉(`hideStatus` 的守卫挡住了唯一的出口);翻页步进的 0.9 屏;
以及上面那条阅读面吃掉手势。

## 测试

web 351 项、server 206 项全过。新增:`gestures.test.ts`(事件层级优先级)、
`reader-paging.test.ts`(页序号算术,含子像素取整与滚动范围)、
`mount.test.ts`(单次渲染与卸载后丢弃更新)、`text-html.test.ts`(分段、
转义、标题提升、不截断)。
2026-09-18 12:39:45 +08:00

244 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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('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);
});
});