teach/test/coloring.test.js
Ramon 6a08cee75c
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 39s
feat: herteken kleurplaten als illustraties (v0.4.73-beta)
- vervang alle 28 kleurplaten door samenhangende scènes met organische contouren en leeftijdsgerichte detaillering
- houd buiten- en binnenlijnen zichtbaar boven kwastwerk en sluit verfemmerkleuren zonder witte rand aan
- borg gesloten kleurvakken, anatomische laagvolgorde en scene-opbouw met uitgebreide regressietests
2026-07-29 15:44:38 +02:00

209 lines
13 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';
async function coloringContext(){
const files=[
'public/js/widgets/coloring-scenes-junior.js','public/js/widgets/coloring-scenes-middle.js',
'public/js/widgets/coloring-scenes-senior.js','public/js/widgets/coloring-scenes-advanced.js',
'public/js/widgets/coloring.js'
];
const source=(await Promise.all(files.map(file=>readFile(file,'utf8')))).join('\n');
const context={};
runInNewContext(source+`;
globalThis.__coloring={
pages:COLORING_PAGES,
themes:COLORING_THEME_KEYS,
pagesFor:coloringPagesFor,
brushWidth:coloringBrushWidth,
safeColor:coloringSafeColor
}`,context);
return{source,api:context.__coloring};
}
test('kleurplatenwidget laadt vóór het bord en is als creatieve leerlingwidget geregistreerd',async()=>{
const [html,board,pupil,cast,core,css]=await Promise.all([
readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8'),
readFile('public/js/pupil.js','utf8'),readFile('public/js/cast.js','utf8'),
readFile('public/js/core.js','utf8'),readFile('public/css/coloring.css','utf8')
]);
for(const name of ['junior','middle','senior','advanced']){
assert.ok(html.indexOf(`js/widgets/coloring-scenes-${name}.js`)>0);
assert.ok(html.indexOf(`js/widgets/coloring-scenes-${name}.js`)<html.indexOf('js/widgets/coloring.js'));
}
assert.ok(html.indexOf('js/widgets/coloring.js')>0);
assert.ok(html.indexOf('js/widgets/coloring.js')<html.indexOf('js/board.js'));
assert.match(html,/css\/coloring\.css\?v=__V__/);
assert.match(board,/id:"coloring"[^\n]*cat:"creatief"[^\n]*mount:mountColoring/);
assert.match(pupil,/PUPIL_WIDGET_CATS\s*=\s*\[[^\]]*"creatief"/);
assert.match(cast,/CAST_WIDGET_CATEGORIES=new Set\(\[[^\]]*"creatief"/);
assert.match(css,/\.coloring-paper[^}]*aspect-ratio:4\/3/);
for(const key of ['wg_coloring','wg_coloring_d','catCreative','colorToolBrush','colorToolBucket','colorBucketHint','colorBrushHint']){
assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key);
}
});
test('elk leerjaar heeft zeven herkenbare kleurplaten met diverse themas en drie detailniveaus',async()=>{
const {api}=await coloringContext();
const pages=JSON.parse(JSON.stringify(api.pages));
assert.equal(pages.length,28);
assert.equal(new Set(pages.map(page=>page.id)).size,pages.length);
const added=[
'farmyard','rainbow-weather','construction-site','forest-picnic',
'dinosaur-valley','spring-nest','fire-station','bee-meadow',
'coral-reef','mountain-camp','robot-workshop','canal-street',
'solar-observatory','savanna-web','japanese-garden'
];
assert.equal(added.filter(id=>pages.some(page=>page.id===id)).length,15);
for(let year=1;year<=8;year++){
const suitable=JSON.parse(JSON.stringify(api.pagesFor(year)));
assert.equal(suitable.length,7,`groep ${year} heeft zeven kleurplaten`);
assert.ok(suitable.every(page=>year>=page.years[0]&&year<=page.years[1]));
assert.equal(new Set(suitable.map(page=>page.theme)).size,5,`groep ${year} heeft alle vijf thema's`);
}
for(const page of pages){
assert.ok(api.themes[page.theme],page.id+' heeft een bekend thema');
assert.ok(page.title.nl&&page.title.en,page.id+' heeft een tweetalige titel');
const parts=[...page.svg.matchAll(/data-part="([^"]+)"/g)].map(match=>match[1]);
const details=(page.svg.match(/class="coloring-detail"/g)||[]).length;
const figures=(page.svg.match(/class="coloring-figure"/g)||[]).length;
const openFillablePaths=[...page.svg.matchAll(/<path\b[^>]*data-fillable="true"[^>]*data-part="([^"]+)"[^>]* d="([^"]+)"/g)]
.filter(match=>!/[zZ]\s*$/.test(match[2])).map(match=>match[1]);
assert.ok(parts.length>=12,page.id+' heeft voldoende afzonderlijke kleurvakken');
assert.equal(new Set(parts).size,parts.length,page.id+' heeft unieke kleurvakken');
assert.deepEqual(openFillablePaths,[],page.id+' heeft alleen gesloten aanklikbare paden');
assert.ok(details>=1,page.id+' heeft herkenbaar, begrensd binnenlijnwerk');
assert.ok(figures>=1,page.id+' groepeert onderdelen tot samenhangende figuren');
assert.match(page.svg,/class="coloring-sheet"[^>]*preserveAspectRatio="xMidYMid meet"/);
assert.match(page.svg,/data-role="backdrop"/,page.id+' heeft een subtiel buitenkader');
assert.match(page.svg,new RegExp(`data-scene="${page.id}"`));
assert.match(page.svg,/data-level="2"/);
assert.match(page.svg,/data-level="3"/);
assert.doesNotMatch(page.svg,/<script|on\w+=/i);
}
const landmarks={
farmyard:['barn','cow-body','cow-head','cow-horn-left','cow-tail'],
'rainbow-weather':['umbrella-left','umbrella-middle','umbrella-right','boot-left'],
'construction-site':['digger-body','bucket','bucket-tooth-a'],
ocean:['fish-body','turtle-shell','turtle-tail'],
rainforest:['forest-canopy-left','parrot-body','parrot-branch'],
'dinosaur-valley':['volcano','dino-body','dino-leg-0','dino-plate-0'],
'fire-station':['engine-body','engine-ladder','engine-hose-reel'],
'coral-reef':['clownfish-body','coral-red','anemone-base','reef-starfish'],
'savanna-web':['lion-mane','zebra-neck','zebra-muzzle','zebra-leg-0'],
'robot-workshop':['robot-head','robot-body','robot-arm-left','gear-a'],
'japanese-garden':['pagoda-base','bridge-deck','cherry-trunk','koi-body']
};
for(const [id,names] of Object.entries(landmarks)){
const page=pages.find(item=>item.id===id);
names.forEach(name=>assert.ok(page.svg.includes(`data-part="${name}"`),`${id} mist ${name}`));
}
const organicContours={
butterfly:['wing-left-top','butterfly-abdomen'],ocean:['fish-body','turtle-shell'],garden:['snail-body','ladybird-body'],
farmyard:['cow-body','cow-head'],'forest-picnic':['rabbit-body','rabbit-head'],
'dinosaur-valley':['dino-body'],'spring-nest':['bird-body','bird-head'],
'bee-meadow':['bee-abdomen','bee-head'],'coral-reef':['clownfish-body','seahorse-body'],
'savanna-web':['lion-body','lion-mane','zebra-body','zebra-head'],rainforest:['parrot-body','parrot-head'],
ecosystem:['frog-body'],rocket:['rocket-body'],'fire-station':['engine-chassis']
};
for(const [id,names] of Object.entries(organicContours)){
const page=pages.find(item=>item.id===id);
names.forEach(name=>{
const match=page.svg.match(new RegExp(`<path\\b[^>]*data-part="${name}"[^>]*d="([^"]+)"`));
assert.ok(match,`${id}/${name} gebruikt een getekend contoursilhouet`);
assert.ok((match[1].match(/C/g)||[]).length>=2,`${id}/${name} heeft vloeiende Bézierovergangen`);
});
}
const paintOrder={
farmyard:['cow-tail','cow-leg-0','cow-body','cow-head','cow-snout'],
'forest-picnic':['rabbit-tail','rabbit-hind-leg','rabbit-ear-back','rabbit-body','rabbit-head'],
'dinosaur-valley':['dino-leg-0','dino-plate-0','dino-body','dino-belly'],
'spring-nest':['bird-tail-a','bird-body','bird-wing','bird-head','bird-beak'],
ocean:['fish-tail','fish-top-fin','fish-body','turtle-tail','turtle-back-flipper','turtle-shell','turtle-head'],
'bee-meadow':['bee-wing-back','bee-abdomen','bee-stripe-a','bee-head'],
'coral-reef':['clownfish-tail','clownfish-fin-top','clownfish-body','seahorse-fin','seahorse-body'],
'savanna-web':['lion-tail','lion-leg-0','lion-body','lion-mane','zebra-tail','zebra-leg-0','zebra-body','zebra-neck','zebra-head'],
rainforest:['parrot-branch','parrot-tail-long','parrot-body','parrot-wing','parrot-head','parrot-beak'],
ecosystem:['frog-leg-a','frog-body','frog-eye-a'],
rocket:['flame-outer','rocket-left-fin','rocket-body','rocket-window'],
'fire-station':['engine-chassis','engine-body','engine-cab','engine-ladder','engine-wheel-a','engine-hub-a'],
'robot-workshop':['robot-leg-left','robot-arm-left','robot-body','robot-neck','robot-head'],
windmill:['mill-body','mill-roof','sail-north','mill-hub']
};
for(const [id,names] of Object.entries(paintOrder)){
const page=pages.find(item=>item.id===id),positions=names.map(name=>page.svg.indexOf(`data-part="${name}"`));
assert.ok(positions.every(position=>position>=0),`${id} bevat alle aansluitende onderdelen`);
assert.deepEqual([...positions].sort((a,b)=>a-b),positions,`${id} schildert appendages achter het hoofdsilhouet`);
}
assert.equal(api.pagesFor(5,'science').length,2);
assert.equal(api.pagesFor(3,'science').length,1);
});
test('kwast reageert op pendruk en één gemaskeerde contourlaag houdt alleen buitenlijnen zichtbaar',async()=>{
const {source,api}=await coloringContext();
assert.ok(api.brushWidth(7,.9,'pen')>api.brushWidth(7,.2,'pen'));
assert.equal(api.brushWidth(7,0,'mouse'),7);
assert.equal(api.safeColor('#AABBCC'),'#aabbcc');
assert.equal(api.safeColor('red'),'#e0554d');
for(const feature of ['getCoalescedEvents','event.pressure','erase?1:.72','quadraticCurveTo'])assert.ok(source.includes(feature),feature);
assert.match(source,/const savedTool=initState&&initState\.tool==="pencil"\?"brush"/);
assert.match(source,/\["brush","🖌️","colorToolBrush"\]/);
const css=await readFile('public/css/coloring.css','utf8');
assert.match(css,/mix-blend-mode:multiply/);
assert.match(source,/class="coloring-art"><\/div><canvas[^>]+><\/canvas><div class="coloring-ink" aria-hidden="true"><\/div>/);
assert.match(source,/coloringInkOverlay\(svg\)/);
assert.match(source,/const overlay=svg\.cloneNode\(true\)/);
assert.match(source,/overlay\.querySelectorAll\("\.coloring-recognition"\)\.forEach\(node=>node\.remove\(\)\)/);
assert.doesNotMatch(source,/overlay\.querySelectorAll\("\.coloring-detail/);
assert.match(source,/node\.removeAttribute\("data-fillable"\)/);
assert.match(source,/node\.setAttribute\("fill","#fff"\)/);
assert.match(source,/inkHost\.replaceChildren\(coloringInkOverlay\(svg\)\)/);
assert.doesNotMatch(source,/coloring-ink-halo|feMorphology|feColorMatrix/);
assert.match(source,/class="coloring-region" data-fillable="true"/);
assert.match(source,/class="coloring-sheet"[^>]*preserveAspectRatio="xMidYMid meet"/);
assert.match(source,/fill-rule="evenodd" clip-rule="evenodd"[^>]*stroke-width="4\.8"[^>]*stroke-miterlimit="2"/);
assert.match(css,/\.coloring-svg-lines\{shape-rendering:geometricPrecision\}/);
assert.match(css,/\.coloring-region\[data-role="backdrop"\]\{stroke-width:2\.8\}/);
assert.match(css,/\.coloring-art\{z-index:1\}\.coloring-paper canvas\{z-index:2\}\.coloring-ink\{z-index:3;pointer-events:none\}/);
assert.match(css,/\.coloring-svg-lines \.coloring-recognition\{display:none\}/);
assert.match(css,/\.coloring-ink-overlay\{pointer-events:none;shape-rendering:geometricPrecision;mix-blend-mode:multiply\}/);
assert.match(css,/\.coloring-ink-svg \.coloring-region\{fill:#fff!important;stroke:var\(--coloring-ink\);stroke-width:4\.8;stroke-opacity:1;paint-order:fill stroke\}/);
assert.match(css,/\.coloring-ink-svg \.coloring-detail\{stroke:var\(--coloring-detail\);stroke-width:2\.4/);
assert.doesNotMatch(css,/coloring-ink-halo/);
assert.match(css,/\.coloring-app\[data-tool="bucket"\] \.coloring-paper canvas\{pointer-events:none\}/);
assert.match(source,/canvas\.style\.pointerEvents=tool==="bucket"\?"none":"auto";art\.style\.pointerEvents=tool==="bucket"\?"auto":"none"/);
assert.match(source,/className="coloring-tool-label"/);
assert.match(source,/toolHost\.setAttribute\("role","toolbar"\)/);
assert.match(css,/\.coloring-tool-buttons button\{width:auto;min-width:72px/);
});
test('verfemmer vult een SVG-vak en de gum wist zowel kwast als opgeslagen vlakkleur',async()=>{
const {source}=await coloringContext();
assert.match(source,/event\.target\.closest\("\[data-fillable\]\[data-part\]\"\)/);
assert.match(source,/part\.setAttribute\("fill",color\)/);
assert.match(source,/fills\[part\.dataset\.part\]=color/);
assert.match(source,/document\.elementFromPoint\(event\.clientX,event\.clientY\)/);
assert.match(source,/Object\.prototype\.hasOwnProperty\.call\(fills,part\.dataset\.part\)/);
assert.match(source,/delete fills\[part\.dataset\.part\];part\.setAttribute\("fill","#fff"\)/);
assert.match(source,/if\(erase\)eraseFillAt\(event\)/);
assert.match(source,/canvas\.toDataURL\("image\/png"\)/);
assert.match(source,/return\{theme,pageId,fills:\{\.\.\.fills\},ink,tool,color,size,completed,yearGroup,level/);
assert.match(source,/opts\.onProgress/);
});
test('creatief niveau is beschikbaar per klas, leerling en thuiskind',async()=>{
const [api,admin,progressions,parent,wiki,migration,live]=await Promise.all([
readFile('src/api.js','utf8'),readFile('public/js/admin.js','utf8'),
readFile('public/js/learning-progressions.js','utf8'),readFile('public/js/parent.js','utf8'),
readFile('public/js/wiki.js','utf8'),
readFile('db/024_creative_pupil_levels.sql','utf8'),readFile('src/live-classroom.js','utf8')
]);
assert.match(api,/LEVEL_SUBJECTS = \[[^\]]*'creatief'/);
assert.match(admin,/KM_SUBJECTS = \[[^\]]*"creatief"/);
assert.match(progressions,/LEARNING_SUBJECTS=\[[^\]]*"creatief"/);
assert.match(parent,/\["taal","rekenen","creatief"\]\.forEach/);
assert.match(wiki,/creatief:"parentLevelCreative"/);
assert.match(migration,/CHECK \(subject IN \('taal','rekenen','world','creatief'\)\)/);
assert.match(live,/LEVEL_SUBJECTS = new Set\(\[[^\]]*'creatief'/);
});