Compare commits
6 Commits
351153e78a
...
4791c34205
| Author | SHA1 | Date | |
|---|---|---|---|
| 4791c34205 | |||
| e582df7962 | |||
| a7b91b5508 | |||
| addf5a2598 | |||
| bdb1e8c8b9 | |||
| ebaafbe6e9 |
@@ -366,6 +366,20 @@ async def preview(
|
||||
return await vision_service.preview(db, task, await _read_image(image))
|
||||
|
||||
|
||||
@router.post("/dxf-preview")
|
||||
async def dxf_preview(
|
||||
dxf: UploadFile = File(...),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> dict:
|
||||
"""#101: anteprima del DXF scelto nel wizard «Crea ricetta da DXF» (contorni
|
||||
in mm e dimensioni), letto dal worker con lo stesso lettore del confronto.
|
||||
Non scrive nulla; stesso limite di dimensione della rotta dxf-compare."""
|
||||
dxf_bytes = await dxf.read()
|
||||
if not validate_file_size(len(dxf_bytes)):
|
||||
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
|
||||
return await vision_service.dxf_preview(dxf_bytes)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/recipe/dxf-compare")
|
||||
async def create_dxf_compare_recipe(
|
||||
task_id: int,
|
||||
|
||||
@@ -224,6 +224,25 @@ async def dxf_compare_recipe(
|
||||
return body
|
||||
|
||||
|
||||
async def dxf_preview(dxf_bytes: bytes, worker_url: str | None = None) -> dict:
|
||||
"""#101: anteprima del DXF (contorni in mm + dimensioni) dal worker. Sostituita dai test."""
|
||||
body = await call_worker_multipart(
|
||||
"/dxf/preview",
|
||||
files={"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream")},
|
||||
data={},
|
||||
worker_url=worker_url,
|
||||
)
|
||||
# forma minima del contratto: senza contorni e dimensioni non c'e' nulla da mostrare
|
||||
required = {"contorni", "bbox", "larghezza_mm", "altezza_mm", "entita", "punti"}
|
||||
if not required <= body.keys():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: missing "
|
||||
+ ", ".join(sorted(required - body.keys())),
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
# Stati di fetch_station_calibration (#83)
|
||||
CAL_OK = "ok"
|
||||
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""#101: POST /api/vision/dxf-preview, anteprima del DXF nel wizard.
|
||||
|
||||
Il worker non gira nei test: `vision_service.dxf_preview` si sostituisce.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.services import vision_service
|
||||
from src.backend.tests.conftest import auth_headers
|
||||
|
||||
BODY = {
|
||||
"contorni": [[[0.0, 0.0], [10.0, 0.0], [10.0, 5.0], [0.0, 5.0], [0.0, 0.0]]],
|
||||
"bbox": {"xmin": 0.0, "ymin": 0.0, "xmax": 10.0, "ymax": 5.0},
|
||||
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 5,
|
||||
}
|
||||
|
||||
|
||||
def _post(client, user, content=b"0\nEOF"):
|
||||
return client.post(
|
||||
"/api/vision/dxf-preview",
|
||||
headers=auth_headers(user),
|
||||
files={"dxf": ("d.dxf", content, "application/octet-stream")},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_preview_forwards_the_file_and_returns_the_worker_body(
|
||||
client, maker_user, monkeypatch,
|
||||
):
|
||||
calls = []
|
||||
|
||||
async def fake(dxf_bytes, worker_url=None):
|
||||
calls.append(dxf_bytes)
|
||||
return BODY
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||
resp = await _post(client, maker_user, b"DXF-BYTES")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == BODY
|
||||
assert calls == [b"DXF-BYTES"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_preview_forwards_the_worker_422(client, maker_user, monkeypatch):
|
||||
async def fake(dxf_bytes, worker_url=None):
|
||||
raise HTTPException(status_code=422, detail="DXF illeggibile: file vuoto")
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||
resp = await _post(client, maker_user, b"")
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"] == "DXF illeggibile: file vuoto"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_preview_requires_maker(client, measurement_tec_user, monkeypatch):
|
||||
async def fake(dxf_bytes, worker_url=None):
|
||||
return BODY
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||
resp = await _post(client, measurement_tec_user)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_preview_too_big_is_422(client, maker_user, monkeypatch):
|
||||
monkeypatch.setattr(settings, "max_upload_size_mb", 0)
|
||||
resp = await _post(client, maker_user, b"x" * 10)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_preview_service_posts_to_worker_and_checks_shape(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_multipart(path, *, files, data, worker_url=None, timeout=120.0):
|
||||
seen.update(path=path, files=files)
|
||||
return BODY
|
||||
|
||||
monkeypatch.setattr(vision_service, "call_worker_multipart", fake_multipart)
|
||||
assert await vision_service.dxf_preview(b"abc") == BODY
|
||||
assert seen["path"] == "/dxf/preview" and seen["files"]["dxf"][1] == b"abc"
|
||||
|
||||
async def malformed(path, *, files, data, worker_url=None, timeout=120.0):
|
||||
return {"contorni": []}
|
||||
|
||||
monkeypatch.setattr(vision_service, "call_worker_multipart", malformed)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await vision_service.dxf_preview(b"abc")
|
||||
assert exc.value.status_code == 502
|
||||
@@ -701,6 +701,25 @@ def api_dxf_compare_recipe(task_id: int):
|
||||
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"])
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
|
||||
@@ -21,6 +21,10 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
imageName: '',
|
||||
imageSrc: '',
|
||||
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
|
||||
bandMm: 0.1,
|
||||
minPointsPct: 95,
|
||||
@@ -87,7 +91,67 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
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: tutti i contorni in un solo attributo `d` ("M x y L x y ..."): x-for non funziona dentro <svg>
|
||||
dxfPreviewD() {
|
||||
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
|
||||
return contorni.filter(c => c && c.length > 1)
|
||||
.map(c => c.map((p, i) => (i ? 'L ' : 'M ') + p[0] + ' ' + p[1]).join(' ')).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
|
||||
async grab() {
|
||||
|
||||
@@ -662,7 +662,7 @@
|
||||
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
|
||||
<template x-if="task.task_type === 'dxf_compare'">
|
||||
<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()">
|
||||
{{ _('Crea ricetta da DXF') }}
|
||||
</button>
|
||||
@@ -714,6 +714,20 @@
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
|
||||
<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 x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
|
||||
class="block w-full rounded border border-[var(--border-color)]"
|
||||
style="max-height: 240px; background: var(--bg-primary);">
|
||||
<!-- #101: un solo path (x-for/template non funziona nel namespace SVG); view-box.camel perche' il parser HTML abbassa viewBox -->
|
||||
<path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)"
|
||||
stroke-width="1.5" vector-effect="non-scaling-stroke"
|
||||
stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pochi parametri -->
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* #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]);
|
||||
|
||||
// #101: path unico per <path :d>
|
||||
check('dxfPreviewD', w.dxfPreviewD() === 'M 0 -5 L 12.3 -5 L 12.3 0 L 0 0 L 0 -5', w.dxfPreviewD());
|
||||
w.dxfPreview = null;
|
||||
check('dxfPreviewD vuoto senza anteprima', w.dxfPreviewD() === '');
|
||||
w.dxfPreview = PREVIEW;
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
def test_stations_page_has_calibration_cell(self, logged_in_client):
|
||||
with patch("blueprints.admin.api_client") as admin_api:
|
||||
@@ -236,4 +279,5 @@ class TestPages:
|
||||
assert "dxfRecipeWizard(" in html
|
||||
assert "@dxf-recipe-saved" in html
|
||||
assert 'x-model="mmPx"' in html # #98: campo mm/px a mano
|
||||
assert "dxfPreviewD()" in html and "view-box.camel" in html # #101: anteprima SVG del DXF
|
||||
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)
|
||||
msgid "Scegli un punto per carta di controllo e istogramma"
|
||||
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)
|
||||
msgid "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à"
|
||||
|
||||
@@ -11,6 +11,7 @@ import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile # #101
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
@@ -288,3 +289,89 @@ async def recipes_dxf_compare(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"recipe build failed: {type(exc).__name__}: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
# #101: tetto ai punti dell'anteprima DXF (un profilo fitto non deve gonfiare
|
||||
# la risposta ne' rallentare il disegno SVG nel browser).
|
||||
_PREVIEW_MAX_POINTS = 20000
|
||||
|
||||
|
||||
def _decimate(points: list[list[float]], keep: int) -> list[list[float]]:
|
||||
"""Decimazione uniforme a `keep` punti (>= 2), primo e ultimo sempre inclusi."""
|
||||
n = len(points)
|
||||
if n <= keep or keep < 2:
|
||||
return points
|
||||
idx = sorted({round(i * (n - 1) / (keep - 1)) for i in range(keep)})
|
||||
return [points[i] for i in idx]
|
||||
|
||||
|
||||
def _dxf_preview(dxf_bytes: bytes) -> dict:
|
||||
"""#101: legge il DXF con lo stesso lettore di CompareDxf (read_xld_dxf) e ne
|
||||
ritorna i contorni in mm, asse y verso il basso (come immagine e SVG).
|
||||
|
||||
Funzione sincrona e pura: la rotta la esegue in un thread. Solleva
|
||||
CostruttoreError per DXF vuoto, illeggibile o senza geometria.
|
||||
"""
|
||||
if not dxf_bytes:
|
||||
raise CostruttoreError("DXF illeggibile: file vuoto")
|
||||
from visionsuite.io.dxf import read_xld_dxf
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "disegno.dxf"
|
||||
path.write_bytes(dxf_bytes)
|
||||
try:
|
||||
# flip_y=False + scale=1: coordinate native del DXF, in mm; il
|
||||
# ribaltamento dell'asse y lo facciamo noi sotto (y = -y_dxf)
|
||||
conts = list(read_xld_dxf(str(path), sampling_distance=0.05,
|
||||
scale=1.0, flip_y=False))
|
||||
except Exception as exc: # noqa: BLE001 - qualunque errore di lettura = DXF illeggibile
|
||||
raise CostruttoreError(f"DXF illeggibile: {type(exc).__name__}: {exc}") from exc
|
||||
|
||||
contorni: list[list[list[float]]] = []
|
||||
for cont in conts:
|
||||
pts = np.asarray(cont.points, dtype=float) # (riga, colonna) = (y_dxf, x_dxf)
|
||||
if pts.ndim != 2 or pts.shape[0] < 2:
|
||||
continue
|
||||
line = [[float(c), float(-r)] for r, c in pts]
|
||||
if cont.closed and line[0] != line[-1]:
|
||||
line.append(list(line[0])) # chiude il giro anche nella polilinea SVG
|
||||
contorni.append(line)
|
||||
if not contorni:
|
||||
raise CostruttoreError("DXF senza geometria")
|
||||
|
||||
# #101: le dimensioni si misurano sui punti completi, prima della decimazione
|
||||
xs = [p[0] for c in contorni for p in c]
|
||||
ys = [p[1] for c in contorni for p in c]
|
||||
xmin, xmax, ymin, ymax = min(xs), max(xs), min(ys), max(ys)
|
||||
|
||||
total = len(xs)
|
||||
if total > _PREVIEW_MAX_POINTS:
|
||||
# quota per contorno proporzionale, minimo 2 punti
|
||||
contorni = [
|
||||
_decimate(c, max(2, int(len(c) * _PREVIEW_MAX_POINTS / total))) for c in contorni
|
||||
]
|
||||
contorni = [[[round(x, 3), round(y, 3)] for x, y in c] for c in contorni]
|
||||
xmin, xmax, ymin, ymax = (round(v, 3) for v in (xmin, xmax, ymin, ymax))
|
||||
return {
|
||||
"contorni": contorni,
|
||||
"bbox": {"xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax},
|
||||
"larghezza_mm": round(xmax - xmin, 3),
|
||||
"altezza_mm": round(ymax - ymin, 3),
|
||||
"entita": len(contorni),
|
||||
"punti": sum(len(c) for c in contorni),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/dxf/preview")
|
||||
async def dxf_preview(dxf: UploadFile = File(...)) -> dict:
|
||||
"""#101: anteprima del disegno DXF (contorni + dimensioni in mm) per il
|
||||
wizard «Crea ricetta da DXF». Solo lettura, leggera: nessun run slot."""
|
||||
dxf_bytes = await dxf.read()
|
||||
try:
|
||||
return await asyncio.to_thread(_dxf_preview, dxf_bytes)
|
||||
except CostruttoreError as exc:
|
||||
raise _unprocessable(str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"dxf preview failed: {type(exc).__name__}: {exc}",
|
||||
) from exc
|
||||
|
||||
@@ -80,3 +80,67 @@ async def test_dxf_compare_immagine_non_decodificabile_e_422():
|
||||
async def test_dxf_compare_parametro_fuori_range_e_422():
|
||||
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# --- #101: POST /dxf/preview ---------------------------------------------------
|
||||
|
||||
def _rect_dxf(width: float, height: float, y0: float = 3.0) -> bytes:
|
||||
"""DXF minimo con ezdxf: un rettangolo chiuso, y in alto rispetto a 0."""
|
||||
import ezdxf
|
||||
doc = ezdxf.new()
|
||||
doc.modelspace().add_lwpolyline(
|
||||
[(1, y0), (1 + width, y0), (1 + width, y0 + height), (1, y0 + height)], close=True,
|
||||
)
|
||||
buffer = io.StringIO()
|
||||
doc.write(buffer)
|
||||
return buffer.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def _post_preview(dxf: bytes):
|
||||
# TestClient sincrono: non serve pytest-asyncio (assente nel venv del worker)
|
||||
from fastapi.testclient import TestClient
|
||||
return TestClient(app).post(
|
||||
"/dxf/preview", files={"dxf": ("d.dxf", dxf, "application/octet-stream")},
|
||||
)
|
||||
|
||||
|
||||
def test_dxf_preview_rettangolo_10x5():
|
||||
resp = _post_preview(_rect_dxf(10, 5))
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["larghezza_mm"] == 10 and body["altezza_mm"] == 5
|
||||
assert body["entita"] == 1 and body["punti"] == sum(len(c) for c in body["contorni"])
|
||||
# y ribaltata: il rettangolo stava a y 3..8 nel DXF, ora a -8..-3 (asse verso il basso)
|
||||
assert body["bbox"] == {"xmin": 1, "ymin": -8, "xmax": 11, "ymax": -3}
|
||||
ring = body["contorni"][0]
|
||||
assert ring[0] == ring[-1] # contorno chiuso: primo == ultimo
|
||||
assert all(-8 <= y <= -3 for _, y in ring)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", [b"", b"questo non e' un dxf"])
|
||||
def test_dxf_preview_illeggibile_e_422(content):
|
||||
resp = _post_preview(content)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"].startswith("DXF")
|
||||
|
||||
|
||||
def test_dxf_preview_senza_geometria_e_422():
|
||||
import ezdxf
|
||||
doc = ezdxf.new()
|
||||
buffer = io.StringIO()
|
||||
doc.write(buffer)
|
||||
resp = _post_preview(buffer.getvalue().encode("utf-8"))
|
||||
assert resp.status_code == 422
|
||||
assert "senza geometria" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_dxf_preview_decima_oltre_il_tetto(monkeypatch):
|
||||
from src.vision_worker import main
|
||||
monkeypatch.setattr(main, "_PREVIEW_MAX_POINTS", 50)
|
||||
import ezdxf
|
||||
doc = ezdxf.new()
|
||||
doc.modelspace().add_circle((0, 0), 20)
|
||||
buffer = io.StringIO()
|
||||
doc.write(buffer)
|
||||
full = main._dxf_preview(buffer.getvalue().encode("utf-8"))
|
||||
assert full["punti"] <= 50 + 1 and full["larghezza_mm"] == pytest.approx(40, abs=0.01) # dimensioni dai punti completi
|
||||
|
||||
Reference in New Issue
Block a user