- {{ "%.3f"|format(m.value) }} {{ m.subtask.unit }}
+ {# #118: quota non misurata (value None) -> NaN, senza unita' #}
+ {% if m.value is none %}NaN{% else %}{{ "%.3f"|format(m.value) }} {{ m.subtask.unit }}{% endif %}
-
- {{ "%+.3f"|format(m.deviation) }}
+
+ {# #118: deviazione None, o valore None (il blueprint la mette a 0.0) -> NaN #}
+ {% if m.value is none or m.deviation is none %}NaN{% else %}{{ "%+.3f"|format(m.deviation) }}{% endif %}
{% if m.pass_fail == 'pass' %}
diff --git a/src/frontend/flask_app/tests/js/test_csv_export.js b/src/frontend/flask_app/tests/js/test_csv_export.js
index 25de58c..fa73128 100644
--- a/src/frontend/flask_app/tests/js/test_csv_export.js
+++ b/src/frontend/flask_app/tests/js/test_csv_export.js
@@ -153,6 +153,20 @@ const MEASUREMENT = {
);
}
+// #118: quota non misurata (value null) -> NaN nel valore e nella deviazione; tolleranze nulle restano vuote
+{
+ const e = csvExport();
+ const nan = { ...MEASUREMENT, value: null, deviation: null, pass_fail: 'fail',
+ subtask: { ...MEASUREMENT.subtask, utl: null, ltl: null } };
+ const row = e.buildMeasurementCSV([nan]).split('\n')[1].split(';');
+ check('#118 value null -> NaN', row[3] === 'NaN', row.join('|'));
+ check('#118 deviazione null -> NaN', row[8] === 'NaN', row.join('|'));
+ check('#118 tolleranze nulle restano vuote', row[6] === '' && row[7] === '', row.join('|'));
+ check('#118 esito fail', row[9] === 'fail', row.join('|'));
+ const ok = e.buildMeasurementCSV([MEASUREMENT]).split('\n')[1].split(';');
+ check('#118 valore presente invariato', ok[3] !== 'NaN' && ok[8] !== 'NaN', ok.join('|'));
+}
+
if (failures > 0) {
console.error(failures + ' test falliti su csv-export.js');
process.exit(1);
diff --git a/src/frontend/flask_app/tests/js/test_dxf_zoom.js b/src/frontend/flask_app/tests/js/test_dxf_zoom.js
index dc4b9a3..a057dc5 100644
--- a/src/frontend/flask_app/tests/js/test_dxf_zoom.js
+++ b/src/frontend/flask_app/tests/js/test_dxf_zoom.js
@@ -139,6 +139,44 @@ delete global.getComputedStyle;
await wd.loadDxfPreview();
check('#113 DXF tolto: vista azzerata', wd.views.dxf.w === 0 && wd.views.dxf.zoom === 1);
+ // #121: overlay SVG al posto del canvas; ridisegno a ogni cambio di zoom (zoomBy, fit)
+ {
+ const calls = [];
+ global.window = { VisionOverlay: { svg: (shapes, o) => { calls.push({ n: shapes.length, zoom: o.zoom }); return ''; } } };
+ const attrs = {};
+ const sv = { innerHTML: '', setAttribute: (k, v) => { attrs[k] = v; } };
+ const mk = () => {
+ const x = make(400);
+ x.$refs = { viewport: { clientWidth: 400, clientHeight: 300, scrollLeft: 0, scrollTop: 0 },
+ img: { naturalWidth: 800, naturalHeight: 600 }, overlay: sv };
+ x.result = { result: { overlay: [{ type: 'contour', role: 'found', rows: [1], cols: [2] }] } };
+ return x;
+ };
+ const wo = mk();
+ wo.drawOverlay();
+ check('#121 viewBox = dimensioni naturali', attrs.viewBox === '0 0 800 600', attrs.viewBox);
+ check('#121 innerHTML = VisionOverlay.svg', sv.innerHTML === '' && calls[0].n === 1);
+ wo.zoomBy(2);
+ check('#121 zoomBy ridisegna col nuovo zoom', calls[calls.length - 1].zoom === wo.zoom && wo.zoom === 2, JSON.stringify(calls));
+ wo.fit();
+ check('#121 fit ridisegna col nuovo zoom', calls[calls.length - 1].zoom === wo.zoom, JSON.stringify(calls));
+ // senza modulo o senza svg: vuoto, nessun errore
+ global.window = {};
+ sv.innerHTML = 'x';
+ let err = null;
+ try { mk().drawOverlay(); } catch (e) { err = e; }
+ check('#121 senza VisionOverlay: vuoto senza errori', err === null && sv.innerHTML === '', String(err));
+ global.window = { VisionOverlay: {} };
+ sv.innerHTML = 'x';
+ try { mk().drawOverlay(); } catch (e) { err = e; }
+ check('#121 senza svg(): vuoto senza errori', err === null && sv.innerHTML === '', String(err));
+ // immagine non ancora caricata: non tocca nulla
+ const wn = mk(); wn.$refs.img.naturalWidth = 0; sv.innerHTML = 'y';
+ wn.drawOverlay();
+ check('#121 immagine non caricata: nessun disegno', sv.innerHTML === 'y');
+ delete global.window;
+ }
+
if (failures) { console.log(failures + ' test falliti'); process.exit(1); }
console.log('tutti i test #113 ok');
})();
diff --git a/src/frontend/flask_app/tests/test_task_complete_nan.py b/src/frontend/flask_app/tests/test_task_complete_nan.py
new file mode 100644
index 0000000..79205d5
--- /dev/null
+++ b/src/frontend/flask_app/tests/test_task_complete_nan.py
@@ -0,0 +1,30 @@
+"""#118: il riepilogo task mostra NaN per una quota non misurata (value None)."""
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def _station(monkeypatch):
+ monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
+
+
+def test_task_complete_renders_nan_for_null_value(logged_in_client, mock_api_client):
+ mock_api_client.get.side_effect = [
+ {"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
+ [{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [
+ {"id": 1, "marker_number": 1, "description": "quota mancante", "nominal": 1.0,
+ "unit": "mm"},
+ {"id": 2, "marker_number": 2, "description": "quota ok", "nominal": 2.0,
+ "unit": "mm"}]}],
+ None,
+ {"items": [
+ {"id": 1, "subtask_id": 1, "value": None, "deviation": None, "pass_fail": "fail",
+ "measured_at": "2026-10-06T08:00:00+00:00"},
+ {"id": 2, "subtask_id": 2, "value": 2.5, "pass_fail": "pass",
+ "measured_at": "2026-10-06T08:00:00+00:00"},
+ ]},
+ ]
+ resp = logged_in_client.get("/measure/complete/1?version_id=5")
+ assert resp.status_code == 200
+ html = resp.get_data(as_text=True)
+ assert "NaN" in html
+ assert "2.500" in html # la quota misurata resta formattata
diff --git a/src/frontend/flask_app/tests/test_wizard_salva_svg.py b/src/frontend/flask_app/tests/test_wizard_salva_svg.py
new file mode 100644
index 0000000..36317e4
--- /dev/null
+++ b/src/frontend/flask_app/tests/test_wizard_salva_svg.py
@@ -0,0 +1,52 @@
+"""Controllo del template del wizard DXF: Salva in due barre zoom e overlay SVG (#119, #121).
+
+Legge task_editor.html come testo (come test_vision_panel_fit.py), senza renderlo.
+"""
+from pathlib import Path
+
+TEMPLATE = Path(__file__).resolve().parent.parent / "templates" / "maker" / "task_editor.html"
+
+
+def _src() -> str:
+ return TEMPLATE.read_text(encoding="utf-8")
+
+
+def _bar_of(src: str, marker: str) -> str:
+ """Testo della barra zoom che contiene il marker (dal div della barra al primo )."""
+ i = src.index(marker)
+ start = src.rfind('absolute top-2 right-2', 0, i)
+ return src[start:src.index("", i)]
+
+
+def test_prova_overlay_is_svg_not_canvas():
+ # #121: l'overlay della Prova e' un