All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 46s
- Nieuwe kleurplaat op verzoek: rondere, zachtere dierenstijl geïnspireerd op publiek-domein referentiebeelden (openclipart.org, bekeken maar niet overgenomen), niet de exacte scenes uit de aangeleverde voorbeelden - Egel getekend met een scherpe, doorlopende zigzag-stekelrand als onderdeel van de romp zelf, in plaats van losse ronde "bladvorm"-plukjes erbovenop - dat laatste las als bloemblaadjes, niet als stekels - Vier poten met een ronde poot-vorm en teentjes in plaats van rechte blokjes; buik is één rustige boog zodat de poten er voorspelbaar onderuit steken zonder door elkaar of de buiklijn heen te kruisen - Boom herbouwd: stam die breed begint bij de grond en versmalt naar boven (net als de getapte dierenpoten van eerder deze sessie, maar omgekeerd), met een zichtbare splitsing in takken en een kroon van drie overlappende ronde plukken in plaats van één gladde ovaal op een paal - Testinvariant "20 platen, 5 per groepenband" versoepeld naar "minstens 20/5" zodat een proefplaat als bonus kan bestaan zonder de gecureerde kernset aan te tasten Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011YErxhic5w9K57zmt8Q1uS
224 lines
12 KiB
JavaScript
224 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));
|
|
/* 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,/<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'/);
|
|
});
|