Verify transport geometry, playback states and themed icons across both engines

This commit is contained in:
Jonathan Sykes
2026-10-09 16:12:28 +08:00
parent 1e83951972
commit a1048f1c30
6 changed files with 6243 additions and 1 deletions

View File

@@ -474,3 +474,13 @@ check Save before playing / Audio-only / Auto-save settings, open a fresh video,
add it to a playlist, and inspect Downloads failure details and Saved after a
relaunch/offline playback. Verify available storage and whether iOS evicted
previous copies. A failing iPhone sequence is needed to continue the bisect.
Transport controls use the same shared-definition approach as media/save icons.
`node perf/ui-icons.mjs` also checks all four layouts × dark/light/contrast in
Chromium and WebKit: shared monochrome symbols, accessible labels, visible
44px targets, F7 filled glyphs, runtime layout changes, main/stage/mini/service
play-pause state swaps, fullscreen and Car mode refreshes, and actual paired
remote toggles against a mocked relay. Transport previews and remote screenshots
are saved in ignored `plans/transport-shots/`. Run `node perf/ui-geometry.mjs`
for the 320–430px search-bar and Saved-row geometry matrix. OS lock-screen buttons
remain platform-rendered through the existing MediaSession handlers.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import {resolve} from 'node:path';
import {mkdir} from 'node:fs/promises';
import {contextFor,appearance} from './ui-fixture.mjs';
export async function transportChecks(page,engine,layout,theme) {
const shots=resolve('plans/transport-shots');await mkdir(shots,{recursive:true});
const result=await page.evaluate(async()=>{
const ids=['playBtn','prevBtn','nextBtn','back10Btn','fwd10Btn','stgBack','stgPlay','stgFwd','miniPlayBtn','servicePrevBtn','servicePlayBtn','serviceNextBtn','rvPrev','rvToggle','rvNext'];
const legacy=/[▶⏸⏮⏭⏪⏩⏯◀❚]/u;
const bad=[...document.querySelectorAll('button,a,.home-pl-play,.brand-mark,.ph-logo,.empty-icon')].filter(n=>legacy.test(n.textContent)).map(n=>n.outerHTML);
const inspect=el=>{
const svg=el.querySelector('.transport-icon'),style=getComputedStyle(svg),parent=getComputedStyle(el),box=el.getBoundingClientRect();
return {id:el.id,kind:svg?.dataset.transportIcon,href:[...svg.querySelectorAll('use')].find(n=>getComputedStyle(n).display!=='none')?.getAttribute('href'),fill:style.fill,stroke:style.stroke,color:style.color,parentColor:parent.color,legacy:!!el.querySelector('.fill,.legacy-nav-icon'),label:el.getAttribute('aria-label'),width:box.width,height:box.height,visible:box.width>0&&box.height>0};
};
const icons=ids.map(id=>inspect(document.getElementById(id)));
const previous=document.querySelector('#prevBtn svg');
const previousBox=[...previous.querySelectorAll('use')].find(n=>getComputedStyle(n).display!=='none').getBBox();
const previousX=previousBox.x;
const shapes=[...document.querySelectorAll('#playBtn .transport-shape')].filter(n=>getComputedStyle(n).display!=='none').map(n=>n.classList[1]);
const old=Object.getOwnPropertyDescriptor(Player.master,'paused');let paused=true;
Object.defineProperty(Player.master,'paused',{configurable:true,get:()=>paused});
const states=[];
for(const state of [true,false,true]){paused=state;updatePlayBtn();states.push(['playBtn','stgPlay','miniPlayBtn','servicePlayBtn'].map(id=>inspect(document.getElementById(id))));}
// Exercise the real fullscreen HUD and Car mode's own refresh callbacks.
const stage=document.querySelector('.player-stage'); const request=stage.requestFullscreen;stage.requestFullscreen=undefined;
await playerFullscreen.enter();paused=false;await new Promise(r=>setTimeout(r,300));
const hudPlaying=inspect(document.querySelector('.fullscreen-controls button'));
paused=true;await new Promise(r=>setTimeout(r,300));const hudPaused=inspect(document.querySelector('.fullscreen-controls button'));playerFullscreen.leave();stage.requestFullscreen=request;
CarMode.configure({input:els.searchInput,search(){},toast(){},state:()=>({paused,title:'Transport fixture'}),action(){}});CarMode.open();
const car=[...document.querySelectorAll('.car-controls button')].map(inspect);
paused=false;await new Promise(r=>setTimeout(r,550));const carPlaying=inspect(document.querySelectorAll('.car-controls button')[1]);CarMode.close();
if(old)Object.defineProperty(Player.master,'paused',old);else delete Player.master.paused;updatePlayBtn();
// A preview strip uses the very same symbols to make all actions visible together.
const strip=document.createElement('section');strip.id='transportPreview';strip.style.cssText='position:fixed;left:8px;right:8px;top:120px;z-index:30000;padding:20px;background:var(--bg-1);color:var(--text);border:1px solid var(--line);border-radius:20px;display:flex;flex-wrap:wrap;gap:12px';
for(const kind of ['previous','back10','play','pause','forward10','next']){const b=document.createElement('button');b.className=kind==='play'||kind==='pause'?'ctrl play':'ctrl';b.title=kind;if(kind==='play'||kind==='pause'){const s=getComputedStyle(document.getElementById('playBtn'));for(const prop of ['width','height','borderRadius','background','color','border','boxShadow','backdropFilter'])b.style[prop]=s[prop];}b.innerHTML=TransportIcons.svg(kind);strip.append(b);}document.body.append(strip);
return {bad,icons,previousX,shapes,states,hudPlaying,hudPaused,car,carPlaying};
});
const label=`${engine}/${layout}/${theme}`;
assert.deepEqual(result.bad,[],label+': legacy transport glyph');
assert.deepEqual(result.shapes,[`transport-shape-${layout}`],label+': wrong drawing');
if(result.icons.find(n=>n.id==='prevBtn').visible)assert.equal(result.previousX,layout==='f7-swipe'?4:5,label+': rendered geometry must switch');
for(const icon of [...result.icons,...result.car,result.hudPlaying,result.hudPaused,result.carPlaying]){
assert(icon.kind&&!icon.legacy&&icon.href===`#transport-${icon.kind}${layout==='classic'?'':'-'+layout}`,label+': nonshared icon');
assert(icon.label,label+': missing accessible name');
assert.equal(icon.stroke,icon.color,label+': nonmonochrome stroke');
if(layout==='f7-swipe'&&['play','pause','previous','next','forward'].includes(icon.kind))assert.equal(icon.fill,icon.color,label+': F7 glyph must be filled '+JSON.stringify(icon));assert.equal(icon.color,icon.parentColor,label+': detached control colour');
if(icon.visible)assert(icon.width>=44&&icon.height>=44,`${label}/${icon.id||icon.kind}: target ${icon.width}x${icon.height}`);
}
for(const [i,icons] of result.states.entries())for(const icon of icons){assert.equal(icon.kind,i===1?'pause':'play');assert.equal(icon.label,i===1?'Pause':'Play');}
assert.equal(result.hudPlaying.kind,'pause');assert.equal(result.hudPaused.kind,'play');assert.equal(result.carPlaying.kind,'pause');
await page.locator('#transportPreview').screenshot({path:resolve(shots,`${engine}-${layout}-${theme}-symbols.png`)});
await page.evaluate(()=>document.getElementById('transportPreview').remove());
return result;
}
export async function remoteTransportFixture(browser,origin,engine) {
const context=await contextFor(browser,origin);
await context.route('**/api/remote/pair',route=>route.fulfill({json:{ok:true,roomId:'fixture',remoteId:'phone',token:'fixture',hostName:'Transport fixture',name:'Phone'}}));
await context.routeWebSocket('**/ws/remote**',ws=>{setTimeout(()=>ws.send(JSON.stringify({type:'hello',hostName:'Transport fixture',state:{paused:true,cur:0,dur:240,volume:1,v:{id:'iconcheck00',title:'Transport fixture',channel:'Fixture'}}})),100);});
const page=await context.newPage();const errors=[];page.on('pageerror',error=>errors.push(error.message));
await page.goto(origin+'/?pair=123456');await page.waitForSelector('#rvControl:not(.hidden)');
await page.waitForFunction(()=>document.getElementById('rvTitle').textContent==='Transport fixture');
return {
async check(layout,theme){
await appearance(page,layout,theme);
const states=[];
for(let i=0;i<3;i++){
states.push(await page.evaluate(()=>['rvPrev','rvToggle','rvNext'].map(id=>{const b=document.getElementById(id),svg=b.querySelector('.transport-icon'),r=b.getBoundingClientRect();return {kind:svg.dataset.transportIcon,href:[...svg.querySelectorAll('use')].find(n=>getComputedStyle(n).display!=='none').getAttribute('href'),label:b.getAttribute('aria-label'),width:r.width,height:r.height};})));
if(i<2)await page.locator('#rvToggle').click();
}
for(const [i,icons] of states.entries()){
assert.equal(icons[1].kind,i===1?'pause':'play');assert.equal(icons[1].label,i===1?'Pause':'Play');
for(const icon of icons){assert.equal(icon.href,'#transport-'+icon.kind+(layout==='classic'?'':'-'+layout));assert(icon.width>=44&&icon.height>=44);}
}
await page.locator('.rv-transport').screenshot({path:resolve('plans/transport-shots',`${engine}-${layout}-${theme}-remote.png`)});
assert.deepEqual(errors,[],engine+': remote script errors');
return states;
},close:()=>context.close()
};
}

View File

@@ -4,6 +4,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { chromium, webkit } from 'playwright';
import { serve, layouts, themes, contextFor, appearance } from './ui-fixture.mjs';
import {transportChecks,remoteTransportFixture} from './transport-fixture.mjs';
const args = process.argv.slice(2);
const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key)+1] : fallback;
@@ -17,6 +18,7 @@ try {
try {
const context = await contextFor(browser,host.origin);
const page = await context.newPage();
const remote=await remoteTransportFixture(browser,host.origin,name);
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto(host.origin);
@@ -85,9 +87,12 @@ try {
});
assert.deepEqual(states,{normal:['#media-download'],progress:['#media-progress'],done:['#media-offline']},`${name}/${layout}/${theme}: save states`);
await page.evaluate(() => document.querySelector('#listPane').classList.remove('hidden'));
results.push({browser:name,layout,theme,...measurements});
const transport=await transportChecks(page,name,layout,theme);
const remoteStates=await remote.check(layout,theme);
results.push({browser:name,layout,theme,...measurements,transport,remoteStates});
}
assert.deepEqual(errors,[],'script errors');
await remote.close();
await context.close();
} finally { await browser.close(); }
}