Pannello visione: zoom, Adatta e trascinamento sull'immagine

Refs #115

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 16:54:13 +01:00
parent 7a1df0bca4
commit 4a11d2595f
5 changed files with 292 additions and 3 deletions
@@ -0,0 +1,111 @@
/**
* #115: zoom, Adatta e pan del riquadro immagine del pannello visione, con plain node.
*
* Run from src/frontend/flask_app: node tests/js/test_vision_zoom.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'js', 'vision_execute.js'), 'utf8',
);
const win = {};
const ctx = vm.createContext({ window: win, document: { addEventListener() {}, querySelector() { return null; } } });
vm.runInContext(src + ';this.visionExecute = visionExecute;', ctx);
let failures = 0;
function check(label, cond, extra) {
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
const near = (a, b) => Math.abs(a - b) < 1e-9 * Math.max(1, Math.abs(b));
// Componente con viewport e immagine finti ($nextTick sincrono); getComputedStyle assente: si usano client*
function make(cw, ch) {
const c = ctx.visionExecute();
const vp = { clientWidth: cw, clientHeight: ch, scrollLeft: 0, scrollTop: 0, setPointerCapture() {} };
const img = { naturalWidth: 2000, naturalHeight: 1000 };
c.$refs = { vp, img, overlay: null };
c.$nextTick = (fn) => fn();
return c;
}
// Prima del load: il wrapper riempie il riquadro
let c = make(800, 400);
check('#115 zSize prima del load = 100 %', c.zSize() === 'width:100%;height:100%', c.zSize());
check('#115 zoomBy senza immagine: ignorato', (c.zoomBy(2), c.z.zoom === 1));
// Load: fit = min(800/2000, 400/1000) = 0.4
c.onImgLoad();
check('#115 load: Adatta = 0.4', near(c.z.zoom, 0.4) && near(c.z.fit, 0.4) && c.z.auto === true, String(c.z.zoom));
check('#115 zSize = naturale x zoom', c.zSize() === 'width:800px;height:400px', c.zSize());
check('#115 zPct = 40%', c.zPct() === '40%', c.zPct());
check('#115 a Adatta non si trascina', c.zCanPan() === false && c.zCursor() === 'default');
// Zoom in/out e limiti
c.zoomBy(1.25);
check('#115 zoomBy moltiplica', near(c.z.zoom, 0.5) && c.z.auto === false);
check('#115 dopo lo zoom si trascina', c.zCanPan() === true && c.zCursor() === 'grab');
c.zoomBy(1e6);
check('#115 limite alto 8', c.z.zoom === 8, String(c.z.zoom));
c.zoomBy(1e-9);
check('#115 limite basso fit*0.5', near(c.z.zoom, 0.2), String(c.z.zoom));
c.zFit();
check('#115 Adatta torna al fit e a auto', near(c.z.zoom, 0.4) && c.z.auto === true);
// Il punto sotto il cursore resta fermo: zoom da 0.4 a 0.8 col cursore a (100, 50), scroll 0
c.zoomBy(2, 100, 50);
check('#115 zoom al cursore: scrollLeft', near(c.$refs.vp.scrollLeft, 100) && near(c.$refs.vp.scrollTop, 50),
c.$refs.vp.scrollLeft + ',' + c.$refs.vp.scrollTop);
// Frame nuovo con le stesse dimensioni: tiene lo zoom; dimensioni diverse: rifa il fit
const z0 = c.z.zoom;
c.onImgLoad();
check('#115 frame con stesse dimensioni: zoom invariato', c.z.zoom === z0 && c.z.auto === false);
c.$refs.img.naturalWidth = 1000; c.$refs.img.naturalHeight = 1000;
c.onImgLoad();
check('#115 dimensioni diverse: nuovo fit', near(c.z.fit, 0.4) && c.z.zoom === c.z.fit && c.z.auto === true);
// Riquadro nascosto (0 x 0): niente fit, stato invariato
c = make(0, 0);
c.onImgLoad();
check('#115 riquadro nascosto: nessun fit', c.z.zoom === 1 && c.z.w === 2000);
c.$refs.vp.clientWidth = 800; c.$refs.vp.clientHeight = 400;
c.zFit();
check('#115 riquadro visibile: il fit parte', near(c.z.zoom, 0.4));
// offset meno bordi (getComputedStyle disponibile)
c = make(780, 380);
c.$refs.vp.offsetWidth = 802; c.$refs.vp.offsetHeight = 402;
ctx.getComputedStyle = () => ({ borderLeftWidth: '1px', borderRightWidth: '1px', borderTopWidth: '1px', borderBottomWidth: '1px' });
c.onImgLoad();
check('#115 area = offset meno bordi', near(c.z.fit, 0.4), String(c.z.fit));
delete ctx.getComputedStyle;
// Rotella: solo con Ctrl
c = make(800, 400); c.onImgLoad();
let prevented = false;
c.zWheel({ ctrlKey: false, deltaY: -1, preventDefault() { prevented = true; } });
check('#115 rotella senza Ctrl: ignorata', !prevented && near(c.z.zoom, 0.4));
c.zWheel({ ctrlKey: true, deltaY: -1, clientX: 0, clientY: 0, preventDefault() { prevented = true; } });
check('#115 Ctrl+rotella su: zoom x1.25', prevented && near(c.z.zoom, 0.5));
c.zWheel({ ctrlKey: true, deltaY: 1, preventDefault() {} });
check('#115 Ctrl+rotella giu: zoom /1.25', near(c.z.zoom, 0.4));
// Pan: sposta lo scroll solo se ingrandito
c = make(800, 400); c.onImgLoad();
c.zPanDown({ clientX: 10, clientY: 10, pointerType: 'mouse', button: 0, pointerId: 1 });
check('#115 pan a Adatta: ignorato', c._zPan === null && c.zPanning === false);
c.zoomBy(2);
c.$refs.vp.scrollLeft = 50; c.$refs.vp.scrollTop = 40;
c.zPanDown({ clientX: 100, clientY: 100, pointerType: 'mouse', button: 0, pointerId: 1 });
check('#115 pan avviato: cursore grabbing', c.zPanning === true && c.zCursor() === 'grabbing');
c.zPanMove({ clientX: 80, clientY: 90 });
check('#115 pan: scroll segue il puntatore', c.$refs.vp.scrollLeft === 70 && c.$refs.vp.scrollTop === 50,
c.$refs.vp.scrollLeft + ',' + c.$refs.vp.scrollTop);
c.zPanEnd();
check('#115 pan finito', c._zPan === null && c.zPanning === false);
process.exit(failures ? 1 : 0);
@@ -46,6 +46,17 @@ def test_image_is_object_contain_without_h_auto():
assert "object-contain" in canvas # stesso fit dell'img: overlay allineato
def test_zoom_viewport_scrolls_inside_the_box():
# #115: il riquadro resta overflow-hidden (il pannello non scorre); lo scorrimento e' solo del viewport interno
panel = _panel()
vp = _tag_with(panel, 'x-ref="vp"')
assert "absolute" in vp and "inset-0" in vp
assert "overflow:auto" in vp # :style stringa del viewport
assert 'style="' not in vp.replace(':style="', "") # niente style statico accanto a :style (Alpine lo cancella)
assert "x-show" not in vp # mai x-show e :style stringa sullo stesso elemento
assert "zSize()" in panel # wrapper naturale x zoom
def test_image_box_and_outcome_column_can_shrink():
panel = _panel()
box = _tag_with(panel, 'x-show="shownImage"')
@@ -0,0 +1,35 @@
"""Runs the plain-node vision panel zoom test (tests/js/test_vision_zoom.js).
Same thin pytest wrapper pattern as test_vision_rows_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#115.
"""
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_vision_zoom.js"
def test_vision_zoom_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS dello zoom del pannello 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_vision_zoom.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)