diff --git a/SUB Control/tests/control_ui.test.cjs b/SUB Control/tests/control_ui.test.cjs index d2c2a77..62f07bf 100644 --- a/SUB Control/tests/control_ui.test.cjs +++ b/SUB Control/tests/control_ui.test.cjs @@ -5,6 +5,99 @@ 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. diff --git a/SUB Control/tests/test_vehicle_state.py b/SUB Control/tests/test_vehicle_state.py index 2c63620..152aa13 100644 --- a/SUB Control/tests/test_vehicle_state.py +++ b/SUB Control/tests/test_vehicle_state.py @@ -112,6 +112,15 @@ class WorkerWatchdogTests(unittest.TestCase): self.assertNeutral() self.assertEqual(self.worker.axes, Axes()) + def test_intermediate_demands_reach_mavlink_with_output_limit(self): + with patch("app.time.monotonic", return_value=10): + self.worker.last_control_at = 9.9 + self.worker.set_axes(Axes(forward=.25, vertical=.25), 1) + self.worker._send_control() + # 25% input at the default 20% limit: 5% command, not 100%. + self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.args, + (1, 50, 0, 525, 0, 0)) + def test_heartbeat_freeze_neutralizes_even_with_live_ui(self): with patch("app.time.monotonic", return_value=13.1): self.worker.set_axes(Axes(forward=1), 1) diff --git a/SUB Control/web/app.js b/SUB Control/web/app.js index 7029b9e..2d06b11 100644 --- a/SUB Control/web/app.js +++ b/SUB Control/web/app.js @@ -90,10 +90,10 @@ function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.m function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))} function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()} heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)}); -function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)} +function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv+(target-sv)*smooth);if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)} window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})}; -function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active){if(turn||forward)window.joystick3D?.setPos(turn,-forward);else window.joystick3D?.easeBack()}if(heavePointer===null){if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else easeHeave()}} +function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)} addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();}); function resetInputs(){ if(gainPointer!==null)releaseGain({pointerId:gainPointer}); diff --git a/SUB Control/web/cursor-light.js b/SUB Control/web/cursor-light.js new file mode 100644 index 0000000..5033c2c --- /dev/null +++ b/SUB Control/web/cursor-light.js @@ -0,0 +1,42 @@ +/* Decorative only: no control demands or vehicle state are accessed here. */ +(() => { + const workspace=document.querySelector('.controls-workspace'); + if(!workspace)return; + const wash=document.createElement('span'); + wash.className='workspace-light';wash.setAttribute('aria-hidden','true');workspace.append(wash); + // Explicit visual surfaces, not every semantic button. The entire sidebar + // and text-only Engage control are excluded from local lighting. + const pillSelector='.config-btn,.modal .button,.range-pill,.number-pill'; + const shapeSelector=`${pillSelector},.heave-track,.heave-knob,.joystick,.modal`; + const layers=[...document.querySelectorAll(shapeSelector)].filter(host=>!host.closest('.sidebar-deck,#engage')).map(host=>{ + host.classList.add('light-surface'); + const layer=document.createElement('span');layer.className='cursor-light'; + layer.setAttribute('aria-hidden','true'); + layer.classList.toggle('pill-light',host.matches(pillSelector)); + host.append(layer);return {host,layer}; + }); + let frame=0,point=null; + const clear=()=>{point=null;cancelAnimationFrame(frame);frame=0;wash.classList.remove('lit');layers.forEach(({layer})=>layer.classList.remove('lit','near'))}; + function paint(){ + frame=0;if(!point)return; + const {x,y}=point,r=workspace.getBoundingClientRect(); + wash.style.setProperty('--light-x',`${x-r.left}px`);wash.style.setProperty('--light-y',`${y-r.top}px`); + wash.classList.toggle('lit',x>=r.left&&x<=r.right&&y>=r.top&&y<=r.bottom); + for(const {host,layer} of layers){ + const b=host.getBoundingClientRect(); + layer.style.setProperty('--light-x',`${x-b.left}px`);layer.style.setProperty('--light-y',`${y-b.top}px`); + const inside=x>=b.left&&x<=b.right&&y>=b.top&&y<=b.bottom; + // CSS gradient angles start at up; atan2 is measured from the x axis. + const angle=Math.atan2(y-(b.top+b.height/2),x-(b.left+b.width/2))*180/Math.PI+90; + layer.style.setProperty('--light-angle',`${angle}deg`); + layer.classList.toggle('lit',inside);layer.classList.add('near'); + } + } + document.addEventListener('pointermove',event=>{ + if(event.pointerType==='touch'){clear();return} + point={x:event.clientX,y:event.clientY};if(!frame)frame=requestAnimationFrame(paint); + },{passive:true}); + document.documentElement.addEventListener('pointerleave',clear); + document.addEventListener('pointercancel',clear);window.addEventListener('blur',clear); + document.addEventListener('visibilitychange',()=>{if(document.hidden)clear()}); +})(); diff --git a/SUB Control/web/index.html b/SUB Control/web/index.html index 9c06abb..10dc543 100644 --- a/SUB Control/web/index.html +++ b/SUB Control/web/index.html @@ -4,7 +4,7 @@