teach/test/avatar.test.js
Ramon ae8d0d4bb4
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m23s
feat: voeg leerpaden dashboard en avatarbeloningen toe
2026-07-23 21:23:54 +02:00

303 lines
16 KiB
JavaScript

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,unlock:avatarAcceptRewards};',
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")<markup.indexOf("av-torso"),id+"-armen moeten achter de romp aansluiten");
assert.ok(markup.indexOf("av-leg")<markup.indexOf("av-torso"),id+"-benen moeten onder de romp aansluiten");
assert.ok(markup.indexOf("av-torso")<markup.indexOf("av-head-large"),id+"-kop moet de romp overlappen");
const svg=api.renderAvatarFace({...base,species,speciesVariant:id},"lg");
assert.ok(svg.attributes.class.includes("av-variant-"+id),id+" ontbreekt op SVG-klasse");
}
assert.equal(new Set(bodies).size,4,species+"-varianten moeten een uniek lichaam hebben");
}
const markup=(species,id)=>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 basisdansen en een vrijspeelbare robotdans",async()=>{
const api=await loadAvatarRenderer();
const basic=["bounce","twist","spin","shuffle","wiggle"];
const dances=[...basic,"robot"];
assert.deepEqual(Array.from(api.dances),dances);
assert.deepEqual(basic.map(()=>api.nextDance()),basic);
assert.equal(api.nextDance("robot"),"bounce","robotdans blijft eerst vergrendeld");
api.unlock(["dance:robot"]);
assert.equal(api.nextDance("robot"),"robot");
assert.equal(api.nextDance("spin"),"spin");
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"));
assert.ok(markup.indexOf("av-hair-front-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"));
assert.ok(bob.indexOf("av-hair-bob-fringe")>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"));
assert.ok(braids.indexOf("av-hair-braids-front")>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','star']);
assert.deepEqual(Array.from(api.bodyIds),['none','scarf','cape','badge','necklace','bowtie','belt','backpack','satchel','wings','medal']);
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-torso'));
assert.ok(cape.indexOf('av-cape-clasp')>cape.indexOf('av-head-large'));
const backpack=api.renderAvatarFace({...base,accessoryBody:'backpack'},'lg').innerHTML;
assert.ok(backpack.indexOf('av-backpack"')<backpack.indexOf('av-torso'));
assert.ok(backpack.indexOf('av-backpack-strap')>backpack.indexOf('av-head-large'));
const satchel=api.renderAvatarFace({...base,accessoryBody:'satchel'},'lg').innerHTML;
assert.ok(satchel.indexOf('av-satchel"')<satchel.indexOf('av-torso'));
assert.ok(satchel.indexOf('av-satchel-strap')>satchel.indexOf('av-head-large'));
const wings=api.renderAvatarFace({...base,accessoryBody:'wings'},'lg').innerHTML;
assert.ok(wings.indexOf('av-wings')<wings.indexOf('av-torso'));
assert.ok(wings.indexOf('av-wing-clasp')>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'],['medal','av-reward-medal']]){
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 15444 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,15444);
});
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","star","medal"];
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");
});