From 1bd304e7abcb8b94e1ed15aa06018d2c8f1ac096 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Tue, 28 Oct 2014 18:56:15 +0100 Subject: [PATCH] feat(Change Detection): Implement collection changes --- .../src/collection_changes.js | 635 ++++++++++++++++++ .../test/collection_changes_spec.js | 280 ++++++++ modules/facade/src/collection.dart | 6 + modules/facade/src/collection.es6 | 18 +- modules/facade/src/lang.dart | 16 +- modules/facade/src/lang.es6 | 22 +- 6 files changed, 974 insertions(+), 3 deletions(-) create mode 100644 modules/change_detection/src/collection_changes.js create mode 100644 modules/change_detection/test/collection_changes_spec.js diff --git a/modules/change_detection/src/collection_changes.js b/modules/change_detection/src/collection_changes.js new file mode 100644 index 0000000000..bd8ca52474 --- /dev/null +++ b/modules/change_detection/src/collection_changes.js @@ -0,0 +1,635 @@ +import { + ListWrapper, + MapWrapper +} from 'facade/collection'; + +import { + int, + isBlank, + isPresent, + stringify, + getMapKey, + looseIdentical, +} from 'facade/lang'; + +export class CollectionChanges { + // todo(vicb) Add fields when supported + /* + _collection; + int _length; + DuplicateMap _linkedRecords; + DuplicateMap _unlinkedRecords; + CollectionChangeItem _previousItHead; + CollectionChangeItem _itHead, _itTail; + CollectionChangeItem _additionsHead, _additionsTail; + CollectionChangeItem _movesHead, _movesTail; + CollectionChangeItem _removalsHead, _removalsTail; + */ + constructor() { + this._collection = null; + this._length = null; + /// Keeps track of the used records at any point in time (during & across `_check()` calls) + this._linkedRecords = null; + /// Keeps track of the removed records at any point in time during `_check()` calls. + this._unlinkedRecords = null; + + this._previousItHead = null; + this._itHead = null; + this._itTail = null; + this._additionsHead = null; + this._additionsTail = null; + this._movesHead = null; + this._movesTail = null; + this._removalsHead = null; + this._removalsTail = null; + } + + get collection() { + return this._collection; + } + + get length():int { + return this._length; + } + + forEachItem(fn:Function) { + var record:CollectionChangeRecord; + for (record = this._itHead; record !== null; record = record.next) { + fn(record); + } + } + + forEachPreviousItem(fn:Function) { + var record:CollectionChangeRecord; + for (record = this._previousItHead; record !== null; record = record._nextPrevious) { + fn(record); + } + } + + forEachAddedItem(fn:Function){ + var record:CollectionChangeRecord; + for (record = this._additionsHead; record !== null; record = record._nextAdded) { + fn(record); + } + } + + forEachMovedItem(fn:Function) { + var record:CollectionChangeRecord; + for (record = this._movesHead; record !== null; record = record._nextMoved) { + fn(record); + } + } + + forEachRemovedItem(fn:Function){ + var record:CollectionChangeRecord; + for (record = this._removalsHead; record !== null; record = record._nextRemoved) { + fn(record); + } + } + + // todo(vicb): optim for UnmodifiableListView (frozen arrays) + check(collection):boolean { + this._reset(); + + var record:CollectionChangeRecord = this._itHead; + var mayBeDirty:boolean = false; + var index:int, item; + + if (ListWrapper.isList(collection)) { + var list = collection; + this._length = collection.length; + + for (index = 0; index < this._length; index++) { + item = list[index]; + if (record === null || !looseIdentical(record.item, item)) { + record = this._mismatch(record, item, index); + mayBeDirty = true; + } else if (mayBeDirty) { + // TODO(misko): can we limit this to duplicates only? + record = this._verifyReinsertion(record, item, index); + } + record = record._next; + } + } else { + // todo(vicb) implement iterators + throw "NYI"; + } + + this._truncate(record); + this._collection = collection; + return this.isDirty; + } + + // CollectionChanges is considered dirty if it has any additions, moves or removals. + get isDirty():boolean { + return this._additionsHead !== null || + this._movesHead !== null || + this._removalsHead !== null; + } + + /** + * 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). + * Set the previousIndexes of moved and added items to their currentIndexes + * Reset the list of additions, moves and removals + */ + _reset() { + if (this.isDirty) { + var record:CollectionChangeRecord; + var nextRecord:CollectionChangeRecord; + + for (record = this._previousItHead = this._itHead; record !== null; record = record._next) { + record._nextPrevious = record._next; + } + + for (record = this._additionsHead; record !== null; record = record._nextAdded) { + record.previousIndex = record.currentIndex; + } + this._additionsHead = this._additionsTail = null; + + for (record = this._movesHead; record !== null; record = nextRecord) { + record.previousIndex = record.currentIndex; + nextRecord = record._nextMoved; + } + this._movesHead = this._movesTail = null; + this._removalsHead = this._removalsTail = null; + + // todo(vicb) when assert gets supported + // assert(!this.isDirty); + } + } + + /** + * 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 + */ + _mismatch(record:CollectionChangeRecord, item, index:int):CollectionChangeRecord { + // The previous record after which we will append the current one. + var previousRecord:CollectionChangeRecord; + + if (record === null) { + previousRecord = this._itTail; + } else { + previousRecord = record._prev; + // Remove the record from the collection since we know it does not match the item. + this._remove(record); + } + + // Attempt to see if we have seen the item before. + record = this._linkedRecords === null ? null : this._linkedRecords.get(item, index); + if (record !== null) { + // We have seen this before, we need to move it forward in the collection. + this._moveAfter(record, previousRecord, index); + } else { + // Never seen it, check evicted list. + record = this._unlinkedRecords === null ? null : this._unlinkedRecords.get(item); + if (record !== null) { + // It is an item which we have evicted earlier: reinsert it back into the list. + this._reinsertAfter(record, previousRecord, index); + } else { + // It is a new item: add it. + record = this._addAfter(new CollectionChangeRecord(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. + */ + _verifyReinsertion(record:CollectionChangeRecord, item, index:int):CollectionChangeRecord { + var reinsertRecord:CollectionChangeRecord = this._unlinkedRecords === null ? + null : this._unlinkedRecords.get(item); + if (reinsertRecord !== null) { + record = this._reinsertAfter(reinsertRecord, record._prev, index); + } else if (record.currentIndex != index) { + record.currentIndex = index; + this._addToMoves(record, index); + } + return record; + } + + /** + * Get rid of any excess [CollectionChangeItem]s from the previous collection + * + * - [record] The first excess [CollectionChangeItem]. + */ + _truncate(record:CollectionChangeRecord) { + // Anything after that needs to be removed; + while (record !== null) { + var nextRecord:CollectionChangeRecord = record._next; + this._addToRemovals(this._unlink(record)); + record = nextRecord; + } + if (this._unlinkedRecords !== null) { + this._unlinkedRecords.clear(); + } + + if (this._additionsTail !== null) { + this._additionsTail._nextAdded = null; + } + if (this._movesTail !== null) { + this._movesTail._nextMoved = null; + } + if (this._itTail !== null) { + this._itTail._next = null; + } + if (this._removalsTail !== null) { + this._removalsTail._nextRemoved = null; + } + } + + _reinsertAfter(record:CollectionChangeRecord, prevRecord:CollectionChangeRecord, + index:int):CollectionChangeRecord { + if (this._unlinkedRecords !== null) { + this._unlinkedRecords.remove(record); + } + var prev = record._prevRemoved; + var next = record._nextRemoved; + + if (prev === null) { + this._removalsHead = next; + } else { + prev._nextRemoved = next; + } + if (next === null) { + this._removalsTail = prev; + } else { + next._prevRemoved = prev; + } + + this._insertAfter(record, prevRecord, index); + this._addToMoves(record, index); + return record; + } + + _moveAfter(record:CollectionChangeRecord, prevRecord:CollectionChangeRecord, + index:int):CollectionChangeRecord { + this._unlink(record); + this._insertAfter(record, prevRecord, index); + this._addToMoves(record, index); + return record; + } + + _addAfter(record:CollectionChangeRecord, prevRecord:CollectionChangeRecord, + index:int):CollectionChangeRecord { + this._insertAfter(record, prevRecord, index); + + if (this._additionsTail === null) { + // todo(vicb) + //assert(this._additionsHead === null); + this._additionsTail = this._additionsHead = record; + } else { + // todo(vicb) + //assert(_additionsTail._nextAdded === null); + //assert(record._nextAdded === null); + this._additionsTail = this._additionsTail._nextAdded = record; + } + return record; + } + + _insertAfter(record:CollectionChangeRecord, prevRecord:CollectionChangeRecord, + index:int):CollectionChangeRecord { + // todo(vicb) + //assert(record != prevRecord); + //assert(record._next === null); + //assert(record._prev === null); + + var next:CollectionChangeRecord = prevRecord === null ? this._itHead :prevRecord._next; + // todo(vicb) + //assert(next != record); + //assert(prevRecord != record); + record._next = next; + record._prev = prevRecord; + if (next === null) { + this._itTail = record; + } else { + next._prev = record; + } + if (prevRecord === null) { + this._itHead = record; + } else { + prevRecord._next = record; + } + + if (this._linkedRecords === null) { + this._linkedRecords = new _DuplicateMap(); + } + this._linkedRecords.put(record); + + record.currentIndex = index; + return record; + } + + _remove(record:CollectionChangeRecord):CollectionChangeRecord { + this._addToRemovals(this._unlink(record)); + } + + _unlink(record:CollectionChangeRecord):CollectionChangeRecord { + if (this._linkedRecords !== null) { + this._linkedRecords.remove(record); + } + + var prev = record._prev; + var next = record._next; + + // todo(vicb) + //assert((record._prev = null) === null); + //assert((record._next = null) === null); + + if (prev === null) { + this._itHead = next; + } else { + prev._next = next; + } + if (next === null) { + this._itTail = prev; + } else { + next._prev = prev; + } + + return record; + } + + _addToMoves(record:CollectionChangeRecord, toIndex:int):CollectionChangeRecord { + // todo(vicb) + //assert(record._nextMoved === null); + + if (record.previousIndex === toIndex) { + return record; + } + + if (this._movesTail === null) { + // todo(vicb) + //assert(_movesHead === null); + this._movesTail = this._movesHead = record; + } else { + // todo(vicb) + //assert(_movesTail._nextMoved === null); + this._movesTail = this._movesTail._nextMoved = record; + } + + return record; + } + + _addToRemovals(record:CollectionChangeRecord):CollectionChangeRecord { + if (this._unlinkedRecords === null) { + this._unlinkedRecords = new _DuplicateMap(); + } + this._unlinkedRecords.put(record); + record.currentIndex = null; + record._nextRemoved = null; + + if (this._removalsTail === null) { + // todo(vicb) + //assert(_removalsHead === null); + this._removalsTail = this._removalsHead = record; + record._prevRemoved = null; + } else { + // todo(vicb) + //assert(_removalsTail._nextRemoved === null); + //assert(record._nextRemoved === null); + record._prevRemoved = this._removalsTail; + this._removalsTail = this._removalsTail._nextRemoved = record; + } + return record; + } + + toString():string { + var record:CollectionChangeRecord; + + var list = []; + for (record = this._itHead; record !== null; record = record._next) { + ListWrapper.push(list, record); + } + + var previous = []; + for (record = this._previousItHead; record !== null; record = record._nextPrevious) { + ListWrapper.push(previous, record); + } + + var additions = []; + for (record = this._additionsHead; record !== null; record = record._nextAdded) { + ListWrapper.push(additions, record); + } + var moves = []; + for (record = this._movesHead; record !== null; record = record._nextMoved) { + ListWrapper.push(moves, record); + } + + var removals = []; + for (record = this._removalsHead; record !== null; record = record._nextRemoved) { + ListWrapper.push(removals, record); + } + + return "collection: " + list.join(', ') + "\n" + + "previous: " + previous.join(', ') + "\n" + + "additions: " + additions.join(', ') + "\n" + + "moves: " + moves.join(', ') + "\n" + + "removals: " + removals.join(', ') + "\n"; + } +} + +export class CollectionChangeRecord { + // todo(vicb) add fields when supported + /* + int currentIndex; + int previousIndex; + V item; + + CollectionChangeItem _nextPrevious; + CollectionChangeItem _prev, _next; + CollectionChangeItem _prevDup, _nextDup; + CollectionChangeItem _prevRemoved, _nextRemoved; + CollectionChangeItem _nextAdded; + CollectionChangeItem _nextMoved; + */ + + constructor(item) { + this.currentIndex = null; + this.previousIndex = null; + this.item = item; + + this._nextPrevious = null; + this._prev = null; + this._next = null; + this._prevDup = null; + this._nextDup = null; + this._prevRemoved = null; + this._nextRemoved = null; + this._nextAdded = null; + this._nextMoved = null; + } + + toString():string { + return this.previousIndex === this.currentIndex ? + stringify(this.item) : + stringify(this.item) + '[' + stringify(this.previousIndex) + '->' + + stringify(this.currentIndex) + ']'; + } +} + +// A linked list of CollectionChangeRecords with the same CollectionChangeRecord.item +class _DuplicateItemRecordList { + /* + todo(vicb): add fields when supported + CollectionChangeRecord _head, _tail; + */ + + constructor() { + this._head = null; + this._tail = null; + } + + /** + * Append the record to the list of duplicates. + * + * Note: by design all records in the list of duplicates hold the same value in record.item. + */ + add(record:CollectionChangeRecord) { + if (this._head === null) { + this._head = this._tail = record; + record._nextDup = null; + record._prevDup = null; + } else { + // todo(vicb) + //assert(record.item == _head.item || + // record.item is num && record.item.isNaN && _head.item is num && _head.item.isNaN); + this._tail._nextDup = record; + record._prevDup = this._tail; + record._nextDup = null; + this._tail = record; + } + } + + // Returns a CollectionChangeRecord having CollectionChangeRecord.item == item and + // CollectionChangeRecord.currentIndex >= afterIndex + get(item, afterIndex:int):CollectionChangeRecord { + var record:CollectionChangeRecord; + for (record = this._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. + */ + remove(record:CollectionChangeRecord):boolean { + // todo(vicb) + //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:CollectionChangeRecord = record._prevDup; + var next:CollectionChangeRecord = record._nextDup; + if (prev === null) { + this._head = next; + } else { + prev._nextDup = next; + } + if (next === null) { + this._tail = prev; + } else { + next._prevDup = prev; + } + return this._head === null; + } +} + +class _DuplicateMap { + // todo(vicb): add fields when supported + constructor() { + this.map = MapWrapper.create(); + } + + put(record:CollectionChangeRecord) { + // todo(vicb) handle corner cases + var key = getMapKey(record.item); + + var duplicates = MapWrapper.get(this.map, key); + if (!isPresent(duplicates)) { + duplicates = new _DuplicateItemRecordList(); + MapWrapper.set(this.map, key, duplicates); + } + duplicates.add(record); + } + + /** + * Retrieve the `value` using key. Because the CollectionChangeRecord 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. + */ + get(value, afterIndex = null):CollectionChangeRecord { + var key = getMapKey(value); + + var recordList = MapWrapper.get(this.map, key); + return isBlank(recordList) ? 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. + */ + remove(record:CollectionChangeRecord):CollectionChangeRecord { + var key = getMapKey(record.item); + // todo(vicb) + //assert(this.map.containsKey(key)); + var recordList:_DuplicateItemRecordList = MapWrapper.get(this.map, key); + // Remove the list of duplicates when it gets empty + if (recordList.remove(record)) { + MapWrapper.delete(this.map, key); + } + return record; + } + + get isEmpty():boolean { + return MapWrapper.size(this.map) === 0; + } + + clear() { + MapWrapper.clear(this.map); + } + + toString():string { + return '_DuplicateMap(' + stringify(this.map) + ')'; + } +} diff --git a/modules/change_detection/test/collection_changes_spec.js b/modules/change_detection/test/collection_changes_spec.js new file mode 100644 index 0000000000..bf2545b46f --- /dev/null +++ b/modules/change_detection/test/collection_changes_spec.js @@ -0,0 +1,280 @@ +import {describe, it, iit, xit, expect, beforeEach, afterEach} from 'test_lib/test_lib'; + +import {CollectionChanges} from 'change_detection/collection_changes'; + +import {isBlank, NumberWrapper} from 'facade/lang'; + +import {ListWrapper} from 'facade/collection'; + +// todo(vicb): UnmodifiableListView / frozen object when implemented +// todo(vicb): Update the code & tests for object equality +export function main() { + describe('collection_changes', function() { + describe('CollectionChanges', function() { + var changes; + var l; + + beforeEach(() => { + changes = new CollectionChanges(); + }); + + afterEach(() => { + changes = null; + }); + + it('should detect additions', () => { + l = []; + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + collection: [] + })); + + ListWrapper.push(l, 'a'); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + collection: ['a[null->0]'], + additions: ['a[null->0]'] + })); + + ListWrapper.push(l, 'b'); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + collection: ['a', 'b[null->1]'], + previous: ['a'], + additions: ['b[null->1]'] + })); + }); + + it('should support changing the reference', () => { + l = [0]; + changes.check(l); + + l = [1, 0]; + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + collection: ['1[null->0]', '0[0->1]'], + previous: ['0[0->1]'], + additions: ['1[null->0]'], + moves: ['0[0->1]'] + })); + + l = [2, 1, 0]; + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + 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 element', () => { + l = [1, 2]; + changes.check(l); + + ListWrapper.clear(l); + ListWrapper.push(l, 2); + ListWrapper.push(l, 1); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + 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 element', () => { + l = ['a', 'b', 'c']; + changes.check(l); + + ListWrapper.removeAt(l, 1); + ListWrapper.insert(l, 0, 'b'); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + collection: ['b[1->0]', 'a[0->1]', 'c'], + previous: ['a[0->1]', 'b[1->0]', 'c'], + moves: ['b[1->0]', 'a[0->1]'] + })); + + ListWrapper.removeAt(l, 1); + ListWrapper.push(l, 'a'); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + 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', () => { + l = []; + changes.check(l); + + ListWrapper.push(l, 'a'); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + collection: ['a[null->0]'], + additions: ['a[null->0]'] + })); + + ListWrapper.push(l, 'b'); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + collection: ['a', 'b[null->1]'], + previous: ['a'], + additions: ['b[null->1]'] + })); + + ListWrapper.push(l, 'c'); + ListWrapper.push(l, 'd'); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + collection: ['a', 'b', 'c[null->2]', 'd[null->3]'], + previous: ['a', 'b'], + additions: ['c[null->2]', 'd[null->3]'] + })); + + ListWrapper.removeAt(l, 2); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + collection: ['a', 'b', 'd[3->2]'], + previous: ['a', 'b', 'c[2->null]', 'd[3->2]'], + moves: ['d[3->2]'], + removals: ['c[2->null]'] + })); + + ListWrapper.clear(l); + ListWrapper.push(l, 'd'); + ListWrapper.push(l, 'c'); + ListWrapper.push(l, 'b'); + ListWrapper.push(l, 'a'); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + 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 (Dart)', () => { + l = ['a', 'boo']; + changes.check(l); + + var b = 'b'; + var oo = 'oo'; + ListWrapper.set(l, 1, b + oo); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + collection: ['a', 'boo'], + previous: ['a', 'boo'] + })); + }); + + it('should ignore [NaN] != [NaN] (JS)', () => { + l = [NumberWrapper.NaN]; + changes.check(l); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + collection: [NumberWrapper.NaN], + previous: [NumberWrapper.NaN] + })); + }); + + it('should detect [NaN] moves', () => { + l = [NumberWrapper.NaN, NumberWrapper.NaN]; + changes.check(l); + + ListWrapper.insert(l, 0, 'foo'); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + 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', () => { + l = ['a', 'b', 'c']; + changes.check(l); + + ListWrapper.removeAt(l, 1); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + collection: ['a', 'c[2->1]'], + previous: ['a', 'b[1->null]', 'c[2->1]'], + moves: ['c[2->1]'], + removals: ['b[1->null]'] + })); + + ListWrapper.insert(l, 1, 'b'); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + 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', () => { + l = ['a', 'a', 'a', 'b', 'b']; + changes.check(l); + + ListWrapper.removeAt(l, 0); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + 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', () => { + l = ['a', 'a', 'b', 'b']; + changes.check(l); + + ListWrapper.insert(l, 0, 'b'); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + 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 not report unnecessary moves', () => { + l = ['a', 'b', 'c']; + changes.check(l); + + ListWrapper.clear(l); + ListWrapper.push(l, 'b'); + ListWrapper.push(l, 'a'); + ListWrapper.push(l, 'c'); + changes.check(l); + expect(changes.toString()).toEqual(changesAsString({ + collection: ['b[1->0]', 'a[0->1]', 'c'], + previous: ['a[0->1]', 'b[1->0]', 'c'], + moves: ['b[1->0]', 'a[0->1]'] + })); + }); + }); + }); +} + +function changesAsString({collection, previous, additions, moves, removals}) { + if (isBlank(collection)) collection = []; + if (isBlank(previous)) previous = []; + if (isBlank(additions)) additions = []; + if (isBlank(moves)) moves = []; + if (isBlank(removals)) removals = []; + + return "collection: " + collection.join(', ') + "\n" + + "previous: " + previous.join(', ') + "\n" + + "additions: " + additions.join(', ') + "\n" + + "moves: " + moves.join(', ') + "\n" + + "removals: " + removals.join(', ') + "\n"; +} diff --git a/modules/facade/src/collection.dart b/modules/facade/src/collection.dart index 0b86d72db8..0816f05c3b 100644 --- a/modules/facade/src/collection.dart +++ b/modules/facade/src/collection.dart @@ -12,6 +12,8 @@ class MapWrapper { m.forEach((k,v) => fn(v,k)); } static int size(m) {return m.length;} + static void delete(m, k) { m.remove(k); } + static void clear(m) { m.clear(); } } // TODO: how to export StringMap=Map as a type? @@ -47,6 +49,10 @@ class ListWrapper { static List reversed(List list) => list.reversed.toList(); static void push(List l, e) { l.add(e); } static List concat(List a, List b) {a.addAll(b); return a;} + static bool isList(l) => l is List; + static void insert(List l, int index, value) { l.insert(index, value); } + static void removeAt(List l, int index) { l.removeAt(index); } + static void clear(List l) { l.clear(); } } class SetWrapper { diff --git a/modules/facade/src/collection.es6 b/modules/facade/src/collection.es6 index cf6ebda1f0..4ed8a35e52 100644 --- a/modules/facade/src/collection.es6 +++ b/modules/facade/src/collection.es6 @@ -1,3 +1,5 @@ +import {int} from 'facade/lang'; + export var List = window.Array; export var Map = window.Map; export var Set = window.Set; @@ -11,6 +13,8 @@ export class MapWrapper { m.forEach(fn); } static size(m) {return m.size;} + static delete(m, k) { m.delete(k); } + static clear(m) { m.clear(); } } // TODO: cannot export StringMap as a type as Dart does not support @@ -80,9 +84,21 @@ export class ListWrapper { return a.reverse(); } static concat(a, b) {return a.concat(b);} + static isList(list) { + return Array.isArray(list); + } + static insert(list, index:int, value) { + list.splice(index, 0, value); + } + static removeAt(list, index:int) { + list.splice(index, 1); + } + static clear(list) { + list.splice(0, list.length); + } } export class SetWrapper { static createFromList(lst:List) { return new Set(lst); } static has(s:Set, key):boolean { return s.has(key); } -} \ No newline at end of file +} diff --git a/modules/facade/src/lang.dart b/modules/facade/src/lang.dart index b9a6e71682..e83019673c 100644 --- a/modules/facade/src/lang.dart +++ b/modules/facade/src/lang.dart @@ -83,6 +83,10 @@ class NumberWrapper { static double parseFloat(String text) { return double.parse(text); } + + static get NaN => double.NAN; + + static bool isNaN(num value) => value.isNaN; } class RegExpWrapper { @@ -117,4 +121,14 @@ class BaseException extends Error { String toString() { return this.message; } -} \ No newline at end of file +} + +// Dart can have identical(str1, str2) == false while str1 == str2 +bool looseIdentical(a, b) => a is String && b is String ? a == b : identical(a, b); + +// Dart compare map keys by equality and we can have NaN != NaN +dynamic getMapKey(value) { + if (value is! num) return value; + return value.isNaN ? _NAN_KEY : value; +} + diff --git a/modules/facade/src/lang.es6 b/modules/facade/src/lang.es6 index 8a4a75f06c..286a60e907 100644 --- a/modules/facade/src/lang.es6 +++ b/modules/facade/src/lang.es6 @@ -110,6 +110,14 @@ export class NumberWrapper { static parseFloat(text:string):number { return parseFloat(text); } + + static isNaN(value) { + return isNaN(value); + } + + static get NaN():number { + return NaN; + } } export function int() {}; @@ -151,4 +159,16 @@ export class BaseException extends Error { toString():String { return this.message; } -} \ No newline at end of file +} + +// JS has NaN !== NaN +export function looseIdentical(a, b):boolean { + return a === b || + typeof a === "number" && typeof b === "number" && isNaN(a) && isNaN(b); +} + +// JS considers NaN is the same as NaN for map Key (while NaN !== NaN otherwise) +// see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map +export function getMapKey(value) { + return value; +}