diff --git a/src/frontend/flask_app/static/js/csv-export.js b/src/frontend/flask_app/static/js/csv-export.js index bd5c685..d8caecd 100644 --- a/src/frontend/flask_app/static/js/csv-export.js +++ b/src/frontend/flask_app/static/js/csv-export.js @@ -84,16 +84,18 @@ function csvExport() { const toleranceMinus = this.hasNumber(nominal) && this.hasNumber(subtask.ltl) ? nominal - subtask.ltl : null; + // #118: quota non misurata (value null) -> NaN nel valore e nella deviazione; le tolleranze nulle restano vuote + const notMeasured = m.value === null || m.value === undefined; const row = [ m.id || '', m.subtask_id || '', this.escapeCsvValue(subtask.description || ''), - this.formatNumber(m.value), + notMeasured ? 'NaN' : this.formatNumber(m.value), this.escapeCsvValue(subtask.unit || 'mm'), this.formatNumber(nominal), this.formatNumber(tolerancePlus), this.formatNumber(toleranceMinus), - this.formatNumber(m.deviation), + notMeasured ? 'NaN' : this.formatNumber(m.deviation), m.pass_fail || '', this.escapeCsvValue(m.lot_number || ''), this.escapeCsvValue(m.serial_number || ''), diff --git a/src/frontend/flask_app/static/js/dxf_recipe_wizard.js b/src/frontend/flask_app/static/js/dxf_recipe_wizard.js index e735f94..e422a7f 100644 --- a/src/frontend/flask_app/static/js/dxf_recipe_wizard.js +++ b/src/frontend/flask_app/static/js/dxf_recipe_wizard.js @@ -4,8 +4,7 @@ // la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'. // Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js. function dxfRecipeWizard(taskId, csrf, msgs) { - // Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found) - const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' }; + // #121: i colori per ruolo stanno in VisionOverlay (vision_overlay.js) const VALUE_UNITS = { 'dxf.points_in_pct': '%', 'dxf.distance_avg': 'mm', 'dxf.distance_min': 'mm', 'dxf.distance_max': 'mm' }; // #104 const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max']; @@ -466,6 +465,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) { let cx = 0, cy = 0; if (vp) { cx = (vp.scrollLeft + vp.clientWidth / 2) / old; cy = (vp.scrollTop + vp.clientHeight / 2) / old; } this.zoom = z; + this.drawOverlay(); // #121: ridisegno al nuovo zoom if (vp) this.$nextTick(() => { vp.scrollLeft = cx * z - vp.clientWidth / 2; vp.scrollTop = cy * z - vp.clientHeight / 2; }); }, // #104: Fit = tutta l'immagine nel viewport (altezza massima, non quella corrente che segue l'immagine) @@ -475,6 +475,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) { const z = Math.min(8, Math.max(0.05, Math.min(vp.clientWidth / img.naturalWidth, this._maxVpH() / img.naturalHeight))); this.zoom = z; this.fitZoom = z; + this.drawOverlay(); // #121: ridisegno al nuovo zoom vp.scrollLeft = 0; vp.scrollTop = 0; }, // #113: zoom per chiave ('img' | 'dxf'), stessa logica di #104 ma con stato in views[k]. Viewport fisso di 240px. @@ -581,26 +582,15 @@ function dxfRecipeWizard(taskId, csrf, msgs) { { maximumFractionDigits: 3 }) : '-'; }, - // Come vision_execute.js: canvas della dimensione dell'immagine, coordinate in pixel immagine + // #121: overlay SVG in pixel immagine (viewBox = dimensioni naturali); tratto a spessore costante a ogni zoom. + // Senza VisionOverlay.svg resta vuoto, senza errori. drawOverlay() { - const img = this.$refs.img, cv = this.$refs.overlay; - if (!img || !cv || !img.naturalWidth) return; - cv.width = img.naturalWidth; - cv.height = img.naturalHeight; - const g = cv.getContext('2d'); - g.clearRect(0, 0, cv.width, cv.height); - g.lineWidth = Math.max(2, cv.width / 400); - g.lineJoin = 'round'; + const img = this.$refs.img, sv = this.$refs.overlay; + if (!img || !sv || !img.naturalWidth) return; + sv.setAttribute('viewBox', '0 0 ' + img.naturalWidth + ' ' + img.naturalHeight); const shapes = (this.result && this.result.result && this.result.result.overlay) || []; - for (const item of shapes) { - const rows = item.rows || [], cols = item.cols || []; - if (!rows.length || rows.length !== cols.length) continue; - g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444'; - g.beginPath(); - g.moveTo(cols[0], rows[0]); - for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]); - g.stroke(); - } + const VO = (typeof window !== 'undefined' && window.VisionOverlay) || null; + sv.innerHTML = (VO && typeof VO.svg === 'function') ? VO.svg(shapes, { zoom: this.zoom }) : ''; }, }; } diff --git a/src/frontend/flask_app/templates/maker/task_editor.html b/src/frontend/flask_app/templates/maker/task_editor.html index bfc985e..fe33193 100644 --- a/src/frontend/flask_app/templates/maker/task_editor.html +++ b/src/frontend/flask_app/templates/maker/task_editor.html @@ -737,6 +737,8 @@ + {# #119: salva il frame grezzo mostrato (PNG, senza disegni) #} + @@ -819,6 +821,8 @@ + {# #119: salva il frame grezzo della Prova (PNG, senza overlay) #} + @@ -830,7 +834,8 @@
- + {# #121: overlay SVG (tratto a spessore costante a ogni zoom) al posto del canvas; il viewBox lo imposta drawOverlay #} +
@@ -1516,6 +1521,8 @@ +{# #121 #} +{# #119 #} {# #97 #} diff --git a/src/frontend/flask_app/templates/measure/task_complete.html b/src/frontend/flask_app/templates/measure/task_complete.html index daf69b2..e3610d8 100644 --- a/src/frontend/flask_app/templates/measure/task_complete.html +++ b/src/frontend/flask_app/templates/measure/task_complete.html @@ -195,10 +195,12 @@ {{ "%.3f"|format(m.subtask.nominal) }} {{ m.subtask.unit }} - {{ "%.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 con preserveAspectRatio none, non piu' un canvas + src = _src() + i = src.index('x-ref="overlay"') + tag = src[src.rfind("<", 0, i):src.index(">", i) + 1] + assert tag.startswith("