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
This commit is contained in:
2026-10-28 21:21:33 +01:00
6 changed files with 287 additions and 51 deletions
@@ -20,9 +20,6 @@
* Extra config: cameraAvailable, modo, checkUrl. * Extra config: cameraAvailable, modo, checkUrl.
*/ */
function visionExecute() { function visionExecute() {
// Overlay colour per role (VisionSuite roles: reference, roi, found).
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
return { return {
busy: false, busy: false,
grabbing: false, // frame requested from the station camera (#72) grabbing: false, // frame requested from the station camera (#72)
@@ -50,6 +47,14 @@ function visionExecute() {
// #97: start on a frame already acquired and analysed; hook visibility and exit. // #97: start on a frame already acquired and analysed; hook visibility and exit.
init() { 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;
this.preview = !!this.result;
this.kick();
});
// #121: l'overlay SVG si ridisegna a ogni cambio di zoom (spessore e croci costanti a video)
if (typeof this.$watch === 'function') this.$watch('z.zoom', () => this.drawOverlay());
if (!this.hasCamera) return; if (!this.hasCamera) return;
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); }); document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
const leave = () => { this.leaving = true; }; const leave = () => { this.leaving = true; };
@@ -150,6 +155,7 @@ function visionExecute() {
if (!this.error && this.result && this.result.vision_result_id) { if (!this.error && this.result && this.result.vision_result_id) {
this.preview = false; this.preview = false;
this.saved = true; this.saved = true;
this._emitRecorded(); // #120: la pagina conta le quote registrate
} }
} finally { } finally {
this.registering = false; this.registering = false;
@@ -186,7 +192,9 @@ function visionExecute() {
return; return;
} }
this.result = body; this.result = body;
// Draw once Alpine has rendered the canvas. // #120: senza camera il file scelto e' salvato subito (nessun «Registra»): stesso evento
if (!this.hasCamera && body.vision_result_id) this._emitRecorded();
// Draw once Alpine has rendered the svg.
this.$nextTick(() => this.drawOverlay()); this.$nextTick(() => this.drawOverlay());
} catch (e) { } catch (e) {
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
@@ -195,6 +203,19 @@ 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 } }));
},
// #119: «Salva» = frame grezzo PNG dell'immagine mostrata, senza disegni (save_image.js)
saveImage() {
const cfg = window.__visionCfg || {};
if (!this.shownImage || typeof tmfSaveImage !== 'function') return Promise.resolve(false);
return tmfSaveImage(this.shownImage, cfg.taskName || 'immagine');
},
// Station camera (#72): the Flask proxy answers a PNG, which goes through // Station camera (#72): the Flask proxy answers a PNG, which goes through
// acquire() like a picked file. Errors show as the execute ones do. // 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 // #97: sends task_id (the task's camera wins over the station's); with a camera
@@ -385,6 +406,11 @@ function visionExecute() {
}, },
zPanEnd() { this._zPan = null; this.zPanning = false; }, zPanEnd() { this._zPan = null; this.zPanning = false; },
// #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) // #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); }, fmtTol(v) { return v == null ? '\u2014' : Number(v).toFixed(3); },
@@ -420,32 +446,14 @@ function visionExecute() {
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]'; 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) // #121: overlay SVG (VisionOverlay.svg) nel wrapper dello zoom: viewBox = dimensioni naturali, quindi
// are used as they are and CSS scales the whole thing to the displayed size. // le coordinate (px immagine) valgono cosi' come sono; spessore e croci restano costanti a ogni zoom
drawOverlay() { drawOverlay() {
const img = this.$refs.img, cv = this.$refs.overlay; const img = this.$refs.img, sv = this.$refs.overlay;
if (!img || !cv || !img.naturalWidth) return; if (!sv) return;
cv.width = img.naturalWidth; if (img && img.naturalWidth) sv.setAttribute('viewBox', '0 0 ' + img.naturalWidth + ' ' + img.naturalHeight);
cv.height = img.naturalHeight; const ok = typeof VisionOverlay !== 'undefined' && typeof VisionOverlay.svg === 'function';
const g = cv.getContext('2d'); sv.innerHTML = ok && this.result ? VisionOverlay.svg(this.result.overlay || [], { zoom: this.z.zoom }) : '';
g.clearRect(0, 0, cv.width, cv.height);
const lw = Math.max(2, cv.width / 400);
g.lineWidth = lw;
g.lineJoin = 'round';
// #116: tutte le forme (rect2, segment, circle, arc...) le disegna vision_overlay.js; il ciclo sotto resta come ripiego
if (typeof VisionOverlay !== 'undefined') {
VisionOverlay.draw(g, (this.result && this.result.overlay) || [], lw);
return;
}
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();
}
}, },
}; };
} }
@@ -69,7 +69,8 @@
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)" @numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
@numpad-rejected.window="onNumpadRejected($event.detail)" @numpad-rejected.window="onNumpadRejected($event.detail)"
@numpad-error.window="onNumpadError($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 HEADER — Compact task info bar
@@ -539,13 +540,14 @@
<div class="flex-1 min-w-0 min-h-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"> <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' : ''"> <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> <span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
<input type="file" accept="image/*" capture="environment" class="hidden" <input type="file" accept="image/*" capture="environment" class="hidden"
@change="onPick($event)" :disabled="busy"> @change="onPick($event)" :disabled="busy">
</label> </label>
{% if camera_enabled %} {% else %}
{# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #} {# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #}
{% if camera_mode == 'in_linea' %} {% if camera_mode == 'in_linea' %}
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()" <button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()"
@@ -558,7 +560,7 @@
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()" <button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''" :class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
:disabled="busy || grabbing"> :disabled="busy || grabbing">
<span>{{ _('Acquisisci') }}</span> <span>{{ _('Acquisisci da camera') }}</span> {# #119 #}
</button> </button>
{% endif %} {% endif %}
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #} {# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
@@ -576,7 +578,7 @@
<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="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
{# #96: riquadro flex-1 min-h-0; #115: dentro, barra zoom in overlay e viewport scorrevole con il wrapper (naturale x zoom) che contiene img e canvas; il canvas ha la stessa risoluzione naturale dell'immagine, quindi l'overlay resta allineato #} {# #96: riquadro flex-1 min-h-0; #115: dentro, barra zoom in overlay e viewport scorrevole con il wrapper (naturale x zoom) che contiene img e svg; #121: l'svg ha il viewBox delle dimensioni naturali dell'immagine, quindi l'overlay resta allineato #}
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5"> <div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
{# #115: x-show sul contenitore della barra, mai sul viewport (con :style stringa Alpine lo pesterebbe) #} {# #115: x-show sul contenitore della barra, mai sul viewport (con :style stringa Alpine lo pesterebbe) #}
<div x-show="z.w" class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5" <div x-show="z.w" class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
@@ -584,6 +586,8 @@
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(0.8)" title="{{ _('Riduci') }}">&minus;</button> <button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(0.8)" title="{{ _('Riduci') }}">&minus;</button>
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button> <button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button>
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zFit()">{{ _('Adatta') }}</button> <button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zFit()">{{ _('Adatta') }}</button>
{# #119: salva il frame grezzo PNG (senza disegni); la barra compare solo con un'immagine (x-show="z.w") #}
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="saveImage()" title="{{ _('Salva immagine') }}">{{ _('Salva') }}</button>
<span class="text-xs px-1" x-text="zPct()"></span> <span class="text-xs px-1" x-text="zPct()"></span>
</div> </div>
{# #115: viewport = tutto il riquadro; solo :style stringa (uno style statico verrebbe cancellato da Alpine) #} {# #115: viewport = tutto il riquadro; solo :style stringa (uno style statico verrebbe cancellato da Alpine) #}
@@ -593,8 +597,9 @@
:style="'overflow:auto;touch-action:none;user-select:none;display:flex;cursor:' + zCursor()"> :style="'overflow:auto;touch-action:none;user-select:none;display:flex;cursor:' + zCursor()">
<div class="relative" :style="'margin:auto;flex:none;' + zSize()"> <div class="relative" :style="'margin:auto;flex:none;' + zSize()">
<img x-ref="img" :src="shownImage" @load="onImgLoad()" alt="" draggable="false" class="absolute inset-0 w-full h-full object-contain"> <img x-ref="img" :src="shownImage" @load="onImgLoad()" alt="" draggable="false" class="absolute inset-0 w-full h-full object-contain">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas> {# #121: overlay SVG al posto del canvas: viewBox = dimensioni naturali (le imposta drawOverlay), tratto costante a ogni zoom #}
{# #116: etichette quota in HTML sopra il canvas (posizione in %, dimensione costante a ogni zoom); solo :style stringa #} <svg x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="none"></svg>
{# #116: etichette quota in HTML sopra l'svg (posizione in %, dimensione costante a ogni zoom); solo :style stringa #}
<template x-for="l in overlayLabels" :key="l.key"> <template x-for="l in overlayLabels" :key="l.key">
<div class="absolute pointer-events-none whitespace-nowrap text-xs font-bold px-1.5 py-0.5 rounded shadow" <div class="absolute pointer-events-none whitespace-nowrap text-xs font-bold px-1.5 py-0.5 rounded shadow"
:class="l.cls" :style="l.style" x-text="l.text"></div> :class="l.cls" :style="l.style" x-text="l.text"></div>
@@ -619,7 +624,8 @@
<tbody> <tbody>
<tr> <tr>
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td> <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> {# #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> <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>
<tr class="border-b border-[var(--border-color)]"> <tr class="border-b border-[var(--border-color)]">
@@ -1209,12 +1215,15 @@
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}', imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
taskId: {{ task.id|tojson }}, taskId: {{ task.id|tojson }},
taskType: {{ (task.task_type or '')|tojson }}, taskType: {{ (task.task_type or '')|tojson }},
taskName: {{ (task.title or '')|tojson }}, {# #119: nome proposto per «Salva» #}
subtasks: window.__taskSubtasks, subtasks: window.__taskSubtasks,
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} }, labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
}; };
</script> </script>
{# #116: disegno overlay ed etichette quote; prima di vision_execute.js, che lo usa se presente #} {# #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> <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>
<script src="{{ url_for('static', filename='js/vision_execute.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) #} {# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
<script> <script>
@@ -1256,6 +1265,7 @@ function taskExecute() {
// ---- Measurement state ---- // ---- Measurement state ----
currentIndex: 0, currentIndex: 0,
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...] 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, saving: false,
// The single reentrancy guard for handleMeasurement (#13): true for the // The single reentrancy guard for handleMeasurement (#13): true for the
// whole time a reading is being processed, including the 1s feedback // whole time a reading is being processed, including the 1s feedback
@@ -1362,6 +1372,8 @@ function taskExecute() {
}, },
get missingQuotesLabel() { 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; const n = this.missingQuotes;
return n === 1 return n === 1
? {{ _('Manca 1 quota')|tojson }} ? {{ _('Manca 1 quota')|tojson }}
@@ -1375,6 +1387,8 @@ function taskExecute() {
ciclo misura», and the quotes it exists to collect would be optional. */ ciclo misura», and the quotes it exists to collect would be optional. */
get canLeaveTask() { get canLeaveTask() {
if (this.blockedByTolerance) return false; 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) { if (this.isMeasureTask && this.subtasks.length > 0) {
return this.cycleConfirmed; return this.cycleConfirmed;
} }
@@ -1385,6 +1399,7 @@ function taskExecute() {
if (this.blockedByTolerance) { if (this.blockedByTolerance) {
return {{ _("Serve l'autorizzazione del capoturno")|tojson }}; return {{ _("Serve l'autorizzazione del capoturno")|tojson }};
} }
if (this.isVisionTask && !this.visionRecorded) return this.missingQuotesLabel; // #120
if (!this.isComplete) return this.missingQuotesLabel; if (!this.isComplete) return this.missingQuotesLabel;
return {{ _('Chiudi prima il ciclo di misura')|tojson }}; return {{ _('Chiudi prima il ciclo di misura')|tojson }};
}, },
@@ -1468,9 +1483,10 @@ function taskExecute() {
subtask_id: m.subtask_id, subtask_id: m.subtask_id,
value: m.value, value: m.value,
pass_fail: m.pass_fail, 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( const next = this.subtasks.findIndex(
s => !this.measurements.some(m => m.subtask_id === s.id), s => !this.measurements.some(m => m.subtask_id === s.id),
); );
@@ -1951,6 +1967,23 @@ function taskExecute() {
this.redirectIn = this.measurementTaskUrl ? 5 : 0; 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. /* Clear the screen for another pass over the same quotes.
Local only: everything already measured is on the server, and the next Local only: everything already measured is on the server, and the next
reading is saved beside it rather than replacing it. */ reading is saved beside it rather than replacing it. */
@@ -1958,6 +1991,7 @@ function taskExecute() {
this.cycleConfirmed = false; this.cycleConfirmed = false;
this.showCompletionOverlay = false; this.showCompletionOverlay = false;
this.measurements = []; this.measurements = [];
this.visionRecorded = false; // #120: nuovo ciclo = pezzo da registrare di nuovo
this.currentIndex = 0; this.currentIndex = 0;
this.currentValue = null; this.currentValue = null;
this.errorMessage = ''; this.errorMessage = '';
@@ -13,7 +13,12 @@ const src = fs.readFileSync(
); );
// Contesto vm con window e document finti; il file definisce visionExecute() globale // Contesto vm con window e document finti; il file definisce visionExecute() globale
const win = {}; const win = {};
const ctx = vm.createContext({ window: win, document: { addEventListener() {}, querySelector() { return null; } } }); // #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); vm.runInContext(src + ';this.visionExecute = visionExecute;', ctx);
let failures = 0; let failures = 0;
@@ -108,15 +113,97 @@ check('#116 labelClass warning', c3.labelClass('warning') === 'bg-amber-400 text
check('#116 labelClass fail', c3.labelClass('fail') === 'bg-red-600 text-white'); 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'); check('#116 labelClass altro', c3.labelClass('boh') === 'bg-gray-700 text-white' && c3.labelClass(undefined) === 'bg-gray-700 text-white');
// drawOverlay usa VisionOverlay.draw con lw = max(2, w/400) // #121: drawOverlay imposta il viewBox naturale e riempie l'svg con VisionOverlay.svg(overlay, {zoom})
let drawn = null; let svgArgs = null;
ctx.VisionOverlay = { draw(g, overlay, lw) { drawn = { g, overlay, lw }; return { drawn: 1, skipped: 0 }; } }; ctx.VisionOverlay = { svg(overlay, opts) { svgArgs = { overlay, opts }; return '<circle cx="1" cy="2" r="3"/>'; } };
const gfake = { clearRect() {}, set lineWidth(v) {}, set lineJoin(v) {} }; const attrs = {};
const cv = { width: 0, height: 0, getContext() { return gfake; } }; const sv = { innerHTML: 'vecchio', setAttribute(k, v) { attrs[k] = v; } };
c3.$refs = { img: { naturalWidth: 2000, naturalHeight: 1000 }, overlay: cv }; c3.$refs = { img: { naturalWidth: 2000, naturalHeight: 1000 }, overlay: sv };
c3.result = { overlay: [{ type: 'circle' }] };
c3.z.zoom = 0.5;
c3.drawOverlay();
check('#121 viewBox = dimensioni naturali', attrs.viewBox === '0 0 2000 1000', attrs.viewBox);
check('#121 innerHTML = VisionOverlay.svg', sv.innerHTML === '<circle cx="1" cy="2" r="3"/>', sv.innerHTML);
check('#121 svg riceve overlay e zoom', svgArgs.overlay.length === 1 && svgArgs.opts.zoom === 0.5, JSON.stringify(svgArgs.opts));
c3.result = null;
c3.drawOverlay();
check('#121 senza risultato: svg vuoto', sv.innerHTML === '');
ctx.VisionOverlay = {};
c3.result = { overlay: [{ type: 'circle' }] }; c3.result = { overlay: [{ type: 'circle' }] };
c3.drawOverlay(); c3.drawOverlay();
check('#116 drawOverlay chiama VisionOverlay.draw', drawn && drawn.g === gfake && drawn.overlay.length === 1 && drawn.lw === 5, JSON.stringify(drawn && drawn.lw)); check('#121 senza VisionOverlay.svg: svg vuoto, nessun errore', sv.innerHTML === '');
check('#116 canvas alla risoluzione naturale', cv.width === 2000 && cv.height === 1000); check('#121 niente codice canvas', !/getContext|clearRect|\.width\s*=/.test(src.slice(src.indexOf('drawOverlay() {'))));
process.exit(failures ? 1 : 0); // #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);
// #119: saveImage -> tmfSaveImage(src dell'immagine mostrata, nome del task)
let saveArgs = null;
ctx.tmfSaveImage = (src, name) => { saveArgs = { src, name }; return Promise.resolve(true); };
win.__visionCfg = { taskName: 'Roto3', subtasks: [] };
const c9 = ctx.visionExecute();
c9.imageSrc = 'blob:abc';
const r9 = await c9.saveImage();
check('#119 saveImage: tmfSaveImage(src, nome task)', r9 === true && saveArgs.src === 'blob:abc' && saveArgs.name === 'Roto3', JSON.stringify(saveArgs));
c9.imageSrc = '';
saveArgs = null;
check('#119 saveImage senza immagine: niente', (await c9.saveImage()) === false && saveArgs === null);
process.exit(failures ? 1 : 0);
})();
@@ -108,4 +108,18 @@ check('#115 pan: scroll segue il puntatore', c.$refs.vp.scrollLeft === 70 && c.$
c.zPanEnd(); c.zPanEnd();
check('#115 pan finito', c._zPan === null && c.zPanning === false); check('#115 pan finito', c._zPan === null && c.zPanning === false);
// #121: init registra un watcher su z.zoom che ridisegna l'overlay SVG (spessore e croci costanti a ogni zoom)
{
const w = make(800, 400);
const watched = {};
w.$watch = (expr, fn) => { watched[expr] = fn; };
win.addEventListener = () => {}; // init ascolta task-result-reset su window
let redraws = 0;
w.drawOverlay = () => { redraws++; };
w.init();
check('#121 init: watcher su z.zoom', typeof watched['z.zoom'] === 'function');
watched['z.zoom']();
check('#121 cambio zoom: overlay ridisegnato', redraws === 1, String(redraws));
}
process.exit(failures ? 1 : 0); process.exit(failures ? 1 : 0);
@@ -0,0 +1,89 @@
"""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_in_zoom_bar_between_adatta_and_percent():
# #119: «Salva» dopo «Adatta», prima della percentuale; script save_image.js incluso
panel = _panel()
assert panel.index("{{ _('Adatta') }}") < panel.index("{{ _('Salva') }}") < panel.index('x-text="zPct()"')
assert '@click="saveImage()"' in panel and "{{ _('Salva immagine') }}" in panel
assert "js/save_image.js" in SRC
assert "taskName:" in SRC
def test_overlay_is_svg_not_canvas():
# #121: svg dentro il wrapper dello zoom, nessun canvas nel pannello
panel = _panel()
assert '<svg x-ref="overlay"' in panel and "<canvas" not in panel
assert panel.index('zSize()') < panel.index('<svg x-ref="overlay"') < panel.index('x-for="l in overlayLabels"')
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
@@ -42,8 +42,12 @@ def test_image_is_object_contain_without_h_auto():
img = _tag_with(panel, 'x-ref="img"') img = _tag_with(panel, 'x-ref="img"')
assert "object-contain" in img assert "object-contain" in img
assert "h-auto" not in img assert "h-auto" not in img
canvas = _tag_with(panel, 'x-ref="overlay"') # #121: l'overlay e' un svg (non piu' un canvas) con viewBox naturale e preserveAspectRatio="none":
assert "object-contain" in canvas # stesso fit dell'img: overlay allineato # riempie il wrapper come l'img (che e' esattamente naturale x zoom), quindi resta allineato
overlay = _tag_with(panel, 'x-ref="overlay"')
assert overlay.startswith("<svg") and "<canvas" not in panel
assert 'preserveAspectRatio="none"' in overlay and "pointer-events-none" in overlay
assert "absolute" in overlay and "inset-0" in overlay and "w-full" in overlay and "h-full" in overlay
def test_zoom_viewport_scrolls_inside_the_box(): def test_zoom_viewport_scrolls_inside_the_box():
@@ -74,7 +78,7 @@ def test_quote_table_shows_nominal_and_tolerances():
def test_overlay_script_loaded_before_vision_execute_and_labels_layer(): def test_overlay_script_loaded_before_vision_execute_and_labels_layer():
# #116: vision_overlay.js prima di vision_execute.js; strato etichette dentro il wrapper dello zoom, dopo il canvas # #116: vision_overlay.js prima di vision_execute.js; strato etichette dentro il wrapper dello zoom, dopo l'svg
src = TEMPLATE.read_text(encoding="utf-8") 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/vision_execute.js")
panel = _panel() panel = _panel()