teach/test/widgets.test.js
Ramon 84fd02d135
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 35s
fix: libelle en dierenvormen herstellen, instellingen omdraaien (v0.4.81-beta)
- Libelle in de vijverplaat kreeg weer vier symmetrische vleugels
- Zeepaardje in het koraalrif herbouwd van een onherkenbare buis naar een
  duidelijk zeepaardje, verplaatst zodat het niet meer over de clownvis valt
- Nieuwe coloringLegPath()-vorm: giraffe en zebra hebben nu getapte,
  licht geknikte poten in plaats van rechte kokerpoten
- Instellingenindeling omgedraaid: de kleurplaat (thema/plaat) is weer
  direct zichtbaar, kleurhulp/reset gaan achter het tandwiel
- Letterblokken en rekenwidgets: niveauknoppen naar het tandwiel, het
  woord/thema blijft direct zichtbaar
- Klok, geldrekenen, plaatswaardetabel, boom&blad/vogels/planten en
  topografie: spel/activiteit (en bij geld/plaatswaarde het bijpassende
  veld) staan weer in de vaste bovenbalk, niveau staat achter het tandwiel
- Plaatswaardetabel toont nu ook in verkennen-stand een vaste bovenbalk
  (spel, getal, dobbelsteen) in plaats van alleen in bouwen-stand
- Bugfix onderweg: niveauwissel in verkennen-stand van de plaatswaardetabel
  sprong per ongeluk naar bouwen-stand - nu blijft de stand behouden

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YErxhic5w9K57zmt8Q1uS
2026-07-30 21:39:30 +02:00

460 lines
29 KiB
JavaScript
Raw Permalink 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\('clockLevel'\),levels\),field\(T\('clockLearningStyle'\),style\),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,[field(T('moneyLevel'),levels),styleSetting],stage)"),'spel en categorie kies je elke keer opnieuw en staan dus zichtbaar; niveau staat achter 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("if(S.game==='change')top.append(categorySetting)"));
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,coloringCss]=await Promise.all([
readFile('public/js/board.js','utf8'),
readFile('public/js/pupil.js','utf8'),
readFile('public/css/teach.css','utf8'),
readFile('public/css/coloring.css','utf8'),
]);
const styles=css+coloringCss;
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',
coloring:'.coloring-paper',
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(styles.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*$/);
});