Unify Glass Stage navigation and page controls with Now Playing
This commit is contained in:
87
tests/glass-controls.spec.js
Normal file
87
tests/glass-controls.spec.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const {test,expect}=require('@playwright/test');
|
||||
const {differentPixels}=require('./helpers/png-diff');
|
||||
const {openClassic,showPlayingFixture}=require('./helpers/classic-fixture');
|
||||
const capture=process.env.YTP_GLASS_CAPTURE;
|
||||
async function fixture(page,layout,state){
|
||||
await openClassic(page,{populated:true,settings:{autoPreload:false}});
|
||||
await page.addStyleTag({content:'*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}'});
|
||||
await page.evaluate(({layout,state})=>{
|
||||
data.settings.layout=layout;applyAppearance();SaveQueue.resumeAll=async()=>{};
|
||||
API.cacheList=async()=>({ok:true,items:data.playlists[0].videos.slice(0,3).map((v,i)=>({id:v.id,size:104857600*(i+1)})),total:629145600});
|
||||
if(state==='downloads'){
|
||||
const videos=data.playlists[0].videos.slice(0,4);downloading.clear();downloadMeta.clear();cachedIds.clear();
|
||||
localStorage.setItem('ytpSaveQueue',JSON.stringify(Object.fromEntries(videos.map((v,i)=>[v.id,{...v,at:Date.now(),status:['active','queued','paused','failed'][i],error:i===3?'Connection lost':''}]))));
|
||||
videos.slice(0,2).forEach((v,i)=>{downloading.add(v.id);downloadMeta.set(v.id,v);SaveQueue.progress(v.id,i?{phase:'waiting'}:{received:10485760,total:104857600});});
|
||||
}
|
||||
if(state==='search'){searchResults=data.playlists[0].videos;searchAttempted=true;}
|
||||
view=state==='playlist'?{type:state,id:'studio'}:{type:state};render();
|
||||
},{layout,state});
|
||||
if(state==='playing'){await showPlayingFixture(page);await page.evaluate(()=>{data.settings.layout='glass-stage';applyAppearance();});}
|
||||
await page.evaluate(async()=>{await document.fonts.ready;await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));});
|
||||
await page.locator('#listPane').scrollIntoViewIfNeeded();
|
||||
if(state==='saved')await expect(page.locator('.saved-card')).toHaveCount(3);
|
||||
if(state==='settings')await expect(page.locator('.settings-section-row').first()).toBeVisible();
|
||||
}
|
||||
for(const layout of ['classic','glass-stage'])for(const width of [390,1440])for(const state of ['search','queue','history','playlist','saved','downloads','settings'])test(`${layout} ${state} ${width}`,async({page})=>{
|
||||
await page.setViewportSize({width,height:900});
|
||||
let baseline, baselineStyles;
|
||||
const styles = target => target.locator('.nav-item,.bottom-nav-btn,.card-menu,.card-del,.saved-file-action,.dl-act,.settings-section-row').evaluateAll(nodes=>nodes.map(el=>{const s=getComputedStyle(el),r=el.getBoundingClientRect();return [el.className,r.x,r.y,r.width,r.height,s.color,s.backgroundColor,s.borderRadius,s.fontSize,s.display];}));
|
||||
if(!capture&&layout==='classic'){
|
||||
const prior=await page.context().newPage();await prior.setViewportSize({width,height:900});
|
||||
await prior.route('**/theme-glass-controls.css',r=>r.fulfill({contentType:'text/css',body:''}));
|
||||
await prior.route('**/glass-controls.js',r=>r.fulfill({contentType:'application/javascript',body:''}));
|
||||
await fixture(prior,layout,state);baselineStyles=await styles(prior);baseline=await prior.screenshot({path:`/tmp/glass-polish-before-${layout}-${width}-${state}.png`});await prior.close();
|
||||
}
|
||||
await fixture(page,layout,state);
|
||||
const path=`/tmp/glass-polish-${capture||'after'}-${layout}-${width}-${state}.png`;
|
||||
const screenshot=await page.screenshot({path});
|
||||
if(!capture&&layout==='classic'){expect(await styles(page)).toEqual(baselineStyles);expect(differentPixels(screenshot,baseline)).toBeLessThanOrEqual(16);}
|
||||
if(!capture&&layout==='glass-stage'){
|
||||
const svg=page.locator('#bottomNav .classic-nav-icon').first();expect(await svg.evaluate(el=>getComputedStyle(el).display)).not.toBe('none');
|
||||
const stroke=await svg.evaluate(el=>getComputedStyle(el).strokeWidth);expect(stroke).toBe('1.9px');
|
||||
const controls=await page.locator('.saved-file-action,.dl-act,.settings-section-row,.card-menu').evaluateAll(nodes=>nodes.filter(n=>n.getBoundingClientRect().width>0).map(n=>{const r=n.getBoundingClientRect();return [r.width,r.height];}));
|
||||
expect(controls.every(([w,h])=>w>=44&&h>=44)).toBe(true);
|
||||
}
|
||||
});
|
||||
test('Now Playing Glass reference',async({page})=>{
|
||||
await page.setViewportSize({width:390,height:900});await fixture(page,'glass-stage','playing');await page.locator('#nowPlayingMeta').scrollIntoViewIfNeeded();
|
||||
await page.screenshot({path:`/tmp/glass-polish-${capture||'after'}-reference.png`});
|
||||
});
|
||||
|
||||
test('Glass navigation, notes and mini-player controls keep labels and round-trip cleanly',async({page})=>{
|
||||
await page.setViewportSize({width:390,height:900});await openClassic(page,{populated:true,settings:{autoPreload:false}});await showPlayingFixture(page);
|
||||
await page.addStyleTag({content:'*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}'});
|
||||
await page.evaluate(async()=>{await document.fonts.ready;await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));});
|
||||
const markup = () => page.locator('#listPane').evaluate(el=>{const copy=el.cloneNode(true);copy.querySelectorAll('[style=""]').forEach(n=>n.removeAttribute('style'));return copy.outerHTML;});
|
||||
const before=await markup();
|
||||
const miniClass=await page.locator('#miniBar').getAttribute('class');
|
||||
await page.evaluate(()=>{data.settings.layout='glass-stage';applyAppearance();});
|
||||
await expect(page.locator('.card-menu .glass-control-icon').first()).toBeAttached();
|
||||
// Isolated installed-navigation fixture: the real app shows this bar in
|
||||
// standalone portrait mode; ordinary Chromium isn't an installed PWA.
|
||||
await page.addStyleTag({content:'html[data-layout="glass-stage"] #bottomNav{display:flex}html[data-layout="glass-stage"] #miniBar{display:block}'});
|
||||
await page.locator('#miniBar').evaluate(el=>el.classList.remove('hidden'));
|
||||
await expect(page.locator('#miniPlayBtn svg')).toBeVisible();
|
||||
await expect(page.locator('#miniPlayBtn')).toHaveAttribute('aria-label',/Play|Pause/);
|
||||
await expect(page.locator('#bottomNav [aria-label="Queue"] svg')).toBeVisible();
|
||||
expect(await page.locator('#bottomNav [aria-label="Downloads"] .bottom-nav-label').evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true);
|
||||
await page.screenshot({path:'/tmp/glass-polish-navigation-notes-mini.png'});
|
||||
await page.evaluate(()=>{data.settings.layout='classic';applyAppearance();});
|
||||
await expect(page.locator('.glass-control-icon')).toHaveCount(0);
|
||||
await page.locator('#miniBar').evaluate((el,className)=>{el.className=className;},miniClass);
|
||||
expect(await markup()).toBe(before);
|
||||
});
|
||||
test('Glass icons preserve button commands and keyboard focus',async({page})=>{
|
||||
await page.setViewportSize({width:1440,height:900});await fixture(page,'glass-stage','queue');
|
||||
const action=page.locator('.card-menu').first();await action.focus();
|
||||
expect(await action.evaluate(el=>getComputedStyle(el).outlineStyle)).toBe('solid');
|
||||
await action.press('Enter');await expect(page.locator('#modal')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Glass settings final row is reachable and file actions are not covered',async({page})=>{
|
||||
await page.setViewportSize({width:390,height:900});await fixture(page,'glass-stage','saved');
|
||||
const covered=await page.locator('.saved-file-action').evaluateAll(nodes=>nodes.some(el=>{const r=el.getBoundingClientRect();if(r.bottom>innerHeight)return false;const target=document.elementFromPoint(r.x+r.width/2,r.y+r.height/2);return !el.contains(target);}));expect(covered).toBe(false);
|
||||
await page.setViewportSize({width:1440,height:900});await fixture(page,'glass-stage','settings');
|
||||
const last=page.locator('.settings-section-row').last();await last.scrollIntoViewIfNeeded();await expect(last).toBeInViewport();
|
||||
await page.screenshot({path:'/tmp/glass-polish-settings-end.png'});
|
||||
});
|
||||
27
tests/helpers/png-diff.js
Normal file
27
tests/helpers/png-diff.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// Lossless Chromium screenshots: tolerate a few rasterization pixels while
|
||||
// keeping strict geometry/style comparisons in the calling UI test.
|
||||
const { inflateSync } = require('node:zlib');
|
||||
function decode(buffer) {
|
||||
let width,height,channels;const chunks=[];
|
||||
for(let offset=8;offset<buffer.length;){
|
||||
const length=buffer.readUInt32BE(offset),type=buffer.toString('ascii',offset+4,offset+8),data=buffer.subarray(offset+8,offset+8+length);offset+=length+12;
|
||||
if(type==='IHDR'){width=data.readUInt32BE(0);height=data.readUInt32BE(4);channels=data[9]===6?4:3;if(data[8]!==8||![2,6].includes(data[9])||data[12])throw new Error('Expected an 8-bit RGB Chromium PNG');}
|
||||
if(type==='IDAT')chunks.push(data);
|
||||
}
|
||||
const raw=inflateSync(Buffer.concat(chunks)),stride=width*channels,pixels=Buffer.alloc(stride*height);let position=0;
|
||||
const paeth=(a,b,c)=>{const p=a+b-c,da=Math.abs(p-a),db=Math.abs(p-b),dc=Math.abs(p-c);return da<=db&&da<=dc?a:db<=dc?b:c;};
|
||||
for(let y=0;y<height;y++){
|
||||
const filter=raw[position++];if(filter>4)throw new Error('Unexpected PNG filter');
|
||||
for(let x=0;x<stride;x++){
|
||||
const index=y*stride+x,a=x>=channels?pixels[index-channels]:0,b=y?pixels[index-stride]:0,c=y&&x>=channels?pixels[index-stride-channels]:0;
|
||||
pixels[index]=(raw[position++]+[0,a,b,Math.floor((a+b)/2),paeth(a,b,c)][filter])&255;
|
||||
}
|
||||
}
|
||||
return {width,height,channels,pixels};
|
||||
}
|
||||
function differentPixels(first,second){
|
||||
const a=decode(first),b=decode(second);if(a.width!==b.width||a.height!==b.height||a.channels!==b.channels)return Infinity;
|
||||
let count=0;for(let i=0;i<a.pixels.length;i+=a.channels)if(!a.pixels.subarray(i,i+a.channels).equals(b.pixels.subarray(i,i+a.channels)))count++;
|
||||
return count;
|
||||
}
|
||||
module.exports={differentPixels};
|
||||
Reference in New Issue
Block a user