teach/test/coloring.test.js
Ramon 03bfb9feda
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
fix: herstel gum en kleurplaatlagen (v0.4.72-beta)
- laat de gum ook verfpotvullingen bij tikken en slepen verwijderen
- vervang dubbele filterlagen door één gemaskeerde contourlaag zonder witte halo
- verberg overlappende herkenningshulplijnen en dek achterliggende contouren correct af
- voeg regressietests toe voor gum, vulranden en lijnlagen
2026-07-29 14:49:34 +02:00

200 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 [extra,widget]=await Promise.all([
readFile('public/js/widgets/coloring-scenes-extra.js','utf8'),readFile('public/js/widgets/coloring.js','utf8')
]),source=extra+'\n'+widget;
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')
]);
assert.ok(html.indexOf('js/widgets/coloring-scenes-extra.js')>0);
assert.ok(html.indexOf('js/widgets/coloring-scenes-extra.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 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>=14,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>=4,page.id+' heeft voldoende herkenbaar binnenlijnwerk');
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-outer-left','umbrella-inner-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-head','dino-tail'],
'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-a','gear-a'],
'japanese-garden':['pagoda-base','bridge','cherry-trunk','koi-a']
};
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','body'],ocean:['fish-body','turtle-shell'],garden:['snail-body','ladybird'],
farmyard:['cow-body','cow-head'],'forest-picnic':['rabbit-body','rabbit-head'],
'dinosaur-valley':['dino-body','dino-neck','dino-head'],'spring-nest':['bird-body','bird-head'],
'bee-meadow':['bee-body','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-body']
};
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-ear-a','rabbit-body','rabbit-hind-leg','rabbit-head'],
'dinosaur-valley':['dino-tail','dino-leg-0','dino-plate-0','dino-body','dino-neck','dino-head'],
'spring-nest':['bird-tail-a','bird-body','bird-wing','bird-head','bird-beak'],
ocean:['fish-tail','fish-top-fin','fish-body','turtle-tail','flipper-0','turtle-shell','turtle-head'],
'bee-meadow':['bee-wing-a','bee-body','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','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-wheel-a','engine-body','engine-cab','engine-hub-a'],
'robot-workshop':['robot-arm-a','robot-leg-a','robot-neck','robot-head','robot-body'],
windmill:['mill-body','mill-roof','sail-0','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-detail,\.coloring-recognition"\)\.forEach\(node=>node\.remove\(\)\)/);
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="5\.6"[^>]*stroke-miterlimit="2"/);
assert.match(css,/\.coloring-svg-lines\{shape-rendering:geometricPrecision\}/);
assert.match(css,/\.coloring-region\[data-role="backdrop"\]\{stroke-width:3\.2\}/);
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:5\.6;stroke-opacity:1\}/);
assert.doesNotMatch(css,/coloring-ink-halo|\.coloring-ink-svg \.coloring-detail/);
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'/);
});