fix(numpad): segno perso e raffica HID concatenata a cifre manuali (#11, #27)

toggleSign() usciva subito a display vuoto: il segno del calibro, che arriva
prima delle cifre, veniva scartato. Ora '-'/'+' impostano il segno in modo
esplicito (non toggle) quando arrivano da tastiera/HID; toggleSign() (bottone
manuale +/-) funziona anche a vuoto.

_trackBurst riconosce l'inizio di una raffica solo dal secondo carattere:
prima ripuliva il display in ritardo, concatenando cifre digitate a mano.
Ora la raffica ripulisce il display fino al proprio inizio quando la pausa
prima di essa è > 300ms. Parte intera oltre 6 cifre: rifiutata con evento
numpad-error invece di essere troncata in silenzio.

Test node in tests/js/test_numpad.js, lanciato da pytest
(tests/test_numpad_js.py, skip se node manca).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:40:21 +02:00
parent 1576fa190b
commit d40c74870e
3 changed files with 216 additions and 7 deletions
+56 -7
View File
@@ -25,6 +25,8 @@ function numpad(options) {
_lastKeyTime: 0, // Timestamp of last keystroke
_burstCount: 0, // Consecutive fast keystrokes
_enterWasFast: false, // Enter arrived in the same burst as the digits
_rejected: false, // Current reading refused (e.g. overflow); ignore the rest of it
_runStart: null, // {value, gap} snapshot taken at the first key of the current run
/**
* Get the display value with sign
@@ -55,6 +57,8 @@ function numpad(options) {
* @param {string} d - The digit to add (0-9)
*/
addDigit(d) {
if (this._rejected) return;
// Validate: don't exceed max digits
const parts = this.value.split('.');
@@ -63,7 +67,15 @@ function numpad(options) {
if (parts[1] && parts[1].length >= this.maxDecDigits) return;
} else {
// Check integer part length
if (parts[0] && parts[0].length >= this.maxIntDigits) return;
if (parts[0] && parts[0].length >= this.maxIntDigits) {
// A caliper reading whose integer part does not fit must not be
// quietly cut down to something that does: refuse the whole
// reading instead of saving a truncated number. Manual typing
// (outside a burst) keeps the old, harmless behaviour of simply
// not accepting more digits.
if (this._burstCount >= 2) this._rejectReading('integer_overflow');
return;
}
}
// Prevent leading zeros (except "0.")
@@ -79,6 +91,8 @@ function numpad(options) {
* Add a decimal point to the current value
*/
addDecimal() {
if (this._rejected) return;
// Don't add decimal if one already exists
if (this.hasDecimal) return;
@@ -90,10 +104,11 @@ function numpad(options) {
},
/**
* Toggle the sign of the current value
* Toggle the sign of the current value. Works on an empty display too -
* the manual +/- key is pressed before the first digit as often as after
* one, and used to be silently dropped in that case.
*/
toggleSign() {
if (!this.value) return;
this.negative = !this.negative;
},
@@ -119,6 +134,8 @@ function numpad(options) {
this.negative = false;
this.hasDecimal = false;
this._enterWasFast = false;
this._rejected = false;
this._runStart = null;
},
/**
@@ -138,6 +155,7 @@ function numpad(options) {
* Confirm the current value and dispatch event
*/
confirm() {
this._rejected = false;
if (!this.hasValue) return;
const val = this.numericValue;
@@ -239,11 +257,32 @@ function numpad(options) {
this._enterWasFast = gap < 80;
this.confirm();
}
// Minus sign - toggle sign
// Minus / plus - the caliper's own sign character. Unlike the manual
// +/- key (toggleSign(), bound to the on-screen button) these are
// absolute rather than a toggle: a caliper transmission says which
// sign the reading is, it never flips one already on screen.
else if (e.key === '-') {
e.preventDefault();
this.toggleSign();
this.negative = true;
}
else if (e.key === '+') {
e.preventDefault();
this.negative = false;
}
},
/**
* Refuse the reading in progress: say why with an event the page can show,
* and wipe the display so the rejected digits do not linger as if valid.
* Further keystrokes of the same burst are ignored until Enter or a clear.
*/
_rejectReading(reason) {
this.$dispatch('numpad-error', { reason: reason });
this.value = '';
this.negative = false;
this.hasDecimal = false;
this._enterWasFast = false;
this._rejected = true;
},
/**
@@ -253,12 +292,22 @@ function numpad(options) {
*/
_trackBurst() {
const now = performance.now();
const gap = now - this._lastKeyTime;
const gap = this._lastKeyTime > 0 ? now - this._lastKeyTime : Infinity;
if (this._lastKeyTime > 0 && gap < 80) {
if (gap < 80) {
this._burstCount++;
// The burst is only recognisable from its second character. If the
// pause before the first one was long enough to be a human keystroke,
// whatever was on display before this run began was typed by hand:
// drop it now instead of letting the caliper's own digits
// concatenate onto it.
if (this._burstCount === 2 && this._runStart && this._runStart.gap > 300) {
this.value = this.value.slice(this._runStart.value.length);
this.hasDecimal = this.value.includes('.');
}
} else {
this._burstCount = 1;
this._runStart = { value: this.value, gap: gap };
}
this._lastKeyTime = now;
@@ -0,0 +1,124 @@
/**
* numpad.js behaviour under a simulated USB caliper / physical keyboard, run
* with plain node (no browser, no test framework) against the same
* handleKeydown() path a real wedge device drives.
*
* Covers gitea:Adriano/TieMeasureFlow#11 (sign lost when the caliper sends it
* before the digits) and #27 (leftover manual digits concatenate onto a
* caliper burst; an integer part over the digit limit used to be truncated
* in silence instead of refused).
*
* Run from src/frontend/flask_app: node tests/js/test_numpad.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
// A fake performance.now(): T advances by the gap the test hands to send()/
// press(), so "fast" (<80ms, HID) and "slow" (>80ms, human) keystrokes are
// simulated without any real waiting.
global.performance = { now: () => T };
let T = 1000;
const numpadSrc = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'js', 'numpad.js'), 'utf8',
);
eval(numpadSrc + ';global.numpad = numpad;');
function mk() {
const n = numpad({});
n._events = [];
n.$dispatch = (name, detail) => n._events.push({ name: name, detail: detail });
return n;
}
function press(n, key, gap) {
T += gap;
n.handleKeydown({
key: key,
target: { tagName: 'BODY' },
preventDefault() {},
});
}
function send(n, str, gap) {
for (const ch of str) press(n, ch === '\r' ? 'Enter' : ch, gap);
}
function lastEvent(n, name) {
const evs = n._events.filter(e => e.name === name);
return evs.length ? evs[evs.length - 1].detail : null;
}
let failures = 0;
function check(label, cond, extra) {
if (cond) {
console.log('ok - ' + label);
} else {
failures++;
console.error('NOT OK - ' + label + (extra ? ' (' + extra + ')' : ''));
}
}
// #11 — the caliper sends the sign before the digits: "-1.25\r"
{
const n = mk();
send(n, '-1.25\r', 10);
const c = lastEvent(n, 'numpad-confirm');
check('#11 raffica negativa "-1.25\\r" -> -1.25', c && c.value === -1.25, JSON.stringify(c));
check('#11 raffica negativa classificata usb_caliper', c && c.inputMethod === 'usb_caliper');
}
// #11 — explicit "+" also arrives as its own character
{
const n = mk();
send(n, '+12.34\r', 10);
const c = lastEvent(n, 'numpad-confirm');
check('#11 raffica positiva "+12.34\\r" -> 12.34', c && c.value === 12.34, JSON.stringify(c));
}
// #11 — a manual "-" press followed by clear must not leak into the next reading
{
const n = mk();
press(n, '-', 10);
n.clearAll();
send(n, '1.2\r', 200);
const c = lastEvent(n, 'numpad-confirm');
check('#11 "-" poi clear poi "1.2\\r" -> 1.2 (segno azzerato)', c && c.value === 1.2, JSON.stringify(c));
}
// #27 — a digit left on screen from manual typing must not concatenate onto
// a caliper burst that follows a real pause.
{
const n = mk();
n.addDigit('1');
T += 5000;
send(n, '12.34\r', 10);
const c = lastEvent(n, 'numpad-confirm');
check('#27 tocco "1", pausa, raffica "12.34\\r" -> 12.34 (non 112.34)', c && c.value === 12.34, JSON.stringify(c));
}
// #27 — an integer part over the digit limit is refused, not silently cut down
{
const n = mk();
send(n, '1234567.5\r', 10);
const c = lastEvent(n, 'numpad-confirm');
const err = lastEvent(n, 'numpad-error');
check('#27 raffica "1234567.5\\r" -> nessun confirm', c === null, JSON.stringify(c));
check('#27 raffica "1234567.5\\r" -> numpad-error emesso', !!err, JSON.stringify(err));
}
// #27 — slow, one-key-at-a-time manual typing is unaffected by any of the above
{
const n = mk();
send(n, '12.5\r', 200);
const c = lastEvent(n, 'numpad-confirm');
check('#27 digitazione manuale lenta "1,2,.,5,Enter" -> 12.5 invariato', c && c.value === 12.5, JSON.stringify(c));
check('#27 digitazione manuale lenta classificata manual', c && c.inputMethod === 'manual');
}
if (failures > 0) {
console.error(failures + ' test falliti su numpad.js');
process.exit(1);
}
console.log('tutti i test numpad.js superati');
@@ -0,0 +1,36 @@
"""Runs the plain-node numpad.js behaviour test (tests/js/test_numpad.js).
There is no browser/JS test harness in this project yet, so this is a thin
pytest wrapper: it shells out to ``node`` and asserts the script's own exit
code. Skips (does not fail) when node is not installed, since numpad.js's
logic still gets exercised by whatever picks this up where node is present.
Covers gitea:Adriano/TieMeasureFlow#11 and #27.
"""
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_numpad.js"
def test_numpad_js_hid_sign_and_burst_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS di numpad.js saltato")
result = subprocess.run(
[node, str(JS_TEST)],
cwd=str(FLASK_APP_DIR),
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, (
f"tests/js/test_numpad.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)