// 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"'){ const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8'); assert.match(html,/')); }); 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…'); });