All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 34s
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
222 lines
12 KiB
JavaScript
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'/);
|
|
});
|