Compare commits

...

105 Commits

Author SHA1 Message Date
Adriano 1dd4b0a20c vendor/visionsuite a TieVision-Core 9eb6f4c (solo vs-demo-api, #370-#372); pin in CLAUDE.md, refs #127
Test visione: 63 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrwGAJvRpprTEtGzx1
2026-11-06 14:23:01 +01:00
Adriano 271ce73d09 costruttori: CompareDxf con pose_search=quarter, il pezzo capovolto di 180 gradi torna in banda, refs #127
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrwGAJvRpprTEtGzx1
2026-11-06 14:14:05 +01:00
Adriano 1bd6c6b011 test: pezzo a L (sample.dxf e il rettangolo con foro non distinguono il capovolgimento), refs #127
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrwGAJvRpprTEtGzx1
2026-11-06 14:13:46 +01:00
Adriano d8bca535ac test: ricetta DXF su pezzo asimmetrico capovolto di 180 gradi e pose_search nel grafo (rossi prima di U2), refs #127
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrwGAJvRpprTEtGzx1
2026-11-06 14:11:02 +01:00
Adriano 14257304f8 vendor/visionsuite a TieVision-Core ffb93b4 (pose_search, #343); pin in CLAUDE.md e uv.lock (zxing-cpp), refs #127
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrwGAJvRpprTEtGzx1
2026-11-06 14:09:59 +01:00
Adriano 5738920c9c sviluppo.ps1: git solo con submodule inizializzato; comando di test corretto (#109)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 12:07:23 +01:00
Adriano 452862e0f3 sviluppo.ps1: ripristinati i backslash dei percorsi (#109)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 11:55:50 +01:00
Adriano 999f47092d sviluppo.ps1 scrive vision-engine-version.txt, gitignore (#109)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 11:54:59 +01:00
Adriano 6441a92062 Archivio sorgente con submodule e vision-engine-version.txt (#109)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 11:54:15 +01:00
Adriano 8c361c14ae Visore <tmf-viewer> di TieVision-Station con no-layers su ogni immagine di TMF (#126)
Rotella senza Ctrl = zoom sul punto del mouse su tutte le immagini. image_viewer.js e
vision_overlay.js copiati byte per byte da TieVision-Station (lavoro/daitarn-nolayers b1af911);
attributo no-layers su ogni <tmf-viewer> (task_editor 3, task_execute 1, calibrazione_pagina 4).
Test JS e pagina demo allineati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-29 13:49:39 +01:00
Adriano 5897a9e5df Wizard: chiusura in più nella Prova tolta, percentuale del DXF rispetto ad Adatta (Refs #122)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 22:15:55 +01:00
Adriano f44b578d5b Unisce lavoro/daitarn-tmf-rev118: correzioni della revisione Fable (Refs #118 #123 #124 #125)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 22:07:44 +01:00
Adriano c5be9a2bb4 tmf-viewer: host [hidden] nascosto, wizard usa viewer-error al posto della sonda, test_dxf_preview senza zoom del wizard (Refs #122)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 22:07:36 +01:00
Adriano 17ea010f7e Test: quota NaN chiede il capoturno, migrazione 022 conserva le righe
Refs #125

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:07:18 +01:00
Adriano 1d6d74b2e4 Unisce lavoro/daitarn-tmf-v-camera: visore standard <tmf-viewer> (Refs #122) 2026-10-28 22:06:44 +01:00
Adriano 2f0a5aaf1e Deviazione NaN nelle esportazioni CSV per le quote non misurate
Refs #124

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:06:43 +01:00
Adriano e96fbb73c2 Unisce lavoro/daitarn-tmf-v-wizard: visore standard <tmf-viewer> (Refs #122) 2026-10-28 22:06:43 +01:00
Adriano f3bacc8238 Unisce lavoro/daitarn-tmf-v-operatore: visore standard <tmf-viewer> (Refs #122) 2026-10-28 22:06:43 +01:00
Adriano e743363a62 Unisce lavoro/daitarn-tmf-visore: visore standard <tmf-viewer> (Refs #122) 2026-10-28 22:06:43 +01:00
Adriano d48a8a1076 <tmf-viewer>: clamp fit*8, viewer-error, etichette autonome, pagina di prova
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:05:54 +01:00
Adriano 8ef61a1285 Registra spento sul frame gia' registrato, nuovo grab dopo il reset
Refs #123

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:04:20 +01:00
Adriano 84fc1a08f7 Pagina camera 8765: i quattro visori diventano tmf-viewer, JS serviti su /visore/
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:03:58 +01:00
Adriano c904800e94 Etichetta NaN sull'immagine per le quote non misurate
Refs #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:02:26 +01:00
Adriano 20c0a47c00 <tmf-viewer>: visualizzatore immagine standard, tmfSaveBlob e tmfSvgToPngBlob
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:59:01 +01:00
Adriano 269bbc03ca Wizard DXF: anteprima immagine, anteprima DXF e Prova usano <tmf-viewer>
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:58:27 +01:00
Adriano 815d49d699 Pannello visione operatore: zoom, overlay e Salva passano a <tmf-viewer>
Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:58:18 +01:00
Adriano 43ef22079d Unione lavoro/daitarn-tmf-nan (Refs #118)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:41:43 +01:00
Adriano b01bba536a Test check: la quota non prodotta entra come NaN/fail
Refs #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:41:22 +01:00
Adriano 17344aae13 Monitor: quota con soli NaN resta in elenco con media null e fuori tolleranza
Refs #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:30:11 +01:00
Adriano fd3384f08c Monitor e quota registrata mostrano NaN invece di andare in errore (Refs #118)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:28:53 +01:00
Adriano 08aefa9888 Salva apre il «Salva con nome» prima di scaricare; riepilogo senza deviazione 0 per quota NaN (Refs #119 #118)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:23:55 +01:00
Adriano 06f308be6f Visione: quota non prodotta salvata come NaN (value NULL, fail) invece del 422; migrazione 022
Refs #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:21:54 +01:00
Adriano 93afd6187c Unione lavoro/daitarn-tmf-wizard (Refs #118 #119 #120 #121)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:21:33 +01:00
Adriano 8217f46d47 Unione lavoro/daitarn-tmf-operatore (Refs #118 #119 #120 #121)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:21:33 +01:00
Adriano d4802fd1a3 Unione lavoro/daitarn-tmf-svg (Refs #118 #119 #120 #121)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 21:21:33 +01:00
Adriano ea64f3c524 Wizard DXF e riepilogo: overlay SVG, Salva immagine, NaN per quota non misurata
Refs #118 #119 #121

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:20:26 +01:00
Adriano 58baed19e2 Pannello visione operatore: barra senza input file con camera, Salva, Registra conta le quote, svg al posto del canvas, NaN
Refs #118 #119 #120 #121

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:20:21 +01:00
Adriano 25b0741003 Test del helper di salvataggio immagine e fix pulizia backslash nel nome
Refs #119

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:18:20 +01:00
Adriano df67a32a82 Test dell'overlay SVG e delle etichette senza valore
Refs #121 #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:17:29 +01:00
Adriano 5723f055c4 Overlay SVG e helper salva immagine per il pannello visione
Refs #121 #119 #118

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:16:11 +01:00
Adriano 4485803122 Unione vision_overlay.js nel pannello visione (Refs #116)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 17:01:28 +01:00
Adriano acd94a9d33 Pannello visione: integrazione overlay ed etichette quota (rows, drawOverlay, overlayLabels)
Refs #116

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 17:00:48 +01:00
Adriano 88b3f202b1 Pannello visione: nominale e tolleranze nella tabella quote
Refs #117

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:57:27 +01:00
Adriano 4a11d2595f Pannello visione: zoom, Adatta e trascinamento sull'immagine
Refs #115

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:54:13 +01:00
Adriano b46ccba1ee Pannello visione: modulo vision_overlay.js (forme overlay, ancore, etichette) con test node e fixture task 14
Refs #116

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:52:50 +01:00
Adriano 7a1df0bca4 Pannello visione: la tabella quote mostra description invece di #id
Refs #114

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 16:33:09 +01:00
Adriano 198c6c63c4 Wizard DXF: Adatta misura l'area senza barre di scorrimento (offset meno bordi). Refs #113
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-28 12:44:26 +01:00
Adriano 04b81ad2f2 Wizard DXF: correzioni revisione zoom anteprime (style statico, x-show, altezza utile). Refs #113
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:39:06 +01:00
Adriano 45c9e6c3f3 Wizard DXF: zoom, Adatta e pan sulle anteprime immagine e DXF. Refs #113
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:35:10 +01:00
Adriano ca6cd0cab1 Etichette uscite visione: count/n_points/area non ereditano la quantità del tool, ratio in %. Refs #112
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:17:13 +01:00
Adriano 34c251aa90 Test blocco: ciclo con latoAnteprima() al posto di 1280. Refs #111
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:59:25 +01:00
Adriano c70d29d6d7 Acquisizione: visore zoom, adatta() rinominata adattaVisore (collideva con quella dei parametri camera)
Aggiunto test che vieta funzioni dichiarate due volte nello script della pagina.

Refs #111

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:55:09 +01:00
Adriano 96aca975d0 Merge branch 'lavoro/daitarn-tmf-flip' into lavoro/daitarn-tmf-zoom 2026-10-28 10:53:00 +01:00
Adriano b3c56ef5b5 Acquisizione: zoom +/-, Adatta e pan sui tre riquadri immagine, lato anteprima che segue lo zoom
Barra in sovrimpressione fuori dal palco che scorre, Ctrl+rotella, pan col puntatore.

Refs #111

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:52:30 +01:00
Adriano 9111eb2741 Orientamento: se il salvataggio fallisce si ripristina lo stato di prima in memoria. Refs #110
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:49:46 +01:00
Adriano b46a70347f Merge branch 'lavoro/daitarn-tmf-flip-pagina' into lavoro/daitarn-tmf-flip-unione 2026-10-28 10:43:39 +01:00
Adriano 155abfd15c Acquisizione: caselle di specchiatura orizzontale e verticale per sorgente
Refs #110

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:39:49 +01:00
Adriano 2c2d601495 Specchiatura orizzontale/verticale per sorgente nel servizio camera, salvata su disco. Refs #110
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:38:49 +01:00
Adriano b2e3867370 Test scalda camere. Refs #108
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:16:40 +01:00
Adriano cab419bdc7 Avvio: scalda le camere configurate con un grab a vuoto. Refs #108
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:16:31 +01:00
Adriano 249999fa1e Test pagina e JS per conversione BMP->PNG. Refs #107
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:10:21 +01:00
Adriano a3e032d000 Disegno Tecnico: BMP convertiti in PNG nel browser, errore upload nel riquadro. Refs #107
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:07:52 +01:00
Adriano 57376556d2 camera: commento admin preciso, test timeout grab maker (Refs #106)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 17:57:43 +01:00
Adriano 7cb877b11b Timeout grab camera 60 s e sorgenti admin 15 s (prima apertura GenTL lenta). Refs #106
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 17:54:56 +01:00
Adriano 08616aa1fa maker: pan dell'immagine di prova non si interrompe col mouse (Refs #104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:46:57 +01:00
Adriano 02f1481fe5 maker: zoom in sovrimpressione, pan col puntatore e viewport che segue l'immagine (Refs #104)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:42:42 +01:00
Adriano 34e1b042f5 maker: risultati della Prova a destra dell'immagine da 640px (Refs #104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:36:44 +01:00
Adriano bdef1f1cd8 maker: anteprima DXF sempre a destra, avviso immagine tolto dopo il salvataggio (Refs #104 #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:35:11 +01:00
Adriano 39b9ad77eb vision: condizione della quota su due righe leggibili (Refs #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:31:16 +01:00
Adriano 12632323e8 merge #105 105b: wizard Modifica ricetta da DXF (Refs #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:31:10 +01:00
Adriano 839a61e479 merge #105 105a: ricetta DXF riapribile, backend (Refs #105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:31:10 +01:00
Adriano 9b8e729927 maker: Modifica ricetta da DXF, il wizard si riapre compilato (Refs #105)
Due proxy Flask (/source e /files, byte) e wizard che all'init legge la ricetta
esistente: etichetta del bottone dinamica, apertura con banda, soglia, polarita',
mm/px, DXF e immagine di riferimento; risposte tardive non sovrascrivono le scelte manuali.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:27:20 +01:00
Adriano 6080e6aebc vision: ricetta DXF riapribile, immagine salvata, rotte source e files, soglia segue la quota (Refs #105)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:25:37 +01:00
Adriano d5db5f3014 merge #102 #104: anteprima immagine, disposizione del wizard DXF, prova su due colonne con zoom (Refs #102 #104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:21:43 +01:00
Adriano 1a19bb6679 maker: wizard DXF con anteprime sotto le scelte, mm/px solo da file, risultato su due colonne con zoom e Fit (Refs #104)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:21:19 +01:00
Adriano 20f33234a8 maker: anteprima immagine nel wizard Crea ricetta da DXF (Refs #102)
Anteprima dell'immagine scelta/acquisita accanto a quella del DXF, con
pixel e campo in mm (scala a mano o calibrazione stazione); fallback se
il formato non e' mostrabile. Test JS e di pagina, voci it/en.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:14:10 +01:00
Adriano 711abc96df merge #103: DXF a entità aperte unito in contorni chiusi (Refs #103)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 16:11:53 +01:00
Adriano 77419f9ebd vision: il DXF di confronto unisce le entita' aperte in contorni chiusi (Refs #103)
chiudi_contorni unisce LINE/ARC aperti in LWPOLYLINE chiuse prima di
costruire la ricetta e per l'anteprima: con banda larga cut_open_ends
non lasciava piu' punti in banda.

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:55:43 +01:00
Adriano 351153e78a merge #100: statistiche con «Tutti i punti» (Refs #100)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:52:17 +01:00
Adriano 842ceca007 statistiche: con Tutti i punti tabella capability per punto, carta e istogramma solo con punto scelto, il select ricarica da solo (Refs #100)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:51:38 +01:00
Adriano b78f2422d8 merge #99: BMP nelle rotte visione (Refs #99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-25 15:47:21 +01:00
Adriano 444dc0a6d1 backend: immagini BMP ammesse nelle rotte visione (Refs #99)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:46:58 +01:00
Adriano 27d7c1e832 maker: mm/px a mano nel wizard DXF per immagini da file (Refs #98)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:37:40 +01:00
Adriano 4b1b23cfb6 Merge branch 'lavoro/daitarn-tmf-97c' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:04 +02:00
Adriano 4b57efc73c Merge branch 'lavoro/daitarn-tmf-97b' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:03 +02:00
Adriano 7c03f53000 Merge branch 'lavoro/daitarn-tmf-97a' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:03 +02:00
Adriano 67337fa232 operatore: immagine gia' analizzata all'ingresso, analisi impulsiva o in linea, Registra (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:30:31 +02:00
Adriano 661b782bad backend: test camera e modo di analisi nel task, /vision/check (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:26:26 +02:00
Adriano 25f8b4d609 maker: camera e modo di analisi nel task visione (Refs #97)
Rotte /maker/api/camera/servizi e /maker/api/camera/sorgenti (url ammesso solo
se e' un servizio delle stazioni), sezione «Acquisizione» nell'editor task
(servizio, sorgente, analisi impulsiva/in linea) salvata come acquisition_json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 18:26:00 +02:00
Adriano b98d1f15f4 operatore: immagine del task visione in fit, pagina senza scorrimento (Refs #96)
Pannello visione overflow-hidden/min-h-0, immagine e overlay object-contain
sovrapposti nel riquadro flex-1, colonna esito con scorrimento proprio.
Test sulle classi del template.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:20:15 +02:00
Adriano 8f9731839a backend: camera e modo di analisi nel task, /vision/check senza salvataggio (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:20:14 +02:00
Adriano 552f96bb47 acquisitore: calibrazione manuale con mm/px impostato a mano (Refs #95)
POST /camera/calibrazioni/manuale salva una voce senza mappa (metodo manuale);
anteprima/calibrata senza mappa risponde il frame grezzo (X-Raddrizzato: no);
scheda «Calibrazione manuale» nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:36:45 +02:00
Adriano 71daab184f acquisitore: il frame bloccato e' quello mostrato (Refs #94)
Con «Blocca immagine» la pagina aspetta l'anteprima in volo (max 3 s) e sospende
ciclo e «Scatta» prima di POST /camera/blocca, cosi' la copia del servizio e' l'ultimo
frame visibile nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:31:38 +02:00
Adriano 9beaf2ed67 acquisitore: immagine viva in Calibrazione e «Blocca immagine» per il calcolo (Refs #94)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:25:28 +02:00
Adriano 800ec41254 acquisitore: «Attiva calibrazione» con scelta e CONFERMA sotto l'immagine calibrata (Refs #93)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:03:16 +02:00
Adriano 9c45a51770 acquisitore: elenco sorgenti in cache, calibrata chiesta solo se serve (Refs #92)
Servizio: la ricerca camere di /camera/sources resta 60 s in cache (lock,
riscaldamento all'avvio, ?aggiorna=true la forza); le immagini caricate si
leggono sempre fresche. Pagina: l'immagine caricata entra nella select senza
rileggere l'elenco, pulsante Aggiorna, nessuna richiesta calibrata se la
sorgente non ha calibrazione attiva o dopo un 409.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:41:18 +02:00
Adriano 4a48af2ae3 merge #92 pagina: sorgente in cima, grezza e calibrata affiancate 2026-10-24 16:28:00 +02:00
Adriano b879563738 merge #92 servizio: immagini come sorgenti, anteprima calibrata 2026-10-24 16:28:00 +02:00
Adriano f504c86491 Acquisizione: immagine da file come sorgente, riquadri grezza e calibrata affiancati (Refs #92)
Pagina: Sorgente in cima alla tab Acquisizione con carica/rimuovi immagine,
riquadro Calibrata con casella, calibrazione sempre dal frame grezzo della
sorgente, ciclo anteprima sospeso nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:27:20 +02:00
102 changed files with 9806 additions and 497 deletions
+3
View File
@@ -76,3 +76,6 @@ docker-compose.override.yml
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
packaging/windows/_strumenti.py
# #109: scritto da packaging/windows/sviluppo.ps1 (sha del submodule), non va versionato
/vision-engine-version.txt
+1 -1
View File
@@ -289,7 +289,7 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 2e73691.
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 9eb6f4c.
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
+65
View File
@@ -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")
+21 -2
View File
@@ -4,14 +4,15 @@ Nuitka (che richiede ~20 minuti): stessi quattro server e stesse porte dell'exe
8000 backend, 8100 visione, 8765 camera), stessa cartella dati %PROGRAMDATA%\Tielogic\TieMeasureFlow.
Prima dell'avvio: ambiente Python 3.13 in build\venv313 (uv sync, solo con -Sincronizza o se manca),
tailwind.css e traduzioni .mo ricompilati, DLL di WeasyPrint in build\gtk (scaricate con
pdf\prendi-gtk.ps1 se mancano).
pdf\prendi-gtk.ps1 se mancano). Scrive vision-engine-version.txt nella radice (sha del submodule
vendor/visionsuite) se git c'e'; senza git e senza il file si ferma con un errore chiaro (#109).
Come si usa (dalla radice del repo):
powershell -ExecutionPolicy Bypass -File packaging\windows\sviluppo.ps1 [-Sincronizza] [-Ascolto 0.0.0.0] [-EscludiCamere OECS]
Dopo una modifica: Ctrl+C e rilancio (pochi secondi). Template e JS non richiedono altro;
classi Tailwind nuove e msgid nuovi vengono ricompilati a ogni avvio.
Dipende da: uv, npx (Node), la licenza tiemeasureflow di QUESTA macchina in
Dipende da: uv, npx (Node), git (opzionale: senza, serve vision-engine-version.txt gia' nella radice), la licenza tiemeasureflow di QUESTA macchina in
%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json (impronta: avvio.py --impronta).
#>
param(
@@ -47,6 +48,24 @@ if (-not (Test-Path (Join-Path $gtk '*.dll'))) {
}
$env:WEASYPRINT_DLL_DIRECTORIES = $gtk
# #109: il worker di visione rifiuta ogni job senza VISION_ENGINE_VERSION, che avvio.py legge da
# <radice>\vision-engine-version.txt (come nel pacchetto, build.ps1). Da sorgente lo scrive questo script.
$versione = Join-Path $radice 'vision-engine-version.txt'
$shaMotore = $null
# #109: solo se il submodule e' inizializzato (ha .git): altrimenti git risale al repo padre e darebbe lo sha di TMF
if ((Get-Command git -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $radice 'vendor\visionsuite\.git'))) {
$shaMotore = (git -C (Join-Path $radice 'vendor\visionsuite') rev-parse HEAD 2>$null)
if ($LASTEXITCODE -ne 0) { $shaMotore = $null }
}
if ($shaMotore) {
# 40 caratteri ASCII, UTF8 senza BOM e senza a capo (avvio.py fa .strip(), che non toglie il BOM)
[System.IO.File]::WriteAllText($versione, $shaMotore.Trim(), (New-Object System.Text.UTF8Encoding $false))
} elseif (Test-Path $versione) {
Write-Host "git non disponibile: uso $versione = $((Get-Content -Raw $versione).Trim())"
} else {
throw "Manca vision-engine-version.txt e git non puo' leggere vendor\visionsuite. Rimedio: crea l'archivio con 'uv run python scripts/archivio_sorgente.py --out tmf.zip' da un clone con git, oppure scrivi in $versione lo sha del submodule (40 caratteri, ASCII, senza BOM e senza a capo)."
}
if ($EscludiCamere) { $env:VS_CAMERA_ESCLUDI = $EscludiCamere } # #339: stesso percorso camera del PC fiera
Set-Location $radice
& $py (Join-Path $PSScriptRoot 'avvio.py') --senza-browser --senza-finestre --host $Ascolto # errori in console, niente MessageBox
+84
View File
@@ -124,6 +124,16 @@ def test_variabili_del_contratto(avvio, tmp_path):
assert len(e[k]) >= 32
def test_versione_motore_visione_letta_dalla_radice_sorgente(avvio, tmp_path, monkeypatch):
# #109: da sorgente il file vision-engine-version.txt sta nella radice del repo e arriva al worker
prog = tmp_path / "prog"
prog.mkdir()
(prog / "vision-engine-version.txt").write_bytes(b"a" * 40)
monkeypatch.setattr(avvio, "cartella_programma", lambda: prog)
avvio.imposta_ambiente(tmp_path / "dati", "127.0.0.1", {k: "x" * 32 for k in avvio.SEGRETI})
assert os.environ["VISION_ENGINE_VERSION"] == "a" * 40
def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path):
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
primo = avvio.carica_segreti(dati)
@@ -271,3 +281,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
+108
View File
@@ -0,0 +1,108 @@
"""Archivio sorgente di TieMeasureFlow per un PC senza git (TieMeasureFlow#109).
Cosa fa
Crea uno zip del repo a un commit, con dentro anche il submodule vendor/visionsuite (che
`git archive` da solo NON include) e il file vision-engine-version.txt nella radice dello zip.
Quel file contiene lo sha del submodule REGISTRATO nel commit (non la HEAD del checkout del
submodule): packaging/windows/avvio.py lo legge e lo passa al worker di visione come
VISION_ENGINE_VERSION; senza, il worker rifiuta ogni job. Formato: 40 caratteri ASCII,
senza BOM e senza a capo (avvio.py usa .strip(), che non toglie un BOM).
Come si usa (dalla radice del repo, con il submodule inizializzato)
uv run python scripts/archivio_sorgente.py --out C:/tmp/tmf.zip [--ref HEAD]
Esce 0 e stampa lo sha; esce 1 con un messaggio e senza lasciare lo zip se il submodule
non e' inizializzato o non ha il commit registrato (git submodule update --init vendor/visionsuite).
Dipende da: git nel PATH, solo libreria standard.
"""
from __future__ import annotations
import argparse
import io
import os
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
SUBMODULE = "vendor/visionsuite"
FILE_VERSIONE = "vision-engine-version.txt"
class ErroreArchivio(RuntimeError):
"""Errore chiaro da mostrare all'operatore (niente traceback)."""
def _git(cartella: Path, *args: str, binario: bool = False):
"""Esegue git in `cartella`; solleva ErroreArchivio con lo stderr se fallisce."""
r = subprocess.run(["git", "-C", str(cartella), *args], capture_output=True)
if r.returncode != 0:
raise ErroreArchivio(f"git {' '.join(args)} fallito in {cartella}: {r.stderr.decode(errors='replace').strip()}")
return r.stdout if binario else r.stdout.decode().strip()
def sha_registrato(repo: Path, ref: str) -> str:
"""Sha del submodule registrato nel commit `ref` (gitlink, modo 160000), non la HEAD del checkout."""
campi = _git(repo, "ls-tree", ref, SUBMODULE).split()
if len(campi) < 3 or campi[0] != "160000":
raise ErroreArchivio(f"{SUBMODULE} non e' un submodule registrato in {ref}")
return campi[2]
def crea_archivio(repo: Path, out: Path, ref: str = "HEAD") -> str:
"""Scrive lo zip in `out` e ritorna lo sha del submodule; in caso di errore non lascia nulla."""
repo, out = Path(repo), Path(out)
sha = sha_registrato(repo, ref)
sub = repo / SUBMODULE
# submodule non inizializzato o senza il commit registrato: errore prima di scrivere lo zip
if not (sub / ".git").exists():
raise ErroreArchivio(f"{SUBMODULE} non e' inizializzato: git submodule update --init {SUBMODULE}")
if subprocess.run(["git", "-C", str(sub), "cat-file", "-e", f"{sha}^{{commit}}"], capture_output=True).returncode != 0:
raise ErroreArchivio(f"{SUBMODULE} non ha il commit {sha}: git submodule update --init {SUBMODULE}")
zip_repo = _git(repo, "archive", "--format=zip", ref, binario=True)
zip_sub = _git(sub, "archive", "--format=zip", f"--prefix={SUBMODULE}/", sha, binario=True)
out.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(suffix=".zip.tmp", dir=out.parent) # scrittura atomica: o c'e' tutto o niente
os.close(fd)
try:
viste: set[str] = set()
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
for blob in (zip_repo, zip_sub):
with zipfile.ZipFile(io.BytesIO(blob)) as sorgente:
for info in sorgente.infolist():
if info.filename in viste:
continue # es. la cartella vendor/visionsuite/ del gitlink, gia' nel primo zip
viste.add(info.filename)
z.writestr(info, sorgente.read(info.filename), compress_type=zipfile.ZIP_DEFLATED)
if FILE_VERSIONE in viste:
raise ErroreArchivio(f"{FILE_VERSIONE} e' gia' nel repo a {ref}: va tolto dal versionamento")
# 40 caratteri ASCII, senza BOM e senza a capo (avvio.py fa .strip(), che non toglie un BOM)
z.writestr(FILE_VERSIONE, sha.encode("ascii"))
os.replace(tmp, out)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
return sha
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Zip del sorgente con submodule e vision-engine-version.txt (#109)")
p.add_argument("--out", required=True, type=Path, help="file .zip da creare")
p.add_argument("--ref", default="HEAD", help="commit da archiviare (default HEAD)")
a = p.parse_args(argv)
radice = Path(__file__).resolve().parents[1]
try:
sha = crea_archivio(radice, a.out, a.ref)
except ErroreArchivio as e:
print(f"ERRORE: {e}", file=sys.stderr)
return 1
print(f"{a.out} creato; {FILE_VERSIONE} = {sha}")
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
+221 -19
View File
@@ -29,6 +29,10 @@ Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente)
POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201
metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato
POST /camera/calibrazioni/manuale json {source, nome, mm_per_px, sovrascrivi=false}
-> 201 metadati (#95: mm/px impostato a mano, metodo "manuale", nessuna
mappa; non la attiva). 400 mm/px fuori 0.00001..100 o nome non valido,
409 nome gia' usato; la risoluzione e' quella del frame tenuto o di un grab
DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega
da ogni sorgente) | 404
PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla
@@ -41,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).
@@ -57,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
@@ -83,11 +98,14 @@ 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
import re # #92: nome sicuro per le immagini caricate
import sys
import threading
import time # #92: scadenza della cache delle camere
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
import uuid # #91 id dei calcoli in attesa di nome
from collections import OrderedDict
@@ -108,8 +126,15 @@ 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
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
# #87: i quattro parametri regolabili, nell'ordine di presentazione
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
@@ -154,11 +179,42 @@ def create_app(folder: Path | str | None = None,
else _config_dir_default()) # #83
aperte: dict = {}
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata)
bloccati: dict = {} # #94: sorgente -> copia del frame grezzo pieno «bloccata» per il calcolo (stesso lock di grezzi)
cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
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:
@@ -169,8 +225,32 @@ def create_app(folder: Path | str | None = None,
with lock:
return lock_sorgenti.setdefault(nome, threading.Lock())
# #92: la ricerca camere di list_sources dura ~10 s: il risultato (gia' filtrato) resta 60 s in cache
cache_camere: dict = {"voci": None, "t": 0.0}
lock_ricerca = threading.Lock() # due richieste insieme non lanciano due ricerche
def _camere(aggiorna: bool = False) -> list:
with lock_ricerca:
fresca = cache_camere["voci"] is not None and time.monotonic() - cache_camere["t"] < _CACHE_CAMERE_S
if fresca and not aggiorna:
return cache_camere["voci"]
voci = list_sources(None, cameras=True, config=_config())
# camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
cache_camere["voci"] = [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
cache_camere["t"] = time.monotonic()
return cache_camere["voci"]
def _prima_ricerca() -> None:
try:
_camere()
except Exception: # noqa: BLE001 - e' solo un riscaldamento: la prima richiesta riprova
_log.exception("prima ricerca camere fallita")
@asynccontextmanager
async def _vita(_app):
# #92: la prima ricerca parte all'avvio, cosi' la prima pagina non aspetta
threading.Thread(target=_prima_ricerca, daemon=True, name="ricerca-camere").start()
yield
with lock: # alla chiusura rilascia le camere aperte
sorgenti = list(aperte.values())
@@ -184,12 +264,9 @@ def create_app(folder: Path | str | None = None,
app = FastAPI(title="TMF camera service", lifespan=_vita)
@app.get("/camera/sources")
def camera_sources():
voci = list_sources(None, cameras=True, config=_config())
# #92: camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
elenco = [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
# #92: in coda le immagini caricate, che sono sorgenti a pieno titolo
def camera_sources(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
elenco = list(_camere(aggiorna))
# #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
for v in list_sources(cartella_immagini, cameras=False):
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
return {"sources": elenco}
@@ -200,6 +277,7 @@ def create_app(folder: Path | str | None = None,
with lock:
s = aperte.pop(nome, None)
grezzi.pop(nome, None)
bloccati.pop(nome, None) # #94: tolta l'immagine/chiusa la sorgente, anche la copia bloccata
if s is not None:
try:
s.close()
@@ -287,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)):
@@ -345,6 +423,7 @@ def create_app(folder: Path | str | None = None,
rows: int | None = Form(None),
image: UploadFile | None = File(None),
save: bool = Form(False), # #91: ignorato, il calcolo non salva mai (si salva con nome)
bloccata: bool = Form(False), # #94: true = calcola sulla copia bloccata, senza grab
):
contenuto = await image.read() if image is not None else b""
if contenuto:
@@ -352,6 +431,13 @@ def create_app(folder: Path | str | None = None,
if frame is None:
raise HTTPException(422, "l'immagine caricata non è leggibile")
origine = "file:" + (Path((image.filename or "").replace("\\", "/")).name or "immagine")
elif bloccata: # #94: copia bloccata, nessun grab e nessun lock camera
nome = _sorgente(source)
with lock:
frame = bloccati.get(nome)
if frame is None:
raise HTTPException(409, "nessuna immagine bloccata su questa sorgente")
origine = nome
else:
nome = _sorgente(source)
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
@@ -374,7 +460,8 @@ def create_app(folder: Path | str | None = None,
calcoli[calcolo_id] = (corpo, mappa)
while len(calcoli) > _MAX_CALCOLI:
calcoli.popitem(last=False)
return {**corpo, "calcolo_id": calcolo_id, "overlay_png": risultato["overlay_png"]}
return {**corpo, "calcolo_id": calcolo_id, "overlay_png": risultato["overlay_png"],
"bloccata": bool(bloccata and not contenuto)} # #94: solo nella risposta (non nel salvato): true se il frame e' la copia bloccata
# ---- #91: archivio unico di calibrazioni con nome
@@ -406,6 +493,35 @@ def create_app(folder: Path | str | None = None,
except calibrazione.NomeGiaUsato as e:
raise HTTPException(409, str(e))
@app.post("/camera/calibrazioni/manuale", status_code=201)
def calibrazioni_manuale(corpo: dict = Body(default=None)):
"""#95: calibrazione con mm/px impostato a mano (nessuna scacchiera, nessuna mappa); NON la attiva."""
corpo = corpo if isinstance(corpo, dict) else {}
sorgente = _sorgente(corpo.get("source"))
mm_px = corpo.get("mm_per_px")
if (isinstance(mm_px, bool) or not isinstance(mm_px, (int, float))
or not math.isfinite(mm_px) or not 0.00001 <= mm_px <= 100):
raise HTTPException(400, "mm/px deve essere un numero positivo")
sovrascrivi = corpo.get("sovrascrivi", False)
if not isinstance(sovrascrivi, bool):
raise HTTPException(400, "«sovrascrivi» deve essere true o false")
try:
nome = calibrazione.pulisci_nome(corpo.get("nome"))
except calibrazione.NomeNonValido as e:
raise HTTPException(400, str(e))
with lock:
frame = grezzi.get(sorgente)
if frame is None: # nessun frame tenuto: un grab come /camera/grab (404/503/504)
frame = _grab(sorgente, 5000)
alt, larg = frame.shape[:2] # serve al controllo W×H di PUT /camera/calibrazione-attiva
dati = {"px_per_mm": 1.0 / mm_px, "mm_per_px": float(mm_px), "metodo": "manuale",
"image_width": larg, "image_height": alt, "origine": sorgente,
"calibrated_at": calibrazione.ora_iso(), "rms_px": None, "square_mm": None}
try:
return archivio.salva_nome(nome, dati, None, sovrascrivi)
except calibrazione.NomeGiaUsato as e:
raise HTTPException(409, str(e))
@app.delete("/camera/calibrazioni", status_code=204)
def calibrazioni_elimina(nome: str = ""):
if not archivio.elimina_nome(nome):
@@ -476,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
@@ -508,9 +624,28 @@ def create_app(folder: Path | str | None = None,
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
"X-Media": f"{media:.1f}"})
@app.post("/camera/blocca")
def camera_blocca(corpo: dict = Body(default=None)):
"""#94: copia l'ultimo frame grezzo della sorgente; /camera/calibrate con bloccata=true calcola su quella."""
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
with lock:
frame = grezzi.get(nome)
if frame is None:
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
bloccati[nome] = frame.copy()
alt, larg = frame.shape[:2]
return {"source": nome, "larghezza": larg, "altezza": alt}
@app.delete("/camera/blocca", status_code=204)
def camera_sblocca(source: str = ""):
with lock: # #94: idempotente, 204 anche se non c'era nulla
bloccati.pop(source.strip(), None)
return Response(status_code=204)
@app.post("/camera/anteprima/calibrata")
def camera_anteprima_calibrata(corpo: dict = Body(default=None)):
"""#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo."""
"""#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo. #95: senza mappa esce grezzo (X-Raddrizzato: no)."""
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
lato = corpo.get("lato_max", 1280)
@@ -524,14 +659,22 @@ def create_app(folder: Path | str | None = None,
if attiva is None:
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
mappa = archivio.leggi_mappa(nome)
if mappa is None: # planare: ha solo scala, nessuna mappa da applicare
raise HTTPException(409, f"la calibrazione «{attiva}» non ha una mappa di correzione (planare)")
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is None: # risoluzione diversa da quella calibrata
ch, cw = mappa.shape[:2]
if mappa is None: # #95: senza mappa (manuale o planare) solo scala: frame grezzo, nessuna correzione
dati = archivio.leggi(nome) or {}
cw, ch = dati.get("image_width"), dati.get("image_height")
alt, larg = frame.shape[:2]
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
if isinstance(cw, int) and isinstance(ch, int) and (cw, ch) != (larg, alt):
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
corretto, raddrizzato = frame, "no"
else:
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is None: # risoluzione diversa da quella calibrata
ch, cw = mappa.shape[:2]
alt, larg = frame.shape[:2]
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
raddrizzato = "si"
alt, larg = corretto.shape[:2]
if corretto.dtype != np.uint8: # il JPEG vuole 8 bit
corretto = _grigio8(corretto)
@@ -543,7 +686,7 @@ def create_app(folder: Path | str | None = None,
if not ok:
raise HTTPException(500, "codifica JPEG fallita")
return Response(buf.tobytes(), media_type="image/jpeg", headers={
"X-Raddrizzato": "si", "X-Larghezza": str(larg), "X-Altezza": str(alt),
"X-Raddrizzato": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt),
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
@app.get("/camera/parametri")
@@ -591,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
+92
View File
@@ -0,0 +1,92 @@
"""Test di scripts/archivio_sorgente.py (TieMeasureFlow#109): repo temporaneo con un submodule finto.
Uso: uv run --no-project --python 3.13 --with pytest pytest -q scripts/tests/test_archivio_sorgente.py
"""
import importlib.util
import subprocess
import zipfile
from pathlib import Path
import pytest
_spec = importlib.util.spec_from_file_location(
"archivio_sorgente", Path(__file__).resolve().parents[1] / "archivio_sorgente.py")
archivio = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(archivio)
GIT = ["git", "-c", "core.autocrlf=false", "-c", "protocol.file.allow=always",
"-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"]
def _g(cartella, *args):
r = subprocess.run([*GIT, "-C", str(cartella), *args], capture_output=True, text=True)
assert r.returncode == 0, r.stderr
return r.stdout.strip()
def _repo(tmp_path, nome):
d = tmp_path / nome
d.mkdir()
_g(d, "init", "-q", "-b", "main")
return d
@pytest.fixture
def padre(tmp_path):
"""Repo padre con vendor/visionsuite registrato (submodule finto da un repo locale)."""
origine = _repo(tmp_path, "origine")
(origine / "motore.py").write_text("x = 1\n")
_g(origine, "add", ".")
_g(origine, "commit", "-q", "-m", "motore")
p = _repo(tmp_path, "padre")
(p / "app.py").write_text("print('tmf')\n")
_g(p, "add", ".")
_g(p, "commit", "-q", "-m", "app")
_g(p, "submodule", "add", "-q", str(origine), "vendor/visionsuite")
_g(p, "commit", "-q", "-m", "submodule")
return p
def test_zip_con_repo_submodule_e_versione(padre, tmp_path):
atteso = _g(padre, "ls-tree", "HEAD", "vendor/visionsuite").split()[2]
out = tmp_path / "o" / "tmf.zip"
assert archivio.crea_archivio(padre, out) == atteso
with zipfile.ZipFile(out) as z:
nomi = z.namelist()
assert "app.py" in nomi and "vendor/visionsuite/motore.py" in nomi
dati = z.read("vision-engine-version.txt")
assert dati == atteso.encode("ascii") and len(dati) == 40
assert not dati.startswith(b"\xef\xbb\xbf") and b"\n" not in dati # niente BOM, niente a capo
def test_sha_resta_quello_registrato_se_il_submodule_avanza(padre, tmp_path):
registrato = _g(padre, "ls-tree", "HEAD", "vendor/visionsuite").split()[2]
sub = padre / "vendor" / "visionsuite"
(sub / "nuovo.py").write_text("y = 2\n")
_g(sub, "add", ".")
_g(sub, "commit", "-q", "-m", "avanti non registrato")
assert _g(sub, "rev-parse", "HEAD") != registrato
out = tmp_path / "tmf.zip"
assert archivio.crea_archivio(padre, out) == registrato
with zipfile.ZipFile(out) as z:
assert z.read("vision-engine-version.txt").decode() == registrato
assert "vendor/visionsuite/nuovo.py" not in z.namelist() # contenuto del commit registrato
def test_submodule_non_inizializzato_errore_e_nessuno_zip(padre, tmp_path):
clone = tmp_path / "clone"
_g(tmp_path, "clone", "-q", str(padre), str(clone)) # senza --recurse-submodules
out = tmp_path / "tmf.zip"
with pytest.raises(archivio.ErroreArchivio, match="inizializzato"):
archivio.crea_archivio(clone, out)
assert not out.exists() and not list(tmp_path.glob("*.tmp")) # nessuno zip ne' temporaneo
def test_cli_esce_1_con_messaggio(padre, tmp_path, capsys, monkeypatch):
clone = tmp_path / "clone"
_g(tmp_path, "clone", "-q", str(padre), str(clone))
originale = archivio.crea_archivio
# la CLI lavora sul repo che contiene lo script: la si dirotta sul clone senza submodule
monkeypatch.setattr(archivio, "crea_archivio", lambda r, o, ref="HEAD": originale(clone, o, ref))
assert archivio.main(["--out", str(tmp_path / "x.zip")]) == 1
assert "ERRORE" in capsys.readouterr().err
+157
View File
@@ -0,0 +1,157 @@
"""Test di #94: «Blocca immagine» (scripts/camera_service.py) e riquadro vivo nella tab Calibrazione (PAGINA).
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgente finta che conta i grab: niente camera vera, niente ProgramData.
"""
import re
import numpy as np
import pytest
from test_calibrazione import _png, cartella, client, una_scacchiera # fixture e helper della calibrazione
from test_immagini_affiancate import _anteprima, _carica, _modulo
from test_pagina_acquisizione import PAGINA
__all__ = ["cartella", "client"] # le fixture importate restano usate
FINTA = "finta"
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
@pytest.fixture
def finta(client, monkeypatch):
"""Sorgente «finta»: il primo grab da' la scacchiera, i successivi un frame bianco (non calibrabile)."""
letture = []
scacchiera = una_scacchiera()
bianco = np.full(scacchiera.shape, 255, np.uint8)
class Finta:
def grab(self, timeout=1000):
letture.append(1)
return scacchiera.copy() if len(letture) == 1 else bianco.copy()
def close(self):
pass
monkeypatch.setitem(_modulo(client), "open_source", lambda *a, **k: Finta())
client.letture = letture
return client
def _blocca(c, nome=FINTA):
return c.post("/camera/blocca", json={"source": nome})
def _calcola(c, **extra):
return c.post("/camera/calibrate", data={"source": FINTA, **CAMPI, **extra})
# ---- servizio
def test_blocca_senza_frame_404(finta):
r = _blocca(finta)
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
def test_blocca_senza_source_400(finta):
assert finta.post("/camera/blocca", json={}).status_code == 400
assert finta.post("/camera/blocca").status_code == 400
def test_anteprima_poi_blocca_200_con_misure(finta):
assert _anteprima(finta, FINTA).status_code == 200
r = _blocca(finta)
alt, larg = una_scacchiera().shape[:2]
assert r.status_code == 200 and r.json() == {"source": FINTA, "larghezza": larg, "altezza": alt}
def test_calibrate_bloccata_usa_la_copia_senza_grab(finta):
assert _anteprima(finta, FINTA).status_code == 200 # frame 1: la scacchiera
assert _blocca(finta).status_code == 200
assert _anteprima(finta, FINTA).status_code == 200 # frame 2: bianco, il grezzo cambia ma la copia no
prima = len(finta.letture)
r = _calcola(finta, bloccata="true")
assert r.status_code == 200, r.text
assert r.json()["bloccata"] is True and r.json()["origine"] == FINTA
assert len(finta.letture) == prima # nessun grab per il calcolo
# senza il blocco il calcolo legge un frame nuovo (bianco): non calibrabile
assert _calcola(finta).status_code == 422
assert len(finta.letture) == prima + 1
def test_calibrate_normale_dice_bloccata_false(client):
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
assert r.status_code == 200, r.text
assert r.json()["bloccata"] is False
def test_calibrate_bloccata_senza_copia_409(finta):
assert _anteprima(finta, FINTA).status_code == 200 # il grezzo c'e', ma non e' bloccato
r = _calcola(finta, bloccata="true")
assert r.status_code == 409 and r.json()["detail"] == "nessuna immagine bloccata su questa sorgente"
def test_sblocca_204_idempotente_e_toglie_la_copia(finta):
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204 # niente da togliere
assert _anteprima(finta, FINTA).status_code == 200
assert _blocca(finta).status_code == 200
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
assert _calcola(finta, bloccata="true").status_code == 409
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
def test_rimuovere_l_immagine_toglie_la_copia_bloccata(client):
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
assert _anteprima(client, "foto.png").status_code == 200
assert _blocca(client, "foto.png").status_code == 200
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
r = client.post("/camera/calibrate", data={"source": "foto.png", "bloccata": "true", **CAMPI})
assert r.status_code == 409
# ---- pagina (contenuto statico)
def test_pagina_riquadro_e_casella_nella_tab_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"immagineCalTab", "vuotoCalTab", "scattaCal", "bloccaImg", "msgBlocco"} <= ids
assert "Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione" in PAGINA
assert "Blocca immagine" in PAGINA
tab = PAGINA.split('id="pannelloCal"')[1].split('id="sezioneSalvate"')[0]
assert "immagineCalTab" in tab and "bloccaImg" in tab # nella sezione Calibrazione, non altrove
def test_pagina_scegli_tab_non_ferma_piu_il_ciclo():
assert "A.riprendi" not in PAGINA
scegli = PAGINA.split("function scegliTab")[1].split("$(\"tabAcq\").onclick")[0]
assert "fermaAnteprima" not in scegli and "avviaAnteprima" not in scegli
def test_pagina_calcola_manda_bloccata_e_non_ferma_il_ciclo():
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
assert 'f.append("bloccata", "true")' in calcola
assert "if (bloccata)" in calcola and "else fermaAnteprima();" in calcola
assert "immagine bloccata" in calcola
assert "l'anteprima resta sospesa" not in PAGINA and "si può bloccare l'immagine" in PAGINA
def test_pagina_blocco_rotte_e_cambio_sorgente():
assert '"/camera/blocca"' in PAGINA and '"/camera/blocca?source="' in PAGINA
assert "method: \"DELETE\"" in PAGINA
cambio = PAGINA.split("function cambioSorgente")[1].split("// ---- Anteprima")[0]
assert "rilasciaBlocco()" in cambio
assert cambio.index("rilasciaBlocco()") < cambio.index("svuotaRiquadri()")
def test_pagina_blocco_attende_l_anteprima_in_volo():
# #94: il frame bloccato e' quello mostrato: flag A.bloccando, attesa di A.inVolo (20 ms, max 3 s), poi POST, flag tolto in finally
blocco = PAGINA.split('$("bloccaImg").onchange')[1].split('$("avvia").onclick')[0]
assert "A.bloccando++" in blocco and "A.bloccando--" in blocco and "finally" in blocco
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]
# #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)")
+2 -2
View File
@@ -169,7 +169,7 @@ def test_c2_immagine_caricata_senza_salvare(client):
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
corpo = r.json()
assert CHIAVI_PLANARE | {"overlay_png", "calcolo_id"} == set(corpo) # #91: niente "saved", niente "source"
assert CHIAVI_PLANARE | {"overlay_png", "calcolo_id", "bloccata"} == set(corpo) # #94: + bloccata; #91: niente "saved", niente "source"
assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia
assert corpo["origine"] == "file:s.png"
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
@@ -337,7 +337,7 @@ def test_c2_senza_cols_rows_e_densa(client_denso):
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert r.status_code == 200, r.text
corpo = r.json()
assert CHIAVI_DENSA | {"overlay_png", "calcolo_id"} == set(corpo)
assert CHIAVI_DENSA | {"overlay_png", "calcolo_id", "bloccata"} == set(corpo)
assert corpo["metodo"] == "densa" and corpo["distorsione"] is True
assert (corpo["cols"], corpo["rows"]) == (15, 11)
assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"])
+148
View File
@@ -0,0 +1,148 @@
"""Test di #95: calibrazione manuale (mm/px impostato a mano) in scripts/camera_service.py e nella PAGINA.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgenti = immagini caricate in tmp_path: niente camera vera, niente ProgramData.
"""
import re
import numpy as np
import pytest
from test_calibrazione import _png, cartella, client # fixture della calibrazione
from test_immagini_affiancate import _anteprima, _calibrata, _carica
from test_pagina_acquisizione import PAGINA
__all__ = ["cartella", "client"] # le fixture importate restano usate
SRC = "banco.png" # 64x48
ALTRA = "grande.png" # 80x60, risoluzione diversa
@pytest.fixture
def sorgenti(client):
assert _carica(client, SRC, np.full((48, 64), 120, np.uint8)).status_code == 201
assert _carica(client, ALTRA, np.full((60, 80), 120, np.uint8)).status_code == 201
return client
def _manuale(c, nome="Manuale", mm=0.05, source=SRC, **extra):
return c.post("/camera/calibrazioni/manuale",
json={"source": source, "nome": nome, "mm_per_px": mm, **extra})
# ---- servizio
def test_manuale_201_con_scala_e_risoluzione_del_frame(sorgenti):
r = _manuale(sorgenti, mm=0.05)
assert r.status_code == 201, r.text
m = r.json()
assert m["nome"] == "Manuale" and m["metodo"] == "manuale"
assert m["px_per_mm"] == pytest.approx(20.0) and m["mm_per_px"] == pytest.approx(0.05)
assert (m["image_width"], m["image_height"]) == (64, 48)
assert m["origine"] == SRC and m["square_mm"] is None and m["attiva_su"] == [] # non la attiva
voci = sorgenti.get("/camera/calibrazioni").json()["calibrazioni"]
assert [v["nome"] for v in voci] == ["Manuale"]
def test_manuale_usa_il_frame_tenuto_se_c_e(sorgenti):
assert _anteprima(sorgenti, SRC).status_code == 200 # tiene il grezzo 64x48
r = _manuale(sorgenti)
assert r.status_code == 201 and (r.json()["image_width"], r.json()["image_height"]) == (64, 48)
@pytest.mark.parametrize("valore", [0, -1, 0.000001, 100.5, "abc", None, True])
def test_manuale_mm_non_valido_400(sorgenti, valore):
r = _manuale(sorgenti, mm=valore)
assert r.status_code == 400
assert "mm/px deve essere un numero positivo" in r.json()["detail"]
def test_manuale_senza_source_o_nome_400_e_sorgente_ignota_404(sorgenti):
assert sorgenti.post("/camera/calibrazioni/manuale", json={"nome": "x", "mm_per_px": 0.1}).status_code == 400
assert _manuale(sorgenti, nome=" ").status_code == 400
assert _manuale(sorgenti, source="nonesiste.png").status_code == 404
def test_manuale_409_nome_doppio_e_sovrascrivi(sorgenti):
assert _manuale(sorgenti, mm=0.05).status_code == 201
r = _manuale(sorgenti, mm=0.1)
assert r.status_code == 409 and "esiste già una calibrazione" in r.json()["detail"]
r = _manuale(sorgenti, mm=0.1, sovrascrivi=True)
assert r.status_code == 201 and r.json()["px_per_mm"] == pytest.approx(10.0)
assert _manuale(sorgenti, mm=0.1, sovrascrivi="si").status_code == 400
def test_attiva_manuale_ok_su_stessa_risoluzione_e_legge_px_per_mm(sorgenti):
assert _manuale(sorgenti, mm=0.05).status_code == 201
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"})
assert r.status_code == 200, r.text
g = sorgenti.get("/camera/calibration", params={"source": SRC})
assert g.status_code == 200 and g.json()["px_per_mm"] == pytest.approx(20.0)
assert g.json()["nome"] == "Manuale" and g.json()["metodo"] == "manuale"
def test_attiva_manuale_409_su_risoluzione_diversa(sorgenti):
assert _manuale(sorgenti).status_code == 201
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": ALTRA, "nome": "Manuale"})
assert r.status_code == 409 and "64×48" in r.json()["detail"]
def test_anteprima_calibrata_con_manuale_200_grezza(sorgenti):
assert _manuale(sorgenti).status_code == 201
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
assert _anteprima(sorgenti, SRC).status_code == 200
r = _calibrata(sorgenti, SRC)
assert r.status_code == 200, r.text
assert r.headers["X-Raddrizzato"] == "no" and r.headers["X-Calibrazione"] == "Manuale"
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("64", "48")
assert r.headers["content-type"] == "image/jpeg"
def test_anteprima_calibrata_manuale_409_se_il_frame_ha_altra_risoluzione(sorgenti):
assert _manuale(sorgenti).status_code == 201
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
assert _anteprima(sorgenti, ALTRA).status_code == 200
# stessa sorgente attiva, ma il frame tenuto cambia (immagine sostituita con altra misura)
assert _carica(sorgenti, SRC, np.full((60, 80), 9, np.uint8)).status_code == 201
assert _anteprima(sorgenti, SRC).status_code == 200
r = _calibrata(sorgenti, SRC)
assert r.status_code == 409 and "64×48" in r.json()["detail"]
# ---- pagina
def test_pagina_scheda_manuale_id_e_testi():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"mmPx", "pxMmCalc", "nomeManuale", "salvaManuale", "msgManuale"} <= ids
tab = PAGINA.split('id="pannelloCal"')[1]
assert tab.index('id="sezioneCalibrazione"') < tab.index('id="sezioneManuale"') < tab.index('id="sezioneSalvate"')
assert "Calibrazione manuale" in PAGINA and "mm per pixel" in PAGINA
assert "salvata: per usarla «Carica» o CONFERMA" in PAGINA
assert "Sovrascrivi «" in PAGINA.split("async function salvaManuale")[1] # stessa conferma del Salva scacchiera
assert "/camera/calibrazioni/manuale" in PAGINA and "alert(" not in PAGINA and "confirm(" not in PAGINA
def test_pagina_manuale_senza_sorgente_pulsante_spento_con_nota():
stato = PAGINA.split("function statoManuale")[1].split("function aggiornaManuale")[0]
assert 'salvaManuale").disabled = !s' in stato and "Scegliere una sorgente" in stato
assert "statoManuale()" in PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
def test_pagina_px_mm_calcolato_a_ogni_tasto():
assert '$("mmPx").oninput = aggiornaManuale' in PAGINA
assert '"= " + fmt(1 / v, 4) + " px/mm"' in PAGINA
def test_pagina_metodo_manuale_e_campi_null():
assert 'c.metodo === "densa" || c.metodo === "manuale" ? c.metodo : "planare"' in PAGINA
righe = PAGINA.split("function righe")[1].split("function tabella")[0]
assert 'd.metodo === "manuale"' in righe and "Lato dello scacco" in righe # la manuale ha il suo elenco, le altre il solito
assert 'v === null || v === undefined' in PAGINA.split("function fmt")[1].split("function fmtCasella")[0]
stato = PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
assert "Number(d.square_mm) > 0" in stato
def test_pagina_calibrata_senza_raddrizzamento_dice_solo_scala():
assert "solo scala, nessuna correzione ottica" in PAGINA
assert 'r.headers.get("X-Raddrizzato") === "no"' in PAGINA
+186
View File
@@ -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)
+32 -3
View File
@@ -106,6 +106,34 @@ def test_sources_con_immagini_e_senza_camera_file(client):
assert nomi.index("camera:folder") < nomi.index("a.png")
def test_sources_camere_in_cache_e_aggiorna(client):
# #92: la ricerca camere (~10 s) si fa una volta e resta 60 s; aggiorna=true la rilancia; le immagini sono sempre fresche
g = _modulo(client)
originale = g["list_sources"]
chiamate = []
def finto(cartella, cameras=True, **kw):
if cameras:
chiamate.append(1)
return originale(cartella, cameras=cameras, **kw)
g["list_sources"] = finto
try:
client.get("/camera/sources") # se la ricerca d'avvio e' ancora in corso aspetta il suo lock
base = len(chiamate)
_carica(client, "nuova.png", una_scacchiera())
voci = client.get("/camera/sources").json()["sources"]
client.get("/camera/sources")
assert len(chiamate) == base # seconda e terza chiamata: nessuna nuova ricerca
assert voci[-1]["name"] == "nuova.png" # ma l'immagine appena caricata c'e'
client.get("/camera/sources", params={"aggiorna": "true"})
assert len(chiamate) == base + 1 # aggiorna=true la rilancia
client.get("/camera/sources")
assert len(chiamate) == base + 1 # e il risultato nuovo torna in cache
finally:
g["list_sources"] = originale
def test_sources_senza_immagini_non_crea_la_cartella(client):
voci = client.get("/camera/sources").json()["sources"]
assert all(v["kind"] != "image" for v in voci)
@@ -254,10 +282,11 @@ def test_calibrata_risoluzione_diversa_409(client):
assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240"
def test_calibrata_planare_senza_mappa_409(client):
def test_calibrata_planare_senza_mappa_200_grezza(client):
# #95: senza mappa (planare) non e' piu' 409: esce il frame grezzo, X-Raddrizzato: no
_carica(client, PIANA, una_scacchiera())
_calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6")
assert _anteprima(client, PIANA).status_code == 200
r = _calibrata(client, PIANA)
assert r.status_code == 409
assert r.json()["detail"] == "la calibrazione «Piana» non ha una mappa di correzione (planare)"
assert r.status_code == 200
assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"
+87 -54
View File
@@ -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", "grezzo", "mirinoCasella", "immagine",
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
"parametri", "salvaParametri", "quadrato", "acquisisci", "scegliFile", "file",
"salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga, nell'elenco
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", "Scegli immagine...", "Questa sorgente non ha parametri regolabili",
"Immagine grezza (senza correzione)"):
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)
+80 -14
View File
@@ -1,4 +1,4 @@
"""Test della pagina a due tab (#91): contenuto statico di PAGINA, senza servizio."""
"""Test della pagina a due tab (#91, #92): contenuto statico di PAGINA, senza servizio."""
import importlib.util
import re
@@ -20,37 +20,58 @@ def test_due_tab_con_ruolo_e_selezione():
assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
def test_anteprima_fuori_dai_pannelli():
# l'anteprima sta nella colonna sinistra, prima dei due pannelli: visibile in entrambe le tab
i_ant = PAGINA.index('id="sezioneAnteprima"')
def test_sorgente_in_cima_poi_riquadri():
# #92: Sorgente, anteprima e parametri stanno nel pannello Acquisizione; Sorgente prima dei riquadri
i_acq = PAGINA.index('id="pannelloAcq"')
i_cal = PAGINA.index('id="pannelloCal"')
assert i_ant < i_acq < i_cal
# Sorgente e Parametri nel pannello Acquisizione, la calibrazione in quello Calibrazione
assert i_acq < PAGINA.index('id="sezioneSorgente"') < PAGINA.index('id="sezioneParametri"') < i_cal
i_sorg = PAGINA.index('id="sezioneSorgente"')
i_ant = PAGINA.index('id="sezioneAnteprima"')
i_riq = PAGINA.index('id="riquadri"')
assert i_acq < i_sorg < i_ant < i_riq < PAGINA.index('id="sezioneParametri"') < i_cal
assert PAGINA.index('id="sezioneCalibrazione"') > i_cal
# barra: casella calibrata e mirino; la vecchia casella «Immagine grezza» non c'e' piu'
assert 'id="mostraCal"' in PAGINA and "Attiva calibrazione" in PAGINA
assert 'id="grezzo"' not in PAGINA and "Immagine grezza (senza correzione)" not in PAGINA
# riquadri: «Immagine» sempre visibile, «Calibrata» nascosta finche' la casella e' spenta
assert re.search(r'<div class="riquadro" id="riquadroCal" hidden>', PAGINA)
assert "grezzo: true" in PAGINA
def test_id_del_pannello_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"calSorgente", "origSorgente", "origFile", "scegliFile", "file", "quadrato", "acquisisci",
"nomeCal", "salva", "msgSalva", "calibrazioni", "msgElenco", "stato", "risultato", "overlay"}
attesi = {"calSorgente", "quadrato", "acquisisci", "nomeCal", "salva", "msgSalva", "calibrazioni",
"msgElenco", "stato", "risultato", "overlay",
"caricaImmagine", "fileImmagine", "rimuoviImmagine", "riquadroCal", "immagineCal", "mirinoCal"}
assert attesi <= ids, attesi - ids
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids
assert len(re.findall(r'id="([^"]+)"', PAGINA)) == len(ids) # nessun id doppio
def test_calibrazione_senza_file_ne_radio():
# #92: l'immagine da file e' una sorgente di acquisizione, non un'opzione della calibrazione
for sparito in ("origFile", "origSorgente", "scegliFile", "nomeFile", 'type="radio"', "Immagine da file", "Sorgente selezionata"):
assert sparito not in PAGINA, sparito
# l'unico input file e' nella sezione Sorgente, in cima alla tab Acquisizione
assert PAGINA.count('type="file"') == 1
assert PAGINA.index('type="file"') < PAGINA.index('id="pannelloCal"')
assert "immagine grezza dalla tab Acquisizione" in PAGINA
# il calcolo manda solo la sorgente (e il lato), mai il file
assert 'f.append("source", sorgente)' in PAGINA and 'f.append("image"' not in PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
def test_testi_del_pannello():
for testo in ("Sorgente selezionata", "Immagine da file", "Scegli immagine...", "Calcola",
"Calibrazioni salvate", "si cambia nella tab Acquisizione", "risoluzione diversa",
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome"):
for testo in ("Camera o immagine", "Carica immagine da file...", "Rimuovi immagine", "Calcola",
"Calibrazioni salvate", "risoluzione diversa", "Calibrata",
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome",
"calibrata: ", "non calibrata"):
assert testo in PAGINA, testo
def test_rotte_del_contratto():
for rotta in ("/camera/calibrazioni", "/camera/calibrazione-attiva", "/camera/calibrate",
"/camera/calibration?source="):
"/camera/calibration?source=", "/camera/immagini", "/camera/immagini?nome=",
"/camera/anteprima/calibrata"): # #92
assert '"' + rotta in PAGINA, rotta
for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"):
assert parola in PAGINA, parola
@@ -65,7 +86,7 @@ def test_nessuna_finestra_del_browser_e_offline():
def test_acquisizione_di_87_90_conservata():
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano
for parola in ("Parametri camera", "Immagine grezza (senza correzione)", "Mirino centrale",
for parola in ("Parametri camera", "Mirino centrale",
"Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
assert parola in PAGINA, parola
@@ -75,3 +96,48 @@ def test_pagina_stretta():
# #91: elenco a schede, niente tabella larga ne' scorrimento orizzontale
assert 'id="calibrazioni" class="elenco"' in PAGINA and "overflow-x" not in PAGINA
assert "Lettura dei parametri..." in PAGINA
def test_ciclo_non_sospeso_nella_tab_calibrazione():
# #94: il ciclo non si ferma piu' entrando in Calibrazione (prima #92: A.riprendi); Calcola senza blocco aspetta le richieste in volo
assert "A.riprendi" not in PAGINA
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
assert "fermaAnteprima();" in calcola and "A.inVolo) await pausa(50)" in calcola
def test_calibrata_dopo_anteprima_e_dopo_carica():
# #92: la calibrata segue ogni anteprima grezza riuscita e si aggiorna dopo Carica/Scollega; 404/409 al posto dell'immagine
assert "await aggiornaCalibrata(lato);" in PAGINA
assert PAGINA.count("aggiornaCalibrata();") >= 3
assert "messaggioCalibrata(await dettaglio(r))" in PAGINA
assert 'decodeURIComponent(r.headers.get("X-Calibrazione")' in PAGINA
def test_elenco_sorgenti_senza_attese_e_calibrata_solo_se_serve():
# #92: la voce dell'immagine caricata si aggiunge in pagina (niente rilettura dell'elenco); «Aggiorna» forza la ricerca camere
assert "aggiungiSorgente(j);" in PAGINA and "caricaSorgenti(j.source)" not in PAGINA
assert 'id="aggiornaSorgenti"' in PAGINA and ">Aggiorna</button>" in PAGINA
assert '"?aggiorna=true"' in PAGINA
# nessuna calibrazione attiva: il riquadro lo dice senza chiamare il servizio; dopo un 409 non si richiede piu'
assert "if (A.calAttiva === false)" in PAGINA
assert "nessuna calibrazione attiva su questa sorgente" in PAGINA
assert "if (r.status === 409) A.bloccoCal = true;" in PAGINA and "if (A.bloccoCal) return;" in PAGINA
assert PAGINA.count("A.bloccoCal = false;") >= 3 # cambio sorgente, Carica, Scollega
def test_attiva_calibrazione_con_scelta_e_conferma():
# #93: sotto la calibrata una combo e CONFERMA; la casella cambia nome
for i in ('id="sceltaCal"', 'id="confermaCal"', 'id="msgSceltaCal"'):
assert i in PAGINA, i
assert "Attiva calibrazione" in PAGINA and ">CONFERMA</button>" in PAGINA
assert "Mostra immagine calibrata" not in PAGINA
assert PAGINA.index('id="riquadroCal"') < PAGINA.index('id="sceltaCal"') < PAGINA.index('id="confermaCal"') < PAGINA.index('id="msgSceltaCal"')
# il gestore di CONFERMA usa PUT calibrazione-attiva (via caricaNome) o DELETE calibration (via scollega), messaggio sotto la calibrata
gestore = PAGINA[PAGINA.index('$("confermaCal").onclick'):]
gestore = gestore[:gestore.index("};")]
assert 'caricaNome(nome, "msgSceltaCal")' in gestore and 'scollega("msgSceltaCal")' in gestore
assert 'method: "PUT"' in PAGINA and "/camera/calibrazione-attiva" in PAGINA
assert 'method: "DELETE"' in PAGINA and '"/camera/calibration?source="' in PAGINA
# voce vuota «nessuna», suffisso di risoluzione, combo ridisegnata con l'elenco e spenta senza sorgente
assert "— nessuna (non calibrata) —" in PAGINA and "— risoluzione diversa" in PAGINA
assert "disegnaSceltaCal();" in PAGINA and "= !$(\"sorgente\").value" in PAGINA
+84
View File
@@ -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
+6 -2
View File
@@ -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 ""
)
+6 -2
View File
@@ -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)
+2
View File
@@ -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,
+91 -7
View File
@@ -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,
)
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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).
+29 -1
View File
@@ -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
+11 -1
View File
@@ -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
+2 -1
View File
@@ -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
)
+3
View File
@@ -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(
+13 -5
View File
@@ -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,
+10 -3
View File
@@ -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 "",
+2
View File
@@ -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,
+5 -3
View File
@@ -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"),
+255 -32
View File
@@ -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)
+90
View File
@@ -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()
+26
View File
@@ -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
+114
View File
@@ -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
+28
View File
@@ -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"
+311
View File
@@ -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)
+109
View File
@@ -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
+226
View File
@@ -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
+94 -32
View File
@@ -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() == []
+1 -1
View File
@@ -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,
+113 -1
View File
@@ -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):
+88 -4
View File
@@ -1,4 +1,5 @@
"""MeasurementTec blueprint - recipe selection and measurement execution."""
import json
import urllib.parse
import requests as http_requests
@@ -16,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"
@@ -337,6 +341,14 @@ def task_execute(task_id: int):
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
# #97: camera del task (acquisition_json) o della stazione; il modo di analisi
# (impulsiva / in_linea) viene dal task. Solo i task visione ne hanno bisogno.
camera = None
camera_mode = "impulsiva"
if task_resp.get("task_type") in ("camera_measure", "dxf_compare"):
camera = _task_camera(task_resp, _current_station()[0])
camera_mode = _task_mode(task_resp)
return render_template(
"measure/task_execute.html",
task=task_resp,
@@ -345,7 +357,8 @@ def task_execute(task_id: int):
all_task_ids=all_task_ids,
measurement_interval_minutes=measurement_interval_minutes,
allow_manual_input=allow_manual_input,
camera_enabled=_station_camera(_current_station()[0]) is not None,
camera_enabled=camera is not None,
camera_mode=camera_mode,
)
@@ -427,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):
@@ -1001,6 +1015,42 @@ def api_vision_execute():
return jsonify(resp), 200
# #97: analysis of a frame without saving anything (preview before «Registra»).
@measure_bp.route("/api/vision/check", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_vision_check():
"""Proxy: run the task's VisionSuite recipe on the image, nothing is saved."""
image = request.files.get("image")
task_id = request.form.get("task_id")
if image is None or not task_id:
return jsonify({
"error": True,
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
}), 400
form = {"task_id": task_id}
if request.form.get("production_run_id"):
form["production_run_id"] = request.form["production_run_id"]
resp = api_client.post(
"/api/vision/check",
data=form,
files={"image": (
image.filename or "capture.jpg",
image.read(),
image.mimetype or "application/octet-stream",
)},
)
if resp.get("error"):
status_code = resp.get("status_code", 500)
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
if resp.get("session_expired"):
body["session_expired"] = True
return jsonify(body), status_code if status_code >= 400 else 500
return jsonify(resp), 200
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
"""(camera_url, camera_source) of the station, None when it has no camera.
@@ -1019,6 +1069,31 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
return str(url), str(source)
def _task_acquisition(task: dict | None) -> dict | None:
"""#97: acquisition_json of a task as a dict, None when absent or unreadable."""
acq = task.get("acquisition_json") if isinstance(task, dict) else None
if isinstance(acq, str):
try:
acq = json.loads(acq)
except ValueError:
return None
return acq if isinstance(acq, dict) else None
def _task_camera(task: dict | None, station_code: str | None) -> tuple[str, str] | None:
"""#97: camera of a task = its acquisition_json (url + source), else the station's."""
acq = _task_acquisition(task)
if acq and acq.get("camera_url") and acq.get("camera_source"):
return str(acq["camera_url"]), str(acq["camera_source"])
return _station_camera(station_code)
def _task_mode(task: dict | None) -> str:
"""#97: analysis mode of a task, «impulsiva» unless acquisition_json says «in_linea»."""
acq = _task_acquisition(task)
return "in_linea" if acq and acq.get("modo") == "in_linea" else "impulsiva"
# #72: the browser asks the station's camera service (vs-gui) for a frame; the
# service address is a server-side matter, so Flask relays and the PNG goes back as is.
@measure_bp.route("/api/vision/grab", methods=["POST"])
@@ -1026,7 +1101,16 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
@role_required("MeasurementTec")
def api_vision_grab():
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
camera = _station_camera(_current_station()[0])
# #97: con task_id (form o json) la camera e' quella del task, altrimenti della stazione
payload = request.get_json(silent=True) or {}
task_id = request.form.get("task_id") or payload.get("task_id")
task = None
if task_id:
try:
task = api_client.get(f"/api/tasks/{int(task_id)}")
except (TypeError, ValueError):
task = None
camera = _task_camera(task, _current_station()[0])
if camera is None:
return jsonify({
"error": True,
@@ -1037,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;
}
};
}
@@ -11,11 +11,15 @@
* taskType, subtasks, labels.
* Run context (lot, serial, production run) is read lazily through `ctx()`,
* which the template binds to the parent task component.
*
* #97: with a camera (cameraAvailable, the task's or the station's) the task opens
* on a frame already grabbed and analysed (/vision/check, nothing saved: «preview»).
* modo «impulsiva»: «Acquisisci» redoes grab+check. modo «in_linea»: continuous
* loop grab+check, one request at a time, standing still while paused / page hidden
* / leaving / saving. «Registra» saves the LAST frame shown through /vision/execute.
* 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)
@@ -24,6 +28,135 @@ function visionExecute() {
imageSrc: '', // object URL of the acquired image (shown under the overlay)
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
// #97: preview / loop state
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
looping: false, // an in-line loop is running
gen: 0, // bumped by «Registra»: answers of older requests are dropped
// #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; };
window.addEventListener('pagehide', leave);
window.addEventListener('beforeunload', leave);
// After x-init has bound ctx(): in-line starts the loop, impulsive grabs once.
this.$nextTick(() => { if (this.inLinea) this.loop(); else this.grab(); });
},
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.lastBlob !== this.recordedBlob && !this.saved && !this.registering; }, // #123: non sul frame gia' registrato
shouldRun() {
return this.inLinea && !this.paused && !this.saved && !this.registering
&& !this.leaving && !document.hidden;
},
// Page visible again: restart the loop unless the operator paused it.
kick() { if (this.shouldRun() && !this.looping) this.loop(); },
// In-line loop: one request at a time, at least 300 ms between two of them
// (longer after an error, so a dead camera is not hammered).
async loop() {
if (this.looping) return;
this.looping = true;
try {
while (this.shouldRun()) {
const ok = await this.grab();
await new Promise((r) => setTimeout(r, ok ? 300 : 2000));
}
} finally {
this.looping = false;
}
},
// «Pausa» / «Riprendi»; after «Registra» only «Riprendi» restarts the loop.
togglePause() {
if (this.paused || this.saved) {
this.paused = false;
this.saved = false;
this.loop();
} else {
this.paused = true;
}
},
// Analysis only (/vision/check): the frame becomes the preview, nothing is saved.
async analyze(blob) {
const cfg = window.__visionCfg || {};
if (!blob || !cfg.checkUrl) return false;
const gen = this.gen;
this.busy = true;
this.error = '';
try {
const c = this.ctx() || {};
const fd = new FormData();
fd.append('task_id', cfg.taskId);
fd.append('image', blob, blob.name || 'capture.jpg');
if (c.runId) fd.append('production_run_id', c.runId);
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
const resp = await fetch(cfg.checkUrl, {
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (gen !== this.gen) return false; // «Registra» took over
if (!resp.ok || body.error) {
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
}
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageSrc = URL.createObjectURL(blob);
this.lastBlob = blob;
this.result = body;
this.preview = true;
this.saved = false;
return true;
} catch (e) {
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
} finally {
if (gen === this.gen) this.busy = false;
}
},
// «Registra»: save the LAST frame shown with the usual /vision/execute.
async record() {
if (!this.canRecord) return;
const blob = this.lastBlob;
this.registering = true;
this.gen++; // drop whatever request is still in flight
this.busy = false;
this.grabbing = false;
try {
await this.acquire(blob);
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;
this.kick(); // in-line: stays still while saved, restarts if the save failed
}
},
// Entry point shared with the future agent camera.
async acquire(blob) {
if (!blob || this.busy) return;
@@ -53,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 {
@@ -62,39 +195,60 @@ 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
// the frame goes to the preview analysis. Returns true when the frame was analysed.
async grab() {
const cfg = window.__visionCfg || {};
if (this.busy || this.grabbing || !cfg.grabUrl) return;
if (this.busy || this.grabbing || this.registering || !cfg.grabUrl) return false;
const gen = this.gen;
this.grabbing = true;
this.error = '';
let blob = null;
try {
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
const fd = new FormData();
fd.append('task_id', cfg.taskId);
const resp = await fetch(cfg.grabUrl, {
method: 'POST', headers: { 'X-CSRFToken': csrf },
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return;
if (gen === this.gen) this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
}
blob = await resp.blob();
} catch (e) {
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
return;
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
} finally {
this.grabbing = false;
if (gen === this.gen) this.grabbing = false;
}
if (blob) await this.acquire(blob);
if (!blob || gen !== this.gen) return false;
if (this.hasCamera) return await this.analyze(blob);
await this.acquire(blob);
return !this.error;
},
// Input change: take the file and allow picking the same one again.
// #97: with a camera the file is a preview too (the loop pauses), «Registra» saves it.
onPick(ev) {
const f = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (f) this.acquire(f);
if (!f) return;
if (this.hasCamera) {
if (this.inLinea) this.paused = true;
this.analyze(f);
} else {
this.acquire(f);
}
},
// Image shown: the saved one from the server when available, else the local one.
@@ -113,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';
@@ -128,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>
@@ -534,23 +536,42 @@
<div x-show="isVisionTask" x-cloak
x-data="visionExecute()"
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
class="flex-1 min-h-0 overflow-hidden p-3 flex flex-col lg:flex-row gap-3">
{# #96: il pannello non scorre mai (overflow-hidden, min-h-0): l'immagine sta in fit nello spazio che resta #}
<div class="flex-1 min-w-0 flex flex-col gap-2">
<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 %}
{# #72: frame dalla camera della stazione, stessa strada di acquire() #}
{% 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()"
:class="registering ? 'opacity-50 pointer-events-none' : ''"
:disabled="registering">
<span x-text="(paused || saved) ? '{{ _('Riprendi') }}' : '{{ _('Pausa') }}'"></span>
</button>
{% else %}
{# #72: frame dalla camera, stessa strada di acquire(); #97: ora grab + analisi senza salvare #}
<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 dalla camera') }}</span>
<span>{{ _('Acquisisci da camera') }}</span> {# #119 #}
</button>
{% endif %}
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
<button type="button" class="btn btn-primary text-sm gap-1.5" @click="record()"
:class="canRecord ? '' : 'opacity-50 pointer-events-none'"
:disabled="!canRecord">
<span>{{ _('Registra') }}</span>
</button>
<span x-show="preview && !saved" x-cloak
class="inline-block px-2 py-0.5 rounded text-xs font-bold bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700">{{ _('Anteprima, non registrata') }}</span>
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
{% endif %}
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
@@ -558,27 +579,49 @@
<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>
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
{# #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">
{# #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».') }}
</div>
</div>
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
{# #96: colonna esito con scorrimento proprio (solo con molte quote); sotto lg max 40% dell'altezza #}
<div class="lg:w-80 shrink-0 min-h-0 overflow-y-auto max-h-[40%] lg:max-h-none flex flex-col gap-2" x-show="result">
<div class="text-sm font-bold text-[var(--text-primary)]">
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
</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>
@@ -1146,13 +1189,23 @@
window.__visionCfg = {
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
grabUrl: '{{ url_for("measure.api_vision_grab") }}',
checkUrl: '{{ url_for("measure.api_vision_check") }}', {# #97: analisi senza salvare #}
cameraAvailable: {{ (camera_enabled|default(false))|tojson }},
modo: {{ (camera_mode|default('impulsiva'))|tojson }},
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>
@@ -1194,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
@@ -1300,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 }}
@@ -1313,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;
}
@@ -1323,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 }};
},
@@ -1406,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),
);
@@ -1889,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. */
@@ -1896,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 &lt;tmf-viewer&gt;</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&#39;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,182 @@
"""gitea:Adriano/TieMeasureFlow#97 - operator: image already analysed on entry.
The backend (POST /api/vision/check, task.acquisition_json) is built elsewhere:
api_client and requests are mocked here with the contract's shapes.
"""
import io
import json
from unittest.mock import MagicMock, patch
from tests.conftest import api_get_router
from tests.test_measure_camera_grab import (
PNG, RECIPE, STATION, TASK, _fake, _station,
)
ACQ = {"camera_url": "http://acq-host:9000", "camera_source": "camera:601020", "modo": "in_linea"}
def _task_with(acq):
return {**TASK, "acquisition_json": acq}
# --- grab: the camera of the task, else the station's -------------------------
def test_grab_with_task_id_uses_task_camera(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200 and resp.data == PNG
args, kwargs = post.call_args
assert args[0] == "http://acq-host:9000/camera/grab"
assert kwargs["json"]["source"] == "camera:601020"
mock_api_client.get_station_by_code.assert_not_called()
def test_grab_task_id_in_json_works_too(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", json={"task_id": 12})
assert resp.status_code == 200
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
def test_grab_task_without_acquisition_uses_station(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(None)})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200
args, kwargs = post.call_args
assert args[0] == "http://host.docker.internal:8765/camera/grab"
assert kwargs["json"]["source"] == "camera:folder"
def test_grab_task_camera_wins_even_if_station_has_none(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
def test_grab_task_lookup_error_falls_back_to_station(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
mock_api_client.get.side_effect = api_get_router(
{"/api/tasks/12": {"error": True, "status_code": 404, "detail": "x"}})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200
assert post.call_args[0][0] == "http://host.docker.internal:8765/camera/grab"
def test_grab_acquisition_as_json_string(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
mock_api_client.get.side_effect = api_get_router(
{"/api/tasks/12": _task_with(json.dumps(ACQ))})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
# --- check proxy ---------------------------------------------------------------
CHECK_OK = {
"outcome": "ok", "overlay": [], "failures": [], "calibration": "ok",
"measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}],
}
def test_check_proxy_forwards_multipart(logged_in_client, mock_api_client):
mock_api_client.post.return_value = CHECK_OK
resp = logged_in_client.post(
"/measure/api/vision/check",
data={
"task_id": "12", "production_run_id": "4",
"image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"),
},
content_type="multipart/form-data",
)
assert resp.status_code == 200
assert resp.get_json()["measurements"][0]["pass_fail"] == "pass"
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/check"
assert kwargs["data"] == {"task_id": "12", "production_run_id": "4"}
name, content, mime = kwargs["files"]["image"]
assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8")
def test_check_proxy_without_run_leaves_it_out(logged_in_client, mock_api_client):
mock_api_client.post.return_value = CHECK_OK
logged_in_client.post(
"/measure/api/vision/check",
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
content_type="multipart/form-data",
)
assert mock_api_client.post.call_args[1]["data"] == {"task_id": "12"}
def test_check_proxy_requires_image(logged_in_client, mock_api_client):
resp = logged_in_client.post("/measure/api/vision/check", data={"task_id": "12"})
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_check_proxy_relays_backend_error(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "nessun grafo"}
resp = logged_in_client.post(
"/measure/api/vision/check",
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
content_type="multipart/form-data",
)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "nessun grafo"
def test_check_requires_login(client):
resp = client.post("/measure/api/vision/check")
assert resp.status_code in (302, 401)
# --- execute page: buttons and config -------------------------------------------
def _page(logged_in_client, mock_api_client, monkeypatch, station, task):
_station(mock_api_client, monkeypatch, station)
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/12": task,
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
"/api/recipes/3": RECIPE,
})
return logged_in_client.get("/measure/execute/12").get_data(as_text=True)
def test_page_impulsive_has_acquire_and_record(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION, TASK)
assert 'cameraAvailable: true' in html
assert 'modo: "impulsiva"' in html
assert "/measure/api/vision/check" in html
assert "grab()" in html and "record()" in html
assert "togglePause()" not in html
assert "Anteprima, non registrata" in html
def test_page_in_line_has_pause_and_record(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION, _task_with(ACQ))
assert 'modo: "in_linea"' in html
assert "togglePause()" in html and "record()" in html
assert "grab()" not in html # the loop grabs by itself
def test_page_task_camera_enables_it_without_station_camera(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch,
{**STATION, "camera_url": None}, _task_with({**ACQ, "modo": "impulsiva"}))
assert 'cameraAvailable: true' in html
assert "grab()" in html
def test_page_without_camera_is_as_before(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch,
{**STATION, "camera_url": None}, TASK)
assert 'cameraAvailable: false' in html
assert "record()" not in html and "togglePause()" not in html and "grab()" not in html
@@ -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}"
)
@@ -0,0 +1,81 @@
"""gitea:Adriano/TieMeasureFlow#96 #122 - l'immagine del task visione sta in fit, senza 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
TEMPLATE = Path(__file__).resolve().parents[1] / "templates" / "measure" / "task_execute.html"
def _panel():
src = TEMPLATE.read_text(encoding="utf-8")
start = src.rindex("<div", 0, src.index('x-data="visionExecute()"'))
end = src.index("MEASUREMENT TIMER BANNER")
return src[start:end]
def _tag_with(panel, marker):
# Il tag (aperto) che contiene il marker; i '>' dentro le virgolette (es. "=>") non lo chiudono.
pos = panel.index(marker)
i = panel.rindex("<", 0, pos)
quote = None
for j in range(i, len(panel)):
c = panel[j]
if quote:
quote = None if c == quote else quote
elif c in "\"'":
quote = c
elif c == ">":
return panel[i:j + 1]
raise AssertionError(marker)
def test_panel_does_not_scroll():
tag = _tag_with(_panel(), 'x-data="visionExecute()"')
assert "overflow-hidden" in tag and "min-h-0" in tag
assert "overflow-auto" not in tag
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()
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():
panel = _panel()
box = _tag_with(panel, 'x-show="shownImage"')
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."
+66
View File
@@ -80,6 +80,9 @@ def _graph(dxf_sha: str, cal_sha: str, band_mm: float, min_points_pct: float, po
"band_plus": float(band_mm),
"band_minus": float(band_mm),
"nominal_unit": "mm",
# #127 (Adriano, 06/11/2026): LocateBlob normalizza l'angolo in (-90, 90], il pezzo a 180 o +-120 gradi
# verrebbe capovolto; "quarter" prova le pose ruotate (Core#343). Lo specchiato resta fuori.
"pose_search": "quarter",
# tolleranza di points_in_pct: nominale = limite, plus = 100 - limite
"points_in_pct_nominal": float(min_points_pct),
"points_in_pct_plus": float(100.0 - min_points_pct),
@@ -184,6 +187,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 +274,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
View File
@@ -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,
+69
View File
@@ -22,6 +22,7 @@ from src.vision.costruttori import (
DXF_FILE,
CostruttoreError,
_candidate_anchors,
_graph,
_maschera_pezzo,
build_dxf_compare,
)
@@ -94,6 +95,74 @@ def test_ricetta_dxf_con_riferimento_gia_ruotato(tmp_path):
assert risultato["outputs"]["dxf.points_in_pct"] > 95.0
# #127: pezzo a L (asimmetrico a 180 gradi: sample.dxf e' simmetrico e non distingue il
# capovolgimento). Un rettangolo con foro fuori centro NON basta: l'ICP di Core non lo risolve.
L_PROFILE_MM = [(-100, -50), (100, -50), (100, 50), (-20, 50), (-20, 10), (-100, 10)]
def _l_dxf(path: Path) -> bytes:
import ezdxf
doc = ezdxf.new()
doc.modelspace().add_lwpolyline(L_PROFILE_MM, close=True)
doc.saveas(str(path))
return path.read_bytes()
def _l_scene(angle_deg: float = 0.0) -> np.ndarray:
"""Il pezzo a L (scuro su bianco) a 4 px/mm; l'asse y del DXF e' verso l'alto."""
w, h = int(320 * PX_PER_MM), int(220 * PX_PER_MM)
cx, cy = w // 2, h // 2
img = np.full((h, w), 255, np.uint8)
pts = np.array([(cx + x * PX_PER_MM, cy - y * PX_PER_MM) for x, y in L_PROFILE_MM], np.int32)
cv2.fillPoly(img, [pts], 0)
if angle_deg:
m = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0)
img = cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_LINEAR, borderValue=255)
return img
def _pct_a_180(tmp_path, togli_quarter: bool) -> tuple[float, float]:
"""points_in_pct della ricetta costruita a 0 gradi: (sulla scena dritta, a 180 gradi)."""
risultato = build_dxf_compare(_l_scene(0.0), _l_dxf(tmp_path / "l.dxf"), PX_PER_MM, band_mm=2.0)
graph = risultato["graph"]
if togli_quarter: # misura di controllo: senza pose_search il pezzo a 180 va capovolto
for tool in graph["tools"]:
tool["values"].pop("pose_search", None)
for nome, b64 in risultato["files"].items():
(tmp_path / nome).write_bytes(base64.b64decode(b64))
def pct(img):
esito = run_graph(img, graph, calibration={"px_per_mm": PX_PER_MM}, recipe_dir=tmp_path)
assert not esito.failures, esito.failures
return esito.outputs["dxf.points_in_pct"]
return pct(_l_scene(0.0)), pct(_l_scene(180.0))
def test_ricetta_dxf_su_pezzo_capovolto_di_180(tmp_path):
# #127: con pose_search=quarter il pezzo a 180 gradi vale quanto quello dritto
pct_0, pct_180 = _pct_a_180(tmp_path, togli_quarter=False)
assert pct_0 >= 95.0
assert pct_180 >= 95.0 and abs(pct_180 - pct_0) <= 2.0
def test_ricetta_dxf_senza_quarter_il_pezzo_a_180_e_capovolto(tmp_path):
# #127: controllo del test sopra - senza pose_search il disegno finisce capovolto
_, pct_180 = _pct_a_180(tmp_path, togli_quarter=True)
assert pct_180 < 20.0 # misurato ~2,8% (Core#343 riporta 3,3% su PR165)
def test_graph_scrive_pose_search_quarter_nel_compare_dxf():
# #127: il CompareDxf porta pose_search=quarter solo se c'e' l'ancora
con = _graph("a" * 64, "b" * 64, 1.0, 95.0, "dark",
{"row": 0.0, "col": 0.0, "angle_deg": 0.0}, (1.0, 2.0, 3.0))
dxf_tool = next(t for t in con["tools"] if t["kind"] == "CompareDxf")
assert dxf_tool["values"]["pose_search"] == "quarter"
senza = _graph("a" * 64, "b" * 64, 1.0, 95.0, "dark",
{"row": 0.0, "col": 0.0, "angle_deg": 0.0}, None)
assert [t["kind"] for t in senza["tools"]] == ["LocateBlob"]
def test_immagine_senza_pezzo_solleva():
bianca = np.full((600, 800), 255, np.uint8)
with pytest.raises(CostruttoreError, match="pezzo"):
+25
View File
@@ -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")
+90 -1
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More