tests/reader.test.cjs
raw · 8270 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:{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=>r.request().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),'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);
await page.touchscreen.tap(210,350);assert(!(await panel.getAttribute('class')).includes('chrome-hidden'));
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').selectOption('white');
assert(messages.some(m=>m.type==='theme'&&m.theme==='white'));
await panel.locator('select').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:'読書に戻る'}).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(messages.filter(m=>m.type==='theme'&&m.theme==='dark').length>=2,'restored theme must reach the native app');
await page.touchscreen.tap(210,350);
// Optional: inspect with a local screenshot here.
await panel.getByRole('button',{name:'元のページ',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(400,220);
await panel.locator('#motion-toggle').click();
await page.waitForTimeout(250);
await panel.locator('#motion-neutral').click();
await panel.getByRole('button',{name:'読書に戻る'}).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');
// Opening controls brakes immediately.
await page.touchscreen.tap(400,220);
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');
sensor.x=0;
await page.waitForTimeout(200);
await panel.locator('#motion-neutral').click();
await panel.locator('#motion-reverse').check();
await panel.getByRole('button',{name:'読書に戻る'}).click();
sensor.x=.5;
await page.waitForTimeout(800);
assert(await v.evaluate(e=>e.scrollLeft)>stopped+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(400,220);
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(400,220);
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)});