V2
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
// 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('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…');
|
||||
});
|
||||
Reference in New Issue
Block a user