Pannello visione: zoom, Adatta e trascinamento sull'immagine
Refs #115 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,13 @@ function visionExecute() {
|
|||||||
looping: false, // an in-line loop is running
|
looping: false, // an in-line loop is running
|
||||||
gen: 0, // bumped by «Registra»: answers of older requests are dropped
|
gen: 0, // bumped by «Registra»: answers of older requests are dropped
|
||||||
|
|
||||||
|
// #115: zoom del riquadro immagine (stesso schema del wizard DXF, #113). zoom = px schermo per px immagine;
|
||||||
|
// fit = zoom di «Adatta»; w/h = dimensioni naturali; auto = la vista e' ad Adatta (segue il resize del riquadro)
|
||||||
|
z: { zoom: 1, fit: 1, w: 0, h: 0, auto: true },
|
||||||
|
zPanning: false, // #115: trascinamento in corso (cursore grabbing)
|
||||||
|
_zPan: null,
|
||||||
|
_zRO: null,
|
||||||
|
|
||||||
// #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() {
|
||||||
if (!this.hasCamera) return;
|
if (!this.hasCamera) return;
|
||||||
@@ -263,6 +270,115 @@ function visionExecute() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// #115: load dell'immagine (anche a ogni frame in linea): dimensioni naturali, fit solo se serve, poi overlay
|
||||||
|
onImgLoad() {
|
||||||
|
const img = this.$refs.img;
|
||||||
|
if (img && img.naturalWidth) {
|
||||||
|
const z = this.z;
|
||||||
|
const changed = z.w !== img.naturalWidth || z.h !== img.naturalHeight;
|
||||||
|
z.w = img.naturalWidth;
|
||||||
|
z.h = img.naturalHeight;
|
||||||
|
// dimensioni uguali e vista ingrandita: il frame nuovo resta ingrandito (in linea ogni frame ricarica l'immagine)
|
||||||
|
if (changed || z.auto) this.zFit();
|
||||||
|
this._zObserve();
|
||||||
|
}
|
||||||
|
this.drawOverlay();
|
||||||
|
},
|
||||||
|
|
||||||
|
// #115: osservatore di resize sul viewport: se la vista e' ad Adatta (o il riquadro era nascosto) rifa il fit
|
||||||
|
_zObserve() {
|
||||||
|
const vp = this.$refs.vp;
|
||||||
|
if (!vp || this._zRO || typeof ResizeObserver === 'undefined') return;
|
||||||
|
this._zRO = new ResizeObserver(() => { if (this.z.w > 0 && this.z.auto) this.zFit(); });
|
||||||
|
this._zRO.observe(vp);
|
||||||
|
},
|
||||||
|
|
||||||
|
// #115: area utile del viewport; offset meno i bordi (client* escludono le barre di scorrimento che spariranno dopo il fit, come #113)
|
||||||
|
_zArea() {
|
||||||
|
const vp = this.$refs.vp;
|
||||||
|
if (!vp) return { cw: 0, ch: 0 };
|
||||||
|
let cw = vp.clientWidth, ch = vp.clientHeight;
|
||||||
|
if (vp.offsetWidth && typeof getComputedStyle === 'function') {
|
||||||
|
const cs = getComputedStyle(vp);
|
||||||
|
cw = vp.offsetWidth - (parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth) || 0);
|
||||||
|
ch = vp.offsetHeight - (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) || 0);
|
||||||
|
}
|
||||||
|
return { cw, ch };
|
||||||
|
},
|
||||||
|
|
||||||
|
// #115: Adatta = tutta l'immagine nel riquadro (come il fit di #96)
|
||||||
|
zFit() {
|
||||||
|
const vp = this.$refs.vp, z = this.z;
|
||||||
|
if (!vp || !z.w || !z.h) return;
|
||||||
|
const a = this._zArea();
|
||||||
|
if (!(a.cw > 0) || !(a.ch > 0)) return; // riquadro nascosto o vuoto: niente, ci pensa l'osservatore
|
||||||
|
const f = Math.min(a.cw / z.w, a.ch / z.h);
|
||||||
|
z.fit = f;
|
||||||
|
z.zoom = f;
|
||||||
|
z.auto = true;
|
||||||
|
vp.scrollLeft = 0;
|
||||||
|
vp.scrollTop = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #115: zoom di un fattore tenendo fermo il punto (cx, cy) del viewport (default: il centro)
|
||||||
|
zoomBy(f, cx, cy) {
|
||||||
|
const vp = this.$refs.vp, z = this.z;
|
||||||
|
if (!vp || !z.w) return;
|
||||||
|
const old = z.zoom;
|
||||||
|
const nz = Math.min(Math.max(8, z.fit), Math.max(z.fit * 0.5, old * f));
|
||||||
|
if (nz === old) return;
|
||||||
|
if (cx == null) cx = vp.clientWidth / 2;
|
||||||
|
if (cy == null) cy = vp.clientHeight / 2;
|
||||||
|
const left = vp.scrollLeft, top = vp.scrollTop;
|
||||||
|
z.zoom = nz;
|
||||||
|
z.auto = false;
|
||||||
|
const apply = () => {
|
||||||
|
vp.scrollLeft = (left + cx) * nz / old - cx;
|
||||||
|
vp.scrollTop = (top + cy) * nz / old - cy;
|
||||||
|
};
|
||||||
|
if (typeof this.$nextTick === 'function') this.$nextTick(apply); else apply();
|
||||||
|
},
|
||||||
|
|
||||||
|
// #115: Ctrl+rotella = zoom sul punto del cursore; senza Ctrl la pagina scorre come sempre
|
||||||
|
zWheel(ev) {
|
||||||
|
if (!ev.ctrlKey) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const vp = this.$refs.vp;
|
||||||
|
let cx, cy;
|
||||||
|
if (vp && vp.getBoundingClientRect) {
|
||||||
|
const r = vp.getBoundingClientRect();
|
||||||
|
cx = ev.clientX - r.left;
|
||||||
|
cy = ev.clientY - r.top;
|
||||||
|
}
|
||||||
|
this.zoomBy(ev.deltaY < 0 ? 1.25 : 1 / 1.25, cx, cy);
|
||||||
|
},
|
||||||
|
|
||||||
|
zCanPan() { const z = this.z; return z.w > 0 && z.zoom > z.fit * 1.0001; },
|
||||||
|
zCursor() { return this.zCanPan() ? (this.zPanning ? 'grabbing' : 'grab') : 'default'; },
|
||||||
|
zPct() { return Math.round(this.z.zoom * 100) + '%'; },
|
||||||
|
// #115: dimensione del wrapper: naturale x zoom; prima del load riempie il riquadro
|
||||||
|
zSize() {
|
||||||
|
const z = this.z;
|
||||||
|
return z.w ? 'width:' + (z.w * z.zoom) + 'px;height:' + (z.h * z.zoom) + 'px' : 'width:100%;height:100%';
|
||||||
|
},
|
||||||
|
|
||||||
|
// #115: trascinamento = scorrimento del viewport (solo se ingrandito)
|
||||||
|
zPanDown(ev) {
|
||||||
|
const vp = this.$refs.vp;
|
||||||
|
if (!vp || !this.zCanPan() || (ev.pointerType === 'mouse' && ev.button)) return;
|
||||||
|
if (ev.preventDefault) ev.preventDefault();
|
||||||
|
this._zPan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
|
||||||
|
this.zPanning = true;
|
||||||
|
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
|
||||||
|
},
|
||||||
|
zPanMove(ev) {
|
||||||
|
const vp = this.$refs.vp, p = this._zPan;
|
||||||
|
if (!p || !vp) return;
|
||||||
|
vp.scrollLeft = p.left - (ev.clientX - p.x);
|
||||||
|
vp.scrollTop = p.top - (ev.clientY - p.y);
|
||||||
|
},
|
||||||
|
zPanEnd() { this._zPan = null; this.zPanning = false; },
|
||||||
|
|
||||||
statusClass(s) {
|
statusClass(s) {
|
||||||
// Same palette as the measurement markers of the manual flow.
|
// 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';
|
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
|
||||||
|
|||||||
@@ -576,11 +576,27 @@
|
|||||||
|
|
||||||
<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; img e canvas object-contain sovrapposti (il canvas ha la stessa risoluzione naturale dell'immagine, quindi l'overlay resta allineato) #}
|
{# #96: riquadro flex-1 min-h-0; #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 #}
|
||||||
<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">
|
||||||
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="absolute inset-0 w-full h-full object-contain">
|
{# #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"
|
||||||
|
style="background: rgba(0,0,0,.55); color: #fff;">
|
||||||
|
<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>
|
||||||
|
<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) #}
|
||||||
|
<div x-ref="vp" class="absolute inset-0" @wheel="zWheel($event)"
|
||||||
|
@pointerdown="zPanDown($event)" @pointermove="zPanMove($event)"
|
||||||
|
@pointerup="zPanEnd()" @pointercancel="zPanEnd()"
|
||||||
|
: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>
|
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
||||||
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* #115: zoom, Adatta e pan del riquadro immagine del pannello visione, con plain node.
|
||||||
|
*
|
||||||
|
* 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 = {};
|
||||||
|
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 : '')); }
|
||||||
|
}
|
||||||
|
const near = (a, b) => Math.abs(a - b) < 1e-9 * Math.max(1, Math.abs(b));
|
||||||
|
|
||||||
|
// Componente con viewport e immagine finti ($nextTick sincrono); getComputedStyle assente: si usano client*
|
||||||
|
function make(cw, ch) {
|
||||||
|
const c = ctx.visionExecute();
|
||||||
|
const vp = { clientWidth: cw, clientHeight: ch, scrollLeft: 0, scrollTop: 0, setPointerCapture() {} };
|
||||||
|
const img = { naturalWidth: 2000, naturalHeight: 1000 };
|
||||||
|
c.$refs = { vp, img, overlay: null };
|
||||||
|
c.$nextTick = (fn) => fn();
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prima del load: il wrapper riempie il riquadro
|
||||||
|
let c = make(800, 400);
|
||||||
|
check('#115 zSize prima del load = 100 %', c.zSize() === 'width:100%;height:100%', c.zSize());
|
||||||
|
check('#115 zoomBy senza immagine: ignorato', (c.zoomBy(2), c.z.zoom === 1));
|
||||||
|
|
||||||
|
// Load: fit = min(800/2000, 400/1000) = 0.4
|
||||||
|
c.onImgLoad();
|
||||||
|
check('#115 load: Adatta = 0.4', near(c.z.zoom, 0.4) && near(c.z.fit, 0.4) && c.z.auto === true, String(c.z.zoom));
|
||||||
|
check('#115 zSize = naturale x zoom', c.zSize() === 'width:800px;height:400px', c.zSize());
|
||||||
|
check('#115 zPct = 40%', c.zPct() === '40%', c.zPct());
|
||||||
|
check('#115 a Adatta non si trascina', c.zCanPan() === false && c.zCursor() === 'default');
|
||||||
|
|
||||||
|
// Zoom in/out e limiti
|
||||||
|
c.zoomBy(1.25);
|
||||||
|
check('#115 zoomBy moltiplica', near(c.z.zoom, 0.5) && c.z.auto === false);
|
||||||
|
check('#115 dopo lo zoom si trascina', c.zCanPan() === true && c.zCursor() === 'grab');
|
||||||
|
c.zoomBy(1e6);
|
||||||
|
check('#115 limite alto 8', c.z.zoom === 8, String(c.z.zoom));
|
||||||
|
c.zoomBy(1e-9);
|
||||||
|
check('#115 limite basso fit*0.5', near(c.z.zoom, 0.2), String(c.z.zoom));
|
||||||
|
c.zFit();
|
||||||
|
check('#115 Adatta torna al fit e a auto', near(c.z.zoom, 0.4) && c.z.auto === true);
|
||||||
|
|
||||||
|
// Il punto sotto il cursore resta fermo: zoom da 0.4 a 0.8 col cursore a (100, 50), scroll 0
|
||||||
|
c.zoomBy(2, 100, 50);
|
||||||
|
check('#115 zoom al cursore: scrollLeft', near(c.$refs.vp.scrollLeft, 100) && near(c.$refs.vp.scrollTop, 50),
|
||||||
|
c.$refs.vp.scrollLeft + ',' + c.$refs.vp.scrollTop);
|
||||||
|
|
||||||
|
// Frame nuovo con le stesse dimensioni: tiene lo zoom; dimensioni diverse: rifa il fit
|
||||||
|
const z0 = c.z.zoom;
|
||||||
|
c.onImgLoad();
|
||||||
|
check('#115 frame con stesse dimensioni: zoom invariato', c.z.zoom === z0 && c.z.auto === false);
|
||||||
|
c.$refs.img.naturalWidth = 1000; c.$refs.img.naturalHeight = 1000;
|
||||||
|
c.onImgLoad();
|
||||||
|
check('#115 dimensioni diverse: nuovo fit', near(c.z.fit, 0.4) && c.z.zoom === c.z.fit && c.z.auto === true);
|
||||||
|
|
||||||
|
// Riquadro nascosto (0 x 0): niente fit, stato invariato
|
||||||
|
c = make(0, 0);
|
||||||
|
c.onImgLoad();
|
||||||
|
check('#115 riquadro nascosto: nessun fit', c.z.zoom === 1 && c.z.w === 2000);
|
||||||
|
c.$refs.vp.clientWidth = 800; c.$refs.vp.clientHeight = 400;
|
||||||
|
c.zFit();
|
||||||
|
check('#115 riquadro visibile: il fit parte', near(c.z.zoom, 0.4));
|
||||||
|
|
||||||
|
// offset meno bordi (getComputedStyle disponibile)
|
||||||
|
c = make(780, 380);
|
||||||
|
c.$refs.vp.offsetWidth = 802; c.$refs.vp.offsetHeight = 402;
|
||||||
|
ctx.getComputedStyle = () => ({ borderLeftWidth: '1px', borderRightWidth: '1px', borderTopWidth: '1px', borderBottomWidth: '1px' });
|
||||||
|
c.onImgLoad();
|
||||||
|
check('#115 area = offset meno bordi', near(c.z.fit, 0.4), String(c.z.fit));
|
||||||
|
delete ctx.getComputedStyle;
|
||||||
|
|
||||||
|
// Rotella: solo con Ctrl
|
||||||
|
c = make(800, 400); c.onImgLoad();
|
||||||
|
let prevented = false;
|
||||||
|
c.zWheel({ ctrlKey: false, deltaY: -1, preventDefault() { prevented = true; } });
|
||||||
|
check('#115 rotella senza Ctrl: ignorata', !prevented && near(c.z.zoom, 0.4));
|
||||||
|
c.zWheel({ ctrlKey: true, deltaY: -1, clientX: 0, clientY: 0, preventDefault() { prevented = true; } });
|
||||||
|
check('#115 Ctrl+rotella su: zoom x1.25', prevented && near(c.z.zoom, 0.5));
|
||||||
|
c.zWheel({ ctrlKey: true, deltaY: 1, preventDefault() {} });
|
||||||
|
check('#115 Ctrl+rotella giu: zoom /1.25', near(c.z.zoom, 0.4));
|
||||||
|
|
||||||
|
// Pan: sposta lo scroll solo se ingrandito
|
||||||
|
c = make(800, 400); c.onImgLoad();
|
||||||
|
c.zPanDown({ clientX: 10, clientY: 10, pointerType: 'mouse', button: 0, pointerId: 1 });
|
||||||
|
check('#115 pan a Adatta: ignorato', c._zPan === null && c.zPanning === false);
|
||||||
|
c.zoomBy(2);
|
||||||
|
c.$refs.vp.scrollLeft = 50; c.$refs.vp.scrollTop = 40;
|
||||||
|
c.zPanDown({ clientX: 100, clientY: 100, pointerType: 'mouse', button: 0, pointerId: 1 });
|
||||||
|
check('#115 pan avviato: cursore grabbing', c.zPanning === true && c.zCursor() === 'grabbing');
|
||||||
|
c.zPanMove({ clientX: 80, clientY: 90 });
|
||||||
|
check('#115 pan: scroll segue il puntatore', c.$refs.vp.scrollLeft === 70 && c.$refs.vp.scrollTop === 50,
|
||||||
|
c.$refs.vp.scrollLeft + ',' + c.$refs.vp.scrollTop);
|
||||||
|
c.zPanEnd();
|
||||||
|
check('#115 pan finito', c._zPan === null && c.zPanning === false);
|
||||||
|
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
@@ -46,6 +46,17 @@ def test_image_is_object_contain_without_h_auto():
|
|||||||
assert "object-contain" in canvas # stesso fit dell'img: overlay allineato
|
assert "object-contain" in canvas # stesso fit dell'img: overlay allineato
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_viewport_scrolls_inside_the_box():
|
||||||
|
# #115: il riquadro resta overflow-hidden (il pannello non scorre); lo scorrimento e' solo del viewport interno
|
||||||
|
panel = _panel()
|
||||||
|
vp = _tag_with(panel, 'x-ref="vp"')
|
||||||
|
assert "absolute" in vp and "inset-0" in vp
|
||||||
|
assert "overflow:auto" in vp # :style stringa del viewport
|
||||||
|
assert 'style="' not in vp.replace(':style="', "") # niente style statico accanto a :style (Alpine lo cancella)
|
||||||
|
assert "x-show" not in vp # mai x-show e :style stringa sullo stesso elemento
|
||||||
|
assert "zSize()" in panel # wrapper naturale x zoom
|
||||||
|
|
||||||
|
|
||||||
def test_image_box_and_outcome_column_can_shrink():
|
def test_image_box_and_outcome_column_can_shrink():
|
||||||
panel = _panel()
|
panel = _panel()
|
||||||
box = _tag_with(panel, 'x-show="shownImage"')
|
box = _tag_with(panel, 'x-show="shownImage"')
|
||||||
|
|||||||
@@ -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.
|
||||||
|
"""
|
||||||
|
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}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user