From c904800e9491059e1b671312666fa8096adabc8e Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 28 Oct 2026 22:02:26 +0100 Subject: [PATCH 1/4] Etichetta NaN sull'immagine per le quote non misurate Refs #118 Co-Authored-By: Claude Sonnet 5.5 --- .../flask_app/static/js/vision_overlay.js | 10 +++++----- .../flask_app/tests/js/test_vision_overlay.js | 15 ++++++++------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/frontend/flask_app/static/js/vision_overlay.js b/src/frontend/flask_app/static/js/vision_overlay.js index 6b2b648..fbbd873 100644 --- a/src/frontend/flask_app/static/js/vision_overlay.js +++ b/src/frontend/flask_app/static/js/vision_overlay.js @@ -249,15 +249,15 @@ var VisionOverlay = (function () { var pt = null; for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]); if (!pt) return; - // #118: valore null/undefined/vuoto = quota non misurata (Number(null) e' 0): nessuna etichetta - if (r.value === null || r.value === undefined || r.value === '' || typeof r.value === 'boolean') return; - var v = Number(r.value); - if (!isFinite(v)) return; + // #118: valore null/undefined/vuoto/non finito = quota non misurata (Number(null) e' 0): etichetta NaN, status della riga (fail) + var v = (r.value === null || r.value === undefined || r.value === '' || typeof r.value === 'boolean') + ? NaN : Number(r.value); + var txt = isFinite(v) ? v.toFixed(3) : 'NaN'; out.push({ key: r.key, x: pt.x, y: pt.y, - text: r.name + ' ' + v.toFixed(3) + ' ' + (r.unit || ''), + text: r.name + ' ' + txt + ' ' + (r.unit || ''), status: r.status, }); }); diff --git a/src/frontend/flask_app/tests/js/test_vision_overlay.js b/src/frontend/flask_app/tests/js/test_vision_overlay.js index 63f7f54..ccb944e 100644 --- a/src/frontend/flask_app/tests/js/test_vision_overlay.js +++ b/src/frontend/flask_app/tests/js/test_vision_overlay.js @@ -224,17 +224,18 @@ const rows = fixture.measurements.map((m) => { check('labels: testo name + valore a 3 decimali + unit', f[0].text === 'T 1.500 mm', f[0].text); } -// --- #118: labels, valore null/undefined/non finito -> nessuna etichetta --- +// --- #118: labels, valore null/undefined/non finito -> etichetta NaN con status della riga --- { const ov = [{ type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 }]; const mk = (value) => ({ key: 1, name: 'T', value, unit: 'mm', status: 'fail', vision_output: 't.value' }); - check('labels: value null -> nessuna etichetta (Number(null) e\' 0)', VisionOverlay.labels([mk(null)], ov).length === 0); - check('labels: value undefined -> nessuna', VisionOverlay.labels([mk(undefined)], ov).length === 0); - check('labels: value NaN/Infinity -> nessuna', - VisionOverlay.labels([mk(NaN), mk(Infinity), mk(-Infinity)], ov).length === 0); - check('labels: value 0 resta un valore valido', VisionOverlay.labels([mk(0)], ov).length === 1); + const nan = (v) => { const l = VisionOverlay.labels([mk(v)], ov); return l.length === 1 && l[0].text === 'T NaN mm' && l[0].status === 'fail'; }; + check('labels: value null -> etichetta NaN, status fail (Number(null) e 0)', nan(null)); + check('labels: value undefined / stringa vuota -> etichetta NaN', nan(undefined) && nan('')); + check('labels: value NaN/Infinity -> etichetta NaN', nan(NaN) && nan(Infinity) && nan(-Infinity)); + check('labels: value 0 resta un valore valido', VisionOverlay.labels([mk(0)], ov)[0].text === 'T 0.000 mm'); const misto = VisionOverlay.labels([{ ...mk(null), key: 1 }, { ...mk(2.5), key: 2 }], ov); - check('labels: null saltata, le altre righe restano', misto.length === 1 && misto[0].key === 2); + check('labels: null e valida entrambe presenti', misto.length === 2 && misto[0].text === 'T NaN mm' && misto[1].key === 2); + check('labels: riga NaN senza ancoraggio -> nessuna etichetta', VisionOverlay.labels([mk(null)], []).length === 0); } // --- #121: svg(overlay, opts) --- From 8ef61a1285862b12c61b33f4b89501bf770cec30 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 28 Oct 2026 22:04:20 +0100 Subject: [PATCH 2/4] Registra spento sul frame gia' registrato, nuovo grab dopo il reset Refs #123 Co-Authored-By: Claude Sonnet 5.5 --- .../flask_app/static/js/vision_execute.js | 10 +++++++--- .../flask_app/tests/js/test_vision_rows.js | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/frontend/flask_app/static/js/vision_execute.js b/src/frontend/flask_app/static/js/vision_execute.js index d5267f9..883d57f 100644 --- a/src/frontend/flask_app/static/js/vision_execute.js +++ b/src/frontend/flask_app/static/js/vision_execute.js @@ -32,6 +32,7 @@ function visionExecute() { preview: false, // result on screen is an analysis only, not saved saved: false, // «Registra» done: the saved result replaces the preview lastBlob: null, // last frame shown, the one «Registra» saves + recordedBlob: null, // #123: frame saved by the last successful «Registra»: not recordable twice paused: false, // operator paused the in-line loop registering: false, // «Registra» in progress: the loop stands still leaving: false, // page is being left: no more requests @@ -43,8 +44,10 @@ function visionExecute() { // #120: «Rimisura»/nuovo ciclo (task-result-reset): il pezzo va registrato di nuovo, anche senza camera window.addEventListener('task-result-reset', () => { this.saved = false; - this.preview = !!this.result; - this.kick(); + // #123: il frame gia' registrato non e' piu' un'anteprima ne' si registra due volte + this.preview = !!this.result && this.lastBlob !== this.recordedBlob; + // #123: camera impulsiva = frame nuovo subito (come all'apertura); in linea riparte il ciclo + if (this.hasCamera && !this.inLinea) this.grab(); else this.kick(); }); if (!this.hasCamera) return; document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); }); @@ -58,7 +61,7 @@ function visionExecute() { get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; }, get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; }, // «Registra» needs a frame on screen that is not saved yet - get canRecord() { return !!this.lastBlob && !this.saved && !this.registering; }, + get canRecord() { return !!this.lastBlob && this.lastBlob !== this.recordedBlob && !this.saved && !this.registering; }, // #123: non sul frame gia' registrato shouldRun() { return this.inLinea && !this.paused && !this.saved && !this.registering @@ -145,6 +148,7 @@ function visionExecute() { if (!this.error && this.result && this.result.vision_result_id) { this.preview = false; this.saved = true; + this.recordedBlob = blob; // #123: questo frame e' registrato, Registra resta spento finche' non ne arriva uno nuovo this._emitRecorded(); // #120: la pagina conta le quote registrate } } finally { diff --git a/src/frontend/flask_app/tests/js/test_vision_rows.js b/src/frontend/flask_app/tests/js/test_vision_rows.js index af6eec7..c9bdf2c 100644 --- a/src/frontend/flask_app/tests/js/test_vision_rows.js +++ b/src/frontend/flask_app/tests/js/test_vision_rows.js @@ -175,6 +175,26 @@ c5.acquire = async function () { this.result = { vision_result_id: 7, measuremen h(); check('#120 task-result-reset: saved = false', c5.saved === false); + // #123: dopo Registra + task-result-reset Registra e' spento sul frame registrato, anteprima spenta, impulsiva = grab + win.__visionCfg = { cameraAvailable: true, modo: 'impulsiva', subtasks: [] }; + const c9 = ctx.visionExecute(); + let grabs = 0; + c9.grab = async function () { grabs++; }; + c9.lastBlob = { name: 'f1' }; + c9.acquire = async function () { this.result = { vision_result_id: 11, measurements: okMs }; }; + check('#123 prima di Registra: canRecord', c9.canRecord === true); + await c9.record(); + check('#123 dopo Registra: canRecord falso (saved)', c9.canRecord === false && c9.recordedBlob === c9.lastBlob); + c9.$nextTick = () => {}; // #123: nel test nessun grab di apertura + c9.init(); // registra l'ascolto di task-result-reset + grabs = 0; + (listeners['task-result-reset'] || []).slice(-1)[0](); + check('#123 reset sul frame registrato: canRecord falso', c9.canRecord === false && c9.saved === false); + check('#123 reset sul frame registrato: niente badge anteprima', c9.preview === false); + check('#123 reset in impulsiva: un solo grab', grabs === 1, String(grabs)); + c9.lastBlob = { name: 'f2' }; + check('#123 frame nuovo: canRecord vero', c9.canRecord === true); + // #119 #122: saveName = titolo del task (nome proposto da ), 'immagine' se manca win.__visionCfg = { taskName: 'Roto3', subtasks: [] }; check('#122 saveName = nome del task', ctx.visionExecute().saveName === 'Roto3'); From 2f0a5aaf1ef957deacae2c30d9d60068bce8090f Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 28 Oct 2026 22:06:43 +0100 Subject: [PATCH 3/4] Deviazione NaN nelle esportazioni CSV per le quote non misurate Refs #124 Co-Authored-By: Claude Sonnet 5.5 --- src/backend/api/routers/measurements.py | 5 +++- .../services/production_export_service.py | 2 +- src/backend/tests/test_nan_measurements.py | 2 +- src/backend/tests/test_production_runs.py | 28 +++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/backend/api/routers/measurements.py b/src/backend/api/routers/measurements.py index bb466d9..cc264e1 100644 --- a/src/backend/api/routers/measurements.py +++ b/src/backend/api/routers/measurements.py @@ -359,8 +359,11 @@ async def export_measurements_csv( # Format decimals with configured separator # #118: quota non misurata dalla visione -> NaN value_str = "NaN" if m.value is None else str(m.value).replace(".", decimal_separator) + # #124: quota NaN (value None) -> deviazione NaN, non vuota deviation_str = ( - str(m.deviation).replace(".", decimal_separator) + "NaN" + if m.value is None + else str(m.deviation).replace(".", decimal_separator) if m.deviation is not None else "" ) diff --git a/src/backend/services/production_export_service.py b/src/backend/services/production_export_service.py index c547f87..5298984 100644 --- a/src/backend/services/production_export_service.py +++ b/src/backend/services/production_export_service.py @@ -160,7 +160,7 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str: neutralise_csv_cell(subtask.description) if subtask else "", number(subtask.nominal) if subtask else "", number(measurement.value) if measurement.value is not None else "NaN", # #118 - number(measurement.deviation), + "NaN" if measurement.value is None else number(measurement.deviation), # #124: quota NaN -> deviazione NaN measurement.pass_fail, number(subtask.ltl) if subtask else "", number(subtask.lwl) if subtask else "", diff --git a/src/backend/tests/test_nan_measurements.py b/src/backend/tests/test_nan_measurements.py index a0d43be..583f3f7 100644 --- a/src/backend/tests/test_nan_measurements.py +++ b/src/backend/tests/test_nan_measurements.py @@ -111,4 +111,4 @@ async def test_csv_export_writes_nan( row = by_id[str(nan.id)] assert row[header.index("value")] == "NaN" assert row[header.index("pass_fail")] == "fail" - assert row[header.index("deviation")] == "" + assert row[header.index("deviation")] == "NaN" # #124 diff --git a/src/backend/tests/test_production_runs.py b/src/backend/tests/test_production_runs.py index c76c326..c4b196a 100644 --- a/src/backend/tests/test_production_runs.py +++ b/src/backend/tests/test_production_runs.py @@ -836,3 +836,31 @@ async def test_close_without_measurements_writes_no_file( )).json() assert closed["statistics_path"] is None assert not (tmp_path / "statistics").exists() + + +async def test_statistics_csv_writes_nan_value_and_deviation( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + """#124: una quota NaN (value NULL) esce NaN sia nel valore sia nella deviazione.""" + from src.backend.services import measurement_service, production_export_service + + station = await _station(db_session, admin_user.id, code="ST-NAN") + recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-NAN") + opened = (await _open(client, measurement_tec_user, station, recipe)).json() + subtask = await _first_subtask(db_session, recipe.id) + await measurement_service.save_measurement( + db_session, subtask_id=subtask.id, version_id=opened["version_id"], + measured_by=measurement_tec_user.id, value=None, input_method="camera", + production_run_id=opened["id"], enforce_tolerance_gate=False, + ) + await db_session.commit() + run = (await db_session.execute( + select(ProductionRun).where(ProductionRun.id == opened["id"]) + )).scalar_one() + + text = await production_export_service.build_statistics_csv(db_session, run) + lines = [line for line in text.lstrip("").splitlines() if line.strip()] + header = lines[0].split(",") + row = lines[1].split(",") + assert row[header.index("value")] == "NaN" + assert row[header.index("deviation")] == "NaN" From 17ea010f7ef61d0cd94e04c63ac79fcff234259e Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 28 Oct 2026 22:07:18 +0100 Subject: [PATCH 4/4] Test: quota NaN chiede il capoturno, migrazione 022 conserva le righe Refs #125 Co-Authored-By: Claude Sonnet 5.5 --- src/backend/tests/test_migrations.py | 52 ++++++++++++++++++++++++ src/backend/tests/test_vision_execute.py | 14 ++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/backend/tests/test_migrations.py b/src/backend/tests/test_migrations.py index 57b8b30..e15acf7 100644 --- a/src/backend/tests/test_migrations.py +++ b/src/backend/tests/test_migrations.py @@ -379,3 +379,55 @@ class TestMeasurementValueNullableMigration: assert value_nullable() is False command.upgrade(cfg, "head") # e di nuovo su assert value_nullable() is True + + def test_022_keeps_rows(self, sqlite_database_url): + """#125: la ricostruzione della tabella conserva righe, somma dei value e indici.""" + import sqlite3 + + db_path, _ = sqlite_database_url + cfg = _alembic_config() + command.upgrade(cfg, "021_task_acquisition") + + def indexes(con): + return sorted(r[0] for r in con.execute( + "select name from sqlite_master where type='index' and tbl_name='measurements'" + )) + + con = sqlite3.connect(db_path) + # Colonne NOT NULL senza default riempite per tipo (stessa idea di mig_check.py) + vals = {} + for _cid, name, typ, notnull, dflt, pk in con.execute("pragma table_info(measurements)").fetchall(): + if pk or not notnull or dflt is not None: + continue + t = typ.upper() + vals[name] = ( + 1 if "INT" in t + else 1.5 if ("DEC" in t or "FLOAT" in t or "NUM" in t) + else "2026-01-01 00:00:00" if ("DATE" in t or "TIME" in t) + else "x" + ) + vals["pass_fail"] = "pass" + vals["value"] = 12.5 + sql = f"insert into measurements ({','.join(vals)}) values ({','.join('?' * len(vals))})" + for _ in range(3): + con.execute(sql, list(vals.values())) + con.commit() + before_idx = indexes(con) + before = con.execute("select count(*), sum(value) from measurements").fetchone() + con.close() + assert before == (3, 37.5) + + command.upgrade(cfg, "head") + + con = sqlite3.connect(db_path) + try: + assert con.execute("select count(*), sum(value) from measurements").fetchone() == before + assert indexes(con) == before_idx + assert con.execute("pragma integrity_check").fetchone()[0] == "ok" + vals["value"] = None + vals["pass_fail"] = "fail" + con.execute(sql, list(vals.values())) # una riga NaN (value NULL) ora e' accettata + con.commit() + assert con.execute("select count(*) from measurements where value is null").fetchone()[0] == 1 + finally: + con.close() diff --git a/src/backend/tests/test_vision_execute.py b/src/backend/tests/test_vision_execute.py index 49b3d6b..6d3be04 100644 --- a/src/backend/tests/test_vision_execute.py +++ b/src/backend/tests/test_vision_execute.py @@ -216,10 +216,11 @@ async def test_a_non_finite_output_is_saved_as_nan( @pytest.mark.asyncio -async def test_a_nan_quote_still_blocks_the_next_acquisition( +async def test_a_nan_quote_needs_supervisor_authorisation( client, db_session, measurement_tec_user, worker_says, ): - """#118: la quota NaN e' un fail come gli altri, il blocco di tolleranza resta.""" + """#125: la quota NaN e' un fail come gli altri e chiede il capoturno (pending_authorisation); + la prossima acquisizione non e' bloccata.""" recipe = await create_test_recipe(db_session, measurement_tec_user.id) version, task, first, second = await two_vision_quotes(db_session, recipe.id) worker_says({"t1.radius": GOOD}) @@ -230,6 +231,15 @@ async def test_a_nan_quote_still_blocks_the_next_acquisition( ) assert blocking is not None and blocking.subtask_id == first.id and blocking.value is None + # #125: una seconda acquisizione sullo stesso task risponde 200, non e' bloccata + again = await client.post( + "/api/vision/execute", + headers=auth_headers(measurement_tec_user), + files={"image": ("p.png", an_image(), "image/png")}, + data={"task_id": str(task.id)}, + ) + assert again.status_code == 200, again.text + @pytest.mark.asyncio async def test_check_lists_a_missing_quote_as_nan(