'use strict'; /* globals generateInputCompilerHelper: false */ describe('ngModel', function() { describe('NgModelController', function() { /* global NgModelController: false */ var ctrl, scope, element, parentFormCtrl; beforeEach(inject(function($rootScope, $controller) { var attrs = {name: 'testAlias', ngModel: 'value'}; parentFormCtrl = { $$setPending: jasmine.createSpy('$$setPending'), $setValidity: jasmine.createSpy('$setValidity'), $setDirty: jasmine.createSpy('$setDirty'), $$clearControlValidity: noop }; element = jqLite('
'); scope = $rootScope; ctrl = $controller(NgModelController, { $scope: scope, $element: element.find('input'), $attrs: attrs }); //Assign the mocked parentFormCtrl to the model controller ctrl.$$parentForm = parentFormCtrl; })); afterEach(function() { dealoc(element); }); it('should init the properties', function() { expect(ctrl.$untouched).toBe(true); expect(ctrl.$touched).toBe(false); expect(ctrl.$dirty).toBe(false); expect(ctrl.$pristine).toBe(true); expect(ctrl.$valid).toBe(true); expect(ctrl.$invalid).toBe(false); expect(ctrl.$viewValue).toBeDefined(); expect(ctrl.$modelValue).toBeDefined(); expect(ctrl.$formatters).toEqual([]); expect(ctrl.$parsers).toEqual([]); expect(ctrl.$name).toBe('testAlias'); }); describe('setValidity', function() { function expectOneError() { expect(ctrl.$error).toEqual({someError: true}); expect(ctrl.$$success).toEqual({}); expect(ctrl.$pending).toBeUndefined(); } function expectOneSuccess() { expect(ctrl.$error).toEqual({}); expect(ctrl.$$success).toEqual({someError: true}); expect(ctrl.$pending).toBeUndefined(); } function expectOnePending() { expect(ctrl.$error).toEqual({}); expect(ctrl.$$success).toEqual({}); expect(ctrl.$pending).toEqual({someError: true}); } function expectCleared() { expect(ctrl.$error).toEqual({}); expect(ctrl.$$success).toEqual({}); expect(ctrl.$pending).toBeUndefined(); } it('should propagate validity to the parent form', function() { expect(parentFormCtrl.$setValidity).not.toHaveBeenCalled(); ctrl.$setValidity('ERROR', false); expect(parentFormCtrl.$setValidity).toHaveBeenCalledOnceWith('ERROR', false, ctrl); }); it('should transition from states correctly', function() { expectCleared(); ctrl.$setValidity('someError', false); expectOneError(); ctrl.$setValidity('someError', undefined); expectOnePending(); ctrl.$setValidity('someError', true); expectOneSuccess(); ctrl.$setValidity('someError', null); expectCleared(); }); it('should set valid/invalid with multiple errors', function() { ctrl.$setValidity('first', false); expect(ctrl.$valid).toBe(false); expect(ctrl.$invalid).toBe(true); ctrl.$setValidity('second', false); expect(ctrl.$valid).toBe(false); expect(ctrl.$invalid).toBe(true); ctrl.$setValidity('third', undefined); expect(ctrl.$valid).toBeUndefined(); expect(ctrl.$invalid).toBeUndefined(); ctrl.$setValidity('third', null); expect(ctrl.$valid).toBe(false); expect(ctrl.$invalid).toBe(true); ctrl.$setValidity('second', true); expect(ctrl.$valid).toBe(false); expect(ctrl.$invalid).toBe(true); ctrl.$setValidity('first', true); expect(ctrl.$valid).toBe(true); expect(ctrl.$invalid).toBe(false); }); }); describe('setPristine', function() { it('should set control to its pristine state', function() { ctrl.$setViewValue('edit'); expect(ctrl.$dirty).toBe(true); expect(ctrl.$pristine).toBe(false); ctrl.$setPristine(); expect(ctrl.$dirty).toBe(false); expect(ctrl.$pristine).toBe(true); }); }); describe('setDirty', function() { it('should set control to its dirty state', function() { expect(ctrl.$pristine).toBe(true); expect(ctrl.$dirty).toBe(false); ctrl.$setDirty(); expect(ctrl.$pristine).toBe(false); expect(ctrl.$dirty).toBe(true); }); it('should set parent form to its dirty state', function() { ctrl.$setDirty(); expect(parentFormCtrl.$setDirty).toHaveBeenCalled(); }); }); describe('setUntouched', function() { it('should set control to its untouched state', function() { ctrl.$setTouched(); ctrl.$setUntouched(); expect(ctrl.$touched).toBe(false); expect(ctrl.$untouched).toBe(true); }); }); describe('setTouched', function() { it('should set control to its touched state', function() { ctrl.$setUntouched(); ctrl.$setTouched(); expect(ctrl.$touched).toBe(true); expect(ctrl.$untouched).toBe(false); }); }); describe('view -> model', function() { it('should set the value to $viewValue', function() { ctrl.$setViewValue('some-val'); expect(ctrl.$viewValue).toBe('some-val'); }); it('should pipeline all registered parsers and set result to $modelValue', function() { var log = []; ctrl.$parsers.push(function(value) { log.push(value); return value + '-a'; }); ctrl.$parsers.push(function(value) { log.push(value); return value + '-b'; }); ctrl.$setViewValue('init'); expect(log).toEqual(['init', 'init-a']); expect(ctrl.$modelValue).toBe('init-a-b'); }); it('should fire viewChangeListeners when the value changes in the view (even if invalid)', function() { var spy = jasmine.createSpy('viewChangeListener'); ctrl.$viewChangeListeners.push(spy); ctrl.$setViewValue('val'); expect(spy).toHaveBeenCalledOnce(); spy.calls.reset(); // invalid ctrl.$parsers.push(function() {return undefined;}); ctrl.$setViewValue('val2'); expect(spy).toHaveBeenCalledOnce(); }); it('should reset the model when the view is invalid', function() { ctrl.$setViewValue('aaaa'); expect(ctrl.$modelValue).toBe('aaaa'); // add a validator that will make any input invalid ctrl.$parsers.push(function() {return undefined;}); expect(ctrl.$modelValue).toBe('aaaa'); ctrl.$setViewValue('bbbb'); expect(ctrl.$modelValue).toBeUndefined(); }); it('should not reset the model when the view is invalid due to an external validator', function() { ctrl.$setViewValue('aaaa'); expect(ctrl.$modelValue).toBe('aaaa'); ctrl.$setValidity('someExternalError', false); ctrl.$setViewValue('bbbb'); expect(ctrl.$modelValue).toBe('bbbb'); }); it('should not reset the view when the view is invalid', function() { // this test fails when the view changes the model and // then the model listener in ngModel picks up the change and // tries to update the view again. // add a validator that will make any input invalid ctrl.$parsers.push(function() {return undefined;}); spyOn(ctrl, '$render'); // first digest ctrl.$setViewValue('bbbb'); expect(ctrl.$modelValue).toBeUndefined(); expect(ctrl.$viewValue).toBe('bbbb'); expect(ctrl.$render).not.toHaveBeenCalled(); expect(scope.value).toBeUndefined(); // further digests scope.$apply('value = "aaa"'); expect(ctrl.$viewValue).toBe('aaa'); ctrl.$render.calls.reset(); ctrl.$setViewValue('cccc'); expect(ctrl.$modelValue).toBeUndefined(); expect(ctrl.$viewValue).toBe('cccc'); expect(ctrl.$render).not.toHaveBeenCalled(); expect(scope.value).toBeUndefined(); }); it('should call parentForm.$setDirty only when pristine', function() { ctrl.$setViewValue(''); expect(ctrl.$pristine).toBe(false); expect(ctrl.$dirty).toBe(true); expect(parentFormCtrl.$setDirty).toHaveBeenCalledOnce(); parentFormCtrl.$setDirty.calls.reset(); ctrl.$setViewValue(''); expect(ctrl.$pristine).toBe(false); expect(ctrl.$dirty).toBe(true); expect(parentFormCtrl.$setDirty).not.toHaveBeenCalled(); }); it('should remove all other errors when any parser returns undefined', function() { var a, b, val = function(val, x) { return x ? val : x; }; ctrl.$parsers.push(function(v) { return val(v, a); }); ctrl.$parsers.push(function(v) { return val(v, b); }); ctrl.$validators.high = function(value) { return !isDefined(value) || value > 5; }; ctrl.$validators.even = function(value) { return !isDefined(value) || value % 2 === 0; }; a = b = true; ctrl.$setViewValue('3'); expect(ctrl.$error).toEqual({ high: true, even: true }); ctrl.$setViewValue('10'); expect(ctrl.$error).toEqual({}); a = undefined; ctrl.$setViewValue('12'); expect(ctrl.$error).toEqual({ parse: true }); a = true; b = undefined; ctrl.$setViewValue('14'); expect(ctrl.$error).toEqual({ parse: true }); a = undefined; b = undefined; ctrl.$setViewValue('16'); expect(ctrl.$error).toEqual({ parse: true }); a = b = false; //not undefined ctrl.$setViewValue('2'); expect(ctrl.$error).toEqual({ high: true }); }); it('should not remove external validators when a parser failed', function() { ctrl.$parsers.push(function(v) { return undefined; }); ctrl.$setValidity('externalError', false); ctrl.$setViewValue('someValue'); expect(ctrl.$error).toEqual({ externalError: true, parse: true }); }); it('should remove all non-parse-related CSS classes from the form when a parser fails', inject(function($compile, $rootScope) { var element = $compile('')($rootScope); var inputElm = element.find('input'); var ctrl = $rootScope.myForm.myControl; var parserIsFailing = false; ctrl.$parsers.push(function(value) { return parserIsFailing ? undefined : value; }); ctrl.$validators.alwaysFail = function() { return false; }; ctrl.$setViewValue('123'); scope.$digest(); expect(element).toHaveClass('ng-valid-parse'); expect(element).not.toHaveClass('ng-invalid-parse'); expect(element).toHaveClass('ng-invalid-always-fail'); parserIsFailing = true; ctrl.$setViewValue('12345'); scope.$digest(); expect(element).not.toHaveClass('ng-valid-parse'); expect(element).toHaveClass('ng-invalid-parse'); expect(element).not.toHaveClass('ng-invalid-always-fail'); dealoc(element); })); it('should set the ng-invalid-parse and ng-valid-parse CSS class when parsers fail and pass', function() { var pass = true; ctrl.$parsers.push(function(v) { return pass ? v : undefined; }); var input = element.find('input'); ctrl.$setViewValue('1'); expect(input).toHaveClass('ng-valid-parse'); expect(input).not.toHaveClass('ng-invalid-parse'); pass = undefined; ctrl.$setViewValue('2'); expect(input).not.toHaveClass('ng-valid-parse'); expect(input).toHaveClass('ng-invalid-parse'); }); it('should update the model after all async validators resolve', inject(function($q) { var defer; ctrl.$asyncValidators.promiseValidator = function(value) { defer = $q.defer(); return defer.promise; }; // set view value on first digest ctrl.$setViewValue('b'); expect(ctrl.$modelValue).toBeUndefined(); expect(scope.value).toBeUndefined(); defer.resolve(); scope.$digest(); expect(ctrl.$modelValue).toBe('b'); expect(scope.value).toBe('b'); // set view value on further digests ctrl.$setViewValue('c'); expect(ctrl.$modelValue).toBe('b'); expect(scope.value).toBe('b'); defer.resolve(); scope.$digest(); expect(ctrl.$modelValue).toBe('c'); expect(scope.value).toBe('c'); })); it('should not throw an error if the scope has been destroyed', function() { scope.$destroy(); ctrl.$setViewValue('some-val'); expect(ctrl.$viewValue).toBe('some-val'); }); }); describe('model -> view', function() { it('should set the value to $modelValue', function() { scope.$apply('value = 10'); expect(ctrl.$modelValue).toBe(10); }); it('should pipeline all registered formatters in reversed order and set result to $viewValue', function() { var log = []; ctrl.$formatters.unshift(function(value) { log.push(value); return value + 2; }); ctrl.$formatters.unshift(function(value) { log.push(value); return value + ''; }); scope.$apply('value = 3'); expect(log).toEqual([3, 5]); expect(ctrl.$viewValue).toBe('5'); }); it('should $render only if value changed', function() { spyOn(ctrl, '$render'); scope.$apply('value = 3'); expect(ctrl.$render).toHaveBeenCalledOnce(); ctrl.$render.calls.reset(); ctrl.$formatters.push(function() {return 3;}); scope.$apply('value = 5'); expect(ctrl.$render).not.toHaveBeenCalled(); }); it('should clear the view even if invalid', function() { spyOn(ctrl, '$render'); ctrl.$formatters.push(function() {return undefined;}); scope.$apply('value = 5'); expect(ctrl.$render).toHaveBeenCalledOnce(); }); it('should render immediately even if there are async validators', inject(function($q) { spyOn(ctrl, '$render'); ctrl.$asyncValidators.someValidator = function() { return $q.defer().promise; }; scope.$apply('value = 5'); expect(ctrl.$viewValue).toBe(5); expect(ctrl.$render).toHaveBeenCalledOnce(); })); it('should not rerender nor validate in case view value is not changed', function() { ctrl.$formatters.push(function(value) { return 'nochange'; }); spyOn(ctrl, '$render'); ctrl.$validators.spyValidator = jasmine.createSpy('spyValidator'); scope.$apply('value = "first"'); scope.$apply('value = "second"'); expect(ctrl.$validators.spyValidator).toHaveBeenCalledOnce(); expect(ctrl.$render).toHaveBeenCalledOnce(); }); it('should always format the viewValue as a string for a blank input type when the value is present', inject(function($compile, $rootScope, $sniffer) { var form = $compile('')($rootScope); $rootScope.val = 123; $rootScope.$digest(); expect($rootScope.form.field.$viewValue).toBe('123'); $rootScope.val = null; $rootScope.$digest(); expect($rootScope.form.field.$viewValue).toBe(null); dealoc(form); })); it('should always format the viewValue as a string for a `text` input type when the value is present', inject(function($compile, $rootScope, $sniffer) { var form = $compile('')($rootScope); $rootScope.val = 123; $rootScope.$digest(); expect($rootScope.form.field.$viewValue).toBe('123'); $rootScope.val = null; $rootScope.$digest(); expect($rootScope.form.field.$viewValue).toBe(null); dealoc(form); })); it('should always format the viewValue as a string for an `email` input type when the value is present', inject(function($compile, $rootScope, $sniffer) { var form = $compile('')($rootScope); $rootScope.val = 123; $rootScope.$digest(); expect($rootScope.form.field.$viewValue).toBe('123'); $rootScope.val = null; $rootScope.$digest(); expect($rootScope.form.field.$viewValue).toBe(null); dealoc(form); })); it('should always format the viewValue as a string for a `url` input type when the value is present', inject(function($compile, $rootScope, $sniffer) { var form = $compile('')($rootScope); $rootScope.val = 123; $rootScope.$digest(); expect($rootScope.form.field.$viewValue).toBe('123'); $rootScope.val = null; $rootScope.$digest(); expect($rootScope.form.field.$viewValue).toBe(null); dealoc(form); })); it('should set NaN as the $modelValue when an asyncValidator is present', inject(function($q) { ctrl.$asyncValidators.test = function() { return $q(function(resolve, reject) { resolve(); }); }; scope.$apply('value = 10'); expect(ctrl.$modelValue).toBe(10); expect(function() { scope.$apply(function() { scope.value = NaN; }); }).not.toThrow(); expect(ctrl.$modelValue).toBeNaN(); })); describe('$processModelValue', function() { // Emulate setting the model on the scope function setModelValue(ctrl, value) { ctrl.$modelValue = ctrl.$$rawModelValue = value; ctrl.$$parserValid = undefined; } it('should run the model -> view pipeline', function() { var log = []; var input = ctrl.$$element; ctrl.$formatters.unshift(function(value) { log.push(value); return value + 2; }); ctrl.$formatters.unshift(function(value) { log.push(value); return value + ''; }); spyOn(ctrl, '$render'); setModelValue(ctrl, 3); expect(ctrl.$modelValue).toBe(3); ctrl.$processModelValue(); expect(ctrl.$modelValue).toBe(3); expect(log).toEqual([3, 5]); expect(ctrl.$viewValue).toBe('5'); expect(ctrl.$render).toHaveBeenCalledOnce(); }); it('should add the validation and empty-state classes', inject(function($compile, $rootScope, $animate) { var input = $compile('')($rootScope); $rootScope.$digest(); spyOn($animate, 'addClass'); spyOn($animate, 'removeClass'); var ctrl = input.controller('ngModel'); expect(input).toHaveClass('ng-empty'); expect(input).toHaveClass('ng-valid'); setModelValue(ctrl, 3); ctrl.$processModelValue(); // $animate adds / removes classes in the $$postDigest, which // we cannot trigger with $digest, because that would set the model from the scope, // so we simply check if the functions have been called expect($animate.removeClass.calls.mostRecent().args[0][0]).toBe(input[0]); expect($animate.removeClass.calls.mostRecent().args[1]).toBe('ng-empty'); expect($animate.addClass.calls.mostRecent().args[0][0]).toBe(input[0]); expect($animate.addClass.calls.mostRecent().args[1]).toBe('ng-not-empty'); $animate.removeClass.calls.reset(); $animate.addClass.calls.reset(); setModelValue(ctrl, 35); ctrl.$processModelValue(); expect($animate.addClass.calls.argsFor(1)[0][0]).toBe(input[0]); expect($animate.addClass.calls.argsFor(1)[1]).toBe('ng-invalid'); expect($animate.addClass.calls.argsFor(2)[0][0]).toBe(input[0]); expect($animate.addClass.calls.argsFor(2)[1]).toBe('ng-invalid-maxlength'); }) ); // this is analogue to $setViewValue it('should run the model -> view pipeline even if the value has not changed', function() { var log = []; ctrl.$formatters.unshift(function(value) { log.push(value); return value + 2; }); ctrl.$formatters.unshift(function(value) { log.push(value); return value + ''; }); spyOn(ctrl, '$render'); setModelValue(ctrl, 3); ctrl.$processModelValue(); expect(ctrl.$modelValue).toBe(3); expect(ctrl.$viewValue).toBe('5'); expect(log).toEqual([3, 5]); expect(ctrl.$render).toHaveBeenCalledOnce(); ctrl.$processModelValue(); expect(ctrl.$modelValue).toBe(3); expect(ctrl.$viewValue).toBe('5'); expect(log).toEqual([3, 5, 3, 5]); // $render() is not called if the viewValue didn't change expect(ctrl.$render).toHaveBeenCalledOnce(); }); }); }); describe('validation', function() { describe('$validate', function() { it('should perform validations when $validate() is called', function() { scope.$apply('value = ""'); var validatorResult = false; ctrl.$validators.someValidator = function(value) { return validatorResult; }; ctrl.$validate(); expect(ctrl.$valid).toBe(false); validatorResult = true; ctrl.$validate(); expect(ctrl.$valid).toBe(true); }); it('should pass the last parsed modelValue to the validators', function() { ctrl.$parsers.push(function(modelValue) { return modelValue + 'def'; }); ctrl.$setViewValue('abc'); ctrl.$validators.test = function(modelValue, viewValue) { return true; }; spyOn(ctrl.$validators, 'test'); ctrl.$validate(); expect(ctrl.$validators.test).toHaveBeenCalledWith('abcdef', 'abc'); }); it('should set the model to undefined when it becomes invalid', function() { var valid = true; ctrl.$validators.test = function(modelValue, viewValue) { return valid; }; scope.$apply('value = "abc"'); expect(scope.value).toBe('abc'); valid = false; ctrl.$validate(); expect(scope.value).toBeUndefined(); }); it('should update the model when it becomes valid', function() { var valid = true; ctrl.$validators.test = function(modelValue, viewValue) { return valid; }; scope.$apply('value = "abc"'); expect(scope.value).toBe('abc'); valid = false; ctrl.$validate(); expect(scope.value).toBeUndefined(); valid = true; ctrl.$validate(); expect(scope.value).toBe('abc'); }); it('should not update the model when it is valid, but there is a parse error', function() { ctrl.$parsers.push(function(modelValue) { return undefined; }); ctrl.$setViewValue('abc'); expect(ctrl.$error.parse).toBe(true); expect(scope.value).toBeUndefined(); ctrl.$validators.test = function(modelValue, viewValue) { return true; }; ctrl.$validate(); expect(ctrl.$error).toEqual({parse: true}); expect(scope.value).toBeUndefined(); }); it('should not set an invalid model to undefined when validity is the same', function() { ctrl.$validators.test = function() { return false; }; scope.$apply('value = "invalid"'); expect(ctrl.$valid).toBe(false); expect(scope.value).toBe('invalid'); ctrl.$validate(); expect(ctrl.$valid).toBe(false); expect(scope.value).toBe('invalid'); }); it('should not change a model that has a formatter', function() { ctrl.$validators.test = function() { return true; }; ctrl.$formatters.push(function(modelValue) { return 'xyz'; }); scope.$apply('value = "abc"'); expect(ctrl.$viewValue).toBe('xyz'); ctrl.$validate(); expect(scope.value).toBe('abc'); }); it('should not change a model that has a parser', function() { ctrl.$validators.test = function() { return true; }; ctrl.$parsers.push(function(modelValue) { return 'xyz'; }); scope.$apply('value = "abc"'); ctrl.$validate(); expect(scope.value).toBe('abc'); }); }); describe('view -> model update', function() { it('should always perform validations using the parsed model value', function() { var captures; ctrl.$validators.raw = function() { captures = Array.prototype.slice.call(arguments); return captures[0]; }; ctrl.$parsers.push(function(value) { return value.toUpperCase(); }); ctrl.$setViewValue('my-value'); expect(captures).toEqual(['MY-VALUE', 'my-value']); }); it('should always perform validations using the formatted view value', function() { var captures; ctrl.$validators.raw = function() { captures = Array.prototype.slice.call(arguments); return captures[0]; }; ctrl.$formatters.push(function(value) { return value + '...'; }); scope.$apply('value = "matias"'); expect(captures).toEqual(['matias', 'matias...']); }); it('should only perform validations if the view value is different', function() { var count = 0; ctrl.$validators.countMe = function() { count++; }; ctrl.$setViewValue('my-value'); expect(count).toBe(1); ctrl.$setViewValue('my-value'); expect(count).toBe(1); ctrl.$setViewValue('your-value'); expect(count).toBe(2); }); }); it('should perform validations twice each time the model value changes within a digest', function() { var count = 0; ctrl.$validators.number = function(value) { count++; return (/^\d+$/).test(value); }; scope.$apply('value = ""'); expect(count).toBe(1); scope.$apply('value = 1'); expect(count).toBe(2); scope.$apply('value = 1'); expect(count).toBe(2); scope.$apply('value = ""'); expect(count).toBe(3); }); it('should only validate to true if all validations are true', function() { ctrl.$modelValue = undefined; ctrl.$validators.a = valueFn(true); ctrl.$validators.b = valueFn(true); ctrl.$validators.c = valueFn(false); ctrl.$validate(); expect(ctrl.$valid).toBe(false); ctrl.$validators.c = valueFn(true); ctrl.$validate(); expect(ctrl.$valid).toBe(true); }); it('should treat all responses as boolean for synchronous validators', function() { var expectValid = function(value, expected) { ctrl.$modelValue = undefined; ctrl.$validators.a = valueFn(value); ctrl.$validate(); expect(ctrl.$valid).toBe(expected); }; // False tests expectValid(false, false); expectValid(undefined, false); expectValid(null, false); expectValid(0, false); expectValid(NaN, false); expectValid('', false); // True tests expectValid(true, true); expectValid(1, true); expectValid('0', true); expectValid('false', true); expectValid([], true); expectValid({}, true); }); it('should register invalid validations on the $error object', function() { ctrl.$modelValue = undefined; ctrl.$validators.unique = valueFn(false); ctrl.$validators.tooLong = valueFn(false); ctrl.$validators.notNumeric = valueFn(true); ctrl.$validate(); expect(ctrl.$error.unique).toBe(true); expect(ctrl.$error.tooLong).toBe(true); expect(ctrl.$error.notNumeric).not.toBe(true); }); it('should render a validator asynchronously when a promise is returned', inject(function($q) { var defer; ctrl.$asyncValidators.promiseValidator = function(value) { defer = $q.defer(); return defer.promise; }; scope.$apply('value = ""'); expect(ctrl.$valid).toBeUndefined(); expect(ctrl.$invalid).toBeUndefined(); expect(ctrl.$pending.promiseValidator).toBe(true); defer.resolve(); scope.$digest(); expect(ctrl.$valid).toBe(true); expect(ctrl.$invalid).toBe(false); expect(ctrl.$pending).toBeUndefined(); scope.$apply('value = "123"'); defer.reject(); scope.$digest(); expect(ctrl.$valid).toBe(false); expect(ctrl.$invalid).toBe(true); expect(ctrl.$pending).toBeUndefined(); })); it('should throw an error when a promise is not returned for an asynchronous validator', inject(function($q) { ctrl.$asyncValidators.async = function(value) { return true; }; expect(function() { scope.$apply('value = "123"'); }).toThrowMinErr('ngModel', 'nopromise', 'Expected asynchronous validator to return a promise but got \'true\' instead.'); })); it('should only run the async validators once all the sync validators have passed', inject(function($q) { var stages = {}; stages.sync = { status1: false, status2: false, count: 0 }; ctrl.$validators.syncValidator1 = function(modelValue, viewValue) { stages.sync.count++; return stages.sync.status1; }; ctrl.$validators.syncValidator2 = function(modelValue, viewValue) { stages.sync.count++; return stages.sync.status2; }; stages.async = { defer: null, count: 0 }; ctrl.$asyncValidators.asyncValidator = function(modelValue, viewValue) { stages.async.defer = $q.defer(); stages.async.count++; return stages.async.defer.promise; }; scope.$apply('value = "123"'); expect(ctrl.$valid).toBe(false); expect(ctrl.$invalid).toBe(true); expect(stages.sync.count).toBe(2); expect(stages.async.count).toBe(0); stages.sync.status1 = true; scope.$apply('value = "456"'); expect(stages.sync.count).toBe(4); expect(stages.async.count).toBe(0); stages.sync.status2 = true; scope.$apply('value = "789"'); expect(stages.sync.count).toBe(6); expect(stages.async.count).toBe(1); stages.async.defer.resolve(); scope.$apply(); expect(ctrl.$valid).toBe(true); expect(ctrl.$invalid).toBe(false); })); it('should ignore expired async validation promises once delivered', inject(function($q) { var defer, oldDefer, newDefer; ctrl.$asyncValidators.async = function(value) { defer = $q.defer(); return defer.promise; }; scope.$apply('value = ""'); oldDefer = defer; scope.$apply('value = "123"'); newDefer = defer; newDefer.reject(); scope.$digest(); oldDefer.resolve(); scope.$digest(); expect(ctrl.$valid).toBe(false); expect(ctrl.$invalid).toBe(true); expect(ctrl.$pending).toBeUndefined(); })); it('should clear and ignore all pending promises when the model value changes', inject(function($q) { ctrl.$validators.sync = function(value) { return true; }; var defers = []; ctrl.$asyncValidators.async = function(value) { var defer = $q.defer(); defers.push(defer); return defer.promise; }; scope.$apply('value = "123"'); expect(ctrl.$pending).toEqual({async: true}); expect(ctrl.$valid).toBeUndefined(); expect(ctrl.$invalid).toBeUndefined(); expect(defers.length).toBe(1); expect(isObject(ctrl.$pending)).toBe(true); scope.$apply('value = "456"'); expect(ctrl.$pending).toEqual({async: true}); expect(ctrl.$valid).toBeUndefined(); expect(ctrl.$invalid).toBeUndefined(); expect(defers.length).toBe(2); expect(isObject(ctrl.$pending)).toBe(true); defers[1].resolve(); scope.$digest(); expect(ctrl.$valid).toBe(true); expect(ctrl.$invalid).toBe(false); expect(isObject(ctrl.$pending)).toBe(false); })); it('should clear and ignore all pending promises when a parser fails', inject(function($q) { var failParser = false; ctrl.$parsers.push(function(value) { return failParser ? undefined : value; }); var defer; ctrl.$asyncValidators.async = function(value) { defer = $q.defer(); return defer.promise; }; ctrl.$setViewValue('x..y..z'); expect(ctrl.$valid).toBeUndefined(); expect(ctrl.$invalid).toBeUndefined(); failParser = true; ctrl.$setViewValue('1..2..3'); expect(ctrl.$valid).toBe(false); expect(ctrl.$invalid).toBe(true); expect(isObject(ctrl.$pending)).toBe(false); defer.resolve(); scope.$digest(); expect(ctrl.$valid).toBe(false); expect(ctrl.$invalid).toBe(true); expect(isObject(ctrl.$pending)).toBe(false); })); it('should clear all errors from async validators if a parser fails', inject(function($q) { var failParser = false; ctrl.$parsers.push(function(value) { return failParser ? undefined : value; }); ctrl.$asyncValidators.async = function(value) { return $q.reject(); }; ctrl.$setViewValue('x..y..z'); expect(ctrl.$error).toEqual({async: true}); failParser = true; ctrl.$setViewValue('1..2..3'); expect(ctrl.$error).toEqual({parse: true}); })); it('should clear all errors from async validators if a sync validator fails', inject(function($q) { var failValidator = false; ctrl.$validators.sync = function(value) { return !failValidator; }; ctrl.$asyncValidators.async = function(value) { return $q.reject(); }; ctrl.$setViewValue('x..y..z'); expect(ctrl.$error).toEqual({async: true}); failValidator = true; ctrl.$setViewValue('1..2..3'); expect(ctrl.$error).toEqual({sync: true}); })); it('should be possible to extend Object prototype and still be able to do form validation', inject(function($compile, $rootScope) { // eslint-disable-next-line no-extend-native Object.prototype.someThing = function() {}; var element = $compile('')($rootScope); var inputElm = element.find('input'); var formCtrl = $rootScope.myForm; var usernameCtrl = formCtrl.username; $rootScope.$digest(); expect(usernameCtrl.$invalid).toBe(true); expect(formCtrl.$invalid).toBe(true); usernameCtrl.$setViewValue('valid-username'); $rootScope.$digest(); expect(usernameCtrl.$invalid).toBe(false); expect(formCtrl.$invalid).toBe(false); delete Object.prototype.someThing; dealoc(element); })); it('should re-evaluate the form validity state once the asynchronous promise has been delivered', inject(function($compile, $rootScope, $q) { var element = $compile('')($rootScope); var inputElm = element.find('input'); var formCtrl = $rootScope.myForm; var usernameCtrl = formCtrl.username; var ageCtrl = formCtrl.age; var usernameDefer; usernameCtrl.$asyncValidators.usernameAvailability = function() { usernameDefer = $q.defer(); return usernameDefer.promise; }; $rootScope.$digest(); expect(usernameCtrl.$invalid).toBe(true); expect(formCtrl.$invalid).toBe(true); usernameCtrl.$setViewValue('valid-username'); $rootScope.$digest(); expect(formCtrl.$pending.usernameAvailability).toBeTruthy(); expect(usernameCtrl.$invalid).toBeUndefined(); expect(formCtrl.$invalid).toBeUndefined(); usernameDefer.resolve(); $rootScope.$digest(); expect(usernameCtrl.$invalid).toBe(false); expect(formCtrl.$invalid).toBe(true); ageCtrl.$setViewValue(22); $rootScope.$digest(); expect(usernameCtrl.$invalid).toBe(false); expect(ageCtrl.$invalid).toBe(false); expect(formCtrl.$invalid).toBe(false); usernameCtrl.$setViewValue('valid'); $rootScope.$digest(); expect(usernameCtrl.$invalid).toBe(true); expect(ageCtrl.$invalid).toBe(false); expect(formCtrl.$invalid).toBe(true); usernameCtrl.$setViewValue('another-valid-username'); $rootScope.$digest(); usernameDefer.resolve(); $rootScope.$digest(); expect(usernameCtrl.$invalid).toBe(false); expect(formCtrl.$invalid).toBe(false); expect(formCtrl.$pending).toBeFalsy(); expect(ageCtrl.$invalid).toBe(false); dealoc(element); })); it('should always use the most recent $viewValue for validation', function() { ctrl.$parsers.push(function(value) { if (value && value.substr(-1) === 'b') { value = 'a'; ctrl.$setViewValue(value); ctrl.$render(); } return value; }); ctrl.$validators.mock = function(modelValue) { return true; }; spyOn(ctrl.$validators, 'mock').and.callThrough(); ctrl.$setViewValue('ab'); expect(ctrl.$validators.mock).toHaveBeenCalledWith('a', 'a'); expect(ctrl.$validators.mock).toHaveBeenCalledTimes(2); }); it('should validate even if the modelValue did not change', function() { ctrl.$parsers.push(function(value) { if (value && value.substr(-1) === 'b') { value = 'a'; } return value; }); ctrl.$validators.mock = function(modelValue) { return true; }; spyOn(ctrl.$validators, 'mock').and.callThrough(); ctrl.$setViewValue('a'); expect(ctrl.$validators.mock).toHaveBeenCalledWith('a', 'a'); expect(ctrl.$validators.mock).toHaveBeenCalledTimes(1); ctrl.$setViewValue('ab'); expect(ctrl.$validators.mock).toHaveBeenCalledWith('a', 'ab'); expect(ctrl.$validators.mock).toHaveBeenCalledTimes(2); }); it('should validate correctly when $parser name equals $validator key', function() { ctrl.$validators.parserOrValidator = function(value) { switch (value) { case 'allInvalid': case 'parseValid-validatorsInvalid': case 'stillParseValid-validatorsInvalid': return false; default: return true; } }; ctrl.$validators.validator = function(value) { switch (value) { case 'allInvalid': case 'parseValid-validatorsInvalid': case 'stillParseValid-validatorsInvalid': return false; default: return true; } }; ctrl.$parsers.push(function(value) { switch (value) { case 'allInvalid': case 'stillAllInvalid': case 'parseInvalid-validatorsValid': case 'stillParseInvalid-validatorsValid': ctrl.$$parserName = 'parserOrValidator'; return undefined; default: return value; } }); //Parser and validators are invalid scope.$apply('value = "allInvalid"'); expect(scope.value).toBe('allInvalid'); expect(ctrl.$error).toEqual({parserOrValidator: true, validator: true}); ctrl.$validate(); expect(scope.value).toEqual('allInvalid'); expect(ctrl.$error).toEqual({parserOrValidator: true, validator: true}); ctrl.$setViewValue('stillAllInvalid'); expect(scope.value).toBeUndefined(); expect(ctrl.$error).toEqual({parserOrValidator: true}); ctrl.$validate(); expect(scope.value).toBeUndefined(); expect(ctrl.$error).toEqual({parserOrValidator: true}); //Parser is valid, validators are invalid scope.$apply('value = "parseValid-validatorsInvalid"'); expect(scope.value).toBe('parseValid-validatorsInvalid'); expect(ctrl.$error).toEqual({parserOrValidator: true, validator: true}); ctrl.$validate(); expect(scope.value).toBe('parseValid-validatorsInvalid'); expect(ctrl.$error).toEqual({parserOrValidator: true, validator: true}); ctrl.$setViewValue('stillParseValid-validatorsInvalid'); expect(scope.value).toBeUndefined(); expect(ctrl.$error).toEqual({parserOrValidator: true, validator: true}); ctrl.$validate(); expect(scope.value).toBeUndefined(); expect(ctrl.$error).toEqual({parserOrValidator: true, validator: true}); //Parser is invalid, validators are valid scope.$apply('value = "parseInvalid-validatorsValid"'); expect(scope.value).toBe('parseInvalid-validatorsValid'); expect(ctrl.$error).toEqual({}); ctrl.$validate(); expect(scope.value).toBe('parseInvalid-validatorsValid'); expect(ctrl.$error).toEqual({}); ctrl.$setViewValue('stillParseInvalid-validatorsValid'); expect(scope.value).toBeUndefined(); expect(ctrl.$error).toEqual({parserOrValidator: true}); ctrl.$validate(); expect(scope.value).toBeUndefined(); expect(ctrl.$error).toEqual({parserOrValidator: true}); }); }); describe('override ModelOptions', function() { it('should replace the previous model options', function() { var $options = ctrl.$options; ctrl.$overrideModelOptions({}); expect(ctrl.$options).not.toBe($options); }); it('should set the given options', function() { var $options = ctrl.$options; ctrl.$overrideModelOptions({ debounce: 1000, updateOn: 'blur' }); expect(ctrl.$options.getOption('debounce')).toEqual(1000); expect(ctrl.$options.getOption('updateOn')).toEqual('blur'); expect(ctrl.$options.getOption('updateOnDefault')).toBe(false); }); it('should inherit from a parent model options if specified', inject(function($compile, $rootScope) { var element = $compile( '')($rootScope); var ctrl = $rootScope.form.input; ctrl.$overrideModelOptions({ debounce: 2000, '*': '$inherit' }); expect(ctrl.$options.getOption('debounce')).toEqual(2000); expect(ctrl.$options.getOption('updateOn')).toEqual('blur'); expect(ctrl.$options.getOption('updateOnDefault')).toBe(false); dealoc(element); })); it('should not inherit from a parent model options if not specified', inject(function($compile, $rootScope) { var element = $compile( '')($rootScope); var ctrl = $rootScope.form.input; ctrl.$overrideModelOptions({ debounce: 2000 }); expect(ctrl.$options.getOption('debounce')).toEqual(2000); expect(ctrl.$options.getOption('updateOn')).toEqual(''); expect(ctrl.$options.getOption('updateOnDefault')).toBe(true); dealoc(element); })); }); }); describe('CSS classes', function() { var EMAIL_REGEXP = /^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; it('should set ng-empty or ng-not-empty when the view value changes', inject(function($compile, $rootScope, $sniffer) { var element = $compile('')($rootScope); $rootScope.$digest(); expect(element).toBeEmpty(); $rootScope.value = 'XXX'; $rootScope.$digest(); expect(element).toBeNotEmpty(); element.val(''); browserTrigger(element, $sniffer.hasEvent('input') ? 'input' : 'change'); expect(element).toBeEmpty(); element.val('YYY'); browserTrigger(element, $sniffer.hasEvent('input') ? 'input' : 'change'); expect(element).toBeNotEmpty(); })); it('should set css classes (ng-valid, ng-invalid, ng-pristine, ng-dirty, ng-untouched, ng-touched)', inject(function($compile, $rootScope, $sniffer) { var element = $compile('')($rootScope); $rootScope.$digest(); expect(element).toBeValid(); expect(element).toBePristine(); expect(element).toBeUntouched(); expect(element.hasClass('ng-valid-email')).toBe(true); expect(element.hasClass('ng-invalid-email')).toBe(false); $rootScope.$apply('value = \'invalid-email\''); expect(element).toBeInvalid(); expect(element).toBePristine(); expect(element.hasClass('ng-valid-email')).toBe(false); expect(element.hasClass('ng-invalid-email')).toBe(true); element.val('invalid-again'); browserTrigger(element, ($sniffer.hasEvent('input')) ? 'input' : 'change'); expect(element).toBeInvalid(); expect(element).toBeDirty(); expect(element.hasClass('ng-valid-email')).toBe(false); expect(element.hasClass('ng-invalid-email')).toBe(true); element.val('vojta@google.com'); browserTrigger(element, $sniffer.hasEvent('input') ? 'input' : 'change'); expect(element).toBeValid(); expect(element).toBeDirty(); expect(element.hasClass('ng-valid-email')).toBe(true); expect(element.hasClass('ng-invalid-email')).toBe(false); browserTrigger(element, 'blur'); expect(element).toBeTouched(); dealoc(element); })); it('should set invalid classes on init', inject(function($compile, $rootScope) { var element = $compile('')($rootScope); $rootScope.$digest(); expect(element).toBeInvalid(); expect(element).toHaveClass('ng-invalid-required'); dealoc(element); })); }); describe('custom formatter and parser that are added by a directive in post linking', function() { var inputElm, scope; beforeEach(module(function($compileProvider) { $compileProvider.directive('customFormat', function() { return { require: 'ngModel', link: function(scope, element, attrs, ngModelCtrl) { ngModelCtrl.$formatters.push(function(value) { return value.part; }); ngModelCtrl.$parsers.push(function(value) { return {part: value}; }); } }; }); })); afterEach(function() { dealoc(inputElm); }); function createInput(type) { inject(function($compile, $rootScope) { scope = $rootScope; inputElm = $compile('')($rootScope); }); } it('should use them after the builtin ones for text inputs', function() { createInput('text'); scope.$apply('val = {part: "a"}'); expect(inputElm.val()).toBe('a'); inputElm.val('b'); browserTrigger(inputElm, 'change'); expect(scope.val).toEqual({part: 'b'}); }); it('should use them after the builtin ones for number inputs', function() { createInput('number'); scope.$apply('val = {part: 1}'); expect(inputElm.val()).toBe('1'); inputElm.val('2'); browserTrigger(inputElm, 'change'); expect(scope.val).toEqual({part: 2}); }); it('should use them after the builtin ones for date inputs', function() { createInput('date'); scope.$apply(function() { scope.val = {part: new Date(2000, 10, 8)}; }); expect(inputElm.val()).toBe('2000-11-08'); inputElm.val('2001-12-09'); browserTrigger(inputElm, 'change'); expect(scope.val).toEqual({part: new Date(2001, 11, 9)}); }); }); describe('$touched', function() { it('should set the control touched state on "blur" event', inject(function($compile, $rootScope) { var element = $compile('')($rootScope); var inputElm = element.find('input'); var control = $rootScope.myForm.myControl; expect(control.$touched).toBe(false); expect(control.$untouched).toBe(true); browserTrigger(inputElm, 'blur'); expect(control.$touched).toBe(true); expect(control.$untouched).toBe(false); dealoc(element); })); it('should not cause a digest on "blur" event if control is already touched', inject(function($compile, $rootScope) { var element = $compile('')($rootScope); var inputElm = element.find('input'); var control = $rootScope.myForm.myControl; control.$setTouched(); spyOn($rootScope, '$apply'); browserTrigger(inputElm, 'blur'); expect($rootScope.$apply).not.toHaveBeenCalled(); dealoc(element); })); it('should digest asynchronously on "blur" event if a apply is already in progress', inject(function($compile, $rootScope) { var element = $compile('')($rootScope); var inputElm = element.find('input'); var control = $rootScope.myForm.myControl; $rootScope.$apply(function() { expect(control.$touched).toBe(false); expect(control.$untouched).toBe(true); browserTrigger(inputElm, 'blur'); expect(control.$touched).toBe(false); expect(control.$untouched).toBe(true); }); expect(control.$touched).toBe(true); expect(control.$untouched).toBe(false); dealoc(element); })); }); describe('nested in a form', function() { it('should register/deregister a nested ngModel with parent form when entering or leaving DOM', inject(function($compile, $rootScope) { var element = $compile('')($rootScope); var isFormValid; $rootScope.inputPresent = false; $rootScope.$watch('myForm.$valid', function(value) { isFormValid = value; }); $rootScope.$apply(); expect($rootScope.myForm.$valid).toBe(true); expect(isFormValid).toBe(true); expect($rootScope.myForm.myControl).toBeUndefined(); $rootScope.inputPresent = true; $rootScope.$apply(); expect($rootScope.myForm.$valid).toBe(false); expect(isFormValid).toBe(false); expect($rootScope.myForm.myControl).toBeDefined(); $rootScope.inputPresent = false; $rootScope.$apply(); expect($rootScope.myForm.$valid).toBe(true); expect(isFormValid).toBe(true); expect($rootScope.myForm.myControl).toBeUndefined(); dealoc(element); })); it('should register/deregister a nested ngModel with parent form when entering or leaving DOM with animations', function() { // ngAnimate performs the dom manipulation after digest, and since the form validity can be affected by a form // control going away we must ensure that the deregistration happens during the digest while we are still doing // dirty checking. module('ngAnimate'); inject(function($compile, $rootScope) { var element = $compile('')($rootScope); var isFormValid; $rootScope.inputPresent = false; // this watch ensure that the form validity gets updated during digest (so that we can observe it) $rootScope.$watch('myForm.$valid', function(value) { isFormValid = value; }); $rootScope.$apply(); expect($rootScope.myForm.$valid).toBe(true); expect(isFormValid).toBe(true); expect($rootScope.myForm.myControl).toBeUndefined(); $rootScope.inputPresent = true; $rootScope.$apply(); expect($rootScope.myForm.$valid).toBe(false); expect(isFormValid).toBe(false); expect($rootScope.myForm.myControl).toBeDefined(); $rootScope.inputPresent = false; $rootScope.$apply(); expect($rootScope.myForm.$valid).toBe(true); expect(isFormValid).toBe(true); expect($rootScope.myForm.myControl).toBeUndefined(); dealoc(element); }); }); it('should keep previously defined watches consistent when changes in validity are made', inject(function($compile, $rootScope) { var isFormValid; $rootScope.$watch('myForm.$valid', function(value) { isFormValid = value; }); var element = $compile('')($rootScope); $rootScope.$apply(); expect(isFormValid).toBe(false); expect($rootScope.myForm.$valid).toBe(false); $rootScope.value = 'value'; $rootScope.$apply(); expect(isFormValid).toBe(true); expect($rootScope.myForm.$valid).toBe(true); dealoc(element); })); }); describe('animations', function() { function findElementAnimations(element, queue) { var node = element[0]; var animations = []; for (var i = 0; i < queue.length; i++) { var animation = queue[i]; if (animation.element[0] === node) { animations.push(animation); } } return animations; } function assertValidAnimation(animation, event, classNameA, classNameB) { expect(animation.event).toBe(event); expect(animation.args[1]).toBe(classNameA); if (classNameB) expect(animation.args[2]).toBe(classNameB); } var doc, input, scope, model; beforeEach(module('ngAnimateMock')); beforeEach(inject(function($rootScope, $compile, $rootElement, $animate) { scope = $rootScope.$new(); doc = jqLite(''); $rootElement.append(doc); $compile(doc)(scope); $animate.queue = []; input = doc.find('input'); model = scope.myForm.myInput; })); afterEach(function() { dealoc(input); }); it('should trigger an animation when invalid', inject(function($animate) { model.$setValidity('required', false); var animations = findElementAnimations(input, $animate.queue); assertValidAnimation(animations[0], 'removeClass', 'ng-valid'); assertValidAnimation(animations[1], 'addClass', 'ng-invalid'); assertValidAnimation(animations[2], 'addClass', 'ng-invalid-required'); })); it('should trigger an animation when valid', inject(function($animate) { model.$setValidity('required', false); $animate.queue = []; model.$setValidity('required', true); var animations = findElementAnimations(input, $animate.queue); assertValidAnimation(animations[0], 'addClass', 'ng-valid'); assertValidAnimation(animations[1], 'removeClass', 'ng-invalid'); assertValidAnimation(animations[2], 'addClass', 'ng-valid-required'); assertValidAnimation(animations[3], 'removeClass', 'ng-invalid-required'); })); it('should trigger an animation when dirty', inject(function($animate) { model.$setViewValue('some dirty value'); var animations = findElementAnimations(input, $animate.queue); assertValidAnimation(animations[0], 'removeClass', 'ng-empty'); assertValidAnimation(animations[1], 'addClass', 'ng-not-empty'); assertValidAnimation(animations[2], 'removeClass', 'ng-pristine'); assertValidAnimation(animations[3], 'addClass', 'ng-dirty'); })); it('should trigger an animation when pristine', inject(function($animate) { model.$setPristine(); var animations = findElementAnimations(input, $animate.queue); assertValidAnimation(animations[0], 'removeClass', 'ng-dirty'); assertValidAnimation(animations[1], 'addClass', 'ng-pristine'); })); it('should trigger an animation when untouched', inject(function($animate) { model.$setUntouched(); var animations = findElementAnimations(input, $animate.queue); assertValidAnimation(animations[0], 'setClass', 'ng-untouched'); expect(animations[0].args[2]).toBe('ng-touched'); })); it('should trigger an animation when touched', inject(function($animate) { model.$setTouched(); var animations = findElementAnimations(input, $animate.queue); assertValidAnimation(animations[0], 'setClass', 'ng-touched', 'ng-untouched'); expect(animations[0].args[2]).toBe('ng-untouched'); })); it('should trigger custom errors as addClass/removeClass when invalid/valid', inject(function($animate) { model.$setValidity('custom-error', false); var animations = findElementAnimations(input, $animate.queue); assertValidAnimation(animations[0], 'removeClass', 'ng-valid'); assertValidAnimation(animations[1], 'addClass', 'ng-invalid'); assertValidAnimation(animations[2], 'addClass', 'ng-invalid-custom-error'); $animate.queue = []; model.$setValidity('custom-error', true); animations = findElementAnimations(input, $animate.queue); assertValidAnimation(animations[0], 'addClass', 'ng-valid'); assertValidAnimation(animations[1], 'removeClass', 'ng-invalid'); assertValidAnimation(animations[2], 'addClass', 'ng-valid-custom-error'); assertValidAnimation(animations[3], 'removeClass', 'ng-invalid-custom-error'); })); }); });