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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
Reference in New Issue
Block a user