Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c361c14ae | |||
| 5897a9e5df | |||
| f44b578d5b | |||
| c5be9a2bb4 | |||
| 17ea010f7e | |||
| 1d6d74b2e4 | |||
| 2f0a5aaf1e | |||
| e96fbb73c2 | |||
| f3bacc8238 | |||
| e743363a62 | |||
| d48a8a1076 | |||
| 8ef61a1285 | |||
| 84fc1a08f7 | |||
| c904800e94 | |||
| 20c0a47c00 | |||
| 269bbc03ca | |||
| 815d49d699 | |||
| 43ef22079d | |||
| b01bba536a | |||
| 17344aae13 | |||
| fd3384f08c | |||
| 08aefa9888 | |||
| 06f308be6f | |||
| 93afd6187c | |||
| 8217f46d47 | |||
| d4802fd1a3 | |||
| ea64f3c524 | |||
| 58baed19e2 | |||
| 25b0741003 | |||
| df67a32a82 | |||
| 5723f055c4 | |||
| 4485803122 | |||
| acd94a9d33 | |||
| 88b3f202b1 | |||
| 4a11d2595f | |||
| b46ccba1ee | |||
| 7a1df0bca4 | |||
| 198c6c63c4 | |||
| 04b81ad2f2 | |||
| 45c9e6c3f3 | |||
| ca6cd0cab1 | |||
| 34c251aa90 | |||
| c70d29d6d7 | |||
| 96aca975d0 | |||
| b3c56ef5b5 | |||
| 9111eb2741 | |||
| b46a70347f | |||
| 155abfd15c | |||
| 2c2d601495 | |||
| b2e3867370 | |||
| cab419bdc7 | |||
| 249999fa1e | |||
| a3e032d000 | |||
| 57376556d2 | |||
| 7cb877b11b | |||
| 08616aa1fa | |||
| 02f1481fe5 | |||
| 34e1b042f5 | |||
| bdef1f1cd8 | |||
| 39b9ad77eb | |||
| 12632323e8 | |||
| 839a61e479 | |||
| 9b8e729927 | |||
| 6080e6aebc | |||
| d5db5f3014 | |||
| 1a19bb6679 | |||
| 20f33234a8 | |||
| 711abc96df | |||
| 77419f9ebd | |||
| 4791c34205 | |||
| e582df7962 | |||
| a7b91b5508 | |||
| addf5a2598 | |||
| bdb1e8c8b9 | |||
| ebaafbe6e9 | |||
| 351153e78a | |||
| 842ceca007 | |||
| b78f2422d8 | |||
| 444dc0a6d1 | |||
| 27d7c1e832 | |||
| 4b1b23cfb6 | |||
| 4b57efc73c | |||
| 7c03f53000 | |||
| 661b782bad | |||
| 25f8b4d609 | |||
| 8f9731839a |
@@ -348,6 +348,69 @@ def _nessun_utente(db: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def camere_da_scaldare(db: Path) -> list[tuple[str, str]]:
|
||||
"""Coppie (camera_url, camera_source) configurate nel db (stazioni e task di ricetta), senza doppioni.
|
||||
Solo sorgenti "camera:" che aprono hardware (non "camera:folder"). Errore sqlite -> lista vuota (#108)."""
|
||||
coppie: list[tuple[str, str]] = []
|
||||
|
||||
def aggiungi(url, src) -> None:
|
||||
# #108: valida la coppia e scarta duplicati e sorgenti che non aprono hardware
|
||||
if not (isinstance(url, str) and isinstance(src, str) and url.strip() and src.strip()):
|
||||
return
|
||||
if not src.startswith("camera:") or src == "camera:folder":
|
||||
return
|
||||
if (url, src) not in coppie:
|
||||
coppie.append((url, src))
|
||||
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
|
||||
try:
|
||||
for url, src in con.execute("SELECT camera_url, camera_source FROM stations"):
|
||||
aggiungi(url, src)
|
||||
for (blob,) in con.execute("SELECT acquisition_json FROM recipe_tasks WHERE acquisition_json IS NOT NULL"):
|
||||
try:
|
||||
cfg = json.loads(blob)
|
||||
except (TypeError, ValueError):
|
||||
continue # #108: JSON non valido, si salta
|
||||
if isinstance(cfg, dict):
|
||||
aggiungi(cfg.get("camera_url"), cfg.get("camera_source"))
|
||||
finally:
|
||||
con.close()
|
||||
except Exception:
|
||||
return []
|
||||
return coppie
|
||||
|
||||
|
||||
def scalda_camere(db: Path) -> None:
|
||||
"""Apre ogni camera configurata con un grab a vuoto, cosi' il primo "Acquisisci" non aspetta ~25 s (#108).
|
||||
Gira in un thread daemon: nessuna eccezione esce."""
|
||||
try:
|
||||
camere = camere_da_scaldare(db)
|
||||
if not camere:
|
||||
return
|
||||
for _ in range(300): # aspetta il servizio camera (max ~60 s), come _apri_browser_quando_pronto
|
||||
if _porta_aperta(PORTA_CAMERA):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
log.warning("scalda camere: servizio camera non raggiungibile, salto")
|
||||
return
|
||||
for url, src in camere:
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
richiesta = urllib.request.Request(
|
||||
f"{url.rstrip('/')}/camera/grab",
|
||||
data=json.dumps({"source": src, "trigger": "continuo", "timeout_ms": 5000}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(richiesta, timeout=90) as r:
|
||||
esito = f"HTTP {r.status}"
|
||||
log.info("scalda camera %s: %s in %.1f s", src, esito, time.monotonic() - t0)
|
||||
except Exception as e: # #108: una camera che non risponde non ferma le altre
|
||||
log.warning("scalda camera %s: %s dopo %.1f s", src, e, time.monotonic() - t0)
|
||||
except Exception:
|
||||
log.exception("scalda camere non riuscito")
|
||||
|
||||
|
||||
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
|
||||
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
|
||||
for _ in range(300):
|
||||
@@ -509,6 +572,8 @@ def main(argv=None) -> int:
|
||||
|
||||
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
|
||||
thread = avvia_server(args.host, dati)
|
||||
# #108: apre le camere configurate con un grab a vuoto, con o senza browser
|
||||
threading.Thread(target=scalda_camere, args=(dati / "tmf.db",), daemon=True).start()
|
||||
if not args.senza_browser:
|
||||
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
||||
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
|
||||
|
||||
@@ -271,3 +271,77 @@ def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path,
|
||||
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- scalda camere (#108)
|
||||
|
||||
def _db_camere(percorso):
|
||||
# #108: db minimo con le due tabelle lette da camere_da_scaldare
|
||||
con = sqlite3.connect(percorso)
|
||||
con.execute("CREATE TABLE stations (id INTEGER PRIMARY KEY, camera_url TEXT, camera_source TEXT)")
|
||||
con.execute("CREATE TABLE recipe_tasks (id INTEGER PRIMARY KEY, acquisition_json TEXT)")
|
||||
con.executemany("INSERT INTO stations (camera_url, camera_source) VALUES (?, ?)", [
|
||||
("http://127.0.0.1:8765", "camera:601020"), # valida
|
||||
(None, None), # stazione senza camera
|
||||
("http://127.0.0.1:8765", "camera:folder"), # non apre hardware
|
||||
])
|
||||
con.executemany("INSERT INTO recipe_tasks (acquisition_json) VALUES (?)", [
|
||||
(json.dumps({"camera_url": "http://127.0.0.1:8765", "camera_source": "camera:601020"}),), # duplicato
|
||||
(json.dumps({"camera_url": "http://10.0.0.5:8765/", "camera_source": "camera:777"}),), # valida
|
||||
("{rotto",), # JSON non valido
|
||||
(None,),
|
||||
])
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def test_camere_da_scaldare_filtra_e_deduplica(avvio, tmp_path):
|
||||
db = tmp_path / "tmf.db"
|
||||
_db_camere(db)
|
||||
assert avvio.camere_da_scaldare(db) == [
|
||||
("http://127.0.0.1:8765", "camera:601020"),
|
||||
("http://10.0.0.5:8765/", "camera:777"),
|
||||
]
|
||||
|
||||
|
||||
def test_camere_da_scaldare_db_inesistente(avvio, tmp_path):
|
||||
assert avvio.camere_da_scaldare(tmp_path / "non_esiste.db") == []
|
||||
|
||||
|
||||
def test_scalda_camere_una_chiamata_per_coppia(avvio, tmp_path, monkeypatch):
|
||||
db = tmp_path / "tmf.db"
|
||||
_db_camere(db)
|
||||
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||
chiamate = []
|
||||
|
||||
class Risposta:
|
||||
status = 200
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): return False
|
||||
|
||||
def finto_urlopen(richiesta, timeout=None):
|
||||
chiamate.append((richiesta.full_url, json.loads(richiesta.data), richiesta.get_method(), timeout))
|
||||
return Risposta()
|
||||
|
||||
monkeypatch.setattr(avvio.urllib.request, "urlopen", finto_urlopen)
|
||||
avvio.scalda_camere(db)
|
||||
corpo = {"trigger": "continuo", "timeout_ms": 5000}
|
||||
assert chiamate == [
|
||||
("http://127.0.0.1:8765/camera/grab", {"source": "camera:601020", **corpo}, "POST", 90),
|
||||
("http://10.0.0.5:8765/camera/grab", {"source": "camera:777", **corpo}, "POST", 90),
|
||||
]
|
||||
|
||||
|
||||
def test_scalda_camere_eccezione_urlopen_non_esce(avvio, tmp_path, monkeypatch):
|
||||
db = tmp_path / "tmf.db"
|
||||
_db_camere(db)
|
||||
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||
tentativi = []
|
||||
|
||||
def urlopen_rotto(richiesta, timeout=None):
|
||||
tentativi.append(richiesta.full_url)
|
||||
raise OSError("camera irraggiungibile")
|
||||
|
||||
monkeypatch.setattr(avvio.urllib.request, "urlopen", urlopen_rotto)
|
||||
avvio.scalda_camere(db) # non solleva e passa alla camera successiva
|
||||
assert len(tentativi) == 2
|
||||
|
||||
+157
-33
@@ -17,7 +17,9 @@ un'opzione della calibrazione: la calibrazione usa sempre il frame grezzo della
|
||||
scelta nella tab Acquisizione. Sorgente in cima alla tab; riquadri «Immagine» (grezza) e,
|
||||
con la casella, «Calibrata» (/camera/anteprima/calibrata) affiancati; il ciclo anteprima
|
||||
si sospende mentre la tab Calibrazione e' aperta.
|
||||
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
|
||||
#122: i quattro visori sono tmf-viewer; i tre file JS del frontend (vision_overlay, save_image, image_viewer)
|
||||
li serve camera_service su /visore/.
|
||||
Dipende da: i tre file JS su /visore/ (camera_service). E' una stringa, cosi' il pacchetto compilato non ha file dati.
|
||||
"""
|
||||
|
||||
PAGINA = r"""<!doctype html>
|
||||
@@ -66,9 +68,7 @@ PAGINA = r"""<!doctype html>
|
||||
/* anteprima */
|
||||
.palco { background: #020617; border: 1px solid var(--bordo); border-radius: 6px; min-height: 340px;
|
||||
display: flex; align-items: center; justify-content: center; margin-top: 10px; overflow: hidden; }
|
||||
.cornice { position: relative; display: inline-block; line-height: 0; max-width: 100%; }
|
||||
.cornice img { max-width: 100%; max-height: calc(100vh - 330px); min-height: 200px; border: 0; margin: 0; }
|
||||
.mirino { position: absolute; inset: 0; pointer-events: none; /* #92: classe, vale per i due riquadri */
|
||||
.mirino { position: absolute; inset: 0; pointer-events: none; /* #92: classe, vale per i due riquadri; #122: sta dentro lo stage del tmf-viewer */
|
||||
background: linear-gradient(#fbbf24, #fbbf24) center / 2px 100% no-repeat,
|
||||
linear-gradient(#fbbf24, #fbbf24) center / 100% 2px no-repeat; opacity: .85; }
|
||||
#vuoto, #vuotoCal, #vuotoCalTab { padding: 30px 16px; color: var(--secondario); text-align: center; }
|
||||
@@ -78,6 +78,10 @@ PAGINA = r"""<!doctype html>
|
||||
.riquadro .titolo { font-size: .88rem; font-weight: 600; }
|
||||
.riquadro .titolo span { font-weight: 400; font-size: .78rem; color: var(--secondario); margin-left: 6px; }
|
||||
.riquadro .palco { margin-top: 4px; }
|
||||
/* #122: i visori sono tmf-viewer (static/js/image_viewer.js servito su /visore/): barra, zoom e pan vengono dal componente.
|
||||
Altezza data dalla pagina (come l'altezza massima che avevano le immagini); [hidden] vale gia' sopra */
|
||||
.palco tmf-viewer { width: 100%; height: max(340px, calc(100vh - 330px)); }
|
||||
.angoli { display: block; width: 100%; margin-top: 8px; border: 1px solid var(--bordo); border-radius: 6px; } /* #122: immagine degli angoli, alta quanto serve (fit-height) */
|
||||
.sorgente-riga { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
|
||||
.sorgente-riga .campo-sorgente { flex: 1 1 260px; min-width: 0; }
|
||||
.sorgente-riga select { width: 100%; }
|
||||
@@ -113,7 +117,7 @@ PAGINA = r"""<!doctype html>
|
||||
@media (max-width: 900px) {
|
||||
.griglia { grid-template-columns: minmax(0, 1fr); }
|
||||
.riquadri.doppio { grid-template-columns: minmax(0, 1fr); } /* #92: in colonna */
|
||||
.cornice img { max-height: 60vh; }
|
||||
.palco tmf-viewer { height: max(340px, 60vh); } /* #122: era max-height delle immagini */
|
||||
}
|
||||
@media (max-width: 500px) { /* #91: utilizzabile anche stretta (400 px) */
|
||||
main { padding: 8px; }
|
||||
@@ -164,22 +168,18 @@ PAGINA = r"""<!doctype html>
|
||||
<div class="riquadri" id="riquadri">
|
||||
<div class="riquadro">
|
||||
<div class="titolo">Immagine<span>grezza, senza correzione</span></div>
|
||||
<div class="palco">
|
||||
<div class="palco"> <!-- #122: tmf-viewer al posto del visore #111; width/height = risoluzione della camera, messe da tornaAdatta() -->
|
||||
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
|
||||
<div class="cornice" id="cornice" hidden>
|
||||
<img id="immagine" alt="Anteprima grezza della sorgente">
|
||||
<div id="mirino" class="mirino" hidden></div>
|
||||
</div>
|
||||
<tmf-viewer id="immagine" save-name="grezza" title-save="Salva l'immagine grezza" no-layers hidden></tmf-viewer> <!-- #126: no-layers = senza menu dei livelli -->
|
||||
<div id="mirino" class="mirino" hidden></div> <!-- #122: spostato nello stage del componente da creaVisore() -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="riquadro" id="riquadroCal" hidden>
|
||||
<div class="titolo">Calibrata<span id="nomeCalibrata"></span></div>
|
||||
<div class="palco">
|
||||
<div class="palco"> <!-- #122 -->
|
||||
<div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div>
|
||||
<div class="cornice" id="corniceCal" hidden>
|
||||
<img id="immagineCal" alt="Anteprima della sorgente raddrizzata dalla calibrazione">
|
||||
<div id="mirinoCal" class="mirino" hidden></div>
|
||||
</div>
|
||||
<tmf-viewer id="immagineCal" save-name="calibrata" title-save="Salva l'immagine calibrata" no-layers hidden></tmf-viewer> <!-- #126: no-layers = senza menu dei livelli -->
|
||||
<div id="mirinoCal" class="mirino" hidden></div>
|
||||
</div>
|
||||
<!-- #93: scelta della calibrazione da attivare sulla sorgente, con CONFERMA -->
|
||||
<div class="riga scelta-cal">
|
||||
@@ -220,6 +220,12 @@ PAGINA = r"""<!doctype html>
|
||||
<section class="scheda" id="sezioneParametri">
|
||||
<h2>Parametri camera</h2>
|
||||
<div id="parametri"><div class="nota">-</div></div>
|
||||
<!-- #110: specchiatura per sorgente, applicata dal servizio a ogni frame -->
|
||||
<div class="riga" id="specchiaRiga" style="margin-top:10px">
|
||||
<label style="margin:0 0 6px"><input id="specchiaO" type="checkbox" disabled> Specchia orizzontale</label>
|
||||
<label style="margin:0 0 6px"><input id="specchiaV" type="checkbox" disabled> Specchia verticale</label>
|
||||
</div>
|
||||
<div id="msgSpecchia" class="messaggio"></div>
|
||||
<div class="riga" id="salvaParametriRiga" hidden>
|
||||
<button id="salvaParametri" class="secondario">Salva come predefiniti</button>
|
||||
</div>
|
||||
@@ -233,11 +239,9 @@ PAGINA = r"""<!doctype html>
|
||||
<h2>Calibrazione</h2>
|
||||
<div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> — immagine grezza dalla tab Acquisizione</div>
|
||||
<!-- #94: immagine viva (stesso frame dell'anteprima) e «Blocca immagine» per calcolare su quella -->
|
||||
<div class="palco" style="margin-top:0">
|
||||
<div class="palco" style="margin-top:0"> <!-- #122 -->
|
||||
<div id="vuotoCalTab">Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione</div>
|
||||
<div class="cornice" id="corniceCalTab" hidden>
|
||||
<img id="immagineCalTab" alt="Immagine grezza della sorgente">
|
||||
</div>
|
||||
<tmf-viewer id="immagineCalTab" save-name="calibrazione" title-save="Salva l'immagine della calibrazione" no-layers hidden></tmf-viewer> <!-- #126: no-layers = senza menu dei livelli -->
|
||||
</div>
|
||||
<div class="riga" style="margin-top:8px">
|
||||
<button id="scattaCal" class="secondario" type="button">Scatta</button>
|
||||
@@ -264,7 +268,7 @@ PAGINA = r"""<!doctype html>
|
||||
<button id="salva">Salva</button>
|
||||
</div>
|
||||
<div id="msgSalva" class="messaggio"></div>
|
||||
<img id="overlay" alt="Angoli trovati sulla scacchiera" style="max-width:100%;margin-top:8px;border:1px solid var(--bordo);border-radius:6px">
|
||||
<tmf-viewer id="overlay" class="angoli" fit-height no-layers save-name="angoli" title-save="Salva l'immagine degli angoli"></tmf-viewer> <!-- #122: era un <img> senza zoom; #126: no-layers -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -293,6 +297,10 @@ PAGINA = r"""<!doctype html>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- #122: file del frontend serviti da camera_service su /visore/ (locali: la pagina resta offline); l'ordine conta -->
|
||||
<script src="/visore/vision_overlay.js"></script>
|
||||
<script src="/visore/save_image.js"></script>
|
||||
<script src="/visore/image_viewer.js"></script>
|
||||
<script>
|
||||
"use strict";
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
@@ -463,6 +471,7 @@ function cambioSorgente(salva) {
|
||||
if (A.bloccata) rilasciaBlocco(); // #94: casella spenta e DELETE sulla sorgente vecchia (prima di svuotare i riquadri)
|
||||
azzeraIndicatori();
|
||||
svuotaRiquadri(); // #92: il frame vecchio non e' della nuova sorgente
|
||||
tornaAdatta(); // #111: nuova sorgente = visori ad Adatta
|
||||
A.calAttiva = null; A.bloccoCal = false; // #92: stato calibrazione della nuova sorgente ancora da leggere
|
||||
annullaRimuovi();
|
||||
$("rimuoviImmagine").hidden = !sorgenteImmagine();
|
||||
@@ -470,6 +479,7 @@ function cambioSorgente(salva) {
|
||||
dire("msgElenco", ""); // #91
|
||||
aggiornaStato();
|
||||
caricaParametri();
|
||||
caricaOrientamento(); // #110: specchiatura della nuova sorgente
|
||||
if (era) avviaAnteprima();
|
||||
}
|
||||
|
||||
@@ -478,6 +488,45 @@ function cambioSorgente(salva) {
|
||||
// #91: ris = {w, h} dell'ultimo frame; #92: calAttiva = la sorgente ha una calibrazione attiva (null = non nota), bloccoCal = un 409 ha fermato le richieste calibrata; urlCal/seqCal = riquadro calibrata, bloccata/sorgenteBloccata/urlBlocco/seqBlocco = #94 immagine bloccata per il calcolo (il riquadro della tab Calibrazione non segue piu' il ciclo), lato = ultimo lato_max
|
||||
var A = { attiva: false, gen: 0, inVolo: false, url: null, urlCal: null, seqCal: 0, bloccata: false, bloccando: 0, sorgenteBloccata: null, urlBlocco: null, seqBlocco: 0, lato: 1280, calAttiva: null, bloccoCal: false, nitMax: 0, ris: null };
|
||||
|
||||
// ---- #122: visori = tmf-viewer (zoom, Adatta, Salva, pan e Ctrl+rotella vengono dal componente, come nel resto di TMF)
|
||||
// width/height del componente = risoluzione del frame pieno (A.ris): 100 % = pixel della camera, anche se l'anteprima scaricata e' piu' piccola
|
||||
var V = {};
|
||||
// #122: tutti i visori dell'anteprima tornano ad Adatta con le dimensioni di A.ris (cambio sorgente, risoluzione del frame diversa); A.ris ignoto = dimensioni naturali
|
||||
function tornaAdatta() {
|
||||
Object.keys(V).forEach(function (k) {
|
||||
var el = V[k].el;
|
||||
if (A.ris) { el.setAttribute("width", A.ris.w); el.setAttribute("height", A.ris.h); }
|
||||
else { el.removeAttribute("width"); el.removeAttribute("height"); }
|
||||
if (el.fit) el.fit();
|
||||
});
|
||||
}
|
||||
// #122: lato_max dell'anteprima: 1280 come prima se nessun visore dell'Acquisizione e' ingrandito, altrimenti abbastanza pixel per lo zoom piu' alto (zoom dall'evento viewer-zoom)
|
||||
function latoAnteprima() {
|
||||
var zm = 0;
|
||||
["acq", "cal"].forEach(function (k) {
|
||||
var v = V[k];
|
||||
if (v.el.offsetWidth > 0 && v.fit && v.z > v.fit * 1.0001 && v.z > zm) zm = v.z;
|
||||
});
|
||||
if (!zm || !A.ris) return 1280;
|
||||
return Math.min(4096, Math.max(1280, Math.ceil(Math.max(A.ris.w, A.ris.h) * zm * (window.devicePixelRatio || 1))));
|
||||
}
|
||||
function creaVisore(chiave, idViewer, idMirino) {
|
||||
var el = $(idViewer);
|
||||
var v = { el: el, z: 0, fit: 0 };
|
||||
V[chiave] = v;
|
||||
el.addEventListener("viewer-zoom", function (ev) { v.z = ev.detail.zoom; v.fit = ev.detail.fit; });
|
||||
// #122: il mirino sta dentro lo stage del componente (segue immagine e zoom); lo stage esiste dopo il connectedCallback, si riprova a ogni viewer-load
|
||||
var monta = function () {
|
||||
var stage = el.querySelector(".tmfv-stage"), m = idMirino ? $(idMirino) : null;
|
||||
if (stage && m && m.parentNode !== stage) stage.appendChild(m);
|
||||
};
|
||||
monta();
|
||||
el.addEventListener("viewer-load", monta);
|
||||
}
|
||||
creaVisore("acq", "immagine", "mirino");
|
||||
creaVisore("cal", "immagineCal", "mirinoCal");
|
||||
creaVisore("tab", "immagineCalTab", "");
|
||||
|
||||
function azzeraIndicatori() {
|
||||
A.nitMax = 0;
|
||||
["nitidezza", "saturati", "media", "dimensioni", "nitidezzaMax"].forEach(function (i) { $(i).textContent = "-"; });
|
||||
@@ -496,17 +545,17 @@ function pulsantiAnteprima() {
|
||||
function mostraImmagine(blob) {
|
||||
var vecchio = A.url;
|
||||
A.url = URL.createObjectURL(blob);
|
||||
$("immagine").src = A.url;
|
||||
$("immagine").setAttribute("src", A.url); // #122
|
||||
if (vecchio && vecchio !== A.urlBlocco) URL.revokeObjectURL(vecchio); // niente memoria che cresce a ogni frame; #94: non quello bloccato
|
||||
$("cornice").hidden = false;
|
||||
$("immagine").hidden = false;
|
||||
$("vuoto").hidden = true;
|
||||
if (!A.bloccata) mostraInTabCal(A.url); // #94: lo stesso blob URL va anche nella tab Calibrazione, salvo immagine bloccata
|
||||
}
|
||||
|
||||
// #94: riquadro «immagine viva» della tab Calibrazione (url null = vuoto)
|
||||
function mostraInTabCal(url) {
|
||||
$("immagineCalTab").src = url || "";
|
||||
$("corniceCalTab").hidden = !url;
|
||||
if (url) $("immagineCalTab").setAttribute("src", url); else $("immagineCalTab").removeAttribute("src"); // #122
|
||||
$("immagineCalTab").hidden = !url;
|
||||
$("vuotoCalTab").hidden = !!url;
|
||||
}
|
||||
|
||||
@@ -514,14 +563,14 @@ function mostraInTabCal(url) {
|
||||
function mostraCalibrata(blob, nome, soloScala) {
|
||||
var vecchio = A.urlCal;
|
||||
A.urlCal = URL.createObjectURL(blob);
|
||||
$("immagineCal").src = A.urlCal;
|
||||
$("immagineCal").setAttribute("src", A.urlCal); // #122
|
||||
if (vecchio) URL.revokeObjectURL(vecchio);
|
||||
$("corniceCal").hidden = false;
|
||||
$("immagineCal").hidden = false;
|
||||
$("vuotoCal").hidden = true;
|
||||
$("nomeCalibrata").textContent = (nome ? "«" + nome + "»" : "") + (soloScala ? " — solo scala, nessuna correzione ottica" : ""); // #95: senza mappa il frame e' grezzo
|
||||
}
|
||||
function messaggioCalibrata(testo) {
|
||||
$("corniceCal").hidden = true;
|
||||
$("immagineCal").hidden = true;
|
||||
$("vuotoCal").hidden = false;
|
||||
$("vuotoCal").textContent = testo;
|
||||
$("nomeCalibrata").textContent = "";
|
||||
@@ -529,7 +578,7 @@ function messaggioCalibrata(testo) {
|
||||
// #92: cambio sorgente: via i due frame, nessuna immagine di un'altra sorgente
|
||||
function svuotaRiquadri() {
|
||||
A.seqCal++; // una risposta calibrata ancora in volo non vale piu'
|
||||
$("cornice").hidden = true;
|
||||
$("immagine").hidden = true;
|
||||
$("vuoto").hidden = false;
|
||||
mostraInTabCal(null); // #94: svuotato anche il riquadro della tab Calibrazione
|
||||
messaggioCalibrata("Premere «Avvia anteprima» o «Scatta».");
|
||||
@@ -587,7 +636,7 @@ function aggiornaIndicatori(h) {
|
||||
$("dimensioni").textContent = fmt(w, 0) + " x " + fmt(a, 0);
|
||||
// #91: risoluzione nota del frame, per segnare le calibrazioni che non combaciano
|
||||
var nw = parseInt(w, 10), na = parseInt(a, 10);
|
||||
if (!A.ris || A.ris.w !== nw || A.ris.h !== na) { A.ris = { w: nw, h: na }; segnaRisoluzioni(); }
|
||||
if (!A.ris || A.ris.w !== nw || A.ris.h !== na) { A.ris = { w: nw, h: na }; segnaRisoluzioni(); tornaAdatta(); } // #111: risoluzione diversa = Adatta
|
||||
}
|
||||
$("raddrizzato").textContent = "frame grezzo"; // #92: l'indicatore viene sempre dal frame grezzo
|
||||
}
|
||||
@@ -632,7 +681,7 @@ async function ciclo(g) {
|
||||
while (A.bloccando) await pausa(20); // #94: durante «Blocca immagine» nessuna nuova anteprima
|
||||
if (!A.attiva || g !== A.gen) return;
|
||||
var t0 = Date.now();
|
||||
var esito = await unaAnteprima(1280);
|
||||
var esito = await unaAnteprima(latoAnteprima()); // #111: 1280 salvo zoom sull'Acquisizione
|
||||
if (g !== A.gen) return;
|
||||
if (esito === "fatale") { fermaAnteprima(); return; }
|
||||
await pausa(esito === "errore" ? 1500 : Math.max(100 - (Date.now() - t0), 0));
|
||||
@@ -675,7 +724,7 @@ function sbloccaLocale() { // casella spenta, il riquadro della tab Calibrazion
|
||||
if (b && b !== A.url) URL.revokeObjectURL(b);
|
||||
$("bloccaImg").checked = false;
|
||||
$("scattaCal").disabled = $("scatta").disabled;
|
||||
mostraInTabCal($("cornice").hidden ? null : A.url);
|
||||
mostraInTabCal($("immagine").hidden ? null : A.url);
|
||||
}
|
||||
function rilasciaBlocco() { // DELETE idempotente: l'esito non conta
|
||||
var s = A.sorgenteBloccata;
|
||||
@@ -686,7 +735,7 @@ function rilasciaBlocco() { // DELETE idempotente: l'esito non conta
|
||||
$("bloccaImg").onchange = async function () {
|
||||
if (!this.checked) { rilasciaBlocco(); return; }
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!sorgente || $("cornice").hidden) {
|
||||
if (!sorgente || $("immagine").hidden) {
|
||||
$("bloccaImg").checked = false;
|
||||
dire("msgBlocco", "Nessuna immagine da bloccare: «Scatta» o avviare l'anteprima.", "errore");
|
||||
return;
|
||||
@@ -793,6 +842,80 @@ async function caricaParametri() {
|
||||
}
|
||||
}
|
||||
|
||||
// #110: specchiatura per sorgente; gen scarta le risposte di una sorgente ormai lasciata, dati = ultimo stato noto del servizio
|
||||
var O = { gen: 0, dati: { orizzontale: false, verticale: false } };
|
||||
|
||||
function caselleSpecchia(attive) { // #110: abilita o disabilita le due caselle
|
||||
$("specchiaO").disabled = $("specchiaV").disabled = !attive;
|
||||
}
|
||||
|
||||
async function caricaOrientamento() { // #110
|
||||
var sorgente = $("sorgente").value;
|
||||
var g = ++O.gen;
|
||||
dire("msgSpecchia", "");
|
||||
O.dati = { orizzontale: false, verticale: false };
|
||||
$("specchiaO").checked = $("specchiaV").checked = false;
|
||||
caselleSpecchia(false); // spente finche' non si sa lo stato (e senza sorgente)
|
||||
if (!sorgente) return;
|
||||
try {
|
||||
var r = await fetch("/camera/orientamento?source=" + encodeURIComponent(sorgente));
|
||||
if (g !== O.gen) return;
|
||||
if (!r.ok) { dire("msgSpecchia", await dettaglio(r), "errore"); return; }
|
||||
var j = await r.json();
|
||||
if (g !== O.gen) return;
|
||||
O.dati = { orizzontale: !!j.orizzontale, verticale: !!j.verticale };
|
||||
$("specchiaO").checked = O.dati.orizzontale;
|
||||
$("specchiaV").checked = O.dati.verticale;
|
||||
caselleSpecchia(true);
|
||||
} catch (e) {
|
||||
if (g === O.gen) dire("msgSpecchia", "Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
}
|
||||
|
||||
async function salvaOrientamento() { // #110: onchange di una casella -> PUT
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!sorgente) return;
|
||||
var g = ++O.gen;
|
||||
var nuovo = { orizzontale: $("specchiaO").checked, verticale: $("specchiaV").checked };
|
||||
caselleSpecchia(false); // durante la PUT niente altri cambi
|
||||
dire("msgSpecchia", "");
|
||||
var rimetti = function () { // #110: casella com'era prima del clic
|
||||
$("specchiaO").checked = O.dati.orizzontale;
|
||||
$("specchiaV").checked = O.dati.verticale;
|
||||
};
|
||||
try {
|
||||
var r = await fetch("/camera/orientamento", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source: sorgente, orizzontale: nuovo.orizzontale, verticale: nuovo.verticale })
|
||||
});
|
||||
if (g !== O.gen) return; // sorgente cambiata: ci pensa caricaOrientamento
|
||||
if (!r.ok) {
|
||||
var d = await dettaglio(r);
|
||||
if (g !== O.gen) return;
|
||||
rimetti();
|
||||
caselleSpecchia(true);
|
||||
dire("msgSpecchia", d, "errore");
|
||||
return;
|
||||
}
|
||||
var j = await r.json();
|
||||
if (g !== O.gen) return;
|
||||
O.dati = nuovo;
|
||||
caselleSpecchia(true);
|
||||
if (j.avviso) dire("msgSpecchia", j.avviso, "avviso"); // es. calibrazione da rifare
|
||||
else dire("msgSpecchia", "Specchiatura salvata.", "ok");
|
||||
if (!A.attiva) { // anteprima ferma: un frame solo per vedere l'effetto (se e' attiva, il prossimo esce gia' specchiato)
|
||||
while (A.inVolo) await pausa(50);
|
||||
if (g === O.gen && !A.attiva) unaAnteprima(A.lato);
|
||||
}
|
||||
} catch (e) {
|
||||
if (g !== O.gen) return;
|
||||
rimetti();
|
||||
caselleSpecchia(true);
|
||||
dire("msgSpecchia", "Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
}
|
||||
|
||||
function costruisciParametri(j) {
|
||||
var box = $("parametri");
|
||||
box.textContent = "";
|
||||
@@ -1283,7 +1406,7 @@ async function calcola() {
|
||||
var d = await r.json();
|
||||
ultimo = { dati: d };
|
||||
tabella($("tabella"), d);
|
||||
$("overlay").src = "data:image/png;base64," + d.overlay_png;
|
||||
$("overlay").setAttribute("src", "data:image/png;base64," + d.overlay_png); // #122
|
||||
$("avviso").textContent = avvisi(d);
|
||||
$("origineCalcolo").textContent = "Origine: " + (d.bloccata ? "immagine bloccata — " : "") + descriviOrigine(d.origine); // #94
|
||||
$("risultato").hidden = false;
|
||||
@@ -1393,6 +1516,7 @@ $("acquisisci").onclick = calcola;
|
||||
$("salva").onclick = salvaNome;
|
||||
$("nomeCal").onkeydown = function (e) { if (e.key === "Enter") salvaNome(); };
|
||||
$("sorgente").onchange = function () { dire("msgSorgente", ""); cambioSorgente(true); }; // #92
|
||||
$("specchiaO").onchange = $("specchiaV").onchange = salvaOrientamento; // #110
|
||||
scegliTab(memoria("tmf.acq.tab") === "cal" ? "cal" : "acq", false); // #91: tab ricordata
|
||||
caricaSorgenti();
|
||||
</script>
|
||||
|
||||
+110
-3
@@ -45,6 +45,7 @@ Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
|
||||
la libreria resta)
|
||||
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
|
||||
GET /acquisizione -> stessa pagina (#87)
|
||||
GET /visore/{image_viewer.js|save_image.js|vision_overlay.js} -> i JS del <tmf-viewer> del frontend (#122), altri nomi 404
|
||||
Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
|
||||
POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096),
|
||||
timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito).
|
||||
@@ -61,7 +62,17 @@ Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
|
||||
POST /camera/parametri/salva json {source} -> {source, salvati, file}
|
||||
scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
|
||||
della camera nel servizio il file, se c'e', viene ricaricato.
|
||||
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json (default
|
||||
#110: specchiatura per sorgente, in software (cv2.flip) su OGNI frame letto dalla
|
||||
sorgente, prima di raddrizzatura/calibrazione (anteprima, grab, calibrazione da camera,
|
||||
copia bloccata); non tocca l'immagine caricata a mano in /camera/calibrate.
|
||||
GET /camera/orientamento?source=S -> {source, orizzontale, verticale} (default false/false);
|
||||
400 se manca source
|
||||
PUT /camera/orientamento json {source, orizzontale: bool, verticale: bool} -> come GET +
|
||||
"avviso" (stringa o null: la calibrazione attiva ha una mappa fatta con l'orientamento
|
||||
di prima e va rifatta). 400 se source manca o i due campi non sono bool. Salva subito
|
||||
su disco; scarta i frame tenuti (anteprima/blocca).
|
||||
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json, l'orientamento
|
||||
per sorgente in orientamento.json (#110) (default
|
||||
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
|
||||
|
||||
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
|
||||
@@ -87,6 +98,7 @@ camera a chiunque raggiunga la porta: solo su rete isolata.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json # #110: orientamento.json
|
||||
import logging
|
||||
import math # #95: mm/px finito nella calibrazione manuale
|
||||
import os
|
||||
@@ -114,6 +126,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import calibrazione # noqa: E402
|
||||
from calibrazione_pagina import PAGINA # noqa: E402
|
||||
|
||||
# #122: i tre file JS del frontend che la pagina carica su /visore/<nome> (solo questi nomi: nessun percorso arbitrario dall'URL).
|
||||
# Cartelle cercate nell'ordine: pacchetto Nuitka (static/ accanto al modulo, copiata da build.ps1) e sorgenti (src/frontend/flask_app/static/js)
|
||||
_FILE_VISORE = ("image_viewer.js", "save_image.js", "vision_overlay.js")
|
||||
_QUI = Path(__file__).resolve().parent
|
||||
_CARTELLE_VISORE = [_QUI / "static" / "js", _QUI.parent / "src" / "frontend" / "flask_app" / "static" / "js"]
|
||||
|
||||
_TRIGGER = ("continuo",)
|
||||
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
|
||||
_CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere
|
||||
@@ -167,6 +185,36 @@ def create_app(folder: Path | str | None = None,
|
||||
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
||||
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
|
||||
lock_calcoli = threading.Lock()
|
||||
file_orientamento = archivio.cartella / "orientamento.json" # #110: {sorgente: {orizzontale, verticale}}
|
||||
|
||||
def _carica_orientamenti() -> dict:
|
||||
"""#110: orientamenti salvati; file assente o rotto -> {} (mai eccezione)."""
|
||||
if not file_orientamento.is_file():
|
||||
return {}
|
||||
try:
|
||||
dati = json.loads(file_orientamento.read_text(encoding="utf-8"))
|
||||
if not isinstance(dati, dict):
|
||||
raise ValueError("non e' un oggetto JSON")
|
||||
return {n: {"orizzontale": v.get("orizzontale") is True, "verticale": v.get("verticale") is True}
|
||||
for n, v in dati.items() if isinstance(n, str) and isinstance(v, dict)}
|
||||
except (OSError, ValueError) as e:
|
||||
_log.warning("orientamento non caricato (%s): %s", file_orientamento, e)
|
||||
return {}
|
||||
|
||||
orientamenti: dict = _carica_orientamenti() # #110: sorgente -> {"orizzontale": bool, "verticale": bool}; protetto da `lock`
|
||||
|
||||
def _specchia(nome: str, frame):
|
||||
"""#110: applica l'orientamento della sorgente al frame letto; nessuno -> lo stesso oggetto, senza copia."""
|
||||
with lock:
|
||||
o = orientamenti.get(nome) or {}
|
||||
orizz, vert = o.get("orizzontale", False), o.get("verticale", False)
|
||||
if orizz and vert:
|
||||
return cv2.flip(frame, -1) # entrambi = rotazione di 180 gradi
|
||||
if orizz:
|
||||
return cv2.flip(frame, 1)
|
||||
if vert:
|
||||
return cv2.flip(frame, 0)
|
||||
return frame
|
||||
|
||||
def _config() -> dict:
|
||||
if folder is None:
|
||||
@@ -317,7 +365,7 @@ def create_app(folder: Path | str | None = None,
|
||||
|
||||
def _grab(nome: str, timeout: int):
|
||||
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
|
||||
return _con_sorgente(nome, lambda s: s.grab(timeout)) # #87: corpo in _con_sorgente
|
||||
return _con_sorgente(nome, lambda s: _specchia(nome, s.grab(timeout))) # #87: corpo in _con_sorgente; #110: specchiato prima di tutto il resto
|
||||
|
||||
@app.post("/camera/grab")
|
||||
def camera_grab(corpo: dict = Body(default=None)):
|
||||
@@ -544,7 +592,7 @@ def create_app(folder: Path | str | None = None,
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||
def leggi(s): # #92: il frame grezzo si tiene sotto il lock della sorgente
|
||||
f = s.grab(timeout)
|
||||
f = _specchia(nome, s.grab(timeout)) # #110: specchiato PRIMA di tenerlo, cosi' blocca/calibrata lo ricevono gia' specchiato
|
||||
with lock:
|
||||
grezzi[nome] = f.copy()
|
||||
return f
|
||||
@@ -686,11 +734,70 @@ def create_app(folder: Path | str | None = None,
|
||||
|
||||
return _con_sorgente(nome, salva)
|
||||
|
||||
# ---- #110: specchiatura orizzontale/verticale per sorgente
|
||||
|
||||
def _orientamento_di(nome: str) -> dict:
|
||||
with lock:
|
||||
o = orientamenti.get(nome) or {}
|
||||
return {"source": nome, "orizzontale": bool(o.get("orizzontale")), "verticale": bool(o.get("verticale"))}
|
||||
|
||||
@app.get("/camera/orientamento")
|
||||
def orientamento_leggi(source: str = ""):
|
||||
return _orientamento_di(_sorgente(source))
|
||||
|
||||
@app.put("/camera/orientamento")
|
||||
def orientamento_scrivi(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
orizz, vert = corpo.get("orizzontale"), corpo.get("verticale")
|
||||
if not isinstance(orizz, bool) or not isinstance(vert, bool):
|
||||
raise HTTPException(400, "«orizzontale» e «verticale» devono essere true o false")
|
||||
with lock:
|
||||
prima = orientamenti.get(nome) or {}
|
||||
prima_voce = orientamenti.get(nome) # #110: voce di prima (None = non c'era), per il ripristino se il salvataggio fallisce
|
||||
cambiato = (bool(prima.get("orizzontale")), bool(prima.get("verticale"))) != (orizz, vert)
|
||||
if orizz or vert:
|
||||
orientamenti[nome] = {"orizzontale": orizz, "verticale": vert}
|
||||
else:
|
||||
orientamenti.pop(nome, None) # tutti false: la sorgente esce dal file
|
||||
# scrittura atomica di tutto il dizionario (tmp + replace)
|
||||
try:
|
||||
file_orientamento.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = file_orientamento.with_name(file_orientamento.name + ".tmp")
|
||||
tmp.write_text(json.dumps(orientamenti, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, file_orientamento)
|
||||
except OSError as e:
|
||||
_log.warning("orientamento non salvato (%s): %s", file_orientamento, e)
|
||||
if prima_voce is None: # #110: salvataggio fallito -> in memoria resta l'orientamento di prima
|
||||
orientamenti.pop(nome, None)
|
||||
else:
|
||||
orientamenti[nome] = prima_voce
|
||||
raise HTTPException(500, "salvataggio dell'orientamento non riuscito")
|
||||
# i frame tenuti hanno l'orientamento di prima
|
||||
grezzi.pop(nome, None)
|
||||
bloccati.pop(nome, None)
|
||||
avviso = None
|
||||
if cambiato and archivio.leggi_mappa(nome) is not None:
|
||||
avviso = (f"la calibrazione attiva «{archivio.attiva_di(nome)}» corregge la distorsione "
|
||||
"con l'orientamento di prima: va rifatta")
|
||||
return {**_orientamento_di(nome), "avviso": avviso}
|
||||
|
||||
@app.get("/calibrazione", response_class=HTMLResponse)
|
||||
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
|
||||
def calibrazione_pagina():
|
||||
return HTMLResponse(PAGINA)
|
||||
|
||||
@app.get("/visore/{nome}")
|
||||
def visore_file(nome: str):
|
||||
"""#122: i file JS del <tmf-viewer> letti dal frontend Flask; la pagina resta offline."""
|
||||
if nome not in _FILE_VISORE: # lista chiusa: niente percorsi dall'URL
|
||||
raise HTTPException(404, "file non trovato")
|
||||
for cartella in _CARTELLE_VISORE:
|
||||
f = cartella / nome
|
||||
if f.is_file():
|
||||
return Response(f.read_bytes(), media_type="application/javascript", headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(404, "file non trovato")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ def test_pagina_blocco_attende_l_anteprima_in_volo():
|
||||
assert "k < 150" in blocco and "pausa(20)" in blocco
|
||||
assert blocco.index("A.inVolo") < blocco.index('fetch("/camera/blocca"')
|
||||
ciclo = PAGINA.split("async function ciclo")[1].split("function avviaAnteprima")[0]
|
||||
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(1280)")
|
||||
# #111: il lato dell'anteprima segue lo zoom (latoAnteprima(), 1280 senza zoom)
|
||||
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(latoAnteprima())")
|
||||
scatta = PAGINA.split("async function scatta")[1].split("// #94: «Blocca immagine»")[0]
|
||||
assert "while (A.bloccando)" in scatta and scatta.index("A.bloccando") < scatta.index("unaAnteprima(4096)")
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Test della specchiatura orizzontale/verticale per sorgente (#110): scripts/camera_service.py.
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Copre: GET/PUT /camera/orientamento, effetto su /camera/grab e /camera/anteprima,
|
||||
persistenza al riavvio, file rotto, validazione, scarto dei frame tenuti, avviso
|
||||
sulla calibrazione densa. Sorgente: camera:folder su un'immagine 4x3 asimmetrica.
|
||||
"""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_calibrazione import ( # calibrazione densa reale (#84), per l'avviso
|
||||
SORGENTE as SORGENTE_DENSA, cartella_densa, carica_su, client_denso, salva_con_nome,
|
||||
)
|
||||
|
||||
__all__ = ["cartella_densa", "client_denso"] # fixture importate, restano usate
|
||||
|
||||
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||
SORGENTE = "camera:folder"
|
||||
|
||||
|
||||
def _carica():
|
||||
spec = importlib.util.spec_from_file_location("camera_service_orient", MODULO)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella(tmp_path):
|
||||
# #110: 4x3 tutta nera con UN pixel acceso in alto a sinistra (riga 0, colonna 0)
|
||||
img = tmp_path / "foto"
|
||||
img.mkdir()
|
||||
f = np.zeros((3, 4, 3), np.uint8)
|
||||
f[0, 0] = 255
|
||||
cv2.imwrite(str(img / "a.png"), f)
|
||||
return img
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(tmp_path):
|
||||
return tmp_path / "cfg"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(cartella, config):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella, config)) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _acceso(png: bytes):
|
||||
"""(riga, colonna) del pixel acceso nel PNG; controlla che sia uno solo."""
|
||||
f = cv2.imdecode(np.frombuffer(png, np.uint8), cv2.IMREAD_COLOR)
|
||||
assert f.shape[:2] == (3, 4)
|
||||
r, c = np.nonzero(f.max(axis=2) > 128)
|
||||
assert len(r) == 1
|
||||
return int(r[0]), int(c[0])
|
||||
|
||||
|
||||
def _imposta(c, orizz, vert, source=SORGENTE):
|
||||
return c.put("/camera/orientamento", json={"source": source, "orizzontale": orizz, "verticale": vert})
|
||||
|
||||
|
||||
def _grab(c):
|
||||
r = c.post("/camera/grab", json={"source": SORGENTE})
|
||||
assert r.status_code == 200, r.text
|
||||
return _acceso(r.content)
|
||||
|
||||
|
||||
def test_get_senza_file_false_false(client):
|
||||
r = client.get("/camera/orientamento", params={"source": SORGENTE})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"source": SORGENTE, "orizzontale": False, "verticale": False}
|
||||
|
||||
|
||||
def test_get_senza_source_400(client):
|
||||
assert client.get("/camera/orientamento").status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("orizz,vert,atteso", [
|
||||
(False, False, (0, 0)), # originale: alto a sinistra
|
||||
(True, False, (0, 3)), # orizzontale: alto a destra
|
||||
(False, True, (2, 0)), # verticale: basso a sinistra
|
||||
(True, True, (2, 3)), # entrambi: basso a destra (180 gradi)
|
||||
])
|
||||
def test_grab_specchiato(client, orizz, vert, atteso):
|
||||
r = _imposta(client, orizz, vert)
|
||||
assert r.status_code == 200, r.text
|
||||
assert (r.json()["orizzontale"], r.json()["verticale"]) == (orizz, vert)
|
||||
assert client.get("/camera/orientamento", params={"source": SORGENTE}).json()["orizzontale"] is orizz
|
||||
assert _grab(client) == atteso
|
||||
|
||||
|
||||
def test_tutti_false_toglie_la_sorgente_dal_file(client, config):
|
||||
_imposta(client, True, False)
|
||||
assert SORGENTE in (config / "orientamento.json").read_text(encoding="utf-8")
|
||||
_imposta(client, False, False)
|
||||
assert SORGENTE not in (config / "orientamento.json").read_text(encoding="utf-8")
|
||||
assert _grab(client) == (0, 0)
|
||||
|
||||
|
||||
def test_anteprima_specchiata_dimensioni_invariate(client):
|
||||
_imposta(client, True, True)
|
||||
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 4096})
|
||||
assert r.status_code == 200, r.text
|
||||
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("4", "3")
|
||||
f = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
|
||||
assert f.shape == (3, 4)
|
||||
# JPEG con perdite: il pixel acceso e' il piu' chiaro, in basso a destra
|
||||
assert np.unravel_index(int(np.argmax(f)), f.shape) == (2, 3)
|
||||
|
||||
|
||||
def test_persistenza_al_riavvio(cartella, config):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella, config)) as c1:
|
||||
assert _imposta(c1, True, False).status_code == 200
|
||||
with TestClient(_carica().create_app(cartella, config)) as c2:
|
||||
j = c2.get("/camera/orientamento", params={"source": SORGENTE}).json()
|
||||
assert (j["orizzontale"], j["verticale"]) == (True, False)
|
||||
assert _grab(c2) == (0, 3)
|
||||
|
||||
|
||||
def test_file_rotto_non_blocca_l_avvio(cartella, config):
|
||||
config.mkdir(parents=True)
|
||||
(config / "orientamento.json").write_text("{non e json", encoding="utf-8")
|
||||
with TestClient(_carica().create_app(cartella, config)) as c:
|
||||
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
|
||||
assert (j["orizzontale"], j["verticale"]) == (False, False)
|
||||
assert _imposta(c, False, True).status_code == 200 # e si puo' riscrivere
|
||||
|
||||
|
||||
def test_put_campi_non_bool_400(client):
|
||||
for corpo in ({"source": SORGENTE, "orizzontale": 1, "verticale": False},
|
||||
{"source": SORGENTE, "orizzontale": True, "verticale": "si"},
|
||||
{"source": SORGENTE, "orizzontale": True},
|
||||
{"orizzontale": True, "verticale": True}):
|
||||
assert client.put("/camera/orientamento", json=corpo).status_code == 400, corpo
|
||||
|
||||
|
||||
def test_put_scarta_i_frame_tenuti(client):
|
||||
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 200
|
||||
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 200
|
||||
_imposta(client, True, False)
|
||||
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 404
|
||||
# col frame tenuto scartato anche l'ultimo grezzo dell'anteprima e' sparito
|
||||
assert client.post("/camera/anteprima/calibrata", json={"source": SORGENTE}).status_code == 404
|
||||
|
||||
|
||||
def test_avviso_senza_mappa_e_null(client):
|
||||
j = _imposta(client, True, False).json()
|
||||
assert j["avviso"] is None
|
||||
|
||||
|
||||
def test_avviso_con_mappa_attiva(client_denso):
|
||||
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE_DENSA, "square_mm": "2"}).json()
|
||||
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
|
||||
assert carica_su(client_denso, SORGENTE_DENSA, "Densa").status_code == 200
|
||||
r = _imposta(client_denso, True, False, source=SORGENTE_DENSA)
|
||||
assert r.status_code == 200, r.text
|
||||
assert "«Densa»" in r.json()["avviso"] and "va rifatta" in r.json()["avviso"]
|
||||
# stesso valore di nuovo: nulla e' cambiato, nessun avviso
|
||||
assert _imposta(client_denso, True, False, source=SORGENTE_DENSA).json()["avviso"] is None
|
||||
|
||||
|
||||
def test_scrittura_fallita_500_e_orientamento_di_prima(cartella, config, monkeypatch):
|
||||
# #110: se orientamento.json non si scrive, in memoria resta l'orientamento di prima
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella, config)) as c:
|
||||
assert _imposta(c, True, False).status_code == 200 # stato di partenza: orizzontale
|
||||
def rotto(*a, **k):
|
||||
raise OSError("disco pieno")
|
||||
monkeypatch.setattr(mod.os, "replace", rotto)
|
||||
assert _imposta(c, False, True).status_code == 500
|
||||
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
|
||||
assert (j["orizzontale"], j["verticale"]) == (True, False)
|
||||
assert _grab(c) == (0, 3) # ancora solo orizzontale
|
||||
# sorgente che prima non c'era: la voce non resta
|
||||
assert c.put("/camera/orientamento", json={"source": "altra", "orizzontale": True, "verticale": True}).status_code == 500
|
||||
j = c.get("/camera/orientamento", params={"source": "altra"}).json()
|
||||
assert (j["orizzontale"], j["verticale"]) == (False, False)
|
||||
@@ -1,54 +1,87 @@
|
||||
"""Test della pagina Acquisizione camera (#87): contenuto statico di PAGINA, senza servizio."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
|
||||
_spec = importlib.util.spec_from_file_location("calibrazione_pagina", _FILE)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
PAGINA = _mod.PAGINA
|
||||
|
||||
|
||||
def test_titolo_e_stile_scuro():
|
||||
assert "<title>Acquisizione camera</title>" in PAGINA
|
||||
assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA
|
||||
|
||||
|
||||
def test_rotte_usate():
|
||||
for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri",
|
||||
"/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"):
|
||||
assert '"' + rotta in PAGINA, rotta
|
||||
|
||||
|
||||
def test_offline_e_senza_finestre_del_browser():
|
||||
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
|
||||
assert vietato not in PAGINA, vietato
|
||||
|
||||
|
||||
def test_id_principali():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
|
||||
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
|
||||
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
|
||||
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
|
||||
assert attesi <= ids, attesi - ids
|
||||
# ogni id usato dal JS con $("...") esiste nel markup
|
||||
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||
assert usati <= ids, usati - ids
|
||||
|
||||
|
||||
def test_sezioni_e_testi_italiani():
|
||||
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
|
||||
"Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
|
||||
"Attiva calibrazione"): # #92 #93
|
||||
assert testo in PAGINA, testo
|
||||
assert "it-IT" in PAGINA # numeri con la virgola
|
||||
assert "Choose File" not in PAGINA
|
||||
|
||||
|
||||
def test_localstorage_protetto():
|
||||
# ogni uso di localStorage sta dentro un try
|
||||
for m in re.finditer(r"localStorage[.]", PAGINA):
|
||||
assert "try {" in PAGINA[max(0, m.start() - 200):m.start()]
|
||||
"""Test della pagina Acquisizione camera (#87): contenuto statico di PAGINA, senza servizio."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
|
||||
_spec = importlib.util.spec_from_file_location("calibrazione_pagina", _FILE)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
PAGINA = _mod.PAGINA
|
||||
|
||||
|
||||
def test_titolo_e_stile_scuro():
|
||||
assert "<title>Acquisizione camera</title>" in PAGINA
|
||||
assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA
|
||||
|
||||
|
||||
def test_rotte_usate():
|
||||
for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri",
|
||||
"/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"):
|
||||
assert '"' + rotta in PAGINA, rotta
|
||||
|
||||
|
||||
def test_offline_e_senza_finestre_del_browser():
|
||||
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
|
||||
assert vietato not in PAGINA, vietato
|
||||
|
||||
|
||||
def test_id_principali():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
|
||||
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
|
||||
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
|
||||
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
|
||||
assert attesi <= ids, attesi - ids
|
||||
# ogni id usato dal JS con $("...") esiste nel markup
|
||||
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||
assert usati <= ids, usati - ids
|
||||
|
||||
|
||||
def test_sezioni_e_testi_italiani():
|
||||
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
|
||||
"Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
|
||||
"Attiva calibrazione"): # #92 #93
|
||||
assert testo in PAGINA, testo
|
||||
assert "it-IT" in PAGINA # numeri con la virgola
|
||||
assert "Choose File" not in PAGINA
|
||||
|
||||
|
||||
def test_localstorage_protetto():
|
||||
# ogni uso di localStorage sta dentro un try
|
||||
for m in re.finditer(r"localStorage[.]", PAGINA):
|
||||
assert "try {" in PAGINA[max(0, m.start() - 200):m.start()]
|
||||
|
||||
|
||||
def test_specchiatura_per_sorgente(): # #110
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
assert {"specchiaO", "specchiaV", "msgSpecchia"} <= ids
|
||||
assert '"/camera/orientamento' in PAGINA
|
||||
assert "Specchia orizzontale" in PAGINA and "Specchia verticale" in PAGINA
|
||||
|
||||
|
||||
def test_zoom_visori(): # #111, #122: i visori sono <tmf-viewer>, la barra e lo zoom vengono dal componente
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
for i in ("immagine", "immagineCal", "immagineCalTab", "overlay"): # grezza, calibrata, tab Calibrazione, angoli
|
||||
assert re.search(r'<tmf-viewer id="%s"' % i, PAGINA), i
|
||||
assert PAGINA.count("<tmf-viewer") == 4
|
||||
for nome in ("grezza", "calibrata", "calibrazione", "angoli"):
|
||||
assert 'save-name="%s"' % nome in PAGINA, nome
|
||||
assert "zoomAdatta" not in PAGINA and "barraZoom" not in PAGINA and "creaVisore(" in PAGINA
|
||||
assert not {"zoomMeno", "zoomPiu", "zoomPct"} & ids
|
||||
assert "latoAnteprima()" in PAGINA and "unaAnteprima(1280)" not in PAGINA # lato_max segue lo zoom
|
||||
assert '"viewer-zoom"' in PAGINA # lo zoom si legge dall'evento del componente
|
||||
|
||||
|
||||
def test_script_del_visore_in_ordine(): # #122: i JS arrivano da /visore/ (offline), prima dello script di pagina
|
||||
src = re.findall(r'<script src="([^"]+)"', PAGINA)
|
||||
assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
|
||||
assert PAGINA.index("/visore/image_viewer.js") < PAGINA.index("<script>")
|
||||
|
||||
|
||||
def test_nessuna_funzione_dichiarata_due_volte(): # #111: in JS vince l'ultima dichiarazione, in silenzio
|
||||
script = re.search(r"<script>(.*?)</script>", PAGINA, re.S).group(1)
|
||||
nomi = re.findall(r"function\s+([A-Za-z0-9_$]+)\s*\(", script)
|
||||
doppi = sorted({n for n in nomi if nomi.count(n) > 1})
|
||||
assert not doppi, "funzioni dichiarate piu' volte: " + ", ".join(doppi)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Test delle rotte /visore/*.js di camera_service e della pagina che le usa (#122).
|
||||
|
||||
Uso: python -m pytest scripts/tests/test_visore_rotte.py -q
|
||||
Dipende da: venv con fastapi, httpx, opencv (come test_camera_service.py).
|
||||
image_viewer.js lo scrive un altro piano (V1): se manca nei sorgenti il test della rotta usa una cartella temporanea con file finti.
|
||||
"""
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||
NOMI = ("image_viewer.js", "save_image.js", "vision_overlay.js")
|
||||
|
||||
|
||||
def _carica():
|
||||
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(tmp_path)) as c:
|
||||
c.mod = mod
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella_finta(client, tmp_path):
|
||||
# file finti in una cartella temporanea: la rotta si prova senza dipendere da image_viewer.js del frontend
|
||||
d = tmp_path / "js"
|
||||
d.mkdir()
|
||||
for n in NOMI:
|
||||
(d / n).write_text("/* finto " + n + " */\n", encoding="utf-8")
|
||||
client.mod._CARTELLE_VISORE[:] = [d]
|
||||
return d
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nome", NOMI)
|
||||
def test_rotta_serve_il_file(client, cartella_finta, nome):
|
||||
r = client.get("/visore/" + nome)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("application/javascript")
|
||||
assert r.content == (cartella_finta / nome).read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nome", ["altro.js", "camera_service.py", "..%2Fcamera_service.py", "image_viewer.JS", "image_viewer.js.map"])
|
||||
def test_altri_nomi_danno_404(client, cartella_finta, nome):
|
||||
assert client.get("/visore/" + nome).status_code == 404
|
||||
|
||||
|
||||
def test_file_mancante_nella_cartella_da_404(client, tmp_path):
|
||||
client.mod._CARTELLE_VISORE[:] = [tmp_path / "vuota"]
|
||||
assert client.get("/visore/save_image.js").status_code == 404
|
||||
|
||||
|
||||
def test_cartelle_di_default_sono_static_js_del_frontend(client):
|
||||
# sviluppo: sorgenti del frontend; pacchetto Nuitka: static/ accanto al modulo (build.ps1 la include)
|
||||
cartelle = _carica()._CARTELLE_VISORE
|
||||
assert any(c.parts[-5:] == ("src", "frontend", "flask_app", "static", "js") for c in cartelle)
|
||||
assert any(c.parts[-2:] == ("static", "js") and c.parent.parent == MODULO.parent for c in cartelle)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nome", ("save_image.js", "vision_overlay.js", "image_viewer.js"))
|
||||
def test_file_reali_del_frontend(client, nome):
|
||||
reale = next((c / nome for c in client.mod._CARTELLE_VISORE if (c / nome).is_file()), None)
|
||||
if reale is None:
|
||||
pytest.skip(nome + " non e' ancora nei sorgenti del frontend (lo scrive il piano V1)")
|
||||
r = client.get("/visore/" + nome)
|
||||
assert r.status_code == 200 and r.content == reale.read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rotta", ("/acquisizione", "/calibrazione"))
|
||||
def test_pagina_usa_quattro_visori_e_i_tre_script(client, rotta):
|
||||
html = client.get(rotta).text
|
||||
assert html.count("<tmf-viewer") == 4
|
||||
src = re.findall(r'<script src="(/visore/[^"]+)"', html)
|
||||
assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
|
||||
assert "zoomAdatta" not in html
|
||||
@@ -357,9 +357,13 @@ async def export_measurements_csv(
|
||||
# Rows
|
||||
for m in measurements:
|
||||
# Format decimals with configured separator
|
||||
value_str = str(m.value).replace(".", decimal_separator)
|
||||
# #118: quota non misurata dalla visione -> NaN
|
||||
value_str = "NaN" if m.value is None else str(m.value).replace(".", decimal_separator)
|
||||
# #124: quota NaN (value None) -> deviazione NaN, non vuota
|
||||
deviation_str = (
|
||||
str(m.deviation).replace(".", decimal_separator)
|
||||
"NaN"
|
||||
if m.value is None
|
||||
else str(m.deviation).replace(".", decimal_separator)
|
||||
if m.deviation is not None
|
||||
else ""
|
||||
)
|
||||
|
||||
@@ -146,7 +146,8 @@ async def get_capability(
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
# #118: le quote NaN (value None) non entrano nei calcoli
|
||||
values = [float(m.value) for m in measurements if m.value is not None]
|
||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||
return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"])
|
||||
|
||||
@@ -171,6 +172,8 @@ async def get_control_chart(
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
# #118: le quote NaN (value None) si saltano, valori e istanti restano allineati
|
||||
measurements = [m for m in measurements if m.value is not None]
|
||||
values = [float(m.value) for m in measurements]
|
||||
timestamps = [m.measured_at for m in measurements]
|
||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||
@@ -201,7 +204,8 @@ async def get_histogram(
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
# #118: le quote NaN (value None) non entrano nell'istogramma
|
||||
values = [float(m.value) for m in measurements if m.value is not None]
|
||||
return compute_histogram(values, n_bins)
|
||||
|
||||
|
||||
|
||||
@@ -250,6 +250,8 @@ async def create_task(
|
||||
file_type=data.file_type,
|
||||
annotations_json=data.annotations_json,
|
||||
vision_json=data.vision_json,
|
||||
# #97: camera e modo di analisi (dict gia' validato dal modello)
|
||||
acquisition_json=data.acquisition_json.model_dump() if data.acquisition_json else None,
|
||||
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
|
||||
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
|
||||
at_close=data.at_close,
|
||||
|
||||
@@ -8,6 +8,7 @@ import base64
|
||||
import binascii
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
from typing import Literal
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -23,7 +24,7 @@ from src.backend.api.routers.files import (
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.models.api.vision import (
|
||||
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
||||
VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
||||
VisionRecipeUploadResponse, VisionReferenceImageResponse,
|
||||
)
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
@@ -40,14 +41,22 @@ _IMAGE_EXTENSIONS = {
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/bmp": "bmp", # #99
|
||||
"image/x-ms-bmp": "bmp", # #99
|
||||
}
|
||||
|
||||
# #99: the Opto bench cameras save BMP and the worker decodes it (PIL), so the
|
||||
# vision routes accept it on top of files.py's list. files.py is untouched: its
|
||||
# attachments are served by extension and BMP is not meant to be shown there.
|
||||
_VISION_IMAGE_TYPES = ALLOWED_IMAGE_TYPES | {"image/bmp", "image/x-ms-bmp"}
|
||||
|
||||
# Sniffed from the first bytes, independent of the declared Content-Type: a
|
||||
# text file uploaded as "image/png" passes the header check above and must
|
||||
# still be caught before it reaches the worker as a bare 500 (#15). BMP and
|
||||
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
|
||||
# because PIL - and so the worker - can decode both; this check only refuses
|
||||
# what is not a real image at all, it does not narrow the allow-list above.
|
||||
# still be caught before it reaches the worker as a bare 500 (#15). TIFF is
|
||||
# included even though the allow-list does not accept it (PIL, and so the
|
||||
# worker, can decode it; browsers do not show it in the result); BMP is now
|
||||
# also allowed (#99). This check only refuses what is not a real image at all,
|
||||
# it does not narrow the allow-list above.
|
||||
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
|
||||
(b"\x89PNG\r\n\x1a\n",), # PNG
|
||||
(b"\xff\xd8\xff",), # JPEG
|
||||
@@ -83,7 +92,7 @@ async def _read_image(image: UploadFile) -> bytes:
|
||||
is in hand - the same two-step convention `files.py::upload_file` already
|
||||
uses, reused rather than reinvented here.
|
||||
"""
|
||||
if image.content_type not in ALLOWED_IMAGE_TYPES:
|
||||
if image.content_type not in _VISION_IMAGE_TYPES: # #99
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
||||
@@ -153,7 +162,10 @@ async def execute(
|
||||
engine_version=result.engine_version,
|
||||
measurements=[
|
||||
VisionMeasurementResult(
|
||||
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
|
||||
subtask_id=m.subtask_id,
|
||||
# #118: None = quota non prodotta, il frontend la mostra NaN
|
||||
value=float(m.value) if m.value is not None else None,
|
||||
pass_fail=m.pass_fail,
|
||||
)
|
||||
for m in saved
|
||||
],
|
||||
@@ -164,6 +176,22 @@ async def execute(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/check", response_model=VisionCheckResponse)
|
||||
async def check(
|
||||
task_id: int = Form(...),
|
||||
image: UploadFile = File(...),
|
||||
production_run_id: int | None = Form(None, gt=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_measurement_tec),
|
||||
) -> VisionCheckResponse:
|
||||
"""#97: analisi di un frame senza salvare nulla (ne' risultato ne' misure)."""
|
||||
task = await _task_or_404(db, task_id)
|
||||
report = await vision_service.check_task(
|
||||
db, task, await _read_image(image), production_run_id=production_run_id,
|
||||
)
|
||||
return VisionCheckResponse(**report)
|
||||
|
||||
|
||||
@router.get("/results/{result_id}/image")
|
||||
async def get_result_image(
|
||||
result_id: int,
|
||||
@@ -342,6 +370,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,
|
||||
@@ -409,8 +451,50 @@ async def create_dxf_compare_recipe(
|
||||
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
|
||||
if errors:
|
||||
raise _422(errors)
|
||||
# #105: l'immagine di riferimento resta nella cartella (non e' in meta, quindi
|
||||
# /validate e l'esecuzione la ignorano) e il wizard puo' riaprirla; entra nel digest.
|
||||
placed[f"riferimento.{_IMAGE_EXTENSIONS.get(image.content_type, 'png')}"] = image_bytes
|
||||
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
|
||||
await vision_service.ensure_points_quote(db, task, min_points_pct)
|
||||
await db.commit()
|
||||
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/recipe/dxf-compare/source")
|
||||
async def get_dxf_compare_source(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> dict:
|
||||
"""#105: i parametri della ricetta fatta con «Crea ricetta da DXF», per modificarla.
|
||||
|
||||
404 se il task non ha una ricetta del wizard (nessuna, o caricata a mano).
|
||||
"""
|
||||
task = await _task_or_404(db, task_id)
|
||||
source = vision_service.dxf_compare_source(task.vision_json, task.vision_recipe_dir)
|
||||
if source is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="nessuna ricetta da DXF su questo task",
|
||||
)
|
||||
return source
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/recipe/files/{path:path}")
|
||||
async def get_recipe_file(
|
||||
task_id: int,
|
||||
path: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> FileResponse:
|
||||
"""#105: un file della cartella ricetta del task (DXF, immagine di riferimento).
|
||||
|
||||
Percorso non sicuro, file assente o task senza ricetta: sempre 404.
|
||||
"""
|
||||
task = await _task_or_404(db, task_id)
|
||||
target = vision_service.recipe_file_path(task.vision_recipe_dir, path)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||
media_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||
return FileResponse(target, media_type=media_type)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""recipe_tasks.acquisition_json (#97)
|
||||
|
||||
recipe_tasks.acquisition_json: camera e modo di analisi scelti nel task,
|
||||
{"camera_url", "camera_source", "modo": "impulsiva" | "in_linea"}; NULL = la
|
||||
camera della stazione dell'operatore, analisi impulsiva (come prima).
|
||||
|
||||
Revision ID: 021_task_acquisition
|
||||
Revises: 020_station_camera
|
||||
Create Date: 2026-10-24
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '021_task_acquisition'
|
||||
down_revision: Union[str, None] = '020_station_camera'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.add_column(sa.Column('acquisition_json', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.drop_column('acquisition_json')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""measurements.value nullable (#118)
|
||||
|
||||
measurements.value: NULL = quota che il grafo di visione non ha prodotto
|
||||
(l'operatore la vede come NaN, esito sempre `fail`, deviazione NULL). Prima
|
||||
execute rifiutava con 422 l'intera acquisizione. measurements.deviation era
|
||||
gia' nullable.
|
||||
|
||||
Downgrade: torna NOT NULL; FALLISCE se esistono righe con value NULL (vanno
|
||||
cancellate o valorizzate prima).
|
||||
|
||||
Revision ID: 022_measurement_value_nullable
|
||||
Revises: 021_task_acquisition
|
||||
Create Date: 2026-10-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '022_measurement_value_nullable'
|
||||
down_revision: Union[str, None] = '021_task_acquisition'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||
with op.batch_alter_table('measurements') as batch:
|
||||
batch.alter_column(
|
||||
'value', existing_type=sa.DECIMAL(12, 6), nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('measurements') as batch:
|
||||
batch.alter_column(
|
||||
'value', existing_type=sa.DECIMAL(12, 6), nullable=False,
|
||||
)
|
||||
@@ -43,7 +43,7 @@ class MeasurementResponse(BaseModel):
|
||||
subtask_id: int
|
||||
version_id: int
|
||||
measured_by: int
|
||||
value: float
|
||||
value: Optional[float] = None # #118: null = quota non prodotta dalla visione (NaN)
|
||||
pass_fail: str
|
||||
deviation: Optional[float] = None
|
||||
lot_number: Optional[str] = None
|
||||
@@ -108,7 +108,7 @@ class CycleMeasurementResponse(BaseModel):
|
||||
"""One quote already measured in the cycle under way."""
|
||||
|
||||
subtask_id: int
|
||||
value: float
|
||||
value: Optional[float] = None # #118: null = NaN
|
||||
pass_fail: str
|
||||
measured_at: UtcDateTime
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class MonitorSubtask(BaseModel):
|
||||
description: str
|
||||
unit: str
|
||||
count: int
|
||||
mean: float
|
||||
mean: Optional[float] = None # #118: None = solo quote NaN
|
||||
ltl: Optional[float] = None
|
||||
utl: Optional[float] = None
|
||||
# True when ltl <= mean <= utl (a missing limit does not constrain).
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import re
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from src.backend.models.api.station import _normalize_http_url
|
||||
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
|
||||
|
||||
# Kept in step with the ORM enum so the two cannot drift apart.
|
||||
@@ -159,6 +160,30 @@ class SubtaskResponse(BaseModel):
|
||||
vision_output: Optional[str] = None
|
||||
|
||||
|
||||
class AcquisitionConfig(BaseModel):
|
||||
"""#97: camera (servizio + sorgente) e modo di analisi scelti nel task."""
|
||||
camera_url: str = Field(..., max_length=500)
|
||||
camera_source: str = Field(..., min_length=1, max_length=200)
|
||||
modo: Literal["impulsiva", "in_linea"] = "impulsiva"
|
||||
|
||||
@field_validator("camera_url")
|
||||
@classmethod
|
||||
def _check_camera_url(cls, value: str) -> str:
|
||||
# stessa normalizzazione delle stazioni; qui il vuoto non e' ammesso
|
||||
normalized = _normalize_http_url(value, "camera_url")
|
||||
if normalized is None:
|
||||
raise ValueError("camera_url must not be empty")
|
||||
return normalized
|
||||
|
||||
@field_validator("camera_source")
|
||||
@classmethod
|
||||
def _check_camera_source(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("camera_source must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
"""Schema for creating a task."""
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
@@ -169,6 +194,7 @@ class TaskCreate(BaseModel):
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||
# #53: assente = default dal tipo (misure e check nel ciclo)
|
||||
in_loop: Optional[bool] = None
|
||||
at_close: bool = False
|
||||
@@ -186,6 +212,7 @@ class TaskUpdate(BaseModel):
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||
in_loop: Optional[bool] = None
|
||||
at_close: Optional[bool] = None
|
||||
fields: Optional[list[dict[str, Any]]] = None
|
||||
@@ -207,6 +234,7 @@ class TaskResponse(BaseModel):
|
||||
file_type: Optional[str] = None
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||
in_loop: bool = False
|
||||
at_close: bool = False
|
||||
fields: Optional[list[dict[str, Any]]] = None
|
||||
|
||||
@@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict
|
||||
class VisionMeasurementResult(BaseModel):
|
||||
"""One quote's outcome, as much of it as the client needs to show."""
|
||||
subtask_id: int
|
||||
value: float
|
||||
value: float | None # #118: null = quota non prodotta dal grafo (NaN), pass_fail "fail"
|
||||
pass_fail: str
|
||||
|
||||
|
||||
@@ -24,6 +24,16 @@ class VisionExecuteResponse(BaseModel):
|
||||
failures: list[dict] = []
|
||||
|
||||
|
||||
class VisionCheckResponse(BaseModel):
|
||||
"""What `POST /api/vision/check` returns (#97): the analysis, nothing saved."""
|
||||
outcome: str | None = None
|
||||
overlay: list[dict] = []
|
||||
failures: list[dict] = []
|
||||
measurements: list[VisionMeasurementResult] = []
|
||||
# esito della lettura della calibrazione: ok | no_camera | not_calibrated | unreachable
|
||||
calibration: str
|
||||
|
||||
|
||||
class VisionRecipeOutput(BaseModel):
|
||||
"""One numeric output a recipe offers to the quotes."""
|
||||
key: str
|
||||
|
||||
@@ -26,7 +26,8 @@ class Measurement(Base):
|
||||
)
|
||||
|
||||
# Measurement data
|
||||
value: Mapped[float] = mapped_column(DECIMAL(12, 6), nullable=False)
|
||||
# #118: NULL = quota che il grafo di visione non ha prodotto (mostrata NaN, sempre fail)
|
||||
value: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True)
|
||||
pass_fail: Mapped[str] = mapped_column(
|
||||
Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False
|
||||
)
|
||||
|
||||
@@ -65,6 +65,9 @@ class RecipeTask(Base):
|
||||
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
||||
# meta della ricetta VisionSuite v2 (#46).
|
||||
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
# #97: camera e modo di analisi del task, {camera_url, camera_source, modo};
|
||||
# NULL = camera della stazione corrente, analisi impulsiva.
|
||||
acquisition_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
|
||||
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
|
||||
in_loop: Mapped[bool] = mapped_column(
|
||||
|
||||
@@ -302,7 +302,7 @@ async def current_cycle_measurements(
|
||||
return [
|
||||
{
|
||||
"subtask_id": m.subtask_id,
|
||||
"value": float(m.value),
|
||||
"value": float(m.value) if m.value is not None else None, # #118: NaN
|
||||
"pass_fail": m.pass_fail,
|
||||
"measured_at": m.measured_at,
|
||||
}
|
||||
@@ -355,7 +355,7 @@ async def save_measurement(
|
||||
subtask_id: int,
|
||||
version_id: int,
|
||||
measured_by: int,
|
||||
value: float,
|
||||
value: float | None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
input_method: str = "manual",
|
||||
@@ -372,8 +372,12 @@ async def save_measurement(
|
||||
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
|
||||
the gate models "moving on" between measurements taken one after another,
|
||||
which does not exist inside a single camera acquisition.
|
||||
|
||||
#118: ``value=None`` is a quote the vision graph did not produce (shown as
|
||||
NaN): saved as a ``fail`` with no deviation. Only the vision path passes it.
|
||||
"""
|
||||
_validate_measured_value(value)
|
||||
if value is not None: # #118: una quota non misurata non ha un valore da validare
|
||||
_validate_measured_value(value)
|
||||
|
||||
# Get subtask for tolerance values
|
||||
result = await db.execute(
|
||||
@@ -421,8 +425,12 @@ async def save_measurement(
|
||||
# Rounded once, here, so the stored value is exactly the one the verdict
|
||||
# above was computed on - calculate_pass_fail rounds internally too, but
|
||||
# matching it here keeps the persisted row from disagreeing with itself.
|
||||
value = round(value, 6)
|
||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||
if value is None:
|
||||
# #118: quota non prodotta dal grafo -> NaN: fail, nessuna deviazione
|
||||
pass_fail, deviation = "fail", None
|
||||
else:
|
||||
value = round(value, 6)
|
||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||
|
||||
measurement = Measurement(
|
||||
subtask_id=subtask_id,
|
||||
|
||||
@@ -96,14 +96,21 @@ async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
|
||||
per_sub[row.subtask_id].append(row)
|
||||
subtasks = []
|
||||
for sid, group in sorted(per_sub.items()):
|
||||
mean = sum(float(g.value) for g in group) / len(group)
|
||||
# #118: le quote NaN (value None) non entrano nella media; con soli
|
||||
# NaN la quota resta in elenco con mean None ed e' fuori tolleranza
|
||||
numeric = [float(g.value) for g in group if g.value is not None]
|
||||
mean = sum(numeric) / len(numeric) if numeric else None
|
||||
first = group[0]
|
||||
subtasks.append(MonitorSubtask(
|
||||
subtask_id=sid, description=first.description, unit=first.unit,
|
||||
count=len(group), mean=round(mean, 6),
|
||||
count=len(group),
|
||||
mean=round(mean, 6) if mean is not None else None, # #118
|
||||
ltl=float(first.ltl) if first.ltl is not None else None,
|
||||
utl=float(first.utl) if first.utl is not None else None,
|
||||
in_tolerance=_in_tolerance(mean, first.ltl, first.utl),
|
||||
in_tolerance=(
|
||||
_in_tolerance(mean, first.ltl, first.utl)
|
||||
if mean is not None else False # #118: tutte NaN = fail
|
||||
),
|
||||
))
|
||||
timer = production_service.describe(run)
|
||||
ev = last_event.get(run.id)
|
||||
|
||||
@@ -159,8 +159,8 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
||||
subtask.marker_number if subtask else "",
|
||||
neutralise_csv_cell(subtask.description) if subtask else "",
|
||||
number(subtask.nominal) if subtask else "",
|
||||
number(measurement.value),
|
||||
number(measurement.deviation),
|
||||
number(measurement.value) if measurement.value is not None else "NaN", # #118
|
||||
"NaN" if measurement.value is None else number(measurement.deviation), # #124: quota NaN -> deviazione NaN
|
||||
measurement.pass_fail,
|
||||
number(subtask.ltl) if subtask else "",
|
||||
number(subtask.lwl) if subtask else "",
|
||||
|
||||
@@ -117,6 +117,8 @@ async def _copy_tasks_to_version(
|
||||
vision_json=task.vision_json,
|
||||
# Cartella immutabile: basta il riferimento (#46).
|
||||
vision_recipe_dir=task.vision_recipe_dir,
|
||||
# #97: camera e modo di analisi seguono il task nella nuova versione.
|
||||
acquisition_json=task.acquisition_json,
|
||||
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
|
||||
in_loop=task.in_loop,
|
||||
at_close=task.at_close,
|
||||
|
||||
@@ -318,8 +318,10 @@ async def generate_spc_report(
|
||||
|
||||
# 2. Compute SPC data
|
||||
pass_fail_values = [m.pass_fail for m in measurements]
|
||||
values = [float(m.value) for m in measurements]
|
||||
timestamps = [m.measured_at for m in measurements]
|
||||
# #118: le quote NaN (value None) contano nell'esito ma non nei calcoli
|
||||
numeric = [m for m in measurements if m.value is not None]
|
||||
values = [float(m.value) for m in numeric]
|
||||
timestamps = [m.measured_at for m in numeric]
|
||||
|
||||
summary = compute_summary(pass_fail_values)
|
||||
|
||||
@@ -426,7 +428,7 @@ async def generate_measurement_report(
|
||||
rows.append({
|
||||
"num": i,
|
||||
"subtask": f"#{st_info.get('marker_number', '?')} — {st_info.get('description', '?')}",
|
||||
"value": f"{float(m.value):.4f}",
|
||||
"value": f"{float(m.value):.4f}" if m.value is not None else "NaN", # #118
|
||||
"unit": st_info.get("unit", "mm"),
|
||||
"pass_fail": m.pass_fail,
|
||||
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
|
||||
|
||||
@@ -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
|
||||
@@ -231,35 +250,36 @@ CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore
|
||||
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
|
||||
|
||||
|
||||
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
|
||||
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
|
||||
async def fetch_calibration(
|
||||
camera_url: str | None, camera_source: str | None, label: str = "",
|
||||
) -> tuple[dict | None, str]:
|
||||
"""Legge la calibrazione da un acquisitore (C1, #83; generalizzata in #97).
|
||||
|
||||
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
|
||||
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
|
||||
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
|
||||
Un px_per_mm non finito o <= 0 conta come non calibrata.
|
||||
Un px_per_mm non finito o <= 0 conta come non calibrata. `label` serve
|
||||
solo ai log (stazione o task).
|
||||
"""
|
||||
if station is None or not station.camera_url or not station.camera_source:
|
||||
if not camera_url or not camera_source:
|
||||
return None, CAL_NO_CAMERA
|
||||
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
|
||||
url = f"{camera_url.rstrip('/')}/camera/calibration"
|
||||
try:
|
||||
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(url, params={"source": station.camera_source})
|
||||
response = await client.get(url, params={"source": camera_source})
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
|
||||
logger.warning("acquisitore %s non raggiungibile: %s", label, exc)
|
||||
return None, CAL_UNREACHABLE
|
||||
if response.status_code == status.HTTP_404_NOT_FOUND:
|
||||
return None, CAL_NOT_CALIBRATED
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
|
||||
)
|
||||
logger.warning("acquisitore %s: HTTP %s", label, response.status_code)
|
||||
return None, CAL_UNREACHABLE
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
|
||||
logger.warning("acquisitore %s: risposta non JSON", label)
|
||||
return None, CAL_UNREACHABLE
|
||||
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
|
||||
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
|
||||
@@ -267,6 +287,33 @@ async def fetch_station_calibration(station: Station | None) -> tuple[dict | Non
|
||||
return body, CAL_OK
|
||||
|
||||
|
||||
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
|
||||
"""Calibrazione dall'acquisitore della stazione (C1, #83): vedi fetch_calibration."""
|
||||
if station is None:
|
||||
return None, CAL_NO_CAMERA
|
||||
return await fetch_calibration(
|
||||
station.camera_url, station.camera_source, f"della stazione {station.code}",
|
||||
)
|
||||
|
||||
|
||||
def task_camera(task: RecipeTask, station: Station | None) -> tuple[str | None, str | None]:
|
||||
"""Camera di un task (#97): acquisition_json, altrimenti quella della stazione."""
|
||||
acquisition = task.acquisition_json
|
||||
if isinstance(acquisition, dict) and acquisition.get("camera_url") and acquisition.get("camera_source"):
|
||||
return acquisition["camera_url"], acquisition["camera_source"]
|
||||
if station is not None:
|
||||
return station.camera_url, station.camera_source
|
||||
return None, None
|
||||
|
||||
|
||||
async def fetch_task_calibration(
|
||||
task: RecipeTask, station: Station | None,
|
||||
) -> tuple[dict | None, str]:
|
||||
"""Calibrazione della camera del task (#97), stessi esiti CAL_* di fetch_calibration."""
|
||||
camera_url, camera_source = task_camera(task, station)
|
||||
return await fetch_calibration(camera_url, camera_source, f"del task {task.id}")
|
||||
|
||||
|
||||
async def get_station_calibration(station: Station | None) -> dict | None:
|
||||
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
|
||||
calibration, _ = await fetch_station_calibration(station)
|
||||
@@ -438,6 +485,84 @@ def list_recipe_files(recipe_dir: str | None) -> list[str]:
|
||||
)
|
||||
|
||||
|
||||
# #105: nomi e id della ricetta costruita da «Crea ricetta da DXF» (src/vision/costruttori.py).
|
||||
_WIZARD_DXF_FILE = "disegno.dxf"
|
||||
_WIZARD_CALIBRATION_FILE = "calibrazione.json"
|
||||
_WIZARD_REFERENCE_STEM = "riferimento"
|
||||
|
||||
|
||||
def _is_real_number(value: object) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
||||
|
||||
|
||||
def dxf_compare_source(graph: dict | None, recipe_dir: str | None) -> dict | None:
|
||||
"""#105: i parametri con cui il wizard ha costruito la ricetta, per riaprirlo.
|
||||
|
||||
Legge i valori dal grafo (tool `pezzo` LocateBlob, tool `dxf` CompareDxf) e i
|
||||
file dalla cartella ricetta. None se il grafo non e' uno del wizard (il task
|
||||
non ha ricetta, o l'ha caricata a mano): la rotta risponde 404. Pura: niente DB.
|
||||
"""
|
||||
if not isinstance(graph, dict) or not isinstance(graph.get("tools"), list):
|
||||
return None
|
||||
tools = {t.get("id"): t for t in graph["tools"] if isinstance(t, dict)}
|
||||
pezzo, confronto = tools.get("pezzo"), tools.get("dxf")
|
||||
if not pezzo or pezzo.get("kind") != "LocateBlob":
|
||||
return None
|
||||
if not confronto or confronto.get("kind") != "CompareDxf":
|
||||
return None
|
||||
p_values = pezzo.get("values") if isinstance(pezzo.get("values"), dict) else {}
|
||||
c_values = confronto.get("values") if isinstance(confronto.get("values"), dict) else {}
|
||||
band, min_pct = c_values.get("band_plus"), c_values.get("points_in_pct_nominal")
|
||||
if not (_is_real_number(band) and _is_real_number(min_pct)):
|
||||
return None
|
||||
polarity = p_values.get("polarity")
|
||||
if polarity not in ("dark", "light"):
|
||||
polarity = "dark" # default di LocateBlob e della rotta di creazione
|
||||
|
||||
folder = None
|
||||
if recipe_dir:
|
||||
try:
|
||||
folder = _safe_join(settings.upload_path, recipe_dir)
|
||||
except (ValueError, OSError):
|
||||
folder = None
|
||||
if folder is not None and not folder.is_dir():
|
||||
folder = None
|
||||
|
||||
px_per_mm = None
|
||||
dxf_name = None
|
||||
image_name = None
|
||||
if folder is not None:
|
||||
try:
|
||||
cal = json.loads((folder / _WIZARD_CALIBRATION_FILE).read_text(encoding="utf-8"))
|
||||
value = cal.get("px_per_mm") if isinstance(cal, dict) else None
|
||||
if _is_real_number(value) and value > 0:
|
||||
px_per_mm = float(value)
|
||||
except (OSError, ValueError):
|
||||
px_per_mm = None # manca o illeggibile: il wizard chiedera' la scala
|
||||
if (folder / _WIZARD_DXF_FILE).is_file():
|
||||
dxf_name = _WIZARD_DXF_FILE
|
||||
for candidate in sorted(folder.glob(f"{_WIZARD_REFERENCE_STEM}.*")):
|
||||
if candidate.is_file():
|
||||
image_name = candidate.name
|
||||
break
|
||||
return {
|
||||
"band_mm": float(band), "min_points_pct": float(min_pct), "polarity": polarity,
|
||||
"px_per_mm": px_per_mm, "dxf": dxf_name, "image": image_name,
|
||||
}
|
||||
|
||||
|
||||
def recipe_file_path(recipe_dir: str | None, relative: str) -> Path | None:
|
||||
"""#105: percorso di un file della cartella ricetta, o None se non sicuro o assente."""
|
||||
if not recipe_dir:
|
||||
return None
|
||||
try:
|
||||
folder = _safe_join(settings.upload_path, recipe_dir)
|
||||
target = _safe_join(folder, relative)
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
return target if target.is_file() else None
|
||||
|
||||
|
||||
def save_result_files(
|
||||
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
|
||||
) -> tuple[str, str]:
|
||||
@@ -498,14 +623,15 @@ async def execute_task(
|
||||
)
|
||||
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||
station_url = None
|
||||
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
|
||||
calibration = None
|
||||
# Calibrazione letta dall'acquisitore (#83); None = non c'e'. #97: la camera e'
|
||||
# quella del task se la ricetta ne sceglie una, altrimenti quella della stazione.
|
||||
station = None
|
||||
if run is not None:
|
||||
station = (await db.execute(
|
||||
select(Station).where(Station.id == run.station_id)
|
||||
)).scalar_one_or_none()
|
||||
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||
calibration = await get_station_calibration(station)
|
||||
calibration, _ = await fetch_task_calibration(task, station)
|
||||
|
||||
if not task.vision_json:
|
||||
raise HTTPException(
|
||||
@@ -567,23 +693,13 @@ async def execute_task(
|
||||
)
|
||||
outputs = report["outputs"]
|
||||
|
||||
# Every quote or none. A half-measured piece read as a whole one is worse
|
||||
# than a piece not measured at all. A quote counts as produced only if its
|
||||
# output is present *and* a finite, non-bool number: the in-process runner
|
||||
# already drops NaN/inf itself (see src/vision/runner.py), but the worker
|
||||
# is reached over HTTP - a different implementation of it, or a malformed
|
||||
# response, could still hand back None or a non-finite float for a mapped
|
||||
# key instead of omitting it outright.
|
||||
missing = [q.vision_output for q in quotes if not _is_measurable(outputs.get(q.vision_output))]
|
||||
if missing:
|
||||
failed = ", ".join(f["tool_name"] for f in report["failures"])
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=(
|
||||
f"the graph did not produce {', '.join(missing)}"
|
||||
+ (f" (tools that failed: {failed})" if failed else "")
|
||||
),
|
||||
)
|
||||
# #118: una quota che il grafo non produce non blocca piu' l'esecuzione
|
||||
# (prima 422 "the graph did not produce"): viene salvata con value NULL,
|
||||
# mostrata NaN, sempre `fail`; i motivi restano nei `failures` del risultato.
|
||||
# Una quota conta come prodotta solo se l'uscita c'e' *ed* e' un numero
|
||||
# finito non-bool: il worker risponde via HTTP, un'altra implementazione o
|
||||
# una risposta malformata potrebbe dare None o un non finito per una chiave
|
||||
# mappata invece di ometterla.
|
||||
|
||||
overlay = report.get("overlay") or []
|
||||
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
|
||||
@@ -611,7 +727,11 @@ async def execute_task(
|
||||
subtask_id=quote.id,
|
||||
version_id=version_id,
|
||||
measured_by=measured_by,
|
||||
value=float(outputs[quote.vision_output]),
|
||||
# #118: None = quota non prodotta (NaN), salvata come fail
|
||||
value=(
|
||||
float(outputs[quote.vision_output])
|
||||
if _is_measurable(outputs.get(quote.vision_output)) else None
|
||||
),
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
input_method="camera",
|
||||
@@ -623,6 +743,89 @@ async def execute_task(
|
||||
return result, saved, report
|
||||
|
||||
|
||||
async def check_task(
|
||||
db: AsyncSession,
|
||||
task: RecipeTask,
|
||||
image_bytes: bytes,
|
||||
*,
|
||||
production_run_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Esegue il grafo come execute_task ma NON salva nulla (#97).
|
||||
|
||||
Anteprima per l'operatore (ingresso nel task, ciclo «in linea»): niente
|
||||
VisionResult, niente file, niente misure. L'esito per quota usa
|
||||
calculate_pass_fail sulle quote con vision_output, nell'ordine di
|
||||
marker_number come execute. Le quote che il grafo non produce (pezzo non
|
||||
ancora in posizione) entrano in `measurements` con `value` None (NaN) e
|
||||
`pass_fail` "fail" (#118, come execute); il perche' sta in `failures`:
|
||||
un'anteprima non deve dare 422 a ogni frame.
|
||||
"""
|
||||
if task.task_type not in VISION_TASK_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"task type {task.task_type!r} is not a camera measurement",
|
||||
)
|
||||
station = None
|
||||
station_url = None
|
||||
if production_run_id is not None:
|
||||
# stesse regole di execute: 404 / 422 / 409 sulla sessione di produzione
|
||||
run = await measurement_service.validate_run_for_version(
|
||||
db, production_run_id, task.version_id,
|
||||
)
|
||||
station = (await db.execute(
|
||||
select(Station).where(Station.id == run.station_id)
|
||||
)).scalar_one_or_none()
|
||||
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||
if not task.vision_json:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="this task carries no vision graph",
|
||||
)
|
||||
quotes = (await db.execute(
|
||||
select(RecipeSubtask)
|
||||
.where(RecipeSubtask.task_id == task.id)
|
||||
.where(RecipeSubtask.vision_output.is_not(None))
|
||||
.order_by(RecipeSubtask.marker_number)
|
||||
)).scalars().all()
|
||||
if not quotes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="no quote on this task names a vision output",
|
||||
)
|
||||
|
||||
# stessa calibrazione di execute: camera del task, altrimenti della stazione
|
||||
calibration, cal_reason = await fetch_task_calibration(task, station)
|
||||
worker_kwargs: dict = {}
|
||||
if calibration is not None:
|
||||
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
|
||||
report = await _call_worker(
|
||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||
worker_url=station_url,
|
||||
**worker_kwargs,
|
||||
)
|
||||
outputs = report["outputs"]
|
||||
# #118: una voce per ogni quota, in ordine di marker_number; non prodotta -> NaN/fail
|
||||
measurements = [
|
||||
{
|
||||
"subtask_id": q.id,
|
||||
"value": float(outputs[q.vision_output]),
|
||||
"pass_fail": measurement_service.calculate_pass_fail(
|
||||
float(outputs[q.vision_output]), q,
|
||||
)[0],
|
||||
} if _is_measurable(outputs.get(q.vision_output)) else {
|
||||
"subtask_id": q.id, "value": None, "pass_fail": "fail",
|
||||
}
|
||||
for q in quotes
|
||||
]
|
||||
return {
|
||||
"outcome": report.get("outcome"),
|
||||
"overlay": report.get("overlay") or [],
|
||||
"failures": report.get("failures") or [],
|
||||
"measurements": measurements,
|
||||
"calibration": cal_reason,
|
||||
}
|
||||
|
||||
|
||||
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
||||
return settings.upload_path / reference.path
|
||||
|
||||
@@ -753,9 +956,29 @@ async def ensure_points_quote(
|
||||
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
|
||||
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
|
||||
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
|
||||
#105: unica eccezione, la quota nata da qui (`dxf.points_in_pct`, «Punti in
|
||||
tolleranza») senza misure segue la soglia nuova: ltl = min_points_pct, e
|
||||
ritorna True anche in quel caso.
|
||||
"""
|
||||
from src.backend.services import recipe_service # import locale: evita cicli
|
||||
|
||||
# #105: ricetta ripresa e salvata con un'altra soglia -> la quota la segue
|
||||
own = (await db.execute(
|
||||
select(RecipeSubtask)
|
||||
.where(RecipeSubtask.task_id == task.id)
|
||||
.where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||
.where(RecipeSubtask.description == "Punti in tolleranza")
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if own is not None:
|
||||
# soglia invariata o versione con misure: la quota resta com'e'
|
||||
same = own.ltl is not None and float(own.ltl) == float(min_points_pct)
|
||||
if same or await recipe_service.version_has_measurements(db, task.version_id):
|
||||
return False
|
||||
own.ltl = min_points_pct
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
existing = (await db.execute(
|
||||
select(RecipeSubtask.id)
|
||||
.where(RecipeSubtask.task_id == task.id)
|
||||
|
||||
@@ -341,3 +341,93 @@ class TestStationCameraMigration:
|
||||
assert "vision_worker_url" in cols("stations") # la 019 resta
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert {"camera_url", "camera_source"} <= cols("stations")
|
||||
|
||||
|
||||
class TestTaskAcquisitionMigration:
|
||||
"""021_task_acquisition: recipe_tasks.acquisition_json, su e giu (#97)."""
|
||||
|
||||
def test_021_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
|
||||
def cols(table):
|
||||
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert "acquisition_json" in cols("recipe_tasks")
|
||||
command.downgrade(cfg, "020_station_camera")
|
||||
assert "acquisition_json" not in cols("recipe_tasks")
|
||||
assert "camera_url" in cols("stations") # la 020 resta
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert "acquisition_json" in cols("recipe_tasks")
|
||||
|
||||
|
||||
class TestMeasurementValueNullableMigration:
|
||||
"""022_measurement_value_nullable: measurements.value accetta NULL, su e giu (#118)."""
|
||||
|
||||
def test_022_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
|
||||
def value_nullable():
|
||||
cols = _inspect(db_path).get_columns("measurements")
|
||||
return {c["name"]: c["nullable"] for c in cols}["value"]
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert value_nullable() is True
|
||||
command.downgrade(cfg, "021_task_acquisition")
|
||||
assert value_nullable() is False
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert value_nullable() is True
|
||||
|
||||
def test_022_keeps_rows(self, sqlite_database_url):
|
||||
"""#125: la ricostruzione della tabella conserva righe, somma dei value e indici."""
|
||||
import sqlite3
|
||||
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "021_task_acquisition")
|
||||
|
||||
def indexes(con):
|
||||
return sorted(r[0] for r in con.execute(
|
||||
"select name from sqlite_master where type='index' and tbl_name='measurements'"
|
||||
))
|
||||
|
||||
con = sqlite3.connect(db_path)
|
||||
# Colonne NOT NULL senza default riempite per tipo (stessa idea di mig_check.py)
|
||||
vals = {}
|
||||
for _cid, name, typ, notnull, dflt, pk in con.execute("pragma table_info(measurements)").fetchall():
|
||||
if pk or not notnull or dflt is not None:
|
||||
continue
|
||||
t = typ.upper()
|
||||
vals[name] = (
|
||||
1 if "INT" in t
|
||||
else 1.5 if ("DEC" in t or "FLOAT" in t or "NUM" in t)
|
||||
else "2026-01-01 00:00:00" if ("DATE" in t or "TIME" in t)
|
||||
else "x"
|
||||
)
|
||||
vals["pass_fail"] = "pass"
|
||||
vals["value"] = 12.5
|
||||
sql = f"insert into measurements ({','.join(vals)}) values ({','.join('?' * len(vals))})"
|
||||
for _ in range(3):
|
||||
con.execute(sql, list(vals.values()))
|
||||
con.commit()
|
||||
before_idx = indexes(con)
|
||||
before = con.execute("select count(*), sum(value) from measurements").fetchone()
|
||||
con.close()
|
||||
assert before == (3, 37.5)
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
con = sqlite3.connect(db_path)
|
||||
try:
|
||||
assert con.execute("select count(*), sum(value) from measurements").fetchone() == before
|
||||
assert indexes(con) == before_idx
|
||||
assert con.execute("pragma integrity_check").fetchone()[0] == "ok"
|
||||
vals["value"] = None
|
||||
vals["pass_fail"] = "fail"
|
||||
con.execute(sql, list(vals.values())) # una riga NaN (value NULL) ora e' accettata
|
||||
con.commit()
|
||||
assert con.execute("select count(*) from measurements where value is null").fetchone()[0] == 1
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
@@ -81,3 +81,29 @@ async def test_open_lists_only_open_runs_with_means(client, db_session, admin_us
|
||||
async def test_open_empty_for_admin(client, admin_user):
|
||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
|
||||
assert resp.status_code == 200 and resp.json() == []
|
||||
|
||||
|
||||
async def test_nan_quotes_in_the_monitor(client, db_session, admin_user):
|
||||
"""#118: solo NaN -> la quota resta, mean null e fuori tolleranza; mista -> media dei numeri."""
|
||||
sup = await _create_user(db_session, "capo", roles=["Supervisor"])
|
||||
r1, s1, v1 = await _run(db_session, admin_user, "N1")
|
||||
r2, s2, v2 = await _run(db_session, admin_user, "N2")
|
||||
for i in range(2):
|
||||
db_session.add(_m(r1, s1, v1, admin_user, None, "fail", i))
|
||||
for i, (val, pf) in enumerate([(None, "fail"), (10.0, "pass"), (10.2, "pass")]):
|
||||
db_session.add(_m(r2, s2, v2, admin_user, val, pf, i))
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(sup))
|
||||
assert resp.status_code == 200
|
||||
body = {r["station_code"]: r for r in resp.json()}
|
||||
only_nan = body["ST-N1"]["subtasks"]
|
||||
assert len(only_nan) == 1
|
||||
assert only_nan[0]["mean"] is None
|
||||
assert only_nan[0]["count"] == 2
|
||||
assert only_nan[0]["in_tolerance"] is False
|
||||
assert body["ST-N1"]["fail_count"] == 2
|
||||
mixed = body["ST-N2"]["subtasks"][0]
|
||||
assert mixed["count"] == 3
|
||||
assert round(mixed["mean"], 6) == 10.1
|
||||
assert mixed["in_tolerance"] is True
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""#118: una quota non misurata dalla visione e' una misura con value NULL (NaN), sempre fail.
|
||||
|
||||
Il valore NULL non deve far cadere statistiche, report, CSV e liste, e non entra
|
||||
nei calcoli numerici (medie, indici, istogramma); conta solo nell'esito.
|
||||
"""
|
||||
import csv
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import measurement_service
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.tests.test_statistics import _seed_measurements
|
||||
|
||||
|
||||
async def _recipe_with_nan(client, db_session, tec_user):
|
||||
"""Una ricetta con tre misure numeriche e una NaN (value NULL, fail)."""
|
||||
recipe = await create_test_recipe(db_session, tec_user.id)
|
||||
subtask_id, version_id = await _seed_measurements(
|
||||
client, tec_user, recipe.id, [10.0, 10.1, 9.9],
|
||||
)
|
||||
nan = await measurement_service.save_measurement(
|
||||
db_session, subtask_id=subtask_id, version_id=version_id,
|
||||
measured_by=tec_user.id, value=None, input_method="camera",
|
||||
enforce_tolerance_gate=False,
|
||||
)
|
||||
await db_session.commit()
|
||||
return recipe, subtask_id, version_id, nan
|
||||
|
||||
|
||||
async def test_save_measurement_none_is_fail_without_deviation(
|
||||
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
|
||||
):
|
||||
_, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
|
||||
assert nan.value is None
|
||||
assert nan.pass_fail == "fail"
|
||||
assert nan.deviation is None
|
||||
|
||||
|
||||
async def test_manual_create_still_requires_a_value(
|
||||
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
|
||||
):
|
||||
"""Solo il percorso visione puo' produrre NaN: la creazione manuale resta float."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
subtask_id, version_id = await _seed_measurements(
|
||||
client, measurement_tec_user, recipe.id, [],
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/measurements/", headers=auth_headers(measurement_tec_user),
|
||||
json={"subtask_id": subtask_id, "version_id": version_id, "value": None},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_statistics_skip_the_nan_measurement(
|
||||
client: AsyncClient, db_session: AsyncSession,
|
||||
measurement_tec_user: User, metrologist_user: User,
|
||||
):
|
||||
recipe, subtask_id, _, _ = await _recipe_with_nan(
|
||||
client, db_session, measurement_tec_user,
|
||||
)
|
||||
params = {"recipe_id": recipe.id, "subtask_id": subtask_id}
|
||||
headers = auth_headers(metrologist_user)
|
||||
|
||||
cap = await client.get("/api/statistics/capability", headers=headers, params=params)
|
||||
assert cap.status_code == 200
|
||||
assert cap.json()["n"] == 3
|
||||
assert abs(cap.json()["mean"] - 10.0) < 1e-9
|
||||
|
||||
chart = await client.get("/api/statistics/control-chart", headers=headers, params=params)
|
||||
assert chart.status_code == 200
|
||||
assert len(chart.json()["values"]) == 3
|
||||
|
||||
hist = await client.get("/api/statistics/histogram", headers=headers, params=params)
|
||||
assert hist.status_code == 200
|
||||
|
||||
summary = await client.get(
|
||||
"/api/statistics/summary", headers=headers, params={"recipe_id": recipe.id},
|
||||
)
|
||||
assert summary.status_code == 200
|
||||
|
||||
|
||||
async def test_measurement_list_returns_null_value(
|
||||
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
|
||||
):
|
||||
_, _, version_id, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
|
||||
resp = await client.get(
|
||||
"/api/measurements/", headers=auth_headers(measurement_tec_user),
|
||||
params={"version_id": version_id, "per_page": 50},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
rows = {m["id"]: m for m in resp.json()["items"]}
|
||||
assert rows[nan.id]["value"] is None
|
||||
assert rows[nan.id]["pass_fail"] == "fail"
|
||||
assert rows[nan.id]["deviation"] is None
|
||||
|
||||
|
||||
async def test_csv_export_writes_nan(
|
||||
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
|
||||
):
|
||||
_, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
|
||||
resp = await client.get(
|
||||
"/api/measurements/export/csv", headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
rows = list(csv.reader(resp.text.splitlines()))
|
||||
header = rows[0]
|
||||
by_id = {r[header.index("id")]: r for r in rows[1:]}
|
||||
row = by_id[str(nan.id)]
|
||||
assert row[header.index("value")] == "NaN"
|
||||
assert row[header.index("pass_fail")] == "fail"
|
||||
assert row[header.index("deviation")] == "NaN" # #124
|
||||
@@ -836,3 +836,31 @@ async def test_close_without_measurements_writes_no_file(
|
||||
)).json()
|
||||
assert closed["statistics_path"] is None
|
||||
assert not (tmp_path / "statistics").exists()
|
||||
|
||||
|
||||
async def test_statistics_csv_writes_nan_value_and_deviation(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""#124: una quota NaN (value NULL) esce NaN sia nel valore sia nella deviazione."""
|
||||
from src.backend.services import measurement_service, production_export_service
|
||||
|
||||
station = await _station(db_session, admin_user.id, code="ST-NAN")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-NAN")
|
||||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
await measurement_service.save_measurement(
|
||||
db_session, subtask_id=subtask.id, version_id=opened["version_id"],
|
||||
measured_by=measurement_tec_user.id, value=None, input_method="camera",
|
||||
production_run_id=opened["id"], enforce_tolerance_gate=False,
|
||||
)
|
||||
await db_session.commit()
|
||||
run = (await db_session.execute(
|
||||
select(ProductionRun).where(ProductionRun.id == opened["id"])
|
||||
)).scalar_one()
|
||||
|
||||
text = await production_export_service.build_statistics_csv(db_session, run)
|
||||
lines = [line for line in text.lstrip("").splitlines() if line.strip()]
|
||||
header = lines[0].split(",")
|
||||
row = lines[1].split(",")
|
||||
assert row[header.index("value")] == "NaN"
|
||||
assert row[header.index("deviation")] == "NaN"
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""#97: camera e modo di analisi nel task, POST /api/vision/check senza salvataggio.
|
||||
|
||||
Il worker e l'acquisitore non girano nei test: si fingono (fixture di
|
||||
test_vision_dxf_compare e test_vision_station_worker).
|
||||
"""
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.tests.test_vision_copy_on_write import (
|
||||
_current_task_and_subtask, _seed_measurement,
|
||||
)
|
||||
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture acquisitore)
|
||||
CAL, CAMERA_URL, acquisitore, redirect_uploads,
|
||||
)
|
||||
from src.backend.tests.test_vision_execute import an_image
|
||||
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
|
||||
_camera_task, _execute, _station_with_run, worker_calls,
|
||||
)
|
||||
|
||||
TASK_CAMERA = "http://10.0.0.50:8300"
|
||||
ACQ = {"camera_url": TASK_CAMERA, "camera_source": "camera:601020", "modo": "in_linea"}
|
||||
|
||||
|
||||
async def _task_with_acquisition(db_session, user_id, acquisition=ACQ):
|
||||
recipe, task = await _camera_task(db_session, user_id)
|
||||
task.acquisition_json = acquisition
|
||||
await db_session.commit()
|
||||
return recipe, task
|
||||
|
||||
|
||||
# --- modello e API del task ---------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_stores_and_normalizes_acquisition(client, db_session, maker_user):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={
|
||||
"title": "Camera", "task_type": "camera_measure",
|
||||
"acquisition_json": {"camera_url": " http://h:1/ ", "camera_source": " camera:1 "},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
# url normalizzato come nelle stazioni, sorgente senza spazi, modo di default
|
||||
expected = {"camera_url": "http://h:1", "camera_source": "camera:1", "modo": "impulsiva"}
|
||||
assert body["acquisition_json"] == expected
|
||||
db_session.expunge_all()
|
||||
task = (await db_session.execute(
|
||||
select(RecipeTask).where(RecipeTask.id == body["id"])
|
||||
)).scalar_one()
|
||||
assert task.acquisition_json == expected # sul DB, non solo nella risposta
|
||||
got = await client.get(f"/api/tasks/{body['id']}", headers=auth_headers(maker_user))
|
||||
assert got.json()["acquisition_json"] == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_without_acquisition_is_null(client, db_session, maker_user):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.get(f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user))
|
||||
assert resp.json()[0]["acquisition_json"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("bad", [
|
||||
{"camera_url": "ftp://h", "camera_source": "camera:1"},
|
||||
{"camera_url": " ", "camera_source": "camera:1"},
|
||||
{"camera_url": "http://h", "camera_source": " "},
|
||||
{"camera_url": "http://h", "camera_source": "x" * 201},
|
||||
{"camera_url": "http://h", "camera_source": "camera:1", "modo": "continua"},
|
||||
{"camera_source": "camera:1"},
|
||||
])
|
||||
async def test_invalid_acquisition_is_422(client, db_session, maker_user, bad):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"title": "Camera", "task_type": "camera_measure", "acquisition_json": bad},
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_sets_and_clears_acquisition(client, db_session, maker_user):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": ACQ},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["acquisition_json"] == ACQ
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": None},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["acquisition_json"] is None # null = camera della stazione
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_on_write_preserves_acquisition(client, db_session, maker_user):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||
task.acquisition_json = ACQ
|
||||
await db_session.flush()
|
||||
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||
db_session.expunge_all()
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user),
|
||||
json={"change_notes": "forza il copy-on-write"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
db_session.expunge_all()
|
||||
version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||
assert version.version_number == 2
|
||||
assert new_task.acquisition_json == ACQ
|
||||
|
||||
|
||||
# --- calibrazione dalla camera del task ----------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_reads_calibration_from_the_task_camera(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
recipe, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||
# stazione con camera propria: la camera del task prevale
|
||||
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||
await db_session.commit()
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
assert acquisitore["urls"] == [(f"{TASK_CAMERA}/camera/calibration", {"source": "camera:601020"})]
|
||||
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
|
||||
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||
assert result.calibration_snapshot["px_per_mm"] == CAL["px_per_mm"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_task_camera_works_without_a_run(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||
await _execute(client, measurement_tec_user, task) # niente sessione di produzione
|
||||
assert acquisitore["urls"][0][0] == f"{TASK_CAMERA}/camera/calibration"
|
||||
assert "calibration" in worker_calls[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_falls_back_to_the_station_camera(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id) # acquisition_json null
|
||||
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||
await db_session.commit()
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
|
||||
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
|
||||
|
||||
|
||||
# --- POST /api/vision/check ----------------------------------------------------
|
||||
|
||||
async def _check(client, user, task, run=None):
|
||||
data = {"task_id": str(task.id)}
|
||||
if run is not None:
|
||||
data["production_run_id"] = str(run.id)
|
||||
return await client.post(
|
||||
"/api/vision/check", headers=auth_headers(user),
|
||||
files={"image": ("p.png", an_image(), "image/png")}, data=data,
|
||||
)
|
||||
|
||||
|
||||
async def _counts(db_session):
|
||||
return tuple([
|
||||
(await db_session.execute(select(func.count()).select_from(model))).scalar_one()
|
||||
for model in (Measurement, VisionResult)
|
||||
])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_saves_nothing_and_reports_pass_fail(
|
||||
client, db_session, measurement_tec_user, monkeypatch, acquisitore,
|
||||
):
|
||||
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||
seen = {}
|
||||
|
||||
async def fake_call(image_bytes, graph, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return {"outputs": {"t1.diameter": 11.0}, "failures": [{"tool_name": "t"}],
|
||||
"overlay": [{"kind": "line"}], "outcome": "fail",
|
||||
"engine_version": "e" * 40, "duration_ms": 3}
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||
before = await _counts(db_session)
|
||||
|
||||
resp = await _check(client, measurement_tec_user, task)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
quote = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().one()
|
||||
assert body == {
|
||||
"outcome": "fail", "overlay": [{"kind": "line"}], "failures": [{"tool_name": "t"}],
|
||||
"measurements": [{"subtask_id": quote.id, "value": 11.0, "pass_fail": "fail"}],
|
||||
"calibration": "ok",
|
||||
}
|
||||
assert seen["calibration"] == {"px_per_mm": CAL["px_per_mm"]} # stessa calibrazione di execute
|
||||
assert await _counts(db_session) == before # niente misure ne' VisionResult
|
||||
assert not list(settings.upload_path.rglob("*.overlay.json")) # ne' file
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("value,expected", [(10.0, "pass"), (10.4, "warning"), (9.0, "fail")])
|
||||
async def test_check_pass_fail_levels(
|
||||
client, db_session, measurement_tec_user, monkeypatch, value, expected,
|
||||
):
|
||||
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
|
||||
async def fake_call(image_bytes, graph, **kwargs):
|
||||
return {"outputs": {"t1.diameter": value}, "failures": [], "engine_version": "e" * 40}
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||
resp = await _check(client, measurement_tec_user, task)
|
||||
assert resp.json()["measurements"][0]["pass_fail"] == expected
|
||||
assert resp.json()["calibration"] == "no_camera" # senza camera ne' stazione
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_calibration_reasons(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||
acquisitore["cal"] = None
|
||||
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "not_calibrated"
|
||||
acquisitore["cal"] = "down"
|
||||
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "unreachable"
|
||||
assert "calibration" not in worker_calls[-1] # senza calibrazione la ricetta usa la sua
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_lists_quotes_the_graph_did_not_produce_as_nan(
|
||||
client, db_session, measurement_tec_user, monkeypatch,
|
||||
):
|
||||
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
|
||||
async def fake_call(image_bytes, graph, **kwargs):
|
||||
return {"outputs": {}, "failures": [{"tool_name": "blob"}], "engine_version": "e" * 40}
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||
resp = await _check(client, measurement_tec_user, task)
|
||||
assert resp.status_code == 200 # un'anteprima non da' 422 a ogni frame
|
||||
# #118: la quota non prodotta c'e', value null (NaN) e fail
|
||||
measurements = resp.json()["measurements"]
|
||||
assert len(measurements) == 1
|
||||
assert measurements[0]["value"] is None and measurements[0]["pass_fail"] == "fail"
|
||||
assert resp.json()["failures"] == [{"tool_name": "blob"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_422_without_graph_or_quotes_or_vision_type(
|
||||
client, db_session, measurement_tec_user, worker_calls,
|
||||
):
|
||||
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
task.vision_json = None
|
||||
await db_session.commit()
|
||||
assert (await _check(client, measurement_tec_user, task)).status_code == 422
|
||||
|
||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||
quote = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().one()
|
||||
quote.vision_output = None
|
||||
await db_session.commit()
|
||||
assert (await _check(client, measurement_tec_user, task)).status_code == 422
|
||||
|
||||
task.task_type = "measure"
|
||||
await db_session.commit()
|
||||
assert (await _check(client, measurement_tec_user, task)).status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_with_run_uses_station_worker_and_station_camera(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
station, run = await _station_with_run(
|
||||
db_session, measurement_tec_user, recipe, "http://10.0.0.7:8100")
|
||||
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||
await db_session.commit()
|
||||
before = await _counts(db_session)
|
||||
resp = await _check(client, measurement_tec_user, task, run)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert worker_calls[0]["worker_url"] == "http://10.0.0.7:8100"
|
||||
assert acquisitore["urls"][0][0] == f"{CAMERA_URL}/camera/calibration"
|
||||
assert await _counts(db_session) == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_requires_login(client, db_session, measurement_tec_user, worker_calls):
|
||||
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
resp = await client.post(
|
||||
"/api/vision/check", files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
assert resp.status_code in (401, 403)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""#99: the vision routes accept BMP (the Opto bench cameras save it); TIFF and
|
||||
non-images stay refused. files.py (attachments) is not touched by this."""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.vision import VisionReferenceImage
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.tests.test_vision_upload_limits import ( # noqa: F401 - fixtures
|
||||
recipe_parts, redirect_uploads, worker_says,
|
||||
)
|
||||
|
||||
|
||||
def a_bmp() -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.new("L", (64, 64), color=0).save(buffer, format="BMP")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def a_tiff() -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.new("L", (64, 64), color=0).save(buffer, format="TIFF")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
async def a_camera_task(db_session, user):
|
||||
recipe = await create_test_recipe(db_session, user.id)
|
||||
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||
task.task_type = "camera_measure"
|
||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||
await db_session.commit()
|
||||
return task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("content_type", ["image/bmp", "image/x-ms-bmp"])
|
||||
async def test_preview_accepts_a_bmp(
|
||||
client, db_session, maker_user, worker_says, content_type,
|
||||
):
|
||||
task = await a_camera_task(db_session, maker_user)
|
||||
worker_says({"t1.diameter": 10.0})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/preview",
|
||||
headers=auth_headers(maker_user),
|
||||
files={"image": ("p.bmp", a_bmp(), content_type)},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code != 400, response.text
|
||||
assert response.status_code != 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_still_rejects_a_tiff(
|
||||
client, db_session, maker_user, worker_says,
|
||||
):
|
||||
task = await a_camera_task(db_session, maker_user)
|
||||
worker_says({"t1.diameter": 10.0})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/preview",
|
||||
headers=auth_headers(maker_user),
|
||||
files={"image": ("p.tif", a_tiff(), "image/tiff")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_text_file_declared_bmp_is_refused_on_its_bytes(
|
||||
client, db_session, maker_user, worker_says,
|
||||
):
|
||||
task = await a_camera_task(db_session, maker_user)
|
||||
worker_says({"t1.diameter": 10.0})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/preview",
|
||||
headers=auth_headers(maker_user),
|
||||
files={"image": ("p.bmp", b"i am not an image", "image/bmp")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reference_image_bmp_is_saved_with_the_bmp_extension(
|
||||
client, db_session, maker_user,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/reference-images",
|
||||
headers=auth_headers(maker_user),
|
||||
files={"image": ("p.bmp", a_bmp(), "image/x-ms-bmp")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
saved = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
|
||||
assert saved.path.endswith(".bmp")
|
||||
assert (settings.upload_path / saved.path).exists()
|
||||
@@ -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
|
||||
@@ -0,0 +1,226 @@
|
||||
"""#105: la ricetta «Crea ricetta da DXF» si puo' riaprire e modificare.
|
||||
|
||||
Il worker non gira nei test: si finge come in test_vision_dxf_compare.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.services import vision_service
|
||||
from src.backend.tests.conftest import auth_headers
|
||||
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture)
|
||||
redirect_uploads, validate_ok,
|
||||
)
|
||||
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
|
||||
|
||||
DXF = b"0\nEOF"
|
||||
|
||||
|
||||
def _wizard_graph(band=0.3, pct=90.0, polarity="light") -> dict:
|
||||
"""Grafo come lo costruisce src/vision/costruttori.py::_graph."""
|
||||
return {
|
||||
"schema_version": 2, "name": "Confronto DXF",
|
||||
"meta": {"dxf": {"disegno": {"path": "disegno.dxf", "sha256": "x"}},
|
||||
"calibration": {"path": "calibrazione.json", "sha256": "y"}},
|
||||
"tools": [
|
||||
{"id": "pezzo", "name": "Pezzo", "kind": "LocateBlob",
|
||||
"values": {"polarity": polarity}, "links": {}},
|
||||
{"id": "dxf", "name": "Confronto DXF", "kind": "CompareDxf",
|
||||
"values": {"band_plus": band, "band_minus": band, "points_in_pct_nominal": pct},
|
||||
"links": {}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def w2(monkeypatch):
|
||||
async def fake(image_bytes, dxf_bytes, **kwargs):
|
||||
graph = _wizard_graph(kwargs["band_mm"], kwargs["min_points_pct"], kwargs["polarity"])
|
||||
return {
|
||||
"graph": graph,
|
||||
"files": {
|
||||
"disegno.dxf": base64.b64encode(dxf_bytes).decode(),
|
||||
"calibrazione.json": base64.b64encode(
|
||||
json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()).decode(),
|
||||
},
|
||||
"outputs": {"dxf.points_in_pct": 99.0}, "overlay": [], "outcome": "pass",
|
||||
}
|
||||
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
|
||||
|
||||
|
||||
def _bmp() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("L", (32, 32)).save(buf, format="BMP")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _save(client, user, task, *, image=None, ctype="image/png", **data):
|
||||
return client.post(
|
||||
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
|
||||
headers=auth_headers(user),
|
||||
files={"image": ("p", image if image is not None else an_image(), ctype),
|
||||
"dxf": ("d.dxf", DXF, "application/octet-stream")},
|
||||
data={"save": "true", "px_per_mm": "10", **{k: str(v) for k, v in data.items()}},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_keeps_the_reference_image_png(client, db_session, maker_user, w2, validate_ok):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
image = an_image()
|
||||
|
||||
resp = await _save(client, maker_user, task, image=image)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
folder = settings.upload_path / resp.json()["recipe_dir"]
|
||||
assert (folder / "riferimento.png").read_bytes() == image
|
||||
assert (folder / "disegno.dxf").read_bytes() == DXF # il resto della cartella non cambia
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_keeps_the_reference_image_bmp(client, db_session, maker_user, w2, validate_ok):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
image = _bmp()
|
||||
|
||||
resp = await _save(client, maker_user, task, image=image, ctype="image/bmp")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
folder = settings.upload_path / resp.json()["recipe_dir"]
|
||||
assert (folder / "riferimento.bmp").read_bytes() == image
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_returns_what_the_wizard_saved(client, db_session, maker_user, w2, validate_ok):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
await _save(client, maker_user, task, band_mm=0.3, min_points_pct=90, polarity="light")
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source", headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {
|
||||
"band_mm": 0.3, "min_points_pct": 90.0, "polarity": "light", "px_per_mm": 10.0,
|
||||
"dxf": "disegno.dxf", "image": "riferimento.png",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_404_without_recipe_or_with_a_hand_made_graph(client, db_session, maker_user):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
url = f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source"
|
||||
|
||||
assert (await client.get(url, headers=auth_headers(maker_user))).status_code == 404
|
||||
|
||||
task.vision_json = {"schema_version": 2, "name": "a mano", "tools": [], "meta": {}}
|
||||
await db_session.commit()
|
||||
resp = await client.get(url, headers=auth_headers(maker_user))
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["detail"] == "nessuna ricetta da DXF su questo task"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_needs_maker(client, db_session, maker_user, measurement_tec_user):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await client.get(
|
||||
f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_source_of_an_old_recipe_has_no_image_and_no_unreadable_scale(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||
folder = tmp_path / "vision" / "recipes" / "1" / "abc"
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "disegno.dxf").write_bytes(DXF)
|
||||
(folder / "calibrazione.json").write_text("{non json", encoding="utf-8")
|
||||
|
||||
source = vision_service.dxf_compare_source(_wizard_graph(), "vision/recipes/1/abc")
|
||||
|
||||
assert source["image"] is None and source["px_per_mm"] is None
|
||||
assert source["dxf"] == "disegno.dxf"
|
||||
assert vision_service.dxf_compare_source(None, None) is None
|
||||
# senza cartella i valori del grafo restano
|
||||
assert vision_service.dxf_compare_source(_wizard_graph(), None)["dxf"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_files_returns_the_bytes(client, db_session, maker_user, w2, validate_ok):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
image = _bmp()
|
||||
await _save(client, maker_user, task, image=image, ctype="image/bmp")
|
||||
|
||||
img = await client.get(
|
||||
f"/api/vision/tasks/{task.id}/recipe/files/riferimento.bmp", headers=auth_headers(maker_user),
|
||||
)
|
||||
dxf = await client.get(
|
||||
f"/api/vision/tasks/{task.id}/recipe/files/disegno.dxf", headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert img.status_code == 200 and img.content == image
|
||||
assert img.headers["content-type"].startswith("image/")
|
||||
assert dxf.status_code == 200 and dxf.content == DXF
|
||||
assert dxf.headers["content-type"] # dal tipo dell'estensione, di ripiego octet-stream
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_files_404_on_unsafe_missing_or_no_recipe(client, db_session, maker_user, w2, validate_ok):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
base = f"/api/vision/tasks/{task.id}/recipe/files/"
|
||||
headers = auth_headers(maker_user)
|
||||
|
||||
assert (await client.get(base + "disegno.dxf", headers=headers)).status_code == 404 # nessuna ricetta
|
||||
|
||||
await _save(client, maker_user, task)
|
||||
assert (await client.get(base + "manca.bmp", headers=headers)).status_code == 404
|
||||
for unsafe in ("../x", "%2e%2e/x", "..%2f..%2fx", "sub/../../x"):
|
||||
assert (await client.get(base + unsafe, headers=headers)).status_code == 404, unsafe
|
||||
# la funzione non lascia uscire dalla cartella nemmeno con un assoluto
|
||||
assert vision_service.recipe_file_path("vision/recipes/1/abc", "../x") is None
|
||||
assert vision_service.recipe_file_path("vision/recipes/1/abc", "/etc/passwd") is None
|
||||
assert vision_service.recipe_file_path("../fuori", "x") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resave_follows_the_new_threshold(client, db_session, maker_user, w2, validate_ok):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
task_id = task.id
|
||||
await _save(client, maker_user, task, min_points_pct=95)
|
||||
|
||||
resp = await _save(client, maker_user, task, min_points_pct=80)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
db_session.expire_all()
|
||||
quotes = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||
.where(RecipeSubtask.task_id == task_id))).scalars().all()
|
||||
assert len(quotes) == 1 and float(quotes[0].ltl) == 80.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_points_quote_leaves_quote_with_measurements(
|
||||
client, db_session, maker_user, w2, validate_ok, monkeypatch,
|
||||
):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
task_id = task.id
|
||||
await _save(client, maker_user, task, min_points_pct=95)
|
||||
|
||||
async def has_measurements(db, version_id):
|
||||
return True
|
||||
monkeypatch.setattr("src.backend.services.recipe_service.version_has_measurements", has_measurements)
|
||||
task = await db_session.get(RecipeTask, task_id)
|
||||
|
||||
changed = await vision_service.ensure_points_quote(db_session, task, 80)
|
||||
|
||||
assert changed is False
|
||||
quote = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||
.where(RecipeSubtask.task_id == task_id))).scalar_one()
|
||||
assert float(quote.ltl) == 95.0
|
||||
@@ -134,72 +134,134 @@ async def test_the_execution_records_which_engine_produced_it(
|
||||
assert result.graph_snapshot == task.vision_json
|
||||
|
||||
|
||||
# #118: una quota che il grafo non produce non e' piu' un 422: e' NaN (value
|
||||
# NULL), `fail`, salvata con le altre.
|
||||
async def _assert_nan_saved(client, db_session, user, task, first, second):
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert [m["subtask_id"] for m in body["measurements"]] == [first.id, second.id]
|
||||
assert body["measurements"][0]["value"] is None
|
||||
assert body["measurements"][0]["pass_fail"] == "fail"
|
||||
assert body["measurements"][1]["value"] == GOOD
|
||||
rows = (await db_session.execute(
|
||||
select(Measurement).order_by(Measurement.subtask_id)
|
||||
)).scalars().all()
|
||||
assert len(rows) == 2
|
||||
assert rows[0].value is None
|
||||
assert rows[0].pass_fail == "fail"
|
||||
assert rows[0].deviation is None
|
||||
assert rows[0].vision_result_id is not None
|
||||
assert rows[1].value is not None and rows[1].pass_fail == "pass"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_missing_output_names_itself(
|
||||
async def test_a_missing_output_is_saved_as_nan(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""No half measurement: if an output is missing, the error names it."""
|
||||
"""#118: output assente -> 200, misura NULL/fail, le altre quote salvate."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||
task.task_type = "camera_measure"
|
||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||
subtask.vision_output = "t1.diameter"
|
||||
await db_session.commit()
|
||||
worker_says({"t1.radius": 5.0})
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
worker_says({"t1.radius": GOOD}, failures=[{"tool_name": "arco_tr"}])
|
||||
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_outputs_missing_still_saves_every_quote(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""#118: anche senza nessuna uscita, una misura NaN per quota."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
worker_says({})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id), "lot_number": "L1"},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "t1.diameter" in response.json()["detail"]
|
||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||
assert response.status_code == 200
|
||||
assert [m["value"] for m in response.json()["measurements"]] == [None, None]
|
||||
rows = (await db_session.execute(select(Measurement))).scalars().all()
|
||||
assert len(rows) == 2 and all(r.value is None and r.pass_fail == "fail" for r in rows)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_null_output_counts_as_not_produced(
|
||||
async def test_a_null_output_is_saved_as_nan(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""A worker answering with the key present but null (not omitted) must not
|
||||
reach float(None) as a bare TypeError - it is "not produced", same as a
|
||||
missing key (#16)."""
|
||||
"""Chiave presente ma null (non omessa): NaN, non TypeError (#16, #118)."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
worker_says({"t1.diameter": None, "t1.radius": GOOD})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "t1.diameter" in response.json()["detail"]
|
||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_non_finite_output_counts_as_not_produced(
|
||||
async def test_a_non_finite_output_is_saved_as_nan(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""NaN/inf must not become a saved measurement (#16)."""
|
||||
"""NaN/inf non diventano un valore salvato: diventano NaN (NULL) (#16, #118)."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
worker_says({"t1.diameter": float("nan"), "t1.radius": GOOD})
|
||||
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||
|
||||
response = await client.post(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_nan_quote_needs_supervisor_authorisation(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""#125: la quota NaN e' un fail come gli altri e chiede il capoturno (pending_authorisation);
|
||||
la prossima acquisizione non e' bloccata."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
worker_says({"t1.radius": GOOD})
|
||||
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||
|
||||
blocking = await measurement_service.pending_authorisation(
|
||||
db_session, version.id, measurement_tec_user.id,
|
||||
)
|
||||
assert blocking is not None and blocking.subtask_id == first.id and blocking.value is None
|
||||
|
||||
# #125: una seconda acquisizione sullo stesso task risponde 200, non e' bloccata
|
||||
again = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
assert again.status_code == 200, again.text
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "t1.diameter" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_lists_a_missing_quote_as_nan(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""#118: check -> una voce per quota, la non prodotta e' value null / fail."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
worker_says({"t1.radius": GOOD})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/check",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["measurements"] == [
|
||||
{"subtask_id": first.id, "value": None, "pass_fail": "fail"},
|
||||
{"subtask_id": second.id, "value": GOOD, "pass_fail": "pass"},
|
||||
]
|
||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ def api_camera_sources():
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
return jsonify({"error": True, "detail": _("Indirizzo della camera non valido")}), 400
|
||||
try:
|
||||
resp = http_requests.get(f"{url.rstrip('/')}/camera/sources", timeout=5)
|
||||
resp = http_requests.get(f"{url.rstrip('/')}/camera/sources", timeout=15) # #106: era 5, la prima enumerazione dopo l'avvio ha preso 3,7 s
|
||||
except http_requests.RequestException as exc:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
|
||||
@@ -5,6 +5,7 @@ import math
|
||||
import requests as http_requests
|
||||
|
||||
from functools import wraps
|
||||
from urllib.parse import quote # #105: percorso del file ricetta nell'URL del backend
|
||||
|
||||
from flask import Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
from flask_babel import gettext as _
|
||||
@@ -16,6 +17,9 @@ from services.file_proxy import proxy_file
|
||||
|
||||
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
||||
|
||||
# #106: la prima apertura GenTL dopo l'avvio supera 15 s, il grab ha bisogno di piu margine
|
||||
CAMERA_GRAB_TIMEOUT_S = 60
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PAGINE (GET)
|
||||
@@ -541,6 +545,55 @@ def api_list_stations_for_maker():
|
||||
return jsonify(out), 200
|
||||
|
||||
|
||||
def _camera_services() -> list[dict]:
|
||||
"""#97: servizi di acquisizione distinti delle stazioni che hanno una camera_url."""
|
||||
services: dict[str, list[str]] = {}
|
||||
for st in _stations_for_maker():
|
||||
url = (st.get("camera_url") or "").strip().rstrip("/")
|
||||
if not url:
|
||||
continue
|
||||
label = f"{st.get('code') or ''} - {st.get('name') or ''}"
|
||||
services.setdefault(url, []).append(label)
|
||||
return [{"camera_url": u, "stazioni": s} for u, s in services.items()]
|
||||
|
||||
|
||||
@maker_bp.route("/api/camera/servizi", methods=["GET"])
|
||||
@login_required
|
||||
@maker_or_admin_required
|
||||
def api_camera_services():
|
||||
"""#97: servizi di acquisizione fra cui scegliere la camera di un task."""
|
||||
return jsonify({"servizi": _camera_services()}), 200
|
||||
|
||||
|
||||
@maker_bp.route("/api/camera/sorgenti", methods=["GET"])
|
||||
@login_required
|
||||
@maker_or_admin_required
|
||||
def api_camera_sources():
|
||||
"""#97: proxy di GET {url}/camera/sources; solo per i servizi delle stazioni."""
|
||||
url = (request.args.get("url") or "").strip().rstrip("/")
|
||||
# Niente richieste verso indirizzi arbitrari: url ammesso solo se e' un servizio noto
|
||||
if not url or url not in {s["camera_url"] for s in _camera_services()}:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Servizio di acquisizione non ammesso"),
|
||||
}), 400
|
||||
try:
|
||||
resp = http_requests.get(f"{url}/camera/sources", timeout=15)
|
||||
body = resp.json() if resp.status_code == 200 else None
|
||||
except (http_requests.RequestException, ValueError) as exc:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
|
||||
}), 502
|
||||
sources = body.get("sources") if isinstance(body, dict) else None
|
||||
if not isinstance(sources, list):
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Risposta del servizio camera non valida (%(status)s)", status=resp.status_code),
|
||||
}), 502
|
||||
return jsonify({"sources": sources}), 200
|
||||
|
||||
|
||||
# Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id
|
||||
@maker_bp.route("/api/stations/<int:station_id>/camera/grab", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@@ -558,7 +611,7 @@ def api_station_camera_grab(station_id: int):
|
||||
resp = http_requests.post(
|
||||
f"{camera_url.rstrip('/')}/camera/grab",
|
||||
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
||||
timeout=15,
|
||||
timeout=CAMERA_GRAB_TIMEOUT_S, # #106: era 15, troppo corto alla prima apertura GenTL
|
||||
)
|
||||
except http_requests.RequestException as exc:
|
||||
return jsonify({
|
||||
@@ -652,6 +705,65 @@ def api_dxf_compare_recipe(task_id: int):
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# #105: parametri della ricetta DXF gia' salvata sul task, per riaprire il wizard compilato
|
||||
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare/source", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_dxf_compare_source(task_id: int):
|
||||
"""Proxy: parametri e file della ricetta DXF del task (404 se non ce n'e' una) (#105)."""
|
||||
resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe/dxf-compare/source")
|
||||
# Codice e corpo del backend arrivano com'e': il 404 vuol dire «nessuna ricetta da DXF»
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# #105: un file della cartella ricetta (DXF, immagine di riferimento) come byte, non JSON
|
||||
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/files/<path:path>", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_vision_recipe_file(task_id: int, path: str):
|
||||
"""Proxy: byte di un file della cartella ricetta del task, con content-type del backend (#105)."""
|
||||
# Niente segmenti '..' o backslash: urllib3 normalizzerebbe l'URL fuori da questa rotta del backend
|
||||
if "\\" in path or ".." in path.split("/"):
|
||||
return jsonify({"error": True, "detail": _("File non trovato")}), 404
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{Config.API_SERVER_URL.rstrip('/')}/api/vision/tasks/{task_id}/recipe/files/{quote(path)}",
|
||||
headers={"X-API-Key": session.get("api_key", "")},
|
||||
timeout=30,
|
||||
)
|
||||
except http_requests.RequestException as exc:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Servizio non raggiungibile: %(detail)s", detail=str(exc)),
|
||||
}), 502
|
||||
return Response(
|
||||
resp.content,
|
||||
status=resp.status_code,
|
||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
|
||||
|
||||
# #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):
|
||||
|
||||
@@ -17,6 +17,9 @@ from services.file_proxy import proxy_file
|
||||
|
||||
measure_bp = Blueprint("measure", __name__)
|
||||
|
||||
# #106: la prima apertura GenTL dopo l'avvio supera 15 s, il grab ha bisogno di piu margine
|
||||
CAMERA_GRAB_TIMEOUT_S = 60
|
||||
|
||||
# Session key holding a station chosen at runtime, overriding Config.STATION_CODE.
|
||||
STATION_OVERRIDE_KEY = "station_override"
|
||||
|
||||
@@ -437,7 +440,8 @@ def task_complete(recipe_id: int):
|
||||
m["subtask"] = st
|
||||
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
|
||||
# Compute deviation if not present
|
||||
if m.get("deviation") is None and st.get("nominal") is not None:
|
||||
# #118: quota non misurata (value None): la deviazione resta None (il template mostra NaN)
|
||||
if m.get("deviation") is None and st.get("nominal") is not None and m.get("value") is not None:
|
||||
try:
|
||||
m["deviation"] = m["value"] - st["nominal"]
|
||||
except (TypeError, KeyError):
|
||||
@@ -1117,7 +1121,7 @@ def api_vision_grab():
|
||||
resp = http_requests.post(
|
||||
f"{camera_url.rstrip('/')}/camera/grab",
|
||||
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
||||
timeout=15,
|
||||
timeout=CAMERA_GRAB_TIMEOUT_S, # #106: era 15, troppo corto alla prima apertura GenTL
|
||||
)
|
||||
except http_requests.RequestException as exc:
|
||||
return jsonify({
|
||||
|
||||
@@ -84,16 +84,18 @@ function csvExport() {
|
||||
const toleranceMinus = this.hasNumber(nominal) && this.hasNumber(subtask.ltl)
|
||||
? nominal - subtask.ltl : null;
|
||||
|
||||
// #118: quota non misurata (value null) -> NaN nel valore e nella deviazione; le tolleranze nulle restano vuote
|
||||
const notMeasured = m.value === null || m.value === undefined;
|
||||
const row = [
|
||||
m.id || '',
|
||||
m.subtask_id || '',
|
||||
this.escapeCsvValue(subtask.description || ''),
|
||||
this.formatNumber(m.value),
|
||||
notMeasured ? 'NaN' : this.formatNumber(m.value),
|
||||
this.escapeCsvValue(subtask.unit || 'mm'),
|
||||
this.formatNumber(nominal),
|
||||
this.formatNumber(tolerancePlus),
|
||||
this.formatNumber(toleranceMinus),
|
||||
this.formatNumber(m.deviation),
|
||||
notMeasured ? 'NaN' : this.formatNumber(m.deviation),
|
||||
m.pass_fail || '',
|
||||
this.escapeCsvValue(m.lot_number || ''),
|
||||
this.escapeCsvValue(m.serial_number || ''),
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'.
|
||||
// Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js.
|
||||
function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
|
||||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||
// #121: i colori per ruolo stanno in VisionOverlay (vision_overlay.js)
|
||||
const VALUE_UNITS = { 'dxf.points_in_pct': '%', 'dxf.distance_avg': 'mm', 'dxf.distance_min': 'mm', 'dxf.distance_max': 'mm' }; // #104
|
||||
const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
|
||||
|
||||
return {
|
||||
@@ -20,7 +20,15 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
imageFile: null, // immagine caricata o acquisita
|
||||
imageName: '',
|
||||
imageSrc: '',
|
||||
imagePx: null, // #102: {w, h} in pixel, letto dall'<img> dell'anteprima
|
||||
imageBroken: false, // #102: il browser non sa mostrare il formato (il file resta valido per l'invio)
|
||||
calPxMm: null, // #102: px/mm della calibrazione della stazione
|
||||
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,
|
||||
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
|
||||
@@ -29,10 +37,108 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
error: '',
|
||||
notice: '',
|
||||
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
|
||||
imageSource: '', // #104: '' | 'file' | 'camera'; il campo mm/px vale solo per 'file'
|
||||
tested: false, // #104: true dopo una Prova (anche fallita): mostra la sezione del risultato
|
||||
// #122: zoom, Adatta e pan delle tre viste (immagine, DXF, Prova) ora stanno nel componente <tmf-viewer>
|
||||
runPxPerMm: null, // #104: scala usata nell'ultima Prova (null = calibrazione stazione)
|
||||
runBandMm: null, // #104: banda usata nell'ultima Prova
|
||||
runMinPointsPct: null, // #104: soglia usata nell'ultima Prova
|
||||
existing: null, // #105: risposta di /source (ricetta DXF gia' salvata sul task); null = nessuna
|
||||
existingInfo: false, // #105: «Ricetta esistente caricata...» dopo aver compilato il wizard
|
||||
existingWarn: '', // #105: avviso (immagine non salvata, file illeggibili)
|
||||
dxfFromRecipe: false, // #105: il DXF viene dalla ricetta (l'<input type=file> non puo' mostrarne il nome)
|
||||
_existingApplied: false, // #105: il wizard e' gia' stato compilato dalla ricetta (non sovrascrivere le modifiche)
|
||||
_existingReady: null, // #105: promessa della prima lettura di /source
|
||||
_srcSeq: 0, // #105: una risposta di /source arrivata dopo una lettura piu' recente si scarta
|
||||
_dxfPickSeq: 0, // #105: sale a ogni scelta manuale del DXF; i file della ricetta arrivati dopo si scartano
|
||||
_imgPickSeq: 0, // #105: idem per l'immagine (_setImage)
|
||||
|
||||
// #105: all'avvio legge se il task ha gia' una ricetta da DXF (serve per l'etichetta del bottone)
|
||||
init() {
|
||||
if (this.taskId) this._existingReady = this.loadExisting();
|
||||
},
|
||||
|
||||
async toggle() {
|
||||
this.open = !this.open;
|
||||
if (this.open && !this.stations.length) await this.loadStations();
|
||||
if (this.open) {
|
||||
this.openExisting(); // #105: compila il wizard dalla ricetta esistente, in parallelo alle stazioni
|
||||
if (!this.stations.length) await this.loadStations();
|
||||
}
|
||||
},
|
||||
|
||||
// #105: GET /source -> this.existing (null se 404 o errore)
|
||||
async loadExisting() {
|
||||
const seq = ++this._srcSeq;
|
||||
let found = null;
|
||||
try {
|
||||
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare/source`);
|
||||
if (resp.ok) {
|
||||
const body = await resp.json().catch(() => null);
|
||||
if (body && typeof body === 'object' && !body.error) found = body;
|
||||
}
|
||||
} catch (e) { console.error('dxfRecipeWizard source', e); }
|
||||
if (seq !== this._srcSeq) return; // lettura piu' recente in corso
|
||||
this.existing = found;
|
||||
},
|
||||
|
||||
// #105: all'apertura, una sola volta, riempie il wizard con la ricetta esistente
|
||||
async openExisting() {
|
||||
if (this._existingReady) await this._existingReady;
|
||||
if (!this.existing || this._existingApplied) return;
|
||||
await this.applyExisting();
|
||||
},
|
||||
|
||||
// #105: mm/px da px/mm, fino a 6 cifre significative e virgola decimale; '' se non valido
|
||||
_mmPxFrom(pxPerMm) {
|
||||
const v = Number(pxPerMm);
|
||||
if (!Number.isFinite(v) || v <= 0) return '';
|
||||
return String(Number((1 / v).toPrecision(6))).replace('.', ',');
|
||||
},
|
||||
|
||||
// #105: un file della cartella ricetta -> File; null se assente o illeggibile (in quel caso avvisa)
|
||||
async _fetchRecipeFile(name) {
|
||||
try {
|
||||
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/files/${encodeURIComponent(name)}`);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const blob = await resp.blob();
|
||||
return new File([blob], name, { type: blob.type || '' });
|
||||
} catch (e) {
|
||||
console.error('dxfRecipeWizard file', name, e);
|
||||
this.existingWarn = this.msgs.existingFilesFailed || 'Cannot read the recipe files';
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// #105: valori salvati nei campi, poi DXF e immagine di riferimento dalla cartella ricetta
|
||||
async applyExisting() {
|
||||
const e = this.existing;
|
||||
if (!e) return;
|
||||
this._existingApplied = true;
|
||||
this.error = '';
|
||||
this.notice = '';
|
||||
this.existingWarn = '';
|
||||
const band = Number(e.band_mm), pct = Number(e.min_points_pct);
|
||||
if (Number.isFinite(band) && band > 0) this.bandMm = band;
|
||||
if (Number.isFinite(pct) && pct > 0) this.minPointsPct = pct;
|
||||
this.dark = e.polarity === 'dark';
|
||||
const mm = this._mmPxFrom(e.px_per_mm);
|
||||
if (mm) this.mmPx = mm; // il campo compare quando l'immagine e' da file
|
||||
this.existingInfo = true;
|
||||
// Sequenze lette prima di scaricare: se l'utente sceglie un file a mano nel frattempo, vince lui
|
||||
const dxfSeq = this._dxfPickSeq, imgSeq = this._imgPickSeq;
|
||||
const [dxf, img] = await Promise.all([
|
||||
e.dxf ? this._fetchRecipeFile(e.dxf) : null,
|
||||
e.image ? this._fetchRecipeFile(e.image) : null,
|
||||
]);
|
||||
if (dxf && dxfSeq === this._dxfPickSeq) {
|
||||
this.dxfFile = dxf;
|
||||
this.dxfFromRecipe = true;
|
||||
this.result = null;
|
||||
this.tested = false;
|
||||
this.loadDxfPreview();
|
||||
}
|
||||
if (img && imgSeq === this._imgPickSeq) this._setImage(img, 'file'); // 'file': il campo mm/px compare gia' compilato
|
||||
if (!e.image && !this.existingWarn) this.existingWarn = this.msgs.noReferenceImage || 'Reference image not saved';
|
||||
},
|
||||
|
||||
async loadStations() {
|
||||
@@ -49,33 +155,147 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
|
||||
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
|
||||
async onStation() {
|
||||
this.calState = ''; this.calLabel = ''; this.calUrl = '';
|
||||
this.calState = ''; this.calLabel = ''; this.calUrl = ''; this.calPxMm = null;
|
||||
this.result = null;
|
||||
this.tested = false; // #104
|
||||
if (!this.stationId) return;
|
||||
const r = await fetchStationCalibration(this.stationId);
|
||||
this.calState = r.state || 'unreachable';
|
||||
this.calUrl = r.calibration_url || '';
|
||||
if (this.calState === 'calibrated') this.calLabel = formatCalibration(r.calibration || {});
|
||||
if (this.calState === 'calibrated') {
|
||||
this.calLabel = formatCalibration(r.calibration || {});
|
||||
const c = Number((r.calibration || {}).px_per_mm); // #102: scala per il campo in mm dell'anteprima
|
||||
this.calPxMm = Number.isFinite(c) && c > 0 ? c : null;
|
||||
}
|
||||
},
|
||||
|
||||
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; },
|
||||
// #104: la scala a mano vale solo per un'immagine da file; con la camera vale la calibrazione della stazione
|
||||
get useManualScale() { return this.imageSource === 'file' && this.manualPxMm !== null; },
|
||||
get canRun() {
|
||||
return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy;
|
||||
// #98: la scala a mano sostituisce la calibrazione della stazione (#104: solo per immagini da file)
|
||||
return !!(this.stationId && (this.calibrated || this.useManualScale) && this.imageFile && this.dxfFile) && !this.busy;
|
||||
},
|
||||
|
||||
_setImage(file) {
|
||||
_setImage(file, source) {
|
||||
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||
this.imageFile = file;
|
||||
this.imageName = file.name || '';
|
||||
this.imageSrc = URL.createObjectURL(file);
|
||||
// #102: con file null (immagine tolta) si azzera anche l'anteprima
|
||||
this.imageFile = file || null;
|
||||
this.imageName = (file && file.name) || '';
|
||||
this.imageSrc = file ? URL.createObjectURL(file) : '';
|
||||
this.imagePx = null;
|
||||
this.imageBroken = false;
|
||||
this.result = null;
|
||||
this.tested = false; // #104
|
||||
this.imageSource = file ? (source || '') : ''; // #104
|
||||
this._imgPickSeq++; // #105: una scelta piu' recente batte i file della ricetta ancora in arrivo
|
||||
},
|
||||
|
||||
// #102: il <tmf-viewer> ha caricato l'immagine (evento viewer-load): legge i pixel dal detail
|
||||
// #122: lo zoom e' del componente; qui restano solo le dimensioni per il riepilogo
|
||||
onImagePreviewLoad(d) {
|
||||
this.imageBroken = false;
|
||||
this.imagePx = d && d.width ? { w: d.width, h: d.height } : null;
|
||||
},
|
||||
// #102: se il formato non e' mostrabile (sonda <img hidden> del template) compare l'avviso
|
||||
onImagePreviewError() {
|
||||
this.imageBroken = !!this.imageSrc;
|
||||
this.imagePx = null;
|
||||
},
|
||||
// #102: nome usato dal template (stesso valore dell'URL gia' usato per l'overlay dei risultati)
|
||||
get imagePreviewUrl() { return this.imageSrc; },
|
||||
// #102: «W × H px · campo ≈ L × H mm»; scala = mm/px a mano se valido, altrimenti calibrazione della stazione
|
||||
get imagePreviewSummary() {
|
||||
const p = this.imagePx;
|
||||
if (!p) return '';
|
||||
const lang = document.documentElement.lang || 'it';
|
||||
const f = (v, d) => Number(v).toLocaleString(lang, { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
let text = `${f(p.w, 0)} × ${f(p.h, 0)} px`;
|
||||
const pxMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null); // #104
|
||||
if (pxMm) text += ` · ${this.msgs.imageField || 'field'} ≈ ${f(p.w / pxMm, 2)} × ${f(p.h / pxMm, 2)} mm`;
|
||||
return text;
|
||||
},
|
||||
|
||||
onImage(ev) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
if (f) this._setImage(f);
|
||||
if (f) this._setImage(f, 'file'); // #104
|
||||
},
|
||||
onDxf(ev) {
|
||||
this.dxfFile = (ev.target.files && ev.target.files[0]) || null;
|
||||
this._dxfPickSeq++; // #105: scelta a mano: il DXF della ricetta in arrivo si scarta
|
||||
this.dxfFromRecipe = false; // #105
|
||||
this.result = null;
|
||||
this.tested = false; // #104
|
||||
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'}`;
|
||||
},
|
||||
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; },
|
||||
|
||||
// Frame dalla camera della stazione scelta
|
||||
async grab() {
|
||||
@@ -91,7 +311,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
this.error = body.detail || this.msgs.requestFailed || 'Error';
|
||||
} else {
|
||||
const blob = await resp.blob();
|
||||
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }));
|
||||
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }), 'camera'); // #104
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = this.msgs.requestFailed || String(e);
|
||||
@@ -115,11 +335,17 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
this.busy = true;
|
||||
this.error = '';
|
||||
this.notice = '';
|
||||
// #104: parametri usati, mostrati accanto ai risultati
|
||||
this.runPxPerMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null);
|
||||
this.runBandMm = this.bandMm;
|
||||
this.runMinPointsPct = this.minPointsPct;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
|
||||
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
||||
fd.append('station_id', this.stationId);
|
||||
// #98: scala a mano; il backend la preferisce alla calibrazione della stazione (#104: solo con immagine da file)
|
||||
if (this.useManualScale) fd.append('px_per_mm', String(this.manualPxMm));
|
||||
fd.append('band_mm', String(this.bandMm));
|
||||
fd.append('min_points_pct', String(this.minPointsPct));
|
||||
fd.append('polarity', this.dark ? 'dark' : 'light');
|
||||
@@ -132,50 +358,58 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
this.error = this._errorText(body);
|
||||
} else {
|
||||
this.result = body;
|
||||
this.$nextTick(() => this.drawOverlay());
|
||||
if (save && body.saved) {
|
||||
this.notice = this.msgs.saved || 'Saved';
|
||||
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
|
||||
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId });
|
||||
// #105: il task ha ora una ricetta (etichetta «Modifica»); il wizard e' gia' allineato, non si ricompila
|
||||
this._existingApplied = true;
|
||||
this.existingInfo = false;
|
||||
this.existingWarn = ''; // #105: l'immagine di riferimento ora e' salvata con la ricetta
|
||||
await this.loadExisting();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = this.msgs.requestFailed || String(e);
|
||||
}
|
||||
this.busy = false;
|
||||
// #104: la sezione del risultato compare anche con Prova fallita (immagine a sinistra, errore a destra)
|
||||
this.tested = true;
|
||||
},
|
||||
|
||||
// Le quattro uscite del confronto, nell'ordine fisso
|
||||
get values() {
|
||||
const out = (this.result && this.result.result && this.result.result.outputs) || {};
|
||||
return VALUE_KEYS.map(k => ({ key: k, value: out[k] }));
|
||||
return VALUE_KEYS.map(k => ({ key: k, value: out[k], label: this._valueLabel(k), unit: VALUE_UNITS[k] })); // #104
|
||||
},
|
||||
|
||||
// #104: etichette leggibili (testi passati da msgs), chiave grezza come ripiego
|
||||
_valueLabel(k) {
|
||||
const m = { 'dxf.points_in_pct': this.msgs.pointsInBand, 'dxf.distance_avg': this.msgs.distAvg,
|
||||
'dxf.distance_min': this.msgs.distMin, 'dxf.distance_max': this.msgs.distMax };
|
||||
return m[k] || k;
|
||||
},
|
||||
|
||||
// #104: esito della ricetta sul riferimento: pass / warning / fail (src/vision/runner.py); altro = valore grezzo
|
||||
get outcome() { return (this.result && this.result.result && this.result.result.outcome) || ''; },
|
||||
get outcomeLabel() {
|
||||
const m = { pass: this.msgs.outcomePass, warning: this.msgs.outcomeWarning, fail: this.msgs.outcomeFail };
|
||||
return m[this.outcome] || this.outcome;
|
||||
},
|
||||
get outcomeClass() {
|
||||
return { pass: 'text-emerald-600', warning: 'text-amber-600', fail: 'text-red-500' }[this.outcome] || 'text-[var(--text-muted)]';
|
||||
},
|
||||
// #104: scala usata nell'ultima Prova, con la fonte
|
||||
get scaleSummary() {
|
||||
if (!this.runPxPerMm) return this.msgs.scaleStation || 'station calibration';
|
||||
const lang = document.documentElement.lang || 'it';
|
||||
const mm = (1 / this.runPxPerMm).toLocaleString(lang, { maximumFractionDigits: 6 });
|
||||
return `${mm} mm/px (${this.imageSource === 'file' ? (this.msgs.scaleManual || 'manual') : (this.msgs.scaleStation || 'station calibration')})`;
|
||||
},
|
||||
|
||||
fmt(v) {
|
||||
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
|
||||
{ maximumFractionDigits: 3 }) : '-';
|
||||
},
|
||||
|
||||
// Come vision_execute.js: canvas della dimensione dell'immagine, coordinate in pixel immagine
|
||||
drawOverlay() {
|
||||
const img = this.$refs.img, cv = this.$refs.overlay;
|
||||
if (!img || !cv || !img.naturalWidth) return;
|
||||
cv.width = img.naturalWidth;
|
||||
cv.height = img.naturalHeight;
|
||||
const g = cv.getContext('2d');
|
||||
g.clearRect(0, 0, cv.width, cv.height);
|
||||
g.lineWidth = Math.max(2, cv.width / 400);
|
||||
g.lineJoin = 'round';
|
||||
const shapes = (this.result && this.result.result && this.result.result.overlay) || [];
|
||||
for (const item of shapes) {
|
||||
const rows = item.rows || [], cols = item.cols || [];
|
||||
if (!rows.length || rows.length !== cols.length) continue;
|
||||
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
|
||||
g.beginPath();
|
||||
g.moveTo(cols[0], rows[0]);
|
||||
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
|
||||
g.stroke();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* image_to_png.js - #107: converte nel browser le immagini che il backend non ammette (es. BMP) in PNG.
|
||||
*
|
||||
* Cosa fa: tmfNeedsPng(type) dice se il MIME e' un'immagine fuori da PNG/JPEG/GIF/WebP;
|
||||
* tmfImageToPng(file) la ridisegna su canvas e ritorna un File PNG (null se illeggibile, es. TIFF).
|
||||
* Come si usa: await tmfImageToPng(file) prima del ritaglio e del caricamento.
|
||||
* Dipende da: createImageBitmap e canvas del browser (non servono nei test: il caso "nessuna conversione" e' puro).
|
||||
*/
|
||||
// #107: tipi che il backend ammette gia': non vanno convertiti
|
||||
const TMF_PNG_PASSTHROUGH = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
|
||||
|
||||
// #107: true per un'immagine (image/*) non ammessa dal backend, es. image/bmp
|
||||
function tmfNeedsPng(type) {
|
||||
return typeof type === 'string' && type.indexOf('image/') === 0 && TMF_PNG_PASSTHROUGH.indexOf(type) === -1;
|
||||
}
|
||||
|
||||
// #107: converte in PNG con canvas; ritorna il file invariato se non serve, null se il browser non sa decodificarlo
|
||||
async function tmfImageToPng(file) {
|
||||
if (!file || !tmfNeedsPng(file.type)) return file;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
if (bitmap.close) bitmap.close();
|
||||
const blob = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
|
||||
if (!blob) return null;
|
||||
const base = (file.name || 'immagine').replace(/\.[^.]+$/, '');
|
||||
return new File([blob], base + '.png', { type: 'image/png' });
|
||||
} catch (err) {
|
||||
// #107: formato non decodificabile dal browser (es. TIFF)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// #107: export per il test node (nel browser module non esiste)
|
||||
if (typeof module !== 'undefined') module.exports = { tmfNeedsPng, tmfImageToPng };
|
||||
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* image_viewer.js - #122: <tmf-viewer>, il visualizzatore immagine standard di TieMeasureFlow.
|
||||
*
|
||||
* Cosa fa: Custom Element in JS puro (senza Alpine ne' Tailwind) con zoom -/+, Adatta, Salva, percentuale,
|
||||
* rotella (e Ctrl+rotella) intorno al puntatore, pan col puntatore, overlay SVG a tratto costante ed etichette,
|
||||
* menu dei livelli in alto a sinistra (#12).
|
||||
* Sostituisce le quattro implementazioni di zoom di prima (pannello operatore #115, wizard DXF #113 e #104,
|
||||
* pagina camera #111). Light DOM, stile iniettato una volta nel <head> (<style id="tmf-viewer-css">).
|
||||
* Come si usa: <script> vision_overlay.js e save_image.js, poi image_viewer.js; poi
|
||||
* <tmf-viewer src="..." width="1920" height="1080" save-name="task_14" fit-height></tmf-viewer>
|
||||
* Attributi: src, width, height (px logici), save-name, save-src, no-save, fit-height,
|
||||
* label-fit, label-save, title-in, title-out, title-save.
|
||||
* Senza src, un figlio <svg> (DXF) va nello stage al 100 % (modo contenuto; Salva lo rasterizza in PNG).
|
||||
* Proprieta': overlay (forme vs-tools -> VisionOverlay.svg), labels ([{key,x,y,text,cls}] in px logici),
|
||||
* zoom (sola lettura); metodi fit(), zoomBy(f). Eventi (bubbling): viewer-load {width,height}, viewer-zoom {zoom,fit}.
|
||||
* #12 livelli: la rotella senza Ctrl zooma sul punto del mouse. L'overlay sta in tre gruppi SVG
|
||||
* (<g data-layer>): ricerca, costruzioni (opacity 0.5), quote; un menu a caselle (.tmfv-layers) accende
|
||||
* Immagine, Costruzioni, Ricerca (spenta di default), Quote, Nomi. #126 attributo no-layers = viewer semplice, come
|
||||
* prima dei livelli: niente menu, un solo disegno (VisionOverlay.svg di tutte le forme, poi le quote), nessun <g data-layer>
|
||||
* ne' opacity, forme e immagine sempre visibili, etichette sempre accese (text) e non trascinabili (niente quota-spostata);
|
||||
* zoom con rotella, pan, Adatta e Salva restano. Lo stato dei livelli non conta con no-layers. Proprieta'
|
||||
* layers get/set {immagine,costruzioni,ricerca,quote,nomi}, evento viewer-layers {layers}; stato in localStorage
|
||||
* (chiave tmf-viewer-layers[:save-name]). Etichette estese: {key,x,y,text,breve,cls,nome,camera,quota,testo};
|
||||
* con quota stanno sul punto della scritta (VisionOverlay.quotaPunto) e si trascinano: evento quota-spostata
|
||||
* {key,nome,camera,testo:[du,dv]|null} (doppio clic = testo null, posizione di default).
|
||||
* In Alpine: :src="...", x-effect="$el.overlay = ...; $el.labels = ...".
|
||||
* Dipende da: window.VisionOverlay (facoltativo, per l'overlay), tmfSaveImage/tmfSaveBlob/tmfSvgToPngBlob di save_image.js
|
||||
* (per Salva). Il modulo si carica anche in node (require): esporta solo TmfViewerMath, funzioni pure.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var TAG = 'tmf-viewer';
|
||||
var STEP = 1.25; // #122: passo dei pulsanti +/- e della rotella (come #111 e #115)
|
||||
// #12: livelli del menu, titoli, valori di default e colore delle quote per esito (classi cls di stazione.js)
|
||||
var LAYER_NAMES = ['immagine', 'costruzioni', 'ricerca', 'quote', 'nomi'];
|
||||
var LAYER_TITLES = { immagine: 'Immagine', costruzioni: 'Costruzioni', ricerca: 'Ricerca', quote: 'Quote', nomi: 'Nomi' };
|
||||
var LAYER_DEFAULT = { immagine: true, costruzioni: true, ricerca: false, quote: true, nomi: true };
|
||||
var QUOTA_COLORS = { 'stz-ok': '#16a34a', 'stz-avviso': '#d97706', 'stz-ko': '#dc2626' };
|
||||
var QUOTA_COLOR_DEFAULT = '#ef4444';
|
||||
|
||||
// #122: matematica pura dello zoom, testata in node senza browser
|
||||
var TmfViewerMath = {
|
||||
// zoom che fa stare tutta l'immagine (w x h) nel box (boxW x boxH); 0 se non calcolabile. boxH puo' essere Infinity
|
||||
fitZoom: function (boxW, boxH, w, h) {
|
||||
if (!(boxW > 0) || !(boxH > 0) || !(w > 0) || !(h > 0)) return 0;
|
||||
var f = Math.min(boxW / w, boxH / h);
|
||||
return isFinite(f) && f > 0 ? f : 0;
|
||||
},
|
||||
// zoom ristretto a [fit * 0.5, max(8, fit * 8)]: il massimo segue il fit, cosi' un DXF in mm (fit ~10) si ingrandisce ancora
|
||||
clampZoom: function (z, fit) {
|
||||
var lo = fit * 0.5, hi = Math.max(8, fit * 8);
|
||||
return Math.min(hi, Math.max(lo, z));
|
||||
},
|
||||
// nuovo scroll che tiene fermo il punto (px, py) del viewport passando dallo zoom z0 a z1
|
||||
zoomAround: function (z0, z1, scrollL, scrollT, px, py) {
|
||||
var k = z1 / z0;
|
||||
return { scrollLeft: (scrollL + px) * k - px, scrollTop: (scrollT + py) * k - py };
|
||||
},
|
||||
pct: function (z) { return Math.round(z * 100) + ' %'; },
|
||||
};
|
||||
|
||||
var CSS = [
|
||||
'tmf-viewer{display:block;position:relative;overflow:hidden;background:#1f2937;min-width:0}',
|
||||
'tmf-viewer *{box-sizing:border-box}',
|
||||
'tmf-viewer [hidden]{display:none!important}',
|
||||
// #122: anche l'host con l'attributo hidden si nasconde (display:block sopra vincerebbe sul [hidden] del browser)
|
||||
'tmf-viewer[hidden]{display:none!important}',
|
||||
'tmf-viewer .tmfv-vp{position:absolute;top:0;right:0;bottom:0;left:0;overflow:auto;display:flex}',
|
||||
'tmf-viewer[fit-height] .tmfv-vp{position:relative;width:100%}',
|
||||
'tmf-viewer .tmfv-stage{position:relative;flex:none;margin:auto;user-select:none;-webkit-user-select:none}',
|
||||
'tmf-viewer .tmfv-img{display:block;width:100%;height:100%;max-width:none;-webkit-user-drag:none}',
|
||||
'tmf-viewer .tmfv-stage>svg:not(.tmfv-ov){display:block;width:100%;height:100%}',
|
||||
'tmf-viewer .tmfv-ov{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:visible}',
|
||||
'tmf-viewer .tmfv-labels{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}',
|
||||
// #122: aspetto completo dell'etichetta; le classi `cls` della pagina (es. bg-red-600 text-white) cambiano solo i colori
|
||||
'tmf-viewer .tmfv-label{position:absolute;transform:translate(8px,-50%);white-space:nowrap;font:700 12px/1.3 system-ui,sans-serif;',
|
||||
'padding:2px 6px;border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.4);pointer-events:none}',
|
||||
':where(tmf-viewer .tmfv-label){color:#fff;background:rgba(0,0,0,.6)}',
|
||||
'tmf-viewer .tmfv-bar{position:absolute;top:6px;right:6px;z-index:3;display:flex;align-items:center;gap:2px;padding:2px;',
|
||||
'background:rgba(0,0,0,.55);color:#fff;border-radius:6px;font:12px/1.4 system-ui,sans-serif}',
|
||||
'tmf-viewer .tmfv-bar button{appearance:none;border:0;border-radius:4px;background:transparent;color:#fff;',
|
||||
'padding:2px 8px;font:inherit;cursor:pointer;min-width:26px}',
|
||||
'tmf-viewer .tmfv-bar button:hover{background:rgba(255,255,255,.22)}',
|
||||
'tmf-viewer .tmfv-pct{min-width:46px;text-align:center;font-variant-numeric:tabular-nums}',
|
||||
// #12: etichette con key trascinabili; menu dei livelli in alto a sinistra, stile della barra
|
||||
'tmf-viewer .tmfv-label[data-key]{pointer-events:auto;cursor:move;touch-action:none}',
|
||||
// #126: con no-layers le etichette non si trascinano (stessa specificita' della regola sopra, viene dopo)
|
||||
'tmf-viewer[no-layers] .tmfv-label,tmf-viewer[no-layers] .tmfv-label[data-key]{pointer-events:none;cursor:default}',
|
||||
'tmf-viewer .tmfv-layers{position:absolute;top:6px;left:6px;z-index:3;display:flex;flex-wrap:wrap;align-items:center;gap:2px 10px;',
|
||||
'padding:3px 8px;max-width:calc(100% - 12px);background:rgba(0,0,0,.55);color:#fff;border-radius:6px;font:12px/1.4 system-ui,sans-serif}',
|
||||
'tmf-viewer .tmfv-layers label{display:inline-flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap}',
|
||||
'tmf-viewer .tmfv-layers input{margin:0;cursor:pointer}',
|
||||
].join('\n');
|
||||
|
||||
// #122: stile iniettato una volta sola nel <head>
|
||||
function injectCss() {
|
||||
if (typeof document === 'undefined' || document.getElementById('tmf-viewer-css')) return;
|
||||
var st = document.createElement('style');
|
||||
st.id = 'tmf-viewer-css';
|
||||
st.textContent = CSS;
|
||||
(document.head || document.documentElement).appendChild(st);
|
||||
}
|
||||
|
||||
function num(v) { var n = parseFloat(v); return n > 0 && isFinite(n) ? n : 0; }
|
||||
|
||||
// #122: dimensioni logiche di un <svg> dal viewBox, altrimenti da width/height
|
||||
function svgDims(svg) {
|
||||
var vb = svg.viewBox && svg.viewBox.baseVal;
|
||||
if (vb && vb.width > 0 && vb.height > 0) return { w: vb.width, h: vb.height };
|
||||
var a = (svg.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(Number);
|
||||
if (a.length === 4 && a[2] > 0 && a[3] > 0) return { w: a[2], h: a[3] };
|
||||
return { w: num(svg.getAttribute('width')), h: num(svg.getAttribute('height')) };
|
||||
}
|
||||
|
||||
function defineViewer() {
|
||||
var OBSERVED = ['src', 'width', 'height', 'fit-height', 'no-save', 'save-name',
|
||||
'label-fit', 'label-save', 'title-in', 'title-out', 'title-save', 'no-layers'];
|
||||
|
||||
class TmfViewer extends HTMLElement {
|
||||
static get observedAttributes() { return OBSERVED; }
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._built = false;
|
||||
this._w = 0; this._h = 0; // dimensioni logiche correnti
|
||||
this._nw = 0; this._nh = 0; // dimensioni naturali dell'immagine
|
||||
this._z = 0; this._fit = 0; this._auto = true;
|
||||
this._ov = []; this._lb = [];
|
||||
this._pan = null;
|
||||
this._content = null; // modo contenuto: l'<svg> adottato
|
||||
this._lastEvt = '';
|
||||
this._lay = Object.assign({}, LAYER_DEFAULT); // #12: livelli visibili
|
||||
this._ldrag = null; // #12: trascinamento di un'etichetta in corso
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
injectCss();
|
||||
if (this._built) return;
|
||||
this._built = true;
|
||||
// proprieta' impostate prima dell'upgrade dell'elemento: le riporto sugli accessor
|
||||
var self = this;
|
||||
this._layersLoad(); // #12: stato salvato, prima di eventuali proprieta' impostate dalla pagina
|
||||
['overlay', 'labels', 'layers'].forEach(function (k) {
|
||||
if (Object.prototype.hasOwnProperty.call(self, k)) { var v = self[k]; delete self[k]; self[k] = v; }
|
||||
});
|
||||
var kids = Array.prototype.slice.call(this.children);
|
||||
|
||||
var bar = document.createElement('div'); bar.className = 'tmfv-bar'; bar.hidden = true;
|
||||
this._bOut = this._btn('−'); this._bIn = this._btn('+'); this._bFit = this._btn(''); this._bSave = this._btn('');
|
||||
this._pctEl = document.createElement('span'); this._pctEl.className = 'tmfv-pct';
|
||||
bar.appendChild(this._bOut); bar.appendChild(this._bIn); bar.appendChild(this._bFit);
|
||||
bar.appendChild(this._bSave); bar.appendChild(this._pctEl);
|
||||
// #12: menu dei livelli (caselle) in alto a sinistra
|
||||
var lay = document.createElement('div'); lay.className = 'tmfv-layers'; lay.hidden = true;
|
||||
this._layBoxes = {};
|
||||
LAYER_NAMES.forEach(function (name) {
|
||||
var lb = document.createElement('label'), cb = document.createElement('input'), tx = document.createElement('span');
|
||||
cb.type = 'checkbox'; cb.setAttribute('data-layer', name); cb.checked = self._lay[name];
|
||||
tx.textContent = LAYER_TITLES[name];
|
||||
cb.addEventListener('change', function () { var o = {}; o[name] = cb.checked; self.layers = o; });
|
||||
lb.appendChild(cb); lb.appendChild(tx); lay.appendChild(lb);
|
||||
self._layBoxes[name] = cb;
|
||||
});
|
||||
var vp = document.createElement('div'); vp.className = 'tmfv-vp';
|
||||
var stage = document.createElement('div'); stage.className = 'tmfv-stage';
|
||||
var img = document.createElement('img'); img.className = 'tmfv-img'; img.draggable = false; img.alt = ''; img.hidden = true;
|
||||
var ov = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
ov.setAttribute('class', 'tmfv-ov'); ov.setAttribute('preserveAspectRatio', 'none');
|
||||
var labels = document.createElement('div'); labels.className = 'tmfv-labels';
|
||||
stage.appendChild(img); stage.appendChild(ov); stage.appendChild(labels);
|
||||
vp.appendChild(stage);
|
||||
this._layBar = lay; this._bar = bar; this._vp = vp; this._stage = stage; this._img = img; this._ovEl = ov; this._lbEl = labels;
|
||||
this.appendChild(bar); this.appendChild(lay); this.appendChild(vp);
|
||||
|
||||
this._bOut.onclick = function () { self.zoomBy(1 / STEP); };
|
||||
this._bIn.onclick = function () { self.zoomBy(STEP); };
|
||||
this._bFit.onclick = function () { self.fit(); };
|
||||
this._bSave.onclick = function () { self._save(); };
|
||||
img.addEventListener('load', function () { self._onLoad(); });
|
||||
// #122: errore di caricamento -> evento viewer-error (bubbling) con l'URL che non si e' caricato
|
||||
img.addEventListener('error', function () {
|
||||
if (!self._hasSrc()) return; // l'errore di un src appena tolto non conta
|
||||
self.dispatchEvent(new CustomEvent('viewer-error', { bubbles: true, detail: { src: self.getAttribute('src') } }));
|
||||
});
|
||||
// Ctrl+rotella = zoom intorno al puntatore; senza Ctrl la pagina scorre come sempre
|
||||
vp.addEventListener('wheel', function (ev) { self._wheel(ev); }, { passive: false });
|
||||
vp.addEventListener('pointerdown', function (ev) { self._panDown(ev); });
|
||||
vp.addEventListener('pointermove', function (ev) { self._panMove(ev); });
|
||||
vp.addEventListener('pointerup', function () { self._panEnd(); });
|
||||
vp.addEventListener('pointercancel', function () { self._panEnd(); });
|
||||
// resize del viewport: in stato «adatta» rifa Adatta; altrimenti aggiorna solo il fit
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
// #122: la callback cambia l'altezza del viewport (fit-height): rimando a un task dopo, senza «ResizeObserver loop»
|
||||
this._ro = new ResizeObserver(function () {
|
||||
if (self._raf) return;
|
||||
var run = function () { self._raf = 0; self._onResize(); };
|
||||
self._raf = setTimeout(run, 0);
|
||||
});
|
||||
this._ro.observe(vp);
|
||||
}
|
||||
// contenuto aggiunto dopo (x-html, template): lo adotto
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
this._mo = new MutationObserver(function () { self._adopt(); });
|
||||
this._mo.observe(this, { childList: true });
|
||||
this._mo2 = new MutationObserver(function () { self._refresh(); });
|
||||
}
|
||||
kids.forEach(function (k) { self._take(k); });
|
||||
this._texts();
|
||||
this._loadSrc();
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// gli osservatori restano: se l'elemento viene ricollegato il viewport e' lo stesso
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldV, newV) {
|
||||
if (!this._built || oldV === newV) return;
|
||||
if (name === 'src') this._loadSrc();
|
||||
else if (name === 'width' || name === 'height') this._refresh();
|
||||
else if (name === 'fit-height') { if (!this.hasAttribute('fit-height')) this._vp.style.height = ''; this._onResize(true); }
|
||||
else if (name === 'no-layers') { this._syncLayers(); this._drawOverlay(); this._drawLabels(); } // #12 #126: ridisegna al cambio
|
||||
else this._texts();
|
||||
}
|
||||
|
||||
// #126: viewer senza livelli (attributo no-layers): disegno semplice, etichette fisse
|
||||
_noLay() { return this.hasAttribute('no-layers'); }
|
||||
|
||||
_btn(txt) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button'; b.textContent = txt;
|
||||
return b;
|
||||
}
|
||||
|
||||
// #122: testi e titoli dei pulsanti dagli attributi
|
||||
_texts() {
|
||||
var g = function (el, n, d) { var v = el.getAttribute(n); return v === null || v === '' ? d : v; };
|
||||
this._bFit.textContent = g(this, 'label-fit', 'Adatta');
|
||||
this._bSave.textContent = g(this, 'label-save', 'Salva');
|
||||
this._bIn.title = g(this, 'title-in', 'Ingrandisci');
|
||||
this._bOut.title = g(this, 'title-out', 'Riduci');
|
||||
this._bSave.title = g(this, 'title-save', 'Salva immagine');
|
||||
this._bSave.hidden = this.hasAttribute('no-save');
|
||||
}
|
||||
|
||||
// #122: adotta un figlio <svg> come contenuto (modo contenuto, DXF)
|
||||
_take(el) {
|
||||
if (!el || el === this._bar || el === this._layBar || el === this._vp || el.parentNode !== this) return;
|
||||
if (String(el.tagName).toLowerCase() !== 'svg') return;
|
||||
if (this._content && this._content !== el && this._content.parentNode) this._content.parentNode.removeChild(this._content);
|
||||
this._content = el;
|
||||
this._stage.insertBefore(el, this._ovEl);
|
||||
if (this._mo2) this._mo2.observe(el, { attributes: true, attributeFilter: ['viewBox', 'width', 'height'] });
|
||||
}
|
||||
|
||||
_adopt() {
|
||||
var kids = Array.prototype.slice.call(this.children), self = this, found = false;
|
||||
kids.forEach(function (k) { if (k !== self._bar && k !== self._layBar && k !== self._vp) { self._take(k); found = true; } });
|
||||
if (found) this._refresh();
|
||||
}
|
||||
|
||||
// #122: (ri)carica l'immagine dall'attributo src
|
||||
_loadSrc() {
|
||||
var src = this.getAttribute('src');
|
||||
if (src) {
|
||||
this._img.hidden = false;
|
||||
this._img.src = src;
|
||||
} else {
|
||||
this._img.removeAttribute('src');
|
||||
this._img.hidden = true;
|
||||
this._nw = 0; this._nh = 0;
|
||||
this._refresh();
|
||||
}
|
||||
}
|
||||
|
||||
_onLoad() {
|
||||
var img = this._img;
|
||||
this._nw = img.naturalWidth; this._nh = img.naturalHeight;
|
||||
this._refresh();
|
||||
this.dispatchEvent(new CustomEvent('viewer-load', { bubbles: true, detail: { width: this._w, height: this._h } }));
|
||||
}
|
||||
|
||||
_hasSrc() { return !!this.getAttribute('src'); }
|
||||
|
||||
// #122: ricalcola le dimensioni logiche; Adatta se cambiano (o se la vista era ad Adatta), zoom mantenuto altrimenti
|
||||
_refresh() {
|
||||
if (!this._built) return;
|
||||
var w = num(this.getAttribute('width')), h = num(this.getAttribute('height'));
|
||||
if (this._hasSrc()) {
|
||||
if (!w) w = this._nw;
|
||||
if (!h) h = this._nh;
|
||||
if (!this._nw || !this._nh) { if (!(w && h)) { w = 0; h = 0; } }
|
||||
} else if (this._content) {
|
||||
var d = svgDims(this._content);
|
||||
if (!w) w = d.w;
|
||||
if (!h) h = d.h;
|
||||
} else { w = 0; h = 0; }
|
||||
if (!(w > 0 && h > 0)) { w = 0; h = 0; }
|
||||
var changed = w !== this._w || h !== this._h;
|
||||
this._w = w; this._h = h;
|
||||
this._bar.hidden = !w;
|
||||
this._img.hidden = !this._hasSrc();
|
||||
if (this._content) this._content.style.display = this._hasSrc() ? 'none' : '';
|
||||
if (!w) { this._z = 0; this._fit = 0; this._auto = true; this._apply(); return; }
|
||||
if (changed || this._auto || !this._z) this.fit(); else this._apply();
|
||||
if (!this._z) this._apply(true); // riquadro nascosto: Adatta rimandata, ma overlay ed etichette (in %) si disegnano gia'
|
||||
}
|
||||
|
||||
// #122: area utile del viewport: offset meno i bordi (client* escludono le barre che spariranno dopo l'Adatta)
|
||||
_area() {
|
||||
var vp = this._vp, cw = vp.clientWidth, ch = vp.clientHeight, bx = 0, by = 0;
|
||||
if (vp.offsetWidth && typeof getComputedStyle === 'function') {
|
||||
var cs = getComputedStyle(vp);
|
||||
bx = (parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth)) || 0;
|
||||
by = (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)) || 0;
|
||||
cw = vp.offsetWidth - bx;
|
||||
ch = vp.offsetHeight - by;
|
||||
}
|
||||
return { cw: cw, ch: ch, by: by };
|
||||
}
|
||||
|
||||
// zoom di Adatta per l'area corrente; 0 se non calcolabile (riquadro nascosto o vuoto)
|
||||
_calcFit() {
|
||||
if (!this._w) return 0;
|
||||
var a = this._area();
|
||||
if (this.hasAttribute('fit-height')) return TmfViewerMath.fitZoom(a.cw, Infinity, this._w, this._h);
|
||||
return TmfViewerMath.fitZoom(a.cw, a.ch, this._w, this._h);
|
||||
}
|
||||
|
||||
// #122: altezza del viewport in fit-height: segue l'immagine adattata alla larghezza
|
||||
_fitHeightBox(fit) {
|
||||
if (!this.hasAttribute('fit-height')) return;
|
||||
this._vp.style.height = fit > 0 ? (this._h * fit + this._area().by) + 'px' : '';
|
||||
}
|
||||
|
||||
fit() {
|
||||
if (!this._built || !this._w) return;
|
||||
var f = this._calcFit();
|
||||
if (!(f > 0)) return; // riquadro nascosto: ci pensa l'osservatore
|
||||
this._fit = f; this._z = f; this._auto = true;
|
||||
this._fitHeightBox(f);
|
||||
this._vp.scrollLeft = 0; this._vp.scrollTop = 0;
|
||||
this._apply();
|
||||
}
|
||||
|
||||
// resize del viewport: Adatta se la vista non e' stata zoomata, altrimenti fit aggiornato e zoom nei limiti
|
||||
_onResize(force) {
|
||||
if (!this._built || !this._w) return;
|
||||
var f = this._calcFit();
|
||||
if (!(f > 0)) return;
|
||||
if (!force && Math.abs(f - this._fit) < 1e-9 && this._z) return;
|
||||
if (this._auto) { this.fit(); return; }
|
||||
this._fit = f;
|
||||
this._z = TmfViewerMath.clampZoom(this._z, f);
|
||||
this._fitHeightBox(f);
|
||||
this._apply();
|
||||
}
|
||||
|
||||
// #122: zoom di un fattore f tenendo fermo il punto (px, py) del viewport (default: il centro)
|
||||
zoomBy(f, px, py, ancora) { // `ancora` {ix, iy} (interna, #12): punto immagine da tenere sotto (px, py)
|
||||
if (!this._built || !this._w || !this._z) return;
|
||||
var vp = this._vp, z0 = this._z;
|
||||
var z1 = TmfViewerMath.clampZoom(z0 * f, this._fit);
|
||||
if (z1 === z0) return;
|
||||
if (px == null) px = vp.clientWidth / 2;
|
||||
if (py == null) py = vp.clientHeight / 2;
|
||||
// #12: punto immagine sotto (px, py) misurato sullo stage reale, cosi' resta fermo anche quando lo stage e' centrato
|
||||
// dal margin:auto (immagine piu' piccola del viewport prima dello zoom); zoomAround copre il caso senza geometria
|
||||
var sr = this._stage.getBoundingClientRect(), vr = vp.getBoundingClientRect();
|
||||
var ix = (px - (sr.left - vr.left - vp.clientLeft)) / z0, iy = (py - (sr.top - vr.top - vp.clientTop)) / z0;
|
||||
// #12: con l'ancora di una sequenza di rotella si riporta sotto il mouse il punto di partenza, non quello corrente
|
||||
if (ancora && isFinite(ancora.ix) && isFinite(ancora.iy)) { ix = ancora.ix; iy = ancora.iy; }
|
||||
this._zpt = { ix: ix, iy: iy };
|
||||
this._z = z1; this._auto = false;
|
||||
this._apply(); // la dimensione dello stage cambia subito, poi si puo' scorrere
|
||||
var sr2 = this._stage.getBoundingClientRect(), vr2 = vp.getBoundingClientRect();
|
||||
if (sr.width > 0 && isFinite(ix) && isFinite(iy)) {
|
||||
vp.scrollLeft += (sr2.left - vr2.left - vp.clientLeft + ix * z1) - px;
|
||||
vp.scrollTop += (sr2.top - vr2.top - vp.clientTop + iy * z1) - py;
|
||||
} else {
|
||||
var s = TmfViewerMath.zoomAround(z0, z1, vp.scrollLeft, vp.scrollTop, px, py);
|
||||
vp.scrollLeft = s.scrollLeft; vp.scrollTop = s.scrollTop;
|
||||
}
|
||||
this._apply(true);
|
||||
}
|
||||
|
||||
get zoom() { return this._z; }
|
||||
|
||||
// #122: applica zoom allo stage, ridisegna overlay ed etichette, aggiorna barra e cursore, avvisa con viewer-zoom
|
||||
_apply(quiet) {
|
||||
if (!this._built) return;
|
||||
var z = this._z;
|
||||
this._stage.style.width = this._w && z ? (this._w * z) + 'px' : '0px';
|
||||
this._stage.style.height = this._w && z ? (this._h * z) + 'px' : '0px';
|
||||
// #122: modo contenuto (DXF in mm) = percentuale rispetto ad Adatta; immagine = 100 % un pixel per pixel
|
||||
this._pctEl.textContent = TmfViewerMath.pct(!this._hasSrc() && this._content && this._fit ? z / this._fit : z);
|
||||
this._drawOverlay();
|
||||
this._drawLabels();
|
||||
this._syncLayers(); // #12
|
||||
this._cursor();
|
||||
if (quiet) return;
|
||||
var key = z + '|' + this._fit;
|
||||
if (key !== this._lastEvt) {
|
||||
this._lastEvt = key;
|
||||
this.dispatchEvent(new CustomEvent('viewer-zoom', { bubbles: true, detail: { zoom: z, fit: this._fit } }));
|
||||
}
|
||||
}
|
||||
|
||||
_canPan() {
|
||||
var vp = this._vp;
|
||||
return this._w > 0 && (vp.scrollWidth > vp.clientWidth + 1 || vp.scrollHeight > vp.clientHeight + 1);
|
||||
}
|
||||
|
||||
_cursor() {
|
||||
var can = this._canPan();
|
||||
this._vp.style.cursor = can ? (this._pan ? 'grabbing' : 'grab') : 'default';
|
||||
this._vp.style.touchAction = can ? 'none' : 'auto'; // il dito trascina solo se c'e' qualcosa da scorrere
|
||||
}
|
||||
|
||||
_wheel(ev) { // Ctrl+rotella (anche il pinch del trackpad) fa lo stesso
|
||||
// #12: la rotella zooma sempre sul punto del mouse (prima solo con Ctrl); senza immagine la pagina scorre
|
||||
if (!this._w) return;
|
||||
ev.preventDefault();
|
||||
var r = this._vp.getBoundingClientRect(), now = Date.now(), wa = this._wa;
|
||||
// #12: sequenza di scatti a mouse fermo (<= 2 px, < 1500 ms) = stessa ancora; altrimenti la nuova ancora si misura in zoomBy.
|
||||
// Cosi' lo scarto accumulato mentre lo stage e' piu' stretto del viewport (centrato, scroll impossibile) si recupera dopo.
|
||||
var riusa = wa && Math.abs(ev.clientX - wa.cx) <= 2 && Math.abs(ev.clientY - wa.cy) <= 2 && now - wa.t < 1500;
|
||||
this._zpt = null;
|
||||
this.zoomBy(ev.deltaY < 0 ? STEP : 1 / STEP, ev.clientX - r.left - this._vp.clientLeft, ev.clientY - r.top - this._vp.clientTop,
|
||||
riusa ? wa : null);
|
||||
var pt = this._zpt || (riusa ? wa : null);
|
||||
this._wa = pt ? { cx: ev.clientX, cy: ev.clientY, t: now, ix: pt.ix, iy: pt.iy } : null;
|
||||
}
|
||||
|
||||
_panDown(ev) {
|
||||
var vp = this._vp;
|
||||
if (!this._canPan() || (ev.pointerType === 'mouse' && ev.button)) return;
|
||||
ev.preventDefault(); // senza, Chromium manda pointercancel al primo movimento
|
||||
this._wa = null; // #12: il pan cambia il punto sotto il mouse: niente ancora della rotella
|
||||
this._pan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
|
||||
this._cursor();
|
||||
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
|
||||
}
|
||||
|
||||
_panMove(ev) {
|
||||
var p = this._pan;
|
||||
if (!p) return;
|
||||
this._vp.scrollLeft = p.left - (ev.clientX - p.x);
|
||||
this._vp.scrollTop = p.top - (ev.clientY - p.y);
|
||||
}
|
||||
|
||||
_panEnd() { this._pan = null; this._cursor(); }
|
||||
|
||||
// #122: overlay SVG a tratto costante; ridisegnato a ogni zoom perche' le croci dei points restano a dimensione fissa.
|
||||
// #12: tre gruppi <g data-layer> (ricerca, costruzioni a opacity 0.5, quote); un livello spento ha display none.
|
||||
_drawOverlay() {
|
||||
var ov = this._ovEl, VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
|
||||
if (!this._w) { ov.innerHTML = ''; return; }
|
||||
ov.setAttribute('viewBox', '0 0 ' + this._w + ' ' + this._h);
|
||||
if (!VO) { ov.innerHTML = ''; return; }
|
||||
var z = this._z || 1, lay = this._lay, noLay = this._noLay(); // #126
|
||||
var by = { ricerca: [], costruzioni: [], quote: [] };
|
||||
(this._ov || []).forEach(function (o) {
|
||||
var k = VO.layerOf ? VO.layerOf(o) : (o && o.role === 'found' ? 'costruzioni' : 'ricerca');
|
||||
by[k].push(o);
|
||||
});
|
||||
var quote = noLay ? '' : VO.svg(by.quote, { zoom: z }); // #126: con no-layers le forme vanno tutte nel disegno unico sotto
|
||||
(this._lb || []).forEach(function (l) {
|
||||
if (!l) return;
|
||||
var col = QUOTA_COLOR_DEFAULT;
|
||||
String(l.cls || '').split(/\s+/).forEach(function (c) { if (QUOTA_COLORS[c]) col = QUOTA_COLORS[c]; });
|
||||
if (l.quota && VO.quotaSvg) { quote += VO.quotaSvg(l.quota, { zoom: z, color: col }); return; }
|
||||
var t = l.testo;
|
||||
if (Array.isArray(t) && t.length === 2 && isFinite(t[0]) && isFinite(t[1]) && isFinite(l.x) && isFinite(l.y)) {
|
||||
// senza quota ma spostata: linea sottile dal punto originale alla scritta
|
||||
var r2 = function (v) { return String(Math.round(v * 100) / 100); };
|
||||
quote += '<line x1="' + r2(l.x) + '" y1="' + r2(l.y) + '" x2="' + r2(l.x + t[0]) + '" y2="' + r2(l.y + t[1]) +
|
||||
'" stroke="' + col + '" stroke-width="1" vector-effect="non-scaling-stroke"/>';
|
||||
}
|
||||
});
|
||||
// #126: no-layers = disegno di prima dei livelli: tutte le forme nell'ordine dato, poi le quote; niente <g>, niente opacity
|
||||
if (noLay) { ov.innerHTML = VO.svg(this._ov || [], { zoom: z }) + quote; return; }
|
||||
var g = function (name, body, extra) {
|
||||
return '<g data-layer="' + name + '"' + (extra || '') + (lay[name] ? '' : ' display="none"') + '>' + body + '</g>';
|
||||
};
|
||||
ov.innerHTML = g('ricerca', VO.svg(by.ricerca, { zoom: z })) +
|
||||
g('costruzioni', VO.svg(by.costruzioni, { zoom: z }), ' opacity="0.5"') + g('quote', quote);
|
||||
}
|
||||
|
||||
// #12: punto della scritta di un'etichetta in px logici: sulla quota, oppure (x,y) + testo [dx,dy], oppure (x,y)
|
||||
_labelPos(l) {
|
||||
var VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
|
||||
if (l.quota && VO && VO.quotaPunto) { var q = VO.quotaPunto(l.quota); if (q) return { x: q.x, y: q.y, quota: true }; }
|
||||
var t = l.testo;
|
||||
if (Array.isArray(t) && t.length === 2 && isFinite(t[0]) && isFinite(t[1])) return { x: l.x + t[0], y: l.y + t[1], quota: false };
|
||||
return { x: l.x, y: l.y, quota: false };
|
||||
}
|
||||
|
||||
// #122: etichette in px logici -> % dello stage, cosi' seguono lo zoom senza ingrandirsi
|
||||
// #12: testo per «Nomi» (text) o solo valore (breve); il livello Quote nasconde tutte le etichette
|
||||
_drawLabels() {
|
||||
var box = this._lbEl;
|
||||
while (box.firstChild) box.removeChild(box.firstChild);
|
||||
var noLay = this._noLay(); // #126
|
||||
box.style.display = noLay || this._lay.quote ? '' : 'none'; // #126: con no-layers le etichette sono sempre visibili
|
||||
if (!this._w) return;
|
||||
var self = this, nomi = noLay || this._lay.nomi; // #126: con no-layers testo = text
|
||||
(this._lb || []).forEach(function (l) {
|
||||
if (!l || !isFinite(l.x) || !isFinite(l.y)) return;
|
||||
var d = document.createElement('div');
|
||||
d.className = 'tmfv-label' + (l.cls ? ' ' + l.cls : '');
|
||||
var p = self._labelPos(l);
|
||||
if (l.key != null) d.setAttribute('data-key', l.key);
|
||||
d.style.left = (p.x / self._w * 100) + '%';
|
||||
d.style.top = (p.y / self._h * 100) + '%';
|
||||
if (p.quota) d.style.transform = 'translate(-50%,-50%)'; // la scritta di una quota e' centrata sul suo punto
|
||||
var txt = nomi ? l.text : (l.breve != null ? l.breve : l.text);
|
||||
d.textContent = txt == null ? '' : String(txt);
|
||||
if (l.key != null && !noLay) { // #126: con no-layers nessun trascinamento
|
||||
d.addEventListener('pointerdown', function (ev) { self._labelDown(ev, l); });
|
||||
d.addEventListener('dblclick', function (ev) { self._labelReset(ev, l); });
|
||||
}
|
||||
box.appendChild(d);
|
||||
});
|
||||
}
|
||||
|
||||
// #12: sposta la scritta dell'etichetta nel punto (x, y) in px logici (testo [du,dv] per le quote, [dx,dy] per le altre)
|
||||
_labelSet(l, x, y) {
|
||||
var VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
|
||||
var t = null;
|
||||
if (l.quota && VO && VO.quotaTesto) t = VO.quotaTesto(l.quota, x, y);
|
||||
if (t) l.quota = Object.assign({}, l.quota, { testo: t });
|
||||
else t = [x - l.x, y - l.y];
|
||||
l.testo = t;
|
||||
}
|
||||
|
||||
_labelEmit(l, testo) {
|
||||
this.dispatchEvent(new CustomEvent('quota-spostata', { bubbles: true,
|
||||
detail: { key: l.key, nome: l.nome == null ? null : l.nome, camera: l.camera == null ? null : l.camera, testo: testo } }));
|
||||
}
|
||||
|
||||
// #12: trascinamento di un'etichetta: niente pan (la propagazione si ferma), movimento sul documento
|
||||
_labelDown(ev, l) {
|
||||
if (ev.pointerType === 'mouse' && ev.button) return;
|
||||
ev.stopPropagation(); ev.preventDefault();
|
||||
var self = this, z = this._z || 1, p0 = this._labelPos(l);
|
||||
var st = { cx: ev.clientX, cy: ev.clientY, moved: false };
|
||||
var move = function (e) {
|
||||
if (!st.moved && Math.abs(e.clientX - st.cx) + Math.abs(e.clientY - st.cy) < 3) return; // soglia: un clic non sposta
|
||||
st.moved = true;
|
||||
self._labelSet(l, p0.x + (e.clientX - st.cx) / z, p0.y + (e.clientY - st.cy) / z);
|
||||
self._drawOverlay(); self._drawLabels();
|
||||
};
|
||||
var up = function () {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
document.removeEventListener('pointercancel', up);
|
||||
self._ldrag = null;
|
||||
if (!st.moved) return;
|
||||
var t = [Math.round(l.testo[0] * 10) / 10, Math.round(l.testo[1] * 10) / 10]; // 0.1 px, come il contratto
|
||||
if (l.quota) l.quota = Object.assign({}, l.quota, { testo: t });
|
||||
l.testo = t;
|
||||
self._drawOverlay(); self._drawLabels();
|
||||
self._labelEmit(l, t);
|
||||
};
|
||||
this._ldrag = st;
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
document.addEventListener('pointercancel', up);
|
||||
}
|
||||
|
||||
// #12: doppio clic = la scritta torna alla posizione di default (testo null)
|
||||
_labelReset(ev, l) {
|
||||
ev.stopPropagation(); ev.preventDefault();
|
||||
if (l.quota) l.quota = Object.assign({}, l.quota, { testo: null });
|
||||
l.testo = null;
|
||||
this._drawOverlay(); this._drawLabels();
|
||||
this._labelEmit(l, null);
|
||||
}
|
||||
|
||||
// #12: livelli. Stato in localStorage (try/catch: puo' essere bloccato), chiave per save-name
|
||||
_layKey() {
|
||||
var n = this.getAttribute('save-name');
|
||||
return 'tmf-viewer-layers' + (n ? ':' + n : '');
|
||||
}
|
||||
|
||||
_layersLoad() {
|
||||
try {
|
||||
var raw = window.localStorage.getItem(this._layKey()), o = raw ? JSON.parse(raw) : null, lay = this._lay;
|
||||
if (o && typeof o === 'object') LAYER_NAMES.forEach(function (k) { if (typeof o[k] === 'boolean') lay[k] = o[k]; });
|
||||
} catch (e) { /* storage non disponibile: restano i default */ }
|
||||
}
|
||||
|
||||
_layersSave() {
|
||||
try { window.localStorage.setItem(this._layKey(), JSON.stringify(this._lay)); } catch (e) { /* storage non disponibile */ }
|
||||
}
|
||||
|
||||
// #12: menu visibile con immagine o contenuto e almeno una forma o un'etichetta; immagine nascosta ma stage invariato
|
||||
_syncLayers() {
|
||||
if (!this._built || !this._layBar) return;
|
||||
var has = (this._ov && this._ov.length) || (this._lb && this._lb.length);
|
||||
this._layBar.hidden = this.hasAttribute('no-layers') || !this._w || !has;
|
||||
this._img.style.visibility = this._noLay() || this._lay.immagine ? '' : 'hidden'; // #126: con no-layers l'immagine resta visibile
|
||||
var self = this;
|
||||
LAYER_NAMES.forEach(function (k) { self._layBoxes[k].checked = !!self._lay[k]; });
|
||||
}
|
||||
|
||||
get layers() { return Object.assign({}, this._lay); }
|
||||
set layers(v) {
|
||||
var changed = false, lay = this._lay;
|
||||
if (v && typeof v === 'object') {
|
||||
LAYER_NAMES.forEach(function (k) {
|
||||
if (k in v && !!v[k] !== lay[k]) { lay[k] = !!v[k]; changed = true; }
|
||||
});
|
||||
}
|
||||
if (!changed || !this._layBar) return; // prima della costruzione del DOM resta solo lo stato
|
||||
this._syncLayers(); this._drawOverlay(); this._drawLabels();
|
||||
this._layersSave();
|
||||
this.dispatchEvent(new CustomEvent('viewer-layers', { bubbles: true, detail: { layers: this.layers } }));
|
||||
}
|
||||
|
||||
get overlay() { return this._ov; }
|
||||
set overlay(v) { this._ov = Array.isArray(v) ? v : []; if (this._built) { this._drawOverlay(); this._syncLayers(); } }
|
||||
get labels() { return this._lb; }
|
||||
set labels(v) { this._lb = Array.isArray(v) ? v : []; if (this._built) { this._drawOverlay(); this._drawLabels(); this._syncLayers(); } } // #12: le quote stanno nell'overlay
|
||||
|
||||
// #122: Salva = immagine grezza (src o save-src) o, in modo contenuto, l'SVG rasterizzato
|
||||
_save() {
|
||||
var name = this.getAttribute('save-name') || 'immagine';
|
||||
if (this._hasSrc()) {
|
||||
if (typeof tmfSaveImage === 'function') return tmfSaveImage(this.getAttribute('save-src') || this.getAttribute('src'), name);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
if (!this._content || typeof tmfSvgToPngBlob !== 'function' || typeof tmfSaveBlob !== 'function') return Promise.resolve(false);
|
||||
var self = this;
|
||||
return tmfSvgToPngBlob(this._content, this._w, this._h).then(function (blob) {
|
||||
return tmfSaveBlob(blob, name);
|
||||
}).catch(function (err) {
|
||||
if (typeof console !== 'undefined') console.error('tmf-viewer salva:', err);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
return TmfViewer;
|
||||
}
|
||||
|
||||
// customElements.define una sola volta, anche se lo script e' caricato due volte
|
||||
if (typeof customElements !== 'undefined' && typeof HTMLElement !== 'undefined' &&
|
||||
!(typeof customElements.get === 'function' && customElements.get(TAG))) {
|
||||
customElements.define(TAG, defineViewer());
|
||||
if (typeof document !== 'undefined') injectCss();
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') window.TmfViewerMath = TmfViewerMath;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = { TmfViewerMath: TmfViewerMath };
|
||||
})();
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* save_image.js - #119: salva il frame grezzo mostrato nel pannello visione come PNG, senza disegni.
|
||||
*
|
||||
* Cosa fa: tmfSaveImage(src, baseName) scarica l'immagine `src` (blob: o http), la converte in PNG se serve
|
||||
* e la salva con nome <baseName>_<AAAAMMGG-hhmmss>.png: «Salva con nome» (showSaveFilePicker)
|
||||
* dove c'e', altrimenti <a download>. Ritorna una Promise<boolean> (false se annullato o fallito).
|
||||
* tmfSaveImageName(baseName, date) e' la funzione pura del nome, esportata per i test.
|
||||
* Come si usa: <script src=".../save_image.js"></script> poi @click="tmfSaveImage(url, 'task_14')".
|
||||
* Dipende da: fetch, canvas, createImageBitmap e window del browser, toccati solo dentro le funzioni
|
||||
* (il modulo si carica anche in node, con require, senza questi globali).
|
||||
* #122: tmfSaveBlob(blob, baseName) salva un blob PNG gia' pronto; tmfSvgToPngBlob(svgEl, w, h) rasterizza un <svg>
|
||||
* (sfondo bianco, lato lungo 1600-4096 px). Entrambi usati da <tmf-viewer> (image_viewer.js).
|
||||
* Idea di conversione da image_to_png.js (#107).
|
||||
*/
|
||||
|
||||
// #119: nome file proposto <baseName>_<AAAAMMGG-hhmmss>.png; baseName ripulito dai caratteri non validi
|
||||
function tmfSaveImageName(baseName, date) {
|
||||
var base = String(baseName === undefined || baseName === null ? '' : baseName)
|
||||
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_') // caratteri vietati nei nomi file Windows
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/^[._]+|[._]+$/g, ''); // niente punti o underscore ai bordi
|
||||
if (!base) base = 'immagine';
|
||||
var d = date instanceof Date && !isNaN(date.getTime()) ? date : new Date();
|
||||
var p = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||
var stamp = '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' +
|
||||
p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
|
||||
return base + '_' + stamp + '.png';
|
||||
}
|
||||
|
||||
// #119: ridisegna su canvas un'immagine non PNG (es. JPEG, BMP) e ritorna il blob PNG
|
||||
async function tmfBlobToPng(blob) {
|
||||
var bitmap = await createImageBitmap(blob);
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
if (bitmap.close) bitmap.close();
|
||||
var png = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
|
||||
if (!png) throw new Error('conversione PNG fallita');
|
||||
return png;
|
||||
}
|
||||
|
||||
// #122: «Salva con nome» (showSaveFilePicker) dove c'e'; null se manca (si ripiega su <a download>). Puo' lanciare AbortError
|
||||
async function tmfPickPng(name) {
|
||||
if (typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function') {
|
||||
return window.showSaveFilePicker({
|
||||
suggestedName: name,
|
||||
types: [{ description: 'PNG', accept: { 'image/png': ['.png'] } }],
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// #122: scrive il blob sul file scelto (handle) oppure, senza handle, lo scarica con <a download>
|
||||
async function tmfWritePng(handle, blob, name) {
|
||||
if (handle) {
|
||||
var w = await handle.createWritable();
|
||||
await w.write(blob);
|
||||
await w.close();
|
||||
return;
|
||||
}
|
||||
// #119: ripiego senza «Salva con nome» (browser non Chromium, contesto non sicuro): <a download>
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||
}
|
||||
|
||||
// #122: gestione comune dell'errore di salvataggio: l'annullo dell'utente non e' un errore
|
||||
function tmfSaveFailed(err, tag) {
|
||||
if (err && err.name === 'AbortError') return false; // #119: annullato dall'utente, non e' un errore
|
||||
if (typeof console !== 'undefined') console.error(tag + ':', err);
|
||||
return false;
|
||||
}
|
||||
|
||||
// #122: salva un blob PNG gia' pronto (es. l'SVG del DXF rasterizzato) come <baseName>_<data>.png; true = salvato
|
||||
async function tmfSaveBlob(blob, baseName) {
|
||||
if (!blob) return false;
|
||||
try {
|
||||
var name = tmfSaveImageName(baseName, new Date());
|
||||
var handle = await tmfPickPng(name);
|
||||
await tmfWritePng(handle, blob, name);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return tmfSaveFailed(err, 'tmfSaveBlob');
|
||||
}
|
||||
}
|
||||
|
||||
// #122: rasterizza un <svg> in PNG (sfondo bianco). w,h = dimensioni logiche (default dal viewBox o dagli attributi);
|
||||
// lato lungo portato fra 1600 e 4096 px. Ritorna una Promise<Blob> o lancia se il browser non riesce a disegnarlo.
|
||||
async function tmfSvgToPngBlob(svgEl, w, h) {
|
||||
var vb = svgEl.getAttribute && svgEl.getAttribute('viewBox');
|
||||
var vbp = vb ? vb.trim().split(/[\s,]+/).map(Number) : [];
|
||||
if (!(w > 0)) w = vbp.length === 4 ? vbp[2] : parseFloat(svgEl.getAttribute('width'));
|
||||
if (!(h > 0)) h = vbp.length === 4 ? vbp[3] : parseFloat(svgEl.getAttribute('height'));
|
||||
if (!(w > 0) || !(h > 0)) throw new Error('dimensioni SVG non valide');
|
||||
var scale = Math.min(4096, Math.max(1600, Math.max(w, h))) / Math.max(w, h);
|
||||
var cw = Math.max(1, Math.round(w * scale));
|
||||
var ch = Math.max(1, Math.round(h * scale));
|
||||
var clone = svgEl.cloneNode(true);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
if (!vb) clone.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
||||
clone.setAttribute('width', cw); // dimensione in pixel del PNG: l'SVG isolato non ha il CSS della pagina
|
||||
clone.setAttribute('height', ch);
|
||||
if (clone.style) { clone.style.width = ''; clone.style.height = ''; }
|
||||
var xml = new XMLSerializer().serializeToString(clone);
|
||||
var url = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }));
|
||||
try {
|
||||
var img = new Image();
|
||||
await new Promise(function (resolve, reject) {
|
||||
img.onload = resolve;
|
||||
img.onerror = function () { reject(new Error('SVG non disegnabile')); };
|
||||
img.src = url;
|
||||
});
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
var g = canvas.getContext('2d');
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(0, 0, cw, ch);
|
||||
g.drawImage(img, 0, 0, cw, ch);
|
||||
var png = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
|
||||
if (!png) throw new Error('conversione PNG fallita');
|
||||
return png;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
// #119: scarica `src`, lo porta a PNG e lo salva; true = salvato, false = annullato dall'utente o errore
|
||||
async function tmfSaveImage(src, baseName) {
|
||||
if (!src) return false; // #119: nessuna immagine mostrata
|
||||
try {
|
||||
var name = tmfSaveImageName(baseName, new Date());
|
||||
// #119: «Salva con nome» per primo, finche' vale il gesto dell'utente (Chrome lo rifiuta dopo ~5 s di fetch e conversione)
|
||||
var handle = await tmfPickPng(name);
|
||||
var resp = await fetch(src);
|
||||
if (!resp.ok) return false;
|
||||
var blob = await resp.blob();
|
||||
if (blob.type !== 'image/png') blob = await tmfBlobToPng(blob);
|
||||
await tmfWritePng(handle, blob, name);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return tmfSaveFailed(err, 'tmfSaveImage');
|
||||
}
|
||||
}
|
||||
|
||||
// #119: globale nel browser, export per il test node
|
||||
// #122: esportati anche tmfSaveBlob e tmfSvgToPngBlob (usati da <tmf-viewer>)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.tmfSaveImage = tmfSaveImage; window.tmfSaveImageName = tmfSaveImageName;
|
||||
window.tmfSaveBlob = tmfSaveBlob; window.tmfSvgToPngBlob = tmfSvgToPngBlob;
|
||||
}
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = { tmfSaveImage, tmfSaveImageName, tmfSaveBlob, tmfSvgToPngBlob };
|
||||
@@ -0,0 +1,70 @@
|
||||
// «Acquisizione» dell'editor task (#97): camera (servizio + sorgente) e modo di analisi
|
||||
// («impulsiva» / «in linea») dei task camera_measure e dxf_compare.
|
||||
// Legge /maker/api/camera/servizi e /maker/api/camera/sorgenti?url=. Lo stato scelto sta
|
||||
// nel componente padre (editTaskData.acquisition): questo componente carica solo gli elenchi.
|
||||
// Uso: x-data="taskAcquisition(() => editTaskData.acquisition, msgs)". Dipende da Alpine.js.
|
||||
function taskAcquisition(getAcq, msgs) {
|
||||
return {
|
||||
msgs: msgs || {},
|
||||
servizi: [],
|
||||
sources: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
async init() {
|
||||
await this.loadServizi();
|
||||
// Task con camera gia' scelta: elenco sorgenti del suo servizio
|
||||
const acq = getAcq();
|
||||
if (acq && acq.camera_url) await this.loadSources(acq.camera_url);
|
||||
},
|
||||
|
||||
async loadServizi() {
|
||||
try {
|
||||
const resp = await fetch('/maker/api/camera/servizi');
|
||||
if (resp.ok) this.servizi = (await resp.json()).servizi || [];
|
||||
} catch (e) { console.error('taskAcquisition servizi', e); }
|
||||
},
|
||||
|
||||
// Servizio salvato nel task ma non piu' fra quelli delle stazioni: resta selezionabile
|
||||
serviziConCorrente() {
|
||||
const acq = getAcq();
|
||||
const out = this.servizi.slice();
|
||||
if (acq && acq.camera_url && !out.some(s => s.camera_url === acq.camera_url)) {
|
||||
out.push({ camera_url: acq.camera_url, stazioni: [] });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
// Cambio servizio: la sorgente scelta non vale piu'
|
||||
async onServizio() {
|
||||
const acq = getAcq();
|
||||
acq.camera_source = '';
|
||||
this.sources = [];
|
||||
this.error = '';
|
||||
if (acq.camera_url) await this.loadSources(acq.camera_url);
|
||||
},
|
||||
|
||||
async loadSources(url) {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const resp = await fetch('/maker/api/camera/sorgenti?url=' + encodeURIComponent(url));
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (resp.ok) this.sources = data.sources || [];
|
||||
else this.error = data.detail || this.msgs.genericError || '';
|
||||
} catch (e) {
|
||||
this.error = this.msgs.genericError || String(e);
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
|
||||
// Sorgente salvata ma non piu' elencata dal servizio: resta visibile
|
||||
sourcesConCorrente() {
|
||||
const acq = getAcq();
|
||||
const out = this.sources.slice();
|
||||
if (acq && acq.camera_source && !out.some(s => s.name === acq.camera_source)) {
|
||||
out.push({ name: acq.camera_source, label: acq.camera_source });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -20,9 +20,6 @@
|
||||
* Extra config: cameraAvailable, modo, checkUrl.
|
||||
*/
|
||||
function visionExecute() {
|
||||
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
||||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||
|
||||
return {
|
||||
busy: false,
|
||||
grabbing: false, // frame requested from the station camera (#72)
|
||||
@@ -35,6 +32,7 @@ function visionExecute() {
|
||||
preview: false, // result on screen is an analysis only, not saved
|
||||
saved: false, // «Registra» done: the saved result replaces the preview
|
||||
lastBlob: null, // last frame shown, the one «Registra» saves
|
||||
recordedBlob: null, // #123: frame saved by the last successful «Registra»: not recordable twice
|
||||
paused: false, // operator paused the in-line loop
|
||||
registering: false, // «Registra» in progress: the loop stands still
|
||||
leaving: false, // page is being left: no more requests
|
||||
@@ -43,6 +41,14 @@ function visionExecute() {
|
||||
|
||||
// #97: start on a frame already acquired and analysed; hook visibility and exit.
|
||||
init() {
|
||||
// #120: «Rimisura»/nuovo ciclo (task-result-reset): il pezzo va registrato di nuovo, anche senza camera
|
||||
window.addEventListener('task-result-reset', () => {
|
||||
this.saved = false;
|
||||
// #123: il frame gia' registrato non e' piu' un'anteprima ne' si registra due volte
|
||||
this.preview = !!this.result && this.lastBlob !== this.recordedBlob;
|
||||
// #123: camera impulsiva = frame nuovo subito (come all'apertura); in linea riparte il ciclo
|
||||
if (this.hasCamera && !this.inLinea) this.grab(); else this.kick();
|
||||
});
|
||||
if (!this.hasCamera) return;
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
|
||||
const leave = () => { this.leaving = true; };
|
||||
@@ -55,7 +61,7 @@ function visionExecute() {
|
||||
get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; },
|
||||
get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; },
|
||||
// «Registra» needs a frame on screen that is not saved yet
|
||||
get canRecord() { return !!this.lastBlob && !this.saved && !this.registering; },
|
||||
get canRecord() { return !!this.lastBlob && this.lastBlob !== this.recordedBlob && !this.saved && !this.registering; }, // #123: non sul frame gia' registrato
|
||||
|
||||
shouldRun() {
|
||||
return this.inLinea && !this.paused && !this.saved && !this.registering
|
||||
@@ -120,7 +126,6 @@ function visionExecute() {
|
||||
this.result = body;
|
||||
this.preview = true;
|
||||
this.saved = false;
|
||||
this.$nextTick(() => this.drawOverlay());
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
@@ -143,6 +148,8 @@ function visionExecute() {
|
||||
if (!this.error && this.result && this.result.vision_result_id) {
|
||||
this.preview = false;
|
||||
this.saved = true;
|
||||
this.recordedBlob = blob; // #123: questo frame e' registrato, Registra resta spento finche' non ne arriva uno nuovo
|
||||
this._emitRecorded(); // #120: la pagina conta le quote registrate
|
||||
}
|
||||
} finally {
|
||||
this.registering = false;
|
||||
@@ -179,8 +186,8 @@ function visionExecute() {
|
||||
return;
|
||||
}
|
||||
this.result = body;
|
||||
// Draw once Alpine has rendered the canvas.
|
||||
this.$nextTick(() => this.drawOverlay());
|
||||
// #120: senza camera il file scelto e' salvato subito (nessun «Registra»): stesso evento
|
||||
if (!this.hasCamera && body.vision_result_id) this._emitRecorded();
|
||||
} catch (e) {
|
||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
} finally {
|
||||
@@ -188,6 +195,12 @@ function visionExecute() {
|
||||
}
|
||||
},
|
||||
|
||||
// #120: misure salvate (risposta di execute) alla pagina: conteggio quote e «Completato»
|
||||
_emitRecorded() {
|
||||
const ms = (this.result && this.result.measurements) || [];
|
||||
window.dispatchEvent(new CustomEvent('vision-recorded', { detail: { measurements: ms } }));
|
||||
},
|
||||
|
||||
// Station camera (#72): the Flask proxy answers a PNG, which goes through
|
||||
// acquire() like a picked file. Errors show as the execute ones do.
|
||||
// #97: sends task_id (the task's camera wins over the station's); with a camera
|
||||
@@ -254,14 +267,51 @@ function visionExecute() {
|
||||
return ((this.result && this.result.measurements) || []).map((m) => {
|
||||
const st = subs.find((s) => s.id === m.subtask_id) || {};
|
||||
return {
|
||||
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
|
||||
// #116: chiave stabile della quota, nome senza numero di marker e uscita ricetta, per le etichette sull'immagine
|
||||
key: m.subtask_id,
|
||||
name: st.description || st.measurement_type || '#' + m.subtask_id,
|
||||
vision_output: st.vision_output,
|
||||
// #114: il nome della quota e' description (prima: measurement_type/name, che per le quote DXF e' vuoto -> '#15')
|
||||
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.description || st.measurement_type || '#' + m.subtask_id),
|
||||
value: m.value,
|
||||
unit: st.unit || 'mm',
|
||||
status: m.pass_fail,
|
||||
// #117: nominale e tolleranze della quota (come la schermata dei calibri); null se mancano
|
||||
nominal: st.nominal, ltl: st.ltl, lwl: st.lwl, uwl: st.uwl, utl: st.utl,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// #122: zoom, pan, resize e Salva li gestisce <tmf-viewer> (static/js/image_viewer.js)
|
||||
|
||||
// #118: valore della cella: una quota non misurata (null) e' «NaN», mai Number(null) = 0
|
||||
fmtValue(v, unit) {
|
||||
return (v == null || !Number.isFinite(Number(v)) ? 'NaN' : Number(v).toFixed(3)) + ' ' + unit;
|
||||
},
|
||||
|
||||
// #117: tolleranza a 3 decimali, trattino se manca (come x-text="...?.toFixed(3) || '—'" dei calibri, ma 0 resta 0.000)
|
||||
fmtTol(v) { return v == null ? '\u2014' : Number(v).toFixed(3); },
|
||||
|
||||
// #119 #122: nome proposto da «Salva» (titolo del task) per <tmf-viewer save-name>
|
||||
get saveName() { return (window.__visionCfg || {}).taskName || 'immagine'; },
|
||||
|
||||
// #116 #122: etichette «nome valore unita» per <tmf-viewer>: x/y in px logici dell'immagine
|
||||
// (il componente le posiziona in % e le tiene a dimensione costante a ogni zoom)
|
||||
get viewerLabels() {
|
||||
if (typeof VisionOverlay === 'undefined' || !this.result) return [];
|
||||
return VisionOverlay.labels(this.rows, this.result.overlay || []).map((l) => ({
|
||||
key: l.key, x: l.x, y: l.y, text: l.text, cls: this.labelClass(l.status),
|
||||
}));
|
||||
},
|
||||
|
||||
// #116: colori dell'etichetta per esito
|
||||
labelClass(s) {
|
||||
if (s === 'pass') return 'bg-green-600 text-white';
|
||||
if (s === 'warning') return 'bg-amber-400 text-black';
|
||||
if (s === 'fail') return 'bg-red-600 text-white';
|
||||
return 'bg-gray-700 text-white';
|
||||
},
|
||||
|
||||
statusClass(s) {
|
||||
// Same palette as the measurement markers of the manual flow.
|
||||
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
|
||||
@@ -269,27 +319,5 @@ function visionExecute() {
|
||||
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
|
||||
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
|
||||
},
|
||||
|
||||
// Canvas is sized to the natural image, so overlay coordinates (image px)
|
||||
// are used as they are and CSS scales the whole thing to the displayed size.
|
||||
drawOverlay() {
|
||||
const img = this.$refs.img, cv = this.$refs.overlay;
|
||||
if (!img || !cv || !img.naturalWidth) return;
|
||||
cv.width = img.naturalWidth;
|
||||
cv.height = img.naturalHeight;
|
||||
const g = cv.getContext('2d');
|
||||
g.clearRect(0, 0, cv.width, cv.height);
|
||||
g.lineWidth = Math.max(2, cv.width / 400);
|
||||
g.lineJoin = 'round';
|
||||
for (const item of ((this.result && this.result.overlay) || [])) {
|
||||
const rows = item.rows || [], cols = item.cols || [];
|
||||
if (!rows.length || rows.length !== cols.length) continue;
|
||||
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
|
||||
g.beginPath();
|
||||
g.moveTo(cols[0], rows[0]);
|
||||
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
|
||||
g.stroke();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* #116: disegno dell'overlay del check visione sul canvas del pannello operatore e
|
||||
* ancoraggio delle etichette delle quote alla geometria del loro strumento.
|
||||
*
|
||||
* Script classico (niente import/export): lo carica task_execute.html prima di
|
||||
* vision_execute.js, che lo usa via window.VisionOverlay. In node (test) si carica
|
||||
* con require.
|
||||
*
|
||||
* API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null;
|
||||
* labels(rows, overlay) -> [{key, x, y, text, status}];
|
||||
* svg(overlay, opts) -> stringa di figli SVG (#121, vedi sotto);
|
||||
* layerOf(o), quotaPunto(q), quotaTesto(q, x, y), quotaSvg(q, opts): livelli e quote CAD (#12).
|
||||
* Convenzioni (vs-tools): row = y, col = x; angoli in gradi positivi antiorari a video.
|
||||
* Porting da TieVision-Studio frontend/src/comune/overlay.js (verticiRect2, forma)
|
||||
* e frontend/src/editor/misure.js (ancoraOverlay).
|
||||
*/
|
||||
var VisionOverlay = (function () {
|
||||
'use strict';
|
||||
|
||||
// #116: colore per ruolo, uguale a quello dello Studio
|
||||
var COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||
var COLOR_OTHER = '#ef4444';
|
||||
|
||||
function rad(d) { return (d * Math.PI) / 180; }
|
||||
function num(v) { return typeof v === 'number' && isFinite(v); }
|
||||
function allNum(o) {
|
||||
for (var i = 1; i < arguments.length; i++) {
|
||||
if (!o || !num(o[arguments[i]])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// #116: serie rows/cols valida = entrambe array, stessa lunghezza, non vuote, tutti numeri finiti
|
||||
function validSeries(o) {
|
||||
if (!o || !Array.isArray(o.rows) || !Array.isArray(o.cols)) return false;
|
||||
if (o.rows.length === 0 || o.rows.length !== o.cols.length) return false;
|
||||
for (var i = 0; i < o.rows.length; i++) {
|
||||
if (!num(o.rows[i]) || !num(o.cols[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// #116: i 4 vertici del rect2 (x = col, y = row), come verticiRect2 dello Studio
|
||||
function rect2Vertices(o) {
|
||||
var a = rad(o.phi_deg);
|
||||
var u = [Math.cos(a), -Math.sin(a)];
|
||||
var v = [-Math.sin(a), -Math.cos(a)]; // u ruotato di +90 antiorario
|
||||
return [[1, 1], [1, -1], [-1, -1], [-1, 1]].map(function (st) {
|
||||
return [
|
||||
o.col + st[0] * o.length1 * u[0] + st[1] * o.length2 * v[0],
|
||||
o.row + st[0] * o.length1 * u[1] + st[1] * o.length2 * v[1],
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
// #116: disegna una forma; true se disegnata, false se saltata (tipo ignoto o dati malformati)
|
||||
function drawShape(g, o, lw) {
|
||||
var type = o && o.type;
|
||||
var i;
|
||||
switch (type) {
|
||||
case 'rect2': {
|
||||
if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return false;
|
||||
var vs = rect2Vertices(o);
|
||||
g.beginPath();
|
||||
g.moveTo(vs[0][0], vs[0][1]);
|
||||
for (i = 1; i < vs.length; i++) g.lineTo(vs[i][0], vs[i][1]);
|
||||
g.closePath();
|
||||
g.stroke();
|
||||
return true;
|
||||
}
|
||||
case 'segment':
|
||||
case 'line':
|
||||
if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return false;
|
||||
g.beginPath();
|
||||
g.moveTo(o.col1, o.row1);
|
||||
g.lineTo(o.col2, o.row2);
|
||||
g.stroke();
|
||||
return true;
|
||||
case 'circle':
|
||||
if (!allNum(o, 'row', 'col', 'radius')) return false;
|
||||
g.beginPath();
|
||||
g.arc(o.col, o.row, o.radius, 0, 2 * Math.PI);
|
||||
g.stroke();
|
||||
return true;
|
||||
case 'arc':
|
||||
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return false;
|
||||
g.beginPath();
|
||||
// #116: y canvas verso il basso, quindi angoli negati; extent > 0 = antiorario a video
|
||||
g.arc(o.col, o.row, o.radius, -rad(o.start_deg), -rad(o.start_deg + o.extent_deg), o.extent_deg > 0);
|
||||
g.stroke();
|
||||
return true;
|
||||
case 'points': {
|
||||
if (!validSeries(o)) return false;
|
||||
var m = Math.max(3, lw * 2); // semi-lato della croce
|
||||
g.beginPath();
|
||||
for (i = 0; i < o.rows.length; i++) {
|
||||
g.moveTo(o.cols[i] - m, o.rows[i]);
|
||||
g.lineTo(o.cols[i] + m, o.rows[i]);
|
||||
g.moveTo(o.cols[i], o.rows[i] - m);
|
||||
g.lineTo(o.cols[i], o.rows[i] + m);
|
||||
}
|
||||
g.stroke();
|
||||
return true;
|
||||
}
|
||||
case 'contour':
|
||||
if (!validSeries(o)) return false;
|
||||
g.beginPath();
|
||||
g.moveTo(o.cols[0], o.rows[0]);
|
||||
for (i = 1; i < o.rows.length; i++) g.lineTo(o.cols[i], o.rows[i]);
|
||||
g.stroke(); // polilinea aperta, come nello Studio
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function draw(g, overlay, lw) {
|
||||
var res = { drawn: 0, skipped: 0 };
|
||||
var items = Array.isArray(overlay) ? overlay : [];
|
||||
for (var k = 0; k < items.length; k++) {
|
||||
var o = items[k];
|
||||
var role = o && o.role;
|
||||
var isRoi = role === 'roi';
|
||||
g.strokeStyle = COLORS[role] || COLOR_OTHER;
|
||||
g.lineWidth = isRoi ? lw * 0.75 : lw;
|
||||
g.setLineDash(isRoi ? [lw * 3, lw * 2] : []); // roi tratteggiata
|
||||
var ok = false;
|
||||
try { ok = drawShape(g, o, lw); } catch (e) { ok = false; } // un elemento rotto non ferma gli altri
|
||||
if (ok) res.drawn++; else res.skipped++;
|
||||
}
|
||||
g.setLineDash([]);
|
||||
return res;
|
||||
}
|
||||
|
||||
// #121: numero per il markup SVG, al piu' 2 decimali (niente stringhe dei dati nel markup)
|
||||
function n2(v) { return String(Math.round(v * 100) / 100); }
|
||||
function pos(v, dflt) { return num(v) && v > 0 ? v : dflt; }
|
||||
|
||||
// #121: path SVG di un arco, porting di percorsoArco dello Studio; extent > 0 = antiorario a video
|
||||
function arcPath(o) {
|
||||
var ext = Math.max(-359.99, Math.min(359.99, o.extent_deg)); // 360 pieni darebbero start == end
|
||||
var a0 = rad(o.start_deg);
|
||||
var a1 = rad(o.start_deg + ext);
|
||||
var x0 = o.col + o.radius * Math.cos(a0), y0 = o.row - o.radius * Math.sin(a0);
|
||||
var x1 = o.col + o.radius * Math.cos(a1), y1 = o.row - o.radius * Math.sin(a1);
|
||||
var large = Math.abs(ext) > 180 ? 1 : 0;
|
||||
var sweep = ext > 0 ? 0 : 1; // y verso il basso: antiorario a video = sweep-flag 0
|
||||
return 'M ' + n2(x0) + ' ' + n2(y0) + ' A ' + n2(o.radius) + ' ' + n2(o.radius) +
|
||||
' 0 ' + large + ' ' + sweep + ' ' + n2(x1) + ' ' + n2(y1);
|
||||
}
|
||||
|
||||
// #121: [tag, attributi geometrici] di una forma, o null se tipo ignoto o dati malformati (stessi criteri di drawShape)
|
||||
function shapeSvg(o, cross) {
|
||||
var i, pts;
|
||||
switch (o && o.type) {
|
||||
case 'rect2':
|
||||
if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return null;
|
||||
pts = rect2Vertices(o).map(function (v) { return n2(v[0]) + ',' + n2(v[1]); }).join(' ');
|
||||
return ['polygon', 'points="' + pts + '"'];
|
||||
case 'segment':
|
||||
case 'line':
|
||||
if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return null;
|
||||
return ['line', 'x1="' + n2(o.col1) + '" y1="' + n2(o.row1) + '" x2="' + n2(o.col2) + '" y2="' + n2(o.row2) + '"'];
|
||||
case 'circle':
|
||||
if (!allNum(o, 'row', 'col', 'radius')) return null;
|
||||
return ['circle', 'cx="' + n2(o.col) + '" cy="' + n2(o.row) + '" r="' + n2(o.radius) + '"'];
|
||||
case 'arc':
|
||||
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null;
|
||||
return ['path', 'd="' + arcPath(o) + '"'];
|
||||
case 'points': {
|
||||
if (!validSeries(o)) return null;
|
||||
var d = [];
|
||||
for (i = 0; i < o.rows.length; i++) {
|
||||
var x = o.cols[i], y = o.rows[i];
|
||||
d.push('M ' + n2(x - cross) + ' ' + n2(y) + ' L ' + n2(x + cross) + ' ' + n2(y) +
|
||||
' M ' + n2(x) + ' ' + n2(y - cross) + ' L ' + n2(x) + ' ' + n2(y + cross));
|
||||
}
|
||||
return ['path', 'd="' + d.join(' ') + '"'];
|
||||
}
|
||||
case 'contour':
|
||||
if (!validSeries(o)) return null;
|
||||
pts = o.rows.map(function (r, k) { return n2(o.cols[k]) + ',' + n2(r); }).join(' ');
|
||||
return ['polyline', 'points="' + pts + '"']; // aperta, come draw
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// #121: overlay -> figli SVG (senza <svg> esterno) in px immagine. Tratto a spessore costante a video
|
||||
// (vector-effect non-scaling-stroke), croci dei points a semi-braccio costante a video (cross / zoom).
|
||||
function svg(overlay, opts) {
|
||||
opts = opts || {};
|
||||
var zoom = pos(opts.zoom, 1), lw = pos(opts.lw, 2), cross = pos(opts.cross, 6);
|
||||
var items = Array.isArray(overlay) ? overlay : [];
|
||||
var out = [];
|
||||
for (var k = 0; k < items.length; k++) {
|
||||
var o = items[k];
|
||||
var sh = null;
|
||||
try { sh = shapeSvg(o, cross / zoom); } catch (e) { sh = null; } // un elemento rotto non ferma gli altri
|
||||
if (!sh) continue;
|
||||
var role = o.role;
|
||||
var isRoi = role === 'roi';
|
||||
// solo colori fissi e numeri: nessuna stringa dei dati finisce nel markup
|
||||
out.push('<' + sh[0] + ' ' + sh[1] + ' fill="none" stroke="' + (COLORS[role] || COLOR_OTHER) +
|
||||
'" stroke-width="' + n2(isRoi ? lw * 0.75 : lw) + '"' +
|
||||
(isRoi ? ' stroke-dasharray="6 4"' : '') + ' vector-effect="non-scaling-stroke"/>');
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
// #116: punto di ancoraggio dell'etichetta (px immagine), come ancoraOverlay dello Studio
|
||||
function anchor(o) {
|
||||
switch (o && o.type) {
|
||||
case 'segment':
|
||||
case 'line':
|
||||
return allNum(o, 'row1', 'col1', 'row2', 'col2')
|
||||
? { x: (o.col1 + o.col2) / 2, y: (o.row1 + o.row2) / 2 } : null;
|
||||
case 'circle':
|
||||
return allNum(o, 'row', 'col', 'radius') ? { x: o.col + o.radius, y: o.row } : null; // punto a destra
|
||||
case 'arc': {
|
||||
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null;
|
||||
var a = rad(o.start_deg + o.extent_deg / 2); // punto medio dell'arco
|
||||
return { x: o.col + o.radius * Math.cos(a), y: o.row - o.radius * Math.sin(a) };
|
||||
}
|
||||
case 'points':
|
||||
return Array.isArray(o.rows) && Array.isArray(o.cols) && num(o.rows[0]) && num(o.cols[0])
|
||||
? { x: o.cols[0], y: o.rows[0] } : null;
|
||||
case 'rect2':
|
||||
return allNum(o, 'row', 'col') ? { x: o.col, y: o.row } : null;
|
||||
case 'contour': {
|
||||
if (!Array.isArray(o.rows) || !Array.isArray(o.cols)) return null;
|
||||
var i = Math.floor(Math.min(o.rows.length, o.cols.length) / 2);
|
||||
return num(o.rows[i]) && num(o.cols[i]) ? { x: o.cols[i], y: o.rows[i] } : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// #116: un'etichetta per riga con vision_output 'tool_id.proprieta' che ha un overlay ancorabile
|
||||
function labels(rows, overlay) {
|
||||
var out = [];
|
||||
var items = Array.isArray(overlay) ? overlay : [];
|
||||
(rows || []).forEach(function (r) {
|
||||
if (!r || typeof r.vision_output !== 'string' || r.vision_output.indexOf('.') < 0) return;
|
||||
var toolId = r.vision_output.split('.')[0];
|
||||
var mine = items.filter(function (o) { return o && o.tool_id === toolId; });
|
||||
// prima gli elementi 'found', poi qualsiasi altro
|
||||
var order = mine.filter(function (o) { return o.role === 'found'; })
|
||||
.concat(mine.filter(function (o) { return o.role !== 'found'; }));
|
||||
var pt = null;
|
||||
for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]);
|
||||
if (!pt) return;
|
||||
// #118: valore null/undefined/vuoto/non finito = quota non misurata (Number(null) e' 0): etichetta NaN, status della riga (fail)
|
||||
var v = (r.value === null || r.value === undefined || r.value === '' || typeof r.value === 'boolean')
|
||||
? NaN : Number(r.value);
|
||||
var txt = isFinite(v) ? v.toFixed(3) : 'NaN';
|
||||
out.push({
|
||||
key: r.key,
|
||||
x: pt.x,
|
||||
y: pt.y,
|
||||
text: r.name + ' ' + txt + ' ' + (r.unit || ''),
|
||||
status: r.status,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- #12: livelli e quote stile CAD ------------------------------------------------------------
|
||||
// Coordinate in px immagine; angoli in gradi antiorari a video, versore u(a) = (cos a, -sin a),
|
||||
// normale n = (-u.y, u.x). Contratto: gitea:Adriano/TieVision-Station#12.
|
||||
|
||||
// #12: colori ammessi per le quote (esiti del visore + rosso di default); mai una stringa dei dati nel markup
|
||||
var QUOTA_COLORS = ['#16a34a', '#d97706', '#dc2626', '#ef4444'];
|
||||
var QUOTA_ARROW = 9, QUOTA_GAP = 3, QUOTA_OVER = 6, QUOTA_LW = 1.5; // px a video
|
||||
|
||||
// #12: livello di una forma: campo "layer" se valido, altrimenti found = costruzioni, il resto = ricerca
|
||||
function layerOf(o) {
|
||||
var l = o && o.layer;
|
||||
if (l === 'costruzioni' || l === 'ricerca' || l === 'quote') return l;
|
||||
return o && o.role === 'found' ? 'costruzioni' : 'ricerca';
|
||||
}
|
||||
|
||||
function pt2(p) { return Array.isArray(p) && p.length === 2 && num(p[0]) && num(p[1]); }
|
||||
function uVec(deg) { var a = rad(deg); return [Math.cos(a), -Math.sin(a)]; }
|
||||
|
||||
// #12: riferimento locale della quota {b, u, n, td, ...} o null se malformata
|
||||
function quotaFrame(q) {
|
||||
if (!q || typeof q !== 'object') return null;
|
||||
var f = { tipo: q.tipo };
|
||||
switch (q.tipo) {
|
||||
case 'lineare': {
|
||||
if (!pt2(q.p1) || !pt2(q.p2)) return null;
|
||||
var dx = q.p2[0] - q.p1[0], dy = q.p2[1] - q.p1[1], L = Math.sqrt(dx * dx + dy * dy);
|
||||
f.u = L > 1e-12 ? [dx / L, dy / L] : [1, 0];
|
||||
f.b = [(q.p1[0] + q.p2[0]) / 2, (q.p1[1] + q.p2[1]) / 2];
|
||||
f.L = L; f.dflt = [0, 0];
|
||||
break;
|
||||
}
|
||||
case 'raggio':
|
||||
if (!pt2(q.centro) || !num(q.r) || !num(q.dir_deg)) return null;
|
||||
f.u = uVec(q.dir_deg); f.b = [q.centro[0], q.centro[1]]; f.dflt = [2 * q.r, 0];
|
||||
break;
|
||||
case 'angolo':
|
||||
if (!pt2(q.centro) || !num(q.r) || !num(q.start_deg) || !num(q.extent_deg)) return null;
|
||||
f.u = uVec(q.start_deg + q.extent_deg / 2); f.b = [q.centro[0], q.centro[1]]; f.dflt = [1.5 * q.r, 0];
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
f.n = [-f.u[1], f.u[0]];
|
||||
f.td = pt2(q.testo) ? [q.testo[0], q.testo[1]] : f.dflt; // testo null o malformato = posizione di default
|
||||
return f;
|
||||
}
|
||||
|
||||
// #12: punto T della scritta (px immagine) = B + du*u + dv*n; null se la quota e' malformata
|
||||
function quotaPunto(q) {
|
||||
var f = quotaFrame(q);
|
||||
if (!f) return null;
|
||||
return { x: f.b[0] + f.td[0] * f.u[0] + f.td[1] * f.n[0], y: f.b[1] + f.td[0] * f.u[1] + f.td[1] * f.n[1] };
|
||||
}
|
||||
|
||||
// #12: inverso di quotaPunto: [du, dv] che porta la scritta nel punto (x, y); serve al trascinamento
|
||||
function quotaTesto(q, x, y) {
|
||||
var f = quotaFrame(q);
|
||||
if (!f || !num(x) || !num(y)) return null;
|
||||
var rx = x - f.b[0], ry = y - f.b[1];
|
||||
return [rx * f.u[0] + ry * f.u[1], rx * f.n[0] + ry * f.n[1]];
|
||||
}
|
||||
|
||||
function qLine(x1, y1, x2, y2, color) {
|
||||
return '<line x1="' + n2(x1) + '" y1="' + n2(y1) + '" x2="' + n2(x2) + '" y2="' + n2(y2) + '" stroke="' + color +
|
||||
'" stroke-width="' + QUOTA_LW + '" vector-effect="non-scaling-stroke"/>';
|
||||
}
|
||||
// #12: freccia piena con la punta in (x, y) rivolta verso (dx, dy) (versore); lunghezza fissa a video
|
||||
function qArrow(x, y, dx, dy, zoom, color) {
|
||||
var len = QUOTA_ARROW / zoom, w = len / 3;
|
||||
var bx = x - dx * len, by = y - dy * len;
|
||||
return '<polygon points="' + n2(x) + ',' + n2(y) + ' ' + n2(bx - dy * w) + ',' + n2(by + dx * w) + ' ' +
|
||||
n2(bx + dy * w) + ',' + n2(by - dx * w) + '" fill="' + color + '" stroke="none"/>';
|
||||
}
|
||||
|
||||
function quotaSvgLineare(q, f, zoom, color) {
|
||||
var out = [], u = f.u, n = f.n, dv = f.td[1], du = f.td[0], i;
|
||||
var sg = dv >= 0 ? 1 : -1, gap = QUOTA_GAP / zoom, over = QUOTA_OVER / zoom;
|
||||
var ps = [q.p1, q.p2];
|
||||
if (Math.abs(dv) * zoom >= 3) { // linee di estensione: assenti se la linea di misura sta sul pezzo
|
||||
for (i = 0; i < 2; i++) {
|
||||
out.push(qLine(ps[i][0] + sg * gap * n[0], ps[i][1] + sg * gap * n[1],
|
||||
ps[i][0] + (dv + sg * over) * n[0], ps[i][1] + (dv + sg * over) * n[1], color));
|
||||
}
|
||||
}
|
||||
var m0 = [f.b[0] + dv * n[0], f.b[1] + dv * n[1]]; // punto medio della linea di misura
|
||||
var h = f.L / 2, inside = f.L * zoom >= 2.5 * QUOTA_ARROW;
|
||||
var lo = Math.min(-h, du), hi = Math.max(h, du); // la linea arriva fino alla scritta se sta oltre A o B
|
||||
if (!inside) { lo = Math.min(lo, -h - 12 / zoom); hi = Math.max(hi, h + 12 / zoom); }
|
||||
out.push(qLine(m0[0] + lo * u[0], m0[1] + lo * u[1], m0[0] + hi * u[0], m0[1] + hi * u[1], color));
|
||||
var k = inside ? 1 : -1; // frecce corte: fuori, rivolte verso l'interno
|
||||
out.push(qArrow(m0[0] - h * u[0], m0[1] - h * u[1], -k * u[0], -k * u[1], zoom, color));
|
||||
out.push(qArrow(m0[0] + h * u[0], m0[1] + h * u[1], k * u[0], k * u[1], zoom, color));
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function quotaSvgRaggio(q, f, zoom, color) {
|
||||
var out = [], c = q.centro, T = quotaPunto(q);
|
||||
var vx = T.x - c[0], vy = T.y - c[1], d = Math.sqrt(vx * vx + vy * vy);
|
||||
var w = d > 1e-9 ? [vx / d, vy / d] : uVec(q.dir_deg);
|
||||
var px = c[0] + q.r * w[0], py = c[1] + q.r * w[1], cr = 4 / zoom;
|
||||
out.push(qLine(c[0] - cr, c[1], c[0] + cr, c[1], color)); // crocetta sul centro
|
||||
out.push(qLine(c[0], c[1] - cr, c[0], c[1] + cr, color));
|
||||
if (d > q.r) { // scritta fuori dal cerchio: da P a T, freccia in P verso il centro
|
||||
out.push(qLine(px, py, T.x, T.y, color));
|
||||
out.push(qArrow(px, py, -w[0], -w[1], zoom, color));
|
||||
} else { // scritta dentro: dal centro a P, freccia in P verso fuori
|
||||
out.push(qLine(c[0], c[1], px, py, color));
|
||||
out.push(qArrow(px, py, w[0], w[1], zoom, color));
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function quotaSvgAngolo(q, f, zoom, color) {
|
||||
var out = [], c = q.centro, T = quotaPunto(q), i;
|
||||
var rho = Math.sqrt((T.x - c[0]) * (T.x - c[0]) + (T.y - c[1]) * (T.y - c[1]));
|
||||
if (!(rho > 1e-9)) rho = q.r > 0 ? 1.5 * q.r : 1;
|
||||
var gap = QUOTA_GAP / zoom, over = QUOTA_OVER / zoom;
|
||||
var ends = [q.start_deg, q.start_deg + q.extent_deg];
|
||||
var sgn = q.extent_deg >= 0 ? 1 : -1;
|
||||
var inside = rho * rad(Math.abs(q.extent_deg)) * zoom >= 2.5 * QUOTA_ARROW;
|
||||
for (i = 0; i < 2; i++) {
|
||||
var u = uVec(ends[i]);
|
||||
var a = rho >= q.r ? q.r + gap : rho - over, b = rho >= q.r ? rho + over : q.r - gap;
|
||||
out.push(qLine(c[0] + a * u[0], c[1] + a * u[1], c[0] + b * u[0], c[1] + b * u[1], color)); // estensione radiale
|
||||
}
|
||||
out.push('<path d="' + arcPath({ col: c[0], row: c[1], radius: rho, start_deg: q.start_deg, extent_deg: q.extent_deg }) +
|
||||
'" fill="none" stroke="' + color + '" stroke-width="' + QUOTA_LW + '" vector-effect="non-scaling-stroke"/>');
|
||||
for (i = 0; i < 2; i++) {
|
||||
var ang = rad(ends[i]), uu = uVec(ends[i]);
|
||||
var tg = [-Math.sin(ang), -Math.cos(ang)]; // tangente nel verso degli angoli crescenti
|
||||
var dir = (i === 0 ? -1 : 1) * sgn * (inside ? 1 : -1); // dentro: verso fuori dall'arco; corte: verso l'interno
|
||||
out.push(qArrow(c[0] + rho * uu[0], c[1] + rho * uu[1], dir * tg[0], dir * tg[1], zoom, color));
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
// #12: quota -> figli SVG (px immagine). opts = {zoom, color}; misure fisse a video divise per lo zoom.
|
||||
// Solo numeri e colori da lista fissa nel markup.
|
||||
function quotaSvg(q, opts) {
|
||||
opts = opts || {};
|
||||
var zoom = pos(opts.zoom, 1);
|
||||
var color = QUOTA_COLORS.indexOf(opts.color) >= 0 ? opts.color : COLOR_OTHER;
|
||||
try {
|
||||
var f = quotaFrame(q);
|
||||
if (!f) return '';
|
||||
if (f.tipo === 'lineare') return quotaSvgLineare(q, f, zoom, color);
|
||||
if (f.tipo === 'raggio') return quotaSvgRaggio(q, f, zoom, color);
|
||||
return quotaSvgAngolo(q, f, zoom, color);
|
||||
} catch (e) { return ''; } // una quota rotta non ferma le altre
|
||||
}
|
||||
|
||||
return { draw: draw, anchor: anchor, labels: labels, svg: svg,
|
||||
layerOf: layerOf, quotaPunto: quotaPunto, quotaTesto: quotaTesto, quotaSvg: quotaSvg };
|
||||
})();
|
||||
|
||||
if (typeof window !== 'undefined') window.VisionOverlay = VisionOverlay;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = VisionOverlay;
|
||||
@@ -504,6 +504,7 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/image_to_png.js') }}"></script>{# #107 #}
|
||||
<script>
|
||||
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
||||
ImageCrop.configure({
|
||||
@@ -686,6 +687,13 @@ function recipeEditor() {
|
||||
async uploadFile(file) {
|
||||
if (!file) return;
|
||||
|
||||
// #107: BMP e simili (leggibili dal browser, non dal backend) diventano PNG prima dei controlli
|
||||
file = await tmfImageToPng(file);
|
||||
if (!file) {
|
||||
this.errorMessage = {{ _("Formato immagine non leggibile. Usa PNG, JPG o PDF.")|tojson }};
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||
if (!allowed.includes(file.type)) {
|
||||
|
||||
@@ -547,6 +547,71 @@
|
||||
|
||||
<div class="tmf-card-body space-y-4">
|
||||
|
||||
<!-- ========== Acquisizione (#97): camera e modo di analisi, solo camera_measure / dxf_compare ========== -->
|
||||
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
||||
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||
{{ _('Acquisizione') }}
|
||||
</p>
|
||||
<!-- Lettura: camera e modo salvati nel task -->
|
||||
<template x-if="editingTask !== task.id">
|
||||
<div class="text-xs space-y-1">
|
||||
<template x-if="task.acquisition_json && task.acquisition_json.camera_url">
|
||||
<div class="space-y-1">
|
||||
<p><span class="text-[var(--text-secondary)]">{{ _('Servizio di acquisizione') }}:</span>
|
||||
<span class="font-mono" x-text="task.acquisition_json.camera_url"></span></p>
|
||||
<p><span class="text-[var(--text-secondary)]">{{ _('Sorgente') }}:</span>
|
||||
<span class="font-mono" x-text="task.acquisition_json.camera_source"></span></p>
|
||||
<p><span class="text-[var(--text-secondary)]">{{ _('Modo di analisi') }}:</span>
|
||||
<span x-text="task.acquisition_json.modo === 'in_linea' ? {{ _('Analisi in linea')|tojson_attr }} : {{ _('Analisi impulsiva')|tojson_attr }}"></span></p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!(task.acquisition_json && task.acquisition_json.camera_url)" class="text-[var(--text-muted)]">
|
||||
{{ _("Camera della stazione dell'operatore") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Modifica: servizio, sorgente, modo (salvati con «Salva» del task) -->
|
||||
<template x-if="editingTask === task.id">
|
||||
<div class="space-y-3"
|
||||
x-data="taskAcquisition(() => editTaskData.acquisition, {{ {'genericError': _('Errore durante il caricamento delle sorgenti')}|tojson_attr }})">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Servizio di acquisizione') }}</label>
|
||||
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_url" @change="onServizio()">
|
||||
<option value="">{{ _("Camera della stazione dell'operatore") }}</option>
|
||||
<template x-for="sv in serviziConCorrente()" :key="sv.camera_url">
|
||||
<option :value="sv.camera_url"
|
||||
x-text="sv.camera_url + (sv.stazioni.length ? ' (' + sv.stazioni.join(', ') + ')' : '')"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="editTaskData.acquisition.camera_url">
|
||||
<label class="tmf-label text-xs">{{ _('Sorgente') }}</label>
|
||||
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_source" :disabled="loading">
|
||||
<option value="">{{ _('Scegli la sorgente') }}</option>
|
||||
<template x-for="src in sourcesConCorrente()" :key="src.name">
|
||||
<option :value="src.name" x-text="src.label || src.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
||||
<div x-show="editTaskData.acquisition.camera_url" class="flex flex-wrap items-center gap-4 text-xs">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" :name="'acq-modo-' + task.id" value="impulsiva" x-model="editTaskData.acquisition.modo">
|
||||
{{ _('Analisi impulsiva') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" :name="'acq-modo-' + task.id" value="in_linea" x-model="editTaskData.acquisition.modo">
|
||||
{{ _('Analisi in linea') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
|
||||
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
|
||||
@@ -597,11 +662,16 @@
|
||||
<!-- ========== 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 }})">
|
||||
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
|
||||
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata'), 'dxfEntities': _('entità'), 'imageField': _('campo'), 'pointsInBand': _('Punti in banda'), 'distAvg': _('Distanza media'), 'distMin': _('Distanza minima'), 'distMax': _('Distanza massima'), 'outcomePass': _('Conforme'), 'outcomeWarning': _('Con avvisi'), 'outcomeFail': _('Non conforme'), 'scaleManual': _('mm/px a mano'), 'scaleStation': _('calibrazione stazione'), 'createLabel': _('Crea ricetta da DXF'), 'editLabel': _('Modifica ricetta da DXF'), 'noReferenceImage': _('Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala'), 'existingFilesFailed': _('Impossibile leggere i file della ricetta esistente')}|tojson_attr }})">{# #104: etichette del risultato della Prova; #105: etichette del bottone e avvisi della ricetta esistente #}
|
||||
{# #105: «Modifica ricetta da DXF» se il task ha gia' una ricetta da DXF #}
|
||||
<button type="button" class="btn btn-secondary text-xs" @click="toggle()"
|
||||
x-text="existing ? msgs.editLabel : msgs.createLabel">
|
||||
{{ _('Crea ricetta da DXF') }}
|
||||
</button>
|
||||
<div x-show="open" x-cloak class="mt-3 space-y-3">
|
||||
<!-- #105: ricetta esistente caricata nel wizard -->
|
||||
<p x-show="existingInfo" class="text-xs text-emerald-600">{{ _('Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla') }}</p>
|
||||
<p x-show="existingWarn" class="text-xs text-amber-600" x-text="existingWarn"></p>
|
||||
<!-- Stazione -->
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Stazione') }}</label>
|
||||
@@ -615,14 +685,14 @@
|
||||
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
|
||||
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
|
||||
</p>
|
||||
<p x-show="stationId && calState && !calibrated" class="mt-1 text-xs text-amber-600">
|
||||
<p x-show="stationId && calState && !calibrated && !useManualScale" class="mt-1 text-xs text-amber-600">{# #98: nascosto con mm/px valido (#104: solo per immagine da file) #}
|
||||
<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"
|
||||
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
|
||||
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Immagine: camera o file -->
|
||||
<!-- Immagine: camera o file. #104: prima le scelte, poi le anteprime affiancate -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
|
||||
@@ -635,10 +705,63 @@
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p>
|
||||
<!-- #98: mm/px a mano, #104: solo per un'immagine da file; sostituisce la calibrazione della stazione -->
|
||||
<div x-show="imageSource === 'file'" class="mt-2">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
|
||||
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
|
||||
<!-- #105: DXF preso dalla ricetta esistente (l'input file non puo' mostrarne il nome) -->
|
||||
<p x-show="dxfFromRecipe && dxfFile" class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfFile ? dxfFile.name : ''"></p>
|
||||
<!-- #101: lettura 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>
|
||||
</div>
|
||||
<!-- #104: anteprime sotto le scelte, scatole alte uguali (240px) per stare allineate -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">
|
||||
<div x-show="imagePreviewUrl">
|
||||
<!-- #102: anteprima dell'immagine -->
|
||||
{# #122: <tmf-viewer> standard (zoom, Adatta, Salva, pan) al posto della barra e del viewport scritti a mano (#113); altezza fissa 240px #}
|
||||
<div x-show="!imageBroken">{# #122: x-show sul contenitore, mai sul <tmf-viewer> #}
|
||||
{# #122: formato non mostrabile -> viewer-error del componente -> avviso imageBroken (al posto della sonda <img hidden>) #}
|
||||
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||
<tmf-viewer :src="imagePreviewUrl" save-name="immagine" no-layers
|
||||
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||
@viewer-load="onImagePreviewLoad($event.detail)" @viewer-error="onImagePreviewError()"
|
||||
class="rounded border border-[var(--border-color)]"
|
||||
style="height: 240px; background: var(--bg-primary);"></tmf-viewer>
|
||||
</div>
|
||||
<p x-show="imageBroken" class="text-xs text-amber-600">{{ _('Anteprima non disponibile per questo formato') }}</p>
|
||||
<p x-show="imagePreviewSummary" class="mt-1 text-xs text-[var(--text-muted)]" x-text="imagePreviewSummary"></p>
|
||||
</div>
|
||||
<div x-show="dxfPreview" class="sm:col-start-2">{# #104: resta a destra anche senza anteprima immagine #}
|
||||
{# #122: <tmf-viewer> in modo contenuto: lo <svg> (viewBox in mm) va nello stage; width/height = misura del viewBox, ora anche Salva (PNG) #}
|
||||
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||
<tmf-viewer save-name="dxf" no-layers
|
||||
:width="dxfPreviewViewBox().split(' ')[2]" :height="dxfPreviewViewBox().split(' ')[3]"
|
||||
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||
class="rounded border border-[var(--border-color)]"
|
||||
style="height: 240px; background: var(--bg-primary);">
|
||||
<svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
|
||||
class="block rounded" style="width: 100%; height: 100%;">
|
||||
<!-- #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>
|
||||
</tmf-viewer>
|
||||
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pochi parametri -->
|
||||
@@ -663,21 +786,43 @@
|
||||
<button type="button" class="btn btn-primary text-xs" @click="run(true)"
|
||||
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
|
||||
</div>
|
||||
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
||||
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
|
||||
<!-- Anteprima con overlay e quattro valori -->
|
||||
<div x-show="imageSrc" class="relative">
|
||||
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]"
|
||||
@load="drawOverlay()">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||
<!-- #104: errore di acquisizione prima di una Prova (dopo, sta nella colonna dei dati) -->
|
||||
<p x-show="error && !tested" class="text-xs text-red-500" x-text="error"></p>
|
||||
<!-- #104: risultato della Prova su due colonne: immagine con zoom a sinistra, dati ed errori a destra -->
|
||||
<div x-show="tested" class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">{# #104: risultati a destra gia' da 640px, come le altre griglie del wizard #}
|
||||
<div>
|
||||
{# #122: <tmf-viewer fit-height> al posto di barra, viewport, pan e overlay della Prova (#104 #121); overlay dalle forme del risultato #}
|
||||
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||
<tmf-viewer fit-height :src="imageSrc" save-name="prova" no-layers
|
||||
x-effect="$el.overlay = (result && result.result && result.result.overlay) || []"
|
||||
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||
class="rounded border border-[var(--border-color)]"
|
||||
style="background: var(--bg-primary);"></tmf-viewer>
|
||||
</div>{# #122: chiude solo la colonna dell'immagine (una chiusura in piu' chiudeva la griglia) #}
|
||||
<div class="space-y-2">
|
||||
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
||||
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
|
||||
<div x-show="result" class="space-y-2">
|
||||
<p class="text-xs">{{ _('Esito') }}: <span class="font-semibold" :class="outcomeClass" x-text="outcomeLabel"></span></p>
|
||||
<table class="tmf-table text-xs">
|
||||
<tbody>
|
||||
<template x-for="v in values" :key="v.key">
|
||||
<tr>
|
||||
<td :title="v.key" x-text="v.label"></td>
|
||||
<td class="text-right"><span x-text="fmt(v.value)"></span> <span class="text-[var(--text-muted)]" x-text="v.unit"></span></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<ul class="text-xs text-[var(--text-muted)] space-y-0.5">
|
||||
<li>{{ _('Scala') }}: <span x-text="scaleSummary"></span></li>
|
||||
<li>{{ _('Banda ± mm') }}: <span x-text="fmt(runBandMm)"></span></li>
|
||||
<li>{{ _('Soglia punti in banda %%') }}: <span x-text="fmt(runMinPointsPct)"></span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table x-show="result" class="tmf-table text-xs">
|
||||
<tbody>
|
||||
<template x-for="v in values" :key="v.key">
|
||||
<tr><td class="font-mono" x-text="v.key"></td><td class="text-right" x-text="fmt(v.value)"></td></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -735,6 +880,8 @@
|
||||
@change="uploadTaskFile(task.id, $event.target.files[0], task); $event.target.value = ''">
|
||||
</label>
|
||||
</template>
|
||||
<!-- #107: errore di caricamento anche dentro il riquadro, non solo nel banner in cima -->
|
||||
<p x-show="uploadErrorTask === task.id && errorMessage" x-text="errorMessage" class="text-xs text-red-600 dark:text-red-400 mt-2"></p>
|
||||
</div>
|
||||
|
||||
<!-- Task Directive / Description (read-only display) -->
|
||||
@@ -1334,11 +1481,16 @@
|
||||
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/vision_overlay.js') }}"></script>{# #121 #}
|
||||
<script src="{{ url_for('static', filename='js/save_image.js') }}"></script>{# #119 #}
|
||||
<script src="{{ url_for('static', filename='js/image_viewer.js') }}"></script>{# #122: <tmf-viewer>, dopo vision_overlay.js e save_image.js #}
|
||||
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/task_acquisition.js') }}"></script>{# #97 #}
|
||||
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
||||
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/image_to_png.js') }}"></script>{# #107 #}
|
||||
<script>
|
||||
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
||||
ImageCrop.configure({
|
||||
@@ -1385,11 +1537,12 @@ function taskEditor() {
|
||||
showAddTask: false,
|
||||
saving: false,
|
||||
errorMessage: '',
|
||||
uploadErrorTask: null, // #107: id del task il cui upload e' fallito (errore mostrato nel riquadro Disegno Tecnico)
|
||||
successMessage: '',
|
||||
|
||||
// ---- Task CRUD state ----
|
||||
editingTask: null,
|
||||
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
|
||||
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }, // #97
|
||||
newTask: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
|
||||
MAX_NOTE_FIELDS: 20, // #55: limite campi per nota (stesso del backend)
|
||||
_fieldUid: 0,
|
||||
@@ -1575,6 +1728,18 @@ function taskEditor() {
|
||||
this.newTask = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
|
||||
},
|
||||
|
||||
// #97: acquisition_json del task; null = camera della stazione (voce vuota o tipo senza camera)
|
||||
_acquisitionPayload(obj) {
|
||||
const a = obj.acquisition || {};
|
||||
const usesCamera = obj.task_type === 'camera_measure' || obj.task_type === 'dxf_compare';
|
||||
if (!usesCamera || !a.camera_url) return { ok: true, value: null };
|
||||
if (!a.camera_source) {
|
||||
this.errorMessage = {{ _("Scegli la sorgente della camera")|tojson }};
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, value: { camera_url: a.camera_url, camera_source: a.camera_source, modo: a.modo === 'in_linea' ? 'in_linea' : 'impulsiva' } };
|
||||
},
|
||||
|
||||
// ---- #53 #55: ciclo di misura e campi della nota ----
|
||||
|
||||
// #53: default di "nel ciclo" per tipo (stessa regola del backend)
|
||||
@@ -1785,7 +1950,13 @@ function taskEditor() {
|
||||
in_loop: task.in_loop != null ? !!task.in_loop : this.loopDefault(task.task_type || 'note'),
|
||||
in_loop_touched: false,
|
||||
at_close: !!task.at_close,
|
||||
fields: this._fieldsToState(task.fields)
|
||||
fields: this._fieldsToState(task.fields),
|
||||
// #97: camera e modo di analisi del task (acquisition_json), vuoti = camera della stazione
|
||||
acquisition: {
|
||||
camera_url: (task.acquisition_json && task.acquisition_json.camera_url) || '',
|
||||
camera_source: (task.acquisition_json && task.acquisition_json.camera_source) || '',
|
||||
modo: (task.acquisition_json && task.acquisition_json.modo) || 'impulsiva'
|
||||
}
|
||||
};
|
||||
// Ensure expanded
|
||||
this.expandedTask = task.id;
|
||||
@@ -1793,7 +1964,7 @@ function taskEditor() {
|
||||
|
||||
cancelEditTask() {
|
||||
this.editingTask = null;
|
||||
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
|
||||
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }; // #97
|
||||
},
|
||||
|
||||
async updateTask(taskId) {
|
||||
@@ -1801,6 +1972,8 @@ function taskEditor() {
|
||||
this.errorMessage = '';
|
||||
const fp = this._fieldsPayload(this.editTaskData); // #55
|
||||
if (!fp.ok) return;
|
||||
const acq = this._acquisitionPayload(this.editTaskData); // #97
|
||||
if (!acq.ok) return;
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
@@ -1818,7 +1991,8 @@ function taskEditor() {
|
||||
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
|
||||
in_loop: !!this.editTaskData.in_loop,
|
||||
at_close: this.editTaskData.task_type === 'note' && !!this.editTaskData.at_close,
|
||||
fields: fp.fields
|
||||
fields: fp.fields,
|
||||
acquisition_json: acq.value // #97
|
||||
})
|
||||
});
|
||||
|
||||
@@ -2249,14 +2423,24 @@ function taskEditor() {
|
||||
async uploadTaskFile(taskId, file, task) {
|
||||
if (!file) return;
|
||||
|
||||
// #107: BMP e simili (leggibili dal browser, non dal backend) diventano PNG prima dei controlli
|
||||
file = await tmfImageToPng(file);
|
||||
if (!file) {
|
||||
this.errorMessage = {{ _("Formato immagine non leggibile. Usa PNG, JPG o PDF.")|tojson }};
|
||||
this.uploadErrorTask = taskId;
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'application/pdf'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
this.errorMessage = {{ _("Formato file non supportato. Usa PNG, JPG o PDF.")|tojson }};
|
||||
this.uploadErrorTask = taskId; // #107
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
this.errorMessage = {{ _("File troppo grande. Dimensione massima: 20MB.")|tojson }};
|
||||
this.uploadErrorTask = taskId; // #107
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2266,6 +2450,7 @@ function taskEditor() {
|
||||
|
||||
this.saving = true;
|
||||
this.errorMessage = '';
|
||||
this.uploadErrorTask = null; // #107: nuovo tentativo, azzera l'errore del riquadro
|
||||
|
||||
try {
|
||||
// 1. Upload file
|
||||
@@ -2282,6 +2467,7 @@ function taskEditor() {
|
||||
const uploadData = await uploadResp.json();
|
||||
if (uploadData.error) {
|
||||
this.errorMessage = uploadData.detail || {{ _("Errore durante il caricamento del file")|tojson }};
|
||||
this.uploadErrorTask = taskId; // #107
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
@@ -2306,6 +2492,7 @@ function taskEditor() {
|
||||
const updateData = await updateResp.json();
|
||||
if (updateData.error) {
|
||||
this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }};
|
||||
this.uploadErrorTask = taskId; // #107
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
@@ -2321,6 +2508,7 @@ function taskEditor() {
|
||||
} catch (err) {
|
||||
console.error('uploadTaskFile error:', err);
|
||||
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
||||
this.uploadErrorTask = taskId; // #107
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
|
||||
@@ -195,10 +195,12 @@
|
||||
{{ "%.3f"|format(m.subtask.nominal) }} {{ m.subtask.unit }}
|
||||
</td>
|
||||
<td class="text-right measure-value font-semibold" style="color: var(--text-primary);">
|
||||
{{ "%.3f"|format(m.value) }} {{ m.subtask.unit }}
|
||||
{# #118: quota non misurata (value None) -> NaN, senza unita' #}
|
||||
{% if m.value is none %}NaN{% else %}{{ "%.3f"|format(m.value) }} {{ m.subtask.unit }}{% endif %}
|
||||
</td>
|
||||
<td class="text-right measure-value {% if m.deviation > 0 %}text-measure-fail dark:text-red-400{% elif m.deviation < 0 %}text-measure-pass dark:text-green-400{% else %}{% endif %}">
|
||||
{{ "%+.3f"|format(m.deviation) }}
|
||||
<td class="text-right measure-value {% if m.value is not none and m.deviation is not none and m.deviation > 0 %}text-measure-fail dark:text-red-400{% elif m.value is not none and m.deviation is not none and m.deviation < 0 %}text-measure-pass dark:text-green-400{% else %}{% endif %}">
|
||||
{# #118: deviazione None, o valore None (il blueprint la mette a 0.0) -> NaN #}
|
||||
{% if m.value is none or m.deviation is none %}NaN{% else %}{{ "%+.3f"|format(m.deviation) }}{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if m.pass_fail == 'pass' %}
|
||||
|
||||
@@ -69,7 +69,8 @@
|
||||
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
||||
@numpad-rejected.window="onNumpadRejected($event.detail)"
|
||||
@numpad-error.window="onNumpadError($event.detail)"
|
||||
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
|
||||
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)"
|
||||
@vision-recorded.window="onVisionRecorded($event.detail)">
|
||||
|
||||
{# ================================================================
|
||||
HEADER — Compact task info bar
|
||||
@@ -471,7 +472,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<span>{{ _('Registrata') }}:</span>
|
||||
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span>
|
||||
{# #118: quota registrata senza valore (visione, NaN) #}
|
||||
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) ?? 'NaN'"></span>
|
||||
<span x-text="currentSubtask?.unit || 'mm'"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -539,13 +541,14 @@
|
||||
|
||||
<div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
|
||||
{% if not camera_enabled %}
|
||||
{# #119: l'input file solo senza camera. On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
|
||||
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
|
||||
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
|
||||
<input type="file" accept="image/*" capture="environment" class="hidden"
|
||||
@change="onPick($event)" :disabled="busy">
|
||||
</label>
|
||||
{% if camera_enabled %}
|
||||
{% else %}
|
||||
{# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #}
|
||||
{% if camera_mode == 'in_linea' %}
|
||||
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()"
|
||||
@@ -558,7 +561,7 @@
|
||||
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
|
||||
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
|
||||
:disabled="busy || grabbing">
|
||||
<span>{{ _('Acquisisci') }}</span>
|
||||
<span>{{ _('Acquisisci da camera') }}</span> {# #119 #}
|
||||
</button>
|
||||
{% endif %}
|
||||
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
|
||||
@@ -576,10 +579,13 @@
|
||||
|
||||
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
|
||||
|
||||
{# #96: riquadro flex-1 min-h-0; img e canvas object-contain sovrapposti (il canvas ha la stessa risoluzione naturale dell'immagine, quindi l'overlay resta allineato) #}
|
||||
{# #96: riquadro flex-1 min-h-0; #122: dentro, <tmf-viewer> (zoom, pan, Salva, overlay SVG a tratto costante ed etichette quota) riempie il riquadro; overlay e labels arrivano da x-effect (px dell'immagine, #121 #116); x-show sul contenitore, mai sul viewer #}
|
||||
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
||||
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="absolute inset-0 w-full h-full object-contain">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas>
|
||||
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||
<tmf-viewer class="block w-full h-full" no-layers :src="shownImage" :save-name="saveName"
|
||||
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||
x-effect="$el.overlay = (result && result.overlay) || []; $el.labels = viewerLabels"></tmf-viewer>
|
||||
</div>
|
||||
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
||||
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
||||
@@ -593,12 +599,29 @@
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm" x-show="rows.length">
|
||||
{# #117: due righe per quota (esito, poi nominale e tolleranze come nella schermata dei calibri); un solo tbody per quota perche' x-for vuole un'unica radice #}
|
||||
<template x-for="r in rows" :key="r.label">
|
||||
<tr>
|
||||
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
|
||||
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
|
||||
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
|
||||
{# #118: quota non misurata (value null) = «NaN», esito fail #}
|
||||
<td class="py-1 pr-2 font-mono text-right" x-text="fmtValue(r.value, r.unit)"></td>
|
||||
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
|
||||
</tr>
|
||||
<tr class="border-b border-[var(--border-color)]">
|
||||
<td colspan="3" class="pb-1.5 text-[11px] font-mono">
|
||||
<span class="text-primary font-semibold">{{ _('Nom') }} <span x-text="fmtTol(r.nominal)"></span></span>
|
||||
<span class="text-measure-fail ml-2">LTL <span x-text="fmtTol(r.ltl)"></span></span>
|
||||
<span class="text-measure-fail ml-1">UTL <span x-text="fmtTol(r.utl)"></span></span>
|
||||
<template x-if="r.lwl != null || r.uwl != null">
|
||||
<span>
|
||||
<span class="text-measure-warning ml-2">LWL <span x-text="fmtTol(r.lwl)"></span></span>
|
||||
<span class="text-measure-warning ml-1">UWL <span x-text="fmtTol(r.uwl)"></span></span>
|
||||
</span>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</template>
|
||||
</table>
|
||||
|
||||
@@ -1172,10 +1195,17 @@
|
||||
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||
taskId: {{ task.id|tojson }},
|
||||
taskType: {{ (task.task_type or '')|tojson }},
|
||||
taskName: {{ (task.title or '')|tojson }}, {# #119: nome proposto per «Salva» #}
|
||||
subtasks: window.__taskSubtasks,
|
||||
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
|
||||
};
|
||||
</script>
|
||||
{# #116: disegno overlay ed etichette quote; prima di vision_execute.js, che lo usa se presente #}
|
||||
<script src="{{ url_for('static', filename='js/vision_overlay.js') }}"></script>
|
||||
{# #119: «Salva» immagine (frame grezzo PNG) #}
|
||||
<script src="{{ url_for('static', filename='js/save_image.js') }}"></script>
|
||||
{# #122: visualizzatore standard <tmf-viewer>, dopo vision_overlay.js e save_image.js #}
|
||||
<script src="{{ url_for('static', filename='js/image_viewer.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
||||
{# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
|
||||
<script>
|
||||
@@ -1217,6 +1247,7 @@ function taskExecute() {
|
||||
// ---- Measurement state ----
|
||||
currentIndex: 0,
|
||||
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
|
||||
visionRecorded: false, // #120: task visione: il pezzo e' stato registrato («Registra»), le quote sono in measurements
|
||||
saving: false,
|
||||
// The single reentrancy guard for handleMeasurement (#13): true for the
|
||||
// whole time a reading is being processed, including the 1s feedback
|
||||
@@ -1323,6 +1354,8 @@ function taskExecute() {
|
||||
},
|
||||
|
||||
get missingQuotesLabel() {
|
||||
// #120: task visione non registrato: le quote arrivano tutte col «Registra», non una a una
|
||||
if (this.isVisionTask && !this.visionRecorded) return {{ _('Registra il pezzo')|tojson }};
|
||||
const n = this.missingQuotes;
|
||||
return n === 1
|
||||
? {{ _('Manca 1 quota')|tojson }}
|
||||
@@ -1336,6 +1369,8 @@ function taskExecute() {
|
||||
ciclo misura», and the quotes it exists to collect would be optional. */
|
||||
get canLeaveTask() {
|
||||
if (this.blockedByTolerance) return false;
|
||||
// #120: un task visione si lascia solo dopo «Registra» (anche dxf_compare, prima sempre attivo)
|
||||
if (this.isVisionTask && !this.visionRecorded) return false;
|
||||
if (this.isMeasureTask && this.subtasks.length > 0) {
|
||||
return this.cycleConfirmed;
|
||||
}
|
||||
@@ -1346,6 +1381,7 @@ function taskExecute() {
|
||||
if (this.blockedByTolerance) {
|
||||
return {{ _("Serve l'autorizzazione del capoturno")|tojson }};
|
||||
}
|
||||
if (this.isVisionTask && !this.visionRecorded) return this.missingQuotesLabel; // #120
|
||||
if (!this.isComplete) return this.missingQuotesLabel;
|
||||
return {{ _('Chiudi prima il ciclo di misura')|tojson }};
|
||||
},
|
||||
@@ -1429,9 +1465,10 @@ function taskExecute() {
|
||||
subtask_id: m.subtask_id,
|
||||
value: m.value,
|
||||
pass_fail: m.pass_fail,
|
||||
deviation: m.value - nominal,
|
||||
deviation: m.value == null ? null : m.value - nominal, // #118: quota non misurata = NaN, nessuna deviazione
|
||||
};
|
||||
});
|
||||
if (this.isVisionTask) this.visionRecorded = true; // #120: misure ripristinate = pezzo gia' registrato
|
||||
const next = this.subtasks.findIndex(
|
||||
s => !this.measurements.some(m => m.subtask_id === s.id),
|
||||
);
|
||||
@@ -1912,6 +1949,23 @@ function taskExecute() {
|
||||
this.redirectIn = this.measurementTaskUrl ? 5 : 0;
|
||||
},
|
||||
|
||||
/* #120: «Registra» del pannello visione: le misure salvate dal server diventano le misure del
|
||||
ciclo (conteggio quote, «Fine ciclo misura»/«Completato»). value null = quota non misurata. */
|
||||
onVisionRecorded(detail) {
|
||||
const ms = (detail && detail.measurements) || [];
|
||||
this.measurements = ms.map(m => {
|
||||
const st = this.subtasks.find(s => s.id === m.subtask_id);
|
||||
const nominal = st ? (st.nominal || 0) : 0;
|
||||
return {
|
||||
subtask_id: m.subtask_id,
|
||||
value: m.value == null ? null : m.value,
|
||||
pass_fail: m.pass_fail,
|
||||
deviation: m.value == null ? null : m.value - nominal,
|
||||
};
|
||||
});
|
||||
this.visionRecorded = true;
|
||||
},
|
||||
|
||||
/* Clear the screen for another pass over the same quotes.
|
||||
Local only: everything already measured is on the server, and the next
|
||||
reading is saved beside it rather than replacing it. */
|
||||
@@ -1919,6 +1973,7 @@ function taskExecute() {
|
||||
this.cycleConfirmed = false;
|
||||
this.showCompletionOverlay = false;
|
||||
this.measurements = [];
|
||||
this.visionRecorded = false; // #120: nuovo ciclo = pezzo da registrare di nuovo
|
||||
this.currentIndex = 0;
|
||||
this.currentValue = null;
|
||||
this.errorMessage = '';
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<span class="truncate" x-text="s.description"></span>
|
||||
<span class="font-mono whitespace-nowrap"
|
||||
:class="s.in_tolerance ? 'text-green-700' : 'text-red-700 font-bold'"
|
||||
x-text="s.mean.toFixed(3) + ' ' + s.unit"></span>
|
||||
x-text="(s.mean == null ? 'NaN' : s.mean.toFixed(3)) + ' ' + s.unit"></span>{# #118: quota con soli valori NaN: media null #}
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
@@ -51,7 +51,9 @@
|
||||
<!-- Subtask -->
|
||||
<div>
|
||||
<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"
|
||||
@change="loadData()"
|
||||
: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">
|
||||
<option value="">{{ _("Tutti i punti") }}</option>
|
||||
@@ -287,20 +289,52 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div id="control-chart" style="min-height: 350px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div id="histogram-chart" style="min-height: 350px;"></div>
|
||||
</div>
|
||||
@@ -346,6 +380,8 @@ function spcDashboard() {
|
||||
subtasks: [],
|
||||
|
||||
// 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,
|
||||
capability: null,
|
||||
controlChart: null,
|
||||
@@ -408,6 +444,8 @@ function spcDashboard() {
|
||||
|
||||
async onRecipeChange() {
|
||||
this.selectedSubtaskId = '';
|
||||
this.loadedSubtaskId = ''; // #100
|
||||
this.capabilityAll = []; // #100
|
||||
this.selectedRunId = ''; // #58
|
||||
this.runs = [];
|
||||
this.subtasks = [];
|
||||
@@ -459,10 +497,11 @@ function spcDashboard() {
|
||||
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.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 {
|
||||
// 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()),
|
||||
this.selectedSubtaskId
|
||||
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
|
||||
@@ -473,6 +512,17 @@ function spcDashboard() {
|
||||
this.selectedSubtaskId
|
||||
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
|
||||
: 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)
|
||||
@@ -505,6 +555,9 @@ function spcDashboard() {
|
||||
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;
|
||||
|
||||
// Render charts after DOM update
|
||||
@@ -522,7 +575,7 @@ function spcDashboard() {
|
||||
|
||||
renderCharts() {
|
||||
// Control chart
|
||||
if (this.controlChart && this.selectedSubtaskId) {
|
||||
if (this.controlChart && this.loadedSubtaskId) { // #100
|
||||
renderControlChart('control-chart', this.controlChart);
|
||||
} else {
|
||||
const el = document.getElementById('control-chart');
|
||||
@@ -530,7 +583,7 @@ function spcDashboard() {
|
||||
}
|
||||
|
||||
// Histogram
|
||||
if (this.histogram && this.selectedSubtaskId) {
|
||||
if (this.histogram && this.loadedSubtaskId) { // #100
|
||||
const tol = this.capability ? {
|
||||
utl: this.capability.utl,
|
||||
ltl: this.capability.ltl,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<!doctype html>
|
||||
<!-- #122: pagina di prova di <tmf-viewer> da aprire in Chrome (file:// va bene): immagine generata col canvas,
|
||||
overlay vs-tools finto, etichette e modo contenuto (svg). Il riquadro #log riporta i controlli automatici. -->
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Prova tmf-viewer</title>
|
||||
<style>
|
||||
body { font: 14px system-ui, sans-serif; margin: 16px; background: #f3f4f6; }
|
||||
.griglia { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.box { height: 320px; }
|
||||
h2 { font-size: 13px; margin: 12px 0 4px; }
|
||||
#log { white-space: pre; font: 12px ui-monospace, monospace; background: #fff; padding: 8px; margin-top: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Prova di <tmf-viewer></h1>
|
||||
<div class="griglia">
|
||||
<div><h2>Immagine 800x500 + overlay + etichette (box fisso, Adatta nel box)</h2>
|
||||
<tmf-viewer id="a" class="box" save-name="demo_immagine" width="800" height="500"></tmf-viewer></div>
|
||||
<div><h2>fit-height, no-save: alto quanto l'immagine</h2>
|
||||
<tmf-viewer id="b" fit-height no-save label-fit="Intera"></tmf-viewer></div>
|
||||
<div><h2>Modo contenuto: svg (DXF) senza src</h2>
|
||||
<tmf-viewer id="c" class="box" save-name="demo_dxf">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><rect x="5" y="5" width="190" height="90" fill="none" stroke="#fff" stroke-width="1"/><circle cx="100" cy="50" r="30" fill="none" stroke="#0ff" stroke-width="1"/></svg>
|
||||
</tmf-viewer></div>
|
||||
<div><h2>Vuoto: barra nascosta</h2><tmf-viewer id="d" class="box"></tmf-viewer></div>
|
||||
<!-- #126: no-layers (overlay misto, etichette con key) e, per confronto, lo stesso senza no-layers -->
|
||||
<div><h2>#126 no-layers: overlay misto, etichette fisse</h2>
|
||||
<tmf-viewer id="f" class="box" no-layers width="400" height="250" save-name="demo_nolayers"></tmf-viewer></div>
|
||||
<div><h2>#126 senza no-layers (confronto): livelli e trascinamento</h2>
|
||||
<tmf-viewer id="g" class="box" width="400" height="250" save-name="demo_conlayers"></tmf-viewer></div>
|
||||
</div>
|
||||
<div id="log">in corso...</div>
|
||||
<script>
|
||||
// #126: stato dei livelli salvato prima del caricamento del visore (ricerca spenta): con no-layers non deve contare
|
||||
try { localStorage.setItem('tmf-viewer-layers', JSON.stringify({ ricerca: false })); } catch (e) { /* storage bloccato */ }
|
||||
</script>
|
||||
<script src="../../../static/js/vision_overlay.js"></script>
|
||||
<script src="../../../static/js/save_image.js"></script>
|
||||
<script src="../../../static/js/image_viewer.js"></script>
|
||||
<script>
|
||||
window.onerror = function (m, u, l) { document.getElementById('log').textContent += '\nERRORE ' + m + ' riga ' + l; }; // errori visibili nel riquadro
|
||||
(function () {
|
||||
var out = [], fails = 0;
|
||||
function check(l, c, x) { if (!c) fails++; out.push((c ? 'ok - ' : 'FAIL - ') + l + (c || x === undefined ? '' : ' :: ' + x)); document.getElementById('log').textContent = out.join('\n'); } // log aggiornato a ogni controllo
|
||||
window.tmfCheck = check; // #126: il blocco no-layers in fondo alla pagina scrive nello stesso #log
|
||||
function img(w, h, col) {
|
||||
var c = document.createElement('canvas'); c.width = w; c.height = h;
|
||||
var g = c.getContext('2d'); g.fillStyle = col; g.fillRect(0, 0, w, h);
|
||||
g.fillStyle = '#fff'; for (var x = 0; x < w; x += 100) g.fillRect(x, 0, 2, h);
|
||||
for (var y = 0; y < h; y += 100) g.fillRect(0, y, w, 2);
|
||||
return c.toDataURL('image/png');
|
||||
}
|
||||
var a = document.getElementById('a'), b = document.getElementById('b'), c = document.getElementById('c'), d = document.getElementById('d');
|
||||
var ev = { load: 0, zoom: 0 };
|
||||
a.addEventListener('viewer-load', function (e) { ev.load++; ev.det = e.detail; });
|
||||
a.addEventListener('viewer-zoom', function () { ev.zoom++; });
|
||||
a.overlay = [{ type: 'segment', role: 'found', row1: 50, col1: 50, row2: 450, col2: 750 },
|
||||
{ type: 'points', role: 'reference', rows: [100, 200], cols: [100, 300] }];
|
||||
a.labels = [{ key: 'q1', x: 400, y: 250, text: 'Quota 1 12.345 mm', cls: 'ok' }];
|
||||
a.setAttribute('src', img(800, 500, '#334155'));
|
||||
b.setAttribute('src', img(1000, 400, '#475569'));
|
||||
function btn(v, i) { return v.querySelectorAll('.tmfv-bar button')[i]; }
|
||||
setTimeout(function () {
|
||||
var vpa = a.querySelector('.tmfv-vp'), st = a.querySelector('.tmfv-stage');
|
||||
var fitA = function () { return Math.min(vpa.clientWidth / 800, vpa.clientHeight / 500); };
|
||||
check('a: viewer-load una volta con 800x500', ev.load === 1 && ev.det && ev.det.width === 800 && ev.det.height === 500, JSON.stringify(ev));
|
||||
check('a: Adatta = min(box/img), stage entra nel box', Math.abs(a.zoom - fitA()) < 0.01 && st.offsetWidth <= vpa.clientWidth + 1 && st.offsetHeight <= vpa.clientHeight + 1,
|
||||
a.zoom + ' ' + st.offsetWidth + 'x' + st.offsetHeight + ' vp ' + vpa.clientWidth + 'x' + vpa.clientHeight);
|
||||
check('a: percentuale mostrata', /^\d+ %$/.test(a.querySelector('.tmfv-pct').textContent), a.querySelector('.tmfv-pct').textContent);
|
||||
check('a: overlay con 2 forme e viewBox 0 0 800 500', a.querySelector('.tmfv-ov').children.length >= 2 && a.querySelector('.tmfv-ov').getAttribute('viewBox') === '0 0 800 500', a.querySelector('.tmfv-ov').innerHTML.slice(0, 80));
|
||||
var lab = a.querySelector('.tmfv-label');
|
||||
check('a: etichetta al 50 % / 50 %, testo, classe', lab && lab.style.left === '50%' && lab.style.top === '50%' && lab.textContent === 'Quota 1 12.345 mm' && lab.classList.contains('ok'));
|
||||
check('a: barra visibile con -, +, Adatta, Salva', !a.querySelector('.tmfv-bar').hidden && a.querySelectorAll('.tmfv-bar button').length === 4 && btn(a, 3).textContent === 'Salva');
|
||||
var z0 = a.zoom; btn(a, 1).click();
|
||||
check('a: + ingrandisce di 1.25', Math.abs(a.zoom / z0 - 1.25) < 1e-9, a.zoom + '/' + z0);
|
||||
for (var i = 0; i < 6; i++) a.zoomBy(1.25);
|
||||
check('a: ingrandito -> scorrimento possibile e cursore grab', vpa.scrollWidth > vpa.clientWidth && vpa.style.cursor === 'grab', vpa.style.cursor);
|
||||
var rect = vpa.getBoundingClientRect(), px = 120, py = 90;
|
||||
var zb = a.zoom, lx = (vpa.scrollLeft + px) / zb, ly = (vpa.scrollTop + py) / zb;
|
||||
vpa.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true, deltaY: -100, clientX: rect.left + vpa.clientLeft + px, clientY: rect.top + vpa.clientTop + py, bubbles: true, cancelable: true }));
|
||||
var za = a.zoom, lx2 = (vpa.scrollLeft + px) / za, ly2 = (vpa.scrollTop + py) / za;
|
||||
check('a: Ctrl+rotella zooma e tiene fermo il punto', za > zb && Math.abs(lx - lx2) < 1 && Math.abs(ly - ly2) < 1, [zb, za, lx, lx2, ly, ly2].join(' '));
|
||||
for (i = 0; i < 40; i++) a.zoomBy(1.25);
|
||||
check('a: zoom limitato a max(8, fit)', a.zoom <= 8.0000001, a.zoom);
|
||||
var we = new WheelEvent('wheel', { deltaY: 100, bubbles: true, cancelable: true }); vpa.dispatchEvent(we);
|
||||
check('a: rotella senza Ctrl zooma sul punto del mouse (intercettata, #12 #126)', we.defaultPrevented === true); // #126: controllo aggiornato al contratto #12
|
||||
btn(a, 2).click();
|
||||
check('a: Adatta torna al fit e scroll a 0', Math.abs(a.zoom - fitA()) < 0.01 && vpa.scrollLeft === 0, a.zoom);
|
||||
check('a: viewer-zoom emesso a ogni cambio', ev.zoom > 3, ev.zoom);
|
||||
a.zoomBy(1.25); var zz = a.zoom; ev.load = 0;
|
||||
a.setAttribute('src', img(800, 500, '#7f1d1d'));
|
||||
setTimeout(function () {
|
||||
check('a: nuovo src, stesse dimensioni -> zoom mantenuto', ev.load === 1 && Math.abs(a.zoom - zz) < 1e-9, a.zoom + ' vs ' + zz);
|
||||
var vpb = b.querySelector('.tmfv-vp');
|
||||
check('b: fit-height: viewport alto quanto l\'immagine adattata alla larghezza', Math.abs(vpb.offsetHeight - 400 * b.zoom) < 1.5 && Math.abs(b.zoom - vpb.clientWidth / 1000) < 0.01,
|
||||
vpb.offsetHeight + ' vs ' + 400 * b.zoom + ' zoom ' + b.zoom);
|
||||
check('b: no-save nasconde Salva, label-fit "Intera"', btn(b, 3).hidden && btn(b, 2).textContent === 'Intera');
|
||||
b.style.width = '300px';
|
||||
// il resize e' consegnato dal ResizeObserver al prossimo frame: in una scheda in background puo' tardare, quindi attendo (max 5 s)
|
||||
var t0 = Date.now();
|
||||
(function attendi() { if (b.zoom < 0.5 || Date.now() - t0 > 5000) dopo(); else setTimeout(attendi, 100); })();
|
||||
function dopo() {
|
||||
check('b: resize in stato adatta -> rifa Adatta (300 px)', Math.abs(b.zoom - vpb.clientWidth / 1000) < 0.01 && Math.abs(vpb.offsetHeight - 400 * b.zoom) < 1.5, b.zoom + ' ' + vpb.offsetHeight);
|
||||
var stc = c.querySelector('.tmfv-stage');
|
||||
check('c: svg nello stage, dimensioni dal viewBox 200x100, Adatta', stc.querySelector('svg:not(.tmfv-ov)') && !c.querySelector(':scope > svg') && c.zoom > 0 && Math.abs(stc.offsetWidth / stc.offsetHeight - 2) < 0.05, c.zoom + ' ' + stc.offsetWidth + 'x' + stc.offsetHeight);
|
||||
// #122: DXF in mm (fit ~ 10): si ingrandisce oltre Adatta; width/height cambiati dopo il connect rifanno Adatta
|
||||
c.setAttribute('width', '50'); c.setAttribute('height', '30'); // contenuto 50 x 30 mm nel box ~ 560 x 320
|
||||
var zc = c.zoom; c.zoomBy(1.25);
|
||||
check('c: width/height 50x30 -> fit > 8 e zoom oltre Adatta', zc > 8 && c.zoom > zc * 1.2, zc + ' -> ' + c.zoom);
|
||||
// #122: viewer-error su immagine non caricabile; src tolto -> componente vuoto senza errori
|
||||
var e = document.createElement('tmf-viewer'); e.style.height = '100px'; document.body.appendChild(e);
|
||||
var gotErr = null; e.addEventListener('viewer-error', function (x) { gotErr = x.detail; });
|
||||
e.overlay = [{ type: 'segment', role: 'found', row1: 1, col1: 1, row2: 5, col2: 9 }]; e.labels = [{ x: 5, y: 5, text: 'x' }]; // prima del load
|
||||
e.setAttribute('src', 'inesistente.png');
|
||||
setTimeout(function () {
|
||||
check('e: viewer-error con src', gotErr && gotErr.src === 'inesistente.png', JSON.stringify(gotErr));
|
||||
e.setAttribute('src', img(100, 50, '#000'));
|
||||
setTimeout(function () {
|
||||
check('e: overlay ed etichette impostati prima del load disegnati dopo', e.querySelectorAll('.tmfv-ov g[data-layer="costruzioni"] > *').length === 1 && e.querySelectorAll('.tmfv-label').length === 1);
|
||||
var lb = getComputedStyle(e.querySelector('.tmfv-label'));
|
||||
check('e: etichetta con aspetto proprio (absolute, bold, padding)', lb.position === 'absolute' && lb.fontWeight === '700' && lb.paddingLeft === '6px' && lb.pointerEvents === 'none');
|
||||
e.removeAttribute('src');
|
||||
check('e: src tolto -> vuoto, barra nascosta', e.querySelector('.tmfv-bar').hidden === true && e.zoom === 0);
|
||||
}, 300);
|
||||
}, 300);
|
||||
check('d: vuoto -> barra nascosta', d.querySelector('.tmfv-bar').hidden === true);
|
||||
check('stile iniettato una sola volta', document.querySelectorAll('#tmf-viewer-css').length === 1);
|
||||
tmfSvgToPngBlob(c.querySelector('.tmfv-stage > svg:not(.tmfv-ov)'), 200, 100).then(function (bl) {
|
||||
check('c: tmfSvgToPngBlob produce un PNG', bl && bl.type === 'image/png' && bl.size > 100, bl && (bl.type + ' ' + bl.size));
|
||||
return createImageBitmap(bl);
|
||||
}).then(function (bm) {
|
||||
check('c: PNG 1600x800', bm.width === 1600 && bm.height === 800, bm.width + 'x' + bm.height);
|
||||
}).catch(function (er) { check('c: rasterizzazione', false, String(er)); }).then(function () { return new Promise(function (r) { setTimeout(r, 900); }); }).then(function () {
|
||||
document.getElementById('log').textContent = out.join('\n') + '\n\n' + (out.length - fails) + '/' + out.length + ' controlli ok';
|
||||
document.title = fails ? 'FALLITO' : 'TUTTO OK';
|
||||
});
|
||||
}
|
||||
}, 400);
|
||||
}, 600);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// #126: controlli del viewer no-layers (f) contro lo stesso overlay senza no-layers (g)
|
||||
(function () {
|
||||
var check = window.tmfCheck;
|
||||
function img(w, h, col) {
|
||||
var c = document.createElement('canvas'); c.width = w; c.height = h;
|
||||
var g = c.getContext('2d'); g.fillStyle = col; g.fillRect(0, 0, w, h);
|
||||
return c.toDataURL('image/png');
|
||||
}
|
||||
var f = document.getElementById('f'), g = document.getElementById('g');
|
||||
function dati() { // overlay misto: found (costruzioni), non found (ricerca), layer 'ricerca' esplicito; due etichette con key
|
||||
return {
|
||||
ov: [{ type: 'segment', role: 'found', row1: 20, col1: 20, row2: 220, col2: 380 },
|
||||
{ type: 'segment', role: 'reference', row1: 20, col1: 380, row2: 220, col2: 20 },
|
||||
{ type: 'points', role: 'reference', layer: 'ricerca', rows: [60, 120], cols: [100, 300] }],
|
||||
lb: [{ key: 'q1', nome: 'Larghezza', camera: 1, x: 200, y: 120, text: 'Larghezza 12.345', breve: '12.345', cls: 'stz-ok',
|
||||
quota: { tipo: 'lineare', p1: [60, 200], p2: [340, 200], testo: [0, 20] } },
|
||||
{ key: 'q2', nome: 'Punto', camera: 1, x: 100, y: 60, text: 'Punto', breve: 'P' }]
|
||||
};
|
||||
}
|
||||
var df = dati(), dg = dati(), movf = 0, movg = 0;
|
||||
f.addEventListener('quota-spostata', function () { movf++; });
|
||||
g.addEventListener('quota-spostata', function () { movg++; });
|
||||
f.overlay = df.ov; f.labels = df.lb; g.overlay = dg.ov; g.labels = dg.lb;
|
||||
f.setAttribute('src', img(400, 250, '#1e293b')); g.setAttribute('src', img(400, 250, '#1e293b'));
|
||||
function drag(el) { // trascinamento simulato di un'etichetta: down sull'etichetta, move e up sul documento
|
||||
var r = el.getBoundingClientRect(), x = r.left + r.width / 2, y = r.top + r.height / 2;
|
||||
var o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1 };
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', Object.assign({ clientX: x, clientY: y }, o)));
|
||||
document.dispatchEvent(new PointerEvent('pointermove', Object.assign({ clientX: x + 30, clientY: y + 20 }, o)));
|
||||
document.dispatchEvent(new PointerEvent('pointerup', Object.assign({ clientX: x + 30, clientY: y + 20 }, o)));
|
||||
}
|
||||
setTimeout(function () {
|
||||
var ovf = f.querySelector('.tmfv-ov'), ovg = g.querySelector('.tmfv-ov');
|
||||
check('f no-layers: localStorage ricerca=false, ma nessun g[data-layer], nessun opacity, nessun display none', ovf.querySelectorAll('g[data-layer]').length === 0 && ovf.querySelectorAll('[opacity]').length === 0 && ovf.innerHTML.indexOf('display="none"') < 0, ovf.innerHTML.slice(0, 120));
|
||||
check('f no-layers: forme found, non found e layer ricerca tutte nel disegno (>= 5 elementi)', ovf.children.length >= 5, ovf.children.length);
|
||||
check('f no-layers: menu dei livelli nascosto', f.querySelector('.tmfv-layers').hidden === true);
|
||||
check('f no-layers: immagine visibile, etichette visibili con text', f.querySelector('.tmfv-img').style.visibility === '' && f.querySelector('.tmfv-labels').style.display !== 'none' &&
|
||||
f.querySelectorAll('.tmfv-label')[0].textContent === 'Larghezza 12.345' && f.querySelectorAll('.tmfv-label')[1].textContent === 'Punto');
|
||||
check('g senza no-layers (confronto): tre gruppi g[data-layer] e menu visibile', ovg.querySelectorAll('g[data-layer]').length === 3 && g.querySelector('.tmfv-layers').hidden === false);
|
||||
var lf = f.querySelector('.tmfv-label[data-key="q1"]');
|
||||
var pf = lf.style.left + '|' + lf.style.top;
|
||||
check('f no-layers: etichetta con pointer-events none e cursore default', getComputedStyle(lf).pointerEvents === 'none' && getComputedStyle(lf).cursor === 'default', getComputedStyle(lf).pointerEvents);
|
||||
drag(lf); drag(g.querySelector('.tmfv-label[data-key="q1"]'));
|
||||
var lf2 = f.querySelector('.tmfv-label[data-key="q1"]');
|
||||
check('f no-layers: trascinamento simulato -> nessun quota-spostata, posizione invariata', movf === 0 && lf2.style.left + '|' + lf2.style.top === pf, movf + ' ' + lf2.style.left);
|
||||
check('g senza no-layers (controllo del test): lo stesso trascinamento emette quota-spostata', movg === 1, movg);
|
||||
var z0 = f.zoom, r = f.querySelector('.tmfv-vp').getBoundingClientRect();
|
||||
var we = new WheelEvent('wheel', { deltaY: -100, clientX: r.left + 100, clientY: r.top + 100, bubbles: true, cancelable: true });
|
||||
f.querySelector('.tmfv-vp').dispatchEvent(we);
|
||||
check('f no-layers: rotella senza Ctrl -> zoom cambiato', f.zoom > z0 && we.defaultPrevented === true, z0 + ' -> ' + f.zoom);
|
||||
f.removeAttribute('no-layers');
|
||||
check('f: no-layers tolto a runtime -> gruppi g[data-layer], menu e trascinamento', ovf.querySelectorAll('g[data-layer]').length === 3 && f.querySelector('.tmfv-layers').hidden === false &&
|
||||
getComputedStyle(f.querySelector('.tmfv-label[data-key="q1"]')).pointerEvents === 'auto');
|
||||
f.setAttribute('no-layers', '');
|
||||
check('f: no-layers rimesso a runtime -> di nuovo disegno unico, menu nascosto', ovf.querySelectorAll('g[data-layer]').length === 0 && f.querySelector('.tmfv-layers').hidden === true && ovf.children.length >= 5);
|
||||
}, 1000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,448 @@
|
||||
{
|
||||
"outcome": "pass",
|
||||
"overlay": [
|
||||
{
|
||||
"type": "points",
|
||||
"role": "found",
|
||||
"rows": [
|
||||
2218.926540676052
|
||||
],
|
||||
"cols": [
|
||||
2371.08496101821
|
||||
],
|
||||
"tool_id": "blob"
|
||||
},
|
||||
{
|
||||
"type": "segment",
|
||||
"role": "found",
|
||||
"row1": 2218.926540676052,
|
||||
"col1": 2371.08496101821,
|
||||
"row2": 2268.925168990387,
|
||||
"col2": 2371.4553212279457,
|
||||
"tool_id": "blob"
|
||||
},
|
||||
{
|
||||
"type": "contour",
|
||||
"role": "found",
|
||||
"rows": [
|
||||
2469.0093822012514,
|
||||
2467.8907055854274,
|
||||
1969.1995500932737,
|
||||
1970.3182267090972,
|
||||
2469.0093822012514
|
||||
],
|
||||
"cols": [
|
||||
2297.7334028311207,
|
||||
2448.7547459672996,
|
||||
2445.0607374080982,
|
||||
2294.0393942719193,
|
||||
2297.7334028311207
|
||||
],
|
||||
"tool_id": "blob"
|
||||
},
|
||||
{
|
||||
"type": "rect2",
|
||||
"role": "roi",
|
||||
"row": 2250.0000476834334,
|
||||
"col": 2296.999963957319,
|
||||
"phi_deg": -89.99999458063853,
|
||||
"length1": 159.9999999999998,
|
||||
"length2": 20.0,
|
||||
"tool_id": "lato_sx"
|
||||
},
|
||||
{
|
||||
"type": "segment",
|
||||
"role": "found",
|
||||
"row1": 2409.997184964675,
|
||||
"col1": 2297.344751969946,
|
||||
"row2": 2089.9983738509072,
|
||||
"col2": 2295.147617847124,
|
||||
"tool_id": "lato_sx"
|
||||
},
|
||||
{
|
||||
"type": "rect2",
|
||||
"role": "roi",
|
||||
"row": 2250.000033590164,
|
||||
"col": 2445.999963957318,
|
||||
"phi_deg": -89.99999458063853,
|
||||
"length1": 159.9999999999998,
|
||||
"length2": 20.0,
|
||||
"tool_id": "lato_dx"
|
||||
},
|
||||
{
|
||||
"type": "segment",
|
||||
"role": "found",
|
||||
"row1": 2410.000338325413,
|
||||
"col1": 2446.9546542059475,
|
||||
"row2": 2090.0001187672883,
|
||||
"col2": 2444.590522414638,
|
||||
"tool_id": "lato_dx"
|
||||
},
|
||||
{
|
||||
"type": "rect2",
|
||||
"role": "roi",
|
||||
"row": 1970.0000406368001,
|
||||
"col": 2371.4999374733225,
|
||||
"phi_deg": 5.419361327409989e-06,
|
||||
"length1": 36.499999999999936,
|
||||
"length2": 15.0,
|
||||
"tool_id": "lato_alto"
|
||||
},
|
||||
{
|
||||
"type": "segment",
|
||||
"role": "found",
|
||||
"row1": 1969.7270815449738,
|
||||
"col1": 2335.0012415021283,
|
||||
"row2": 1969.9815342357629,
|
||||
"col2": 2408.0075386065682,
|
||||
"tool_id": "lato_alto"
|
||||
},
|
||||
{
|
||||
"type": "rect2",
|
||||
"role": "roi",
|
||||
"row": 2466.000040636798,
|
||||
"col": 2371.4999843878295,
|
||||
"phi_deg": 5.419361327409989e-06,
|
||||
"length1": 36.499999999999936,
|
||||
"length2": 15.0,
|
||||
"tool_id": "lato_basso"
|
||||
},
|
||||
{
|
||||
"type": "segment",
|
||||
"role": "found",
|
||||
"row1": 2466.9468485672455,
|
||||
"col1": 2335.0088484836465,
|
||||
"row2": 2465.293164536675,
|
||||
"col2": 2407.9979613716796,
|
||||
"tool_id": "lato_basso"
|
||||
},
|
||||
{
|
||||
"type": "arc",
|
||||
"role": "roi",
|
||||
"row": 2015.6000435878736,
|
||||
"col": 2340.2999417864303,
|
||||
"radius": 46.0,
|
||||
"start_deg": 95.00000541936147,
|
||||
"extent_deg": 80.0,
|
||||
"tool_id": "arco_tl"
|
||||
},
|
||||
{
|
||||
"type": "arc",
|
||||
"role": "found",
|
||||
"row": 2014.0729365101115,
|
||||
"col": 2338.325339664551,
|
||||
"radius": 43.69800114451398,
|
||||
"start_deg": 92.61763130401864,
|
||||
"extent_deg": 84.15103825789168,
|
||||
"tool_id": "arco_tl"
|
||||
},
|
||||
{
|
||||
"type": "arc",
|
||||
"role": "roi",
|
||||
"row": 2016.1000381586543,
|
||||
"col": 2397.699941833723,
|
||||
"radius": 46.0,
|
||||
"start_deg": 5.000005419361472,
|
||||
"extent_deg": 80.0,
|
||||
"tool_id": "arco_tr"
|
||||
},
|
||||
{
|
||||
"type": "arc",
|
||||
"role": "found",
|
||||
"row": 2014.7155277666257,
|
||||
"col": 2400.767078642102,
|
||||
"radius": 43.26606749223183,
|
||||
"start_deg": 3.5348692687035217,
|
||||
"extent_deg": 85.33536916618716,
|
||||
"tool_id": "arco_tr"
|
||||
},
|
||||
{
|
||||
"type": "arc",
|
||||
"role": "roi",
|
||||
"row": 2418.4000378086853,
|
||||
"col": 2401.3999798855507,
|
||||
"radius": 46.0,
|
||||
"start_deg": 275.00000541936146,
|
||||
"extent_deg": 80.0,
|
||||
"tool_id": "arco_br"
|
||||
},
|
||||
{
|
||||
"type": "arc",
|
||||
"role": "found",
|
||||
"row": 2420.300713032017,
|
||||
"col": 2404.4253040181898,
|
||||
"radius": 42.604285715934104,
|
||||
"start_deg": -76.0934883340429,
|
||||
"extent_deg": 73.28101449433917,
|
||||
"tool_id": "arco_br"
|
||||
},
|
||||
{
|
||||
"type": "arc",
|
||||
"role": "roi",
|
||||
"row": 2421.8000432662807,
|
||||
"col": 2343.699980207142,
|
||||
"radius": 46.0,
|
||||
"start_deg": 185.00000541936146,
|
||||
"extent_deg": 80.0,
|
||||
"tool_id": "arco_bl"
|
||||
},
|
||||
{
|
||||
"type": "arc",
|
||||
"role": "found",
|
||||
"row": 2423.3935764712314,
|
||||
"col": 2338.8006257209418,
|
||||
"radius": 41.362918221221754,
|
||||
"start_deg": -176.60049573516702,
|
||||
"extent_deg": 77.4552373110766,
|
||||
"tool_id": "arco_bl"
|
||||
},
|
||||
{
|
||||
"type": "segment",
|
||||
"role": "found",
|
||||
"row1": 2250.0002285463506,
|
||||
"col1": 2445.772588310293,
|
||||
"row2": 2251.0268387327387,
|
||||
"col2": 2296.253250501527,
|
||||
"tool_id": "larghezza"
|
||||
},
|
||||
{
|
||||
"type": "segment",
|
||||
"role": "found",
|
||||
"row1": 2466.12000655196,
|
||||
"col1": 2371.503404927663,
|
||||
"row2": 1969.8603328620893,
|
||||
"col2": 2373.2330448944704,
|
||||
"tool_id": "lunghezza"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"role": "found",
|
||||
"row": 2014.0729365101115,
|
||||
"col": 2338.325339664551,
|
||||
"radius": 43.69800114451398,
|
||||
"tool_id": "R_tl"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"role": "found",
|
||||
"row": 2014.7155277666257,
|
||||
"col": 2400.767078642102,
|
||||
"radius": 43.26606749223183,
|
||||
"tool_id": "R_tr"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"role": "found",
|
||||
"row": 2420.300713032017,
|
||||
"col": 2404.4253040181898,
|
||||
"radius": 42.604285715934104,
|
||||
"tool_id": "R_br"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"role": "found",
|
||||
"row": 2423.3935764712314,
|
||||
"col": 2338.8006257209418,
|
||||
"radius": 41.362918221221754,
|
||||
"tool_id": "R_bl"
|
||||
}
|
||||
],
|
||||
"failures": [],
|
||||
"measurements": [
|
||||
{
|
||||
"subtask_id": 15,
|
||||
"value": 1.6682265730716714,
|
||||
"pass_fail": "pass"
|
||||
},
|
||||
{
|
||||
"subtask_id": 16,
|
||||
"value": 5.536802808714362,
|
||||
"pass_fail": "pass"
|
||||
},
|
||||
{
|
||||
"subtask_id": 17,
|
||||
"value": 0.48753859876934247,
|
||||
"pass_fail": "pass"
|
||||
},
|
||||
{
|
||||
"subtask_id": 18,
|
||||
"value": 0.4827195150108305,
|
||||
"pass_fail": "pass"
|
||||
},
|
||||
{
|
||||
"subtask_id": 19,
|
||||
"value": 0.4753360157326768,
|
||||
"pass_fail": "pass"
|
||||
},
|
||||
{
|
||||
"subtask_id": 20,
|
||||
"value": 0.4614860785941711,
|
||||
"pass_fail": "pass"
|
||||
},
|
||||
{
|
||||
"subtask_id": 21,
|
||||
"value": 0.004819083758511997,
|
||||
"pass_fail": "pass"
|
||||
},
|
||||
{
|
||||
"subtask_id": 22,
|
||||
"value": 0.012202583036665687,
|
||||
"pass_fail": "pass"
|
||||
},
|
||||
{
|
||||
"subtask_id": 23,
|
||||
"value": 0.026052520175171373,
|
||||
"pass_fail": "pass"
|
||||
},
|
||||
{
|
||||
"subtask_id": 24,
|
||||
"value": 0.00738349927815369,
|
||||
"pass_fail": "pass"
|
||||
},
|
||||
{
|
||||
"subtask_id": 25,
|
||||
"value": 0.021233436416659375,
|
||||
"pass_fail": "pass"
|
||||
},
|
||||
{
|
||||
"subtask_id": 26,
|
||||
"value": 0.013849937138505686,
|
||||
"pass_fail": "pass"
|
||||
}
|
||||
],
|
||||
"calibration": "ok",
|
||||
"_subtasks": [
|
||||
{
|
||||
"id": 15,
|
||||
"marker_number": 1,
|
||||
"description": "Larghezza",
|
||||
"measurement_type": null,
|
||||
"unit": "mm",
|
||||
"vision_output": "larghezza.value",
|
||||
"nominal": 1.668,
|
||||
"ltl": null,
|
||||
"utl": null
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"marker_number": 2,
|
||||
"description": "Lunghezza",
|
||||
"measurement_type": null,
|
||||
"unit": "mm",
|
||||
"vision_output": "lunghezza.value",
|
||||
"nominal": 5.537,
|
||||
"ltl": null,
|
||||
"utl": null
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"marker_number": 3,
|
||||
"description": "R alto-sinistra",
|
||||
"measurement_type": null,
|
||||
"unit": "mm",
|
||||
"vision_output": "R_tl.value",
|
||||
"nominal": 0.488,
|
||||
"ltl": null,
|
||||
"utl": null
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"marker_number": 4,
|
||||
"description": "R alto-destra",
|
||||
"measurement_type": null,
|
||||
"unit": "mm",
|
||||
"vision_output": "R_tr.value",
|
||||
"nominal": 0.483,
|
||||
"ltl": null,
|
||||
"utl": null
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"marker_number": 5,
|
||||
"description": "R basso-destra",
|
||||
"measurement_type": null,
|
||||
"unit": "mm",
|
||||
"vision_output": "R_br.value",
|
||||
"nominal": 0.475,
|
||||
"ltl": null,
|
||||
"utl": null
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"marker_number": 6,
|
||||
"description": "R basso-sinistra",
|
||||
"measurement_type": null,
|
||||
"unit": "mm",
|
||||
"vision_output": "R_bl.value",
|
||||
"nominal": 0.464,
|
||||
"ltl": null,
|
||||
"utl": null
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"marker_number": 7,
|
||||
"description": "Diff. R alto-sx / alto-dx",
|
||||
"measurement_type": null,
|
||||
"unit": "mm",
|
||||
"vision_output": "d_tl_tr.value",
|
||||
"nominal": 0.0,
|
||||
"ltl": -0.05,
|
||||
"utl": 0.05
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"marker_number": 8,
|
||||
"description": "Diff. R alto-sx / basso-dx",
|
||||
"measurement_type": null,
|
||||
"unit": "mm",
|
||||
"vision_output": "d_tl_br.value",
|
||||
"nominal": 0.0,
|
||||
"ltl": -0.05,
|
||||
"utl": 0.05
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"marker_number": 9,
|
||||
"description": "Diff. R alto-sx / basso-sx",
|
||||
"measurement_type": null,
|
||||
"unit": "mm",
|
||||
"vision_output": "d_tl_bl.value",
|
||||
"nominal": 0.0,
|
||||
"ltl": -0.05,
|
||||
"utl": 0.05
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"marker_number": 10,
|
||||
"description": "Diff. R alto-dx / basso-dx",
|
||||
"measurement_type": null,
|
||||
"unit": "mm",
|
||||
"vision_output": "d_tr_br.value",
|
||||
"nominal": 0.0,
|
||||
"ltl": -0.05,
|
||||
"utl": 0.05
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"marker_number": 11,
|
||||
"description": "Diff. R alto-dx / basso-sx",
|
||||
"measurement_type": null,
|
||||
"unit": "mm",
|
||||
"vision_output": "d_tr_bl.value",
|
||||
"nominal": 0.0,
|
||||
"ltl": -0.05,
|
||||
"utl": 0.05
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"marker_number": 12,
|
||||
"description": "Diff. R basso-dx / basso-sx",
|
||||
"measurement_type": null,
|
||||
"unit": "mm",
|
||||
"vision_output": "d_br_bl.value",
|
||||
"nominal": 0.0,
|
||||
"ltl": -0.05,
|
||||
"utl": 0.05
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -153,6 +153,20 @@ const MEASUREMENT = {
|
||||
);
|
||||
}
|
||||
|
||||
// #118: quota non misurata (value null) -> NaN nel valore e nella deviazione; tolleranze nulle restano vuote
|
||||
{
|
||||
const e = csvExport();
|
||||
const nan = { ...MEASUREMENT, value: null, deviation: null, pass_fail: 'fail',
|
||||
subtask: { ...MEASUREMENT.subtask, utl: null, ltl: null } };
|
||||
const row = e.buildMeasurementCSV([nan]).split('\n')[1].split(';');
|
||||
check('#118 value null -> NaN', row[3] === 'NaN', row.join('|'));
|
||||
check('#118 deviazione null -> NaN', row[8] === 'NaN', row.join('|'));
|
||||
check('#118 tolleranze nulle restano vuote', row[6] === '' && row[7] === '', row.join('|'));
|
||||
check('#118 esito fail', row[9] === 'fail', row.join('|'));
|
||||
const ok = e.buildMeasurementCSV([MEASUREMENT]).split('\n')[1].split(';');
|
||||
check('#118 valore presente invariato', ok[3] !== 'NaN' && ok[8] !== 'NaN', ok.join('|'));
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(failures + ' test falliti su csv-export.js');
|
||||
process.exit(1);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* #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);
|
||||
|
||||
// #102: anteprima dell'immagine
|
||||
const wi = dxfRecipeWizard(1, () => 'tok', { imageField: 'campo' });
|
||||
check('#102 senza immagine: summary vuoto', wi.imagePreviewSummary === '');
|
||||
global.URL.createObjectURL = () => 'blob:x';
|
||||
global.URL.revokeObjectURL = () => {};
|
||||
wi._setImage({ name: 'a.bmp' }, 'file'); // #104: la scala a mano vale solo per file
|
||||
check('#102 _setImage imposta URL e nome', wi.imagePreviewUrl === 'blob:x' && wi.imageName === 'a.bmp');
|
||||
wi.onImagePreviewLoad({ width: 2000, height: 1000 }); // #122: detail di viewer-load
|
||||
check('#102 senza scala: solo pixel', /2[. ]?000 × 1[. ]?000 px$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
|
||||
wi.mmPx = '0,01';
|
||||
check('#102 con mm/px a mano: campo in mm', /px · campo ≈ 20,00 × 10,00 mm$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
|
||||
wi.mmPx = ''; wi.calState = 'calibrated'; wi.calPxMm = 100;
|
||||
check('#102 con calibrazione stazione: campo in mm', /campo ≈ 20,00 × 10,00 mm$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
|
||||
wi.onImagePreviewError();
|
||||
check('#102 errore <img>: imageBroken e file ancora valido', wi.imageBroken === true && wi.imageFile && wi.imagePx === null);
|
||||
wi._setImage(null);
|
||||
check('#102 _setImage(null) azzera', wi.imagePreviewUrl === '' && wi.imageFile === null && wi.imageName === ''
|
||||
&& wi.imagePx === null && wi.imageBroken === false && wi.imagePreviewSummary === '');
|
||||
|
||||
// #104: scala a mano solo per immagini da file
|
||||
const w4 = dxfRecipeWizard(1, () => 'tok', {});
|
||||
w4._setImage({ name: 'a.bmp' }, 'file');
|
||||
w4.mmPx = '0,01406';
|
||||
check('#104 useManualScale vero con file', w4.useManualScale === true);
|
||||
w4._setImage({ name: 'camera.png' }, 'camera');
|
||||
check('#104 useManualScale falso con camera anche se mmPx valido', w4.manualPxMm !== null && w4.useManualScale === false);
|
||||
w4.stationId = '1'; w4.dxfFile = { name: 'd.dxf' };
|
||||
check('#104 canRun con camera richiede la calibrazione', w4.canRun === false);
|
||||
w4.calState = 'calibrated'; w4.calPxMm = 50;
|
||||
check('#104 canRun con camera e stazione calibrata', w4.canRun === true);
|
||||
|
||||
// #104: run() con la camera non invia px_per_mm; con il file si
|
||||
const sent = [];
|
||||
global.FormData = class { append(k, v) { sent.push(k); } };
|
||||
global.fetch = async () => ({ ok: true, json: async () => ({ result: { outputs: {}, overlay: [], outcome: 'pass' } }) });
|
||||
const tick = [];
|
||||
w4.$nextTick = f => tick.push(f);
|
||||
w4.$dispatch = () => {};
|
||||
w4.$refs = {};
|
||||
await w4.run(false);
|
||||
check('#104 run con camera: niente px_per_mm', !sent.includes('px_per_mm') && sent.includes('station_id'), sent.join(','));
|
||||
check('#104 run: tested true e scala = stazione', w4.tested === true && w4.runPxPerMm === 50);
|
||||
sent.length = 0;
|
||||
w4._setImage({ name: 'a.bmp' }, 'file');
|
||||
w4.mmPx = '0,02';
|
||||
await w4.run(false);
|
||||
check('#104 run con file: px_per_mm inviato', sent.includes('px_per_mm') && Math.abs(w4.runPxPerMm - 50) < 1e-9, sent.join(','));
|
||||
check('#104 esito pass', w4.outcome === 'pass' && w4.outcomeClass.includes('emerald'));
|
||||
|
||||
// #104: tested torna false dopo _setImage / onDxf / onStation
|
||||
w4.tested = true; w4._setImage(null);
|
||||
check('#104 tested falso dopo _setImage', w4.tested === false && w4.imageSource === '');
|
||||
w4.tested = true; w4.onDxf({ target: { files: [] } });
|
||||
check('#104 tested falso dopo onDxf', w4.tested === false);
|
||||
w4.tested = true; w4.stationId = ''; await w4.onStation();
|
||||
check('#104 tested falso dopo onStation', w4.tested === false);
|
||||
|
||||
// #122: zoom, Adatta e pan della Prova stanno in <tmf-viewer> (tests/js/test_image_viewer.js), non piu' nel wizard
|
||||
const w5 = dxfRecipeWizard(1, () => 'tok', {});
|
||||
check('#122 zoom e Adatta tolti dal wizard', ['zoomBy', 'fit', 'onPanDown', 'onPanMove', 'onPanEnd'].every(k => typeof w5[k] !== 'function'));
|
||||
|
||||
// #105: ricetta esistente -> wizard compilato
|
||||
global.File = class { constructor(parts, name, opts) { this.parts = parts; this.name = name; this.type = (opts && opts.type) || ''; } };
|
||||
const SRC = { band_mm: 0.3, min_points_pct: 90, polarity: 'light', px_per_mm: 71.1237, dxf: 'disegno.dxf', image: 'riferimento.bmp' };
|
||||
const blobOf = t => ({ type: t });
|
||||
const mkFetch = (routes, calls) => async (url) => {
|
||||
calls.push(url);
|
||||
for (const k of Object.keys(routes)) if (url.endsWith(k)) return routes[k]();
|
||||
return { ok: false, status: 404, json: async () => ({ error: true }) };
|
||||
};
|
||||
const okJson = body => () => ({ ok: true, json: async () => body });
|
||||
const okBlob = t => () => ({ ok: true, blob: async () => blobOf(t) });
|
||||
|
||||
const w6 = dxfRecipeWizard(8, () => 'tok', { noReferenceImage: 'senza immagine', editLabel: 'Modifica', createLabel: 'Crea' });
|
||||
w6.$nextTick = f => f();
|
||||
const calls6 = [];
|
||||
global.fetch = mkFetch({
|
||||
'/recipe/dxf-compare/source': okJson(SRC),
|
||||
'/recipe/files/disegno.dxf': okBlob('application/dxf'),
|
||||
'/recipe/files/riferimento.bmp': okBlob('image/bmp'),
|
||||
'/dxf-preview': okJson(PREVIEW),
|
||||
}, calls6);
|
||||
global.FormData = class { append() {} };
|
||||
check('#105 senza ricetta: existing null', w6.existing === null);
|
||||
w6.init();
|
||||
await w6._existingReady;
|
||||
check('#105 init legge /source', w6.existing && w6.existing.band_mm === 0.3 && calls6[0] === '/maker/api/vision/tasks/8/recipe/dxf-compare/source', calls6.join(','));
|
||||
await w6.toggle();
|
||||
await w6.openExisting();
|
||||
check('#105 apertura: banda, soglia, polarita', w6.bandMm === 0.3 && w6.minPointsPct === 90 && w6.dark === false);
|
||||
check('#105 mm/px = 1/px_per_mm con virgola', w6.mmPx === '0,01406', w6.mmPx);
|
||||
check('#105 DXF caricato dalla cartella', w6.dxfFile && w6.dxfFile.name === 'disegno.dxf' && w6.dxfFromRecipe === true);
|
||||
await new Promise(r => setTimeout(r, 0)); // l'anteprima parte senza essere attesa
|
||||
check('#105 anteprima DXF richiesta', calls6.some(u => u.endsWith('/dxf-preview')) && w6.dxfPreview !== null);
|
||||
check('#105 immagine con source file: il campo mm/px vale', w6.imageFile && w6.imageFile.name === 'riferimento.bmp'
|
||||
&& w6.imageSource === 'file' && w6.imageSrc === 'blob:x');
|
||||
check('#105 info mostrata, nessun avviso', w6.existingInfo === true && w6.existingWarn === '');
|
||||
const nCalls = calls6.length;
|
||||
w6.bandMm = 0.2;
|
||||
await w6.openExisting();
|
||||
check('#105 seconda apertura non ricompila', w6.bandMm === 0.2 && calls6.length === nCalls);
|
||||
|
||||
// #105: ricetta senza immagine salvata -> avviso
|
||||
const w7 = dxfRecipeWizard(8, () => 'tok', { noReferenceImage: 'senza immagine' });
|
||||
global.fetch = mkFetch({
|
||||
'/recipe/dxf-compare/source': okJson({ ...SRC, image: null, px_per_mm: null }),
|
||||
'/recipe/files/disegno.dxf': okBlob('application/dxf'),
|
||||
'/dxf-preview': okJson(PREVIEW),
|
||||
}, []);
|
||||
w7.init(); await w7._existingReady; await w7.openExisting();
|
||||
check('#105 senza immagine: avviso e nessun file', w7.existingWarn === 'senza immagine' && w7.imageFile === null && w7.imageSource === '');
|
||||
check('#105 px_per_mm null: mm/px resta vuoto', w7.mmPx === '');
|
||||
|
||||
// #105: 404 -> existing null, etichetta «Crea»
|
||||
const w8 = dxfRecipeWizard(9, () => 'tok', {});
|
||||
global.fetch = mkFetch({}, []);
|
||||
w8.init(); await w8._existingReady; await w8.openExisting();
|
||||
check('#105 404: existing null e nessuna compilazione', w8.existing === null && w8.existingInfo === false && w8.dxfFile === null);
|
||||
|
||||
// #105: file scelti a mano durante il download della ricetta non si sovrascrivono
|
||||
const w9 = dxfRecipeWizard(8, () => 'tok', {});
|
||||
const gates = {};
|
||||
const gate = k => () => new Promise(res => { gates[k] = () => res({ ok: true, blob: async () => blobOf(k) }); });
|
||||
global.fetch = mkFetch({
|
||||
'/recipe/dxf-compare/source': okJson(SRC),
|
||||
'/recipe/files/disegno.dxf': gate('dxf'),
|
||||
'/recipe/files/riferimento.bmp': gate('bmp'),
|
||||
'/dxf-preview': okJson(PREVIEW),
|
||||
}, []);
|
||||
w9.init(); await w9._existingReady;
|
||||
const pa = w9.openExisting();
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
w9.onDxf({ target: { files: [{ name: 'mio.dxf' }] } });
|
||||
w9._setImage({ name: 'mia.bmp' }, 'file');
|
||||
gates.dxf(); gates.bmp();
|
||||
await pa;
|
||||
check('#105 DXF scelto a mano non sovrascritto', w9.dxfFile.name === 'mio.dxf' && w9.dxfFromRecipe === false);
|
||||
check('#105 immagine scelta a mano non sovrascritta', w9.imageFile.name === 'mia.bmp');
|
||||
|
||||
// #105: file della ricetta illeggibile -> avviso
|
||||
const w10 = dxfRecipeWizard(8, () => 'tok', { existingFilesFailed: 'illeggibili' });
|
||||
global.fetch = mkFetch({
|
||||
'/recipe/dxf-compare/source': okJson(SRC),
|
||||
'/recipe/files/disegno.dxf': () => ({ ok: false, status: 404 }),
|
||||
'/recipe/files/riferimento.bmp': okBlob('image/bmp'),
|
||||
}, []);
|
||||
w10.init(); await w10._existingReady; await w10.openExisting();
|
||||
check('#105 DXF illeggibile: avviso, immagine comunque caricata', w10.existingWarn === 'illeggibili' && w10.dxfFile === null && w10.imageFile !== null);
|
||||
|
||||
// #105: dopo Salva si rilegge /source e il wizard non si ricompila
|
||||
const w11 = dxfRecipeWizard(8, () => 'tok', {});
|
||||
w11.$nextTick = f => f(); w11.$dispatch = () => {}; w11.$refs = {};
|
||||
w11.stationId = '1'; w11.calState = 'calibrated'; w11.calPxMm = 50;
|
||||
w11.imageFile = { name: 'a.bmp' }; w11.dxfFile = { name: 'd.dxf' };
|
||||
global.FormData = class { append() {} };
|
||||
global.fetch = async (url, opts) => url.endsWith('/source')
|
||||
? { ok: true, json: async () => SRC }
|
||||
: { ok: true, json: async () => ({ saved: true, result: { outputs: {}, overlay: [], outcome: 'pass' } }) };
|
||||
await w11.run(true);
|
||||
check('#105 dopo Salva: existing letto e non ricompilato', w11.existing && w11.existing.band_mm === 0.3 && w11._existingApplied === true);
|
||||
|
||||
if (failures) { console.log(failures + ' fallimenti'); process.exit(1); }
|
||||
console.log('tutto ok');
|
||||
})();
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* #122: lo zoom delle tre viste del wizard (immagine, DXF, Prova) e' nel componente <tmf-viewer>.
|
||||
* Qui si prova cio' che resta nel wizard (dimensioni dal detail di viewer-load, anteprima DXF)
|
||||
* e che le funzioni di zoom/pan/fit/overlay non ci siano piu'.
|
||||
* (Il nome del file resta quello di #113 per non rompere i riferimenti.)
|
||||
*
|
||||
* Run from src/frontend/flask_app: node tests/js/test_dxf_zoom.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 w = dxfRecipeWizard(1, () => 'tok', {});
|
||||
w.$nextTick = fn => fn();
|
||||
|
||||
// #122: via tutto lo zoom/pan/fit/overlay del wizard
|
||||
const gone = ['views', 'viewPanKey', 'zoom', 'fitZoom', 'panning', 'resPx', 'vZoomBy', 'vFit', 'vWheel', 'vPanDown',
|
||||
'vPanMove', 'vPanEnd', 'vCursor', 'vPct', 'vW', 'vH', 'vReset', 'vSetContent', 'zoomBy', 'fit', 'onWheel',
|
||||
'onPanDown', 'onPanMove', 'onPanEnd', 'onResultImgLoad', 'drawOverlay', 'zoomPct', 'zoomW', 'zoomH', 'vpHeight'];
|
||||
for (const k of gone) check('#122 tolto dal wizard: ' + k, !(k in w));
|
||||
check('#122 sorgente senza $refs di zoom', !/\$refs\./.test(src));
|
||||
|
||||
// onImagePreviewLoad riceve il detail di viewer-load e alimenta il riepilogo
|
||||
global.URL.createObjectURL = () => 'blob:x';
|
||||
global.URL.revokeObjectURL = () => {};
|
||||
w._setImage({ name: 'a.bmp' }, 'file');
|
||||
w.onImagePreviewLoad({ width: 800, height: 240 });
|
||||
check('#122 viewer-load: dimensioni nel riepilogo', w.imagePx.w === 800 && w.imagePx.h === 240 && w.imageBroken === false);
|
||||
check('#122 riepilogo «W × H px»', /800.*240 px/.test(w.imagePreviewSummary), w.imagePreviewSummary);
|
||||
w.onImagePreviewLoad({ width: 0, height: 0 });
|
||||
check('#122 viewer-load senza dimensioni: imagePx null', w.imagePx === null);
|
||||
w.onImagePreviewLoad({ width: 800, height: 240 });
|
||||
w.onImagePreviewError();
|
||||
check('#122 errore immagine: avviso e dimensioni azzerate', w.imageBroken === true && w.imagePx === null);
|
||||
w._setImage({ name: 'b.bmp' }, 'file');
|
||||
check('#122 nuova immagine: avviso tolto', w.imageBroken === false && w.imagePx === null);
|
||||
w._setImage(null);
|
||||
check('#122 immagine tolta: src vuoto', w.imageSrc === '' && w.imagePreviewUrl === '');
|
||||
|
||||
// Anteprima DXF: il viewBox resta quello per il <tmf-viewer> (width/height del template)
|
||||
(async () => {
|
||||
const PREVIEW = {
|
||||
contorni: [[[0, 0], [20, 0], [20, 10]]],
|
||||
bbox: { xmin: 0, ymin: 0, xmax: 20, ymax: 10 },
|
||||
larghezza_mm: 20, altezza_mm: 10, entita: 1, punti: 3,
|
||||
};
|
||||
global.FormData = class { append() {} };
|
||||
global.fetch = async () => ({ ok: true, json: async () => PREVIEW });
|
||||
const wd = dxfRecipeWizard(1, () => 'tok', {});
|
||||
wd.$nextTick = fn => fn();
|
||||
wd.dxfFile = { name: 'a.dxf' };
|
||||
await wd.loadDxfPreview();
|
||||
const vb = wd.dxfPreviewViewBox().split(' ').map(Number);
|
||||
check('#122 DXF: viewBox con margine 5 % (w 22 x h 12)', vb.length === 4 && Math.abs(vb[2] - 22) < 1e-6 && Math.abs(vb[3] - 12) < 1e-6, vb.join(' '));
|
||||
check('#122 DXF: anteprima caricata', wd.dxfPreview && wd.dxfPreviewD().startsWith('M 0 0'));
|
||||
wd.dxfFile = null;
|
||||
await wd.loadDxfPreview();
|
||||
check('#122 DXF tolto: anteprima azzerata', wd.dxfPreview === null && wd.dxfPreviewViewBox() === '0 0 1 1');
|
||||
|
||||
if (failures) { console.log(failures + ' test falliti'); process.exit(1); }
|
||||
console.log('tutti i test #122 ok');
|
||||
})();
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* image_to_png.js, run with plain node (#107): solo la parte pura (niente canvas qui).
|
||||
* Run from src/frontend/flask_app: node tests/js/test_image_to_png.js
|
||||
*/
|
||||
'use strict';
|
||||
const path = require('path');
|
||||
const { tmfNeedsPng, tmfImageToPng } = require(
|
||||
path.join(__dirname, '..', '..', 'static', 'js', 'image_to_png.js'),
|
||||
);
|
||||
|
||||
let failures = 0;
|
||||
function check(label, cond) {
|
||||
if (cond) { console.log('ok - ' + label); } else { console.error('FAIL - ' + label); failures++; }
|
||||
}
|
||||
|
||||
check('bmp va convertito', tmfNeedsPng('image/bmp') === true);
|
||||
check('tiff va convertito', tmfNeedsPng('image/tiff') === true);
|
||||
check('png no', tmfNeedsPng('image/png') === false);
|
||||
check('jpeg no', tmfNeedsPng('image/jpeg') === false);
|
||||
check('gif no', tmfNeedsPng('image/gif') === false);
|
||||
check('webp no', tmfNeedsPng('image/webp') === false);
|
||||
check('pdf no', tmfNeedsPng('application/pdf') === false);
|
||||
check('tipo vuoto no', tmfNeedsPng('') === false);
|
||||
|
||||
(async () => {
|
||||
const fake = { name: 'a.png', type: 'image/png' };
|
||||
check('png ritorna lo stesso oggetto', (await tmfImageToPng(fake)) === fake);
|
||||
const pdf = { name: 'a.pdf', type: 'application/pdf' };
|
||||
check('pdf ritorna lo stesso oggetto', (await tmfImageToPng(pdf)) === pdf);
|
||||
if (failures) { console.error(failures + ' failure(s)'); process.exit(1); }
|
||||
console.log('all ok');
|
||||
})();
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* #122: static/js/image_viewer.js (<tmf-viewer>) con plain node: matematica pura (TmfViewerMath) e
|
||||
* registrazione del Custom Element una sola volta. Il DOM vero si prova in Chrome (fixtures/image_viewer_demo.html).
|
||||
*
|
||||
* Il modulo si carica senza window/document/customElements.
|
||||
* Run from src/frontend/flask_app: node tests/js/test_image_viewer.js
|
||||
*/
|
||||
'use strict';
|
||||
const path = require('path');
|
||||
|
||||
const MOD = path.join(__dirname, '..', '..', 'static', 'js', 'image_viewer.js');
|
||||
|
||||
let failures = 0;
|
||||
let total = 0;
|
||||
function check(label, cond, extra) {
|
||||
total++;
|
||||
if (cond) console.log('ok - ' + label);
|
||||
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||
}
|
||||
const near = (a, b) => Math.abs(a - b) < 1e-9;
|
||||
|
||||
// --- caricamento in node senza globali del browser ---
|
||||
check('node senza window/document/customElements/HTMLElement',
|
||||
typeof window === 'undefined' && typeof document === 'undefined' &&
|
||||
typeof customElements === 'undefined' && typeof HTMLElement === 'undefined');
|
||||
const { TmfViewerMath: M } = require(MOD);
|
||||
check('esporta TmfViewerMath con le quattro funzioni',
|
||||
M && ['fitZoom', 'clampZoom', 'zoomAround', 'pct'].every((k) => typeof M[k] === 'function'));
|
||||
|
||||
// --- fitZoom ---
|
||||
check('fitZoom: box largo, immagine alta -> limita l\'altezza', near(M.fitZoom(800, 400, 1000, 1000), 0.4));
|
||||
check('fitZoom: box alto, immagine larga -> limita la larghezza', near(M.fitZoom(400, 800, 1000, 500), 0.4));
|
||||
check('fitZoom: immagine piu\' piccola del box -> zoom > 1', near(M.fitZoom(800, 600, 400, 300), 2));
|
||||
check('fitZoom: box esatto -> 1', near(M.fitZoom(640, 480, 640, 480), 1));
|
||||
check('fitZoom: boxH Infinity (fit-height) -> solo larghezza', near(M.fitZoom(500, Infinity, 1000, 4000), 0.5));
|
||||
check('fitZoom: 0 se box nascosto o immagine senza dimensioni',
|
||||
M.fitZoom(0, 400, 10, 10) === 0 && M.fitZoom(400, 0, 10, 10) === 0 &&
|
||||
M.fitZoom(400, 400, 0, 10) === 0 && M.fitZoom(400, 400, 10, 0) === 0);
|
||||
check('fitZoom: 0 con NaN, negativi, undefined',
|
||||
M.fitZoom(NaN, 400, 10, 10) === 0 && M.fitZoom(-5, 400, 10, 10) === 0 && M.fitZoom(400, 400, undefined, 10) === 0 &&
|
||||
M.fitZoom(400, 400, 10, null) === 0);
|
||||
|
||||
// --- clampZoom ---
|
||||
check('clampZoom: dentro i limiti resta uguale', near(M.clampZoom(1, 0.5), 1));
|
||||
check('clampZoom: sotto fit*0.5 -> fit*0.5', near(M.clampZoom(0.01, 0.5), 0.25));
|
||||
check('clampZoom: sopra 8 -> 8', near(M.clampZoom(20, 0.5), 8));
|
||||
check('clampZoom: fit sopra 1 alza il massimo a fit*8 (DXF in mm, fit ~10)', near(M.clampZoom(20, 12), 20) && near(M.clampZoom(200, 12), 96) && near(M.clampZoom(11, 12), 11));
|
||||
check('clampZoom: con fit piccolo il massimo resta 8', near(M.clampZoom(20, 0.5), 8) && near(M.clampZoom(20, 1), 8) && near(M.clampZoom(100, 2), 16));
|
||||
check('clampZoom: limiti esatti', near(M.clampZoom(0.25, 0.5), 0.25) && near(M.clampZoom(8, 0.5), 8));
|
||||
|
||||
// --- zoomAround ---
|
||||
{
|
||||
// il punto del contenuto sotto (px,py) prima e dopo lo zoom deve essere lo stesso
|
||||
const z0 = 0.5, z1 = 1.25, sl = 120, st = 40, px = 300, py = 200;
|
||||
const r = M.zoomAround(z0, z1, sl, st, px, py);
|
||||
const before = { x: (sl + px) / z0, y: (st + py) / z0 }; // coordinate logiche sotto il puntatore
|
||||
const after = { x: (r.scrollLeft + px) / z1, y: (r.scrollTop + py) / z1 };
|
||||
check('zoomAround: il punto sotto il puntatore resta fermo', near(before.x, after.x) && near(before.y, after.y),
|
||||
JSON.stringify([before, after]));
|
||||
const same = M.zoomAround(2, 2, 50, 60, 10, 10);
|
||||
check('zoomAround: z1 == z0 non sposta lo scroll', near(same.scrollLeft, 50) && near(same.scrollTop, 60));
|
||||
const c = M.zoomAround(1, 2, 0, 0, 100, 50);
|
||||
check('zoomAround: da scroll 0 raddoppiando (100,50) -> scroll (100,50)', near(c.scrollLeft, 100) && near(c.scrollTop, 50));
|
||||
const o = M.zoomAround(2, 1, 100, 100, 0, 0);
|
||||
check('zoomAround: puntatore all\'origine, riduzione -> scroll dimezzato', near(o.scrollLeft, 50) && near(o.scrollTop, 50));
|
||||
}
|
||||
|
||||
// --- pct ---
|
||||
check('pct: 1 -> "100 %"', M.pct(1) === '100 %', M.pct(1));
|
||||
check('pct: arrotonda 0.4567 -> "46 %"', M.pct(0.4567) === '46 %', M.pct(0.4567));
|
||||
check('pct: 8 -> "800 %", 0 -> "0 %"', M.pct(8) === '800 %' && M.pct(0) === '0 %');
|
||||
|
||||
// --- define una sola volta, anche caricando due volte ---
|
||||
{
|
||||
const defined = {};
|
||||
let defineCalls = 0;
|
||||
globalThis.HTMLElement = class {};
|
||||
globalThis.customElements = {
|
||||
get: (n) => defined[n],
|
||||
define: (n, c) => { defineCalls++; defined[n] = c; },
|
||||
};
|
||||
delete require.cache[require.resolve(MOD)];
|
||||
require(MOD);
|
||||
delete require.cache[require.resolve(MOD)];
|
||||
require(MOD);
|
||||
check('customElements.define("tmf-viewer") una sola volta con due caricamenti',
|
||||
defineCalls === 1 && typeof defined['tmf-viewer'] === 'function', 'chiamate: ' + defineCalls);
|
||||
const C = defined['tmf-viewer'];
|
||||
check('osserva src, width, height, fit-height, no-save, save-name',
|
||||
['src', 'width', 'height', 'fit-height', 'no-save', 'save-name'].every((a) => C.observedAttributes.includes(a)));
|
||||
check('espone fit, zoomBy, overlay, labels, zoom sul prototipo',
|
||||
typeof C.prototype.fit === 'function' && typeof C.prototype.zoomBy === 'function' &&
|
||||
['overlay', 'labels', 'zoom'].every((k) => Object.getOwnPropertyDescriptor(C.prototype, k)));
|
||||
delete globalThis.customElements;
|
||||
delete globalThis.HTMLElement;
|
||||
}
|
||||
|
||||
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* #119: helper static/js/save_image.js (salva il frame grezzo come PNG) con plain node.
|
||||
*
|
||||
* Fetch, window e document sono finti: il modulo li tocca solo dentro le funzioni.
|
||||
* Run from src/frontend/flask_app: node tests/js/test_save_image.js
|
||||
*/
|
||||
'use strict';
|
||||
const path = require('path');
|
||||
|
||||
const mod = require(path.join(__dirname, '..', '..', 'static', 'js', 'save_image.js'));
|
||||
const { tmfSaveImage, tmfSaveImageName, tmfSaveBlob, tmfSvgToPngBlob } = mod; // #122: + tmfSaveBlob, tmfSvgToPngBlob
|
||||
|
||||
let failures = 0;
|
||||
let total = 0;
|
||||
function check(label, cond, extra) {
|
||||
total++;
|
||||
if (cond) console.log('ok - ' + label);
|
||||
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||
}
|
||||
|
||||
// --- nome proposto (funzione pura) ---
|
||||
const d = new Date(2026, 9, 28, 9, 5, 7); // 28/10/2026 09:05:07 ora locale
|
||||
check('nome: baseName + AAAAMMGG-hhmmss + .png', tmfSaveImageName('task_14', d) === 'task_14_20261028-090507.png',
|
||||
tmfSaveImageName('task_14', d));
|
||||
check('nome: caratteri non validi -> _', tmfSaveImageName('a/b\\c:d*e?f"g<h>i|j', d) === 'a_b_c_d_e_f_g_h_i_j_20261028-090507.png',
|
||||
tmfSaveImageName('a/b\\c:d*e?f"g<h>i|j', d));
|
||||
check('nome: spazi -> _, punti ai bordi tolti', tmfSaveImageName(' ..Ricetta test 1.. ', d) === 'Ricetta_test_1_20261028-090507.png',
|
||||
tmfSaveImageName(' ..Ricetta test 1.. ', d));
|
||||
check('nome: vuoto/null/undefined -> immagine',
|
||||
tmfSaveImageName('', d).startsWith('immagine_') && tmfSaveImageName(null, d).startsWith('immagine_') &&
|
||||
tmfSaveImageName(undefined, d).startsWith('immagine_'));
|
||||
check('nome: solo caratteri vietati -> immagine', tmfSaveImageName('///', d) === 'immagine_20261028-090507.png',
|
||||
tmfSaveImageName('///', d));
|
||||
check('nome: senza data usa ora corrente e ha il formato giusto',
|
||||
/^x_\d{8}-\d{6}\.png$/.test(tmfSaveImageName('x')), tmfSaveImageName('x'));
|
||||
check('nome: data non valida -> ora corrente', /^x_\d{8}-\d{6}\.png$/.test(tmfSaveImageName('x', new Date('boh'))));
|
||||
check('nome: non esiste doppia estensione', !/\.png\.png$/.test(tmfSaveImageName('foto.png', d)));
|
||||
|
||||
// --- finti del browser ---
|
||||
const globals = ['fetch', 'window', 'document', 'URL', 'createImageBitmap', 'setTimeout', 'Image', 'XMLSerializer']; // #122: + Image, XMLSerializer
|
||||
const saved = {};
|
||||
globals.forEach((k) => { saved[k] = Object.getOwnPropertyDescriptor(globalThis, k); });
|
||||
function restore() {
|
||||
globals.forEach((k) => {
|
||||
if (saved[k]) Object.defineProperty(globalThis, k, saved[k]); else delete globalThis[k];
|
||||
});
|
||||
}
|
||||
function setGlobal(k, v) { Object.defineProperty(globalThis, k, { value: v, configurable: true, writable: true }); }
|
||||
|
||||
function fakeEnv({ blobType = 'image/png', ok = true, picker, pickerErr, fetchErr } = {}) {
|
||||
const log = { fetched: [], written: [], closed: 0, clicks: [], appended: 0, removed: 0, revoked: [], pickerOpts: null, canvases: [] };
|
||||
const blob = { type: blobType, size: 3, tag: 'blob-originale' };
|
||||
setGlobal('fetch', async (src) => {
|
||||
log.fetched.push(src);
|
||||
if (fetchErr) throw fetchErr;
|
||||
return { ok, blob: async () => blob };
|
||||
});
|
||||
const win = {};
|
||||
if (picker) {
|
||||
win.showSaveFilePicker = async (opts) => {
|
||||
log.pickerOpts = opts;
|
||||
if (pickerErr) throw pickerErr;
|
||||
return { createWritable: async () => ({ write: async (b) => { log.written.push(b); }, close: async () => { log.closed++; } }) };
|
||||
};
|
||||
}
|
||||
setGlobal('window', win);
|
||||
setGlobal('document', {
|
||||
body: { appendChild() { log.appended++; }, removeChild() { log.removed++; } },
|
||||
createElement(tag) {
|
||||
if (tag === 'canvas') {
|
||||
const c = {
|
||||
width: 0, height: 0,
|
||||
getContext() { return { drawImage() { c.drawn = true; } }; },
|
||||
toBlob(cb, type) { cb({ type, size: 9, tag: 'blob-png' }); },
|
||||
};
|
||||
log.canvases.push(c);
|
||||
return c;
|
||||
}
|
||||
const a = { click() { log.clicks.push({ href: a.href, download: a.download }); } };
|
||||
return a;
|
||||
},
|
||||
});
|
||||
setGlobal('URL', { createObjectURL: (b) => 'blob:fake/' + b.tag, revokeObjectURL: (u) => { log.revoked.push(u); } });
|
||||
setGlobal('createImageBitmap', async () => ({ width: 20, height: 10, close() {} }));
|
||||
setGlobal('setTimeout', (fn) => { fn(); return 0; }); // il revoke differito scatta subito
|
||||
return { log, blob };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const origErr = console.error;
|
||||
try {
|
||||
// --- ramo showSaveFilePicker, blob gia' PNG ---
|
||||
{
|
||||
const { log, blob } = fakeEnv({ picker: true });
|
||||
const r = await tmfSaveImage('blob:http://x/abc', 'task 14');
|
||||
check('picker: risolve a true', r === true);
|
||||
check('picker: fetch dell\'src', log.fetched.length === 1 && log.fetched[0] === 'blob:http://x/abc');
|
||||
check('picker: suggestedName del formato atteso',
|
||||
log.pickerOpts && /^task_14_\d{8}-\d{6}\.png$/.test(log.pickerOpts.suggestedName), JSON.stringify(log.pickerOpts));
|
||||
check('picker: tipo PNG', log.pickerOpts && log.pickerOpts.types[0].accept['image/png'][0] === '.png');
|
||||
check('picker: scritto il blob originale (gia\' PNG, nessuna conversione) e chiuso',
|
||||
log.written.length === 1 && log.written[0] === blob && log.closed === 1 && log.canvases.length === 0);
|
||||
check('picker: nessun <a download>', log.clicks.length === 0);
|
||||
}
|
||||
// --- picker, blob JPEG -> convertito su canvas ---
|
||||
{
|
||||
const { log } = fakeEnv({ picker: true, blobType: 'image/jpeg' });
|
||||
const r = await tmfSaveImage('http://x/frame.jpg', 'f');
|
||||
check('picker + JPEG: risolve a true', r === true);
|
||||
check('picker + JPEG: ridisegnato su canvas 20x10 e scritto il PNG',
|
||||
log.canvases.length === 1 && log.canvases[0].width === 20 && log.canvases[0].height === 10 && log.canvases[0].drawn &&
|
||||
log.written.length === 1 && log.written[0].tag === 'blob-png' && log.written[0].type === 'image/png');
|
||||
}
|
||||
// --- ramo <a download> (nessun showSaveFilePicker) ---
|
||||
{
|
||||
const { log, blob } = fakeEnv({ picker: false });
|
||||
const r = await tmfSaveImage('blob:http://x/abc', 'task_14');
|
||||
check('a download: risolve a true', r === true);
|
||||
check('a download: un click con href del blob e nome atteso',
|
||||
log.clicks.length === 1 && log.clicks[0].href === 'blob:fake/' + blob.tag &&
|
||||
/^task_14_\d{8}-\d{6}\.png$/.test(log.clicks[0].download), JSON.stringify(log.clicks));
|
||||
check('a download: elemento aggiunto, rimosso e URL liberato',
|
||||
log.appended === 1 && log.removed === 1 && log.revoked.length === 1);
|
||||
}
|
||||
// --- AbortError: annullo dell'utente, nessun errore ---
|
||||
{
|
||||
const abort = Object.assign(new Error('The user aborted a request.'), { name: 'AbortError' });
|
||||
const errs = [];
|
||||
console.error = (...a) => errs.push(a);
|
||||
const { log } = fakeEnv({ picker: true, pickerErr: abort });
|
||||
let thrown = null, r = null;
|
||||
try { r = await tmfSaveImage('blob:x', 'a'); } catch (e) { thrown = e; }
|
||||
check('AbortError: nessuna eccezione, risolve a false', thrown === null && r === false, String(thrown));
|
||||
check('AbortError: nessun errore in console, niente scritto, niente <a download>',
|
||||
errs.length === 0 && log.written.length === 0 && log.clicks.length === 0);
|
||||
}
|
||||
// --- errori veri: fetch fallisce, risposta non ok, picker con errore diverso ---
|
||||
{
|
||||
console.error = () => {};
|
||||
let thrown = null, r1 = null, r2 = null, r3 = null;
|
||||
try {
|
||||
fakeEnv({ picker: true, fetchErr: new TypeError('rete') });
|
||||
r1 = await tmfSaveImage('blob:x', 'a');
|
||||
fakeEnv({ picker: true, ok: false });
|
||||
r2 = await tmfSaveImage('blob:x', 'a');
|
||||
fakeEnv({ picker: true, pickerErr: Object.assign(new Error('disco pieno'), { name: 'NotAllowedError' }) });
|
||||
r3 = await tmfSaveImage('blob:x', 'a');
|
||||
} catch (e) { thrown = e; }
|
||||
check('errori: fetch rotto, risposta non ok, picker che fallisce -> false senza eccezioni',
|
||||
thrown === null && r1 === false && r2 === false && r3 === false, String(thrown) + [r1, r2, r3]);
|
||||
}
|
||||
// --- #122: tmfSaveBlob con picker ---
|
||||
{
|
||||
const { log } = fakeEnv({ picker: true });
|
||||
const png = { type: 'image/png', size: 5, tag: 'blob-dxf' };
|
||||
const r = await tmfSaveBlob(png, 'dxf ricetta');
|
||||
check('saveBlob picker: true, scrive il blob dato senza fetch ne conversioni',
|
||||
r === true && log.written.length === 1 && log.written[0] === png && log.closed === 1 &&
|
||||
log.fetched.length === 0 && log.canvases.length === 0);
|
||||
check('saveBlob picker: nome e tipo PNG proposti',
|
||||
/^dxf_ricetta_\d{8}-\d{6}\.png$/.test(log.pickerOpts.suggestedName) && log.pickerOpts.types[0].accept['image/png'][0] === '.png');
|
||||
}
|
||||
// --- #122: tmfSaveBlob senza picker -> <a download> ---
|
||||
{
|
||||
const { log } = fakeEnv({ picker: false });
|
||||
const png = { type: 'image/png', size: 5, tag: 'blob-dxf' };
|
||||
const r = await tmfSaveBlob(png, 'dxf');
|
||||
check('saveBlob a download: true, un click con href del blob e nome atteso, URL liberato',
|
||||
r === true && log.clicks.length === 1 && log.clicks[0].href === 'blob:fake/blob-dxf' &&
|
||||
/^dxf_\d{8}-\d{6}\.png$/.test(log.clicks[0].download) && log.revoked.length === 1);
|
||||
}
|
||||
// --- #122: tmfSaveBlob: blob mancante, annullo, errore ---
|
||||
{
|
||||
const errs = [];
|
||||
console.error = (...a) => errs.push(a);
|
||||
fakeEnv({ picker: true });
|
||||
const rNull = await tmfSaveBlob(null, 'x');
|
||||
const abort = Object.assign(new Error('annullato'), { name: 'AbortError' });
|
||||
fakeEnv({ picker: true, pickerErr: abort });
|
||||
const rAbort = await tmfSaveBlob({ type: 'image/png', tag: 'b' }, 'x');
|
||||
const nAbort = errs.length;
|
||||
fakeEnv({ picker: true, pickerErr: Object.assign(new Error('disco pieno'), { name: 'NotAllowedError' }) });
|
||||
const rErr = await tmfSaveBlob({ type: 'image/png', tag: 'b' }, 'x');
|
||||
check('saveBlob: null -> false; AbortError -> false senza log; altro errore -> false con log',
|
||||
rNull === false && rAbort === false && nAbort === 0 && rErr === false && errs.length === 1);
|
||||
console.error = () => {};
|
||||
}
|
||||
// --- #122: tmfSvgToPngBlob ---
|
||||
{
|
||||
const { log } = fakeEnv({ picker: false });
|
||||
const svgs = [];
|
||||
setGlobal('XMLSerializer', class { serializeToString(el) { svgs.push(el); return '<svg/>'; } });
|
||||
setGlobal('Image', class { set src(v) { this._src = v; setImmediate(() => this.onload()); } });
|
||||
const fills = [];
|
||||
const cv = document.createElement;
|
||||
document.createElement = (tag) => {
|
||||
const e = cv(tag);
|
||||
if (tag === 'canvas') e.getContext = () => ({ set fillStyle(v) { fills.push(v); }, fillRect() { fills.push('rect'); }, drawImage() { e.drawn = true; } });
|
||||
return e;
|
||||
};
|
||||
const made = [];
|
||||
const svgEl = {
|
||||
attrs: { viewBox: '0 0 300 200' }, style: { width: '100%', height: '100%' },
|
||||
getAttribute(k) { return this.attrs[k] === undefined ? null : this.attrs[k]; },
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); },
|
||||
cloneNode() { const c = Object.assign({}, this, { attrs: Object.assign({}, this.attrs), style: Object.assign({}, this.style) }); made.push(c); return c; },
|
||||
};
|
||||
const png = await tmfSvgToPngBlob(svgEl, 300, 200);
|
||||
const c = log.canvases[0];
|
||||
check('svgToPng: ritorna il PNG del canvas, sfondo bianco, lato lungo portato a 1600 (300x200 -> 1600x1067)',
|
||||
png && png.tag === 'blob-png' && c.width === 1600 && c.height === 1067 && fills[0] === '#ffffff' && fills[1] === 'rect' && c.drawn,
|
||||
JSON.stringify([c.width, c.height, fills]));
|
||||
check('svgToPng: clone con xmlns, width/height in pixel del PNG e stile 100% tolto; originale intatto',
|
||||
made.length === 1 && made[0].attrs.xmlns === 'http://www.w3.org/2000/svg' && made[0].attrs.width === '1600' &&
|
||||
made[0].attrs.height === '1067' && made[0].style.width === '' && svgEl.attrs.width === undefined && svgs[0] === made[0]);
|
||||
check('svgToPng: URL del blob SVG liberato', log.revoked.length === 1);
|
||||
// lato lungo oltre 4096 -> ridotto
|
||||
const big = await tmfSvgToPngBlob(svgEl, 10000, 5000);
|
||||
check('svgToPng: 10000x5000 -> 4096x2048', log.canvases[1].width === 4096 && log.canvases[1].height === 2048 && big);
|
||||
// senza w/h usa il viewBox; senza dimensioni lancia
|
||||
await tmfSvgToPngBlob(svgEl);
|
||||
check('svgToPng: senza w/h usa il viewBox', log.canvases[2].width === 1600 && log.canvases[2].height === 1067);
|
||||
let thrown = null;
|
||||
try { await tmfSvgToPngBlob({ getAttribute: () => null }); } catch (e) { thrown = e; }
|
||||
check('svgToPng: senza dimensioni lancia', thrown !== null);
|
||||
document.createElement = cv;
|
||||
}
|
||||
// --- esposizione ---
|
||||
{
|
||||
fakeEnv({ picker: false });
|
||||
check('esposto come funzione e export', typeof tmfSaveImage === 'function' && typeof tmfSaveImageName === 'function' &&
|
||||
typeof tmfSaveBlob === 'function' && typeof tmfSvgToPngBlob === 'function');
|
||||
}
|
||||
} finally {
|
||||
console.error = origErr;
|
||||
restore();
|
||||
}
|
||||
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
|
||||
process.exit(failures ? 1 : 0);
|
||||
})();
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* #116: overlay del check visione (static/js/vision_overlay.js) con plain node.
|
||||
*
|
||||
* Run from src/frontend/flask_app: node tests/js/test_vision_overlay.js
|
||||
*/
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const VisionOverlay = require(path.join(__dirname, '..', '..', 'static', 'js', 'vision_overlay.js'));
|
||||
const fixture = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, 'fixtures', 'vision_check_task14.json'), 'utf8'),
|
||||
);
|
||||
|
||||
let failures = 0;
|
||||
let total = 0;
|
||||
function check(label, cond, extra) {
|
||||
total++;
|
||||
if (cond) console.log('ok - ' + label);
|
||||
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||
}
|
||||
const near = (a, b) => Math.abs(a - b) < 1e-9;
|
||||
|
||||
// Canvas finto: registra le chiamate e le proprieta' al momento dello stroke
|
||||
function fakeCanvas() {
|
||||
const calls = [];
|
||||
const g = {
|
||||
calls,
|
||||
strokes: [],
|
||||
dash: [],
|
||||
strokeStyle: '',
|
||||
lineWidth: 0,
|
||||
beginPath() { calls.push(['beginPath']); },
|
||||
moveTo(x, y) { calls.push(['moveTo', x, y]); },
|
||||
lineTo(x, y) { calls.push(['lineTo', x, y]); },
|
||||
arc(...a) { calls.push(['arc', ...a]); },
|
||||
closePath() { calls.push(['closePath']); },
|
||||
stroke() {
|
||||
calls.push(['stroke']);
|
||||
g.strokes.push({ style: g.strokeStyle, width: g.lineWidth, dash: g.dash.slice() });
|
||||
},
|
||||
setLineDash(d) { g.dash = d.slice(); calls.push(['setLineDash', d.slice()]); },
|
||||
};
|
||||
return g;
|
||||
}
|
||||
const onlyCalls = (g, name) => g.calls.filter((c) => c[0] === name);
|
||||
|
||||
// --- fixture vero: task 14 TEST1 ---
|
||||
const overlay = fixture.overlay;
|
||||
check('fixture: overlay con 25 elementi', overlay.length === 25, String(overlay.length));
|
||||
{
|
||||
const g = fakeCanvas();
|
||||
const res = VisionOverlay.draw(g, overlay, 2);
|
||||
check('draw sul fixture: drawn 25, skipped 0', res.drawn === 25 && res.skipped === 0, JSON.stringify(res));
|
||||
const lastDash = onlyCalls(g, 'setLineDash').pop();
|
||||
check('draw: ultimo setLineDash e\' []', lastDash && lastDash[1].length === 0);
|
||||
check('draw: uno stroke per elemento', g.strokes.length === 25, String(g.strokes.length));
|
||||
const roiIdx = overlay.findIndex((o) => o.role === 'roi');
|
||||
const foundIdx = overlay.findIndex((o) => o.role === 'found');
|
||||
check('roi: arancione, tratteggiata, spessore lw*0.75',
|
||||
g.strokes[roiIdx].style === '#f59e0b' && g.strokes[roiIdx].width === 1.5 &&
|
||||
JSON.stringify(g.strokes[roiIdx].dash) === '[6,4]', JSON.stringify(g.strokes[roiIdx]));
|
||||
check('found: verde, piena, spessore lw',
|
||||
g.strokes[foundIdx].style === '#16a34a' && g.strokes[foundIdx].width === 2 &&
|
||||
g.strokes[foundIdx].dash.length === 0, JSON.stringify(g.strokes[foundIdx]));
|
||||
}
|
||||
|
||||
// --- colori: reference e ruolo ignoto ---
|
||||
{
|
||||
const g = fakeCanvas();
|
||||
VisionOverlay.draw(g, [
|
||||
{ type: 'segment', role: 'reference', row1: 0, col1: 0, row2: 1, col2: 1 },
|
||||
{ type: 'segment', role: 'boh', row1: 0, col1: 0, row2: 1, col2: 1 },
|
||||
], 1);
|
||||
check('reference blu', g.strokes[0].style === '#2563eb');
|
||||
check('ruolo ignoto rosso', g.strokes[1].style === '#ef4444');
|
||||
}
|
||||
|
||||
// --- rect2 ---
|
||||
function xs(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[1]); }
|
||||
function ys(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[2]); }
|
||||
{
|
||||
const g = fakeCanvas();
|
||||
const r = VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 }], 1);
|
||||
const X = xs(g), Y = ys(g);
|
||||
check('rect2 phi 0: 4 vertici chiusi', r.drawn === 1 && X.length === 4 && onlyCalls(g, 'closePath').length === 1);
|
||||
check('rect2 phi 0: col 90/110', X.every((x) => near(x, 90) || near(x, 110)) && X.filter((x) => near(x, 90)).length === 2, String(X));
|
||||
check('rect2 phi 0: row 46/54', Y.every((y) => near(y, 46) || near(y, 54)) && Y.filter((y) => near(y, 46)).length === 2, String(Y));
|
||||
}
|
||||
{
|
||||
const g = fakeCanvas();
|
||||
VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 90, length1: 10, length2: 4 }], 1);
|
||||
const X = xs(g), Y = ys(g);
|
||||
check('rect2 phi 90: lati scambiati, col 96/104', X.every((x) => near(x, 96) || near(x, 104)), String(X));
|
||||
check('rect2 phi 90: lati scambiati, row 40/60', Y.every((y) => near(y, 40) || near(y, 60)), String(Y));
|
||||
}
|
||||
|
||||
// --- arc: argomenti come da contratto ---
|
||||
{
|
||||
const g = fakeCanvas();
|
||||
const a = { type: 'arc', role: 'found', row: 30, col: 40, radius: 12, start_deg: 90, extent_deg: 90 };
|
||||
VisionOverlay.draw(g, [a], 1);
|
||||
const c = onlyCalls(g, 'arc')[0];
|
||||
check('arc: g.arc(col, row, r, -rad(start), -rad(start+extent), extent>0)',
|
||||
c && near(c[1], 40) && near(c[2], 30) && near(c[3], 12) &&
|
||||
near(c[4], -Math.PI / 2) && near(c[5], -Math.PI) && c[6] === true, JSON.stringify(c));
|
||||
const g2 = fakeCanvas();
|
||||
VisionOverlay.draw(g2, [{ ...a, start_deg: 0, extent_deg: -45 }], 1);
|
||||
const c2 = onlyCalls(g2, 'arc')[0];
|
||||
check('arc: extent negativo = orario (anticlockwise false)',
|
||||
c2 && near(c2[4], 0) && near(c2[5], Math.PI / 4) && c2[6] === false, JSON.stringify(c2));
|
||||
}
|
||||
|
||||
// --- circle, points, contour ---
|
||||
{
|
||||
const g = fakeCanvas();
|
||||
VisionOverlay.draw(g, [{ type: 'circle', role: 'found', row: 5, col: 7, radius: 3 }], 1);
|
||||
const c = onlyCalls(g, 'arc')[0];
|
||||
check('circle: cerchio pieno', c && c[1] === 7 && c[2] === 5 && c[3] === 3 && c[4] === 0 && near(c[5], 2 * Math.PI));
|
||||
}
|
||||
{
|
||||
const g = fakeCanvas();
|
||||
VisionOverlay.draw(g, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 1);
|
||||
const m = onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo'));
|
||||
// lw 1 -> semi-lato max(3, 2) = 3
|
||||
check('points: croce di semi-lato 3', m.length === 4 && m.some((c) => c[1] === 17 && c[2] === 10) &&
|
||||
m.some((c) => c[1] === 23 && c[2] === 10) && m.some((c) => c[1] === 20 && c[2] === 7) &&
|
||||
m.some((c) => c[1] === 20 && c[2] === 13), JSON.stringify(m));
|
||||
const g2 = fakeCanvas();
|
||||
VisionOverlay.draw(g2, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 5);
|
||||
check('points: semi-lato lw*2 per lw grande', onlyCalls(g2, 'moveTo').some((c) => c[1] === 10 && c[2] === 10));
|
||||
}
|
||||
{
|
||||
const g = fakeCanvas();
|
||||
VisionOverlay.draw(g, [{ type: 'contour', role: 'found', rows: [1, 2, 3], cols: [4, 5, 6] }], 1);
|
||||
check('contour: polilinea aperta di 3 punti',
|
||||
onlyCalls(g, 'moveTo').length === 1 && onlyCalls(g, 'lineTo').length === 2 && onlyCalls(g, 'closePath').length === 0);
|
||||
}
|
||||
|
||||
// --- elementi malformati: saltati, nessuna eccezione ---
|
||||
{
|
||||
const g = fakeCanvas();
|
||||
let thrown = null;
|
||||
let res = null;
|
||||
try {
|
||||
res = VisionOverlay.draw(g, [
|
||||
{ type: 'circle', role: 'found', row: 1, col: 1 }, // manca radius
|
||||
{ type: 'points', role: 'found', rows: [1, 2], cols: [1] }, // lunghezze diverse
|
||||
{ type: 'contour', role: 'found', rows: [], cols: [] }, // vuoto
|
||||
{ type: 'boh', role: 'found' }, // tipo ignoto
|
||||
{ type: 'segment', role: 'found', row1: NaN, col1: 0, row2: 1, col2: 1 }, // non finito
|
||||
null, // elemento nullo
|
||||
{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }, // valido
|
||||
], 1);
|
||||
} catch (e) { thrown = e; }
|
||||
check('malformati: nessuna eccezione', thrown === null, String(thrown));
|
||||
check('malformati: 6 saltati, 1 disegnato', res && res.drawn === 1 && res.skipped === 6, JSON.stringify(res));
|
||||
check('malformati: draw con overlay non array', (() => {
|
||||
try { const r = VisionOverlay.draw(fakeCanvas(), null, 1); return r.drawn === 0 && r.skipped === 0; } catch (e) { return false; }
|
||||
})());
|
||||
}
|
||||
|
||||
// --- anchor, valori calcolati a mano ---
|
||||
{
|
||||
const A = VisionOverlay.anchor;
|
||||
const seg = A({ type: 'segment', row1: 10, col1: 0, row2: 20, col2: 40 });
|
||||
check('anchor segment: punto medio', seg && seg.x === 20 && seg.y === 15);
|
||||
const lin = A({ type: 'line', row1: 0, col1: 2, row2: 0, col2: 4 });
|
||||
check('anchor line: punto medio', lin && lin.x === 3 && lin.y === 0);
|
||||
const cir = A({ type: 'circle', row: 50, col: 100, radius: 8 });
|
||||
check('anchor circle: centro + raggio a destra', cir && cir.x === 108 && cir.y === 50);
|
||||
const arc = A({ type: 'arc', row: 50, col: 100, radius: 10, start_deg: 0, extent_deg: 180 });
|
||||
check('anchor arc: punto medio (90 gradi = in alto a video)', arc && near(arc.x, 100) && near(arc.y, 40), JSON.stringify(arc));
|
||||
const pts = A({ type: 'points', rows: [7, 8], cols: [3, 4] });
|
||||
check('anchor points: primo punto', pts && pts.x === 3 && pts.y === 7);
|
||||
const r2 = A({ type: 'rect2', row: 9, col: 11, phi_deg: 30, length1: 5, length2: 2 });
|
||||
check('anchor rect2: centro', r2 && r2.x === 11 && r2.y === 9);
|
||||
const con = A({ type: 'contour', rows: [0, 1, 2, 3], cols: [10, 11, 12, 13] });
|
||||
check('anchor contour: punto a meta\' (indice 2)', con && con.x === 12 && con.y === 2);
|
||||
check('anchor: tipo ignoto -> null', A({ type: 'boh' }) === null);
|
||||
check('anchor: dati mancanti -> null', A({ type: 'circle', row: 1, col: 1 }) === null && A(null) === null);
|
||||
}
|
||||
|
||||
// --- labels sul fixture, righe costruite come fara' vision_execute.js ---
|
||||
const subById = {};
|
||||
fixture._subtasks.forEach((s) => { subById[s.id] = s; });
|
||||
const rows = fixture.measurements.map((m) => {
|
||||
const s = subById[m.subtask_id];
|
||||
return {
|
||||
key: m.subtask_id, name: s.description, value: m.value, unit: s.unit,
|
||||
status: m.pass_fail, vision_output: s.vision_output,
|
||||
};
|
||||
});
|
||||
{
|
||||
const L = VisionOverlay.labels(rows, overlay);
|
||||
check('labels fixture: 6 etichette', L.length === 6, String(L.length));
|
||||
const names = L.map((l) => l.text.split(' ')[0]).join(',');
|
||||
check('labels fixture: larghezza, lunghezza e 4 raggi',
|
||||
L[0].text.startsWith('Larghezza') && L[1].text.startsWith('Lunghezza') &&
|
||||
L.slice(2).every((l) => l.text.startsWith('R ')), names);
|
||||
check('labels fixture: nessuna per le differenze', L.every((l) => !/^Diff/.test(l.text)));
|
||||
const l15 = L.find((l) => l.key === 15);
|
||||
check('quota 15: testo con Larghezza e 1.668', l15 && l15.text.includes('Larghezza') && l15.text.includes('1.668'), l15 && l15.text);
|
||||
check('quota 15: status riportato', l15 && l15.status === fixture.measurements[0].pass_fail);
|
||||
const seg = overlay.find((o) => o.tool_id === 'larghezza' && o.role === 'found');
|
||||
check('quota 15: ancorata al punto medio del segmento', l15 && near(l15.x, (seg.col1 + seg.col2) / 2) && near(l15.y, (seg.row1 + seg.row2) / 2));
|
||||
}
|
||||
{
|
||||
const nuda = rows.map((r) => ({ ...r, vision_output: undefined }));
|
||||
check('labels: righe senza vision_output -> nessuna etichetta', VisionOverlay.labels(nuda, overlay).length === 0);
|
||||
const sola = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass' }], overlay);
|
||||
check('labels: riga senza vision_output (assente) -> nessuna', sola.length === 0);
|
||||
const noTool = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass', vision_output: 'nonesiste.value' }], overlay);
|
||||
check('labels: tool senza overlay -> nessuna', noTool.length === 0);
|
||||
// found preferito: la roi (per prima) ha un'altra ancora, ma vince il found
|
||||
const ov = [
|
||||
{ type: 'rect2', role: 'roi', tool_id: 't', row: 1, col: 1, phi_deg: 0, length1: 1, length2: 1 },
|
||||
{ type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 },
|
||||
];
|
||||
const f = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'warning', vision_output: 't.value' }], ov);
|
||||
check('labels: preferisce l\'ancora del found', f.length === 1 && f[0].x === 15 && f[0].y === 10 && f[0].status === 'warning', JSON.stringify(f));
|
||||
const solo = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'fail', vision_output: 't.value' }], [ov[0]]);
|
||||
check('labels: senza found ripiega sul primo elemento', solo.length === 1 && solo[0].x === 1 && solo[0].y === 1);
|
||||
check('labels: testo name + valore a 3 decimali + unit', f[0].text === 'T 1.500 mm', f[0].text);
|
||||
}
|
||||
|
||||
// --- #118: labels, valore null/undefined/non finito -> etichetta NaN con status della riga ---
|
||||
{
|
||||
const ov = [{ type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 }];
|
||||
const mk = (value) => ({ key: 1, name: 'T', value, unit: 'mm', status: 'fail', vision_output: 't.value' });
|
||||
const nan = (v) => { const l = VisionOverlay.labels([mk(v)], ov); return l.length === 1 && l[0].text === 'T NaN mm' && l[0].status === 'fail'; };
|
||||
check('labels: value null -> etichetta NaN, status fail (Number(null) e 0)', nan(null));
|
||||
check('labels: value undefined / stringa vuota -> etichetta NaN', nan(undefined) && nan(''));
|
||||
check('labels: value NaN/Infinity -> etichetta NaN', nan(NaN) && nan(Infinity) && nan(-Infinity));
|
||||
check('labels: value 0 resta un valore valido', VisionOverlay.labels([mk(0)], ov)[0].text === 'T 0.000 mm');
|
||||
const misto = VisionOverlay.labels([{ ...mk(null), key: 1 }, { ...mk(2.5), key: 2 }], ov);
|
||||
check('labels: null e valida entrambe presenti', misto.length === 2 && misto[0].text === 'T NaN mm' && misto[1].key === 2);
|
||||
check('labels: riga NaN senza ancoraggio -> nessuna etichetta', VisionOverlay.labels([mk(null)], []).length === 0);
|
||||
}
|
||||
|
||||
// --- #121: svg(overlay, opts) ---
|
||||
const count = (str, re) => (str.match(re) || []).length;
|
||||
const SHAPE_RE = /<(polygon|line|circle|path|polyline) /g;
|
||||
{
|
||||
const out = VisionOverlay.svg(overlay);
|
||||
// il fixture vero ha 25 forme: 7 segment, 1 contour, 1 points, 4 rect2 roi, 4 arc roi, 4 arc found, 4 circle
|
||||
check('svg fixture: 25 forme', count(out, SHAPE_RE) === 25, String(count(out, SHAPE_RE)));
|
||||
check('svg fixture: ogni forma con vector-effect non-scaling-stroke',
|
||||
count(out, /vector-effect="non-scaling-stroke"/g) === 25);
|
||||
check('svg fixture: ogni forma fill none', count(out, /fill="none"/g) === 25);
|
||||
check('svg fixture: 8 roi (4 rect2 + 4 arc) con stroke-dasharray 6 4', count(out, /stroke-dasharray="6 4"/g) === 8,
|
||||
String(count(out, /stroke-dasharray="6 4"/g)));
|
||||
check('svg fixture: nessun <svg> esterno', !/<svg/.test(out));
|
||||
check('svg fixture: nessun NaN/undefined/null nel markup', !/NaN|undefined|null|Infinity/.test(out));
|
||||
check('svg fixture: numeri con al piu\' 2 decimali', !/\d\.\d{3,}/.test(out));
|
||||
}
|
||||
{
|
||||
const one = (o, opts) => VisionOverlay.svg([o], opts);
|
||||
const roi = one({ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 });
|
||||
check('svg roi: polygon 4 vertici, colore roi, dash 6 4, spessore lw*0.75',
|
||||
roi.startsWith('<polygon ') && roi.includes('points="110,46 110,54 90,54 90,46"') && roi.includes('stroke="#f59e0b"') &&
|
||||
roi.includes('stroke-dasharray="6 4"') && roi.includes('stroke-width="1.5"'), roi);
|
||||
const roi4 = one({ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 }, { lw: 4 });
|
||||
check('svg roi: lw 4 -> stroke-width 3', roi4.includes('stroke-width="3"'), roi4);
|
||||
const fnd = one({ type: 'segment', role: 'found', row1: 1, col1: 2, row2: 3, col2: 4 });
|
||||
check('svg found: line verde piena, spessore lw 2 predefinito',
|
||||
fnd === '<line x1="2" y1="1" x2="4" y2="3" fill="none" stroke="#16a34a" stroke-width="2" vector-effect="non-scaling-stroke"/>', fnd);
|
||||
check('svg reference blu, ruolo ignoto rosso',
|
||||
one({ type: 'segment', role: 'reference', row1: 0, col1: 0, row2: 1, col2: 1 }).includes('stroke="#2563eb"') &&
|
||||
one({ type: 'segment', role: 'boh', row1: 0, col1: 0, row2: 1, col2: 1 }).includes('stroke="#ef4444"'));
|
||||
check('svg: spessore costante, non dipende dallo zoom',
|
||||
one({ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }, { zoom: 4 }).includes('stroke-width="2"'));
|
||||
const cir = one({ type: 'circle', role: 'found', row: 5, col: 7, radius: 3 });
|
||||
check('svg circle: cx cy r', cir.startsWith('<circle cx="7" cy="5" r="3" '), cir);
|
||||
const con = one({ type: 'contour', role: 'found', rows: [1, 2, 3], cols: [4, 5, 6] });
|
||||
check('svg contour: polyline aperta', con.startsWith('<polyline points="4,1 5,2 6,3" ') && !/ Z/.test(con), con);
|
||||
|
||||
// points: croce di semi-braccio cross/zoom
|
||||
const p1 = one({ type: 'points', role: 'found', rows: [10], cols: [20] }, { zoom: 1 });
|
||||
check('svg points zoom 1: semi-braccio 6', p1.includes('d="M 14 10 L 26 10 M 20 4 L 20 16"'), p1);
|
||||
const p2 = one({ type: 'points', role: 'found', rows: [10], cols: [20] }, { zoom: 2 });
|
||||
check('svg points zoom 2: semi-braccio 3', p2.includes('d="M 17 10 L 23 10 M 20 7 L 20 13"'), p2);
|
||||
const p3 = one({ type: 'points', role: 'found', rows: [10, 30], cols: [20, 40] }, { zoom: 1, cross: 2 });
|
||||
check('svg points: cross personalizzato, 2 croci in un solo path',
|
||||
count(p3, /<path /g) === 1 && count(p3, /M /g) === 4 && p3.includes('M 18 10 L 22 10'), p3);
|
||||
|
||||
// arc: estremi calcolati (y verso il basso, angoli antiorari)
|
||||
const arcD = (o) => /d="([^"]+)"/.exec(one({ type: 'arc', role: 'found', ...o }))[1];
|
||||
const parse = (d) => d.match(/-?\d+(\.\d+)?/g).map(Number); // [x0, y0, rx, ry, rot, large, sweep, x1, y1]
|
||||
{
|
||||
// start 90, extent 90 su centro (40,30) r 12: da (40,18) a (28,30), antiorario (sweep 0), arco piccolo
|
||||
const d = arcD({ row: 30, col: 40, radius: 12, start_deg: 90, extent_deg: 90 });
|
||||
const v = parse(d);
|
||||
check('svg arc 90+90: M 40 18 A 12 12 0 0 0 28 30',
|
||||
near(v[0], 40) && near(v[1], 18) && v[2] === 12 && v[3] === 12 && v[4] === 0 && v[5] === 0 && v[6] === 0 &&
|
||||
near(v[7], 28) && near(v[8], 30), d);
|
||||
}
|
||||
{
|
||||
// start 0, extent -90 (orario): da (10,0) a (0,10) a video, sweep 1
|
||||
const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: -90 }));
|
||||
check('svg arc extent negativo: sweep 1, fine in basso a video',
|
||||
near(v[0], 10) && near(v[1], 0) && v[5] === 0 && v[6] === 1 && near(v[7], 0) && near(v[8], 10), JSON.stringify(v));
|
||||
}
|
||||
{
|
||||
// 270 gradi antiorari da 0: fine a 270 = in basso a video (0, +10)
|
||||
const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: 270 }));
|
||||
check('svg arc 270: large-arc 1, sweep 0, fine (0,10) a video',
|
||||
v[5] === 1 && v[6] === 0 && near(v[7], 0) && near(v[8], 10), JSON.stringify(v));
|
||||
}
|
||||
{
|
||||
const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: 360 }));
|
||||
check('svg arc 360: estensione limitata a 359.99 (large-arc, fine quasi sullo start)',
|
||||
v[5] === 1 && Math.abs(v[8]) < 0.01 && v[7] > 9.99, JSON.stringify(v));
|
||||
}
|
||||
{
|
||||
// coincide con draw: punto finale del g.arc del canvas (angolo -rad(start+extent))
|
||||
const a = { type: 'arc', role: 'found', row: 30, col: 40, radius: 12, start_deg: 30, extent_deg: 100 };
|
||||
const g = fakeCanvas();
|
||||
VisionOverlay.draw(g, [a], 1);
|
||||
const c = onlyCalls(g, 'arc')[0];
|
||||
const ex = c[1] + c[3] * Math.cos(c[5]), ey = c[2] + c[3] * Math.sin(c[5]);
|
||||
const sx = c[1] + c[3] * Math.cos(c[4]), sy = c[2] + c[3] * Math.sin(c[4]);
|
||||
const v = parse(arcD(a));
|
||||
check('svg arc: estremi uguali a quelli di draw su canvas',
|
||||
Math.abs(v[7] - ex) < 0.01 && Math.abs(v[8] - ey) < 0.01 && Math.abs(v[0] - sx) < 0.01 && Math.abs(v[1] - sy) < 0.01,
|
||||
JSON.stringify([v, ex, ey]));
|
||||
}
|
||||
|
||||
// malformati e ignoti: saltati, nessuna eccezione, la forma valida resta
|
||||
let thrown = null, out = '';
|
||||
try {
|
||||
out = VisionOverlay.svg([
|
||||
{ type: 'circle', role: 'found', row: 1, col: 1 },
|
||||
{ type: 'points', role: 'found', rows: [1, 2], cols: [1] },
|
||||
{ type: 'contour', role: 'found', rows: [], cols: [] },
|
||||
{ type: 'boh', role: 'found' },
|
||||
{ type: 'segment', role: 'found', row1: NaN, col1: 0, row2: 1, col2: 1 },
|
||||
{ type: 'arc', role: 'found', row: 0, col: 0, radius: 1, start_deg: 0 },
|
||||
{ type: 'rect2', role: 'roi', row: 0, col: 0, phi_deg: 0, length1: Infinity, length2: 1 },
|
||||
null,
|
||||
{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 },
|
||||
], { zoom: 1 });
|
||||
} catch (e) { thrown = e; }
|
||||
check('svg malformati: nessuna eccezione, una sola forma valida',
|
||||
thrown === null && count(out, /<line /g) === 1 && count(out, /vector-effect/g) === 1, String(thrown) + out);
|
||||
check('svg: overlay non array o vuoto -> stringa vuota',
|
||||
VisionOverlay.svg(null) === '' && VisionOverlay.svg(undefined) === '' && VisionOverlay.svg([]) === '');
|
||||
check('svg: opts non validi -> predefiniti',
|
||||
VisionOverlay.svg([{ type: 'points', role: 'found', rows: [10], cols: [20] }], { zoom: 0, lw: -1, cross: NaN })
|
||||
.includes('d="M 14 10 L 26 10 M 20 4 L 20 16"'));
|
||||
|
||||
// nessuna stringa dei dati nel markup
|
||||
const evil = '<script>alert(1)</script>"onload="x';
|
||||
const sicuro = VisionOverlay.svg([
|
||||
{ type: 'segment', role: evil, tool_id: evil, name: evil, row1: 0, col1: 0, row2: 1, col2: 1 },
|
||||
{ type: 'circle', role: 'found', tool_id: evil, row: 1, col: 1, radius: 2 },
|
||||
]);
|
||||
check('svg: tool_id e role dei dati non compaiono nel markup',
|
||||
!/script|onload|alert/.test(sicuro) && count(sicuro, /vector-effect/g) === 2, sicuro);
|
||||
const strNum = VisionOverlay.svg([{ type: 'segment', role: 'found', row1: '<b>', col1: 0, row2: 1, col2: 1 }]);
|
||||
check('svg: coordinata stringa -> forma saltata', strNum === '');
|
||||
}
|
||||
|
||||
// --- #12: livelli e quote CAD ---
|
||||
{
|
||||
const VO = VisionOverlay;
|
||||
check('layerOf: layer esplicito vince sul ruolo',
|
||||
VO.layerOf({ role: 'found', layer: 'quote' }) === 'quote' && VO.layerOf({ role: 'roi', layer: 'costruzioni' }) === 'costruzioni');
|
||||
check('layerOf: senza campo found = costruzioni, altro = ricerca',
|
||||
VO.layerOf({ role: 'found' }) === 'costruzioni' && VO.layerOf({ role: 'roi' }) === 'ricerca' &&
|
||||
VO.layerOf({ role: 'reference' }) === 'ricerca' && VO.layerOf({}) === 'ricerca' && VO.layerOf(null) === 'ricerca');
|
||||
check('layerOf: layer ignoto -> secondo il ruolo', VO.layerOf({ role: 'found', layer: 'boh' }) === 'costruzioni');
|
||||
|
||||
const lin = { tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: null };
|
||||
const rag = { tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 90, testo: null };
|
||||
const ang = { tipo: 'angolo', centro: [50, 50], r: 20, start_deg: 0, extent_deg: 90, testo: null };
|
||||
// default
|
||||
let p = VO.quotaPunto(lin);
|
||||
check('quotaPunto lineare default = punto medio', near(p.x, 50) && near(p.y, 0), JSON.stringify(p));
|
||||
p = VO.quotaPunto(rag);
|
||||
check('quotaPunto raggio default = centro + 2r lungo dir (90 gradi = verso l\'alto a video)', near(p.x, 50) && near(p.y, 30), JSON.stringify(p));
|
||||
p = VO.quotaPunto(ang);
|
||||
{ // u a 45 gradi = (cos45, -sin45); T = centro + 1.5r u
|
||||
const e = 30 * Math.SQRT1_2;
|
||||
check('quotaPunto angolo default = centro + 1.5r a meta arco', near(p.x, 50 + e) && near(p.y, 50 - e), JSON.stringify(p));
|
||||
}
|
||||
// con testo: lineare p1->p2 lungo +x, n = (0, 1): dv positivo = verso il basso a video
|
||||
p = VO.quotaPunto({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [10, 20] });
|
||||
check('quotaPunto lineare con testo [du,dv]', near(p.x, 60) && near(p.y, 20), JSON.stringify(p));
|
||||
// inverse
|
||||
[lin, rag, ang].forEach((q) => {
|
||||
[[3, -7], [-12.5, 40], [0, 0]].forEach((t) => {
|
||||
const q2 = Object.assign({}, q, { testo: t });
|
||||
const pt = VO.quotaPunto(q2);
|
||||
const back = VO.quotaTesto(q2, pt.x, pt.y);
|
||||
check('quotaTesto inverso di quotaPunto (' + q.tipo + ' ' + t + ')', near(back[0], t[0]) && near(back[1], t[1]), JSON.stringify(back));
|
||||
});
|
||||
// dal punto di default si torna a testo di default
|
||||
const d = VO.quotaPunto(q), back = VO.quotaTesto(q, d.x, d.y), p2 = VO.quotaPunto(Object.assign({}, q, { testo: back }));
|
||||
check('quotaTesto dal punto di default riproduce il default (' + q.tipo + ')', near(p2.x, d.x) && near(p2.y, d.y));
|
||||
});
|
||||
check('quota malformata: quotaPunto/quotaTesto null, quotaSvg stringa vuota',
|
||||
VO.quotaPunto(null) === null && VO.quotaPunto({ tipo: 'boh' }) === null &&
|
||||
VO.quotaPunto({ tipo: 'lineare', p1: [0, 'a'], p2: [1, 1] }) === null &&
|
||||
VO.quotaTesto({ tipo: 'raggio' }, 1, 1) === null &&
|
||||
VO.quotaSvg({ tipo: 'angolo', centro: [0, 0] }) === '' && VO.quotaSvg(undefined) === '');
|
||||
check('quotaPunto: testo malformato = default', near(VO.quotaPunto({ tipo: 'lineare', p1: [0, 0], p2: [10, 0], testo: ['x', 1] }).x, 5));
|
||||
|
||||
// quotaSvg lineare: linee di estensione solo con dv != 0
|
||||
const conDv = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 30] }, { zoom: 1, color: '#16a34a' });
|
||||
const senzaDv = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 0] }, { zoom: 1, color: '#16a34a' });
|
||||
check('quotaSvg lineare dv=30: 2 estensioni + 1 linea di misura, 2 frecce', count(conDv, /<line /g) === 3 && count(conDv, /<polygon /g) === 2, conDv);
|
||||
check('quotaSvg lineare dv=0: nessuna estensione (solo linea di misura), 2 frecce', count(senzaDv, /<line /g) === 1 && count(senzaDv, /<polygon /g) === 2, senzaDv);
|
||||
check('quotaSvg: tratto non-scaling 1.5 e colore passato', /stroke-width="1.5" vector-effect="non-scaling-stroke"/.test(conDv) && conDv.includes('#16a34a'));
|
||||
// frecce dentro/fuori: lineare lunga 200 a zoom 1 -> la punta sta in A (0, 30): primo vertice del poligono
|
||||
{ // dentro: punta in A, coda verso +u (x crescente) -> il secondo vertice ha x > 0? no: dentro la freccia punta verso fuori, coda verso l'interno (x>0)
|
||||
const m = conDv.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||
check('freccia dentro: punta in A, coda verso l\'interno della misura', near(m[0][0], 0) && near(m[0][1], 30) && m[1][0] > 0, m.join('|'));
|
||||
const corta = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [10, 0], testo: [0, 30] }, { zoom: 1 });
|
||||
const mc = corta.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||
check('freccia fuori (misura corta): punta in A, coda fuori (x < 0)', near(mc[0][0], 0) && mc[1][0] < 0, mc.join('|'));
|
||||
const zz = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 30] }, { zoom: 4 });
|
||||
const mz = zz.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||
check('freccia: lunghezza fissa a video (9 px / zoom)', near(mz[1][0], 9 / 4) && count(zz, /<line /g) === 3, mz.join('|'));
|
||||
}
|
||||
// T oltre B: la linea di misura si allunga
|
||||
const oltre = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [150, 30] }, { zoom: 1 });
|
||||
check('quotaSvg lineare: scritta oltre B allunga la linea di misura fino a lei', oltre.includes('x2="200"'), oltre);
|
||||
// raggio
|
||||
const rFuori = VO.quotaSvg({ tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 0, testo: [30, 0] }, { zoom: 1 });
|
||||
const rDentro = VO.quotaSvg({ tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 0, testo: [5, 0] }, { zoom: 1 });
|
||||
check('quotaSvg raggio: crocetta + linea + freccia (fuori)', count(rFuori, /<line /g) === 3 && count(rFuori, /<polygon /g) === 1, rFuori);
|
||||
{ // fuori: P = (60, 50), freccia verso il centro: coda a x > 60
|
||||
const m = rFuori.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||
check('raggio fuori: punta in P sul cerchio, coda verso fuori', near(m[0][0], 60) && near(m[0][1], 50) && m[1][0] > 60, m.join('|'));
|
||||
const d = rDentro.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||
check('raggio dentro: punta in P, coda verso il centro (x < 60)', near(d[0][0], 60) && d[1][0] < 60, d.join('|'));
|
||||
}
|
||||
// angolo
|
||||
const a1 = VO.quotaSvg(ang, { zoom: 1 });
|
||||
check('quotaSvg angolo: 2 estensioni radiali + arco + 2 frecce', count(a1, /<line /g) === 2 && count(a1, /<path /g) === 1 && count(a1, /<polygon /g) === 2, a1);
|
||||
// markup senza stringhe dei dati
|
||||
const evil = '<script>alert(1)</script>"onload="x';
|
||||
const s1 = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [0, 20], nome: evil, extra: evil }, { zoom: 1, color: evil });
|
||||
const s2 = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [0, evil] }, { zoom: 1 });
|
||||
check('quotaSvg: nessuna stringa dei dati nel markup, colore non in lista -> rosso',
|
||||
!/script|onload|alert/.test(s1) && s1.includes('#ef4444') && !/script|onload|alert/.test(s2), s1);
|
||||
check('quotaSvg: colori ammessi (esiti)', ['#16a34a', '#d97706', '#dc2626'].every((c) => VO.quotaSvg(lin, { color: c }).includes(c)));
|
||||
check('svg() invariato: uscita di un segmento', VO.svg([{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }]).startsWith('<line x1="0"'));
|
||||
}
|
||||
|
||||
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* #114: etichette della tabella quote nel pannello visione (getter `rows`), con plain node.
|
||||
*
|
||||
* Run from src/frontend/flask_app: node tests/js/test_vision_rows.js
|
||||
*/
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'static', 'js', 'vision_execute.js'), 'utf8',
|
||||
);
|
||||
// Contesto vm con window e document finti; il file definisce visionExecute() globale
|
||||
const win = {};
|
||||
// #120: CustomEvent finto (la pagina emette vision-recorded con window.dispatchEvent)
|
||||
const ctx = vm.createContext({
|
||||
window: win,
|
||||
document: { addEventListener() {}, querySelector() { return null; } },
|
||||
CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
|
||||
});
|
||||
vm.runInContext(src + ';this.visionExecute = visionExecute;', ctx);
|
||||
|
||||
let failures = 0;
|
||||
function check(label, cond, extra) {
|
||||
if (cond) console.log('ok - ' + label);
|
||||
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||
}
|
||||
|
||||
win.__visionCfg = {
|
||||
subtasks: [
|
||||
{ id: 15, marker_number: 1, description: 'Larghezza', measurement_type: null, unit: 'mm' },
|
||||
{ id: 16, marker_number: 2, description: '', measurement_type: 'Diametro', unit: 'mm' },
|
||||
{ id: 17, marker_number: 3, description: null, measurement_type: null },
|
||||
],
|
||||
};
|
||||
const comp = ctx.visionExecute();
|
||||
comp.result = {
|
||||
measurements: [
|
||||
{ subtask_id: 15, value: 1, pass_fail: 'pass' },
|
||||
{ subtask_id: 16, value: 2, pass_fail: 'pass' },
|
||||
{ subtask_id: 17, value: 3, pass_fail: 'fail' },
|
||||
{ subtask_id: 99, value: 4, pass_fail: 'pass' },
|
||||
],
|
||||
};
|
||||
const rows = comp.rows;
|
||||
check('#114 riga 15: description', rows[0].label === '1. Larghezza', rows[0].label);
|
||||
check('#114 riga 16: ripiego su measurement_type', rows[1].label === '2. Diametro', rows[1].label);
|
||||
check('#114 riga 17: ripiego su #id', rows[2].label === '3. #17', rows[2].label);
|
||||
check('#114 riga 99: subtask sconosciuta', rows[3].label === '#99', rows[3].label);
|
||||
check('#114 unit riga 17 = mm', rows[2].unit === 'mm', rows[2].unit);
|
||||
check('#114 unit riga 99 = mm', rows[3].unit === 'mm', rows[3].unit);
|
||||
|
||||
// #117: nominale e tolleranze portati dalla quota; fmtTol
|
||||
win.__visionCfg = {
|
||||
subtasks: [
|
||||
{ id: 20, description: 'Largh', unit: 'mm', nominal: 12.5, ltl: 12.4, utl: 12.6, lwl: 12.45, uwl: 12.55 },
|
||||
{ id: 21, description: 'Diam', unit: 'mm', nominal: 0, ltl: null, utl: 0.05 },
|
||||
{ id: 22, description: 'Nuda', unit: 'mm' },
|
||||
],
|
||||
};
|
||||
const c2 = ctx.visionExecute();
|
||||
c2.result = { measurements: [
|
||||
{ subtask_id: 20, value: 12.5, pass_fail: 'pass' },
|
||||
{ subtask_id: 21, value: 0, pass_fail: 'pass' },
|
||||
{ subtask_id: 22, value: 1, pass_fail: 'pass' },
|
||||
] };
|
||||
const r2 = c2.rows;
|
||||
check('#117 riga 20: nominale e tolleranze',
|
||||
r2[0].nominal === 12.5 && r2[0].ltl === 12.4 && r2[0].utl === 12.6 && r2[0].lwl === 12.45 && r2[0].uwl === 12.55);
|
||||
check('#117 riga 21: nominale 0 resta 0, ltl null', r2[1].nominal === 0 && r2[1].ltl == null && r2[1].utl === 0.05);
|
||||
check('#117 riga 22: senza tolleranze sono undefined/null', r2[2].nominal == null && r2[2].utl == null && r2[2].lwl == null);
|
||||
check('#117 label invariato', r2[0].label === 'Largh', r2[0].label);
|
||||
check('#117 fmtTol(null) = trattino', c2.fmtTol(null) === '\u2014', c2.fmtTol(null));
|
||||
check('#117 fmtTol(undefined) = trattino', c2.fmtTol(undefined) === '\u2014');
|
||||
check('#117 fmtTol(0.05) = 0.050', c2.fmtTol(0.05) === '0.050', c2.fmtTol(0.05));
|
||||
check('#117 fmtTol(0) = 0.000', c2.fmtTol(0) === '0.000', c2.fmtTol(0));
|
||||
|
||||
// #116: righe con key/name/vision_output, etichette in % con un VisionOverlay finto, labelClass
|
||||
win.__visionCfg = {
|
||||
subtasks: [
|
||||
{ id: 30, marker_number: 1, description: 'Larghezza', unit: 'mm', vision_output: 'larg.value' },
|
||||
{ id: 31, description: '', measurement_type: 'Diametro', unit: 'mm' },
|
||||
],
|
||||
};
|
||||
const c3 = ctx.visionExecute();
|
||||
c3.result = { overlay: [{ type: 'circle', tool_id: 'larg' }], measurements: [
|
||||
{ subtask_id: 30, value: 1.5, pass_fail: 'pass' },
|
||||
{ subtask_id: 31, value: 2, pass_fail: 'fail' },
|
||||
] };
|
||||
const r3 = c3.rows;
|
||||
check('#116 riga: key = subtask_id', r3[0].key === 30 && r3[1].key === 31);
|
||||
check('#116 riga: name senza marker', r3[0].name === 'Larghezza' && r3[1].name === 'Diametro', r3[0].name + '/' + r3[1].name);
|
||||
check('#116 riga: vision_output', r3[0].vision_output === 'larg.value' && r3[1].vision_output == null);
|
||||
check('#116 label invariato (#114)', r3[0].label === '1. Larghezza', r3[0].label);
|
||||
check('#116 senza VisionOverlay: nessuna etichetta', c3.viewerLabels.length === 0);
|
||||
|
||||
let seen = null;
|
||||
ctx.VisionOverlay = {
|
||||
labels(rows, overlay) { seen = { rows, overlay }; return [{ key: 30, x: 500, y: 250, text: 'Larghezza 1.500 mm', status: 'pass' }]; },
|
||||
};
|
||||
const ls = c3.viewerLabels;
|
||||
check('#116 una etichetta', ls.length === 1 && ls[0].key === 30 && ls[0].text === 'Larghezza 1.500 mm', JSON.stringify(ls));
|
||||
// #122: posizione in px logici dell'immagine (la percentuale la calcola <tmf-viewer>), niente style
|
||||
check('#122 posizione in px logici', ls[0].x === 500 && ls[0].y === 250 && ls[0].style === undefined, JSON.stringify(ls[0]));
|
||||
check('#116 classe da esito', ls[0].cls === 'bg-green-600 text-white', ls[0].cls);
|
||||
check('#116 labels riceve rows e overlay', seen.rows.length === 2 && seen.overlay.length === 1);
|
||||
c3.result = null;
|
||||
check('#116 senza risultato: nessuna etichetta', c3.viewerLabels.length === 0);
|
||||
check('#116 labelClass pass', c3.labelClass('pass') === 'bg-green-600 text-white');
|
||||
check('#116 labelClass warning', c3.labelClass('warning') === 'bg-amber-400 text-black');
|
||||
check('#116 labelClass fail', c3.labelClass('fail') === 'bg-red-600 text-white');
|
||||
check('#116 labelClass altro', c3.labelClass('boh') === 'bg-gray-700 text-white' && c3.labelClass(undefined) === 'bg-gray-700 text-white');
|
||||
|
||||
// #122: zoom, overlay SVG e Salva sono di <tmf-viewer>: il componente non li ha piu'
|
||||
check('#122 niente drawOverlay/saveImage/overlayLabels/z', c3.drawOverlay === undefined && c3.saveImage === undefined && c3.overlayLabels === undefined && c3.z === undefined);
|
||||
check('#122 niente codice canvas', !/getContext|clearRect/.test(src));
|
||||
|
||||
// #118: quota non misurata (value null) = NaN, mai Number(null) = 0
|
||||
check('#118 fmtValue(null) = NaN', c3.fmtValue(null, 'mm') === 'NaN mm', c3.fmtValue(null, 'mm'));
|
||||
check('#118 fmtValue(undefined) = NaN', c3.fmtValue(undefined, 'mm') === 'NaN mm');
|
||||
check('#118 fmtValue(0) = 0.000', c3.fmtValue(0, 'mm') === '0.000 mm', c3.fmtValue(0, 'mm'));
|
||||
check('#118 fmtValue(1.5) = 1.500', c3.fmtValue(1.5, 'mm') === '1.500 mm');
|
||||
win.__visionCfg = { subtasks: [{ id: 40, description: 'Persa', unit: 'mm', nominal: 5, ltl: 4, utl: 6 }] };
|
||||
const c4 = ctx.visionExecute();
|
||||
c4.result = { measurements: [{ subtask_id: 40, value: null, pass_fail: 'fail' }] };
|
||||
check('#118 riga con value null: valore null, esito fail, tolleranze come prima',
|
||||
c4.rows[0].value === null && c4.rows[0].status === 'fail' && c4.rows[0].nominal === 5 && c4.rows[0].utl === 6);
|
||||
|
||||
// #120: evento vision-recorded dopo un record riuscito, non dopo uno fallito
|
||||
const events = [];
|
||||
win.dispatchEvent = (e) => { events.push(e); return true; };
|
||||
const listeners = {};
|
||||
win.addEventListener = (t, f) => { (listeners[t] = listeners[t] || []).push(f); };
|
||||
win.__visionCfg = { cameraAvailable: true, modo: 'impulsiva', subtasks: [{ id: 40, description: 'Persa', unit: 'mm' }] };
|
||||
const c5 = ctx.visionExecute();
|
||||
c5.lastBlob = { name: 'x' };
|
||||
const okMs = [{ subtask_id: 40, value: null, pass_fail: 'fail' }, { subtask_id: 41, value: 2, pass_fail: 'pass' }];
|
||||
c5.acquire = async function () { this.result = { vision_result_id: 7, measurements: okMs }; };
|
||||
(async () => {
|
||||
await c5.record();
|
||||
const ev = events.filter((e) => e.type === 'vision-recorded');
|
||||
check('#120 record riuscito: un evento vision-recorded', ev.length === 1, String(ev.length));
|
||||
check('#120 evento: misure di execute', ev[0].detail.measurements === okMs);
|
||||
check('#120 record riuscito: saved', c5.saved === true && c5.preview === false);
|
||||
|
||||
events.length = 0;
|
||||
const c6 = ctx.visionExecute();
|
||||
c6.lastBlob = { name: 'x' };
|
||||
c6.acquire = async function () { this.error = 'boom'; this.result = null; };
|
||||
await c6.record();
|
||||
check('#120 record fallito (errore): nessun evento', events.length === 0 && c6.saved === false);
|
||||
const c7 = ctx.visionExecute();
|
||||
c7.lastBlob = { name: 'x' };
|
||||
c7.acquire = async function () { this.result = { measurements: okMs }; }; // niente vision_result_id
|
||||
await c7.record();
|
||||
check('#120 record senza vision_result_id: nessun evento', events.length === 0 && c7.saved === false);
|
||||
|
||||
// senza camera il file e' salvato subito da acquire(): stesso evento
|
||||
events.length = 0;
|
||||
win.__visionCfg = { cameraAvailable: false, executeUrl: '/e', taskId: 1, subtasks: [] };
|
||||
const c8 = ctx.visionExecute();
|
||||
c8.$nextTick = (fn) => fn();
|
||||
ctx.URL = { createObjectURL: () => 'blob:x', revokeObjectURL() {} };
|
||||
ctx.FormData = class { append() {} };
|
||||
ctx.fetch = async () => ({ ok: true, json: async () => ({ vision_result_id: 9, measurements: okMs }) });
|
||||
await c8.acquire({ name: 'a.png' });
|
||||
check('#120 senza camera: acquire riuscito emette vision-recorded', events.length === 1 && events[0].type === 'vision-recorded', String(events.length));
|
||||
|
||||
// task-result-reset: saved torna false (anche senza camera, init registra l'ascolto)
|
||||
c5.init();
|
||||
const h = (listeners['task-result-reset'] || [])[0];
|
||||
check('#120 init ascolta task-result-reset', typeof h === 'function');
|
||||
c5.saved = true;
|
||||
h();
|
||||
check('#120 task-result-reset: saved = false', c5.saved === false);
|
||||
|
||||
// #123: dopo Registra + task-result-reset Registra e' spento sul frame registrato, anteprima spenta, impulsiva = grab
|
||||
win.__visionCfg = { cameraAvailable: true, modo: 'impulsiva', subtasks: [] };
|
||||
const c9 = ctx.visionExecute();
|
||||
let grabs = 0;
|
||||
c9.grab = async function () { grabs++; };
|
||||
c9.lastBlob = { name: 'f1' };
|
||||
c9.acquire = async function () { this.result = { vision_result_id: 11, measurements: okMs }; };
|
||||
check('#123 prima di Registra: canRecord', c9.canRecord === true);
|
||||
await c9.record();
|
||||
check('#123 dopo Registra: canRecord falso (saved)', c9.canRecord === false && c9.recordedBlob === c9.lastBlob);
|
||||
c9.$nextTick = () => {}; // #123: nel test nessun grab di apertura
|
||||
c9.init(); // registra l'ascolto di task-result-reset
|
||||
grabs = 0;
|
||||
(listeners['task-result-reset'] || []).slice(-1)[0]();
|
||||
check('#123 reset sul frame registrato: canRecord falso', c9.canRecord === false && c9.saved === false);
|
||||
check('#123 reset sul frame registrato: niente badge anteprima', c9.preview === false);
|
||||
check('#123 reset in impulsiva: un solo grab', grabs === 1, String(grabs));
|
||||
c9.lastBlob = { name: 'f2' };
|
||||
check('#123 frame nuovo: canRecord vero', c9.canRecord === true);
|
||||
|
||||
// #119 #122: saveName = titolo del task (nome proposto da <tmf-viewer save-name>), 'immagine' se manca
|
||||
win.__visionCfg = { taskName: 'Roto3', subtasks: [] };
|
||||
check('#122 saveName = nome del task', ctx.visionExecute().saveName === 'Roto3');
|
||||
win.__visionCfg = { subtasks: [] };
|
||||
check('#122 saveName di riserva', ctx.visionExecute().saveName === 'immagine');
|
||||
|
||||
process.exit(failures ? 1 : 0);
|
||||
})();
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* #122: lo zoom del pannello visione non e' piu' in vision_execute.js ma in <tmf-viewer> (image_viewer.js).
|
||||
* Qui: il componente passa al viewer solo le etichette in px logici (viewerLabels) e non ha piu' codice di zoom.
|
||||
*
|
||||
* Run from src/frontend/flask_app: node tests/js/test_vision_zoom.js
|
||||
*/
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'static', 'js', 'vision_execute.js'), 'utf8',
|
||||
);
|
||||
const win = { __visionCfg: { subtasks: [{ id: 1, description: 'Larg', unit: 'mm' }] } };
|
||||
const ctx = vm.createContext({ window: win, document: { addEventListener() {}, querySelector() { return null; } } });
|
||||
vm.runInContext(src + ';this.visionExecute = visionExecute;', ctx);
|
||||
|
||||
let failures = 0;
|
||||
function check(label, cond, extra) {
|
||||
if (cond) console.log('ok - ' + label);
|
||||
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||
}
|
||||
|
||||
// Niente piu' stato ne' funzioni di zoom/pan/resize/Salva/overlay nel componente
|
||||
const c = ctx.visionExecute();
|
||||
for (const k of ['z', 'zoomBy', 'zFit', 'zWheel', 'zCanPan', 'zCursor', 'zPct', 'zSize', 'zPanDown', 'zPanMove', 'zPanEnd',
|
||||
'onImgLoad', 'drawOverlay', 'saveImage', 'overlayLabels']) {
|
||||
check('#122 componente senza ' + k, c[k] === undefined);
|
||||
}
|
||||
check('#122 sorgente senza $watch di zoom ne ResizeObserver', !/z\.zoom|ResizeObserver|tmfSaveImage/.test(src));
|
||||
|
||||
// viewerLabels: {key, x, y, text, cls} in px logici, classe dall'esito; vuote senza risultato o senza VisionOverlay
|
||||
check('#122 senza VisionOverlay: nessuna etichetta', (c.result = { measurements: [] }, c.viewerLabels.length === 0));
|
||||
win.VisionOverlay = ctx.VisionOverlay = {
|
||||
labels() { return [{ key: 1, x: 500, y: 250, text: 'Larg 1.500 mm', status: 'fail' }]; },
|
||||
};
|
||||
const ls = c.viewerLabels;
|
||||
check('#122 viewerLabels in px logici', ls.length === 1 && ls[0].x === 500 && ls[0].y === 250 && ls[0].key === 1 && ls[0].text === 'Larg 1.500 mm', JSON.stringify(ls));
|
||||
check('#122 viewerLabels: classe da esito', ls[0].cls === 'bg-red-600 text-white', ls[0].cls);
|
||||
c.result = null;
|
||||
check('#122 senza risultato: nessuna etichetta', c.viewerLabels.length === 0);
|
||||
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -278,7 +278,7 @@ def test_camera_sources_proxy_keeps_only_the_three_fields(logged_in_client, mock
|
||||
"name": "camera:folder", "label": "Cartella", "kind": "folder"}
|
||||
assert len(resp.get_json()["sources"]) == 2
|
||||
assert get.call_args.args[0] == "http://acq:8765/camera/sources"
|
||||
assert get.call_args.kwargs["timeout"] == 5
|
||||
assert get.call_args.kwargs["timeout"] == 15 # #106
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["ftp://x", "", "http://", "acq:8765"])
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -72,6 +72,7 @@ class TestStationCameraGrab:
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == "http://acq:8765/camera/grab" # slash finale tolto
|
||||
assert kwargs["json"] == {"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000}
|
||||
assert kwargs["timeout"] == 60 # #106: margine per la prima apertura GenTL
|
||||
|
||||
def test_get_also_works(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
@@ -178,6 +179,12 @@ class TestDxfCompareProxy:
|
||||
assert set(kwargs["files"]) == {"image", "dxf"}
|
||||
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):
|
||||
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
|
||||
resp = self._post(logged_in_client, save="true")
|
||||
@@ -204,6 +211,122 @@ 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 TestDxfSourceProxy:
|
||||
"""#105: parametri della ricetta DXF gia' salvata (proxy verso il backend)."""
|
||||
URL = "/maker/api/vision/tasks/7/recipe/dxf-compare/source"
|
||||
OK = {"band_mm": 0.3, "min_points_pct": 95.0, "polarity": "dark", "px_per_mm": 71.12,
|
||||
"dxf": "disegno.dxf", "image": "riferimento.bmp"}
|
||||
|
||||
def test_forwards_body(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = self.OK
|
||||
resp = logged_in_client.get(self.URL)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == self.OK
|
||||
assert mock_api_client.get.call_args.args[0] == "/api/vision/tasks/7/recipe/dxf-compare/source"
|
||||
|
||||
def test_404_propagates(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = {
|
||||
"error": True, "status_code": 404, "detail": "nessuna ricetta da DXF su questo task"}
|
||||
resp = logged_in_client.get(self.URL)
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json()["detail"] == "nessuna ricetta da DXF su questo task"
|
||||
|
||||
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 logged_in_client.get(self.URL).status_code == 403
|
||||
mock_api_client.get.assert_not_called()
|
||||
|
||||
|
||||
class TestRecipeFileProxy:
|
||||
"""#105: file della cartella ricetta (DXF, immagine di riferimento) passati come byte."""
|
||||
URL = "/maker/api/vision/tasks/7/recipe/files/riferimento.bmp"
|
||||
BMP = b"BM 1 fake bmp"
|
||||
|
||||
def _fake_file(self, status=200, content=BMP, ctype="image/bmp"):
|
||||
resp = MagicMock(status_code=status, content=content)
|
||||
resp.headers = {"content-type": ctype}
|
||||
return resp
|
||||
|
||||
def test_bytes_and_content_type_pass_through(self, logged_in_client, mock_api_client):
|
||||
with patch("blueprints.maker.http_requests.get", return_value=self._fake_file()) as get:
|
||||
resp = logged_in_client.get(self.URL)
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == self.BMP
|
||||
assert resp.mimetype == "image/bmp"
|
||||
args, kwargs = get.call_args
|
||||
assert args[0].endswith("/api/vision/tasks/7/recipe/files/riferimento.bmp")
|
||||
assert kwargs["headers"]["X-API-Key"] == "test-api-key-12345"
|
||||
|
||||
def test_backend_404_propagates(self, logged_in_client, mock_api_client):
|
||||
body = b'{"detail":"file non trovato"}'
|
||||
with patch("blueprints.maker.http_requests.get",
|
||||
return_value=self._fake_file(404, body, "application/json")):
|
||||
resp = logged_in_client.get(self.URL)
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json()["detail"] == "file non trovato"
|
||||
|
||||
def test_unsafe_path_is_404_without_calling_backend(self, logged_in_client, mock_api_client):
|
||||
with patch("blueprints.maker.http_requests.get") as get:
|
||||
for path in ("a/../../x", "..%5Cx", "%2e%2e/x"):
|
||||
assert logged_in_client.get(f"/maker/api/vision/tasks/7/recipe/files/{path}").status_code == 404
|
||||
get.assert_not_called()
|
||||
|
||||
def test_service_down_is_502(self, logged_in_client, mock_api_client):
|
||||
with patch("blueprints.maker.http_requests.get", side_effect=requests.ConnectionError("giu")):
|
||||
assert logged_in_client.get(self.URL).status_code == 502
|
||||
|
||||
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"]}
|
||||
with patch("blueprints.maker.http_requests.get") as get:
|
||||
assert logged_in_client.get(self.URL).status_code == 403
|
||||
get.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:
|
||||
@@ -229,4 +352,15 @@ class TestPages:
|
||||
assert "js/dxf_recipe_wizard.js" in html
|
||||
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 "imageSource === 'file'" in html # #104: mm/px solo da file
|
||||
assert "imagePreviewUrl" in html and "imagePreviewSummary" in html # #102: anteprima immagine
|
||||
# #122: le tre viste (immagine, DXF, Prova) sono <tmf-viewer>; zoom, Adatta, pan e Salva stanno nel componente
|
||||
assert html.count("<tmf-viewer") == 3 and "js/image_viewer.js" in html
|
||||
assert '@viewer-load="onImagePreviewLoad($event.detail)"' in html # #122: dimensioni per il riepilogo
|
||||
assert "fit-height" in html and 'save-name="prova"' in html # #122: la Prova segue l'immagine
|
||||
for old in ('x-ref="vpImg"', 'x-ref="vpDxf"', "vZoomBy(", "vFit(", "vPanDown(", "onPanDown(", "zoomBy(", 'x-ref="viewport"'):
|
||||
assert old not in html, old # #122: zoom a mano tolto dal wizard
|
||||
assert "existing ? msgs.editLabel : msgs.createLabel" in html and "Modifica ricetta da DXF" in html # #105: etichetta dinamica
|
||||
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Runs the plain-node DXF wizard zoom test (tests/js/test_dxf_zoom.js).
|
||||
|
||||
Same thin pytest wrapper pattern as test_dxf_preview_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#113.
|
||||
"""
|
||||
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_zoom.js"
|
||||
|
||||
|
||||
def test_dxf_zoom_js_behaviour():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node non installato: test JS dello zoom del wizard 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_zoom.js fallito (exit {result.returncode})\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Runs the plain-node image_to_png.js test (tests/js/test_image_to_png.js).
|
||||
|
||||
Covers gitea:Adriano/TieMeasureFlow#107.
|
||||
"""
|
||||
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_image_to_png.js"
|
||||
|
||||
|
||||
def test_image_to_png_js_pure_behaviour():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node non installato: test JS di image_to_png.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_image_to_png.js fallito (exit {result.returncode})\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Runs the plain-node image viewer (TmfViewerMath) test (tests/js/test_image_viewer.js).
|
||||
|
||||
Same thin pytest wrapper pattern as test_vision_overlay_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#122.
|
||||
"""
|
||||
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_image_viewer.js"
|
||||
|
||||
|
||||
def test_image_viewer_js_behaviour():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node non installato: test JS del visualizzatore immagine 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_image_viewer.js fallito (exit {result.returncode})\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
@@ -313,6 +313,10 @@ class TestImageCropInclusion:
|
||||
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
|
||||
assert "js/image-crop.js" in html
|
||||
assert "tmfCropBeforeUpload(file)" in html
|
||||
# #107: conversione BMP->PNG e errore upload nel riquadro Disegno Tecnico
|
||||
assert "js/image_to_png.js" in html
|
||||
assert "tmfImageToPng(file)" in html
|
||||
assert "uploadErrorTask" in html
|
||||
|
||||
def test_recipe_editor_includes_crop(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.side_effect = [
|
||||
@@ -322,3 +326,6 @@ class TestImageCropInclusion:
|
||||
html = logged_in_client.get("/maker/recipes/1/edit").get_data(as_text=True)
|
||||
assert "js/image-crop.js" in html
|
||||
assert "tmfCropBeforeUpload(file)" in html
|
||||
# #107: conversione BMP->PNG prima dei controlli di formato
|
||||
assert "js/image_to_png.js" in html
|
||||
assert "tmfImageToPng(file)" in html
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""gitea:Adriano/TieMeasureFlow#97 - camera e modo di analisi nel task visione (lato Maker).
|
||||
|
||||
api_client e requests sono finti: nessun servizio camera vero.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from tests.conftest import api_get_router
|
||||
|
||||
ST1 = {"id": 4, "code": "ST-4", "name": "Banco", "camera_url": "http://acq:8765/", "camera_source": "camera:1"}
|
||||
ST2 = {"id": 5, "code": "ST-5", "name": "Linea", "camera_url": "http://acq:8765", "camera_source": "camera:2"}
|
||||
ST3 = {"id": 6, "code": "ST-6", "name": "Altra", "camera_url": "http://altro:9000", "camera_source": "x"}
|
||||
NO_CAMERA = {"id": 7, "code": "ST-7", "name": "Senza", "camera_url": None, "camera_source": None}
|
||||
SOURCES = {"sources": [
|
||||
{"name": "camera:601020", "kind": "camera", "label": "Basler 601020"},
|
||||
{"name": "pezzo.png", "kind": "image", "label": "immagine - pezzo.png"},
|
||||
]}
|
||||
|
||||
|
||||
def _stations(mock_api_client, stations=(ST1, ST2, ST3, NO_CAMERA)):
|
||||
mock_api_client.get.side_effect = api_get_router({"/api/stations": list(stations)})
|
||||
|
||||
|
||||
def _fake(status=200, body=None):
|
||||
resp = MagicMock(status_code=status, text="")
|
||||
if body is None:
|
||||
resp.json.side_effect = ValueError("no json")
|
||||
else:
|
||||
resp.json.return_value = body
|
||||
return resp
|
||||
|
||||
|
||||
class TestCameraServizi:
|
||||
def test_distinct_urls_with_stations(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
resp = logged_in_client.get("/maker/api/camera/servizi")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"servizi": [
|
||||
{"camera_url": "http://acq:8765", "stazioni": ["ST-4 - Banco", "ST-5 - Linea"]},
|
||||
{"camera_url": "http://altro:9000", "stazioni": ["ST-6 - Altra"]},
|
||||
]}
|
||||
|
||||
def test_no_stations_with_camera(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client, [NO_CAMERA])
|
||||
assert logged_in_client.get("/maker/api/camera/servizi").get_json() == {"servizi": []}
|
||||
|
||||
|
||||
class TestCameraSorgenti:
|
||||
def test_lists_sources(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
with patch("blueprints.maker.http_requests.get", return_value=_fake(200, SOURCES)) as get:
|
||||
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://acq:8765/")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == SOURCES
|
||||
args, kwargs = get.call_args
|
||||
assert args[0] == "http://acq:8765/camera/sources"
|
||||
assert kwargs["timeout"] == 15
|
||||
|
||||
def test_url_not_a_station_service_is_400(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
with patch("blueprints.maker.http_requests.get") as get:
|
||||
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://evil:1")
|
||||
assert resp.status_code == 400
|
||||
get.assert_not_called()
|
||||
|
||||
def test_missing_url_is_400(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
with patch("blueprints.maker.http_requests.get") as get:
|
||||
assert logged_in_client.get("/maker/api/camera/sorgenti").status_code == 400
|
||||
get.assert_not_called()
|
||||
|
||||
def test_service_down_is_502(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
with patch("blueprints.maker.http_requests.get",
|
||||
side_effect=requests.ConnectionError("refused")):
|
||||
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://acq:8765")
|
||||
assert resp.status_code == 502
|
||||
assert "refused" in resp.get_json()["detail"]
|
||||
|
||||
def test_service_error_status_is_502(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
with patch("blueprints.maker.http_requests.get", return_value=_fake(500, {"detail": "x"})):
|
||||
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://acq:8765")
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
class TestTaskProxyForwardsAcquisition:
|
||||
def test_put_forwards_acquisition_json(self, logged_in_client, mock_api_client):
|
||||
acq = {"camera_url": "http://acq:8765", "camera_source": "camera:1", "modo": "in_linea"}
|
||||
mock_api_client.put.return_value = {"id": 11, "title": "T", "acquisition_json": acq}
|
||||
resp = logged_in_client.put("/maker/api/tasks/11", json={"title": "T", "acquisition_json": acq})
|
||||
assert resp.status_code == 200
|
||||
assert mock_api_client.put.call_args.kwargs["data"]["acquisition_json"] == acq
|
||||
|
||||
def test_put_forwards_null(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.put.return_value = {"id": 11, "title": "T", "acquisition_json": None}
|
||||
logged_in_client.put("/maker/api/tasks/11", json={"title": "T", "acquisition_json": None})
|
||||
assert mock_api_client.put.call_args.kwargs["data"]["acquisition_json"] is None
|
||||
|
||||
|
||||
class TestTaskEditorAcquisitionUi:
|
||||
def _html(self, client, mock_api_client, task_type="camera_measure"):
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
|
||||
"/api/recipes/3/tasks": [{
|
||||
"id": 11, "title": "T", "task_type": task_type, "subtasks": [],
|
||||
"file_path": None, "order_index": 1,
|
||||
"acquisition_json": {"camera_url": "http://acq:8765", "camera_source": "camera:1",
|
||||
"modo": "in_linea"},
|
||||
}],
|
||||
})
|
||||
return client.get("/maker/recipes/3/tasks").get_data(as_text=True)
|
||||
|
||||
def test_section_and_fields(self, logged_in_client, mock_api_client):
|
||||
html = self._html(logged_in_client, mock_api_client)
|
||||
assert "taskAcquisition(" in html
|
||||
assert "js/task_acquisition.js" in html
|
||||
assert "Servizio di acquisizione" in html
|
||||
assert "Sorgente" in html
|
||||
assert "Analisi impulsiva" in html and "Analisi in linea" in html
|
||||
assert 'value="impulsiva"' in html and 'value="in_linea"' in html
|
||||
assert "Camera della stazione dell'operatore" in html or \
|
||||
"Camera della stazione dell'operatore" in html
|
||||
|
||||
def test_payload_carries_acquisition_json(self, logged_in_client, mock_api_client):
|
||||
html = self._html(logged_in_client, mock_api_client)
|
||||
assert "acquisition_json: acq.value" in html
|
||||
assert "_acquisitionPayload(this.editTaskData)" in html
|
||||
# il task salvato riempie lo stato di modifica
|
||||
assert "task.acquisition_json.camera_url" in html
|
||||
|
||||
def test_static_js_endpoints(self):
|
||||
import os
|
||||
path = os.path.join(os.path.dirname(__file__), "..", "static", "js", "task_acquisition.js")
|
||||
src = open(path, encoding="utf-8").read()
|
||||
assert "/maker/api/camera/servizi" in src
|
||||
assert "/maker/api/camera/sorgenti?url=" in src
|
||||
@@ -68,7 +68,7 @@ def test_grab_passes_png_through(logged_in_client, mock_api_client, monkeypatch)
|
||||
assert kwargs["json"] == {
|
||||
"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000,
|
||||
}
|
||||
assert kwargs["timeout"] == 15
|
||||
assert kwargs["timeout"] == 60 # #106
|
||||
|
||||
|
||||
def test_grab_service_503_is_502(logged_in_client, mock_api_client, monkeypatch):
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Runs the plain-node save-image helper test (tests/js/test_save_image.js).
|
||||
|
||||
Same thin pytest wrapper pattern as test_vision_overlay_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#119.
|
||||
"""
|
||||
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_save_image.js"
|
||||
|
||||
|
||||
def test_save_image_js_behaviour():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node non installato: test JS del salvataggio immagine 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_save_image.js fallito (exit {result.returncode})\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
@@ -167,3 +167,11 @@ class TestRunTaskResultsProxy:
|
||||
assert 'id="run-task-results"' in html
|
||||
assert "loadTaskResults" 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
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""#118: il riepilogo task mostra NaN per una quota non misurata (value None)."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _station(monkeypatch):
|
||||
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||
|
||||
|
||||
def test_task_complete_renders_nan_for_null_value(logged_in_client, mock_api_client):
|
||||
mock_api_client.get.side_effect = [
|
||||
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
||||
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [
|
||||
{"id": 1, "marker_number": 1, "description": "quota mancante", "nominal": 1.0,
|
||||
"unit": "mm"},
|
||||
{"id": 2, "marker_number": 2, "description": "quota ok", "nominal": 2.0,
|
||||
"unit": "mm"}]}],
|
||||
None,
|
||||
{"items": [
|
||||
{"id": 1, "subtask_id": 1, "value": None, "deviation": None, "pass_fail": "fail",
|
||||
"measured_at": "2026-10-06T08:00:00+00:00"},
|
||||
{"id": 2, "subtask_id": 2, "value": 2.5, "pass_fail": "pass",
|
||||
"measured_at": "2026-10-06T08:00:00+00:00"},
|
||||
]},
|
||||
]
|
||||
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
assert "NaN" in html
|
||||
assert "2.500" in html # la quota misurata resta formattata
|
||||
@@ -0,0 +1,97 @@
|
||||
"""gitea:Adriano/TieMeasureFlow#118 #119 #120 #121 - pannello visione dell'operatore (task_execute.html).
|
||||
|
||||
Controlli sul testo del template: barra in alto con/senza camera, pulsante Salva,
|
||||
conteggio quote e «Completato» dopo «Registra», cella NaN, svg al posto del canvas.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
TEMPLATE = Path(__file__).resolve().parents[1] / "templates" / "measure" / "task_execute.html"
|
||||
SRC = TEMPLATE.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _panel():
|
||||
start = SRC.rindex("<div", 0, SRC.index('x-data="visionExecute()"'))
|
||||
return SRC[start:SRC.index("MEASUREMENT TIMER BANNER")]
|
||||
|
||||
|
||||
def _bar(panel):
|
||||
# barra in alto: dall'apertura fino al blocco di errore
|
||||
return panel[:panel.index('<div x-show="error"')]
|
||||
|
||||
|
||||
def test_file_input_only_without_camera():
|
||||
# #119: l'input file «Acquisisci immagine/Riprova» sta nel ramo `not camera_enabled`
|
||||
bar = _bar(_panel())
|
||||
no_cam = bar[bar.index("{% if not camera_enabled %}"):bar.index("{% else %}")]
|
||||
assert 'type="file"' in no_cam
|
||||
cam = bar[bar.index("{% else %}"):]
|
||||
assert 'type="file"' not in cam
|
||||
assert bar.count('type="file"') == 1
|
||||
|
||||
|
||||
def test_camera_bar_has_grab_and_record_only():
|
||||
# #119: con camera «Acquisisci da camera» (grab) e «Registra»; in_linea resta Pausa/Riprendi
|
||||
cam = _bar(_panel())
|
||||
cam = cam[cam.index("{% else %}"):]
|
||||
assert "{{ _('Acquisisci da camera') }}" in cam and '@click="grab()"' in cam
|
||||
assert '@click="record()"' in cam and "{{ _('Registra') }}" in cam
|
||||
assert "togglePause()" in cam and "{{ _('Pausa') }}" in cam and "{{ _('Riprendi') }}" in cam
|
||||
assert "onPick(" not in cam
|
||||
|
||||
|
||||
def test_save_button_is_tmf_viewer_with_texts():
|
||||
# #119 #122: «Salva» e' del componente <tmf-viewer> (barra: Riduci, Ingrandisci, Adatta, Salva, %); il template gli passa nome e testi
|
||||
panel = _panel()
|
||||
assert "<tmf-viewer" in panel and ':src="shownImage"' in panel and ':save-name="saveName"' in panel
|
||||
for t in ("Adatta", "Salva", "Ingrandisci", "Riduci", "Salva immagine"):
|
||||
assert "{{ _('%s') }}" % t in panel, t
|
||||
assert "saveImage()" not in panel and "zPct()" not in panel
|
||||
assert "taskName:" in SRC
|
||||
|
||||
|
||||
def test_viewer_scripts_order():
|
||||
# #122: image_viewer.js dopo vision_overlay.js e save_image.js (e prima di vision_execute.js non serve: e' un Custom Element)
|
||||
assert SRC.index("js/vision_overlay.js") < SRC.index("js/image_viewer.js")
|
||||
assert SRC.index("js/save_image.js") < SRC.index("js/image_viewer.js")
|
||||
|
||||
|
||||
def test_overlay_and_labels_passed_to_viewer():
|
||||
# #121 #122: overlay e etichette arrivano al viewer con x-effect (proprieta'), niente canvas
|
||||
panel = _panel()
|
||||
assert "<canvas" not in panel
|
||||
assert "$el.overlay = (result && result.overlay) || []" in panel and "$el.labels = viewerLabels" in panel
|
||||
assert "overlayLabels" not in panel
|
||||
|
||||
|
||||
def test_value_cell_shows_nan_for_null():
|
||||
# #118: la cella passa da fmtValue (null -> NaN), non da Number(r.value).toFixed
|
||||
panel = _panel()
|
||||
assert 'fmtValue(r.value, r.unit)' in panel
|
||||
assert "Number(r.value)" not in panel
|
||||
|
||||
|
||||
def test_can_leave_task_depends_on_vision_recorded():
|
||||
# #120: «Completato» e «Fine ciclo misura» richiedono visionRecorded per i task visione
|
||||
m = re.search(r"get canLeaveTask\(\) \{(.*?)\n \},", SRC, re.S)
|
||||
assert m and "isVisionTask && !this.visionRecorded" in m.group(1)
|
||||
assert m.group(1).index("visionRecorded") < m.group(1).index("cycleConfirmed")
|
||||
assert "visionRecorded: false" in SRC
|
||||
|
||||
|
||||
def test_missing_label_says_record_the_piece():
|
||||
# #120: non registrato -> «Registra il pezzo», non «Mancano N quote»
|
||||
m = re.search(r"get missingQuotesLabel\(\) \{(.*?)\n \},", SRC, re.S)
|
||||
assert m and "Registra il pezzo" in m.group(1)
|
||||
assert m.group(1).index("Registra il pezzo") < m.group(1).index("Mancano")
|
||||
|
||||
|
||||
def test_page_listens_to_vision_recorded_and_resets():
|
||||
# #120: la radice ascolta l'evento; resetForNewCycle azzera visionRecorded; il ripristino mette deviation null
|
||||
assert '@vision-recorded.window="onVisionRecorded($event.detail)"' in SRC
|
||||
m = re.search(r"resetForNewCycle\(\) \{(.*?)\n \},", SRC, re.S)
|
||||
assert m and "visionRecorded = false" in m.group(1)
|
||||
m = re.search(r"onVisionRecorded\(detail\) \{(.*?)\n \},", SRC, re.S)
|
||||
assert m and "visionRecorded = true" in m.group(1) and "this.measurements = " in m.group(1)
|
||||
assert "deviation: m.value == null ? null" in SRC
|
||||
assert "if (this.isVisionTask) this.visionRecorded = true" in SRC
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Runs the plain-node vision overlay draw/anchor/labels test (tests/js/test_vision_overlay.js).
|
||||
|
||||
Same thin pytest wrapper pattern as test_vision_rows_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#116.
|
||||
"""
|
||||
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_vision_overlay.js"
|
||||
|
||||
|
||||
def test_vision_overlay_js_behaviour():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node non installato: test JS dell'overlay 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_vision_overlay.js fallito (exit {result.returncode})\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""gitea:Adriano/TieMeasureFlow#96 - l'immagine del task visione sta in fit, senza scorrimento.
|
||||
"""gitea:Adriano/TieMeasureFlow#96 #122 - l'immagine del task visione sta in fit, senza scorrimento.
|
||||
|
||||
Controllo sulle classi del template: pannello overflow-hidden, img/canvas
|
||||
object-contain sovrapposti, nessun h-auto sull'immagine (era la causa dello scorrimento).
|
||||
Controllo sulle classi del template: pannello overflow-hidden, riquadro flex-1 min-h-0 che contiene
|
||||
<tmf-viewer> a tutta altezza (#122: zoom, img e overlay stanno dentro il componente).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
@@ -37,13 +37,22 @@ def test_panel_does_not_scroll():
|
||||
assert "overflow-auto" not in tag
|
||||
|
||||
|
||||
def test_image_is_object_contain_without_h_auto():
|
||||
def test_image_is_inside_tmf_viewer():
|
||||
# #122: img, svg overlay, barra zoom e pan sono di <tmf-viewer>: il template non ha piu' img/svg/viewport propri
|
||||
panel = _panel()
|
||||
img = _tag_with(panel, 'x-ref="img"')
|
||||
assert "object-contain" in img
|
||||
assert "h-auto" not in img
|
||||
canvas = _tag_with(panel, 'x-ref="overlay"')
|
||||
assert "object-contain" in canvas # stesso fit dell'img: overlay allineato
|
||||
viewer = _tag_with(panel, "tmf-viewer class")
|
||||
assert "block" in viewer and "w-full" in viewer and "h-full" in viewer
|
||||
assert ':src="shownImage"' in viewer
|
||||
assert "x-show" not in viewer # mai x-show e :style stringa sullo stesso elemento: x-show sta sul riquadro
|
||||
assert 'x-ref="img"' not in panel and 'x-ref="overlay"' not in panel and "<canvas" not in panel
|
||||
assert "<img" not in panel and "<svg" not in panel
|
||||
|
||||
|
||||
def test_no_own_zoom_viewport_in_template():
|
||||
# #122: niente piu' viewport/wrapper di zoom del pannello (#115): li fa il componente
|
||||
panel = _panel()
|
||||
assert 'x-ref="vp"' not in panel
|
||||
assert "zSize()" not in panel and "zoomBy(" not in panel and "zFit()" not in panel
|
||||
|
||||
|
||||
def test_image_box_and_outcome_column_can_shrink():
|
||||
@@ -52,3 +61,21 @@ def test_image_box_and_outcome_column_can_shrink():
|
||||
assert "flex-1" in box and "min-h-0" in box
|
||||
outcome = _tag_with(panel, 'x-show="result">')
|
||||
assert "overflow-y-auto" in outcome and "min-h-0" in outcome
|
||||
|
||||
|
||||
def test_quote_table_shows_nominal_and_tolerances():
|
||||
# #117: la tabella quote del pannello mostra nominale e tolleranze come la schermata dei calibri
|
||||
panel = _panel()
|
||||
for marker in ("fmtTol(r.nominal)", "fmtTol(r.ltl)", "fmtTol(r.utl)", "fmtTol(r.lwl)", "fmtTol(r.uwl)"):
|
||||
assert marker in panel, marker
|
||||
assert "text-measure-fail" in panel and "text-measure-warning" in panel
|
||||
|
||||
|
||||
def test_overlay_script_loaded_before_vision_execute_and_viewer():
|
||||
# #116 #122: vision_overlay.js prima di vision_execute.js; le etichette le disegna <tmf-viewer> (viewerLabels via x-effect)
|
||||
src = TEMPLATE.read_text(encoding="utf-8")
|
||||
assert src.index("js/vision_overlay.js") < src.index("js/vision_execute.js")
|
||||
assert src.index("js/vision_overlay.js") < src.index("js/image_viewer.js")
|
||||
panel = _panel()
|
||||
assert "$el.labels = viewerLabels" in panel
|
||||
assert 'x-for="l in overlayLabels"' not in panel
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Runs the plain-node vision panel quote rows label test (tests/js/test_vision_rows.js).
|
||||
|
||||
Same thin pytest wrapper pattern as test_dxf_zoom_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#114.
|
||||
"""
|
||||
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_vision_rows.js"
|
||||
|
||||
|
||||
def test_vision_rows_js_behaviour():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node non installato: test JS delle etichette quote 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_vision_rows.js fallito (exit {result.returncode})\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Runs the plain-node vision panel zoom test (tests/js/test_vision_zoom.js).
|
||||
|
||||
Same thin pytest wrapper pattern as test_vision_rows_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#115 #122 (dal #122 il test JS verifica che lo zoom non sia piu' nel componente).
|
||||
"""
|
||||
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_vision_zoom.js"
|
||||
|
||||
|
||||
def test_vision_zoom_js_behaviour():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node non installato: test JS dello zoom del pannello 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_vision_zoom.js fallito (exit {result.returncode})\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Controllo del template del wizard DXF: le tre viste sono <tmf-viewer> (#122, prima #113 #119 #121).
|
||||
|
||||
Legge task_editor.html come testo (come test_vision_panel_fit.py), senza renderlo.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
TEMPLATE = Path(__file__).resolve().parent.parent / "templates" / "maker" / "task_editor.html"
|
||||
|
||||
|
||||
def _src() -> str:
|
||||
# senza commenti Jinja e HTML, che citano <tmf-viewer> e i vecchi nomi
|
||||
text = TEMPLATE.read_text(encoding="utf-8")
|
||||
text = re.sub(r"\{#.*?#\}", "", text, flags=re.S)
|
||||
return re.sub(r"<!--.*?-->", "", text, flags=re.S)
|
||||
|
||||
|
||||
def _tag_of(src: str, marker: str) -> str:
|
||||
"""Tag di apertura <tmf-viewer ...> che contiene il marker."""
|
||||
i = src.index(marker)
|
||||
start = src.rfind("<tmf-viewer", 0, i)
|
||||
return src[start:src.index(">", i) + 1]
|
||||
|
||||
|
||||
def test_three_viewers_image_dxf_prova():
|
||||
# #122: tre <tmf-viewer>: immagine (src), DXF (modo contenuto, con <svg>), Prova (fit-height)
|
||||
src = _src()
|
||||
assert src.count("<tmf-viewer") == 3
|
||||
img = _tag_of(src, 'save-name="immagine"')
|
||||
assert ':src="imagePreviewUrl"' in img
|
||||
dxf = _tag_of(src, 'save-name="dxf"')
|
||||
assert ":src" not in dxf and ":width=" in dxf and ":height=" in dxf
|
||||
prova = _tag_of(src, 'save-name="prova"')
|
||||
assert "fit-height" in prova and ':src="imageSrc"' in prova
|
||||
assert "result.result.overlay" in prova # overlay delle forme del risultato
|
||||
# il DXF contiene lo <svg> da disegnare, dentro il componente
|
||||
i = src.index('save-name="dxf"')
|
||||
assert src.index("<svg", i) < src.index("</tmf-viewer>", i)
|
||||
assert "dxfPreviewD()" in src[i:src.index("</tmf-viewer>", i)]
|
||||
|
||||
|
||||
def test_old_viewports_gone():
|
||||
# #122: niente piu' viewport, barre e overlay scritti a mano
|
||||
src = _src()
|
||||
for old in ('x-ref="vpImg"', 'x-ref="vpDxf"', 'x-ref="overlay"', 'x-ref="viewport"', "tmfSaveImage(",
|
||||
"vZoomBy", "vFit(", "zoomPct", "vPct("):
|
||||
assert old not in src, old
|
||||
|
||||
|
||||
def test_no_style_string_on_viewers():
|
||||
# #122: un :style a stringa sul <tmf-viewer> cancella lo style statico; x-show non sta sul componente
|
||||
src = _src()
|
||||
for m in re.finditer(r"<tmf-viewer[^>]*>", src):
|
||||
tag = m.group(0)
|
||||
assert ":style" not in tag and "x-show" not in tag, tag
|
||||
|
||||
|
||||
def test_scripts_loaded_before_wizard():
|
||||
# #119 #121 #122: vision_overlay.js e save_image.js prima di image_viewer.js, che sta prima di dxf_recipe_wizard.js
|
||||
src = _src()
|
||||
viewer = src.index("js/image_viewer.js")
|
||||
assert src.index("js/vision_overlay.js") < viewer
|
||||
assert src.index("js/save_image.js") < viewer
|
||||
assert viewer < src.index("js/dxf_recipe_wizard.js")
|
||||
@@ -3485,3 +3485,106 @@ msgstr "Invalid camera address"
|
||||
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||
msgid "Risposta non valida dal servizio camera"
|
||||
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"
|
||||
|
||||
#: templates/maker/task_editor.html (#102)
|
||||
msgid "campo"
|
||||
msgstr "field"
|
||||
|
||||
#: templates/maker/task_editor.html (#102)
|
||||
msgid "Anteprima non disponibile per questo formato"
|
||||
msgstr "Preview not available for this format"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Riduci"
|
||||
msgstr "Zoom out"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Ingrandisci"
|
||||
msgstr "Zoom in"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Adatta"
|
||||
msgstr "Fit"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Scala"
|
||||
msgstr "Scale"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Punti in banda"
|
||||
msgstr "Points in band"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Distanza media"
|
||||
msgstr "Average distance"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Distanza minima"
|
||||
msgstr "Minimum distance"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Distanza massima"
|
||||
msgstr "Maximum distance"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Conforme"
|
||||
msgstr "Pass"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Con avvisi"
|
||||
msgstr "Warning"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Non conforme"
|
||||
msgstr "Fail"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "mm/px a mano"
|
||||
msgstr "manual mm/px"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "calibrazione stazione"
|
||||
msgstr "station calibration"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Modifica ricetta da DXF"
|
||||
msgstr "Edit recipe from DXF"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala"
|
||||
msgstr "Reference image not saved (recipe created earlier): capture or upload it"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Impossibile leggere i file della ricetta esistente"
|
||||
msgstr "Cannot read the files of the existing recipe"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla"
|
||||
msgstr "Existing recipe loaded: Test, then Save recipe to replace it"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "File non trovato"
|
||||
msgstr "File not found"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
#, python-format
|
||||
msgid "Servizio non raggiungibile: %(detail)s"
|
||||
msgstr "Service not reachable: %(detail)s"
|
||||
|
||||
#: templates/maker/task_editor.html templates/maker/recipe_editor.html (#107)
|
||||
msgid "Formato immagine non leggibile. Usa PNG, JPG o PDF."
|
||||
msgstr "Unreadable image format. Use PNG, JPG or PDF."
|
||||
|
||||
@@ -3473,3 +3473,102 @@ 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à"
|
||||
|
||||
#: templates/maker/task_editor.html (#102)
|
||||
msgid "campo"
|
||||
msgstr "campo"
|
||||
|
||||
#: templates/maker/task_editor.html (#102)
|
||||
msgid "Anteprima non disponibile per questo formato"
|
||||
msgstr "Anteprima non disponibile per questo formato"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Riduci"
|
||||
msgstr "Riduci"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Ingrandisci"
|
||||
msgstr "Ingrandisci"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Adatta"
|
||||
msgstr "Adatta"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Scala"
|
||||
msgstr "Scala"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Punti in banda"
|
||||
msgstr "Punti in banda"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Distanza media"
|
||||
msgstr "Distanza media"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Distanza minima"
|
||||
msgstr "Distanza minima"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Distanza massima"
|
||||
msgstr "Distanza massima"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Conforme"
|
||||
msgstr "Conforme"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Con avvisi"
|
||||
msgstr "Con avvisi"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Non conforme"
|
||||
msgstr "Non conforme"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "mm/px a mano"
|
||||
msgstr "mm/px a mano"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "calibrazione stazione"
|
||||
msgstr "calibrazione stazione"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Modifica ricetta da DXF"
|
||||
msgstr "Modifica ricetta da DXF"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala"
|
||||
msgstr "Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Impossibile leggere i file della ricetta esistente"
|
||||
msgstr "Impossibile leggere i file della ricetta esistente"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla"
|
||||
msgstr "Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "File non trovato"
|
||||
msgstr "File non trovato"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
#, python-format
|
||||
msgid "Servizio non raggiungibile: %(detail)s"
|
||||
msgstr "Servizio non raggiungibile: %(detail)s"
|
||||
|
||||
#: templates/maker/task_editor.html templates/maker/recipe_editor.html (#107)
|
||||
msgid "Formato immagine non leggibile. Usa PNG, JPG o PDF."
|
||||
msgstr "Formato immagine non leggibile. Usa PNG, JPG o PDF."
|
||||
|
||||
@@ -184,6 +184,66 @@ def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float,
|
||||
return anchors
|
||||
|
||||
|
||||
def chiudi_contorni(dxf_bytes: bytes) -> tuple[bytes, dict]:
|
||||
"""#103: unisce le entita' aperte (LINE/ARC/polilinee aperte in fila) in contorni chiusi.
|
||||
|
||||
CompareDxf taglia il contorno misurato agli estremi di ogni entita' aperta
|
||||
del disegno (cut_open_ends): con un profilo spezzato in LINE+ARC e banda
|
||||
larga non resta nessun punto in banda e il confronto fallisce. Qui le catene
|
||||
chiuse diventano una LWPOLYLINE chiusa (con bulge per gli archi).
|
||||
|
||||
Pura e senza eccezioni nuove: se non c'e' nulla da unire, o ezdxf non legge
|
||||
il file, ritorna i byte originali (sha invariato, ricette esistenti stabili).
|
||||
Ritorna (bytes, {"anelli", "entita_unite", "entita_libere"}).
|
||||
"""
|
||||
nulla = {"anelli": 0, "entita_unite": 0, "entita_libere": 0}
|
||||
try:
|
||||
import ezdxf
|
||||
from ezdxf import edgeminer as em, edgesmith as es
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = Path(tmp) / "in.dxf"
|
||||
src.write_bytes(dxf_bytes)
|
||||
doc = ezdxf.readfile(str(src))
|
||||
msp = doc.modelspace()
|
||||
# edges_from_entities_2d salta le entita' gia' chiuse (restano dove sono)
|
||||
edges = list(es.edges_from_entities_2d(msp))
|
||||
if not edges:
|
||||
return dxf_bytes, nulla
|
||||
loops = em.find_all_loops(em.Deposit(edges, gap_tol=0.01)) # 0.01 mm: giunzioni CAD
|
||||
usati: set = set()
|
||||
unite: list = [] # (polilinea, entita' di partenza)
|
||||
# loop piu' lungo per primo, ogni edge usato una sola volta
|
||||
for loop in sorted(loops, key=lambda lp: -sum(e.length for e in lp)):
|
||||
ids = {e.id for e in loop}
|
||||
if ids & usati:
|
||||
continue
|
||||
usati |= ids
|
||||
unite.append((loop, es.lwpolyline_from_chain(loop)))
|
||||
if not unite:
|
||||
return dxf_bytes, {**nulla, "entita_libere": len(edges)}
|
||||
entita_unite = 0
|
||||
for loop, pl in unite:
|
||||
pl.closed = True
|
||||
payload = getattr(loop[0], "payload", None)
|
||||
layer = getattr(getattr(payload, "dxf", None), "layer", None)
|
||||
if layer:
|
||||
pl.dxf.layer = layer # stesso layer dell'entita' di partenza
|
||||
for e in loop:
|
||||
ent = getattr(e, "payload", None)
|
||||
if ent is not None and ent.is_alive:
|
||||
msp.delete_entity(ent) # sostituita dalla polilinea chiusa
|
||||
msp.add_foreign_entity(pl)
|
||||
entita_unite += len(loop)
|
||||
dst = Path(tmp) / "out.dxf"
|
||||
doc.saveas(str(dst))
|
||||
info = {"anelli": len(unite), "entita_unite": entita_unite,
|
||||
"entita_libere": len(edges) - entita_unite}
|
||||
return dst.read_bytes(), info
|
||||
except Exception: # noqa: BLE001 - DXF illeggibile: l'errore resta quello a valle
|
||||
return dxf_bytes, nulla
|
||||
|
||||
|
||||
def _run(gray, graph, px_per_mm, folder) -> RunOutcome:
|
||||
return run_graph(gray, graph, calibration={"px_per_mm": px_per_mm}, recipe_dir=folder)
|
||||
|
||||
@@ -211,6 +271,9 @@ def build_dxf_compare(
|
||||
raise CostruttoreError(f"polarity deve essere una di {list(_POLARITIES)}")
|
||||
if not dxf_bytes:
|
||||
raise CostruttoreError("DXF vuoto")
|
||||
# #103: entita' aperte unite in contorni chiusi PRIMA dello sha: disegno.dxf,
|
||||
# grafo e runtime usano tutti il DXF chiuso
|
||||
dxf_bytes, _info_chiusura = chiudi_contorni(dxf_bytes)
|
||||
|
||||
frame = _gray_u8(gray)
|
||||
calibration_bytes = json.dumps({"px_per_mm": px_per_mm}).encode("utf-8")
|
||||
|
||||
+18
-1
@@ -217,6 +217,14 @@ def _is_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
# #112: quantity/unit delle uscite che il tool non dichiara in value_quantities
|
||||
_ETICHETTE_EXTRA = {
|
||||
"count": ("count", "pz"),
|
||||
"n_points": ("count", "punti"),
|
||||
"area": ("area", "px²"),
|
||||
}
|
||||
|
||||
|
||||
def validate_graph(
|
||||
graph: dict, recipe_dir: Any = None
|
||||
) -> tuple[bool, list[str], list[dict]]:
|
||||
@@ -245,7 +253,11 @@ def validate_graph(
|
||||
for prop in tool.outputs():
|
||||
if not (_is_number(prop.default) or prop.name in values):
|
||||
continue
|
||||
quantity = value_quantities.get(prop.name, getattr(tool, "quantity", None))
|
||||
if prop.name in values:
|
||||
quantity = value_quantities.get(prop.name, getattr(tool, "quantity", None))
|
||||
else:
|
||||
# #112: fuori da values mai ereditare tool.quantity (count/area non sono lunghezze)
|
||||
quantity = value_quantities.get(prop.name)
|
||||
if not isinstance(quantity, str):
|
||||
quantity = None
|
||||
unit = None
|
||||
@@ -254,6 +266,11 @@ def validate_graph(
|
||||
unit = nominal_unit if isinstance(nominal_unit, str) else None
|
||||
elif quantity == "angle":
|
||||
unit = "deg"
|
||||
elif quantity == "ratio":
|
||||
unit = "%" # #112: rapporti (points_in_pct) in percentuale
|
||||
if quantity is None and prop.name not in values:
|
||||
# #112: etichette delle uscite note non dichiarate dal tool
|
||||
quantity, unit = _ETICHETTE_EXTRA.get(prop.name, (None, None))
|
||||
outputs.append({
|
||||
"key": f"{tool.id}.{prop.name}",
|
||||
"quantity": quantity,
|
||||
|
||||
@@ -288,3 +288,28 @@ def test_validate_reports_a_missing_file(tmp_path):
|
||||
ok, errors, outputs = runner.validate_graph(_dxf_recipe(), tmp_path)
|
||||
|
||||
assert not ok and errors and outputs == []
|
||||
|
||||
|
||||
def test_validate_labels_wizard_outputs(tmp_path):
|
||||
"""#112: count/n_points/area non ereditano la quantita' del tool, ratio in %."""
|
||||
from src.vision import costruttori
|
||||
|
||||
dxf_bytes = _SAMPLE_DXF.read_bytes()
|
||||
cal_bytes = b'{"px_per_mm": 4.0}'
|
||||
(tmp_path / costruttori.DXF_FILE).write_bytes(dxf_bytes)
|
||||
(tmp_path / costruttori.CALIBRATION_FILE).write_bytes(cal_bytes)
|
||||
graph = costruttori._graph(
|
||||
hashlib.sha256(dxf_bytes).hexdigest(), hashlib.sha256(cal_bytes).hexdigest(),
|
||||
1.0, 95.0, "dark", {"row": 0.0, "col": 0.0, "angle_deg": 0.0},
|
||||
(0.0, 0.0, 0.0),
|
||||
)
|
||||
|
||||
ok, errors, outputs = runner.validate_graph(graph, tmp_path)
|
||||
|
||||
assert ok, errors
|
||||
by_key = {o["key"]: (o["quantity"], o["unit"]) for o in outputs}
|
||||
assert by_key["pezzo.count"] == ("count", "pz")
|
||||
assert by_key["pezzo.area"] == ("area", "px²")
|
||||
assert by_key["dxf.n_points"] == ("count", "punti")
|
||||
assert by_key["dxf.points_in_pct"] == ("ratio", "%")
|
||||
assert by_key["dxf.distance_max"] == ("length", "mm")
|
||||
|
||||
@@ -11,13 +11,14 @@ import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile # #101
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from src.vision.costruttori import CostruttoreError, build_dxf_compare # #83
|
||||
from src.vision.costruttori import CostruttoreError, build_dxf_compare, chiudi_contorni # #83 #103
|
||||
from src.vision.runner import (
|
||||
GraphBuildError,
|
||||
RunOutcome,
|
||||
@@ -288,3 +289,91 @@ 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
|
||||
# #103: l'anteprima mostra cio' che il confronto usera' (contorni chiusi)
|
||||
dxf_bytes, _ = chiudi_contorni(dxf_bytes)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""#103: chiudi_contorni unisce LINE/ARC aperti in contorni chiusi (test sincroni, no pytest-asyncio)."""
|
||||
import hashlib
|
||||
import io
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import ezdxf
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from src.vision.costruttori import CostruttoreError, build_dxf_compare, chiudi_contorni
|
||||
from src.vision_worker.main import _dxf_preview
|
||||
|
||||
|
||||
def _dxf(riempi) -> bytes:
|
||||
doc = ezdxf.new("R2010")
|
||||
riempi(doc.modelspace())
|
||||
buf = io.StringIO()
|
||||
doc.write(buf)
|
||||
return buf.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def _quattro_line(msp, w=20.0, h=10.0):
|
||||
pts = [(0, 0), (w, 0), (w, h), (0, h)]
|
||||
for i in range(4):
|
||||
msp.add_line(pts[i], pts[(i + 1) % 4])
|
||||
|
||||
|
||||
def _entita(dxf: bytes):
|
||||
with tempfile.TemporaryDirectory() as tmp: # readfile gestisce da solo codifica e CRLF
|
||||
path = Path(tmp) / "o.dxf"
|
||||
path.write_bytes(dxf)
|
||||
return list(ezdxf.readfile(str(path)).modelspace())
|
||||
|
||||
|
||||
def test_chiudi_rettangolo_di_4_line_in_una_polilinea_chiusa():
|
||||
out, info = chiudi_contorni(_dxf(_quattro_line))
|
||||
assert info == {"anelli": 1, "entita_unite": 4, "entita_libere": 0}
|
||||
ents = _entita(out)
|
||||
assert [e.dxftype() for e in ents] == ["LWPOLYLINE"] and ents[0].closed
|
||||
xs = [p[0] for p in ents[0].get_points()]
|
||||
ys = [p[1] for p in ents[0].get_points()]
|
||||
assert (min(xs), max(xs), min(ys), max(ys)) == (0, 20, 0, 10) # bbox invariato
|
||||
|
||||
|
||||
def test_chiudi_asola_line_e_arc_ha_bulge():
|
||||
def asola(msp): # asola 20x10: due LINE orizzontali + due semicerchi r=5
|
||||
msp.add_line((5, 0), (15, 0))
|
||||
msp.add_arc((15, 5), 5, -90, 90)
|
||||
msp.add_line((15, 10), (5, 10))
|
||||
msp.add_arc((5, 5), 5, 90, 270)
|
||||
out, info = chiudi_contorni(_dxf(asola))
|
||||
assert info["anelli"] == 1 and info["entita_unite"] == 4
|
||||
ents = _entita(out)
|
||||
assert len(ents) == 1 and ents[0].closed
|
||||
assert any(abs(p[4]) > 1e-6 for p in ents[0].get_points()) # gli archi diventano bulge
|
||||
|
||||
|
||||
def test_dxf_gia_chiuso_resta_identico():
|
||||
def chiuso(msp):
|
||||
msp.add_lwpolyline([(0, 0), (20, 0), (20, 10), (0, 10)], close=True)
|
||||
msp.add_circle((10, 5), 2)
|
||||
dxf = _dxf(chiuso)
|
||||
out, info = chiudi_contorni(dxf)
|
||||
assert out == dxf and info["anelli"] == 0
|
||||
|
||||
|
||||
def test_line_isolata_resta_e_byte_identici():
|
||||
dxf = _dxf(lambda msp: msp.add_line((0, 0), (10, 0)))
|
||||
out, info = chiudi_contorni(dxf)
|
||||
assert out == dxf and info["anelli"] == 0 and info["entita_libere"] == 1
|
||||
|
||||
|
||||
def test_byte_non_dxf_ritornati_identici():
|
||||
brutto = b"questo non e' un dxf"
|
||||
out, info = chiudi_contorni(brutto)
|
||||
assert out == brutto and info["anelli"] == 0
|
||||
|
||||
|
||||
def test_preview_di_quattro_line_e_una_sola_entita():
|
||||
resp = _dxf_preview(_dxf(_quattro_line))
|
||||
assert resp["entita"] == 1 and resp["larghezza_mm"] == 20 and resp["altezza_mm"] == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("band", [0.1, 0.3])
|
||||
def test_chiudi_build_dxf_compare_banda_larga_con_4_line(band):
|
||||
# rettangolo scuro 20x10 mm su bianco, 20 px/mm; disegno = 4 LINE aperte
|
||||
ppm = 20.0
|
||||
img = np.full((int(40 * ppm), int(60 * ppm)), 255, np.uint8)
|
||||
cx, cy = img.shape[1] // 2, img.shape[0] // 2
|
||||
cv2.rectangle(img, (cx - int(10 * ppm), cy - int(5 * ppm)),
|
||||
(cx + int(10 * ppm), cy + int(5 * ppm)), 0, -1)
|
||||
dxf = _dxf(_quattro_line)
|
||||
ris = build_dxf_compare(img, dxf, px_per_mm=ppm, band_mm=band)
|
||||
assert ris["outputs"]["dxf.points_in_pct"] > 90.0
|
||||
# il disegno nella ricetta e' quello chiuso, non l'originale
|
||||
import base64
|
||||
salvato = base64.b64decode(ris["files"]["disegno.dxf"])
|
||||
assert salvato != dxf and hashlib.sha256(salvato).hexdigest() == \
|
||||
ris["graph"]["meta"]["dxf"]["disegno"]["sha256"]
|
||||
@@ -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