Pannello visione: modulo vision_overlay.js (forme overlay, ancore, etichette) con test node e fixture task 14

Refs #116

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 16:52:50 +01:00
parent 7a1df0bca4
commit b46ccba1ee
4 changed files with 903 additions and 0 deletions
@@ -0,0 +1,228 @@
/**
* #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);
}
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
process.exit(failures ? 1 : 0);