From 5723f055c461580746425e08261fc8aee77a23e2 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 28 Oct 2026 21:16:11 +0100 Subject: [PATCH 1/3] Overlay SVG e helper salva immagine per il pannello visione Refs #121 #119 #118 Co-Authored-By: Claude Sonnet 5.5 --- .../flask_app/static/js/save_image.js | 78 +++++++++++++++++ .../flask_app/static/js/vision_overlay.js | 83 ++++++++++++++++++- 2 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 src/frontend/flask_app/static/js/save_image.js diff --git a/src/frontend/flask_app/static/js/save_image.js b/src/frontend/flask_app/static/js/save_image.js new file mode 100644 index 0000000..0eec167 --- /dev/null +++ b/src/frontend/flask_app/static/js/save_image.js @@ -0,0 +1,78 @@ +/** + * save_image.js - #119: salva il frame grezzo mostrato nel pannello visione come PNG, senza disegni. + * + * Cosa fa: tmfSaveImage(src, baseName) scarica l'immagine `src` (blob: o http), la converte in PNG se serve + * e la salva con nome _.png: «Salva con nome» (showSaveFilePicker) + * dove c'e', altrimenti . Ritorna una Promise (false se annullato o fallito). + * tmfSaveImageName(baseName, date) e' la funzione pura del nome, esportata per i test. + * Come si usa: poi @click="tmfSaveImage(url, 'task_14')". + * Dipende da: fetch, canvas, createImageBitmap e window del browser, toccati solo dentro le funzioni + * (il modulo si carica anche in node, con require, senza questi globali). + * Idea di conversione da image_to_png.js (#107). + */ + +// #119: nome file proposto _.png; baseName ripulito dai caratteri non validi +function tmfSaveImageName(baseName, date) { + var base = String(baseName === undefined || baseName === null ? '' : baseName) + .replace(/[\/:*?"<>|\u0000-\u001f]/g, '_') // caratteri vietati nei nomi file Windows + .replace(/\s+/g, '_') + .replace(/^[._]+|[._]+$/g, ''); // niente punti o underscore ai bordi + if (!base) base = 'immagine'; + var d = date instanceof Date && !isNaN(date.getTime()) ? date : new Date(); + var p = function (n) { return (n < 10 ? '0' : '') + n; }; + var stamp = '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()); + return base + '_' + stamp + '.png'; +} + +// #119: ridisegna su canvas un'immagine non PNG (es. JPEG, BMP) e ritorna il blob PNG +async function tmfBlobToPng(blob) { + var bitmap = await createImageBitmap(blob); + var canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d').drawImage(bitmap, 0, 0); + if (bitmap.close) bitmap.close(); + var png = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); }); + if (!png) throw new Error('conversione PNG fallita'); + return png; +} + +// #119: scarica `src`, lo porta a PNG e lo salva; true = salvato, false = annullato dall'utente o errore +async function tmfSaveImage(src, baseName) { + try { + var resp = await fetch(src); + if (!resp.ok) return false; + var blob = await resp.blob(); + if (blob.type !== 'image/png') blob = await tmfBlobToPng(blob); + var name = tmfSaveImageName(baseName, new Date()); + if (typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function') { + var handle = await window.showSaveFilePicker({ + suggestedName: name, + types: [{ description: 'PNG', accept: { 'image/png': ['.png'] } }], + }); + var w = await handle.createWritable(); + await w.write(blob); + await w.close(); + return true; + } + // #119: ripiego senza «Salva con nome» (browser non Chromium, contesto non sicuro): + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(function () { URL.revokeObjectURL(url); }, 1000); + return true; + } catch (err) { + if (err && err.name === 'AbortError') return false; // #119: annullato dall'utente, non e' un errore + if (typeof console !== 'undefined') console.error('tmfSaveImage:', err); + return false; + } +} + +// #119: globale nel browser, export per il test node +if (typeof window !== 'undefined') { window.tmfSaveImage = tmfSaveImage; window.tmfSaveImageName = tmfSaveImageName; } +if (typeof module !== 'undefined' && module.exports) module.exports = { tmfSaveImage, tmfSaveImageName }; diff --git a/src/frontend/flask_app/static/js/vision_overlay.js b/src/frontend/flask_app/static/js/vision_overlay.js index b5b79eb..6b2b648 100644 --- a/src/frontend/flask_app/static/js/vision_overlay.js +++ b/src/frontend/flask_app/static/js/vision_overlay.js @@ -7,7 +7,8 @@ * con require. * * API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null; - * labels(rows, overlay) -> [{key, x, y, text, status}]. + * labels(rows, overlay) -> [{key, x, y, text, status}]; + * svg(overlay, opts) -> stringa di figli SVG (#121, vedi sotto). * Convenzioni (vs-tools): row = y, col = x; angoli in gradi positivi antiorari a video. * Porting da TieVision-Studio frontend/src/comune/overlay.js (verticiRect2, forma) * e frontend/src/editor/misure.js (ancoraOverlay). @@ -129,6 +130,82 @@ var VisionOverlay = (function () { return res; } + // #121: numero per il markup SVG, al piu' 2 decimali (niente stringhe dei dati nel markup) + function n2(v) { return String(Math.round(v * 100) / 100); } + function pos(v, dflt) { return num(v) && v > 0 ? v : dflt; } + + // #121: path SVG di un arco, porting di percorsoArco dello Studio; extent > 0 = antiorario a video + function arcPath(o) { + var ext = Math.max(-359.99, Math.min(359.99, o.extent_deg)); // 360 pieni darebbero start == end + var a0 = rad(o.start_deg); + var a1 = rad(o.start_deg + ext); + var x0 = o.col + o.radius * Math.cos(a0), y0 = o.row - o.radius * Math.sin(a0); + var x1 = o.col + o.radius * Math.cos(a1), y1 = o.row - o.radius * Math.sin(a1); + var large = Math.abs(ext) > 180 ? 1 : 0; + var sweep = ext > 0 ? 0 : 1; // y verso il basso: antiorario a video = sweep-flag 0 + return 'M ' + n2(x0) + ' ' + n2(y0) + ' A ' + n2(o.radius) + ' ' + n2(o.radius) + + ' 0 ' + large + ' ' + sweep + ' ' + n2(x1) + ' ' + n2(y1); + } + + // #121: [tag, attributi geometrici] di una forma, o null se tipo ignoto o dati malformati (stessi criteri di drawShape) + function shapeSvg(o, cross) { + var i, pts; + switch (o && o.type) { + case 'rect2': + if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return null; + pts = rect2Vertices(o).map(function (v) { return n2(v[0]) + ',' + n2(v[1]); }).join(' '); + return ['polygon', 'points="' + pts + '"']; + case 'segment': + case 'line': + if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return null; + return ['line', 'x1="' + n2(o.col1) + '" y1="' + n2(o.row1) + '" x2="' + n2(o.col2) + '" y2="' + n2(o.row2) + '"']; + case 'circle': + if (!allNum(o, 'row', 'col', 'radius')) return null; + return ['circle', 'cx="' + n2(o.col) + '" cy="' + n2(o.row) + '" r="' + n2(o.radius) + '"']; + case 'arc': + if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null; + return ['path', 'd="' + arcPath(o) + '"']; + case 'points': { + if (!validSeries(o)) return null; + var d = []; + for (i = 0; i < o.rows.length; i++) { + var x = o.cols[i], y = o.rows[i]; + d.push('M ' + n2(x - cross) + ' ' + n2(y) + ' L ' + n2(x + cross) + ' ' + n2(y) + + ' M ' + n2(x) + ' ' + n2(y - cross) + ' L ' + n2(x) + ' ' + n2(y + cross)); + } + return ['path', 'd="' + d.join(' ') + '"']; + } + case 'contour': + if (!validSeries(o)) return null; + pts = o.rows.map(function (r, k) { return n2(o.cols[k]) + ',' + n2(r); }).join(' '); + return ['polyline', 'points="' + pts + '"']; // aperta, come draw + default: + return null; + } + } + + // #121: overlay -> figli SVG (senza esterno) in px immagine. Tratto a spessore costante a video + // (vector-effect non-scaling-stroke), croci dei points a semi-braccio costante a video (cross / zoom). + function svg(overlay, opts) { + opts = opts || {}; + var zoom = pos(opts.zoom, 1), lw = pos(opts.lw, 2), cross = pos(opts.cross, 6); + var items = Array.isArray(overlay) ? overlay : []; + var out = []; + for (var k = 0; k < items.length; k++) { + var o = items[k]; + var sh = null; + try { sh = shapeSvg(o, cross / zoom); } catch (e) { sh = null; } // un elemento rotto non ferma gli altri + if (!sh) continue; + var role = o.role; + var isRoi = role === 'roi'; + // solo colori fissi e numeri: nessuna stringa dei dati finisce nel markup + out.push('<' + sh[0] + ' ' + sh[1] + ' fill="none" stroke="' + (COLORS[role] || COLOR_OTHER) + + '" stroke-width="' + n2(isRoi ? lw * 0.75 : lw) + '"' + + (isRoi ? ' stroke-dasharray="6 4"' : '') + ' vector-effect="non-scaling-stroke"/>'); + } + return out.join(''); + } + // #116: punto di ancoraggio dell'etichetta (px immagine), come ancoraOverlay dello Studio function anchor(o) { switch (o && o.type) { @@ -172,6 +249,8 @@ var VisionOverlay = (function () { var pt = null; for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]); if (!pt) return; + // #118: valore null/undefined/vuoto = quota non misurata (Number(null) e' 0): nessuna etichetta + if (r.value === null || r.value === undefined || r.value === '' || typeof r.value === 'boolean') return; var v = Number(r.value); if (!isFinite(v)) return; out.push({ @@ -185,7 +264,7 @@ var VisionOverlay = (function () { return out; } - return { draw: draw, anchor: anchor, labels: labels }; + return { draw: draw, anchor: anchor, labels: labels, svg: svg }; })(); if (typeof window !== 'undefined') window.VisionOverlay = VisionOverlay; From df67a32a82f802431f6d9f2fc18f25f3d8c197e4 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 28 Oct 2026 21:17:29 +0100 Subject: [PATCH 2/3] Test dell'overlay SVG e delle etichette senza valore Refs #121 #118 Co-Authored-By: Claude Sonnet 5.5 --- .../flask_app/tests/js/test_vision_overlay.js | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/src/frontend/flask_app/tests/js/test_vision_overlay.js b/src/frontend/flask_app/tests/js/test_vision_overlay.js index e514989..63f7f54 100644 --- a/src/frontend/flask_app/tests/js/test_vision_overlay.js +++ b/src/frontend/flask_app/tests/js/test_vision_overlay.js @@ -224,5 +224,141 @@ const rows = fixture.measurements.map((m) => { 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 -> nessuna etichetta --- +{ + 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' }); + check('labels: value null -> nessuna etichetta (Number(null) e\' 0)', VisionOverlay.labels([mk(null)], ov).length === 0); + check('labels: value undefined -> nessuna', VisionOverlay.labels([mk(undefined)], ov).length === 0); + check('labels: value NaN/Infinity -> nessuna', + VisionOverlay.labels([mk(NaN), mk(Infinity), mk(-Infinity)], ov).length === 0); + check('labels: value 0 resta un valore valido', VisionOverlay.labels([mk(0)], ov).length === 1); + const misto = VisionOverlay.labels([{ ...mk(null), key: 1 }, { ...mk(2.5), key: 2 }], ov); + check('labels: null saltata, le altre righe restano', misto.length === 1 && misto[0].key === 2); +} + +// --- #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 === ''); +} + console.log('\n' + (total - failures) + '/' + total + ' controlli ok'); process.exit(failures ? 1 : 0); From 25b0741003a4a1abbb7a7959a6c836c978349084 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 28 Oct 2026 21:18:20 +0100 Subject: [PATCH 3/3] Test del helper di salvataggio immagine e fix pulizia backslash nel nome Refs #119 Co-Authored-By: Claude Sonnet 5.5 --- .../flask_app/static/js/save_image.js | 2 +- .../flask_app/tests/js/test_save_image.js | 163 ++++++++++++++++++ .../flask_app/tests/test_save_image_js.py | 35 ++++ 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 src/frontend/flask_app/tests/js/test_save_image.js create mode 100644 src/frontend/flask_app/tests/test_save_image_js.py diff --git a/src/frontend/flask_app/static/js/save_image.js b/src/frontend/flask_app/static/js/save_image.js index 0eec167..a723490 100644 --- a/src/frontend/flask_app/static/js/save_image.js +++ b/src/frontend/flask_app/static/js/save_image.js @@ -14,7 +14,7 @@ // #119: nome file proposto _.png; baseName ripulito dai caratteri non validi function tmfSaveImageName(baseName, date) { var base = String(baseName === undefined || baseName === null ? '' : baseName) - .replace(/[\/:*?"<>|\u0000-\u001f]/g, '_') // caratteri vietati nei nomi file Windows + .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_') // caratteri vietati nei nomi file Windows .replace(/\s+/g, '_') .replace(/^[._]+|[._]+$/g, ''); // niente punti o underscore ai bordi if (!base) base = 'immagine'; diff --git a/src/frontend/flask_app/tests/js/test_save_image.js b/src/frontend/flask_app/tests/js/test_save_image.js new file mode 100644 index 0000000..aa3f4a6 --- /dev/null +++ b/src/frontend/flask_app/tests/js/test_save_image.js @@ -0,0 +1,163 @@ +/** + * #119: helper static/js/save_image.js (salva il frame grezzo come PNG) con plain node. + * + * Fetch, window e document sono finti: il modulo li tocca solo dentro le funzioni. + * Run from src/frontend/flask_app: node tests/js/test_save_image.js + */ +'use strict'; +const path = require('path'); + +const mod = require(path.join(__dirname, '..', '..', 'static', 'js', 'save_image.js')); +const { tmfSaveImage, tmfSaveImageName } = mod; + +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 : '')); } +} + +// --- nome proposto (funzione pura) --- +const d = new Date(2026, 9, 28, 9, 5, 7); // 28/10/2026 09:05:07 ora locale +check('nome: baseName + AAAAMMGG-hhmmss + .png', tmfSaveImageName('task_14', d) === 'task_14_20261028-090507.png', + tmfSaveImageName('task_14', d)); +check('nome: caratteri non validi -> _', tmfSaveImageName('a/b\\c:d*e?f"gi|j', d) === 'a_b_c_d_e_f_g_h_i_j_20261028-090507.png', + tmfSaveImageName('a/b\\c:d*e?f"gi|j', d)); +check('nome: spazi -> _, punti ai bordi tolti', tmfSaveImageName(' ..Ricetta test 1.. ', d) === 'Ricetta_test_1_20261028-090507.png', + tmfSaveImageName(' ..Ricetta test 1.. ', d)); +check('nome: vuoto/null/undefined -> immagine', + tmfSaveImageName('', d).startsWith('immagine_') && tmfSaveImageName(null, d).startsWith('immagine_') && + tmfSaveImageName(undefined, d).startsWith('immagine_')); +check('nome: solo caratteri vietati -> immagine', tmfSaveImageName('///', d) === 'immagine_20261028-090507.png', + tmfSaveImageName('///', d)); +check('nome: senza data usa ora corrente e ha il formato giusto', + /^x_\d{8}-\d{6}\.png$/.test(tmfSaveImageName('x')), tmfSaveImageName('x')); +check('nome: data non valida -> ora corrente', /^x_\d{8}-\d{6}\.png$/.test(tmfSaveImageName('x', new Date('boh')))); +check('nome: non esiste doppia estensione', !/\.png\.png$/.test(tmfSaveImageName('foto.png', d))); + +// --- finti del browser --- +const globals = ['fetch', 'window', 'document', 'URL', 'createImageBitmap', 'setTimeout']; +const saved = {}; +globals.forEach((k) => { saved[k] = Object.getOwnPropertyDescriptor(globalThis, k); }); +function restore() { + globals.forEach((k) => { + if (saved[k]) Object.defineProperty(globalThis, k, saved[k]); else delete globalThis[k]; + }); +} +function setGlobal(k, v) { Object.defineProperty(globalThis, k, { value: v, configurable: true, writable: true }); } + +function fakeEnv({ blobType = 'image/png', ok = true, picker, pickerErr, fetchErr } = {}) { + const log = { fetched: [], written: [], closed: 0, clicks: [], appended: 0, removed: 0, revoked: [], pickerOpts: null, canvases: [] }; + const blob = { type: blobType, size: 3, tag: 'blob-originale' }; + setGlobal('fetch', async (src) => { + log.fetched.push(src); + if (fetchErr) throw fetchErr; + return { ok, blob: async () => blob }; + }); + const win = {}; + if (picker) { + win.showSaveFilePicker = async (opts) => { + log.pickerOpts = opts; + if (pickerErr) throw pickerErr; + return { createWritable: async () => ({ write: async (b) => { log.written.push(b); }, close: async () => { log.closed++; } }) }; + }; + } + setGlobal('window', win); + setGlobal('document', { + body: { appendChild() { log.appended++; }, removeChild() { log.removed++; } }, + createElement(tag) { + if (tag === 'canvas') { + const c = { + width: 0, height: 0, + getContext() { return { drawImage() { c.drawn = true; } }; }, + toBlob(cb, type) { cb({ type, size: 9, tag: 'blob-png' }); }, + }; + log.canvases.push(c); + return c; + } + const a = { click() { log.clicks.push({ href: a.href, download: a.download }); } }; + return a; + }, + }); + setGlobal('URL', { createObjectURL: (b) => 'blob:fake/' + b.tag, revokeObjectURL: (u) => { log.revoked.push(u); } }); + setGlobal('createImageBitmap', async () => ({ width: 20, height: 10, close() {} })); + setGlobal('setTimeout', (fn) => { fn(); return 0; }); // il revoke differito scatta subito + return { log, blob }; +} + +(async () => { + const origErr = console.error; + try { + // --- ramo showSaveFilePicker, blob gia' PNG --- + { + const { log, blob } = fakeEnv({ picker: true }); + const r = await tmfSaveImage('blob:http://x/abc', 'task 14'); + check('picker: risolve a true', r === true); + check('picker: fetch dell\'src', log.fetched.length === 1 && log.fetched[0] === 'blob:http://x/abc'); + check('picker: suggestedName del formato atteso', + log.pickerOpts && /^task_14_\d{8}-\d{6}\.png$/.test(log.pickerOpts.suggestedName), JSON.stringify(log.pickerOpts)); + check('picker: tipo PNG', log.pickerOpts && log.pickerOpts.types[0].accept['image/png'][0] === '.png'); + check('picker: scritto il blob originale (gia\' PNG, nessuna conversione) e chiuso', + log.written.length === 1 && log.written[0] === blob && log.closed === 1 && log.canvases.length === 0); + check('picker: nessun ', log.clicks.length === 0); + } + // --- picker, blob JPEG -> convertito su canvas --- + { + const { log } = fakeEnv({ picker: true, blobType: 'image/jpeg' }); + const r = await tmfSaveImage('http://x/frame.jpg', 'f'); + check('picker + JPEG: risolve a true', r === true); + check('picker + JPEG: ridisegnato su canvas 20x10 e scritto il PNG', + log.canvases.length === 1 && log.canvases[0].width === 20 && log.canvases[0].height === 10 && log.canvases[0].drawn && + log.written.length === 1 && log.written[0].tag === 'blob-png' && log.written[0].type === 'image/png'); + } + // --- ramo (nessun showSaveFilePicker) --- + { + const { log, blob } = fakeEnv({ picker: false }); + const r = await tmfSaveImage('blob:http://x/abc', 'task_14'); + check('a download: risolve a true', r === true); + check('a download: un click con href del blob e nome atteso', + log.clicks.length === 1 && log.clicks[0].href === 'blob:fake/' + blob.tag && + /^task_14_\d{8}-\d{6}\.png$/.test(log.clicks[0].download), JSON.stringify(log.clicks)); + check('a download: elemento aggiunto, rimosso e URL liberato', + log.appended === 1 && log.removed === 1 && log.revoked.length === 1); + } + // --- AbortError: annullo dell'utente, nessun errore --- + { + const abort = Object.assign(new Error('The user aborted a request.'), { name: 'AbortError' }); + const errs = []; + console.error = (...a) => errs.push(a); + const { log } = fakeEnv({ picker: true, pickerErr: abort }); + let thrown = null, r = null; + try { r = await tmfSaveImage('blob:x', 'a'); } catch (e) { thrown = e; } + check('AbortError: nessuna eccezione, risolve a false', thrown === null && r === false, String(thrown)); + check('AbortError: nessun errore in console, niente scritto, niente ', + errs.length === 0 && log.written.length === 0 && log.clicks.length === 0); + } + // --- errori veri: fetch fallisce, risposta non ok, picker con errore diverso --- + { + console.error = () => {}; + let thrown = null, r1 = null, r2 = null, r3 = null; + try { + fakeEnv({ picker: true, fetchErr: new TypeError('rete') }); + r1 = await tmfSaveImage('blob:x', 'a'); + fakeEnv({ picker: true, ok: false }); + r2 = await tmfSaveImage('blob:x', 'a'); + fakeEnv({ picker: true, pickerErr: Object.assign(new Error('disco pieno'), { name: 'NotAllowedError' }) }); + r3 = await tmfSaveImage('blob:x', 'a'); + } catch (e) { thrown = e; } + check('errori: fetch rotto, risposta non ok, picker che fallisce -> false senza eccezioni', + thrown === null && r1 === false && r2 === false && r3 === false, String(thrown) + [r1, r2, r3]); + } + // --- esposizione --- + { + fakeEnv({ picker: false }); + check('esposto come funzione e export', typeof tmfSaveImage === 'function' && typeof tmfSaveImageName === 'function'); + } + } finally { + console.error = origErr; + restore(); + } + console.log('\n' + (total - failures) + '/' + total + ' controlli ok'); + process.exit(failures ? 1 : 0); +})(); diff --git a/src/frontend/flask_app/tests/test_save_image_js.py b/src/frontend/flask_app/tests/test_save_image_js.py new file mode 100644 index 0000000..9d181a0 --- /dev/null +++ b/src/frontend/flask_app/tests/test_save_image_js.py @@ -0,0 +1,35 @@ +"""Runs the plain-node save-image helper test (tests/js/test_save_image.js). + +Same thin pytest wrapper pattern as test_vision_overlay_js.py: shells out to +``node`` and asserts the script's own exit code. Skips (does not fail) when +node is not installed. + +Covers gitea:Adriano/TieMeasureFlow#119. +""" +import shutil +import subprocess +from pathlib import Path + +import pytest + +FLASK_APP_DIR = Path(__file__).resolve().parent.parent +JS_TEST = Path(__file__).resolve().parent / "js" / "test_save_image.js" + + +def test_save_image_js_behaviour(): + node = shutil.which("node") + if not node: + pytest.skip("node non installato: test JS del salvataggio immagine saltato") + + result = subprocess.run( + [node, str(JS_TEST)], + cwd=str(FLASK_APP_DIR), + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode == 0, ( + f"tests/js/test_save_image.js fallito (exit {result.returncode})\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + )