ensemble / app.test.js
nanye's picture
support model random seed
f13c9e5
Raw History Blame Contribute Delete
7.15 kB
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);
});