const {test}=require('node:test'); const assert=require('node:assert/strict'); const vm=require('node:vm'); const fs=require('node:fs'); const Ensemble=require('./ensemble.js'); const Datasets=require('./datasets.js'); test('async training ignores stale results, accepts parameters and preserves hover/removal',async()=>{ const elements=new Map(),calls=[]; const fills=[]; const ctx=new Proxy({}, {get:(target,key)=>key==='fillRect'?()=>fills.push(target.fillStyle):(key in target?target[key]:()=>{})}); const document={ getElementById(id){ if(!elements.has(id)) elements.set(id,{ width:6,height:6,value:id==='method'?'boosting':id==='params'?'{}':id==='seed'?'42':'DecisionTreeClassifier', getContext:()=>ctx,addEventListener(){},replaceChildren(...children){this.children=children;}, setAttribute(key,value){this[key]=value;},removeAttribute(){},style:{},offsetWidth:240,offsetHeight:60, }); return elements.get(id); },querySelectorAll:()=>[],createElement:()=>({}), }; let resolveReady; class PythonBackend{ ready=new Promise(resolve=>{resolveReady=resolve;}); add(request){return new Promise(resolve=>calls.push({request,resolve}));} } const sandbox=vm.createContext({document,Ensemble,Datasets,PythonBackend,window:{innerWidth:1000,innerHeight:800}}); vm.runInContext(fs.readFileSync(require.resolve('./app.js'),'utf8'),sandbox); const get=id=>document.getElementById(id); assert.equal(get('runtime-status')['data-state'],'loading'); assert.equal(get('runtime-status-text').textContent,'Loading Pythong runtime...'); get('dataset-type').value='two_moons';get('dataset-type').onchange(); assert.equal(get('add').disabled,true); resolveReady([{name:'DecisionTreeClassifier',weighted:true}]); await Promise.resolve(); assert.equal(get('runtime-status')['data-state'],'ready'); assert.equal(get('runtime-status-text').textContent,'Python runtime ready'); assert.equal(get('add').disabled,false); assert.equal(get('method-badge').textContent,'AdaBoost'); get('dataset-type').value='custom';get('dataset-type').onchange(); vm.runInContext('points=[{x:.2,y:.2,label:-1},{x:.8,y:.8,label:1}]; reset();',sandbox); const model={tree:{size:2,grid:[-1,-1,1,1]},predictions:[-1,1],alpha:1,perfect:true}; let pending=get('add').onclick(); assert.equal(get('add').disabled,true); assert.equal(calls[0].request.seed,42); get('params').value='{"max_depth":2}';get('params').oninput(); calls[0].resolve(model);await pending; assert.equal(get('count').textContent,0); for(let i=1;i<=2;i++){ pending=get('add').onclick();assert.equal(calls[i].request.params.max_depth,2); calls[i].resolve(model);await pending; assert.equal(get('count').textContent,i);assert.equal(get('add').disabled,false); } assert.match(get('models').children[0].title,/Training error: 0\.0%/); get('models').children[0].onmouseenter();assert.equal(vm.runInContext('hoveredModel',sandbox),0); get('models').children[0].onmouseleave();assert.equal(vm.runInContext('hoveredModel',sandbox),null); get('remove').onclick();assert.equal(get('count').textContent,1); get('boundary').getBoundingClientRect=()=>({left:0,top:0,width:602,height:602}); get('boundary').onpointermove({clientX:121,clientY:481}); assert.match(get('point-tooltip').textContent,/Sample weight: 0.5000/); get('params').value='[]';await get('add').onclick(); assert.match(get('status').textContent,/Invalid arguments/); // The hovered learner disagrees with the ensemble across the entire plot. vm.runInContext('models=[{classes:[-1,1,2],alpha:1,predictions:[2,2],tree:{size:1,grid:[2]}},{classes:[-1,1,2],alpha:2,predictions:[1,1],tree:{size:1,grid:[1]}}]; render();',sandbox); assert.equal(fills.at(-1),'#fae8d8'); assert.match(get('models').children[0].title,/Training error: 100\.0%/); assert.match(get('models').children[1].title,/Training error: 50\.0%/); fills.length=0;get('models').children[0].onmouseenter(); assert.ok(fills.length>0);assert.ok(fills.every(color=>color==='#e6f5f0')); fills.length=0;get('models').children[0].onmouseleave(); assert.ok(fills.every(color=>color==='#fae8d8')); fills.length=0;get('models').children[0].onfocus(); assert.ok(fills.every(color=>color==='#e6f5f0')); get('models').children[0].onblur(); // Each model's weighted error uses only the learners preceding it. vm.runInContext('models=[{alpha:Math.log(3),predictions:[1,1],tree:{size:1,grid:[1]}},{alpha:2,predictions:[1,1],tree:{size:1,grid:[1]}}]; render();',sandbox); assert.equal(get('models').children[0].title,'Training error: 50.0%\nWeighted error: 50.0%\nVote weight: 1.099'); assert.equal(get('models').children[1].title,'Training error: 50.0%\nWeighted error: 75.0%\nVote weight: 2.000'); get('method').value='bagging'; vm.runInContext('render();',sandbox); assert.equal(get('models').children[1].title,'Training error: 50.0%\nWeighted error: 50.0%\nVote weight: 2.000'); // Presets prevent both pointer and keyboard editing and restore custom data. const custom=vm.runInContext('JSON.stringify(points)',sandbox); get('dataset-type').value='xor';get('dataset-type').onchange(); assert.equal(get('count').textContent,0); assert.equal(get('points').textContent,4); assert.equal(get('undo').disabled,true); assert.equal(get('clear').disabled,true); const xor=vm.runInContext('JSON.stringify(points)',sandbox); assert.deepEqual(JSON.parse(xor),Datasets.create('xor')); get('boundary').onpointerdown({button:0}); get('boundary').onkeydown({key:'Enter'}); get('undo').onclick();get('clear').onclick(); assert.equal(vm.runInContext('JSON.stringify(points)',sandbox),xor); get('dataset-type').value='custom';get('dataset-type').onchange(); assert.equal(vm.runInContext('JSON.stringify(points)',sandbox),custom); assert.equal(get('undo').disabled,false); get('boundary').onkeydown({key:'Enter',preventDefault(){}}); assert.equal(get('points').textContent,3); // Switching datasets also invalidates an in-flight training result. get('params').value='{}'; pending=get('add').onclick(); get('dataset-type').value='circles';get('dataset-type').onchange(); calls.at(-1).resolve(model);await pending; assert.equal(get('count').textContent,0); assert.equal(get('points').textContent,120); const callCount=calls.length; for(const invalid of ['', '-1', '1.5', '4294967296']){ get('seed').value=invalid;get('seed').oninput(); await get('add').onclick(); assert.match(get('status').textContent,/Invalid random seed/); assert.equal(calls.length,callCount); } get('seed').value='0';get('seed').oninput(); pending=get('add').onclick(); assert.equal(calls.at(-1).request.seed,0); get('seed').value='17';get('seed').oninput(); calls.at(-1).resolve(model);await pending; assert.equal(get('count').textContent,0); pending=get('add').onclick(); assert.equal(calls.at(-1).request.seed,17); calls.at(-1).resolve(model);await pending; assert.equal(get('count').textContent,1); get('seed').value='42';get('seed').oninput(); assert.equal(get('count').textContent,0); });