teach/test/widgets.test.js
Ramon 24d89b8bb5
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
feat: eigen personage (avatar) voor leerlingen, ouders en staf (v0.4.23-beta)
- Nieuwe migratie db/018_user_avatar.sql: kolom avatar_data (jsonb) op users.
- publicUser() geeft avatar overal automatisch mee (login, /auth/me,
  /admin/users) - bestaande school/klas-rechten regelen vanzelf wie welke
  personages ziet, geen nieuwe permissie-sleutel nodig.
- Nieuwe route PUT /me/avatar (open voor elke ingelogde rol, zelfde patroon
  als /me/data): valideert soort/kleuren/haar/accessoires, slaat de eigen rij op.
- Nieuwe gedeelde module public/js/avatar.js: personage samenstellen uit
  soort (dino/monster/dier - elk met een vast eigen lichaamsdeel, ook de
  gevraagde dinostekels), lichaams- en haarkleur, haarstijl en hoofd-/
  lichaamsaccessoires; gedeelde editor-UI voor zowel de instellingen-sectie
  "Mijn personage" als de nieuwe widget.
- Instellingen-sectie "Mijn personage" (cms.js-patroon) - de leerling-toegang,
  want widgets van categorie "class" zijn niet toewijsbaar in het
  afgeschermde leerling-speelscherm.
- Widget 🦖 toegevoegd aan het bord (categorie "class") voor staf en
  volledige-whiteboard-gebruikers.
- School-navigator (admin.js): leerkracht/beheerder ziet nu het personage
  van elk klaslid in de personenlijst i.p.v. altijd de initialen-cirkel.
- Voorbereiding gerelateerd roadmappunt: math.js toont het eigen personage
  kort naast "Goed!" als aanmoediging (pilot in één widget, geen nieuw
  feedbacktype); zonder ingesteld personage blijft het gedrag ongewijzigd.
- Vertalingen (nl+en) voor de nieuwe widget, instellingen-sectie en editor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ
2026-07-19 07:34:25 +02:00

437 lines
28 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('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*$/);
});