Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
This commit is contained in:
@@ -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 <baseName>_<AAAAMMGG-hhmmss>.png: «Salva con nome» (showSaveFilePicker)
|
||||||
|
* dove c'e', altrimenti <a download>. Ritorna una Promise<boolean> (false se annullato o fallito).
|
||||||
|
* tmfSaveImageName(baseName, date) e' la funzione pura del nome, esportata per i test.
|
||||||
|
* Come si usa: <script src=".../save_image.js"></script> 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 <baseName>_<AAAAMMGG-hhmmss>.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): <a download>
|
||||||
|
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 };
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
* con require.
|
* con require.
|
||||||
*
|
*
|
||||||
* API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null;
|
* 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.
|
* 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)
|
* Porting da TieVision-Studio frontend/src/comune/overlay.js (verticiRect2, forma)
|
||||||
* e frontend/src/editor/misure.js (ancoraOverlay).
|
* e frontend/src/editor/misure.js (ancoraOverlay).
|
||||||
@@ -129,6 +130,82 @@ var VisionOverlay = (function () {
|
|||||||
return res;
|
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 <svg> 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
|
// #116: punto di ancoraggio dell'etichetta (px immagine), come ancoraOverlay dello Studio
|
||||||
function anchor(o) {
|
function anchor(o) {
|
||||||
switch (o && o.type) {
|
switch (o && o.type) {
|
||||||
@@ -172,6 +249,8 @@ var VisionOverlay = (function () {
|
|||||||
var pt = null;
|
var pt = null;
|
||||||
for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]);
|
for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]);
|
||||||
if (!pt) return;
|
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);
|
var v = Number(r.value);
|
||||||
if (!isFinite(v)) return;
|
if (!isFinite(v)) return;
|
||||||
out.push({
|
out.push({
|
||||||
@@ -185,7 +264,7 @@ var VisionOverlay = (function () {
|
|||||||
return out;
|
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;
|
if (typeof window !== 'undefined') window.VisionOverlay = VisionOverlay;
|
||||||
|
|||||||
@@ -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"g<h>i|j', d) === 'a_b_c_d_e_f_g_h_i_j_20261028-090507.png',
|
||||||
|
tmfSaveImageName('a/b\\c:d*e?f"g<h>i|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 <a download>', 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 <a download> (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 <a download>',
|
||||||
|
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);
|
||||||
|
})();
|
||||||
@@ -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);
|
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 <svg> esterno', !/<svg/.test(out));
|
||||||
|
check('svg fixture: nessun NaN/undefined/null nel markup', !/NaN|undefined|null|Infinity/.test(out));
|
||||||
|
check('svg fixture: numeri con al piu\' 2 decimali', !/\d\.\d{3,}/.test(out));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const one = (o, opts) => 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('<polygon ') && roi.includes('points="110,46 110,54 90,54 90,46"') && roi.includes('stroke="#f59e0b"') &&
|
||||||
|
roi.includes('stroke-dasharray="6 4"') && roi.includes('stroke-width="1.5"'), roi);
|
||||||
|
const roi4 = one({ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 }, { lw: 4 });
|
||||||
|
check('svg roi: lw 4 -> 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 === '<line x1="2" y1="1" x2="4" y2="3" fill="none" stroke="#16a34a" stroke-width="2" vector-effect="non-scaling-stroke"/>', 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('<circle cx="7" cy="5" r="3" '), cir);
|
||||||
|
const con = one({ type: 'contour', role: 'found', rows: [1, 2, 3], cols: [4, 5, 6] });
|
||||||
|
check('svg contour: polyline aperta', con.startsWith('<polyline points="4,1 5,2 6,3" ') && !/ Z/.test(con), con);
|
||||||
|
|
||||||
|
// points: croce di semi-braccio cross/zoom
|
||||||
|
const p1 = one({ type: 'points', role: 'found', rows: [10], cols: [20] }, { zoom: 1 });
|
||||||
|
check('svg points zoom 1: semi-braccio 6', p1.includes('d="M 14 10 L 26 10 M 20 4 L 20 16"'), p1);
|
||||||
|
const p2 = one({ type: 'points', role: 'found', rows: [10], cols: [20] }, { zoom: 2 });
|
||||||
|
check('svg points zoom 2: semi-braccio 3', p2.includes('d="M 17 10 L 23 10 M 20 7 L 20 13"'), p2);
|
||||||
|
const p3 = one({ type: 'points', role: 'found', rows: [10, 30], cols: [20, 40] }, { zoom: 1, cross: 2 });
|
||||||
|
check('svg points: cross personalizzato, 2 croci in un solo path',
|
||||||
|
count(p3, /<path /g) === 1 && count(p3, /M /g) === 4 && p3.includes('M 18 10 L 22 10'), p3);
|
||||||
|
|
||||||
|
// arc: estremi calcolati (y verso il basso, angoli antiorari)
|
||||||
|
const arcD = (o) => /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, /<line /g) === 1 && count(out, /vector-effect/g) === 1, String(thrown) + out);
|
||||||
|
check('svg: overlay non array o vuoto -> 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 = '<script>alert(1)</script>"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: '<b>', col1: 0, row2: 1, col2: 1 }]);
|
||||||
|
check('svg: coordinata stringa -> forma saltata', strNum === '');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
|
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
|
||||||
process.exit(failures ? 1 : 0);
|
process.exit(failures ? 1 : 0);
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user