From 93b777c916ccff243c5a6080bf5f39860ac7bf39 Mon Sep 17 00:00:00 2001 From: Misko Hevery Date: Tue, 25 Oct 2011 10:30:27 -0700 Subject: move(scope): appease the History God - renamed: src/Scope.js -> src/service/scope.js - renamed: test/ScopeSpec.js -> test/service/scopeSpec.js --- angularFiles.js | 2 +- src/Scope.js | 679 ------------------------------------------- src/service/scope.js | 679 +++++++++++++++++++++++++++++++++++++++++++ test/ScopeSpec.js | 717 ---------------------------------------------- test/service/scopeSpec.js | 717 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1397 insertions(+), 1397 deletions(-) delete mode 100644 src/Scope.js create mode 100644 src/service/scope.js delete mode 100644 test/ScopeSpec.js create mode 100644 test/service/scopeSpec.js diff --git a/angularFiles.js b/angularFiles.js index 37dff5c0..5603dde7 100644 --- a/angularFiles.js +++ b/angularFiles.js @@ -3,7 +3,6 @@ angularFiles = { 'src/Angular.js', 'src/JSON.js', 'src/Compiler.js', - 'src/Scope.js', 'src/Injector.js', 'src/parser.js', 'src/Resource.js', @@ -23,6 +22,7 @@ angularFiles = { 'src/service/resource.js', 'src/service/route.js', 'src/service/routeParams.js', + 'src/service/scope.js', 'src/service/sniffer.js', 'src/service/window.js', 'src/service/xhr.bulk.js', diff --git a/src/Scope.js b/src/Scope.js deleted file mode 100644 index c4b9513b..00000000 --- a/src/Scope.js +++ /dev/null @@ -1,679 +0,0 @@ -'use strict'; - -/** - * DESIGN NOTES - * - * The design decisions behind the scope ware heavily favored for speed and memory consumption. - * - * The typical use of scope is to watch the expressions, which most of the time return the same - * value as last time so we optimize the operation. - * - * Closures construction is expensive from speed as well as memory: - * - no closures, instead ups prototypical inheritance for API - * - Internal state needs to be stored on scope directly, which means that private state is - * exposed as $$____ properties - * - * Loop operations are optimized by using while(count--) { ... } - * - this means that in order to keep the same order of execution as addition we have to add - * items to the array at the begging (shift) instead of at the end (push) - * - * Child scopes are created and removed often - * - Using array would be slow since inserts in meddle are expensive so we use linked list - * - * There are few watches then a lot of observers. This is why you don't want the observer to be - * implemented in the same way as watch. Watch requires return of initialization function which - * are expensive to construct. - */ - - -function createScope(providers, instanceCache) { - var scope = new Scope(); - (scope.$service = createInjector(scope, providers, instanceCache)).eager(); - return scope; -} - - -/** - * @ngdoc function - * @name angular.scope - * - * @description - * A root scope can be created by calling {@link angular.scope angular.scope()}. Child scopes - * are created using the {@link angular.scope.$new $new()} method. - * (Most scopes are created automatically when compiled HTML template is executed.) - * - * Here is a simple scope snippet to show how you can interact with the scope. - *
-       var scope = angular.scope();
-       scope.salutation = 'Hello';
-       scope.name = 'World';
-
-       expect(scope.greeting).toEqual(undefined);
-
-       scope.$watch('name', function() {
-         this.greeting = this.salutation + ' ' + this.name + '!';
-       }); // initialize the watch
-
-       expect(scope.greeting).toEqual(undefined);
-       scope.name = 'Misko';
-       // still old value, since watches have not been called yet
-       expect(scope.greeting).toEqual(undefined);
-
-       scope.$digest(); // fire all  the watches
-       expect(scope.greeting).toEqual('Hello Misko!');
- * 
- * - * # Inheritance - * A scope can inherit from a parent scope, as in this example: - *
-     var parent = angular.scope();
-     var child = parent.$new();
-
-     parent.salutation = "Hello";
-     child.name = "World";
-     expect(child.salutation).toEqual('Hello');
-
-     child.salutation = "Welcome";
-     expect(child.salutation).toEqual('Welcome');
-     expect(parent.salutation).toEqual('Hello');
- * 
- * - * # Dependency Injection - * See {@link guide/dev_guide.di dependency injection}. - * - * - * @param {Object.=} providers Map of service factory which need to be provided - * for the current scope. Defaults to {@link angular.service}. - * @param {Object.=} instanceCache Provides pre-instantiated services which should - * append/override services provided by `providers`. This is handy when unit-testing and having - * the need to override a default service. - * @returns {Object} Newly created scope. - * - */ -function Scope() { - this.$id = nextUid(); - this.$$phase = this.$parent = this.$$watchers = - this.$$nextSibling = this.$$prevSibling = - this.$$childHead = this.$$childTail = null; - this.$destructor = noop; - this['this'] = this.$root = this; - this.$$asyncQueue = []; - this.$$listeners = {}; -} - -/** - * @ngdoc property - * @name angular.scope.$id - * @returns {number} Unique scope ID (monotonically increasing alphanumeric sequence) useful for - * debugging. - */ - -/** - * @ngdoc property - * @name angular.scope.$service - * @function - * - * @description - * Provides reference to an instance of {@link angular.injector injector} which can be used to - * retrieve {@link angular.service services}. In general the use of this api is discouraged, - * in favor of proper {@link guide/dev_guide.di dependency injection}. - * - * @returns {function} {@link angular.injector injector} - */ - -/** - * @ngdoc property - * @name angular.scope.$root - * @returns {Scope} The root scope of the current scope hierarchy. - */ - -/** - * @ngdoc property - * @name angular.scope.$parent - * @returns {Scope} The parent scope of the current scope. - */ - - -Scope.prototype = { - /** - * @ngdoc function - * @name angular.scope.$new - * @function - * - * @description - * Creates a new child {@link angular.scope scope}. The new scope can optionally behave as a - * controller. The parent scope will propagate the {@link angular.scope.$digest $digest()} and - * {@link angular.scope.$digest $digest()} events. The scope can be removed from the scope - * hierarchy using {@link angular.scope.$destroy $destroy()}. - * - * {@link angular.scope.$destroy $destroy()} must be called on a scope when it is desired for - * the scope and its child scopes to be permanently detached from the parent and thus stop - * participating in model change detection and listener notification by invoking. - * - * @param {function()=} Class Constructor function which the scope should be applied to the scope. - * @param {...*} curryArguments Any additional arguments which are curried into the constructor. - * See {@link guide/dev_guide.di dependency injection}. - * @returns {Object} The newly created child scope. - * - */ - $new: function(Class, curryArguments) { - var Child = function() {}; // should be anonymous; This is so that when the minifier munges - // the name it does not become random set of chars. These will then show up as class - // name in the debugger. - var child; - Child.prototype = this; - child = new Child(); - child['this'] = child; - child.$$listeners = {}; - child.$parent = this; - child.$id = nextUid(); - child.$$asyncQueue = []; - child.$$phase = child.$$watchers = - child.$$nextSibling = child.$$childHead = child.$$childTail = null; - child.$$prevSibling = this.$$childTail; - if (this.$$childHead) { - this.$$childTail.$$nextSibling = child; - this.$$childTail = child; - } else { - this.$$childHead = this.$$childTail = child; - } - // short circuit if we have no class - if (Class) { - // can't use forEach, we need speed! - var ClassPrototype = Class.prototype; - for(var key in ClassPrototype) { - child[key] = bind(child, ClassPrototype[key]); - } - this.$service.invoke(child, Class, curryArguments); - } - return child; - }, - - /** - * @ngdoc function - * @name angular.scope.$watch - * @function - * - * @description - * Registers a `listener` callback to be executed whenever the `watchExpression` changes. - * - * - The `watchExpression` is called on every call to {@link angular.scope.$digest $digest()} and - * should return the value which will be watched. (Since {@link angular.scope.$digest $digest()} - * reruns when it detects changes the `watchExpression` can execute multiple times per - * {@link angular.scope.$digest $digest()} and should be idempotent.) - * - The `listener` is called only when the value from the current `watchExpression` and the - * previous call to `watchExpression' are not equal. The inequality is determined according to - * {@link angular.equals} function. To save the value of the object for later comparison - * {@link angular.copy} function is used. It also means that watching complex options will - * have adverse memory and performance implications. - * - The watch `listener` may change the model, which may trigger other `listener`s to fire. This - * is achieved by rerunning the watchers until no changes are detected. The rerun iteration - * limit is 100 to prevent infinity loop deadlock. - * - * - * If you want to be notified whenever {@link angular.scope.$digest $digest} is called, - * you can register an `watchExpression` function with no `listener`. (Since `watchExpression`, - * can execute multiple times per {@link angular.scope.$digest $digest} cycle when a change is - * detected, be prepared for multiple calls to your listener.) - * - * - * # Example -
-       var scope = angular.scope();
-       scope.name = 'misko';
-       scope.counter = 0;
-
-       expect(scope.counter).toEqual(0);
-       scope.$watch('name', function(scope, newValue, oldValue) { counter = counter + 1; });
-       expect(scope.counter).toEqual(0);
-
-       scope.$digest();
-       // no variable change
-       expect(scope.counter).toEqual(0);
-
-       scope.name = 'adam';
-       scope.$digest();
-       expect(scope.counter).toEqual(1);
-     
- * - * - * - * @param {(function()|string)} watchExpression Expression that is evaluated on each - * {@link angular.scope.$digest $digest} cycle. A change in the return value triggers a - * call to the `listener`. - * - * - `string`: Evaluated as {@link guide/dev_guide.expressions expression} - * - `function(scope)`: called with current `scope` as a parameter. - * @param {(function()|string)=} listener Callback called whenever the return value of - * the `watchExpression` changes. - * - * - `string`: Evaluated as {@link guide/dev_guide.expressions expression} - * - `function(scope, newValue, oldValue)`: called with current `scope` an previous and - * current values as parameters. - * @returns {function()} Returns a deregistration function for this listener. - */ - $watch: function(watchExp, listener) { - var scope = this, - get = compileToFn(watchExp, 'watch'), - listenFn = compileToFn(listener || noop, 'listener'), - array = scope.$$watchers, - watcher = { - fn: listenFn, - last: Number.NaN, // NaN !== NaN. We used this to force $watch to fire on first run. - get: get, - exp: watchExp - }; - - if (!array) { - array = scope.$$watchers = []; - } - // we use unshift since we use a while loop in $digest for speed. - // the while loop reads in reverse order. - array.unshift(watcher); - - return function() { - angularArray.remove(array, watcher); - }; - }, - - /** - * @ngdoc function - * @name angular.scope.$digest - * @function - * - * @description - * Process all of the {@link angular.scope.$watch watchers} of the current scope and its children. - * Because a {@link angular.scope.$watch watcher}'s listener can change the model, the - * `$digest()` keeps calling the {@link angular.scope.$watch watchers} until no more listeners are - * firing. This means that it is possible to get into an infinite loop. This function will throw - * `'Maximum iteration limit exceeded.'` if the number of iterations exceeds 100. - * - * Usually you don't call `$digest()` directly in - * {@link angular.directive.ng:controller controllers} or in {@link angular.directive directives}. - * Instead a call to {@link angular.scope.$apply $apply()} (typically from within a - * {@link angular.directive directive}) will force a `$digest()`. - * - * If you want to be notified whenever `$digest()` is called, - * you can register a `watchExpression` function with {@link angular.scope.$watch $watch()} - * with no `listener`. - * - * You may have a need to call `$digest()` from within unit-tests, to simulate the scope - * life-cycle. - * - * # Example -
-       var scope = angular.scope();
-       scope.name = 'misko';
-       scope.counter = 0;
-
-       expect(scope.counter).toEqual(0);
-       scope.$digest('name', function(scope, newValue, oldValue) { counter = counter + 1; });
-       expect(scope.counter).toEqual(0);
-
-       scope.$digest();
-       // no variable change
-       expect(scope.counter).toEqual(0);
-
-       scope.name = 'adam';
-       scope.$digest();
-       expect(scope.counter).toEqual(1);
-     
- * - */ - $digest: function() { - var watch, value, last, - watchers, - asyncQueue, - length, - dirty, ttl = 100, - next, current, target = this, - watchLog = []; - - if (target.$$phase) { - throw Error(target.$$phase + ' already in progress'); - } - do { - - dirty = false; - current = target; - do { - current.$$phase = '$digest'; - asyncQueue = current.$$asyncQueue; - while(asyncQueue.length) { - try { - current.$eval(asyncQueue.shift()); - } catch (e) { - current.$service('$exceptionHandler')(e); - } - } - if ((watchers = current.$$watchers)) { - // process our watches - length = watchers.length; - while (length--) { - try { - watch = watchers[length]; - // Most common watches are on primitives, in which case we can short - // circuit it with === operator, only when === fails do we use .equals - if ((value = watch.get(current)) !== (last = watch.last) && !equals(value, last)) { - dirty = true; - watch.last = copy(value); - watch.fn(current, value, last); - if (ttl < 5) { - if (!watchLog[4-ttl]) watchLog[4-ttl] = []; - if (isFunction(watch.exp)) { - watchLog[4-ttl].push('fn: ' + (watch.exp.name || watch.exp.toString())); - } else { - watchLog[4-ttl].push(watch.exp); - } - } - } - } catch (e) { - current.$service('$exceptionHandler')(e); - } - } - } - - current.$$phase = null; - - // Insanity Warning: scope depth-first traversal - // yes, this code is a bit crazy, but it works and we have tests to prove it! - // this piece should be kept in sync with the traversal in $broadcast - if (!(next = (current.$$childHead || (current !== target && current.$$nextSibling)))) { - while(current !== target && !(next = current.$$nextSibling)) { - current = current.$parent; - } - } - } while ((current = next)); - - if(!(ttl--)) { - throw Error('100 $digest() iterations reached. Aborting!\n' + - 'Watchers fired in the last 5 iterations: ' + toJson(watchLog)); - } - } while (dirty); - }, - - /** - * @ngdoc function - * @name angular.scope.$destroy - * @function - * - * @description - * Remove the current scope (and all of its children) from the parent scope. Removal implies - * that calls to {@link angular.scope.$digest $digest()} will no longer propagate to the current - * scope and its children. Removal also implies that the current scope is eligible for garbage - * collection. - * - * The destructing scope emits an `$destroy` {@link angular.scope.$emit event}. - * - * The `$destroy()` is usually used by directives such as - * {@link angular.widget.@ng:repeat ng:repeat} for managing the unrolling of the loop. - * - */ - $destroy: function() { - if (this.$root == this) return; // we can't remove the root node; - this.$emit('$destroy'); - var parent = this.$parent; - - if (parent.$$childHead == this) parent.$$childHead = this.$$nextSibling; - if (parent.$$childTail == this) parent.$$childTail = this.$$prevSibling; - if (this.$$prevSibling) this.$$prevSibling.$$nextSibling = this.$$nextSibling; - if (this.$$nextSibling) this.$$nextSibling.$$prevSibling = this.$$prevSibling; - }, - - /** - * @ngdoc function - * @name angular.scope.$eval - * @function - * - * @description - * Executes the `expression` on the current scope returning the result. Any exceptions in the - * expression are propagated (uncaught). This is useful when evaluating engular expressions. - * - * # Example -
-       var scope = angular.scope();
-       scope.a = 1;
-       scope.b = 2;
-
-       expect(scope.$eval('a+b')).toEqual(3);
-       expect(scope.$eval(function(scope){ return scope.a + scope.b; })).toEqual(3);
-     
- * - * @param {(string|function())=} expression An angular expression to be executed. - * - * - `string`: execute using the rules as defined in {@link guide/dev_guide.expressions expression}. - * - `function(scope)`: execute the function with the current `scope` parameter. - * - * @returns {*} The result of evaluating the expression. - */ - $eval: function(expr) { - var fn = isString(expr) - ? expressionCompile(expr) - : expr || noop; - return fn(this); - }, - - /** - * @ngdoc function - * @name angular.scope.$evalAsync - * @function - * - * @description - * Executes the expression on the current scope at a later point in time. - * - * The `$evalAsync` makes no guarantees as to when the `expression` will be executed, only that: - * - * - it will execute in the current script execution context (before any DOM rendering). - * - at least one {@link angular.scope.$digest $digest cycle} will be performed after - * `expression` execution. - * - * Any exceptions from the execution of the expression are forwarded to the - * {@link angular.service.$exceptionHandler $exceptionHandler} service. - * - * @param {(string|function())=} expression An angular expression to be executed. - * - * - `string`: execute using the rules as defined in {@link guide/dev_guide.expressions expression}. - * - `function(scope)`: execute the function with the current `scope` parameter. - * - */ - $evalAsync: function(expr) { - this.$$asyncQueue.push(expr); - }, - - /** - * @ngdoc function - * @name angular.scope.$apply - * @function - * - * @description - * `$apply()` is used to execute an expression in angular from outside of the angular framework. - * (For example from browser DOM events, setTimeout, XHR or third party libraries). - * Because we are calling into the angular framework we need to perform proper scope life-cycle - * of {@link angular.service.$exceptionHandler exception handling}, - * {@link angular.scope.$digest executing watches}. - * - * ## Life cycle - * - * # Pseudo-Code of `$apply()` - function $apply(expr) { - try { - return $eval(expr); - } catch (e) { - $exceptionHandler(e); - } finally { - $root.$digest(); - } - } - * - * - * Scope's `$apply()` method transitions through the following stages: - * - * 1. The {@link guide/dev_guide.expressions expression} is executed using the - * {@link angular.scope.$eval $eval()} method. - * 2. Any exceptions from the execution of the expression are forwarded to the - * {@link angular.service.$exceptionHandler $exceptionHandler} service. - * 3. The {@link angular.scope.$watch watch} listeners are fired immediately after the expression - * was executed using the {@link angular.scope.$digest $digest()} method. - * - * - * @param {(string|function())=} exp An angular expression to be executed. - * - * - `string`: execute using the rules as defined in {@link guide/dev_guide.expressions expression}. - * - `function(scope)`: execute the function with current `scope` parameter. - * - * @returns {*} The result of evaluating the expression. - */ - $apply: function(expr) { - try { - return this.$eval(expr); - } catch (e) { - this.$service('$exceptionHandler')(e); - } finally { - this.$root.$digest(); - } - }, - - /** - * @ngdoc function - * @name angular.scope.$on - * @function - * - * @description - * Listen on events of a given type. See {@link angular.scope.$emit $emit} for discussion of - * event life cycle. - * - * @param {string} name Event name to listen on. - * @param {function(event)} listener Function to call when the event is emitted. - * @returns {function()} Returns a deregistration function for this listener. - * - * The event listener function format is: `function(event)`. The `event` object passed into the - * listener has the following attributes - * - `targetScope` - {Scope}: the scope on which the event was `$emit`-ed or `$broadcast`-ed. - * - `currentScope` - {Scope}: the current scope which is handling the event. - * - `name` - {string}: Name of the event. - * - `cancel` - {function=}: calling `cancel` function will cancel further event propagation - * (available only for events that were `$emit`-ed). - */ - $on: function(name, listener) { - var namedListeners = this.$$listeners[name]; - if (!namedListeners) { - this.$$listeners[name] = namedListeners = []; - } - namedListeners.push(listener); - - return function() { - angularArray.remove(namedListeners, listener); - }; - }, - - - /** - * @ngdoc function - * @name angular.scope.$emit - * @function - * - * @description - * Dispatches an event `name` upwards through the scope hierarchy notifying the - * registered {@link angular.scope.$on} listeners. - * - * The event life cycle starts at the scope on which `$emit` was called. All - * {@link angular.scope.$on listeners} listening for `name` event on this scope get notified. - * Afterwards, the event traverses upwards toward the root scope and calls all registered - * listeners along the way. The event will stop propagating if one of the listeners cancels it. - * - * Any exception emmited from the {@link angular.scope.$on listeners} will be passed - * onto the {@link angular.service.$exceptionHandler $exceptionHandler} service. - * - * @param {string} name Event name to emit. - * @param {...*} args Optional set of arguments which will be passed onto the event listeners. - */ - $emit: function(name, args) { - var empty = [], - namedListeners, - canceled = false, - scope = this, - event = { - name: name, - targetScope: scope, - cancel: function() {canceled = true;} - }, - listenerArgs = concat([event], arguments, 1), - i, length; - - do { - namedListeners = scope.$$listeners[name] || empty; - event.currentScope = scope; - for (i=0, length=namedListeners.length; i + var scope = angular.scope(); + scope.salutation = 'Hello'; + scope.name = 'World'; + + expect(scope.greeting).toEqual(undefined); + + scope.$watch('name', function() { + this.greeting = this.salutation + ' ' + this.name + '!'; + }); // initialize the watch + + expect(scope.greeting).toEqual(undefined); + scope.name = 'Misko'; + // still old value, since watches have not been called yet + expect(scope.greeting).toEqual(undefined); + + scope.$digest(); // fire all the watches + expect(scope.greeting).toEqual('Hello Misko!'); + * + * + * # Inheritance + * A scope can inherit from a parent scope, as in this example: + *
+     var parent = angular.scope();
+     var child = parent.$new();
+
+     parent.salutation = "Hello";
+     child.name = "World";
+     expect(child.salutation).toEqual('Hello');
+
+     child.salutation = "Welcome";
+     expect(child.salutation).toEqual('Welcome');
+     expect(parent.salutation).toEqual('Hello');
+ * 
+ * + * # Dependency Injection + * See {@link guide/dev_guide.di dependency injection}. + * + * + * @param {Object.=} providers Map of service factory which need to be provided + * for the current scope. Defaults to {@link angular.service}. + * @param {Object.=} instanceCache Provides pre-instantiated services which should + * append/override services provided by `providers`. This is handy when unit-testing and having + * the need to override a default service. + * @returns {Object} Newly created scope. + * + */ +function Scope() { + this.$id = nextUid(); + this.$$phase = this.$parent = this.$$watchers = + this.$$nextSibling = this.$$prevSibling = + this.$$childHead = this.$$childTail = null; + this.$destructor = noop; + this['this'] = this.$root = this; + this.$$asyncQueue = []; + this.$$listeners = {}; +} + +/** + * @ngdoc property + * @name angular.scope.$id + * @returns {number} Unique scope ID (monotonically increasing alphanumeric sequence) useful for + * debugging. + */ + +/** + * @ngdoc property + * @name angular.scope.$service + * @function + * + * @description + * Provides reference to an instance of {@link angular.injector injector} which can be used to + * retrieve {@link angular.service services}. In general the use of this api is discouraged, + * in favor of proper {@link guide/dev_guide.di dependency injection}. + * + * @returns {function} {@link angular.injector injector} + */ + +/** + * @ngdoc property + * @name angular.scope.$root + * @returns {Scope} The root scope of the current scope hierarchy. + */ + +/** + * @ngdoc property + * @name angular.scope.$parent + * @returns {Scope} The parent scope of the current scope. + */ + + +Scope.prototype = { + /** + * @ngdoc function + * @name angular.scope.$new + * @function + * + * @description + * Creates a new child {@link angular.scope scope}. The new scope can optionally behave as a + * controller. The parent scope will propagate the {@link angular.scope.$digest $digest()} and + * {@link angular.scope.$digest $digest()} events. The scope can be removed from the scope + * hierarchy using {@link angular.scope.$destroy $destroy()}. + * + * {@link angular.scope.$destroy $destroy()} must be called on a scope when it is desired for + * the scope and its child scopes to be permanently detached from the parent and thus stop + * participating in model change detection and listener notification by invoking. + * + * @param {function()=} Class Constructor function which the scope should be applied to the scope. + * @param {...*} curryArguments Any additional arguments which are curried into the constructor. + * See {@link guide/dev_guide.di dependency injection}. + * @returns {Object} The newly created child scope. + * + */ + $new: function(Class, curryArguments) { + var Child = function() {}; // should be anonymous; This is so that when the minifier munges + // the name it does not become random set of chars. These will then show up as class + // name in the debugger. + var child; + Child.prototype = this; + child = new Child(); + child['this'] = child; + child.$$listeners = {}; + child.$parent = this; + child.$id = nextUid(); + child.$$asyncQueue = []; + child.$$phase = child.$$watchers = + child.$$nextSibling = child.$$childHead = child.$$childTail = null; + child.$$prevSibling = this.$$childTail; + if (this.$$childHead) { + this.$$childTail.$$nextSibling = child; + this.$$childTail = child; + } else { + this.$$childHead = this.$$childTail = child; + } + // short circuit if we have no class + if (Class) { + // can't use forEach, we need speed! + var ClassPrototype = Class.prototype; + for(var key in ClassPrototype) { + child[key] = bind(child, ClassPrototype[key]); + } + this.$service.invoke(child, Class, curryArguments); + } + return child; + }, + + /** + * @ngdoc function + * @name angular.scope.$watch + * @function + * + * @description + * Registers a `listener` callback to be executed whenever the `watchExpression` changes. + * + * - The `watchExpression` is called on every call to {@link angular.scope.$digest $digest()} and + * should return the value which will be watched. (Since {@link angular.scope.$digest $digest()} + * reruns when it detects changes the `watchExpression` can execute multiple times per + * {@link angular.scope.$digest $digest()} and should be idempotent.) + * - The `listener` is called only when the value from the current `watchExpression` and the + * previous call to `watchExpression' are not equal. The inequality is determined according to + * {@link angular.equals} function. To save the value of the object for later comparison + * {@link angular.copy} function is used. It also means that watching complex options will + * have adverse memory and performance implications. + * - The watch `listener` may change the model, which may trigger other `listener`s to fire. This + * is achieved by rerunning the watchers until no changes are detected. The rerun iteration + * limit is 100 to prevent infinity loop deadlock. + * + * + * If you want to be notified whenever {@link angular.scope.$digest $digest} is called, + * you can register an `watchExpression` function with no `listener`. (Since `watchExpression`, + * can execute multiple times per {@link angular.scope.$digest $digest} cycle when a change is + * detected, be prepared for multiple calls to your listener.) + * + * + * # Example +
+       var scope = angular.scope();
+       scope.name = 'misko';
+       scope.counter = 0;
+
+       expect(scope.counter).toEqual(0);
+       scope.$watch('name', function(scope, newValue, oldValue) { counter = counter + 1; });
+       expect(scope.counter).toEqual(0);
+
+       scope.$digest();
+       // no variable change
+       expect(scope.counter).toEqual(0);
+
+       scope.name = 'adam';
+       scope.$digest();
+       expect(scope.counter).toEqual(1);
+     
+ * + * + * + * @param {(function()|string)} watchExpression Expression that is evaluated on each + * {@link angular.scope.$digest $digest} cycle. A change in the return value triggers a + * call to the `listener`. + * + * - `string`: Evaluated as {@link guide/dev_guide.expressions expression} + * - `function(scope)`: called with current `scope` as a parameter. + * @param {(function()|string)=} listener Callback called whenever the return value of + * the `watchExpression` changes. + * + * - `string`: Evaluated as {@link guide/dev_guide.expressions expression} + * - `function(scope, newValue, oldValue)`: called with current `scope` an previous and + * current values as parameters. + * @returns {function()} Returns a deregistration function for this listener. + */ + $watch: function(watchExp, listener) { + var scope = this, + get = compileToFn(watchExp, 'watch'), + listenFn = compileToFn(listener || noop, 'listener'), + array = scope.$$watchers, + watcher = { + fn: listenFn, + last: Number.NaN, // NaN !== NaN. We used this to force $watch to fire on first run. + get: get, + exp: watchExp + }; + + if (!array) { + array = scope.$$watchers = []; + } + // we use unshift since we use a while loop in $digest for speed. + // the while loop reads in reverse order. + array.unshift(watcher); + + return function() { + angularArray.remove(array, watcher); + }; + }, + + /** + * @ngdoc function + * @name angular.scope.$digest + * @function + * + * @description + * Process all of the {@link angular.scope.$watch watchers} of the current scope and its children. + * Because a {@link angular.scope.$watch watcher}'s listener can change the model, the + * `$digest()` keeps calling the {@link angular.scope.$watch watchers} until no more listeners are + * firing. This means that it is possible to get into an infinite loop. This function will throw + * `'Maximum iteration limit exceeded.'` if the number of iterations exceeds 100. + * + * Usually you don't call `$digest()` directly in + * {@link angular.directive.ng:controller controllers} or in {@link angular.directive directives}. + * Instead a call to {@link angular.scope.$apply $apply()} (typically from within a + * {@link angular.directive directive}) will force a `$digest()`. + * + * If you want to be notified whenever `$digest()` is called, + * you can register a `watchExpression` function with {@link angular.scope.$watch $watch()} + * with no `listener`. + * + * You may have a need to call `$digest()` from within unit-tests, to simulate the scope + * life-cycle. + * + * # Example +
+       var scope = angular.scope();
+       scope.name = 'misko';
+       scope.counter = 0;
+
+       expect(scope.counter).toEqual(0);
+       scope.$digest('name', function(scope, newValue, oldValue) { counter = counter + 1; });
+       expect(scope.counter).toEqual(0);
+
+       scope.$digest();
+       // no variable change
+       expect(scope.counter).toEqual(0);
+
+       scope.name = 'adam';
+       scope.$digest();
+       expect(scope.counter).toEqual(1);
+     
+ * + */ + $digest: function() { + var watch, value, last, + watchers, + asyncQueue, + length, + dirty, ttl = 100, + next, current, target = this, + watchLog = []; + + if (target.$$phase) { + throw Error(target.$$phase + ' already in progress'); + } + do { + + dirty = false; + current = target; + do { + current.$$phase = '$digest'; + asyncQueue = current.$$asyncQueue; + while(asyncQueue.length) { + try { + current.$eval(asyncQueue.shift()); + } catch (e) { + current.$service('$exceptionHandler')(e); + } + } + if ((watchers = current.$$watchers)) { + // process our watches + length = watchers.length; + while (length--) { + try { + watch = watchers[length]; + // Most common watches are on primitives, in which case we can short + // circuit it with === operator, only when === fails do we use .equals + if ((value = watch.get(current)) !== (last = watch.last) && !equals(value, last)) { + dirty = true; + watch.last = copy(value); + watch.fn(current, value, last); + if (ttl < 5) { + if (!watchLog[4-ttl]) watchLog[4-ttl] = []; + if (isFunction(watch.exp)) { + watchLog[4-ttl].push('fn: ' + (watch.exp.name || watch.exp.toString())); + } else { + watchLog[4-ttl].push(watch.exp); + } + } + } + } catch (e) { + current.$service('$exceptionHandler')(e); + } + } + } + + current.$$phase = null; + + // Insanity Warning: scope depth-first traversal + // yes, this code is a bit crazy, but it works and we have tests to prove it! + // this piece should be kept in sync with the traversal in $broadcast + if (!(next = (current.$$childHead || (current !== target && current.$$nextSibling)))) { + while(current !== target && !(next = current.$$nextSibling)) { + current = current.$parent; + } + } + } while ((current = next)); + + if(!(ttl--)) { + throw Error('100 $digest() iterations reached. Aborting!\n' + + 'Watchers fired in the last 5 iterations: ' + toJson(watchLog)); + } + } while (dirty); + }, + + /** + * @ngdoc function + * @name angular.scope.$destroy + * @function + * + * @description + * Remove the current scope (and all of its children) from the parent scope. Removal implies + * that calls to {@link angular.scope.$digest $digest()} will no longer propagate to the current + * scope and its children. Removal also implies that the current scope is eligible for garbage + * collection. + * + * The destructing scope emits an `$destroy` {@link angular.scope.$emit event}. + * + * The `$destroy()` is usually used by directives such as + * {@link angular.widget.@ng:repeat ng:repeat} for managing the unrolling of the loop. + * + */ + $destroy: function() { + if (this.$root == this) return; // we can't remove the root node; + this.$emit('$destroy'); + var parent = this.$parent; + + if (parent.$$childHead == this) parent.$$childHead = this.$$nextSibling; + if (parent.$$childTail == this) parent.$$childTail = this.$$prevSibling; + if (this.$$prevSibling) this.$$prevSibling.$$nextSibling = this.$$nextSibling; + if (this.$$nextSibling) this.$$nextSibling.$$prevSibling = this.$$prevSibling; + }, + + /** + * @ngdoc function + * @name angular.scope.$eval + * @function + * + * @description + * Executes the `expression` on the current scope returning the result. Any exceptions in the + * expression are propagated (uncaught). This is useful when evaluating engular expressions. + * + * # Example +
+       var scope = angular.scope();
+       scope.a = 1;
+       scope.b = 2;
+
+       expect(scope.$eval('a+b')).toEqual(3);
+       expect(scope.$eval(function(scope){ return scope.a + scope.b; })).toEqual(3);
+     
+ * + * @param {(string|function())=} expression An angular expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/dev_guide.expressions expression}. + * - `function(scope)`: execute the function with the current `scope` parameter. + * + * @returns {*} The result of evaluating the expression. + */ + $eval: function(expr) { + var fn = isString(expr) + ? expressionCompile(expr) + : expr || noop; + return fn(this); + }, + + /** + * @ngdoc function + * @name angular.scope.$evalAsync + * @function + * + * @description + * Executes the expression on the current scope at a later point in time. + * + * The `$evalAsync` makes no guarantees as to when the `expression` will be executed, only that: + * + * - it will execute in the current script execution context (before any DOM rendering). + * - at least one {@link angular.scope.$digest $digest cycle} will be performed after + * `expression` execution. + * + * Any exceptions from the execution of the expression are forwarded to the + * {@link angular.service.$exceptionHandler $exceptionHandler} service. + * + * @param {(string|function())=} expression An angular expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/dev_guide.expressions expression}. + * - `function(scope)`: execute the function with the current `scope` parameter. + * + */ + $evalAsync: function(expr) { + this.$$asyncQueue.push(expr); + }, + + /** + * @ngdoc function + * @name angular.scope.$apply + * @function + * + * @description + * `$apply()` is used to execute an expression in angular from outside of the angular framework. + * (For example from browser DOM events, setTimeout, XHR or third party libraries). + * Because we are calling into the angular framework we need to perform proper scope life-cycle + * of {@link angular.service.$exceptionHandler exception handling}, + * {@link angular.scope.$digest executing watches}. + * + * ## Life cycle + * + * # Pseudo-Code of `$apply()` + function $apply(expr) { + try { + return $eval(expr); + } catch (e) { + $exceptionHandler(e); + } finally { + $root.$digest(); + } + } + * + * + * Scope's `$apply()` method transitions through the following stages: + * + * 1. The {@link guide/dev_guide.expressions expression} is executed using the + * {@link angular.scope.$eval $eval()} method. + * 2. Any exceptions from the execution of the expression are forwarded to the + * {@link angular.service.$exceptionHandler $exceptionHandler} service. + * 3. The {@link angular.scope.$watch watch} listeners are fired immediately after the expression + * was executed using the {@link angular.scope.$digest $digest()} method. + * + * + * @param {(string|function())=} exp An angular expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/dev_guide.expressions expression}. + * - `function(scope)`: execute the function with current `scope` parameter. + * + * @returns {*} The result of evaluating the expression. + */ + $apply: function(expr) { + try { + return this.$eval(expr); + } catch (e) { + this.$service('$exceptionHandler')(e); + } finally { + this.$root.$digest(); + } + }, + + /** + * @ngdoc function + * @name angular.scope.$on + * @function + * + * @description + * Listen on events of a given type. See {@link angular.scope.$emit $emit} for discussion of + * event life cycle. + * + * @param {string} name Event name to listen on. + * @param {function(event)} listener Function to call when the event is emitted. + * @returns {function()} Returns a deregistration function for this listener. + * + * The event listener function format is: `function(event)`. The `event` object passed into the + * listener has the following attributes + * - `targetScope` - {Scope}: the scope on which the event was `$emit`-ed or `$broadcast`-ed. + * - `currentScope` - {Scope}: the current scope which is handling the event. + * - `name` - {string}: Name of the event. + * - `cancel` - {function=}: calling `cancel` function will cancel further event propagation + * (available only for events that were `$emit`-ed). + */ + $on: function(name, listener) { + var namedListeners = this.$$listeners[name]; + if (!namedListeners) { + this.$$listeners[name] = namedListeners = []; + } + namedListeners.push(listener); + + return function() { + angularArray.remove(namedListeners, listener); + }; + }, + + + /** + * @ngdoc function + * @name angular.scope.$emit + * @function + * + * @description + * Dispatches an event `name` upwards through the scope hierarchy notifying the + * registered {@link angular.scope.$on} listeners. + * + * The event life cycle starts at the scope on which `$emit` was called. All + * {@link angular.scope.$on listeners} listening for `name` event on this scope get notified. + * Afterwards, the event traverses upwards toward the root scope and calls all registered + * listeners along the way. The event will stop propagating if one of the listeners cancels it. + * + * Any exception emmited from the {@link angular.scope.$on listeners} will be passed + * onto the {@link angular.service.$exceptionHandler $exceptionHandler} service. + * + * @param {string} name Event name to emit. + * @param {...*} args Optional set of arguments which will be passed onto the event listeners. + */ + $emit: function(name, args) { + var empty = [], + namedListeners, + canceled = false, + scope = this, + event = { + name: name, + targetScope: scope, + cancel: function() {canceled = true;} + }, + listenerArgs = concat([event], arguments, 1), + i, length; + + do { + namedListeners = scope.$$listeners[name] || empty; + event.currentScope = scope; + for (i=0, length=namedListeners.length; i'; - } - - beforeEach(function() { - log = ''; - child = root.$new(); - grandChild = child.$new(); - greatGrandChild = grandChild.$new(); - - root.id = 0; - child.id = 1; - grandChild.id = 2; - greatGrandChild.id = 3; - - root.$on('myEvent', logger); - child.$on('myEvent', logger); - grandChild.$on('myEvent', logger); - greatGrandChild.$on('myEvent', logger); - }); - - it('should bubble event up to the root scope', function() { - grandChild.$emit('myEvent'); - expect(log).toEqual('2>1>0>'); - }); - - - it('should dispatch exceptions to the $exceptionHandler', function() { - child.$on('myEvent', function() { throw 'bubbleException'; }); - grandChild.$emit('myEvent'); - expect(log).toEqual('2>1>0>'); - expect(mockHandler.errors).toEqual(['bubbleException']); - }); - - - it('should allow cancelation of event propagation', function() { - child.$on('myEvent', function(event){ event.cancel(); }); - grandChild.$emit('myEvent'); - expect(log).toEqual('2>1>'); - }); - - - it('should forward method arguments', function() { - child.$on('abc', function(event, arg1, arg2){ - expect(event.name).toBe('abc'); - expect(arg1).toBe('arg1'); - expect(arg2).toBe('arg2'); - }); - child.$emit('abc', 'arg1', 'arg2'); - }); - - describe('event object', function() { - it('should have methods/properties', function() { - var event; - child.$on('myEvent', function(e){ - expect(e.targetScope).toBe(grandChild); - expect(e.currentScope).toBe(child); - expect(e.name).toBe('myEvent'); - event = e; - }); - grandChild.$emit('myEvent'); - expect(event).toBeDefined(); - }); - }); - }); - - - describe('$broadcast', function() { - describe('event propagation', function() { - var log, child1, child2, child3, grandChild11, grandChild21, grandChild22, grandChild23, - greatGrandChild211; - - function logger(event) { - log += event.currentScope.id + '>'; - } - - beforeEach(function() { - log = ''; - child1 = root.$new(); - child2 = root.$new(); - child3 = root.$new(); - grandChild11 = child1.$new(); - grandChild21 = child2.$new(); - grandChild22 = child2.$new(); - grandChild23 = child2.$new(); - greatGrandChild211 = grandChild21.$new(); - - root.id = 0; - child1.id = 1; - child2.id = 2; - child3.id = 3; - grandChild11.id = 11; - grandChild21.id = 21; - grandChild22.id = 22; - grandChild23.id = 23; - greatGrandChild211.id = 211; - - root.$on('myEvent', logger); - child1.$on('myEvent', logger); - child2.$on('myEvent', logger); - child3.$on('myEvent', logger); - grandChild11.$on('myEvent', logger); - grandChild21.$on('myEvent', logger); - grandChild22.$on('myEvent', logger); - grandChild23.$on('myEvent', logger); - greatGrandChild211.$on('myEvent', logger); - - // R - // / | \ - // 1 2 3 - // / / | \ - // 11 21 22 23 - // | - // 211 - }); - - - it('should broadcast an event from the root scope', function() { - root.$broadcast('myEvent'); - expect(log).toBe('0>1>11>2>21>211>22>23>3>'); - }); - - - it('should broadcast an event from a child scope', function() { - child2.$broadcast('myEvent'); - expect(log).toBe('2>21>211>22>23>'); - }); - - - it('should broadcast an event from a leaf scope with a sibling', function() { - grandChild22.$broadcast('myEvent'); - expect(log).toBe('22>'); - }); - - - it('should broadcast an event from a leaf scope without a sibling', function() { - grandChild23.$broadcast('myEvent'); - expect(log).toBe('23>'); - }); - - - it('should not not fire any listeners for other events', function() { - root.$broadcast('fooEvent'); - expect(log).toBe(''); - }); - }); - - - describe('listener', function() { - it('should receive event object', function() { - var scope = angular.scope(), - child = scope.$new(), - event; - - child.$on('fooEvent', function(e) { - event = e; - }); - scope.$broadcast('fooEvent'); - - expect(event.name).toBe('fooEvent'); - expect(event.targetScope).toBe(scope); - expect(event.currentScope).toBe(child); - }); - - - it('should support passing messages as varargs', function() { - var scope = angular.scope(), - child = scope.$new(), - args; - - child.$on('fooEvent', function() { - args = arguments; - }); - scope.$broadcast('fooEvent', 'do', 're', 'me', 'fa'); - - expect(args.length).toBe(5); - expect(sliceArgs(args, 1)).toEqual(['do', 're', 'me', 'fa']); - }); - }); - }); - }); -}); diff --git a/test/service/scopeSpec.js b/test/service/scopeSpec.js new file mode 100644 index 00000000..d3f58918 --- /dev/null +++ b/test/service/scopeSpec.js @@ -0,0 +1,717 @@ +'use strict'; + +describe('Scope', function() { + var root = null, mockHandler = null; + + beforeEach(function() { + root = createScope(angular.service, { + '$exceptionHandler': $exceptionHandlerMockFactory() + }); + mockHandler = root.$service('$exceptionHandler'); + }); + + + describe('$root', function() { + it('should point to itself', function() { + expect(root.$root).toEqual(root); + expect(root.hasOwnProperty('$root')).toBeTruthy(); + }); + + + it('should not have $root on children, but should inherit', function() { + var child = root.$new(); + expect(child.$root).toEqual(root); + expect(child.hasOwnProperty('$root')).toBeFalsy(); + }); + + }); + + + describe('$parent', function() { + it('should point to itself in root', function() { + expect(root.$root).toEqual(root); + }); + + + it('should point to parent', function() { + var child = root.$new(); + expect(root.$parent).toEqual(null); + expect(child.$parent).toEqual(root); + expect(child.$new().$parent).toEqual(child); + }); + }); + + + describe('$id', function() { + it('should have a unique id', function() { + expect(root.$id < root.$new().$id).toBeTruthy(); + }); + }); + + + describe('this', function() { + it('should have a \'this\'', function() { + expect(root['this']).toEqual(root); + }); + }); + + + describe('$new()', function() { + it('should create a child scope', function() { + var child = root.$new(); + root.a = 123; + expect(child.a).toEqual(123); + }); + + + it('should instantiate controller and bind functions', function() { + function Cntl($browser, name){ + this.$browser = $browser; + this.callCount = 0; + this.name = name; + } + Cntl.$inject = ['$browser']; + + Cntl.prototype = { + myFn: function() { + expect(this).toEqual(cntl); + this.callCount++; + } + }; + + var cntl = root.$new(Cntl, ['misko']); + + expect(root.$browser).toBeUndefined(); + expect(root.myFn).toBeUndefined(); + + expect(cntl.$browser).toBeDefined(); + expect(cntl.name).toEqual('misko'); + + cntl.myFn(); + cntl.$new().myFn(); + expect(cntl.callCount).toEqual(2); + }); + }); + + + describe('$service', function() { + it('should have it on root', function() { + expect(root.hasOwnProperty('$service')).toBeTruthy(); + }); + }); + + + describe('$watch/$digest', function() { + it('should watch and fire on simple property change', function() { + var spy = jasmine.createSpy(); + root.$watch('name', spy); + root.$digest(); + spy.reset(); + + expect(spy).not.wasCalled(); + root.$digest(); + expect(spy).not.wasCalled(); + root.name = 'misko'; + root.$digest(); + expect(spy).wasCalledWith(root, 'misko', undefined); + }); + + + it('should watch and fire on expression change', function() { + var spy = jasmine.createSpy(); + root.$watch('name.first', spy); + root.$digest(); + spy.reset(); + + root.name = {}; + expect(spy).not.wasCalled(); + root.$digest(); + expect(spy).not.wasCalled(); + root.name.first = 'misko'; + root.$digest(); + expect(spy).wasCalled(); + }); + + it('should delegate exceptions', function() { + root.$watch('a', function() {throw new Error('abc');}); + root.a = 1; + root.$digest(); + expect(mockHandler.errors[0].message).toEqual('abc'); + $logMock.error.logs.length = 0; + }); + + + it('should fire watches in order of addition', function() { + // this is not an external guarantee, just our own sanity + var log = ''; + root.$watch('a', function() { log += 'a'; }); + root.$watch('b', function() { log += 'b'; }); + root.$watch('c', function() { log += 'c'; }); + root.a = root.b = root.c = 1; + root.$digest(); + expect(log).toEqual('abc'); + }); + + + it('should call child $watchers in addition order', function() { + // this is not an external guarantee, just our own sanity + var log = ''; + var childA = root.$new(); + var childB = root.$new(); + var childC = root.$new(); + childA.$watch('a', function() { log += 'a'; }); + childB.$watch('b', function() { log += 'b'; }); + childC.$watch('c', function() { log += 'c'; }); + childA.a = childB.b = childC.c = 1; + root.$digest(); + expect(log).toEqual('abc'); + }); + + + it('should allow $digest on a child scope with and without a right sibling', function() { + // tests a traversal edge case which we originally missed + var log = '', + childA = root.$new(), + childB = root.$new(); + + root.$watch(function() { log += 'r'; }); + childA.$watch(function() { log += 'a'; }); + childB.$watch(function() { log += 'b'; }); + + // init + root.$digest(); + expect(log).toBe('rabrab'); + + log = ''; + childA.$digest(); + expect(log).toBe('a'); + + log = ''; + childB.$digest(); + expect(log).toBe('b'); + }); + + + it('should repeat watch cycle while model changes are identified', function() { + var log = ''; + root.$watch('c', function(self, v){self.d = v; log+='c'; }); + root.$watch('b', function(self, v){self.c = v; log+='b'; }); + root.$watch('a', function(self, v){self.b = v; log+='a'; }); + root.$digest(); + log = ''; + root.a = 1; + root.$digest(); + expect(root.b).toEqual(1); + expect(root.c).toEqual(1); + expect(root.d).toEqual(1); + expect(log).toEqual('abc'); + }); + + + it('should repeat watch cycle from the root elemnt', function() { + var log = ''; + var child = root.$new(); + root.$watch(function() { log += 'a'; }); + child.$watch(function() { log += 'b'; }); + root.$digest(); + expect(log).toEqual('abab'); + }); + + + it('should prevent infinite recursion and print watcher expression', function() { + root.$watch('a', function(self){self.b++;}); + root.$watch('b', function(self){self.a++;}); + root.a = root.b = 0; + + expect(function() { + root.$digest(); + }).toThrow('100 $digest() iterations reached. Aborting!\n'+ + 'Watchers fired in the last 5 iterations: ' + + '[["a","b"],["a","b"],["a","b"],["a","b"],["a","b"]]'); + }); + + + it('should prevent infinite recurcion and print print watcher function name or body', + function() { + root.$watch(function watcherA() {return root.a;}, function(self){self.b++;}); + root.$watch(function() {return root.b;}, function(self){self.a++;}); + root.a = root.b = 0; + + try { + root.$digest(); + throw Error('Should have thrown exception'); + } catch(e) { + expect(e.message.match(/"fn: (watcherA|function)/g).length).toBe(10); + } + }); + + + it('should not fire upon $watch registration on initial $digest', function() { + var log = ''; + root.a = 1; + root.$watch('a', function() { log += 'a'; }); + root.$watch('b', function() { log += 'b'; }); + root.$digest(); + log = ''; + root.$digest(); + expect(log).toEqual(''); + }); + + + it('should watch objects', function() { + var log = ''; + root.a = []; + root.b = {}; + root.$watch('a', function(scope, value){ + log +='.'; + expect(value).toBe(root.a); + }); + root.$watch('b', function(scope, value){ + log +='!'; + expect(value).toBe(root.b); + }); + root.$digest(); + log = ''; + + root.a.push({}); + root.b.name = ''; + + root.$digest(); + expect(log).toEqual('.!'); + }); + + + it('should prevent recursion', function() { + var callCount = 0; + root.$watch('name', function() { + expect(function() { + root.$digest(); + }).toThrow('$digest already in progress'); + callCount++; + }); + root.name = 'a'; + root.$digest(); + expect(callCount).toEqual(1); + }); + + + it('should return a function that allows listeners to be unregistered', function() { + var root = angular.scope(), + listener = jasmine.createSpy('watch listener'), + listenerRemove; + + listenerRemove = root.$watch('foo', listener); + root.$digest(); //init + expect(listener).toHaveBeenCalled(); + expect(listenerRemove).toBeDefined(); + + listener.reset(); + root.foo = 'bar'; + root.$digest(); //triger + expect(listener).toHaveBeenCalledOnce(); + + listener.reset(); + root.foo = 'baz'; + listenerRemove(); + root.$digest(); //trigger + expect(listener).not.toHaveBeenCalled(); + }); + }); + + + describe('$destroy', function() { + var first = null, middle = null, last = null, log = null; + + beforeEach(function() { + log = ''; + + first = root.$new(); + middle = root.$new(); + last = root.$new(); + + first.$watch(function() { log += '1';}); + middle.$watch(function() { log += '2';}); + last.$watch(function() { log += '3';}); + + root.$digest(); + log = ''; + }); + + + it('should ignore remove on root', function() { + root.$destroy(); + root.$digest(); + expect(log).toEqual('123'); + }); + + + it('should remove first', function() { + first.$destroy(); + root.$digest(); + expect(log).toEqual('23'); + }); + + + it('should remove middle', function() { + middle.$destroy(); + root.$digest(); + expect(log).toEqual('13'); + }); + + + it('should remove last', function() { + last.$destroy(); + root.$digest(); + expect(log).toEqual('12'); + }); + + it('should fire a $destroy event', function() { + var destructedScopes = []; + middle.$on('$destroy', function(event) { + destructedScopes.push(event.currentScope); + }); + middle.$destroy(); + expect(destructedScopes).toEqual([middle]); + }); + + }); + + + describe('$eval', function() { + it('should eval an expression', function() { + expect(root.$eval('a=1')).toEqual(1); + expect(root.a).toEqual(1); + + root.$eval(function(self){self.b=2;}); + expect(root.b).toEqual(2); + }); + }); + + describe('$evalAsync', function() { + + it('should run callback before $watch', function() { + var log = ''; + var child = root.$new(); + root.$evalAsync(function(scope){ log += 'parent.async;'; }); + root.$watch('value', function() { log += 'parent.$digest;'; }); + child.$evalAsync(function(scope){ log += 'child.async;'; }); + child.$watch('value', function() { log += 'child.$digest;'; }); + root.$digest(); + expect(log).toEqual('parent.async;parent.$digest;child.async;child.$digest;'); + }); + + it('should cause a $digest rerun', function() { + root.log = ''; + root.value = 0; + root.$watch('value', 'log = log + ".";'); + root.$watch('init', function() { + root.$evalAsync('value = 123; log = log + "=" '); + expect(root.value).toEqual(0); + }); + root.$digest(); + expect(root.log).toEqual('.=.'); + }); + + it('should run async in the same order as added', function() { + root.log = ''; + root.$evalAsync("log = log + 1"); + root.$evalAsync("log = log + 2"); + root.$digest(); + expect(root.log).toBe('12'); + }); + + }); + + + describe('$apply', function() { + it('should apply expression with full lifecycle', function() { + var log = ''; + var child = root.$new(); + root.$watch('a', function(scope, a){ log += '1'; }); + child.$apply('$parent.a=0'); + expect(log).toEqual('1'); + }); + + + it('should catch exceptions', function() { + var log = ''; + var child = root.$new(); + root.$watch('a', function(scope, a){ log += '1'; }); + root.a = 0; + child.$apply(function() { throw new Error('MyError'); }); + expect(log).toEqual('1'); + expect(mockHandler.errors[0].message).toEqual('MyError'); + $logMock.error.logs.shift(); + }); + + + describe('exceptions', function() { + var $exceptionHandler, log; + beforeEach(function() { + log = ''; + $exceptionHandler = jasmine.createSpy('$exceptionHandler'); + root.$service = function(name) { + return {$exceptionHandler:$exceptionHandler}[name]; + }; + root.$watch(function() { log += '$digest;'; }); + root.$digest(); + log = ''; + }); + + + it('should execute and return value and update', function() { + root.name = 'abc'; + expect(root.$apply(function(scope){ + return scope.name; + })).toEqual('abc'); + expect(log).toEqual('$digest;'); + expect($exceptionHandler).not.wasCalled(); + }); + + + it('should catch exception and update', function() { + var error = new Error('MyError'); + root.$apply(function() { throw error; }); + expect(log).toEqual('$digest;'); + expect($exceptionHandler).wasCalledWith(error); + }); + }); + }); + + + describe('events', function() { + + describe('$on', function() { + + it('should add listener for both $emit and $broadcast events', function() { + var log = '', + root = angular.scope(), + child = root.$new(); + + function eventFn() { + log += 'X'; + } + + child.$on('abc', eventFn); + expect(log).toEqual(''); + + child.$emit('abc'); + expect(log).toEqual('X'); + + child.$broadcast('abc'); + expect(log).toEqual('XX'); + }); + + + it('should return a function that deregisters the listener', function() { + var log = '', + root = angular.scope(), + child = root.$new(), + listenerRemove; + + function eventFn() { + log += 'X'; + } + + listenerRemove = child.$on('abc', eventFn); + expect(log).toEqual(''); + expect(listenerRemove).toBeDefined(); + + child.$emit('abc'); + child.$broadcast('abc'); + expect(log).toEqual('XX'); + + log = ''; + listenerRemove(); + child.$emit('abc'); + child.$broadcast('abc'); + expect(log).toEqual(''); + }); + }); + + + describe('$emit', function() { + var log, child, grandChild, greatGrandChild; + + function logger(event) { + log += event.currentScope.id + '>'; + } + + beforeEach(function() { + log = ''; + child = root.$new(); + grandChild = child.$new(); + greatGrandChild = grandChild.$new(); + + root.id = 0; + child.id = 1; + grandChild.id = 2; + greatGrandChild.id = 3; + + root.$on('myEvent', logger); + child.$on('myEvent', logger); + grandChild.$on('myEvent', logger); + greatGrandChild.$on('myEvent', logger); + }); + + it('should bubble event up to the root scope', function() { + grandChild.$emit('myEvent'); + expect(log).toEqual('2>1>0>'); + }); + + + it('should dispatch exceptions to the $exceptionHandler', function() { + child.$on('myEvent', function() { throw 'bubbleException'; }); + grandChild.$emit('myEvent'); + expect(log).toEqual('2>1>0>'); + expect(mockHandler.errors).toEqual(['bubbleException']); + }); + + + it('should allow cancelation of event propagation', function() { + child.$on('myEvent', function(event){ event.cancel(); }); + grandChild.$emit('myEvent'); + expect(log).toEqual('2>1>'); + }); + + + it('should forward method arguments', function() { + child.$on('abc', function(event, arg1, arg2){ + expect(event.name).toBe('abc'); + expect(arg1).toBe('arg1'); + expect(arg2).toBe('arg2'); + }); + child.$emit('abc', 'arg1', 'arg2'); + }); + + describe('event object', function() { + it('should have methods/properties', function() { + var event; + child.$on('myEvent', function(e){ + expect(e.targetScope).toBe(grandChild); + expect(e.currentScope).toBe(child); + expect(e.name).toBe('myEvent'); + event = e; + }); + grandChild.$emit('myEvent'); + expect(event).toBeDefined(); + }); + }); + }); + + + describe('$broadcast', function() { + describe('event propagation', function() { + var log, child1, child2, child3, grandChild11, grandChild21, grandChild22, grandChild23, + greatGrandChild211; + + function logger(event) { + log += event.currentScope.id + '>'; + } + + beforeEach(function() { + log = ''; + child1 = root.$new(); + child2 = root.$new(); + child3 = root.$new(); + grandChild11 = child1.$new(); + grandChild21 = child2.$new(); + grandChild22 = child2.$new(); + grandChild23 = child2.$new(); + greatGrandChild211 = grandChild21.$new(); + + root.id = 0; + child1.id = 1; + child2.id = 2; + child3.id = 3; + grandChild11.id = 11; + grandChild21.id = 21; + grandChild22.id = 22; + grandChild23.id = 23; + greatGrandChild211.id = 211; + + root.$on('myEvent', logger); + child1.$on('myEvent', logger); + child2.$on('myEvent', logger); + child3.$on('myEvent', logger); + grandChild11.$on('myEvent', logger); + grandChild21.$on('myEvent', logger); + grandChild22.$on('myEvent', logger); + grandChild23.$on('myEvent', logger); + greatGrandChild211.$on('myEvent', logger); + + // R + // / | \ + // 1 2 3 + // / / | \ + // 11 21 22 23 + // | + // 211 + }); + + + it('should broadcast an event from the root scope', function() { + root.$broadcast('myEvent'); + expect(log).toBe('0>1>11>2>21>211>22>23>3>'); + }); + + + it('should broadcast an event from a child scope', function() { + child2.$broadcast('myEvent'); + expect(log).toBe('2>21>211>22>23>'); + }); + + + it('should broadcast an event from a leaf scope with a sibling', function() { + grandChild22.$broadcast('myEvent'); + expect(log).toBe('22>'); + }); + + + it('should broadcast an event from a leaf scope without a sibling', function() { + grandChild23.$broadcast('myEvent'); + expect(log).toBe('23>'); + }); + + + it('should not not fire any listeners for other events', function() { + root.$broadcast('fooEvent'); + expect(log).toBe(''); + }); + }); + + + describe('listener', function() { + it('should receive event object', function() { + var scope = angular.scope(), + child = scope.$new(), + event; + + child.$on('fooEvent', function(e) { + event = e; + }); + scope.$broadcast('fooEvent'); + + expect(event.name).toBe('fooEvent'); + expect(event.targetScope).toBe(scope); + expect(event.currentScope).toBe(child); + }); + + + it('should support passing messages as varargs', function() { + var scope = angular.scope(), + child = scope.$new(), + args; + + child.$on('fooEvent', function() { + args = arguments; + }); + scope.$broadcast('fooEvent', 'do', 're', 'me', 'fa'); + + expect(args.length).toBe(5); + expect(sliceArgs(args, 1)).toEqual(['do', 're', 'me', 'fa']); + }); + }); + }); + }); +}); -- cgit v1.2.3