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>
This commit is contained in:
@@ -20,9 +20,6 @@
|
||||
* Extra config: cameraAvailable, modo, checkUrl.
|
||||
*/
|
||||
function visionExecute() {
|
||||
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
||||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||
|
||||
return {
|
||||
busy: false,
|
||||
grabbing: false, // frame requested from the station camera (#72)
|
||||
@@ -50,6 +47,14 @@ function visionExecute() {
|
||||
|
||||
// #97: start on a frame already acquired and analysed; hook visibility and exit.
|
||||
init() {
|
||||
// #120: «Rimisura»/nuovo ciclo (task-result-reset): il pezzo va registrato di nuovo, anche senza camera
|
||||
window.addEventListener('task-result-reset', () => {
|
||||
this.saved = false;
|
||||
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;
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
|
||||
const leave = () => { this.leaving = true; };
|
||||
@@ -150,6 +155,7 @@ function visionExecute() {
|
||||
if (!this.error && this.result && this.result.vision_result_id) {
|
||||
this.preview = false;
|
||||
this.saved = true;
|
||||
this._emitRecorded(); // #120: la pagina conta le quote registrate
|
||||
}
|
||||
} finally {
|
||||
this.registering = false;
|
||||
@@ -186,7 +192,9 @@ function visionExecute() {
|
||||
return;
|
||||
}
|
||||
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());
|
||||
} catch (e) {
|
||||
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
|
||||
// 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
|
||||
@@ -385,6 +406,11 @@ function visionExecute() {
|
||||
},
|
||||
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)
|
||||
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)]';
|
||||
},
|
||||
|
||||
// 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.
|
||||
// #121: overlay SVG (VisionOverlay.svg) nel wrapper dello zoom: viewBox = dimensioni naturali, quindi
|
||||
// le coordinate (px immagine) valgono cosi' come sono; spessore e croci restano costanti a ogni zoom
|
||||
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);
|
||||
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();
|
||||
}
|
||||
const img = this.$refs.img, sv = this.$refs.overlay;
|
||||
if (!sv) return;
|
||||
if (img && img.naturalWidth) sv.setAttribute('viewBox', '0 0 ' + img.naturalWidth + ' ' + img.naturalHeight);
|
||||
const ok = typeof VisionOverlay !== 'undefined' && typeof VisionOverlay.svg === 'function';
|
||||
sv.innerHTML = ok && this.result ? VisionOverlay.svg(this.result.overlay || [], { zoom: this.z.zoom }) : '';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -539,13 +540,14 @@
|
||||
|
||||
<div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
|
||||
{% if not camera_enabled %}
|
||||
{# #119: l'input file solo senza camera. On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
|
||||
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
|
||||
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
|
||||
<input type="file" accept="image/*" capture="environment" class="hidden"
|
||||
@change="onPick($event)" :disabled="busy">
|
||||
</label>
|
||||
{% if camera_enabled %}
|
||||
{% else %}
|
||||
{# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #}
|
||||
{% if camera_mode == 'in_linea' %}
|
||||
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()"
|
||||
@@ -558,7 +560,7 @@
|
||||
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
|
||||
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
|
||||
:disabled="busy || grabbing">
|
||||
<span>{{ _('Acquisisci') }}</span>
|
||||
<span>{{ _('Acquisisci da camera') }}</span> {# #119 #}
|
||||
</button>
|
||||
{% endif %}
|
||||
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
|
||||
@@ -576,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>
|
||||
|
||||
{# #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">
|
||||
{# #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"
|
||||
@@ -584,6 +586,8 @@
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(0.8)" title="{{ _('Riduci') }}">−</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>
|
||||
{# #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>
|
||||
</div>
|
||||
{# #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()">
|
||||
<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">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas>
|
||||
{# #116: etichette quota in HTML sopra il canvas (posizione in %, dimensione costante a ogni zoom); solo :style stringa #}
|
||||
{# #121: overlay SVG al posto del canvas: viewBox = dimensioni naturali (le imposta drawOverlay), tratto costante a ogni zoom #}
|
||||
<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">
|
||||
<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>
|
||||
@@ -619,7 +624,8 @@
|
||||
<tbody>
|
||||
<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>
|
||||
{# #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)]">
|
||||
@@ -1209,12 +1215,15 @@
|
||||
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>
|
||||
<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>
|
||||
@@ -1256,6 +1265,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
|
||||
@@ -1362,6 +1372,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 }}
|
||||
@@ -1375,6 +1387,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;
|
||||
}
|
||||
@@ -1385,6 +1399,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 }};
|
||||
},
|
||||
@@ -1468,9 +1483,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),
|
||||
);
|
||||
@@ -1951,6 +1967,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. */
|
||||
@@ -1958,6 +1991,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 = '';
|
||||
|
||||
@@ -13,7 +13,12 @@ const src = fs.readFileSync(
|
||||
);
|
||||
// Contesto vm con window e document finti; il file definisce visionExecute() globale
|
||||
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);
|
||||
|
||||
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 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)
|
||||
let drawn = null;
|
||||
ctx.VisionOverlay = { draw(g, overlay, lw) { drawn = { g, overlay, lw }; return { drawn: 1, skipped: 0 }; } };
|
||||
const gfake = { clearRect() {}, set lineWidth(v) {}, set lineJoin(v) {} };
|
||||
const cv = { width: 0, height: 0, getContext() { return gfake; } };
|
||||
c3.$refs = { img: { naturalWidth: 2000, naturalHeight: 1000 }, overlay: cv };
|
||||
// #121: drawOverlay imposta il viewBox naturale e riempie l'svg con VisionOverlay.svg(overlay, {zoom})
|
||||
let svgArgs = null;
|
||||
ctx.VisionOverlay = { svg(overlay, opts) { svgArgs = { overlay, opts }; return '<circle cx="1" cy="2" r="3"/>'; } };
|
||||
const attrs = {};
|
||||
const sv = { innerHTML: 'vecchio', setAttribute(k, v) { attrs[k] = v; } };
|
||||
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.drawOverlay();
|
||||
check('#116 drawOverlay chiama VisionOverlay.draw', drawn && drawn.g === gfake && drawn.overlay.length === 1 && drawn.lw === 5, JSON.stringify(drawn && drawn.lw));
|
||||
check('#116 canvas alla risoluzione naturale', cv.width === 2000 && cv.height === 1000);
|
||||
check('#121 senza VisionOverlay.svg: svg vuoto, nessun errore', sv.innerHTML === '');
|
||||
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();
|
||||
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);
|
||||
|
||||
@@ -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"')
|
||||
assert "object-contain" in img
|
||||
assert "h-auto" not in img
|
||||
canvas = _tag_with(panel, 'x-ref="overlay"')
|
||||
assert "object-contain" in canvas # stesso fit dell'img: overlay allineato
|
||||
# #121: l'overlay e' un svg (non piu' un canvas) con viewBox naturale e preserveAspectRatio="none":
|
||||
# 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():
|
||||
@@ -74,7 +78,7 @@ def test_quote_table_shows_nominal_and_tolerances():
|
||||
|
||||
|
||||
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")
|
||||
assert src.index("js/vision_overlay.js") < src.index("js/vision_execute.js")
|
||||
panel = _panel()
|
||||
|
||||
Reference in New Issue
Block a user