Compare commits

...

10 Commits

Author SHA1 Message Date
Adriano 4791c34205 merge #101 101b: anteprima DXF disegnata con path (Refs #101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:06:54 +01:00
Adriano e582df7962 maker: anteprima DXF disegnata con path e viewBox camel (Refs #101)
Un solo <path :d="dxfPreviewD()"> al posto di template x-for + polyline
(x-for non funziona nel namespace SVG) e x-bind:view-box.camel al posto
di :viewBox (il parser HTML abbassa il nome dell'attributo).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:02:04 +01:00
Adriano a7b91b5508 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
2026-10-25 15:59:25 +01:00
Adriano addf5a2598 merge #101 101a: anteprima DXF nel wizard (Refs #101)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:59:14 +01:00
Adriano bdb1e8c8b9 maker: anteprima del DXF nel wizard «Crea ricetta da DXF» (Refs #101)
Proxy Flask /maker/api/vision/dxf-preview (piano B), stato e funzioni
dxfPreview* nel wizard, riquadro SVG con L x H sotto il campo .dxf,
traduzioni e test (proxy, JS con node, pagina).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:56:45 +01:00
Adriano ebaafbe6e9 vision: anteprima DXF nel worker e nel backend (Refs #101)
Worker: POST /dxf/preview legge il DXF con read_xld_dxf (lo stesso lettore di
CompareDxf) e ritorna contorni in mm con y verso il basso, bbox, dimensioni,
entita e punti (tetto 20000, decimazione uniforme). Backend: POST
/api/vision/dxf-preview (Maker) che inoltra al worker; 422 inoltrato.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:55:43 +01:00
Adriano 351153e78a merge #100: statistiche con «Tutti i punti» (Refs #100)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:52:17 +01:00
Adriano 842ceca007 statistiche: con Tutti i punti tabella capability per punto, carta e istogramma solo con punto scelto, il select ricarica da solo (Refs #100)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:51:38 +01:00
Adriano b78f2422d8 merge #99: BMP nelle rotte visione (Refs #99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:47:21 +01:00
Adriano 27d7c1e832 maker: mm/px a mano nel wizard DXF per immagini da file (Refs #98)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:37:40 +01:00
15 changed files with 666 additions and 10 deletions
+14
View File
@@ -366,6 +366,20 @@ async def preview(
return await vision_service.preview(db, task, await _read_image(image)) 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") @router.post("/tasks/{task_id}/recipe/dxf-compare")
async def create_dxf_compare_recipe( async def create_dxf_compare_recipe(
task_id: int, task_id: int,
+19
View File
@@ -224,6 +224,25 @@ async def dxf_compare_recipe(
return body 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) # Stati di fetch_station_calibration (#83)
CAL_OK = "ok" CAL_OK = "ok"
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source 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 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,11 @@ 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
bandMm: 0.1, bandMm: 0.1,
minPointsPct: 95, minPointsPct: 95,
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce) dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
@@ -59,8 +64,19 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
}, },
get calibrated() { return this.calState === 'calibrated'; }, get calibrated() { return this.calState === 'calibrated'; },
// #98: mm/px inserito a mano -> px/mm; null se vuoto o fuori da 0.00001..100 (limiti di /camera/calibrazioni/manuale, #95)
get manualPxMm() {
const raw = String(this.mmPx || '').trim().replace(',', '.');
if (!raw) return null;
const v = Number(raw);
if (!Number.isFinite(v) || v < 0.00001 || v > 100) return null;
return 1 / v;
},
// #98: compilato ma non valido -> errore in pagina
get mmPxInvalid() { return String(this.mmPx || '').trim() !== '' && this.manualPxMm === null; },
get canRun() { get canRun() {
return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy; // #98: la scala a mano sostituisce la calibrazione della stazione
return !!(this.stationId && (this.calibrated || this.manualPxMm !== null) && this.imageFile && this.dxfFile) && !this.busy;
}, },
_setImage(file) { _setImage(file) {
@@ -75,7 +91,67 @@ 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: 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 // Frame dalla camera della stazione scelta
async grab() { async grab() {
@@ -120,6 +196,8 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png'); fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
fd.append('dxf', this.dxfFile, this.dxfFile.name); fd.append('dxf', this.dxfFile, this.dxfFile.name);
fd.append('station_id', this.stationId); fd.append('station_id', this.stationId);
// #98: scala a mano; il backend la preferisce alla calibrazione della stazione
if (this.manualPxMm !== null) fd.append('px_per_mm', String(this.manualPxMm));
fd.append('band_mm', String(this.bandMm)); fd.append('band_mm', String(this.bandMm));
fd.append('min_points_pct', String(this.minPointsPct)); fd.append('min_points_pct', String(this.minPointsPct));
fd.append('polarity', this.dark ? 'dark' : 'light'); fd.append('polarity', this.dark ? 'dark' : 'light');
@@ -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>
@@ -680,13 +680,23 @@
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600"> <p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
{{ _('Calibrazione') }}: <span x-text="calLabel"></span> {{ _('Calibrazione') }}: <span x-text="calLabel"></span>
</p> </p>
<p x-show="stationId && calState && !calibrated" class="mt-1 text-xs text-amber-600"> <p x-show="stationId && calState && !calibrated && manualPxMm === null" class="mt-1 text-xs text-amber-600">{# #98: nascosto con mm/px valido #}
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span> <span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl" <a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a> class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span> <span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
</p> </p>
</div> </div>
<!-- #98: mm/px a mano per immagini da file, sostituisce la calibrazione della stazione -->
<div>
<label class="tmf-label text-xs">{{ _('mm/px (immagine da file)') }}</label>
<input type="text" inputmode="decimal" class="tmf-input text-xs" x-model="mmPx" placeholder="0,01406">
<p x-show="manualPxMm !== null" class="mt-1 text-xs text-emerald-600">
= <span x-text="fmt(manualPxMm)"></span> px/mm
</p>
<p x-show="mmPxInvalid" class="mt-1 text-xs text-amber-600">{{ _('mm/px non valido') }}</p>
<p class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Se compilato sostituisce la calibrazione della stazione; la scala resta salvata nella ricetta.') }}</p>
</div>
<!-- Immagine: camera o file --> <!-- Immagine: camera o file -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
@@ -704,6 +714,20 @@
<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 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>
</div> </div>
<!-- Pochi parametri --> <!-- Pochi parametri -->
@@ -51,7 +51,9 @@
<!-- Subtask --> <!-- Subtask -->
<div> <div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label> <label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
<!-- #100: il cambio di punto ricarica subito, senza «Applica filtri» -->
<select x-model="selectedSubtaskId" <select x-model="selectedSubtaskId"
@change="loadData()"
:disabled="subtasks.length === 0" :disabled="subtasks.length === 0"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"> class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
<option value="">{{ _("Tutti i punti") }}</option> <option value="">{{ _("Tutti i punti") }}</option>
@@ -287,20 +289,52 @@
</div> </div>
</div> </div>
</template> </template>
<template x-if="!capability || capability.cp == null"> <!-- #100: con «Tutti i punti» tabella con una riga per punto di misura -->
<template x-if="!loadedSubtaskId && capabilityAll.length > 0">
<div>
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-steel text-left">
<th class="py-1 font-medium">{{ _("Punto di misura") }}</th>
<th class="py-1 font-medium text-right">n</th>
<th class="py-1 font-medium text-right">Cp</th>
<th class="py-1 font-medium text-right">Cpk</th>
</tr>
</thead>
<tbody>
<template x-for="row in capabilityAll" :key="row.subtask.id">
<tr class="cursor-pointer hover:bg-[var(--bg-primary)] border-t border-[var(--border-color)]"
@click="selectedSubtaskId = String(row.subtask.id); loadData()">
<td class="py-1.5 pr-2" x-text="'#' + row.subtask.marker_number + ' — ' + row.subtask.description"></td>
<td class="py-1.5 text-right font-mono" x-text="row.data && row.data.n != null ? row.data.n : '—'"></td>
<td class="py-1.5 text-right font-mono font-bold"
:class="row.data && row.data.cp != null ? capabilityColor(row.data.cp) : 'text-steel'"
x-text="row.data && row.data.cp != null ? row.data.cp : '—'"></td>
<td class="py-1.5 text-right font-mono font-bold"
:class="row.data && row.data.cpk != null ? capabilityColor(row.data.cpk) : 'text-steel'"
x-text="row.data && row.data.cpk != null ? row.data.cpk : '—'"></td>
</tr>
</template>
</tbody>
</table>
<p class="text-xs text-steel mt-3">{{ _("Scegli un punto per carta di controllo e istogramma") }}</p>
</div>
</template>
<template x-if="(!capability || capability.cp == null) && (loadedSubtaskId || capabilityAll.length === 0)">
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p> <p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
</template> </template>
</div> </div>
</div> </div>
<!-- Row 2: Carta di Controllo --> <!-- Row 2: Carta di Controllo -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6"> <!-- #100: x-show (non x-if) perche' renderCharts() cerca gli id nel DOM; nascosta senza punto -->
<div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2> <h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
<div id="control-chart" style="min-height: 350px;"></div> <div id="control-chart" style="min-height: 350px;"></div>
</div> </div>
<!-- Row 3: Istogramma --> <!-- Row 3: Istogramma -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6"> <div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2> <h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
<div id="histogram-chart" style="min-height: 350px;"></div> <div id="histogram-chart" style="min-height: 350px;"></div>
</div> </div>
@@ -346,6 +380,8 @@ function spcDashboard() {
subtasks: [], subtasks: [],
// Data state // Data state
loadedSubtaskId: '', // #100 punto con cui sono stati caricati i dati (i pannelli dipendono da questo)
capabilityAll: [], // #100 con «Tutti i punti»: [{subtask, data}] una riga per punto
summary: null, summary: null,
capability: null, capability: null,
controlChart: null, controlChart: null,
@@ -408,6 +444,8 @@ function spcDashboard() {
async onRecipeChange() { async onRecipeChange() {
this.selectedSubtaskId = ''; this.selectedSubtaskId = '';
this.loadedSubtaskId = ''; // #100
this.capabilityAll = []; // #100
this.selectedRunId = ''; // #58 this.selectedRunId = ''; // #58
this.runs = []; this.runs = [];
this.subtasks = []; this.subtasks = [];
@@ -459,10 +497,11 @@ function spcDashboard() {
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58 if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString()); if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString()); if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
const requestedSubtaskId = this.selectedSubtaskId; // #100 punto richiesto da questa chiamata
try { try {
// Parallel fetch of all endpoints // Parallel fetch of all endpoints
const [summaryResp, capabilityResp, controlResp, histogramResp] = await Promise.allSettled([ const [summaryResp, capabilityResp, controlResp, histogramResp, allResp] = await Promise.allSettled([
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()), fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
this.selectedSubtaskId this.selectedSubtaskId
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json()) ? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
@@ -473,6 +512,17 @@ function spcDashboard() {
this.selectedSubtaskId this.selectedSubtaskId
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json()) ? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
: Promise.resolve(null), : Promise.resolve(null),
// #100: senza punto scelto, capability di ogni punto della ricetta
requestedSubtaskId
? Promise.resolve([])
: Promise.all(this.subtasks.map(st => {
const p = new URLSearchParams(params.toString());
p.set('subtask_id', st.id);
return fetch('/statistics/api/capability?' + p.toString())
.then(r => r.json())
.then(d => ({ subtask: st, data: (d && !d.error) ? d : null }))
.catch(() => ({ subtask: st, data: null }));
})),
]); ]);
// Summary (always available) // Summary (always available)
@@ -505,6 +555,9 @@ function spcDashboard() {
this.histogram = null; this.histogram = null;
} }
// #100: lista per-punto solo con «Tutti i punti»; con un punto resta il flusso di prima
this.capabilityAll = (allResp.status === 'fulfilled' && Array.isArray(allResp.value)) ? allResp.value : [];
this.loadedSubtaskId = requestedSubtaskId;
this.hasData = true; this.hasData = true;
// Render charts after DOM update // Render charts after DOM update
@@ -522,7 +575,7 @@ function spcDashboard() {
renderCharts() { renderCharts() {
// Control chart // Control chart
if (this.controlChart && this.selectedSubtaskId) { if (this.controlChart && this.loadedSubtaskId) { // #100
renderControlChart('control-chart', this.controlChart); renderControlChart('control-chart', this.controlChart);
} else { } else {
const el = document.getElementById('control-chart'); const el = document.getElementById('control-chart');
@@ -530,7 +583,7 @@ function spcDashboard() {
} }
// Histogram // Histogram
if (this.histogram && this.selectedSubtaskId) { if (this.histogram && this.loadedSubtaskId) { // #100
const tol = this.capability ? { const tol = this.capability ? {
utl: this.capability.utl, utl: this.capability.utl,
ltl: this.capability.ltl, ltl: this.capability.ltl,
@@ -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}"
)
@@ -178,6 +178,12 @@ class TestDxfCompareProxy:
assert set(kwargs["files"]) == {"image", "dxf"} assert set(kwargs["files"]) == {"image", "dxf"}
assert kwargs["files"]["image"][1] == PNG assert kwargs["files"]["image"][1] == PNG
def test_manual_px_per_mm_forwarded(self, logged_in_client, mock_api_client):
# #98: scala a mano dal wizard (immagine da file): inoltrata al backend
mock_api_client.post.return_value = self.OK
self._post(logged_in_client, station_id="4", px_per_mm="71.1237")
assert mock_api_client.post.call_args.kwargs["data"]["px_per_mm"] == "71.1237"
def test_save_true_forwarded(self, logged_in_client, mock_api_client): def test_save_true_forwarded(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"} mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
resp = self._post(logged_in_client, save="true") resp = self._post(logged_in_client, save="true")
@@ -204,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:
@@ -229,4 +278,6 @@ class TestPages:
assert "js/dxf_recipe_wizard.js" in html assert "js/dxf_recipe_wizard.js" in html
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 "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 assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
@@ -167,3 +167,11 @@ class TestRunTaskResultsProxy:
assert 'id="run-task-results"' in html assert 'id="run-task-results"' in html
assert "loadTaskResults" in html assert "loadTaskResults" in html
assert "/statistics/api/runs/" in html assert "/statistics/api/runs/" in html
def test_dashboard_all_points_overview(self, logged_in_client, mock_api_client):
# #100: con «Tutti i punti» la pagina ha tabella per-punto e select che ricarica da solo
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
assert "capabilityAll" in html
assert "loadedSubtaskId" in html
assert '@change="loadData()"' in html
@@ -3485,3 +3485,17 @@ msgstr "Invalid camera address"
#: 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 "Invalid response from the camera service" 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) #: 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à"
+87
View File
@@ -11,6 +11,7 @@ import io
import json import json
import os import os
import re import re
import tempfile # #101
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
@@ -288,3 +289,89 @@ async def recipes_dxf_compare(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"recipe build failed: {type(exc).__name__}: {exc}", detail=f"recipe build failed: {type(exc).__name__}: {exc}",
) from 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(): async def test_dxf_compare_parametro_fuori_range_e_422():
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio") response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
assert response.status_code == 422 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