feat(Change Detector): Add support for collection content watch

This commit is contained in:
Victor Berchet 2014-11-24 18:42:53 +01:00
parent 2d2f44949d
commit bf71b94bde
15 changed files with 609 additions and 206 deletions

View File

@ -1,4 +1,6 @@
import {
isListLikeIterable,
iterateListLike,
ListWrapper,
MapWrapper
} from 'facade/collection';
@ -12,20 +14,21 @@ import {
looseIdentical,
} from 'facade/lang';
export class CollectionChanges {
export class ArrayChanges {
_collection;
_length:int;
_linkedRecords:_DuplicateMap;
_unlinkedRecords:_DuplicateMap;
_previousItHead:CollectionChangeRecord<V> ;
_previousItHead:CollectionChangeRecord<V>;
_itHead:CollectionChangeRecord<V>;
_itTail:CollectionChangeRecord<V> ;
_itTail:CollectionChangeRecord<V>;
_additionsHead:CollectionChangeRecord<V>;
_additionsTail:CollectionChangeRecord<V> ;
_additionsTail:CollectionChangeRecord<V>;
_movesHead:CollectionChangeRecord<V>;
_movesTail:CollectionChangeRecord<V> ;
_removalsHead:CollectionChangeRecord<V>;
_removalsTail:CollectionChangeRecord<V> ;
_removalsTail:CollectionChangeRecord<V>;
constructor() {
this._collection = null;
this._length = null;
@ -45,6 +48,10 @@ export class CollectionChanges {
this._removalsTail = null;
}
static supports(obj):boolean {
return isListLikeIterable(obj);
}
get collection() {
return this._collection;
}
@ -112,8 +119,19 @@ export class CollectionChanges {
record = record._next;
}
} else {
// todo(vicb) implement iterators
throw "NYI";
index = 0;
iterateListLike(collection, (item) => {
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;
index++
});
this._length = index;
}
this._truncate(record);

View File

@ -1,19 +1,19 @@
import {ListWrapper, MapWrapper} from 'facade/collection';
import {ListWrapper, MapWrapper, StringMapWrapper} from 'facade/collection';
import {stringify, looseIdentical} from 'facade/lang';
import {stringify, looseIdentical, isJsObject} from 'facade/lang';
export class MapChanges {
export class KeyValueChanges {
_records:Map;
_map:Map;
_map:any;
_mapHead:MapChangeRecord;
_previousMapHead:MapChangeRecord;
_changesHead:MapChangeRecord;
_changesTail:MapChangeRecord;
_additionsHead:MapChangeRecord;
_additionsTail:MapChangeRecord;
_removalsHead:MapChangeRecord;
_removalsTail:MapChangeRecord;
_mapHead:KVChangeRecord;
_previousMapHead:KVChangeRecord;
_changesHead:KVChangeRecord;
_changesTail:KVChangeRecord;
_additionsHead:KVChangeRecord;
_additionsTail:KVChangeRecord;
_removalsHead:KVChangeRecord;
_removalsTail:KVChangeRecord;
constructor() {
this._records = MapWrapper.create();
@ -28,57 +28,61 @@ export class MapChanges {
this._removalsTail = null;
}
static supports(obj):boolean {
return obj instanceof Map || isJsObject(obj);
}
get isDirty():boolean {
return this._additionsHead !== null ||
this._changesHead !== null ||
this._removalsHead !== null;
}
forEachItem(fn:Function) {
var record:MapChangeRecord;
for (record = this._mapHead; record !== null; record = record._next) {
fn(record);
}
forEachItem(fn:Function) {
var record:KVChangeRecord;
for (record = this._mapHead; record !== null; record = record._next) {
fn(record);
}
}
forEachPreviousItem(fn:Function) {
var record:MapChangeRecord;
for (record = this._previousMapHead; record !== null; record = record._nextPrevious) {
fn(record);
}
forEachPreviousItem(fn:Function) {
var record:KVChangeRecord;
for (record = this._previousMapHead; record !== null; record = record._nextPrevious) {
fn(record);
}
}
forEachChangedItem(fn:Function) {
var record:MapChangeRecord;
for (record = this._changesHead; record !== null; record = record._nextChanged) {
fn(record);
}
forEachChangedItem(fn:Function) {
var record:KVChangeRecord;
for (record = this._changesHead; record !== null; record = record._nextChanged) {
fn(record);
}
}
forEachAddedItem(fn:Function){
var record:MapChangeRecord;
for (record = this._additionsHead; record !== null; record = record._nextAdded) {
fn(record);
}
forEachAddedItem(fn:Function){
var record:KVChangeRecord;
for (record = this._additionsHead; record !== null; record = record._nextAdded) {
fn(record);
}
}
forEachRemovedItem(fn:Function){
var record:MapChangeRecord;
for (record = this._removalsHead; record !== null; record = record._nextRemoved) {
fn(record);
}
forEachRemovedItem(fn:Function){
var record:KVChangeRecord;
for (record = this._removalsHead; record !== null; record = record._nextRemoved) {
fn(record);
}
}
check(map):boolean {
this._reset();
this._map = map;
var records = this._records;
var oldSeqRecord:MapChangeRecord = this._mapHead;
var lastOldSeqRecord:MapChangeRecord = null;
var lastNewSeqRecord:MapChangeRecord = null;
var oldSeqRecord:KVChangeRecord = this._mapHead;
var lastOldSeqRecord:KVChangeRecord = null;
var lastNewSeqRecord:KVChangeRecord = null;
var seqChanged:boolean = false;
MapWrapper.forEach(map, (value, key) => {
this._forEach(map, (value, key) => {
var newSeqRecord;
if (oldSeqRecord !== null && key === oldSeqRecord.key) {
newSeqRecord = oldSeqRecord;
@ -97,7 +101,7 @@ export class MapChanges {
if (MapWrapper.contains(records, key)) {
newSeqRecord = MapWrapper.get(records, key);
} else {
newSeqRecord = new MapChangeRecord(key);
newSeqRecord = new KVChangeRecord(key);
MapWrapper.set(records, key, newSeqRecord);
newSeqRecord._currentValue = value;
this._addToAdditions(newSeqRecord);
@ -124,7 +128,7 @@ export class MapChanges {
_reset() {
if (this.isDirty) {
var record:MapChangeRecord;
var record:KVChangeRecord;
// Record the state of the mapping
for (record = this._previousMapHead = this._mapHead;
record !== null;
@ -171,7 +175,7 @@ export class MapChanges {
}
}
_truncate(lastRecord:MapChangeRecord, record:MapChangeRecord) {
_truncate(lastRecord:KVChangeRecord, record:KVChangeRecord) {
while (record !== null) {
if (lastRecord === null) {
this._mapHead = null;
@ -189,20 +193,20 @@ export class MapChanges {
record = nextRecord;
}
for (var rec:MapChangeRecord = this._removalsHead; rec !== null; rec = rec._nextRemoved) {
for (var rec:KVChangeRecord = this._removalsHead; rec !== null; rec = rec._nextRemoved) {
rec._previousValue = rec._currentValue;
rec._currentValue = null;
MapWrapper.delete(this._records, rec.key);
}
}
_isInRemovals(record:MapChangeRecord) {
_isInRemovals(record:KVChangeRecord) {
return record === this._removalsHead ||
record._nextRemoved !== null ||
record._prevRemoved !== null;
}
_addToRemovals(record:MapChangeRecord) {
_addToRemovals(record:KVChangeRecord) {
// todo(vicb) assert
//assert(record._next == null);
//assert(record._nextAdded == null);
@ -218,7 +222,7 @@ export class MapChanges {
}
}
_removeFromSeq(prev:MapChangeRecord, record:MapChangeRecord) {
_removeFromSeq(prev:KVChangeRecord, record:KVChangeRecord) {
var next = record._next;
if (prev === null) {
this._mapHead = next;
@ -232,7 +236,7 @@ export class MapChanges {
//})());
}
_removeFromRemovals(record:MapChangeRecord) {
_removeFromRemovals(record:KVChangeRecord) {
// todo(vicb) assert
//assert(record._next == null);
//assert(record._nextAdded == null);
@ -253,7 +257,7 @@ export class MapChanges {
record._prevRemoved = record._nextRemoved = null;
}
_addToAdditions(record:MapChangeRecord) {
_addToAdditions(record:KVChangeRecord) {
// todo(vicb): assert
//assert(record._next == null);
//assert(record._nextAdded == null);
@ -268,7 +272,7 @@ export class MapChanges {
}
}
_addToChanges(record:MapChangeRecord) {
_addToChanges(record:KVChangeRecord) {
// todo(vicb) assert
//assert(record._nextAdded == null);
//assert(record._nextChanged == null);
@ -288,7 +292,7 @@ export class MapChanges {
var changes = [];
var additions = [];
var removals = [];
var record:MapChangeRecord;
var record:KVChangeRecord;
for (record = this._mapHead; record !== null; record = record._next) {
ListWrapper.push(items, stringify(record));
@ -312,19 +316,29 @@ export class MapChanges {
"changes: " + changes.join(', ') + "\n" +
"removals: " + removals.join(', ') + "\n";
}
_forEach(obj, fn:Function) {
if (obj instanceof Map) {
MapWrapper.forEach(obj, fn);
} else {
StringMapWrapper.forEach(obj, fn);
}
}
}
export class MapChangeRecord {
export class KVChangeRecord {
key;
_previousValue;
_currentValue;
_nextPrevious:MapChangeRecord;
_next:MapChangeRecord;
_nextAdded:MapChangeRecord;
_nextRemoved:MapChangeRecord;
_prevRemoved:MapChangeRecord;
_nextChanged:MapChangeRecord;
_nextPrevious:KVChangeRecord;
_next:KVChangeRecord;
_nextAdded:KVChangeRecord;
_nextRemoved:KVChangeRecord;
_prevRemoved:KVChangeRecord;
_nextChanged:KVChangeRecord;
constructor(key) {
this.key = key;
@ -345,5 +359,4 @@ export class MapChangeRecord {
(stringify(this.key) + '[' + stringify(this._previousValue) + '->' +
stringify(this._currentValue) + ']');
}
}

View File

@ -29,6 +29,21 @@ export class EmptyExpr extends AST {
}
}
export class Collection extends AST {
value:AST;
constructor(value:AST) {
this.value = value;
}
eval(context) {
return value.eval(context);
}
visit(visitor, args) {
visitor.visitCollection(this, args);
}
}
export class ImplicitReceiver extends AST {
eval(context) {
return context;
@ -386,20 +401,21 @@ export class TemplateBinding {
//INTERFACE
export class AstVisitor {
visitChain(ast:Chain, args){}
visitImplicitReceiver(ast:ImplicitReceiver, args) {}
visitConditional(ast:Conditional, args) {}
visitAccessMember(ast:AccessMember, args) {}
visitKeyedAccess(ast:KeyedAccess, args) {}
visitBinary(ast:Binary, args) {}
visitPrefixNot(ast:PrefixNot, args) {}
visitLiteralPrimitive(ast:LiteralPrimitive, args) {}
visitFormatter(ast:Formatter, args) {}
visitAssignment(ast:Assignment, args) {}
visitBinary(ast:Binary, args) {}
visitChain(ast:Chain, args){}
visitCollection(ast:Collection, args) {}
visitConditional(ast:Conditional, args) {}
visitFormatter(ast:Formatter, args) {}
visitFunctionCall(ast:FunctionCall, args) {}
visitImplicitReceiver(ast:ImplicitReceiver, args) {}
visitKeyedAccess(ast:KeyedAccess, args) {}
visitLiteralArray(ast:LiteralArray, args) {}
visitLiteralMap(ast:LiteralMap, args) {}
visitLiteralPrimitive(ast:LiteralPrimitive, args) {}
visitMethodCall(ast:MethodCall, args) {}
visitFunctionCall(ast:FunctionCall, args) {}
visitPrefixNot(ast:PrefixNot, args) {}
}
var _evalListCache = [[],[0],[0,0],[0,0,0],[0,0,0,0],[0,0,0,0,0]];
@ -410,4 +426,4 @@ function evalList(context, exps:List){
result[i] = exps[i].eval(context);
}
return result;
}
}

View File

@ -1,6 +1,8 @@
import {ProtoRecordRange, RecordRange} from './record_range';
import {FIELD, isPresent, isBlank, int, StringWrapper, FunctionWrapper, BaseException} from 'facade/lang';
import {List, Map, ListWrapper, MapWrapper} from 'facade/collection';
import {ArrayChanges} from './array_changes';
import {KeyValueChanges} from './keyvalue_changes';
var _fresh = new Object();
@ -10,15 +12,15 @@ export const RECORD_TYPE_INVOKE_CLOSURE = 0x0001;
export const RECORD_TYPE_INVOKE_FORMATTER = 0x0002;
export const RECORD_TYPE_INVOKE_METHOD = 0x0003;
export const RECORD_TYPE_INVOKE_PURE_FUNCTION = 0x0004;
export const RECORD_TYPE_LIST = 0x0005;
export const RECORD_TYPE_MAP = 0x0006;
export const RECORD_TYPE_MARKER = 0x0007;
const RECORD_TYPE_ARRAY = 0x0005;
const RECORD_TYPE_KEY_VALUE = 0x0006;
const RECORD_TYPE_MARKER = 0x0007;
export const RECORD_TYPE_PROPERTY = 0x0008;
const RECORD_TYPE_NULL= 0x0009;
const RECORD_FLAG_DISABLED = 0x0100;
export const RECORD_FLAG_IMPLICIT_RECEIVER = 0x0200;
export const RECORD_FLAG_COLLECTION = 0x0400;
/**
* For now we are dropping expression coalescence. We can always add it later, but
@ -112,7 +114,6 @@ export class Record {
this.dest = null;
this.previousValue = null;
this.currentValue = _fresh;
this.context = null;
this.funcOrValue = null;
@ -125,6 +126,11 @@ export class Record {
this._mode = protoRecord._mode;
// Return early for collections, further init delayed until updateContext()
if (this.isCollection) return;
this.currentValue = _fresh;
var type = this.type;
if (type === RECORD_TYPE_CONST) {
@ -150,15 +156,21 @@ export class Record {
}
}
get type() {
// todo(vicb): getter / setters are much slower than regular methods
// todo(vicb): update the whole code base
get type():int {
return this._mode & RECORD_TYPE_MASK;
}
get disabled() {
set type(value:int) {
this._mode = (this._mode & ~RECORD_TYPE_MASK) | value;
}
get disabled():boolean {
return (this._mode & RECORD_FLAG_DISABLED) === RECORD_FLAG_DISABLED;
}
set disabled(value) {
set disabled(value:boolean) {
if (value) {
this._mode |= RECORD_FLAG_DISABLED;
} else {
@ -166,23 +178,33 @@ export class Record {
}
}
get isImplicitReceiver() {
get isImplicitReceiver():boolean {
return (this._mode & RECORD_FLAG_IMPLICIT_RECEIVER) === RECORD_FLAG_IMPLICIT_RECEIVER;
}
static createMarker(rr:RecordRange) {
get isCollection():boolean {
return (this._mode & RECORD_FLAG_COLLECTION) === RECORD_FLAG_COLLECTION;
}
static createMarker(rr:RecordRange):Record {
return new Record(rr, null, null);
}
check():boolean {
this.previousValue = this.currentValue;
this.currentValue = this._calculateNewValue();
if (isSame(this.previousValue, this.currentValue)) return false;
this._updateDestination();
return true;
if (this.isCollection) {
var changed = this._checkCollection();
if (changed) {
this._notifyDispatcher();
return true;
}
return false;
} else {
this.previousValue = this.currentValue;
this.currentValue = this._calculateNewValue();
if (isSame(this.previousValue, this.currentValue)) return false;
this._updateDestination();
return true;
}
}
_updateDestination() {
@ -194,13 +216,38 @@ export class Record {
this.dest.updateContext(this.currentValue);
}
} else {
this.recordRange.dispatcher.onRecordChange(this, this.protoRecord.dest);
this._notifyDispatcher();
}
}
_notifyDispatcher() {
this.recordRange.dispatcher.onRecordChange(this, this.protoRecord.dest);
}
// return whether the content has changed
_checkCollection():boolean {
switch(this.type) {
case RECORD_TYPE_KEY_VALUE:
var kvChangeDetector:KeyValueChanges = this.currentValue;
return kvChangeDetector.check(this.context);
case RECORD_TYPE_ARRAY:
var arrayChangeDetector:ArrayChanges = this.currentValue;
return arrayChangeDetector.check(this.context);
case RECORD_TYPE_NULL:
// no need to check the content again unless the context changes
this.recordRange.disableRecord(this);
this.currentValue = null;
return true;
default:
throw new BaseException(`Unsupported record type (${this.type})`);
}
}
_calculateNewValue() {
var type = this.type;
switch (type) {
switch (this.type) {
case RECORD_TYPE_PROPERTY:
return this.funcOrValue(this.context);
@ -219,17 +266,8 @@ export class Record {
this.recordRange.disableRecord(this);
return this.funcOrValue;
case RECORD_TYPE_MARKER:
throw new BaseException('Marker not implemented');
case RECORD_TYPE_MAP:
throw new BaseException('Map not implemented');
case RECORD_TYPE_LIST:
throw new BaseException('List not implemented');
default:
throw new BaseException(`Unsupported record type ($type)`);
throw new BaseException(`Unsupported record type (${this.type})`);
}
}
@ -241,6 +279,34 @@ export class Record {
updateContext(value) {
this.context = value;
this.recordRange.enableRecord(this);
if (!this.isMarkerRecord) {
this.recordRange.enableRecord(this);
}
if (this.isCollection) {
if (ArrayChanges.supports(value)) {
if (this.type != RECORD_TYPE_ARRAY) {
this.type = RECORD_TYPE_ARRAY;
this.currentValue = new ArrayChanges();
}
return;
}
if (KeyValueChanges.supports(value)) {
if (this.type != RECORD_TYPE_KEY_VALUE) {
this.type = RECORD_TYPE_KEY_VALUE;
this.currentValue = new KeyValueChanges();
}
return;
}
if (isBlank(value)) {
this.type = RECORD_TYPE_NULL;
} else {
throw new BaseException("Collection records must be array like, map like or null");
}
}
}
get isMarkerRecord() {

View File

@ -1,6 +1,7 @@
import {
ProtoRecord,
Record,
RECORD_FLAG_COLLECTION,
RECORD_FLAG_IMPLICIT_RECEIVER,
RECORD_TYPE_CONST,
RECORD_TYPE_INVOKE_CLOSURE,
@ -13,10 +14,26 @@ import {
import {FIELD, IMPLEMENTS, isBlank, isPresent, int, toBool, autoConvertAdd, BaseException,
NumberWrapper} from 'facade/lang';
import {List, Map, ListWrapper, MapWrapper} from 'facade/collection';
import {AST, AccessMember, ImplicitReceiver, AstVisitor, LiteralPrimitive,
Binary, Formatter, MethodCall, FunctionCall, PrefixNot, Conditional,
LiteralArray, LiteralMap, KeyedAccess, Chain, Assignment} from './parser/ast';
import {ContextWithVariableBindings} from './parser/context_with_variable_bindings';
import {
AccessMember,
Assignment,
AST,
AstVisitor,
Binary,
Chain,
Collection,
Conditional,
Formatter,
FunctionCall,
ImplicitReceiver,
KeyedAccess,
LiteralArray,
LiteralMap,
LiteralPrimitive,
MethodCall,
PrefixNot
} from './parser/ast';
export class ProtoRecordRange {
headRecord:ProtoRecord;
@ -32,13 +49,16 @@ export class ProtoRecordRange {
* @param ast The expression to watch
* @param memento an opaque object which will be passed to WatchGroupDispatcher on
* detecting a change.
* @param shallow Should collections be shallow watched
* @param content Wether to watch collection content (true) or reference (false, default)
*/
addRecordsFromAST(ast:AST,
memento,
shallow = false)
content:boolean = false)
{
var creator = new ProtoRecordCreator(this);
if (content) {
ast = new Collection(ast);
}
creator.createRecordsFromAST(ast, memento);
this._addRecords(creator.headRecord, creator.tailRecord);
}
@ -436,6 +456,12 @@ class ProtoRecordCreator {
this.add(record);
}
visitCollection(ast: Collection, dest) {
var record = this.construct(RECORD_FLAG_COLLECTION, null, null, null, dest);
ast.value.visit(this, new Destination(record, null));
this.add(record);
}
visitConditional(ast:Conditional, dest) {
var record = this.construct(RECORD_TYPE_INVOKE_PURE_FUNCTION, _cond, 3, null, dest);
ast.condition.visit(this, new Destination(record, 0));

View File

@ -1,13 +1,11 @@
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';
import {ArrayChanges} from 'change_detection/array_changes';
import {NumberWrapper} from 'facade/lang';
import {ListWrapper, MapWrapper} from 'facade/collection';
import {TestIterable} from './iterable';
import {arrayChangesAsString} from './util';
// 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() {
@ -15,30 +13,62 @@ export function main() {
var l;
beforeEach(() => {
changes = new CollectionChanges();
changes = new ArrayChanges();
});
afterEach(() => {
changes = null;
});
it('should support list and iterables', () => {
expect(ArrayChanges.supports([])).toBeTruthy();
expect(ArrayChanges.supports(new TestIterable())).toBeTruthy();
expect(ArrayChanges.supports(MapWrapper.create())).toBeFalsy();
expect(ArrayChanges.supports(null)).toBeFalsy();
});
it('should support iterables', () => {
l = new TestIterable();
changes.check(l);
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: []
}));
l.list = [1];
changes.check(l);
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['1[null->0]'],
additions: ['1[null->0]']
}));
l.list = [2, 1];
changes.check(l);
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['2[null->0]', '1[0->1]'],
previous: ['1[0->1]'],
additions: ['2[null->0]'],
moves: ['1[0->1]']
}));
});
it('should detect additions', () => {
l = [];
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: []
}));
ListWrapper.push(l, 'a');
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['a[null->0]'],
additions: ['a[null->0]']
}));
ListWrapper.push(l, 'b');
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['a', 'b[null->1]'],
previous: ['a'],
additions: ['b[null->1]']
@ -51,7 +81,7 @@ export function main() {
l = [1, 0];
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['1[null->0]', '0[0->1]'],
previous: ['0[0->1]'],
additions: ['1[null->0]'],
@ -60,7 +90,7 @@ export function main() {
l = [2, 1, 0];
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['2[null->0]', '1[0->1]', '0[1->2]'],
previous: ['1[0->1]', '0[1->2]'],
additions: ['2[null->0]'],
@ -76,7 +106,7 @@ export function main() {
ListWrapper.push(l, 2);
ListWrapper.push(l, 1);
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['2[1->0]', '1[0->1]'],
previous: ['1[0->1]', '2[1->0]'],
moves: ['2[1->0]', '1[0->1]']
@ -90,7 +120,7 @@ export function main() {
ListWrapper.removeAt(l, 1);
ListWrapper.insert(l, 0, 'b');
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['b[1->0]', 'a[0->1]', 'c'],
previous: ['a[0->1]', 'b[1->0]', 'c'],
moves: ['b[1->0]', 'a[0->1]']
@ -99,7 +129,7 @@ export function main() {
ListWrapper.removeAt(l, 1);
ListWrapper.push(l, 'a');
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['b', 'c[2->1]', 'a[1->2]'],
previous: ['b', 'a[1->2]', 'c[2->1]'],
moves: ['c[2->1]', 'a[1->2]']
@ -112,14 +142,14 @@ export function main() {
ListWrapper.push(l, 'a');
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['a[null->0]'],
additions: ['a[null->0]']
}));
ListWrapper.push(l, 'b');
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['a', 'b[null->1]'],
previous: ['a'],
additions: ['b[null->1]']
@ -128,7 +158,7 @@ export function main() {
ListWrapper.push(l, 'c');
ListWrapper.push(l, 'd');
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['a', 'b', 'c[null->2]', 'd[null->3]'],
previous: ['a', 'b'],
additions: ['c[null->2]', 'd[null->3]']
@ -136,7 +166,7 @@ export function main() {
ListWrapper.removeAt(l, 2);
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['a', 'b', 'd[3->2]'],
previous: ['a', 'b', 'c[2->null]', 'd[3->2]'],
moves: ['d[3->2]'],
@ -149,7 +179,7 @@ export function main() {
ListWrapper.push(l, 'b');
ListWrapper.push(l, 'a');
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
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]'],
@ -165,7 +195,7 @@ export function main() {
var oo = 'oo';
ListWrapper.set(l, 1, b + oo);
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['a', 'boo'],
previous: ['a', 'boo']
}));
@ -175,7 +205,7 @@ export function main() {
l = [NumberWrapper.NaN];
changes.check(l);
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: [NumberWrapper.NaN],
previous: [NumberWrapper.NaN]
}));
@ -187,7 +217,7 @@ export function main() {
ListWrapper.insert(l, 0, 'foo');
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['foo[null->0]', 'NaN[0->1]', 'NaN[1->2]'],
previous: ['NaN[0->1]', 'NaN[1->2]'],
additions: ['foo[null->0]'],
@ -201,7 +231,7 @@ export function main() {
ListWrapper.removeAt(l, 1);
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['a', 'c[2->1]'],
previous: ['a', 'b[1->null]', 'c[2->1]'],
moves: ['c[2->1]'],
@ -210,7 +240,7 @@ export function main() {
ListWrapper.insert(l, 1, 'b');
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['a', 'b[null->1]', 'c[1->2]'],
previous: ['a', 'c[1->2]'],
additions: ['b[null->1]'],
@ -224,7 +254,7 @@ export function main() {
ListWrapper.removeAt(l, 0);
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
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]'],
@ -238,7 +268,7 @@ export function main() {
ListWrapper.insert(l, 0, 'b');
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
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]'],
@ -255,7 +285,7 @@ export function main() {
ListWrapper.push(l, 'a');
ListWrapper.push(l, 'c');
changes.check(l);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(arrayChangesAsString({
collection: ['b[1->0]', 'a[0->1]', 'c'],
previous: ['a[0->1]', 'b[1->0]', 'c'],
moves: ['b[1->0]', 'a[0->1]']
@ -265,16 +295,3 @@ export function main() {
});
}
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";
}

View File

@ -1,10 +1,11 @@
import {ddescribe, describe, it, iit, xit, expect} from 'test_lib/test_lib';
import {isPresent} from 'facade/lang';
import {isPresent, isBlank, isJsObject} from 'facade/lang';
import {List, ListWrapper, MapWrapper} from 'facade/collection';
import {ContextWithVariableBindings} from 'change_detection/parser/context_with_variable_bindings';
import {Parser} from 'change_detection/parser/parser';
import {Lexer} from 'change_detection/parser/lexer';
import {arrayChangesAsString, kvChangesAsString} from './util';
import {
ChangeDetector,
@ -22,9 +23,10 @@ export function main() {
return parser.parseBinding(exp).ast;
}
function createChangeDetector(memo:string, exp:string, context = null, formatters = null) {
function createChangeDetector(memo:string, exp:string, context = null, formatters = null,
content = false) {
var prr = new ProtoRecordRange();
prr.addRecordsFromAST(ast(exp), memo, false);
prr.addRecordsFromAST(ast(exp), memo, content);
var dispatcher = new LoggingDispatcher();
var rr = prr.instantiate(dispatcher, formatters);
@ -35,8 +37,9 @@ export function main() {
return {"changeDetector" : cd, "dispatcher" : dispatcher};
}
function executeWatch(memo:string, exp:string, context = null, formatters = null) {
var res = createChangeDetector(memo, exp, context, formatters);
function executeWatch(memo:string, exp:string, context = null, formatters = null,
content = false) {
var res = createChangeDetector(memo, exp, context, formatters, content);
res["changeDetector"].detectChanges();
return res["dispatcher"].log;
}
@ -194,10 +197,11 @@ export function main() {
});
});
describe("ContextWithVariableBindings", () => {
it('should read a field from ContextWithVariableBindings', () => {
var locals = new ContextWithVariableBindings(null,
MapWrapper.createFromPairs([["key", "value"]]));
MapWrapper.createFromPairs([["key", "value"]]));
expect(executeWatch('key', 'key', locals))
.toEqual(['key=value']);
@ -205,7 +209,7 @@ export function main() {
it('should handle nested ContextWithVariableBindings', () => {
var nested = new ContextWithVariableBindings(null,
MapWrapper.createFromPairs([["key", "value"]]));
MapWrapper.createFromPairs([["key", "value"]]));
var locals = new ContextWithVariableBindings(nested, MapWrapper.create());
expect(executeWatch('key', 'key', locals))
@ -213,14 +217,132 @@ export function main() {
});
it("should fall back to a regular field read when ContextWithVariableBindings " +
"does not have the requested field", () => {
"does not have the requested field", () => {
var locals = new ContextWithVariableBindings(new Person("Jim"),
MapWrapper.createFromPairs([["key", "value"]]));
MapWrapper.createFromPairs([["key", "value"]]));
expect(executeWatch('name', 'name', locals))
.toEqual(['name=Jim']);
});
});
describe("collections", () => {
it("should support null values", () => {
var context = new TestData(null);
var c = createChangeDetector('a', 'a', context, null, true);
var cd = c["changeDetector"];
var dsp = c["dispatcher"];
cd.detectChanges();
expect(dsp.log).toEqual(['a=null']);
dsp.clear();
cd.detectChanges();
expect(dsp.log).toEqual([]);
context.a = [0];
cd.detectChanges();
expect(dsp.log).toEqual(["a=" +
arrayChangesAsString({
collection: ['0[null->0]'],
additions: ['0[null->0]']
})
]);
dsp.clear();
context.a = null;
cd.detectChanges();
expect(dsp.log).toEqual(['a=null']);
});
it("should throw if not collection / null", () => {
var context = new TestData("not collection / null");
var c = createChangeDetector('a', 'a', context, null, true);
expect(() => c["changeDetector"].detectChanges())
.toThrowError("Collection records must be array like, map like or null");
});
describe("list", () => {
it("should support list changes", () => {
var context = new TestData([1, 2]);
expect(executeWatch("a", "a", context, null, true))
.toEqual(["a=" +
arrayChangesAsString({
collection: ['1[null->0]', '2[null->1]'],
additions: ['1[null->0]', '2[null->1]']
})]);
});
it("should handle reference changes", () => {
var context = new TestData([1, 2]);
var objs = createChangeDetector("a", "a", context, null, true);
var cd = objs["changeDetector"];
var dispatcher = objs["dispatcher"];
cd.detectChanges();
dispatcher.clear();
context.a = [2, 1];
cd.detectChanges();
expect(dispatcher.log).toEqual(["a=" +
arrayChangesAsString({
collection: ['2[1->0]', '1[0->1]'],
previous: ['1[0->1]', '2[1->0]'],
moves: ['2[1->0]', '1[0->1]']
})]);
});
});
describe("map", () => {
it("should support map changes", () => {
var map = MapWrapper.create();
MapWrapper.set(map, "foo", "bar");
var context = new TestData(map);
expect(executeWatch("a", "a", context, null, true))
.toEqual(["a=" +
kvChangesAsString({
map: ['foo[null->bar]'],
additions: ['foo[null->bar]']
})]);
});
it("should handle reference changes", () => {
var map = MapWrapper.create();
MapWrapper.set(map, "foo", "bar");
var context = new TestData(map);
var objs = createChangeDetector("a", "a", context, null, true);
var cd = objs["changeDetector"];
var dispatcher = objs["dispatcher"];
cd.detectChanges();
dispatcher.clear();
context.a = MapWrapper.create();
MapWrapper.set(context.a, "bar", "foo");
cd.detectChanges();
expect(dispatcher.log).toEqual(["a=" +
kvChangesAsString({
map: ['bar[null->foo]'],
previous: ['foo[bar->null]'],
additions: ['bar[null->foo]'],
removals: ['foo[bar->null]']
})]);
});
});
if (isJsObject({})) {
describe("js objects", () => {
it("should support object changes", () => {
var map = {"foo": "bar"};
var context = new TestData(map);
expect(executeWatch("a", "a", context, null, true))
.toEqual(["a=" +
kvChangesAsString({
map: ['foo[null->bar]'],
additions: ['foo[null->bar]']
})]);
});
});
}
});
});
});
}

View File

@ -0,0 +1,6 @@
import 'dart:collection';
class TestIterable extends IterableBase<int> {
List<int> list = [];
Iterator<int> get iterator => list.iterator;
}

View File

@ -0,0 +1,9 @@
export class TestIterable {
constructor() {
this.list = [];
}
[Symbol.iterator]() {
return this.list[Symbol.iterator]();
}
}

View File

@ -1,20 +1,18 @@
import {describe, it, iit, xit, expect, beforeEach, afterEach} from 'test_lib/test_lib';
import {MapChanges} from 'change_detection/map_changes';
import {isBlank, NumberWrapper} from 'facade/lang';
import {KeyValueChanges} from 'change_detection/keyvalue_changes';
import {NumberWrapper, isJsObject} from 'facade/lang';
import {MapWrapper} from 'facade/collection';
import {kvChangesAsString} from './util';
// todo(vicb): Update the code & tests for object equality
export function main() {
describe('map_changes', function() {
describe('MapChanges', function() {
describe('keyvalue_changes', function() {
describe('KeyValueChanges', function() {
var changes;
var m;
beforeEach(() => {
changes = new MapChanges();
changes = new KeyValueChanges();
m = MapWrapper.create();
});
@ -27,14 +25,14 @@ export function main() {
MapWrapper.set(m, 'a', 1);
changes.check(m);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(kvChangesAsString({
map: ['a[null->1]'],
additions: ['a[null->1]']
}));
MapWrapper.set(m, 'b', 2);
changes.check(m);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(kvChangesAsString({
map: ['a', 'b[null->2]'],
previous: ['a'],
additions: ['b[null->2]']
@ -49,7 +47,7 @@ export function main() {
MapWrapper.set(m, 2, 10);
MapWrapper.set(m, 1, 20);
changes.check(m);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(kvChangesAsString({
map: ['1[10->20]', '2[20->10]'],
previous: ['1[10->20]', '2[20->10]'],
changes: ['1[10->20]', '2[20->10]']
@ -61,14 +59,14 @@ export function main() {
MapWrapper.set(m, 'a', 'A');
changes.check(m);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(kvChangesAsString({
map: ['a[null->A]'],
additions: ['a[null->A]']
}));
MapWrapper.set(m, 'b', 'B');
changes.check(m);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(kvChangesAsString({
map: ['a', 'b[null->B]'],
previous: ['a'],
additions: ['b[null->B]']
@ -77,7 +75,7 @@ export function main() {
MapWrapper.set(m, 'b', 'BB');
MapWrapper.set(m, 'd', 'D');
changes.check(m);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(kvChangesAsString({
map: ['a', 'b[B->BB]', 'd[null->D]'],
previous: ['a', 'b[B->BB]'],
additions: ['d[null->D]'],
@ -86,7 +84,7 @@ export function main() {
MapWrapper.delete(m, 'b');
changes.check(m);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(kvChangesAsString({
map: ['a', 'd'],
previous: ['a', 'b[BB->null]', 'd'],
removals: ['b[BB->null]']
@ -94,7 +92,7 @@ export function main() {
MapWrapper.clear(m);
changes.check(m);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(kvChangesAsString({
previous: ['a[A->null]', 'd[D->null]'],
removals: ['a[A->null]', 'd[D->null]']
}));
@ -112,7 +110,7 @@ export function main() {
MapWrapper.set(m, f + oo, b + ar);
changes.check(m);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(kvChangesAsString({
map: ['foo'],
previous: ['foo']
}));
@ -123,25 +121,70 @@ export function main() {
changes.check(m);
changes.check(m);
expect(changes.toString()).toEqual(changesAsString({
expect(changes.toString()).toEqual(kvChangesAsString({
map: ['foo'],
previous: ['foo']
}));
});
// JS specific tests (JS Objects)
if (isJsObject({})) {
describe('JsObject changes', () => {
it('should support JS Object', () => {
expect(KeyValueChanges.supports({})).toBeTruthy();
expect(KeyValueChanges.supports("not supported")).toBeFalsy();
expect(KeyValueChanges.supports(0)).toBeFalsy();
expect(KeyValueChanges.supports(null)).toBeFalsy();
});
it('should do basic object watching', () => {
m = {};
changes.check(m);
m['a'] = 'A';
changes.check(m);
expect(changes.toString()).toEqual(kvChangesAsString({
map: ['a[null->A]'],
additions: ['a[null->A]']
}));
m['b'] = 'B';
changes.check(m);
expect(changes.toString()).toEqual(kvChangesAsString({
map: ['a', 'b[null->B]'],
previous: ['a'],
additions: ['b[null->B]']
}));
m['b'] = 'BB';
m['d'] = 'D';
changes.check(m);
expect(changes.toString()).toEqual(kvChangesAsString({
map: ['a', 'b[B->BB]', 'd[null->D]'],
previous: ['a', 'b[B->BB]'],
additions: ['d[null->D]'],
changes: ['b[B->BB]']
}));
m = {};
m['a'] = 'A';
m['d'] = 'D';
changes.check(m);
expect(changes.toString()).toEqual(kvChangesAsString({
map: ['a', 'd'],
previous: ['a', 'b[BB->null]', 'd'],
removals: ['b[BB->null]']
}));
m = {};
changes.check(m);
expect(changes.toString()).toEqual(kvChangesAsString({
previous: ['a[A->null]', 'd[D->null]'],
removals: ['a[A->null]', 'd[D->null]']
}));
});
});
}
});
});
}
function changesAsString({map, previous, additions, changes, removals}) {
if (isBlank(map)) map = [];
if (isBlank(previous)) previous = [];
if (isBlank(additions)) additions = [];
if (isBlank(changes)) changes = [];
if (isBlank(removals)) removals = [];
return "map: " + map.join(', ') + "\n" +
"previous: " + previous.join(', ') + "\n" +
"additions: " + additions.join(', ') + "\n" +
"changes: " + changes.join(', ') + "\n" +
"removals: " + removals.join(', ') + "\n";
}

View File

@ -0,0 +1,29 @@
import {isBlank} from 'facade/lang';
export function arrayChangesAsString({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";
}
export function kvChangesAsString({map, previous, additions, changes, removals}) {
if (isBlank(map)) map = [];
if (isBlank(previous)) previous = [];
if (isBlank(additions)) additions = [];
if (isBlank(changes)) changes = [];
if (isBlank(removals)) removals = [];
return "map: " + map.join(', ') + "\n" +
"previous: " + previous.join(', ') + "\n" +
"additions: " + additions.join(', ') + "\n" +
"changes: " + changes.join(', ') + "\n" +
"removals: " + removals.join(', ') + "\n";
}

View File

@ -51,7 +51,7 @@ class ListWrapper {
static filter(List list, fn) => list.where(fn).toList();
static find(List list, fn) => list.firstWhere(fn, orElse:() => null);
static any(List list, fn) => list.any(fn);
static forEach(list, fn) {
static forEach(list, Function fn) {
list.forEach(fn);
}
static reduce(List list, Function fn, init) {
@ -68,6 +68,15 @@ class ListWrapper {
static void clear(List l) { l.clear(); }
}
bool isListLikeIterable(obj) => obj is Iterable;
void iterateListLike(iter, Function fn) {
assert(iter is Iterable);
for (var item in iter) {
fn (item);
}
}
class SetWrapper {
static Set createFromList(List l) { return new Set.from(l); }
static bool has(Set s, key) { return s.contains(key); }

View File

@ -1,4 +1,4 @@
import {int} from 'facade/lang';
import {int, isJsObject} from 'facade/lang';
export var List = window.Array;
export var Map = window.Map;
@ -25,12 +25,17 @@ export class MapWrapper {
static clear(m) { m.clear(); }
}
// TODO: cannot export StringMap as a type as Dart does not support
// renaming types...
// TODO: cannot export StringMap as a type as Dart does not support renaming types...
/**
* Wraps Javascript Objects
*/
export class StringMapWrapper {
// Note: We are not using Object.create(null) here due to
// performance!
static create():Object { return { }; }
static create():Object {
// Note: We are not using Object.create(null) here due to
// performance!
// http://jsperf.com/ng2-object-create-null
return { };
}
static get(map, key) {
return map.hasOwnProperty(key) ? map[key] : undefined;
}
@ -45,7 +50,9 @@ export class StringMapWrapper {
}
static forEach(map, callback) {
for (var prop in map) {
callback(map[prop], prop);
if (map.hasOwnProperty(prop)) {
callback(map[prop], prop);
}
}
}
}
@ -117,6 +124,19 @@ export class ListWrapper {
}
}
export function isListLikeIterable(obj):boolean {
if (!isJsObject(obj)) return false;
return ListWrapper.isList(obj) ||
(!(obj instanceof Map) && // JS Map are iterables but return entries as [k, v]
Symbol.iterator in obj); // JS Iterable have a Symbol.iterator prop
}
export function iterateListLike(obj, fn:Function) {
for (var item of obj) {
fn(item);
}
}
export class SetWrapper {
static createFromList(lst:List) { return new Set(lst); }
static has(s:Set, key):boolean { return s.has(key); }

View File

@ -161,6 +161,11 @@ dynamic getMapKey(value) {
return value.isNaN ? _NAN_KEY : value;
}
normalizeBlank(obj) {
dynamic normalizeBlank(obj) {
return isBlank(obj) ? null : obj;
}
bool isJsObject(o) {
return false;
}

View File

@ -202,3 +202,7 @@ export function getMapKey(value) {
export function normalizeBlank(obj) {
return isBlank(obj) ? null : obj;
}
export function isJsObject(o):boolean {
return o !== null && (typeof o === "function" || typeof o === "object");
}