Files
argo/tests/control_ui.test.cjs

301 lines
20 KiB
JavaScript

// Deterministic DOM/pointer harness. No browser, vehicle, or serial port involved.
const {test}=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs'),vm=require('node:vm'),path=require('node:path');
const {present}=require('../web/control-state.js');
const armed={connected:true,armed_known:true,armed:true,app_state:'armed',servos:{}};
test('model viewport fills the available space above battery status',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.rov-stage \{ flex:1; height:auto; min-height:0; width:100%/);
assert.match(css,/\.telemetry-zone \.model-card \{ flex: 1; min-height: 0;/);
});
test('white wash stays behind controls; right panel is gray and edges reach 50 percent',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.workspace-light \{[^}]*z-index:-1/);
assert.match(css,/\.controls-workspace \{[^}]*isolation:isolate/);
assert.match(css,/\.controls-workspace \{[^}]*background:#ededed/);
assert.match(css,/\.sidebar-deck \{[^}]*background:linear-gradient\(160deg,#333,#242424\)/);
assert.match(css,/\.cursor-light::after \{[^}]*rgba\(255,255,255,\.50\)/);
});
test('keyboard easing publishes intermediate demands to the real Python bridge',async()=>{
const h=harness();h.state();h.run('bridgeReady=true');
keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');
for(let i=0;i<10;i++){h.advance(50);await Promise.resolve();await Promise.resolve();await Promise.resolve();}
const sent=h.calls.filter(call=>call[0]==='axes');
assert.ok(sent.some(call=>call[1]>0&&call[1]<1),'surge ramp is transmitted, not just drawn');
assert.ok(sent.some(call=>call[3]>0&&call[3]<1),'vertical ramp is transmitted, not just drawn');
assert.ok(sent.every(call=>call[1]>=0&&call[1]<=1&&call[3]>=0&&call[3]<=1));
});
test('cursor lighting stays decorative and arrows use centered geometry',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
assert.doesNotMatch(js,/set_axes|setJoyControl|setHeave|requestControl/);
assert.match(js,/workspace\.append\(wash\)/);
assert.match(js,/pointerleave',clear/);
assert.match(css,/\.cursor-light \{[^}]*pointer-events:none/);
assert.match(css,/range-pill button \{[^}]*place-items:center/);
assert.match(css,/#engage\.danger \.engage-label \{ color:#777/);
});
test('edge lighting uses angle; only visible pills get blue and sidebar is excluded',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const js=fs.readFileSync(path.join(__dirname,'../web/cursor-light.js'),'utf8');
assert.match(js,/Math\.atan2/);
assert.match(js,/!host\.closest\('\.sidebar-deck,#engage'\)/);
assert.match(js,/host\.matches\(pillSelector\)/);
assert.match(css,/\.cursor-light\.pill-light::before \{ background:radial-gradient/);
assert.match(css,/\.cursor-light::after \{[^}]*linear-gradient\(var\(--light-angle/);
assert.doesNotMatch(css,/\.cursor-light::after \{[^}]*radial-gradient/);
assert.match(css,/\.rov-stage \{[^}]*border-radius:31px; overflow:hidden/);
});
test('all interface typography uses Helvetica with inherited control fonts',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
const families=[...css.matchAll(/font-family\s*:\s*([^;}]+)/g)].map(match=>match[1].trim());
assert.ok(families.includes('"Helvetica Neue", Helvetica, Arial, sans-serif'));
assert.ok(families.every(family=>family==='inherit'||family==='"Helvetica Neue", Helvetica, Arial, sans-serif'));
assert.match(css,/button, select, input \{ font: inherit; \}/);
});
function keyEvent(h,type,key,repeat=false){h.fire(type,{key,repeat,target:{tagName:'BODY'},preventDefault(){}})}
test('keyboard ramps joystick and vertical together, then eases back on release',()=>{
const h=harness();h.state();
keyEvent(h,'keydown','w');keyEvent(h,'keydown','d');keyEvent(h,'keydown','r');
assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
h.advance(180);
for(const axis of ['forward','turn','vertical'])assert.ok(h.run(`control.${axis}`)>0&&h.run(`control.${axis}`)<1);
keyEvent(h,'keydown','w',true);h.advance(240);
assert.ok(Math.abs(h.run('control.forward')-Math.SQRT1_2)<1e-6);
assert.ok(Math.abs(h.run('control.turn')-Math.SQRT1_2)<1e-6);
assert.equal(h.run('control.vertical'),1);
keyEvent(h,'keyup','w');keyEvent(h,'keyup','d');keyEvent(h,'keyup','r');
h.advance(180);assert.ok(h.run('control.forward')>0);assert.ok(h.run('control.vertical')>0);
h.advance(250);assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
});
test('keyboard reversal is continuous and focus loss cancels its ramp',()=>{
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(420);
keyEvent(h,'keyup','w');keyEvent(h,'keyup','r');keyEvent(h,'keydown','s');keyEvent(h,'keydown','f');
assert.equal(h.run('control.forward'),1);assert.equal(h.run('control.vertical'),1);
h.advance(200);assert.ok(Math.abs(h.run('control.forward'))<.3);assert.ok(Math.abs(h.run('control.vertical'))<.3);
h.advance(220);assert.equal(h.run('control.forward'),-1);assert.equal(h.run('control.vertical'),-1);
keyEvent(h,'keyup','s');keyEvent(h,'keyup','f');h.fire('blur');h.advance(500);
assert.equal(h.run('control.forward+control.vertical'),0);
});
test('touch takes ownership from a keyboard ramp without later keyboard overwrite',()=>{
const h=harness();h.state();keyEvent(h,'keydown','w');keyEvent(h,'keydown','r');h.advance(100);
h.el('#joystick-canvas').emit('pointerdown',{clientX:300,clientY:160});
h.el('#heave').emit('pointerdown',{pointerId:2,clientY:300});
keyEvent(h,'keydown','a');h.advance(450);
assert.equal(h.run('control.turn'),1);assert.equal(Math.abs(h.run('control.forward')),0);assert.equal(h.run('control.vertical'),-1);
});
test('hold fill and pending engagement share the same green and glow',()=>{
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/\.engage-progress-label \{ color:var\(--engage-green\).*filter:var\(--engage-glow\)/);
assert.match(css,/#engage\.pending \.engage-label \{ color:var\(--engage-green\); filter:var\(--engage-glow\)/);
});
test('native pywebview result wrapper can execute alongside app globals',()=>{
const h=harness();
// Window.evaluate_js declares these variables in the page's global scope.
// A global const/let collision prevents every Python reply from executing.
assert.doesNotThrow(()=>h.run(`var value; try { value = ['COM3','COM4']; }
catch(e) { value = {name:e.name}; var keys = Object.getOwnPropertyNames(e);
keys.forEach(function(key) { value[key] = e[key]; }); }
populatePorts(value);`));
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
});
test('an empty pywebview API cannot permanently latch startup as completed',async()=>{
const h=harness();h.context.pywebview.api={};await h.run('boot()');
assert.equal(h.run('booted'),false);assert.equal(h.run('bridgeReady'),false);
});
test('desktop startup retries after failure and populates detected COM ports',async()=>{
const h=harness();let attempts=0;
h.run('loadDefaultModel=()=>Promise.resolve(false)');
h.context.pywebview.api.refresh_ports=()=>Promise.resolve([{device:'COM3',label:'COM3'},{device:'COM4',label:'COM4'}]);
h.context.pywebview.api.initial_state=()=>++attempts===1?Promise.reject(Error('Temporary startup failure')):Promise.resolve({ports:['COM3','COM4'],battery_config:{},state:{connected:false,armed:null,servos:{}}});
h.context.pywebview.api.poll=()=>Promise.resolve({connected:false,armed:null,servos:{}});
await h.run('boot()');assert.equal(h.run('booted'),false);
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'),'COM discovery survives a configuration startup failure');
await h.run('boot()');assert.equal(h.run('booted'),true);
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
assert.ok(h.el('#port').options.some(p=>p.value==='COM4'));
});
test('visual status changes cannot resize the header or percentage label',()=>{
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.doesNotMatch(html,/class="water-ring/);
assert.match(html,/class="engage-sheen-label"/);
assert.match(css,/padding-bottom:\.22em; margin-bottom:-\.22em/);
assert.match(css,/grid-template-rows:120px minmax\(0,1fr\) 52px/);
assert.match(css,/\.control-status \{ position:absolute/);
assert.match(css,/flex:0 0 128px; width:128px/);
assert.match(css,/\.vertical-card footer \{ justify-content:center/);
assert.match(css,/mask-image:linear-gradient\(to right,#000 calc\(var\(--engage-progress\) - 8%\),transparent var\(--engage-progress\)\)/);
});
test('activity renders oldest at top, latest above its footer; both control states sweep',()=>{
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
assert.ok(html.indexOf('id="activity-oldest"')<html.indexOf('id="activity-previous"'));
assert.ok(html.indexOf('id="activity-previous"')<html.indexOf('id="status-text"'));
assert.ok(html.indexOf('id="status-text"')<html.indexOf('class="activity-heading"'));
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/#engage:is\(\.arm,\.danger\).*animation:engage-sweep/);
});
test('native drag surface has no interactive descendants; output limit is outside sidebar',()=>{
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
assert.match(html,/<aside class="sidebar-deck"/);
assert.match(html,/<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"><\/div>/);
assert.ok(html.indexOf('id="gain"')>html.indexOf('</aside>'));
});
function harness(){
let now=100,id=0;const timers=new Map(),elements=new Map(),listeners=new Map();
class Element{
constructor(name){this.name=name;this.dataset={};this.style={setProperty(k,v){this[k]=v}};this.classes=new Set();this.classList={add:(...n)=>n.forEach(x=>this.classes.add(x)),remove:(...n)=>n.forEach(x=>this.classes.delete(x)),contains:n=>this.classes.has(n),toggle:(n,b)=>b?this.classes.add(n):this.classes.delete(n)};this.handlers={};this.captured=new Set();this.value='demo';this.options=[];this.tagName='DIV';this.children=[];this.textContent='';}
addEventListener(n,fn){(this.handlers[n]??=[]).push(fn)}
removeEventListener(){}
emit(n,fields={}){const e={pointerId:1,button:0,pointerType:'touch',clientX:160,clientY:160,preventDefault(){},target:this,...fields};for(const fn of this.handlers[n]||[])fn(e);return e}
getBoundingClientRect(){return this.name==='#heave'?{left:0,top:0,width:48,height:300,right:48,bottom:300}:this.name==='#engage'?{left:0,top:0,width:180,height:44,right:180,bottom:44}:{left:0,top:0,width:320,height:320,right:320,bottom:320}}
setPointerCapture(p){this.captured.add(p)}
releasePointerCapture(p){if(this.captured.delete(p))this.emit('lostpointercapture',{pointerId:p})}
getContext(){return new Proxy({}, {get:()=>()=>{}})}
querySelector(s){return el(this.name+' '+s)}
setAttribute(k,v){this[k]=v}
append(n){this.children.push(n)}
add(n){this.options.push(n)}
closest(){return null}
click(){if(!this.disabled)this.onclick?.({target:this,currentTarget:this})}
}
function el(n){if(!elements.has(n))elements.set(n,new Element(n));return elements.get(n)}
const document={querySelector:el,querySelectorAll:()=>[],getElementById:n=>el('#'+n),createElement:n=>new Element(n),body:el('body'),readyState:'loading',hidden:false,hasFocus:()=>true,addEventListener(n,f){listeners.set('doc:'+n,f)}};
const schedule=(fn,ms,interval=0)=>{timers.set(++id,{fn,at:now+ms,interval});return id};
const calls=[];
const context=vm.createContext({console,document,devicePixelRatio:1,performance:{now:()=>now},setTimeout:(f,ms=0)=>schedule(f,ms),clearTimeout:i=>timers.delete(i),setInterval:(f,ms)=>schedule(f,ms,ms),requestAnimationFrame:f=>schedule(()=>f(now),16),cancelAnimationFrame:i=>timers.delete(i),addEventListener(n,f){listeners.set(n,f)},Option:function(label,value){this.label=label;this.value=value},fetch:()=>Promise.reject(Error('No network in unit tests'))});
context.window=context;
context.pywebview={api:{set_axes:(...a)=>{calls.push(['axes',...a]);return Promise.resolve()},stop_controls:(...a)=>{calls.push(['stop',...a]);return Promise.resolve()},command:(name)=>{calls.push(['command',name]);return Promise.resolve(true)}}};
for(const f of ['control-state.js','joystick-3d.js','app.js'])vm.runInContext(fs.readFileSync(path.join(__dirname,'../web',f),'utf8'),context,{filename:f});
function run(s){return vm.runInContext(s,context)}
function advance(ms){const end=now+ms;while(true){let next=[...timers].filter(([,t])=>t.at<=end).sort((a,b)=>a[1].at-b[1].at)[0];if(!next)break;const [i,t]=next;now=t.at;timers.delete(i);if(t.interval)timers.set(i,{...t,at:now+t.interval});t.fn()}now=end;}
function state(s=armed){context.snapshot=s;run('render(snapshot)')}
return {el,run,advance,state,calls,context,fire:(name,e={})=>listeners.get(name)?.(e)};
}
test('pending/timeout/unknown presentation never invents armed state',()=>{
let p=present({...armed,pending_command:'disarm'});
assert.equal(p.label,'DISARMING…');assert.equal(p.drive,false);
p=present({...armed,command_error:'Disarm confirmation timed out.'});
assert.equal(p.label,'DISARM');assert.match(p.message,/Vehicle armed/);
p=present({...armed,armed:false,pending_command:'arm'});
assert.equal(p.label,'ENGAGING…');assert.equal(p.drive,false);
p=present({...armed,armed:false,command_error:'Engage failed'});
assert.equal(p.label,'ENGAGE');assert.equal(p.action,'arm');
p=present({...armed,connected:false,armed_known:false});
assert.equal(p.label,'STATE UNKNOWN');assert.equal(p.drive,false);
});
test('activity history keeps three unique recent events, latest first',()=>{
const h=harness();h.run("recordActivity('One');recordActivity('Two');recordActivity('Three');recordActivity('Three');recordActivity('Four')");
assert.equal(h.el('#status-text').textContent,'Four');assert.equal(h.el('#activity-previous').textContent,'Three');assert.equal(h.el('#activity-oldest').textContent,'Two');
});
test('output limit drag is bounded, owns one pointer and does not recenter',()=>{
const h=harness(),slider=h.el('#gain');slider.min='5';slider.max='50';slider.value='20';
slider.emit('pointerdown',{pointerId:1,clientX:9});assert.equal(h.el('#gain-value').textContent,'5%');
slider.emit('pointermove',{pointerId:2,clientX:311});assert.equal(h.el('#gain-value').textContent,'5%');
slider.emit('pointermove',{pointerId:1,clientX:311});assert.equal(h.el('#gain-value').textContent,'50%');
slider.emit('pointerup');h.advance(500);assert.equal(h.el('#gain-value').textContent,'50%');
});
test('maximum travel, diagonal and smooth touch return',()=>{
const h=harness();h.state();const c=h.el('#joystick-canvas');
c.emit('pointerdown',{clientX:1000,clientY:160});
assert.equal(h.context.joystick3D.getState().x,1);
c.emit('pointermove',{clientX:1000,clientY:1000});
const s=h.context.joystick3D.getState();assert.ok(Math.abs(s.x-Math.SQRT1_2)<1e-6);assert.ok(Math.abs(s.y-Math.SQRT1_2)<1e-6);
c.emit('pointerup');h.advance(180);assert.ok(h.context.joystick3D.getState().x>0);h.advance(220);assert.equal(h.context.joystick3D.getState().x,0);
});
test('independent multi-touch: second joystick finger ignored; heave continues on joystick release',()=>{
const h=harness();h.state();const c=h.el('#joystick-canvas'),v=h.el('#heave');
c.emit('pointerdown',{pointerId:1,clientX:250});
v.emit('pointerdown',{pointerId:2,clientY:0});
const x=h.context.joystick3D.getState().x;
c.emit('pointerdown',{pointerId:3,clientX:0});c.emit('pointerup',{pointerId:3});
assert.equal(h.context.joystick3D.getState().x,x);
c.emit('pointerup',{pointerId:1});h.advance(400);
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,1);
v.emit('pointercancel',{pointerId:2});h.advance(450);assert.equal(h.context.verticalControl.getState().value,0);
});
test('mouse leaving joystick returns to center',()=>{
const h=harness();h.state();const c=h.el('#joystick-canvas');
c.emit('pointerdown',{pointerType:'mouse',clientX:300});c.emit('pointerleave',{pointerType:'mouse'});h.advance(400);
assert.equal(h.context.joystick3D.getState().x,0);
});
test('focus loss clears both captured pointers and prevents replay',()=>{
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.el('#heave').emit('pointerdown',{pointerId:2,clientY:0});
h.fire('blur');h.advance(500);
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,0);
assert.equal(h.context.joystick3D.getState().active,false);assert.equal(h.context.verticalControl.getState().active,false);
assert.equal(h.run('view.drive'),false);h.fire('focus');assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
});
test('lost connection during commanded joystick clears command and shows unknown',()=>{
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
h.state({...armed,connected:false,armed_known:false,app_state:'disconnected'});
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
h.state();assert.equal(h.run('control.turn'),0);
});
test('half hold cancels on touch interruption and focus loss; extra fingers cannot complete it',()=>{
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});const b=h.el('#engage');
b.emit('pointerdown');h.advance(600);b.emit('pointerdown',{pointerId:2});b.emit('pointerup',{pointerId:2});
assert.equal(h.run('engagePointer'),1);b.emit('pointercancel');h.advance(1500);assert.equal(h.calls.length,0);
b.emit('pointerdown');h.advance(600);h.fire('blur');h.advance(1500);assert.equal(h.calls.length,0);
});
test('demo full hold waits for simulated confirmation; release cannot immediately disarm',()=>{
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
b.emit('pointerdown');h.advance(1450);assert.equal(h.el('#engage-label').textContent,'engaging…');assert.equal(h.run('demoState.armed'),false);
b.emit('pointerup');b.click();h.advance(700);assert.equal(h.el('#engage-label').textContent,'disengage');
b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');assert.equal(h.run('demoState.armed'),true);
h.advance(700);assert.equal(h.el('#engage-label').textContent,'engage');
});
test('real send result and accepted ACK cannot optimistically arm; timeout is visible',async()=>{
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});
h.run("requestControl('arm')");await Promise.resolve();assert.equal(h.el('#engage-label').textContent,'engaging…');
assert.equal(h.run('vehicleSnapshot.armed'),false);h.advance(6100);
assert.equal(h.el('#engage-label').textContent,'engage');assert.match(h.el('#control-status').textContent,/timed out/);
});
test('bridge freeze clears input even if armed snapshot has not changed',()=>{
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.run('markBridgeStale()');
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
assert.match(h.el('#control-status').textContent,/Telemetry unavailable/);
});
test('hung bridge poll has an independent watchdog and cannot trap the controls',async()=>{
const h=harness();h.state();h.context.pywebview.api.poll=()=>new Promise(()=>{});
h.run('bridgeReady=true;lastBridgeAt=performance.now();poll()');
h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
h.advance(1700);await Promise.resolve();
assert.equal(h.run('view.drive'),false);assert.equal(h.el('#engage-label').textContent,'state unknown');
assert.equal(h.context.joystick3D.getState().x,0);
});
test('holding through confirmation then releasing outside cannot swallow the next disarm tap',()=>{
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
b.emit('pointerdown');h.advance(2300);assert.equal(h.el('#engage-label').textContent,'disengage');assert.ok(b.captured.has(1));
b.emit('pointerup',{clientX:500});h.advance(1);b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');
});