From 25b0741003a4a1abbb7a7959a6c836c978349084 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 28 Oct 2026 21:18:20 +0100 Subject: [PATCH] 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}" + )