From d355c8c793747b0dd1412f90f41050a4b7b7565c Mon Sep 17 00:00:00 2001 From: Georgii Dolzhykov Date: Thu, 16 Apr 2026 16:47:53 +0300 Subject: [PATCH] Fix numeric min/max validation across input, blur, isValid, and setvalue Input / blur / isValid - Make below-min postValidation symmetric with above-max: when SetMaxOnOverflow is true and the value goes negative past min, clamp to min; otherwise reject the keystroke. - Reject a negation toggle when the resulting value would fall out of range on either side. - After revalidateMask strips the negation sign from the buffer, an overflowing positive in postValidation can be a valid negative. The max-overflow check consults the live DOM (skipped during checkVal where the DOM is stale) and falls back to opts.min < 0 in element-less contexts (Inputmask.isValid / Inputmask.format), returning currentResult instead of clamping. - Empty-field guard checks maskset.validPositions.length > 0 rather than buffer.join() !== "", so mask-literal padding and whitespace-only values no longer trigger a clamp to min on blur. setvalue - applyInputValue invokes onBeforeMask with __skipRounding on the setvalue path: the alias parser clamps before checkVal while the parseFloat round-trip that mangles bignums (#2715) is suppressed. - Internal numeric rewrites (negation-delete, radix-dance) call applyInputValue directly via setBufferAndCaret with skipOnBeforeMask=true, bypassing the parser for already-clean masked buffers without round-tripping through the public setvalue trigger. Tests added across DOM input, element-less APIs, and the setvalue path (in-range pass-through, empty, number-arg, formatted prefix/groupSeparator above and below range, European locale, onBeforeMask=null, bignum, internal-rewrite bypass). Closes #1763, #2715, #2775, #2846, #2863, #2651. Adds regression coverage for previously closed #951, #2284, #2829. Also covers the element-less-validation case from #2485 (input-path RangeError in #2485 out of scope). Co-Authored-By: Claude Opus 4.6 --- lib/eventhandlers.js | 20 +- .../inputmask.numeric.extensions.js | 141 ++- lib/inputHandling.js | 12 +- qunit/tests_formatvalidate.js | 110 +++ qunit/tests_numeric.js | 815 ++++++++++++++++++ 5 files changed, 1037 insertions(+), 61 deletions(-) diff --git a/lib/eventhandlers.js b/lib/eventhandlers.js index 817313b1b..2efc9a444 100644 --- a/lib/eventhandlers.js +++ b/lib/eventhandlers.js @@ -523,23 +523,21 @@ const EventHandlers = { }, setValueEvent: function (e) { const inputmask = this.inputmask, - $ = inputmask.dependencyLib; - let input = this, - value = e && e.detail ? e.detail[0] : arguments[1]; - - if (value === undefined) { - value = input.inputmask._valueGet(true); - } + $ = inputmask.dependencyLib, + input = this, + explicitValue = e && e.detail ? e.detail[0] : arguments[1], + caretPos = e && e.detail ? e.detail[1] : arguments[2]; applyInputValue( input, - value, + explicitValue !== undefined ? explicitValue : input.inputmask._valueGet(true), new $.Event("input"), - (e && e.detail ? e.detail[0] : arguments[1]) !== undefined + false, + explicitValue !== undefined ); - if ((e.detail && e.detail[1] !== undefined) || arguments[2] !== undefined) { - caret.call(inputmask, input, e.detail ? e.detail[1] : arguments[2]); + if (caretPos !== undefined) { + caret.call(inputmask, input, caretPos); } }, focusEvent: function (e) { diff --git a/lib/extensions/inputmask.numeric.extensions.js b/lib/extensions/inputmask.numeric.extensions.js index 2e46c87d0..845869463 100755 --- a/lib/extensions/inputmask.numeric.extensions.js +++ b/lib/extensions/inputmask.numeric.extensions.js @@ -5,9 +5,10 @@ Licensed under the MIT license */ import { escapeRegex } from "../escapeRegex"; +import { applyInputValue } from "../inputHandling"; import Inputmask from "../inputmask"; import { keys } from "../keycode"; -import { seekNext } from "../positioning"; +import { caret, seekNext } from "../positioning"; const $ = Inputmask.dependencyLib; @@ -57,6 +58,30 @@ function alignDigits(buffer, digits, opts, force) { return buffer; } +function unmaskAsNumber(str, opts) { + return opts.onUnMask( + str, + undefined, + $.extend({}, opts, { unmaskAsNumber: true }) + ); +} + +function boundaryBuffer(bound, opts) { + return alignDigits( + bound.toString().replace(".", opts.radixPoint).split(""), + opts.digits, + opts + ).reverse(); +} + +// Internal numeric rewrites (negation-delete, radix-dance) — push an +// already-clean masked buffer back into the input without re-running the +// alias parser. +function setBufferAndCaret(input, value, caretBegin) { + applyInputValue(input, value, new $.Event("input"), true); + caret.call(input.inputmask, input, caretBegin); +} + function findValidator(symbol, maskset) { let posNdx = 0; if (symbol === "+") { @@ -387,14 +412,31 @@ Inputmask.extendAliases({ pos = handleRadixDance(pos, c, radixPos, maskset, opts); if (c === "-" || c === opts.negationSymbol.front) { if (opts.allowMinus !== true) return false; - let isNegative = false, - front = findValid("+", maskset), + let isNegative = false; + const front = findValid("+", maskset), back = findValid("-", maskset); if (front !== -1) { isNegative = [front]; if (back !== -1) isNegative.push(back); } + const checkMax = isNegative !== false && opts.max !== null, + checkMin = isNegative === false && opts.min !== null; + // Reject typing "-" against a non-negative min — alignDigits would + // pad the orphan sign to "-0". SetMaxOnOverflow=true has its own + // boundary refresh in postValidation. + if (!opts.SetMaxOnOverflow && checkMin && opts.min >= 0) return false; + // Reject sign flips that would push the buffer out of range. + // postValidation's range check doesn't fire after the validator's + // `{remove: ...}` return (toggle-off path), so overflow on that + // path must be caught here. + if ((checkMax || checkMin) && this.maskset.validPositions.length > 0) { + const absVal = Math.abs( + unmaskAsNumber(buffer.slice().reverse().join(""), opts) + ); + if (checkMax && absVal > opts.max) return false; + if (checkMin && -absVal < opts.min) return false; + } return isNegative !== false ? { remove: isNegative, @@ -489,37 +531,39 @@ Inputmask.extendAliases({ if (currentResult === false) return currentResult; if (strict) return true; if (opts.min !== null || opts.max !== null) { - const unmasked = opts.onUnMask( + const unmasked = unmaskAsNumber( buffer.slice().reverse().join(""), - undefined, - $.extend({}, opts, { - unmaskAsNumber: true - }) + opts ); if ( opts.min !== null && unmasked < opts.min && - fromAlternate !== true && + (fromAlternate !== true || unmasked < 0) && (unmasked.toString().length > opts.min.toString().length || // > instead of >= because we want to allow to type a bigger number buffer[0] === opts.radixPoint || // disallow radixpoint when value is smaller than min unmasked < 0) ) { - return false; - // return { - // refreshFromBuffer: true, - // buffer: alignDigits(opts.min.toString().replace(".", opts.radixPoint).split(""), opts.digits, opts).reverse() - // }; + return unmasked < 0 && opts.SetMaxOnOverflow + ? { + refreshFromBuffer: true, + buffer: boundaryBuffer(opts.min, opts) + } + : false; } if (opts.max !== null && opts.max >= 0 && unmasked > opts.max) { + // #2846: revalidateMask may strip the negation sign, so an + // overflowing positive here can actually be a valid negative. + // Consult the DOM (stale during checkval) or, element-less, + // trust min < 0. + const isNegativeContext = this.el + ? !fromCheckval && unmaskAsNumber(this._valueGet(true), opts) < 0 + : opts.min !== null && opts.min < 0; + if (isNegativeContext) return currentResult; return opts.SetMaxOnOverflow ? { refreshFromBuffer: true, - buffer: alignDigits( - opts.max.toString().replace(".", opts.radixPoint).split(""), - opts.digits, - opts - ).reverse() + buffer: boundaryBuffer(opts.max, opts) } : false; } @@ -586,6 +630,10 @@ Inputmask.extendAliases({ maskedValue = maskedValue.replace(escapeRegex(opts.radixPoint), "."); return isFinite(maskedValue); }, + // Numeric alias onBeforeMask hook — parses/normalizes a value into the + // alias buffer format and clamps to min/max. + // #2715: opts.__skipRounding suppresses the parseFloat round-trip so bignum + // precision survives the setvalue path; clamping and other transforms remain. onBeforeMask: function (initialValue, opts) { initialValue = initialValue ?? ""; const radixPoint = opts.radixPoint || ","; @@ -617,7 +665,10 @@ Inputmask.extendAliases({ : opts.digits < decimalPart.length ? opts.digits : decimalPart.length; - if (decimalPart !== "" || !opts.digitsOptional) { + if ( + !opts.__skipRounding && + (decimalPart !== "" || !opts.digitsOptional) + ) { const digitsFactor = Math.pow(10, digits || 1); // make the initialValue a valid javascript number for the parsefloat @@ -639,16 +690,22 @@ Inputmask.extendAliases({ ); } + let clamped = false; if (initialValue !== "" && (opts.min !== null || opts.max !== null)) { const numberValue = initialValue.toString().replace(radixPoint, "."); if (opts.min !== null && numberValue < opts.min) { initialValue = opts.min.toString().replace(".", radixPoint); + clamped = true; } else if (opts.max !== null && numberValue > opts.max) { initialValue = opts.max.toString().replace(".", radixPoint); + clamped = true; } } - if (isNegative && initialValue.charAt(0) !== "-") { + // After a clamp the boundary's own sign already lives in initialValue — + // re-prepending the original input's "-" would invert nonneg boundaries + // (setvalue("-5") with min:10 → "-10") or strand "-0" (min:0). + if (isNegative && !clamped && initialValue.charAt(0) !== "-") { initialValue = "-" + initialValue; } return alignDigits( @@ -696,35 +753,23 @@ Inputmask.extendAliases({ switch (e.type) { case "blur": case "checkval": - if (opts.min !== null || opts.max !== null) { - const unmasked = opts.onUnMask( + if ( + (opts.min !== null || opts.max !== null) && + this.maskset.validPositions.length > 0 + ) { + const unmasked = unmaskAsNumber( buffer.slice().reverse().join(""), - undefined, - $.extend({}, opts, { - unmaskAsNumber: true - }) + opts ); - if ( - opts.min !== null && - unmasked < opts.min && - buffer.join() !== "" - ) { + if (opts.min !== null && unmasked < opts.min) { return { refreshFromBuffer: true, - buffer: alignDigits( - opts.min.toString().replace(".", opts.radixPoint).split(""), - opts.digits, - opts - ).reverse() + buffer: boundaryBuffer(opts.min, opts) }; } else if (opts.max !== null && unmasked > opts.max) { return { refreshFromBuffer: true, - buffer: alignDigits( - opts.max.toString().replace(".", opts.radixPoint).split(""), - opts.digits, - opts - ).reverse() + buffer: boundaryBuffer(opts.max, opts) }; } } @@ -830,7 +875,7 @@ Inputmask.extendAliases({ bffr = buffer.slice().reverse(); if (opts.negationSymbol.front !== "") bffr.shift(); if (opts.negationSymbol.back !== "") bffr.pop(); - $input.trigger("setvalue", [bffr.join(""), caretPos.begin]); + setBufferAndCaret(this, bffr.join(""), caretPos.begin); return false; } else if (opts._radixDance === true) { const radixPos = buffer.indexOf(opts.radixPoint); @@ -869,19 +914,21 @@ Inputmask.extendAliases({ if (restoreCaretPos) { caretPos = restoreCaretPos; } - $input.trigger("setvalue", [ + setBufferAndCaret( + this, bffr, caretPos.begin >= bffr.length ? radixPos + 1 : caretPos.begin - ]); + ); return false; } } else if (radixPos === 0) { bffr = buffer.slice().reverse(); bffr.pop(); - $input.trigger("setvalue", [ + setBufferAndCaret( + this, bffr.join(""), caretPos.begin >= bffr.length ? bffr.length : caretPos.begin - ]); + ); return false; } } diff --git a/lib/inputHandling.js b/lib/inputHandling.js index c2f1340c4..46ed73fc7 100644 --- a/lib/inputHandling.js +++ b/lib/inputHandling.js @@ -24,13 +24,19 @@ export { writeBuffer }; -function applyInputValue(input, value, initialEvent, strict) { +function applyInputValue(input, value, initialEvent, skipOnBeforeMask, skipRounding) { const inputmask = input ? input.inputmask : this, opts = inputmask.opts; input.inputmask.refreshValue = false; - if (strict !== true && typeof opts.onBeforeMask === "function") - value = opts.onBeforeMask.call(inputmask, value, opts) || value; + // skipRounding suppresses onBeforeMask's parseFloat round-trip while + // preserving its other transforms — guards bignum precision (#2715) on the + // setvalue path. skipOnBeforeMask bypasses the hook entirely for internal + // buffer rewrites that already produced a clean masked value (#2846). + if (skipOnBeforeMask !== true && typeof opts.onBeforeMask === "function") { + const callOpts = skipRounding === true ? { ...opts, __skipRounding: true } : opts; + value = opts.onBeforeMask.call(inputmask, value, callOpts) || value; + } value = (value || "").toString().split(""); checkVal(input, true, false, value, initialEvent); inputmask.undoValue = inputmask._valueGet(true); diff --git a/qunit/tests_formatvalidate.js b/qunit/tests_formatvalidate.js index 6a2069b6c..d491095bd 100644 --- a/qunit/tests_formatvalidate.js +++ b/qunit/tests_formatvalidate.js @@ -218,6 +218,116 @@ export default function (qunit, Inputmask) { } ); + // https://github.com/RobinHerbots/Inputmask/issues/2846 + qunit.test( + 'isValid("-32", { alias: "numeric", min: -100, max: 30 }) - element-less negative #2846', + function (assert) { + var isValid = Inputmask.isValid("-32", { + alias: "numeric", + min: -100, + max: 30, + allowMinus: true, + digits: 3 + }); + assert.equal(isValid, true, "Result " + isValid); + } + ); + + qunit.test( + 'isValid("-32.123", { alias: "numeric", min: -100, max: 30 }) - element-less negative decimal #2846', + function (assert) { + var isValid = Inputmask.isValid("-32.123", { + alias: "numeric", + min: -100, + max: 30, + allowMinus: true, + digits: 3 + }); + assert.equal(isValid, true, "Result " + isValid); + } + ); + + qunit.test( + 'isValid("32.1", { alias: "numeric", min: -100, max: 30 }) - exceeds max even with min<0 #2846', + function (assert) { + var isValid = Inputmask.isValid("32.1", { + alias: "numeric", + min: -100, + max: 30, + digits: 3 + }); + assert.equal(isValid, false, "Result " + isValid); + } + ); + + qunit.test( + 'isValid("50", { alias: "numeric", max: 30 }) - element-less exceeds max', + function (assert) { + var isValid = Inputmask.isValid("50", { + alias: "numeric", + max: 30 + }); + assert.equal(isValid, false, "Result " + isValid); + } + ); + + // https://github.com/RobinHerbots/Inputmask/issues/951 + qunit.test( + 'format("-1000", { alias: "decimal", min: -999, allowMinus: true, digits: 0 }) #951', + function (assert) { + var formatted = Inputmask.format("-1000", { + alias: "decimal", + min: -999, + allowMinus: true, + digits: 0 + }); + assert.equal(formatted, "-999", "Result " + formatted); + } + ); + + // https://github.com/RobinHerbots/Inputmask/issues/2485 + qunit.test( + 'isValid("-$3.578,965", { prefix: "$", groupSeparator: ".", radixPoint: ",", min: -4000, digits: 3 }) #2485', + function (assert) { + var isValid = Inputmask.isValid("-$3.578,965", { + alias: "numeric", + prefix: "$", + groupSeparator: ".", + radixPoint: ",", + digits: 3, + min: -4000, + max: 4000, + allowMinus: true + }); + assert.equal(isValid, true, "Result " + isValid); + } + ); + + qunit.test( + 'format("50", { alias: "numeric", min: -100, max: 30 }) - positive above max clamped', + function (assert) { + var formatted = Inputmask.format("50", { + alias: "numeric", + min: -100, + max: 30 + }); + assert.equal(formatted, "30", "Result " + formatted); + } + ); + + qunit.test( + 'format("-150", { min: -100, max: 30, allowMinus }) - clamps to -100', + function (assert) { + var formatted = Inputmask.format("-150", { + alias: "numeric", + min: -100, + max: 30, + allowMinus: true + }); + assert.equal(formatted, "-100", "Result " + formatted); + } + ); + qunit.test( 'thomstark isValid("12lbs", {mask:"99[9]lb\\s", greedy:false, skipOptionalPartCharacter: "", "clearIncomplete":true}', function (assert) { diff --git a/qunit/tests_numeric.js b/qunit/tests_numeric.js index b19538f4e..d5497cdf0 100644 --- a/qunit/tests_numeric.js +++ b/qunit/tests_numeric.js @@ -2576,4 +2576,819 @@ export default function (qunit, Inputmask) { assert.equal(testmask.value, "-234", 'Result "' + testmask.value + '"'); } ); + + // ---- min/max bug reproductions ---- + + [ + { + label: + "numeric min=-100 max=30 digits=3 SMOO=true - Type '-77777' clamps to min #2846", + alias: "numeric", + opts: { + min: -100, + max: 30, + digits: 3, + SetMaxOnOverflow: true + }, + type: "-77777", + expected: "-100" + }, + { + label: "numeric min=120 max=2345 - Type '1111' passes through", + alias: "numeric", + opts: { min: 120, max: 2345, digits: 0 }, + type: "1111", + expected: "1111" + }, + { + label: "integer min=250 - Type '1500' passes through #2284", + alias: "integer", + opts: { min: 250 }, + type: "1500", + expected: "1500" + }, + { + label: + "numeric min=-100 max=30 digits=3 - Type '-32' passes through #2846", + alias: "numeric", + opts: { min: -100, max: 30, digits: 3 }, + type: "-32", + expected: "-32" + }, + { + label: + "numeric min=-100 max=30 digits=3 - Type '-32.123' passes through #2846", + alias: "numeric", + opts: { min: -100, max: 30, digits: 3 }, + type: "-32.123", + expected: "-32.123" + }, + { + label: + "numeric min=-100 max=30 - Type '50' rejects '0' (would exceed max 30) #2846", + alias: "numeric", + opts: { min: -100, max: 30, digits: 0 }, + type: "50", + expected: "5" + }, + { + label: "numeric min=-100 max=30 - Type '-30' at exact max boundary #2846", + alias: "numeric", + opts: { min: -100, max: 30, digits: 0 }, + type: "-30", + expected: "-30" + }, + { + label: + "numeric max=30 digits=3 no min - Type '-5.5' passes through #2846", + alias: "numeric", + opts: { max: 30, digits: 3 }, + type: "-5.5", + expected: "-5.5" + }, + { + label: + "numeric max=30 digits=3 - Type '30.1' rejects '1' (would exceed max 30)", + alias: "numeric", + opts: { max: 30, digits: 3 }, + type: "30.1", + expected: "30." + }, + { + label: + "numeric min=-100 max=30 digits=3 - Type '99999' rejected (above max) #2846", + alias: "numeric", + opts: { min: -100, max: 30, digits: 3 }, + type: "99999", + expected: "9" + }, + { + label: + "decimal allowMinus min=-999 SMOO=true - Type '-1000' clamps to min #951", + alias: "decimal", + opts: { min: -999, digits: 0, SetMaxOnOverflow: true }, + type: "-1000", + expected: "-999" + }, + { + label: "numeric min=-100 max=30 SMOO=true - Type '-777' clamps to min", + alias: "numeric", + opts: { + min: -100, + max: 30, + digits: 0, + SetMaxOnOverflow: true + }, + type: "-777", + expected: "-100" + }, + { + label: + "numeric min=-100 SMOO=false - Type '-777' rejects keystroke below min, symmetric to above max #2846", + alias: "numeric", + opts: { + min: -100, + max: 30, + digits: 0, + SetMaxOnOverflow: false + }, + type: "-777", + expected: "-77" + }, + { + label: + "numeric min=-100 default SMOO - Type '-777' rejects (default SMOO is false) #2846", + alias: "numeric", + opts: { min: -100, max: 30, digits: 0 }, + type: "-777", + expected: "-77" + }, + { + label: + "numeric min=-10 max=-1 SMOO=false - Type '-50' rejects below min in negative-only range #2846", + alias: "numeric", + opts: { + min: -10, + max: -1, + digits: 0, + SetMaxOnOverflow: false + }, + type: "-50", + expected: "-5" + }, + { + label: + "numeric min=-10 max=-1 SMOO=true - Type '-50' clamps to min in negative-only range #2846", + alias: "numeric", + opts: { + min: -10, + max: -1, + digits: 0, + SetMaxOnOverflow: true + }, + type: "-50", + expected: "-10" + }, + { + label: + "numeric min=0 allowMinus SMOO=false - Type '-5' rejects (negation disallowed) #2846", + alias: "numeric", + opts: { + min: 0, + max: 100, + digits: 0, + SetMaxOnOverflow: false + }, + type: "-5", + expected: "5" + }, + { + label: "numeric min=0 allowMinus SMOO=true - Type '-5' clamps to 0 #2846", + alias: "numeric", + opts: { + min: 0, + max: 100, + digits: 0, + SetMaxOnOverflow: true + }, + type: "-5", + expected: "0" + }, + { + label: "numeric min=-100 max=30 - Type '-100' at exact min boundary", + alias: "numeric", + opts: { min: -100, max: 30, digits: 0 }, + type: "-100", + expected: "-100" + }, + { + label: + "numeric min=-100 max=30 digits=3 SMOO=true - Type '-100.5' clamps to min", + alias: "numeric", + opts: { + min: -100, + max: 30, + digits: 3, + SetMaxOnOverflow: true + }, + type: "-100.5", + expected: "-100" + }, + // #2846 negation-ambiguity guard: a '-' that comes from a literal + // (suffix, groupSeparator) in the buffer must not be reparsed as unary + // minus. Otherwise an above-max positive could sneak past as its negation + // within min — so |min| > max below, making the bypass observable (50 vs + // -50, 100000 vs -100000 both fit their respective mins). + { + label: + "numeric min=-100 max=30 suffix='-' - Type '50' must not bypass max via suffix negation check", + alias: "numeric", + opts: { min: -100, max: 30, suffix: "-", digits: 0 }, + type: "50", + expected: "5-" + }, + // Expected "100" (not "99999") because an insertable groupSeparator of '-' + // is refused past the first thousands boundary: building "1-000" would be + // the exact ambiguous buffer the guard rejects, so typing stops at 999. + { + label: + "numeric min=-200000 max=99999 groupSeparator='-' - Type '100000' must not bypass max via groupSeparator negation check", + alias: "numeric", + opts: { min: -200000, max: 99999, groupSeparator: "-", digits: 0 }, + type: "100000", + expected: "100" + }, + { + label: + "numeric prefix='$' groupSeparator='.' radixPoint=',' min=-4000 digits=3 - Type '-3578' formats correctly", + alias: "numeric", + opts: { + prefix: "$", + groupSeparator: ".", + radixPoint: ",", + digits: 3, + min: -4000, + max: 4000, + allowMinus: true + }, + type: "-3578", + expected: "-$3.578" + }, + { + label: + "numeric prefix='$' groupSeparator='.' radixPoint=',' min=-4000 digits=3 - Type '-3578,965' (manual radix) formats correctly", + alias: "numeric", + opts: { + prefix: "$", + groupSeparator: ".", + radixPoint: ",", + digits: 3, + min: -4000, + max: 4000, + allowMinus: true + }, + type: "-3578,965", + expected: "-$3.578,965" + }, + { + label: + "numeric min=0 SMOO=false - Type '-5' rejects '-' upfront, no orphan '-0'", + alias: "numeric", + opts: { min: 0, max: 100, digits: 0 }, + type: "-5", + expected: "5" + }, + { + label: + "integer max=0 SMOO=false - toggle '-' off on '-0' normalizes to '0'", + alias: "integer", + opts: { max: 0 }, + type: "-0-", + expected: "0" + } + ].forEach(function (tc) { + qunit.test(tc.label, function (assert) { + const done = assert.async(), + $fixture = $("#qunit-fixture"); + $fixture.append(''); + const testmask = document.getElementById("testmask"); + Inputmask(tc.alias, tc.opts).mask(testmask); + testmask.focus(); + setTimeout(function () { + $("#testmask").Type(tc.type); + const msg = tc.label + " - got " + testmask.value; + assert.equal(testmask.value, tc.expected, msg); + done(); + }, 0); + }); + }); + + // ---- blur entry path (focus → optional Type → blur → assert) ---- + + [ + { + label: "numeric min=120 - value below min is clamped on blur", + alias: "numeric", + opts: { min: 120, max: 2345, digits: 0 }, + type: "50", + expected: "120" + }, + { + label: "numeric min=120 - empty field stays empty on blur", + alias: "numeric", + opts: { min: 120, max: 2345, digits: 0 }, + type: "", + expected: "" + }, + { + label: "numeric min=-100 max=30 - negative value clamped on blur #2846", + alias: "numeric", + opts: { min: -100, max: 30, digits: 0 }, + type: "-50", + expected: "-50" + }, + // https://github.com/RobinHerbots/Inputmask/issues/2863 + { + label: "numeric min=1 - focus then blur empty field stays empty #2863", + alias: "numeric", + opts: { min: 1, digits: 0 }, + type: "", + expected: "" + }, + { + label: "integer min=0 max=10 - empty field stays empty on blur", + alias: "integer", + opts: { min: 0, max: 10, rightAlign: false, clearIncomplete: true }, + type: "", + expected: "" + } + ].forEach(function (tc) { + qunit.test(tc.label, function (assert) { + const done = assert.async(), + $fixture = $("#qunit-fixture"); + $fixture.append(''); + const testmask = document.getElementById("testmask"); + Inputmask(tc.alias, tc.opts).mask(testmask); + testmask.focus(); + setTimeout(function () { + if (tc.type) $("#testmask").Type(tc.type); + testmask.blur(); + setTimeout(function () { + assert.equal( + testmask.value, + tc.expected, + tc.label + " - got " + testmask.value + ); + done(); + }, 0); + }, 0); + }); + }); + + // https://github.com/RobinHerbots/Inputmask/issues/1763 + qunit.test( + "integer min=1 max=255 - clear via val('') should stay empty #1763", + function (assert) { + const done = assert.async(), + $fixture = $("#qunit-fixture"); + $fixture.append(''); + const testmask = document.getElementById("testmask"); + Inputmask("integer", { + min: 1, + max: 255 + }).mask(testmask); + testmask.focus(); + setTimeout(function () { + $("#testmask").Type("100"); + testmask.blur(); + setTimeout(function () { + assert.equal(testmask.value, "100", "Value should be 100"); + $("#testmask").val(""); + assert.equal( + testmask.value, + "", + "After val('') field should be empty - got " + testmask.value + ); + done(); + }, 0); + }, 0); + } + ); + + qunit.test( + "integer min=1 max=255 - val('') then blur stays empty #1763", + function (assert) { + const done = assert.async(), + $fixture = $("#qunit-fixture"); + $fixture.append(''); + const testmask = document.getElementById("testmask"); + Inputmask("integer", { + min: 1, + max: 255 + }).mask(testmask); + testmask.focus(); + setTimeout(function () { + $("#testmask").Type("100"); + testmask.blur(); + setTimeout(function () { + $("#testmask").val(""); + testmask.focus(); + testmask.blur(); + setTimeout(function () { + assert.equal( + testmask.value, + "", + "After val('') + blur field should stay empty - got " + + testmask.value + ); + done(); + }, 0); + }, 0); + }, 0); + } + ); + + qunit.test( + "numeric min=-10 max=-1 - val('') stays empty not clamped to max #1763", + function (assert) { + const done = assert.async(), + $fixture = $("#qunit-fixture"); + $fixture.append(''); + const testmask = document.getElementById("testmask"); + Inputmask("numeric", { + min: -10, + max: -1, + digits: 0 + }).mask(testmask); + testmask.focus(); + setTimeout(function () { + $("#testmask").Type("-5"); + testmask.blur(); + setTimeout(function () { + assert.equal(testmask.value, "-5", "Value should be -5"); + $("#testmask").val(""); + assert.equal( + testmask.value, + "", + "After val('') field should be empty - got " + testmask.value + ); + done(); + }, 0); + }, 0); + } + ); + + qunit.test( + "numeric min=-100 max=30 - val('50') clamped to max", + function (assert) { + const done = assert.async(), + $fixture = $("#qunit-fixture"); + $fixture.append(''); + const testmask = document.getElementById("testmask"); + Inputmask("numeric", { + min: -100, + max: 30, + digits: 0 + }).mask(testmask); + $("#testmask").val("50"); + setTimeout(function () { + assert.equal( + testmask.value, + "30", + "val('50') should clamp to max 30 - got " + testmask.value + ); + done(); + }, 0); + } + ); + + qunit.test( + "numeric min=-100 max=30 - toggle negation from -50 rejected (|50| > max)", + function (assert) { + const done = assert.async(), + $fixture = $("#qunit-fixture"); + $fixture.append(''); + const testmask = document.getElementById("testmask"); + Inputmask("numeric", { + min: -100, + max: 30, + digits: 0 + }).mask(testmask); + testmask.focus(); + setTimeout(function () { + $("#testmask").Type("-50"); + assert.equal(testmask.value, "-50", "Should have -50"); + // Toggle negation off - blocked because 50 > max 30 + $("#testmask").Type("-"); + setTimeout(function () { + assert.equal( + testmask.value, + "-50", + "Negation removal blocked because |50| > max 30 - got " + + testmask.value + ); + done(); + }, 0); + }, 0); + } + ); + + // ---- reproductions from GitHub issues ---- + + // https://github.com/RobinHerbots/Inputmask/issues/2829 + qunit.test( + "numeric min=1 - programmatic input.value='' stays empty #2829", + function (assert) { + const done = assert.async(), + $fixture = $("#qunit-fixture"); + $fixture.append(''); + const testmask = document.getElementById("testmask"); + Inputmask("numeric", { min: 1 }).mask(testmask); + testmask.focus(); + setTimeout(function () { + $("#testmask").Type("5"); + testmask.blur(); + setTimeout(function () { + assert.equal(testmask.value, "5", "Value should be 5"); + $("#testmask").val(""); + assert.equal( + testmask.value, + "", + "Programmatic val('') should clear - got " + testmask.value + ); + done(); + }, 0); + }, 0); + } + ); + + // https://github.com/RobinHerbots/Inputmask/issues/2863 + qunit.test( + "numeric min=1 - whitespace-only value stays empty on blur #2863", + function (assert) { + const done = assert.async(), + $fixture = $("#qunit-fixture"); + $fixture.append(''); + const testmask = document.getElementById("testmask"); + Inputmask("numeric", { min: 1, digits: 0 }).mask(testmask); + testmask.focus(); + setTimeout(function () { + $("#testmask").val(" "); + testmask.blur(); + setTimeout(function () { + assert.equal( + testmask.value, + "", + "Whitespace-only field should stay empty on blur - got " + + testmask.value + ); + done(); + }, 0); + }, 0); + } + ); + + // ---- setvalue min/max clamping (#2846) ---- + // applyInputValue invokes onBeforeMask with __skipRounding on the setvalue + // path: the alias parser clamps before checkval while the bignum-unsafe + // parseFloat round-trip (#2715) is suppressed. + + [ + // ---- above-max clamping with SMOO=false ---- + { + label: + "integer min=-999 max=999 SMOO=false - setvalue('10000') clamps to max #2846", + alias: "integer", + opts: { min: -999, max: 999, SetMaxOnOverflow: false }, + value: "10000", + expected: "999" + }, + { + label: + "integer min=0 max=50 SMOO=false - setvalue('99') clamps to max #2846", + alias: "integer", + opts: { min: 0, max: 50, SetMaxOnOverflow: false }, + value: "99", + expected: "50" + }, + { + label: + "integer min=0 max=500 SMOO=false - setvalue('123456') clamps to max #2846", + alias: "integer", + opts: { min: 0, max: 500, SetMaxOnOverflow: false }, + value: "123456", + expected: "500" + }, + { + label: + "numeric max=30.5 digits=1 SMOO=false - setvalue('99.9') clamps to max #2846", + alias: "numeric", + opts: { max: 30.5, digits: 1, SetMaxOnOverflow: false }, + value: "99.9", + expected: "30.5" + }, + + // Bignum precision (#2715): values beyond Number.MAX_SAFE_INTEGER must + // round-trip through setvalue intact, not collapse through parseFloat. + { + label: + "numeric digits=2 - setvalue preserves bignum precision (no #2715 regression)", + alias: "numeric", + opts: { digits: 2, groupSeparator: ",", radixPoint: "." }, + value: "99,999,999,999,999,999,999,999.00", + expected: "99,999,999,999,999,999,999,999.00" + }, + + // ---- below-min clamping ---- + // Negative-below-min and positive-below-min hit different branches in + // postValidation, so each is covered. + { + label: + "integer min=-999 max=999 SMOO=false - setvalue('-10000') clamps to min #2846", + alias: "integer", + opts: { min: -999, max: 999, SetMaxOnOverflow: false }, + value: "-10000", + expected: "-999" + }, + // Positive-below-min: postValidation's min branch only refreshes for + // negative values, so positive-below-min relies on the onBeforeMask + // clamp running on the setvalue path. + { + label: + "integer min=10 max=999 SMOO=false - setvalue('5') clamps to min #2846", + alias: "integer", + opts: { min: 10, max: 999, SetMaxOnOverflow: false }, + value: "5", + expected: "10" + }, + { + label: "numeric digits=0 min=100 - setvalue('50') clamps to min #2846", + alias: "numeric", + opts: { digits: 0, min: 100 }, + value: "50", + expected: "100" + }, + + // The pre-checkval onBeforeMask clamp absorbs most overflows; SMOO=true + // exercises the postValidation SMO branch directly. + { + label: "integer max=999 SMOO=true - setvalue('5000') clamps to max", + alias: "integer", + opts: { min: 0, max: 999, SetMaxOnOverflow: true }, + value: "5000", + expected: "999" + }, + + // ---- formatted input (prefix + groupSeparator + radix) ---- + // The numeric parser strips prefix/suffix/groupSeparator before the + // min/max comparison, so formatted programmatic values clamp correctly + // instead of NaN-passing through. + { + label: + "numeric prefix='$' groupSeparator=',' max=1000 SMOO=false - setvalue clamps to max #2846", + alias: "numeric", + opts: { + prefix: "$", + groupSeparator: ",", + radixPoint: ".", + digits: 2, + min: 0, + max: 1000, + SetMaxOnOverflow: false + }, + value: "12345", + expected: "$1,000" + }, + { + label: + "numeric prefix='$' groupSeparator=',' max=1000 SMOO=false - setvalue('$5,000.00') clamps to max #2846", + alias: "numeric", + opts: { + prefix: "$", + groupSeparator: ",", + radixPoint: ".", + digits: 2, + min: 0, + max: 1000, + SetMaxOnOverflow: false + }, + value: "$5,000.00", + expected: "$1,000.00" + }, + // Symmetric counterpart of the "$5,000.00" above-max test: formatted + // below-min input must also clamp (not coerce to NaN and pass through). + { + label: + "numeric groupSeparator=',' min=10000 - setvalue('1,234') clamps to min #2846", + alias: "numeric", + opts: { + groupSeparator: ",", + radixPoint: ".", + digits: 2, + min: 10000, + max: 99999 + }, + value: "1,234", + expected: "10,000" + }, + + // Setvalue must not throw when onBeforeMask is null (frameworks + // sometimes nullify alias hooks). + { + label: "numeric onBeforeMask=null - setvalue does not throw #2846", + alias: "numeric", + opts: { digits: 0, max: 50, onBeforeMask: null }, + value: "10", + expected: "10" + }, + + // European locale: JS Number.toString() yields "1234.56", but with + // radixPoint="," and groupSeparator=".", a naive unmask would treat the + // dot as a thousands separator and read 123456 instead of 1234.56. + // Mirror onBeforeMask's number→radixPoint normalization. + { + label: + "numeric radixPoint=',' groupSeparator='.' - setValue(1234.56) preserves in-range value #2846", + alias: "numeric", + opts: { + radixPoint: ",", + groupSeparator: ".", + digits: 2, + min: 0, + max: 2000 + }, + value: 1234.56, + expected: "1.234,56" + }, + + // ---- baseline pass-through cases ---- + // In-range setvalue must pass through unchanged. + { + label: + "numeric min=0 max=100 - setvalue('50') passes through in-range #2846", + alias: "numeric", + opts: { digits: 0, min: 0, max: 100 }, + value: "50", + expected: "50" + }, + // Empty input must not trigger the min clamp — parseNumeric returns "" + // and the clamp block is guarded by `initialValue !== ""`. + { + label: "numeric min=10 - setvalue('') leaves field empty #2846", + alias: "numeric", + opts: { digits: 0, min: 10, max: 999 }, + value: "", + expected: "" + }, + // Number arg below min: covers typeof==="number" normalization plus + // the clamp branch in one path. + { + label: "numeric min=100 - setvalue(50) as number clamps to min #2846", + alias: "numeric", + opts: { digits: 0, min: 100, max: 999 }, + value: 50, + expected: "100" + }, + + // After clamping a negative input up to a non-negative min, the + // original "-" must not be re-prepended onto the boundary. + { + label: + "integer min=10 max=100 - setvalue('-5') clamps to '10' (no stray '-' re-prepended)", + alias: "integer", + opts: { min: 10, max: 100 }, + value: "-5", + expected: "10" + } + ].forEach(function (tc) { + qunit.test(tc.label, function (assert) { + const $fixture = $("#qunit-fixture"); + $fixture.append(''); + const testmask = document.getElementById("testmask"); + Inputmask(tc.alias, tc.opts).mask(testmask); + testmask.inputmask.setValue(tc.value); + assert.equal( + testmask.value, + tc.expected, + tc.label + " - got " + testmask.value + ); + }); + }); + + // Internal numeric rewrites (negation-delete, radix-dance) push their + // already-clean buffer back with onBeforeMask bypassed — must not be + // re-clamped through the alias parser. Deleting "-" from "-50" with + // {max:30, SMOO:false} leaves keyboard validation to settle on "5"; + // re-clamping would coerce that to "30". + qunit.test( + "numeric min=-100 max=30 SMOO=false - Backspace on '-' of '-50' does not clamp to max #2846", + function (assert) { + const done = assert.async(), + $fixture = $("#qunit-fixture"); + $fixture.append(''); + const testmask = document.getElementById("testmask"); + Inputmask("numeric", { + min: -100, + max: 30, + digits: 0, + SetMaxOnOverflow: false + }).mask(testmask); + testmask.focus(); + setTimeout(function () { + $("#testmask").Type("-50"); + assert.equal(testmask.value, "-50", "Should have -50"); + $.caret(testmask, 1); + $("#testmask").SendKey(keys.Backspace); + setTimeout(function () { + assert.equal( + testmask.value, + "5", + "Internal buffer rewrite from negation-delete should keep keyboard validation - got " + + testmask.value + ); + done(); + }, 0); + }, 0); + } + ); }