Files
reader/web/tools/ui-review/reader-regression.mjs
hectorqinandClaude Opus 5.5 1137b0d90d fix(web): paged mode lands on page boundaries; highlights draw and follow reflow; bottom bar stays visible
Paged mode
- Restoring the position after the viewport height changes (address bar,
  keyboard, rotation) wrote max * fraction, which is almost never a column
  boundary: the screen showed half of one page and half of the next. Every
  paged scrollLeft write (restore, TTS follow, revealBlock, search) now goes
  through one page-snapping helper, and a scroll the browser makes on its own
  (focus, selection autoscroll) is snapped back.

Highlights
- localSectionId prefixed `xhtml:` from the format alone, so a windowed book
  whose sections have no prefix saved highlights that matched no chapter and
  were never drawn (and its progress failed to restore). The loaded document's
  own section ids are now the authority.
- Highlight boxes are measured once; font size, margins, mode switch, resize
  or a late image moved the text under them. They are repainted after each.

Mobile bottom bar
- `.reader-route-host` had min-height: 100vh, which on a phone browser is
  taller than the visible viewport while the address bar shows, pushing the
  reader's bottom toolbar under it. Height is 100dvh with a 100vh fallback.
- Notices are bottom-centre, exactly over 朗读/外观; in the reader they now
  stack above the bottom bar instead of swallowing the next tap.

Tap to turn hides the toolbar
- A tap on the outer thirds turns the page and puts a showing toolbar away;
  the middle third still toggles it.

UI review scripts: page-turn-regression waits for login to finish rather than
for the shelf (the app now opens on media); reader-regression shows the
toolbar before pressing 下一章 after tap turns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:24:49 +00:00

178 lines
10 KiB
JavaScript

