diff --git a/lib/application_factory.dart b/lib/application_factory.dart index 351ded2fb..b6737142e 100644 --- a/lib/application_factory.dart +++ b/lib/application_factory.dart @@ -12,8 +12,8 @@ library angular.app.factory; import 'package:angular/angular.dart'; import 'package:angular/core/registry.dart'; import 'package:angular/core/parser/parser.dart' show ClosureMap; -import 'package:angular/change_detection/change_detection.dart'; -import 'package:angular/change_detection/dirty_checking_change_detector_dynamic.dart'; +import 'package:angular/change_detector/change_detector.dart'; +import 'package:angular/change_detector/field_getter_factory_dynamic.dart'; import 'package:angular/core/registry_dynamic.dart'; import 'package:angular/core/parser/dynamic_closure_map.dart'; import 'package:angular/core_dom/type_to_uri_mapper.dart'; diff --git a/lib/application_factory_static.dart b/lib/application_factory_static.dart index f41783f3e..c572a1a1b 100644 --- a/lib/application_factory_static.dart +++ b/lib/application_factory_static.dart @@ -30,10 +30,10 @@ import 'package:angular/core/parser/parser.dart'; import 'package:angular/core/parser/static_closure_map.dart'; import 'package:angular/core_dom/type_to_uri_mapper.dart'; import 'package:angular/core/registry_static.dart'; -import 'package:angular/change_detection/change_detection.dart'; -import 'package:angular/change_detection/dirty_checking_change_detector_static.dart'; +import 'package:angular/change_detector/change_detector.dart'; +import 'package:angular/change_detector/field_getter_factory_static.dart'; -export 'package:angular/change_detection/change_detection.dart' show +export 'package:angular/change_detector/change_detector.dart' show FieldGetter, FieldSetter; diff --git a/lib/change_detection/change_detection.dart b/lib/change_detection/change_detection.dart deleted file mode 100644 index 5919f1cc2..000000000 --- a/lib/change_detection/change_detection.dart +++ /dev/null @@ -1,195 +0,0 @@ -library change_detection; - -typedef void EvalExceptionHandler(error, stack); - -/** - * An interface for [ChangeDetectorGroup] groups related watches together. It - * guarantees that within the group all watches will be reported in the order in - * which they were registered. It also provides an efficient way of removing the - * watch group. - */ -abstract class ChangeDetectorGroup { - /** - * Watch a specific [field] on an [object]. - * - * If the [field] is: - * * _name_ - Name of the property to watch. (If the [object] is a Map then - * treat the name as a key.) - * * _null_ - Watch all the items for arrays and maps otherwise the object - * identity. - * - * Parameters: - * * [object] to watch. - * * [field] to watch on the [object]. - * * [handler] an opaque object passed on to [Record]. - */ - WatchRecord watch(Object object, String field, H handler); - - /// Remove all the watches in an efficient manner. - void remove(); - - /// Create a child [ChangeDetectorGroup] - ChangeDetectorGroup newGroup(); -} - -/** - * An interface for [ChangeDetector]. An application can have multiple instances - * of the [ChangeDetector] to be used for checking different application - * domains. - * - * [ChangeDetector] works by comparing the identity of the objects not by - * calling the `.equals()` method. This is because ChangeDetector needs to have - * predictable performance, and the developer can implement `.equals()` on top - * of identity checks. - * - * [H] A [Record] has associated handler object. The handler object is opaque - * to the [ChangeDetector] but it is meaningful to the code which registered the - * watcher. It can be a data structure, an object, or a function. It is up to - * the developer to attach meaning to it. - */ -abstract class ChangeDetector extends ChangeDetectorGroup { - /** - * This method does the work of collecting the changes and returns them as a - * linked list of [Record]s. The [Record]s are returned in the - * same order as they were registered. - */ - Iterator> collectChanges({EvalExceptionHandler exceptionHandler, - AvgStopwatch stopwatch }); -} - -abstract class Record { - /** The observed object. */ - Object get object; - - /** - * The field which is being watched: - * * _name_ - Name of the field to watch. - * * _null_ - Watch all the items for arrays and maps otherwise the object - * identity. - */ - String get field; - - /** - * An application provided object which contains the specific logic which - * needs to be applied when the change is detected. The handler is opaque to - * the ChangeDetector and as such can be anything the application desires. - */ - H get handler; - - /** - * * The current value of the [field] on the [object], - * * a [CollectionChangeRecord] if an iterable is observed, - * * a [MapChangeRecord] if a map is observed. - */ - get currentValue; - /** - * * Previous value of the [field] on the [object], - * * [:null:] when an iterable or a map are observed. - */ - get previousValue; -} - -/** - * [WatchRecord] API which allows changing what object is being watched and - * manually triggering the checking. - */ -abstract class WatchRecord extends Record { - /// Set a new object for checking - set object(value); - - /// Returns [:true:] when changes have been detected - bool check(); - - void remove(); -} - -/** - * If the [ChangeDetector] is watching a [Map] then the [currentValue] of - * [Record] will contain an instance of [MapChangeRecord]. A [MapChangeRecord] - * contains the changes to the map since the last execution. The changes are - * reported as a list of [MapKeyValue]s which contain the key as well as its - * current and previous value. - */ -abstract class MapChangeRecord { - /// The underlying map object - Map get map; - - void forEachItem(void f(MapKeyValue item)); - void forEachPreviousItem(void f(MapKeyValue previousItem)); - void forEachChange(void f(MapKeyValue change)); - void forEachAddition(void f(MapKeyValue addition)); - void forEachRemoval(void f(MapKeyValue removal)); -} - -/** - * Each item in map is wrapped in [MapKeyValue], which can track - * the [item]s [currentValue] and [previousValue] location. - */ -abstract class MapKeyValue { - /// The item. - K get key; - - /// Previous item location in the list or [null] if addition. - V get previousValue; - - /// Current item location in the list or [null] if removal. - V get currentValue; -} - -/** - * If the [ChangeDetector] is watching an [Iterable] then the [currentValue] of - * [Record] will contain an instance of [CollectionChangeRecord]. The - * [CollectionChangeRecord] contains the changes to the collection since the - * last execution. The changes are reported as a list of [CollectionChangeItem]s - * which contain the item as well as its current and previous index. - */ -abstract class CollectionChangeRecord { - /** The underlying iterable object */ - Iterable get iterable; - int get length; - - void forEachItem(void f(CollectionChangeItem item)); - void forEachPreviousItem(void f(CollectionChangeItem previousItem)); - void forEachAddition(void f(CollectionChangeItem addition)); - void forEachMove(void f(CollectionChangeItem move)); - void forEachRemoval(void f(CollectionChangeItem removal)); -} - -/** - * Each changed item in the collection is wrapped in a [CollectionChangeItem], - * which tracks the [item]s [currentKey] and [previousKey] location. - */ -abstract class CollectionChangeItem { - /** Previous item location in the list or [:null:] if addition. */ - int get previousIndex; - - /** Current item location in the list or [:null:] if removal. */ - int get currentIndex; - - /** The item. */ - V get item; -} - -typedef dynamic FieldGetter(object); -typedef void FieldSetter(object, value); - -abstract class FieldGetterFactory { - FieldGetter getter(Object object, String name); -} - -class AvgStopwatch extends Stopwatch { - int _count = 0; - - int get count => _count; - - void reset() { - _count = 0; - super.reset(); - } - - int increment(int count) => _count += count; - - double get ratePerMs => elapsedMicroseconds == 0 - ? 0.0 - : _count / elapsedMicroseconds * 1000; -} diff --git a/lib/change_detection/dirty_checking_change_detector.dart b/lib/change_detection/dirty_checking_change_detector.dart deleted file mode 100644 index 0fec28340..000000000 --- a/lib/change_detection/dirty_checking_change_detector.dart +++ /dev/null @@ -1,1447 +0,0 @@ -library dirty_checking_change_detector; - -import 'dart:collection'; -import 'package:angular/change_detection/change_detection.dart'; - -/** - * [DirtyCheckingChangeDetector] determines which object properties have changed - * by comparing them to the their previous value. - * - * GOALS: - * - Plugable implementation, replaceable with other technologies, such as - * Object.observe(). - * - SPEED this needs to be as fast as possible. - * - No GC pressure. Since change detection runs often it should perform no - * memory allocations. - * - The changes need to be delivered in a single data-structure at once. - * There are two reasons for this: - * 1. It should be easy to measure the cost of change detection vs - * processing. - * 2. The feature may move to VM for performance reason. The VM should be - * free to implement it in any way. The only requirement is that the - * list of changes need to be delivered. - * - * [DirtyCheckingRecord] - * - * Each property to be watched is recorded as a [DirtyCheckingRecord] and kept - * in a linked list. Linked list are faster than Arrays for iteration. They also - * allow removal of large blocks of watches in an efficient manner. - */ -class DirtyCheckingChangeDetectorGroup implements ChangeDetectorGroup { - /** - * A group must have at least one record so that it can act as a placeholder. - * This record has minimal cost and never detects change. Once actual records - * get added the marker record gets removed, but it gets reinserted if all - * other records are removed. - */ - final DirtyCheckingRecord _marker = new DirtyCheckingRecord.marker(); - - final FieldGetterFactory _fieldGetterFactory; - - /** - * All records for group are kept together and are denoted by head/tail. - * The [_recordHead]-[_recordTail] only include our own records. If you want - * to see our childGroup records as well use - * [_head]-[_childInclRecordTail]. - */ - DirtyCheckingRecord _recordHead, _recordTail; - - /** - * Same as [_tail] but includes child-group records as well. - */ - DirtyCheckingRecord get _childInclRecordTail { - DirtyCheckingChangeDetectorGroup tail = this, nextTail; - while ((nextTail = tail._childTail) != null) { - tail = nextTail; - } - return tail._recordTail; - } - - bool get isAttached { - DirtyCheckingChangeDetectorGroup current = this; - DirtyCheckingChangeDetectorGroup parent; - while ((parent = current._parent) != null) { - current = parent; - } - return current is DirtyCheckingChangeDetector - ? true - : current._prev != null && current._next != null; - } - - - DirtyCheckingChangeDetector get _root { - var root = this; - var parent; - while ((parent = root._parent) != null) { - root = parent; - } - return root is DirtyCheckingChangeDetector ? root : null; - } - - /** - * ChangeDetectorGroup is organized hierarchically, a root group can have - * child groups and so on. We keep track of parent, children and next, - * previous here. - */ - DirtyCheckingChangeDetectorGroup _parent, _childHead, _childTail, _prev, _next; - - DirtyCheckingChangeDetectorGroup(this._parent, this._fieldGetterFactory) { - // we need to insert the marker record at the beginning. - if (_parent == null) { - _recordHead = _marker; - _recordTail = _marker; - } else { - _recordTail = _parent._childInclRecordTail; - // _recordAdd uses _recordTail from above. - _recordHead = _recordTail = _recordAdd(_marker); - } - } - - /** - * Returns the number of watches in this group (including child groups). - */ - get count { - int count = 0; - DirtyCheckingRecord cursor = _recordHead; - DirtyCheckingRecord end = _childInclRecordTail; - while (cursor != null) { - if (cursor._mode != DirtyCheckingRecord._MODE_MARKER_) { - count++; - } - if (cursor == end) break; - cursor = cursor._nextRecord; - } - return count; - } - - WatchRecord watch(Object object, String field, H handler) { - assert(_root != null); // prove that we are not deleted connected; - return _recordAdd(new DirtyCheckingRecord(this, _fieldGetterFactory, - handler, field, object)); - } - - /** - * Create a child [ChangeDetector] group. - */ - DirtyCheckingChangeDetectorGroup newGroup() { - // Disabled due to issue https://github.com/angular/angular.dart/issues/812 - // assert(_root._assertRecordsOk()); - var child = new DirtyCheckingChangeDetectorGroup(this, _fieldGetterFactory); - if (_childHead == null) { - _childHead = _childTail = child; - } else { - child._prev = _childTail; - _childTail._next = child; - _childTail = child; - } - // Disabled due to issue https://github.com/angular/angular.dart/issues/812 - // assert(_root._assertRecordsOk()); - return child; - } - - /** - * Bulk remove all records. - */ - void remove() { - var root; - assert((root = _root) != null); - assert(root._assertRecordsOk()); - DirtyCheckingRecord prevRecord = _recordHead._prevRecord; - var childInclRecordTail = _childInclRecordTail; - DirtyCheckingRecord nextRecord = childInclRecordTail._nextRecord; - - if (prevRecord != null) prevRecord._nextRecord = nextRecord; - if (nextRecord != null) nextRecord._prevRecord = prevRecord; - - var prevGroup = _prev; - var nextGroup = _next; - - if (prevGroup == null) { - _parent._childHead = nextGroup; - } else { - prevGroup._next = nextGroup; - } - if (nextGroup == null) { - _parent._childTail = prevGroup; - } else { - nextGroup._prev = prevGroup; - } - _parent = null; - _prev = _next = null; - _recordHead._prevRecord = null; - childInclRecordTail._nextRecord = null; - assert(root._assertRecordsOk()); - } - - DirtyCheckingRecord _recordAdd(DirtyCheckingRecord record) { - DirtyCheckingRecord previous = _recordTail; - DirtyCheckingRecord next = previous == null ? null : previous._nextRecord; - - record._nextRecord = next; - record._prevRecord = previous; - - if (previous != null) previous._nextRecord = record; - if (next != null) next._prevRecord = record; - - _recordTail = record; - - if (previous == _marker) _recordRemove(_marker); - - return record; - } - - void _recordRemove(DirtyCheckingRecord record) { - DirtyCheckingRecord previous = record._prevRecord; - DirtyCheckingRecord next = record._nextRecord; - - if (record == _recordHead && record == _recordTail) { - // we are the last one, must leave marker behind. - _recordHead = _recordTail = _marker; - _marker._nextRecord = next; - _marker._prevRecord = previous; - if (previous != null) previous._nextRecord = _marker; - if (next != null) next._prevRecord = _marker; - } else { - if (record == _recordTail) _recordTail = previous; - if (record == _recordHead) _recordHead = next; - if (previous != null) previous._nextRecord = next; - if (next != null) next._prevRecord = previous; - } - } - - String toString() { - var lines = []; - if (_parent == null) { - var allRecords = []; - DirtyCheckingRecord record = _recordHead; - var includeChildrenTail = _childInclRecordTail; - do { - allRecords.add(record.toString()); - record = record._nextRecord; - } while (record != includeChildrenTail); - allRecords.add(includeChildrenTail); - lines.add('FIELDS: ${allRecords.join(', ')}'); - } - - var records = []; - DirtyCheckingRecord record = _recordHead; - while (record != _recordTail) { - records.add(record.toString()); - record = record._nextRecord; - } - records.add(record.toString()); - - lines.add('DirtyCheckingChangeDetectorGroup(fields: ${records.join(', ')})'); - var childGroup = _childHead; - while (childGroup != null) { - lines.add(' ' + childGroup.toString().split('\n').join('\n ')); - childGroup = childGroup._next; - } - return lines.join('\n'); - } -} - -class DirtyCheckingChangeDetector extends DirtyCheckingChangeDetectorGroup - implements ChangeDetector { - - final DirtyCheckingRecord _fakeHead = new DirtyCheckingRecord.marker(); - - DirtyCheckingChangeDetector(FieldGetterFactory fieldGetterFactory) - : super(null, fieldGetterFactory); - - DirtyCheckingChangeDetector get _root => this; - - _assertRecordsOk() { - var record = this._recordHead; - var groups = [this]; - DirtyCheckingChangeDetectorGroup group; - while (groups.isNotEmpty) { - group = groups.removeAt(0); - DirtyCheckingChangeDetectorGroup childGroup = group._childTail; - while (childGroup != null) { - groups.insert(0, childGroup); - childGroup = childGroup._prev; - } - var groupRecord = group._recordHead; - var groupLast = group._recordTail; - if (record != groupRecord) { - throw "Next record is $record expecting $groupRecord"; - } - var done = false; - while (!done && groupRecord != null) { - if (groupRecord == record) { - if (record._group != null && record._group != group) { - throw "Wrong group: $record " - "Got ${record._group} Expecting: $group"; - } - record = record._nextRecord; - } else { - throw 'lost: $record found $groupRecord\n$this'; - } - - if (groupRecord._nextRecord != null && - groupRecord._nextRecord._prevRecord != groupRecord) { - throw "prev/next pointer missmatch on " - "$groupRecord -> ${groupRecord._nextRecord} " - "<= ${groupRecord._nextRecord._prevRecord} in $this"; - } - if (groupRecord._prevRecord != null && - groupRecord._prevRecord._nextRecord != groupRecord) { - throw "prev/next pointer missmatch on " - "$groupRecord -> ${groupRecord._prevRecord} " - "<= ${groupRecord._prevRecord._nextChange} in $this"; - } - if (groupRecord == groupLast) { - done = true; - } - groupRecord = groupRecord._nextRecord; - } - } - if(record != null) { - throw "Extra records at tail: $record on $this"; - } - return true; - } - - Iterator> collectChanges({EvalExceptionHandler exceptionHandler, - AvgStopwatch stopwatch}) { - if (stopwatch != null) stopwatch.start(); - DirtyCheckingRecord changeTail = _fakeHead; - DirtyCheckingRecord current = _recordHead; // current index - - int count = 0; - while (current != null) { - try { - if (current.check()) changeTail = changeTail._nextChange = current; - count++; - } catch (e, s) { - if (exceptionHandler == null) { - rethrow; - } else { - exceptionHandler(e, s); - } - } - current = current._nextRecord; - } - - changeTail._nextChange = null; - if (stopwatch != null) stopwatch..stop()..increment(count); - DirtyCheckingRecord changeHead = _fakeHead._nextChange; - _fakeHead._nextChange = null; - - return new _ChangeIterator(changeHead); - } - - void remove() { - throw new StateError('Root ChangeDetector can not be removed'); - } -} - -class _ChangeIterator implements Iterator>{ - DirtyCheckingRecord _current; - DirtyCheckingRecord _next; - DirtyCheckingRecord get current => _current; - - _ChangeIterator(this._next); - - bool moveNext() { - _current = _next; - if (_next != null) { - _next = _current._nextChange; - /* - * This is important to prevent memory leaks. If we don't reset then - * a record maybe pointing to a deleted change detector group and it - * will not release the reference until it fires again. So we have - * to be eager about releasing references. - */ - _current._nextChange = null; - } - return _current != null; - } -} - -/** - * [DirtyCheckingRecord] represents as single item to check. The heart of the - * [DirtyCheckingRecord] is a the [check] method which can read the - * [currentValue] and compare it to the [previousValue]. - * - * [DirtyCheckingRecord]s form linked list. This makes traversal, adding, and - * removing efficient. [DirtyCheckingRecord] also has a [nextChange] field which - * creates a single linked list of all of the changes for efficient traversal. - */ -class DirtyCheckingRecord implements Record, WatchRecord { - static const List _MODE_NAMES = const [ - 'MARKER', - 'NOOP', - 'IDENTITY', - 'GETTER', - 'GETTER / CLOSURE' - 'MAP[]', - 'ITERABLE', - 'MAP']; - static const int _MODE_MARKER_ = 0; - static const int _MODE_NOOP_ = 1; - static const int _MODE_IDENTITY_ = 2; - static const int _MODE_GETTER_ = 3; - static const int _MODE_GETTER_OR_METHOD_CLOSURE_ = 4; - static const int _MODE_MAP_FIELD_ = 5; - static const int _MODE_ITERABLE_ = 6; - static const int _MODE_MAP_ = 7; - - final DirtyCheckingChangeDetectorGroup _group; - final FieldGetterFactory _fieldGetterFactory; - final String field; - final H handler; - - int _mode; - - var previousValue; - var currentValue; - DirtyCheckingRecord _nextRecord; - DirtyCheckingRecord _prevRecord; - Record _nextChange; - var _object; - FieldGetter _getter; - - DirtyCheckingRecord(this._group, this._fieldGetterFactory, this.handler, - this.field, _object) { - object = _object; - } - - DirtyCheckingRecord.marker() - : _group = null, - _fieldGetterFactory = null, - handler = null, - field = null, - _getter = null, - _mode = _MODE_MARKER_; - - dynamic get object => _object; - - /** - * Setting an [object] will cause the setter to introspect it and place - * [DirtyCheckingRecord] into different access modes. If Object it sets up - * reflection. If [Map] then it sets up map accessor. - */ - void set object(obj) { - _object = obj; - if (obj == null) { - _mode = _MODE_IDENTITY_; - _getter = null; - return; - } - - if (field == null) { - _getter = null; - if (obj is Map) { - if (_mode != _MODE_MAP_) { - _mode = _MODE_MAP_; - currentValue = new _MapChangeRecord(); - } - if (currentValue.isDirty) { - // We're dirty because the mapping we tracked by reference mutated. - // In addition, our reference has now changed. We should compare the - // previous reported value of that mapping with the one from the - // new reference. - currentValue._revertToPreviousState(); - } - - } else if (obj is Iterable) { - if (_mode != _MODE_ITERABLE_) { - _mode = _MODE_ITERABLE_; - currentValue = new _CollectionChangeRecord(); - } - if (currentValue.isDirty) { - // We're dirty because the collection we tracked by reference mutated. - // In addition, our reference has now changed. We should compare the - // previous reported value of that collection with the one from the - // new reference. - currentValue._revertToPreviousState(); - } - } else { - _mode = _MODE_IDENTITY_; - } - - return; - } - - if (obj is Map) { - _mode = _MODE_MAP_FIELD_; - _getter = null; - } else { - _mode = _MODE_GETTER_OR_METHOD_CLOSURE_; - _getter = _fieldGetterFactory.getter(obj, field); - } - } - - bool check() { - assert(_mode != null); - var current; - switch (_mode) { - case _MODE_MARKER_: - return false; - case _MODE_NOOP_: - return false; - case _MODE_GETTER_: - current = _getter(object); - break; - case _MODE_GETTER_OR_METHOD_CLOSURE_: - // NOTE: When Dart looks up a method "foo" on object "x", it returns a - // new closure for each lookup. They compare equal via "==" but are no - // identical(). There's no point getting a new value each time and - // decide it's the same so we'll skip further checking after the first - // time. - current = _getter(object); - if (current is Function && !identical(current, _getter(object))) { - _mode = _MODE_NOOP_; - } else { - _mode = _MODE_GETTER_; - } - break; - case _MODE_MAP_FIELD_: - current = object[field]; - break; - case _MODE_IDENTITY_: - current = object; - _mode = _MODE_NOOP_; - break; - case _MODE_MAP_: - return (currentValue as _MapChangeRecord)._check(object); - case _MODE_ITERABLE_: - return (currentValue as _CollectionChangeRecord)._check(object); - default: - assert(false); - } - - var last = currentValue; - if (!_looseIdentical(last, current)) { - previousValue = currentValue; - currentValue = current; - return true; - } - return false; - } - - - void remove() { - _group._recordRemove(this); - } - - String toString() => - '${_mode < _MODE_NAMES.length ? _MODE_NAMES[_mode] : '?'}[$field]{$hashCode}'; -} - -final Object _INITIAL_ = new Object(); - -class _MapChangeRecord implements MapChangeRecord { - final _records = new HashMap(); - Map _map; - - Map get map => _map; - - KeyValueRecord _mapHead; - KeyValueRecord _previousMapHead; - KeyValueRecord _changesHead, _changesTail; - KeyValueRecord _additionsHead, _additionsTail; - KeyValueRecord _removalsHead, _removalsTail; - - get isDirty => _additionsHead != null || - _changesHead != null || - _removalsHead != null; - - _revertToPreviousState() { - if (!isDirty) { - return; - } - KeyValueRecord record, prev; - int i = 0; - for (record = _mapHead = _previousMapHead; - record != null; - prev = record, record = record._nextPrevious, ++i) { - record._currentValue = record._previousValue; - if (prev != null) { - prev._next = prev._nextPrevious = record; - } - } - prev._next = null; - _undoDeltas(); - } - - KeyValueRecord r; - - void forEachItem(void f(MapKeyValue change)) { - for (r = _mapHead; r != null; r = r._next) { - f(r); - } - } - - void forEachPreviousItem(void f(MapKeyValue change)) { - for (r = _previousMapHead; r != null; r = r._nextPrevious) { - f(r); - } - } - - void forEachChange(void f(MapKeyValue change)) { - for (r = _changesHead; r != null; r = r._nextChanged) { - f(r); - } - } - - void forEachAddition(void f(MapKeyValue addition)){ - for (r = _additionsHead; r != null; r = r._nextAdded) { - f(r); - } - } - - void forEachRemoval(void f(MapKeyValue removal)){ - for (r = _removalsHead; r != null; r = r._nextRemoved) { - f(r); - } - } - - bool _check(Map map) { - _reset(); - _map = map; - Map records = _records; - KeyValueRecord oldSeqRecord = _mapHead; - KeyValueRecord lastOldSeqRecord; - KeyValueRecord lastNewSeqRecord; - var seqChanged = false; - map.forEach((key, value) { - var newSeqRecord; - if (oldSeqRecord != null && key == oldSeqRecord.key) { - newSeqRecord = oldSeqRecord; - if (!_looseIdentical(value, oldSeqRecord._currentValue)) { - var prev = oldSeqRecord._previousValue = oldSeqRecord._currentValue; - oldSeqRecord._currentValue = value; - _addToChanges(oldSeqRecord); - } - } else { - seqChanged = true; - if (oldSeqRecord != null) { - oldSeqRecord._next = null; - _removeFromSeq(lastOldSeqRecord, oldSeqRecord); - _addToRemovals(oldSeqRecord); - } - if (records.containsKey(key)) { - newSeqRecord = records[key]; - } else { - newSeqRecord = records[key] = new KeyValueRecord(key); - newSeqRecord._currentValue = value; - _addToAdditions(newSeqRecord); - } - } - - if (seqChanged) { - if (_isInRemovals(newSeqRecord)) { - _removeFromRemovals(newSeqRecord); - } - if (lastNewSeqRecord == null) { - _mapHead = newSeqRecord; - } else { - lastNewSeqRecord._next = newSeqRecord; - } - } - lastOldSeqRecord = oldSeqRecord; - lastNewSeqRecord = newSeqRecord; - oldSeqRecord = oldSeqRecord == null ? null : oldSeqRecord._next; - }); - _truncate(lastOldSeqRecord, oldSeqRecord); - return isDirty; - } - - void _reset() { - if (isDirty) { - // Record the state of the mapping for a possible _revertToPreviousState() - for (KeyValueRecord record = _previousMapHead = _mapHead; - record != null; - record = record._next) { - record._nextPrevious = record._next; - } - _undoDeltas(); - } - } - - void _undoDeltas() { - KeyValueRecord r; - - for (r = _changesHead; r != null; r = r._nextChanged) { - r._previousValue = r._currentValue; - } - - for (r = _additionsHead; r != null; r = r._nextAdded) { - r._previousValue = r._currentValue; - } - - assert((() { - var r = _changesHead; - while (r != null) { - var nextRecord = r._nextChanged; - r._nextChanged = null; - r = nextRecord; - } - - r = _additionsHead; - while (r != null) { - var nextRecord = r._nextAdded; - r._nextAdded = null; - r = nextRecord; - } - - r = _removalsHead; - while (r != null) { - var nextRecord = r._nextRemoved; - r._nextRemoved = null; - r = nextRecord; - } - - return true; - })()); - _changesHead = _changesTail = null; - _additionsHead = _additionsTail = null; - _removalsHead = _removalsTail = null; - } - - void _truncate(KeyValueRecord lastRecord, KeyValueRecord record) { - while (record != null) { - if (lastRecord == null) { - _mapHead = null; - } else { - lastRecord._next = null; - } - var nextRecord = record._next; - assert((() { - record._next = null; - return true; - })()); - _addToRemovals(record); - lastRecord = record; - record = nextRecord; - } - - for (var r = _removalsHead; r != null; r = r._nextRemoved) { - r._previousValue = r._currentValue; - r._currentValue = null; - _records.remove(r.key); - } - } - - bool _isInRemovals(KeyValueRecord record) => - record == _removalsHead || - record._nextRemoved != null || - record._prevRemoved != null; - - void _addToRemovals(KeyValueRecord record) { - assert(record._next == null); - assert(record._nextAdded == null); - assert(record._nextChanged == null); - assert(record._nextRemoved == null); - assert(record._prevRemoved == null); - if (_removalsHead == null) { - _removalsHead = _removalsTail = record; - } else { - _removalsTail._nextRemoved = record; - record._prevRemoved = _removalsTail; - _removalsTail = record; - } - } - - void _removeFromSeq(KeyValueRecord prev, KeyValueRecord record) { - KeyValueRecord next = record._next; - if (prev == null) { - _mapHead = next; - } else { - prev._next = next; - } - assert((() { - record._next = null; - return true; - })()); - } - - void _removeFromRemovals(KeyValueRecord record) { - assert(record._next == null); - assert(record._nextAdded == null); - assert(record._nextChanged == null); - - var prev = record._prevRemoved; - var next = record._nextRemoved; - if (prev == null) { - _removalsHead = next; - } else { - prev._nextRemoved = next; - } - if (next == null) { - _removalsTail = prev; - } else { - next._prevRemoved = prev; - } - record._prevRemoved = record._nextRemoved = null; - } - - void _addToAdditions(KeyValueRecord record) { - assert(record._next == null); - assert(record._nextAdded == null); - assert(record._nextChanged == null); - assert(record._nextRemoved == null); - assert(record._prevRemoved == null); - if (_additionsHead == null) { - _additionsHead = _additionsTail = record; - } else { - _additionsTail._nextAdded = record; - _additionsTail = record; - } - } - - void _addToChanges(KeyValueRecord record) { - assert(record._nextAdded == null); - assert(record._nextChanged == null); - assert(record._nextRemoved == null); - assert(record._prevRemoved == null); - if (_changesHead == null) { - _changesHead = _changesTail = record; - } else { - _changesTail._nextChanged = record; - _changesTail = record; - } - } - - String toString() { - List itemsList = [], previousList = [], changesList = [], additionsList = [], removalsList = []; - KeyValueRecord r; - for (r = _mapHead; r != null; r = r._next) { - itemsList.add("$r"); - } - for (r = _previousMapHead; r != null; r = r._nextPrevious) { - previousList.add("$r"); - } - for (r = _changesHead; r != null; r = r._nextChanged) { - changesList.add("$r"); - } - for (r = _additionsHead; r != null; r = r._nextAdded) { - additionsList.add("$r"); - } - for (r = _removalsHead; r != null; r = r._nextRemoved) { - removalsList.add("$r"); - } - return """ -map: ${itemsList.join(", ")} -previous: ${previousList.join(", ")} -changes: ${changesList.join(", ")} -additions: ${additionsList.join(", ")} -removals: ${removalsList.join(", ")} -"""; - } -} - -class KeyValueRecord implements MapKeyValue { - final K key; - V _previousValue, _currentValue; - - V get previousValue => _previousValue; - V get currentValue => _currentValue; - - KeyValueRecord _nextPrevious; - KeyValueRecord _next; - KeyValueRecord _nextAdded; - KeyValueRecord _nextRemoved, _prevRemoved; - KeyValueRecord _nextChanged; - - KeyValueRecord(this.key); - - String toString() => _previousValue == _currentValue - ? "$key" - : '$key[$_previousValue -> $_currentValue]'; -} - -class _CollectionChangeRecord implements CollectionChangeRecord { - Iterable _iterable; - int _length; - - /// Keeps track of the used records at any point in time (during & across `_check()` calls) - DuplicateMap _linkedRecords; - - /// Keeps track of the removed records at any point in time during `_check()` calls. - DuplicateMap _unlinkedRecords; - - ItemRecord _previousItHead; - ItemRecord _itHead, _itTail; - ItemRecord _additionsHead, _additionsTail; - ItemRecord _movesHead, _movesTail; - ItemRecord _removalsHead, _removalsTail; - - void _revertToPreviousState() { - if (!isDirty) return; - - if (_linkedRecords != null) _linkedRecords.clear(); - ItemRecord prev; - int i = 0; - - for (ItemRecord record = _itHead = _previousItHead; - record != null; - prev = record, record = record._nextPrevious, i++) { - record.currentIndex = record.previousIndex = i; - record._prev = prev; - if (prev != null) prev._next = prev._nextPrevious = record; - - if (_linkedRecords == null) _linkedRecords = new DuplicateMap(); - _linkedRecords.put(record); - } - - prev._next = null; - _itTail = prev; - _undoDeltas(); - } - - void forEachItem(void f(CollectionChangeItem item)) { - for (var r = _itHead; r != null; r = r._next) { - f(r); - } - } - - void forEachPreviousItem(void f(CollectionChangeItem previousItem)) { - for (var r = _previousItHead; r != null; r = r._nextPrevious) { - f(r); - } - } - - void forEachAddition(void f(CollectionChangeItem addition)){ - for (var r = _additionsHead; r != null; r = r._nextAdded) { - f(r); - } - } - - void forEachMove(void f(CollectionChangeItem change)) { - for (var r = _movesHead; r != null; r = r._nextMoved) { - f(r); - } - } - - void forEachRemoval(void f(CollectionChangeItem removal)){ - for (var r = _removalsHead; r != null; r = r._nextRemoved) { - f(r); - } - } - - Iterable get iterable => _iterable; - int get length => _length; - - bool _check(Iterable collection) { - _reset(); - - if (collection is UnmodifiableListView && identical(_iterable, collection)) { - // Short circuit and assume that the list has not been modified. - return false; - } - - ItemRecord record = _itHead; - bool maybeDirty = false; - - if (collection is List) { - List list = collection; - _length = list.length; - for (int index = 0; index < _length; index++) { - var item = list[index]; - if (record == null || !_looseIdentical(record.item, item)) { - record = mismatch(record, item, index); - maybeDirty = true; - } else if (maybeDirty) { - // TODO(misko): can we limit this to duplicates only? - record = verifyReinsertion(record, item, index); - } - record = record._next; - } - } else { - int index = 0; - for (var item in collection) { - if (record == null || !_looseIdentical(record.item, item)) { - record = mismatch(record, item, index); - maybeDirty = true; - } else if (maybeDirty) { - // TODO(misko): can we limit this to duplicates only? - record = verifyReinsertion(record, item, index); - } - record = record._next; - index++; - } - _length = index; - } - - _truncate(record); - _iterable = collection; - return isDirty; - } - - /** - * Reset the state of the change objects to show no changes. This means set previousKey to - * currentKey, and clear all of the queues (additions, moves, removals). - */ - void _reset() { - if (isDirty) { - // Record the state of the collection for a possible `_revertToPreviousState()` - for (ItemRecord r = _previousItHead = _itHead; r != null; r = r._next) { - r._nextPrevious = r._next; - } - _undoDeltas(); - } - } - - /// Set the [previousIndex]es of moved and added items to their [currentIndex]es - /// Reset the list of additions, moves and removals - void _undoDeltas() { - ItemRecord record; - - record = _additionsHead; - while (record != null) { - record.previousIndex = record.currentIndex; - record = record._nextAdded; - } - _additionsHead = _additionsTail = null; - - record = _movesHead; - while (record != null) { - record.previousIndex = record.currentIndex; - var nextRecord = record._nextMoved; - assert((record._nextMoved = null) == null); - record = nextRecord; - } - _movesHead = _movesTail = null; - _removalsHead = _removalsTail = null; - assert(isDirty == false); - } - - /// A [_CollectionChangeRecord] is considered dirty if it has additions, moves or removals. - bool get isDirty => _additionsHead != null || - _movesHead != null || - _removalsHead != null; - - /** - * This is the core function which handles differences between collections. - * - * - [record] is the record which we saw at this position last time. If [:null:] then it is a new - * item. - * - [item] is the current item in the collection - * - [index] is the position of the item in the collection - */ - ItemRecord mismatch(ItemRecord record, item, int index) { - // The previous record after which we will append the current one. - ItemRecord previousRecord; - - if (record == null) { - previousRecord = _itTail; - } else { - previousRecord = record._prev; - // Remove the record from the collection since we know it does not match the item. - _remove(record); - } - - // Attempt to see if we have seen the item before. - record = _linkedRecords == null ? null : _linkedRecords.get(item, index); - if (record != null) { - // We have seen this before, we need to move it forward in the collection. - _moveAfter(record, previousRecord, index); - } else { - // Never seen it, check evicted list. - record = _unlinkedRecords == null ? null : _unlinkedRecords.get(item); - if (record != null) { - // It is an item which we have evicted earlier: reinsert it back into the list. - _reinsertAfter(record, previousRecord, index); - } else { - // It is a new item: add it. - record = _addAfter(new ItemRecord(item), previousRecord, index); - } - } - return record; - } - - /** - * This check is only needed if an array contains duplicates. (Short circuit - * of nothing dirty) - * - * Use case: `[a, a]` => `[b, a, a]` - * - * If we did not have this check then the insertion of `b` would: - * 1) evict first `a` - * 2) insert `b` at `0` index. - * 3) leave `a` at index `1` as is. <-- this is wrong! - * 3) reinsert `a` at index 2. <-- this is wrong! - * - * The correct behavior is: - * 1) evict first `a` - * 2) insert `b` at `0` index. - * 3) reinsert `a` at index 1. - * 3) move `a` at from `1` to `2`. - * - * - * Double check that we have not evicted a duplicate item. We need to check if - * the item type may have already been removed: - * The insertion of b will evict the first 'a'. If we don't reinsert it now it - * will be reinserted at the end. Which will show up as the two 'a's switching - * position. This is incorrect, since a better way to think of it is as insert - * of 'b' rather then switch 'a' with 'b' and then add 'a' at the end. - */ - ItemRecord verifyReinsertion(ItemRecord record, item, int index) { - ItemRecord reinsertRecord = _unlinkedRecords == null ? null : _unlinkedRecords.get(item); - if (reinsertRecord != null) { - record = _reinsertAfter(reinsertRecord, record._prev, index); - } else if (record.currentIndex != index) { - record.currentIndex = index; - _addToMoves(record, index); - } - return record; - } - - /** - * Get rid of any excess [ItemRecord]s from the previous collection - * - * - [record] The first excess [ItemRecord]. - */ - void _truncate(ItemRecord record) { - // Anything after that needs to be removed; - while (record != null) { - ItemRecord nextRecord = record._next; - _addToRemovals(_unlink(record)); - record = nextRecord; - } - if (_unlinkedRecords != null) _unlinkedRecords.clear(); - - if (_additionsTail != null) _additionsTail._nextAdded = null; - if (_movesTail != null) _movesTail._nextMoved = null; - if (_itTail != null) _itTail._next = null; - if (_removalsTail != null) _removalsTail._nextRemoved = null; - } - - ItemRecord _reinsertAfter(ItemRecord record, ItemRecord prevRecord, int index) { - if (_unlinkedRecords != null) _unlinkedRecords.remove(record); - var prev = record._prevRemoved; - var next = record._nextRemoved; - - if (prev == null) { - _removalsHead = next; - } else { - prev._nextRemoved = next; - } - if (next == null) { - _removalsTail = prev; - } else { - next._prevRemoved = prev; - } - - _insertAfter(record, prevRecord, index); - _addToMoves(record, index); - return record; - } - - ItemRecord _moveAfter(ItemRecord record, ItemRecord prevRecord, int index) { - _unlink(record); - _insertAfter(record, prevRecord, index); - _addToMoves(record, index); - return record; - } - - ItemRecord _addAfter(ItemRecord record, ItemRecord prevRecord, int index) { - _insertAfter(record, prevRecord, index); - - if (_additionsTail == null) { - assert(_additionsHead == null); - _additionsTail = _additionsHead = record; - } else { - assert(_additionsTail._nextAdded == null); - assert(record._nextAdded == null); - _additionsTail = _additionsTail._nextAdded = record; - } - return record; - } - - ItemRecord _insertAfter(ItemRecord record, ItemRecord prevRecord, int index) { - assert(record != prevRecord); - assert(record._next == null); - assert(record._prev == null); - - ItemRecord next = prevRecord == null ? _itHead : prevRecord._next; - assert(next != record); - assert(prevRecord != record); - record._next = next; - record._prev = prevRecord; - if (next == null) { - _itTail = record; - } else { - next._prev = record; - } - if (prevRecord == null) { - _itHead = record; - } else { - prevRecord._next = record; - } - - if (_linkedRecords == null) _linkedRecords = new DuplicateMap(); - _linkedRecords.put(record); - - record.currentIndex = index; - return record; - } - - ItemRecord _remove(ItemRecord record) => _addToRemovals(_unlink(record)); - - ItemRecord _unlink(ItemRecord record) { - if (_linkedRecords != null) _linkedRecords.remove(record); - - var prev = record._prev; - var next = record._next; - - assert((record._prev = null) == null); - assert((record._next = null) == null); - - if (prev == null) { - _itHead = next; - } else { - prev._next = next; - } - if (next == null) { - _itTail = prev; - } else { - next._prev = prev; - } - - return record; - } - - ItemRecord _addToMoves(ItemRecord record, int toIndex) { - assert(record._nextMoved == null); - - if (record.previousIndex == toIndex) return record; - - if (_movesTail == null) { - assert(_movesHead == null); - _movesTail = _movesHead = record; - } else { - assert(_movesTail._nextMoved == null); - _movesTail = _movesTail._nextMoved = record; - } - - return record; - } - - ItemRecord _addToRemovals(ItemRecord record) { - if (_unlinkedRecords == null) _unlinkedRecords = new DuplicateMap(); - _unlinkedRecords.put(record); - record.currentIndex = null; - record._nextRemoved = null; - - if (_removalsTail == null) { - assert(_removalsHead == null); - _removalsTail = _removalsHead = record; - record._prevRemoved = null; - } else { - assert(_removalsTail._nextRemoved == null); - assert(record._nextRemoved == null); - record._prevRemoved = _removalsTail; - _removalsTail = _removalsTail._nextRemoved = record; - } - return record; - } - - String toString() { - ItemRecord r; - - var list = []; - for (r = _itHead; r != null; r = r._next) { - list.add(r); - } - - var previous = []; - for (r = _previousItHead; r != null; r = r._nextPrevious) { - previous.add(r); - } - - var additions = []; - for (r = _additionsHead; r != null; r = r._nextAdded) { - additions.add(r); - } - var moves = []; - for (r = _movesHead; r != null; r = r._nextMoved) { - moves.add(r); - } - - var removals = []; - for (r = _removalsHead; r != null; r = r._nextRemoved) { - removals.add(r); - } - - return """ -collection: ${list.join(", ")} -previous: ${previous.join(", ")} -additions: ${additions.join(", ")} -moves: ${moves.join(", ")} -removals: ${removals.join(", ")} -"""; - } -} - -class ItemRecord extends CollectionChangeItem { - int currentIndex; - int previousIndex; - V item; - - ItemRecord _nextPrevious; - ItemRecord _prev, _next; - ItemRecord _prevDup, _nextDup; - ItemRecord _prevRemoved, _nextRemoved; - ItemRecord _nextAdded; - ItemRecord _nextMoved; - - ItemRecord(this.item); - - String toString() => previousIndex == currentIndex - ? '$item' - : '$item[$previousIndex -> $currentIndex]'; -} - -/// A linked list of [ItemRecord]s with the same [ItemRecord.item] -class _DuplicateItemRecordList { - ItemRecord _head, _tail; - - /** - * Append the [record] to the list of duplicates. - * - * Note: by design all records in the list of duplicates hold the save value in [record.item]. - */ - void add(ItemRecord record) { - if (_head == null) { - _head = _tail = record; - record._nextDup = null; - record._prevDup = null; - } else { - assert(record.item == _head.item || - record.item is num && record.item.isNaN && _head.item is num && _head.item.isNaN); - _tail._nextDup = record; - record._prevDup = _tail; - record._nextDup = null; - _tail = record; - } - } - - /// Returns an [ItemRecord] having [ItemRecord.item] == [item] and - /// [ItemRecord.currentIndex] >= [afterIndex] - ItemRecord get(item, int afterIndex) { - ItemRecord record; - for (record = _head; record != null; record = record._nextDup) { - if ((afterIndex == null || afterIndex < record.currentIndex) && - _looseIdentical(record.item, item)) { - return record; - } - } - return null; - } - - /** - * Remove one [ItemRecord] from the list of duplicates. - * - * Returns whether the list of duplicates is empty. - */ - bool remove(ItemRecord record) { - assert(() { - // verify that the record being removed is in the list. - for (ItemRecord cursor = _head; cursor != null; cursor = cursor._nextDup) { - if (identical(cursor, record)) return true; - } - return false; - }); - - var prev = record._prevDup; - var next = record._nextDup; - if (prev == null) { - _head = next; - } else { - prev._nextDup = next; - } - if (next == null) { - _tail = prev; - } else { - next._prevDup = prev; - } - return _head == null; - } -} - -/** - * [DuplicateMap] maps [ItemRecord.value] to a list of [ItemRecord] having the same value - * (duplicates). - * - * The list of duplicates is implemented by [_DuplicateItemRecordList]. - */ -class DuplicateMap { - static final _nanKey = const Object(); - final map = new HashMap(); - - void put(ItemRecord record) { - var key = _getKey(record.item); - _DuplicateItemRecordList duplicates = map[key]; - if (duplicates == null) { - duplicates = map[key] = new _DuplicateItemRecordList(); - } - duplicates.add(record); - } - - /** - * Retrieve the `value` using [key]. Because the [ItemRecord] value maybe one which we have - * already iterated over, we use the [afterIndex] to pretend it is not there. - * - * Use case: `[a, b, c, a, a]` if we are at index `3` which is the second `a` then asking if we - * have any more `a`s needs to return the last `a` not the first or second. - */ - ItemRecord get(value, [int afterIndex]) { - var key = _getKey(value); - _DuplicateItemRecordList recordList = map[key]; - return recordList == null ? null : recordList.get(value, afterIndex); - } - - /** - * Removes an [ItemRecord] from the list of duplicates. - * - * The list of duplicates also is removed from the map if it gets empty. - */ - ItemRecord remove(ItemRecord record) { - var key = _getKey(record.item); - assert(map.containsKey(key)); - _DuplicateItemRecordList recordList = map[key]; - // Remove the list of duplicates when it gets empty - if (recordList.remove(record)) map.remove(key); - return record; - } - - bool get isEmpty => map.isEmpty; - - void clear() { - map.clear(); - } - - /// Required to handle num.NAN as a Map value - dynamic _getKey(value) => value is num && value.isNaN ? _nanKey : value; - - String toString() => "DuplicateMap($map)"; -} - -/** - * Returns whether the [dst] and [src] are loosely identical: - * * true when the value are identical, - * * true when both values are equal strings, - * * true when both values are NaN - * - * If both values are equal string, src is assigned to dst. - */ -bool _looseIdentical(dst, src) { - if (identical(dst, src)) return true; - - if (dst is String && src is String && dst == src) { - // this is false change in strings we need to recover, and pretend it is the same. We save the - // value so that next time identity can pass - dst = src; - return true; - } - - // we need this for JavaScript since in JS NaN !== NaN. - if (dst is num && (dst as num).isNaN && src is num && (src as num).isNaN) return true; - - return false; -} diff --git a/lib/change_detection/linked_list.dart b/lib/change_detection/linked_list.dart deleted file mode 100644 index e90a80cc3..000000000 --- a/lib/change_detection/linked_list.dart +++ /dev/null @@ -1,162 +0,0 @@ -part of angular.watch_group; - - -class _LinkedListItem { - I _previous, _next; -} - -class _LinkedList { - L _head, _tail; - - static _Handler _add(_Handler list, _LinkedListItem item) { - assert(item._next == null); - assert(item._previous == null); - if (list._tail == null) { - list._head = list._tail = item; - } else { - item._previous = list._tail; - list._tail._next = item; - list._tail = item; - } - return item; - } - - static bool _isEmpty(_Handler list) => list._head == null; - - static void _remove(_Handler list, _Handler item) { - var previous = item._previous; - var next = item._next; - - if (previous == null) list._head = next; else previous._next = next; - if (next == null) list._tail = previous; else next._previous = previous; - } -} - -class _ArgHandlerList { - _ArgHandler _argHandlerHead, _argHandlerTail; - - static _Handler _add(_ArgHandlerList list, _ArgHandler item) { - assert(item._nextArgHandler == null); - assert(item._previousArgHandler == null); - if (list._argHandlerTail == null) { - list._argHandlerHead = list._argHandlerTail = item; - } else { - item._previousArgHandler = list._argHandlerTail; - list._argHandlerTail._nextArgHandler = item; - list._argHandlerTail = item; - } - return item; - } - - static bool _isEmpty(_InvokeHandler list) => list._argHandlerHead == null; - - static void _remove(_InvokeHandler list, _ArgHandler item) { - var previous = item._previousArgHandler; - var next = item._nextArgHandler; - - if (previous == null) list._argHandlerHead = next; else previous._nextArgHandler = next; - if (next == null) list._argHandlerTail = previous; else next._previousArgHandler = previous; - } -} - -class _WatchList { - Watch _watchHead, _watchTail; - - static Watch _add(_WatchList list, Watch item) { - assert(item._nextWatch == null); - assert(item._previousWatch == null); - if (list._watchTail == null) { - list._watchHead = list._watchTail = item; - } else { - item._previousWatch = list._watchTail; - list._watchTail._nextWatch = item; - list._watchTail = item; - } - return item; - } - - static bool _isEmpty(_Handler list) => list._watchHead == null; - - static void _remove(_Handler list, Watch item) { - var previous = item._previousWatch; - var next = item._nextWatch; - - if (previous == null) list._watchHead = next; else previous._nextWatch = next; - if (next == null) list._watchTail = previous; else next._previousWatch = previous; - } -} - -abstract class _EvalWatchList { - _EvalWatchRecord _evalWatchHead, _evalWatchTail; - _EvalWatchRecord get _marker; - - static _EvalWatchRecord _add(_EvalWatchList list, _EvalWatchRecord item) { - assert(item._nextEvalWatch == null); - assert(item._prevEvalWatch == null); - var prev = list._evalWatchTail; - var next = prev._nextEvalWatch; - - if (prev == list._marker) { - list._evalWatchHead = list._evalWatchTail = item; - prev = prev._prevEvalWatch; - list._marker._prevEvalWatch = null; - list._marker._nextEvalWatch = null; - } - item._nextEvalWatch = next; - item._prevEvalWatch = prev; - - if (prev != null) prev._nextEvalWatch = item; - if (next != null) next._prevEvalWatch = item; - - return list._evalWatchTail = item; - } - - static bool _isEmpty(_EvalWatchList list) => list._evalWatchHead == null; - - static void _remove(_EvalWatchList list, _EvalWatchRecord item) { - assert(item.watchGrp == list); - var prev = item._prevEvalWatch; - var next = item._nextEvalWatch; - - if (list._evalWatchHead == list._evalWatchTail) { - list._evalWatchHead = list._evalWatchTail = list._marker; - list._marker - .._nextEvalWatch = next - .._prevEvalWatch = prev; - if (prev != null) prev._nextEvalWatch = list._marker; - if (next != null) next._prevEvalWatch = list._marker; - } else { - if (item == list._evalWatchHead) list._evalWatchHead = next; - if (item == list._evalWatchTail) list._evalWatchTail = prev; - if (prev != null) prev._nextEvalWatch = next; - if (next != null) next._prevEvalWatch = prev; - } - } -} - -class _WatchGroupList { - WatchGroup _watchGroupHead, _watchGroupTail; - - static WatchGroup _add(_WatchGroupList list, WatchGroup item) { - assert(item._nextWatchGroup == null); - assert(item._prevWatchGroup == null); - if (list._watchGroupTail == null) { - list._watchGroupHead = list._watchGroupTail = item; - } else { - item._prevWatchGroup = list._watchGroupTail; - list._watchGroupTail._nextWatchGroup = item; - list._watchGroupTail = item; - } - return item; - } - - static bool _isEmpty(_WatchGroupList list) => list._watchGroupHead == null; - - static void _remove(_WatchGroupList list, WatchGroup item) { - var previous = item._prevWatchGroup; - var next = item._nextWatchGroup; - - if (previous == null) list._watchGroupHead = next; else previous._nextWatchGroup = next; - if (next == null) list._watchGroupTail = previous; else next._prevWatchGroup = previous; - } -} diff --git a/lib/change_detection/watch_group.dart b/lib/change_detection/watch_group.dart deleted file mode 100644 index 456541432..000000000 --- a/lib/change_detection/watch_group.dart +++ /dev/null @@ -1,887 +0,0 @@ -library angular.watch_group; - -import 'package:angular/change_detection/change_detection.dart'; -import 'dart:collection'; -import 'package:angular/ng_tracing.dart'; - -part 'linked_list.dart'; -part 'ast.dart'; -part 'prototype_map.dart'; - -/** - * A function that is notified of changes to the model. - * - * ReactionFn is a function implemented by the developer that executes when a change is detected - * in a watched expression. - * - * * [value]: The current value of the watched expression. - * * [previousValue]: The previous value of the watched expression. - * - * If the expression is watching a collection (a list or a map), then [value] is wrapped in - * a [CollectionChangeItem] that lists all the changes. - */ -typedef void ReactionFn(value, previousValue); -typedef void ChangeLog(String expression, current, previous); - -/** - * Extend this class if you wish to pretend to be a function, but you don't know - * number of arguments with which the function will get called with. - */ -abstract class FunctionApply { - dynamic call() { throw new StateError('Use apply()'); } - dynamic apply(List arguments); -} - -/** - * [WatchGroup] is a logical grouping of a set of watches. [WatchGroup]s are - * organized into a hierarchical tree parent-children configuration. - * [WatchGroup] builds upon [ChangeDetector] and adds expression (field chains - * as in `a.b.c`) support as well as support function/closure/method (function - * invocation as in `a.b()`) watching. - */ -class WatchGroup implements _EvalWatchList, _WatchGroupList { - /** A unique ID for the WatchGroup */ - final String id; - /** - * A marker to be inserted when a group has no watches. We need the marker to - * hold our position information in the linked list of all [Watch]es. - */ - final _EvalWatchRecord _marker = new _EvalWatchRecord.marker(); - - /** All Expressions are evaluated against a context object. */ - final Object context; - - /** [ChangeDetector] used for field watching */ - final ChangeDetectorGroup<_Handler> _changeDetector; - final RootWatchGroup _rootGroup; - - /// STATS: Number of field watchers which are in use. - int _fieldCost = 0; - int _collectionCost = 0; - int _evalCost = 0; - - /// STATS: Number of field watchers which are in use including child [WatchGroup]s. - int get fieldCost => _fieldCost; - int get totalFieldCost { - var cost = _fieldCost; - WatchGroup group = _watchGroupHead; - while (group != null) { - cost += group.totalFieldCost; - group = group._nextWatchGroup; - } - return cost; - } - - /// STATS: Number of collection watchers which are in use including child [WatchGroup]s. - int get collectionCost => _collectionCost; - int get totalCollectionCost { - var cost = _collectionCost; - WatchGroup group = _watchGroupHead; - while (group != null) { - cost += group.totalCollectionCost; - group = group._nextWatchGroup; - } - return cost; - } - - /// STATS: Number of invocation watchers (closures/methods) which are in use. - int get evalCost => _evalCost; - - /// STATS: Number of invocation watchers which are in use including child [WatchGroup]s. - int get totalEvalCost { - var cost = _evalCost; - WatchGroup group = _watchGroupHead; - while (group != null) { - cost += group.evalCost; - group = group._nextWatchGroup; - } - return cost; - } - - int _nextChildId = 0; - _EvalWatchRecord _evalWatchHead, _evalWatchTail; - /// Pointer for creating tree of [WatchGroup]s. - WatchGroup _parentWatchGroup; - WatchGroup _watchGroupHead, _watchGroupTail; - WatchGroup _prevWatchGroup, _nextWatchGroup; - - WatchGroup._child(_parentWatchGroup, this._changeDetector, this.context, - this._rootGroup) - : _parentWatchGroup = _parentWatchGroup, - id = '${_parentWatchGroup.id}.${_parentWatchGroup._nextChildId++}' - { - _marker.watchGrp = this; - _evalWatchTail = _evalWatchHead = _marker; - } - - WatchGroup._root(this._changeDetector, this.context) - : id = '', - _rootGroup = null, - _parentWatchGroup = null - { - _marker.watchGrp = this; - _evalWatchTail = _evalWatchHead = _marker; - } - - get isAttached { - var group = this; - var root = _rootGroup; - while (group != null) { - if (group == root){ - return true; - } - group = group._parentWatchGroup; - } - return false; - } - - Watch watch(AST expression, ReactionFn reactionFn) { - WatchRecord<_Handler> watchRecord = expression.setupWatch(this); - return watchRecord.handler.addReactionFn(reactionFn); - } - - /** - * Watch a [name] field on [lhs] represented by [expression]. - * - * - [name] the field to watch. - * - [lhs] left-hand-side of the field. - */ - WatchRecord<_Handler> addFieldWatch(AST lhs, String name, String expression) { - var fieldHandler = new _FieldHandler(this, expression); - - // Create a Record for the current field and assign the change record - // to the handler. - var watchRecord = _changeDetector.watch(null, name, fieldHandler); - _fieldCost++; - fieldHandler.watchRecord = watchRecord; - - WatchRecord<_Handler> lhsWR = lhs.setupWatch(this); - - // We set a field forwarding handler on LHS. This will allow the change - // objects to propagate to the current WatchRecord. - lhsWR.handler.addForwardHandler(fieldHandler); - - // propagate the value from the LHS to here - fieldHandler.acceptValue(lhsWR.currentValue); - return watchRecord; - } - - WatchRecord<_Handler> addCollectionWatch(AST ast) { - var collectionHandler = new _CollectionHandler(this, ast.expression); - var watchRecord = _changeDetector.watch(null, null, collectionHandler); - _collectionCost++; - collectionHandler.watchRecord = watchRecord; - WatchRecord<_Handler> astWR = ast.setupWatch(this); - - // We set a field forwarding handler on LHS. This will allow the change - // objects to propagate to the current WatchRecord. - astWR.handler.addForwardHandler(collectionHandler); - - // propagate the value from the LHS to here - collectionHandler.acceptValue(astWR.currentValue); - return watchRecord; - } - - /** - * Watch a [fn] function represented by an [expression]. - * - * - [fn] function to evaluate. - * - [argsAST] list of [AST]es which represent arguments passed to function. - * - [expression] normalized expression used for caching. - * - [isPure] A pure function is one which holds no internal state. This implies that the - * function is idempotent. - */ - _EvalWatchRecord addFunctionWatch(Function fn, List argsAST, - Map namedArgsAST, - String expression, bool isPure) => - _addEvalWatch(null, fn, null, argsAST, namedArgsAST, expression, isPure); - - /** - * Watch a method [name]ed represented by an [expression]. - * - * - [lhs] left-hand-side of the method. - * - [name] name of the method. - * - [argsAST] list of [AST]es which represent arguments passed to method. - * - [expression] normalized expression used for caching. - */ - _EvalWatchRecord addMethodWatch(AST lhs, String name, List argsAST, - Map namedArgsAST, - String expression) => - _addEvalWatch(lhs, null, name, argsAST, namedArgsAST, expression, false); - - - - _EvalWatchRecord _addEvalWatch(AST lhsAST, Function fn, String name, - List argsAST, - Map namedArgsAST, - String expression, bool isPure) { - _InvokeHandler invokeHandler = new _InvokeHandler(this, expression); - var evalWatchRecord = new _EvalWatchRecord( - _rootGroup._fieldGetterFactory, this, invokeHandler, fn, name, - argsAST.length, isPure); - invokeHandler.watchRecord = evalWatchRecord; - - if (lhsAST != null) { - var lhsWR = lhsAST.setupWatch(this); - lhsWR.handler.addForwardHandler(invokeHandler); - invokeHandler.acceptValue(lhsWR.currentValue); - } - - // Convert the args from AST to WatchRecords - for (var i = 0; i < argsAST.length; i++) { - var ast = argsAST[i]; - WatchRecord<_Handler> record = ast.setupWatch(this); - _ArgHandler handler = new _PositionalArgHandler(this, evalWatchRecord, i); - _ArgHandlerList._add(invokeHandler, handler); - record.handler.addForwardHandler(handler); - handler.acceptValue(record.currentValue); - } - - namedArgsAST.forEach((Symbol name, AST ast) { - WatchRecord<_Handler> record = ast.setupWatch(this); - _ArgHandler handler = new _NamedArgHandler(this, evalWatchRecord, name); - _ArgHandlerList._add(invokeHandler, handler); - record.handler.addForwardHandler(handler); - handler.acceptValue(record.currentValue); - }); - - // Must be done last - _EvalWatchList._add(this, evalWatchRecord); - _evalCost++; - if (_rootGroup.isInsideInvokeDirty) { - // This check means that we are inside invoke reaction function. - // Registering a new EvalWatch at this point will not run the - // .check() on it which means it will not be processed, but its - // reaction function will be run with null. So we process it manually. - evalWatchRecord.check(); - } - return evalWatchRecord; - } - - WatchGroup get _childWatchGroupTail { - var tail = this, nextTail; - while ((nextTail = tail._watchGroupTail) != null) { - tail = nextTail; - } - return tail; - } - - /** - * Create a new child [WatchGroup]. - * - * - [context] if present the the child [WatchGroup] expressions will evaluate against the new - * [context]. If not present than child expressions will evaluate on same context. - */ - WatchGroup newGroup([Object context]) { - _EvalWatchRecord prev = _childWatchGroupTail._evalWatchTail; - _EvalWatchRecord next = prev._nextEvalWatch; - var childGroup = new WatchGroup._child( - this, - _changeDetector.newGroup(), - context == null ? this.context : context, - _rootGroup == null ? this : _rootGroup); - _WatchGroupList._add(this, childGroup); - var marker = childGroup._marker; - - marker._prevEvalWatch = prev; - marker._nextEvalWatch = next; - prev._nextEvalWatch = marker; - if (next != null) next._prevEvalWatch = marker; - - return childGroup; - } - - /** - * Remove/destroy [WatchGroup] and all of its [Watches]. - */ - void remove() { - // TODO:(misko) This code is not right. - // 1) It fails to release [ChangeDetector] [WatchRecord]s. - - _WatchGroupList._remove(_parentWatchGroup, this); - _nextWatchGroup = _prevWatchGroup = null; - _changeDetector.remove(); - _rootGroup._removeCount++; - _parentWatchGroup = null; - - // Unlink the _watchRecord - _EvalWatchRecord firstEvalWatch = _evalWatchHead; - _EvalWatchRecord lastEvalWatch = _childWatchGroupTail._evalWatchTail; - _EvalWatchRecord previous = firstEvalWatch._prevEvalWatch; - _EvalWatchRecord next = lastEvalWatch._nextEvalWatch; - if (previous != null) previous._nextEvalWatch = next; - if (next != null) next._prevEvalWatch = previous; - _evalWatchHead._prevEvalWatch = null; - _evalWatchTail._nextEvalWatch = null; - _evalWatchHead = _evalWatchTail = null; - } - - toString() { - var lines = []; - if (this == _rootGroup) { - var allWatches = []; - var watch = _evalWatchHead; - var prev = null; - while (watch != null) { - allWatches.add(watch.toString()); - assert(watch._prevEvalWatch == prev); - prev = watch; - watch = watch._nextEvalWatch; - } - lines.add('WATCHES: ${allWatches.join(', ')}'); - } - - var watches = []; - var watch = _evalWatchHead; - while (watch != _evalWatchTail) { - watches.add(watch.toString()); - watch = watch._nextEvalWatch; - } - watches.add(watch.toString()); - - lines.add('WatchGroup[$id](watches: ${watches.join(', ')})'); - var childGroup = _watchGroupHead; - while (childGroup != null) { - lines.add(' ' + childGroup.toString().replaceAll('\n', '\n ')); - childGroup = childGroup._nextWatchGroup; - } - return lines.join('\n'); - } -} - -/** - * [RootWatchGroup] - */ -class RootWatchGroup extends WatchGroup { - final FieldGetterFactory _fieldGetterFactory; - Watch _dirtyWatchHead, _dirtyWatchTail; - - /** - * Every time a [WatchGroup] is destroyed we increment the counter. During - * [detectChanges] we reset the count. Before calling the reaction function, - * we check [_removeCount] and if it is unchanged we can safely call the - * reaction function. If it is changed we only call the reaction function - * if the [WatchGroup] is still attached. - */ - int _removeCount = 0; - - - RootWatchGroup(this._fieldGetterFactory, - ChangeDetector changeDetector, - Object context) - : super._root(changeDetector, context); - - RootWatchGroup get _rootGroup => this; - - /** - * Detect changes and process the [ReactionFn]s. - * - * Algorithm: - * 1) process the [ChangeDetector#collectChanges]. - * 2) process function/closure/method changes - * 3) call an [ReactionFn]s - * - * Each step is called in sequence. ([ReactionFn]s are not called until all - * previous steps are completed). - */ - int detectChanges({ EvalExceptionHandler exceptionHandler, - ChangeLog changeLog, - AvgStopwatch fieldStopwatch, - AvgStopwatch evalStopwatch, - AvgStopwatch processStopwatch}) { - // Process the Records from the change detector - var sDetect = traceEnter(ChangeDetector_check); - var sFields = traceEnter(ChangeDetector_fields); - Iterator> changedRecordIterator = - (_changeDetector as ChangeDetector<_Handler>).collectChanges( - exceptionHandler:exceptionHandler, - stopwatch: fieldStopwatch); - if (processStopwatch != null) processStopwatch.start(); - while (changedRecordIterator.moveNext()) { - var record = changedRecordIterator.current; - if (changeLog != null) changeLog(record.handler.expression, - record.currentValue, - record.previousValue); - record.handler.onChange(record); - } - traceLeave(sFields); - if (processStopwatch != null) processStopwatch.stop(); - - if (evalStopwatch != null) evalStopwatch.start(); - // Process our own function evaluations - _EvalWatchRecord evalRecord = _evalWatchHead; - var sEval = traceEnter(ChangeDetector_eval); - int evalCount = 0; - while (evalRecord != null) { - try { - if (evalStopwatch != null) evalCount++; - if (evalRecord.check() && changeLog != null) { - changeLog(evalRecord.handler.expression, - evalRecord.currentValue, - evalRecord.previousValue); - } - } catch (e, s) { - if (exceptionHandler == null) rethrow; else exceptionHandler(e, s); - } - evalRecord = evalRecord._nextEvalWatch; - } - - traceLeave(sEval); - traceLeave(sDetect); - if (evalStopwatch != null) evalStopwatch..stop()..increment(evalCount); - - // Because the handler can forward changes between each other synchronously - // We need to call reaction functions asynchronously. This processes the - // asynchronous reaction function queue. - var sReaction = traceEnter(ChangeDetector_reaction); - int count = 0; - if (processStopwatch != null) processStopwatch.start(); - Watch dirtyWatch = _dirtyWatchHead; - _dirtyWatchHead = null; - RootWatchGroup root = _rootGroup; - try { - while (dirtyWatch != null) { - count++; - try { - if (root._removeCount == 0 || dirtyWatch._watchGroup.isAttached) { - dirtyWatch.invoke(); - } - } catch (e, s) { - if (exceptionHandler == null) rethrow; else exceptionHandler(e, s); - } - var nextDirtyWatch = dirtyWatch._nextDirtyWatch; - dirtyWatch._nextDirtyWatch = null; - dirtyWatch = nextDirtyWatch; - } - } finally { - _dirtyWatchTail = null; - root._removeCount = 0; - } - traceLeaveVal(sReaction, count); - if (processStopwatch != null) processStopwatch..stop()..increment(count); - return count; - } - - bool get isInsideInvokeDirty => - _dirtyWatchHead == null && _dirtyWatchTail != null; - - /** - * Add Watch into the asynchronous queue for later processing. - */ - Watch _addDirtyWatch(Watch watch) { - if (!watch._dirty) { - watch._dirty = true; - if (_dirtyWatchTail == null) { - _dirtyWatchHead = _dirtyWatchTail = watch; - } else { - _dirtyWatchTail._nextDirtyWatch = watch; - _dirtyWatchTail = watch; - } - watch._nextDirtyWatch = null; - } - return watch; - } -} - -/** - * [Watch] corresponds to an individual [watch] registration on the watchGrp. - */ -class Watch { - Watch _previousWatch, _nextWatch; - - final Record<_Handler> _record; - final ReactionFn reactionFn; - final WatchGroup _watchGroup; - - bool _dirty = false; - bool _deleted = false; - Watch _nextDirtyWatch; - - Watch(this._watchGroup, this._record, this.reactionFn); - - get expression => _record.handler.expression; - void invoke() { - if (_deleted || !_dirty) return; - _dirty = false; - var s = traceEnabled ? traceEnter1(ChangeDetector_invoke, expression) : null; - try { - reactionFn(_record.currentValue, _record.previousValue); - } finally { - if (traceEnabled) traceLeave(s); - } - } - - void remove() { - if (_deleted) throw new StateError('Already deleted!'); - _deleted = true; - var handler = _record.handler; - _WatchList._remove(handler, this); - handler.release(); - } -} - -/** - * This class processes changes from the change detector. The changes are - * forwarded onto the next [_Handler] or queued up in case of reaction function. - * - * Given these two expression: 'a.b.c' => rfn1 and 'a.b' => rfn2 - * The resulting data structure is: - * - * _Handler +--> _Handler +--> _Handler - * - delegateHandler -+ - delegateHandler -+ - delegateHandler = null - * - expression: 'a' - expression: 'a.b' - expression: 'a.b.c' - * - watchObject: context - watchObject: context.a - watchObject: context.a.b - * - watchRecord: 'a' - watchRecord 'b' - watchRecord 'c' - * - reactionFn: null - reactionFn: rfn1 - reactionFn: rfn2 - * - * Notice how the [_Handler]s coalesce their watching. Also notice that any - * changes detected at one handler are propagated to the next handler. - */ -abstract class _Handler implements _LinkedList, _LinkedListItem, _WatchList { - // Used for forwarding changes to delegates - _Handler _head, _tail; - _Handler _next, _previous; - Watch _watchHead, _watchTail; - - final String expression; - final WatchGroup watchGrp; - - WatchRecord<_Handler> watchRecord; - _Handler forwardingHandler; - - _Handler(this.watchGrp, this.expression) { - assert(watchGrp != null); - assert(expression != null); - } - - Watch addReactionFn(ReactionFn reactionFn) { - assert(_next != this); // verify we are not detached - return watchGrp._rootGroup._addDirtyWatch(_WatchList._add(this, - new Watch(watchGrp, watchRecord, reactionFn))); - } - - void addForwardHandler(_Handler forwardToHandler) { - assert(forwardToHandler.forwardingHandler == null); - _LinkedList._add(this, forwardToHandler); - forwardToHandler.forwardingHandler = this; - } - - /// Return true if release has happened - bool release() { - if (_WatchList._isEmpty(this) && _LinkedList._isEmpty(this)) { - _releaseWatch(); - - if (forwardingHandler != null) { - // TODO(misko): why do we need this check? - _LinkedList._remove(forwardingHandler, this); - forwardingHandler.release(); - } - - // We can remove ourselves - assert((_next = _previous = this) == this); // mark ourselves as detached - return true; - } else { - return false; - } - } - - void _releaseWatch() { - watchRecord.remove(); - watchGrp._fieldCost--; - } - acceptValue(object) => null; - - void onChange(Record<_Handler> record) { - assert(_next != this); // verify we are not detached - // If we have reaction functions than queue them up for asynchronous - // processing. - Watch watch = _watchHead; - while (watch != null) { - watchGrp._rootGroup._addDirtyWatch(watch); - watch = watch._nextWatch; - } - // If we have a delegateHandler then forward the new value to it. - _Handler delegateHandler = _head; - while (delegateHandler != null) { - delegateHandler.acceptValue(record.currentValue); - delegateHandler = delegateHandler._next; - } - } -} - -class _ConstantHandler extends _Handler { - _ConstantHandler(WatchGroup watchGroup, String expression, constantValue) - : super(watchGroup, expression) - { - watchRecord = new _EvalWatchRecord.constant(this, constantValue); - } - release() => null; -} - -class _FieldHandler extends _Handler { - _FieldHandler(watchGrp, expression): super(watchGrp, expression); - - /** - * This function forwards the watched object to the next [_Handler] - * synchronously. - */ - void acceptValue(object) { - watchRecord.object = object; - if (watchRecord.check()) onChange(watchRecord); - } -} - -class _CollectionHandler extends _Handler { - _CollectionHandler(WatchGroup watchGrp, String expression) - : super(watchGrp, expression); - /** - * This function forwards the watched object to the next [_Handler] synchronously. - */ - void acceptValue(object) { - watchRecord.object = object; - if (watchRecord.check()) onChange(watchRecord); - } - - void _releaseWatch() { - watchRecord.remove(); - watchGrp._collectionCost--; - } -} - -abstract class _ArgHandler extends _Handler { - _ArgHandler _previousArgHandler, _nextArgHandler; - - // TODO(misko): Why do we override parent? - final _EvalWatchRecord watchRecord; - _ArgHandler(WatchGroup watchGrp, String expression, this.watchRecord) - : super(watchGrp, expression); - - _releaseWatch() => null; -} - -class _PositionalArgHandler extends _ArgHandler { - static final List _ARGS = new List.generate(20, (index) => 'arg[$index]'); - final int index; - _PositionalArgHandler(WatchGroup watchGrp, _EvalWatchRecord record, int index) - : this.index = index, - super(watchGrp, _ARGS[index], record); - - void acceptValue(object) { - watchRecord.dirtyArgs = true; - watchRecord.args[index] = object; - } -} - -class _NamedArgHandler extends _ArgHandler { - static final Map _NAMED_ARG = new HashMap(); - static String _GET_NAMED_ARG(Symbol symbol) { - String name = _NAMED_ARG[symbol]; - if (name == null) name = _NAMED_ARG[symbol] = 'namedArg[$name]'; - return name; - } - final Symbol name; - - _NamedArgHandler(WatchGroup watchGrp, _EvalWatchRecord record, Symbol name) - : this.name = name, - super(watchGrp, _GET_NAMED_ARG(name), record); - - void acceptValue(object) { - if (watchRecord.namedArgs == null) { - watchRecord.namedArgs = new HashMap(); - } - watchRecord.dirtyArgs = true; - watchRecord.namedArgs[name] = object; - } -} - -class _InvokeHandler extends _Handler implements _ArgHandlerList { - _ArgHandler _argHandlerHead, _argHandlerTail; - - _InvokeHandler(WatchGroup watchGrp, String expression) - : super(watchGrp, expression); - - void acceptValue(object) { - watchRecord.object = object; - } - - void _releaseWatch() { - (watchRecord as _EvalWatchRecord).remove(); - } - - bool release() { - if (super.release()) { - _ArgHandler current = _argHandlerHead; - while (current != null) { - current.release(); - current = current._nextArgHandler; - } - return true; - } else { - return false; - } - } -} - - -class _EvalWatchRecord implements WatchRecord<_Handler> { - static const int _MODE_INVALID_ = -2; - static const int _MODE_DELETED_ = -1; - static const int _MODE_MARKER_ = 0; - static const int _MODE_PURE_FUNCTION_ = 1; - static const int _MODE_FUNCTION_ = 2; - static const int _MODE_PURE_FUNCTION_APPLY_ = 3; - static const int _MODE_NULL_ = 4; - static const int _MODE_FIELD_OR_METHOD_CLOSURE_ = 5; - static const int _MODE_METHOD_ = 6; - static const int _MODE_FIELD_CLOSURE_ = 7; - static const int _MODE_MAP_CLOSURE_ = 8; - WatchGroup watchGrp; - final _Handler handler; - final List args; - Map namedArgs = null; - final String name; - int mode; - Function fn; - FieldGetterFactory _fieldGetterFactory; - bool dirtyArgs = true; - - dynamic currentValue, previousValue, _object; - _EvalWatchRecord _prevEvalWatch, _nextEvalWatch; - - _EvalWatchRecord(this._fieldGetterFactory, this.watchGrp, this.handler, - this.fn, this.name, int arity, bool pure) - : args = new List(arity) - { - if (fn is FunctionApply) { - mode = pure ? _MODE_PURE_FUNCTION_APPLY_: _MODE_INVALID_; - } else if (fn is Function) { - mode = pure ? _MODE_PURE_FUNCTION_ : _MODE_FUNCTION_; - } else { - mode = _MODE_NULL_; - } - } - - _EvalWatchRecord.marker() - : mode = _MODE_MARKER_, - _fieldGetterFactory = null, - watchGrp = null, - handler = null, - args = null, - fn = null, - name = null; - - _EvalWatchRecord.constant(_Handler handler, dynamic constantValue) - : mode = _MODE_MARKER_, - _fieldGetterFactory = null, - handler = handler, - currentValue = constantValue, - watchGrp = null, - args = null, - fn = null, - name = null; - - get field => '()'; - - get object => _object; - - set object(value) { - assert(mode != _MODE_DELETED_); - assert(mode != _MODE_MARKER_); - assert(mode != _MODE_FUNCTION_); - assert(mode != _MODE_PURE_FUNCTION_); - assert(mode != _MODE_PURE_FUNCTION_APPLY_); - _object = value; - - if (value == null) { - mode = _MODE_NULL_; - } else { - if (value is Map) { - mode = _MODE_MAP_CLOSURE_; - } else { - mode = _MODE_FIELD_OR_METHOD_CLOSURE_; - fn = _fieldGetterFactory.getter(value, name); - } - } - } - - bool check() { - var value; - switch (mode) { - case _MODE_MARKER_: - case _MODE_NULL_: - return false; - case _MODE_PURE_FUNCTION_: - if (!dirtyArgs) return false; - value = Function.apply(fn, args, namedArgs); - dirtyArgs = false; - break; - case _MODE_FUNCTION_: - value = Function.apply(fn, args, namedArgs); - dirtyArgs = false; - break; - case _MODE_PURE_FUNCTION_APPLY_: - if (!dirtyArgs) return false; - value = (fn as FunctionApply).apply(args); - dirtyArgs = false; - break; - case _MODE_FIELD_OR_METHOD_CLOSURE_: - var closure = fn(_object); - // NOTE: When Dart looks up a method "foo" on object "x", it returns a - // new closure for each lookup. They compare equal via "==" but are no - // identical(). There's no point getting a new value each time and - // decide it's the same so we'll skip further checking after the first - // time. - if (closure is Function && !identical(closure, fn(_object))) { - fn = closure; - mode = _MODE_METHOD_; - } else { - mode = _MODE_FIELD_CLOSURE_; - } - value = (closure == null) ? null : Function.apply(closure, args, namedArgs); - break; - case _MODE_METHOD_: - value = Function.apply(fn, args, namedArgs); - break; - case _MODE_FIELD_CLOSURE_: - var closure = fn(_object); - value = (closure == null) ? null : Function.apply(closure, args, namedArgs); - break; - case _MODE_MAP_CLOSURE_: - var closure = object[name]; - value = (closure == null) ? null : Function.apply(closure, args, namedArgs); - break; - default: - assert(false); - } - - var current = currentValue; - if (!identical(current, value)) { - if (value is String && current is String && value == current) { - // it is really the same, recover and save so next time identity is same - current = value; - } else if (value is num && value.isNaN && current is num && current.isNaN) { - // we need this for the compiled JavaScript since in JS NaN !== NaN. - } else { - previousValue = current; - currentValue = value; - handler.onChange(this); - return true; - } - } - return false; - } - - get nextChange => null; - - void remove() { - assert(mode != _MODE_DELETED_); - assert((mode = _MODE_DELETED_) == _MODE_DELETED_); // Mark as deleted. - watchGrp._evalCost--; - _EvalWatchList._remove(watchGrp, this); - } - - String toString() { - if (mode == _MODE_MARKER_) return 'MARKER[$currentValue]'; - return '${watchGrp.id}:${handler.expression}'; - } -} diff --git a/lib/change_detection/watch_group_dynamic.dart b/lib/change_detection/watch_group_dynamic.dart deleted file mode 100644 index 78b8f4c52..000000000 --- a/lib/change_detection/watch_group_dynamic.dart +++ /dev/null @@ -1,4 +0,0 @@ -library watch_group_dynamic; - -import 'package:angular/change_detection/watch_group.dart'; - diff --git a/lib/change_detection/watch_group_static.dart b/lib/change_detection/watch_group_static.dart deleted file mode 100644 index 817d6a984..000000000 --- a/lib/change_detection/watch_group_static.dart +++ /dev/null @@ -1,4 +0,0 @@ -library watch_group_static; - -import 'package:angular/change_detection/watch_group.dart'; - diff --git a/lib/change_detection/ast.dart b/lib/change_detector/ast.dart similarity index 53% rename from lib/change_detection/ast.dart rename to lib/change_detector/ast.dart index a18bc5b30..a0f8d74aa 100644 --- a/lib/change_detection/ast.dart +++ b/lib/change_detector/ast.dart @@ -1,5 +1,4 @@ -part of angular.watch_group; - +part of angular.change_detector; /** * RULES: @@ -10,14 +9,17 @@ abstract class AST { static final String _CONTEXT = '#'; final String expression; var parsedExp; // The parsed version of expression. + AST(expression) : expression = expression.startsWith('#.') ? expression.substring(2) : expression { - assert(expression!=null); + assert(expression != null); } - WatchRecord<_Handler> setupWatch(WatchGroup watchGroup); + + Record setupRecord(WatchGroup watchGroup); + String toString() => expression; } @@ -28,8 +30,9 @@ abstract class AST { */ class ContextReferenceAST extends AST { ContextReferenceAST(): super(AST._CONTEXT); - WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) => - new _ConstantWatchRecord(watchGroup, expression, watchGroup.context); + + Record setupRecord(WatchGroup watchGroup) => + watchGroup.addConstantRecord(expression, watchGroup._context); } /** @@ -46,8 +49,7 @@ class ConstantAST extends AST { ? constant is String ? '"$constant"' : '$constant' : expression); - WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) => - new _ConstantWatchRecord(watchGroup, expression, constant); + Record setupRecord(WatchGroup watchGroup) => watchGroup.addConstantRecord(expression, constant); } /** @@ -56,16 +58,16 @@ class ConstantAST extends AST { * This is the '.' dot operator. */ class FieldReadAST extends AST { - AST lhs; + AST lhsAST; final String name; - FieldReadAST(lhs, name) - : lhs = lhs, + FieldReadAST(lhsAST, name) + : lhsAST = lhsAST, name = name, - super('$lhs.$name'); + super('$lhsAST.$name'); - WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) => - watchGroup.addFieldWatch(lhs, name, expression); + Record setupRecord(WatchGroup watchGroup) => + watchGroup.addFieldRecord(lhsAST, name, expression); } /** @@ -82,10 +84,10 @@ class PureFunctionAST extends AST { PureFunctionAST(name, this.fn, argsAST) : argsAST = argsAST, name = name, - super('$name(${_argList(argsAST)})'); + super(_fnToString(name, argsAST)); - WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) => - watchGroup.addFunctionWatch(fn, argsAST, const {}, expression, true); + Record setupRecord(WatchGroup watchGroup) => + watchGroup.addFunctionRecord(fn, argsAST, const {}, expression, true); } /** @@ -101,10 +103,10 @@ class ClosureAST extends AST { ClosureAST(name, this.fn, argsAST) : argsAST = argsAST, name = name, - super('$name(${_argList(argsAST)})'); + super(_fnToString(name, argsAST)); - WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) => - watchGroup.addFunctionWatch(fn, argsAST, const {}, expression, false); + Record setupRecord(WatchGroup watchGroup) => + watchGroup.addFunctionRecord(fn, argsAST, const {}, expression, false); } /** @@ -122,46 +124,23 @@ class MethodAST extends AST { : lhsAST = lhsAST, name = name, argsAST = argsAST, - super('$lhsAST.$name(${_argList(argsAST)})'); + super('$lhsAST.${_fnToString(name, argsAST)}'); - WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) => - watchGroup.addMethodWatch(lhsAST, name, argsAST, namedArgsAST, expression); + Record setupRecord(WatchGroup watchGroup) => + watchGroup.addMethodRecord(lhsAST, name, argsAST, namedArgsAST, expression); } - class CollectionAST extends AST { final AST valueAST; + CollectionAST(valueAST) : valueAST = valueAST, super('#collection($valueAST)'); - WatchRecord<_Handler> setupWatch(WatchGroup watchGroup) => - watchGroup.addCollectionWatch(valueAST); + Record setupRecord(WatchGroup watchGroup) => watchGroup.addCollectionRecord(this); } -String _argList(List items) => items.join(', '); +String _fnToString(String name, List items) => name + '(' + items.join(', ') + ')'; + -/** - * The name is a bit oxymoron, but it is essentially the NullObject pattern. - * - * This allows children to set a handler on this Record and then let it write - * the initial constant value to the forwarding Record. - */ -class _ConstantWatchRecord extends WatchRecord<_Handler> { - final currentValue; - final _Handler handler; - - _ConstantWatchRecord(WatchGroup watchGroup, String expression, currentValue) - : currentValue = currentValue, - handler = new _ConstantHandler(watchGroup, expression, currentValue); - - bool check() => false; - void remove() => null; - - get field => null; - get previousValue => null; - get object => null; - set object(_) => null; - get nextChange => null; -} diff --git a/lib/change_detection/ast_parser.dart b/lib/change_detector/ast_parser.dart similarity index 96% rename from lib/change_detection/ast_parser.dart rename to lib/change_detector/ast_parser.dart index 898faa9c3..a6b4b7426 100644 --- a/lib/change_detection/ast_parser.dart +++ b/lib/change_detector/ast_parser.dart @@ -1,4 +1,4 @@ -library angular.change_detection.ast_parser; +library angular.change_detector.ast_parser; import 'dart:collection'; @@ -6,9 +6,7 @@ import 'package:di/annotations.dart'; import 'package:angular/core/parser/syntax.dart' as syntax; import 'package:angular/core/parser/parser.dart'; import 'package:angular/core/formatter.dart'; -import 'package:angular/core/annotation_src.dart'; -import 'package:angular/change_detection/watch_group.dart'; -import 'package:angular/change_detection/change_detection.dart'; +import 'package:angular/change_detector/change_detector.dart'; import 'package:angular/core/parser/utils.dart'; class _FunctionChain { @@ -193,8 +191,9 @@ _operation_bitwise_and(left, right) => (left == null || right == null _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) { +dynamic _operation_ternary(condition, yes, no) => toBool(condition) ? yes : no; + +dynamic _operation_bracket(obj, key) { if (obj != null && ( obj is! List || (key is int && key >= 0 && key < obj.length))) { return obj[key]; diff --git a/lib/change_detector/change_detector.dart b/lib/change_detector/change_detector.dart new file mode 100644 index 000000000..d52616a21 --- /dev/null +++ b/lib/change_detector/change_detector.dart @@ -0,0 +1,1126 @@ +library angular.change_detector; + +// TODO: +// - Why do we need to watch "attach(count++)" in element_binder l218 + +// TODO: check for recent changes in: +// - dccd +// - watch group +// Checked on 2014.09.04 +// -> disable coalescence (make it optional, add test for both cases) + +// TODO: +// - re-enable tracing +// - re-enable stopwatches + +// TODO: +// +// - It should no more be possible to add a watch during a rfn. This should allow simplifying the +// code (the processChanges() loop, and the logic to get the next checkable record). +// - It should no more be possible to remove a watch group from inside a rfn. We should assert that +// the current watch group has not been detached when returning from a rfn. + + +// TODO: +// Currently the (H&T) markers participate in the checkable & releasable lists. The code could be +// more efficient if the markers are not part of those lists. +// Note: For efficiency when manipulating groups, the marker should still have their checkNext +// field pointing to the first checkable record in the group (releaseNext/releasable Marker) +// +// Also each group has its own head & tail markers, it could be possible to coalesce head-tail +// but that implies that the head/tail marker of a group could be updated when a group is removed +// or added - this had been implemented but reverted for now until Proto are implemented + + +// TODO: +// misko: replace _hasFreshListener by initializing _value with a unique value (ie this), should allow +// avoiding to read the mode field +// victor: only possible once Proto has been implemented, for now a listener can be added at any point +// to a record and its `_value` could not be changed to something different from the current value + +// TODO: implement with ProtoWatchGroup / ProtoRecord +// We need to talk about this more in depth. But my latest thinking is that we can not create +// WatchGroup with context. The context needs to be something which we can assign later. The reason +// for this is that we want to be able to reset the context at runtime. The benefit would be to be +// able to reuse an instance of WatchGroup for performance reasons. Let's discus. + +import 'dart:collection'; + +part 'ast.dart'; +part 'map_changes.dart'; +part 'collection_changes.dart'; +part 'prototype_map.dart'; + +typedef void EvalExceptionHandler(error, stack); + +typedef dynamic FieldGetter(object); +typedef void FieldSetter(object, value); + +typedef void ReactionFn(value, previousValue); +typedef void ChangeLog(String expression, current, previous); + +abstract class FieldGetterFactory { + FieldGetter getter(Object object, String name); +} + +class AvgStopwatch extends Stopwatch { + int _count = 0; + + int get count => _count; + + void reset() { + _count = 0; + super.reset(); + } + + int increment(int count) => _count += count; + + double get ratePerMs => elapsedMicroseconds == 0 + ? 0.0 + : _count / elapsedMicroseconds * 1000; +} + +/** + * Extend this class if you wish to pretend to be a function, but you don't know + * number of arguments with which the function will get called with. + */ +abstract class FunctionApply { + dynamic call() { throw new StateError('Use apply()'); } + dynamic apply(List arguments); +} + +/** + * [ChangeDetector] allows creating root watch groups holding the [Watch]es + */ +class ChangeDetector { + static int _nextRootId = 0; + + final FieldGetterFactory _fieldGetterFactory; + + ChangeDetector(this._fieldGetterFactory); + + /// Creates a root watch group + WatchGroup createWatchGroup(Object context) => new WatchGroup(null, context, _fieldGetterFactory); +} + +// TODO: add toString +class WatchGroup { + Record _headMarker, _tailMarker; + + WatchGroup _root; + WatchGroup _parent; + WatchGroup _next, _prev; + WatchGroup _childHead, _childTail; + + Object _context; + + String _id; + String get id => _id; + + int _nextChildId = 0; + + // We need to keep track of whether we are processing changes because in such a case the added + // records must have their context initialized as they will be processed in the same cycle + bool _processingChanges = false; + + // maps the currently watched expressions to their record to allow coalescence + final _recordCache = new HashMap(); + + final FieldGetterFactory _fieldGetterFactory; + + // TODO: Do we really need this ? (keep the API simple) + // Watches in this group + int _watchedFields = 0; + int _watchedCollections = 0; + int _watchedEvals = 0; + int get watchedFields => _watchedFields; + int get watchedCollections => _watchedCollections; + int get watchedEvals => _watchedEvals; + // TODO: test + int get checkedRecords { + int count = 0; + for (Record record= _headMarker._checkNext; + record != _tailMarker; + record = record._checkNext) { + count++; + } + return count; + } + + /// Number of watched fields in this group and child groups + int get totalWatchedFields { + var count = _watchedFields; + for (WatchGroup child = _childHead; child != null; child = child._next) { + count += child.totalWatchedFields; + } + return count; + } + + /// Number of watched collections in this group and child groups + int get totalWatchedCollections { + var count = _watchedCollections; + for (WatchGroup child = _childHead; child != null; child = child._next) { + count += child.totalWatchedCollections; + } + return count; + } + + /// Number of watched evals in this group and child groups + int get totalWatchedEvals { + var count = _watchedEvals; + for (WatchGroup child = _childHead; child != null; child = child._next) { + count += child.totalWatchedEvals; + } + return count; + } + + /// Number of [Record]s in this group and child groups + int get totalCount { + var count = 0; + for (Record current = _headMarker._next; current != _tailMarker; current = current._next) { + count++; + } + for (WatchGroup child = _childHead; child != null; child = child._next) { + count += child.totalCount; + } + return count; + } + + /// Number of checked [Record]s in this group and child groups + int get totalCheckedRecords { + int count = 0; + for (Record record= _headMarker._checkNext; + record != _tailMarkerIncludingChildren; + record = record._checkNext) { + count++; + } + return count; + } + + WatchGroup(this._parent, this._context, this._fieldGetterFactory) { + _tailMarker = new Record._marker(this); + _headMarker = new Record._marker(this); + + // Cross link the head and tail markers + _headMarker._next = _tailMarker; + _headMarker._releasableNext = _tailMarker; + _headMarker._checkNext = _tailMarker; + + _tailMarker._prev = _headMarker; + _tailMarker._checkPrev = _headMarker; + + if (_parent == null) { + _root = this; + _id = '${ChangeDetector._nextRootId++}'; + } else { + _id = '${_parent._id}.${_parent._nextChildId++}'; + _root = _parent._root; + // Re-use the previous group [_tailMarker] as our head + var prevGroupTail = _parent._tailMarkerIncludingChildren; + + assert(prevGroupTail == null || prevGroupTail.isMarker); + + _tailMarker._releasableNext = prevGroupTail._releasableNext; + _tailMarker._checkNext = prevGroupTail._checkNext; + _tailMarker._next = prevGroupTail._next; + + _headMarker._prev = prevGroupTail; + _headMarker._checkPrev = prevGroupTail; + + prevGroupTail._next = _headMarker; + prevGroupTail._checkNext = _headMarker; + prevGroupTail._releasableNext = _headMarker; + + if (_tailMarker._next != null) { + _tailMarker._next._prev = _tailMarker; + _tailMarker._checkNext._checkPrev = _tailMarker; + } + + // Link this group in the parent's group list + if (_parent._childHead == null) { + _parent._childHead = this; + _parent._childTail = this; + } else { + // Append the child group at the end of the child list + _parent._childTail._next = this; + _prev = _parent._childTail; + _parent._childTail = this; + } + } + } + + /// Creates a child [WatchGroup] + WatchGroup createChild([Object context]) => + new WatchGroup(this, context == null ? _context : context, _fieldGetterFactory); + + /// Watches the [AST] and invoke the [reactionFn] when a change is detected during a call to + /// [processChanges] + Watch watch(AST ast, reactionFn) { + Record trigger = _getRecordFor(ast); + return new Watch(trigger, reactionFn); + } + + /// Calls reaction functions when a watched [AST] value has been modified since last call + int processChanges({EvalExceptionHandler exceptionHandler, ChangeLog changeLog}) { + _processingChanges = true; + int changes = 0; + // We need to keep a reference on the previously checked record to find out the next one + List checkPrevs = [_headMarker]; + Record record = _headMarker._checkNext; + while(record != _tailMarkerIncludingChildren) { + try { + changes += record.processChange(changeLog: changeLog); + } catch (e, s) { + if (exceptionHandler == null) { + rethrow; + } else { + exceptionHandler(e, s); + } + } + + // TODO: It is no more possible to add a watch during a rfn so this code could be simplified + + // TODO: assert that the watch group is not removed (only needed when returning from a rfn) + + if (record.removeFromCheckQueue) { + // If the record gets removed from the check queue, do not update `checkPrev` + if (record.isChecked) record.$removeFromCheckQueue(); + } else if (record._checkNext != null) { + // Update `checkPrev` to be the current record unless it's no more checked (removed from + // inside the reaction function) + checkPrevs.add(record); + } + + // To find the next checkable record, we get hold of the `_checkNext` of the previous + // checkable record - it is not always the last checked record as a checked record could be + // removed from the check queue + record = checkPrevs.reversed.firstWhere((record) => record._checkNext != null)._checkNext; + } + + _processingChanges = false; + return changes; + } + + /// Whether the group is currently attached (=active) + bool get isAttached { + WatchGroup group = this; + while (group._parent != null) { + group = group._parent; + } + return group == _root; + } + + /// De-activate the group and free any underlying resources + void remove() { + if (this == _root) throw new StateError('Root ChangeDetector can not be removed'); + + // Release the resources associated with the records + for (Record record = _headMarker._releasableNext; + record != _tailMarkerIncludingChildren; + record = record._releasableNext) { + record.release(); + } + + _recordCache.clear(); + + // Unlink the records + var nextGroupHead = _tailMarkerIncludingChildren._next; + var prevGroupTail = _headMarker._prev; + + assert(nextGroupHead == null || nextGroupHead.isMarker); + assert(prevGroupTail == null || prevGroupTail.isMarker); + + if (prevGroupTail != null) { + prevGroupTail._next = nextGroupHead; + prevGroupTail._checkNext = nextGroupHead; + prevGroupTail._releasableNext = nextGroupHead; + } + + if (nextGroupHead != null) { + nextGroupHead._prev = prevGroupTail; + nextGroupHead._checkPrev = prevGroupTail; + } + + // Unlink the group + var prevGroup = _prev; + var nextGroup = _next; + + if (prevGroup == null) { + if (_parent != null) _parent._childHead = nextGroup; + } else { + prevGroup._next = nextGroup; + } + + if (nextGroup == null) { + if (_parent != null) _parent._childTail = prevGroup; + } else { + nextGroup._prev = prevGroup; + } + + _parent = null; + _prev = null; + _next = null; + } + + /// Called when setting up a watch to watch a constant + Record addConstantRecord(String expression, value) { + Record record = new Record.constant(this, expression, value); + _addRecord(record); + return record; + } + + /// Called when setting up a watch to watch a field + Record addFieldRecord(AST lhs, String name, String expression) { + Record lhsRecord = _getRecordFor(lhs); + _watchedFields++; + Record fieldRecord = new Record.field(this, expression, name); + _addRecord(fieldRecord); + lhsRecord._addListener(fieldRecord); + return fieldRecord; + } + + /// Called when setting up a watch to watch a collection (`Map` or `Iterable`) + Record addCollectionRecord(CollectionAST ast) { + Record valueRecord = _getRecordFor(ast.valueAST); + _watchedCollections++; + Record fieldRecord = new Record.collection(this, ast.expression); + _addRecord(fieldRecord); + valueRecord._addListener(fieldRecord); + + return fieldRecord; + } + + /// Called when setting up a watch to watch a function + Record addFunctionRecord(Function fn, List argsAST, Map namedArgsAST, + String expression, bool isPure) => + _addEvalRecord(null, fn, null, argsAST, namedArgsAST, expression, isPure); + + /// Called when setting up a watch to watch a method + Record addMethodRecord(AST lhs, String name, List argsAST, Map namedArgsAST, + String expression) => + _addEvalRecord(lhs, null, name, argsAST, namedArgsAST, expression, false); + + Record _addEvalRecord(AST lhs, Function fn, String name, List argsAST, + Map namedArgsAST, String expression, bool isPure) { + _watchedEvals++; + Record evalRecord = new Record.eval(this, expression, fn, name, argsAST.length, isPure); + + var argsListeners = []; + + // Trigger an evaluation of the eval when a positional parameter changes + for (var i = 0; i < argsAST.length; i++) { + var ast = argsAST[i]; + Record record = _getRecordFor(ast); + var listener = new PositionalArgumentListener(evalRecord, i); + record._addListener(listener); + argsListeners.add(listener); + } + + // Trigger an evaluation of the eval when a named parameter changes + if (namedArgsAST.isNotEmpty) { + evalRecord._namedArgs = new HashMap(); + namedArgsAST.forEach((Symbol symbol, AST ast) { + evalRecord._namedArgs[symbol] = null; + Record record = _getRecordFor(ast); + var listener = new NamedArgumentListener(evalRecord, symbol); + record._addListener(listener); + argsListeners.add(listener); + }); + } + + if (lhs != null) { + var lhsRecord = _getRecordFor(lhs); + _addRecord(evalRecord); + lhsRecord._addListener(evalRecord); + } else { + _addRecord(evalRecord); + } + + // Release the arguments listeners when the eval listener is released + if (argsListeners.isNotEmpty) { + evalRecord._releaseFn = (_) { + for (ChangeListener listener in argsListeners) { + listener.remove(); + } + }; + } + + return evalRecord; + } + + void _addRecord(Record record) { + // Insert the record right before the tail + record._prev = _tailMarker._prev; + record._prev._next = record; + record._next = _tailMarker; + _tailMarker._prev = record; + // Add the record to the check queue + // Records must be checked at least when they are added then they might be removed from the + // check queue ie if they use notification. + record._moveToCheckQueue(); + } + + /// Get the record for the given [AST] from the cache. Add it on miss. + Record _getRecordFor(AST ast) { + String expression = ast.expression; + Record record = _recordCache[expression]; + // We can only share records for collection when they have not yet fired. After they have first + // fired the underlying `CollectionChangeRecord` or `MapChangeRecord` is initialized and can + // not be re-used. + // TODO: should the following be optimized - see once Proto has been implemented + // Because a collection can be embedded in any AST (ie `|stringify(#collection(foo))`) it is not + // possible to re-use the record if it has already fired as the collection changes would not be + // detected properly. The current implementation only re-use the record when it has not fired + // yet (which is not optimal if the AST does not contain a collection). + if (record == null || record._hasFired) { + record = ast.setupRecord(this); + _recordCache[expression] = record; + } + return record; + } + + /// Returns the tail marker for the last child group + Record get _tailMarkerIncludingChildren { + var lastChild = this; + while (lastChild._childTail != null) { + lastChild = lastChild._childTail; + } + return lastChild._tailMarker; + } +} + +class Watch extends ChangeListener { + Function _reactionFn; + + Watch(Record triggerRecord, this._reactionFn) { + triggerRecord._addListener(this); + } + + String get expression => _triggerRecord._expression; + + /// Calls the [_reactionFn] when the observed [Record] value changes + void _onChange(value, previous) { + assert(_triggerRecord._watchGroup.isAttached); + super._onChange(value, previous); + _reactionFn(value, previous); + } + + /// Removes this watch from its [WacthGroup] + void remove() { + if (_triggerRecord == null) throw "Already deleted!"; + super.remove(); + } +} + +/// [ChangeListener] listens on [Record] changes +abstract class ChangeListener { + Record _triggerRecord; + ChangeListener _listenerNext; + // We need to keep track of the listener who have not fired yet to fire them on the first + // cycle after they have been added + bool _hasFired = false; + + /// De-registers this change listener + /// Calling [remove] might result in [Record]s being removed when they were only used by the + /// current listener. + void remove() { + if (_triggerRecord != null) { + // The [WatchGroup] has been detached, no need to remove individual [Record] / [Watch] + if (!_triggerRecord._watchGroup.isAttached) return; + if (identical(_triggerRecord._listenerHead, this)) { + _triggerRecord._listenerHead = _listenerNext; + if (_triggerRecord._listenerHead == null) { + // The trigger record does not trigger any listeners any more, remove it + _triggerRecord.remove(); + } + } else { + ChangeListener currentListener = _triggerRecord._listenerHead; + while (!identical(currentListener._listenerNext, this)) { + currentListener = currentListener._listenerNext; + assert(currentListener != null); + } + // Unlink the listener (either a [Record] or a [Watch]) + currentListener._listenerNext = _listenerNext; + } + _triggerRecord = null; + _listenerNext = null; + } + } + + /// Called when the [_triggerRecord] value changes + void _onChange(value, previous) { + _hasFired = true; + } +} + +/// A `PositionalArgumentListener` is created for each of the function positional argument. +/// Its role is to forward the value when it is changed and mark the arguments as dirty +class PositionalArgumentListener extends ChangeListener { + Record _record; + int _index; + + PositionalArgumentListener(this._record, this._index) { + assert(_index < _record._args.length); + } + + void _onChange(value, previous) { + super._onChange(value, previous); + _record._args[_index] = value; + _record.areArgsDirty = true; + } +} + +/// A `NamedArgumentListener` is created for each of the function named argument. +/// Its role is to forward the value when it is changed and mark the arguments as dirty +class NamedArgumentListener extends ChangeListener { + Record _record; + Symbol _symbol; + + NamedArgumentListener(this._record, this._symbol) { + assert(_record._namedArgs.containsKey(_symbol)); + } + + void _onChange(value, previous) { + super._onChange(value, previous); + _record._namedArgs[_symbol] = value; + _record.areArgsDirty = true; + } +} + +class Record extends ChangeListener { + // flags + static const int _FLAG_IS_MARKER = 0x001000; + static const int _FLAG_IS_COLLECTION = 0x002000; + static const int _FLAG_IS_CONSTANT = 0x004000; + static const int _FLAG_HAS_FRESH_LISTENER = 0x010000; + static const int _FLAG_REMOVE_FROM_CHECK_QUEUE = 0x020000; + static const int _FLAG_HAS_DIRTY_ARGS = 0x040000; + + // modes + static const int _MASK_MODE = 0x000fff; + + static const int _FLAG_MODE_FIELD = 0x000100; + static const int _FLAG_MODE_EVAL = 0x000200; + static const int _MODE_NULL_FIELD = 0x000000 | _FLAG_MODE_FIELD; + static const int _MODE_IDENTITY = 0x000001 | _FLAG_MODE_FIELD; + static const int _MODE_GETTER = 0x000002 | _FLAG_MODE_FIELD; + static const int _MODE_GETTER_OR_METHOD_CLOSURE = 0x000003 | _FLAG_MODE_FIELD; + static const int _MODE_MAP_FIELD = 0x000004 | _FLAG_MODE_FIELD; + static const int _MODE_ITERABLE = 0x000005 | _FLAG_MODE_FIELD; + static const int _MODE_MAP = 0x000006 | _FLAG_MODE_FIELD; + static const int _MODE_NULL_EVAL = 0x000000 | _FLAG_MODE_EVAL; + static const int _MODE_PURE_FUNCTION = 0x000001 | _FLAG_MODE_EVAL; + static const int _MODE_FUNCTION = 0x000002 | _FLAG_MODE_EVAL; + static const int _MODE_PURE_FUNCTION_APPLY = 0x000003 | _FLAG_MODE_EVAL; + static const int _MODE_FIELD_OR_METHOD_CLOSURE = 0x000004 | _FLAG_MODE_EVAL; + static const int _MODE_METHOD = 0x000005 | _FLAG_MODE_EVAL; + static const int _MODE_FIELD_CLOSURE = 0x000006 | _FLAG_MODE_EVAL; + static const int _MODE_MAP_CLOSURE = 0x000007 | _FLAG_MODE_EVAL; + + Function _fnOrGetter; + String _name; + + final List _args; + Map _namedArgs; + + /// The "_$" prefix denotes fields that should be accessed through getters / setters from outside + /// this class + Function _$releaseFn; + int _$mode = 0; + + WatchGroup _watchGroup; + + // List of all the records in the system + Record _next, _prev; + + // List of record that need to be dirty-checked + Record _checkNext, _checkPrev; + + // List of records that need to be released + Record _releasableNext; + + // List of dependent ChangeListeners + ChangeListener _listenerHead; + + // Context for evaluation + var _context; + + // The associated expression + String _expression; + + // The value observed during the last `processChange` call + var _value; + + // Whether listeners have been added since the [_processChange] last return. + // When this is the case, we must ensure that the added listeners are triggered even if no changes + // are detected as they must always fire in the [_processChange] cycle following their addition. + bool get _hasFreshListener => _$mode & _FLAG_HAS_FRESH_LISTENER != 0; + + void set _hasFreshListener(bool fresh) { + if (fresh) { + _$mode |= _FLAG_HAS_FRESH_LISTENER; + if (fresh && !isChecked) _moveToCheckQueue(); + } else { + _$mode &= ~_FLAG_HAS_FRESH_LISTENER; + } + } + + /// Whether this record is dirty checked (when not, changes are notified) + bool get isChecked => _checkNext != null; + + bool get isMarker => _$mode & _FLAG_IS_MARKER != 0; + + bool get isCollectionMode => _$mode & _FLAG_IS_COLLECTION != 0; + bool get isEvalMode => _$mode & _FLAG_MODE_EVAL != 0; + bool get isFieldMode => _$mode & _FLAG_MODE_FIELD != 0; + bool get isConstant => _$mode & _FLAG_IS_CONSTANT != 0; + bool get isRelesable => _releasableNext != null; + + bool get areArgsDirty { + assert(isEvalMode); + return _$mode & _FLAG_HAS_DIRTY_ARGS != 0; + } + + void set areArgsDirty(bool dirty) { + assert(isEvalMode); + if (dirty) { + _$mode |= _FLAG_HAS_DIRTY_ARGS; + } else { + _$mode &= ~_FLAG_HAS_DIRTY_ARGS; + } + } + + int get _mode => _$mode & _MASK_MODE; + + void set _mode(mode) { + assert(mode & ~_MASK_MODE == 0); + _$mode = _$mode & ~_MASK_MODE | mode; + } + + /// Mark the record to be removed from the check queue + void set removeFromCheckQueue(remove) { + if (remove) { + _$mode |= _FLAG_REMOVE_FROM_CHECK_QUEUE; + } else { + _$mode &= ~_FLAG_REMOVE_FROM_CHECK_QUEUE; + } + } + + bool get removeFromCheckQueue => _$mode & _FLAG_REMOVE_FROM_CHECK_QUEUE != 0; + + // Release the resources associated with the record + void set _releaseFn(Function releaseFn) { + assert(releaseFn != null); + _$releaseFn = releaseFn; + _releasableNext = _watchGroup._headMarker._releasableNext; + _watchGroup._headMarker._releasableNext = this; + } + + FieldGetterFactory get _fieldGetterFactory => _watchGroup._fieldGetterFactory; + + Record._marker(this._watchGroup) + : _args = null + { + assert((_expression = 'marker') != null); + _$mode |= _FLAG_IS_MARKER; + } + + Record.field(this._watchGroup, this._expression, this._name) + : _args = null + { + _$mode = _FLAG_MODE_FIELD; + } + + Record.collection(this._watchGroup, this._expression) + : _args = null + { + _$mode = _FLAG_MODE_FIELD; + } + + Record.eval(this._watchGroup, this._expression, this._fnOrGetter, this._name, int arity, + bool pure) + : _args = new List(arity) + { + if (_fnOrGetter is FunctionApply) { + if (!pure) throw "Cannot watch a non-pure FunctionApply '$_expression'"; + _mode = _MODE_PURE_FUNCTION_APPLY; + } else if (_fnOrGetter is Function) { + _mode = pure ? _MODE_PURE_FUNCTION : _MODE_FUNCTION; + } else { + _mode = _MODE_NULL_EVAL; + } + } + + Record.constant(this._watchGroup, this._expression, value) + : _args = null + { + _value = value; + _$mode = _FLAG_IS_CONSTANT; + } + + /** + * Setting an [object] will cause the setter to introspect it and place + * [DirtyCheckingRecord] into different access modes. If Object it sets up + * reflection. If [Map] then it sets up map accessor. + */ + void set context(context) { + assert(!isConstant); + _context = context; + + if (isFieldMode) { + _setFieldContext(context); + } else { + assert(isEvalMode); + _setEvalContext(context); + } + } + + void release() { + assert(isMarker || _$releaseFn != null); + if (!isMarker) _$releaseFn(this); + } + + void _onChange(currentValue, previousValue) { + super._onChange(currentValue, previousValue); + context = currentValue; + } + + void remove() { + assert(_watchGroup.isAttached); + + // Update watch counters + if (isCollectionMode) { + assert(!isConstant); + _watchGroup._watchedCollections--; + } else if (isFieldMode) { + assert(!isConstant); + _watchGroup._watchedFields--; + } else if (isEvalMode) { + assert(!isConstant); + _watchGroup._watchedEvals--; + } + + _watchGroup._recordCache.remove(_expression); + + // Release associated resources when any + if (_$releaseFn != null) { + release(); + // Unlink the record from the releasable list + var previousReleasable = _watchGroup._headMarker; + while (!identical(previousReleasable._releasableNext,this)) { + assert(previousReleasable._releasableNext != _watchGroup._tailMarker); + previousReleasable = previousReleasable._releasableNext; + } + previousReleasable._releasableNext = _releasableNext; + } + + // Unlink the record from the checkable list + if (isChecked) $removeFromCheckQueue(); + + // Assert that no more watches are triggered by this record + assert(_listenerHead == null); + + // Unlink the record from the record list + var prevRecord = _prev; + var nextRecord = _next; + prevRecord._next = nextRecord; + nextRecord._prev = prevRecord; + + super.remove(); + } + + /// Returns the number of invoked reaction function + int processChange({ChangeLog changeLog}) { + if (isMarker) return 0; + assert(_mode != null); + + if (isConstant) { + // Constant records should only get checked when they are added or when listeners are added + // then they must be removed from the check queue as they can not change. + assert(!_hasFired || _hasFreshListener); + int rfnCount = _notifyFreshListeners(changeLog, _value); + removeFromCheckQueue = true; + return rfnCount; + } + + _hasFired = true; + + if (isFieldMode) { + return _processFieldChange(changeLog: changeLog); + } else { + assert(isEvalMode); + return _processEvalChange(changeLog: changeLog); + } + } + + String toString() { + var asString; + if (isMarker) { + asString = '${_watchGroup._headMarker == this ? 'head' : 'tail'} marker'; + } else { + var attrs = []; + if (isFieldMode) attrs.add('type=field'); + if (isEvalMode) attrs.add('type=eval'); + if (isConstant) attrs.add('type=constant'); + if (isCollectionMode) attrs.add('collection'); + if (_hasFreshListener) attrs.add('has fresh listeners'); + if (_hasFired) attrs.add('has fired'); + if (isChecked) attrs.add('is Checked'); + attrs.add('mode=$_mode'); + asString = attrs.join(', '); + } + return "Record '$_expression' [$asString]"; + } + + void _setFieldContext(context) { + _$mode &= ~_FLAG_IS_COLLECTION; + + if (context == null) { + _mode = _MODE_IDENTITY; + _fnOrGetter = null; + return; + } + + if (_name == null) { + _fnOrGetter = null; + if (context is Map) { + _$mode |= _FLAG_IS_COLLECTION; + if (_mode != _MODE_MAP) { + _mode = _MODE_MAP; + _value = new MapChangeRecord(); + } + } else if (context is Iterable) { + _$mode |= _FLAG_IS_COLLECTION; + if (_mode != _MODE_ITERABLE) { + _mode = _MODE_ITERABLE; + _value = new CollectionChangeRecord(); + } + } else { + _mode = _MODE_IDENTITY; + } + + return; + } + + if (context is Map) { + _mode = _MODE_MAP_FIELD; + _fnOrGetter = null; + } else { + _mode = _MODE_GETTER_OR_METHOD_CLOSURE; + _fnOrGetter = _fieldGetterFactory.getter(context, _name); + } + } + + void _setEvalContext(context) { + assert(_mode != _MODE_FUNCTION); + assert(_mode != _MODE_PURE_FUNCTION); + assert(_mode != _MODE_PURE_FUNCTION_APPLY); + _context = context; + + if (context == null) { + _mode = _MODE_NULL_EVAL; + } else { + if (context is Map) { + _mode = _MODE_MAP_CLOSURE; + } else { + _mode = _MODE_FIELD_OR_METHOD_CLOSURE; + _fnOrGetter = _fieldGetterFactory.getter(context, _name); + } + } + } + + int _processFieldChange({ChangeLog changeLog}) { + var value; + switch (_mode) { + case _MODE_NULL_FIELD: + return 0; + case _MODE_GETTER: + value = _fnOrGetter(_context); + break; + case _MODE_GETTER_OR_METHOD_CLOSURE: + // NOTE: When Dart looks up a method "foo" on object "x", it returns a + // new closure for each lookup. They compare equal via "==" but are no + // identical(). There's no point getting a new value each time and + // decide it's the same so we'll skip further checking after the first + // time. + value = _fnOrGetter(_context); + if (value is Function && !identical(value, _fnOrGetter(_context))) { + _mode = _MODE_NULL_FIELD; + } else { + _mode = _MODE_GETTER; + } + break; + case _MODE_MAP_FIELD: + value = _context[_name]; + break; + case _MODE_IDENTITY: + value = _context; + _mode = _MODE_NULL_FIELD; + break; + case _MODE_MAP: + return (_value as MapChangeRecord)._check(_context) ? + _notifyListeners(changeLog, _value, null): + _notifyFreshListeners(changeLog, _value); + case _MODE_ITERABLE: + return (_value as CollectionChangeRecord)._check(_context) ? + _notifyListeners(changeLog, _value, null): + _notifyFreshListeners(changeLog, _value); + default: + assert(false); + } + + if (!_looseIdentical(value, _value)) { + var previousValue = _value; + _value = value; + return _notifyListeners(changeLog, value, previousValue); + } else { + return _notifyFreshListeners(changeLog, value); + } + return 0; + } + + int _processEvalChange({ChangeLog changeLog}) { + var value; + switch (_mode) { + case _MODE_NULL_EVAL: + return 0; + case _MODE_PURE_FUNCTION: + if (!areArgsDirty) return 0; + value = Function.apply(_fnOrGetter, _args, _namedArgs); + areArgsDirty = false; + break; + case _MODE_FUNCTION: + case _MODE_METHOD: + value = Function.apply(_fnOrGetter, _args, _namedArgs); + areArgsDirty = false; + break; + case _MODE_PURE_FUNCTION_APPLY: + if (!areArgsDirty) return 0; + value = (_fnOrGetter as FunctionApply).apply(_args); + areArgsDirty = false; + break; + case _MODE_FIELD_OR_METHOD_CLOSURE: + var closure = _fnOrGetter(_context); + // NOTE: When Dart looks up a method "foo" on object "x", it returns a + // new closure for each lookup. They compare equal via "==" but are no + // identical(). There's no point getting a new value each time and + // decide it's the same so we'll skip further checking after the first + // time. + if (closure is Function && !identical(closure, _fnOrGetter(_context))) { + _fnOrGetter = closure; + _mode = _MODE_METHOD; + } else { + _mode = _MODE_FIELD_CLOSURE; + } + value = (closure == null) ? null : Function.apply(closure, _args, _namedArgs); + break; + case _MODE_FIELD_CLOSURE: + var closure = _fnOrGetter(_context); + value = (closure == null) ? null : Function.apply(closure, _args, _namedArgs); + break; + case _MODE_MAP_CLOSURE: + var closure = _context[_name]; + value = (closure == null) ? null : Function.apply(closure, _args, _namedArgs); + break; + default: + throw ("$_mode is not supported in FunctionRecord.check()"); + } + + if (!_looseIdentical(_value, value)) { + var previousValue = _value; + _value = value; + return _notifyListeners(changeLog, value, previousValue); + } else { + return _notifyFreshListeners(changeLog, value); + } + return 0; + } + + void $removeFromCheckQueue() { + assert(_checkNext != null && _checkPrev != null); + _checkPrev._checkNext = _checkNext; + _checkNext._checkPrev = _checkPrev; + _checkNext = null; + _checkPrev = null; + } + + void _moveToCheckQueue() { + assert(!isChecked); + Record prevCheckable = _prev; + + while (!prevCheckable.isChecked) { + // Assert that we do not pass the watch group + assert(prevCheckable != _watchGroup._headMarker._prev); + prevCheckable = prevCheckable._prev; + } + + _checkNext = prevCheckable._checkNext; + _checkNext._checkPrev = this; + _checkPrev = prevCheckable; + _checkPrev._checkNext = this; + } + + int _notifyListeners(ChangeLog changeLog, currentValue, previousValue) { + int invokedWatches = 0; + if (changeLog != null) changeLog(_expression, currentValue, previousValue); + for (ChangeListener listener = _listenerHead; + listener != null; + listener = listener._listenerNext) { + listener._onChange(currentValue, previousValue); + if (listener is Watch) invokedWatches++; + } + _hasFreshListener = false; + return invokedWatches; + } + + /// Notify the listeners added after the last check and not fired yet + int _notifyFreshListeners(ChangeLog changeLog, value) { + int invokedWatches = 0; + if (_hasFreshListener) { + if (changeLog != null) changeLog(_expression, value, null); + for (ChangeListener listener = _listenerHead; + listener != null; + listener = listener._listenerNext) { + if (!listener._hasFired) { + if (listener is Watch) invokedWatches++; + listener._onChange(value, null); + } + } + _hasFreshListener = false; + } + return invokedWatches; + } + + void _addListener(ChangeListener listener) { + assert(listener._triggerRecord == null); + // Records must be present in the record list before a listener is added + assert(listener is! Record || + listener._prev != null && listener._next != null); + + if (_listenerHead == null) { + _listenerHead = listener; + } else { + ChangeListener changeListener = this._listenerHead; + while (changeListener._listenerNext != null) { + changeListener = changeListener._listenerNext; + } + changeListener._listenerNext = listener; + } + + listener._triggerRecord = this; + + // Listeners must be processed in the same cycle when they are added from a reaction function + // (ie `_watchGroup._processingChanges == true`) then the `Record` context must be + // initialized. When a listener is added outside of a reaction function, we do not need to + // initialize the context until the next cycle which is achieved by setting + // `_hasFreshListener = true` + if (_watchGroup._processingChanges) { + if (listener is Record) listener.context = _value; + // Setting the context is a no-op for constant records we need to set the fresh listeners + // flag to true to make sure the listeners will be triggered + if (isConstant) _hasFreshListener = true; + } else { + _hasFreshListener = true; + } + } +} diff --git a/lib/change_detector/collection_changes.dart b/lib/change_detector/collection_changes.dart new file mode 100644 index 000000000..6b19ecf85 --- /dev/null +++ b/lib/change_detector/collection_changes.dart @@ -0,0 +1,551 @@ +part of angular.change_detector; + +class CollectionChangeRecord { + Iterable _iterable; + int _length; + + /// Keeps track of the used records at any point in time (during & across `_check()` calls) + DuplicateMap _linkedRecords; + + /// Keeps track of the removed records at any point in time during `_check()` calls. + DuplicateMap _unlinkedRecords; + + CollectionChangeItem _previousItHead; + CollectionChangeItem _itHead, _itTail; + CollectionChangeItem _additionsHead, _additionsTail; + CollectionChangeItem _movesHead, _movesTail; + CollectionChangeItem _removalsHead, _removalsTail; + + void forEachItem(void f(CollectionChangeItem item)) { + for (var r = _itHead; r != null; r = r._next) { + f(r); + } + } + + void forEachPreviousItem(void f(CollectionChangeItem previousItem)) { + for (var r = _previousItHead; r != null; r = r._nextPrevious) { + f(r); + } + } + + void forEachAddition(void f(CollectionChangeItem addition)){ + for (var r = _additionsHead; r != null; r = r._nextAdded) { + f(r); + } + } + + void forEachMove(void f(CollectionChangeItem change)) { + for (var r = _movesHead; r != null; r = r._nextMoved) { + f(r); + } + } + + void forEachRemoval(void f(CollectionChangeItem removal)){ + for (var r = _removalsHead; r != null; r = r._nextRemoved) { + f(r); + } + } + + Iterable get iterable => _iterable; + int get length => _length; + + bool _check(Iterable collection) { + _reset(); + + if (collection is UnmodifiableListView && identical(_iterable, collection)) { + // Short circuit and assume that the list has not been modified. + return false; + } + + CollectionChangeItem record = _itHead; + bool maybeDirty = false; + + if (collection is List) { + List list = collection; + _length = list.length; + for (int index = 0; index < _length; index++) { + var item = list[index]; + if (record == null || !_looseIdentical(record.item, item)) { + record = mismatch(record, item, index); + maybeDirty = true; + } else if (maybeDirty) { + // TODO(misko): can we limit this to duplicates only? + record = verifyReinsertion(record, item, index); + } + record = record._next; + } + } else { + int index = 0; + for (var item in collection) { + if (record == null || !_looseIdentical(record.item, item)) { + record = mismatch(record, item, index); + maybeDirty = true; + } else if (maybeDirty) { + // TODO(misko): can we limit this to duplicates only? + record = verifyReinsertion(record, item, index); + } + record = record._next; + index++; + } + _length = index; + } + + _truncate(record); + _iterable = collection; + return isDirty; + } + + /** + * Reset the state of the change objects to show no changes. This means set previousKey to + * currentKey, and clear all of the queues (additions, moves, removals). + */ + void _reset() { + if (isDirty) { + // Record the state of the collection + for (CollectionChangeItem r = _previousItHead = _itHead; r != null; r = r._next) { + r._nextPrevious = r._next; + } + _undoDeltas(); + } + } + + /// Set the [previousIndex]es of moved and added items to their [currentIndex]es + /// Reset the list of additions, moves and removals + void _undoDeltas() { + CollectionChangeItem record; + + record = _additionsHead; + while (record != null) { + record.previousIndex = record.currentIndex; + record = record._nextAdded; + } + _additionsHead = _additionsTail = null; + + record = _movesHead; + while (record != null) { + record.previousIndex = record.currentIndex; + var nextRecord = record._nextMoved; + assert((record._nextMoved = null) == null); + record = nextRecord; + } + _movesHead = _movesTail = null; + _removalsHead = _removalsTail = null; + assert(isDirty == false); + } + + /// A [_CollectionChangeRecord] is considered dirty if it has additions, moves or removals. + bool get isDirty => _additionsHead != null || + _movesHead != null || + _removalsHead != null; + + /** + * This is the core function which handles differences between collections. + * + * - [record] is the record which we saw at this position last time. If [:null:] then it is a new + * item. + * - [item] is the current item in the collection + * - [index] is the position of the item in the collection + */ + CollectionChangeItem mismatch(CollectionChangeItem record, item, int index) { + // The previous record after which we will append the current one. + CollectionChangeItem previousRecord; + + if (record == null) { + previousRecord = _itTail; + } else { + previousRecord = record._prev; + // Remove the record from the collection since we know it does not match the item. + _remove(record); + } + + // Attempt to see if we have seen the item before. + record = _linkedRecords == null ? null : _linkedRecords.get(item, index); + if (record != null) { + // We have seen this before, we need to move it forward in the collection. + _moveAfter(record, previousRecord, index); + } else { + // Never seen it, check evicted list. + record = _unlinkedRecords == null ? null : _unlinkedRecords.get(item); + if (record != null) { + // It is an item which we have evicted earlier: reinsert it back into the list. + _reinsertAfter(record, previousRecord, index); + } else { + // It is a new item: add it. + record = _addAfter(new CollectionChangeItem(item), previousRecord, index); + } + } + return record; + } + + /** + * This check is only needed if an array contains duplicates. (Short circuit of nothing dirty) + * + * Use case: `[a, a]` => `[b, a, a]` + * + * If we did not have this check then the insertion of `b` would: + * 1) evict first `a` + * 2) insert `b` at `0` index. + * 3) leave `a` at index `1` as is. <-- this is wrong! + * 3) reinsert `a` at index 2. <-- this is wrong! + * + * The correct behavior is: + * 1) evict first `a` + * 2) insert `b` at `0` index. + * 3) reinsert `a` at index 1. + * 3) move `a` at from `1` to `2`. + * + * + * Double check that we have not evicted a duplicate item. We need to check if the item type may + * have already been removed: + * The insertion of b will evict the first 'a'. If we don't reinsert it now it will be reinserted + * at the end. Which will show up as the two 'a's switching position. This is incorrect, since a + * better way to think of it is as insert of 'b' rather then switch 'a' with 'b' and then add 'a' + * at the end. + */ + CollectionChangeItem verifyReinsertion(CollectionChangeItem record, item, int index) { + CollectionChangeItem reinsertRecord = _unlinkedRecords == null ? + null : + _unlinkedRecords.get(item); + if (reinsertRecord != null) { + record = _reinsertAfter(reinsertRecord, record._prev, index); + } else if (record.currentIndex != index) { + record.currentIndex = index; + _addToMoves(record, index); + } + return record; + } + + /** + * Get rid of any excess [CollectionChangeItem]s from the previous collection + * + * - [record] The first excess [CollectionChangeItem]. + */ + void _truncate(CollectionChangeItem record) { + // Anything after that needs to be removed; + while (record != null) { + CollectionChangeItem nextRecord = record._next; + _addToRemovals(_unlink(record)); + record = nextRecord; + } + if (_unlinkedRecords != null) _unlinkedRecords.clear(); + + if (_additionsTail != null) _additionsTail._nextAdded = null; + if (_movesTail != null) _movesTail._nextMoved = null; + if (_itTail != null) _itTail._next = null; + if (_removalsTail != null) _removalsTail._nextRemoved = null; + } + + CollectionChangeItem _reinsertAfter(CollectionChangeItem record, + CollectionChangeItem prevRecord, int index) { + if (_unlinkedRecords != null) _unlinkedRecords.remove(record); + var prev = record._prevRemoved; + var next = record._nextRemoved; + + if (prev == null) { + _removalsHead = next; + } else { + prev._nextRemoved = next; + } + if (next == null) { + _removalsTail = prev; + } else { + next._prevRemoved = prev; + } + + _insertAfter(record, prevRecord, index); + _addToMoves(record, index); + return record; + } + + CollectionChangeItem _moveAfter(CollectionChangeItem record, + CollectionChangeItem prevRecord, int index) { + _unlink(record); + _insertAfter(record, prevRecord, index); + _addToMoves(record, index); + return record; + } + + CollectionChangeItem _addAfter(CollectionChangeItem record, + CollectionChangeItem prevRecord, int index) { + _insertAfter(record, prevRecord, index); + + if (_additionsTail == null) { + assert(_additionsHead == null); + _additionsTail = _additionsHead = record; + } else { + assert(_additionsTail._nextAdded == null); + assert(record._nextAdded == null); + _additionsTail = _additionsTail._nextAdded = record; + } + return record; + } + + CollectionChangeItem _insertAfter(CollectionChangeItem record, + CollectionChangeItem prevRecord, int index) { + assert(record != prevRecord); + assert(record._next == null); + assert(record._prev == null); + + CollectionChangeItem next = prevRecord == null ? _itHead : prevRecord._next; + assert(next != record); + assert(prevRecord != record); + record._next = next; + record._prev = prevRecord; + if (next == null) { + _itTail = record; + } else { + next._prev = record; + } + if (prevRecord == null) { + _itHead = record; + } else { + prevRecord._next = record; + } + + if (_linkedRecords == null) _linkedRecords = new DuplicateMap(); + _linkedRecords.put(record); + + record.currentIndex = index; + return record; + } + + CollectionChangeItem _remove(CollectionChangeItem record) => _addToRemovals(_unlink(record)); + + CollectionChangeItem _unlink(CollectionChangeItem record) { + if (_linkedRecords != null) _linkedRecords.remove(record); + + var prev = record._prev; + var next = record._next; + + assert((record._prev = null) == null); + assert((record._next = null) == null); + + if (prev == null) { + _itHead = next; + } else { + prev._next = next; + } + if (next == null) { + _itTail = prev; + } else { + next._prev = prev; + } + + return record; + } + + CollectionChangeItem _addToMoves(CollectionChangeItem record, int toIndex) { + assert(record._nextMoved == null); + + if (record.previousIndex == toIndex) return record; + + if (_movesTail == null) { + assert(_movesHead == null); + _movesTail = _movesHead = record; + } else { + assert(_movesTail._nextMoved == null); + _movesTail = _movesTail._nextMoved = record; + } + + return record; + } + + CollectionChangeItem _addToRemovals(CollectionChangeItem record) { + if (_unlinkedRecords == null) _unlinkedRecords = new DuplicateMap(); + _unlinkedRecords.put(record); + record.currentIndex = null; + record._nextRemoved = null; + + if (_removalsTail == null) { + assert(_removalsHead == null); + _removalsTail = _removalsHead = record; + record._prevRemoved = null; + } else { + assert(_removalsTail._nextRemoved == null); + assert(record._nextRemoved == null); + record._prevRemoved = _removalsTail; + _removalsTail = _removalsTail._nextRemoved = record; + } + return record; + } + + String toString() { + CollectionChangeItem r; + + var list = []; + for (r = _itHead; r != null; r = r._next) { + list.add(r); + } + + var previous = []; + for (r = _previousItHead; r != null; r = r._nextPrevious) { + previous.add(r); + } + + var additions = []; + for (r = _additionsHead; r != null; r = r._nextAdded) { + additions.add(r); + } + var moves = []; + for (r = _movesHead; r != null; r = r._nextMoved) { + moves.add(r); + } + + var removals = []; + for (r = _removalsHead; r != null; r = r._nextRemoved) { + removals.add(r); + } + + return """ +collection: ${list.join(", ")} +previous: ${previous.join(", ")} +additions: ${additions.join(", ")} +moves: ${moves.join(", ")} +removals: ${removals.join(", ")} +"""; + } +} + +class CollectionChangeItem { + int currentIndex; + int previousIndex; + V item; + + CollectionChangeItem _nextPrevious; + CollectionChangeItem _prev, _next; + CollectionChangeItem _prevDup, _nextDup; + CollectionChangeItem _prevRemoved, _nextRemoved; + CollectionChangeItem _nextAdded; + CollectionChangeItem _nextMoved; + + CollectionChangeItem(this.item); + + String toString() => previousIndex == currentIndex + ? '$item' + : '$item[$previousIndex -> $currentIndex]'; +} + +/// A linked list of [CollectionChangeItem]s with the same [CollectionChangeItem.item] +class _DuplicateItemRecordList { + CollectionChangeItem _head, _tail; + + /** + * Append the [record] to the list of duplicates. + * + * Note: by design all records in the list of duplicates hold the save value in [record.item]. + */ + void add(CollectionChangeItem record) { + if (_head == null) { + _head = _tail = record; + record._nextDup = null; + record._prevDup = null; + } else { + assert(record.item == _head.item || + record.item is num && record.item.isNaN && _head.item is num && _head.item.isNaN); + _tail._nextDup = record; + record._prevDup = _tail; + record._nextDup = null; + _tail = record; + } + } + + /// Returns an [CollectionChangeItem] having [CollectionChangeItem.item] == [item] and + /// [CollectionChangeItem.currentIndex] >= [afterIndex] + CollectionChangeItem get(item, int afterIndex) { + CollectionChangeItem record; + for (record = _head; record != null; record = record._nextDup) { + if ((afterIndex == null || afterIndex < record.currentIndex) && + _looseIdentical(record.item, item)) { + return record; + } + } + return null; + } + + /** + * Remove one [CollectionChangeItem] from the list of duplicates. + * + * Returns whether the list of duplicates is empty. + */ + bool remove(CollectionChangeItem record) { + assert(() { + // verify that the record being removed is in the list. + for (CollectionChangeItem cursor = _head; cursor != null; cursor = cursor._nextDup) { + if (identical(cursor, record)) return true; + } + return false; + }); + + var prev = record._prevDup; + var next = record._nextDup; + if (prev == null) { + _head = next; + } else { + prev._nextDup = next; + } + if (next == null) { + _tail = prev; + } else { + next._prevDup = prev; + } + return _head == null; + } +} + +/** + * [DuplicateMap] maps [CollectionChangeItem.value] to a list of [CollectionChangeItem] having the + * same value (duplicates). + * + * The list of duplicates is implemented by [_DuplicateItemRecordList]. + */ +class DuplicateMap { + static final _nanKey = const Object(); + final map = new HashMap(); + + void put(CollectionChangeItem record) { + var key = _getKey(record.item); + _DuplicateItemRecordList duplicates = map[key]; + if (duplicates == null) { + duplicates = map[key] = new _DuplicateItemRecordList(); + } + duplicates.add(record); + } + + /** + * Retrieve the `value` using [key]. Because the [CollectionChangeItem] value maybe one which we + * have already iterated over, we use the [afterIndex] to pretend it is not there. + * + * Use case: `[a, b, c, a, a]` if we are at index `3` which is the second `a` then asking if we + * have any more `a`s needs to return the last `a` not the first or second. + */ + CollectionChangeItem get(value, [int afterIndex]) { + var key = _getKey(value); + _DuplicateItemRecordList recordList = map[key]; + return recordList == null ? null : recordList.get(value, afterIndex); + } + + /** + * Removes an [CollectionChangeItem] from the list of duplicates. + * + * The list of duplicates also is removed from the map if it gets empty. + */ + CollectionChangeItem remove(CollectionChangeItem record) { + var key = _getKey(record.item); + assert(map.containsKey(key)); + _DuplicateItemRecordList recordList = map[key]; + // Remove the list of duplicates when it gets empty + if (recordList.remove(record)) map.remove(key); + return record; + } + + bool get isEmpty => map.isEmpty; + + void clear() { + map.clear(); + } + + /// Required to handle num.NAN as a Map value + dynamic _getKey(value) => value is num && value.isNaN ? _nanKey : value; + + String toString() => "DuplicateMap($map)"; +} diff --git a/lib/change_detection/dirty_checking_change_detector_dynamic.dart b/lib/change_detector/field_getter_factory_dynamic.dart similarity index 60% rename from lib/change_detection/dirty_checking_change_detector_dynamic.dart rename to lib/change_detector/field_getter_factory_dynamic.dart index f5f8bb973..f8c665b95 100644 --- a/lib/change_detection/dirty_checking_change_detector_dynamic.dart +++ b/lib/change_detector/field_getter_factory_dynamic.dart @@ -1,13 +1,13 @@ -library dirty_checking_change_detector_dynamic; +library field_getter_factory.dynamic; -import 'package:angular/change_detection/change_detection.dart'; -export 'package:angular/change_detection/change_detection.dart' show +import 'change_detector.dart' show + FieldGetter, FieldGetterFactory; /** * We are using mirrors, but there is no need to import anything. */ -@MirrorsUsed(targets: const [ DynamicFieldGetterFactory ], metaTargets: const [] ) +@MirrorsUsed(targets: const [DynamicFieldGetterFactory], metaTargets: const [] ) import 'dart:mirrors'; class DynamicFieldGetterFactory implements FieldGetterFactory { diff --git a/lib/change_detection/dirty_checking_change_detector_static.dart b/lib/change_detector/field_getter_factory_static.dart similarity index 73% rename from lib/change_detection/dirty_checking_change_detector_static.dart rename to lib/change_detector/field_getter_factory_static.dart index 1c7764dfd..041012789 100644 --- a/lib/change_detection/dirty_checking_change_detector_static.dart +++ b/lib/change_detector/field_getter_factory_static.dart @@ -1,6 +1,8 @@ -library dirty_checking_change_detector_static; +library field_getter_factory.static; -import 'package:angular/change_detection/change_detection.dart'; +import 'change_detector.dart' show + FieldGetter, + FieldGetterFactory; class StaticFieldGetterFactory implements FieldGetterFactory { Map getters; diff --git a/lib/change_detector/map_changes.dart b/lib/change_detector/map_changes.dart new file mode 100644 index 000000000..508dc686b --- /dev/null +++ b/lib/change_detector/map_changes.dart @@ -0,0 +1,328 @@ +part of angular.change_detector; + +class MapChangeRecord { + final _records = new HashMap(); + Map _map; + + Map get map => _map; + + MapKeyValue _mapHead; + MapKeyValue _previousMapHead; + MapKeyValue _changesHead, _changesTail; + MapKeyValue _additionsHead, _additionsTail; + MapKeyValue _removalsHead, _removalsTail; + + bool get isDirty => _additionsHead != null || + _changesHead != null || + _removalsHead != null; + + MapKeyValue r; + + void forEachItem(void f(MapKeyValue change)) { + for (r = _mapHead; r != null; r = r._next) { + f(r); + } + } + + void forEachPreviousItem(void f(MapKeyValue change)) { + for (r = _previousMapHead; r != null; r = r._nextPrevious) { + f(r); + } + } + + void forEachChange(void f(MapKeyValue change)) { + for (r = _changesHead; r != null; r = r._nextChanged) { + f(r); + } + } + + void forEachAddition(void f(MapKeyValue addition)){ + for (r = _additionsHead; r != null; r = r._nextAdded) { + f(r); + } + } + + void forEachRemoval(void f(MapKeyValue removal)){ + for (r = _removalsHead; r != null; r = r._nextRemoved) { + f(r); + } + } + + bool _check(Map map) { + _reset(); + _map = map; + Map records = _records; + MapKeyValue oldSeqRecord = _mapHead; + MapKeyValue lastOldSeqRecord; + MapKeyValue lastNewSeqRecord; + var seqChanged = false; + map.forEach((key, value) { + var newSeqRecord; + if (oldSeqRecord != null && key == oldSeqRecord.key) { + newSeqRecord = oldSeqRecord; + if (!_looseIdentical(value, oldSeqRecord._currentValue)) { + var prev = oldSeqRecord._previousValue = oldSeqRecord._currentValue; + oldSeqRecord._currentValue = value; + _addToChanges(oldSeqRecord); + } + } else { + seqChanged = true; + if (oldSeqRecord != null) { + oldSeqRecord._next = null; + _removeFromSeq(lastOldSeqRecord, oldSeqRecord); + _addToRemovals(oldSeqRecord); + } + if (records.containsKey(key)) { + newSeqRecord = records[key]; + } else { + newSeqRecord = records[key] = new MapKeyValue(key); + newSeqRecord._currentValue = value; + _addToAdditions(newSeqRecord); + } + } + + if (seqChanged) { + if (_isInRemovals(newSeqRecord)) { + _removeFromRemovals(newSeqRecord); + } + if (lastNewSeqRecord == null) { + _mapHead = newSeqRecord; + } else { + lastNewSeqRecord._next = newSeqRecord; + } + } + lastOldSeqRecord = oldSeqRecord; + lastNewSeqRecord = newSeqRecord; + oldSeqRecord = oldSeqRecord == null ? null : oldSeqRecord._next; + }); + _truncate(lastOldSeqRecord, oldSeqRecord); + return isDirty; + } + + void _reset() { + if (isDirty) { + // Record the state of the mapping + for (MapKeyValue record = _previousMapHead = _mapHead; + record != null; + record = record._next) { + record._nextPrevious = record._next; + } + _undoDeltas(); + } + } + + void _undoDeltas() { + MapKeyValue r; + + for (r = _changesHead; r != null; r = r._nextChanged) { + r._previousValue = r._currentValue; + } + + for (r = _additionsHead; r != null; r = r._nextAdded) { + r._previousValue = r._currentValue; + } + + assert((() { + var r = _changesHead; + while (r != null) { + var nextRecord = r._nextChanged; + r._nextChanged = null; + r = nextRecord; + } + + r = _additionsHead; + while (r != null) { + var nextRecord = r._nextAdded; + r._nextAdded = null; + r = nextRecord; + } + + r = _removalsHead; + while (r != null) { + var nextRecord = r._nextRemoved; + r._nextRemoved = null; + r = nextRecord; + } + + return true; + })()); + _changesHead = _changesTail = null; + _additionsHead = _additionsTail = null; + _removalsHead = _removalsTail = null; + } + + void _truncate(MapKeyValue lastRecord, MapKeyValue record) { + while (record != null) { + if (lastRecord == null) { + _mapHead = null; + } else { + lastRecord._next = null; + } + var nextRecord = record._next; + assert((() { + record._next = null; + return true; + })()); + _addToRemovals(record); + lastRecord = record; + record = nextRecord; + } + + for (var r = _removalsHead; r != null; r = r._nextRemoved) { + r._previousValue = r._currentValue; + r._currentValue = null; + _records.remove(r.key); + } + } + + bool _isInRemovals(MapKeyValue record) => + record == _removalsHead || + record._nextRemoved != null || + record._prevRemoved != null; + + void _addToRemovals(MapKeyValue record) { + assert(record._next == null); + assert(record._nextAdded == null); + assert(record._nextChanged == null); + assert(record._nextRemoved == null); + assert(record._prevRemoved == null); + if (_removalsHead == null) { + _removalsHead = _removalsTail = record; + } else { + _removalsTail._nextRemoved = record; + record._prevRemoved = _removalsTail; + _removalsTail = record; + } + } + + void _removeFromSeq(MapKeyValue prev, MapKeyValue record) { + MapKeyValue next = record._next; + if (prev == null) { + _mapHead = next; + } else { + prev._next = next; + } + assert((() { + record._next = null; + return true; + })()); + } + + void _removeFromRemovals(MapKeyValue record) { + assert(record._next == null); + assert(record._nextAdded == null); + assert(record._nextChanged == null); + + var prev = record._prevRemoved; + var next = record._nextRemoved; + if (prev == null) { + _removalsHead = next; + } else { + prev._nextRemoved = next; + } + if (next == null) { + _removalsTail = prev; + } else { + next._prevRemoved = prev; + } + record._prevRemoved = record._nextRemoved = null; + } + + void _addToAdditions(MapKeyValue record) { + assert(record._next == null); + assert(record._nextAdded == null); + assert(record._nextChanged == null); + assert(record._nextRemoved == null); + assert(record._prevRemoved == null); + if (_additionsHead == null) { + _additionsHead = _additionsTail = record; + } else { + _additionsTail._nextAdded = record; + _additionsTail = record; + } + } + + void _addToChanges(MapKeyValue record) { + assert(record._nextAdded == null); + assert(record._nextChanged == null); + assert(record._nextRemoved == null); + assert(record._prevRemoved == null); + if (_changesHead == null) { + _changesHead = _changesTail = record; + } else { + _changesTail._nextChanged = record; + _changesTail = record; + } + } + + String toString() { + List itemsList = [], previousList = [], changesList = [], additionsList = [], removalsList = []; + MapKeyValue r; + for (r = _mapHead; r != null; r = r._next) { + itemsList.add("$r"); + } + for (r = _previousMapHead; r != null; r = r._nextPrevious) { + previousList.add("$r"); + } + for (r = _changesHead; r != null; r = r._nextChanged) { + changesList.add("$r"); + } + for (r = _additionsHead; r != null; r = r._nextAdded) { + additionsList.add("$r"); + } + for (r = _removalsHead; r != null; r = r._nextRemoved) { + removalsList.add("$r"); + } + return """ +map: ${itemsList.join(", ")} +previous: ${previousList.join(", ")} +changes: ${changesList.join(", ")} +additions: ${additionsList.join(", ")} +removals: ${removalsList.join(", ")} +"""; + } +} + +class MapKeyValue { + final K key; + V _previousValue, _currentValue; + + V get previousValue => _previousValue; + V get currentValue => _currentValue; + + MapKeyValue _nextPrevious; + MapKeyValue _next; + MapKeyValue _nextAdded; + MapKeyValue _nextRemoved, _prevRemoved; + MapKeyValue _nextChanged; + + MapKeyValue(this.key); + + String toString() => _previousValue == _currentValue + ? "$key" + : '$key[$_previousValue -> $_currentValue]'; +} + + +/** + * Returns whether the [dst] and [src] are loosely identical: + * * true when the value are identical, + * * true when both values are equal strings, + * * true when both values are NaN + * + * If both values are equal string, src is assigned to dst. + */ +bool _looseIdentical(dst, src) { + if (identical(dst, src)) return true; + + if (dst is String && src is String && dst == src) { + // this is false change in strings we need to recover, and pretend it is the same. We save the + // value so that next time identity can pass + return true; + } + + // we need this for JavaScript since in JS NaN !== NaN. + if (dst is num && (dst as num).isNaN && src is num && (src as num).isNaN) return true; + + return false; +} diff --git a/lib/change_detection/prototype_map.dart b/lib/change_detector/prototype_map.dart similarity index 97% rename from lib/change_detection/prototype_map.dart rename to lib/change_detector/prototype_map.dart index b324a12a1..2613f1d0b 100644 --- a/lib/change_detection/prototype_map.dart +++ b/lib/change_detector/prototype_map.dart @@ -1,4 +1,4 @@ -part of angular.watch_group; +part of angular.change_detector; class PrototypeMap implements Map { final Map prototype; diff --git a/lib/change_detector/test.dart b/lib/change_detector/test.dart new file mode 100644 index 000000000..4b425ccc1 --- /dev/null +++ b/lib/change_detector/test.dart @@ -0,0 +1,65 @@ +import 'package:angular/core/module_internal.dart'; +import 'package:angular/core/parser/dynamic_closure_map.dart'; +import 'package:angular/core/parser/dynamic_parser.dart'; +import 'package:angular/core/registry_dynamic.dart'; + +import 'package:angular/change_detector/ast_parser.dart'; +import 'package:angular/change_detector/change_detector.dart'; +import 'package:angular/change_detector/field_getter_factory_dynamic.dart'; + +import 'package:angular/core/parser/parser.dart'; + +import 'package:di/di.dart'; +import 'package:di/dynamic_injector.dart'; +import 'package:angular/cache/module.dart'; + +class MyModule extends Module { + MyModule() { + install(new CoreModule()); + bind(MetadataExtractor, toImplementation: DynamicMetadataExtractor); + bind(FieldGetterFactory, toImplementation: DynamicFieldGetterFactory); + bind(CacheRegister); + bind(ClosureMap, toValue: new DynamicClosureMap()); + } +} + +Function rfnLog(msg) => (v, p) => print('$msg: $p -> $v'); + + +main() { + Injector inj = new DynamicInjector(modules: [new CoreModule(), new MyModule()]); + + var parser = inj.get(Parser); + + ASTParser parse = new ASTParser(parser, null); + + ChangeDetector detector = new ChangeDetector(inj.get(FieldGetterFactory)); + + var context = { + 'a': {'b' : {'c': 0}}, + 'c': [1], + 'd': 0, + 's1': 5, + 's2': 10, + 'list': [1, 2, 3], + 'map': {'a': 'a'}, + 'add': (a, b) => a+b + }; + + var g0 = detector.createWatchGroup(context); + + var ws1; + + ws1 = g0.watch(parse('s1'), (v, p) { + print('s1 $p->$v'); + ws1.remove(); + }); + + g0.watch(parse('s2'), (v, p) => print('s2 $p->$v')); + + g0.processChanges(); + + + + +} diff --git a/lib/core/module.dart b/lib/core/module.dart index b1d5dbab0..efca4c830 100644 --- a/lib/core/module.dart +++ b/lib/core/module.dart @@ -9,13 +9,11 @@ */ library angular.core; -export "package:angular/change_detection/watch_group.dart" show - ReactionFn; - export "package:angular/core/parser/parser.dart" show Parser, ClosureMap; -export "package:angular/change_detection/change_detection.dart" show +export "package:angular/change_detector/change_detector.dart" show + ReactionFn, AvgStopwatch, FieldGetterFactory; diff --git a/lib/core/module_internal.dart b/lib/core/module_internal.dart index 8454ff3a5..08ff90ff1 100644 --- a/lib/core/module_internal.dart +++ b/lib/core/module_internal.dart @@ -16,11 +16,8 @@ import 'package:angular/ng_tracing.dart'; import 'package:angular/core/annotation_src.dart'; import 'package:angular/cache/module.dart'; -import 'package:angular/change_detection/watch_group.dart'; -export 'package:angular/change_detection/watch_group.dart'; -import 'package:angular/change_detection/ast_parser.dart'; -import 'package:angular/change_detection/change_detection.dart'; -import 'package:angular/change_detection/dirty_checking_change_detector.dart'; +import 'package:angular/change_detector/change_detector.dart'; +import 'package:angular/change_detector/ast_parser.dart'; import 'package:angular/core/formatter.dart'; export 'package:angular/core/formatter.dart'; import 'package:angular/core/parser/utils.dart'; diff --git a/lib/core/scope.dart b/lib/core/scope.dart index 8a58fadab..acae50f3d 100644 --- a/lib/core/scope.dart +++ b/lib/core/scope.dart @@ -219,7 +219,7 @@ class Scope { * [CollectionChangeItem] that lists all the changes. */ Watch watch(String expression, ReactionFn reactionFn, {context, - FormatterMap formatters, bool canChangeModel: true, bool collection: false}) { + FormatterMap formatters, bool canChangeModel: true, bool collection: false}) { assert(isAttached); assert(expression is String); assert(canChangeModel is bool); @@ -255,13 +255,12 @@ class Scope { } } - String astKey = - "${collection ? "C" : "."}${formatters == null ? "." : formatters.hashCode}$expression"; + String astKey = "${collection ? "C" : "."}" + "${formatters == null ? "." : formatters.hashCode}$expression"; AST ast = rootScope.astCache[astKey]; if (ast == null) { - ast = rootScope.astCache[astKey] = - rootScope._astParser(expression, - formatters: formatters, collection: collection); + ast = rootScope._astParser(expression, formatters: formatters, collection: collection); + rootScope.astCache[astKey] = ast; } return watch = watchAST(ast, fn, canChangeModel: canChangeModel); @@ -295,6 +294,7 @@ class Scope { 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); @@ -366,8 +366,8 @@ class Scope { var s = traceEnter(Scope_createChild); assert(isAttached); var child = new Scope(childContext, rootScope, this, - _readWriteGroup.newGroup(childContext), - _readOnlyGroup.newGroup(childContext), + _readWriteGroup.createChild(childContext), + _readOnlyGroup.createChild(childContext), '$id:${_childScopeNextId++}', _stats); @@ -749,13 +749,13 @@ class RootScope extends Scope { : _scopeStats = _scopeStats, _parser = parser, _astParser = astParser, - super(context, null, null, - new RootWatchGroup(fieldGetterFactory, - new DirtyCheckingChangeDetector(fieldGetterFactory), context), - new RootWatchGroup(fieldGetterFactory, - new DirtyCheckingChangeDetector(fieldGetterFactory), context), - '', - _scopeStats) + super(context, + null, + null, + new WatchGroup(null, context, fieldGetterFactory), + new WatchGroup(null, context, fieldGetterFactory), + '', + _scopeStats) { _zone.onTurnDone = apply; _zone.onError = (e, s, ls) => _exceptionHandler(e, s); @@ -786,8 +786,6 @@ class RootScope extends Scope { void digest() { _transitionState(null, STATE_DIGEST); try { - var rootWatchGroup = _readWriteGroup as RootWatchGroup; - int digestTTL = _ttl.ttl; const int LOG_COUNT = 3; List log; @@ -796,16 +794,16 @@ class RootScope extends Scope { ChangeLog changeLog; _scopeStats.digestStart(); do { - int asyncCount = _runAsyncFns(); digestTTL--; - count = rootWatchGroup.detectChanges( + count = _readWriteGroup.processChanges( exceptionHandler: _exceptionHandler, - changeLog: changeLog, - fieldStopwatch: _scopeStats.fieldStopwatch, - evalStopwatch: _scopeStats.evalStopwatch, - processStopwatch: _scopeStats.processStopwatch); + changeLog: changeLog); + // TODO + //fieldStopwatch: _scopeStats.fieldStopwatch, + //evalStopwatch: _scopeStats.evalStopwatch, + //processStopwatch: _scopeStats.processStopwatch); if (digestTTL <= LOG_COUNT) { if (changeLog == null) { @@ -832,7 +830,7 @@ class RootScope extends Scope { void flush() { _stats.flushStart(); _transitionState(null, STATE_FLUSH); - RootWatchGroup readOnlyGroup = this._readOnlyGroup as RootWatchGroup; + WatchGroup readOnlyGroup = _readOnlyGroup; bool runObservers = true; try { do { @@ -845,10 +843,11 @@ class RootScope extends Scope { } if (runObservers) { runObservers = false; - readOnlyGroup.detectChanges(exceptionHandler:_exceptionHandler, - fieldStopwatch: _scopeStats.fieldStopwatch, - evalStopwatch: _scopeStats.evalStopwatch, - processStopwatch: _scopeStats.processStopwatch); + readOnlyGroup.processChanges(exceptionHandler:_exceptionHandler); + // TODO + //fieldStopwatch: _scopeStats.fieldStopwatch, + //evalStopwatch: _scopeStats.evalStopwatch, + //processStopwatch: _scopeStats.processStopwatch); } if (_domReadCounter > 0) { _stats.domReadStart(); @@ -864,20 +863,27 @@ class RootScope extends Scope { _stats.flushAssertStart(); var digestLog = []; var flushLog = []; - (_readWriteGroup as RootWatchGroup).detectChanges( - changeLog: (s, c, p) => digestLog.add('$s: $c <= $p'), - fieldStopwatch: _scopeStats.fieldStopwatch, - evalStopwatch: _scopeStats.evalStopwatch, - processStopwatch: _scopeStats.processStopwatch); - (_readOnlyGroup as RootWatchGroup).detectChanges( - changeLog: (s, c, p) => flushLog.add('$s: $c <= $p'), - fieldStopwatch: _scopeStats.fieldStopwatch, - evalStopwatch: _scopeStats.evalStopwatch, - processStopwatch: _scopeStats.processStopwatch); + _readWriteGroup.processChanges( + changeLog: (s, c, p) => digestLog.add('$s: $c <= $p')); + // TODO + //fieldStopwatch: _scopeStats.fieldStopwatch, + //evalStopwatch: _scopeStats.evalStopwatch, + //processStopwatch: _scopeStats.processStopwatch); + _readOnlyGroup.processChanges( + changeLog: (s, c, p) => flushLog.add('$s: $c <= $p')); + // TODO + //fieldStopwatch: _scopeStats.fieldStopwatch, + //evalStopwatch: _scopeStats.evalStopwatch, + //processStopwatch: _scopeStats.processStopwatch); if (digestLog.isNotEmpty || flushLog.isNotEmpty) { - throw 'Observer reaction functions should not change model. \n' - 'These watch changes were detected: ${digestLog.join('; ')}\n' - 'These observe changes were detected: ${flushLog.join('; ')}'; + var msgs = ['Observer reaction functions should not change model.']; + if (digestLog.isNotEmpty) { + msgs.add('These watch changes were detected: ${digestLog.join('; ')}'); + } + if (flushLog.isNotEmpty) { + msgs.add('These observe changes were detected: ${flushLog.join('; ')}'); + } + throw msgs.join('\n'); } _stats.flushAssertEnd(); return true; diff --git a/lib/core_dom/element_binder.dart b/lib/core_dom/element_binder.dart index fbec8a1b4..9b3d7a9f9 100644 --- a/lib/core_dom/element_binder.dart +++ b/lib/core_dom/element_binder.dart @@ -203,9 +203,12 @@ class ElementBinder { try { directive = directiveInjector.getByKey(ref.typeKey); - var tasks = directive is AttachAware ? new _TaskList(() { - if (scope.isAttached) directive.attach(); - }) : null; + var attached = false; + + var tasks = new _TaskList(() { + attached = true; + if (directive is AttachAware && scope.isAttached) directive.attach(); + }); if (ref.mappings.isNotEmpty) { if (nodeAttrs == null) nodeAttrs = new _AnchorAttrs(ref); @@ -215,17 +218,22 @@ class ElementBinder { if (directive is AttachAware) { var taskId = (tasks != null) ? tasks.registerTask() : 0; Watch watch; - watch = scope.watch('"attach()"', // Cheat a bit. + watch = scope.watch('"attach($_count)"', // Cheat a bit. (_, __) { watch.remove(); if (tasks != null) tasks.completeTask(taskId); }); + _count++; } if (tasks != null) tasks.doneRegistering(); if (directive is DetachAware) { - scope.on(ScopeEvent.DESTROY).listen((_) => directive.detach()); + scope.on(ScopeEvent.DESTROY).listen((_) { + // if the scope has been destroyed before the directive got a chance to be attached + // do not detach it + if (attached) directive.detach(); + }); } } finally { traceLeave(s); @@ -233,6 +241,8 @@ class ElementBinder { } } + static int _count = 0; + void _createDirectiveFactories(DirectiveRef ref, DirectiveInjector nodeInjector, node, nodeAttrs) { if (ref.typeKey == TEXT_MUSTACHE_KEY) { diff --git a/lib/core_dom/module_internal.dart b/lib/core_dom/module_internal.dart index 053880a1f..07bb6bd56 100644 --- a/lib/core_dom/module_internal.dart +++ b/lib/core_dom/module_internal.dart @@ -24,8 +24,9 @@ import 'package:angular/core_dom/resource_url_resolver.dart'; export 'package:angular/core_dom/resource_url_resolver.dart' show ResourceUrlResolver, ResourceResolverConfig; -import 'package:angular/change_detection/watch_group.dart' show Watch, PrototypeMap; -import 'package:angular/change_detection/ast_parser.dart'; +import 'package:angular/change_detector/change_detector.dart'; +export 'package:angular/change_detector/change_detector.dart' show PrototypeMap; +import 'package:angular/change_detector/ast_parser.dart'; import 'package:angular/core/registry.dart'; import 'package:angular/ng_tracing.dart'; diff --git a/lib/core_dom/mustache.dart b/lib/core_dom/mustache.dart index 920cbdea1..ff8dc82b2 100644 --- a/lib/core_dom/mustache.dart +++ b/lib/core_dom/mustache.dart @@ -32,11 +32,10 @@ class AttrMustache { _updateMarkup('', 'INITIAL-VALUE'); _attrs.listenObserverChanges(_attrName, (hasObservers) { - if (_hasObservers != hasObservers) { - _hasObservers = hasObservers; - if (_watch != null) _watch.remove(); - _watch = scope.watchAST(valueAST, _updateMarkup, - canChangeModel: _hasObservers); + if (_hasObservers != hasObservers) { + _hasObservers = hasObservers; + if (_watch != null) _watch.remove(); + _watch = scope.watchAST(valueAST, _updateMarkup, canChangeModel: _hasObservers); } }); } diff --git a/lib/directive/module.dart b/lib/directive/module.dart index 8b903d3cf..1ecbd7ef9 100644 --- a/lib/directive/module.dart +++ b/lib/directive/module.dart @@ -26,8 +26,7 @@ import 'package:angular/core/parser/parser.dart'; import 'package:angular/core_dom/module_internal.dart'; import 'package:angular/core_dom/directive_injector.dart'; import 'package:angular/utils.dart'; -import 'package:angular/change_detection/watch_group.dart'; -import 'package:angular/change_detection/change_detection.dart'; +import 'package:angular/change_detector/change_detector.dart'; import 'package:angular/directive/static_keys.dart'; import 'dart:collection'; diff --git a/lib/mock/log.dart b/lib/mock/log.dart index ad28f2045..dfd1d5133 100644 --- a/lib/mock/log.dart +++ b/lib/mock/log.dart @@ -13,9 +13,7 @@ part of angular.mock; */ @Decorator( selector: '[log]', - map: const { - 'log': '@logMessage' - }) + map: const {'log': '@logMessage'}) class LogAttrDirective implements AttachAware { final Logger log; String logMessage; diff --git a/lib/tools/transformer/expression_generator.dart b/lib/tools/transformer/expression_generator.dart index 1133e6d7e..916df937b 100644 --- a/lib/tools/transformer/expression_generator.dart +++ b/lib/tools/transformer/expression_generator.dart @@ -140,7 +140,7 @@ void _writeStaticExpressionHeader(AssetId id, StringSink sink) { sink.write(''' library ${id.package}.$libPath.generated_expressions; -import 'package:angular/change_detection/change_detection.dart'; +import 'package:angular/change_detector/change_detector.dart'; '''); } diff --git a/test/angular_spec.dart b/test/angular_spec.dart index 8f60669fa..6094ba159 100644 --- a/test/angular_spec.dart +++ b/test/angular_spec.dart @@ -259,11 +259,9 @@ main() { "angular.tracing.traceEnter1", "angular.tracing.traceLeave", "angular.tracing.traceLeaveVal", - "angular.watch_group.PrototypeMap", - "angular.watch_group.ReactionFn", - "angular.watch_group.Watch", - "change_detection.AvgStopwatch", - "change_detection.FieldGetterFactory", + "angular.change_detector.ReactionFn", + "angular.change_detector.AvgStopwatch", + "angular.change_detector.FieldGetterFactory", "di.annotations.Injectable", "di.annotations.Injectables", "di.errors.CircularDependencyError", diff --git a/test/change_detection/dirty_checking_change_detector_spec.dart b/test/change_detection/dirty_checking_change_detector_spec.dart deleted file mode 100644 index 3fcabaa7d..000000000 --- a/test/change_detection/dirty_checking_change_detector_spec.dart +++ /dev/null @@ -1,1061 +0,0 @@ -library dirty_chekcing_change_detector_spec; - -import '../_specs.dart'; -import 'package:angular/change_detection/change_detection.dart'; -import 'package:angular/change_detection/dirty_checking_change_detector.dart'; -import 'package:angular/change_detection/dirty_checking_change_detector_static.dart'; -import 'package:angular/change_detection/dirty_checking_change_detector_dynamic.dart'; -import 'dart:collection'; -import 'dart:math'; - -void testWithGetterFactory(FieldGetterFactory getterFactory) { - describe('DirtyCheckingChangeDetector with ${getterFactory.runtimeType}', () { - DirtyCheckingChangeDetector detector; - - beforeEach(() { - detector = new DirtyCheckingChangeDetector(getterFactory); - }); - - describe('object field', () { - it('should detect nothing', () { - var changes = detector.collectChanges(); - expect(changes.moveNext()).toEqual(false); - }); - - it('should detect field changes', () { - var user = new _User('', ''); - Iterator changeIterator; - - detector..watch(user, 'first', null) - ..watch(user, 'last', null) - ..collectChanges(); // throw away first set - - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(false); - user..first = 'misko' - ..last = 'hevery'; - - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.current.currentValue).toEqual('misko'); - expect(changeIterator.current.previousValue).toEqual(''); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.current.currentValue).toEqual('hevery'); - expect(changeIterator.current.previousValue).toEqual(''); - expect(changeIterator.moveNext()).toEqual(false); - - // force different instance - user.first = 'mis'; - user.first += 'ko'; - - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(false); - - user.last = 'Hevery'; - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.current.currentValue).toEqual('Hevery'); - expect(changeIterator.current.previousValue).toEqual('hevery'); - expect(changeIterator.moveNext()).toEqual(false); - }); - - it('should ignore NaN != NaN', () { - var user = new _User(); - user.age = double.NAN; - detector..watch(user, 'age', null)..collectChanges(); // throw away first set - - var changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(false); - - user.age = 123; - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.current.currentValue).toEqual(123); - expect(changeIterator.current.previousValue.isNaN).toEqual(true); - expect(changeIterator.moveNext()).toEqual(false); - }); - - it('should treat map field dereference as []', () { - var obj = {'name':'misko'}; - detector.watch(obj, 'name', null); - detector.collectChanges(); // throw away first set - - obj['name'] = 'Misko'; - var changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.current.currentValue).toEqual('Misko'); - expect(changeIterator.current.previousValue).toEqual('misko'); - }); - }); - - describe('insertions / removals', () { - it('should insert at the end of list', () { - var obj = {}; - var a = detector.watch(obj, 'a', 'a'); - var b = detector.watch(obj, 'b', 'b'); - - obj['a'] = obj['b'] = 1; - var changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.current.handler).toEqual('a'); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.current.handler).toEqual('b'); - expect(changeIterator.moveNext()).toEqual(false); - - obj['a'] = obj['b'] = 2; - a.remove(); - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.current.handler).toEqual('b'); - expect(changeIterator.moveNext()).toEqual(false); - - obj['a'] = obj['b'] = 3; - b.remove(); - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(false); - }); - - it('should remove all watches in group and group\'s children', () { - var obj = {}; - detector.watch(obj, 'a', '0a'); - var child1a = detector.newGroup(); - var child1b = detector.newGroup(); - var child2 = child1a.newGroup(); - child1a.watch(obj,'a', '1a'); - child1b.watch(obj,'a', '1b'); - detector.watch(obj, 'a', '0A'); - child1a.watch(obj,'a', '1A'); - child2.watch(obj,'a', '2A'); - - var iterator; - obj['a'] = 1; - expect(detector.collectChanges(), - toEqualChanges(['0a', '0A', '1a', '1A', '2A', '1b'])); - - obj['a'] = 2; - child1a.remove(); // should also remove child2 - expect(detector.collectChanges(), toEqualChanges(['0a', '0A', '1b'])); - }); - - it('should add watches within its own group', () { - var obj = {}; - var ra = detector.watch(obj, 'a', 'a'); - var child = detector.newGroup(); - var cb = child.watch(obj,'b', 'b'); - var iterotar; - - obj['a'] = obj['b'] = 1; - expect(detector.collectChanges(), toEqualChanges(['a', 'b'])); - - obj['a'] = obj['b'] = 2; - ra.remove(); - expect(detector.collectChanges(), toEqualChanges(['b'])); - - obj['a'] = obj['b'] = 3; - cb.remove(); - expect(detector.collectChanges(), toEqualChanges([])); - - // TODO: add them back in wrong order, assert events in right order - cb = child.watch(obj,'b', 'b'); - ra = detector.watch(obj, 'a', 'a'); - obj['a'] = obj['b'] = 4; - expect(detector.collectChanges(), toEqualChanges(['a', 'b'])); - }); - - it('should properly add children', () { - var a = detector.newGroup(); - var aChild = a.newGroup(); - var b = detector.newGroup(); - expect(detector.collectChanges).not.toThrow(); - }); - - it('should properly disconnect group in case watch is removed in disconected group', () { - var map = {}; - var detector0 = new DirtyCheckingChangeDetector(getterFactory); - var detector1 = detector0.newGroup(); - var detector2 = detector1.newGroup(); - var watch2 = detector2.watch(map, 'f1', null); - var detector3 = detector0.newGroup(); - detector1.remove(); - watch2.remove(); // removing a dead record - detector3.watch(map, 'f2', null); - }); - - it('should find random bugs', () { - List detectors; - List records; - List steps; - var field = 'someField'; - step(text) { - //print(text); - steps.add(text); - } - Map map = {}; - var random = new Random(); - try { - for (var i = 0; i < 100000; i++) { - if (i % 50 == 0) { - records = []; - steps = []; - detectors = [new DirtyCheckingChangeDetector(getterFactory)]; - } - switch (random.nextInt(4)) { - case 0: // new child detector - if (detectors.length > 10) break; - var index = random.nextInt(detectors.length); - ChangeDetectorGroup detector = detectors[index]; - step('detectors[$index].newGroup()'); - var child = detector.newGroup(); - detectors.add(child); - break; - case 1: // add watch - var index = random.nextInt(detectors.length); - ChangeDetectorGroup detector = detectors[index]; - step('detectors[$index].watch(map, field, null)'); - WatchRecord record = detector.watch(map, field, null); - records.add(record); - break; - case 2: // destroy watch group - if (detectors.length == 1) break; - var index = random.nextInt(detectors.length - 1) + 1; - ChangeDetectorGroup detector = detectors[index]; - step('detectors[$index].remove()'); - detector.remove(); - detectors = detectors - .where((s) => s.isAttached) - .toList(); - break; - case 3: // remove watch on watch group - if (records.length == 0) break; - var index = random.nextInt(records.length); - WatchRecord record = records.removeAt(index); - step('records.removeAt($index).remove()'); - record.remove(); - break; - } - } - } catch(e) { - print(steps); - rethrow; - } - }); - - }); - - describe('list watching', () { - describe('previous state', () { - it('should store on addition', () { - var list = []; - var record = detector.watch(list, null, null); - expect(detector.collectChanges().moveNext()).toEqual(false); - var iterator; - - list.add('a'); - iterator = detector.collectChanges(); - expect(iterator.moveNext()).toEqual(true); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['a[null -> 0]'], - previous: [], - additions: ['a[null -> 0]'], - moves: [], - removals: [])); - - list.add('b'); - iterator = detector.collectChanges(); - expect(iterator.moveNext()).toEqual(true); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['a', 'b[null -> 1]'], - previous: ['a'], - additions: ['b[null -> 1]'], - moves: [], - removals: [])); - }); - - it('should support switching refs - gh 1158', async(() { - var list = [0]; - - var record = detector.watch(list, null, null); - if (detector.collectChanges().moveNext()) { - detector.collectChanges(); - } - - record.object = [1, 0]; - var iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['1[null -> 0]', '0[0 -> 1]'], - previous: ['0[0 -> 1]'], - additions: ['1[null -> 0]'], - moves: ['0[0 -> 1]'], - removals: [])); - - record.object = [2, 1, 0]; - iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['2[null -> 0]', '1[null -> 1]', '0[0 -> 2]'], - previous: ['0[0 -> 2]'], - additions: ['2[null -> 0]', '1[null -> 1]'], - moves: ['0[0 -> 2]'], - removals: [])); - })); - - it('should handle swapping elements correctly', () { - var list = [1, 2]; - var record = detector.watch(list, null, null); - detector.collectChanges().moveNext(); - var iterator; - - // reverse the list. - list.setAll(0, list.reversed.toList()); - iterator = detector.collectChanges(); - expect(iterator.moveNext()).toEqual(true); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['2[1 -> 0]', '1[0 -> 1]'], - previous: ['1[0 -> 1]', '2[1 -> 0]'], - additions: [], - moves: ['2[1 -> 0]', '1[0 -> 1]'], - removals: [])); - }); - - it('should handle swapping elements correctly - gh1097', () { - // This test would only have failed in non-checked mode only - var list = ['a', 'b', 'c']; - var record = detector.watch(list, null, null); - var iterator = detector.collectChanges()..moveNext(); - - list..clear()..addAll(['b', 'a', 'c']); - iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['b[1 -> 0]', 'a[0 -> 1]', 'c'], - previous: ['a[0 -> 1]', 'b[1 -> 0]', 'c'], - additions: [], - moves: ['b[1 -> 0]', 'a[0 -> 1]'], - removals: [])); - - list..clear()..addAll(['b', 'c', 'a']); - iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['b', 'c[2 -> 1]', 'a[1 -> 2]'], - previous: ['b', 'a[1 -> 2]', 'c[2 -> 1]'], - additions: [], - moves: ['c[2 -> 1]', 'a[1 -> 2]'], - removals: [])); - }); - }); - - it('should detect changes in list', () { - var list = []; - var record = detector.watch(list, null, 'handler'); - expect(detector.collectChanges().moveNext()).toEqual(false); - var iterator; - - list.add('a'); - iterator = detector.collectChanges(); - expect(iterator.moveNext()).toEqual(true); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['a[null -> 0]'], - additions: ['a[null -> 0]'], - moves: [], - removals: [])); - - list.add('b'); - iterator = detector.collectChanges(); - expect(iterator.moveNext()).toEqual(true); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['a', 'b[null -> 1]'], - previous: ['a'], - additions: ['b[null -> 1]'], - moves: [], - removals: [])); - - list.add('c'); - list.add('d'); - iterator = detector.collectChanges(); - expect(iterator.moveNext()).toEqual(true); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['a', 'b', 'c[null -> 2]', 'd[null -> 3]'], - previous: ['a', 'b'], - additions: ['c[null -> 2]', 'd[null -> 3]'], - moves: [], - removals: [])); - - list.remove('c'); - expect(list).toEqual(['a', 'b', 'd']); - iterator = detector.collectChanges(); - expect(iterator.moveNext()).toEqual(true); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['a', 'b', 'd[3 -> 2]'], - previous: ['a', 'b', 'c[2 -> null]', 'd[3 -> 2]'], - additions: [], - moves: ['d[3 -> 2]'], - removals: ['c[2 -> null]'])); - - list.clear(); - list.addAll(['d', 'c', 'b', 'a']); - iterator = detector.collectChanges(); - expect(iterator.moveNext()).toEqual(true); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['d[2 -> 0]', 'c[null -> 1]', 'b[1 -> 2]', 'a[0 -> 3]'], - previous: ['a[0 -> 3]', 'b[1 -> 2]', 'd[2 -> 0]'], - additions: ['c[null -> 1]'], - moves: ['d[2 -> 0]', 'b[1 -> 2]', 'a[0 -> 3]'], - removals: [])); - }); - - it('should test string by value rather than by reference', () { - var list = ['a', 'boo']; - detector..watch(list, null, null)..collectChanges(); - - list[1] = 'b' + 'oo'; - - expect(detector.collectChanges().moveNext()).toEqual(false); - }); - - it('should ignore [NaN] != [NaN]', () { - var list = [double.NAN]; - var record = detector..watch(list, null, null)..collectChanges(); - - expect(detector.collectChanges().moveNext()).toEqual(false); - }); - - it('should detect [NaN] moves', () { - var list = [double.NAN, double.NAN]; - detector..watch(list, null, null)..collectChanges(); - - list..clear()..addAll(['foo', double.NAN, double.NAN]); - var iterator = detector.collectChanges(); - expect(iterator.moveNext()).toEqual(true); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['foo[null -> 0]', 'NaN[0 -> 1]', 'NaN[1 -> 2]'], - previous: ['NaN[0 -> 1]', 'NaN[1 -> 2]'], - additions: ['foo[null -> 0]'], - moves: ['NaN[0 -> 1]', 'NaN[1 -> 2]'], - removals: [])); - }); - - it('should remove and add same item', () { - var list = ['a', 'b', 'c']; - var record = detector.watch(list, null, 'handler'); - var iterator; - detector.collectChanges(); - - list.remove('b'); - iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['a', 'c[2 -> 1]'], - previous: ['a', 'b[1 -> null]', 'c[2 -> 1]'], - additions: [], - moves: ['c[2 -> 1]'], - removals: ['b[1 -> null]'])); - - list.insert(1, 'b'); - expect(list).toEqual(['a', 'b', 'c']); - iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['a', 'b[null -> 1]', 'c[1 -> 2]'], - previous: ['a', 'c[1 -> 2]'], - additions: ['b[null -> 1]'], - moves: ['c[1 -> 2]'], - removals: [])); - }); - - it('should support duplicates', () { - var list = ['a', 'a', 'a', 'b', 'b']; - var record = detector.watch(list, null, 'handler'); - detector.collectChanges(); - - list.removeAt(0); - var iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['a', 'a', 'b[3 -> 2]', 'b[4 -> 3]'], - previous: ['a', 'a', 'a[2 -> null]', 'b[3 -> 2]', 'b[4 -> 3]'], - additions: [], - moves: ['b[3 -> 2]', 'b[4 -> 3]'], - removals: ['a[2 -> null]'])); - }); - - - it('should support insertions/moves', () { - var list = ['a', 'a', 'b', 'b']; - var record = detector.watch(list, null, 'handler'); - var iterator; - detector.collectChanges(); - list.insert(0, 'b'); - expect(list).toEqual(['b', 'a', 'a', 'b', 'b']); - iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['b[2 -> 0]', 'a[0 -> 1]', 'a[1 -> 2]', 'b', 'b[null -> 4]'], - previous: ['a[0 -> 1]', 'a[1 -> 2]', 'b[2 -> 0]', 'b'], - additions: ['b[null -> 4]'], - moves: ['b[2 -> 0]', 'a[0 -> 1]', 'a[1 -> 2]'], - removals: [])); - }); - - it('should support UnmodifiableListView', () { - var hiddenList = [1]; - var list = new UnmodifiableListView(hiddenList); - var record = detector.watch(list, null, 'handler'); - var iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['1[null -> 0]'], - additions: ['1[null -> 0]'], - moves: [], - removals: [])); - - // assert no changes detected - expect(detector.collectChanges().moveNext()).toEqual(false); - - // change the hiddenList normally this should trigger change detection - // but because we are wrapped in UnmodifiableListView we see nothing. - hiddenList[0] = 2; - expect(detector.collectChanges().moveNext()).toEqual(false); - }); - - it('should bug', () { - var list = [1, 2, 3, 4]; - var record = detector.watch(list, null, 'handler'); - var iterator; - - iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['1[null -> 0]', '2[null -> 1]', '3[null -> 2]', '4[null -> 3]'], - additions: ['1[null -> 0]', '2[null -> 1]', '3[null -> 2]', '4[null -> 3]'], - moves: [], - removals: [])); - detector.collectChanges(); - - list.removeRange(0, 1); - iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['2[1 -> 0]', '3[2 -> 1]', '4[3 -> 2]'], - previous: ['1[0 -> null]', '2[1 -> 0]', '3[2 -> 1]', '4[3 -> 2]'], - additions: [], - moves: ['2[1 -> 0]', '3[2 -> 1]', '4[3 -> 2]'], - removals: ['1[0 -> null]'])); - - list.insert(0, 1); - iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['1[null -> 0]', '2[0 -> 1]', '3[1 -> 2]', '4[2 -> 3]'], - previous: ['2[0 -> 1]', '3[1 -> 2]', '4[2 -> 3]'], - additions: ['1[null -> 0]'], - moves: ['2[0 -> 1]', '3[1 -> 2]', '4[2 -> 3]'], - removals: [])); - }); - - it('should properly support objects with equality', () { - FooBar.fooIds = 0; - var list = [new FooBar('a', 'a'), new FooBar('a', 'a')]; - var record = detector.watch(list, null, 'handler'); - var iterator; - - iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['(0)a-a[null -> 0]', '(1)a-a[null -> 1]'], - additions: ['(0)a-a[null -> 0]', '(1)a-a[null -> 1]'], - moves: [], - removals: [])); - detector.collectChanges(); - - list.removeRange(0, 1); - iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['(1)a-a[1 -> 0]'], - previous: ['(0)a-a[0 -> null]', '(1)a-a[1 -> 0]'], - additions: [], - moves: ['(1)a-a[1 -> 0]'], - removals: ['(0)a-a[0 -> null]'])); - - list.insert(0, new FooBar('a', 'a')); - iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['(2)a-a[null -> 0]', '(1)a-a[0 -> 1]'], - previous: ['(1)a-a[0 -> 1]'], - additions: ['(2)a-a[null -> 0]'], - moves: ['(1)a-a[0 -> 1]'], - removals: [])); - }); - - it('should not report unnecessary moves', () { - var list = ['a', 'b', 'c']; - var record = detector.watch(list, null, null); - var iterator = detector.collectChanges()..moveNext(); - - list..clear()..addAll(['b', 'a', 'c']); - iterator = detector.collectChanges()..moveNext(); - expect(iterator.current.currentValue, toEqualCollectionRecord( - collection: ['b[1 -> 0]', 'a[0 -> 1]', 'c'], - previous: ['a[0 -> 1]', 'b[1 -> 0]', 'c'], - additions: [], - moves: ['b[1 -> 0]', 'a[0 -> 1]'], - removals: [])); - }); - }); - - describe('map watching', () { - describe('previous state', () { - it('should store on insertion', () { - var map = {}; - var record = detector.watch(map, null, null); - expect(detector.collectChanges().moveNext()).toEqual(false); - var iterator; - - map['a'] = 1; - iterator = detector.collectChanges(); - expect(iterator.moveNext()).toEqual(true); - expect(iterator.current.currentValue, toEqualMapRecord( - map: ['a[null -> 1]'], - previous: [], - additions: ['a[null -> 1]'], - changes: [], - removals: [])); - - map['b'] = 2; - iterator = detector.collectChanges(); - expect(iterator.moveNext()).toEqual(true); - expect(iterator.current.currentValue, toEqualMapRecord( - map: ['a', 'b[null -> 2]'], - previous: ['a'], - additions: ['b[null -> 2]'], - changes: [], - removals: [])); - }); - - it('should handle changing key/values correctly', () { - var map = {1: 10, 2: 20}; - var record = detector.watch(map, null, null); - detector.collectChanges().moveNext(); - var iterator; - - map[1] = 20; - map[2] = 10; - iterator = detector.collectChanges(); - expect(iterator.moveNext()).toEqual(true); - expect(iterator.current.currentValue, toEqualMapRecord( - map: ['1[10 -> 20]', '2[20 -> 10]'], - previous: ['1[10 -> 20]', '2[20 -> 10]'], - additions: [], - changes: ['1[10 -> 20]', '2[20 -> 10]'], - removals: [])); - }); - }); - - it('should do basic map watching', () { - var map = {}; - var record = detector.watch(map, null, 'handler'); - expect(detector.collectChanges().moveNext()).toEqual(false); - - var changeIterator; - map['a'] = 'A'; - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.current.currentValue, toEqualMapRecord( - map: ['a[null -> A]'], - previous: [], - additions: ['a[null -> A]'], - changes: [], - removals: [])); - - map['b'] = 'B'; - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.current.currentValue, toEqualMapRecord( - map: ['a', 'b[null -> B]'], - previous: ['a'], - additions: ['b[null -> B]'], - changes: [], - removals: [])); - - map['b'] = 'BB'; - map['d'] = 'D'; - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.current.currentValue, toEqualMapRecord( - map: ['a', 'b[B -> BB]', 'd[null -> D]'], - previous: ['a', 'b[B -> BB]'], - additions: ['d[null -> D]'], - changes: ['b[B -> BB]'], - removals: [])); - - map.remove('b'); - expect(map).toEqual({'a': 'A', 'd':'D'}); - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.current.currentValue, toEqualMapRecord( - map: ['a', 'd'], - previous: ['a', 'b[BB -> null]', 'd'], - additions: [], - changes: [], - removals: ['b[BB -> null]'])); - - map.clear(); - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.current.currentValue, toEqualMapRecord( - map: [], - previous: ['a[A -> null]', 'd[D -> null]'], - additions: [], - changes: [], - removals: ['a[A -> null]', 'd[D -> null]'])); - }); - - it('should test string keys by value rather than by reference', () { - var map = {'foo': 0}; - detector..watch(map, null, null)..collectChanges(); - - map['f' + 'oo'] = 0; - - expect(detector.collectChanges().moveNext()).toEqual(false); - }); - - it('should test string values by value rather than by reference', () { - var map = {'foo': 'bar'}; - detector..watch(map, null, null)..collectChanges(); - - map['foo'] = 'b' + 'ar'; - - expect(detector.collectChanges().moveNext()).toEqual(false); - }); - - it('should not see a NaN value as a change', () { - var map = {'foo': double.NAN}; - var record = detector..watch(map, null, null)..collectChanges(); - - expect(detector.collectChanges().moveNext()).toEqual(false); - }); - }); - - describe('function watching', () { - it('should detect no changes when watching a function', () { - var user = new _User('marko', 'vuksanovic', 15); - Iterator changeIterator; - - detector..watch(user, 'isUnderAge', null); - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - expect(changeIterator.moveNext()).toEqual(false); - - user.age = 17; - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(false); - - user.age = 30; - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(false); - }); - - it('should detect change when watching a property function', () { - var user = new _User('marko', 'vuksanovic', 30); - Iterator changeIterator; - - detector..watch(user, 'isUnderAgeAsVariable', null); - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(false); - - user.isUnderAgeAsVariable = () => false; - changeIterator = detector.collectChanges(); - expect(changeIterator.moveNext()).toEqual(true); - }); - }); - - describe('DuplicateMap', () { - DuplicateMap map; - beforeEach(() => map = new DuplicateMap()); - - it('should do basic operations', () { - var k1 = 'a'; - var r1 = new ItemRecord(k1)..currentIndex = 1; - map.put(r1); - expect(map.get(k1, 2)).toEqual(null); - expect(map.get(k1, 1)).toEqual(null); - expect(map.get(k1, 0)).toEqual(r1); - expect(map.remove(r1)).toEqual(r1); - expect(map.get(k1, -1)).toEqual(null); - }); - - it('should do basic operations on duplicate keys', () { - var k1 = 'a'; - var r1 = new ItemRecord(k1)..currentIndex = 1; - var r2 = new ItemRecord(k1)..currentIndex = 2; - map..put(r1)..put(r2); - expect(map.get(k1, 0)).toEqual(r1); - expect(map.get(k1, 1)).toEqual(r2); - expect(map.get(k1, 2)).toEqual(null); - expect(map.remove(r2)).toEqual(r2); - expect(map.get(k1, 0)).toEqual(r1); - expect(map.remove(r1)).toEqual(r1); - expect(map.get(k1, 0)).toEqual(null); - }); - }); - }); -} - - -void main() { - testWithGetterFactory(new DynamicFieldGetterFactory()); - - testWithGetterFactory(new StaticFieldGetterFactory({ - "first": (o) => o.first, - "age": (o) => o.age, - "last": (o) => o.last, - "toString": (o) => o.toString, - "isUnderAge": (o) => o.isUnderAge, - "isUnderAgeAsVariable": (o) => o.isUnderAgeAsVariable, - })); -} - - -class _User { - String first; - String last; - num age; - var isUnderAgeAsVariable; - List list = ['foo', 'bar', 'baz']; - Map map = {'foo': 'bar', 'baz': 'cux'}; - - _User([this.first, this.last, this.age]) { - isUnderAgeAsVariable = isUnderAge; - } - - bool isUnderAge() { - return age != null ? age < 18 : false; - } -} - -Matcher toEqualCollectionRecord({collection, previous, additions, moves, removals}) => - new CollectionRecordMatcher(collection:collection, previous: previous, - additions:additions, moves:moves, removals:removals); -Matcher toEqualMapRecord({map, previous, additions, changes, removals}) => - new MapRecordMatcher(map:map, previous: previous, - additions:additions, changes:changes, removals:removals); -Matcher toEqualChanges(List changes) => new ChangeMatcher(changes); - -class ChangeMatcher extends Matcher { - List expected; - - ChangeMatcher(this.expected); - - Description describe(Description description) => - description..add(expected.toString()); - - Description describeMismatch(Iterator changes, - Description mismatchDescription, - Map matchState, bool verbose) { - List list = []; - while(changes.moveNext()) { - list.add(changes.current.handler); - } - return mismatchDescription..add(list.toString()); - } - - bool matches(Iterator changes, Map matchState) { - int count = 0; - while(changes.moveNext()) { - if (changes.current.handler != expected[count++]) return false; - } - return count == expected.length; - } -} - -abstract class _CollectionMatcher extends Matcher { - List _getList(Function it) { - var result = []; - it((item) { - result.add(item); - }); - return result; - } - - bool _compareLists(String tag, List expected, List actual, List diffs) { - var equals = true; - Iterator iActual = actual.iterator; - iActual.moveNext(); - T actualItem = iActual.current; - if (expected == null) { - expected = []; - } - for (String expectedItem in expected) { - if (actualItem == null) { - equals = false; - diffs.add('$tag too short: $expectedItem'); - } else { - if ("$actualItem" != expectedItem) { - equals = false; - diffs.add('$tag mismatch: $actualItem != $expectedItem'); - } - iActual.moveNext(); - actualItem = iActual.current; - } - } - if (actualItem != null) { - diffs.add('$tag too long: $actualItem'); - equals = false; - } - return equals; - } -} - -class CollectionRecordMatcher extends _CollectionMatcher { - final List collection; - final List previous; - final List additions; - final List moves; - final List removals; - - CollectionRecordMatcher({this.collection, this.previous, - this.additions, this.moves, this.removals}); - - Description describeMismatch(changes, Description mismatchDescription, - Map matchState, bool verbose) { - List diffs = matchState['diffs']; - if (diffs == null) return mismatchDescription; - return mismatchDescription..add(diffs.join('\n')); - } - - Description describe(Description description) { - add(name, collection) { - if (collection != null) { - description.add('$name: ${collection.join(', ')}\n '); - } - } - - add('collection', collection); - add('previous', previous); - add('additions', additions); - add('moves', moves); - add('removals', removals); - return description; - } - - bool matches(CollectionChangeRecord changeRecord, Map matchState) { - var diffs = matchState['diffs'] = []; - return checkCollection(changeRecord, diffs) && - checkPrevious(changeRecord, diffs) && - checkAdditions(changeRecord, diffs) && - checkMoves(changeRecord, diffs) && - checkRemovals(changeRecord, diffs); - } - - bool checkCollection(CollectionChangeRecord changeRecord, List diffs) { - List items = _getList((fn) => changeRecord.forEachItem(fn)); - bool equals = _compareLists("collection", collection, items, diffs); - int iterableLength = changeRecord.iterable.toList().length; - if (iterableLength != items.length) { - diffs.add('collection length mismatched: $iterableLength != ${items.length}'); - equals = false; - } - return equals; - } - - bool checkPrevious(CollectionChangeRecord changeRecord, List diffs) { - List items = _getList((fn) => changeRecord.forEachPreviousItem(fn)); - return _compareLists("previous", previous, items, diffs); - } - - bool checkAdditions(CollectionChangeRecord changeRecord, List diffs) { - List items = _getList((fn) => changeRecord.forEachAddition(fn)); - return _compareLists("additions", additions, items, diffs); - } - - bool checkMoves(CollectionChangeRecord changeRecord, List diffs) { - List items = _getList((fn) => changeRecord.forEachMove(fn)); - return _compareLists("moves", moves, items, diffs); - } - - bool checkRemovals(CollectionChangeRecord changeRecord, List diffs) { - List items = _getList((fn) => changeRecord.forEachRemoval(fn)); - return _compareLists("removes", removals, items, diffs); - } -} - -class MapRecordMatcher extends _CollectionMatcher { - final List map; - final List previous; - final List additions; - final List changes; - final List removals; - - MapRecordMatcher({this.map, this.previous, this.additions, this.changes, this.removals}); - - Description describeMismatch(changes, Description mismatchDescription, - Map matchState, bool verbose) { - List diffs = matchState['diffs']; - if (diffs == null) return mismatchDescription; - return mismatchDescription..add(diffs.join('\n')); - } - - Description describe(Description description) { - add(name, map) { - if (map != null) { - description.add('$name: ${map.join(', ')}\n '); - } - } - - add('map', map); - add('previous', previous); - add('additions', additions); - add('changes', changes); - add('removals', removals); - return description; - } - - bool matches(MapChangeRecord changeRecord, Map matchState) { - var diffs = matchState['diffs'] = []; - return checkMap(changeRecord, diffs) && - checkPrevious(changeRecord, diffs) && - checkAdditions(changeRecord, diffs) && - checkChanges(changeRecord, diffs) && - checkRemovals(changeRecord, diffs); - } - - bool checkMap(MapChangeRecord changeRecord, List diffs) { - List items = _getList((fn) => changeRecord.forEachItem(fn)); - bool equals = _compareLists("map", map, items, diffs); - int mapLength = changeRecord.map.length; - if (mapLength != items.length) { - diffs.add('map length mismatched: $mapLength != ${items.length}'); - equals = false; - } - return equals; - } - - bool checkPrevious(MapChangeRecord changeRecord, List diffs) { - List items = _getList((fn) => changeRecord.forEachPreviousItem(fn)); - return _compareLists("previous", previous, items, diffs); - } - - bool checkAdditions(MapChangeRecord changeRecord, List diffs) { - List items = _getList((fn) => changeRecord.forEachAddition(fn)); - return _compareLists("additions", additions, items, diffs); - } - - bool checkChanges(MapChangeRecord changeRecord, List diffs) { - List items = _getList((fn) => changeRecord.forEachChange(fn)); - return _compareLists("changes", changes, items, diffs); - } - - bool checkRemovals(MapChangeRecord changeRecord, List diffs) { - List items = _getList((fn) => changeRecord.forEachRemoval(fn)); - return _compareLists("removals", removals, items, diffs); - } -} - -class FooBar { - static int fooIds = 0; - - int id; - String foo, bar; - - FooBar(this.foo, this.bar) { - id = fooIds++; - } - - bool operator==(other) => - other is FooBar && foo == other.foo && bar == other.bar; - - int get hashCode => foo.hashCode ^ bar.hashCode; - - String toString() => '($id)$foo-$bar'; -} diff --git a/test/change_detection/watch_group_spec.dart b/test/change_detection/watch_group_spec.dart deleted file mode 100644 index 43dd95c9b..000000000 --- a/test/change_detection/watch_group_spec.dart +++ /dev/null @@ -1,1005 +0,0 @@ -library watch_group_spec; - -import '../_specs.dart'; -import 'dart:collection'; -import 'package:angular/change_detection/ast_parser.dart'; -import 'package:angular/change_detection/watch_group.dart'; -import 'package:angular/change_detection/dirty_checking_change_detector.dart'; -import 'package:angular/change_detection/dirty_checking_change_detector_dynamic.dart'; -import 'dirty_checking_change_detector_spec.dart' hide main; -import 'package:angular/core/parser/dynamic_closure_map.dart'; - -class TestData { - sub1(a, {b: 0}) => a - b; - sub2({a: 0, b: 0}) => a - b; -} - -void main() { - describe('WatchGroup', () { - var context; - var watchGrp; - DirtyCheckingChangeDetector changeDetector; - Logger logger; - Parser parser; - ASTParser astParser; - - beforeEach((Logger _logger, Parser _parser, ASTParser _astParser) { - context = {}; - var getterFactory = new DynamicFieldGetterFactory(); - changeDetector = new DirtyCheckingChangeDetector(getterFactory); - watchGrp = new RootWatchGroup(getterFactory, changeDetector, context); - logger = _logger; - parser = _parser; - astParser = _astParser; - }); - - AST parse(String expression) => astParser(expression); - - eval(String expression, [evalContext]) { - AST ast = parse(expression); - - if (evalContext == null) evalContext = context; - WatchGroup group = watchGrp.newGroup(evalContext); - - List log = []; - Watch watch = group.watch(ast, (v, p) => log.add(v)); - - watchGrp.detectChanges(); - group.remove(); - - if (log.isEmpty) { - throw new StateError('Expression <$expression> was not evaluated'); - } else if (log.length > 1) { - throw new StateError('Expression <$expression> produced too many values: $log'); - } else { - return log.first; - } - } - - expectOrder(list) { - logger.clear(); - watchGrp.detectChanges(); // Clear the initial queue - logger.clear(); - watchGrp.detectChanges(); - expect(logger).toEqual(list); - } - - beforeEach((Logger _logger) { - context = {}; - var getterFactory = new DynamicFieldGetterFactory(); - changeDetector = new DirtyCheckingChangeDetector(getterFactory); - watchGrp = new RootWatchGroup(getterFactory, changeDetector, context); - logger = _logger; - }); - - it('should have a toString for debugging', () { - watchGrp.watch(parse('a'), (v, p) {}); - watchGrp.newGroup({}); - expect("$watchGrp").toEqual( - 'WATCHES: MARKER[null], MARKER[null]\n' - 'WatchGroup[](watches: MARKER[null])\n' - ' WatchGroup[.0](watches: MARKER[null])' - ); - }); - - describe('watch lifecycle', () { - it('should prevent reaction fn on removed', () { - context['a'] = 'hello'; - var watch ; - watchGrp.watch(parse('a'), (v, p) { - logger('removed'); - watch.remove(); - }); - watch = watchGrp.watch(parse('a'), (v, p) => logger(v)); - watchGrp.detectChanges(); - expect(logger).toEqual(['removed']); - }); - }); - - describe('property chaining', () { - it('should read property', () { - context['a'] = 'hello'; - - // should fire on initial adding - expect(watchGrp.fieldCost).toEqual(0); - var watch = watchGrp.watch(parse('a'), (v, p) => logger(v)); - expect(watch.expression).toEqual('a'); - expect(watchGrp.fieldCost).toEqual(1); - watchGrp.detectChanges(); - expect(logger).toEqual(['hello']); - - // make sore no new changes are logged on extra detectChanges - watchGrp.detectChanges(); - expect(logger).toEqual(['hello']); - - // Should detect value change - context['a'] = 'bye'; - watchGrp.detectChanges(); - expect(logger).toEqual(['hello', 'bye']); - - // should cleanup after itself - watch.remove(); - expect(watchGrp.fieldCost).toEqual(0); - context['a'] = 'cant see me'; - watchGrp.detectChanges(); - expect(logger).toEqual(['hello', 'bye']); - }); - - describe('sequence mutations and ref changes', () { - it('should handle a simultaneous map mutation and reference change', () { - context['a'] = context['b'] = {1: 10, 2: 20}; - var watchA = watchGrp.watch(new CollectionAST(parse('a')), (v, p) => logger(v)); - var watchB = watchGrp.watch(new CollectionAST(parse('b')), (v, p) => logger(v)); - - watchGrp.detectChanges(); - expect(logger.length).toEqual(2); - expect(logger[0], toEqualMapRecord( - map: ['1', '2'], - previous: ['1', '2'])); - expect(logger[1], toEqualMapRecord( - map: ['1', '2'], - previous: ['1', '2'])); - logger.clear(); - - // context['a'] is set to a copy with an addition. - context['a'] = new Map.from(context['a'])..[3] = 30; - // context['b'] still has the original collection. We'll mutate it. - context['b'].remove(1); - - watchGrp.detectChanges(); - expect(logger.length).toEqual(2); - expect(logger[0], toEqualMapRecord( - map: ['1', '2', '3[null -> 30]'], - previous: ['1', '2'], - additions: ['3[null -> 30]'])); - expect(logger[1], toEqualMapRecord( - map: ['2'], - previous: ['1[10 -> null]', '2'], - removals: ['1[10 -> null]'])); - logger.clear(); - }); - - it('should handle a simultaneous list mutation and reference change', () { - context['a'] = context['b'] = [0, 1]; - var watchA = watchGrp.watch(new CollectionAST(parse('a')), (v, p) => logger(v)); - var watchB = watchGrp.watch(new CollectionAST(parse('b')), (v, p) => logger(v)); - - watchGrp.detectChanges(); - expect(logger.length).toEqual(2); - expect(logger[0], toEqualCollectionRecord( - collection: ['0', '1'], - previous: ['0', '1'], - additions: [], moves: [], removals: [])); - expect(logger[1], toEqualCollectionRecord( - collection: ['0', '1'], - previous: ['0', '1'], - additions: [], moves: [], removals: [])); - logger.clear(); - - // context['a'] is set to a copy with an addition. - context['a'] = context['a'].toList()..add(2); - // context['b'] still has the original collection. We'll mutate it. - context['b'].remove(0); - - watchGrp.detectChanges(); - expect(logger.length).toEqual(2); - expect(logger[0], toEqualCollectionRecord( - collection: ['0', '1', '2[null -> 2]'], - previous: ['0', '1'], - additions: ['2[null -> 2]'], - moves: [], - removals: [])); - expect(logger[1], toEqualCollectionRecord( - collection: ['1[1 -> 0]'], - previous: ['0[0 -> null]', '1[1 -> 0]'], - additions: [], - moves: ['1[1 -> 0]'], - removals: ['0[0 -> null]'])); - logger.clear(); - }); - - it('should work correctly with UnmodifiableListView', () { - context['a'] = new UnmodifiableListView([0, 1]); - var watch = watchGrp.watch(new CollectionAST(parse('a')), (v, p) => logger(v)); - - watchGrp.detectChanges(); - expect(logger.length).toEqual(1); - expect(logger[0], toEqualCollectionRecord( - collection: ['0', '1'], - previous: ['0', '1'])); - logger.clear(); - - context['a'] = new UnmodifiableListView([1, 0]); - - watchGrp.detectChanges(); - expect(logger.length).toEqual(1); - expect(logger[0], toEqualCollectionRecord( - collection: ['1[1 -> 0]', '0[0 -> 1]'], - previous: ['0[0 -> 1]', '1[1 -> 0]'], - moves: ['1[1 -> 0]', '0[0 -> 1]'])); - logger.clear(); - }); - - }); - - it('should read property chain', () { - context['a'] = {'b': 'hello'}; - - // should fire on initial adding - expect(watchGrp.fieldCost).toEqual(0); - expect(changeDetector.count).toEqual(0); - 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); - watchGrp.detectChanges(); - expect(logger).toEqual(['hello']); - - // make sore no new changes are logged on extra detectChanges - watchGrp.detectChanges(); - expect(logger).toEqual(['hello']); - - // make sure no changes or logged when intermediary object changes - context['a'] = {'b': 'hello'}; - watchGrp.detectChanges(); - expect(logger).toEqual(['hello']); - - // Should detect value change - context['a'] = {'b': 'hello2'}; - watchGrp.detectChanges(); - expect(logger).toEqual(['hello', 'hello2']); - - // Should detect value change - context['a']['b'] = 'bye'; - watchGrp.detectChanges(); - expect(logger).toEqual(['hello', 'hello2', 'bye']); - - // should cleanup after itself - watch.remove(); - expect(watchGrp.fieldCost).toEqual(0); - context['a']['b'] = 'cant see me'; - watchGrp.detectChanges(); - expect(logger).toEqual(['hello', 'hello2', 'bye']); - }); - - it('should not reuse handlers', () { - var user1 = {'first': 'misko', 'last': 'hevery'}; - var user2 = {'first': 'misko', 'last': 'Hevery'}; - - context['user'] = user1; - - // should fire on initial adding - expect(watchGrp.fieldCost).toEqual(0); - 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(5); - - watchGrp.detectChanges(); - expect(logger).toEqual([user1, 'misko', 'hevery']); - logger.clear(); - - context['user'] = user2; - watchGrp.detectChanges(); - expect(logger).toEqual([user2, 'Hevery']); - - - watch.remove(); - expect(watchGrp.fieldCost).toEqual(4); - - watchFirst.remove(); - expect(watchGrp.fieldCost).toEqual(2); - - watchLast.remove(); - expect(watchGrp.fieldCost).toEqual(0); - - expect(() => watch.remove()).toThrow('Already deleted!'); - }); - - it('should eval pure FunctionApply', () { - context['a'] = {'val': 1}; - - FunctionApply fn = new LoggingFunctionApply(logger); - var watch = watchGrp.watch( - new PureFunctionAST('add', fn, [parse('a.val')]), - (v, p) => logger(v) - ); - - // a; a.val; b; b.val; - expect(watchGrp.fieldCost).toEqual(2); - // add - expect(watchGrp.evalCost).toEqual(1); - - watchGrp.detectChanges(); - expect(logger).toEqual([[1], null]); - logger.clear(); - - context['a'] = {'val': 2}; - watchGrp.detectChanges(); - expect(logger).toEqual([[2]]); - }); - - - it('should eval pure function', () { - context['a'] = {'val': 1}; - context['b'] = {'val': 2}; - - var watch = watchGrp.watch( - new PureFunctionAST('add', - (a, b) { logger('+'); return a+b; }, - [parse('a.val'), parse('b.val')] - ), - (v, p) => logger(v) - ); - - // a; a.val; b; b.val; - expect(watchGrp.fieldCost).toEqual(4); - // add - expect(watchGrp.evalCost).toEqual(1); - - watchGrp.detectChanges(); - expect(logger).toEqual(['+', 3]); - - // extra checks should not trigger functions - watchGrp.detectChanges(); - watchGrp.detectChanges(); - expect(logger).toEqual(['+', 3]); - - // multiple arg changes should only trigger function once. - context['a']['val'] = 3; - context['b']['val'] = 4; - - watchGrp.detectChanges(); - expect(logger).toEqual(['+', 3, '+', 7]); - - watch.remove(); - expect(watchGrp.fieldCost).toEqual(0); - expect(watchGrp.evalCost).toEqual(0); - - context['a']['val'] = 0; - context['b']['val'] = 0; - - watchGrp.detectChanges(); - expect(logger).toEqual(['+', 3, '+', 7]); - }); - - - it('should eval closure', () { - context['a'] = {'val': 1}; - context['b'] = {'val': 2}; - var innerState = 1; - - var watch = watchGrp.watch( - new ClosureAST('sum', - (a, b) { logger('+'); return innerState+a+b; }, - [parse('a.val'), parse('b.val')] - ), - (v, p) => logger(v) - ); - - // a; a.val; b; b.val; - expect(watchGrp.fieldCost).toEqual(4); - // add - expect(watchGrp.evalCost).toEqual(1); - - watchGrp.detectChanges(); - expect(logger).toEqual(['+', 4]); - - // extra checks should trigger closures - watchGrp.detectChanges(); - watchGrp.detectChanges(); - expect(logger).toEqual(['+', 4, '+', '+']); - logger.clear(); - - // multiple arg changes should only trigger function once. - context['a']['val'] = 3; - context['b']['val'] = 4; - - watchGrp.detectChanges(); - expect(logger).toEqual(['+', 8]); - logger.clear(); - - // inner state change should only trigger function once. - innerState = 2; - - watchGrp.detectChanges(); - expect(logger).toEqual(['+', 9]); - logger.clear(); - - watch.remove(); - expect(watchGrp.fieldCost).toEqual(0); - expect(watchGrp.evalCost).toEqual(0); - - context['a']['val'] = 0; - context['b']['val'] = 0; - - watchGrp.detectChanges(); - expect(logger).toEqual([]); - }); - - - it('should eval chained pure function', () { - context['a'] = {'val': 1}; - context['b'] = {'val': 2}; - context['c'] = {'val': 3}; - - var a_plus_b = new PureFunctionAST('add1', - (a, b) { logger('$a+$b'); return a + b; }, - [parse('a.val'), parse('b.val')]); - - var a_plus_b_plus_c = new PureFunctionAST('add2', - (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) => logger(v)); - - // a; a.val; b; b.val; c; c.val; - expect(watchGrp.fieldCost).toEqual(6); - // add - expect(watchGrp.evalCost).toEqual(2); - - watchGrp.detectChanges(); - expect(logger).toEqual(['1+2', '3+3', 6]); - logger.clear(); - - // extra checks should not trigger functions - watchGrp.detectChanges(); - watchGrp.detectChanges(); - expect(logger).toEqual([]); - logger.clear(); - - // multiple arg changes should only trigger function once. - context['a']['val'] = 3; - context['b']['val'] = 4; - context['c']['val'] = 5; - watchGrp.detectChanges(); - expect(logger).toEqual(['3+4', '7+5', 12]); - logger.clear(); - - context['a']['val'] = 9; - watchGrp.detectChanges(); - expect(logger).toEqual(['9+4', '13+5', 18]); - logger.clear(); - - context['c']['val'] = 9; - watchGrp.detectChanges(); - expect(logger).toEqual(['13+9', 22]); - logger.clear(); - - - watch.remove(); - expect(watchGrp.fieldCost).toEqual(0); - expect(watchGrp.evalCost).toEqual(0); - - context['a']['val'] = 0; - context['b']['val'] = 0; - - watchGrp.detectChanges(); - expect(logger).toEqual([]); - }); - - - it('should eval closure', () { - var obj; - obj = { - 'methodA': (arg1) { - logger('methodA($arg1) => ${obj['valA']}'); - return obj['valA']; - }, - 'valA': 'A' - }; - context['obj'] = obj; - context['arg0'] = 1; - - var watch = watchGrp.watch( - new MethodAST(parse('obj'), 'methodA', [parse('arg0')]), - (v, p) => logger(v) - ); - - // obj, arg0; - expect(watchGrp.fieldCost).toEqual(2); - // methodA() - expect(watchGrp.evalCost).toEqual(1); - - watchGrp.detectChanges(); - expect(logger).toEqual(['methodA(1) => A', 'A']); - logger.clear(); - - watchGrp.detectChanges(); - watchGrp.detectChanges(); - expect(logger).toEqual(['methodA(1) => A', 'methodA(1) => A']); - logger.clear(); - - obj['valA'] = 'B'; - context['arg0'] = 2; - - watchGrp.detectChanges(); - expect(logger).toEqual(['methodA(2) => B', 'B']); - logger.clear(); - - watch.remove(); - expect(watchGrp.fieldCost).toEqual(0); - expect(watchGrp.evalCost).toEqual(0); - - obj['valA'] = 'C'; - context['arg0'] = 3; - - watchGrp.detectChanges(); - expect(logger).toEqual([]); - }); - - it('should ignore NaN != NaN', () { - watchGrp.watch(new ClosureAST('NaN', () => double.NAN, []), (_, __) => logger('NaN')); - - watchGrp.detectChanges(); - expect(logger).toEqual(['NaN']); - - logger.clear(); - watchGrp.detectChanges(); - expect(logger).toEqual([]); - }) ; - - it('should test string by value', () { - watchGrp.watch(new ClosureAST('String', () => 'value', []), (v, _) => logger(v)); - - watchGrp.detectChanges(); - expect(logger).toEqual(['value']); - - logger.clear(); - watchGrp.detectChanges(); - expect(logger).toEqual([]); - }); - - it('should eval method', () { - var obj = new MyClass(logger); - obj.valA = 'A'; - context['obj'] = obj; - context['arg0'] = 1; - - var watch = watchGrp.watch( - new MethodAST(parse('obj'), 'methodA', [parse('arg0')]), - (v, p) => logger(v) - ); - - // obj, arg0; - expect(watchGrp.fieldCost).toEqual(2); - // methodA() - expect(watchGrp.evalCost).toEqual(1); - - watchGrp.detectChanges(); - expect(logger).toEqual(['methodA(1) => A', 'A']); - logger.clear(); - - watchGrp.detectChanges(); - watchGrp.detectChanges(); - expect(logger).toEqual(['methodA(1) => A', 'methodA(1) => A']); - logger.clear(); - - obj.valA = 'B'; - context['arg0'] = 2; - - watchGrp.detectChanges(); - expect(logger).toEqual(['methodA(2) => B', 'B']); - logger.clear(); - - watch.remove(); - expect(watchGrp.fieldCost).toEqual(0); - expect(watchGrp.evalCost).toEqual(0); - - obj.valA = 'C'; - context['arg0'] = 3; - - watchGrp.detectChanges(); - expect(logger).toEqual([]); - }); - - it('should eval method chain', () { - var obj1 = new MyClass(logger); - var obj2 = new MyClass(logger); - obj1.valA = obj2; - obj2.valA = 'A'; - context['obj'] = obj1; - context['arg0'] = 0; - context['arg1'] = 1; - - // 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) => logger(v)); - - // obj, arg0, arg1; - expect(watchGrp.fieldCost).toEqual(3); - // methodA(), methodA() - expect(watchGrp.evalCost).toEqual(2); - - watchGrp.detectChanges(); - expect(logger).toEqual(['methodA(0) => MyClass', 'methodA(1) => A', 'A']); - logger.clear(); - - watchGrp.detectChanges(); - watchGrp.detectChanges(); - expect(logger).toEqual(['methodA(0) => MyClass', 'methodA(1) => A', - 'methodA(0) => MyClass', 'methodA(1) => A']); - logger.clear(); - - obj2.valA = 'B'; - context['arg0'] = 10; - context['arg1'] = 11; - - watchGrp.detectChanges(); - expect(logger).toEqual(['methodA(10) => MyClass', 'methodA(11) => B', 'B']); - logger.clear(); - - watch.remove(); - expect(watchGrp.fieldCost).toEqual(0); - expect(watchGrp.evalCost).toEqual(0); - - obj2.valA = 'C'; - context['arg0'] = 20; - context['arg1'] = 21; - - watchGrp.detectChanges(); - expect(logger).toEqual([]); - }); - - it('should not return null when evaling method first time', () { - context['text'] ='abc'; - var ast = new MethodAST(parse('text'), 'toUpperCase', []); - var watch = watchGrp.watch(ast, (v, p) => logger(v)); - - watchGrp.detectChanges(); - expect(logger).toEqual(['ABC']); - }); - - it('should not eval a function if registered during reaction', () { - context['text'] ='abc'; - var ast = new MethodAST(parse('text'), 'toLowerCase', []); - var watch = watchGrp.watch(ast, (v, p) { - var ast = new MethodAST(parse('text'), 'toUpperCase', []); - watchGrp.watch(ast, (v, p) { - logger(v); - }); - }); - - watchGrp.detectChanges(); - watchGrp.detectChanges(); - expect(logger).toEqual(['ABC']); - }); - - - it('should eval function eagerly when registered during reaction', () { - var fn = (arg) { logger('fn($arg)'); return arg; }; - context['obj'] = {'fn': fn}; - context['arg1'] = 'OUT'; - context['arg2'] = 'IN'; - var ast = new MethodAST(parse('obj'), 'fn', [parse('arg1')]); - var watch = watchGrp.watch(ast, (v, p) { - var ast = new MethodAST(parse('obj'), 'fn', [parse('arg2')]); - watchGrp.watch(ast, (v, p) { - logger('reaction: $v'); - }); - }); - - expect(logger).toEqual([]); - watchGrp.detectChanges(); - expect(logger).toEqual(['fn(OUT)', 'fn(IN)', 'reaction: IN']); - logger.clear(); - watchGrp.detectChanges(); - expect(logger).toEqual(['fn(OUT)', 'fn(IN)']); - }); - - - it('should read constant', () { - // should fire on initial adding - expect(watchGrp.fieldCost).toEqual(0); - var watch = watchGrp.watch(new ConstantAST(123), (v, p) => logger(v)); - expect(watch.expression).toEqual('123'); - expect(watchGrp.fieldCost).toEqual(0); - watchGrp.detectChanges(); - expect(logger).toEqual([123]); - - // make sore no new changes are logged on extra detectChanges - watchGrp.detectChanges(); - expect(logger).toEqual([123]); - }); - - it('should wrap iterable in ObservableList', () { - context['list'] = []; - var watch = watchGrp.watch(new CollectionAST(parse('list')), (v, p) => logger(v)); - - expect(watchGrp.fieldCost).toEqual(1); - expect(watchGrp.collectionCost).toEqual(1); - expect(watchGrp.evalCost).toEqual(0); - - watchGrp.detectChanges(); - expect(logger.length).toEqual(1); - expect(logger[0], toEqualCollectionRecord( - collection: [], - additions: [], - moves: [], - removals: [])); - logger.clear(); - - context['list'] = [1]; - watchGrp.detectChanges(); - expect(logger.length).toEqual(1); - expect(logger[0], toEqualCollectionRecord( - collection: ['1[null -> 0]'], - additions: ['1[null -> 0]'], - moves: [], - removals: [])); - logger.clear(); - - watch.remove(); - expect(watchGrp.fieldCost).toEqual(0); - expect(watchGrp.collectionCost).toEqual(0); - expect(watchGrp.evalCost).toEqual(0); - }); - - it('should watch literal arrays made of expressions', () { - context['a'] = 1; - var ast = new CollectionAST( - new PureFunctionAST('[a]', new ArrayFn(), [parse('a')]) - ); - var watch = watchGrp.watch(ast, (v, p) => logger(v)); - watchGrp.detectChanges(); - expect(logger[0], toEqualCollectionRecord( - collection: ['1[null -> 0]'], - additions: ['1[null -> 0]'], - moves: [], - removals: [])); - logger.clear(); - - context['a'] = 2; - watchGrp.detectChanges(); - expect(logger[0], toEqualCollectionRecord( - collection: ['2[null -> 0]'], - previous: ['1[0 -> null]'], - additions: ['2[null -> 0]'], - moves: [], - removals: ['1[0 -> null]'])); - logger.clear(); - }); - - it('should watch pure function whose result goes to pure function', () { - context['a'] = 1; - var ast = new PureFunctionAST( - '-', - (v) => -v, - [new PureFunctionAST('++', (v) => v + 1, [parse('a')])] - ); - var watch = watchGrp.watch(ast, (v, p) => logger(v)); - - expect(watchGrp.detectChanges()).not.toBe(0); - expect(logger).toEqual([-2]); - logger.clear(); - - context['a'] = 2; - expect(watchGrp.detectChanges()).not.toBe(0); - expect(logger).toEqual([-3]); - }); - }); - - describe('evaluation', () { - it('should support simple literals', () { - expect(eval('42')).toBe(42); - expect(eval('87')).toBe(87); - }); - - it('should support context access', () { - context['x'] = 42; - expect(eval('x')).toBe(42); - context['y'] = 87; - expect(eval('y')).toBe(87); - }); - - it('should support custom context', () { - expect(eval('x', {'x': 42})).toBe(42); - expect(eval('x', {'x': 87})).toBe(87); - }); - - it('should support named arguments for scope calls', () { - var data = new TestData(); - expect(eval("sub1(1)", data)).toEqual(1); - expect(eval("sub1(3, b: 2)", data)).toEqual(1); - - expect(eval("sub2()", data)).toEqual(0); - expect(eval("sub2(a: 3)", data)).toEqual(3); - expect(eval("sub2(a: 3, b: 2)", data)).toEqual(1); - expect(eval("sub2(b: 4)", data)).toEqual(-4); - }); - - it('should support named arguments for scope calls (map)', () { - context["sub1"] = (a, {b: 0}) => a - b; - expect(eval("sub1(1)")).toEqual(1); - expect(eval("sub1(3, b: 2)")).toEqual(1); - - context["sub2"] = ({a: 0, b: 0}) => a - b; - expect(eval("sub2()")).toEqual(0); - expect(eval("sub2(a: 3)")).toEqual(3); - expect(eval("sub2(a: 3, b: 2)")).toEqual(1); - expect(eval("sub2(b: 4)")).toEqual(-4); - }); - - it('should support named arguments for member calls', () { - context['o'] = new TestData(); - expect(eval("o.sub1(1)")).toEqual(1); - expect(eval("o.sub1(3, b: 2)")).toEqual(1); - - expect(eval("o.sub2()")).toEqual(0); - expect(eval("o.sub2(a: 3)")).toEqual(3); - expect(eval("o.sub2(a: 3, b: 2)")).toEqual(1); - expect(eval("o.sub2(b: 4)")).toEqual(-4); - }); - - it('should support named arguments for member calls (map)', () { - context['o'] = { - 'sub1': (a, {b: 0}) => a - b, - 'sub2': ({a: 0, b: 0}) => a - b - }; - expect(eval("o.sub1(1)")).toEqual(1); - expect(eval("o.sub1(3, b: 2)")).toEqual(1); - - expect(eval("o.sub2()")).toEqual(0); - expect(eval("o.sub2(a: 3)")).toEqual(3); - expect(eval("o.sub2(a: 3, b: 2)")).toEqual(1); - expect(eval("o.sub2(b: 4)")).toEqual(-4); - }); - }); - - describe('child group', () { - it('should remove all field watches in group and group\'s children', () { - 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) => 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); - // expect(logger).toEqual(['0a', '0A', '1a', '1A', '2A', '1b']); - expect(logger).toEqual(['0a', '1a', '1b', '0A', '1A', '2A']); // we go by registration order - expect(watchGrp.fieldCost).toEqual(2); - expect(watchGrp.totalFieldCost).toEqual(6); - logger.clear(); - - context['a'] = 1; - expect(watchGrp.detectChanges()).toEqual(6); - expect(logger).toEqual(['0a', '0A', '1a', '1A', '2A', '1b']); // we go by group order - logger.clear(); - - context['a'] = 2; - child1a.remove(); // should also remove child2 - expect(watchGrp.detectChanges()).toEqual(3); - expect(logger).toEqual(['0a', '0A', '1b']); - expect(watchGrp.fieldCost).toEqual(2); - expect(watchGrp.totalFieldCost).toEqual(3); - }); - - 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) => 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)); - var child3 = child2.newGroup(new PrototypeMap(context)); - child1a.watch(countMethod, (v, p) => logger('1a')); - expectOrder(['0a', '1a']); - child1b.watch(countMethod, (v, p) => logger('1b')); - expectOrder(['0a', '1a', '1b']); - watchGrp.watch(countMethod, (v, p) => logger('0A')); - expectOrder(['0a', '0A', '1a', '1b']); - child1a.watch(countMethod, (v, p) => logger('1A')); - expectOrder(['0a', '0A', '1a', '1A', '1b']); - child2.watch(countMethod, (v, p) => logger('2A')); - expectOrder(['0a', '0A', '1a', '1A', '2A', '1b']); - child3.watch(countMethod, (v, p) => logger('3')); - expectOrder(['0a', '0A', '1a', '1A', '2A', '3', '1b']); - - // flush initial reaction functions - expect(watchGrp.detectChanges()).toEqual(7); - expectOrder(['0a', '0A', '1a', '1A', '2A', '3', '1b']); - - child1a.remove(); // should also remove child2 and child 3 - expect(watchGrp.detectChanges()).toEqual(3); - expectOrder(['0a', '0A', '1b']); - }); - - 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) => logger('a')); - var child = watchGrp.newGroup(new PrototypeMap(context)); - var cb = child.watch(countMethod, (v, p) => logger('b')); - - expectOrder(['a', 'b']); - expectOrder(['a', 'b']); - - ra.remove(); - expectOrder(['b']); - - cb.remove(); - expectOrder([]); - - // TODO: add them back in wrong order, assert events in right order - cb = child.watch(countMethod, (v, p) => logger('b')); - ra = watchGrp.watch(countMethod, (v, p) => logger('a'));; - expectOrder(['a', 'b']); - }); - - - it('should not call reaction function on removed group', () { - var log = []; - context['name'] = 'misko'; - var child = watchGrp.newGroup(context); - watchGrp.watch(parse('name'), (v, _) { - log.add('root $v'); - if (v == 'destroy') { - child.remove(); - } - }); - child.watch(parse('name'), (v, _) => log.add('child $v')); - watchGrp.detectChanges(); - expect(log).toEqual(['root misko', 'child misko']); - log.clear(); - - context['name'] = 'destroy'; - watchGrp.detectChanges(); - expect(log).toEqual(['root destroy']); - }); - - - - it('should watch children', () { - var childContext = new PrototypeMap(context); - context['a'] = 'OK'; - context['b'] = 'BAD'; - childContext['b'] = 'OK'; - watchGrp.watch(parse('a'), (v, p) => logger(v)); - watchGrp.newGroup(childContext).watch(parse('b'), (v, p) => logger(v)); - - watchGrp.detectChanges(); - expect(logger).toEqual(['OK', 'OK']); - logger.clear(); - - context['a'] = 'A'; - childContext['b'] = 'B'; - - watchGrp.detectChanges(); - expect(logger).toEqual(['A', 'B']); - logger.clear(); - }); - }); - - }); -} - -class MyClass { - final Logger logger; - var valA; - int _count = 0; - - MyClass(this.logger); - - methodA(arg1) { - logger('methodA($arg1) => $valA'); - return valA; - } - - count() => _count++; - - String toString() => 'MyClass'; -} - -class LoggingFunctionApply extends FunctionApply { - Logger logger; - LoggingFunctionApply(this.logger); - apply(List args) => logger(args); -} diff --git a/test/change_detector/change_detector_spec.dart b/test/change_detector/change_detector_spec.dart new file mode 100644 index 000000000..d8d49a245 --- /dev/null +++ b/test/change_detector/change_detector_spec.dart @@ -0,0 +1,1976 @@ +library change_detector_spec; + +import '../_specs.dart'; +import 'package:angular/change_detector/change_detector.dart'; +import 'package:angular/change_detector/field_getter_factory_dynamic.dart'; +import 'package:angular/change_detector/ast_parser.dart'; +import 'dart:math'; +import 'dart:collection'; + + +void main() { + describe('Change detector', () { + ChangeDetector detector; + Parser parser; + ASTParser parse; + Logger log; + var context; + var watchGrp; + + beforeEach((Logger _log, ASTParser _parse) { + context = {}; + log = _log; + detector = new ChangeDetector(new DynamicFieldGetterFactory()); + watchGrp = detector.createWatchGroup(context); + parse = _parse; + }); + + logReactionFn(v, p) => log('$p=>$v'); + + // Ported from previous impl (dccd) - make it a describe ? + describe('Field records: property, map, collections, functions - former dccd', () { + describe('object field', () { + it('should detect nothing', () { + expect(watchGrp.processChanges()).toEqual(0); + }); + + it('should process field changes', () { + context['user'] = new _User('', ''); + watchGrp.watch(parse('user.first'), logReactionFn); + watchGrp.watch(parse('user.last'), logReactionFn); + watchGrp.processChanges(); + log.clear(); + + expect(watchGrp.processChanges()).toEqual(0); + expect(log).toEqual([]); + + context['user'] + ..first = 'misko' + ..last = 'hevery'; + expect(watchGrp.processChanges()).toEqual(2); + expect(log).toEqual(['=>misko', '=>hevery']); + + log.clear(); + // Make the strings equal but not identical + context['user'].first = 'mis'; + context['user'].first += 'ko'; + expect(watchGrp.processChanges()).toEqual(0); + expect(log).toEqual([]); + + log.clear(); + context['user'].last = 'Hevery'; + expect(watchGrp.processChanges()).toEqual(1); + expect(log).toEqual(['hevery=>Hevery']); + }); + + it('should ignore NaN != NaN', () { + context['user'] = new _User()..age = double.NAN; + watchGrp.watch(parse('user.age'), logReactionFn); + watchGrp.processChanges(); + + log.clear(); + expect(watchGrp.processChanges()).toEqual(0); + expect(log).toEqual([]); + + context['user'].age = 123; + expect(watchGrp.processChanges()).toEqual(1); + expect(log).toEqual(['NaN=>123']); + }); + + it('should treat map field dereference as []', () { + context['map'] = {'name': 'misko'}; + watchGrp.watch(parse('map.name'), logReactionFn); + watchGrp.processChanges(); + + log.clear(); + context['map']['name'] = 'Misko'; + expect(watchGrp.processChanges()).toEqual(1); + expect(log).toEqual(['misko=>Misko']); + }); + }); + + describe('insertions / removals', () { + + it('should insert at the end of list', () { + var watchA = watchGrp.watch(parse('a'), logReactionFn); + var watchB = watchGrp.watch(parse('b'), logReactionFn); + + context['a'] = 'a1'; + context['b'] = 'b1'; + expect(watchGrp.processChanges()).toEqual(2); + expect(log).toEqual(['null=>a1', 'null=>b1']); + + log.clear(); + context['a'] = 'a2'; + context['b'] = 'b2'; + watchA.remove(); + expect(watchGrp.processChanges()).toEqual(1); + expect(log).toEqual(['b1=>b2']); + + log.clear(); + context['a'] = 'a3'; + context['b'] = 'b3'; + watchB.remove(); + expect(watchGrp.processChanges()).toEqual(0); + expect(log).toEqual([]); + }); + }); + + it('should be possible to remove a watch from within its reaction function', () { + context['a'] = 'a'; + context['b'] = 'b'; + var watchA; + watchA = watchGrp.watch(parse('a'), (v, _) { + log(v); + watchA.remove(); + }); + watchGrp.watch(parse('b'), (v, _) => log(v)); + expect(() => watchGrp.processChanges()).not.toThrow(); + expect(log).toEqual(['a', 'b']); + }); + + it('should properly disconnect group in case watch is removed in disconected group', () { + expect(() { + var child1a = watchGrp.createChild(context); + var child2 = child1a.createChild(context); + var child2Watch = child2.watch(parse('f1'), logReactionFn); + var child1b = watchGrp.createChild(context); + child1a.remove(); + child2Watch.remove(); + child1b.watch(parse('f2'), logReactionFn); + }).not.toThrow(); + }); + + it('should find random bugs', () { + List groups; + List watches = []; + List steps = []; + var field = 'someField'; + var random = new Random(); + + void step(text) { + steps.add(text); + } + try { + for (var i = 0; i < 100000; i++) { + if (i % 50 == 0) { + watches.clear(); + steps.clear(); + groups = [detector.createWatchGroup(context)]; + } + switch (random.nextInt(4)) { + case 0: // new child detector + if (groups.length > 10) break; + var index = random.nextInt(groups.length); + var group = groups[index]; + step('group[$index].newGroup()'); + groups.add(group.createChild(context)); + break; + case 1: // add watch + var index = random.nextInt(groups.length); + var group = groups[index]; + step('group[$index].watch($field)'); + watches.add(group.watch(parse('field'), (_, __) { + })); + break; + case 2: // destroy watch group + if (groups.length == 1) break; + var index = random.nextInt(groups.length - 1) + 1; + var group = groups[index]; + step('group[$index].remove()'); + group.remove(); + groups = groups.where((s) => s.isAttached).toList(); + break; + case 3: // remove watch on watch group + if (watches.length == 0) break; + var index = random.nextInt(watches.length); + var record = watches.removeAt(index); + step('watches.removeAt($index).remove()'); + record.remove(); + break; + } + } + } catch (e) { + print(steps); + rethrow; + } + }); + + describe('list watching', () { + it('should coalesce records added in the same cycle', () { + context['list'] = [0]; + + watchGrp.watch(parse('list', collection: true), (_, __) => null); + expect(watchGrp.watchedCollections).toEqual(1); + // Should not add a record when added in the same cycle + watchGrp.watch(parse('list', collection: true), (_, __) => null); + watchGrp.watch(parse('list', collection: true), (_, __) => null); + expect(watchGrp.watchedCollections).toEqual(1); + + watchGrp.processChanges(); + // Should add a record when not added in the same cycle + watchGrp.watch(parse('list', collection: true), (_, __) => null); + expect(watchGrp.watchedCollections).toEqual(2); + // Should not add a record when added in the same cycle + watchGrp.watch(parse('list', collection: true), (_, __) => null); + watchGrp.watch(parse('list', collection: true), (_, __) => null); + expect(watchGrp.watchedCollections).toEqual(2); + }); + + describe('previous state', () { + it('should store on addition', () { + var value; + context['list'] = []; + watchGrp.watch(parse('list', collection: true), (v, _) => value = v); + watchGrp.processChanges(); + + context['list'].add('a'); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['a[null -> 0]'], + additions: ['a[null -> 0]'])); + + context['list'].add('b'); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['a', 'b[null -> 1]'], + previous: ['a'], + additions: ['b[null -> 1]'])); + }); + }); + + it('should support switching refs - gh 1158', async(() { + var value; + context['list'] = [0]; + watchGrp.watch(parse('list', collection: true), (v, _) => value = v); + watchGrp.processChanges(); + + context['list'] = [1, 0]; + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['1[null -> 0]', '0[0 -> 1]'], + previous: ['0[0 -> 1]'], + additions: ['1[null -> 0]'], + moves: ['0[0 -> 1]'])); + + context['list'] = [2, 1, 0]; + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['2[null -> 0]', '1[0 -> 1]', '0[1 -> 2]'], + previous: ['1[0 -> 1]', '0[1 -> 2]'], + additions: ['2[null -> 0]'], + moves: ['1[0 -> 1]', '0[1 -> 2]'])); + })); + + it('should handle swapping elements correctly', () { + var value; + context['list'] = [1, 2]; + watchGrp.watch(parse('list', collection: true), (v, _) => value = v); + watchGrp.processChanges(); + + context['list'].setAll(0, context['list'].reversed.toList()); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['2[1 -> 0]', '1[0 -> 1]'], + previous: ['1[0 -> 1]', '2[1 -> 0]'], + moves: ['2[1 -> 0]', '1[0 -> 1]'])); + }); + + it('should handle swapping elements correctly - gh1097', () { + // This test would have failed in non-checked mode only + var value; + context['list'] = ['a', 'b', 'c']; + watchGrp.watch(parse('list', collection: true), (v, _) => value = v); + watchGrp.processChanges(); + + context['list'] + ..clear() + ..addAll(['b', 'a', 'c']); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['b[1 -> 0]', 'a[0 -> 1]', 'c'], + previous: ['a[0 -> 1]', 'b[1 -> 0]', 'c'], + moves: ['b[1 -> 0]', 'a[0 -> 1]'])); + + context['list'] + ..clear() + ..addAll(['b', 'c', 'a']); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['b', 'c[2 -> 1]', 'a[1 -> 2]'], + previous: ['b', 'a[1 -> 2]', 'c[2 -> 1]'], + moves: ['c[2 -> 1]', 'a[1 -> 2]'])); + }); + }); + + it('should detect changes in list', () { + var value; + context['list'] = []; + watchGrp.watch(parse('list', collection: true), (v, _) => value = v); + watchGrp.processChanges(); + + context['list'].add('a'); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['a[null -> 0]'], + additions: ['a[null -> 0]'])); + + context['list'].add('b'); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['a', 'b[null -> 1]'], + previous: ['a'], + additions: ['b[null -> 1]'])); + + context['list'].add('c'); + context['list'].add('d'); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['a', 'b', 'c[null -> 2]', 'd[null -> 3]'], + previous: ['a', 'b'], + additions: ['c[null -> 2]', 'd[null -> 3]'])); + + context['list'].remove('c'); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['a', 'b', 'd[3 -> 2]'], + previous: ['a', 'b', 'c[2 -> null]', 'd[3 -> 2]'], + moves: ['d[3 -> 2]'], + removals: ['c[2 -> null]'])); + + context['list'].clear(); + context['list'].addAll(['d', 'c', 'b', 'a']); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['d[2 -> 0]', 'c[null -> 1]', 'b[1 -> 2]', 'a[0 -> 3]'], + previous: ['a[0 -> 3]', 'b[1 -> 2]', 'd[2 -> 0]'], + additions: ['c[null -> 1]'], + moves: ['d[2 -> 0]', 'b[1 -> 2]', 'a[0 -> 3]'])); + }); + + it('should test string by value rather than by reference', () { + context['list'] = ['a', 'boo']; + watchGrp.watch(parse('list', collection: true), logReactionFn); + watchGrp.processChanges(); + + context['list'][1] = 'b' + 'oo'; + expect(watchGrp.processChanges()).toEqual(0); + }); + + it('should ignore [NaN] != [NaN]', () { + context['list'] = [double.NAN]; + watchGrp.watch(parse('list', collection: true), logReactionFn); + watchGrp.processChanges(); + + expect(watchGrp.processChanges()).toEqual(0); + }); + + it('should detect [NaN] moves', () { + var value; + context['list'] = [double.NAN, double.NAN]; + watchGrp.watch(parse('list', collection: true), (v, _) => value = v); + watchGrp.processChanges(); + + context['list'] + ..clear() + ..addAll(['foo', double.NAN, double.NAN]); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['foo[null -> 0]', 'NaN[0 -> 1]', 'NaN[1 -> 2]'], + previous: ['NaN[0 -> 1]', 'NaN[1 -> 2]'], + additions: ['foo[null -> 0]'], + moves: ['NaN[0 -> 1]', 'NaN[1 -> 2]'])); + }); + + it('should remove and add same item', () { + var value; + context['list'] = ['a', 'b', 'c']; + watchGrp.watch(parse('list', collection: true), (v, _) => value = v); + watchGrp.processChanges(); + + context['list'].remove('b'); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['a', 'c[2 -> 1]'], + previous: ['a', 'b[1 -> null]', 'c[2 -> 1]'], + moves: ['c[2 -> 1]'], + removals: ['b[1 -> null]'])); + + context['list'].insert(1, 'b'); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['a', 'b[null -> 1]', 'c[1 -> 2]'], + previous: ['a', 'c[1 -> 2]'], + additions: ['b[null -> 1]'], + moves: ['c[1 -> 2]'])); + }); + + it('should support duplicates', () { + var value; + context['list'] = ['a', 'a', 'a', 'b', 'b']; + watchGrp.watch(parse('list', collection: true), (v, _) => value = v); + watchGrp.processChanges(); + + context['list'].removeAt(0); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['a', 'a', 'b[3 -> 2]', 'b[4 -> 3]'], + previous: ['a', 'a', 'a[2 -> null]', 'b[3 -> 2]', 'b[4 -> 3]'], + moves: ['b[3 -> 2]', 'b[4 -> 3]'], + removals: ['a[2 -> null]'])); + }); + + + it('should support insertions/moves', () { + var value; + context['list'] = ['a', 'a', 'b', 'b']; + watchGrp.watch(parse('list', collection: true), (v, _) => value = v); + watchGrp.processChanges(); + + context['list'].insert(0, 'b'); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['b[2 -> 0]', 'a[0 -> 1]', 'a[1 -> 2]', 'b', 'b[null -> 4]'], + previous: ['a[0 -> 1]', 'a[1 -> 2]', 'b[2 -> 0]', 'b'], + additions: ['b[null -> 4]'], + moves: ['b[2 -> 0]', 'a[0 -> 1]', 'a[1 -> 2]'])); + }); + + it('should support UnmodifiableListView', () { + var hiddenList = [1]; + var value; + context['list'] = new UnmodifiableListView(hiddenList); + watchGrp.watch(parse('list', collection: true), (v, _) => value = v); + + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['1[null -> 0]'], + additions: ['1[null -> 0]'])); + + // assert no changes detected + expect(watchGrp.processChanges()).toEqual(0); + + // change the hiddenList normally this should trigger change detection + // but because we are wrapped in UnmodifiableListView we see nothing. + hiddenList[0] = 2; + expect(watchGrp.processChanges()).toEqual(0); + }); + + it('should bug', () { + var value; + context['list'] = [1, 2, 3, 4]; + watchGrp.watch(parse('list', collection: true), (v, _) => value = v); + + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['1[null -> 0]', '2[null -> 1]', '3[null -> 2]', '4[null -> 3]'], + additions: ['1[null -> 0]', '2[null -> 1]', '3[null -> 2]', '4[null -> 3]'])); + + context['list'].removeRange(0, 1); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['2[1 -> 0]', '3[2 -> 1]', '4[3 -> 2]'], + previous: ['1[0 -> null]', '2[1 -> 0]', '3[2 -> 1]', '4[3 -> 2]'], + moves: ['2[1 -> 0]', '3[2 -> 1]', '4[3 -> 2]'], + removals: ['1[0 -> null]'])); + + context['list'].insert(0, 1); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['1[null -> 0]', '2[0 -> 1]', '3[1 -> 2]', '4[2 -> 3]'], + previous: ['2[0 -> 1]', '3[1 -> 2]', '4[2 -> 3]'], + additions: ['1[null -> 0]'], + moves: ['2[0 -> 1]', '3[1 -> 2]', '4[2 -> 3]'])); + }); + + it('should properly support objects with equality', () { + var value; + context['list'] = [new _FooBar('a', 'a'), new _FooBar('a', 'a')]; + watchGrp.watch(parse('list', collection: true), (v, _) => value = v); + + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['(0)a-a[null -> 0]', '(1)a-a[null -> 1]'], + additions: ['(0)a-a[null -> 0]', '(1)a-a[null -> 1]'])); + + context['list'].removeRange(0, 1); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['(1)a-a[1 -> 0]'], + previous: ['(0)a-a[0 -> null]', '(1)a-a[1 -> 0]'], + moves: ['(1)a-a[1 -> 0]'], + removals: ['(0)a-a[0 -> null]'])); + + context['list'].insert(0, new _FooBar('a', 'a')); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['(2)a-a[null -> 0]', '(1)a-a[0 -> 1]'], + previous: ['(1)a-a[0 -> 1]'], + additions: ['(2)a-a[null -> 0]'], + moves: ['(1)a-a[0 -> 1]'])); + }); + + it('should not report unnecessary moves', () { + var value; + context['list'] = ['a', 'b', 'c']; + watchGrp.watch(parse('list', collection: true), (v, _) => value = v); + watchGrp.processChanges(); + + context['list'] + ..clear() + ..addAll(['b', 'a', 'c']); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualCollectionRecord( + collection: ['b[1 -> 0]', 'a[0 -> 1]', 'c'], + previous: ['a[0 -> 1]', 'b[1 -> 0]', 'c'], + moves: ['b[1 -> 0]', 'a[0 -> 1]'])); + }); + + describe('map watching', () { + it('should coalesce records added in the same cycle', () { + context['map'] = {'foo': 'bar'}; + + watchGrp.watch(parse('map', collection: true), (_, __) => null); + expect(watchGrp.watchedCollections).toEqual(1); + // Should not add a record when added in the same cycle + watchGrp.watch(parse('map', collection: true), (_, __) => null); + watchGrp.watch(parse('map', collection: true), (_, __) => null); + expect(watchGrp.watchedCollections).toEqual(1); + + watchGrp.processChanges(); + // Should add a record when not added in the same cycle + watchGrp.watch(parse('map', collection: true), (_, __) => null); + expect(watchGrp.watchedCollections).toEqual(2); + // Should not add a record when added in the same cycle + watchGrp.watch(parse('map', collection: true), (_, __) => null); + watchGrp.watch(parse('map', collection: true), (_, __) => null); + expect(watchGrp.watchedCollections).toEqual(2); + }); + + describe('previous state', () { + it('should store on insertion', () { + var value; + context['map'] = {}; + watchGrp.watch(parse('map', collection: true), (v, _) => value = v); + watchGrp.processChanges(); + + context['map']['a'] = 1; + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualMapRecord( + map: ['a[null -> 1]'], + additions: ['a[null -> 1]'])); + + context['map']['b'] = 2; + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualMapRecord( + map: ['a', 'b[null -> 2]'], + previous: ['a'], + additions: ['b[null -> 2]'])); + }); + + it('should handle changing key/values correctly', () { + var value; + context['map'] = {1: 10, 2: 20}; + watchGrp.watch(parse('map', collection: true), (v, _) => value = v); + watchGrp.processChanges(); + + context['map'][1] = 20; + context['map'][2] = 10; + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualMapRecord( + map: ['1[10 -> 20]', '2[20 -> 10]'], + previous: ['1[10 -> 20]', '2[20 -> 10]'], + changes: ['1[10 -> 20]', '2[20 -> 10]'])); + }); + }); + + it('should do basic map watching', () { + var value; + context['map'] = {}; + watchGrp.watch(parse('map', collection: true), (v, _) => value = v); + watchGrp.processChanges(); + + context['map']['a'] = 'A'; + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualMapRecord( + map: ['a[null -> A]'], + additions: ['a[null -> A]'])); + + context['map']['b'] = 'B'; + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualMapRecord( + map: ['a', 'b[null -> B]'], + previous: ['a'], + additions: ['b[null -> B]'])); + + context['map']['b'] = 'BB'; + context['map']['d'] = 'D'; + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualMapRecord( + map: ['a', 'b[B -> BB]', 'd[null -> D]'], + previous: ['a', 'b[B -> BB]'], + additions: ['d[null -> D]'], + changes: ['b[B -> BB]'])); + + context['map'].remove('b'); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualMapRecord( + map: ['a', 'd'], + previous: ['a', 'b[BB -> null]', 'd'], + removals: ['b[BB -> null]'])); + + context['map'].clear(); + // TODO expect(watchGrp.processChanges()).toBeGreaterThan(0); when guinness >= 0.1.11 + watchGrp.processChanges(); + expect(value, toEqualMapRecord( + map: [], + previous: ['a[A -> null]', 'd[D -> null]'], + removals: ['a[A -> null]', 'd[D -> null]'])); + }); + + it('should test string by value rather than by reference', () { + context['map'] = {'foo': 'bar'}; + watchGrp.watch(parse('map'), logReactionFn); + watchGrp.processChanges(); + + context['map']['f' + 'oo'] = 'b' + 'ar'; + + expect(watchGrp.processChanges()).toEqual(0); + }); + + it('should not see a NaN value as a change', () { + context['map'] = {'foo': double.NAN}; + watchGrp.watch(parse('map'), logReactionFn); + watchGrp.processChanges(); + + expect(watchGrp.processChanges()).toEqual(0); + }); + }); + + describe('function watching', () { + it('should detect no changes when watching a function', () { + context['user'] = new _User('marko', 'vuksanovic', 15); + + watchGrp.watch(parse('user.isUnderAge'), logReactionFn); + expect(watchGrp.processChanges()).toEqual(1); + + context['user'].age = 17; + expect(watchGrp.processChanges()).toEqual(0); + + context['user'].age = 30; + expect(watchGrp.processChanges()).toEqual(0); + }); + + it('should detect change when watching a property function', () { + context['user'] = new _User('marko', 'vuksanovic', 15); + watchGrp.watch(parse('user.isUnderAgeAsVariable'), logReactionFn); + expect(watchGrp.processChanges()).toEqual(1); + expect(watchGrp.processChanges()).toEqual(0); + context['user'].isUnderAgeAsVariable = () => false; + expect(watchGrp.processChanges()).toEqual(1); + }); + }); + }); + + describe('Eval records, coalescence - former watch group', () { + eval(String expression, [evalContext]) { + List log = []; + var group = detector.createWatchGroup(evalContext == null ? context : evalContext); + var watch = group.watch(parse(expression), (v, _) => log.add(v)); + group.processChanges(); + if (log.isEmpty) { + throw new StateError('Expression <$expression> was not evaluated'); + } else if (log.length > 1) { + throw new StateError('Expression <$expression> produced too many values: $log'); + } else { + return log.first; + } + } + + expectOrder(list) { + watchGrp.processChanges(); // Clear the initial queue + log.clear(); + watchGrp.processChanges(); + expect(log).toEqual(list); + } + + // TODO to String +// it('should have a toString for debugging', () { +// watchGrp.watch(parse('a'), (v, p) {}); +// watchGrp.newGroup({}); +// expect("$watchGrp").toEqual( +// 'WATCHES: MARKER[null], MARKER[null]\n' +// 'WatchGroup[](watches: MARKER[null])\n' +// ' WatchGroup[.0](watches: MARKER[null])' +// ); +// }); + + describe('watch lifecycle', () { + it('should prevent reaction fn on removed', () { + context['a'] = 'hello'; + + var watch ; + watchGrp.watch(parse('a'), (v, _) { + log('removed'); + watch.remove(); + }); + watch = watchGrp.watch(parse('a'), (v, _) => log(v)); + watchGrp.processChanges(); + expect(log).toEqual(['removed']); + }); + }); + + // TODO: not all tests seems to be related to "property chaining" + describe('property chaining', () { + it('should read property', () { + context['a'] = 'hello'; + + // should fire on initial adding + expect(watchGrp.watchedFields).toEqual(0); + var watch = watchGrp.watch(parse('a'), (v, _) => log(v)); + expect(watch.expression).toEqual('a'); + expect(watchGrp.watchedFields).toEqual(1); + watchGrp.processChanges(); + expect(log).toEqual(['hello']); + + // make sure no new changes are logged on extra detectChanges + watchGrp.processChanges(); + expect(log).toEqual(['hello']); + + // Should detect value change + context['a'] = 'bye'; + watchGrp.processChanges(); + expect(log).toEqual(['hello', 'bye']); + + // should cleanup after itself + watch.remove(); + expect(watchGrp.watchedFields).toEqual(0); + context['a'] = 'cant see me'; + watchGrp.processChanges(); + expect(log).toEqual(['hello', 'bye']); + }); + + describe('sequence mutations and ref changes', () { + it('should handle a simultaneous map mutation and reference change', () { + context['a'] = context['b'] = {1: 10, 2: 20}; + watchGrp.watch(new CollectionAST(parse('a')), (v, _) => log(v)); + watchGrp.watch(new CollectionAST(parse('b')), (v, _) => log(v)); + watchGrp.processChanges(); + + expect(log.length).toEqual(2); + expect(log[0], toEqualMapRecord( + map: ['1[null -> 10]', '2[null -> 20]'], + additions: ['1[null -> 10]', '2[null -> 20]'])); + expect(log[1], toEqualMapRecord( + map: ['1[null -> 10]', '2[null -> 20]'], + additions: ['1[null -> 10]', '2[null -> 20]'])); + log.clear(); + + // context['a'] is set to a copy with an addition. + context['a'] = new Map.from(context['a'])..[3] = 30; + // context['b'] still has the original collection. We'll mutate it. + context['b'].remove(1); + + expect(watchGrp.processChanges()).toEqual(2); + expect(log[0], toEqualMapRecord( + map: ['1', '2', '3[null -> 30]'], + previous: ['1', '2'], + additions: ['3[null -> 30]'])); + expect(log[1], toEqualMapRecord( + map: ['2'], + previous: ['1[10 -> null]', '2'], + removals: ['1[10 -> null]'])); + }); + + it('should handle a simultaneous list mutation and reference change', () { + context['a'] = context['b'] = [0, 1]; + var watchA = watchGrp.watch(new CollectionAST(parse('a')), (v, _) => log(v)); + var watchB = watchGrp.watch(new CollectionAST(parse('b')), (v, p) => log(v)); + + watchGrp.processChanges(); + + expect(log.length).toEqual(2); + expect(log[0], toEqualCollectionRecord( + collection: ['0[null -> 0]', '1[null -> 1]'], + additions: ['0[null -> 0]', '1[null -> 1]'])); + expect(log[1], toEqualCollectionRecord( + collection: ['0[null -> 0]', '1[null -> 1]'], + additions: ['0[null -> 0]', '1[null -> 1]'])); + log.clear(); + + // context['a'] is set to a copy with an addition. + context['a'] = context['a'].toList()..add(2); + // context['b'] still has the original collection. We'll mutate it. + context['b'].remove(0); + + watchGrp.processChanges(); + expect(log.length).toEqual(2); + expect(log[0], toEqualCollectionRecord( + collection: ['0', '1', '2[null -> 2]'], + previous: ['0', '1'], + additions: ['2[null -> 2]'])); + expect(log[1], toEqualCollectionRecord( + collection: ['1[1 -> 0]'], + previous: ['0[0 -> null]', '1[1 -> 0]'], + moves: ['1[1 -> 0]'], + removals: ['0[0 -> null]'])); + }); + + it('should work correctly with UnmodifiableListView', () { + context['a'] = new UnmodifiableListView([0, 1]); + var watchA = watchGrp.watch(new CollectionAST(parse('a')), (v, _) => log(v)); + + watchGrp.processChanges(); + expect(log.length).toEqual(1); + expect(log[0], toEqualCollectionRecord( + collection: ['0[null -> 0]', '1[null -> 1]'], + additions: ['0[null -> 0]', '1[null -> 1]'])); + log.clear(); + + context['a'] = new UnmodifiableListView([1, 0]); + + watchGrp.processChanges(); + expect(log.length).toEqual(1); + expect(log[0], toEqualCollectionRecord( + collection: ['1[1 -> 0]', '0[0 -> 1]'], + previous: ['0[0 -> 1]', '1[1 -> 0]'], + moves: ['1[1 -> 0]', '0[0 -> 1]'])); + }); + }); + + it('should read property chain', () { + context['a'] = {'b': 'hello'}; + + // should fire on initial adding + expect(watchGrp.watchedFields).toEqual(0); + expect(watchGrp.totalCount).toEqual(0); + var watch = watchGrp.watch(parse('a.b'), (v, _) => log(v)); + expect(watch.expression).toEqual('a.b'); + expect(watchGrp.watchedFields).toEqual(2); + expect(watchGrp.totalCount).toEqual(3); + watchGrp.processChanges(); + expect(log).toEqual(['hello']); + + // make sore no new changes are logged on extra detectChanges + watchGrp.processChanges(); + expect(log).toEqual(['hello']); + + // make sure no changes or logged when intermediary object changes + context['a'] = {'b': 'hello'}; + watchGrp.processChanges(); + expect(log).toEqual(['hello']); + + // Should detect value change + context['a'] = {'b': 'hello2'}; + watchGrp.processChanges(); + expect(log).toEqual(['hello', 'hello2']); + + // Should detect value change + context['a']['b'] = 'bye'; + watchGrp.processChanges(); + expect(log).toEqual(['hello', 'hello2', 'bye']); + + // should cleanup after itself + watch.remove(); + expect(watchGrp.watchedFields).toEqual(0); + context['a']['b'] = 'cant see me'; + watchGrp.processChanges(); + expect(log).toEqual(['hello', 'hello2', 'bye']); + }); + + it('should coalesce records', () { + var user1 = {'first': 'misko', 'last': 'hevery'}; + var user2 = {'first': 'misko', 'last': 'Hevery'}; + + context['user'] = user1; + + // should fire on initial adding + expect(watchGrp.watchedFields).toEqual(0); + var watch = watchGrp.watch(parse('user'), (v, _) => log(v)); + var watchFirst = watchGrp.watch(parse('user.first'), (v, _) => log(v)); + var watchLast = watchGrp.watch(parse('user.last'), (v, _) => log(v)); + expect(watchGrp.watchedFields).toEqual(3); + + watchGrp.processChanges(); + expect(log).toEqual([user1, 'misko', 'hevery']); + log.clear(); + + context['user'] = user2; + watchGrp.processChanges(); + expect(log).toEqual([user2, 'Hevery']); + + watch.remove(); + expect(watchGrp.watchedFields).toEqual(3); + + watchFirst.remove(); + expect(watchGrp.watchedFields).toEqual(2); + + watchLast.remove(); + expect(watchGrp.watchedFields).toEqual(0); + + expect(() => watch.remove()).toThrow('Already deleted!'); + }); + + it('should eval pure FunctionApply', () { + context['a'] = {'val': 1}; + + FunctionApply fn = new _LoggingFunctionApply(log); + var watch = watchGrp.watch( + new PureFunctionAST('add', fn, [parse('a.val')]), + (v, _) => log(v) + ); + + // "a" & "a.val" + expect(watchGrp.watchedFields).toEqual(2); + // "add" + expect(watchGrp.watchedEvals).toEqual(1); + + watchGrp.processChanges(); + expect(log).toEqual([[1], null]); + log.clear(); + + context['a'] = {'val': 2}; + watchGrp.processChanges(); + expect(log).toEqual([[2]]); + }); + + it('should eval pure function', () { + context['a'] = {'val': 1}; + context['b'] = {'val': 2}; + + var watch = watchGrp.watch( + new PureFunctionAST( + 'add', + (a, b) { log('+'); return a+b; }, + [parse('a.val'), parse('b.val')] + ), + (v, _) => log(v) + ); + + // "a", "a.val", "b", "b.val" + expect(watchGrp.watchedFields).toEqual(4); + // add + expect(watchGrp.watchedEvals).toEqual(1); + watchGrp.processChanges(); + expect(log).toEqual(['+', 3]); + + // extra checks should not trigger functions + log.clear(); + watchGrp.processChanges(); + watchGrp.processChanges(); + expect(log).toEqual([]); + + // multiple arg changes should only trigger function once. + log.clear(); + context['a']['val'] = 3; + context['b']['val'] = 4; + watchGrp.processChanges(); + expect(log).toEqual(['+', 7]); + + log.clear(); + watch.remove(); + expect(watchGrp.watchedFields).toEqual(0); + expect(watchGrp.watchedEvals).toEqual(0); + context['a']['val'] = 0; + context['b']['val'] = 0; + watchGrp.processChanges(); + expect(log).toEqual([]); + }); + + it('should eval closure', () { + context['a'] = {'val': 1}; + context['b'] = {'val': 2}; + var innerState = 1; + + var watch = watchGrp.watch( + new ClosureAST( + 'sum', + (a, b) { log('+'); return innerState+a+b; }, + [parse('a.val'), parse('b.val')] + ), + (v, _) => log(v) + ); + + // "a", "a.val", "b", "b.val" + expect(watchGrp.watchedFields).toEqual(4); + // add + expect(watchGrp.watchedEvals).toEqual(1); + watchGrp.processChanges(); + expect(log).toEqual(['+', 4]); + + // extra checks should trigger closures + log.clear(); + watchGrp.processChanges(); + watchGrp.processChanges(); + expect(log).toEqual(['+', '+']); + + // multiple arg changes should only trigger function once. + log.clear(); + context['a']['val'] = 3; + context['b']['val'] = 4; + watchGrp.processChanges(); + expect(log).toEqual(['+', 8]); + + // inner state change should only trigger function once. + log.clear(); + innerState = 2; + watchGrp.processChanges(); + expect(log).toEqual(['+', 9]); + + log.clear(); + watch.remove(); + expect(watchGrp.watchedFields).toEqual(0); + expect(watchGrp.watchedEvals).toEqual(0); + context['a']['val'] = 0; + context['b']['val'] = 0; + watchGrp.processChanges(); + expect(log).toEqual([]); + }); + + it('should eval closure', () { + var obj; + obj = { + 'methodA': (arg1) { + log('methodA($arg1) => ${obj['valA']}'); + return obj['valA']; + }, + 'valA': 'A' + }; + context['obj'] = obj; + context['arg0'] = 1; + + var watch = watchGrp.watch( + new MethodAST(parse('obj'), 'methodA', [parse('arg0')]), (v, _) => log(v)); + + // "obj", "arg0" + expect(watchGrp.watchedFields).toEqual(2); + // "methodA()" + expect(watchGrp.watchedEvals).toEqual(1); + watchGrp.processChanges(); + expect(log).toEqual(['methodA(1) => A', 'A']); + + log.clear(); + watchGrp.processChanges(); + watchGrp.processChanges(); + expect(log).toEqual(['methodA(1) => A', 'methodA(1) => A']); + + log.clear(); + obj['valA'] = 'B'; + context['arg0'] = 2; + watchGrp.processChanges(); + expect(log).toEqual(['methodA(2) => B', 'B']); + + log.clear(); + watch.remove(); + expect(watchGrp.watchedFields).toEqual(0); + expect(watchGrp.watchedEvals).toEqual(0); + obj['valA'] = 'C'; + context['arg0'] = 3; + watchGrp.processChanges(); + expect(log).toEqual([]); + }); + + + it('should eval chained pure function', () { + context['a'] = {'val': 1}; + context['b'] = {'val': 2}; + context['c'] = {'val': 3}; + + var aPlusB = new PureFunctionAST( + 'add1', + (a, b) { log('$a+$b'); return a + b; }, + [parse('a.val'), parse('b.val')]); + + var aPlusBPlusC = new PureFunctionAST( + 'add2', + (b, c) { log('$b+$c'); return b + c; }, + [aPlusB, parse('c.val')]); + + var watch = watchGrp.watch(aPlusBPlusC, (v, _) => log(v)); + + // "a", "a.val", "b", "b.val", "c", "c.val" + expect(watchGrp.watchedFields).toEqual(6); + // "add1", "add2" + expect(watchGrp.watchedEvals).toEqual(2); + + watchGrp.processChanges(); + expect(log).toEqual(['1+2', '3+3', 6]); + + // extra checks should not trigger functions + log.clear(); + watchGrp.processChanges(); + watchGrp.processChanges(); + expect(log).toEqual([]); + + // multiple arg changes should only trigger function once. + log.clear(); + context['a']['val'] = 3; + context['b']['val'] = 4; + context['c']['val'] = 5; + watchGrp.processChanges(); + expect(log).toEqual(['3+4', '7+5', 12]); + + log.clear(); + context['a']['val'] = 9; + watchGrp.processChanges(); + expect(log).toEqual(['9+4', '13+5', 18]); + + log.clear(); + context['c']['val'] = 9; + watchGrp.processChanges(); + expect(log).toEqual(['13+9', 22]); + + log.clear(); + watch.remove(); + expect(watchGrp.watchedFields).toEqual(0); + expect(watchGrp.watchedEvals).toEqual(0); + context['a']['val'] = 0; + context['b']['val'] = 0; + watchGrp.processChanges(); + expect(log).toEqual([]); + }); + + it('should eval method', () { + var obj = new _MyClass(log); + obj.valA = 'A'; + context['obj'] = obj; + context['arg0'] = 1; + + var watch = watchGrp.watch( + new MethodAST(parse('obj'), 'methodA', [parse('arg0')]), (v, _) => log(v)); + + // "obj", "arg0" + expect(watchGrp.watchedFields).toEqual(2); + // "methodA()" + expect(watchGrp.watchedEvals).toEqual(1); + watchGrp.processChanges(); + expect(log).toEqual(['methodA(1) => A', 'A']); + + log.clear(); + + watchGrp.processChanges(); + watchGrp.processChanges(); + expect(log).toEqual(['methodA(1) => A', 'methodA(1) => A']); + + log.clear(); + obj.valA = 'B'; + context['arg0'] = 2; + watchGrp.processChanges(); + expect(log).toEqual(['methodA(2) => B', 'B']); + + log.clear(); + watch.remove(); + expect(watchGrp.watchedFields).toEqual(0); + expect(watchGrp.watchedEvals).toEqual(0); + obj.valA = 'C'; + context['arg0'] = 3; + watchGrp.processChanges(); + expect(log).toEqual([]); + }); + + it('should eval method chain', () { + var obj1 = new _MyClass(log); + var obj2 = new _MyClass(log); + obj1.valA = obj2; + obj2.valA = 'A'; + context['obj'] = obj1; + context['arg0'] = 0; + context['arg1'] = 1; + + // 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, _) => log(v)); + + // "obj", "arg0", "arg1"; + expect(watchGrp.watchedFields).toEqual(3); + // "methodA()", "methodA()" + expect(watchGrp.watchedEvals).toEqual(2); + watchGrp.processChanges(); + expect(log).toEqual(['methodA(0) => MyClass', 'methodA(1) => A', 'A']); + + log.clear(); + watchGrp.processChanges(); + watchGrp.processChanges(); + expect(log).toEqual(['methodA(0) => MyClass', 'methodA(1) => A', + 'methodA(0) => MyClass', 'methodA(1) => A']); + + log.clear(); + obj2.valA = 'B'; + context['arg0'] = 10; + context['arg1'] = 11; + watchGrp.processChanges(); + expect(log).toEqual(['methodA(10) => MyClass', 'methodA(11) => B', 'B']); + + log.clear(); + watch.remove(); + expect(watchGrp.watchedFields).toEqual(0); + expect(watchGrp.watchedEvals).toEqual(0); + obj2.valA = 'C'; + context['arg0'] = 20; + context['arg1'] = 21; + watchGrp.processChanges(); + expect(log).toEqual([]); + }); + + it('should not return null when evaling method first time', () { + context['text'] ='abc'; + var ast = new MethodAST(parse('text'), 'toUpperCase', []); + var watch = watchGrp.watch(ast, (v, _) => log(v)); + + watchGrp.processChanges(); + expect(log).toEqual(['ABC']); + }); + + it('should not eval a function if registered during reaction', () { + context['text'] ='abc'; + + var ast = new MethodAST(parse('text'), 'toLowerCase', []); + var watch = watchGrp.watch(ast, (v, _) { + var ast = new MethodAST(parse('text'), 'toUpperCase', []); + watchGrp.watch(ast, (v, _) { + log(v); + }); + }); + + watchGrp.processChanges(); + watchGrp.processChanges(); + expect(log).toEqual(['ABC']); + }); + + + it('should eval function eagerly when registered during reaction', () { + context['obj'] = {'fn': (arg) { log('fn($arg)'); return arg; }}; + context['arg1'] = 'OUT'; + context['arg2'] = 'IN'; + var ast = new MethodAST(parse('obj'), 'fn', [parse('arg1')]); + var watch = watchGrp.watch(ast, (v, _) { + var ast = new MethodAST(parse('obj'), 'fn', [parse('arg2')]); + watchGrp.watch(ast, (v, _) { + log('reaction: $v'); + }); + }); + + expect(log).toEqual([]); + watchGrp.processChanges(); + expect(log).toEqual(['fn(OUT)', 'fn(IN)', 'reaction: IN']); + log.clear(); + watchGrp.processChanges(); + expect(log).toEqual(['fn(OUT)', 'fn(IN)']); + }); + + it('should ignore NaN != NaN', () { + watchGrp.watch(new ClosureAST('NaN', () => double.NAN, []), (_, __) => log('NaN')); + + watchGrp.processChanges(); + expect(log).toEqual(['NaN']); + + log.clear(); + watchGrp.processChanges(); + expect(log).toEqual([]); + }) ; + + it('should test string by value', () { + watchGrp.watch(new ClosureAST('String', () => 'value', []), (v, _) => log(v)); + + watchGrp.processChanges(); + expect(log).toEqual(['value']); + + log.clear(); + watchGrp.processChanges(); + expect(log).toEqual([]); + }); + + it('should read constant', () { + var watch = watchGrp.watch(new ConstantAST(123), (v, _) => log(v)); + expect(watch.expression).toEqual('123'); + expect(watchGrp.watchedFields).toEqual(0); + log.clear(); + watchGrp.processChanges(); + expect(log).toEqual([123]); + + // make sore no new changes are logged on extra detectChanges + log.clear(); + watchGrp.processChanges(); + expect(log).toEqual([]); + }); + + it('should wrap iterable in ObservableList', () { + context['list'] = []; + var watch = watchGrp.watch(new CollectionAST(parse('list')), (v, _) => log(v)); + + expect(watchGrp.watchedFields).toEqual(1); + expect(watchGrp.watchedCollections).toEqual(1); + expect(watchGrp.watchedEvals).toEqual(0); + + watchGrp.processChanges(); + expect(log.length).toEqual(1); + expect(log[0], toEqualCollectionRecord()); + + log.clear(); + context['list'] = [1]; + watchGrp.processChanges(); + expect(log.length).toEqual(1); + expect(log[0], toEqualCollectionRecord( + collection: ['1[null -> 0]'], + additions: ['1[null -> 0]'])); + + log.clear(); + watch.remove(); + expect(watchGrp.watchedFields).toEqual(0); + expect(watchGrp.watchedCollections).toEqual(0); + expect(watchGrp.watchedEvals).toEqual(0); + }); + + it('should watch literal arrays made of expressions', () { + context['a'] = 1; + var ast = new CollectionAST( + new PureFunctionAST('[a]', new ArrayFn(), [parse('a')]) + ); + var watch = watchGrp.watch(ast, (v, _) => log(v)); + watchGrp.processChanges(); + expect(log[0], toEqualCollectionRecord( + collection: ['1[null -> 0]'], + additions: ['1[null -> 0]'])); + + log.clear(); + context['a'] = 2; + watchGrp.processChanges(); + expect(log[0], toEqualCollectionRecord( + collection: ['2[null -> 0]'], + previous: ['1[0 -> null]'], + additions: ['2[null -> 0]'], + removals: ['1[0 -> null]'])); + }); + + it('should watch pure function whose result goes to pure function', () { + context['a'] = 1; + var ast = new PureFunctionAST( + '-', + (v) => -v, + [new PureFunctionAST('++', (v) => v + 1, [parse('a')])] + ); + var watch = watchGrp.watch(ast, (v, _) => log(v)); + expect(watchGrp.processChanges()).not.toBe(null); + expect(log).toEqual([-2]); + + log.clear(); + context['a'] = 2; + expect(watchGrp.processChanges()).not.toBe(null); + expect(log).toEqual([-3]); + }); + }); + + describe('evaluation', () { + it('should support simple literals', () { + expect(eval('42')).toBe(42); + expect(eval('87')).toBe(87); + }); + + it('should support context access', () { + context['x'] = 42; + expect(eval('x')).toBe(42); + context['y'] = 87; + expect(eval('y')).toBe(87); + }); + + it('should support custom context', () { + expect(eval('x', {'x': 42})).toBe(42); + expect(eval('x', {'x': 87})).toBe(87); + }); + + it('should support named arguments for scope calls', () { + var context = new _TestData(); + expect(eval("sub1(1)", context)).toEqual(1); + expect(eval("sub1(3, b: 2)", context)).toEqual(1); + + expect(eval("sub2()", context)).toEqual(0); + expect(eval("sub2(a: 3)", context)).toEqual(3); + expect(eval("sub2(a: 3, b: 2)", context)).toEqual(1); + expect(eval("sub2(b: 4)", context)).toEqual(-4); + }); + + it('should support named arguments for scope calls (map)', () { + context["sub1"] = (a, {b: 0}) => a - b; + expect(eval("sub1(1)")).toEqual(1); + expect(eval("sub1(3, b: 2)")).toEqual(1); + + context["sub2"] = ({a: 0, b: 0}) => a - b; + expect(eval("sub2()")).toEqual(0); + expect(eval("sub2(a: 3)")).toEqual(3); + expect(eval("sub2(a: 3, b: 2)")).toEqual(1); + expect(eval("sub2(b: 4)")).toEqual(-4); + }); + + it('should support named arguments for member calls', () { + context['o'] = new _TestData(); + expect(eval("o.sub1(1)")).toEqual(1); + expect(eval("o.sub1(3, b: 2)")).toEqual(1); + + expect(eval("o.sub2()")).toEqual(0); + expect(eval("o.sub2(a: 3)")).toEqual(3); + expect(eval("o.sub2(a: 3, b: 2)")).toEqual(1); + expect(eval("o.sub2(b: 4)")).toEqual(-4); + }); + + it('should support named arguments for member calls (map)', () { + context['o'] = { + 'sub1': (a, {b: 0}) => a - b, + 'sub2': ({a: 0, b: 0}) => a - b + }; + expect(eval("o.sub1(1)")).toEqual(1); + expect(eval("o.sub1(3, b: 2)")).toEqual(1); + + expect(eval("o.sub2()")).toEqual(0); + expect(eval("o.sub2(a: 3)")).toEqual(3); + expect(eval("o.sub2(a: 3, b: 2)")).toEqual(1); + expect(eval("o.sub2(b: 4)")).toEqual(-4); + }); + }); + + describe('child group', () { + it('should remove all watches in group and group\'s children', () { + var child1a = watchGrp.createChild(context); + var child1b = watchGrp.createChild(context); + var child2 = child1a.createChild(context); + var ast = parse('a'); + watchGrp.watch(ast, (_, __) => log('0a')); + child1a.watch(ast, (_, __) => log('1a')); + child1b.watch(ast, (_, __) => log('1b')); + watchGrp.watch(ast, (_, __) => log('0A')); + child1a.watch(ast, (_, __) => log('1A')); + child2.watch(ast, (_, __) => log('2A')); + + context['a'] = 1; + watchGrp.processChanges(); + expect(log).toEqual(['0a', '0A', '1a', '1A', '2A', '1b']); + + log.clear(); + context['a'] = 2; + child1a.remove(); // also remove child2 + watchGrp.processChanges(); + expect(log).toEqual(['0a', '0A', '1b']); + }); + + it('should add watches within its own group', () { + var child = watchGrp.createChild(context); + var watchA = watchGrp.watch(parse('a'), (_, __) => log('a')); + var watchB = child.watch(parse('b'), (_, __) => log('b')); + + context['a'] = 1; + context['b'] = 1; + expect(watchGrp.processChanges()).toEqual(2); + expect(log).toEqual(['a', 'b']); + + log.clear(); + context['a'] = 2; + context['b'] = 2; + watchA.remove(); + expect(watchGrp.processChanges()).toEqual(1); + expect(log).toEqual(['b']); + + log.clear(); + context['a'] = 3; + context['b'] = 3; + watchB.remove(); + expect(watchGrp.processChanges()).toEqual(0); + expect(log).toEqual([]); + + log.clear(); + watchB = child.watch(parse('b'), (_, __) => log('b')); + watchA = watchGrp.watch(parse('a'), (_, __) => log('a')); + context['a'] = 4; + context['b'] = 4; + expect(watchGrp.processChanges()).toEqual(2); + expect(log).toEqual(['a', 'b']); + }); + + it('should properly add children', () { + expect(() { + var a = detector.createWatchGroup(context); + var b = detector.createWatchGroup(context); + var aChild = a.createChild(context); + a.processChanges(); + b.processChanges(); + }).not.toThrow(); + }); + + + it('should remove all field watches in group and group\'s children', () { + watchGrp.watch(parse('a'), (_, __) => log('0a')); + var child1a = watchGrp.createChild(context); + var child1b = watchGrp.createChild(context); + var child2 = child1a.createChild(context); + child1a.watch(parse('a'), (_, __) => log('1a')); + child1b.watch(parse('a'), (_, __) => log('1b')); + watchGrp.watch(parse('a'), (_, __) => log('0A')); + child1a.watch(parse('a'), (_, __) => log('1A')); + child2.watch(parse('a'), (_, __) => log('2A')); + + // flush initial reaction functions + expect(watchGrp.processChanges()).toEqual(6); + // This is a BC wrt the former implementation which was preserving registration order + // -> expect(log).toEqual(['0a', '1a', '1b', '0A', '1A', '2A']); + expect(log).toEqual(['0a', '0A', '1a', '1A', '2A', '1b']); + expect(watchGrp.watchedFields).toEqual(1); + expect(watchGrp.totalWatchedFields).toEqual(4); + + log.clear(); + context['a'] = 1; + expect(watchGrp.processChanges()).toEqual(6); + expect(log).toEqual(['0a', '0A', '1a', '1A', '2A', '1b']); // we go by group order + + log.clear(); + context['a'] = 2; + child1a.remove(); // should also remove child2 + expect(watchGrp.processChanges()).toEqual(3); + expect(log).toEqual(['0a', '0A', '1b']); + expect(watchGrp.watchedFields).toEqual(1); + expect(watchGrp.totalWatchedFields).toEqual(2); + }); + + it('should remove all method watches in group and group\'s children', () { + context['my'] = new _MyClass(log); + var countMethod = new MethodAST(parse('my'), 'count', []); + watchGrp.watch(countMethod, (_, __) => log('0a')); + expectOrder(['0a']); + + var child1a = watchGrp.createChild(new PrototypeMap(context)); + var child1b = watchGrp.createChild(new PrototypeMap(context)); + var child2 = child1a.createChild(new PrototypeMap(context)); + var child3 = child2.createChild(new PrototypeMap(context)); + child1a.watch(countMethod, (_, __) => log('1a')); + expectOrder(['0a', '1a']); + child1b.watch(countMethod, (_, __) => log('1b')); + expectOrder(['0a', '1a', '1b']); + watchGrp.watch(countMethod, (_, __) => log('0A')); + expectOrder(['0a', '0A', '1a', '1b']); + child1a.watch(countMethod, (_, __) => log('1A')); + expectOrder(['0a', '0A', '1a', '1A', '1b']); + child2.watch(countMethod, (_, __) => log('2A')); + expectOrder(['0a', '0A', '1a', '1A', '2A', '1b']); + child3.watch(countMethod, (_, __) => log('3')); + expectOrder(['0a', '0A', '1a', '1A', '2A', '3', '1b']); + + // flush initial reaction functions + expect(watchGrp.processChanges()).toEqual(7); + expectOrder(['0a', '0A', '1a', '1A', '2A', '3', '1b']); + + child1a.remove(); // should also remove child2 and child 3 + expect(watchGrp.processChanges()).toEqual(3); + expectOrder(['0a', '0A', '1b']); + }); + + it('should add watches within its own group', () { + context['my'] = new _MyClass(log); + var countMethod = new MethodAST(parse('my'), 'count', []); + var ra = watchGrp.watch(countMethod, (_, __) => log('a')); + var child = watchGrp.createChild(context); + var cb = child.watch(countMethod, (_, __) => log('b')); + + expectOrder(['a', 'b']); + expectOrder(['a', 'b']); + + ra.remove(); + expectOrder(['b']); + + cb.remove(); + expectOrder([]); + + cb = child.watch(countMethod, (_, __) => log('b')); + ra = watchGrp.watch(countMethod, (_, __) => log('a'));; + expectOrder(['a', 'b']); + }); + + it('should not call reaction function on removed group', () { + context['name'] = 'misko'; + var child = watchGrp.createChild(context); + watchGrp.watch(parse('name'), (v, _) { + log.add('root $v'); + if (v == 'destroy') child.remove(); + }); + + child.watch(parse('name'), (v, _) => log.add('child $v')); + watchGrp.processChanges(); + expect(log).toEqual(['root misko', 'child misko']); + log.clear(); + + context['name'] = 'destroy'; + watchGrp.processChanges(); + expect(log).toEqual(['root destroy']); + }); + + it('should watch children', () { + var childContext = new PrototypeMap(context); + context['a'] = 'OK'; + context['b'] = 'BAD'; + childContext['b'] = 'OK'; + watchGrp.watch(parse('a'), (v, _) => log(v)); + watchGrp.createChild(childContext).watch(parse('b'), (v, _) => log(v)); + + watchGrp.processChanges(); + expect(log).toEqual(['OK', 'OK']); + + log.clear(); + context['a'] = 'A'; + childContext['b'] = 'B'; + + watchGrp.processChanges(); + expect(log).toEqual(['A', 'B']); + }); + }); + }); + + describe('Releasable records', () { + it('should release records when a watch is removed', () { + context['td'] = new _TestData(); + context['a'] = 10; + context['b'] = 5; + var watch = watchGrp.watch(parse('td.sub1(a, b: b)'), (v, _) => log(v)); + watchGrp.processChanges(); + expect(log).toEqual([5]); + // td, a & b + expect(watchGrp.watchedFields).toEqual(3); + expect(watchGrp.watchedEvals).toEqual(1); + + watch.remove(); + expect(watchGrp.watchedFields).toEqual(0); + expect(watchGrp.watchedEvals).toEqual(0); + }); + + it('should release records when a group is removed', () { + context['td'] = new _TestData(); + context['a'] = 10; + context['b'] = 5; + var childGroup = watchGrp.createChild(context); + var watch = childGroup.watch(parse('td.sub1(a, b: b)'), (v, _) => log(v)); + watchGrp.processChanges(); + expect(log).toEqual([5]); + // td, a & b + expect(watchGrp.totalWatchedFields).toEqual(3); + expect(watchGrp.totalWatchedEvals).toEqual(1); + + childGroup.remove(); + expect(watchGrp.totalWatchedFields).toEqual(0); + expect(watchGrp.totalWatchedEvals).toEqual(0); + }); + }); + + describe('Checked records', () { + it('should checked constant records only once', () { + context['log'] = (arg) => log(arg); + watchGrp.watch(parse('log("foo")'), (_, __) => null); + + // log() + expect(watchGrp.watchedEvals).toEqual(1); + // context, "foo" & log() + expect(watchGrp.totalCheckedRecords).toEqual(3); + + watchGrp.processChanges(); + + // log() + expect(watchGrp.watchedEvals).toEqual(1); + // log() only, context & "foo" are constant watched only once + expect(watchGrp.totalCheckedRecords).toEqual(1); + }); + }); + + describe('Unchanged records', () { + it('should trigger fresh listeners', () { + context['foo'] = 'bar'; + watchGrp.watch(parse('foo'), (v, _) => log('foo=$v')); + watchGrp.processChanges(); + expect(log).toEqual(['foo=bar']); + + log.clear(); + watchGrp.watch(parse('foo'), (v, _) => log('foo[fresh]=$v')); + watchGrp.processChanges(); + expect(log).toEqual(['foo[fresh]=bar']); + }); + }); + + }); +} + +Matcher toEqualCollectionRecord({collection, previous, additions, moves, removals}) => + new CollectionRecordMatcher(collection:collection, previous: previous, additions:additions, + moves:moves, removals:removals); + +Matcher toEqualMapRecord({map, previous, additions, changes, removals}) => + new MapRecordMatcher(map:map, previous: previous, additions:additions, changes:changes, + removals:removals); + +abstract class _CollectionMatcher extends Matcher { + List _getList(Function it) { + var result = []; + it((item) { + result.add(item); + }); + return result; + } + + bool _compareLists(String tag, List expected, List actual, List diffs) { + var equals = true; + Iterator iActual = actual.iterator; + iActual.moveNext(); + T actualItem = iActual.current; + if (expected == null) { + expected = []; + } + for (String expectedItem in expected) { + if (actualItem == null) { + equals = false; + diffs.add('$tag too short: $expectedItem'); + } else { + if ("$actualItem" != expectedItem) { + equals = false; + diffs.add('$tag mismatch: $actualItem != $expectedItem'); + } + iActual.moveNext(); + actualItem = iActual.current; + } + } + if (actualItem != null) { + diffs.add('$tag too long: $actualItem'); + equals = false; + } + return equals; + } +} + +class CollectionRecordMatcher extends _CollectionMatcher { + final List collection; + final List previous; + final List additions; + final List moves; + final List removals; + + CollectionRecordMatcher({this.collection, this.previous, + this.additions, this.moves, this.removals}); + + Description describeMismatch(changes, Description mismatchDescription, + Map matchState, bool verbose) { + List diffs = matchState['diffs']; + if (diffs == null) return mismatchDescription; + return mismatchDescription..add(diffs.join('\n')); + } + + Description describe(Description description) { + add(name, collection) { + if (collection != null) { + description.add('$name: ${collection.join(', ')}\n '); + } + } + + add('collection', collection); + add('previous', previous); + add('additions', additions); + add('moves', moves); + add('removals', removals); + return description; + } + + bool matches(CollectionChangeRecord changeRecord, Map matchState) { + var diffs = matchState['diffs'] = []; + return checkCollection(changeRecord, diffs) && + checkPrevious(changeRecord, diffs) && + checkAdditions(changeRecord, diffs) && + checkMoves(changeRecord, diffs) && + checkRemovals(changeRecord, diffs); + } + + bool checkCollection(CollectionChangeRecord changeRecord, List diffs) { + List items = _getList((fn) => changeRecord.forEachItem(fn)); + bool equals = _compareLists("collection", collection, items, diffs); + int iterableLength = changeRecord.iterable.toList().length; + if (iterableLength != items.length) { + diffs.add('collection length mismatched: $iterableLength != ${items.length}'); + equals = false; + } + return equals; + } + + bool checkPrevious(CollectionChangeRecord changeRecord, List diffs) { + List items = _getList((fn) => changeRecord.forEachPreviousItem(fn)); + return _compareLists("previous", previous, items, diffs); + } + + bool checkAdditions(CollectionChangeRecord changeRecord, List diffs) { + List items = _getList((fn) => changeRecord.forEachAddition(fn)); + return _compareLists("additions", additions, items, diffs); + } + + bool checkMoves(CollectionChangeRecord changeRecord, List diffs) { + List items = _getList((fn) => changeRecord.forEachMove(fn)); + return _compareLists("moves", moves, items, diffs); + } + + bool checkRemovals(CollectionChangeRecord changeRecord, List diffs) { + List items = _getList((fn) => changeRecord.forEachRemoval(fn)); + return _compareLists("removes", removals, items, diffs); + } +} + +class MapRecordMatcher extends _CollectionMatcher { + final List map; + final List previous; + final List additions; + final List changes; + final List removals; + + MapRecordMatcher({this.map, this.previous, this.additions, this.changes, this.removals}); + + Description describeMismatch(changes, Description mismatchDescription, Map matchState, + bool verbose) { + List diffs = matchState['diffs']; + if (diffs == null) return mismatchDescription; + return mismatchDescription..add(diffs.join('\n')); + } + + Description describe(Description description) { + add(name, map) { + if (map != null) description.add('$name: ${map.join(', ')}\n '); + } + + add('map', map); + add('previous', previous); + add('additions', additions); + add('changes', changes); + add('removals', removals); + return description; + } + + bool matches(MapChangeRecord changeRecord, Map matchState) { + var diffs = matchState['diffs'] = []; + return checkMap(changeRecord, diffs) && + checkPrevious(changeRecord, diffs) && + checkAdditions(changeRecord, diffs) && + checkChanges(changeRecord, diffs) && + checkRemovals(changeRecord, diffs); + } + + bool checkMap(MapChangeRecord changeRecord, List diffs) { + List items = _getList((fn) => changeRecord.forEachItem(fn)); + bool equals = _compareLists("map", map, items, diffs); + int mapLength = changeRecord.map.length; + if (mapLength != items.length) { + diffs.add('map length mismatched: $mapLength != ${items.length}'); + equals = false; + } + return equals; + } + + bool checkPrevious(MapChangeRecord changeRecord, List diffs) { + List items = _getList((fn) => changeRecord.forEachPreviousItem(fn)); + return _compareLists("previous", previous, items, diffs); + } + + bool checkAdditions(MapChangeRecord changeRecord, List diffs) { + List items = _getList((fn) => changeRecord.forEachAddition(fn)); + return _compareLists("additions", additions, items, diffs); + } + + bool checkChanges(MapChangeRecord changeRecord, List diffs) { + List items = _getList((fn) => changeRecord.forEachChange(fn)); + return _compareLists("changes", changes, items, diffs); + } + + bool checkRemovals(MapChangeRecord changeRecord, List diffs) { + List items = _getList((fn) => changeRecord.forEachRemoval(fn)); + return _compareLists("removals", removals, items, diffs); + } +} + +class _User { + String first; + String last; + num age; + var isUnderAgeAsVariable; + List list = ['foo', 'bar', 'baz']; + Map map = {'foo': 'bar', 'baz': 'cux'}; + + _User([this.first, this.last, this.age]) { + isUnderAgeAsVariable = isUnderAge; + } + + bool isUnderAge() => age != null ? age < 18 : false; +} + +class _FooBar { + static int fooIds = 0; + + int id; + String foo, bar; + + _FooBar(this.foo, this.bar) { + id = fooIds++; + } + + bool operator==(other) => other is _FooBar && foo == other.foo && bar == other.bar; + + int get hashCode => foo.hashCode ^ bar.hashCode; + + String toString() => '($id)$foo-$bar'; +} + +class _TestData { + sub1(a, {b: 0}) => a - b; + sub2({a: 0, b: 0}) => a - b; +} + +class _LoggingFunctionApply extends FunctionApply { + Logger logger; + _LoggingFunctionApply(this.logger); + apply(List args) => logger(args); +} + +class _MyClass { + final Logger logger; + var valA; + int _count = 0; + + _MyClass(this.logger); + + dynamic methodA(arg1) { + logger('methodA($arg1) => $valA'); + return valA; + } + + int count() => _count++; + + String toString() => 'MyClass'; +} + + diff --git a/test/core/scope_spec.dart b/test/core/scope_spec.dart index 4767113db..f6c520402 100644 --- a/test/core/scope_spec.dart +++ b/test/core/scope_spec.dart @@ -1,8 +1,7 @@ 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'; +import 'package:angular/change_detector/change_detector.dart' hide ExceptionHandler; import 'dart:async'; import 'dart:math'; @@ -11,7 +10,6 @@ void main() { beforeEachModule((Module module) { Map context = {}; module - ..bind(ChangeDetector, toImplementation: DirtyCheckingChangeDetector) ..bind(Object, toValue: context) ..bind(Map, toValue: context) ..bind(RootScope) @@ -109,8 +107,9 @@ void main() { 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']); + expect(logger).toEqual(['fn', '=> 1', 'a.fn', '-> 2', + // Second loop + 'fn', 'a.fn']); logger.clear(); rootScope.digest(); expect(logger).toEqual(['fn', 'a.fn']); @@ -884,7 +883,9 @@ void main() { it(r'should apply expression with full lifecycle', (RootScope rootScope) { var log = ''; var child = rootScope.createChild({"parent": rootScope.context}); - rootScope.watch('a', (a, _) { log += '1'; }, canChangeModel: false); + rootScope.watch('a', (_, __) { + log += '1'; + }, canChangeModel: false); child.apply('parent.a = 0'); expect(log).toEqual('1'); }); @@ -963,9 +964,8 @@ void main() { retValue = 2; expect(rootScope.flush). - toThrow('Observer reaction functions should not change model. \n' - 'These watch changes were detected: logger("watch"): 2 <= 1\n' - 'These observe changes were detected: '); + toThrow('Observer reaction functions should not change model.\n' + 'These watch changes were detected: logger("watch"): 2 <= 1'); }); }); @@ -1314,8 +1314,8 @@ void main() { it('should watch closures both as a leaf and as method call', (RootScope rootScope, Logger log) { rootScope.context['foo'] = new Foo(); rootScope.context['increment'] = null; - rootScope.watch('foo.increment', (v, _) => rootScope.context['increment'] = v); rootScope.watch('increment(1)', (v, o) => log([v, o])); + rootScope.watch('foo.increment', (v, _) => rootScope.context['increment'] = v); expect(log).toEqual([]); rootScope.apply(); expect(log).toEqual([[null, null], [2, null]]); diff --git a/test/core_dom/mustache_spec.dart b/test/core_dom/mustache_spec.dart index cdcc1f3ac..1f033dfc4 100644 --- a/test/core_dom/mustache_spec.dart +++ b/test/core_dom/mustache_spec.dart @@ -35,9 +35,8 @@ main() { expect(log).toEqual([]); expect(_.rootElement.attributes['dir-foo']).toEqual(''); - _.rootScope.apply(() { - _.rootScope.context['val'] = 'value'; - }); + _.rootScope.apply('val = "value"'); + // _FooDirective should have observed exactly one change. expect(_.rootElement.attributes['dir-foo']).toEqual('value'); expect(log).toEqual(['value']); diff --git a/test/tools/transformer/expression_generator_spec.dart b/test/tools/transformer/expression_generator_spec.dart index f7b9b3bb6..7477ff5e8 100644 --- a/test/tools/transformer/expression_generator_spec.dart +++ b/test/tools/transformer/expression_generator_spec.dart @@ -210,7 +210,7 @@ Future generates(List> phases, const String header = ''' library a.web.main.generated_expressions; -import 'package:angular/change_detection/change_detection.dart'; +import 'package:angular/change_detector/change_detector.dart'; ''';