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, clipsStrokes:coloringClipsStrokes, paper:COLORING_PAPER }`,context); return{source,api:context.__coloring}; } /* Telt de kleurvakken die op een niveau zichtbaar zijn. Een vak verdwijnt alleen doordat een omhullende groep een hoger data-tier heeft - vandaar een simpele tagscanner met een stapel. */ function visibleRegions(svg,level){ let count=0;const stack=[]; const tagPattern=/<(\/?)(g|path|circle|rect|ellipse|svg|defs)\b([^>]*)>/g; let match; while((match=tagPattern.exec(svg))){ const closing=match[1],name=match[2],attrs=match[3]; if(name==="g"){ if(closing)stack.pop(); else{ const tier=/data-tier="(\d)"/.exec(attrs); stack.push(tier?Number(tier[1]):1); } continue; } if(closing||!/data-fillable="true"/.test(attrs))continue; if(stack.every(tier=>tier<=level))count++; } return count; } const regionTags=svg=>[...svg.matchAll(/<(?:path|circle|rect|ellipse)\b[^>]*class="coloring-region[^"]*"[^>]*>/g)].map(match=>match[0]); const bandOf=page=>page.years[0]; /* de "mag niet voorkomen"-controles gaan over code, niet over commentaar: in de toelichting bóven het bestand staat juist wél waarom de oude blendmode-laag weg is */ const stripComments=text=>text.replace(/\/\*[\s\S]*?\*\//g,""); 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`)0); assert.ok(html.indexOf('js/widgets/coloring.js'){ const {api}=await coloringContext(); const pages=JSON.parse(JSON.stringify(api.pages)); /* minstens de vijf gecureerde platen per band; extra proefplaten (zoals een nog-niet-goedgekeurde bonusplaat) mogen de kern niet verdringen */ assert.ok(pages.length>=20,'minstens twintig platen in de atlas'); assert.equal(new Set(pages.map(page=>page.id)).size,pages.length); for(let year=1;year<=8;year++){ const suitable=JSON.parse(JSON.stringify(api.pagesFor(year))); assert.ok(suitable.length>=5,`groep ${year} heeft minstens vijf 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){ const svg=page.svg,id=page.id; assert.ok(api.themes[page.theme],id+' heeft een bekend thema'); assert.ok(page.title.nl&&page.title.en,id+' heeft een tweetalige titel'); const parts=[...svg.matchAll(/data-part="([^"]+)"/g)].map(match=>match[1]); assert.equal(new Set(parts).size,parts.length,id+' heeft unieke kleurvakken'); assert.match(svg,/class="coloring-sheet"[^>]*preserveAspectRatio="xMidYMid meet"/); assert.match(svg,new RegExp(`data-scene="${id}"`)); assert.match(svg,/data-role="backdrop"|class="coloring-region coloring-backdrop"/,id+' heeft een achtergrondvak'); assert.doesNotMatch(svg,/]*data-fillable="true"[^>]*d="([^"]+)"/g)] .filter(match=>!/[zZ]\s*$/.test(match[1].trim())).map(match=>match[1].slice(0,24)); assert.deepEqual(openPaths,[],id+' heeft alleen gesloten aanklikbare paden'); const regions=regionTags(svg); /* geen transform op een kleurvak: hetzelfde pad dient ook als clip voor de kwast */ assert.deepEqual(regions.filter(tag=>/transform=/.test(tag)),[],id+' heeft geen transform op een kleurvak'); /* elk vak draagt een voorgestelde kleur voor de kleurwijzer van niveau 1 */ assert.deepEqual(regions.filter(tag=>!/data-hint="#[0-9a-f]{6}"/.test(tag)),[],id+' heeft overal een kleurwijzerkleur'); /* data-tier hoort op een groep of een detaillijn, nooit op een los vak: zo verdwijnt een rekwisiet altijd als geheel en blijft de plaat compleet */ assert.deepEqual(regions.filter(tag=>/data-tier=/.test(tag)),[],id+' zet data-tier nooit op een los kleurvak'); const total=regions.length,niveau1=visibleRegions(svg,1),niveau2=visibleRegions(svg,2),niveau3=visibleRegions(svg,3); assert.equal(niveau2,total,`${id} toont op niveau 2 alle vakken`); assert.equal(niveau3,niveau2,`${id} voegt op niveau 3 alleen lijnwerk toe, geen nieuwe vakken`); assert.ok(niveau1>=8,`${id} houdt op niveau 1 een complete tekening (${niveau1} vakken)`); assert.ok(niveau1 ${niveau2})`); /* de plaat groeit mee met de groepenband */ if(bandOf(page)===1)assert.ok(total<=27,`${id} blijft voor groep 1-2 overzichtelijk (${total})`); if(bandOf(page)===7)assert.ok(total>=28,`${id} heeft voor groep 7-8 genoeg detail (${total})`); assert.ok(total>=13,`${id} heeft genoeg kleurvakken (${total})`); /* niveau 3 voegt uitsluitend fijn lijnwerk toe */ assert.match(svg,/class="coloring-detail" data-tier="3"/,id+' heeft fijn lijnwerk op niveau 3'); } }); test('kwast schildert in de SVG, blijft op de lage niveaus binnen de lijnen en houdt de contour scherp',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'); /* binnen de lijnen is hulp: groep 1-4 op niveau 1 en 2, groep 5-8 op niveau 1 */ assert.equal(api.clipsStrokes(2,1),true); assert.equal(api.clipsStrokes(2,2),true); assert.equal(api.clipsStrokes(2,3),false); assert.equal(api.clipsStrokes(4,2),true); assert.equal(api.clipsStrokes(6,1),true); assert.equal(api.clipsStrokes(6,2),false); assert.equal(api.clipsStrokes(8,3),false); for(const feature of ['getCoalescedEvents','event.pressure','COLORING_WASH_ALPHA','stroke-linecap']) assert.ok(source.includes(feature),feature+' ontbreekt'); assert.match(source,/const savedTool=initState&&initState\.tool==="pencil"\?"brush"/); assert.match(source,/\["brush","🖌️","colorToolBrush"\]/); /* schildersvolgorde: vak -> verflaag -> contour, en de contour vult nooit */ assert.match(source,/function expandRegions\(\)/); assert.match(source,/region\.parentNode\.insertBefore\(wash,region\.nextSibling\)/); assert.match(source,/region\.parentNode\.insertBefore\(outline,wash\.nextSibling\)/); assert.match(source,/outline\.setAttribute\("fill","none"\)/); assert.match(source,/const COLORING_PAPER="#ffffff"/); assert.equal(api.paper,'#ffffff'); /* aanwijzen gebeurt meetkundig, niet door pointer-events om te zetten */ assert.match(source,/isPointInFill/); assert.match(source,/isPointInStroke/); const code=stripComments(source); assert.doesNotMatch(code,/document\.elementFromPoint/); assert.doesNotMatch(code,/canvas\.style\.pointerEvents/); /* de oude, onzichtbare inktlaag mag niet terugkomen */ assert.doesNotMatch(code,/coloringInkOverlay|coloring-ink-overlay|mix-blend-mode/); assert.doesNotMatch(code,/{ const css=await readFile('public/css/coloring.css','utf8'),rules=stripComments(css); /* dit is de regressietest op de fout die de verf onzichtbaar maakte: een CSS-regel op een kleurvak wint van het fill-attribuut van de verfemmer */ assert.doesNotMatch(rules,/\.coloring-region[^{]*\{[^}]*fill:/,'CSS mag geen fill op een kleurvak zetten'); assert.doesNotMatch(rules,/fill:[^;}]*!important/,'CSS mag een vulling nooit forceren'); assert.doesNotMatch(rules,/mix-blend-mode/,'geen blendmode-truc meer'); assert.doesNotMatch(rules,/coloring-ink-overlay|coloring-ink-svg|coloring-recognition/); assert.doesNotMatch(rules,/\.coloring-paper canvas/); /* de plaat is zelfvoorzienend en vangt zelf geen kliks: dat doet het papier */ assert.match(css,/\.coloring-sheet\{--coloring-ink:#1c293d;--coloring-detail:#2d3e56;[^}]*pointer-events:none/); assert.match(css,/\.coloring-paper\{[^}]*touch-action:none/); /* twee lijndiktes, zodat voor- en achtergrond leesbaar blijven */ assert.match(css,/\.coloring-outline\{stroke:var\(--coloring-ink,#1c293d\)/); assert.match(css,/\.coloring-outline\.coloring-contour\{stroke-width:6\.4\}/); assert.match(css,/\.coloring-outline\.coloring-flat\{stroke-width:3\.4\}/); assert.match(css,/\.coloring-region\{stroke:none\}/); assert.match(css,/\.coloring-stroke\{pointer-events:none\}/); assert.match(css,/\.coloring-app\[data-tool="eraser"\] \.coloring-stroke\{pointer-events:stroke\}/); }); test('verfemmer vult een vak, de gum pelt de bovenste laag af en de staat bewaart de streken',async()=>{ const {source}=await coloringContext(); assert.match(source,/region\.setAttribute\("fill",color\);fills\[part\]=color/); assert.match(source,/function coloringTopLayer/); assert.match(source,/DOCUMENT_POSITION_FOLLOWING/); assert.match(source,/delete fills\[filled\.dataset\.part\];filled\.setAttribute\("fill",COLORING_PAPER\)/); assert.match(source,/else\{top\.remove\(\);strokes=readStrokes\(\)\}/); assert.match(source,/function readStrokes\(\)/); assert.match(source,/strokes:strokes\.map\(stroke=>\(\{part:stroke\.part,color:stroke\.color/); /* oud werk uit de canvasversie blijft zichtbaar */ assert.match(source,/class","coloring-legacy-ink/); assert.match(source,/opts\.onProgress/); /* kleurwijzer: hulp op niveau 1, uitdaging op niveau 3 */ assert.match(source,/function renderGuide\(\)/); assert.match(source,/guideBtn\.hidden=level>=3/); assert.match(source,/T\("colorLevelHelp"\+level\)/); assert.match(source,/document\.removeEventListener\("langchange",onLang\)/); }); 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'/); });