merge #101 101b: anteprima DXF nel wizard (Refs #101)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
This commit is contained in:
2026-10-25 15:59:25 +01:00
8 changed files with 276 additions and 2 deletions
@@ -701,6 +701,25 @@ def api_dxf_compare_recipe(task_id: int):
return jsonify(resp), 200 return jsonify(resp), 200
# #101: anteprima del DXF appena scelto nel wizard (contorni letti dal vision worker)
@maker_bp.route("/api/vision/dxf-preview", methods=["POST"])
@login_required
@role_required("Maker")
def api_dxf_preview():
"""Proxy: anteprima del DXF (contorni, bbox, L x H) prima di creare la ricetta (#101)."""
dxf = request.files.get("dxf")
if dxf is None or not dxf.filename:
return jsonify({"error": True, "detail": _("Serve il file DXF")}), 400
resp = api_client.post(
"/api/vision/dxf-preview",
files={"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream")},
)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"]) @maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required @login_required
def api_get_file(file_path: str): def api_get_file(file_path: str):
@@ -21,6 +21,10 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
imageName: '', imageName: '',
imageSrc: '', imageSrc: '',
dxfFile: null, dxfFile: null,
dxfPreview: null, // #101: risposta di /dxf-preview {contorni, bbox, larghezza_mm, altezza_mm, entita, punti}
dxfPreviewBusy: false,
dxfPreviewError: '',
_dxfPreviewSeq: 0, // #101: una risposta arrivata dopo un cambio di file si scarta
mmPx: '', // #98: mm/px a mano (immagine da file), stringa con virgola o punto mmPx: '', // #98: mm/px a mano (immagine da file), stringa con virgola o punto
bandMm: 0.1, bandMm: 0.1,
minPointsPct: 95, minPointsPct: 95,
@@ -87,7 +91,60 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
const f = ev.target.files && ev.target.files[0]; const f = ev.target.files && ev.target.files[0];
if (f) this._setImage(f); if (f) this._setImage(f);
}, },
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; }, onDxf(ev) {
this.dxfFile = (ev.target.files && ev.target.files[0]) || null;
this.result = null;
this.loadDxfPreview(); // #101: anteprima appena si sceglie il file
},
// #101: legge il DXF sul vision worker (via Flask e backend) e ne mostra i contorni
async loadDxfPreview() {
const seq = ++this._dxfPreviewSeq;
this.dxfPreview = null;
this.dxfPreviewError = '';
this.dxfPreviewBusy = false;
if (!this.dxfFile) return; // file tolto: tutto azzerato
this.dxfPreviewBusy = true;
try {
const fd = new FormData();
fd.append('dxf', this.dxfFile, this.dxfFile.name);
const resp = await fetch('/maker/api/vision/dxf-preview', {
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (seq !== this._dxfPreviewSeq) return; // file cambiato nel frattempo
if (!resp.ok || body.error) this.dxfPreviewError = this._errorText(body);
else this.dxfPreview = body;
} catch (e) {
if (seq !== this._dxfPreviewSeq) return;
this.dxfPreviewError = this.msgs.requestFailed || String(e);
}
if (seq === this._dxfPreviewSeq) this.dxfPreviewBusy = false;
},
// #101: una stringa `points` per ogni contorno, per <polyline>
dxfPreviewPaths() {
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
return contorni.filter(c => c && c.length > 1).map(c => c.map(p => p[0] + ',' + p[1]).join(' '));
},
// #101: viewBox sul bbox con margine 5% (minimo 0.5 mm per lato)
dxfPreviewViewBox() {
const b = this.dxfPreview && this.dxfPreview.bbox;
if (!b) return '0 0 1 1';
const w = b.xmax - b.xmin, h = b.ymax - b.ymin;
const m = Math.max(0.5, 0.05 * Math.max(w, h));
return [b.xmin - m, b.ymin - m, w + 2 * m, h + 2 * m].map(v => +v.toFixed(3)).join(' ');
},
// #101: «L × H mm · N entità», decimale con virgola (2 decimali) secondo la lingua pagina
get dxfPreviewSummary() {
const d = this.dxfPreview;
if (!d) return '';
const f = v => Number(v).toLocaleString(document.documentElement.lang || 'it',
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
return `${f(d.larghezza_mm)} × ${f(d.altezza_mm)} mm · ${d.entita} ${this.msgs.dxfEntities || 'entities'}`;
},
// Frame dalla camera della stazione scelta // Frame dalla camera della stazione scelta
async grab() { async grab() {
@@ -662,7 +662,7 @@
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== --> <!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
<template x-if="task.task_type === 'dxf_compare'"> <template x-if="task.task_type === 'dxf_compare'">
<div class="mt-4 pt-3 border-t border-[var(--border-color)]" <div class="mt-4 pt-3 border-t border-[var(--border-color)]"
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata')}|tojson_attr }})"> x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata'), 'dxfEntities': _('entità')}|tojson_attr }})">
<button type="button" class="btn btn-secondary text-xs" @click="toggle()"> <button type="button" class="btn btn-secondary text-xs" @click="toggle()">
{{ _('Crea ricetta da DXF') }} {{ _('Crea ricetta da DXF') }}
</button> </button>
@@ -714,6 +714,21 @@
<div> <div>
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label> <label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)"> <input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
<!-- #101: anteprima del disegno appena scelto il .dxf -->
<p x-show="dxfPreviewBusy" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Lettura DXF…') }}</p>
<p x-show="dxfPreviewError" class="mt-1 text-xs text-red-500" x-text="dxfPreviewError"></p>
<div x-show="dxfPreview" class="mt-2">
<svg :viewBox="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
class="block w-full rounded border border-[var(--border-color)]"
style="max-height: 240px; background: var(--bg-primary);">
<template x-for="(pts, i) in dxfPreviewPaths()" :key="i">
<polyline :points="pts" fill="none" stroke="var(--color-primary, #2563eb)"
stroke-width="1.5" vector-effect="non-scaling-stroke"
stroke-linejoin="round"></polyline>
</template>
</svg>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
</div>
</div> </div>
</div> </div>
<!-- Pochi parametri --> <!-- Pochi parametri -->
@@ -0,0 +1,84 @@
/**
* #101: anteprima DXF del wizard «Crea ricetta da DXF», con plain node.
* Copre dxfPreviewPaths, dxfPreviewViewBox, dxfPreviewSummary e lo scarto
* di una risposta arrivata dopo un cambio di file.
*
* Run from src/frontend/flask_app: node tests/js/test_dxf_preview.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
global.document = { documentElement: { lang: 'it' } };
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'js', 'dxf_recipe_wizard.js'), 'utf8',
);
eval(src + ';global.dxfRecipeWizard = dxfRecipeWizard;');
let failures = 0;
function check(label, cond, extra) {
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
const PREVIEW = {
contorni: [[[0, -5], [12.3, -5], [12.3, 0], [0, 0], [0, -5]], [[1, -1]]],
bbox: { xmin: 0, ymin: -5.1, xmax: 12.3, ymax: 0 },
larghezza_mm: 12.3, altezza_mm: 5.1, entita: 2, punti: 6,
};
(async () => {
const w = dxfRecipeWizard(1, () => 'tok', { dxfEntities: 'entità' });
check('senza anteprima: nessun path', w.dxfPreviewPaths().length === 0);
check('senza anteprima: summary vuoto', w.dxfPreviewSummary === '');
w.dxfPreview = PREVIEW;
const paths = w.dxfPreviewPaths();
check('un contorno con >1 punti diventa una polyline (quello da 1 punto no)', paths.length === 1);
check('formato points', paths[0] === '0,-5 12.3,-5 12.3,0 0,0 0,-5', paths[0]);
const vb = w.dxfPreviewViewBox().split(' ').map(Number);
const m = Math.max(0.5, 0.05 * 12.3);
check('viewBox con margine 5%', Math.abs(vb[0] + m) < 1e-3 && Math.abs(vb[2] - (12.3 + 2 * m)) < 1e-3, vb.join(' '));
// Disegno minuscolo: margine minimo 0.5 mm
w.dxfPreview = { ...PREVIEW, bbox: { xmin: 0, ymin: 0, xmax: 1, ymax: 1 } };
check('margine minimo 0.5 mm', Math.abs(w.dxfPreviewViewBox().split(' ').map(Number)[0] + 0.5) < 1e-3);
w.dxfPreview = PREVIEW;
const s = w.dxfPreviewSummary;
check('summary L × H mm · N entità', s.includes('×') && s.includes('mm') && s.endsWith('2 entità')
&& /12[,.]30/.test(s) && /5[,.]10/.test(s), s);
// Risposta tardiva scartata: due scelte di file, la prima risponde per ultima
const resolvers = [];
global.fetch = () => new Promise(res => resolvers.push(res));
global.FormData = class { append() {} };
const w2 = dxfRecipeWizard(1, () => 'tok', {});
const reply = (body, ok = true) => ({ ok, json: async () => body });
w2.dxfFile = { name: 'a.dxf' };
const p1 = w2.loadDxfPreview();
w2.dxfFile = { name: 'b.dxf' };
const p2 = w2.loadDxfPreview();
resolvers[1](reply(PREVIEW));
await p2;
resolvers[0](reply({ ...PREVIEW, entita: 99 }));
await p1;
check('la risposta vecchia non sovrascrive la nuova', w2.dxfPreview && w2.dxfPreview.entita === 2);
check('busy spento', w2.dxfPreviewBusy === false);
// File tolto: tutto azzerato
w2.dxfFile = null;
await w2.loadDxfPreview();
check('file tolto azzera anteprima ed errore', w2.dxfPreview === null && w2.dxfPreviewError === '');
// Errore 422 con detail
global.fetch = async () => reply({ detail: 'DXF illeggibile: x' }, false);
w2.dxfFile = { name: 'c.dxf' };
await w2.loadDxfPreview();
check('errore col detail', w2.dxfPreviewError === 'DXF illeggibile: x' && w2.dxfPreview === null, w2.dxfPreviewError);
if (failures) { console.log(failures + ' fallimenti'); process.exit(1); }
console.log('tutto ok');
})();
@@ -0,0 +1,35 @@
"""Runs the plain-node DXF preview behaviour test (tests/js/test_dxf_preview.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#101.
"""
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_dxf_preview.js"
def test_dxf_preview_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS di dxf_recipe_wizard.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_dxf_preview.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -210,6 +210,49 @@ class TestDxfCompareProxy:
mock_api_client.post.assert_not_called() mock_api_client.post.assert_not_called()
class TestDxfPreviewProxy:
"""#101: anteprima del DXF scelto nel wizard (proxy verso il backend)."""
URL = "/maker/api/vision/dxf-preview"
OK = {
"contorni": [[[0.0, -5.0], [10.0, -5.0], [10.0, 0.0], [0.0, 0.0]]],
"bbox": {"xmin": 0.0, "ymin": -5.0, "xmax": 10.0, "ymax": 0.0},
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 4,
}
def _post(self, client, with_file=True):
data = {"dxf": (io.BytesIO(b"0\nEOF"), "d.dxf")} if with_file else {}
return client.post(self.URL, data=data, content_type="multipart/form-data")
def test_forwards_file(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = self.OK
resp = self._post(logged_in_client)
assert resp.status_code == 200
assert resp.get_json()["larghezza_mm"] == 10.0
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/dxf-preview"
assert set(kwargs["files"]) == {"dxf"}
assert kwargs["files"]["dxf"][0] == "d.dxf"
assert kwargs["files"]["dxf"][1] == b"0\nEOF"
def test_missing_file_is_400(self, logged_in_client, mock_api_client):
resp = self._post(logged_in_client, with_file=False)
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_backend_422_propagates(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {
"error": True, "status_code": 422, "detail": "DXF illeggibile: x"}
resp = self._post(logged_in_client)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "DXF illeggibile: x"
def test_requires_maker_role(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
assert self._post(logged_in_client).status_code == 403
mock_api_client.post.assert_not_called()
class TestPages: class TestPages:
def test_stations_page_has_calibration_cell(self, logged_in_client): def test_stations_page_has_calibration_cell(self, logged_in_client):
with patch("blueprints.admin.api_client") as admin_api: with patch("blueprints.admin.api_client") as admin_api:
@@ -236,4 +279,5 @@ class TestPages:
assert "dxfRecipeWizard(" in html assert "dxfRecipeWizard(" in html
assert "@dxf-recipe-saved" in html assert "@dxf-recipe-saved" in html
assert 'x-model="mmPx"' in html # #98: campo mm/px a mano assert 'x-model="mmPx"' in html # #98: campo mm/px a mano
assert "dxfPreviewPaths()" in html # #101: anteprima SVG del DXF
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
@@ -3489,3 +3489,13 @@ msgstr "Invalid response from the camera service"
#: templates/statistics/dashboard.html (#100) #: templates/statistics/dashboard.html (#100)
msgid "Scegli un punto per carta di controllo e istogramma" msgid "Scegli un punto per carta di controllo e istogramma"
msgstr "Choose a point for the control chart and histogram" msgstr "Choose a point for the control chart and histogram"
#: templates/maker/task_editor.html blueprints/maker.py (#101)
msgid "Serve il file DXF"
msgstr "The DXF file is required"
msgid "Lettura DXF…"
msgstr "Reading DXF…"
msgid "entità"
msgstr "entities"
@@ -3473,3 +3473,13 @@ msgstr "Indirizzo della camera non valido"
#: templates/admin/stations.html blueprints/admin.py (#88) #: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Risposta non valida dal servizio camera" msgid "Risposta non valida dal servizio camera"
msgstr "Risposta non valida dal servizio camera" msgstr "Risposta non valida dal servizio camera"
#: templates/maker/task_editor.html blueprints/maker.py (#101)
msgid "Serve il file DXF"
msgstr "Serve il file DXF"
msgid "Lettura DXF…"
msgstr "Lettura DXF…"
msgid "entità"
msgstr "entità"