diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 7e26ca16f6d1..4c9cdc360924 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1529,13 +1529,62 @@ function parseNumberAttrVal(val) { return !isNumberNaN(val) ? val : undefined; } +function isNumberInteger(num) { + // See http://stackoverflow.com/questions/14636536/how-to-check-if-a-variable-is-an-integer-in-javascript#14794066 + // (minus the assumption that `num` is a number) + + // eslint-disable-next-line no-bitwise + return (num | 0) === num; +} + +function countDecimals(num) { + var numString = num.toString(); + var decimalSymbolIndex = numString.indexOf('.'); + + if (decimalSymbolIndex === -1) { + if (-1 < num && num < 1) { + // It may be in the exponential notation format (`1e-X`) + var match = /e-(\d+)$/.exec(numString); + + if (match) { + return Number(match[1]); + } + } + + return 0; + } + + return numString.length - decimalSymbolIndex - 1; +} + +function isValidForStep(viewValue, stepBase, step) { + // At this point `stepBase` and `step` are expected to be non-NaN values + // and `viewValue` is expected to be a valid stringified number. + var value = Number(viewValue); + + // Due to limitations in Floating Point Arithmetic (e.g. `0.3 - 0.2 !== 0.1` or + // `0.5 % 0.1 !== 0`), we need to convert all numbers to integers. + if (!isNumberInteger(value) || !isNumberInteger(stepBase) || !isNumberInteger(step)) { + var decimalCount = Math.max(countDecimals(value), countDecimals(stepBase), countDecimals(step)); + var multiplier = Math.pow(10, decimalCount); + + value = value * multiplier; + stepBase = stepBase * multiplier; + step = step * multiplier; + } + + return (value - stepBase) % step === 0; +} + function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { badInputChecker(scope, element, attr, ctrl); numberFormatterParser(ctrl); baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + var minVal; + var maxVal; + if (isDefined(attr.min) || attr.ngMin) { - var minVal; ctrl.$validators.min = function(value) { return ctrl.$isEmpty(value) || isUndefined(minVal) || value >= minVal; }; @@ -1548,7 +1597,6 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { } if (isDefined(attr.max) || attr.ngMax) { - var maxVal; ctrl.$validators.max = function(value) { return ctrl.$isEmpty(value) || isUndefined(maxVal) || value <= maxVal; }; @@ -1563,7 +1611,8 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { if (isDefined(attr.step) || attr.ngStep) { var stepVal; ctrl.$validators.step = function(modelValue, viewValue) { - return ctrl.$isEmpty(viewValue) || isUndefined(stepVal) || viewValue % stepVal === 0; + return ctrl.$isEmpty(viewValue) || isUndefined(stepVal) || + isValidForStep(viewValue, minVal || 0, stepVal); }; attr.$observe('step', function(val) { @@ -1633,7 +1682,8 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { } : // ngStep doesn't set the setp attr, so the browser doesn't adjust the input value as setting step would function stepValidator(modelValue, viewValue) { - return ctrl.$isEmpty(viewValue) || isUndefined(stepVal) || viewValue % stepVal === 0; + return ctrl.$isEmpty(viewValue) || isUndefined(stepVal) || + isValidForStep(viewValue, minVal || 0, stepVal); }; setInitialValueAndObserver('step', stepChange); diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index 15471354c56f..40ceff4ac879 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -2621,154 +2621,173 @@ describe('input', function() { }); }); - describe('step', function() { - it('should validate', function() { - $rootScope.step = 10; - $rootScope.value = 20; - var inputElm = helper.compileInput(''); - expect(inputElm.val()).toBe('20'); - expect(inputElm).toBeValid(); - expect($rootScope.value).toBe(20); - expect($rootScope.form.alias.$error.step).toBeFalsy(); + forEach({ + step: 'step="{{step}}"', + ngStep: 'ng-step="step"' + }, function(attrHtml, attrName) { - helper.changeInputValueTo('18'); - expect(inputElm).toBeInvalid(); - expect(inputElm.val()).toBe('18'); - expect($rootScope.value).toBeUndefined(); - expect($rootScope.form.alias.$error.step).toBeTruthy(); + describe(attrName, function() { - helper.changeInputValueTo('10'); - expect(inputElm).toBeValid(); - expect(inputElm.val()).toBe('10'); - expect($rootScope.value).toBe(10); - expect($rootScope.form.alias.$error.step).toBeFalsy(); + it('should validate', function() { + $rootScope.step = 10; + $rootScope.value = 20; + var inputElm = helper.compileInput( + ''); - $rootScope.$apply('value = 12'); - expect(inputElm).toBeInvalid(); - expect(inputElm.val()).toBe('12'); - expect($rootScope.value).toBe(12); - expect($rootScope.form.alias.$error.step).toBeTruthy(); - }); + expect(inputElm.val()).toBe('20'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(20); + expect($rootScope.form.alias.$error.step).toBeFalsy(); - it('should validate even if the step value changes on-the-fly', function() { - $rootScope.step = 10; - var inputElm = helper.compileInput(''); + helper.changeInputValueTo('18'); + expect(inputElm).toBeInvalid(); + expect(inputElm.val()).toBe('18'); + expect($rootScope.value).toBeUndefined(); + expect($rootScope.form.alias.$error.step).toBeTruthy(); - helper.changeInputValueTo('10'); - expect(inputElm).toBeValid(); - expect($rootScope.value).toBe(10); + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('10'); + expect($rootScope.value).toBe(10); + expect($rootScope.form.alias.$error.step).toBeFalsy(); - // Step changes, but value matches - $rootScope.$apply('step = 5'); - expect(inputElm.val()).toBe('10'); - expect(inputElm).toBeValid(); - expect($rootScope.value).toBe(10); - expect($rootScope.form.alias.$error.step).toBeFalsy(); + $rootScope.$apply('value = 12'); + expect(inputElm).toBeInvalid(); + expect(inputElm.val()).toBe('12'); + expect($rootScope.value).toBe(12); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + }); - // Step changes, value does not match - $rootScope.$apply('step = 6'); - expect(inputElm).toBeInvalid(); - expect($rootScope.value).toBeUndefined(); - expect(inputElm.val()).toBe('10'); - expect($rootScope.form.alias.$error.step).toBeTruthy(); + it('should validate even if the step value changes on-the-fly', function() { + $rootScope.step = 10; + var inputElm = helper.compileInput( + ''); - // null = valid - $rootScope.$apply('step = null'); - expect(inputElm).toBeValid(); - expect($rootScope.value).toBe(10); - expect(inputElm.val()).toBe('10'); - expect($rootScope.form.alias.$error.step).toBeFalsy(); + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); - // Step val as string - $rootScope.$apply('step = "7"'); - expect(inputElm).toBeInvalid(); - expect($rootScope.value).toBeUndefined(); - expect(inputElm.val()).toBe('10'); - expect($rootScope.form.alias.$error.step).toBeTruthy(); + // Step changes, but value matches + $rootScope.$apply('step = 5'); + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + expect($rootScope.form.alias.$error.step).toBeFalsy(); - // unparsable string is ignored - $rootScope.$apply('step = "abc"'); - expect(inputElm).toBeValid(); - expect($rootScope.value).toBe(10); - expect(inputElm.val()).toBe('10'); - expect($rootScope.form.alias.$error.step).toBeFalsy(); - }); - }); + // Step changes, value does not match + $rootScope.$apply('step = 6'); + expect(inputElm).toBeInvalid(); + expect($rootScope.value).toBeUndefined(); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + // null = valid + $rootScope.$apply('step = null'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeFalsy(); - describe('ngStep', function() { - it('should validate', function() { - $rootScope.step = 10; - $rootScope.value = 20; - var inputElm = helper.compileInput(''); + // Step val as string + $rootScope.$apply('step = "7"'); + expect(inputElm).toBeInvalid(); + expect($rootScope.value).toBeUndefined(); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeTruthy(); - expect(inputElm.val()).toBe('20'); - expect(inputElm).toBeValid(); - expect($rootScope.value).toBe(20); - expect($rootScope.form.alias.$error.step).toBeFalsy(); + // unparsable string is ignored + $rootScope.$apply('step = "abc"'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + }); - helper.changeInputValueTo('18'); - expect(inputElm).toBeInvalid(); - expect(inputElm.val()).toBe('18'); - expect($rootScope.value).toBeUndefined(); - expect($rootScope.form.alias.$error.step).toBeTruthy(); + it('should use the correct "step base" when `[min]` is specified', function() { + $rootScope.min = 5; + $rootScope.step = 10; + $rootScope.value = 10; + var inputElm = helper.compileInput( + ''); + var ngModel = inputElm.controller('ngModel'); - helper.changeInputValueTo('10'); - expect(inputElm).toBeValid(); - expect(inputElm.val()).toBe('10'); - expect($rootScope.value).toBe(10); - expect($rootScope.form.alias.$error.step).toBeFalsy(); + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); - $rootScope.$apply('value = 12'); - expect(inputElm).toBeInvalid(); - expect(inputElm.val()).toBe('12'); - expect($rootScope.value).toBe(12); - expect($rootScope.form.alias.$error.step).toBeTruthy(); - }); + helper.changeInputValueTo('15'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(15); - it('should validate even if the step value changes on-the-fly', function() { - $rootScope.step = 10; - var inputElm = helper.compileInput(''); + $rootScope.$apply('step = 3'); + expect(inputElm.val()).toBe('15'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); - helper.changeInputValueTo('10'); - expect(inputElm).toBeValid(); - expect($rootScope.value).toBe(10); + helper.changeInputValueTo('8'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(8); - // Step changes, but value matches - $rootScope.$apply('step = 5'); - expect(inputElm.val()).toBe('10'); - expect(inputElm).toBeValid(); - expect($rootScope.value).toBe(10); - expect($rootScope.form.alias.$error.step).toBeFalsy(); + $rootScope.$apply('min = 10; step = 20'); + helper.changeInputValueTo('30'); + expect(inputElm.val()).toBe('30'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(30); - // Step changes, value does not match - $rootScope.$apply('step = 6'); - expect(inputElm).toBeInvalid(); - expect($rootScope.value).toBeUndefined(); - expect(inputElm.val()).toBe('10'); - expect($rootScope.form.alias.$error.step).toBeTruthy(); + $rootScope.$apply('min = 5'); + expect(inputElm.val()).toBe('30'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); - // null = valid - $rootScope.$apply('step = null'); - expect(inputElm).toBeValid(); - expect($rootScope.value).toBe(10); - expect(inputElm.val()).toBe('10'); - expect($rootScope.form.alias.$error.step).toBeFalsy(); + $rootScope.$apply('step = 0.00000001'); + expect(inputElm.val()).toBe('30'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(30); - // Step val as string - $rootScope.$apply('step = "7"'); - expect(inputElm).toBeInvalid(); - expect($rootScope.value).toBeUndefined(); - expect(inputElm.val()).toBe('10'); - expect($rootScope.form.alias.$error.step).toBeTruthy(); + // 0.3 - 0.2 === 0.09999999999999998 + $rootScope.$apply('min = 0.2; step = (0.3 - 0.2)'); + helper.changeInputValueTo('0.3'); + expect(inputElm.val()).toBe('0.3'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + }); - // unparsable string is ignored - $rootScope.$apply('step = "abc"'); - expect(inputElm).toBeValid(); - expect($rootScope.value).toBe(10); - expect(inputElm.val()).toBe('10'); - expect($rootScope.form.alias.$error.step).toBeFalsy(); + it('should correctly validate even in cases where the JS floating point arithmetic fails', + function() { + $rootScope.step = 0.1; + var inputElm = helper.compileInput( + ''); + var ngModel = inputElm.controller('ngModel'); + + expect(inputElm.val()).toBe(''); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBeUndefined(); + + helper.changeInputValueTo('0.3'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(0.3); + + helper.changeInputValueTo('2.9999999999999996'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + + // 0.5 % 0.1 === 0.09999999999999998 + helper.changeInputValueTo('0.5'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(0.5); + + // 3.5 % 0.1 === 0.09999999999999981 + helper.changeInputValueTo('3.5'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(3.5); + } + ); }); }); @@ -3001,7 +3020,6 @@ describe('input', function() { }); describe('range', function() { - var scope; var rangeTestEl = angular.element(''); @@ -3048,7 +3066,6 @@ describe('input', function() { expect(scope.age).toBe(50); expect(inputElm).toBeValid(); }); - } else { it('should reset the model if view is invalid', function() { @@ -3438,7 +3455,6 @@ describe('input', function() { expect(scope.value).toBe(40); }); }); - } @@ -3448,6 +3464,7 @@ describe('input', function() { // Browsers that implement range will never allow you to set a value that doesn't match the step value // However, currently only Firefox fully implements the spec when setting the value after the step value changes. // Other browsers fail in various edge cases, which is why they are not tested here. + it('should round the input value to the nearest step on user input', function() { var inputElm = helper.compileInput(''); @@ -3510,8 +3527,8 @@ describe('input', function() { expect(scope.value).toBe(10); expect(scope.form.alias.$error.step).toBeFalsy(); }); - } else { + it('should validate if "range" is not implemented', function() { scope.step = 10; scope.value = 20; @@ -3584,6 +3601,87 @@ describe('input', function() { expect(inputElm.val()).toBe('10'); expect(scope.form.alias.$error.step).toBeFalsy(); }); + + it('should use the correct "step base" when `[min]` is specified', function() { + $rootScope.min = 5; + $rootScope.step = 10; + $rootScope.value = 10; + var inputElm = helper.compileInput( + ''); + var ngModel = inputElm.controller('ngModel'); + + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + + helper.changeInputValueTo('15'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(15); + + $rootScope.$apply('step = 3'); + expect(inputElm.val()).toBe('15'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + + helper.changeInputValueTo('8'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(8); + + $rootScope.$apply('min = 10; step = 20; value = 30'); + expect(inputElm.val()).toBe('30'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(30); + + $rootScope.$apply('min = 5'); + expect(inputElm.val()).toBe('30'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + + $rootScope.$apply('step = 0.00000001'); + expect(inputElm.val()).toBe('30'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(30); + + // 0.3 - 0.2 === 0.09999999999999998 + $rootScope.$apply('min = 0.2; step = 0.09999999999999998; value = 0.3'); + expect(inputElm.val()).toBe('0.3'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + }); + + it('should correctly validate even in cases where the JS floating point arithmetic fails', + function() { + var inputElm = helper.compileInput(''); + var ngModel = inputElm.controller('ngModel'); + + expect(inputElm.val()).toBe(''); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBeUndefined(); + + helper.changeInputValueTo('0.3'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(0.3); + + helper.changeInputValueTo('2.9999999999999996'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + + // 0.5 % 0.1 === 0.09999999999999998 + helper.changeInputValueTo('0.5'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(0.5); + + // 3.5 % 0.1 === 0.09999999999999981 + helper.changeInputValueTo('3.5'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(3.5); + } + ); } }); });