Test del helper di salvataggio immagine e fix pulizia backslash nel nome

Refs #119

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 21:18:20 +01:00
parent df67a32a82
commit 25b0741003
3 changed files with 199 additions and 1 deletions
@@ -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);
})();
@@ -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}"
)