fix(csv): export dal browser legge nominale/tolleranze/descrizione da m.subtask (#31)

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:52:21 +02:00
parent 4384c4860b
commit e8d21b78a9
3 changed files with 168 additions and 6 deletions
+29 -6
View File
@@ -71,22 +71,38 @@ function csvExport() {
let csv = headers.join(this.delimiter) + '\n'; let csv = headers.join(this.delimiter) + '\n';
for (const m of measurements) { 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 = [ const row = [
m.id || '', m.id || '',
m.subtask_id || '', m.subtask_id || '',
this.escapeCsvValue(m.subtask_name || ''), this.escapeCsvValue(subtask.description || ''),
this.formatNumber(m.value), this.formatNumber(m.value),
this.escapeCsvValue(m.unit || 'mm'), this.escapeCsvValue(subtask.unit || 'mm'),
this.formatNumber(m.nominal), this.formatNumber(nominal),
this.formatNumber(m.tolerance_plus), this.formatNumber(tolerancePlus),
this.formatNumber(m.tolerance_minus), this.formatNumber(toleranceMinus),
this.formatNumber(m.deviation), this.formatNumber(m.deviation),
m.pass_fail || '', m.pass_fail || '',
this.escapeCsvValue(m.lot_number || ''), this.escapeCsvValue(m.lot_number || ''),
this.escapeCsvValue(m.serial_number || ''), this.escapeCsvValue(m.serial_number || ''),
m.input_method || '', m.input_method || '',
this.formatDateTime(m.measured_at), 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'; csv += row.join(this.delimiter) + '\n';
} }
@@ -163,6 +179,13 @@ function csvExport() {
URL.revokeObjectURL(url); 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 * Format number with Italian decimal separator
*/ */
@@ -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');
@@ -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}"
)