/** * #116: overlay del check visione (static/js/vision_overlay.js) con plain node. * * Run from src/frontend/flask_app: node tests/js/test_vision_overlay.js */ 'use strict'; const fs = require('fs'); const path = require('path'); const VisionOverlay = require(path.join(__dirname, '..', '..', 'static', 'js', 'vision_overlay.js')); const fixture = JSON.parse( fs.readFileSync(path.join(__dirname, 'fixtures', 'vision_check_task14.json'), 'utf8'), ); let failures = 0; let total = 0; function check(label, cond, extra) { total++; if (cond) console.log('ok - ' + label); else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); } } const near = (a, b) => Math.abs(a - b) < 1e-9; // Canvas finto: registra le chiamate e le proprieta' al momento dello stroke function fakeCanvas() { const calls = []; const g = { calls, strokes: [], dash: [], strokeStyle: '', lineWidth: 0, beginPath() { calls.push(['beginPath']); }, moveTo(x, y) { calls.push(['moveTo', x, y]); }, lineTo(x, y) { calls.push(['lineTo', x, y]); }, arc(...a) { calls.push(['arc', ...a]); }, closePath() { calls.push(['closePath']); }, stroke() { calls.push(['stroke']); g.strokes.push({ style: g.strokeStyle, width: g.lineWidth, dash: g.dash.slice() }); }, setLineDash(d) { g.dash = d.slice(); calls.push(['setLineDash', d.slice()]); }, }; return g; } const onlyCalls = (g, name) => g.calls.filter((c) => c[0] === name); // --- fixture vero: task 14 TEST1 --- const overlay = fixture.overlay; check('fixture: overlay con 25 elementi', overlay.length === 25, String(overlay.length)); { const g = fakeCanvas(); const res = VisionOverlay.draw(g, overlay, 2); check('draw sul fixture: drawn 25, skipped 0', res.drawn === 25 && res.skipped === 0, JSON.stringify(res)); const lastDash = onlyCalls(g, 'setLineDash').pop(); check('draw: ultimo setLineDash e\' []', lastDash && lastDash[1].length === 0); check('draw: uno stroke per elemento', g.strokes.length === 25, String(g.strokes.length)); const roiIdx = overlay.findIndex((o) => o.role === 'roi'); const foundIdx = overlay.findIndex((o) => o.role === 'found'); check('roi: arancione, tratteggiata, spessore lw*0.75', g.strokes[roiIdx].style === '#f59e0b' && g.strokes[roiIdx].width === 1.5 && JSON.stringify(g.strokes[roiIdx].dash) === '[6,4]', JSON.stringify(g.strokes[roiIdx])); check('found: verde, piena, spessore lw', g.strokes[foundIdx].style === '#16a34a' && g.strokes[foundIdx].width === 2 && g.strokes[foundIdx].dash.length === 0, JSON.stringify(g.strokes[foundIdx])); } // --- colori: reference e ruolo ignoto --- { const g = fakeCanvas(); VisionOverlay.draw(g, [ { type: 'segment', role: 'reference', row1: 0, col1: 0, row2: 1, col2: 1 }, { type: 'segment', role: 'boh', row1: 0, col1: 0, row2: 1, col2: 1 }, ], 1); check('reference blu', g.strokes[0].style === '#2563eb'); check('ruolo ignoto rosso', g.strokes[1].style === '#ef4444'); } // --- rect2 --- function xs(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[1]); } function ys(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[2]); } { const g = fakeCanvas(); const r = VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 }], 1); const X = xs(g), Y = ys(g); check('rect2 phi 0: 4 vertici chiusi', r.drawn === 1 && X.length === 4 && onlyCalls(g, 'closePath').length === 1); check('rect2 phi 0: col 90/110', X.every((x) => near(x, 90) || near(x, 110)) && X.filter((x) => near(x, 90)).length === 2, String(X)); check('rect2 phi 0: row 46/54', Y.every((y) => near(y, 46) || near(y, 54)) && Y.filter((y) => near(y, 46)).length === 2, String(Y)); } { const g = fakeCanvas(); VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 90, length1: 10, length2: 4 }], 1); const X = xs(g), Y = ys(g); check('rect2 phi 90: lati scambiati, col 96/104', X.every((x) => near(x, 96) || near(x, 104)), String(X)); check('rect2 phi 90: lati scambiati, row 40/60', Y.every((y) => near(y, 40) || near(y, 60)), String(Y)); } // --- arc: argomenti come da contratto --- { const g = fakeCanvas(); const a = { type: 'arc', role: 'found', row: 30, col: 40, radius: 12, start_deg: 90, extent_deg: 90 }; VisionOverlay.draw(g, [a], 1); const c = onlyCalls(g, 'arc')[0]; check('arc: g.arc(col, row, r, -rad(start), -rad(start+extent), extent>0)', c && near(c[1], 40) && near(c[2], 30) && near(c[3], 12) && near(c[4], -Math.PI / 2) && near(c[5], -Math.PI) && c[6] === true, JSON.stringify(c)); const g2 = fakeCanvas(); VisionOverlay.draw(g2, [{ ...a, start_deg: 0, extent_deg: -45 }], 1); const c2 = onlyCalls(g2, 'arc')[0]; check('arc: extent negativo = orario (anticlockwise false)', c2 && near(c2[4], 0) && near(c2[5], Math.PI / 4) && c2[6] === false, JSON.stringify(c2)); } // --- circle, points, contour --- { const g = fakeCanvas(); VisionOverlay.draw(g, [{ type: 'circle', role: 'found', row: 5, col: 7, radius: 3 }], 1); const c = onlyCalls(g, 'arc')[0]; check('circle: cerchio pieno', c && c[1] === 7 && c[2] === 5 && c[3] === 3 && c[4] === 0 && near(c[5], 2 * Math.PI)); } { const g = fakeCanvas(); VisionOverlay.draw(g, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 1); const m = onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')); // lw 1 -> semi-lato max(3, 2) = 3 check('points: croce di semi-lato 3', m.length === 4 && m.some((c) => c[1] === 17 && c[2] === 10) && m.some((c) => c[1] === 23 && c[2] === 10) && m.some((c) => c[1] === 20 && c[2] === 7) && m.some((c) => c[1] === 20 && c[2] === 13), JSON.stringify(m)); const g2 = fakeCanvas(); VisionOverlay.draw(g2, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 5); check('points: semi-lato lw*2 per lw grande', onlyCalls(g2, 'moveTo').some((c) => c[1] === 10 && c[2] === 10)); } { const g = fakeCanvas(); VisionOverlay.draw(g, [{ type: 'contour', role: 'found', rows: [1, 2, 3], cols: [4, 5, 6] }], 1); check('contour: polilinea aperta di 3 punti', onlyCalls(g, 'moveTo').length === 1 && onlyCalls(g, 'lineTo').length === 2 && onlyCalls(g, 'closePath').length === 0); } // --- elementi malformati: saltati, nessuna eccezione --- { const g = fakeCanvas(); let thrown = null; let res = null; try { res = VisionOverlay.draw(g, [ { type: 'circle', role: 'found', row: 1, col: 1 }, // manca radius { type: 'points', role: 'found', rows: [1, 2], cols: [1] }, // lunghezze diverse { type: 'contour', role: 'found', rows: [], cols: [] }, // vuoto { type: 'boh', role: 'found' }, // tipo ignoto { type: 'segment', role: 'found', row1: NaN, col1: 0, row2: 1, col2: 1 }, // non finito null, // elemento nullo { type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }, // valido ], 1); } catch (e) { thrown = e; } check('malformati: nessuna eccezione', thrown === null, String(thrown)); check('malformati: 6 saltati, 1 disegnato', res && res.drawn === 1 && res.skipped === 6, JSON.stringify(res)); check('malformati: draw con overlay non array', (() => { try { const r = VisionOverlay.draw(fakeCanvas(), null, 1); return r.drawn === 0 && r.skipped === 0; } catch (e) { return false; } })()); } // --- anchor, valori calcolati a mano --- { const A = VisionOverlay.anchor; const seg = A({ type: 'segment', row1: 10, col1: 0, row2: 20, col2: 40 }); check('anchor segment: punto medio', seg && seg.x === 20 && seg.y === 15); const lin = A({ type: 'line', row1: 0, col1: 2, row2: 0, col2: 4 }); check('anchor line: punto medio', lin && lin.x === 3 && lin.y === 0); const cir = A({ type: 'circle', row: 50, col: 100, radius: 8 }); check('anchor circle: centro + raggio a destra', cir && cir.x === 108 && cir.y === 50); const arc = A({ type: 'arc', row: 50, col: 100, radius: 10, start_deg: 0, extent_deg: 180 }); check('anchor arc: punto medio (90 gradi = in alto a video)', arc && near(arc.x, 100) && near(arc.y, 40), JSON.stringify(arc)); const pts = A({ type: 'points', rows: [7, 8], cols: [3, 4] }); check('anchor points: primo punto', pts && pts.x === 3 && pts.y === 7); const r2 = A({ type: 'rect2', row: 9, col: 11, phi_deg: 30, length1: 5, length2: 2 }); check('anchor rect2: centro', r2 && r2.x === 11 && r2.y === 9); const con = A({ type: 'contour', rows: [0, 1, 2, 3], cols: [10, 11, 12, 13] }); check('anchor contour: punto a meta\' (indice 2)', con && con.x === 12 && con.y === 2); check('anchor: tipo ignoto -> null', A({ type: 'boh' }) === null); check('anchor: dati mancanti -> null', A({ type: 'circle', row: 1, col: 1 }) === null && A(null) === null); } // --- labels sul fixture, righe costruite come fara' vision_execute.js --- const subById = {}; fixture._subtasks.forEach((s) => { subById[s.id] = s; }); const rows = fixture.measurements.map((m) => { const s = subById[m.subtask_id]; return { key: m.subtask_id, name: s.description, value: m.value, unit: s.unit, status: m.pass_fail, vision_output: s.vision_output, }; }); { const L = VisionOverlay.labels(rows, overlay); check('labels fixture: 6 etichette', L.length === 6, String(L.length)); const names = L.map((l) => l.text.split(' ')[0]).join(','); check('labels fixture: larghezza, lunghezza e 4 raggi', L[0].text.startsWith('Larghezza') && L[1].text.startsWith('Lunghezza') && L.slice(2).every((l) => l.text.startsWith('R ')), names); check('labels fixture: nessuna per le differenze', L.every((l) => !/^Diff/.test(l.text))); const l15 = L.find((l) => l.key === 15); check('quota 15: testo con Larghezza e 1.668', l15 && l15.text.includes('Larghezza') && l15.text.includes('1.668'), l15 && l15.text); check('quota 15: status riportato', l15 && l15.status === fixture.measurements[0].pass_fail); const seg = overlay.find((o) => o.tool_id === 'larghezza' && o.role === 'found'); check('quota 15: ancorata al punto medio del segmento', l15 && near(l15.x, (seg.col1 + seg.col2) / 2) && near(l15.y, (seg.row1 + seg.row2) / 2)); } { const nuda = rows.map((r) => ({ ...r, vision_output: undefined })); check('labels: righe senza vision_output -> nessuna etichetta', VisionOverlay.labels(nuda, overlay).length === 0); const sola = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass' }], overlay); check('labels: riga senza vision_output (assente) -> nessuna', sola.length === 0); const noTool = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass', vision_output: 'nonesiste.value' }], overlay); check('labels: tool senza overlay -> nessuna', noTool.length === 0); // found preferito: la roi (per prima) ha un'altra ancora, ma vince il found const ov = [ { type: 'rect2', role: 'roi', tool_id: 't', row: 1, col: 1, phi_deg: 0, length1: 1, length2: 1 }, { type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 }, ]; const f = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'warning', vision_output: 't.value' }], ov); check('labels: preferisce l\'ancora del found', f.length === 1 && f[0].x === 15 && f[0].y === 10 && f[0].status === 'warning', JSON.stringify(f)); const solo = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'fail', vision_output: 't.value' }], [ov[0]]); check('labels: senza found ripiega sul primo elemento', solo.length === 1 && solo[0].x === 1 && solo[0].y === 1); check('labels: testo name + valore a 3 decimali + unit', f[0].text === 'T 1.500 mm', f[0].text); } // --- #118: labels, valore null/undefined/non finito -> etichetta NaN con status della riga --- { const ov = [{ type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 }]; const mk = (value) => ({ key: 1, name: 'T', value, unit: 'mm', status: 'fail', vision_output: 't.value' }); const nan = (v) => { const l = VisionOverlay.labels([mk(v)], ov); return l.length === 1 && l[0].text === 'T NaN mm' && l[0].status === 'fail'; }; check('labels: value null -> etichetta NaN, status fail (Number(null) e 0)', nan(null)); check('labels: value undefined / stringa vuota -> etichetta NaN', nan(undefined) && nan('')); check('labels: value NaN/Infinity -> etichetta NaN', nan(NaN) && nan(Infinity) && nan(-Infinity)); check('labels: value 0 resta un valore valido', VisionOverlay.labels([mk(0)], ov)[0].text === 'T 0.000 mm'); const misto = VisionOverlay.labels([{ ...mk(null), key: 1 }, { ...mk(2.5), key: 2 }], ov); check('labels: null e valida entrambe presenti', misto.length === 2 && misto[0].text === 'T NaN mm' && misto[1].key === 2); check('labels: riga NaN senza ancoraggio -> nessuna etichetta', VisionOverlay.labels([mk(null)], []).length === 0); } // --- #121: svg(overlay, opts) --- const count = (str, re) => (str.match(re) || []).length; const SHAPE_RE = /<(polygon|line|circle|path|polyline) /g; { const out = VisionOverlay.svg(overlay); // il fixture vero ha 25 forme: 7 segment, 1 contour, 1 points, 4 rect2 roi, 4 arc roi, 4 arc found, 4 circle check('svg fixture: 25 forme', count(out, SHAPE_RE) === 25, String(count(out, SHAPE_RE))); check('svg fixture: ogni forma con vector-effect non-scaling-stroke', count(out, /vector-effect="non-scaling-stroke"/g) === 25); check('svg fixture: ogni forma fill none', count(out, /fill="none"/g) === 25); check('svg fixture: 8 roi (4 rect2 + 4 arc) con stroke-dasharray 6 4', count(out, /stroke-dasharray="6 4"/g) === 8, String(count(out, /stroke-dasharray="6 4"/g))); check('svg fixture: nessun esterno', !/ VisionOverlay.svg([o], opts); const roi = one({ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 }); check('svg roi: polygon 4 vertici, colore roi, dash 6 4, spessore lw*0.75', roi.startsWith(' stroke-width 3', roi4.includes('stroke-width="3"'), roi4); const fnd = one({ type: 'segment', role: 'found', row1: 1, col1: 2, row2: 3, col2: 4 }); check('svg found: line verde piena, spessore lw 2 predefinito', fnd === '', fnd); check('svg reference blu, ruolo ignoto rosso', one({ type: 'segment', role: 'reference', row1: 0, col1: 0, row2: 1, col2: 1 }).includes('stroke="#2563eb"') && one({ type: 'segment', role: 'boh', row1: 0, col1: 0, row2: 1, col2: 1 }).includes('stroke="#ef4444"')); check('svg: spessore costante, non dipende dallo zoom', one({ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }, { zoom: 4 }).includes('stroke-width="2"')); const cir = one({ type: 'circle', role: 'found', row: 5, col: 7, radius: 3 }); check('svg circle: cx cy r', cir.startsWith(' /d="([^"]+)"/.exec(one({ type: 'arc', role: 'found', ...o }))[1]; const parse = (d) => d.match(/-?\d+(\.\d+)?/g).map(Number); // [x0, y0, rx, ry, rot, large, sweep, x1, y1] { // start 90, extent 90 su centro (40,30) r 12: da (40,18) a (28,30), antiorario (sweep 0), arco piccolo const d = arcD({ row: 30, col: 40, radius: 12, start_deg: 90, extent_deg: 90 }); const v = parse(d); check('svg arc 90+90: M 40 18 A 12 12 0 0 0 28 30', near(v[0], 40) && near(v[1], 18) && v[2] === 12 && v[3] === 12 && v[4] === 0 && v[5] === 0 && v[6] === 0 && near(v[7], 28) && near(v[8], 30), d); } { // start 0, extent -90 (orario): da (10,0) a (0,10) a video, sweep 1 const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: -90 })); check('svg arc extent negativo: sweep 1, fine in basso a video', near(v[0], 10) && near(v[1], 0) && v[5] === 0 && v[6] === 1 && near(v[7], 0) && near(v[8], 10), JSON.stringify(v)); } { // 270 gradi antiorari da 0: fine a 270 = in basso a video (0, +10) const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: 270 })); check('svg arc 270: large-arc 1, sweep 0, fine (0,10) a video', v[5] === 1 && v[6] === 0 && near(v[7], 0) && near(v[8], 10), JSON.stringify(v)); } { const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: 360 })); check('svg arc 360: estensione limitata a 359.99 (large-arc, fine quasi sullo start)', v[5] === 1 && Math.abs(v[8]) < 0.01 && v[7] > 9.99, JSON.stringify(v)); } { // coincide con draw: punto finale del g.arc del canvas (angolo -rad(start+extent)) const a = { type: 'arc', role: 'found', row: 30, col: 40, radius: 12, start_deg: 30, extent_deg: 100 }; const g = fakeCanvas(); VisionOverlay.draw(g, [a], 1); const c = onlyCalls(g, 'arc')[0]; const ex = c[1] + c[3] * Math.cos(c[5]), ey = c[2] + c[3] * Math.sin(c[5]); const sx = c[1] + c[3] * Math.cos(c[4]), sy = c[2] + c[3] * Math.sin(c[4]); const v = parse(arcD(a)); check('svg arc: estremi uguali a quelli di draw su canvas', Math.abs(v[7] - ex) < 0.01 && Math.abs(v[8] - ey) < 0.01 && Math.abs(v[0] - sx) < 0.01 && Math.abs(v[1] - sy) < 0.01, JSON.stringify([v, ex, ey])); } // malformati e ignoti: saltati, nessuna eccezione, la forma valida resta let thrown = null, out = ''; try { out = VisionOverlay.svg([ { type: 'circle', role: 'found', row: 1, col: 1 }, { type: 'points', role: 'found', rows: [1, 2], cols: [1] }, { type: 'contour', role: 'found', rows: [], cols: [] }, { type: 'boh', role: 'found' }, { type: 'segment', role: 'found', row1: NaN, col1: 0, row2: 1, col2: 1 }, { type: 'arc', role: 'found', row: 0, col: 0, radius: 1, start_deg: 0 }, { type: 'rect2', role: 'roi', row: 0, col: 0, phi_deg: 0, length1: Infinity, length2: 1 }, null, { type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }, ], { zoom: 1 }); } catch (e) { thrown = e; } check('svg malformati: nessuna eccezione, una sola forma valida', thrown === null && count(out, / stringa vuota', VisionOverlay.svg(null) === '' && VisionOverlay.svg(undefined) === '' && VisionOverlay.svg([]) === ''); check('svg: opts non validi -> predefiniti', VisionOverlay.svg([{ type: 'points', role: 'found', rows: [10], cols: [20] }], { zoom: 0, lw: -1, cross: NaN }) .includes('d="M 14 10 L 26 10 M 20 4 L 20 16"')); // nessuna stringa dei dati nel markup const evil = '"onload="x'; const sicuro = VisionOverlay.svg([ { type: 'segment', role: evil, tool_id: evil, name: evil, row1: 0, col1: 0, row2: 1, col2: 1 }, { type: 'circle', role: 'found', tool_id: evil, row: 1, col: 1, radius: 2 }, ]); check('svg: tool_id e role dei dati non compaiono nel markup', !/script|onload|alert/.test(sicuro) && count(sicuro, /vector-effect/g) === 2, sicuro); const strNum = VisionOverlay.svg([{ type: 'segment', role: 'found', row1: '', col1: 0, row2: 1, col2: 1 }]); check('svg: coordinata stringa -> forma saltata', strNum === ''); } // --- #12: livelli e quote CAD --- { const VO = VisionOverlay; check('layerOf: layer esplicito vince sul ruolo', VO.layerOf({ role: 'found', layer: 'quote' }) === 'quote' && VO.layerOf({ role: 'roi', layer: 'costruzioni' }) === 'costruzioni'); check('layerOf: senza campo found = costruzioni, altro = ricerca', VO.layerOf({ role: 'found' }) === 'costruzioni' && VO.layerOf({ role: 'roi' }) === 'ricerca' && VO.layerOf({ role: 'reference' }) === 'ricerca' && VO.layerOf({}) === 'ricerca' && VO.layerOf(null) === 'ricerca'); check('layerOf: layer ignoto -> secondo il ruolo', VO.layerOf({ role: 'found', layer: 'boh' }) === 'costruzioni'); const lin = { tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: null }; const rag = { tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 90, testo: null }; const ang = { tipo: 'angolo', centro: [50, 50], r: 20, start_deg: 0, extent_deg: 90, testo: null }; // default let p = VO.quotaPunto(lin); check('quotaPunto lineare default = punto medio', near(p.x, 50) && near(p.y, 0), JSON.stringify(p)); p = VO.quotaPunto(rag); check('quotaPunto raggio default = centro + 2r lungo dir (90 gradi = verso l\'alto a video)', near(p.x, 50) && near(p.y, 30), JSON.stringify(p)); p = VO.quotaPunto(ang); { // u a 45 gradi = (cos45, -sin45); T = centro + 1.5r u const e = 30 * Math.SQRT1_2; check('quotaPunto angolo default = centro + 1.5r a meta arco', near(p.x, 50 + e) && near(p.y, 50 - e), JSON.stringify(p)); } // con testo: lineare p1->p2 lungo +x, n = (0, 1): dv positivo = verso il basso a video p = VO.quotaPunto({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [10, 20] }); check('quotaPunto lineare con testo [du,dv]', near(p.x, 60) && near(p.y, 20), JSON.stringify(p)); // inverse [lin, rag, ang].forEach((q) => { [[3, -7], [-12.5, 40], [0, 0]].forEach((t) => { const q2 = Object.assign({}, q, { testo: t }); const pt = VO.quotaPunto(q2); const back = VO.quotaTesto(q2, pt.x, pt.y); check('quotaTesto inverso di quotaPunto (' + q.tipo + ' ' + t + ')', near(back[0], t[0]) && near(back[1], t[1]), JSON.stringify(back)); }); // dal punto di default si torna a testo di default const d = VO.quotaPunto(q), back = VO.quotaTesto(q, d.x, d.y), p2 = VO.quotaPunto(Object.assign({}, q, { testo: back })); check('quotaTesto dal punto di default riproduce il default (' + q.tipo + ')', near(p2.x, d.x) && near(p2.y, d.y)); }); check('quota malformata: quotaPunto/quotaTesto null, quotaSvg stringa vuota', VO.quotaPunto(null) === null && VO.quotaPunto({ tipo: 'boh' }) === null && VO.quotaPunto({ tipo: 'lineare', p1: [0, 'a'], p2: [1, 1] }) === null && VO.quotaTesto({ tipo: 'raggio' }, 1, 1) === null && VO.quotaSvg({ tipo: 'angolo', centro: [0, 0] }) === '' && VO.quotaSvg(undefined) === ''); check('quotaPunto: testo malformato = default', near(VO.quotaPunto({ tipo: 'lineare', p1: [0, 0], p2: [10, 0], testo: ['x', 1] }).x, 5)); // quotaSvg lineare: linee di estensione solo con dv != 0 const conDv = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 30] }, { zoom: 1, color: '#16a34a' }); const senzaDv = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 0] }, { zoom: 1, color: '#16a34a' }); check('quotaSvg lineare dv=30: 2 estensioni + 1 linea di misura, 2 frecce', count(conDv, / la punta sta in A (0, 30): primo vertice del poligono { // dentro: punta in A, coda verso +u (x crescente) -> il secondo vertice ha x > 0? no: dentro la freccia punta verso fuori, coda verso l'interno (x>0) const m = conDv.match(/ s.split(',').map(Number)); check('freccia dentro: punta in A, coda verso l\'interno della misura', near(m[0][0], 0) && near(m[0][1], 30) && m[1][0] > 0, m.join('|')); const corta = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [10, 0], testo: [0, 30] }, { zoom: 1 }); const mc = corta.match(/ s.split(',').map(Number)); check('freccia fuori (misura corta): punta in A, coda fuori (x < 0)', near(mc[0][0], 0) && mc[1][0] < 0, mc.join('|')); const zz = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 30] }, { zoom: 4 }); const mz = zz.match(/ s.split(',').map(Number)); check('freccia: lunghezza fissa a video (9 px / zoom)', near(mz[1][0], 9 / 4) && count(zz, / 60 const m = rFuori.match(/ s.split(',').map(Number)); check('raggio fuori: punta in P sul cerchio, coda verso fuori', near(m[0][0], 60) && near(m[0][1], 50) && m[1][0] > 60, m.join('|')); const d = rDentro.match(/ s.split(',').map(Number)); check('raggio dentro: punta in P, coda verso il centro (x < 60)', near(d[0][0], 60) && d[1][0] < 60, d.join('|')); } // angolo const a1 = VO.quotaSvg(ang, { zoom: 1 }); check('quotaSvg angolo: 2 estensioni radiali + arco + 2 frecce', count(a1, / rosso', !/script|onload|alert/.test(s1) && s1.includes('#ef4444') && !/script|onload|alert/.test(s2), s1); check('quotaSvg: colori ammessi (esiti)', ['#16a34a', '#d97706', '#dc2626'].every((c) => VO.quotaSvg(lin, { color: c }).includes(c))); check('svg() invariato: uscita di un segmento', VO.svg([{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }]).startsWith('