mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 07:01:42 +01:00
* Add opt-in desktop low-battery comfort alerts * Fix desktop battery alert test harness and Windows encoding --------- Co-authored-by: SAPHID <saphid@users.noreply.github.com>
603 lines
40 KiB
JavaScript
603 lines
40 KiB
JavaScript
// Real Control Center logic with a minimal DOM; no network, browser or headset.
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const vm = require('node:vm');
|
||
const cc = require('../ui/control-center.js');
|
||
|
||
const sample = () => ({time:Date.now()/1000, hostname:'Test Frame', battery:{percent:99,status:'Discharging',watts:0,tempC:33,health:'Good'},
|
||
power:{type:'C PD [PD_PPS]',watts:18}, disk:{home:{total:256*1073741824,free:128*1073741824}},
|
||
memory:{total:16*1073741824,available:6*1073741824}, wifi:{ssid:'Test LAN',signal:80},
|
||
performance:{compositorFps:90,appFps:45,frameMs:11.11,gpuMs:3,cpuPercent:12,gpuMHz:600},
|
||
volume:{level:.4,muted:false}, os:{version:'0.4.1',build:'test-build'},services:{steamvr:true}, thermalAlerts:[],games:[],flatpaks:[]});
|
||
function fixture() {
|
||
const elements = new Map();
|
||
function element(id) {
|
||
if (!elements.has(id)) elements.set(id, {id,hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',dataset:{},
|
||
classList:{toggle(){}},querySelectorAll(){return [];},close(){this.open=false;},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
|
||
return elements.get(id);
|
||
}
|
||
const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
|
||
const buttons = cc.categories.map(([id])=>{const e=element('category-'+id);e.dataset.ccCategory=id;e.querySelector=()=>element('count-'+id);return e;});
|
||
const metrics = Object.keys(cc.metrics(null)).map(id=>{const e=element('metric-'+id);e.dataset.ccMetric=id;return e;});
|
||
const page = element('controlCenter');
|
||
page.querySelectorAll = selector => selector === '[data-cc-category]' ? buttons : selector === '[data-cc-metric]' ? metrics : selector === '[data-cc-mac-link]' ? [element('macLink')] : [];
|
||
page.querySelector = selector => selector === '[href="#connection"]' ? element('connectionLink') : cards.find(e=>selector === `[data-cc-card="${e.dataset.ccCard}"]`);
|
||
const listeners = {};
|
||
return {document:{hidden:false,getElementById:element,addEventListener(name,callback){listeners[name]=callback;}},listeners,element,cards,buttons};
|
||
}
|
||
function mounted(options = {}) {
|
||
const f = fixture(), calls = [];
|
||
let context = {online:true,generation:1,device:'frame-a'}, refreshes = 0, opened = 0;
|
||
const ui = cc.mount({document:f.document,platform:'darwin',api:async(...args)=>{calls.push(args);return {message:'Volume updated'};},
|
||
refresh:async()=>{refreshes++;},getContext:()=>context,openConnection:()=>opened++,...options});
|
||
return {...f,ui,calls,get context(){return context;},set context(v){context=v;},get refreshes(){return refreshes;},get opened(){return opened;}};
|
||
}
|
||
const pageSource = fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
|
||
const deferred = () => { let resolve, reject; const promise=new Promise((ok,bad)=>{resolve=ok;reject=bad;});return {promise,resolve,reject}; };
|
||
const flush = async () => { for(let i=0;i<12;i++)await Promise.resolve(); };
|
||
// Execute the actual shared refresh/deduplication, host callback and mount
|
||
// bootstrap, including the real Home-to-Control status handoff. Only unrelated
|
||
// rendering helpers, timers and transport are stubbed.
|
||
function sharedPage({productionApi=false} = {}) {
|
||
const f=fixture(), requests=[], writes=[], notifications=[], host=deferred(), job=deferred();
|
||
let now=0;
|
||
const context={document:f.document,FrameControlCenter:cc,performance:{now:()=>now},
|
||
window:{frameApp:{setUpConnection(){},notify:async(message,request)=>{notifications.push({message,request});}}},
|
||
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
|
||
battery(){},setVolume(){},renderHome(){},gb:String,dur:String,esc:String,meter(){},toast(){},
|
||
runJob(title,id){context.installing.add(id);return job.promise;},
|
||
api:(path,body,device)=>{
|
||
if(path==='/api/host')return host.promise;
|
||
if(path==='/api/status'){const request=deferred();requests.push(request);return request.promise;}
|
||
writes.push({path,body,device});return Promise.resolve({message:'Volume updated'});
|
||
},renderPerformance(){},setConn(){},setOnline(ok){vm.runInContext(`online = ${ok}`,context);},log(){},failed(){},
|
||
watchConnection(){context.watched=true;}};
|
||
vm.createContext(context);
|
||
const stateDeclaration=pageSource.match(/let state = null[^\n]*/)[0];
|
||
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1, comfortActive = false; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
|
||
// Load the real alert state machine and DOM wiring used by render, refresh,
|
||
// setOnline and onConnection; do not stub away their lifecycle behavior.
|
||
const batteryStart=pageSource.indexOf('class DesktopBatteryAlert'),batteryEnd=pageSource.indexOf('// ---- battery ----',batteryStart);
|
||
vm.runInContext(pageSource.slice(batteryStart,batteryEnd),context);
|
||
const refreshStart=pageSource.indexOf('let refreshing = null'),refreshEnd=pageSource.indexOf('// ---- our VR HUD',refreshStart);
|
||
const renderStart=pageSource.indexOf('function render(s) {'),renderEnd=pageSource.indexOf('// ---- view ----',renderStart);
|
||
vm.runInContext(pageSource.slice(renderStart,renderEnd)+pageSource.slice(refreshStart,refreshEnd),context);
|
||
if(productionApi) {
|
||
// Keep api/onConnection/setOnline intact: defer fetch itself, not api(), so
|
||
// rejection classification and the real switch/reset notifications run.
|
||
context.fetch=(path,opts)=>{
|
||
if(path==='/api/host')return host.promise.then(data=>({ok:true,status:200,json:async()=>data}));
|
||
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
|
||
};
|
||
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
|
||
padReset(){},ctrlReset(){},resetShots(){},resetClips(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||
vm.runInContext(`const UI_KEY = 'test-only';
|
||
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
|
||
const disp = {}, gm = {seq:0}, cat = {}, wi = {}, sourceState = {jobs:new Map(),detail:null,detailRequest:0};
|
||
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], androidSeq = 0, titlesSeq = 0, runningGames = [], runningSeq = 0, page = 'control';
|
||
const androidPending = new Set();`,context);
|
||
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
|
||
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
|
||
const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
|
||
vm.runInContext(pageSource.slice(apiStart,apiEnd)+pageSource.slice(onlineStart,onlineEnd)+pageSource.slice(connectionStart,connectionEnd),context);
|
||
}
|
||
const installStart=pageSource.indexOf('function installFlatpak(id) {'),installEnd=pageSource.indexOf('$("fpInstall").onclick',installStart);
|
||
vm.runInContext(pageSource.slice(installStart,installEnd),context);
|
||
const hostStart=pageSource.lastIndexOf('api("/api/host").then(h =>'),hostEnd=pageSource.indexOf('</script>',hostStart);
|
||
vm.runInContext(pageSource.slice(hostStart,hostEnd),context);
|
||
const bootstrapStart=pageSource.indexOf('window.frameControlCenter = FrameControlCenter.mount('),bootstrapEnd=pageSource.indexOf('</script>',bootstrapStart);
|
||
return {...f,context,requests,writes,notifications,host,setNow(value){now=value;},refresh:()=>vm.runInContext('refresh()',context),
|
||
switchHeadset:()=>vm.runInContext('onConnection({now:Date.now()/1000,phase:"connected",devices_rev:0,device:{id:"frame-b",name:"Second Frame"}})',context),
|
||
installFlatpak:()=>vm.runInContext('installFlatpak("test.app")',context),
|
||
mount(){vm.runInContext(pageSource.slice(bootstrapStart,bootstrapEnd),context);return context.window.frameControlCenter;}};
|
||
}
|
||
const response = data => ({ok:true,status:200,json:async()=>data});
|
||
test('production refresh confirms desktop alerts and failures interrupt confirmation', async () => {
|
||
const f=sharedPage({productionApi:true});
|
||
const low=sample();low.battery={percent:15,status:'Discharging',watts:-4};
|
||
const read=async(now,data=low)=>{
|
||
f.setNow(now);const pending=f.refresh();
|
||
f.requests.at(-1).resolve(response(data));await pending;
|
||
};
|
||
await read(0);await read(30000);
|
||
assert.equal(f.notifications.length,0,'desktop alert starts off');
|
||
f.element('desktopBatteryAlert').checked=true;
|
||
f.element('desktopBatteryAlert').onchange();
|
||
await read(60000);
|
||
const failed=f.refresh();f.requests.at(-1).reject(Error('Status transport failed'));await failed;
|
||
await read(90000);
|
||
assert.equal(f.notifications.length,0,'failed status interrupts low-battery confirmation');
|
||
await read(120000);
|
||
assert.equal(f.notifications.length,1);
|
||
assert.match(f.notifications[0].message,/15%.*draining/);
|
||
assert.equal(f.notifications[0].request,false,'refresh never requests notification permission');
|
||
await read(150000);
|
||
assert.equal(f.notifications.length,1,'same episode stays latched');
|
||
assert.equal(f.requests.length,7,'only explicit existing status reads are used');
|
||
});
|
||
test('production headset switch clears desktop opt-in and pending confirmation', async () => {
|
||
const f=sharedPage({productionApi:true});
|
||
f.element('desktopBatteryAlert').checked=true;
|
||
const low=sample();low.battery={percent:10,status:'Discharging',watts:-3};
|
||
const pending=f.refresh();f.requests[0].resolve(response(low));await pending;
|
||
assert.equal(vm.runInContext('desktopBattery.pending.condition',f.context),'low');
|
||
f.setNow(30000);f.switchHeadset();
|
||
assert.equal(f.element('desktopBatteryAlert').checked,false);
|
||
assert.equal(vm.runInContext('desktopBattery.pending',f.context),null);
|
||
assert.match(f.element('desktopBatteryStatus').textContent,/Headset changed/);
|
||
f.requests[1].resolve(response(low));await flush();
|
||
assert.equal(f.notifications.length,0,'new headset cannot inherit the prior opt-in');
|
||
});
|
||
for(const rejection of ['transport','body'])for(const delayedMount of [false,true]) {
|
||
test(`production ${rejection} rejection from A preserves B ${delayedMount ? 'before delayed mount' : 'after mount'}`, async t => {
|
||
const f=sharedPage({productionApi:true});
|
||
if(!delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
|
||
const old=f.refresh(),body=deferred();
|
||
assert.equal(f.requests[0].path,'/api/status');
|
||
if(rejection==='body') {
|
||
f.requests[0].resolve({ok:true,status:200,json:()=>body.promise});await flush();
|
||
}
|
||
f.switchHeadset();
|
||
assert.equal(f.requests.length,2,'real connected B callback starts its own status fetch');
|
||
const next=sample();next.hostname='Second Frame';next.battery.percent=20;next.volume.level=.2;
|
||
f.requests[1].resolve(response(next));await flush();
|
||
const receipt=vm.runInContext('stateReceivedAt',f.context);
|
||
assert.equal(vm.runInContext('stateRead.valid && online && devGen === 2',f.context),true);
|
||
if(!delayedMount)assert.equal(f.element('metric-battery').textContent,'20%');
|
||
(rejection==='body' ? body : f.requests[0]).reject(Error('Old A request failed'));await old;
|
||
assert.equal(vm.runInContext('stateRead.valid',f.context),true,'A must not invalidate B');
|
||
assert.equal(vm.runInContext('stateRead.error',f.context),null);
|
||
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
|
||
if(delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
|
||
const assertB=()=>{
|
||
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
|
||
assert.equal(f.element('metric-battery').textContent,'20%');
|
||
assert.equal(f.element('ccVolume').value,20);
|
||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,false);
|
||
assert.doesNotMatch(f.element('ccSampleState').textContent,/No live sample|local server|Old A/);
|
||
};
|
||
assertB();vm.runInContext('render(state)',f.context);assertB();
|
||
assert.equal(f.requests.length,2,'preservation and redraw need no recovery poll');
|
||
// An ordinary failure on B must still invalidate the current sample.
|
||
const failed=f.refresh();
|
||
if(rejection==='body')f.requests[2].resolve({ok:true,status:200,json:async()=>{throw Error('B body failed');}});
|
||
else f.requests[2].reject(Error('B transport failed'));
|
||
await failed;
|
||
assert.equal(vm.runInContext('stateRead.valid',f.context),false);
|
||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||
});
|
||
}
|
||
test('production headset-free API keeps transport errors and responses across a switch', async t => {
|
||
for(const failure of [false,true]) {
|
||
const f=sharedPage({productionApi:true}),ui=f.mount();t.after(()=>ui.destroy());
|
||
const pending=vm.runInContext('api("/api/devices")',f.context);
|
||
const result=pending.then(value=>({value}),error=>({error}));
|
||
f.switchHeadset();f.requests[1].resolve(response(sample()));await flush();
|
||
if(failure)f.requests[0].reject(Error('Device list transport failed'));
|
||
else f.requests[0].resolve(response({devices:['frame-b']}));
|
||
const settled=await result;
|
||
if(failure){assert.equal(settled.error.stale,undefined);assert.match(settled.error.message,/local server isn't running/);}
|
||
else assert.deepEqual(settled.value,{devices:['frame-b']});
|
||
assert.equal(vm.runInContext('stateRead.valid',f.context),true);
|
||
}
|
||
});
|
||
test('search finds settings by aliases, all words, category and guidance', () => {
|
||
const find = (q,category='all')=>cc.catalog.filter(item=>cc.matches(item,q,category)).map(item=>item.id);
|
||
assert.ok(find('DPI').includes('display'));
|
||
assert.ok(find('sleep inactivity').includes('power'));
|
||
assert.ok(find('MOTION SICKNESS').includes('playspace'));
|
||
assert.ok(find('font','apps').includes('display'));
|
||
assert.deepEqual(find('font','vr'),[]);
|
||
assert.deepEqual(find('<script>alert(1)</script>'),[]);
|
||
assert.ok(find(' ').length > 20);
|
||
});
|
||
test('metrics use real fields, preserve zero, and distinguish unavailable values', () => {
|
||
const m = cc.metrics(sample());
|
||
assert.equal(m.battery,'99%');
|
||
assert.equal(m.power,'0 W');
|
||
assert.match(m.batteryDetail,/not charging/);
|
||
assert.equal(m.fps,'90'); assert.equal(m.appFps,'45');
|
||
assert.equal(m.storage,'128 GiB'); assert.equal(m.memory,'10 GiB');
|
||
assert.equal(m.thermal,'No hot / critical trip reported');
|
||
assert.equal(cc.metrics(null).fps,'Unavailable');
|
||
assert.equal(cc.metrics({performance:{appFps:null,gpuMHz:0,cpuPercent:0}}).appFps,'Unavailable');
|
||
assert.equal(cc.metrics({performance:{cpuPercent:0}}).cpu,'0%');
|
||
assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%');
|
||
assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/);
|
||
});
|
||
test('battery health distinguishes charging states and optional sensors', () => {
|
||
for (const status of [null, 'Unknown', 'Unexpected']) {
|
||
const m = cc.metrics({battery:{percent:100,status,timeToEmpty:3600}});
|
||
assert.equal(m.batteryDetail,'Charging state unavailable');
|
||
assert.equal(m.estimate,'Unavailable');
|
||
}
|
||
assert.equal(cc.metrics({battery:{status:'Not charging'}}).batteryDetail,'Not charging');
|
||
assert.equal(cc.metrics({battery:{status:'Full'}}).batteryDetail,'Fully charged');
|
||
assert.equal(cc.metrics({battery:{status:'Charging',timeToFull:3600}}).estimate,'1 h 0 min to full');
|
||
assert.equal(cc.metrics({battery:{status:'Discharging',timeToEmpty:600}}).estimate,'10 min remaining');
|
||
const m = cc.metrics({battery:{cycleCount:0,fullMah:4500,designMah:5000,capacityHealthPercent:90,
|
||
source:'/sys/class/power_supply/max1720x_bat_7-36',health:'Unknown',tempC:0}});
|
||
assert.equal(m.cycles,'0'); assert.equal(m.capacityHealth,'90%');
|
||
assert.equal(m.fullCapacity,'4,500 mAh'); assert.equal(m.batteryTemp,'0 °C');
|
||
assert.equal(m.batteryHealth,'Unavailable'); assert.match(m.batterySource,/max1720x/);
|
||
for (const key of ['cycles','fullCapacity','designCapacity','capacityHealth','batterySource','batteryHealth','batteryTemp'])
|
||
assert.equal(cc.metrics(null)[key],'Unavailable');
|
||
});
|
||
test('battery chip preserves unknown status and does not infer Full at 100%', () => {
|
||
const f=fixture();
|
||
const start=pageSource.indexOf('function battery(b, power)');
|
||
const end=pageSource.indexOf('// hidePopover', start);
|
||
const context={$:f.element,dur:String}; vm.createContext(context);
|
||
vm.runInContext(pageSource.slice(start,end),context);
|
||
context.battery({percent:100,status:'Unknown'},{watts:20});
|
||
assert.equal(f.element('battState').textContent,'Charging state unavailable');
|
||
context.battery({percent:50,status:'Not charging'},null);
|
||
assert.equal(f.element('battState').textContent,'Not charging');
|
||
context.battery({percent:50,status:'Charging',watts:2,tempC:0},null);
|
||
assert.match(f.element('battState').textContent,/Charging/);
|
||
assert.match(f.element('battDetail').textContent,/battery 0°C/);
|
||
context.battery(null,null);
|
||
assert.equal(f.element('battState').textContent,'Battery unknown');
|
||
});
|
||
test('new health readings clear after a failed status read', () => {
|
||
const f=mounted();
|
||
f.ui.status({...sample(),battery:{...sample().battery,cycleCount:42,capacityHealthPercent:92,
|
||
fullMah:4600,designMah:5000,source:'/sys/class/power_supply/max1720x_bat_7-36'}});
|
||
assert.equal(f.element('metric-capacityHealth').textContent,'92%');
|
||
f.ui.unavailable(Error('Read failed'));
|
||
for (const key of ['cycles','fullCapacity','designCapacity','capacityHealth','batterySource'])
|
||
assert.equal(f.element('metric-'+key).textContent,'Unavailable');
|
||
f.ui.destroy();
|
||
});
|
||
test('audio controller validates before any API call and rejects offline or expired readings', async () => {
|
||
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[];
|
||
const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context});
|
||
for (const change of [{level:-.1},{level:1.1},{level:NaN},{level:Infinity},{level:'0.5'},{level:null},{muted:'false'},{unknown:true},{}]) {
|
||
await assert.rejects(controller.audio(change),/valid mute state/);
|
||
}
|
||
context.online=false;await assert.rejects(controller.audio({level:.5}),/Connect and refresh/);
|
||
context.online=true;context.fresh=false;await assert.rejects(controller.audio({muted:true}),/Connect and refresh/);
|
||
assert.equal(calls.length,0);
|
||
});
|
||
test('audio uses the captured device, prevents duplicate writes, and ignores switched-headset success', async () => {
|
||
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},complete,calls=[];
|
||
const controller=cc.createController({api:(...args)=>{calls.push(args);return new Promise(r=>complete=r);},getContext:()=>context});
|
||
const pending=controller.audio({level:.7});
|
||
await assert.rejects(controller.audio({muted:true}),/still in progress/);
|
||
assert.deepEqual(calls,[['/api/volume',{level:.7},'frame-a']]);
|
||
context={...context,device:'frame-b',generation:2};complete({message:'Done'});
|
||
assert.equal(await pending,null);assert.equal(controller.busy,false);
|
||
});
|
||
test('controller surfaces real API errors and allows a deliberate retry', async () => {
|
||
let calls=0;
|
||
const controller=cc.createController({api:async()=>{if(++calls===1)throw Error('SSH connection lost');return {message:'Done'};},
|
||
getContext:()=>({online:true,fresh:true,sample:sample(),device:'frame-a',generation:1})});
|
||
await assert.rejects(controller.audio({muted:true}),/SSH connection lost/);
|
||
assert.equal(controller.busy,false);
|
||
assert.deepEqual(await controller.audio({muted:true}),{message:'Done'});
|
||
});
|
||
test('mounted controls only write on Apply or Mute, show readback, and reset on switch', async t => {
|
||
const f=mounted();t.after(()=>f.ui.destroy());
|
||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
f.ui.status(sample());
|
||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||
assert.equal(f.element('ccVolume').value,40);
|
||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||
assert.equal(f.calls.length,0);
|
||
assert.equal(f.element('ccVolumeValue').textContent,'65%');
|
||
f.ui.status(sample()); // a poll must not overwrite an unapplied value
|
||
assert.equal(f.element('ccVolume').value,'65');
|
||
await f.element('ccApplyVolume').onclick();
|
||
assert.deepEqual(f.calls,[['/api/volume',{level:.65},'frame-a']]);
|
||
assert.equal(f.refreshes,1);
|
||
await f.element('ccMute').onclick();
|
||
assert.deepEqual(f.calls[1],['/api/volume',{muted:true},'frame-a']);
|
||
f.context={online:null,generation:2,device:'frame-b'};f.ui.reset();
|
||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||
});
|
||
test('offline/errors clear live readings, disable audio and keep companion navigation usable', async t => {
|
||
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||
f.context.online=false;f.ui.connection(false);
|
||
assert.equal(f.element('metric-fps').textContent,'Unavailable');
|
||
assert.equal(f.element('ccVolume').disabled,true);
|
||
await f.element('ccMute').onclick();
|
||
assert.equal(f.calls.length,0);
|
||
assert.match(f.element('ccAudioResult').textContent,/Connect and refresh/);
|
||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='privacy').hidden,false);
|
||
f.ui.unavailable(Error('Local server is unavailable'));
|
||
assert.match(f.element('ccSampleState').textContent,/Local server is unavailable/);
|
||
f.context.online=true;f.ui.status(sample());
|
||
assert.equal(f.element('ccVolume').disabled,false);
|
||
});
|
||
test('mounted audio failure is visible and never reports success', async t => {
|
||
const f=mounted({api:async()=>{throw Error('Permission denied');}});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||
await f.element('ccApplyVolume').onclick();
|
||
assert.match(f.element('ccAudioResult').textContent,/Couldn’t apply audio: Permission denied/);
|
||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||
assert.equal(f.refreshes,0);
|
||
});
|
||
test('audio confirmation depends on fresh readback rather than command acknowledgment', async t => {
|
||
let expected=.65, mode='confirmed';
|
||
const f=mounted({refresh:async()=>{
|
||
if(mode==='offline')return f.ui.unavailable(Error('Headset unreachable'));
|
||
const reading=sample();reading.volume.level=mode==='different' ? .64 : expected;f.ui.status(reading);
|
||
}});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||
f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
|
||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
|
||
mode='different';f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
|
||
assert.match(f.element('ccAudioResult').textContent,/reported output differs: 64%/);
|
||
mode='offline';await f.element('ccApplyVolume').onclick();
|
||
assert.match(f.element('ccAudioResult').textContent,/command sent; current output could not be confirmed/);
|
||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
});
|
||
test('pending audio never overwrites the new headset with old success or error', async t => {
|
||
let complete;
|
||
const f=mounted({api:()=>new Promise(resolve=>{complete=resolve;})});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||
const pending=f.element('ccMute').onclick();
|
||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
|
||
const newSample=sample();newSample.hostname='Second Frame';newSample.volume.muted=true;f.ui.status(newSample);
|
||
complete({message:'Old headset muted'});await pending;
|
||
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
|
||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||
assert.equal(f.refreshes,0);
|
||
assert.equal(f.element('ccMute').textContent,'Unmute');
|
||
f.ui.unavailable({stale:true,message:'Switched headsets'});
|
||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||
assert.equal(f.element('ccMute').disabled,false);
|
||
});
|
||
test('pending old-headset volume cannot become a draft on the new headset', async t => {
|
||
for(const failure of [false,true]) {
|
||
const request=deferred(),f=mounted({api:()=>request.promise});t.after(()=>f.ui.destroy());
|
||
f.ui.status(sample());f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||
const pending=f.element('ccApplyVolume').onclick();
|
||
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
|
||
const next=sample();next.volume.level=.2;f.ui.status(next);
|
||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
if(failure)request.reject(Error('Old headset failed'));else request.resolve({message:'Done'});
|
||
await pending;
|
||
assert.equal(f.element('ccVolume').value,20);
|
||
assert.equal(f.element('ccVolumeValue').textContent,'20%');
|
||
assert.equal(f.element('ccAudioReadback').textContent,'Reported 20% · unmuted');
|
||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||
}
|
||
});
|
||
test('production host response works before and after mounting with the actual bridge shape', async t => {
|
||
for(const early of [true,false]) {
|
||
const f=sharedPage();let ui;
|
||
if(!early){ui=f.mount();t.after(()=>ui.destroy());assert.equal(f.element('macLink').hidden,true);}
|
||
f.host.resolve({os:'macOS',mobile:false});await flush();
|
||
if(early){ui=f.mount();t.after(()=>ui.destroy());}
|
||
assert.equal(f.context.watched,true);
|
||
assert.equal(f.element('macLink').hidden,false);
|
||
f.element('ccSearch').value='mac';f.element('ccSearch').input();
|
||
assert.equal(f.element('macLink').hidden,false);
|
||
ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||
}
|
||
});
|
||
test('production shared refresh drains a pre-command request then samples after acknowledgment', async t => {
|
||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
|
||
const prior=f.refresh();assert.equal(f.requests.length,1);
|
||
assert.equal(f.refresh(),prior); // the ordinary poll/manual deduplication still holds
|
||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||
const command=f.element('ccApplyVolume').onclick();await flush();
|
||
assert.equal(f.writes.length,1);assert.equal(f.requests.length,1);
|
||
f.requests[0].resolve(sample());await prior;await flush();
|
||
assert.equal(f.requests.length,2,'readback must issue a real post-command status request');
|
||
assert.doesNotMatch(f.element('ccAudioResult').textContent,/Confirmed|reported output differs/);
|
||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
const after=sample();after.volume.level=.65;f.requests[1].resolve(after);await command;
|
||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
|
||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||
});
|
||
test('production audio readback survives an old poll failure and treats its own failed read as unconfirmed', async t => {
|
||
for(const failedReadback of [false,true]) {
|
||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
|
||
const prior=f.refresh();f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||
const command=f.element('ccApplyVolume').onclick();await flush();
|
||
f.requests[0].reject(Error('Pre-command poll failed'));await prior;await flush();
|
||
assert.equal(f.requests.length,2);
|
||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
if(failedReadback)f.requests[1].reject(Error('Readback unavailable'));
|
||
else {const after=sample();after.volume.level=.64;f.requests[1].resolve(after);}
|
||
await command;
|
||
assert.match(f.element('ccAudioResult').textContent,failedReadback ? /command sent; current output could not be confirmed/ : /reported output differs: 64%/);
|
||
assert.equal(f.element('ccApplyVolume').disabled,failedReadback);
|
||
}
|
||
});
|
||
test('production Flatpak redraw cannot revive a failed status; only a fresh successful poll recovers', async t => {
|
||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());
|
||
const first=f.refresh(),reading=sample();f.requests[0].resolve(reading);await first;
|
||
const receipt=vm.runInContext('stateReceivedAt',f.context);
|
||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||
const failed=f.refresh();f.requests[1].reject(Error('Synthetic status parsing error'));await failed;
|
||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
f.installFlatpak(); // actual production render(state), with job held pending
|
||
assert.equal(f.requests.length,2,'redraw must not start a replacement reading');
|
||
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
|
||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||
assert.match(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
|
||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||
const fresh=f.refresh();
|
||
assert.equal(f.element('ccApplyVolume').disabled,true,'starting a poll does not clear failure');
|
||
const next=sample();next.battery.percent=98;next.volume.level=.2;
|
||
f.requests[2].resolve(next);await fresh;
|
||
assert.equal(f.element('metric-battery').textContent,'98%');
|
||
assert.equal(f.element('ccVolume').value,20);
|
||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
|
||
});
|
||
test('delayed production bootstrap retains failed-read state and fresh-poll recovery', async t => {
|
||
for(const priorSuccess of [true,false]) {
|
||
const f=sharedPage();
|
||
if(priorSuccess){const first=f.refresh();f.requests[0].resolve(sample());await first;}
|
||
const failed=f.refresh();f.requests.at(-1).reject(Error('Status failed before mount'));await failed;
|
||
const calls=f.requests.length,ui=f.mount();t.after(()=>ui.destroy());
|
||
assert.equal(f.requests.length,calls);
|
||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||
assert.match(f.element('ccSampleState').textContent,/Status failed before mount/);
|
||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||
const fresh=f.refresh();assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
f.requests.at(-1).resolve(sample());await fresh;
|
||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Status failed before mount/);
|
||
}
|
||
});
|
||
test('same-object replay preserves invalidation and receipt age after a failed reading', t => {
|
||
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();
|
||
f.ui.status(reading);f.ui.unavailable(Error('Status failure'));
|
||
f.ui.status(reading,Date.now()+1000);
|
||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
assert.match(f.element('ccSampleState').textContent,/Status failure/);
|
||
f.ui.status(sample());assert.equal(f.element('ccApplyVolume').disabled,false);
|
||
});
|
||
test('delayed production bootstrap preserves client receipt age despite headset clock skew', async t => {
|
||
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||
const f=sharedPage(),prior=f.refresh();const reading=sample();reading.time-=3600;
|
||
f.requests[0].resolve(reading);await prior;
|
||
now+=91000;const ui=f.mount();t.after(()=>ui.destroy());
|
||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
assert.match(f.element('ccSampleState').textContent,/expired/);
|
||
const fresh=f.refresh();f.requests[1].resolve(sample());await fresh;
|
||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||
});
|
||
test('sample replay cannot renew receipt age; fresh receipts tolerate missing or skewed headset time', t => {
|
||
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();f.ui.status(reading);
|
||
now+=91000;f.ui.status(reading);
|
||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||
for(const time of [undefined,null,'invalid',NaN,Infinity,now/1000-3600,now/1000+3600]) {
|
||
f.ui.status({...sample(),time});
|
||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Invalid Date/);
|
||
}
|
||
for(const receipt of [null,NaN,Infinity,now+1000,now-91000]) {
|
||
f.ui.status(sample(),receipt);assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
}
|
||
});
|
||
test('mute-only readback preserves an unapplied volume until Apply sends it', async t => {
|
||
let reading=sample();const f=mounted({api:async(path,change,device)=>{
|
||
f.calls.push([path,change,device]);reading={...reading,volume:{...reading.volume,...change}};return {message:'Done'};
|
||
},refresh:async()=>f.ui.status({...reading})});t.after(()=>f.ui.destroy());f.ui.status(reading);
|
||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||
await f.element('ccMute').onclick();
|
||
assert.deepEqual(f.calls[0],['/api/volume',{muted:true},'frame-a']);
|
||
assert.equal(f.element('ccVolume').value,'65');assert.equal(f.element('ccVolumeValue').textContent,'65%');
|
||
assert.match(f.element('ccAudioReadback').textContent,/Reported 40% · muted/);
|
||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 40%, muted/);
|
||
await f.element('ccApplyVolume').onclick();
|
||
assert.deepEqual(f.calls[1],['/api/volume',{level:.65},'frame-a']);
|
||
assert.equal(f.element('ccVolume').value,65);
|
||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, muted/);
|
||
});
|
||
test('live samples expire even when the status poll is paused', t => {
|
||
const original=Date.now;let now=100000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||
now+=91000;
|
||
// A connection update must also re-check freshness before enabling a control.
|
||
f.ui.connection(true);
|
||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||
});
|
||
test('mounted filters show empty state, counts, reset and host-dependent features', t => {
|
||
const f=mounted();t.after(()=>f.ui.destroy());
|
||
f.element('ccSearch').value='dpi';f.element('ccSearch').input();
|
||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='display').hidden,false);
|
||
assert.equal(f.element('ccResultCount').textContent,'1 section');
|
||
f.element('ccSearch').value='nothing-matches-this';f.element('ccSearch').input();
|
||
assert.equal(f.element('ccEmpty').hidden,false);
|
||
f.element('ccResetFilter').onclick();assert.equal(f.element('ccEmpty').hidden,true);
|
||
f.ui.host({os:'Windows'});assert.equal(f.element('macLink').hidden,true);
|
||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='mac').hidden,false);
|
||
f.ui.host({os:'macOS'});assert.equal(f.element('macLink').hidden,false);
|
||
f.ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||
f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
|
||
});
|
||
test('every Control Center workflow reaches its intentional workspace', () => {
|
||
const {workspaceFixture}=require('./workspace_fixture.cjs');
|
||
const f=workspaceFixture();
|
||
const expected={view:'capture',shots:'capture',vrTools:'tools',comfort:'tools',pad:'tools',library:'games',getgames:'games',sideloaded:'games',android:'apps',display:'settings',androidLibrary:'apps',appStore:'apps',power:'settings',transfer:'tools',media:'tools',linuxApps:'apps',artworkSettings:'settings',privacy:'settings',panels:'tools',macview:'tools',devices:'devices'};
|
||
for(const item of cc.catalog) for(const [href] of item.links||[]) {
|
||
if(!href.startsWith('#')||href==='#connection')continue;
|
||
f.navigate(href);assert.equal(f.activePage,expected[href.slice(1)],href);
|
||
}
|
||
assert.equal(f.run('PAGES.length'),4);
|
||
});
|
||
// Force quit: an explicit check, a confirmation naming the game, and the captured headset.
|
||
function quitPage({confirmAnswer=true, running=[{appid:620,name:'Portal <2>',kind:'steam'}], quit} = {}) {
|
||
const asked=[], calls=[];
|
||
const f=mounted({confirm:text=>{asked.push(text);return confirmAnswer;},
|
||
api:async(path,body,device)=>{calls.push({path,body,device});
|
||
if(body===undefined)return {running};
|
||
return quit ? quit(body) : {message:'Quit Portal 2',method:'steam'};}});
|
||
return Object.assign(f,{asked,apiCalls:calls,click:appid=>f.element('ccRunningList').onclick({target:{closest:()=>({dataset:{ccQuit:String(appid)}})}})});
|
||
}
|
||
test('force quit lists what is running and says so when nothing is', async t => {
|
||
const f=quitPage({running:[]});t.after(()=>f.ui.destroy());
|
||
await f.element('ccRunningCheck').onclick();
|
||
assert.deepEqual(f.apiCalls,[{path:'/api/running',body:undefined,device:'frame-a'}]);
|
||
assert.equal(f.element('ccRunningResult').textContent,'Nothing is running on the Frame.');
|
||
assert.equal(f.element('ccRunningList').innerHTML,'');
|
||
const g=quitPage();t.after(()=>g.ui.destroy());
|
||
await g.element('ccRunningCheck').onclick();
|
||
assert.match(g.element('ccRunningList').innerHTML,/Portal <2><small>Steam game<\/small>.*data-cc-quit="620"/);
|
||
assert.match(g.element('ccRunningResult').textContent,/^1 running/);
|
||
});
|
||
test('force quit confirms with the unsaved-progress warning before any request', async t => {
|
||
const f=quitPage({confirmAnswer:false});t.after(()=>f.ui.destroy());
|
||
await f.element('ccRunningCheck').onclick();
|
||
f.click(620);await flush();
|
||
assert.deepEqual(f.asked,['Force quit Portal <2>? Unsaved progress will be lost.']);
|
||
assert.equal(f.apiCalls.length,1,'declining sends nothing');
|
||
const g=quitPage();t.after(()=>g.ui.destroy());
|
||
await g.element('ccRunningCheck').onclick();
|
||
g.click(620);await flush();
|
||
assert.deepEqual(g.apiCalls[1],{path:'/api/running',body:{action:'quit',appid:'620'},device:'frame-a'});
|
||
assert.equal(g.element('ccRunningResult').textContent,'Quit Portal 2');
|
||
assert.equal(g.element('ccRunningList').innerHTML,'');
|
||
g.click(999);await flush();
|
||
assert.equal(g.apiCalls.length,2,'an id that was not listed is ignored');
|
||
});
|
||
test('force quit shows errors, refuses duplicates and offline use, and ignores a switched headset', async t => {
|
||
const pending=deferred();
|
||
const f=quitPage({quit:()=>pending.promise});t.after(()=>f.ui.destroy());
|
||
await f.element('ccRunningCheck').onclick();
|
||
f.click(620);f.click(620);await flush();
|
||
assert.equal(f.asked.length,1);
|
||
assert.match(f.element('ccRunningList').innerHTML,/disabled/);
|
||
pending.reject(Error("That game isn't running any more"));await flush();
|
||
assert.equal(f.element('ccRunningResult').textContent,"Couldn’t force quit Portal <2>: That game isn't running any more");
|
||
assert.doesNotMatch(f.element('ccRunningList').innerHTML,/disabled/);
|
||
// A switch while quitting: the old answer is dropped and the list cleared.
|
||
const late=deferred(),g=quitPage({quit:()=>late.promise});t.after(()=>g.ui.destroy());
|
||
await g.element('ccRunningCheck').onclick();g.click(620);await flush();
|
||
g.context={online:true,generation:2,device:'frame-b'};g.ui.reset();
|
||
late.resolve({message:'Quit Portal 2'});await flush();
|
||
assert.equal(g.element('ccRunningResult').textContent,'');
|
||
assert.equal(g.element('ccRunningList').innerHTML,'');
|
||
const h=quitPage();t.after(()=>h.ui.destroy());h.context={online:false,generation:1,device:'frame-a'};
|
||
await h.element('ccRunningCheck').onclick();
|
||
assert.equal(h.apiCalls.length,0);
|
||
assert.match(h.element('ccRunningResult').textContent,/Connect to the Frame/);
|
||
const controller=cc.createController({api:async()=>assert.fail('no request'),getContext:()=>({online:true,generation:1})});
|
||
await assert.rejects(controller.forceQuit({appid:'620; reboot'}),/Choose a running game/);
|
||
});
|
||
test('the Control Center finds force quit by what a stuck game looks like', () => {
|
||
const item=cc.catalog.find(i=>i.id==='forcequit');
|
||
for(const q of ['frozen game','hung','not responding','kill','stuck app'])assert.ok(cc.matches(item,q,'all'),q);
|
||
assert.ok(cc.matches(item,'','apps'));
|
||
});
|