From e8d21b78a9cb4f38764b233162608f110c74bdb2 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 21 Sep 2026 22:52:21 +0200 Subject: [PATCH] fix(csv): export dal browser legge nominale/tolleranze/descrizione da m.subtask (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildMeasurementCSV leggeva m.subtask_name/unit/nominal/tolerance_plus/ tolerance_minus/operator_name: nessuno di questi campi esiste su MeasurementResponse. I dati reali stanno annidati in m.subtask (description, unit, nominal, utl, ltl - misurati.py:task_complete() li allega lato server). tolleranza + = utl-nominal, tolleranza - = nominal-ltl, solo quando entrambi i lati sono presenti (nessun NaN su tolleranza a un lato solo). Unità dalla quota, "mm" solo se assente. Operatore: measured_by (id), l'unico campo reale - non esiste un nome operatore da nessuna parte in questi dati. Test node in tests/js/test_csv_export.js (stesso pattern di test_numpad.js). Co-Authored-By: Claude Sonnet 5 --- .../flask_app/static/js/csv-export.js | 35 +++++- .../flask_app/tests/js/test_csv_export.js | 104 ++++++++++++++++++ .../flask_app/tests/test_csv_export_js.py | 35 ++++++ 3 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 src/frontend/flask_app/tests/js/test_csv_export.js create mode 100644 src/frontend/flask_app/tests/test_csv_export_js.py diff --git a/src/frontend/flask_app/static/js/csv-export.js b/src/frontend/flask_app/static/js/csv-export.js index 8602d81..ed01a6c 100644 --- a/src/frontend/flask_app/static/js/csv-export.js +++ b/src/frontend/flask_app/static/js/csv-export.js @@ -71,22 +71,38 @@ function csvExport() { let csv = headers.join(this.delimiter) + '\n'; for (const m of measurements) { + // MeasurementResponse (src/backend/models/api/measurement.py) carries + // none of subtask_name/unit/nominal/tolerance_plus/tolerance_minus - + // that data lives nested under m.subtask (attached server-side in + // measure.py's task_complete(), see subtask/task_info enrichment). + // Reading the flat, nonexistent names is why every one of these + // columns came out empty (#31). + const subtask = m.subtask || {}; + const nominal = subtask.nominal; + const tolerancePlus = this.hasNumber(subtask.utl) && this.hasNumber(nominal) + ? subtask.utl - nominal : null; + const toleranceMinus = this.hasNumber(nominal) && this.hasNumber(subtask.ltl) + ? nominal - subtask.ltl : null; + const row = [ m.id || '', m.subtask_id || '', - this.escapeCsvValue(m.subtask_name || ''), + this.escapeCsvValue(subtask.description || ''), this.formatNumber(m.value), - this.escapeCsvValue(m.unit || 'mm'), - this.formatNumber(m.nominal), - this.formatNumber(m.tolerance_plus), - this.formatNumber(m.tolerance_minus), + this.escapeCsvValue(subtask.unit || 'mm'), + this.formatNumber(nominal), + this.formatNumber(tolerancePlus), + this.formatNumber(toleranceMinus), this.formatNumber(m.deviation), m.pass_fail || '', this.escapeCsvValue(m.lot_number || ''), this.escapeCsvValue(m.serial_number || ''), m.input_method || '', this.formatDateTime(m.measured_at), - this.escapeCsvValue(m.operator_name || '') + // No operator name anywhere on this object (or on + // MeasurementResponse) - measured_by is the real field, the + // user's id who took the reading. + this.escapeCsvValue(this.hasNumber(m.measured_by) ? m.measured_by : '') ]; csv += row.join(this.delimiter) + '\n'; } @@ -163,6 +179,13 @@ function csvExport() { URL.revokeObjectURL(url); }, + /** + * True for a value that is actually a usable number (not null/undefined/NaN). + */ + hasNumber(value) { + return typeof value === 'number' && !Number.isNaN(value); + }, + /** * Format number with Italian decimal separator */ diff --git a/src/frontend/flask_app/tests/js/test_csv_export.js b/src/frontend/flask_app/tests/js/test_csv_export.js new file mode 100644 index 0000000..a2b191f --- /dev/null +++ b/src/frontend/flask_app/tests/js/test_csv_export.js @@ -0,0 +1,104 @@ +/** + * csv-export.js behaviour, run with plain node (no browser, no test framework). + * + * Covers gitea:Adriano/TieMeasureFlow#31: buildMeasurementCSV used to read + * m.subtask_name/m.unit/m.nominal/m.tolerance_plus/m.tolerance_minus/ + * m.operator_name, none of which MeasurementResponse (or the server's + * task_complete() enrichment) ever puts directly on the measurement - the + * real data is nested under m.subtask (description/unit/nominal/utl/ltl), + * so every one of those columns came out empty. #22 (formula injection) is + * covered separately, by t_csv_injection tests elsewhere. + * + * Run from src/frontend/flask_app: node tests/js/test_csv_export.js + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); + +// csv-export.js reads window.CSV_I18N for header labels; no browser here. +global.window = {}; + +const src = fs.readFileSync( + path.join(__dirname, '..', '..', 'static', 'js', 'csv-export.js'), 'utf8', +); +eval(src + ';global.csvExport = csvExport;'); + +let failures = 0; +function check(label, cond, extra) { + if (cond) { + console.log('ok - ' + label); + } else { + failures++; + console.error('NOT OK - ' + label + (extra ? ' (' + extra + ')' : '')); + } +} + +// A measurement shaped exactly as task_complete() builds it: the flat +// MeasurementResponse fields plus the server-attached "subtask" and +// "task_info" nested objects (measure.py, task_complete()). +const MEASUREMENT = { + id: 42, + subtask_id: 21, + version_id: 5, + measured_by: 3, + value: 10.234, + pass_fail: 'pass', + deviation: 0.234, + lot_number: 'L1', + serial_number: 'S1', + input_method: 'usb_caliper', + measured_at: '2026-09-21T10:15:00', + subtask: { + id: 21, task_id: 11, marker_number: 1, + description: "Altezza foro", + nominal: 10.0, utl: 10.5, uwl: 10.2, lwl: 9.8, ltl: 9.5, + unit: 'mm', + }, + task_info: { id: 11, title: 'Quota 1', order_index: 0 }, +}; + +{ + const e = csvExport(); + const csv = e.buildMeasurementCSV([MEASUREMENT]); + const lines = csv.split('\n'); + const header = lines[0]; + const row = lines[1].split(';'); + + check('riga generata', lines.length >= 2, csv); + check('descrizione dal subtask', row.includes('Altezza foro'), row.join('|')); + check('nominale valorizzato (10,000)', row.some(c => c === '10,000' || c === '10'), row.join('|')); + // utl(10.5) - nominal(10.0) = 0.5 ; nominal(10.0) - ltl(9.5) = 0.5 + check('tolleranza + = utl - nominal (0,5)', row.some(c => c.replace(',', '.') === '0.5'), row.join('|')); + check('unita dalla quota (mm)', row.includes('mm'), row.join('|')); + check('operatore = measured_by (3), non vuoto', row.includes('3'), row.join('|')); + check("nessuna colonna 'undefined'", !csv.includes('undefined'), csv); +} + +// unit falls back to 'mm' only when the subtask itself has none. +{ + const e = csvExport(); + const noUnit = { ...MEASUREMENT, subtask: { ...MEASUREMENT.subtask, unit: undefined } }; + const csv = e.buildMeasurementCSV([noUnit]); + const row = csv.split('\n')[1].split(';'); + check('unita assente -> ricade su mm', row.includes('mm'), row.join('|')); +} + +// A one-sided tolerance (only utl, say) must not fabricate the missing side. +{ + const e = csvExport(); + const oneSided = { + ...MEASUREMENT, + subtask: { ...MEASUREMENT.subtask, utl: 10.5, ltl: null }, + }; + const csv = e.buildMeasurementCSV([oneSided]); + const row = csv.split('\n')[1].split(';'); + // tolerance_minus column (index 7) must be empty, not "NaN" or "0". + check('tolleranza - assente resta vuota (ltl null)', row[7] === '', row.join('|')); + check('tolleranza + presente comunque (0,5)', row[6].replace(',', '.') === '0.5', row.join('|')); +} + +if (failures > 0) { + console.error(failures + ' test falliti su csv-export.js'); + process.exit(1); +} +console.log('tutti i test csv-export.js superati'); diff --git a/src/frontend/flask_app/tests/test_csv_export_js.py b/src/frontend/flask_app/tests/test_csv_export_js.py new file mode 100644 index 0000000..c0ffa3d --- /dev/null +++ b/src/frontend/flask_app/tests/test_csv_export_js.py @@ -0,0 +1,35 @@ +"""Runs the plain-node csv-export.js behaviour test (tests/js/test_csv_export.js). + +Same thin pytest wrapper pattern as test_numpad_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#31. +""" +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_csv_export.js" + + +def test_csv_export_js_measurement_row_behaviour(): + node = shutil.which("node") + if not node: + pytest.skip("node non installato: test JS di csv-export.js 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_csv_export.js fallito (exit {result.returncode})\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + )