penguin/narou-android

Simple 縦書き reader for narou

tests/reader.test.cjs

raw · 14597 bytes

const fs=require('fs'), path=require('path'), assert=require('assert').strict;
const {firefox}=require(process.env.CODEX_PRIMARY_RUNTIME_NODE_MODULES ? process.env.CODEX_PRIMARY_RUNTIME_NODE_MODULES+'/playwright' : 'playwright');
const ROOT=path.resolve(__dirname,'..');
(async()=>{
 const browser=await firefox.launch({headless:true,env:{...process.env,MOZ_DISABLE_CONTENT_SANDBOX:'1'}});
 const context=await browser.newContext({viewport:{width:412,height:840},hasTouch:true});
 const data={},messages=[],errors=[];
 let sensor={available:true,active:true,x:0,rotation:0};
 await context.exposeFunction('readStore',async keys=>keys===null?data:Object.fromEntries((Array.isArray(keys)?keys:[keys]).filter(k=>k in data).map(k=>[k,data[k]])));
 await context.exposeFunction('writeStore',async values=>Object.assign(data,values));
 await context.exposeFunction('removeStore',async keys=>keys.forEach(k=>delete data[k]));
 await context.exposeFunction('nativeMessage',async (_name,msg)=>{messages.push(msg);return msg.type==='motion'?{...sensor}:null;});
 await context.addInitScript(()=>{window.browser={storage:{local:{get:window.readStore,set:window.writeStore,remove:window.removeStore}},runtime:{getURL:path=>'https://ncode.syosetu.com/__reader/'+path,sendNativeMessage:window.nativeMessage,onMessage:{addListener:fn=>{window.toggleReader=fn;}}}};});
 const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
 const html='<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><h1 class="p-novel__title">縦書きのテスト</h1><div class="c-pager"><a href="https://ncode.syosetu.com/n0552ep/74/">前へ</a><a href="https://ncode.syosetu.com/n0552ep/">目次</a><a href="https://ncode.syosetu.com/n0552ep/76/">次へ</a></div><div class="p-novel__text">'+Array.from({length:100},(_,i)=>`<p> これは第${i+1}段落です。<ruby>読書<rt>どくしょ</rt></ruby>の時間を、静かに楽しみます。縦書きの列は右から左へ進みます。</p><p><br><br></p><p>  <span><br></span></p>`).join('')+'</div></body></html>';
 await context.route('**/*',r=>{const url=r.request().url();if(url.startsWith('https://ncode.syosetu.com/__reader/langpacks/'))return r.fulfill({contentType:'application/json',body:fs.readFileSync(path.join(ROOT,'app/src/main/assets/reader/langpacks',url.split('/').pop()),'utf8')});return url==='https://ncode.syosetu.com/n0552ep/75/'?r.fulfill({contentType:'text/html',body:html}):r.abort();});
 const script=path.join(ROOT,'app/src/main/assets/reader/content.js');
 async function load(){await page.goto('https://ncode.syosetu.com/n0552ep/75/',{waitUntil:'domcontentloaded'});await page.addScriptTag({path:path.join(path.dirname(script),'i18n.js')});await page.addScriptTag({path:path.join(path.dirname(script),'motion.js')});await page.addScriptTag({path:script});await page.locator('#narou-tategaki-host #panel').waitFor({state:'visible'});await page.waitForTimeout(250);}
 await load();
 const host=page.locator('#narou-tategaki-host'),panel=host.locator('#panel'),v=host.locator('#viewport');
 assert((await panel.getAttribute('class')).includes('chrome-hidden'));
 assert.equal(await v.evaluate(e=>e.clientHeight),840);
 assert.equal(await panel.locator('article p').evaluateAll(ps=>ps.filter(p=>!p.textContent.trim()&&!p.querySelector('img')).length),0);
 assert(messages.some(m=>m.type==='reader'&&m.open));
 assert(messages.some(m=>m.type==='theme'&&m.theme==='paper'));
 const width=await v.evaluate(e=>e.scrollWidth);
 const firstParagraph=panel.locator('article p').first();
 // A tap on ruby text focuses its paragraph without opening chrome.
 await firstParagraph.locator('rt').dispatchEvent('pointerdown',{clientX:100,clientY:100});
 await firstParagraph.locator('rt').dispatchEvent('pointerup',{clientX:100,clientY:100});
 assert.equal(await panel.locator('article p.reading-focus').count(),1);
 assert.equal(await firstParagraph.getAttribute('aria-current'),'true');
 assert((await panel.getAttribute('class')).includes('chrome-hidden'),'first paragraph tap only highlights');
 assert.equal(await v.evaluate(e=>e.scrollWidth),width,'focus color must not reflow text');
 await panel.locator('article p').nth(1).dispatchEvent('keydown',{key:'Enter'});
 assert.equal(await panel.locator('article p').nth(1).getAttribute('aria-current'),'true','keyboard paragraph focus');
 await page.touchscreen.tap(5,5);assert(!(await panel.getAttribute('class')).includes('chrome-hidden'));
 await panel.locator('#reading-normalColor').evaluate(e=>{e.value='#8899aa';e.dispatchEvent(new Event('input'));});
 await panel.locator('#reading-highlightColor').evaluate(e=>{e.value='#ff7755';e.dispatchEvent(new Event('input'));});
 assert.equal(data.narouTategaki.normalColor,'#8899aa');
 assert.equal(data.narouTategaki.highlightColor,'#ff7755');
 assert.equal(await firstParagraph.evaluate(e=>getComputedStyle(e).color),'rgb(136, 153, 170)');
 assert.equal(await panel.locator('article p.reading-focus').evaluate(e=>getComputedStyle(e).color),'rgb(255, 119, 85)');
 assert.equal(await panel.locator('article p.reading-focus rt').evaluate(e=>getComputedStyle(e).color),'rgb(255, 119, 85)','ruby inherits highlight');
 assert.equal(await v.evaluate(e=>e.scrollWidth),width,'controls must not reflow text');
 await panel.locator('#paragraph').evaluate(e=>{e.value='1.2';e.dispatchEvent(new Event('input'));});
 await page.waitForTimeout(100);assert.equal(data.narouTategaki.paragraph,1.2);
 await panel.locator('select').first().selectOption('white');
 assert(messages.some(m=>m.type==='theme'&&m.theme==='white'));
 await panel.locator('select').first().selectOption('dark');
 assert(messages.some(m=>m.type==='theme'&&m.theme==='dark'));
 await panel.getByRole('button',{name:'App menu'}).click();assert(messages.some(m=>m.type==='menu'));
 await panel.getByRole('button',{name:'Continue reading'}).click();
 await v.focus();await page.keyboard.press('PageDown');await page.keyboard.press('PageDown');
 await page.waitForTimeout(400);
 const before=await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth));assert(before>0);
 assert(Math.abs(data['narouPosition:/n0552ep/75/'].position-before)<.001);
 await load();
 const restored=await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth));
 assert(Math.abs(before-restored)<.002,`${before} != ${restored}`);
 assert.equal(await panel.getAttribute('data-theme'),'dark');
 assert.equal(await panel.locator('#reading-highlightColor').inputValue(),'#ff7755','custom colors survive reload and theme change');
 assert(messages.filter(m=>m.type==='theme'&&m.theme==='dark').length>=2,'restored theme must reach the native app');
 await page.touchscreen.tap(5,5);
 // Language switches update existing controls without losing focus, colors or position.
 const language=panel.locator('#reading-language');
 const positionBeforeLanguage=await v.evaluate(e=>e.scrollLeft);
 await language.selectOption('ja');
 await page.waitForFunction(()=>document.querySelector('#narou-tategaki-host').shadowRoot.querySelector('#panel').lang==='ja');
 assert.equal(await panel.locator('#reading-reset-colors').textContent(),'文字色を戻す');
 assert.equal(data.narouTategaki.language,'ja');
 assert.equal(await panel.locator('#reading-highlightColor').inputValue(),'#ff7755');
 assert.equal(await v.evaluate(e=>e.scrollLeft),positionBeforeLanguage);
 assert(messages.some(m=>m.type==='language'&&m.language==='ja'));
 await load();
 assert.equal(await panel.getAttribute('lang'),'ja','language survives reload');
 await page.touchscreen.tap(5,5);
 await language.selectOption('en');
 await page.waitForFunction(()=>document.querySelector('#narou-tategaki-host').shadowRoot.querySelector('#panel').lang==='en');
 // Optional: inspect with a local screenshot here.
 await panel.getByRole('button',{name:'Original page',exact:true}).click();assert(await panel.isHidden());
 assert(messages.some(m=>m.type==='reader'&&!m.open));
 await host.locator('#launch').click();await page.waitForTimeout(100);
 assert(Math.abs(before-await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth)))<.002);
 await page.setViewportSize({width:840,height:412});await page.waitForTimeout(200);
 assert(Math.abs(before-await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth)))<.002);
 assert.equal(await v.evaluate(e=>e.scrollHeight),await v.evaluate(e=>e.clientHeight));
 // Optional: inspect landscape layout here.
 // Motion uses mocked native accelerometer samples, real Gecko layout and animation.
 await page.touchscreen.tap(5,5);
 await panel.locator('#motion-toggle').click();
 await page.waitForTimeout(250);
 await panel.locator('#motion-neutral').click();
 await panel.getByRole('button',{name:'Continue reading'}).click();
 const motionStart=await v.evaluate(e=>e.scrollLeft);
 sensor.x=.5;
 await page.waitForTimeout(900);
 assert(await v.evaluate(e=>e.scrollLeft)<motionStart-20,'tilt must scroll forward');
 assert.equal(await panel.locator('article p.reading-focus').count(),1,'tilt automatically focuses a paragraph');
 assert(await panel.locator('article p.reading-focus').evaluate(e=>{const r=e.getBoundingClientRect();return Math.max(r.left-innerWidth/2,innerWidth/2-r.right,0)<40;}),'automatic focus follows the screen center (including column gaps and update interval)');
 sensor.x=0;await page.waitForTimeout(180);
 // Keep a manually selected paragraph while braking, including residual movement.
 await panel.locator('article p').last().dispatchEvent('keydown',{key:'Enter'});
 await page.waitForTimeout(250);
 assert.equal(await panel.locator('article p').last().getAttribute('aria-current'),'true','braking range suppresses automatic selection');
 sensor.x=.5;await page.waitForTimeout(250);
 assert.equal(await panel.locator('article p').last().getAttribute('aria-current'),null,'automatic focus resumes outside braking range');
 // Opening controls brakes immediately.
 await page.touchscreen.tap(5,5);
 const stopped=await v.evaluate(e=>e.scrollLeft);
 await page.waitForTimeout(350);
 assert.equal(await v.evaluate(e=>e.scrollLeft),stopped,'controls must pause motion');
 // Calibration uses the same live sensor and physics as reading, even with controls open.
 const marker=panel.locator('#motion-marker'), band=panel.locator('#motion-band');
 const preview=panel.locator('#motion-preview');
 assert(await marker.isVisible(),'live tilt marker is visible while paused');
 assert.equal(await marker.evaluate(e=>e.style.left),'75%');
 assert.equal(await band.evaluate(e=>e.style.left),'46%');
 assert.match(await panel.locator('#motion-boundaries').textContent(),/-0.08.*0.08/);
 await preview.click();
 await page.waitForTimeout(500);
 assert(await v.evaluate(e=>e.scrollLeft)<stopped-10,'preview scrolls actual text with calibration open');
 await preview.click();
 const previewStopped=await v.evaluate(e=>e.scrollLeft);
 sensor.x=-.4;
 await page.waitForTimeout(250);
 assert.equal(await v.evaluate(e=>e.scrollLeft),previewStopped,'pause immediately stops preview');
 assert.equal(await marker.evaluate(e=>e.style.left),'30%','sensor marker updates while paused');
 await panel.locator('#motion-offset').evaluate(e=>{e.value='-0.4';e.dispatchEvent(new Event('input'));});
 await panel.locator('#motion-deadzone').evaluate(e=>{e.value='0.12';e.dispatchEvent(new Event('input'));});
 assert.match(await panel.locator('#motion-boundaries').textContent(),/-0.52.*-0.28/);
 await preview.click();await page.waitForTimeout(250);
 assert.equal(await v.evaluate(e=>e.scrollLeft),previewStopped,'shaded neutral range matches actual physics');
 sensor.x=.5;await page.waitForTimeout(300);
 await panel.locator('#motion-settings summary').click();
 const collapsed=await v.evaluate(e=>e.scrollLeft);
 await page.waitForTimeout(250);
 assert.equal(await v.evaluate(e=>e.scrollLeft),collapsed,'collapsing calibration stops preview');
 await panel.locator('#motion-settings summary').click();
 assert.equal(await preview.getAttribute('aria-pressed'),'false');
 await panel.locator('#motion-deadzone').evaluate(e=>{e.value='0.08';e.dispatchEvent(new Event('input'));});
 if(process.env.CALIBRATION_SCREENSHOT){
   await page.setViewportSize({width:412,height:840});await page.waitForTimeout(200);
   await page.screenshot({path:process.env.CALIBRATION_SCREENSHOT});
   await page.setViewportSize({width:840,height:412});await page.waitForTimeout(200);
 }
 sensor.x=0;
 await page.waitForTimeout(200);
 await panel.locator('#motion-neutral').click();
 await panel.locator('#motion-reverse').check();
 await panel.getByRole('button',{name:'Continue reading'}).click();
 sensor.x=.5;
 await page.waitForTimeout(800);
 assert(await v.evaluate(e=>e.scrollLeft)>collapsed+15,'reverse must change direction');
 sensor.active=false;
 await page.waitForTimeout(250);
 const inactive=await v.evaluate(e=>e.scrollLeft);
 await page.waitForTimeout(300);
 assert.equal(await v.evaluate(e=>e.scrollLeft),inactive,'inactive sensor must stop');
 sensor.active=true;sensor.rotation=1;sensor.x=-.6;
 await page.waitForTimeout(350);
 assert.equal(await v.evaluate(e=>e.scrollLeft),inactive,'rotation must reset neutral');
 await page.touchscreen.tap(5,5);
 await panel.locator('#motion-toggle').click();
 assert(messages.some(m=>m.type==='motion'&&!m.enabled));
 await load();
 assert.equal(await panel.locator('#motion-toggle').getAttribute('aria-pressed'),'false','disabled state persists');
 assert.equal(data.narouMotion.reverse,true,'motion preferences persist');
 await page.touchscreen.tap(5,5);
 await panel.locator('#motion-toggle').click();
 await page.waitForTimeout(150);
 await load();
 assert.equal(await panel.locator('#motion-toggle').getAttribute('aria-pressed'),'true','enabled state persists');
 const calibrated=await v.evaluate(e=>e.scrollLeft);
 await page.waitForTimeout(300);
 assert.equal(await v.evaluate(e=>e.scrollLeft),calibrated,'restored enabled state calibrates without jumping');
 assert.equal(errors.length,0,errors.join('\n'));
 console.log(JSON.stringify({result:'PASS',engine:await browser.version(),checks:['spacer paragraphs removed','paragraph control saved','tap toggles controls without reflow','native menu bridge','full viewport reading','reading position restored after reload and rotation','theme persisted','original page toggle','accelerometer scrolling','controls brake motion','reverse direction','inactive sensor stops motion','rotation recalibrates','motion preferences persist']}));
 await browser.close();
})().catch(e=>{console.error(e);process.exit(1)});