teach/test/coloring.test.js
Ramon c6b3a14923
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 34s
fix: maak kleurplaatverf zichtbaar en herteken de atlas (v0.4.75-beta)
Kwast en verfemmer werkten al, maar het resultaat was onzichtbaar: de
contourlaag zette fill="#fff" op elk vak en vertrouwde op
mix-blend-mode:multiply om dat onschadelijk te maken. Doordat .coloring-ink
zelf een stacking context is (position+z-index), vormt die een geisoleerde
blendgroep zonder achtergrond, en werd het wit gewoon dekkend over de hele
tekening gelegd. Live nagemeten met headless Chromium.

- Tekenmodel omgezet naar een echte schildersvolgorde in een enkele SVG: elk
  vak wordt uitgeklapt naar vak -> verflaag -> contour, zodat verf zichtbaar
  blijft en latere vakken de verf en contour van eerdere vakken afdekken.
  Daarmee kijk je ook niet meer door figuren heen.
- Canvas vervangen door SVG-streken: geen opschalingsonscherpte meer en geen
  donkere naden (dekking staat per streek op de groep, niet per segment).
- Kwast blijft binnen de lijnen als hulp, gestuurd door niveau: groep 1-4 op
  niveau 1 en 2, groep 5-8 op niveau 1, daarboven vrij schilderen.
- Verfemmer dekt een vak volledig; de gum pelt de bovenste laag af (eerst de
  vulling, daarna de streken eronder).
- Kleurwijzer toegevoegd: op niveau 1 laat een stip per vak de voorgestelde
  kleur zien, op niveau 2 op te vragen met een knop, op niveau 3 uit.
- Niveau sloopt de tekening niet meer: data-tier staat alleen op groepen en
  detaillijnen, dus een rekwisiet verdwijnt als geheel en de plaat is op elk
  niveau een complete tekening. Niveau 3 voegt alleen fijn lijnwerk toe.
- Atlas hertekend naar 20 platen, 5 per groepenband en 1 per thema. Twee
  lijndiktes, vakken op maat van de band, geen evenodd-gaten, en composities
  met voor-, midden- en achtergrond.
- partBelow/elementFromPoint en het omzetten van pointer-events vervallen:
  aanwijzen gebeurt meetkundig met isPointInFill/isPointInStroke.
- langchange-listener ruimt zichzelf op na demontage.
- Staat bewaart nu streken in plaats van een PNG; oud werk met een ink-PNG
  blijft zichtbaar.
- Tests: asserties die de bug vastzetten verwijderd en vervangen door een
  regressietest die verbiedt dat CSS een fill op een kleurvak zet, plus
  controles op gesloten vakken, geen transform, tier-regels, vakaantallen per
  niveau en de clip-matrix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YErxhic5w9K57zmt8Q1uS
2026-07-30 00:39:14 +02:00

222 lines
12 KiB
JavaScript

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`)<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','colorEraserHint','colorGuide',
'colorLevelHelp1','colorLevelHelp2','colorLevelHelp3']){
assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key);
}
});
test('elk leerjaar heeft vijf platen met alle thema\'s, gesloten vakken en een kleurwijzer',async()=>{
const {api}=await coloringContext();
const pages=JSON.parse(JSON.stringify(api.pages));
assert.equal(pages.length,20);
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.equal(suitable.length,5,`groep ${year} heeft 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,/<script|on\w+=/i,id+' bevat geen scripts');
/* alleen gesloten aanklikbare paden: anders vult de verfemmer een open vorm */
const openPaths=[...svg.matchAll(/<path\b[^>]*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,`${id} voegt op niveau 2 vakken toe (${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,/<canvas/);
assert.doesNotMatch(code,/toDataURL/);
});
test('de opmaak legt nooit een dekkende laag over de verf',async()=>{
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'/);
});