mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 05:50:54 +08:00
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>
178 lines
10 KiB
JavaScript
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();
|
|
}
|