teach/test/widgets.test.js
Ramon 99e807595a
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 51s
feat: alle widgets mobielvriendelijk maken (v0.3.64-beta)
2026-07-16 08:02:20 +02:00

265 lines
16 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {runInNewContext} from 'node:vm';
const ids=['birthday','numberline','sentence'];
const learningIds=['fractions','clock','placevalue','money','wordtypes','schedule','mood','poll'];
test('nieuwe widgets zijn geladen vóór het widgetregister', async()=>{
const html=await readFile('public/index.html','utf8');
const board=html.indexOf('js/board.js');
for(const id of ids){
const pos=html.indexOf(`js/widgets/${id}.js`);
assert.ok(pos>0 && pos<board, `${id} moet vóór board.js laden`);
}
});
test('nieuwe widgets zijn geregistreerd en bewaren state', async()=>{
const registry=await readFile('public/js/board.js','utf8');
for(const id of ids){
assert.match(registry,new RegExp(`id:"${id}"`));
const source=await readFile(`public/js/widgets/${id}.js`,'utf8');
assert.match(source,/getState/);
assert.doesNotMatch(source,/innerHTML\s*=.*(?:name|word\.value)/);
}
});
test('nieuwe widgetnamen en beschrijvingen zijn tweetalig', async()=>{
const core=await readFile('public/js/core.js','utf8');
for(const id of ids){
assert.equal((core.match(new RegExp(`wg_${id}:`,'g'))||[]).length,2);
assert.equal((core.match(new RegExp(`wg_${id}_d:`,'g'))||[]).length,2);
}
});
test('educatieve widgetmodule laadt en registreert alle acht widgets', async()=>{
const html=await readFile('public/index.html','utf8');
assert.ok(html.indexOf('js/widgets/learning.js')<html.indexOf('js/board.js'));
const [board,source]=await Promise.all([readFile('public/js/board.js','utf8'),readFile('public/js/widgets/learning.js','utf8')]);
for(const id of learningIds){
assert.match(board,new RegExp('id:"'+id+'"'));
const mount='mount'+id[0].toUpperCase()+id.slice(1);
assert.match(source,new RegExp('function '+mount+'\\('));
}
assert.equal((source.match(/getState:/g)||[]).length,learningIds.length);
});
test('nieuwe widgets zijn tweetalig beschreven', async()=>{
const core=await readFile('public/js/core.js','utf8');
for(const id of learningIds){
assert.equal((core.match(new RegExp('wg_'+id+':','g'))||[]).length,2,id);
assert.equal((core.match(new RegExp('wg_'+id+'_d:','g'))||[]).length,2,id);
}
});
test('widgetbibliotheek bevat zoeken favorieten sjablonen en borddata-acties', async()=>{
const [html,board]=await Promise.all([readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8')]);
for(const id of ['gallerySearch','galleryFavOnly','galleryTemplates','boardUndo','boardRedo','boardExport','boardImport'])assert.match(html,new RegExp('id="'+id+'"'));
for(const feature of ['teachWidgetFavorites','structuredClone','application/json','widgetDuplicate','widgetLock','widgetPreview'])assert.ok(board.includes(feature),feature);
});
test('oefenklok biedt drie niveaus leerwijzen en meerdere spellen', async()=>{
const [source,core]=await Promise.all([readFile('public/js/widgets/learning.js','utf8'),readFile('public/js/core.js','utf8')]);
for(const value of ["'analogue'","'digital'","'words'","'both'","'set'","'read'","'choose'","amode:'blocks'","clock-type-answer","clock-task-digital","clock-task-words"])assert.ok(source.includes(value),value);
assert.match(source,/S\.level===1\?\[0,30\]:S\.level===2\?Array\.from\(\{length:12\}/);
for(const key of ['clockLevel','clockLearningStyle','clockGame','clockScore','clockLevel1Hint','clockLevel2Hint','clockLevel3Hint','clockTypeHint','clock24Hour'])assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key);
});
test('analoge oefenklok is bedienbaar en houdt opdrachtacties buiten instellingen', async()=>{
const [source,css,core]=await Promise.all([readFile('public/js/widgets/learning.js','utf8'),readFile('public/css/teach.css','utf8'),readFile('public/js/core.js','utf8')]);
for(const feature of ['onpointerdown','onpointermove','setPointerCapture','releasePointerCapture','getBoundingClientRect','clock-time-blocks','dotGrid'])assert.ok(source.includes(feature),feature);
assert.match(css,/clock\.interactive[^}]*touch-action:none/);
assert.match(source,/learningShell\(root,\[field\(T\('clockLearningStyle'\),style\),field\(T\('clockGame'\),game\),linkSetting,hour24Setting\],stage\)/);
const clockSource=source.slice(source.indexOf('function mountClock'),source.indexOf('function mountPlacevalue'));
assert.doesNotMatch(clockSource,/learningShell\(root,\[[^\]]*(?:numberlineCheck|numberlineNew|clockHour|clockMinute)/);
for(const feature of ['answerDigits','clock-answer-slots','clock-block-tray','dragClone','droptarget',"'0123456789'.split('')",'clock-keypad',"S.amode==='blocks'?'🧱':'⌨️'"])assert.ok(clockSource.includes(feature),feature);
assert.match(clockSource,/String\(hour\)\.padStart\(2,'0'\)\+String\(minute\)\.padStart\(2,'0'\)/);
assert.equal((core.match(/clockIncomplete:/g)||[]).length,2);
assert.ok(clockSource.includes("'clock-tick hour'"), 'uurposities krijgen een eigen korte markering');
assert.match(css,/clock-tick\.hour[^}]*width:4px[^}]*height:5%[^}]*background:var\(--blue\)/);
assert.match(css,/translateY\(calc\(var\(--clock-size\)\*-.34\)\)/);
assert.doesNotMatch(css,/clock-score\{margin-left:auto\{/);
assert.ok(clockSource.includes("linkHands:true"));
assert.ok(clockSource.includes("linkHands.type='checkbox'"));
assert.ok(clockSource.includes("linkSetting.hidden=game.value!=='set'"));
assert.ok(clockSource.includes('for(let hour=1;hour<=12;hour++)'), 'gekoppelde urenwijzer rekent de volledige tijd uit');
assert.ok(clockSource.includes("game.onchange=()=>{syncSettings();newTask()}"));
assert.doesNotMatch(clockSource,/clockStartTask|clockWelcomeHint/);
assert.equal((core.match(/clockLinkHands:/g)||[]).length,2);
assert.ok(clockSource.includes('Math.floor(Math.random()*24)'), 'niveau 3 gebruikt de volledige dag');
assert.ok(clockSource.includes('S.level===3&&S.use24'), '24-uursklok is een niveau-3-optie');
assert.ok(clockSource.includes("hour24Setting.hidden=S.level!==3"));
assert.ok(clockSource.includes("use24.onchange=()=>{S.use24=use24.checked;newTask()}"));
assert.ok(clockSource.includes("hourHand.onpointerdown=e=>start(e,'hour')"));
assert.ok(clockSource.includes("minuteHand.onpointerdown=e=>start(e,'minute')"));
assert.ok(!clockSource.includes('face.onpointerdown='), 'de hele klok is geen sleepzone');
assert.doesNotMatch(css,/\.clock-hand::(?:before|after)/, 'geen extra sleepzones rond de wijzers');
for(const key of ['clockMorning','clockAfternoon','clockEvening','clockNight'])assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key);
assert.match(css,/clock-top\{[^}]*position:sticky[^}]*flex-wrap:wrap/);
assert.match(css,/clock-app\{[^}]*justify-content:flex-start/);
});
test('geldrekenen biedt drie niveaus, leerwijzen, spellen en directe bediening', async()=>{
const [source,css,core,board]=await Promise.all([readFile('public/js/widgets/learning.js','utf8'),readFile('public/css/teach.css','utf8'),readFile('public/js/core.js','utf8'),readFile('public/js/board.js','utf8')]);
const moneySource=source.slice(source.indexOf('function mountMoney'),source.indexOf('function mountWordtypes'));
for(const value of ["['make','moneyGameMake']","['count','moneyGameCount']","['change','moneyGameChange']","['amount','moneyStyleAmount']","['words','moneyStyleWords']","['both','moneyStyleBoth']","S.amode==='blocks'?'🧱':'⌨️'"])assert.ok(moneySource.includes(value),value);
assert.ok(moneySource.includes("const levelRule=()=>MONEY_LEVELS[S.level]"));
assert.ok(source.includes("function createMoneyTask(level,game,category='all'"));
assert.ok(moneySource.includes("denominations=()=>levelRule().denominations"));
assert.ok(moneySource.includes("S.game==='change'"));
for(const feature of ['money-wallet','money-cash-tray','money-answer-slots','money-digit-tray','money-keypad','dragClone','droptarget','getBoundingClientRect','amountWords','money-piece-image','money-product-icon','money-product-name','money-level-hint'])assert.ok(moneySource.includes(feature),feature);
assert.ok(moneySource.includes("learningShell(root,[styleSetting,gameSetting,categorySetting],stage)"),'alleen voorkeuren staan onder instellingen');
assert.ok(moneySource.includes("style.onchange=draw"));
assert.ok(moneySource.includes("game.onchange=()=>{S.game=game.value;syncSettings();newTask()}"));
assert.ok(moneySource.includes("category.onchange=()=>{S.category=category.value;newTask()}"));
assert.ok(moneySource.includes("categorySetting.hidden=S.game!=='change'"));
assert.doesNotMatch(moneySource,/learningShell\(root,\[[^\]]*(?:numberlineCheck|numberlineNew|moneyClear|moneyNextTask)/);
for(const key of ['moneyLearningStyle','moneyStyleAmount','moneyStyleWords','moneyStyleBoth','moneyGame','moneyGameMake','moneyGameCount','moneyGameChange','moneyLevel','moneyLevel1Hint','moneyLevel2Hint','moneyLevel3Hint','moneyScore','moneyMakeTitle','moneyCountTitle','moneyChangeTitle','moneyWallet','moneyTray','moneyBuildHint','moneyTypeHint','moneyCorrect','moneyNextTask','moneyRemove','moneyCategory','moneyCategoryAll','moneyCategoryFood','moneyCategorySchool','moneyCategoryPlay'])assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key);
assert.match(css,/money-app\{[^}]*align-self:flex-start[^}]*justify-content:flex-start/);
assert.match(css,/money-wallet\.droptarget/);
assert.match(css,/money-wallet\.money-remove-target/);
assert.ok(moneySource.includes("pile(S.selected,true,wallet)"));
assert.ok(moneySource.includes("const moved=hasMoved(ev),outside=!over(ev)"));
assert.ok(moneySource.includes("moved&&outside"));
assert.match(css,/coin\[data-value="1"\]\{width:36px;height:36px/);
assert.match(css,/coin\[data-value="200"\]\{width:57px;height:57px/);
assert.match(css,/note\[data-value="500"\]\{width:82px;height:42px/);
assert.match(css,/note\[data-value="5000"\]\{width:93px;height:51px/);
assert.match(css,/money-piece\.coin\[data-value="100"\]/);
assert.match(css,/money-piece-image\{[^}]*object-fit:contain/);
assert.match(css,/@media\(max-width:520px\)[\s\S]*?money-shop-task\{grid-template-columns:minmax\(0,.8fr\) minmax\(0,1.2fr\)/);
assert.ok(moneySource.includes("image.src="));
assert.ok(moneySource.includes("/img/money/"));
assert.ok(moneySource.includes("image.draggable=false"));
assert.doesNotMatch(moneySource,/money-(?:arch|hologram|serial|stars|map|unit)/);
for(const file of ['coin-1.png','coin-2.png','coin-5.png','coin-10.png','coin-20.png','coin-50.png','coin-100.png','coin-200.png','note-500.jpg','note-1000.jpg','note-2000.jpg','note-5000.jpg']){
const asset=await readFile('public/img/money/'+file);
assert.ok(asset.length>5000,file);
}
assert.match(board,/id:"money"[^\n]*w:580, h:560, minW:380, minH:360/);
});
test('elk geldspel gebruikt geldige niveaugrenzen en realistische productcategorieën', async()=>{
const source=await readFile('public/js/widgets/learning.js','utf8'),context={};
runInNewContext(source+';globalThis.__moneyData={levels:MONEY_LEVELS,products:MONEY_PRODUCTS,createTask:createMoneyTask}',context);
const data=JSON.parse(JSON.stringify({levels:context.__moneyData.levels,products:context.__moneyData.products})),createTask=context.__moneyData.createTask,categories=['food','school','play'],games=['make','count','change'];
assert.equal(Object.keys(data.levels).length,3);
assert.ok(data.products.length>=24);
assert.equal(new Set(data.products.map(item=>item.id)).size,data.products.length);
for(const level of [1,2,3]){
const rule=data.levels[level];
assert.ok(rule.min<rule.max);
assert.ok(rule.payments.some(value=>value>rule.max),'niveau '+level+' heeft passend betaalgeld');
for(const category of categories){
const pool=data.products.filter(item=>item.category===category&&Array.isArray(item.prices[level])&&item.prices[level].length);
assert.ok(pool.length>=5,category+' heeft producten op niveau '+level);
}
for(const item of data.products){
for(const price of item.prices[level]||[]){
assert.ok(price>=rule.min&&price<=rule.max,item.id+' valt binnen niveau '+level);
assert.equal((price-rule.min)%rule.step,0,item.id+' volgt de stapgrootte van niveau '+level);
}
}
for(const game of games){
const taskCategories=game==='change'?categories:['all'];
for(const category of taskCategories){
for(let sample=0;sample<30;sample++){
const task=createTask(level,game,category,null,null);
assert.ok(task.targetCents>=rule.min&&task.targetCents<=rule.max,game+' niveau '+level);
assert.equal((task.targetCents-rule.min)%rule.step,0,game+' stapgrootte niveau '+level);
assert.ok(task.paidCents>task.targetCents,game+' betaalgeld niveau '+level);
if(game==='change'){
const product=data.products.find(item=>item.id===task.productId);
assert.equal(product.category,category);
assert.ok(product.prices[level].includes(task.targetCents));
}
}
}
}
}
for(const item of data.products){
assert.ok(item.icon&&item.nl&&item.en);
assert.ok(categories.includes(item.category));
}
});
test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px-bord',async()=>{
const [board,pupil,css]=await Promise.all([
readFile('public/js/board.js','utf8'),
readFile('public/js/pupil.js','utf8'),
readFile('public/css/teach.css','utf8'),
]);
const registrySource=board.slice(board.indexOf('const REGISTRY'),board.indexOf('const WIDGET_CATS'));
const registered=[...registrySource.matchAll(/id:"([^"]+)"/g)].map(match=>match[1]).sort();
const coverage={
anchor:'.an-hint',
letters:'.lg-slots',
flits:'.fl-word',
hak:'.hk-word',
ball:'.bl-word',
sentence:'.sb-banks',
madd:'.mt-pad',
msub:'.mt-pad',
mmul:'.mt-pad',
mdiv:'.mt-pad',
mpct:'.mt-pad',
numberline:'.nlw-settings',
fractions:'.fraction-wall',
clock:'.clock-task-card',
placevalue:'.place-grid',
money:'.money-task-card',
wordtypes:'.wordtypes',
write:'.wp-bar',
mind:'.mm-top',
notes:'.notes{',
timer:'.timer',
dice:'.dc-row',
names:'.np-result',
birthday:'.bd{',
schedule:'.schedule>div',
mood:'.mood',
poll:'.poll',
media:'.media-bar',
};
assert.deepEqual(registered,Object.keys(coverage).sort(),'ieder geregistreerd widgettype staat in de mobiele audit');
for(const [id,selector] of Object.entries(coverage)){
assert.ok(css.includes(selector),id+' mist responsive/breedte-onafhankelijke opmaak');
}
assert.match(css,/\.widget-body\{[^}]*container-type:inline-size[^}]*container-name:widget/);
assert.match(css,/@container widget \(max-width:520px\)/);
const compact=css.slice(css.indexOf('Widgetinhoud op telefoonformaat'));
for(const selector of [
'.lg,.mt,.dc,.np,.timer,.nlw,.sb',
'.wp-bar',
'.media-bar',
'.mm-top',
'.an-hint',
'.bd{grid-template-columns:1fr}',
'.sb-banks{grid-template-columns:1fr}',
'.edu-stage{align-items:flex-start',
'.money-task-card,.clock-task-card',
]){
assert.ok(compact.includes(selector),selector+' mist in de compacte containerregels');
}
const sizeCode=board.slice(
board.indexOf('function widgetSizeBounds'),
board.indexOf('/* stabiel widget-instantie-id'),
);
const context={board:{clientWidth:320,clientHeight:480}};
runInNewContext(sizeCode+';globalThis.mobileSize={bounds:widgetSizeBounds({minW:640,minH:540}),size:clampWidgetSize(760,640,{minW:640,minH:540})}',context);
assert.deepEqual(JSON.parse(JSON.stringify(context.mobileSize)),{
bounds:{minW:312,minH:472,maxW:312,maxH:472},
size:{w:312,h:472},
});
assert.ok(board.includes('new ResizeObserver(fitWidgetsToBoard).observe(board)'));
assert.ok(board.includes('win.dataset.widget = def.id'));
assert.match(pupil,/card\.style\.width = .min\(100%, \$\{def\.w\}px\)./);
assert.ok(pupil.includes('card.style.minWidth = "0"'));
});