teach/test/widgets.test.js
Ramon c33eb71640
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m4s
fix: centreer letter- en cijferdoelvakken
Gewijzigde functionaliteiten:

- Centreert doelvakken in letter-, reken-, klok-, geld-, plaatswaarde- en wereldwidgets.

- Houdt lange woordrijen scrollbaar zonder de eerste vakken af te snijden.

- Centreert kleinere geplaatste cijferblokken binnen hun doelvak.

- Verwijdert de links uitgelijnde compacte woordrij.

Tests: 160 geslaagd.
2026-07-23 17:06:26 +02:00

457 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'];
const worldIds=['trees','birds','plants','topography'];
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('wereldoriëntatiewidgets laden, registreren en vertalen', async()=>{
const [html,board,source,core]=await Promise.all([readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8'),readFile('public/js/widgets/world.js','utf8'),readFile('public/js/core.js','utf8')]);
assert.ok(html.indexOf('js/widgets/world.js')<html.indexOf('js/board.js'));
for(const [id,mount] of [['trees','mountTrees'],['birds','mountBirds'],['plants','mountPlants'],['topography','mountTopography']]){
assert.ok(worldIds.includes(id));
assert.match(board,new RegExp('id:"'+id+'"[^\\n]*cat:"world"[^\\n]*mount:'+mount));
assert.match(source,new RegExp('function '+mount+'\\('));
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);
}
assert.equal((core.match(/catWorld:/g)||[]).length,2);
for(const key of ['worldActivity','worldLearn','worldQuiz','worldExercise','worldExerciseRecognize','worldExercisePair','worldExerciseClue','worldExercisePhoto','worldExerciseLatin','worldTreeView','worldQuestionLeaf','worldQuestionBird','worldQuestionPlant','worldQuestionPair','worldQuestionClue','worldQuestionLatin','worldFindTreePhoto','worldFindBirdPhoto','worldFindPlantPhoto','worldPhotoTree','worldPhotoLeaf','worldPhotoBird','worldPhotoPlant','worldPhotoCredit','worldPhotoOption','topoProvinces','topoCapitals','topoCities','topoRegions','topoRivers','topoContinents','topoFindProvince','topoFindCapital','topoFindCity','topoFindRegion','topoFindRiver','topoFindContinent','topoMapSource','topoWorldMapSource'])assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key);
for(const exercise of ['recognize','pair','clue','photo','latin'])assert.ok(source.includes('"'+exercise+'"'),exercise);
for(const mode of ['regions','rivers','continents'])assert.ok(source.includes('["'+mode+'","topo'),mode);
assert.match(source,/getState/);
assert.ok(source.includes('name.textContent=worldName(item)'));
assert.ok(source.includes('hint.textContent=worldHint(item)'));
assert.doesNotMatch(source,/innerHTML\s*=\s*(?:worldName|worldHint)\s*\(/);
});
test('wereldoriëntatiedata bevat echte soortmedia en uitgebreide topografie', async()=>{
const [source,map,worldMap,attribution]=await Promise.all([
readFile('public/js/widgets/world.js','utf8'),
readFile('public/img/world/netherlands-provinces.svg','utf8'),
readFile('public/img/world/world-continents.svg','utf8'),
readFile('public/img/world/ATTRIBUTION.md','utf8'),
]);
const context={};
runInNewContext(source+';globalThis.__world={trees:WORLD_TREES,birds:WORLD_BIRDS,plants:WORLD_PLANTS,media:WORLD_MEDIA,provinces:NL_PROVINCES,cities:NL_CITIES,regions:NL_REGIONS,rivers:NL_RIVERS,continents:WORLD_CONTINENTS}',context);
const data=JSON.parse(JSON.stringify(context.__world));
assert.equal(data.trees.length,18);
assert.equal(data.birds.length,18);
assert.equal(data.plants.length,12);
assert.equal(data.provinces.length,12);
assert.equal(data.cities.filter(city=>city.capital).length,12);
assert.equal(data.cities.filter(city=>!city.capital).length,8);
assert.equal(data.regions.length,4);
assert.equal(data.rivers.length,5);
assert.equal(data.continents.length,7);
for(const collection of [data.trees,data.birds,data.plants,data.provinces,data.cities,data.regions,data.rivers,data.continents])assert.equal(new Set(collection.map(item=>item.id)).size,collection.length);
for(const item of [...data.trees,...data.birds,...data.plants])assert.ok(item.nl&&item.en&&item.latin&&item.hintNl&&item.hintEn&&item.tier>=1&&item.tier<=3,item.id);
const assetPaths=[];
for(const tree of data.trees){
assert.ok(data.media[tree.id]?.tree?.file&&data.media[tree.id]?.leaf?.file,tree.id);
assetPaths.push(data.media[tree.id].tree.file,data.media[tree.id].leaf.file);
}
for(const bird of data.birds){
assert.ok(data.media[bird.id]?.bird?.file,bird.id);
assetPaths.push(data.media[bird.id].bird.file);
}
for(const plant of data.plants){
assert.ok(data.media[plant.id]?.plant?.file,plant.id);
assetPaths.push(data.media[plant.id].plant.file);
}
assert.equal(new Set(assetPaths).size,66);
for(const relativePath of assetPaths){
const image=await readFile('public/img/world/'+relativePath);
assert.ok(image.length>10_000,relativePath+' is geen bruikbare foto');
assert.equal(image.subarray(0,4).toString(),'RIFF',relativePath);
assert.equal(image.subarray(8,12).toString(),'WEBP',relativePath);
assert.ok(attribution.includes('`'+relativePath+'`'),relativePath+' mist bronvermelding');
}
const cities=new Map(data.cities.map(city=>[city.id,city]));
const capitals=Object.fromEntries(data.provinces.map(province=>[province.nl,cities.get(province.capital)?.nl]));
assert.deepEqual(capitals,{
Friesland:'Leeuwarden',Groningen:'Groningen',Drenthe:'Assen',Overijssel:'Zwolle',Flevoland:'Lelystad','Noord-Holland':'Haarlem',Utrecht:'Utrecht',Gelderland:'Arnhem','Zuid-Holland':'Den Haag',Zeeland:'Middelburg','Noord-Brabant':"'s-Hertogenbosch",Limburg:'Maastricht',
});
for(const province of data.provinces){
assert.ok(province.shape&&Number.isFinite(province.x)&&Number.isFinite(province.y)&&cities.has(province.capital),province.id);
assert.match(map,new RegExp('<path id="'+province.shape+'"[^>]+d="[^"]+"'));
}
assert.equal((map.match(/<path id=/g)||[]).length,12);
const regionProvinces=data.regions.flatMap(region=>region.provinces);
assert.equal(regionProvinces.length,12);
assert.equal(new Set(regionProvinces).size,12);
assert.deepEqual([...regionProvinces].sort(),data.provinces.map(province=>province.id).sort());
for(const river of data.rivers)assert.ok(/^M [\d ]+/.test(river.path)&&river.color&&Number.isFinite(river.labelX)&&Number.isFinite(river.labelY),river.id);
for(const continent of data.continents){
assert.ok(continent.shape&&continent.color&&Number.isFinite(continent.x)&&Number.isFinite(continent.y),continent.id);
assert.match(worldMap,new RegExp('<g id="'+continent.shape+'">'));
}
assert.equal((worldMap.match(/<g id=/g)||[]).length,7);
assert.ok(Buffer.byteLength(worldMap)<200_000,'de wereldkaart moet compact genoeg blijven voor mobiel gebruik');
assert.ok(attribution.includes('Natural Earth 1:110m Geography Regions Polygons'));
assert.ok(attribution.includes('CBS-indeling voor Gebieden in Nederland 2026'));
assert.ok(attribution.includes('Rijkswaterstaat-overzicht Rivieren'));
assert.match(source,/href:TOPO_MAP_ASSET\+"#"\+province\.shape/);
assert.match(source,/href:TOPO_WORLD_ASSET\+"#"\+continent\.shape/);
});
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("period?'1223':'0011'"), '24-uursmodus laat de juiste daghelft kiezen');
assert.ok(clockSource.includes('face._dragAngle=S.h*30+S.m/2'), 'de urenwijzer start vanaf de actuele positie binnen 24 uur');
assert.ok(clockSource.includes('for(let hour=0;hour<24;hour++)'), 'de urenwijzer kan alle 24 uren aanwijzen');
assert.ok(clockSource.includes('button.dataset.period=period'), 'de daghelft-indicator kan tijdens het draaien live wisselen');
assert.ok(clockSource.includes("const expectedHour=S.game==='set'?(uses24()?S.targetH:clockHour(S.targetH)):S.targetH"), '24-uursantwoorden worden exact gecontroleerd');
assert.ok(clockSource.includes('S.h=uses24()?0:12'), 'een 24-uursopdracht begint met een geldige invoerwaarde');
assert.ok(clockSource.includes("hourHand.onpointerdown=e=>start(e,'hour')"));
assert.ok(clockSource.includes("minuteHand.onpointerdown=e=>start(e,'minute')"));
assert.ok(clockSource.includes("if(hand==='minute')face._lastPointerAngle=null"), 'de minutenwijzer volgt zijn draairichting over twaalf uur');
assert.ok(clockSource.includes('if(step)S.h=clockStepHour(S.h,step,uses24())'), 'een volledige minutenomloop verzet de urenwijzer');
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/);
assert.match(css,/clock-set-answer\{[^}]*align-items:center[^}]*justify-content:center/);
assert.match(css,/clock-period \.tbtn\.active\{[^}]*background:var\(--purple\)/);
const context={};
runInNewContext(source+';globalThis.__clock24={delta:clockTurnDelta,crossing:clockHourCrossing,step:clockStepHour,angle:clock24Angle,hour:clock24Hour}',context);
assert.equal(context.__clock24.delta(2,358),4,'vooruit over twaalf uur blijft vooruit draaien');
assert.equal(context.__clock24.delta(358,2),-4,'achteruit over twaalf uur blijft achteruit draaien');
assert.equal(context.__clock24.crossing(2,358),1,'de grote wijzer verhoogt het uur bij een voorwaartse omloop');
assert.equal(context.__clock24.crossing(358,2),-1,'de grote wijzer verlaagt het uur bij een achterwaartse omloop');
assert.equal(context.__clock24.crossing(30,20),0,'bewegen binnen hetzelfde uur verzet de kleine wijzer niet');
assert.deepEqual([
context.__clock24.step(11,1,false),context.__clock24.step(12,1,false),context.__clock24.step(1,-1,false),
context.__clock24.step(23,1,true),context.__clock24.step(0,-1,true),
],[12,1,12,0,23]);
assert.equal(context.__clock24.angle(719,2),1,'na 23 uur draait de klok door naar 0 uur');
assert.equal(context.__clock24.angle(1,-2),719,'achteruit vanaf 0 uur komt de klok bij 23 uur');
assert.deepEqual([0,11,12,23].map(hour=>context.__clock24.hour(hour*30,0)),[0,11,12,23]);
});
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('letter- en cijferdoelvakken blijven gecentreerd en lange woorden blijven bereikbaar',async()=>{
const css=await readFile('public/css/teach.css','utf8');
const start=css.indexOf('/* Letter- en cijferdoelen staan intrinsiek in het midden.');
const end=css.indexOf('/* ============================================================',start);
assert.ok(start>0&&end>start,'gedeelde doelvakregel ontbreekt');
const centered=css.slice(start,end);
for(const selector of [
'.lg-slots','.mt-slots','.clock-answer-slots','.money-answer-slots',
'.place-answer-slots','.world-spell-word',
])assert.ok(centered.includes(selector),selector+' mist expliciete centrering');
for(const declaration of [
'width:max-content','max-width:100%','margin-inline:auto','justify-content:safe center',
'overflow-x:auto','scrollbar-width:thin',
])assert.ok(centered.includes(declaration),declaration+' ontbreekt bij de doelvakken');
assert.match(css,/\.slot\.filled\{[^}]*justify-content:center/,'geplaatst blok staat niet midden in het vak');
const compact=css.slice(css.indexOf('@container widget (max-width:520px)'));
assert.doesNotMatch(compact,/\.lg-slots\{[^}]*width:100%/,'compacte woordrij mag niet links worden uitgerekt');
assert.doesNotMatch(compact,/\.lg-slots\{[^}]*justify-content:flex-start/,'compacte woordrij mag niet links worden vastgezet');
});
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',
trees:'.world-gallery',
birds:'.world-picture',
plants:'.world-picture',
topography:'.topo-map',
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',
light:'.tl-house',
mood:'.mood',
poll:'.poll',
noise:'.nm-gauge',
feelings:'.fz-zones',
breathe:'.bre-circle',
compliments:'.cpl-card',
avatar:'.avatar-editor',
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:safe center;justify-content:safe center',
'.money-task-card,.clock-task-card,.world-task-card',
'.world-gallery{grid-template-columns:1fr}',
'.world-photo-credit',
'.world-photo-answers{gap:6px}',
'.topo-map-wrap{padding:6px}',
'.world-map-wrap{padding:4px}',
'.topo-world-legend{gap:4px}',
]){
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"'));
});
test('alle widgetgroepen delen een kindvriendelijke spelstijl met toegankelijke bediening',async()=>{
const [css,board]=await Promise.all([readFile('public/css/teach.css','utf8'),readFile('public/js/board.js','utf8')]);
assert.ok(css.includes('.widget:not(.bare){border-top:4px solid var(--widget-accent)}'));
assert.ok(css.includes('.widget-body .tbtn:active{transform:translateY(2px) scale(.985)'));
assert.ok(css.includes('@media(prefers-reduced-motion:reduce)'));
for(const id of ['letters','clock','trees','topography','birthday','light','media']){
assert.ok(css.includes(`[data-widget="${id}"]`),id+' mist een herkenbaar categorie-accent');
}
assert.match(board,/id:"light"[^\n]*cat:"class"[^\n]*mount:mountLight/);
});
test('compacte widgets hebben nu speldoel, voortgang en directe feedback',async()=>{
const [flits,hak,dice,names,notes,numberline,learning,sentence,light]=await Promise.all([
'flits','hak','dice','names','notes','numberline','learning','sentence','light'
].map(name=>readFile(`public/js/widgets/${name}.js`,'utf8')));
assert.ok(flits.includes('class="fl-reflect"')&&flits.includes('S.score++')&&flits.includes('if(assessed)return'));
assert.ok(dice.includes('dc-history')&&dice.includes('history.push(total)'));
assert.ok(names.includes('np-progress')&&names.includes('np-reset'));
assert.ok(notes.includes('notes-swatches')&&notes.includes('notes-count'));
assert.ok(numberline.includes('nlw-score')&&numberline.includes('S.streak++'));
assert.ok(learning.includes("stage.className='fraction-game'")&&learning.includes("className='class-total'"));
assert.ok(sentence.includes('sb-surprise')&&sentence.includes("replace(/\\s+([,.!?])/g"));
assert.ok(light.includes('aria-pressed')&&light.includes('lightStatus'));
const chunks=hak.slice(hak.indexOf('function soundChunks'),hak.indexOf('/* =========================================================',hak.indexOf('function soundChunks')));
const context={LANG:'nl'};
runInNewContext(chunks+';globalThis.examples=[soundChunks("boom"),soundChunks("schaap"),soundChunks("koning")]',context);
assert.deepEqual(JSON.parse(JSON.stringify(context.examples)),[['b','oo','m'],['sch','aa','p'],['k','o','n','i','ng']]);
});
test('topografie heeft een inhoudelijk brede naam en de beta-versie is gelijkgetrokken',async()=>{
const [core,version]=await Promise.all([readFile('public/js/core.js','utf8'),readFile('VERSION','utf8')]);
assert.ok(core.includes('wg_topography:"Topografie"'));
assert.ok(core.includes('wg_topography:"Topography"'));
assert.doesNotMatch(core,/wg_topography:"(?:Topografie Nederland|Map of the Netherlands)"/);
assert.match(core,new RegExp(`const VERSION = "${version.trim().replaceAll('.','\\.')}"`));
/* geen vast nummer pinnen (dat breekt bij elke bump): het beleid is dat de
dev-branch altijd een -beta-versie voert en core.js gelijkloopt (hierboven) */
assert.match(version,/^\d+\.\d+\.\d+-beta\s*$/);
});