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{ 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'){ 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.minvalue>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"')); });