From 3bde820e6cf0819d02434afb41479552487323e7 Mon Sep 17 00:00:00 2001 From: Misko Hevery Date: Fri, 24 Jan 2014 23:55:39 -0800 Subject: [PATCH] feat(scope2): Basic implementation of Scope v2 --- lib/change_detection/ast.dart | 10 +- lib/change_detection/change_detection.dart | 4 +- .../dirty_checking_change_detector.dart | 18 +- lib/change_detection/watch_group.dart | 27 +- lib/core/scope2.dart | 775 ++++++++++++ lib/mock/test_injection.dart | 6 + perf/watch_group_perf.dart | 2 +- test/change_detection/watch_group_spec.dart | 58 +- test/core/scope2_spec.dart | 1065 +++++++++++++++++ 9 files changed, 1920 insertions(+), 45 deletions(-) create mode 100644 lib/core/scope2.dart create mode 100644 test/core/scope2_spec.dart diff --git a/lib/change_detection/ast.dart b/lib/change_detection/ast.dart index d6ba9c5c3..1f8d1bf65 100644 --- a/lib/change_detection/ast.dart +++ b/lib/change_detection/ast.dart @@ -9,7 +9,11 @@ part of angular.watch_group; abstract class AST { static final String _CONTEXT = '#'; final String expression; - AST(this.expression) { assert(expression!=null); } + AST(expression) + : expression = expression.startsWith('#.') ? expression.substring(2) : expression + { + assert(expression!=null); + } WatchRecord<_Handler> setupWatch(WatchGroup watchGroup); toString() => expression; } @@ -34,7 +38,7 @@ class ConstantAST extends AST { final constant; ConstantAST(dynamic constant): - super('$constant'), + super(constant is String ? '"$constant"' : '$constant'), constant = constant; WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) @@ -51,7 +55,7 @@ class FieldReadAST extends AST { final String name; FieldReadAST(lhs, name) - : super(lhs.expression == AST._CONTEXT ? name : '$lhs.$name'), + : super('$lhs.$name'), lhs = lhs, name = name; diff --git a/lib/change_detection/change_detection.dart b/lib/change_detection/change_detection.dart index e7237b5b3..1c41631a0 100644 --- a/lib/change_detection/change_detection.dart +++ b/lib/change_detection/change_detection.dart @@ -1,5 +1,7 @@ library change_detection; +typedef EvalExceptionHandler(error, stack); + /** * An interface for [ChangeDetectorGroup] groups related watches together. It * guarentees that within the group all watches will be reported in the order in @@ -53,7 +55,7 @@ abstract class ChangeDetector extends ChangeDetectorGroup { * linked list of [ChangeRecord]s. The [ChangeRecord]s are to be returned in * the same order as they were registered. */ - ChangeRecord collectChanges(); + ChangeRecord collectChanges([EvalExceptionHandler exceptionHandler]); } abstract class Record { diff --git a/lib/change_detection/dirty_checking_change_detector.dart b/lib/change_detection/dirty_checking_change_detector.dart index 1d32b7fae..6a144acff 100644 --- a/lib/change_detection/dirty_checking_change_detector.dart +++ b/lib/change_detection/dirty_checking_change_detector.dart @@ -216,17 +216,25 @@ class DirtyCheckingChangeDetector extends DirtyCheckingChangeDetectorGroup implements ChangeDetector { DirtyCheckingChangeDetector(GetterCache getterCache): super(null, getterCache); - DirtyCheckingRecord collectChanges() { + DirtyCheckingRecord collectChanges([EvalExceptionHandler exceptionHandler]) { DirtyCheckingRecord changeHead = null; DirtyCheckingRecord changeTail = null; DirtyCheckingRecord current = _head; // current index while (current != null) { - if (current.check() != null) { - if (changeHead == null) { - changeHead = changeTail = current; + try { + if (current.check() != null) { + if (changeHead == null) { + changeHead = changeTail = current; + } else { + changeTail = changeTail.nextChange = current; + } + } + } catch (e, s) { + if (exceptionHandler == null) { + rethrow; } else { - changeTail = changeTail.nextChange = current; + exceptionHandler(e, s); } } current = current._nextWatch; diff --git a/lib/change_detection/watch_group.dart b/lib/change_detection/watch_group.dart index 923cb1b2a..196c930b3 100644 --- a/lib/change_detection/watch_group.dart +++ b/lib/change_detection/watch_group.dart @@ -7,7 +7,8 @@ part 'linked_list.dart'; part 'ast.dart'; part 'prototype_map.dart'; -typedef ReactionFn(value, previousValue, object); +typedef ReactionFn(value, previousValue); +typedef ChangeLog(expression); /** * Extend this class if you wish to pretend to be a function, but you don't know @@ -333,11 +334,14 @@ class RootWatchGroup extends WatchGroup { * Each step is called in sequence. ([ReactionFn]s are not called until all previous steps are * completed). */ - int detectChanges() { + int detectChanges({ExceptionHandler exceptionHandler, ChangeLog changeLog}) { // Process the ChangeRecords from the change detector ChangeRecord<_Handler> changeRecord = - (_changeDetector as ChangeDetector<_Handler>).collectChanges(); + (_changeDetector as ChangeDetector<_Handler>).collectChanges(exceptionHandler); while (changeRecord != null) { + if (changeLog != null) { + changeLog(changeRecord.handler.expression); + } changeRecord.handler.onChange(changeRecord); changeRecord = changeRecord.nextChange; } @@ -346,7 +350,14 @@ class RootWatchGroup extends WatchGroup { // Process our own function evaluations _EvalWatchRecord evalRecord = _evalWatchHead; while (evalRecord != null) { - evalRecord.check(); + try { + var change = evalRecord.check(); + if (change != null && changeLog != null) { + changeLog(evalRecord.handler.expression); + } + } catch (e, s) { + if (exceptionHandler == null) rethrow; else exceptionHandler(e, s); + } evalRecord = evalRecord._nextEvalWatch; } @@ -356,7 +367,11 @@ class RootWatchGroup extends WatchGroup { Watch dirtyWatch = _dirtyWatchHead; while(dirtyWatch != null) { count++; - dirtyWatch.invoke(); + try { + dirtyWatch.invoke(); + } catch (e, s) { + if (exceptionHandler == null) rethrow; else exceptionHandler(e, s); + } dirtyWatch = dirtyWatch._nextDirtyWatch; } _dirtyWatchHead = _dirtyWatchTail = null; @@ -400,7 +415,7 @@ class Watch { invoke() { _dirty = false; - reactionFn(_record.currentValue, _record.previousValue, _record.object); + reactionFn(_record.currentValue, _record.previousValue); } remove() { diff --git a/lib/core/scope2.dart b/lib/core/scope2.dart new file mode 100644 index 000000000..8943f3b8b --- /dev/null +++ b/lib/core/scope2.dart @@ -0,0 +1,775 @@ +part of angular.core; + +NOT_IMPLEMENTED() { + throw new StateError('Not Implemented'); +} + +typedef EvalFunction0(); +typedef EvalFunction1(dynamic context); + +/** + * Injected into the listener function within [Scope.on] to provide event-specific + * details to the scope listener. + */ +class ScopeEvent { + static final String DESTROY = 'ng-destroy'; + + final dynamic data; + + /** + * The name of the intercepted scope event. + */ + final String name; + + /** + * The origin scope that triggered the event (via $broadcast or $emit). + */ + final Scope targetScope; + + /** + * The destination scope that intercepted the event. + */ + Scope get currentScope => _currentScope; + Scope _currentScope; + + /** + * true or false depending on if stopPropagation() was executed. + */ + bool get propagationStopped => _propagationStopped; + bool _propagationStopped = false; + + /** + * true or false depending on if preventDefault() was executed. + */ + bool get defaultPrevented => _defaultPrevented; + bool _defaultPrevented = false; + + /** + ** [name] - The name of the scope event. + ** [targetScope] - The destination scope that is listening on the event. + */ + ScopeEvent(this.name, this.targetScope, this.data); + + /** + * Prevents the intercepted event from propagating further to successive scopes. + */ + stopPropagation () => _propagationStopped = true; + + /** + * Sets the defaultPrevented flag to true. + */ + preventDefault() => _defaultPrevented = true; +} + +/** + * Allows the configuration of [Scope.digest] iteration maximum time-to-live + * value. Digest keeps checking the state of the watcher getters until it + * can execute one full iteration with no watchers triggering. TTL is used + * to prevent an infinite loop where watch A triggers watch B which in turn + * triggers watch A. If the system does not stabilize in TTL iteration then + * an digest is stop an an exception is thrown. + */ +@NgInjectableService() +class ScopeDigestTTL { + final num ttl; + ScopeDigestTTL(): ttl = 5; + ScopeDigestTTL.value(num this.ttl); +} + +//TODO(misko): I don't think this should be in scope. +class ScopeLocals implements Map { + static wrapper(dynamic scope, Map locals) => new ScopeLocals(scope, locals); + + Map _scope; + Map _locals; + + ScopeLocals(this._scope, this._locals); + + operator []=(String name, value) => _scope[name] = value; + operator [](String name) => (_locals.containsKey(name) ? _locals : _scope)[name]; + + get isEmpty => _scope.isEmpty && _locals.isEmpty; + get isNotEmpty => _scope.isNotEmpty || _locals.isNotEmpty; + get keys => _scope.keys; + get values => _scope.values; + get length => _scope.length; + + forEach(fn) => _scope.forEach(fn); + remove(key) => _scope.remove(key); + clear() => _scope.clear; + containsKey(key) => _scope.containsKey(key); + containsValue(key) => _scope.containsValue(key); + addAll(map) => _scope.addAll(map); + putIfAbsent(key, fn) => _scope.putIfAbsent(key, fn); +} + +class Scope { + final dynamic context; + final RootScope rootScope; + Scope _parentScope; + Scope get parentScope => _parentScope; + + final WatchGroup watchGroup; + final WatchGroup observeGroup; + final int _depth; + final int _index; + + Scope _childHead, _childTail, _next, _prev; + _Streams _streams; + int _nextChildIndex = 0; + + Scope(Object this.context, this.rootScope, this._parentScope, + this._depth, this._index, + this.watchGroup, this.observeGroup); + + // TODO(misko): this is a hack and should be removed + // A better way to do this is to remove the praser from the scope. + Watch watchSet(List exprs, Function reactionFn) { + var expr = '{{${exprs.join('}}?{{')}}}'; + List items = exprs.map(rootScope._parse).toList(); + AST ast = new PureFunctionAST(expr, new ArrayFn(), items); + return watchGroup.watch(ast, reactionFn); + } + + Watch watch(expression, ReactionFn reactionFn) { + // Todo(misko): remove the parser from here. It should only take AST. + assert(expression != null); + AST ast = expression is AST ? expression : rootScope._parse(expression); + return watchGroup.watch(ast, reactionFn); + } + + Watch observe(expression, ReactionFn reactionFn) { + // Todo(misko): remove the parser from here. It should only take AST. + assert(expression != null); + AST ast = expression is AST ? expression : rootScope._parse(expression); + return observeGroup.watch(ast, reactionFn); + } + + dynamic eval(expression, [Map locals]) { + assert(expression == null || + expression is String || + expression is Function); + if (expression is String && expression.isNotEmpty) { + var obj = locals == null ? context : new ScopeLocals(context, locals); + return rootScope._parser(expression).eval(obj); + } else if (expression is EvalFunction1) { + assert(locals == null); + return expression(context); + } else if (expression is EvalFunction0) { + assert(locals == null); + return expression(); + } + } + + dynamic applyInZone([expression, Map locals]) + => rootScope._zone.run(() => apply(expression, locals)); + + dynamic apply([expression, Map locals]) { + rootScope._transitionState(null, RootScope.STATE_APPLY); + try { + return eval(expression, locals); + } catch (e, s) { + rootScope._exceptionHandler(e, s); + } finally { + rootScope._transitionState(RootScope.STATE_APPLY, null); + rootScope.digest(); + rootScope.flush(); + } + } + + + ScopeEvent emit(String name, [data]) => _Streams.emit(this, name, data); + ScopeEvent broadcast(String name, [data]) => _Streams.broadcast(this, name, data); + ScopeStream on(String name) => _Streams.on(this, rootScope._exceptionHandler, name); + + Scope createChild([Object childContext]) { + if (childContext == null) childContext = context; + Scope child = new Scope(childContext, rootScope, this, + _depth + 1, _nextChildIndex++, + watchGroup.newGroup(childContext), + observeGroup.newGroup(childContext)); + var next = null; + var prev = _childTail; + child._next = next; + child._prev = prev; + if (prev == null) _childHead = child; else prev._next = child; + if (next == null) _childTail = child; else next._prev = child; + return child; + } + + void destroy() { + var prev = this._prev; + var next = this._next; + if (prev == null) _parentScope._childHead = next; else prev._next = next; + if (next == null) _parentScope._childTail = prev; else next._prev = prev; + + this._next = this._prev = null; + + watchGroup.remove(); + observeGroup.remove(); + _Streams.destroy(this); + + _parentScope = null; + broadcast(ScopeEvent.DESTROY); + } +} + + +class RootScope extends Scope { + static final STATE_APPLY = 'apply'; + static final STATE_DIGEST = 'digest'; + static final STATE_FLUSH = 'digest'; + + final ExceptionHandler _exceptionHandler; + final Parser _parser; + final ScopeDigestTTL _ttl; + final ExpressionVisitor visitor = new ExpressionVisitor(); // TODO(misko): delete me + final NgZone _zone; + + _FunctionChain _runAsyncHead, _runAsyncTail; + _FunctionChain _domWriteHead, _domWriteTail; + _FunctionChain _domReadHead, _domReadTail; + + String _state; + + RootScope(Object context, Parser this._parser, GetterCache cacheGetter, + FilterMap filterMap, ExceptionHandler this._exceptionHandler, + ScopeDigestTTL this._ttl, this._zone) + : super(context, null, null, 0, 0, + new RootWatchGroup(new DirtyCheckingChangeDetector(cacheGetter), context), + new RootWatchGroup(new DirtyCheckingChangeDetector(cacheGetter), context)) + { + _zone.onTurnDone = () { + digest(); + flush(); + }; + } + + RootScope get rootScope => this; + + void digest() { + _transitionState(null, STATE_DIGEST); + try { + RootWatchGroup rootWatchGroup = (watchGroup as RootWatchGroup); + + int digestTTL = _ttl.ttl; + const int logCount = 3; + List log; + List digestLog; + var count; + ChangeLog changeLog; + do { + while(_runAsyncHead != null) { + try { _runAsyncHead.fn(); } + catch (e, s) { _exceptionHandler(e, s); } + _runAsyncHead = _runAsyncHead._next; + } + + digestTTL--; + count = rootWatchGroup.detectChanges( + exceptionHandler: _exceptionHandler, + changeLog: changeLog); + + if (digestTTL <= logCount) { + if (changeLog == null) { + log = []; + digestLog = []; + changeLog = (value) => digestLog.add(value); + } else { + log.add(digestLog.join(', ')); + digestLog.clear(); + } + } + if (digestTTL == 0) { + throw 'Model did not stabilize in ${_ttl.ttl} digests. ' + + 'Last $logCount iterations:\n${log.join('\n')}'; + } + } while (count > 0); + } finally { + _transitionState(STATE_DIGEST, null); + } + } + + void flush() { + _transitionState(null, STATE_FLUSH); + RootWatchGroup observeGroup = this.observeGroup as RootWatchGroup; + bool runObservers = true; + try { + do { + while(_domWriteHead != null) { + try { _domWriteHead.fn(); } + catch (e, s) { _exceptionHandler(e, s); } + _domWriteHead = _domWriteHead._next; + } + if (runObservers) { + runObservers = false; + observeGroup.detectChanges(exceptionHandler:_exceptionHandler); + } + while(_domReadHead != null) { + try { _domReadHead.fn(); } + catch (e, s) { _exceptionHandler(e, s); } + _domReadHead = _domReadHead._next; + } + } while (_domWriteHead != null || _domReadHead != null); + assert((() { + var watchLog = []; + var observeLog = []; + (watchGroup as RootWatchGroup).detectChanges(changeLog: watchLog.add); + (observeGroup as RootWatchGroup).detectChanges(changeLog: observeLog.add); + if (watchLog.isNotEmpty || observeLog.isNotEmpty) { + throw 'Observer reaction functions should not change model. \n' + 'These watch changes were detected: ${watchLog.join('; ')}\n' + 'These observe changes were detected: ${observeLog.join('; ')}'; + } + return true; + })()); + } finally { + _transitionState(STATE_FLUSH, null); + } + + } + + // QUEUES + void runAsync(Function fn) { + var chain = new _FunctionChain(fn); + if (_runAsyncHead == null) { + _runAsyncHead = _runAsyncTail = chain; + } else { + _runAsyncTail = _runAsyncTail._next = chain; + } + } + + void domWrite(Function fn) { + var chain = new _FunctionChain(fn); + if (_domWriteHead == null) { + _domWriteHead = _domWriteTail = chain; + } else { + _domWriteTail = _domWriteTail._next = chain; + } + } + + void domRead(Function fn) { + var chain = new _FunctionChain(fn); + if (_domReadHead == null) { + _domReadHead = _domReadTail = chain; + } else { + _domReadTail = _domReadTail._next = chain; + } + } + + + AST _parse(expression) => visitor.visit(_parser.call(expression)); + void destroy() {} + + void _transitionState(String from, String to) { + if (_state != from) { + throw "$_state already in progress can not enter $to."; + } + _state = to; + } +} + +/** + * Keeps track of Streams for each Scope. When emitting events + * we would need to walk the whole tree. Its faster if we can prune + * the Scopes we have to visit. + * + * Scope with no [_ScopeStreams] has no events registered on itself or children + * + * We keep track of [Stream]s, and also child scope [Stream]s. To save + * memory we use the same stream object on all of our parents if they don't + * have one. But that means that we have to keep track if the stream belongs + * to the node. + * + * Scope with [_ScopeStreams] but who's [_scope] dose not match the scope + * is only inherited + * + * Only [Scope] with [_ScopeStreams] who's [_scope] matches the [Scope] + * instance is the actual scope. + * + * Once the [Stream] is created it can not be removed even if all listeners + * are canceled. That is because we don't know if someone still has reference + * to it. + */ +class _Streams { + final ExceptionHandler _exceptionHandler; + /// Scope we belong to. + final Scope _scope; + /// [Stream]s for [_scope] only + final Map _streams = new Map(); + /// Child [Scope] event counts. + final Map _typeCounts; + + _Streams(this._scope, this._exceptionHandler, _Streams inheritStreams) + : _typeCounts = inheritStreams == null + ? new Map() + : new Map.from(inheritStreams._typeCounts); + + static ScopeEvent emit(Scope scope, String name, data) { + ScopeEvent event = new ScopeEvent(name, scope, data); + Scope scopeCursor = scope; + while(scopeCursor != null) { + if (scopeCursor._streams._scope == scopeCursor) { + ScopeStream stream = scopeCursor._streams._streams[name]; + if (stream != null) { + event._currentScope = scopeCursor; + stream._fire(event); + if (event.propagationStopped) return event; + } + } + scopeCursor = scopeCursor._parentScope; + } + return event; + } + + static ScopeEvent broadcast(Scope scope, String name, data) { + _Streams scopeStreams = scope._streams; + ScopeEvent event = new ScopeEvent(name, scope, data); + if (scopeStreams != null && scopeStreams._typeCounts.containsKey(name)) { + Queue queue = new Queue(); + queue.addFirst(scopeStreams._scope); + while(queue.isNotEmpty) { + scope = queue.removeFirst(); + scopeStreams = scope._streams; + assert(scopeStreams._scope == scope); + assert(scopeStreams._streams.containsKey(name)); + var stream = scopeStreams._streams[name]; + event._currentScope = scope; + stream._fire(event); + // Reverse traversal so that when the queue is read it is correct order. + var childScope = scope._childTail; + while(childScope != null) { + scopeStreams = childScope._streams; + if (scopeStreams != null) { + queue.addFirst(scopeStreams._scope); + } + childScope = childScope._prev; + } + } + } + return event; + } + + static ScopeStream on(Scope scope, ExceptionHandler _exceptionHandler, String name) { + var scopeStream = scope._streams; + if (scopeStream == null || scopeStream._scope != scope) { + // We either don't have [_ScopeStreams] or it is inherited. + var newStreams = new _Streams(scope, _exceptionHandler, scopeStream); + var scopeCursor = scope; + while (scopeCursor != null && scopeCursor._streams == scopeStream) { + scopeCursor._streams = newStreams; + scopeCursor = scopeCursor._parentScope; + } + scopeStream = newStreams; + } + return scopeStream._get(scope, name); + } + + static void destroy(Scope scope) { + var toBeDeletedStreams = scope._streams; + if (toBeDeletedStreams == null) return; + scope = scope._parentScope; // skip current state as not to delete listeners + while (scope != null && + scope._streams == toBeDeletedStreams) { + scope._streams = null; + scope = scope._parentScope; + } + if (scope == null) return; + var parentStreams = scope._streams; + assert(parentStreams != toBeDeletedStreams); + toBeDeletedStreams._typeCounts.forEach( + (name, count) => parentStreams._addCount(name, -count)); + } + + async.Stream _get(Scope scope, String name) { + assert(scope._streams == this); + assert(scope._streams._scope == scope); + assert(_exceptionHandler != null); + return _streams.putIfAbsent(name, () => new ScopeStream(this, _exceptionHandler, name)); + } + + void _addCount(String name, int amount) { + // decrement the counters on all parent scopes + _Streams lastStreams = null; + Scope scope = _scope; + while (scope != null) { + if (lastStreams != scope._streams) { + // we have a transition, need to decrement it + lastStreams = scope._streams; + int count = lastStreams._typeCounts[name]; + count = count == null ? amount : count + amount; + assert(count >= 0); + if (count == 0) { + lastStreams._typeCounts.remove(name); + } else { + lastStreams._typeCounts[name] = count; + } + } + scope = scope._parentScope; + } + } +} + +class ScopeStream extends async.Stream { + final ExceptionHandler _exceptionHandler; + final _Streams _streams; + final String _name; + final List subscriptions = []; + + ScopeStream(this._streams, this._exceptionHandler, this._name); + + ScopeStreamSubscription listen(void onData(ScopeEvent event), + { Function onError, + void onDone(), + bool cancelOnError }) { + if (subscriptions.isEmpty) { + _streams._addCount(_name, 1); + } + ScopeStreamSubscription subscription = new ScopeStreamSubscription(this, onData); + subscriptions.add(subscription); + return subscription; + } + + _fire(ScopeEvent event) { + for(ScopeStreamSubscription subscription in subscriptions) { + try { + subscription._onData(event); + } catch (e, s) { + _exceptionHandler(e, s); + } + } + } + + _remove(ScopeStreamSubscription subscription) { + assert(subscription._scopeStream == this); + if (subscriptions.remove(subscription)) { + if (subscriptions.isEmpty) { + _streams._addCount(_name, -1); + } + } else { + throw new StateError('AlreadyCanceled'); + } + return null; + } +} + +class ScopeStreamSubscription implements async.StreamSubscription { + final ScopeStream _scopeStream; + final Function _onData; + ScopeStreamSubscription(this._scopeStream, this._onData); + + async.Future cancel() => _scopeStream._remove(this); + + void onData(void handleData(ScopeEvent data)) => NOT_IMPLEMENTED(); + void onError(Function handleError) => NOT_IMPLEMENTED(); + void onDone(void handleDone()) => NOT_IMPLEMENTED(); + void pause([async.Future resumeSignal]) => NOT_IMPLEMENTED(); + void resume() => NOT_IMPLEMENTED(); + bool get isPaused => NOT_IMPLEMENTED(); + async.Future asFuture([var futureValue]) => NOT_IMPLEMENTED(); +} + +class _FunctionChain { + final Function fn; + _FunctionChain _next; + + _FunctionChain(this.fn); +} + +class AstParser { + final Parser _parser; + int _id = 0; + ExpressionVisitor _visitor = new ExpressionVisitor(); + + AstParser(this._parser); + + AST call(String exp, { FilterMap filters, + bool collection:false, + Object context:null }) { + _visitor.filters = filters; + AST contextRef = _visitor.contextRef; + try { + if (context != null) { + _visitor.contextRef = new ConstantAST(context, '#${_id++}'); + } + var ast = _parser(exp); + return collection ? _visitor.visitCollection(ast) : _visitor.visit(ast); + } finally { + _visitor.contextRef = contextRef; + _visitor.filters = null; + } + } +} + +class ExpressionVisitor implements Visitor { + static final ContextReferenceAST scopeContextRef = new ContextReferenceAST(); + AST contextRef = scopeContextRef; + + AST ast; + FilterMap filters; + + AST visit(Expression exp) { + exp.accept(this); + assert(this.ast != null); + try { + return ast; + } finally { + ast = null; + } + } + + AST visitCollection(Expression exp) => new CollectionAST(visit(exp)); + AST _mapToAst(Expression expression) => visit(expression); + + List _toAst(List expressions) => expressions.map(_mapToAst).toList(); + + visitCallScope(CallScope exp) => ast = new MethodAST(contextRef, exp.name, _toAst(exp.arguments)); + visitCallMember(CallMember exp) => ast = new MethodAST(visit(exp.object), exp.name, _toAst(exp.arguments)); + + visitAccessScope(AccessScope exp) => ast = new FieldReadAST(contextRef, exp.name); + visitAccessMember(AccessMember exp) => ast = new FieldReadAST(visit(exp.object), exp.name); + visitBinary(Binary exp) => ast = new PureFunctionAST(exp.operation, + _operationToFunction(exp.operation), + [visit(exp.left), visit(exp.right)]); + visitPrefix(Prefix exp) => ast = new PureFunctionAST(exp.operation, + _operationToFunction(exp.operation), + [visit(exp.expression)]); + visitConditional(Conditional exp) => ast = new PureFunctionAST('?:', _operation_ternary, + [visit(exp.condition), visit(exp.yes), visit(exp.no)]); + visitAccessKeyed(AccessKeyed exp) => ast = new PureFunctionAST('[]', _operation_bracket, + [visit(exp.object), visit(exp.key)]); + + visitLiteralPrimitive(LiteralPrimitive exp) => ast = new ConstantAST(exp.value); + visitLiteralString(LiteralString exp) => ast = new ConstantAST(exp.value); + + visitLiteralArray(LiteralArray exp) { + List items = _toAst(exp.elements); + ast = new PureFunctionAST('[${items.join(', ')}]', new ArrayFn(), items); + } + + visitLiteralObject(LiteralObject exp) { + List keys = exp.keys; + List values = _toAst(exp.values); + assert(keys.length == values.length); + List kv = []; + for(var i = 0; i < keys.length; i++) { + kv.add('${keys[i]}: ${values[i]}'); + } + ast = new PureFunctionAST('{${kv.join(', ')}}', new MapFn(keys), values); + } + + visitFilter(Filter exp) { + Function filterFunction = filters(exp.name); + List args = [visitCollection(exp.expression)]; + args.addAll(_toAst(exp.arguments).map((ast) => new CollectionAST(ast))); + ast = new PureFunctionAST('|${exp.name}', new _FilterWrapper(filterFunction, args.length), args); + } + + // TODO(misko): this is a corner case. Choosing not to implement for now. + visitCallFunction(CallFunction exp) => _notSupported("function's returing functions"); + visitAssign(Assign exp) => _notSupported('assignement'); + visitLiteral(Literal exp) => _notSupported('literal'); + visitExpression(Expression exp) => _notSupported('?'); + visitChain(Chain exp) => _notSupported(';'); + + _notSupported(String name) { + throw new StateError("Can not watch expression containing '$name'."); + } +} + +_operationToFunction(String operation) { + switch(operation) { + case '!' : return _operation_negate; + case '+' : return _operation_add; + case '-' : return _operation_subtract; + case '*' : return _operation_multiply; + case '/' : return _operation_divide; + case '~/' : return _operation_divide_int; + case '%' : return _operation_remainder; + case '==' : return _operation_equals; + case '!=' : return _operation_not_equals; + case '<' : return _operation_less_then; + case '>' : return _operation_greater_then; + case '<=' : return _operation_less_or_equals_then; + case '>=' : return _operation_greater_or_equals_then; + case '^' : return _operation_power; + case '&' : return _operation_bitwise_and; + case '&&' : return _operation_logical_and; + case '||' : return _operation_logical_or; + default: throw new StateError(operation); + } +} + +_operation_negate(value) => !toBool(value); +_operation_add(left, right) => autoConvertAdd(left, right); +_operation_subtract(left, right) => left - right; +_operation_multiply(left, right) => left * right; +_operation_divide(left, right) => left / right; +_operation_divide_int(left, right) => left ~/ right; +_operation_remainder(left, right) => left % right; +_operation_equals(left, right) => left == right; +_operation_not_equals(left, right) => left != right; +_operation_less_then(left, right) => left < right; +_operation_greater_then(left, right) => left > right; +_operation_less_or_equals_then(left, right) => left <= right; +_operation_greater_or_equals_then(left, right) => left >= right; +_operation_power(left, right) => left ^ right; +_operation_bitwise_and(left, right) => left & right; +// TODO(misko): these should short circuit the evaluation. +_operation_logical_and(left, right) => toBool(left) && toBool(right); +_operation_logical_or(left, right) => toBool(left) || toBool(right); + +_operation_ternary(condition, yes, no) => toBool(condition) ? yes : no; +_operation_bracket(obj, key) => obj == null ? null : obj[key]; + +class ArrayFn extends FunctionApply { + // TODO(misko): figure out why do we need to make a copy? + apply(List args) => new List.from(args); +} + +class MapFn extends FunctionApply { + final List keys; + + MapFn(this.keys); + + apply(List values) { + // TODO(misko): figure out why do we need to make a copy instead of reusing instance? + Map map = {}; + assert(values.length == keys.length); + for(var i = 0; i < keys.length; i++) { + map[keys[i]] = values[i]; + } + return map; + } +} + +class _FilterWrapper extends FunctionApply { + final Function filterFn; + final List args; + final List argsWatches; + _FilterWrapper(this.filterFn, length): + args = new List(length), + argsWatches = new List(length); + + apply(List values) { + for(var i=0; i < values.length; i++) { + var value = values[i]; + var lastValue = args[i]; + if (!identical(value, lastValue)) { + if (value is CollectionChangeRecord) { + args[i] = (value as CollectionChangeRecord).iterable; + } else { + args[i] = value; + } + } + } + var value = Function.apply(filterFn, args); + if (value is Iterable) { + // Since filters are pure we can guarantee that this well never change. + // By wrapping in UnmodifiableListView we can hint to the dirty checker and + // short circuit the iterator. + value = new UnmodifiableListView(value); + } + return value; + } +} diff --git a/lib/mock/test_injection.dart b/lib/mock/test_injection.dart index ae1542d34..eb45e1815 100644 --- a/lib/mock/test_injection.dart +++ b/lib/mock/test_injection.dart @@ -5,6 +5,7 @@ _SpecInjector _currentSpecInjector = null; class _SpecInjector { DynamicInjector moduleInjector; DynamicInjector injector; + dynamic injectiorCreateLocation; List modules = []; List initFns = []; @@ -23,6 +24,9 @@ class _SpecInjector { } module(fnOrModule, [declarationStack]) { + if (injectiorCreateLocation != null) { + throw "Injector already created at:\n$injectiorCreateLocation"; + } try { if (fnOrModule is Function) { var initFn = moduleInjector.invoke(fnOrModule); @@ -42,6 +46,7 @@ class _SpecInjector { inject(Function fn, [declarationStack]) { try { if (injector == null) { + injectiorCreateLocation = declarationStack; injector = new DynamicInjector(modules: modules); // Implicit injection is disabled. initFns.forEach((fn) { injector.invoke(fn); @@ -55,6 +60,7 @@ class _SpecInjector { reset() { injector = null; + injectiorCreateLocation = null; } } diff --git a/perf/watch_group_perf.dart b/perf/watch_group_perf.dart index d0e9142af..a5923c6bd 100644 --- a/perf/watch_group_perf.dart +++ b/perf/watch_group_perf.dart @@ -13,7 +13,7 @@ import 'package:benchmark_harness/benchmark_harness.dart'; ) import 'dart:mirrors' show MirrorsUsed; -var _reactionFn = (_, __, ___) => null; +var _reactionFn = (_, __) => null; var _getterCache = new GetterCache({}); main() { _fieldRead(); diff --git a/test/change_detection/watch_group_spec.dart b/test/change_detection/watch_group_spec.dart index 2608addc2..e36cdc25b 100644 --- a/test/change_detection/watch_group_spec.dart +++ b/test/change_detection/watch_group_spec.dart @@ -40,7 +40,7 @@ main() => describe('WatchGroup', () { // should fire on initial adding expect(watchGrp.fieldCost).toEqual(0); - var watch = watchGrp.watch(parse('a'), (v, p, o) => logger(v)); + var watch = watchGrp.watch(parse('a'), (v, p) => logger(v)); expect(watch.expression).toEqual('a'); expect(watchGrp.fieldCost).toEqual(1); watchGrp.detectChanges(); @@ -69,7 +69,7 @@ main() => describe('WatchGroup', () { // should fire on initial adding expect(watchGrp.fieldCost).toEqual(0); expect(changeDetector.count).toEqual(0); - var watch = watchGrp.watch(parse('a.b'), (v, p, o) => logger(v)); + var watch = watchGrp.watch(parse('a.b'), (v, p) => logger(v)); expect(watch.expression).toEqual('a.b'); expect(watchGrp.fieldCost).toEqual(2); expect(changeDetector.count).toEqual(2); @@ -111,9 +111,9 @@ main() => describe('WatchGroup', () { // should fire on initial adding expect(watchGrp.fieldCost).toEqual(0); - var watch = watchGrp.watch(parse('user'), (v, p, o) => logger(v)); - var watchFirst = watchGrp.watch(parse('user.first'), (v, p, o) => logger(v)); - var watchLast = watchGrp.watch(parse('user.last'), (v, p, o) => logger(v)); + var watch = watchGrp.watch(parse('user'), (v, p) => logger(v)); + var watchFirst = watchGrp.watch(parse('user.first'), (v, p) => logger(v)); + var watchLast = watchGrp.watch(parse('user.last'), (v, p) => logger(v)); expect(watchGrp.fieldCost).toEqual(3); watchGrp.detectChanges(); @@ -143,7 +143,7 @@ main() => describe('WatchGroup', () { FunctionApply fn = new LoggingFunctionApply(logger); var watch = watchGrp.watch( new PureFunctionAST('add', fn, [parse('a.val')]), - (v, p, o) => logger(v) + (v, p) => logger(v) ); // a; a.val; b; b.val; @@ -165,7 +165,7 @@ main() => describe('WatchGroup', () { (a, b) { logger('+'); return a+b; }, [parse('a.val'), parse('b.val')] ), - (v, p, o) => logger(v) + (v, p) => logger(v) ); // a; a.val; b; b.val; @@ -213,7 +213,7 @@ main() => describe('WatchGroup', () { (b, c) { logger('$b+$c'); return b + c; }, [a_plus_b, parse('c.val')]); - var watch = watchGrp.watch(a_plus_b_plus_c, (v, p, o) => logger(v)); + var watch = watchGrp.watch(a_plus_b_plus_c, (v, p) => logger(v)); // a; a.val; b; b.val; c; c.val; expect(watchGrp.fieldCost).toEqual(6); @@ -275,7 +275,7 @@ main() => describe('WatchGroup', () { var watch = watchGrp.watch( new MethodAST(parse('obj'), 'methodA', [parse('arg0')]), - (v, p, o) => logger(v) + (v, p) => logger(v) ); // obj, arg0; @@ -319,7 +319,7 @@ main() => describe('WatchGroup', () { var watch = watchGrp.watch( new MethodAST(parse('obj'), 'methodA', [parse('arg0')]), - (v, p, o) => logger(v) + (v, p) => logger(v) ); // obj, arg0; @@ -366,7 +366,7 @@ main() => describe('WatchGroup', () { // obj.methodA(arg0) var ast = new MethodAST(parse('obj'), 'methodA', [parse('arg0')]); ast = new MethodAST(ast, 'methodA', [parse('arg1')]); - var watch = watchGrp.watch(ast, (v, p, o) => logger(v)); + var watch = watchGrp.watch(ast, (v, p) => logger(v)); // obj, arg0, arg1; expect(watchGrp.fieldCost).toEqual(3); @@ -406,7 +406,7 @@ main() => describe('WatchGroup', () { it('should read connstant', () { // should fire on initial adding expect(watchGrp.fieldCost).toEqual(0); - var watch = watchGrp.watch(new ConstantAST(123), (v, p, o) => logger(v)); + var watch = watchGrp.watch(new ConstantAST(123), (v, p) => logger(v)); expect(watch.expression).toEqual('123'); expect(watchGrp.fieldCost).toEqual(0); watchGrp.detectChanges(); @@ -419,7 +419,7 @@ main() => describe('WatchGroup', () { it('should wrap iterable in ObservableList', () { context['list'] = []; - var watch = watchGrp.watch(new CollectionAST(parse('list')), (v, p, o) => logger(v)); + var watch = watchGrp.watch(new CollectionAST(parse('list')), (v, p) => logger(v)); expect(watchGrp.fieldCost).toEqual(1); expect(watchGrp.collectionCost).toEqual(1); @@ -453,15 +453,15 @@ main() => describe('WatchGroup', () { describe('child group', () { it('should remove all field watches in group and group\'s children', () { - watchGrp.watch(parse('a'), (v, p, o) => logger('0a')); + watchGrp.watch(parse('a'), (v, p) => logger('0a')); var child1a = watchGrp.newGroup(new PrototypeMap(context)); var child1b = watchGrp.newGroup(new PrototypeMap(context)); var child2 = child1a.newGroup(new PrototypeMap(context)); - child1a.watch(parse('a'), (v, p, o) => logger('1a')); - child1b.watch(parse('a'), (v, p, o) => logger('1b')); - watchGrp.watch(parse('a'), (v, p, o) => logger('0A')); - child1a.watch(parse('a'), (v, p, o) => logger('1A')); - child2.watch(parse('a'), (v, p, o) => logger('2A')); + child1a.watch(parse('a'), (v, p) => logger('1a')); + child1b.watch(parse('a'), (v, p) => logger('1b')); + watchGrp.watch(parse('a'), (v, p) => logger('0A')); + child1a.watch(parse('a'), (v, p) => logger('1A')); + child2.watch(parse('a'), (v, p) => logger('2A')); // flush initial reaction functions expect(watchGrp.detectChanges()).toEqual(6); @@ -487,21 +487,21 @@ main() => describe('WatchGroup', () { it('should remove all method watches in group and group\'s children', () { context['my'] = new MyClass(logger); AST countMethod = new MethodAST(parse('my'), 'count', []); - watchGrp.watch(countMethod, (v, p, o) => logger('0a')); + watchGrp.watch(countMethod, (v, p) => logger('0a')); expectOrder(['0a']); var child1a = watchGrp.newGroup(new PrototypeMap(context)); var child1b = watchGrp.newGroup(new PrototypeMap(context)); var child2 = child1a.newGroup(new PrototypeMap(context)); - child1a.watch(countMethod, (v, p, o) => logger('1a')); + child1a.watch(countMethod, (v, p) => logger('1a')); expectOrder(['0a', '1a']); - child1b.watch(countMethod, (v, p, o) => logger('1b')); + child1b.watch(countMethod, (v, p) => logger('1b')); expectOrder(['0a', '1a', '1b']); - watchGrp.watch(countMethod, (v, p, o) => logger('0A')); + watchGrp.watch(countMethod, (v, p) => logger('0A')); expectOrder(['0a', '0A', '1a', '1b']); - child1a.watch(countMethod, (v, p, o) => logger('1A')); + child1a.watch(countMethod, (v, p) => logger('1A')); expectOrder(['0a', '0A', '1a', '1A', '1b']); - child2.watch(countMethod, (v, p, o) => logger('2A')); + child2.watch(countMethod, (v, p) => logger('2A')); expectOrder(['0a', '0A', '1a', '1A', '2A', '1b']); // flush initial reaction functions @@ -516,9 +516,9 @@ main() => describe('WatchGroup', () { it('should add watches within its own group', () { context['my'] = new MyClass(logger); AST countMethod = new MethodAST(parse('my'), 'count', []); - var ra = watchGrp.watch(countMethod, (v, p, o) => logger('a')); + var ra = watchGrp.watch(countMethod, (v, p) => logger('a')); var child = watchGrp.newGroup(new PrototypeMap(context)); - var cb = child.watch(countMethod, (v, p, o) => logger('b')); + var cb = child.watch(countMethod, (v, p) => logger('b')); expectOrder(['a', 'b']); expectOrder(['a', 'b']); @@ -530,8 +530,8 @@ main() => describe('WatchGroup', () { expectOrder([]); // TODO: add them back in wrong order, assert events in right order - cb = child.watch(countMethod, (v, p, o) => logger('b')); - ra = watchGrp.watch(countMethod, (v, p, o) => logger('a'));; + cb = child.watch(countMethod, (v, p) => logger('b')); + ra = watchGrp.watch(countMethod, (v, p) => logger('a'));; expectOrder(['a', 'b']); }); }); diff --git a/test/core/scope2_spec.dart b/test/core/scope2_spec.dart new file mode 100644 index 000000000..bd4dbe4f0 --- /dev/null +++ b/test/core/scope2_spec.dart @@ -0,0 +1,1065 @@ +library scope2_spec; + +import '../_specs.dart'; +import 'package:angular/change_detection/change_detection.dart' hide ExceptionHandler; +import 'package:angular/change_detection/dirty_checking_change_detector.dart'; + +main() => describe('scope2', () { + beforeEach(module((Module module) { + Map context = {}; + module.value(GetterCache, new GetterCache({})); + module.type(ChangeDetector, implementedBy: DirtyCheckingChangeDetector); + module.value(Object, context); + module.value(Map, context); + module.type(RootScope); + module.type(_MultiplyFilter); + module.type(_ListHeadFilter); + module.type(_ListTailFilter); + module.type(_SortFilter); + })); + + describe('AST Bridge', () { + it('should watch field', inject((Logger logger, Map context, RootScope rootScope) { + context['field'] = 'Worked!'; + rootScope.watch('field', (value, previous) => logger([value, previous])); + expect(logger).toEqual([]); + rootScope.digest(); + expect(logger).toEqual([['Worked!', null]]); + rootScope.digest(); + expect(logger).toEqual([['Worked!', null]]); + })); + + it('should watch field path', inject((Logger logger, Map context, RootScope rootScope) { + context['a'] = {'b': 'AB'}; + rootScope.watch('a.b', (value, previous) => logger(value)); + rootScope.digest(); + expect(logger).toEqual(['AB']); + context['a']['b'] = '123'; + rootScope.digest(); + expect(logger).toEqual(['AB', '123']); + context['a'] = {'b': 'XYZ'}; + rootScope.digest(); + expect(logger).toEqual(['AB', '123', 'XYZ']); + })); + + it('should watch math operations', inject((Logger logger, Map context, RootScope rootScope) { + context['a'] = 1; + context['b'] = 2; + rootScope.watch('a + b + 1', (value, previous) => logger(value)); + rootScope.digest(); + expect(logger).toEqual([4]); + context['a'] = 3; + rootScope.digest(); + expect(logger).toEqual([4, 6]); + context['b'] = 5; + rootScope.digest(); + expect(logger).toEqual([4, 6, 9]); + })); + + + it('should watch literals', inject((Logger logger, Map context, RootScope rootScope) { + context['a'] = 1; + rootScope.watch('1', (value, previous) => logger(value)); + rootScope.watch('"str"', (value, previous) => logger(value)); + rootScope.watch('[a, 2, 3]', (value, previous) => logger(value)); + rootScope.watch('{a:a, b:2}', (value, previous) => logger(value)); + rootScope.digest(); + expect(logger).toEqual([1, 'str', [1, 2, 3], {'a': 1, 'b': 2}]); + logger.clear(); + context['a'] = 3; + rootScope.digest(); + expect(logger).toEqual([[3, 2, 3], {'a': 3, 'b': 2}]); + })); + + it('should invoke closures', inject((Logger logger, Map context, RootScope rootScope) { + context['fn'] = () { + logger('fn'); + return 1; + }; + context['a'] = {'fn': () { + logger('a.fn'); + return 2; + }}; + rootScope.watch('fn()', (value, previous) => logger('=> $value')); + rootScope.watch('a.fn()', (value, previous) => logger('-> $value')); + rootScope.digest(); + expect(logger).toEqual(['fn', 'a.fn', '=> 1', '-> 2', + /* second loop*/ 'fn', 'a.fn']); + logger.clear(); + rootScope.digest(); + expect(logger).toEqual(['fn', 'a.fn']); + })); + + it('should perform conditionals', inject((Logger logger, Map context, RootScope rootScope) { + context['a'] = 1; + context['b'] = 2; + context['c'] = 3; + rootScope.watch('a?b:c', (value, previous) => logger(value)); + rootScope.digest(); + expect(logger).toEqual([2]); + logger.clear(); + context['a'] = 0; + rootScope.digest(); + expect(logger).toEqual([3]); + })); + + + xit('should call function', inject((Logger logger, Map context, RootScope rootScope) { + context['a'] = () { + return () { return 123; }; + }; + rootScope.watch('a()()', (value, previous) => logger(value)); + rootScope.digest(); + expect(logger).toEqual([123]); + logger.clear(); + rootScope.digest(); + expect(logger).toEqual([]); + })); + + it('should access bracket', inject((Logger logger, Map context, RootScope rootScope) { + context['a'] = {'b': 123}; + rootScope.watch('a["b"]', (value, previous) => logger(value)); + rootScope.digest(); + expect(logger).toEqual([123]); + logger.clear(); + rootScope.digest(); + expect(logger).toEqual([]); + })); + + + it('should prefix', inject((Logger logger, Map context, RootScope rootScope) { + context['a'] = true; + rootScope.watch('!a', (value, previous) => logger(value)); + rootScope.digest(); + expect(logger).toEqual([false]); + logger.clear(); + context['a'] = false; + rootScope.digest(); + expect(logger).toEqual([true]); + })); + + it('should support filters', inject((Logger logger, Map context, + RootScope rootScope, AstParser parser, + FilterMap filters) { + context['a'] = 123; + context['b'] = 2; + rootScope.watch( + parser('a | multiply:b', filters: filters), + (value, previous) => logger(value)); + rootScope.digest(); + expect(logger).toEqual([246]); + logger.clear(); + rootScope.digest(); + expect(logger).toEqual([]); + logger.clear(); + })); + + it('should support arrays in filters', inject((Logger logger, Map context, + RootScope rootScope, + AstParser parser, + FilterMap filters) { + context['a'] = [1]; + rootScope.watch( + parser('a | sort | listHead:"A" | listTail:"B"', filters: filters), + (value, previous) => logger(value)); + rootScope.digest(); + expect(logger).toEqual(['sort', 'listHead', 'listTail', ['A', 1, 'B']]); + logger.clear(); + + rootScope.digest(); + expect(logger).toEqual([]); + logger.clear(); + + context['a'].add(2); + rootScope.digest(); + expect(logger).toEqual(['sort', 'listHead', 'listTail', ['A', 1, 2, 'B']]); + logger.clear(); + + // We change the order, but sort should change it to same one and it should not + // call subsequent filters. + context['a'] = [2, 1]; + rootScope.digest(); + expect(logger).toEqual(['sort']); + logger.clear(); + })); + }); + + describe('properties', () { + describe('root', () { + it('should point to itself', inject((RootScope rootScope) { + expect(rootScope.rootScope).toEqual(rootScope); + })); + + it('children should point to root', inject((RootScope rootScope) { + var child = rootScope.createChild(); + expect(child.rootScope).toEqual(rootScope); + expect(child.createChild().rootScope).toEqual(rootScope); + })); + }); + + + describe('parent', () { + it('should not have parent', inject((RootScope rootScope) { + expect(rootScope.parentScope).toEqual(null); + })); + + + it('should point to parent', inject((RootScope rootScope) { + var child = rootScope.createChild(); + expect(rootScope.parentScope).toEqual(null); + expect(child.parentScope).toEqual(rootScope); + expect(child.createChild().parentScope).toEqual(child); + })); + }); + }); + + describe(r'events', () { + + describe('on', () { + it(r'should add listener for both emit and broadcast events', inject((RootScope rootScope) { + var log = '', + child = rootScope.createChild(); + + eventFn(event) { + expect(event).not.toEqual(null); + log += 'X'; + } + + child.on('abc').listen(eventFn); + expect(log).toEqual(''); + + child.emit('abc'); + expect(log).toEqual('X'); + + child.broadcast('abc'); + expect(log).toEqual('XX'); + })); + + + it(r'should return a function that deregisters the listener', inject((RootScope rootScope) { + var log = ''; + var child = rootScope.createChild(); + var subscription; + + eventFn(e) { + log += 'X'; + } + + subscription = child.on('abc').listen(eventFn); + expect(log).toEqual(''); + expect(subscription).toBeDefined(); + + child.emit(r'abc'); + child.broadcast('abc'); + expect(log).toEqual('XX'); + + log = ''; + expect(subscription.cancel()).toBe(null); + child.emit(r'abc'); + child.broadcast('abc'); + expect(log).toEqual(''); + })); + }); + + + describe('emit', () { + var log, child, grandChild, greatGrandChild; + + logger(event) { + log.add(event.currentScope.context['id']); + } + + beforeEach(module(() { + return (RootScope rootScope) { + log = []; + child = rootScope.createChild({'id': 1}); + grandChild = child.createChild({'id': 2}); + greatGrandChild = grandChild.createChild({'id': 3}); + + rootScope.context['id'] = 0; + + rootScope.on('myEvent').listen(logger); + child.on('myEvent').listen(logger); + grandChild.on('myEvent').listen(logger); + greatGrandChild.on('myEvent').listen(logger); + }; + })); + + it(r'should bubble event up to the root scope', inject((RootScope rootScope) { + grandChild.emit(r'myEvent'); + expect(log.join('>')).toEqual('2>1>0'); + })); + + + it(r'should dispatch exceptions to the exceptionHandler', () { + module((Module module) { + module.type(ExceptionHandler, implementedBy: LoggingExceptionHandler); + }); + inject((ExceptionHandler e) { + LoggingExceptionHandler exceptionHandler = e; + child.on('myEvent').listen((e) { throw 'bubbleException'; }); + grandChild.emit(r'myEvent'); + expect(log.join('>')).toEqual('2>1>0'); + expect(exceptionHandler.errors[0].error).toEqual('bubbleException'); + }); + }); + + + it(r'should allow stopping event propagation', inject((RootScope rootScope) { + child.on('myEvent').listen((event) { event.stopPropagation(); }); + grandChild.emit(r'myEvent'); + expect(log.join('>')).toEqual('2>1'); + })); + + + it(r'should forward method arguments', inject((RootScope rootScope) { + var eventName; + var eventData; + child.on('abc').listen((event) { + eventName = event.name; + eventData = event.data; + }); + child.emit('abc', ['arg1', 'arg2']); + expect(eventName).toEqual('abc'); + expect(eventData).toEqual(['arg1', 'arg2']); + })); + + + describe(r'event object', () { + it(r'should have methods/properties', inject((RootScope rootScope) { + var event; + child.on('myEvent').listen((e) { + expect(e.targetScope).toBe(grandChild); + expect(e.currentScope).toBe(child); + expect(e.name).toBe('myEvent'); + event = e; + }); + grandChild.emit(r'myEvent'); + expect(event).toBeDefined(); + })); + + + it(r'should have preventDefault method and defaultPrevented property', inject((RootScope rootScope) { + var event = grandChild.emit(r'myEvent'); + expect(event.defaultPrevented).toBe(false); + + child.on('myEvent').listen((event) { + event.preventDefault(); + }); + event = grandChild.emit(r'myEvent'); + expect(event.defaultPrevented).toBe(true); + })); + }); + }); + + + describe('broadcast', () { + describe(r'event propagation', () { + var log, child1, child2, child3, grandChild11, grandChild21, grandChild22, grandChild23, + greatGrandChild211; + + logger(event) { + log.add(event.currentScope.context['id']); + } + + beforeEach(inject((RootScope rootScope) { + log = []; + child1 = rootScope.createChild({}); + child2 = rootScope.createChild({}); + child3 = rootScope.createChild({}); + grandChild11 = child1.createChild({}); + grandChild21 = child2.createChild({}); + grandChild22 = child2.createChild({}); + grandChild23 = child2.createChild({}); + greatGrandChild211 = grandChild21.createChild({}); + + rootScope.context['id'] = 0; + child1.context['id'] = 1; + child2.context['id'] = 2; + child3.context['id'] = 3; + grandChild11.context['id'] = 11; + grandChild21.context['id'] = 21; + grandChild22.context['id'] = 22; + grandChild23.context['id'] = 23; + greatGrandChild211.context['id'] = 211; + + rootScope.on('myEvent').listen(logger); + child1.on('myEvent').listen(logger); + child2.on('myEvent').listen(logger); + child3.on('myEvent').listen(logger); + grandChild11.on('myEvent').listen(logger); + grandChild21.on('myEvent').listen(logger); + grandChild22.on('myEvent').listen(logger); + grandChild23.on('myEvent').listen(logger); + greatGrandChild211.on('myEvent').listen(logger); + + // R + // / | \ + // 1 2 3 + // / / | \ + // 11 21 22 23 + // | + // 211 + })); + + + it(r'should broadcast an event from the root scope', inject((RootScope rootScope) { + rootScope.broadcast('myEvent'); + expect(log.join('>')).toEqual('0>1>11>2>21>211>22>23>3'); + })); + + + it(r'should broadcast an event from a child scope', inject((RootScope rootScope) { + child2.broadcast('myEvent'); + expect(log.join('>')).toEqual('2>21>211>22>23'); + })); + + + it(r'should broadcast an event from a leaf scope with a sibling', inject((RootScope rootScope) { + grandChild22.broadcast('myEvent'); + expect(log.join('>')).toEqual('22'); + })); + + + it(r'should broadcast an event from a leaf scope without a sibling', inject((RootScope rootScope) { + grandChild23.broadcast('myEvent'); + expect(log.join('>')).toEqual('23'); + })); + + + it(r'should not not fire any listeners for other events', inject((RootScope rootScope) { + rootScope.broadcast('fooEvent'); + expect(log.join('>')).toEqual(''); + })); + + + it(r'should return event object', inject((RootScope rootScope) { + var result = child1.broadcast('some'); + + expect(result).toBeDefined(); + expect(result.name).toBe('some'); + expect(result.targetScope).toBe(child1); + })); + }); + + + describe(r'listener', () { + it(r'should receive event object', inject((RootScope rootScope) { + var scope = rootScope, + child = scope.createChild({}), + event; + + child.on('fooEvent').listen((e) { + event = e; + }); + scope.broadcast('fooEvent'); + + expect(event.name).toBe('fooEvent'); + expect(event.targetScope).toBe(scope); + expect(event.currentScope).toBe(child); + })); + + it(r'should support passing messages as varargs', inject((RootScope rootScope) { + var scope = rootScope, + child = scope.createChild({}), + args; + + child.on('fooEvent').listen((e) { + args = e.data; + }); + scope.broadcast('fooEvent', ['do', 're', 'me', 'fa']); + + expect(args.length).toBe(4); + expect(args).toEqual(['do', 're', 'me', 'fa']); + })); + }); + }); + }); + + describe(r'$destroy', () { + var first = null, middle = null, last = null, log = null; + + beforeEach(inject((RootScope rootScope) { + log = ''; + + first = rootScope.createChild({"check": (n) { log+= '$n'; return n;}}); + middle = rootScope.createChild({"check": (n) { log+= '$n'; return n;}}); + last = rootScope.createChild({"check": (n) { log+= '$n'; return n;}}); + + first.watch('check(1)', (v, l) {}); + middle.watch('check(2)', (v, l) {}); + last.watch('check(3)', (v, l) {}); + + first.on(ScopeEvent.DESTROY).listen((e) { log += 'destroy:first;'; }); + + rootScope.digest(); + log = ''; + })); + + + it(r'should ignore remove on root', inject((RootScope rootScope) { + rootScope.destroy(); + rootScope.digest(); + expect(log).toEqual('123'); + })); + + + it(r'should remove first', inject((RootScope rootScope) { + first.destroy(); + rootScope.digest(); + expect(log).toEqual('destroy:first;23'); + })); + + + it(r'should remove middle', inject((RootScope rootScope) { + middle.destroy(); + rootScope.digest(); + expect(log).toEqual('13'); + })); + + + it(r'should remove last', inject((RootScope rootScope) { + last.destroy(); + rootScope.digest(); + expect(log).toEqual('12'); + })); + + + it(r'should broadcast the $destroy event', inject((RootScope rootScope) { + var log = []; + first.on(ScopeEvent.DESTROY).listen((s) => log.add('first')); + first.createChild({}).on(ScopeEvent.DESTROY).listen((s) => log.add('first-child')); + + first.destroy(); + expect(log).toEqual(['first', 'first-child']); + })); + }); + + describe('digest lifecycle', () { + it(r'should apply expression with full lifecycle', inject((RootScope rootScope) { + var log = ''; + var child = rootScope.createChild({"parent": rootScope.context}); + rootScope.watch('a', (a, _) { log += '1'; }); + child.apply('parent.a = 0'); + expect(log).toEqual('1'); + })); + + + it(r'should catch exceptions', () { + module((Module module) => module.type(ExceptionHandler, implementedBy: LoggingExceptionHandler)); + inject((RootScope rootScope, ExceptionHandler e) { + LoggingExceptionHandler $exceptionHandler = e; + var log = []; + var child = rootScope.createChild({}); + rootScope.watch('a', (a, _) => log.add('1')); + rootScope.context['a'] = 0; + child.apply(() { throw 'MyError'; }); + expect(log.join(',')).toEqual('1'); + expect($exceptionHandler.errors[0].error).toEqual('MyError'); + $exceptionHandler.errors.removeAt(0); + $exceptionHandler.assertEmpty(); + }); + }); + + + describe(r'exceptions', () { + var log; + beforeEach(module((Module module) { + return module.type(ExceptionHandler, implementedBy: LoggingExceptionHandler); + })); + beforeEach(inject((RootScope rootScope) { + rootScope.context['log'] = () { log += 'digest;'; return null; }; + log = ''; + rootScope.watch('log()', (v, o) => null); + rootScope.digest(); + log = ''; + })); + + + it(r'should execute and return value and update', inject( + (RootScope rootScope, ExceptionHandler e) { + LoggingExceptionHandler $exceptionHandler = e; + rootScope.context['name'] = 'abc'; + expect(rootScope.apply((context) => context['name'])).toEqual('abc'); + expect(log).toEqual('digest;digest;'); + $exceptionHandler.assertEmpty(); + })); + + + it(r'should execute and return value and update', inject((RootScope rootScope) { + rootScope.context['name'] = 'abc'; + expect(rootScope.apply('name', {'name': 123})).toEqual(123); + })); + + + it(r'should catch exception and update', inject((RootScope rootScope, ExceptionHandler e) { + LoggingExceptionHandler $exceptionHandler = e; + var error = 'MyError'; + rootScope.apply(() { throw error; }); + expect(log).toEqual('digest;digest;'); + expect($exceptionHandler.errors[0].error).toEqual(error); + })); + }); + + it(r'should proprely reset phase on exception', inject((RootScope rootScope) { + var error = 'MyError'; + expect(() => rootScope.apply(() { throw error; })).toThrow(error); + expect(() => rootScope.apply(() { throw error; })).toThrow(error); + })); + }); + + + describe('flush lifecycle', () { + it(r'should apply expression with full lifecycle', inject((RootScope rootScope) { + var log = ''; + var child = rootScope.createChild({"parent": rootScope.context}); + rootScope.observe('a', (a, _) { log += '1'; }); + child.apply('parent.a = 0'); + expect(log).toEqual('1'); + })); + + + it(r'should schedule domWrites and domReads', inject((RootScope rootScope) { + var log = ''; + var child = rootScope.createChild({"parent": rootScope.context}); + rootScope.observe('a', (a, _) { log += '1'; }); + child.apply('parent.a = 0'); + expect(log).toEqual('1'); + })); + + + it(r'should catch exceptions', () { + module((Module module) => module.type(ExceptionHandler, implementedBy: LoggingExceptionHandler)); + inject((RootScope rootScope, ExceptionHandler e) { + LoggingExceptionHandler $exceptionHandler = e; + var log = []; + var child = rootScope.createChild({}); + rootScope.observe('a', (a, _) => log.add('1')); + rootScope.context['a'] = 0; + child.apply(() { throw 'MyError'; }); + expect(log.join(',')).toEqual('1'); + expect($exceptionHandler.errors[0].error).toEqual('MyError'); + $exceptionHandler.errors.removeAt(0); + $exceptionHandler.assertEmpty(); + }); + }); + + + describe(r'exceptions', () { + var log; + beforeEach(module((Module module) { + return module.type(ExceptionHandler, implementedBy: LoggingExceptionHandler); + })); + beforeEach(inject((RootScope rootScope) { + rootScope.context['log'] = () { log += 'digest;'; return null; }; + log = ''; + rootScope.observe('log()', (v, o) => null); + rootScope.digest(); + log = ''; + })); + + + it(r'should execute and return value and update', inject( + (RootScope rootScope, ExceptionHandler e) { + LoggingExceptionHandler $exceptionHandler = e; + rootScope.context['name'] = 'abc'; + expect(rootScope.apply((context) => context['name'])).toEqual('abc'); + expect(log).toEqual('digest;digest;'); + $exceptionHandler.assertEmpty(); + })); + + it(r'should execute and return value and update', inject((RootScope rootScope) { + rootScope.context['name'] = 'abc'; + expect(rootScope.apply('name', {'name': 123})).toEqual(123); + })); + + it(r'should catch exception and update', inject((RootScope rootScope, ExceptionHandler e) { + LoggingExceptionHandler $exceptionHandler = e; + var error = 'MyError'; + rootScope.apply(() { throw error; }); + expect(log).toEqual('digest;digest;'); + expect($exceptionHandler.errors[0].error).toEqual(error); + })); + + it(r'should throw assertion when model changes in flush', inject((RootScope rootScope, Logger log) { + var retValue = 1; + rootScope.context['logger'] = (name) { log(name); return retValue; }; + + rootScope.watch('logger("watch")', (n, v) => null); + rootScope.observe('logger("flush")', (n, v) => null); + + // clear watches + rootScope.digest(); + log.clear(); + + rootScope.flush(); + expect(log).toEqual(['flush', /*assertion*/ 'watch', 'flush']); + + retValue = 2; + expect(rootScope.flush). + toThrow('Observer reaction functions should not change model. \n' + 'These watch changes were detected: logger("watch")\n' + 'These observe changes were detected: '); + })); + }); + + }); + + + describe('ScopeLocals', () { + it('should read from locals', inject((RootScope scope) { + scope.context['a'] = 'XXX'; + scope.context['c'] = 'C'; + var scopeLocal = new ScopeLocals(scope.context, {'a': 'A', 'b': 'B'}); + expect(scopeLocal['a']).toEqual('A'); + expect(scopeLocal['b']).toEqual('B'); + expect(scopeLocal['c']).toEqual('C'); + })); + + it('should write to Scope', inject((RootScope scope) { + scope.context['a'] = 'XXX'; + scope.context['c'] = 'C'; + var scopeLocal = new ScopeLocals(scope.context, {'a': 'A', 'b': 'B'}); + + scopeLocal['a'] = 'aW'; + scopeLocal['b'] = 'bW'; + scopeLocal['c'] = 'cW'; + + expect(scope.context['a']).toEqual('aW'); + expect(scope.context['b']).toEqual('bW'); + expect(scope.context['c']).toEqual('cW'); + + expect(scopeLocal['a']).toEqual('A'); + expect(scopeLocal['b']).toEqual('B'); + expect(scopeLocal['c']).toEqual('cW'); + })); + }); + + + describe(r'watch/digest', () { + it(r'should watch and fire on simple property change', inject((RootScope rootScope) { + var log; + + rootScope.watch('name', (a, b) { + log = [a, b]; + }); + rootScope.digest(); + log = null; + + expect(log).toEqual(null); + rootScope.digest(); + expect(log).toEqual(null); + rootScope.context['name'] = 'misko'; + rootScope.digest(); + expect(log).toEqual(['misko', null]); + })); + + + it(r'should watch and fire on expression change', inject((RootScope rootScope) { + var log; + + rootScope.watch('name.first', (a, b) => log = [a, b]); + rootScope.digest(); + log = null; + + rootScope.context['name'] = {}; + expect(log).toEqual(null); + rootScope.digest(); + expect(log).toEqual(null); + rootScope.context['name']['first'] = 'misko'; + rootScope.digest(); + expect(log).toEqual(['misko', null]); + })); + + + it(r'should delegate exceptions', () { + module((Module module) { + module.type(ExceptionHandler, implementedBy: LoggingExceptionHandler); + }); + inject((RootScope rootScope, ExceptionHandler e) { + LoggingExceptionHandler $exceptionHandler = e; + rootScope.watch('a', (n, o) {throw 'abc';}); + rootScope.context['a'] = 1; + rootScope.digest(); + expect($exceptionHandler.errors.length).toEqual(1); + expect($exceptionHandler.errors[0].error).toEqual('abc'); + }); + }); + + + it(r'should fire watches in order of addition', inject((RootScope rootScope) { + // this is not an external guarantee, just our own sanity + var log = ''; + rootScope.watch('a', (a, b) { log += 'a'; }); + rootScope.watch('b', (a, b) { log += 'b'; }); + rootScope.watch('c', (a, b) { log += 'c'; }); + rootScope.context['a'] = rootScope.context['b'] = rootScope.context['c'] = 1; + rootScope.digest(); + expect(log).toEqual('abc'); + })); + + + it(r'should call child watchers in addition order', inject((RootScope rootScope) { + // this is not an external guarantee, just our own sanity + var log = ''; + var childA = rootScope.createChild({}); + var childB = rootScope.createChild({}); + var childC = rootScope.createChild({}); + childA.watch('a', (a, b) { log += 'a'; }); + childB.watch('b', (a, b) { log += 'b'; }); + childC.watch('c', (a, b) { log += 'c'; }); + childA.context['a'] = childB.context['b'] = childC.context['c'] = 1; + rootScope.digest(); + expect(log).toEqual('abc'); + })); + + + it(r'should run digest multiple times', inject( + (RootScope rootScope) { + // tests a traversal edge case which we originally missed + var log = []; + var childA = rootScope.createChild({'log': log}); + var childB = rootScope.createChild({'log': log}); + + rootScope.context['log'] = log; + + rootScope.watch("log.add('r')", (_, __) => null); + childA.watch("log.add('a')", (_, __) => null); + childB.watch("log.add('b')", (_, __) => null); + + // init + rootScope.digest(); + expect(log.join('')).toEqual('rabrab'); + })); + + + it(r'should repeat watch cycle while model changes are identified', inject((RootScope rootScope) { + var log = ''; + rootScope.watch('c', (v, b) {rootScope.context['d'] = v; log+='c'; }); + rootScope.watch('b', (v, b) {rootScope.context['c'] = v; log+='b'; }); + rootScope.watch('a', (v, b) {rootScope.context['b'] = v; log+='a'; }); + rootScope.digest(); + log = ''; + rootScope.context['a'] = 1; + rootScope.digest(); + expect(rootScope.context['b']).toEqual(1); + expect(rootScope.context['c']).toEqual(1); + expect(rootScope.context['d']).toEqual(1); + expect(log).toEqual('abc'); + })); + + + it(r'should repeat watch cycle from the root element', inject((RootScope rootScope) { + var log = []; + rootScope.context['log'] = log; + var child = rootScope.createChild({'log':log}); + rootScope.watch("log.add('a')", (_, __) => null); + child.watch("log.add('b')", (_, __) => null); + rootScope.digest(); + expect(log.join('')).toEqual('abab'); + })); + + + it(r'should not fire upon watch registration on initial digest', inject((RootScope rootScope) { + var log = ''; + rootScope.context['a'] = 1; + rootScope.watch('a', (a, b) { log += 'a'; }); + rootScope.watch('b', (a, b) { log += 'b'; }); + rootScope.digest(); + log = ''; + rootScope.digest(); + expect(log).toEqual(''); + })); + + + it(r'should prevent digest recursion', inject((RootScope rootScope) { + var callCount = 0; + rootScope.watch('name', (a, b) { + expect(() { + rootScope.digest(); + }).toThrow(r'digest already in progress'); + callCount++; + }); + rootScope.context['name'] = 'a'; + rootScope.digest(); + expect(callCount).toEqual(1); + })); + + + it(r'should return a function that allows listeners to be unregistered', inject( + (RootScope rootScope) { + var listener = jasmine.createSpy('watch listener'); + var watch; + + watch = rootScope.watch('foo', listener); + rootScope.digest(); //init + expect(listener).toHaveBeenCalled(); + expect(watch).toBeDefined(); + + listener.reset(); + rootScope.context['foo'] = 'bar'; + rootScope.digest(); //triger + expect(listener).toHaveBeenCalledOnce(); + + listener.reset(); + rootScope.context['foo'] = 'baz'; + watch.remove(); + rootScope.digest(); //trigger + expect(listener).not.toHaveBeenCalled(); + })); + + + it(r'should not infinitely digest when current value is NaN', inject((RootScope rootScope) { + rootScope.context['nan'] = double.NAN; + rootScope.watch('nan', (_, __) => null); + + expect(() { + rootScope.digest(); + }).not.toThrow(); + })); + + + it(r'should prevent infinite digest and should log firing expressions', inject((RootScope rootScope) { + rootScope.context['a'] = 0; + rootScope.context['b'] = 0; + rootScope.watch('a', (a, __) => rootScope.context['a'] = a + 1); + rootScope.watch('b', (b, __) => rootScope.context['b'] = b + 1); + + expect(() { + rootScope.digest(); + }).toThrow('Model did not stabilize in 5 digests. ' + 'Last 3 iterations:\n' + 'a, b\n' + 'a, b\n' + 'a, b'); + })); + + + it(r'should always call the watchr with newVal and oldVal equal on the first run', + inject((RootScope rootScope) { + var log = []; + var logger = (newVal, oldVal) { + var val = (newVal == oldVal || (newVal != oldVal && oldVal != newVal)) ? newVal : 'xxx'; + log.add(val); + }; + + rootScope.context['nanValue'] = double.NAN; + rootScope.context['nullValue'] = null; + rootScope.context['emptyString'] = ''; + rootScope.context['falseValue'] = false; + rootScope.context['numberValue'] = 23; + + rootScope.watch('nanValue', logger); + rootScope.watch('nullValue', logger); + rootScope.watch('emptyString', logger); + rootScope.watch('falseValue', logger); + rootScope.watch('numberValue', logger); + + rootScope.digest(); + expect(log.removeAt(0).isNaN).toEqual(true); //jasmine's toBe and toEqual don't work well with NaNs + expect(log).toEqual([null, '', false, 23]); + log = []; + rootScope.digest(); + expect(log).toEqual([]); + })); + }); + + + describe('runAsync', () { + it(r'should run callback before watch', inject((RootScope rootScope) { + var log = ''; + rootScope.runAsync(() { log += 'parent.async;'; }); + rootScope.watch('value', (_, __) { log += 'parent.digest;'; }); + rootScope.digest(); + expect(log).toEqual('parent.async;parent.digest;'); + })); + + it(r'should cause a $digest rerun', inject((RootScope rootScope) { + rootScope.context['log'] = ''; + rootScope.context['value'] = 0; + // NOTE(deboer): watch listener string functions not yet supported + //rootScope.watch('value', 'log = log + ".";'); + rootScope.watch('value', (_, __) { rootScope.context['log'] += "."; }); + rootScope.watch('init', (_, __) { + rootScope.runAsync(() => rootScope.eval('value = 123; log = log + "=" ')); + expect(rootScope.context['value']).toEqual(0); + }); + rootScope.digest(); + expect(rootScope.context['log']).toEqual('.=.'); + })); + + it(r'should run async in the same order as added', inject((RootScope rootScope) { + rootScope.context['log'] = ''; + rootScope.runAsync(() => rootScope.eval("log = log + 1")); + rootScope.runAsync(() => rootScope.eval("log = log + 2")); + rootScope.digest(); + expect(rootScope.context['log']).toEqual('12'); + })); + }); + + + + describe('domRead/domWrite', () { + it(r'should run writes before reads', () { + module((Module module) { + module.type(ExceptionHandler, implementedBy: LoggingExceptionHandler); + }); + inject((RootScope rootScope, Logger logger, ExceptionHandler e) { + LoggingExceptionHandler exceptionHandler = e as LoggingExceptionHandler; + rootScope.domWrite(() { + logger('write1'); + rootScope.domWrite(() => logger('write2')); + throw 'write1'; + }); + rootScope.domRead(() { + logger('read1'); + rootScope.domRead(() => logger('read2')); + rootScope.domWrite(() => logger('write3')); + throw 'read1'; + }); + rootScope.observe('value', (_, __) => logger('observe')); + rootScope.flush(); + expect(logger).toEqual(['write1', 'write2', 'observe', 'read1', 'read2', 'write3']); + expect(exceptionHandler.errors.length).toEqual(2); + expect(exceptionHandler.errors[0].error).toEqual('write1'); + expect(exceptionHandler.errors[1].error).toEqual('read1'); + }); + }); + }); +}); + +@NgFilter(name: 'multiply') +class _MultiplyFilter { + call(a, b) => a * b; +} + +@NgFilter(name: 'listHead') +class _ListHeadFilter { + Logger logger; + _ListHeadFilter(Logger this.logger); + call(list, head) { + logger('listHead'); + return [head]..addAll(list); + } +} + + +@NgFilter(name: 'listTail') +class _ListTailFilter { + Logger logger; + _ListTailFilter(Logger this.logger); + call(list, tail) { + logger('listTail'); + return new List.from(list)..add(tail); + } +} + +@NgFilter(name: 'sort') +class _SortFilter { + Logger logger; + _SortFilter(Logger this.logger); + call(list) { + logger('sort'); + return new List.from(list)..sort(); + } +}