import test from 'node:test'; import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; import {runInNewContext} from 'node:vm'; function fakeAvatarElement(){ const classes=new Set(); return { attributes:{}, dataset:{}, children:[], innerHTML:"", removed:false, classList:{add(...ids){ids.forEach(id=>classes.add(id));},remove(...ids){ids.forEach(id=>classes.delete(id));},contains(id){return classes.has(id);}}, setAttribute(name,value){this.attributes[name]=String(value);}, appendChild(child){this.children.push(child);return child;}, remove(){this.removed=true;}, }; } async function loadAvatarRenderer(){ const source=await readFile('public/js/avatar.js','utf8'); const cutoff=source.indexOf('/* bouwt de interactieve kiezer'); assert.ok(cutoff>0,'rendergedeelte van avatar.js ontbreekt'); const timers=[]; const context={ T:key=>key, currentUser:{avatar:{species:"animal",speciesVariant:"cat",bodyColor:"#3fae6a"}}, window:{matchMedia:()=>({matches:false})}, setTimeout(fn,delay){timers.push({fn,delay});}, document:{createElement:fakeAvatarElement,createElementNS:fakeAvatarElement}, }; runInNewContext(source.slice(0,cutoff)+ ';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,sanitize:avatarSanitize,variants:AVATAR_VARIANTS,hair:AVATAR_HAIR_MARKUP,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP,hairIds:AVATAR_HAIR,headIds:AVATAR_HEAD_ACC,bodyIds:AVATAR_BODY_ACC,dances:AVATAR_DANCES,nextDance:avatarDanceChoice,celebrate:celebrateAvatar};', context); context.__avatar.timers=timers; return context.__avatar; } const base={ species:'dino', speciesVariant:'rex', bodyColor:'#3fae6a', hairStyle:'none', hairColor:'#5b3a29', accessoryHead:'none', accessoryBody:'none', }; test("iedere categorie heeft vier anatomisch herkenbare avatarvarianten",async()=>{ const api=await loadAvatarRenderer(); const variants={ dino:["rex","triceratops","stegosaurus","raptor"], monster:["horned","cyclops","slime","alien"], animal:["cat","dog","rabbit","bear"], }; for(const [species,ids] of Object.entries(variants)){ assert.deepEqual(Array.from(api.variants[species]),ids); const bodies=[]; for(const id of ids){ const markup=api.avatarBodyMarkup(species,base.bodyColor,id); bodies.push(markup); assert.ok(markup.includes("av-creature-"+id),species+"/"+id+" mist variantklasse"); assert.equal((markup.match(/av-arm av-arm-/g)||[]).length,2,species+"/"+id+" mist twee armen"); assert.equal((markup.match(/av-leg av-leg-/g)||[]).length,2,species+"/"+id+" mist twee benen"); for(const part of ["av-neck","av-torso-"+species,"av-head-large","av-eye"]){ assert.ok(markup.includes(part),species+"/"+id+" mist "+part); } assert.ok(markup.indexOf("av-arm")api.avatarBodyMarkup(species,base.bodyColor,id); assert.ok(markup("dino","rex").includes("av-tail-rex")); assert.ok(markup("dino","triceratops").includes("av-frill-triceratops")); assert.ok(markup("dino","triceratops").includes("av-nose-horn")); assert.ok(markup("dino","stegosaurus").includes("av-dino-plates")); assert.ok(markup("dino","raptor").includes("av-feather-crest-raptor")); assert.ok(markup("dino","raptor").includes("av-feather-chest")); assert.ok(markup("monster","horned").includes("av-horn-left")); assert.equal((markup("monster","cyclops").match(/class="av-part av-eye/g)||[]).length,1); assert.ok(markup("monster","cyclops").includes("av-cyclops-eye")); assert.ok(markup("monster","slime").includes("av-slime-drop")); assert.equal((markup("monster","alien").match(/av-alien-eye/g)||[]).length,3); assert.ok(markup("monster","alien").includes("av-antenna-tip")); assert.ok(markup("animal","cat").includes("av-whiskers")); assert.ok(markup("animal","dog").includes("av-ear-dog")); assert.ok(markup("animal","dog").includes("av-tongue")); assert.ok(markup("animal","rabbit").includes("av-ear-rabbit")); assert.ok(markup("animal","rabbit").includes("av-rabbit-teeth")); assert.ok(markup("animal","bear").includes("av-ear-bear")); assert.ok(markup("animal","bear").includes("av-muzzle-bear")); assert.equal(api.sanitize({species:"dino"}).speciesVariant,"rex"); assert.equal(api.sanitize({species:"monster"}).speciesVariant,"horned"); assert.equal(api.sanitize({species:"animal"}).speciesVariant,"cat"); assert.equal(api.sanitize({species:"animal",speciesVariant:"rabbit"}).speciesVariant,"rabbit"); assert.equal(api.sanitize({species:"monster",speciesVariant:"rabbit"}).speciesVariant,"horned"); }); test("voltooide opdrachten rouleren door vijf herkenbare avatardansen",async()=>{ const api=await loadAvatarRenderer(); const dances=["bounce","twist","spin","shuffle","wiggle"]; assert.deepEqual(Array.from(api.dances),dances); assert.deepEqual(dances.map(()=>api.nextDance()),dances); assert.equal(api.nextDance("spin"),"spin"); assert.equal(api.nextDance("onbekend"),"bounce"); const hostClasses=new Set(),stages=[]; const host={ classList:{add:id=>hostClasses.add(id),remove:id=>hostClasses.delete(id)}, querySelectorAll:()=>stages.filter(stage=>!stage.removed), querySelector:()=>stages.find(stage=>!stage.removed)||null, appendChild(stage){stages.push(stage);}, }; const stage=api.celebrate(host,{dance:"shuffle"}); assert.equal(stage.dataset.dance,"shuffle"); assert.equal(stage.attributes["aria-hidden"],"true"); assert.equal(stage.children.length,1); assert.ok(stage.children[0].classList.contains("av-dancer")); assert.ok(hostClasses.has("avatar-dance-host")); assert.equal(api.timers[0].delay,3400); api.timers[0].fn(); assert.equal(stage.removed,true); assert.equal(hostClasses.has("avatar-dance-host"),false); const [css,letters,math,flits,ball]=await Promise.all([ readFile("public/css/teach.css","utf8"), readFile("public/js/widgets/letters.js","utf8"), readFile("public/js/widgets/math.js","utf8"), readFile("public/js/widgets/flits.js","utf8"), readFile("public/js/widgets/ball.js","utf8"), ]); for(const dance of dances){ assert.ok(css.includes(".av-dance-"+dance),dance+" mist een dansklasse"); assert.ok(css.includes("@keyframes avDance"+dance[0].toUpperCase()+dance.slice(1)),dance+" mist keyframes"); } assert.ok(css.includes(".av-dance-reduced"),"verminderde beweging mist een statische terugval"); assert.match(letters,/function showCardDone\(\)[\s\S]*celebrateAvatar\(root\)/); assert.match(math,/function endRound\(\)[\s\S]*celebrateAvatar\(root\)/); assert.match(flits,/S\.idx >= S\.order\.length\)\{ celebrateAvatar\(root\); newRound\(\); \}/); assert.match(ball,/S\.idx >= S\.order\.length\) celebrateAvatar\(root\)/); }); test("haar volgt de kop met natuurlijke details en correcte lagen",async()=>{ const api=await loadAvatarRenderer(); assert.deepEqual(Array.from(api.hairIds),["none","tuft","spiky","curly","wavy","bob","ponytail","braids","mohawk"]); for(const id of api.hairIds){ assert.equal(typeof api.hair[id].back,"function",id+" mist haar-achterlaag"); assert.equal(typeof api.hair[id].front,"function",id+" mist haar-voorlaag"); } for(const id of api.hairIds.filter(id=>id!=="none")){ const front=api.hair[id].front("#123456"); const back=api.hair[id].back("#123456"); const combined=back+front; assert.ok(front.includes("av-hair-"+id),id+" mist een herkenbare voorlaag"); assert.ok(combined.includes("av-hair-texture"),id+" mist natuurlijke haartextuur"); assert.doesNotMatch(combined,/undefined|NaN/,id+" bevat ongeldige SVG-waarden"); } for(const id of ["curly","wavy","bob","ponytail","braids"]){ assert.notEqual(api.hair[id].back("#123456"),"",id+" mist volume achter de kop"); } for(const id of ["tuft","spiky","mohawk"]){ assert.equal(api.hair[id].back("#123456"),"",id+" hoort geen onzichtbare achterlaag te hebben"); } for(const id of ["wavy","bob","ponytail","braids"]){ assert.ok(api.hair[id].front("#123456").includes("av-hair-part"),id+" mist een natuurlijke scheiding"); } assert.ok(api.hair.wavy.front("#123456").includes("av-side-lock")); assert.ok(api.hair.bob.front("#123456").includes("av-side-lock")); assert.ok(api.hair.ponytail.back("#123456").includes("av-hair-tie")); assert.ok(api.hair.braids.back("#123456").includes("av-hair-tie")); const transforms={dino:"translate(1 0)",monster:"translate(0 -1)",animal:"translate(0 0)"}; for(const [species,transform] of Object.entries(transforms)){ const markup=api.renderAvatarFace({...base,species,hairStyle:"wavy",hairColor:"#123456"},"lg").innerHTML; assert.equal((markup.match(new RegExp("av-hair-fit-"+species,"g"))||[]).length,2,species+" mist voor- of achterpasvorm"); assert.ok(markup.includes(`transform="${transform}"`),species+" gebruikt een verkeerde kruincorrectie"); assert.ok(markup.indexOf("av-hair-back-layer")markup.indexOf("av-head-large")); } const bob=api.renderAvatarFace({...base,hairStyle:"bob"},"lg").innerHTML; assert.ok(bob.indexOf("av-hair-bob")bob.indexOf("av-head-large")); const braids=api.renderAvatarFace({...base,hairStyle:"braids"},"lg").innerHTML; assert.ok(braids.indexOf("av-hair-braids")braids.indexOf("av-head-large")); const cap=api.renderAvatarFace({...base,hairStyle:"spiky",hairColor:"#123456",accessoryHead:"cap"},"lg").innerHTML; const beanie=api.renderAvatarFace({...base,hairStyle:"spiky",hairColor:"#123456",accessoryHead:"beanie"},"lg").innerHTML; const crown=api.renderAvatarFace({...base,hairStyle:"spiky",hairColor:"#123456",accessoryHead:"crown"},"lg").innerHTML; const cappedBraids=api.renderAvatarFace({...base,hairStyle:"braids",hairColor:"#123456",accessoryHead:"cap"},"lg").innerHTML; assert.ok(!cap.includes("av-hair-front-layer"),"pet hoort kort haar af te dekken"); assert.ok(!beanie.includes("av-hair-front-layer"),"muts hoort kort haar af te dekken"); assert.ok(crown.includes("av-hair-front-layer"),"kroon hoort op het haar te rusten"); assert.ok(cappedBraids.includes("av-hair-back-layer"),"vlechten horen naast een pet zichtbaar te blijven"); assert.ok(!cappedBraids.includes("av-hair-front-layer"),"pet hoort de bovenkant van vlechten af te dekken"); }); test('accessoires gebruiken vaste ankers en correcte voor- en achterlagen',async()=>{ const api=await loadAvatarRenderer(); assert.deepEqual(Array.from(api.headIds),['none','cap','beanie','crown','wizardhat','glasses','sunglasses','eyepatch','bow','flower','headphones','partyhat']); assert.deepEqual(Array.from(api.bodyIds),['none','scarf','cape','badge','necklace','bowtie','belt','backpack','satchel','wings']); for(const id of api.bodyIds){ assert.equal(typeof api.bodyAccessories[id].back,'function',id+' mist achterlaag'); assert.equal(typeof api.bodyAccessories[id].front,'function',id+' mist voorlaag'); } for(const [id,part] of Object.entries({beanie:'av-beanie',wizardhat:'av-wizardhat',eyepatch:'av-eyepatch',flower:'av-flower'})){ const markup=api.renderAvatarFace({...base,accessoryHead:id},'lg').innerHTML; assert.ok(markup.indexOf(part)>markup.indexOf('av-head-large'),id+' hoort op de kop'); } const cyclopsGlasses=api.renderAvatarFace({...base,species:"monster",speciesVariant:"cyclops",accessoryHead:"glasses"},"lg").innerHTML; const cyclopsPatch=api.renderAvatarFace({...base,species:"monster",speciesVariant:"cyclops",accessoryHead:"eyepatch"},"lg").innerHTML; const alienGlasses=api.renderAvatarFace({...base,species:"monster",speciesVariant:"alien",accessoryHead:"glasses"},"lg").innerHTML; const alienShades=api.renderAvatarFace({...base,species:"monster",speciesVariant:"alien",accessoryHead:"sunglasses"},"lg").innerHTML; assert.ok(cyclopsGlasses.includes("av-glasses-cyclops"),"cycloopbril hoort rond het ene oog"); assert.ok(cyclopsPatch.includes("av-eyepatch-cyclops"),"cycloopooglap hoort voor het ene oog"); assert.ok(alienGlasses.includes("av-glasses-alien"),"alienbril hoort rond drie ogen"); assert.ok(alienShades.includes("av-sunglasses-alien"),"alienzonnebril hoort alle drie ogen af te dekken"); const cape=api.renderAvatarFace({...base,accessoryBody:'cape'},'lg').innerHTML; assert.ok(cape.indexOf('av-cape"')cape.indexOf('av-head-large')); const backpack=api.renderAvatarFace({...base,accessoryBody:'backpack'},'lg').innerHTML; assert.ok(backpack.indexOf('av-backpack"')backpack.indexOf('av-head-large')); const satchel=api.renderAvatarFace({...base,accessoryBody:'satchel'},'lg').innerHTML; assert.ok(satchel.indexOf('av-satchel"')satchel.indexOf('av-head-large')); const wings=api.renderAvatarFace({...base,accessoryBody:'wings'},'lg').innerHTML; assert.ok(wings.indexOf('av-wings')wings.indexOf('av-head-large')); for(const [id,part] of [['scarf','av-scarf'],['badge','av-badge'],['necklace','av-necklace'],['bowtie','av-bowtie'],['belt','av-belt']]){ const markup=api.renderAvatarFace({...base,accessoryBody:id},'lg').innerHTML; assert.ok(markup.indexOf(part)>markup.indexOf('av-head-large'),id+' hoort voor op het lijf'); } }); test("alle 12960 variant-, haar- en accessoirecombinaties renderen volledig",async()=>{ const api=await loadAvatarRenderer(); let combinations=0; for(const speciesId of ["dino","monster","animal"]){ for(const variantId of api.variants[speciesId]){ for(const hairId of api.hairIds){ for(const headId of api.headIds){ for(const bodyId of api.bodyIds){ const svg=api.renderAvatarFace({ ...base, species:speciesId, speciesVariant:variantId, hairStyle:hairId, accessoryHead:headId, accessoryBody:bodyId, },"sm"); assert.equal(svg.attributes.viewBox,"-8 -21 116 132"); assert.equal(svg.attributes.preserveAspectRatio,"xMidYMid meet"); assert.doesNotMatch(svg.innerHTML,/undefined|NaN/); assert.ok(svg.innerHTML.includes("av-head-large")); assert.ok(svg.innerHTML.includes("av-creature-"+variantId)); combinations++; } } } } } assert.equal(combinations,12960); }); test("client, API en beide talen bevatten dezelfde avataropties",async()=>{ const [apiSource,core]=await Promise.all([ readFile("src/api.js","utf8"), readFile("public/js/core.js","utf8"), ]); const hair=["tuft","wavy","bob","braids"]; const accessories=["beanie","wizardhat","eyepatch","flower","bowtie","belt","satchel","wings"]; const variants=["rex","triceratops","stegosaurus","raptor","horned","cyclops","slime","alien","cat","dog","rabbit","bear"]; for(const id of hair){ assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-validatie"); assert.equal(core.split("avatarHair_"+id+":").length-1,2,id+" mist een vertaling"); } for(const id of accessories){ assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-validatie"); assert.equal(core.split("avatarAcc_"+id+":").length-1,2,id+" mist een vertaling"); } for(const id of variants){ assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-variantvalidatie"); assert.equal(core.split("avatarVariant_"+id+":").length-1,2,id+" mist een variantvertaling"); } assert.ok(apiSource.includes("speciesVariant"),"API slaat de soortvariant niet op"); assert.equal(core.split("avatarVariant:").length-1,2,"variantveld mist een label"); });