import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { CDP } from './cdp.mjs';
import { createReviewServer, BOOK_ID, ILLUSTRATED_ID } from './server.mjs';
const server = createReviewServer({ port: 5201 });
const origin = await server.listen();
const shots = process.env.UI_REVIEW_DIR ? resolve(process.env.UI_REVIEW_DIR) : join(resolve(import.meta.dirname, '../../..'), 'artifacts/ui-review/reader-mobile');
const cdp = await CDP.launch({ viewport: { width: 390, height: 844 }, scale: 2 });
const read = () => cdp.run(`
const host = document.querySelector('book-content');
const flow = host.shadowRoot.querySelector('.book-flow');
const scroller = host.dataset.paginated === 'true' ? flow : host;
const rect = host.getBoundingClientRect();
return { top: rect.top, height: rect.height, x: scroller.scrollLeft, y: scroller.scrollTop,
page: document.querySelector('.progress-scrubber').value,
pages: document.querySelector('.progress-scrubber').max,
chrome: document.querySelector('.reader-screen').dataset.chrome };
`);
try {
await cdp.navigate(`${origin}/#/shelf`);
await cdp.waitFor('document.querySelector(".login-screen") !== null');
await cdp.fill('input[autocomplete="username"]', 'review');
await cdp.fill('input[type="password"]', 'password12');
await cdp.click('button[type="submit"]');
await cdp.waitFor('document.querySelector(".book-card") !== null');
await cdp.navigate(`${origin}/#/book/${BOOK_ID}`);
await cdp.waitFor('Number(document.querySelector(".progress-scrubber")?.max) > 1');
await cdp.sleep(500);
await cdp.tapMiddle();
await cdp.sleep(350);
await cdp.tapThird(0.85);
await cdp.sleep(350);
const before = await read();
assert.ok(Number(before.page) > 1, `must reach a later page: ${JSON.stringify(before)}`);
await cdp.tapMiddle();
await cdp.sleep(350);
const after = await read();
console.log(JSON.stringify({ before, after }));
assert.equal(after.page, before.page, 'middle tap must preserve page');
assert.equal(after.y, before.y, 'middle tap must preserve scroll offset');
assert.equal(after.top, before.top, 'chrome must not move the reading surface');
assert.equal(after.height, before.height, 'chrome must not resize the reading surface');
await mkdir(shots, { recursive: true });
await cdp.screenshot(join(shots, 'reader.png'));
for (const label of ['目录', '外观', '设置', '朗读']) {
await cdp.clickText('.reader-actions button', label);
await cdp.waitFor('document.querySelector(".sheet") !== null');
await cdp.run(`await Promise.all(document.querySelector('.sheet').getAnimations().map(a=>a.finished));`);
const opened = await read();
assert.equal(opened.y, after.y, `${label} must preserve scroll position`);
assert.equal(opened.height, after.height, `${label} must preserve surface height`);
const panel = await cdp.run(`const r=document.querySelector('.sheet').getBoundingClientRect();return {top:r.top,bottom:r.bottom};`);
assert.ok(panel.top > 250 && panel.bottom <= 845, `sheet must leave a preview and fit the screen: ${JSON.stringify(panel)}`);
await cdp.screenshot(join(shots, `panel-${label}.png`));
await cdp.click('.sheet button[aria-label="关闭"]');
await cdp.waitFor('document.querySelector(".sheet") === null');
assert.equal((await read()).y, after.y, `${label} close must preserve position`);
}
await cdp.clickText('.reader-actions button', '外观');
const beforeReadouts = await read();
for (const [label, mode] of [['顶部左侧','book'],['顶部右侧','chapter'],['底部左侧','time'],['底部右侧','progress']]) {
await cdp.run(`const el=document.querySelector('select[aria-label="${label}"]');el.value='${mode}';el.dispatchEvent(new Event('change',{bubbles:true}));`);
await cdp.sleep(100);
const next = await read();
for (const key of ['x','y','page','pages','top','height']) assert.equal(next[key], beforeReadouts[key], 'readout setting preserves '+key);
}
const modes = await cdp.evaluate('Array.from(document.querySelectorAll(".reading-indicator span"),el=>el.dataset.mode)');
assert.deepEqual(modes, ['book','chapter','time','progress']);
await cdp.screenshot(join(shots, 'readout-settings.png'));
await cdp.click('.sheet button[aria-label="关闭"]');
await cdp.tapMiddle();
await cdp.screenshot(join(shots, 'readout-custom.png'));
await cdp.navigate(`${origin}/#/shelf`);
await cdp.waitFor('document.querySelector(".shelf-screen") !== null');
await cdp.navigate(`${origin}/#/book/${BOOK_ID}`);
await cdp.waitFor('Number(document.querySelector(".progress-scrubber")?.max) > 1');
assert.deepEqual(await cdp.evaluate('Array.from(document.querySelectorAll(".reading-indicator span"),el=>el.dataset.mode)'), modes, 'readout settings survive reopening');
if (await cdp.evaluate('document.querySelector(".reader-screen")?.dataset.chrome === "hidden"')) await cdp.tapMiddle();
await cdp.clickText('.reader-actions button', '外观');
await cdp.waitFor('document.querySelector(\'select[aria-label="顶部左侧"]\') !== null');
for (const [label, mode] of [['顶部左侧','chapter'],['顶部右侧','none'],['底部左侧','progress'],['底部右侧','time']]) {
await cdp.run(`const el=document.querySelector('select[aria-label="${label}"]');el.value='${mode}';el.dispatchEvent(new Event('change',{bubbles:true}));`);
await cdp.sleep(100);
}
await cdp.clickText('.sheet button', '黑白灰');
await cdp.click('.sheet button[aria-label="关闭"]');
await cdp.sleep(300);
assert.equal((await read()).page, after.page, 'theme must preserve page');
await cdp.tapMiddle();
await cdp.sleep(250);
await cdp.screenshot(join(shots, 'immersive-green.png'));
for (let i=0; i<6; i++) {
const prior = await read();
await cdp.tapMiddle();
await cdp.sleep(150);
const next = await read();
for (const key of ['x','y','page','pages','top','height']) assert.equal(next[key],prior[key],`repeated toggle: ${key}`);
}
await cdp.tapMiddle();
await cdp.clickText('.reader-actions button', '设置');
await cdp.clickText('.sheet button', '翻页');
await cdp.click('.sheet button[aria-label="关闭"]');
await cdp.sleep(300);
await cdp.run(`const el=document.querySelector('.progress-scrubber');el.value='2';el.dispatchEvent(new Event('input',{bubbles:true}));`);
await cdp.sleep(250);
const paged = await read();
assert.equal(paged.page, '2', 'chapter slider reaches page two');
for(let i=0;i<4;i++) {
await cdp.tapMiddle();
await cdp.sleep(200);
const next=await read();
for(const key of ['x','y','page','pages','top','height']) assert.equal(next[key],paged[key],`paged toggle: ${key}`);
}
await cdp.tapThird(0.85);
await cdp.sleep(300);
assert.equal(Number((await read()).page),3,'tap advances one page');
await cdp.tapThird(0.15);
await cdp.sleep(300);
assert.equal((await read()).page,'2','reverse tap returns to same page');
await cdp.screenshot(join(shots, 'paged.png'));
// A page-turn tap puts the toolbar away; the middle third brings it back.
assert.equal((await read()).chrome, 'hidden', 'tap page turn hides the toolbar');
await cdp.tapMiddle();
await cdp.sleep(200);
await cdp.clickText('.footer button', '下一章');
await cdp.sleep(350);
assert.equal((await read()).page,'1','next chapter starts at page one');
await cdp.navigate(`${origin}/#/book/${ILLUSTRATED_ID}`);
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector("img")?.naturalWidth > 0');
await cdp.sleep(300);
const epub=await read();
await cdp.tapMiddle();
await cdp.sleep(250);
const epubHidden=await read();
for(const key of ['x','y','page','pages','top','height']) assert.equal(epubHidden[key],epub[key],`EPUB toggle: ${key}`);
await cdp.screenshot(join(shots, 'epub.png'));
await cdp.navigate(`${origin}/#/book/review-comic`);
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector("img")?.naturalWidth > 0');
await cdp.sleep(200);
await cdp.tapThird(0.85);
await cdp.sleep(300);
const comic = await read();
const chapter = await cdp.evaluate('document.querySelector(".reader-heading span").textContent');
assert.equal(chapter, '第 2 页', 'comic page advances');
assert.equal(comic.page, '1', 'fixed page has one page inside its chapter');
assert.equal(comic.pages, '1', 'fixed chapter slider must not become a whole-book slider');
for (let i=0; i<4; i++) {
await cdp.tapMiddle();
await cdp.sleep(200);
assert.equal(await cdp.evaluate('document.querySelector(".reader-heading span").textContent'), chapter);
assert.equal((await read()).height, comic.height, 'fixed layout geometry stays stable');
}
await cdp.screenshot(join(shots, 'comic.png'));
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 320, height: 568, deviceScaleFactor: 2, mobile: true });
await cdp.sleep(250);
for(const label of ['外观','设置']) {
await cdp.clickText('.reader-actions button', label);
await cdp.run(`await Promise.all(document.querySelector('.sheet').getAnimations().map(a=>a.finished));`);
const overflow=await cdp.run(`const p=document.querySelector('.sheet');return p.scrollWidth-p.clientWidth;`);
assert.ok(overflow <= 1, `${label} fits a narrow phone`);
await cdp.screenshot(join(shots, `narrow-${label}.png`));
await cdp.click('.sheet button[aria-label="关闭"]');
}
await cdp.navigate(`${origin}/#/book/review-pdf`);
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector("iframe")?.src.startsWith("blob:")');
const pdf = await cdp.run(`const frame=document.querySelector('book-content').shadowRoot.querySelector('iframe');return (await fetch(frame.src).then(r=>r.text())).slice(0,8);`);
assert.equal(pdf, '%PDF-1.4', 'browser viewer receives the actual PDF bytes');
// The browser's PDF extension starts asynchronously after the frame commits.
await cdp.sleep(1500);
await cdp.screenshot(join(shots, 'pdf.png'));
console.log('PASS: page and geometry survive chrome toggle');
} finally {
await cdp.close();
await server.close();
}