diff --git a/src/frontend/flask_app/static/js/image_viewer.js b/src/frontend/flask_app/static/js/image_viewer.js new file mode 100644 index 0000000..f6744f8 --- /dev/null +++ b/src/frontend/flask_app/static/js/image_viewer.js @@ -0,0 +1,446 @@ +/** + * image_viewer.js - #122: , il visualizzatore immagine standard di TieMeasureFlow. + * + * Cosa fa: Custom Element in JS puro (senza Alpine ne' Tailwind) con zoom -/+, Adatta, Salva, percentuale, + * Ctrl+rotella intorno al puntatore, pan col puntatore, overlay SVG a tratto costante ed etichette. + * Sostituisce le quattro implementazioni di zoom di prima (pannello operatore #115, wizard DXF #113 e #104, + * pagina camera #111). Light DOM, stile iniettato una volta nel ( + + +

Prova di <tmf-viewer>

+
+

Immagine 800x500 + overlay + etichette (box fisso, Adatta nel box)

+
+

fit-height, no-save: alto quanto l'immagine

+
+

Modo contenuto: svg (DXF) senza src

+ + +
+

Vuoto: barra nascosta

+
+
in corso...
+ + + + + + diff --git a/src/frontend/flask_app/tests/js/test_image_viewer.js b/src/frontend/flask_app/tests/js/test_image_viewer.js new file mode 100644 index 0000000..cc4bb55 --- /dev/null +++ b/src/frontend/flask_app/tests/js/test_image_viewer.js @@ -0,0 +1,99 @@ +/** + * #122: static/js/image_viewer.js () con plain node: matematica pura (TmfViewerMath) e + * registrazione del Custom Element una sola volta. Il DOM vero si prova in Chrome (fixtures/image_viewer_demo.html). + * + * Il modulo si carica senza window/document/customElements. + * Run from src/frontend/flask_app: node tests/js/test_image_viewer.js + */ +'use strict'; +const path = require('path'); + +const MOD = path.join(__dirname, '..', '..', 'static', 'js', 'image_viewer.js'); + +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; + +// --- caricamento in node senza globali del browser --- +check('node senza window/document/customElements/HTMLElement', + typeof window === 'undefined' && typeof document === 'undefined' && + typeof customElements === 'undefined' && typeof HTMLElement === 'undefined'); +const { TmfViewerMath: M } = require(MOD); +check('esporta TmfViewerMath con le quattro funzioni', + M && ['fitZoom', 'clampZoom', 'zoomAround', 'pct'].every((k) => typeof M[k] === 'function')); + +// --- fitZoom --- +check('fitZoom: box largo, immagine alta -> limita l\'altezza', near(M.fitZoom(800, 400, 1000, 1000), 0.4)); +check('fitZoom: box alto, immagine larga -> limita la larghezza', near(M.fitZoom(400, 800, 1000, 500), 0.4)); +check('fitZoom: immagine piu\' piccola del box -> zoom > 1', near(M.fitZoom(800, 600, 400, 300), 2)); +check('fitZoom: box esatto -> 1', near(M.fitZoom(640, 480, 640, 480), 1)); +check('fitZoom: boxH Infinity (fit-height) -> solo larghezza', near(M.fitZoom(500, Infinity, 1000, 4000), 0.5)); +check('fitZoom: 0 se box nascosto o immagine senza dimensioni', + M.fitZoom(0, 400, 10, 10) === 0 && M.fitZoom(400, 0, 10, 10) === 0 && + M.fitZoom(400, 400, 0, 10) === 0 && M.fitZoom(400, 400, 10, 0) === 0); +check('fitZoom: 0 con NaN, negativi, undefined', + M.fitZoom(NaN, 400, 10, 10) === 0 && M.fitZoom(-5, 400, 10, 10) === 0 && M.fitZoom(400, 400, undefined, 10) === 0 && + M.fitZoom(400, 400, 10, null) === 0); + +// --- clampZoom --- +check('clampZoom: dentro i limiti resta uguale', near(M.clampZoom(1, 0.5), 1)); +check('clampZoom: sotto fit*0.5 -> fit*0.5', near(M.clampZoom(0.01, 0.5), 0.25)); +check('clampZoom: sopra 8 -> 8', near(M.clampZoom(20, 0.5), 8)); +check('clampZoom: fit sopra 1 alza il massimo a fit*8 (DXF in mm, fit ~10)', near(M.clampZoom(20, 12), 20) && near(M.clampZoom(200, 12), 96) && near(M.clampZoom(11, 12), 11)); +check('clampZoom: con fit piccolo il massimo resta 8', near(M.clampZoom(20, 0.5), 8) && near(M.clampZoom(20, 1), 8) && near(M.clampZoom(100, 2), 16)); +check('clampZoom: limiti esatti', near(M.clampZoom(0.25, 0.5), 0.25) && near(M.clampZoom(8, 0.5), 8)); + +// --- zoomAround --- +{ + // il punto del contenuto sotto (px,py) prima e dopo lo zoom deve essere lo stesso + const z0 = 0.5, z1 = 1.25, sl = 120, st = 40, px = 300, py = 200; + const r = M.zoomAround(z0, z1, sl, st, px, py); + const before = { x: (sl + px) / z0, y: (st + py) / z0 }; // coordinate logiche sotto il puntatore + const after = { x: (r.scrollLeft + px) / z1, y: (r.scrollTop + py) / z1 }; + check('zoomAround: il punto sotto il puntatore resta fermo', near(before.x, after.x) && near(before.y, after.y), + JSON.stringify([before, after])); + const same = M.zoomAround(2, 2, 50, 60, 10, 10); + check('zoomAround: z1 == z0 non sposta lo scroll', near(same.scrollLeft, 50) && near(same.scrollTop, 60)); + const c = M.zoomAround(1, 2, 0, 0, 100, 50); + check('zoomAround: da scroll 0 raddoppiando (100,50) -> scroll (100,50)', near(c.scrollLeft, 100) && near(c.scrollTop, 50)); + const o = M.zoomAround(2, 1, 100, 100, 0, 0); + check('zoomAround: puntatore all\'origine, riduzione -> scroll dimezzato', near(o.scrollLeft, 50) && near(o.scrollTop, 50)); +} + +// --- pct --- +check('pct: 1 -> "100 %"', M.pct(1) === '100 %', M.pct(1)); +check('pct: arrotonda 0.4567 -> "46 %"', M.pct(0.4567) === '46 %', M.pct(0.4567)); +check('pct: 8 -> "800 %", 0 -> "0 %"', M.pct(8) === '800 %' && M.pct(0) === '0 %'); + +// --- define una sola volta, anche caricando due volte --- +{ + const defined = {}; + let defineCalls = 0; + globalThis.HTMLElement = class {}; + globalThis.customElements = { + get: (n) => defined[n], + define: (n, c) => { defineCalls++; defined[n] = c; }, + }; + delete require.cache[require.resolve(MOD)]; + require(MOD); + delete require.cache[require.resolve(MOD)]; + require(MOD); + check('customElements.define("tmf-viewer") una sola volta con due caricamenti', + defineCalls === 1 && typeof defined['tmf-viewer'] === 'function', 'chiamate: ' + defineCalls); + const C = defined['tmf-viewer']; + check('osserva src, width, height, fit-height, no-save, save-name', + ['src', 'width', 'height', 'fit-height', 'no-save', 'save-name'].every((a) => C.observedAttributes.includes(a))); + check('espone fit, zoomBy, overlay, labels, zoom sul prototipo', + typeof C.prototype.fit === 'function' && typeof C.prototype.zoomBy === 'function' && + ['overlay', 'labels', 'zoom'].every((k) => Object.getOwnPropertyDescriptor(C.prototype, k))); + delete globalThis.customElements; + delete globalThis.HTMLElement; +} + +console.log('\n' + (total - failures) + '/' + total + ' controlli ok'); +process.exit(failures ? 1 : 0); diff --git a/src/frontend/flask_app/tests/js/test_save_image.js b/src/frontend/flask_app/tests/js/test_save_image.js index aa3f4a6..7f96ad1 100644 --- a/src/frontend/flask_app/tests/js/test_save_image.js +++ b/src/frontend/flask_app/tests/js/test_save_image.js @@ -8,7 +8,7 @@ const path = require('path'); const mod = require(path.join(__dirname, '..', '..', 'static', 'js', 'save_image.js')); -const { tmfSaveImage, tmfSaveImageName } = mod; +const { tmfSaveImage, tmfSaveImageName, tmfSaveBlob, tmfSvgToPngBlob } = mod; // #122: + tmfSaveBlob, tmfSvgToPngBlob let failures = 0; let total = 0; @@ -37,7 +37,7 @@ check('nome: data non valida -> ora corrente', /^x_\d{8}-\d{6}\.png$/.test(tmfSa 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 globals = ['fetch', 'window', 'document', 'URL', 'createImageBitmap', 'setTimeout', 'Image', 'XMLSerializer']; // #122: + Image, XMLSerializer const saved = {}; globals.forEach((k) => { saved[k] = Object.getOwnPropertyDescriptor(globalThis, k); }); function restore() { @@ -149,10 +149,87 @@ function fakeEnv({ blobType = 'image/png', ok = true, picker, pickerErr, fetchEr 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]); } + // --- #122: tmfSaveBlob con picker --- + { + const { log } = fakeEnv({ picker: true }); + const png = { type: 'image/png', size: 5, tag: 'blob-dxf' }; + const r = await tmfSaveBlob(png, 'dxf ricetta'); + check('saveBlob picker: true, scrive il blob dato senza fetch ne conversioni', + r === true && log.written.length === 1 && log.written[0] === png && log.closed === 1 && + log.fetched.length === 0 && log.canvases.length === 0); + check('saveBlob picker: nome e tipo PNG proposti', + /^dxf_ricetta_\d{8}-\d{6}\.png$/.test(log.pickerOpts.suggestedName) && log.pickerOpts.types[0].accept['image/png'][0] === '.png'); + } + // --- #122: tmfSaveBlob senza picker -> --- + { + const { log } = fakeEnv({ picker: false }); + const png = { type: 'image/png', size: 5, tag: 'blob-dxf' }; + const r = await tmfSaveBlob(png, 'dxf'); + check('saveBlob a download: true, un click con href del blob e nome atteso, URL liberato', + r === true && log.clicks.length === 1 && log.clicks[0].href === 'blob:fake/blob-dxf' && + /^dxf_\d{8}-\d{6}\.png$/.test(log.clicks[0].download) && log.revoked.length === 1); + } + // --- #122: tmfSaveBlob: blob mancante, annullo, errore --- + { + const errs = []; + console.error = (...a) => errs.push(a); + fakeEnv({ picker: true }); + const rNull = await tmfSaveBlob(null, 'x'); + const abort = Object.assign(new Error('annullato'), { name: 'AbortError' }); + fakeEnv({ picker: true, pickerErr: abort }); + const rAbort = await tmfSaveBlob({ type: 'image/png', tag: 'b' }, 'x'); + const nAbort = errs.length; + fakeEnv({ picker: true, pickerErr: Object.assign(new Error('disco pieno'), { name: 'NotAllowedError' }) }); + const rErr = await tmfSaveBlob({ type: 'image/png', tag: 'b' }, 'x'); + check('saveBlob: null -> false; AbortError -> false senza log; altro errore -> false con log', + rNull === false && rAbort === false && nAbort === 0 && rErr === false && errs.length === 1); + console.error = () => {}; + } + // --- #122: tmfSvgToPngBlob --- + { + const { log } = fakeEnv({ picker: false }); + const svgs = []; + setGlobal('XMLSerializer', class { serializeToString(el) { svgs.push(el); return ''; } }); + setGlobal('Image', class { set src(v) { this._src = v; setImmediate(() => this.onload()); } }); + const fills = []; + const cv = document.createElement; + document.createElement = (tag) => { + const e = cv(tag); + if (tag === 'canvas') e.getContext = () => ({ set fillStyle(v) { fills.push(v); }, fillRect() { fills.push('rect'); }, drawImage() { e.drawn = true; } }); + return e; + }; + const made = []; + const svgEl = { + attrs: { viewBox: '0 0 300 200' }, style: { width: '100%', height: '100%' }, + getAttribute(k) { return this.attrs[k] === undefined ? null : this.attrs[k]; }, + setAttribute(k, v) { this.attrs[k] = String(v); }, + cloneNode() { const c = Object.assign({}, this, { attrs: Object.assign({}, this.attrs), style: Object.assign({}, this.style) }); made.push(c); return c; }, + }; + const png = await tmfSvgToPngBlob(svgEl, 300, 200); + const c = log.canvases[0]; + check('svgToPng: ritorna il PNG del canvas, sfondo bianco, lato lungo portato a 1600 (300x200 -> 1600x1067)', + png && png.tag === 'blob-png' && c.width === 1600 && c.height === 1067 && fills[0] === '#ffffff' && fills[1] === 'rect' && c.drawn, + JSON.stringify([c.width, c.height, fills])); + check('svgToPng: clone con xmlns, width/height in pixel del PNG e stile 100% tolto; originale intatto', + made.length === 1 && made[0].attrs.xmlns === 'http://www.w3.org/2000/svg' && made[0].attrs.width === '1600' && + made[0].attrs.height === '1067' && made[0].style.width === '' && svgEl.attrs.width === undefined && svgs[0] === made[0]); + check('svgToPng: URL del blob SVG liberato', log.revoked.length === 1); + // lato lungo oltre 4096 -> ridotto + const big = await tmfSvgToPngBlob(svgEl, 10000, 5000); + check('svgToPng: 10000x5000 -> 4096x2048', log.canvases[1].width === 4096 && log.canvases[1].height === 2048 && big); + // senza w/h usa il viewBox; senza dimensioni lancia + await tmfSvgToPngBlob(svgEl); + check('svgToPng: senza w/h usa il viewBox', log.canvases[2].width === 1600 && log.canvases[2].height === 1067); + let thrown = null; + try { await tmfSvgToPngBlob({ getAttribute: () => null }); } catch (e) { thrown = e; } + check('svgToPng: senza dimensioni lancia', thrown !== null); + document.createElement = cv; + } // --- esposizione --- { fakeEnv({ picker: false }); - check('esposto come funzione e export', typeof tmfSaveImage === 'function' && typeof tmfSaveImageName === 'function'); + check('esposto come funzione e export', typeof tmfSaveImage === 'function' && typeof tmfSaveImageName === 'function' && + typeof tmfSaveBlob === 'function' && typeof tmfSvgToPngBlob === 'function'); } } finally { console.error = origErr; diff --git a/src/frontend/flask_app/tests/test_image_viewer_js.py b/src/frontend/flask_app/tests/test_image_viewer_js.py new file mode 100644 index 0000000..f09e318 --- /dev/null +++ b/src/frontend/flask_app/tests/test_image_viewer_js.py @@ -0,0 +1,35 @@ +"""Runs the plain-node image viewer (TmfViewerMath) test (tests/js/test_image_viewer.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#122. +""" +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_image_viewer.js" + + +def test_image_viewer_js_behaviour(): + node = shutil.which("node") + if not node: + pytest.skip("node non installato: test JS del visualizzatore 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_image_viewer.js fallito (exit {result.returncode})\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + )