Files
polymer/components/observe-js/tests/test.js
2014-11-17 11:10:54 -08:00

2111 lines
46 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
* This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
* The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
* The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
* Code distributed by Google as part of the polymer project is also
* subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
*/
var observer;
var callbackArgs = undefined;
var callbackInvoked = false;
function then(fn) {
setTimeout(function() {
Platform.performMicrotaskCheckpoint();
fn();
}, 0);
return {
then: function(next) {
return then(next);
}
};
}
function noop() {}
function callback() {
callbackArgs = Array.prototype.slice.apply(arguments);
callbackInvoked = true;
}
function doSetup() {}
function doTeardown() {
callbackInvoked = false;
callbackArgs = undefined;
}
function assertNoChanges() {
if (observer)
observer.deliver();
assert.isFalse(callbackInvoked);
assert.isUndefined(callbackArgs);
}
function assertPathChanges(expectNewValue, expectOldValue, dontDeliver) {
if (!dontDeliver)
observer.deliver();
assert.isTrue(callbackInvoked);
var newValue = callbackArgs[0];
var oldValue = callbackArgs[1];
assert.deepEqual(expectNewValue, newValue);
assert.deepEqual(expectOldValue, oldValue);
if (!dontDeliver) {
assert.isTrue(window.dirtyCheckCycleCount === undefined ||
window.dirtyCheckCycleCount === 1);
}
callbackArgs = undefined;
callbackInvoked = false;
}
function assertCompoundPathChanges(expectNewValues, expectOldValues,
expectObserved, dontDeliver) {
if (!dontDeliver)
observer.deliver();
assert.isTrue(callbackInvoked);
var newValues = callbackArgs[0];
var oldValues = callbackArgs[1];
var observed = callbackArgs[2];
assert.deepEqual(expectNewValues, newValues);
assert.deepEqual(expectOldValues, oldValues);
assert.deepEqual(expectObserved, observed);
if (!dontDeliver) {
assert.isTrue(window.dirtyCheckCycleCount === undefined ||
window.dirtyCheckCycleCount === 1);
}
callbackArgs = undefined;
callbackInvoked = false;
}
var createObject = ('__proto__' in {}) ?
function(obj) { return obj; } :
function(obj) {
var proto = obj.__proto__;
if (!proto)
return obj;
var newObject = Object.create(proto);
Object.getOwnPropertyNames(obj).forEach(function(name) {
Object.defineProperty(newObject, name,
Object.getOwnPropertyDescriptor(obj, name));
});
return newObject;
};
function assertPath(pathString, expectKeys, expectSerialized) {
var path = Path.get(pathString);
if (!expectKeys) {
assert.isFalse(path.valid);
return;
}
assert.deepEqual(Array.prototype.slice.apply(path), expectKeys);
assert.strictEqual(path.toString(), expectSerialized);
}
function assertInvalidPath(pathString) {
assertPath(pathString);
}
suite('Path', function() {
test('constructor throws', function() {
assert.throws(function() {
new Path('foo')
});
});
test('path validity', function() {
// invalid path get value is always undefined
var p = Path.get('a b');
assert.isFalse(p.valid);
assert.isUndefined(p.getValueFrom({ a: { b: 2 }}));
assertPath('', [], '');
assertPath(' ', [], '');
assertPath(null, [], '');
assertPath(undefined, [], '');
assertPath('a', ['a'], 'a');
assertPath('a.b', ['a', 'b'], 'a.b');
assertPath('a. b', ['a', 'b'], 'a.b');
assertPath('a .b', ['a', 'b'], 'a.b');
assertPath('a . b', ['a', 'b'], 'a.b');
assertPath(' a . b ', ['a', 'b'], 'a.b');
assertPath('a[0]', ['a', '0'], 'a[0]');
assertPath('a [0]', ['a', '0'], 'a[0]');
assertPath('a[0][1]', ['a', '0', '1'], 'a[0][1]');
assertPath('a [ 0 ] [ 1 ] ', ['a', '0', '1'], 'a[0][1]');
assertPath('[1234567890] ', ['1234567890'], '[1234567890]');
assertPath(' [1234567890] ', ['1234567890'], '[1234567890]');
assertPath('opt0', ['opt0'], 'opt0');
assertPath('$foo.$bar._baz', ['$foo', '$bar', '_baz'], '$foo.$bar._baz');
assertPath('foo["baz"]', ['foo', 'baz'], 'foo.baz');
assertPath('foo["b\\"az"]', ['foo', 'b"az'], 'foo["b\\"az"]');
assertPath("foo['b\\'az']", ['foo', "b'az"], 'foo["b\'az"]');
assertPath(['a', 'b'], ['a', 'b'], 'a.b');
assertPath([''], [''], '[""]');
function Foo(val) { this.val = val; }
Foo.prototype.toString = function() { return 'Foo' + this.val; };
assertPath([new Foo('a'), new Foo('b')], ['Fooa', 'Foob'], 'Fooa.Foob');
assertInvalidPath('.');
assertInvalidPath(' . ');
assertInvalidPath('..');
assertInvalidPath('a[4');
assertInvalidPath('a.b.');
assertInvalidPath('a,b');
assertInvalidPath('a["foo]');
assertInvalidPath('[0x04]');
assertInvalidPath('[0foo]');
assertInvalidPath('[foo-bar]');
assertInvalidPath('foo-bar');
assertInvalidPath('42');
assertInvalidPath('a[04]');
assertInvalidPath(' a [ 04 ]');
assertInvalidPath(' 42 ');
assertInvalidPath('foo["bar]');
assertInvalidPath("foo['bar]");
});
test('Paths are interned', function() {
var p = Path.get('foo.bar');
var p2 = Path.get('foo.bar');
assert.strictEqual(p, p2);
var p3 = Path.get('');
var p4 = Path.get('');
assert.strictEqual(p3, p4);
});
test('null is empty path', function() {
assert.strictEqual(Path.get(''), Path.get(null));
});
test('undefined is empty path', function() {
assert.strictEqual(Path.get(undefined), Path.get(null));
});
test('Path.getValueFrom', function() {
var obj = {
a: {
b: {
c: 1
}
}
};
var p1 = Path.get('a');
var p2 = Path.get('a.b');
var p3 = Path.get('a.b.c');
assert.strictEqual(obj.a, p1.getValueFrom(obj));
assert.strictEqual(obj.a.b, p2.getValueFrom(obj));
assert.strictEqual(1, p3.getValueFrom(obj));
obj.a.b.c = 2;
assert.strictEqual(2, p3.getValueFrom(obj));
obj.a.b = {
c: 3
};
assert.strictEqual(3, p3.getValueFrom(obj));
obj.a = {
b: 4
};
assert.strictEqual(undefined, p3.getValueFrom(obj));
assert.strictEqual(4, p2.getValueFrom(obj));
});
test('Path.setValueFrom', function() {
var obj = {};
var p2 = Path.get('bar');
Path.get('foo').setValueFrom(obj, 3);
assert.equal(3, obj.foo);
var bar = { baz: 3 };
Path.get('bar').setValueFrom(obj, bar);
assert.equal(bar, obj.bar);
var p = Path.get('bar.baz.bat');
p.setValueFrom(obj, 'not here');
assert.equal(undefined, p.getValueFrom(obj));
});
test('Degenerate Values', function() {
var emptyPath = Path.get();
var foo = {};
assert.equal(null, emptyPath.getValueFrom(null));
assert.equal(foo, emptyPath.getValueFrom(foo));
assert.equal(3, emptyPath.getValueFrom(3));
assert.equal(undefined, Path.get('a').getValueFrom(undefined));
});
});
suite('Basic Tests', function() {
test('Exception Doesnt Stop Notification', function() {
var model = [1];
var count = 0;
var observer1 = new ObjectObserver(model);
observer1.open(function() {
count++;
throw 'ouch';
});
var observer2 = new PathObserver(model, '[0]');
observer2.open(function() {
count++;
throw 'ouch';
});
var observer3 = new ArrayObserver(model);
observer3.open(function() {
count++;
throw 'ouch';
});
model[0] = 2;
model[1] = 2;
observer1.deliver();
observer2.deliver();
observer3.deliver();
assert.equal(3, count);
observer1.close();
observer2.close();
observer3.close();
});
test('Can only open once', function() {
observer = new PathObserver({ id: 1 }, 'id');
observer.open(callback);
assert.throws(function() {
observer.open(callback);
});
observer.close();
observer = new CompoundObserver();
observer.open(callback);
assert.throws(function() {
observer.open(callback);
});
observer.close();
observer = new ObjectObserver({}, 'id');
observer.open(callback);
assert.throws(function() {
observer.open(callback);
});
observer.close();
observer = new ArrayObserver([], 'id');
observer.open(callback);
assert.throws(function() {
observer.open(callback);
});
observer.close();
});
test('No Object.observe performMicrotaskCheckpoint', function() {
if (typeof Object.observe == 'function')
return;
var model = [1];
var count = 0;
var observer1 = new ObjectObserver(model);
observer1.open(function() {
count++;
});
var observer2 = new PathObserver(model, '[0]');
observer2.open(function() {
count++;
});
var observer3 = new ArrayObserver(model);
observer3.open(function() {
count++;
});
model[0] = 2;
model[1] = 2;
Platform.performMicrotaskCheckpoint();
assert.equal(3, count);
observer1.close();
observer2.close();
observer3.close();
});
});
suite('ObserverTransform', function() {
test('Close Invokes Close', function() {
var count = 0;
var observer = {
open: function() {},
close: function() { count++; }
};
var observer = new ObserverTransform(observer);
observer.open();
observer.close();
assert.strictEqual(1, count);
});
test('valueFn/setValueFn', function() {
var obj = { foo: 1 };
function valueFn(value) { return value * 2; }
function setValueFn(value) { return value / 2; }
observer = new ObserverTransform(new PathObserver(obj, 'foo'),
valueFn,
setValueFn);
observer.open(callback);
obj.foo = 2;
assert.strictEqual(4, observer.discardChanges());
assertNoChanges();
observer.setValue(2);
assert.strictEqual(obj.foo, 1);
assertPathChanges(2, 4);
obj.foo = 10;
assertPathChanges(20, 2);
observer.close();
});
test('valueFn - object literal', function() {
var model = {};
function valueFn(value) {
return [ value ];
}
observer = new ObserverTransform(new PathObserver(model, 'foo'), valueFn);
observer.open(callback);
model.foo = 1;
assertPathChanges([1], [undefined]);
model.foo = 3;
assertPathChanges([3], [1]);
observer.close();
});
test('CompoundObserver - valueFn reduction', function() {
var model = { a: 1, b: 2, c: 3 };
function valueFn(values) {
return values.reduce(function(last, cur) {
return typeof cur === 'number' ? last + cur : undefined;
}, 0);
}
var compound = new CompoundObserver();
compound.addPath(model, 'a');
compound.addPath(model, 'b');
compound.addPath(model, Path.get('c'));
observer = new ObserverTransform(compound, valueFn);
assert.strictEqual(6, observer.open(callback));
model.a = -10;
model.b = 20;
model.c = 30;
assertPathChanges(40, 6);
observer.close();
});
})
suite('PathObserver Tests', function() {
setup(doSetup);
teardown(doTeardown);
test('Callback args', function() {
var obj = {
foo: 'bar'
};
var path = Path.get('foo');
var observer = new PathObserver(obj, path);
var args;
observer.open(function() {
args = Array.prototype.slice.apply(arguments);
});
obj.foo = 'baz';
observer.deliver();
assert.strictEqual(args.length, 3);
assert.strictEqual(args[0], 'baz');
assert.strictEqual(args[1], 'bar');
assert.strictEqual(args[2], observer);
assert.strictEqual(args[2].path, path);
observer.close();
});
test('PathObserver.path', function() {
var obj = {
foo: 'bar'
};
var path = Path.get('foo');
var observer = new PathObserver(obj, 'foo');
assert.strictEqual(observer.path, Path.get('foo'));
});
test('invalid', function() {
var observer = new PathObserver({ a: { b: 1 }} , 'a b');
observer.open(callback);
assert.strictEqual(undefined, observer.value);
observer.deliver();
assert.isFalse(callbackInvoked);
});
test('Optional target for callback', function() {
var target = {
changed: function(value, oldValue) {
this.called = true;
}
};
var obj = { foo: 1 };
var observer = new PathObserver(obj, 'foo');
observer.open(target.changed, target);
obj.foo = 2;
observer.deliver();
assert.isTrue(target.called);
observer.close();
});
test('Delivery Until No Changes', function() {
var obj = { foo: { bar: 5 }};
var callbackCount = 0;
var observer = new PathObserver(obj, 'foo . bar');
observer.open(function() {
callbackCount++;
if (!obj.foo.bar)
return;
obj.foo.bar--;
});
obj.foo.bar--;
observer.deliver();
assert.equal(5, callbackCount);
observer.close();
});
test('Path disconnect', function() {
var arr = {};
arr.foo = 'bar';
observer = new PathObserver(arr, 'foo');
observer.open(callback);
arr.foo = 'baz';
assertPathChanges('baz', 'bar');
arr.foo = 'bar';
observer.close();
arr.foo = 'boo';
assertNoChanges();
});
test('Path discardChanges', function() {
var arr = {};
arr.foo = 'bar';
observer = new PathObserver(arr, 'foo');
observer.open(callback);
arr.foo = 'baz';
assertPathChanges('baz', 'bar');
arr.foo = 'bat';
observer.discardChanges();
assertNoChanges();
arr.foo = 'bag';
assertPathChanges('bag', 'bat');
observer.close();
});
test('Path setValue', function() {
var obj = {};
obj.foo = 'bar';
observer = new PathObserver(obj, 'foo');
observer.open(callback);
obj.foo = 'baz';
observer.setValue('bat');
assert.strictEqual(obj.foo, 'bat');
assertPathChanges('bat', 'bar');
observer.setValue('bot');
observer.discardChanges();
assertNoChanges();
observer.close();
});
test('Degenerate Values', function() {
var emptyPath = Path.get();
observer = new PathObserver(null, '');
observer.open(callback);
assert.equal(null, observer.value);
observer.close();
var foo = {};
observer = new PathObserver(foo, '');
assert.equal(foo, observer.open(callback));
observer.close();
observer = new PathObserver(3, '');
assert.equal(3, observer.open(callback));
observer.close();
observer = new PathObserver(undefined, 'a');
assert.equal(undefined, observer.open(callback));
observer.close();
var bar = { id: 23 };
observer = new PathObserver(undefined, 'a/3!');
assert.equal(undefined, observer.open(callback));
observer.close();
});
test('Path NaN', function() {
var foo = { val: 1 };
observer = new PathObserver(foo, 'val');
observer.open(callback);
foo.val = 0/0;
// Can't use assertSummary because deepEqual() will fail with NaN
observer.deliver();
assert.isTrue(callbackInvoked);
assert.isTrue(isNaN(callbackArgs[0]));
assert.strictEqual(1, callbackArgs[1]);
observer.close();
});
test('Path Set Value Back To Same', function() {
var obj = {};
var path = Path.get('foo');
path.setValueFrom(obj, 3);
assert.equal(3, obj.foo);
observer = new PathObserver(obj, 'foo');
assert.equal(3, observer.open(callback));
path.setValueFrom(obj, 2);
assert.equal(2, observer.discardChanges());
path.setValueFrom(obj, 3);
assert.equal(3, observer.discardChanges());
assertNoChanges();
observer.close();
});
test('Path Triple Equals', function() {
var model = { };
observer = new PathObserver(model, 'foo');
observer.open(callback);
model.foo = null;
assertPathChanges(null, undefined);
model.foo = undefined;
assertPathChanges(undefined, null);
observer.close();
});
test('Path Simple', function() {
var model = { };
observer = new PathObserver(model, 'foo');
observer.open(callback);
model.foo = 1;
assertPathChanges(1, undefined);
model.foo = 2;
assertPathChanges(2, 1);
delete model.foo;
assertPathChanges(undefined, 2);
observer.close();
});
test('Path Simple - path object', function() {
var model = { };
var path = Path.get('foo');
observer = new PathObserver(model, path);
observer.open(callback);
model.foo = 1;
assertPathChanges(1, undefined);
model.foo = 2;
assertPathChanges(2, 1);
delete model.foo;
assertPathChanges(undefined, 2);
observer.close();
});
test('Path - root is initially null', function(done) {
var model = { };
var path = Path.get('foo');
observer = new PathObserver(model, 'foo.bar');
observer.open(callback);
model.foo = { };
then(function() {
model.foo.bar = 1;
}).then(function() {
assertPathChanges(1, undefined, true);
observer.close();
done();
});
});
test('Path With Indices', function() {
var model = [];
observer = new PathObserver(model, '[0]');
observer.open(callback);
model.push(1);
assertPathChanges(1, undefined);
observer.close();
});
test('Path Observation', function() {
var model = {
a: {
b: {
c: 'hello, world'
}
}
};
observer = new PathObserver(model, 'a.b.c');
observer.open(callback);
model.a.b.c = 'hello, mom';
assertPathChanges('hello, mom', 'hello, world');
model.a.b = {
c: 'hello, dad'
};
assertPathChanges('hello, dad', 'hello, mom');
model.a = {
b: {
c: 'hello, you'
}
};
assertPathChanges('hello, you', 'hello, dad');
model.a.b = 1;
assertPathChanges(undefined, 'hello, you');
// Stop observing
observer.close();
model.a.b = {c: 'hello, back again -- but not observing'};
assertNoChanges();
// Resume observing
observer = new PathObserver(model, 'a.b.c');
observer.open(callback);
model.a.b.c = 'hello. Back for reals';
assertPathChanges('hello. Back for reals',
'hello, back again -- but not observing');
observer.close();
});
test('Path Set To Same As Prototype', function() {
var model = createObject({
__proto__: {
id: 1
}
});
observer = new PathObserver(model, 'id');
observer.open(callback);
model.id = 1;
assertNoChanges();
observer.close();
});
test('Path Set Read Only', function() {
var model = {};
Object.defineProperty(model, 'x', {
configurable: true,
writable: false,
value: 1
});
observer = new PathObserver(model, 'x');
observer.open(callback);
model.x = 2;
assertNoChanges();
observer.close();
});
test('Path Set Shadows', function() {
var model = createObject({
__proto__: {
x: 1
}
});
observer = new PathObserver(model, 'x');
observer.open(callback);
model.x = 2;
assertPathChanges(2, 1);
observer.close();
});
test('Delete With Same Value On Prototype', function() {
var model = createObject({
__proto__: {
x: 1,
},
x: 1
});
observer = new PathObserver(model, 'x');
observer.open(callback);
delete model.x;
assertNoChanges();
observer.close();
});
test('Delete With Different Value On Prototype', function() {
var model = createObject({
__proto__: {
x: 1,
},
x: 2
});
observer = new PathObserver(model, 'x');
observer.open(callback);
delete model.x;
assertPathChanges(1, 2);
observer.close();
});
test('Value Change On Prototype', function() {
var proto = {
x: 1
}
var model = createObject({
__proto__: proto
});
observer = new PathObserver(model, 'x');
observer.open(callback);
model.x = 2;
assertPathChanges(2, 1);
delete model.x;
assertPathChanges(1, 2);
proto.x = 3;
assertPathChanges(3, 1);
observer.close();
});
// FIXME: Need test of observing change on proto.
test('Delete Of Non Configurable', function() {
var model = {};
Object.defineProperty(model, 'x', {
configurable: false,
value: 1
});
observer = new PathObserver(model, 'x');
observer.open(callback);
delete model.x;
assertNoChanges();
observer.close();
});
test('Notify', function() {
if (typeof Object.getNotifier !== 'function')
return;
var model = {
a: {}
}
var _b = 2;
Object.defineProperty(model.a, 'b', {
get: function() { return _b; },
set: function(b) {
Object.getNotifier(this).notify({
type: 'update',
name: 'b',
oldValue: _b
});
_b = b;
}
});
observer = new PathObserver(model, 'a.b');
observer.open(callback);
_b = 3;
assertPathChanges(3, 2);
model.a.b = 4; // will be observed.
assertPathChanges(4, 3);
observer.close();
});
test('issue-161', function(done) {
var model = { model: 'model' };
var ob1 = new PathObserver(model, 'obj.bar');
var called = false
ob1.open(function() {
called = true;
});
var obj2 = new PathObserver(model, 'obj');
obj2.open(function() {
model.obj.bar = true;
});
model.obj = { 'obj': 'obj' };
model.obj.foo = true;
then(function() {
assert.strictEqual(called, true);
done();
});
});
test('object cycle', function(done) {
var model = { a: {}, c: 1 };
model.a.b = model;
var called = 0;
new PathObserver(model, 'a.b.c').open(function() {
called++;
});
// This change should be detected, even though it's a change to the root
// object and isn't a change to `a`.
model.c = 42;
then(function() {
assert.equal(called, 1);
done();
});
});
});
suite('CompoundObserver Tests', function() {
setup(doSetup);
teardown(doTeardown);
test('Simple', function() {
var model = { a: 1, b: 2, c: 3 };
observer = new CompoundObserver();
observer.addPath(model, 'a');
observer.addPath(model, 'b');
observer.addPath(model, Path.get('c'));
observer.open(callback);
assertNoChanges();
var observerCallbackArg = [model, Path.get('a'),
model, Path.get('b'),
model, Path.get('c')];
model.a = -10;
model.b = 20;
model.c = 30;
assertCompoundPathChanges([-10, 20, 30], [1, 2, 3],
observerCallbackArg);
model.a = 'a';
model.c = 'c';
assertCompoundPathChanges(['a', 20, 'c'], [-10,, 30],
observerCallbackArg);
model.a = 2;
model.b = 3;
model.c = 4;
assertCompoundPathChanges([2, 3, 4], ['a', 20, 'c'],
observerCallbackArg);
model.a = 'z';
model.b = 'y';
model.c = 'x';
assert.deepEqual(['z', 'y', 'x'], observer.discardChanges());
assertNoChanges();
assert.strictEqual('z', model.a);
assert.strictEqual('y', model.b);
assert.strictEqual('x', model.c);
assertNoChanges();
observer.close();
});
test('reportChangesOnOpen', function() {
var model = { a: 1, b: 2, c: 3 };
observer = new CompoundObserver(true);
observer.addPath(model, 'a');
observer.addPath(model, 'b');
observer.addPath(model, Path.get('c'));
model.a = -10;
model.b = 20;
observer.open(callback);
var observerCallbackArg = [model, Path.get('a'),
model, Path.get('b'),
model, Path.get('c')];
assertCompoundPathChanges([-10, 20, 3], [1, 2, ],
observerCallbackArg, true);
observer.close();
});
test('All Observers', function() {
function ident(value) { return value; }
var model = { a: 1, b: 2, c: 3 };
observer = new CompoundObserver();
var pathObserver1 = new PathObserver(model, 'a');
var pathObserver2 = new PathObserver(model, 'b');
var pathObserver3 = new PathObserver(model, Path.get('c'));
observer.addObserver(pathObserver1);
observer.addObserver(pathObserver2);
observer.addObserver(pathObserver3);
observer.open(callback);
var observerCallbackArg = [Observer.observerSentinel_, pathObserver1,
Observer.observerSentinel_, pathObserver2,
Observer.observerSentinel_, pathObserver3];
model.a = -10;
model.b = 20;
model.c = 30;
assertCompoundPathChanges([-10, 20, 30], [1, 2, 3],
observerCallbackArg);
model.a = 'a';
model.c = 'c';
assertCompoundPathChanges(['a', 20, 'c'], [-10,, 30],
observerCallbackArg);
observer.close();
});
test('Degenerate Values', function() {
var model = {};
observer = new CompoundObserver();
observer.addPath({}, '.'); // invalid path
observer.addPath('obj-value', ''); // empty path
observer.addPath({}, 'foo'); // unreachable
observer.addPath(3, 'bar'); // non-object with non-empty path
var values = observer.open(callback);
assert.strictEqual(4, values.length);
assert.strictEqual(undefined, values[0]);
assert.strictEqual('obj-value', values[1]);
assert.strictEqual(undefined, values[2]);
assert.strictEqual(undefined, values[3]);
observer.close();
});
test('valueFn - return object literal', function() {
var model = { a: 1};
function valueFn(values) {
return {};
}
observer = new CompoundObserver(valueFn);
observer.addPath(model, 'a');
observer.open(callback);
model.a = 2;
observer.deliver();
assert.isTrue(window.dirtyCheckCycleCount === undefined ||
window.dirtyCheckCycleCount === 1);
observer.close();
});
test('reset', function() {
var model = { a: 1, b: 2, c: 3 };
var callCount = 0;
function callback() {
callCount++;
}
observer = new CompoundObserver();
observer.addPath(model, 'a');
observer.addPath(model, 'b');
assert.deepEqual([1, 2], observer.open(callback));
model.a = 2;
observer.deliver();
assert.strictEqual(1, callCount);
model.b = 3;
observer.deliver();
assert.strictEqual(2, callCount);
model.c = 4;
observer.deliver();
assert.strictEqual(2, callCount);
observer.startReset();
observer.addPath(model, 'b');
observer.addPath(model, 'c');
assert.deepEqual([3, 4], observer.finishReset())
model.a = 3;
observer.deliver();
assert.strictEqual(2, callCount);
model.b = 4;
observer.deliver();
assert.strictEqual(3, callCount);
model.c = 5;
observer.deliver();
assert.strictEqual(4, callCount);
observer.close();
});
test('Heterogeneous', function() {
var model = { a: 1, b: 2 };
var otherModel = { c: 3 };
function valueFn(value) { return value * 2; }
function setValueFn(value) { return value / 2; }
var compound = new CompoundObserver;
compound.addPath(model, 'a');
compound.addObserver(new ObserverTransform(new PathObserver(model, 'b'),
valueFn, setValueFn));
compound.addObserver(new PathObserver(otherModel, 'c'));
function combine(values) {
return values[0] + values[1] + values[2];
};
observer = new ObserverTransform(compound, combine);
assert.strictEqual(8, observer.open(callback));
model.a = 2;
model.b = 4;
assertPathChanges(13, 8);
model.b = 10;
otherModel.c = 5;
assertPathChanges(27, 13);
model.a = 20;
model.b = 1;
otherModel.c = 5;
assertNoChanges();
observer.close();
})
});
suite('ArrayObserver Tests', function() {
setup(doSetup);
teardown(doTeardown);
function ensureNonSparse(arr) {
for (var i = 0; i < arr.length; i++) {
if (i in arr)
continue;
arr[i] = undefined;
}
}
function assertArrayChanges(expectSplices) {
observer.deliver();
var splices = callbackArgs[0];
assert.isTrue(callbackInvoked);
splices.forEach(function(splice) {
ensureNonSparse(splice.removed);
});
expectSplices.forEach(function(splice) {
ensureNonSparse(splice.removed);
});
assert.deepEqual(expectSplices, splices);
callbackArgs = undefined;
callbackInvoked = false;
}
function applySplicesAndAssertDeepEqual(orig, copy) {
observer.deliver();
if (callbackInvoked) {
var splices = callbackArgs[0];
ArrayObserver.applySplices(copy, orig, splices);
}
ensureNonSparse(orig);
ensureNonSparse(copy);
assert.deepEqual(orig, copy);
callbackArgs = undefined;
callbackInvoked = false;
}
function assertEditDistance(orig, expectDistance) {
observer.deliver();
var splices = callbackArgs[0];
var actualDistance = 0;
if (callbackInvoked) {
splices.forEach(function(splice) {
actualDistance += splice.addedCount + splice.removed.length;
});
}
assert.deepEqual(expectDistance, actualDistance);
callbackArgs = undefined;
callbackInvoked = false;
}
function arrayMutationTest(arr, operations) {
var copy = arr.slice();
observer = new ArrayObserver(arr);
observer.open(callback);
operations.forEach(function(op) {
switch(op.name) {
case 'delete':
delete arr[op.index];
break;
case 'update':
arr[op.index] = op.value;
break;
default:
arr[op.name].apply(arr, op.args);
break;
}
});
applySplicesAndAssertDeepEqual(arr, copy);
observer.close();
}
test('Optional target for callback', function() {
var target = {
changed: function(splices) {
this.called = true;
}
};
var obj = [];
var observer = new ArrayObserver(obj);
observer.open(target.changed, target);
obj.length = 1;
observer.deliver();
assert.isTrue(target.called);
observer.close();
});
test('Delivery Until No Changes', function() {
var arr = [0, 1, 2, 3, 4];
var callbackCount = 0;
var observer = new ArrayObserver(arr);
observer.open(function() {
callbackCount++;
arr.shift();
});
arr.shift();
observer.deliver();
assert.equal(5, callbackCount);
observer.close();
});
test('Array disconnect', function() {
var arr = [ 0 ];
observer = new ArrayObserver(arr);
observer.open(callback);
arr[0] = 1;
assertArrayChanges([{
index: 0,
removed: [0],
addedCount: 1
}]);
observer.close();
arr[1] = 2;
assertNoChanges();
});
test('Array discardChanges', function() {
var arr = [];
arr.push(1);
observer = new ArrayObserver(arr);
observer.open(callback);
arr.push(2);
assertArrayChanges([{
index: 1,
removed: [],
addedCount: 1
}]);
arr.push(3);
observer.discardChanges();
assertNoChanges();
arr.pop();
assertArrayChanges([{
index: 2,
removed: [3],
addedCount: 0
}]);
observer.close();
});
test('Array', function() {
var model = [0, 1];
observer = new ArrayObserver(model);
observer.open(callback);
model[0] = 2;
assertArrayChanges([{
index: 0,
removed: [0],
addedCount: 1
}]);
model[1] = 3;
assertArrayChanges([{
index: 1,
removed: [1],
addedCount: 1
}]);
observer.close();
});
test('Array observe non-array throws', function() {
assert.throws(function () {
observer = new ArrayObserver({});
});
});
test('Array Set Same', function() {
var model = [1];
observer = new ArrayObserver(model);
observer.open(callback);
model[0] = 1;
observer.deliver();
assert.isFalse(callbackInvoked);
observer.close();
});
test('Array Splice', function() {
var model = [0, 1]
observer = new ArrayObserver(model);
observer.open(callback);
model.splice(1, 1, 2, 3); // [0, 2, 3]
assertArrayChanges([{
index: 1,
removed: [1],
addedCount: 2
}]);
model.splice(0, 1); // [2, 3]
assertArrayChanges([{
index: 0,
removed: [0],
addedCount: 0
}]);
model.splice();
assertNoChanges();
model.splice(0, 0);
assertNoChanges();
model.splice(0, -1);
assertNoChanges();
model.splice(-1, 0, 1.5); // [2, 1.5, 3]
assertArrayChanges([{
index: 1,
removed: [],
addedCount: 1
}]);
model.splice(3, 0, 0); // [2, 1.5, 3, 0]
assertArrayChanges([{
index: 3,
removed: [],
addedCount: 1
}]);
model.splice(0); // []
assertArrayChanges([{
index: 0,
removed: [2, 1.5, 3, 0],
addedCount: 0
}]);
observer.close();
});
test('Array Splice Truncate And Expand With Length', function() {
var model = ['a', 'b', 'c', 'd', 'e'];
observer = new ArrayObserver(model);
observer.open(callback);
model.length = 2;
assertArrayChanges([{
index: 2,
removed: ['c', 'd', 'e'],
addedCount: 0
}]);
model.length = 5;
assertArrayChanges([{
index: 2,
removed: [],
addedCount: 3
}]);
observer.close();
});
test('Array Splice Delete Too Many', function() {
var model = ['a', 'b', 'c'];
observer = new ArrayObserver(model);
observer.open(callback);
model.splice(2, 3); // ['a', 'b']
assertArrayChanges([{
index: 2,
removed: ['c'],
addedCount: 0
}]);
observer.close();
});
test('Array Length', function() {
var model = [0, 1];
observer = new ArrayObserver(model);
observer.open(callback);
model.length = 5; // [0, 1, , , ,];
assertArrayChanges([{
index: 2,
removed: [],
addedCount: 3
}]);
model.length = 1;
assertArrayChanges([{
index: 1,
removed: [1, , , ,],
addedCount: 0
}]);
model.length = 1;
assertNoChanges();
observer.close();
});
test('Array Push', function() {
var model = [0, 1];
observer = new ArrayObserver(model);
observer.open(callback);
model.push(2, 3); // [0, 1, 2, 3]
assertArrayChanges([{
index: 2,
removed: [],
addedCount: 2
}]);
model.push();
assertNoChanges();
observer.close();
});
test('Array Pop', function() {
var model = [0, 1];
observer = new ArrayObserver(model);
observer.open(callback);
model.pop(); // [0]
assertArrayChanges([{
index: 1,
removed: [1],
addedCount: 0
}]);
model.pop(); // []
assertArrayChanges([{
index: 0,
removed: [0],
addedCount: 0
}]);
model.pop();
assertNoChanges();
observer.close();
});
test('Array Shift', function() {
var model = [0, 1];
observer = new ArrayObserver(model);
observer.open(callback);
model.shift(); // [1]
assertArrayChanges([{
index: 0,
removed: [0],
addedCount: 0
}]);
model.shift(); // []
assertArrayChanges([{
index: 0,
removed: [1],
addedCount: 0
}]);
model.shift();
assertNoChanges();
observer.close();
});
test('Array Unshift', function() {
var model = [0, 1];
observer = new ArrayObserver(model);
observer.open(callback);
model.unshift(-1); // [-1, 0, 1]
assertArrayChanges([{
index: 0,
removed: [],
addedCount: 1
}]);
model.unshift(-3, -2); // []
assertArrayChanges([{
index: 0,
removed: [],
addedCount: 2
}]);
model.unshift();
assertNoChanges();
observer.close();
});
test('Array Tracker Contained', function() {
arrayMutationTest(
['a', 'b'],
[
{ name: 'splice', args: [1, 1] },
{ name: 'unshift', args: ['c', 'd', 'e'] },
{ name: 'splice', args: [1, 2, 'f'] }
]
);
});
test('Array Tracker Delete Empty', function() {
arrayMutationTest(
[],
[
{ name: 'delete', index: 0 },
{ name: 'splice', args: [0, 0, 'a', 'b', 'c'] }
]
);
});
test('Array Tracker Right Non Overlap', function() {
arrayMutationTest(
['a', 'b', 'c', 'd'],
[
{ name: 'splice', args: [0, 1, 'e'] },
{ name: 'splice', args: [2, 1, 'f', 'g'] }
]
);
});
test('Array Tracker Left Non Overlap', function() {
arrayMutationTest(
['a', 'b', 'c', 'd'],
[
{ name: 'splice', args: [3, 1, 'f', 'g'] },
{ name: 'splice', args: [0, 1, 'e'] }
]
);
});
test('Array Tracker Right Adjacent', function() {
arrayMutationTest(
['a', 'b', 'c', 'd'],
[
{ name: 'splice', args: [1, 1, 'e'] },
{ name: 'splice', args: [2, 1, 'f', 'g'] }
]
);
});
test('Array Tracker Left Adjacent', function() {
arrayMutationTest(
['a', 'b', 'c', 'd'],
[
{ name: 'splice', args: [2, 2, 'e'] },
{ name: 'splice', args: [1, 1, 'f', 'g'] }
]
);
});
test('Array Tracker Right Overlap', function() {
arrayMutationTest(
['a', 'b', 'c', 'd'],
[
{ name: 'splice', args: [1, 1, 'e'] },
{ name: 'splice', args: [1, 1, 'f', 'g'] }
]
);
});
test('Array Tracker Left Overlap', function() {
arrayMutationTest(
['a', 'b', 'c', 'd'],
[
// a b [e f g] d
{ name: 'splice', args: [2, 1, 'e', 'f', 'g'] },
// a [h i j] f g d
{ name: 'splice', args: [1, 2, 'h', 'i', 'j'] }
]
);
});
test('Array Tracker Prefix And Suffix One In', function() {
arrayMutationTest(
['a', 'b', 'c', 'd'],
[
{ name: 'unshift', args: ['z'] },
{ name: 'push', arg: ['z'] }
]
);
});
test('Array Tracker Shift One', function() {
arrayMutationTest(
[16, 15, 15],
[
{ name: 'shift', args: ['z'] }
]
);
});
test('Array Tracker Update Delete', function() {
arrayMutationTest(
['a', 'b', 'c', 'd'],
[
{ name: 'splice', args: [2, 1, 'e', 'f', 'g'] },
{ name: 'update', index: 0, value: 'h' },
{ name: 'delete', index: 1 }
]
);
});
test('Array Tracker Update After Delete', function() {
arrayMutationTest(
['a', 'b', undefined, 'd'],
[
{ name: 'update', index: 2, value: 'e' }
]
);
});
test('Array Tracker Delete Mid Array', function() {
arrayMutationTest(
['a', 'b', 'c', 'd'],
[
{ name: 'delete', index: 2 }
]
);
});
test('Array Random Case 1', function() {
var model = ['a','b'];
var copy = model.slice();
observer = new ArrayObserver(model);
observer.open(callback);
model.splice(0, 1, 'c', 'd', 'e');
model.splice(4,0,'f');
model.splice(3,2);
applySplicesAndAssertDeepEqual(model, copy);
});
test('Array Random Case 2', function() {
var model = [3,4];
var copy = model.slice();
observer = new ArrayObserver(model);
observer.open(callback);
model.splice(2,0,8);
model.splice(0,1,0,5);
model.splice(2,2);
applySplicesAndAssertDeepEqual(model, copy);
});
test('Array Random Case 3', function() {
var model = [1,3,6];
var copy = model.slice();
observer = new ArrayObserver(model);
observer.open(callback);
model.splice(1,1);
model.splice(0,2,1,7);
model.splice(1,0,3,7);
applySplicesAndAssertDeepEqual(model, copy);
});
test('Array Tracker Fuzzer', function() {
var testCount = 64;
console.log('Fuzzing spliceProjection ' + testCount +
' passes with ' + ArrayFuzzer.operationCount + ' operations each.');
for (var i = 0; i < testCount; i++) {
console.log('pass: ' + i);
var fuzzer = new ArrayFuzzer();
fuzzer.go();
ensureNonSparse(fuzzer.arr);
ensureNonSparse(fuzzer.copy);
assert.deepEqual(fuzzer.arr, fuzzer.copy);
}
});
test('Array Tracker No Proxies Edits', function() {
model = [];
observer = new ArrayObserver(model);
observer.open(callback);
model.length = 0;
model.push(1, 2, 3);
assertEditDistance(model, 3);
observer.close();
model = ['x', 'x', 'x', 'x', '1', '2', '3'];
observer = new ArrayObserver(model);
observer.open(callback);
model.length = 0;
model.push('1', '2', '3', 'y', 'y', 'y', 'y');
assertEditDistance(model, 8);
observer.close();
model = ['1', '2', '3', '4', '5'];
observer = new ArrayObserver(model);
observer.open(callback);
model.length = 0;
model.push('a', '2', 'y', 'y', '4', '5', 'z', 'z');
assertEditDistance(model, 7);
observer.close();
});
});
suite('ObjectObserver Tests', function() {
setup(doSetup);
teardown(doTeardown);
function assertObjectChanges(expect) {
observer.deliver();
assert.isTrue(callbackInvoked);
var added = callbackArgs[0];
var removed = callbackArgs[1];
var changed = callbackArgs[2];
var getOldValue = callbackArgs[3];
var oldValues = {};
function collectOldValues(type) {
Object.keys(type).forEach(function(prop) {
oldValues[prop] = getOldValue(prop);
});
};
collectOldValues(added);
collectOldValues(removed);
collectOldValues(changed);
assert.deepEqual(expect.added, added);
assert.deepEqual(expect.removed, removed);
assert.deepEqual(expect.changed, changed);
assert.deepEqual(expect.oldValues, oldValues);
callbackArgs = undefined;
callbackInvoked = false;
}
test('Optional target for callback', function() {
var target = {
changed: function(value, oldValue) {
this.called = true;
}
};
var obj = { foo: 1 };
var observer = new PathObserver(obj, 'foo');
observer.open(target.changed, target);
obj.foo = 2;
observer.deliver();
assert.isTrue(target.called);
observer.close();
});
test('Delivery Until No Changes', function() {
var obj = { foo: 5 };
var callbackCount = 0;
var observer = new ObjectObserver(obj);
observer.open(function() {
callbackCount++;
if (!obj.foo)
return;
obj.foo--;
});
obj.foo--;
observer.deliver();
assert.equal(5, callbackCount);
observer.close();
});
test('Object disconnect', function() {
var obj = {};
obj.foo = 'bar';
observer = new ObjectObserver(obj);
observer.open(callback);
obj.foo = 'baz';
obj.bat = 'bag';
obj.blaz = 'foo';
delete obj.foo;
delete obj.blaz;
assertObjectChanges({
added: {
'bat': 'bag'
},
removed: {
'foo': undefined
},
changed: {},
oldValues: {
'foo': 'bar',
'bat': undefined
}
});
obj.foo = 'blarg';
observer.close();
obj.bar = 'blaz';
assertNoChanges();
});
test('Object discardChanges', function() {
var obj = {};
obj.foo = 'bar';
observer = new ObjectObserver(obj);
observer.open(callback);
obj.foo = 'baz';
assertObjectChanges({
added: {},
removed: {},
changed: {
foo: 'baz'
},
oldValues: {
foo: 'bar'
}
});
obj.blaz = 'bat';
observer.discardChanges();
assertNoChanges();
obj.bat = 'bag';
assertObjectChanges({
added: {
bat: 'bag'
},
removed: {},
changed: {},
oldValues: {
bat: undefined
}
});
observer.close();
});
test('Object observe array', function() {
var arr = [];
observer = new ObjectObserver(arr);
observer.open(callback);
arr.length = 5;
arr.foo = 'bar';
arr[3] = 'baz';
assertObjectChanges({
added: {
foo: 'bar',
'3': 'baz'
},
removed: {},
changed: {
'length': 5
},
oldValues: {
length: 0,
foo: undefined,
'3': undefined
}
});
observer.close();
});
test('Object', function() {
var model = {};
observer = new ObjectObserver(model);
observer.open(callback);
model.id = 0;
assertObjectChanges({
added: {
id: 0
},
removed: {},
changed: {},
oldValues: {
id: undefined
}
});
delete model.id;
assertObjectChanges({
added: {},
removed: {
id: undefined
},
changed: {},
oldValues: {
id: 0
}
});
// Stop observing -- shouldn't see an event
observer.close();
model.id = 101;
assertNoChanges();
// Re-observe -- should see an new event again.
observer = new ObjectObserver(model);
observer.open(callback);
model.id2 = 202;;
assertObjectChanges({
added: {
id2: 202
},
removed: {},
changed: {},
oldValues: {
id2: undefined
}
});
observer.close();
});
test('Object Delete Add Delete', function() {
var model = { id: 1 };
observer = new ObjectObserver(model);
observer.open(callback);
// If mutation occurs in seperate "runs", two events fire.
delete model.id;
assertObjectChanges({
added: {},
removed: {
id: undefined
},
changed: {},
oldValues: {
id: 1
}
});
model.id = 1;
assertObjectChanges({
added: {
id: 1
},
removed: {},
changed: {},
oldValues: {
id: undefined
}
});
// If mutation occurs in the same "run", no events fire (nothing changed).
delete model.id;
model.id = 1;
assertNoChanges();
observer.close();
});
test('Object Set Undefined', function() {
var model = {};
observer = new ObjectObserver(model);
observer.open(callback);
model.x = undefined;
assertObjectChanges({
added: {
x: undefined
},
removed: {},
changed: {},
oldValues: {
x: undefined
}
});
observer.close();
});
});