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
@@ -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()