feat(Change Detection): Implement collection changes
This commit is contained in:
parent
7d0a83a24c
commit
1bd304e7ab
|
@ -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<V> _previousItHead;
|
||||
CollectionChangeItem<V> _itHead, _itTail;
|
||||
CollectionChangeItem<V> _additionsHead, _additionsTail;
|
||||
CollectionChangeItem<V> _movesHead, _movesTail;
|
||||
CollectionChangeItem<V> _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<V> _nextPrevious;
|
||||
CollectionChangeItem<V> _prev, _next;
|
||||
CollectionChangeItem<V> _prevDup, _nextDup;
|
||||
CollectionChangeItem<V> _prevRemoved, _nextRemoved;
|
||||
CollectionChangeItem<V> _nextAdded;
|
||||
CollectionChangeItem<V> _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) + ')';
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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,6 +84,18 @@ 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 {
|
||||
|
|
|
@ -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 {
|
||||
|
@ -118,3 +122,13 @@ class BaseException extends Error {
|
|||
return this.message;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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() {};
|
||||
|
@ -152,3 +160,15 @@ export class BaseException extends Error {
|
|||
return this.message;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue