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{ 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('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'){ 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(']+d="[^"]+"')); } assert.equal((map.match(/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('')); } assert.equal((worldMap.match(/{ 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?'12–23':'00–11'"), '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.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('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')&¬es.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*$/); });