forked from akai/readest
219 lines
8.5 KiB
TypeScript
219 lines
8.5 KiB
TypeScript
import path from 'node:path';
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
import { motionGesture } from './helpers/adb';
|
|
import { CdpPage } from './helpers/cdp';
|
|
import {
|
|
clearDomSelection,
|
|
detectAndroidEnv,
|
|
dismissSelection,
|
|
findSelectionTargets,
|
|
getCustomHandles,
|
|
getReaderMetrics,
|
|
getSelectionState,
|
|
gotoChapter,
|
|
gotoFrameX,
|
|
locateWord,
|
|
longPressWord,
|
|
openFixtureBook,
|
|
SelectionTargets,
|
|
waitFor,
|
|
} from './helpers/reader';
|
|
|
|
// End-to-end coverage for issue #1553 (Blink paints touch-selection bounds on
|
|
// generated hyphen fragments): the app must repair corrupted long-press drags,
|
|
// replace the native handles with its own for prone selections, and keep
|
|
// everything else native.
|
|
//
|
|
// The fixture is a plain English book; the harness discovers a hyphenated,
|
|
// on-screen paragraph at runtime and derives all gesture targets from live
|
|
// layout, so the suite is independent of fonts, screen sizes, and the
|
|
// fixture's exact text. Runs against any adb device/emulator with Readest
|
|
// installed; soft-skips otherwise.
|
|
|
|
const FIXTURE = path.resolve(__dirname, '../fixtures/data/sample-alice.epub');
|
|
|
|
const env = await detectAndroidEnv();
|
|
if (!env) {
|
|
console.warn('[test:android] no adb device with Readest installed — skipping the Android lane');
|
|
}
|
|
|
|
describe.runIf(env)('Android text selection over CDP (#1553)', () => {
|
|
let page: CdpPage;
|
|
let targets: SelectionTargets;
|
|
|
|
beforeAll(async () => {
|
|
page = await openFixtureBook(FIXTURE);
|
|
// Start the search in the book's main text — front matter rarely has
|
|
// full hyphenated paragraphs. Hyphenation itself is forced by the
|
|
// harness (ensureHyphenation), so app settings don't matter.
|
|
await gotoChapter(page, 'chapter\\s*4');
|
|
targets = await findSelectionTargets(page);
|
|
}, 120_000);
|
|
|
|
afterAll(() => {
|
|
page?.close();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await gotoFrameX(page, targets.frameX, targets.sectionIndex);
|
|
const handles = await getCustomHandles(page);
|
|
const sel = await getSelectionState(page);
|
|
if (handles.length > 0 || (sel.exists && !sel.collapsed)) {
|
|
await dismissSelection(page);
|
|
} else {
|
|
await clearDomSelection(page);
|
|
}
|
|
}, 60_000);
|
|
|
|
it('selects the first word of a hyphenated paragraph and shows the app handles', async () => {
|
|
await longPressWord(page, targets.prefix, targets.firstWord);
|
|
|
|
const sel = await getSelectionState(page);
|
|
expect(sel.text).toBe(targets.firstWord.word);
|
|
|
|
// Native handles are suppressed for this prone selection; the app's own
|
|
// drag handles take over (rendered in the top document).
|
|
const handles = await waitFor(
|
|
async () => {
|
|
const h = await getCustomHandles(page);
|
|
return h.length === 2 ? h : null;
|
|
},
|
|
{ label: 'app selection handles' },
|
|
);
|
|
expect(handles).toHaveLength(2);
|
|
});
|
|
|
|
it('repairs a long-press drag from the first word to end at the finger', async () => {
|
|
const from = await waitFor(() => locateWord(page, targets.prefix, targets.firstWord), {
|
|
label: targets.firstWord.word,
|
|
});
|
|
const to = await waitFor(() => locateWord(page, targets.prefix, targets.dragWord), {
|
|
label: targets.dragWord.word,
|
|
});
|
|
|
|
// Long-press, then drag without lifting — the gesture the Blink bug
|
|
// corrupts by re-anchoring one end at the paragraph's last hyphen.
|
|
await motionGesture([
|
|
{ x: from.deviceX, y: from.deviceY, pauseSec: 0.9 },
|
|
{ x: (from.deviceX + to.deviceX) / 2, y: (from.deviceY + to.deviceY) / 2, pauseSec: 0.15 },
|
|
{ x: to.deviceX, y: to.deviceY, pauseSec: 0.3 },
|
|
]);
|
|
|
|
const sel = await waitFor(
|
|
async () => {
|
|
const s = await getSelectionState(page);
|
|
return s.exists && !s.collapsed ? s : null;
|
|
},
|
|
{ label: 'drag selection' },
|
|
);
|
|
expect(sel.text.startsWith(targets.firstWord.word)).toBe(true);
|
|
// Clamped to the finger: ends at the dragged-to word, not at the
|
|
// paragraph's last hyphen (which would overshoot by hundreds of chars).
|
|
expect(sel.text.endsWith(targets.dragWord.word)).toBe(true);
|
|
});
|
|
|
|
it('dismisses the selection and app handles on a tap outside', async () => {
|
|
await longPressWord(page, targets.prefix, targets.firstWord);
|
|
await waitFor(async () => (await getCustomHandles(page)).length === 2, {
|
|
label: 'app handles',
|
|
});
|
|
|
|
// dismissSelection taps away and asserts handles + selection are gone —
|
|
// the regression here was a pair of empty handles left at the tap point.
|
|
await dismissSelection(page);
|
|
expect((await getCustomHandles(page)).length).toBe(0);
|
|
});
|
|
|
|
it('extends the selection by dragging the app end handle', async () => {
|
|
await longPressWord(page, targets.prefix, targets.firstWord);
|
|
const handles = await waitFor(
|
|
async () => {
|
|
const h = await getCustomHandles(page);
|
|
return h.length === 2 ? h : null;
|
|
},
|
|
{ label: 'app handles' },
|
|
);
|
|
const { dpr } = await getReaderMetrics(page);
|
|
const endHandle = handles.reduce((a, b) => (a.y > b.y || (a.y === b.y && a.x > b.x) ? a : b));
|
|
const to = await waitFor(() => locateWord(page, targets.prefix, targets.dragWord), {
|
|
label: targets.dragWord.word,
|
|
});
|
|
|
|
await motionGesture([
|
|
{ x: (endHandle.x + 15) * dpr, y: (endHandle.y + 33) * dpr, pauseSec: 0.3 },
|
|
{ x: to.deviceX, y: to.deviceY + 30, pauseSec: 0.2 },
|
|
{ x: to.deviceX, y: to.deviceY, pauseSec: 0.3 },
|
|
]);
|
|
|
|
const sel = await waitFor(
|
|
async () => {
|
|
const s = await getSelectionState(page);
|
|
return s.text.length > targets.firstWord.word.length ? s : null;
|
|
},
|
|
{ label: 'extended selection' },
|
|
);
|
|
expect(sel.text.startsWith(targets.firstWord.word)).toBe(true);
|
|
expect(sel.text.endsWith(targets.dragWord.word)).toBe(true);
|
|
});
|
|
|
|
it('keeps native selection handles for mid-paragraph selections', async () => {
|
|
await longPressWord(page, targets.prefix, targets.midWord);
|
|
const sel = await getSelectionState(page);
|
|
expect(sel.text).toBe(targets.midWord.word);
|
|
|
|
// Give the app a moment to (wrongly) mount its handles, then assert the
|
|
// native flow was left alone.
|
|
await new Promise((r) => setTimeout(r, 1200));
|
|
expect((await getCustomHandles(page)).length).toBe(0);
|
|
});
|
|
|
|
it('keeps the previous page selected across a corner-dwell auto page turn', async () => {
|
|
await longPressWord(page, targets.prefix, targets.firstWord);
|
|
const handles = await waitFor(
|
|
async () => {
|
|
const h = await getCustomHandles(page);
|
|
return h.length === 2 ? h : null;
|
|
},
|
|
{ label: 'app handles' },
|
|
);
|
|
const metrics = await getReaderMetrics(page, targets.sectionIndex);
|
|
const { dpr, viewWidth, viewHeight, marginRight, marginBottom, frameX } = metrics;
|
|
const endHandle = handles.reduce((a, b) => (a.y > b.y || (a.y === b.y && a.x > b.x) ? a : b));
|
|
// A point inside the text area near the bottom-right corner — inside the
|
|
// auto-turn corner zone but not in the page margins (which the corner
|
|
// detector ignores).
|
|
const cornerX = (viewWidth - marginRight - viewWidth * 0.04) * dpr;
|
|
const cornerY = (viewHeight - marginBottom - viewHeight * 0.03) * dpr;
|
|
|
|
await motionGesture([
|
|
{ x: (endHandle.x + 15) * dpr, y: (endHandle.y + 33) * dpr, pauseSec: 0.3 },
|
|
{ x: cornerX * 0.7, y: cornerY * 0.7, pauseSec: 0.15 },
|
|
// Dwell in the corner long enough for the auto page turn (500ms).
|
|
{ x: cornerX, y: cornerY, pauseSec: 1.6 },
|
|
// Keep dragging on the new page so the selection rebuilds after the turn.
|
|
{ x: viewWidth * 0.7 * dpr, y: viewHeight * 0.55 * dpr, pauseSec: 0.2 },
|
|
{ x: viewWidth * 0.55 * dpr, y: viewHeight * 0.45 * dpr, pauseSec: 0.3 },
|
|
]);
|
|
|
|
const turned = await waitFor(
|
|
async () => {
|
|
const m = await getReaderMetrics(page, targets.sectionIndex);
|
|
return m.frameX <= frameX - viewWidth ? m : null;
|
|
},
|
|
{ label: 'auto page turn' },
|
|
);
|
|
expect(turned.frameX).toBeLessThanOrEqual(frameX - viewWidth);
|
|
|
|
// The regression lost the previous page's part of the selection: after
|
|
// the turn only current-page text stayed selected. The selection must
|
|
// still start at the word the gesture began on.
|
|
const sel = await getSelectionState(page);
|
|
expect(sel.text.startsWith(targets.firstWord.word)).toBe(true);
|
|
expect(sel.text.length).toBeGreaterThan(targets.firstWord.word.length);
|
|
|
|
// Restore for any test added after this one.
|
|
await dismissSelection(page);
|
|
await gotoFrameX(page, targets.frameX, targets.sectionIndex);
|
|
});
|
|
});
|