Improve controller input, console, and UI consistency
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
/* Pure conditioning and log parsing tests: no hardware or browser needed. */
|
||||
const assert=require('node:assert/strict');
|
||||
const {deadZone,defaults,read,Motion}=require('../web/controller-input.js');
|
||||
const {parse}=require('../web/console-view.js');
|
||||
const pad={connected:true,axes:[0,0,0,0]};
|
||||
assert.equal(deadZone(.06),0);assert.equal(deadZone(-1),-1);assert.equal(deadZone(1),1);
|
||||
assert.equal(defaults({axes:[0,0]}).vertical,-1);
|
||||
assert.equal(read({...pad,connected:false},defaults(pad)),null);
|
||||
assert.equal(read(pad,{...defaults(pad),vertical:10}),null);
|
||||
const diagonal=read({...pad,axes:[1,-1,0,-1]},defaults(pad));
|
||||
assert(Math.abs(Math.hypot(diagonal.turn,diagonal.forward)-1)<1e-10);
|
||||
assert.equal(diagonal.vertical,1);
|
||||
assert.equal(read({...pad,axes:[-.5,0,0,0]},{...defaults(pad),invertTurn:true}).turn,deadZone(.5));
|
||||
const motion=new Motion(),target={turn:.6,forward:.8,vertical:1};
|
||||
assert.deepEqual(motion.step(target,0),{turn:0,forward:0,vertical:0});
|
||||
const partial=motion.step(target,16);assert(partial.vertical>0&&partial.vertical<.3);
|
||||
let current;for(let time=32;time<=1000;time+=16){current=motion.step(target,time);assert(Math.hypot(current.turn,current.forward)<=1+1e-10)}
|
||||
assert(current.vertical>.99);
|
||||
const neutral={turn:0,forward:0,vertical:0};
|
||||
assert(motion.step(neutral,1024).vertical>.99);
|
||||
assert(motion.step(neutral,1200).vertical>0);
|
||||
assert.deepEqual(motion.step(neutral,1400),neutral);
|
||||
motion.reset();assert.deepEqual(motion.step(target,1500),neutral);
|
||||
assert.equal(parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"HEARTBEAT","base_mode":128}').quiet,true);
|
||||
const ack=parse('2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0}');
|
||||
assert.equal(ack.time,'10:11:12.123');assert.equal(ack.type,'COMMAND_ACK');assert.equal(ack.tone,'command');
|
||||
assert.equal(parse('2026-10-01T10:11:12 [TX_LINK_PROBE_TIMEOUT] retry').quiet,false);
|
||||
assert.equal(parse('2026-10-01T10:11:12 [RX] {"mavpackettype":"STATUSTEXT","severity":3,"text":"warning"}').tone,'warning');
|
||||
assert.equal(parse('2026-10-01T10:11:12 [RX] null').type,'RX');
|
||||
assert.equal(parse('unstructured text').payload,'unstructured text');
|
||||
console.log('PASS: dead zone, mapping/inversion, diagonal cap, ramp, finite center return, resets, console parsing and warning visibility.');
|
||||
@@ -24,19 +24,23 @@ async function main(){
|
||||
try{
|
||||
const page=await browser.newPage({viewport:{width:1080,height:720}});
|
||||
const errors=[];page.on('pageerror',error=>errors.push(error.message));
|
||||
const setArmed=value=>page.evaluate(value=>{
|
||||
Object.assign(testBridge.state,{connected:true,armed:value,armed_known:true,app_state:value?'armed':'connected_disarmed'});render(testBridge.state);
|
||||
},value);
|
||||
await page.addInitScript(()=>{
|
||||
const clone=value=>JSON.parse(JSON.stringify(value));
|
||||
const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,motor:i<=3?i:null,direction:i<=3?1:null};
|
||||
const state={connected:false,armed:false,armed_known:false,mode:null,app_state:'disconnected',status:'Ready',servos,link_quality:null};
|
||||
const battery={cells:4,capacity_mah:5500,c_rating:35,full_v_per_cell:4.2,empty_v_per_cell:3.3,warning_percent:20};
|
||||
const link={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1};
|
||||
const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[]};
|
||||
const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[],axes:[],axisVisual:[],stops:0};
|
||||
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
window.testBridge={state,battery,link,calls,failBattery:false,failMotor:false};
|
||||
window.testPads=[];Object.defineProperty(navigator,'getGamepads',{value:()=>window.testPads});
|
||||
window.pywebview={api:{
|
||||
initial_state:async()=>({ports:[{device:'COM5',label:'COM5'}],state:clone(state),battery_config:clone(battery),link_config:clone(link)}),
|
||||
refresh_ports:async()=>[{device:'COM5',label:'COM5'}],poll:async()=>clone(state),
|
||||
get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>true,set_axes:async()=>true,
|
||||
get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>{calls.stops++;return true},set_axes:async(...axes)=>{calls.axes.push(axes);calls.axisVisual.push({joy:joystick3D.getState(),vertical:verticalControl.getState().value});return true},
|
||||
save_battery:async data=>{calls.battery++;await sleep(200);if(window.testBridge.failBattery)throw Error('Battery save failed');Object.keys(battery).forEach(k=>battery[k]=+data[k]);return clone(battery)},
|
||||
save_link_settings:async data=>{calls.link++;await sleep(250);Object.keys(link).forEach(k=>link[k]=+data[k]);return clone(link)},
|
||||
connect:async(port,baud,rate)=>{calls.connect.push({port,baud,rate});Object.assign(state,{connected:true,armed_known:true,app_state:'connected_disarmed',mode:'MANUAL',link:'LINK OK',link_quality:{grade:'excellent',probe_samples:20,rx_packets_30s:100,rtt_median_ms:24,rtt_p95_ms:40,probe_loss_percent:0,rx_sequence_loss_percent:0,heartbeat_age_s:.2}});return clone(state)},
|
||||
@@ -45,7 +49,7 @@ async function main(){
|
||||
set_servo_function:async(servo,motor)=>{if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>Object.assign(state.servos[servo],{motor:motor||null,function:motor===0?0:motor<=8?32+motor:73+motor,direction:motor?1:null}),650);return true},
|
||||
start_heavy_quality_test:async()=>{calls.heavy++;await sleep(250);state.heavy_quality={running:true,status:'Stage 1 of 3: 1 Hz',sent:4,replies:4,results:[]};return clone(state.heavy_quality)},
|
||||
cancel_heavy_quality_test:async()=>{state.heavy_quality={running:false,status:'Cancelled',results:[]};return clone(state.heavy_quality)},
|
||||
read_console:async()=>{calls.console++;return {rx:['RX sample '+calls.console],tx:['TX sample '+calls.console]}},
|
||||
read_console:async()=>{calls.console++;return {rx:['2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0,"sample":1}', '2026-10-01T10:11:12.124 [RX_BAD_DATA] corrupt_frame='+calls.console, '2026-10-01T10:11:12.125 [RX] {"mavpackettype":"HEARTBEAT"}'],tx:['2026-10-01T10:11:12.124 [TX_COMMAND] ARM '+calls.console]}},
|
||||
minimize_window:async()=>true,toggle_maximize_window:async()=>false,close_window:async()=>true
|
||||
}};
|
||||
});
|
||||
@@ -107,27 +111,146 @@ async function main(){
|
||||
await page.waitForFunction(()=>!testBridge.state.heavy_quality.running);
|
||||
await page.screenshot({path:path.join(output,'quality.png')});await close('link-quality-modal');
|
||||
await page.locator('#console').click();
|
||||
await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('RX sample'));
|
||||
await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('COMMAND_ACK'));
|
||||
await page.locator('#console-pause').click();
|
||||
const paused=await page.locator('#console-rx-output').textContent();
|
||||
await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output').textContent(),paused);
|
||||
await page.locator('#console-pause').click();
|
||||
await page.waitForFunction(old=>document.getElementById('console-rx-output').textContent!==old,paused);
|
||||
await page.screenshot({path:path.join(output,'console.png')});await close('console-modal');
|
||||
await page.screenshot({path:path.join(output,'console.png')});await page.locator('#console-close').click();
|
||||
for(const viewport of [{width:900,height:560},{width:480,height:720}]){
|
||||
await page.setViewportSize(viewport);
|
||||
for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','console-modal']){
|
||||
for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','controller-modal']){
|
||||
await page.evaluate(id=>openModal(id),id);
|
||||
const box=await page.locator('#'+id).boundingBox();
|
||||
assert(box.x>=0&&box.y>=0&&box.width<=viewport.width&&box.height<=viewport.height,id+' must fit viewport');
|
||||
assert.equal(await page.evaluate(()=>canDrive()),false);
|
||||
const closeGeometry=await page.locator('#'+id+' .modal-close').evaluate(button=>{
|
||||
const b=button.getBoundingClientRect(),s=button.querySelector('svg').getBoundingClientRect();
|
||||
return {dx:Math.abs(b.x+b.width/2-s.x-s.width/2),dy:Math.abs(b.y+b.height/2-s.y-s.height/2),bg:getComputedStyle(button).backgroundColor};
|
||||
});
|
||||
assert(closeGeometry.dx<.5&&closeGeometry.dy<.5,id+' close icon must be centered');
|
||||
assert.equal(closeGeometry.bg,'rgba(0, 0, 0, 0)');
|
||||
await close(id);
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({width:1080,height:720});await openLink();
|
||||
await page.screenshot({path:path.join(output,'link.png')});await close('link-modal');
|
||||
|
||||
await page.setViewportSize({width:1080,height:720});
|
||||
await page.evaluate(()=>{Object.assign(testBridge.state,{connected:true,armed:false,armed_known:true,app_state:'connected_disarmed',battery:{voltage:15.8,percent:72,cells:4,low:false}});focusChanged(true);render(testBridge.state)});
|
||||
await page.locator('#engage').focus();
|
||||
assert.equal(await page.locator('#engage').evaluate(el=>getComputedStyle(el).outlineStyle),'none');
|
||||
await page.evaluate(()=>startEngageHold());
|
||||
assert.equal(await page.locator('#input-joystick').isDisabled(),true);
|
||||
await page.evaluate(()=>cancelEngageHold(false));
|
||||
assert.equal(await page.locator('#input-joystick').isDisabled(),false);
|
||||
await page.locator('#input-joystick').click();
|
||||
for(const pending of ['arm','disarm']){
|
||||
await page.evaluate(pending=>{testBridge.state.pending_command=pending;render(testBridge.state)},pending);
|
||||
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
|
||||
assert.equal(await page.evaluate(()=>{setInputMode('keyboard');return inputMode}),'joystick');
|
||||
}
|
||||
await page.evaluate(()=>{delete testBridge.state.pending_command;testBridge.state.armed_known=false;render(testBridge.state)});
|
||||
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
|
||||
await setArmed(true);
|
||||
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
|
||||
await page.waitForFunction(()=>document.getElementById('controller-status').textContent.includes('Waiting'));
|
||||
await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[1,-1,0,-1]}]);
|
||||
await page.waitForFunction(()=>document.getElementById('controller-status').textContent==='Center sticks to resume');
|
||||
assert.deepEqual(await page.evaluate(()=>({...control})),{turn:0,forward:0,vertical:0});
|
||||
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);
|
||||
await page.waitForFunction(()=>!controllerNeedCenter);
|
||||
await page.keyboard.down('w');await page.waitForTimeout(420);await page.keyboard.up('w');
|
||||
assert.equal(await page.evaluate(()=>control.forward),0);
|
||||
await page.evaluate(()=>testPads[0].axes=[1,-1,0,-1]);
|
||||
await page.waitForFunction(()=>control.vertical>0&&control.vertical<.8);
|
||||
await page.waitForFunction(()=>control.vertical>.99);
|
||||
const matching=await page.evaluate(()=>{
|
||||
const joy=joystick3D.getState(),sent=testBridge.calls.axes.at(-1);
|
||||
return {length:Math.hypot(control.turn,control.forward),joy,vertical:control.vertical,sent,visual:testBridge.calls.axisVisual.at(-1),control:{...control}};
|
||||
});
|
||||
assert(matching.length<=1.00001);
|
||||
assert.equal(matching.joy.x,matching.control.turn);assert.equal(matching.joy.y,-matching.control.forward);
|
||||
assert.equal(matching.sent[0],-matching.visual.joy.y);assert.equal(matching.sent[1],matching.visual.joy.x);assert.equal(matching.sent[2],matching.visual.vertical);
|
||||
// Dispatch even though CSS excludes pointers, to exercise the handler gate.
|
||||
await page.locator('#joystick-canvas').dispatchEvent('pointerdown',{pointerId:91,clientX:20,clientY:20,button:0});
|
||||
await page.locator('#heave').dispatchEvent('pointerdown',{pointerId:92,clientX:20,clientY:20,button:0});
|
||||
assert.equal(await page.evaluate(()=>joystick3D.getState().active||verticalControl.getState().active),false);
|
||||
await page.locator('#console').click();
|
||||
await page.waitForFunction(()=>!document.getElementById('console-dock').hidden);
|
||||
await page.waitForFunction(()=>document.querySelector('.rx-dot').classList.contains('lit')&&document.querySelector('.tx-dot').classList.contains('lit'));
|
||||
await page.locator('#console-pause').click();
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await page.locator('.rx-dot').evaluate(el=>el.classList.contains('lit')),false);
|
||||
assert.equal(await page.locator('.tx-dot').evaluate(el=>el.classList.contains('lit')),false);
|
||||
await page.locator('#console-pause').click();
|
||||
assert.equal(await page.evaluate(()=>canDrive()),true);
|
||||
assert.equal(await page.locator('#console-rx-output .console-row').count(),2);
|
||||
await page.locator('#console-filter').selectOption('all');
|
||||
assert.equal(await page.locator('#console-rx-output .console-row').count(),3);
|
||||
await page.locator('#console-rx-output details').first().locator('summary').click();
|
||||
await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output details').first().evaluate(el=>el.open),true);
|
||||
await page.screenshot({path:path.join(output,'controller-console.png')});await page.locator('#console-close').click();
|
||||
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);
|
||||
await page.waitForFunction(()=>control.vertical>0&&control.vertical<.95);
|
||||
await page.waitForFunction(()=>control.vertical===0&&control.turn===0&&control.forward===0);
|
||||
await setArmed(false);
|
||||
assert.equal(await page.locator('#input-keyboard').isDisabled(),false);
|
||||
await page.locator('#controller-settings').click();
|
||||
await page.screenshot({path:path.join(output,'controller-setup.png')});
|
||||
await page.locator('#controller-form [name="invertTurn"]').check();
|
||||
await page.locator('#controller-form').getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await page.waitForFunction(()=>!document.getElementById('controller-modal').open);
|
||||
await page.locator('#controller-settings').click();
|
||||
assert.equal(await page.locator('#controller-form [name="invertTurn"]').isChecked(),false);
|
||||
await page.locator('#controller-form [name="invertTurn"]').check();
|
||||
await page.locator('#controller-form').getByRole('button',{name:'Save mapping',exact:true}).click();
|
||||
await page.waitForFunction(()=>!document.getElementById('controller-modal').open);
|
||||
await setArmed(true);
|
||||
await page.waitForFunction(()=>!controllerNeedCenter);
|
||||
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);
|
||||
await page.waitForFunction(()=>control.turn<-.2);
|
||||
await page.evaluate(()=>testPads=[]);
|
||||
await page.waitForFunction(()=>control.turn===0);
|
||||
await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[.5,0,0,0]}]);
|
||||
await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0);
|
||||
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter);
|
||||
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2);
|
||||
await page.evaluate(()=>focusChanged(false));assert.equal(await page.evaluate(()=>control.turn),0);
|
||||
await page.evaluate(()=>focusChanged(true));await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0);
|
||||
await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter);
|
||||
await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2);
|
||||
await page.evaluate(()=>testPads[0].id='Different controller');
|
||||
await page.waitForFunction(()=>control.turn===0);
|
||||
assert.equal(await page.locator('#input-keyboard').isDisabled(),true);
|
||||
await setArmed(false);
|
||||
await page.locator('#input-keyboard').click();
|
||||
await setArmed(true);
|
||||
await page.keyboard.down('w');await page.waitForFunction(()=>control.forward>.9);await page.keyboard.up('w');
|
||||
await page.waitForFunction(()=>control.forward===0);
|
||||
await page.evaluate(()=>{Object.assign(testBridge.state,{connected:false,armed_known:false});render(testBridge.state)});
|
||||
assert.equal(await page.evaluate(()=>canDrive()),false);
|
||||
|
||||
await setArmed(false);
|
||||
const deckLayout=await page.evaluate(()=>{
|
||||
const battery=document.querySelector('.battery-card'),sidebar=document.querySelector('.sidebar-deck'),actions=document.querySelector('.activity-actions'),heading=document.querySelector('.activity-heading');
|
||||
return {last:battery===document.querySelector('.telemetry-zone').lastElementChild,title:heading.textContent,
|
||||
actionHeight:actions.querySelector('button').getBoundingClientRect().height,
|
||||
batteryHeight:battery.getBoundingClientRect().height,connectHeight:document.querySelector('#connect').getBoundingClientRect().height,
|
||||
bottomGap:sidebar.getBoundingClientRect().bottom-battery.getBoundingClientRect().bottom,
|
||||
overlay:getComputedStyle(document.querySelector('.battery-copy')).zIndex,
|
||||
actionRightGap:actions.getBoundingClientRect().right-actions.lastElementChild.getBoundingClientRect().right,
|
||||
sidebarPadding:getComputedStyle(sidebar).paddingTop,
|
||||
workspacePadding:getComputedStyle(document.querySelector('.controls-workspace')).paddingTop};
|
||||
});
|
||||
assert.equal(deckLayout.last,true);assert.equal(deckLayout.title,'Activity:');
|
||||
assert.equal(deckLayout.actionHeight,32);assert.equal(deckLayout.batteryHeight,62);assert.equal(deckLayout.connectHeight,62);
|
||||
assert(deckLayout.bottomGap>=10&&deckLayout.bottomGap<=24);assert.equal(deckLayout.overlay,'1');
|
||||
assert.equal(deckLayout.actionRightGap,0);assert.equal(deckLayout.sidebarPadding,deckLayout.workspacePadding);
|
||||
await page.screenshot({path:path.join(output,'ui-cleanup.png')});
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, and five dialogs at two window sizes.');
|
||||
console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, five dialogs at two window sizes; controller conditioning/read-only graphics, actual bridge values, mapping, focus/unplug/identity safety and dock filtering; mode locking during hold/armed/pending/unknown states, centered SVG close icons and bottom battery layout.');
|
||||
}catch(error){
|
||||
const page=browser.contexts()[0]?.pages()[0];
|
||||
if(page){
|
||||
|
||||
Reference in New Issue
Block a user