ampersand-state
Version:
An observable, extensible state object with derived watchable properties.
989 lines (905 loc) • 26.2 kB
JavaScript
/*jshint expr: true*/
var test = require('tape');
var State = require('../ampersand-state');
var Person = State.extend({
props: {
name: 'string'
}
});
test('init with nothing should be okay', function (t) {
var EmptyModel = State.extend();
var something = new EmptyModel();
something.foo = 'bar';
t.ok(something);
t.equal(something.foo, 'bar');
t.end();
});
test('init with values', function (t) {
var person = new Person({name: 'henrik'});
t.ok(person);
t.equal(person.name, 'henrik');
t.end();
});
test('after initialized change should be empty until a set op', function (t) {
var person = new Person({name: 'phil'});
t.deepEqual(person._changed, {});
t.notOk(person.changedAttributes());
t.end();
});
test('extended object maintains existing props', function (t) {
var AwesomePerson = Person.extend({
props: {
awesomeness: 'number'
}
});
var awesome = new AwesomePerson({
name: 'Captain Awesome',
awesomeness: 11
});
t.equals(awesome.name, 'Captain Awesome');
t.equals(awesome.awesomeness, 11);
t.end();
});
test('extended object maintains existing methods', function (t) {
var NewPerson = State.extend({
props: {
awesomeness: 'number'
},
isTrulyAwesome: function () {
if (this.awesomeness > 10) return true;
}
});
var AwesomePerson = NewPerson.extend({});
var awesome = new AwesomePerson({
awesomeness: 11
});
t.ok(awesome.isTrulyAwesome());
t.end();
});
test('cached derived properties are calculated once per change', function (t) {
var count = 0;
var NewPerson = Person.extend({
derived: {
greeting: {
deps: ['name'],
fn: function () {
count++;
return 'hi, ' + this.name + '!';
}
}
}
});
var person = new NewPerson({name: 'henrik'});
t.equal(person.greeting, 'hi, henrik!');
// use again, should not increment counter
person.greeting;
t.equal(count, 1);
person.name = 'something';
t.equal(person.greeting, 'hi, something!');
// reference again
person.greeting;
t.equal(count, 2);
t.end();
});
test('cached derived properties fire events on dependency change', function (t) {
var NewPerson = Person.extend({
derived: {
greeting: {
deps: ['name'],
fn: function () {
return 'hi, ' + this.name + '!';
}
}
}
});
var person = new NewPerson({name: 'henrik'});
person.on('change:greeting', function (model, value) {
t.equal(value, 'hi, something!', "shouldn't fire if value is unchanged same value");
t.end();
});
person.name = 'something';
});
test('cached derived properties fire events if result is different', function (t) {
t.plan(1);
var NewPerson = Person.extend({
derived: {
greeting: {
deps: ['name'],
fn: function () {
return 'hi, ' + this.name + '!';
}
}
}
});
var person = new NewPerson({name: 'henrik'});
person.on('change:greeting', function (model, value) {
t.ok(false, "shouldn't fire if value if derived value is unchanged");
});
person.name = 'henrik';
t.equal(person.name, 'henrik');
t.end();
});
test('uncached derived properties always fire events on dependency change', function (t) {
t.plan(1);
var NewPerson = Person.extend({
derived: {
greeting: {
deps: ['name'],
cache: false,
fn: function () {
return 'hello!';
}
}
}
});
var person = new NewPerson({name: 'henrik'});
person.on('change:greeting', function (model, value) {
t.equal(value, 'hello!', "Fires despite being same value");
t.end();
});
person.name = 'different';
});
test('everything should work with a property called `type`. Issue #6.', function (t) {
var Model = State.extend({
props: {
id: 'string',
type: 'string'
}
});
var model = new Model({id: '50', type: 'hello'});
t.equal(model.type, 'hello');
model.type = 'wat?';
t.equal(model.type, 'wat?');
t.end();
});
test('should have cid', function (t) {
var Model = State.extend({
props: {
id: 'string',
type: 'string'
}
});
var m = new Model();
t.ok(m.cid);
t.end();
});
test('instanceof checks should pass for all parents in the chain', function (t) {
var P1 = Person.extend({});
var P2 = P1.extend({});
var P3 = P2.extend({});
var p1 = new P1();
var p2 = new P2();
var p3 = new P3();
t.ok(p1 instanceof Person);
t.ok(p2 instanceof Person);
t.ok(p3 instanceof Person);
t.notOk(p1 instanceof P2);
t.ok(p2 instanceof P2);
t.ok(p3 instanceof P2);
t.notOk(p2 instanceof P3);
t.ok(p3 instanceof P3);
// all of them should have the isState flag too
t.ok(p1.isState);
t.ok(p2.isState);
t.ok(p3.isState);
// shouldn't be possible to change
p1.isState = false;
p2.isState = false;
p3.isState = false;
t.ok(p1.isState);
t.ok(p2.isState);
t.ok(p3.isState);
t.end();
});
test('custom id and namespace attributes', function (t) {
var NewPerson = State.extend({
props: {
name: 'string',
_id: 'number',
ns: 'string'
},
idAttribute: '_id',
namespaceAttribute: 'ns'
});
var person = new NewPerson({name: 'henrik', ns: 'group1', _id: 47});
t.equal(person.getId(), 47);
t.equal(person.getNamespace(), 'group1');
t.end();
});
test('customizable `type` attribute', function (t) {
var FirstModel = State.extend({
type: 'hello',
typeAttribute: 'type'
});
var SecondModel = State.extend({
modelType: 'second'
});
var first = new FirstModel();
var second = new SecondModel();
t.equal(first.getType(), 'hello');
t.equal(second.getType(), 'second');
t.end();
});
test('constructor should be defined', function (t) {
var Foo = State.extend({
props: { name: 'string' }
});
var foo = new Foo();
t.ok(foo.constructor);
t.end();
});
test('isValid is a thing', function (t) {
var Foo = State.extend({
props: { name: ['string', true] },
validate: function (attrs) {
if (attrs.name.length < 2) {
return "can't be too short";
}
}
});
var foo = new Foo();
t.notOk(foo.isValid());
foo.name = 'thing';
t.ok(foo.isValid());
t.end();
});
test("isNew", function (t) {
var Foo = State.extend({
props: {
id: 'number',
foo: 'number',
bar: 'number',
baz: 'number'
}
});
var a = new Foo({ 'foo': 1, 'bar': 2, 'baz': 3});
t.ok(a.isNew(), "it should be new");
a = new Foo({ 'foo': 1, 'bar': 2, 'baz': 3, 'id': -5 });
t.ok(!a.isNew(), "any defined ID is legal, negative or positive");
a = new Foo({ 'foo': 1, 'bar': 2, 'baz': 3, 'id': 0 });
t.ok(!a.isNew(), "any defined ID is legal, including zero");
t.ok(new Foo({}).isNew(), "is true when there is no id");
t.ok(!new Foo({'id': 2}).isNew(), "is false for a positive integer");
t.ok(!new Foo({'id': -5}).isNew(), "is false for a negative integer");
t.end();
});
test("escape", function (t) {
var Doc = State.extend({
props: {
id: 'string',
title: 'string',
author: 'string',
length: 'number',
audience: 'string'
}
});
var doc = new Doc({
id: '1-the-tempest',
title: "The Tempest",
author: "Bill Shakespeare",
length: 123
});
t.equal(doc.escape('title'), 'The Tempest');
doc.set({audience: 'Bill & Bob'});
t.equal(doc.escape('audience'), 'Bill & Bob');
doc.set({audience: 'Tim > Joan'});
t.equal(doc.escape('audience'), 'Tim > Joan');
doc.unset('audience');
t.equal(doc.escape('audience'), '');
t.end();
});
test("set an empty string", function (t) {
var Model = State.extend({
props: {
name: 'string'
}
});
var model = new Model({name : "Model"});
model.set({name : ''});
t.equal(model.get('name'), '');
t.end();
});
test("setting an object", function (t) {
var Model = State.extend({
props: {
custom: 'object'
}
});
var model = new Model({
custom: {foo: 1}
});
model.on('change', function () {
t.equal(model.custom.foo, 2);
t.end();
});
model.set({
custom: {foo: 1} // no change should be fired
});
model.set({
custom: {foo: 2} // change event should be fired
});
});
test("clear", function (t) {
var Model = State.extend({
props: {
name: 'string',
id: 'number',
length: 'number'
}
});
var changed;
var model = new Model({id: 1, name : "Model", length: 1});
model.on("change:name", function () { changed = true; });
model.clear();
t.equal(changed, true);
t.equal(model.get('name'), undefined);
t.end();
});
test("changedAttributes", function (t) {
var Model = State.extend({
props: {
a: 'string',
b: 'string'
}
});
var model = new Model({a: 'a', b: 'b'});
t.deepEqual(model.changedAttributes(), false);
t.equal(model.changedAttributes({a: 'a'}), false);
t.equal(model.changedAttributes({a: 'b'}).a, 'b');
t.end();
});
test("change with options", function (t) {
var value;
var Model = State.extend({
props: {
name: 'string'
}
});
var model = new Model({name: 'Rob'});
model.on('change', function (model, options) {
value = options.prefix + model.get('name');
});
model.set({name: 'Bob'}, {prefix: 'Mr. '});
t.equal(value, 'Mr. Bob');
model.set({name: 'Sue'}, {prefix: 'Ms. '});
t.equal(value, 'Ms. Sue');
t.end();
});
test("change after initialize", function (t) {
var changed = 0;
var Model = State.extend({
props: {
id: 'number',
label: 'string'
}
});
var attrs = {id: 1, label: 'c'};
var obj = new Model(attrs);
obj.on('change', function () { changed += 1; });
obj.set(attrs);
t.equal(changed, 0);
t.end();
});
test("set triggers changes in the correct order", function (t) {
var value = null;
var M = State.extend({});
var model = new M();
model.on('last', function () { value = 'last'; });
model.on('first', function () { value = 'first'; });
model.trigger('first');
model.trigger('last');
t.equal(value, 'last');
t.end();
});
test("multiple unsets", function (t) {
var i = 0;
var counter = function () { i++; };
var Model = State.extend({
props: {
a: 'string'
}
});
var model = new Model({a: 'a'});
model.on("change:a", counter);
model.set({a: 'b'});
model.unset('a');
model.unset('a');
t.equal(i, 2, 'Unset does not fire an event for missing attributes.');
t.end();
});
test("unset and changedAttributes", function (t) {
var Model = State.extend({
props: {
a: 'number'
}
});
var model = new Model({a: 1});
model.on('change', function () {
t.ok('a' in model.changedAttributes(), 'changedAttributes should contain unset properties');
t.end();
});
model.unset('a');
});
test("change, hasChanged, changedAttributes, previous, previousAttributes", function (t) {
var Model = State.extend({
props: {
name: 'string',
age: 'number'
}
});
var model = new Model({name: "Tim", age: 10});
t.deepEqual(model.changedAttributes(), false);
model.on('change', function () {
t.ok(model.hasChanged('name'), 'name changed');
t.ok(!model.hasChanged('age'), 'age did not');
t.deepEqual(model.changedAttributes(), {name : 'Rob'}, 'changedAttributes returns the changed attrs');
t.equal(model.previous('name'), 'Tim');
t.deepEqual(model.previousAttributes(), {name : "Tim", age : 10}, 'previousAttributes is correct');
t.end();
});
t.equal(model.hasChanged(), false);
t.equal(model.hasChanged(undefined), false);
model.set({name : 'Rob'});
t.equal(model.get('name'), 'Rob');
});
test("validate on unset and clear", function (t) {
var error;
var Model = State.extend({
props: {
name: 'string'
}
});
var model = new Model({name: "One"});
model.validate = function (attrs) {
if (!attrs.name) {
error = true;
return "No thanks.";
}
};
model.set({name: "Two"});
t.equal(model.get('name'), 'Two');
t.equal(error, undefined);
model.unset('name', {validate: true});
t.equal(error, true);
t.equal(model.get('name'), 'Two');
model.clear({validate: true});
t.equal(model.get('name'), 'Two');
delete model.validate;
model.clear();
t.equal(model.get('name'), undefined);
t.end();
});
test("validate with error callback", function (t) {
var lastError, boundError;
var Model = State.extend({
props: {
a: 'number',
admin: 'boolean'
}
});
var model = new Model();
model.validate = function (attrs) {
if (attrs.admin) return "Can't change admin status.";
};
model.on('invalid', function (model, error) {
boundError = true;
});
var result = model.set({a: 100}, {validate: true});
t.equal(result, model);
t.equal(model.get('a'), 100);
t.equal(model.validationError, null);
t.equal(boundError, undefined);
result = model.set({a: 200, admin: true}, {validate: true});
t.equal(result, false);
t.equal(model.get('a'), 100);
t.equal(model.validationError, "Can't change admin status.");
t.equal(boundError, true);
t.end();
});
test("Nested change events don't clobber previous attributes", function (t) {
new (State.extend({props: {state: 'string', other: 'string'}}))()
.on('change:state', function (model, newState) {
t.equal(model.previous('state'), undefined);
t.equal(newState, 'hello');
// Fire a nested change event.
model.set({other: 'whatever'});
})
.on('change:state', function (model, newState) {
t.equal(model.previous('state'), undefined);
t.equal(newState, 'hello');
t.end();
})
.set({state: 'hello'});
});
test("hasChanged/set should use same comparison", function (t) {
var changed = 0;
var Model = State.extend({
props: {
a: 'string'
}
});
var model = new Model({a: 'something'});
model.on('change', function () {
t.ok(this.hasChanged('a'));
})
.on('change:a', function () {
changed++;
})
.set({a: 'else'});
t.equal(changed, 1);
t.end();
});
test("#582, #425, change:attribute callbacks should fire after all changes have occurred", 9, function (t) {
var Model = State.extend({
props: {
a: 'string',
b: 'string',
c: 'string'
}
});
var model = new Model();
var assertion = function () {
t.equal(model.get('a'), 'a');
t.equal(model.get('b'), 'b');
t.equal(model.get('c'), 'c');
};
model.on('change:a', assertion);
model.on('change:b', assertion);
model.on('change:c', assertion);
model.set({a: 'a', b: 'b', c: 'c'});
t.end();
});
test("set same value does not trigger change", function (t) {
var Model = State.extend({
props: {
x: 'number'
}
});
var model = new Model({x: 1});
model.on('change change:x', function () { t.ok(false); });
model.set({x: 1});
model.set({x: 1});
t.end();
});
test("unset does not fire a change for undefined attributes", 0, function (t) {
var Model = State.extend({
props: {
x: 'number'
}
});
var model = new Model({x: undefined});
model.on('change:x', function () { t.ok(false); });
model.unset('x');
t.end();
});
test("hasChanged works outside of change events, and true within", 6, function (t) {
var Model = State.extend({
props: {
x: 'number'
}
});
var model = new Model({x: 1});
model.on('change:x', function () {
t.ok(model.hasChanged('x'));
t.equal(model.get('x'), 1);
});
model.set({x: 2}, {silent: true});
t.ok(model.hasChanged());
t.equal(model.hasChanged('x'), true);
model.set({x: 1});
t.ok(model.hasChanged());
t.equal(model.hasChanged('x'), true);
t.end();
});
test("hasChanged gets cleared on the following set", function (t) {
var Model = State.extend({
props: {
x: 'number'
}
});
var model = new Model();
model.set({x: 1});
t.ok(model.hasChanged());
model.set({x: 1});
t.ok(!model.hasChanged());
model.set({x: 2});
t.ok(model.hasChanged());
model.set({});
t.ok(!model.hasChanged());
t.end();
});
test("`hasChanged` for falsey keys", function (t) {
var Model = State.extend({
props: {
x: 'boolean'
}
});
var model = new Model();
model.set({x: true}, {silent: true});
t.ok(!model.hasChanged(0));
t.ok(!model.hasChanged(''));
t.end();
});
test("`previous` for falsey keys", function (t) {
var Model = State.extend({
props: {
0: 'boolean',
'': 'boolean'
}
});
var model = new Model({0: true, '': true});
model.set({0: false, '': false}, {silent: true});
t.equal(model.previous(0), true);
t.equal(model.previous(''), true);
t.end();
});
test("validate", function (t) {
var lastError;
var Model = State.extend({
props: {
admin: ['boolean', true, true],
a: 'number'
}
});
var model = new Model();
model.validate = function (attrs) {
if (attrs.admin != this.get('admin')) return "Can't change admin status.";
};
model.on('invalid', function (model, error) {
lastError = error;
});
var result = model.set({a: 100});
t.equal(result, model);
t.equal(model.get('a'), 100);
t.equal(lastError, undefined);
result = model.set({admin: true});
t.equal(model.get('admin'), true);
result = model.set({a: 200, admin: false}, {validate: true});
t.equal(lastError, "Can't change admin status.");
t.equal(result, false);
t.equal(model.get('a'), 100);
t.end();
});
test("set and unset", function (t) {
var Model = State.extend({
props: {
id: 'string',
foo: 'number',
bar: 'number',
baz: 'number',
extra: 'string'
}
});
var a = new Model({id: 'id', foo: 1, bar: 2, baz: 3});
var changeCount = 0;
a.on("change:foo", function () { changeCount += 1; });
a.set({'foo': 2});
t.ok(a.get('foo') == 2, "Foo should have changed.");
t.ok(changeCount == 1, "Change count should have incremented.");
a.set({'foo': 2}); // set with value that is not new shouldn't fire change event
t.ok(a.get('foo') == 2, "Foo should NOT have changed, still 2");
t.ok(changeCount == 1, "Change count should NOT have incremented.");
a.validate = function (attrs) {
t.equal(attrs.foo, void 0, "validate: true passed while unsetting");
};
a.unset('foo', {validate: true});
t.equal(a.get('foo'), void 0, "Foo should have changed");
delete a.validate;
t.ok(changeCount == 2, "Change count should have incremented for unset.");
a.unset('id');
t.equal(a.id, undefined, "Unsetting the id should remove the id property.");
t.end();
});
test("unset even if value has been specified", function (t) {
var Model = State.extend({
props: {
foo: 'string'
}
});
var model = new Model({ foo: 'bar' });
model.on('change:foo', function () {
t.equal(model.get('foo'), undefined);
});
model.set({ foo: 'baz' }, { unset: true });
t.equal(model.get('foo'), undefined);
t.end();
});
test("nested `set` during `'change:attr'`", function (t) {
var events = [];
var Model = State.extend({
props: {
x: 'boolean',
y: 'boolean',
z: 'boolean'
}
});
var model = new Model();
model.on('all', function (event) { events.push(event); });
model.on('change', function () {
model.set({z: true}, {silent: true});
});
model.on('change:x', function () {
model.set({y: true});
});
model.set({x: true});
t.deepEqual(events, ['change:y', 'change:x', 'change']);
events = [];
model.set({z: true});
t.deepEqual(events, []);
t.end();
});
test("nested `change` only fires once", function (t) {
t.plan(1);
var model = new (State.extend({props: {x: 'boolean'}}))();
model.on('change', function () {
t.ok(true);
model.set({x: true});
});
model.set({x: true});
});
test("nested `set` during `'change'`", function (t) {
var count = 0;
var Model = State.extend({
props: {
x: 'boolean',
y: 'boolean',
z: 'boolean'
}
});
var model = new Model();
model.on('change', function () {
switch (count++) {
case 0:
t.deepEqual(this.changedAttributes(), {x: true});
t.equal(model.previous('x'), undefined);
model.set({y: true});
break;
case 1:
t.deepEqual(this.changedAttributes(), {x: true, y: true});
t.equal(model.previous('x'), undefined);
model.set({z: true});
break;
case 2:
t.deepEqual(this.changedAttributes(), {x: true, y: true, z: true});
t.equal(model.previous('y'), undefined);
break;
default:
t.ok(false);
}
});
model.set({x: true});
t.end();
});
test("nested `change` with silent", function (t) {
var count = 0;
var Model = State.extend({
props: {
x: 'boolean',
y: 'boolean',
z: 'boolean'
}
});
var model = new Model();
model.on('change:y', function () { t.ok(false); });
model.on('change', function () {
switch (count++) {
case 0:
t.deepEqual(this.changedAttributes(), {x: true});
model.set({y: true}, {silent: true});
model.set({z: true});
break;
case 1:
t.deepEqual(this.changedAttributes(), {x: true, y: true, z: true});
break;
case 2:
t.deepEqual(this.changedAttributes(), {z: false});
break;
default:
t.ok(false);
}
});
model.set({x: true});
model.set({z: false});
t.end();
});
test("nested `change:attr` with silent", function (t) {
var Model = State.extend({
props: {
x: 'boolean',
y: 'boolean',
z: 'boolean'
}
});
var model = new Model();
model.on('change:y', function () { t.ok(false); });
model.on('change', function () {
model.set({y: true}, {silent: true});
model.set({z: true});
});
model.set({x: true});
t.end();
});
test("multiple nested changes with silent", function (t) {
var Model = State.extend({
props: {
x: 'boolean',
y: 'number'
}
});
var model = new Model();
model.on('change:x', function () {
model.set({y: 1}, {silent: true});
model.set({y: 2});
});
model.on('change:y', function (model, val) {
t.equal(val, 2);
});
model.set({x: true});
t.end();
});
test("multiple nested changes with silent", function (t) {
var changes = [];
var Model = State.extend({
props: {
b: 'number'
}
});
var model = new Model();
model.on('change:b', function (model, val) { changes.push(val); });
model.on('change', function () {
model.set({b: 1});
});
model.set({b: 0});
t.deepEqual(changes, [0, 1]);
t.end();
});
test("basic silent change semantics", function (t) {
var Model = State.extend({
props: {
x: 'number'
}
});
var model = new Model();
model.set({x: 1});
model.on('change', function () { t.ok(true); });
model.set({x: 2}, {silent: true});
model.set({x: 1});
t.end();
});
test("nested set multiple times", function (t) {
var Model = State.extend({
props: {
a: 'boolean',
b: 'boolean'
}
});
var model = new Model();
model.on('change:b', function () {
t.ok(true);
});
model.on('change:a', function () {
model.set({b: true});
model.set({b: true});
});
model.set({a: true});
t.end();
});
test("#1122 - clear does not alter options.", function (t) {
var model = new (State.extend({}))();
var options = {};
model.clear(options);
t.ok(!options.unset);
t.end();
});
test("#1122 - unset does not alter options.", function (t) {
var Model = State.extend({
props: {
x: 'number'
}
});
var model = new Model();
var options = {};
model.unset('x', options);
t.ok(!options.unset);
t.end();
});