Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial commit. Core idea is working. Backbone.Model regression tests…

… pass. Fiddle demonstrates concept.
  • Loading branch information...
commit e285c7bd0c485038024a8e7c147fd4b3dfa750c1 0 parents
@hunterloftis authored
129 backbone.viewmodel.js
@@ -0,0 +1,129 @@
+(function(Backbone) {
+
+ // description is an object with: node, viewModel, attribute
+ var TextBinding = function(description) {
+ _.bindAll(this);
+
+ console.log("New 'text' binding:", description);
+ _.extend(this, description);
+
+ this.bind();
+ this.onModelChange();
+ };
+
+ _.extend(TextBinding.prototype, {
+
+ bind: function() {
+ this.viewModel.on('change:' + this.attribute, this.onModelChange);
+ },
+
+ onModelChange: function() {
+ var val = this.viewModel.get(this.attribute);
+ $(this.node).text(val);
+ },
+
+ unbind: function() {
+ this.viewModel.removeListener('change:' + this.attribute, this.update);
+ }
+ });
+
+ var ValBinding = function(description) {
+ _.bindAll(this);
+
+ console.log("New 'val' binding:", description);
+ _.extend(this, description);
+
+ this.bind();
+ this.onModelChange();
+ };
+
+ _.extend(ValBinding.prototype, {
+
+ bind: function() {
+ this.viewModel.on('change:', this.attribute, this.onModelChange);
+ $(this.node).on('keyup change', this.onViewChange);
+ },
+
+ onModelChange: function() {
+ var val = this.viewModel.get(this.attribute);
+ $(this.node).val(val);
+ },
+
+ onViewChange: function() {
+ var val = $(this.node).val();
+ this.viewModel.set(this.attribute, val);
+ },
+
+ unbind: function() {
+
+ }
+ });
+
+ Backbone.ViewModel = Backbone.Model.extend({
+
+ initialize: function(attributes, options) {
+ this._bindings = [];
+ },
+
+ bindTo: function(attribute, container) {
+ container = container || 'body';
+ var nodes = $(container).find('*[' + attribute + ']');
+
+ _.each(nodes, this.bindToNode(attribute));
+ },
+
+ isBoundTo: function(node) {
+ return _.any(this._bindings, function(binding) {
+ return binding.node === node;
+ });
+ },
+
+ bindToNode: function(attribute) {
+ var self = this;
+ return function(node) {
+ if (self.isBoundTo(node)) return;
+
+ var bindingString = $(node).attr(attribute);
+ var bindingList = bindingString.split(',');
+ var descriptions = _.map(bindingList, self.parseBinding(node));
+
+ _.each(descriptions, self.createBinding, self);
+ };
+ },
+
+ parseBinding: function(node) {
+ var self = this;
+ return function(bindingPair) {
+ var bindingSplit = bindingPair.split(':');
+ var type = bindingSplit[0].trim();
+ var attribute = bindingSplit[1].trim();
+ return {
+ type: type,
+ node: node,
+ viewModel: self,
+ attribute: attribute
+ };
+ };
+ },
+
+ createBinding: function(description) {
+ var Binding = Backbone.ViewModel.bindings[description.type];
+ if (Binding) {
+ var binding = new Binding(description);
+ if (binding) {
+ this._bindings.push(binding);
+ return binding;
+ }
+ throw new Error("Unable to create '" + description.type + "' binding");
+ }
+ throw new Error("Trying to create a binding of unknown type '" + description.type + "'");
+ }
+
+ }, {
+ bindings: {
+ text: TextBinding,
+ val: ValBinding
+ }
+ });
+
+})(Backbone);
57 test/attributes.js
@@ -0,0 +1,57 @@
+describe('Attributes', function() {
+ describe('setting and getting', function() {
+ var vm = new Backbone.ViewModel({
+ num: 123,
+ str: 'abc',
+ bool: false
+ });
+ strictEqual(vm.)
+ });
+});
+
+$(document).ready(function() {
+
+ module("Bases");
+
+ test("setting and getting", function() {
+ var base, obj;
+ obj = {
+ num: 123,
+ str: 'abc',
+ bool: false
+ };
+ base = ok.base('abc');
+ strictEqual(base(), 'abc', 'can store a string in a base');
+ base = ok.base(123);
+ strictEqual(base(), 123, 'can store a number in a base');
+ base = ok.base(true);
+ strictEqual(base(), true, 'can store a boolean in a base');
+ base = ok.base(null);
+ strictEqual(base(), null, 'can store null in a base');
+ base = ok.base(undefined);
+ strictEqual(base(), undefined, 'can store undefined in a base');
+ base = ok.base(obj);
+ strictEqual(base(), obj, 'can store an object in a base');
+ base('replaced');
+ strictEqual(base(), 'replaced', 'can replace a stored base');
+ });
+
+ test("undefined bases", function() {
+ var base = ok.base();
+ strictEqual(typeof(base()), 'undefined', 'empty base returns undefined');
+ base('okay');
+ strictEqual(base(), 'okay', 'assigning a value to an empty base works');
+ });
+
+ test("subscribing to updates", function() {
+ var base = ok.base('abc'),
+ val = base();
+ base.subscribe(function(newVal) {
+ val = newVal;
+ });
+ strictEqual(val, 'abc', 'initial value is correct');
+ base(123);
+ strictEqual(val, 123, 'subscription updates on value change');
+ });
+
+});
35 test/backbone.regression.html
@@ -0,0 +1,35 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset='utf8'>
+ <title>Backbone Test Suite</title>
+
+ <!-- Test dependencies -->
+ <link rel="stylesheet" href="../vendor/qunit.css" type="text/css" media="screen">
+ <script src="../vendor/qunit.js"></script>
+ <script src="../vendor/jslitmus.js"></script>
+
+ <!-- Platform dependencies -->
+ <script src='../vendor/zepto-1.0rc1.js'></script>
+ <script src='../vendor/underscore-1.3.3.js'></script>
+ <script src='../vendor/backbone-0.9.2.js'></script>
+
+ <!-- Backbone.ViewModel -->
+ <script src='../backbone.viewmodel.js'></script>
+
+ <script src="model.regression.js"></script>
+ <script src="collection.regression.js"></script>
+</head>
+<body>
+ <div id="qunit"></div>
+ <div id="qunit-fixture">
+ <div id='testElement'>
+ <h1>Test</h1>
+ </div>
+ </div>
+ <br>
+ <br>
+ <h1 id="qunit-header"><a href="#">Backbone Speed Suite</a></h1>
+ <div id="jslitmus_container" style="margin: 20px 10px;"></div>
+</body>
+</html>
77 test/bindings.js
@@ -0,0 +1,77 @@
+$(document).ready(function() {
+
+ module("Bindings");
+
+ test("simple bindings", function() {
+
+ var viewModel = {
+ testVisible: ok.base(false)
+ };
+ viewModel.testHtml = ok.dependent(function() {
+ if (this.testVisible()) {
+ return 'visible';
+ }
+ return 'invisible';
+ }, viewModel);
+
+ strictEqual($('#testVisible').css('display'), 'block', 'no effect to visible before binding');
+ strictEqual($('#testHtml').html(), 'html', 'no effect to html before binding');
+
+ ok.bind(viewModel);
+
+ strictEqual($('#testVisible').css('display'), 'none', 'bind visible to a base value');
+ strictEqual($('#testHtml').html(), 'invisible', 'bind html to a dependent value');
+
+ viewModel.testVisible(true);
+
+ strictEqual($('#testVisible').css('display'), 'block', 'display property should update to "block" when bound base value updates');
+ strictEqual($('#testHtml').html(), 'visible', 'html property should be "visible" after bound dependent autoupdates');
+
+ ok.unbind();
+
+ viewModel.testVisible(false);
+
+ strictEqual(viewModel.testVisible(), false, 'viewmodel base is changed');
+ strictEqual(viewModel.testHtml(), 'invisible', 'viewmodel dependent is changed');
+ strictEqual($('#testVisible').css('display'), 'block', 'view bound to base is unchanged');
+ strictEqual($('#testHtml').html(), 'visible', 'view bound to dependent is unchanged');
+
+ });
+
+
+ test("simple namespaced bindings", function() {
+
+ var vm1 = {
+ visible: ok.base(false)
+ };
+
+ var vm2 = {
+ visible: ok.base(false)
+ }
+
+ strictEqual($('#namespace1').css('display'), 'block', 'no effect to namespaced nodes before binding');
+ strictEqual($('#namespace2').css('display'), 'block', 'no effect to namespaced nodes before binding');
+
+ ok.bind(vm1, 'namespace1');
+
+ strictEqual($('#namespace1').css('display'), 'none', 'namespaced nodes can bind to view model');
+ strictEqual($('#namespace2').css('display'), 'block', 'other namespaces are unaffected');
+
+ ok.bind(vm2, 'namespace2');
+
+ strictEqual($('#namespace2').css('display'), 'none', 'other namespaces can bind to different view models');
+
+ vm1.visible(true);
+
+ strictEqual($('#namespace1').css('display'), 'block', 'namespaced view models update nodes on change');
+ strictEqual($('#namespace2').css('display'), 'none', 'other namespaces are unaffected');
+
+ ok.unbind('namespace1');
+ vm1.visible(false);
+ vm2.visible(true);
+
+ strictEqual($('#namespace1').css('display'), 'block', 'namespaces can be unbound');
+ strictEqual($('#namespace2').css('display'), 'block', 'other namespaces are unaffected');
+ });
+
+});
29 test/bindings/css.js
@@ -0,0 +1,29 @@
+$(document).ready(function() {
+
+ module("Binding - Css");
+
+ test("setting two classes from a base", function() {
+ var div = $('#testCss');
+
+ var vm = {
+ first: ok.base(false),
+ second: ok.base(true)
+ };
+
+ strictEqual($(div).hasClass('default'), true, 'div starts with class "default"');
+
+ ok.bind(vm, 'css');
+
+ strictEqual($(div).hasClass('default'), true, 'div keeps original classes after binding');
+ strictEqual($(div).hasClass('first'), false, '"first" class is false after binding');
+ strictEqual($(div).hasClass('second'), true, '"second" class is true after binding');
+
+ vm.first(true);
+ vm.second(false);
+
+ strictEqual($(div).hasClass('first'), true, '"first" class is true after update');
+ strictEqual($(div).hasClass('second'), false, '"second" class is false after update');
+
+ });
+
+});
199 test/bindings/debounce.js
@@ -0,0 +1,199 @@
+$(document).ready(function() {
+
+ module("Bindings - Debouncing");
+
+ var repeat_renders = 0,
+ html_renders = 0;
+
+ // Hijack repeater update
+ var repeat_update = ok.debug.RepeatBinding.prototype._update;
+ ok.debug.RepeatBinding.prototype._update = function(val) {
+ repeat_renders++;
+ repeat_update.call(this, val);
+ }
+
+ // Hijack html update
+ var html_update = ok.debug.HtmlBinding.prototype._update;
+ ok.debug.HtmlBinding.prototype._update = function(val) {
+ html_renders++;
+ html_update.call(this, val);
+ }
+
+ function Base(val) {
+ this.value = ok.base(val);
+ this.label = ok.dependent(function() {
+ return parseInt(this.value(), 10);
+ }, this);
+ }
+
+ function Incrementor(previous) {
+ this.prev = previous;
+
+ this.value = ok.dependent(function() {
+ return (this.prev.value() + 1);
+ }, this);
+
+ this.label = ok.dependent(function() {
+ return parseInt(this.value(), 10);
+ }, this);
+ }
+
+ var vm = {
+ items: ok.collection([])
+ };
+
+ var ul = $('#testDebounce');
+
+ test("multiple updates to a single repeat binding", function() {
+
+ expect(9);
+
+ repeat_renders = 0;
+ html_renders = 0;
+
+ strictEqual(ul.children().size(), 0, 'Size should be empty before binding.');
+
+ var bindings = ok.bind(vm, 'debounce');
+
+ strictEqual(ul.children().size(), 0, 'Size should be empty before adding items.');
+ strictEqual(repeat_renders, 0, 'Renders should be zero before call stack is empty.');
+ strictEqual(bindings.length, 1, 'Should have one (repeater) binding.');
+
+ stop();
+ setTimeout(function() {
+ strictEqual(repeat_renders, 1, 'Renders should be one before adding items.');
+ start();
+ }, 100);
+
+ // Add initial items
+ var last = new Base(1);
+ vm.items.push(last);
+ for(var i = 0; i < 9; i++) {
+ last = new Incrementor(last);
+ vm.items.push(last);
+ }
+
+ stop();
+ setTimeout(function() {
+ strictEqual(ul.children().size(), 10, 'Size should be 10 after adding items.');
+ strictEqual(repeat_renders, 1, 'Renders should be 1 after adding items.');
+ strictEqual($(ul.children().get(0)).html(), '1', 'First item should be 1.');
+ strictEqual($(ul.children().get(9)).html(), '10', 'Last item should be 10.');
+ start();
+ }, 100);
+
+
+ });
+
+ test("multiple updates to a single html binding", function() {
+
+ expect(3);
+
+ html_renders = 0;
+
+ // Change the base item
+ for(var i = 101; i < 111; i++) {
+ vm.items()[0].value(i);
+ }
+
+ stop();
+ setTimeout(function() {
+ strictEqual(html_renders, 10, 'Renders should be 10 after changing the base item.');
+ strictEqual($(ul.children().get(0)).html(), '110', 'First item should be 101.');
+ strictEqual($(ul.children().get(9)).html(), '119', 'Last item should be 110.');
+ start();
+ }, 100);
+
+ });
+
+ var then = 0;
+ function timer() {
+ var now = new Date().getTime();
+ var time = now - then;
+ then = now;
+ return time;
+ }
+
+ test("adding new items to bound repeaters", function() {
+
+ var renderVM = {
+ items: ok.collection([])
+ };
+
+ html_renders = 0;
+
+ ok.bind(renderVM, 'dbrender');
+
+ strictEqual(html_renders, 0, 'Renders should be 0 before adding items.');
+
+ // Add initial items
+ var last = new Base(1);
+ renderVM.items.push(last);
+ for(var i = 0; i < 9; i++) {
+ last = new Incrementor(last);
+ renderVM.items.push(last);
+ }
+
+ strictEqual(html_renders, 0, 'Renders should be 0 after adding items.');
+
+ });
+
+ return;
+
+ test("speed test", function() {
+
+ var item_count = 256;
+ var change_range = 256;
+
+ var bindVM = {
+ items: ok.collection([])
+ };
+
+ var watchVM = {
+ items: ok.collection([])
+ };
+
+ ok.bind(bindVM, 'bindSpeed');
+ ok.bind(watchVM, 'watchSpeed');
+
+ timer();
+
+ // Add initial items to bindVM
+ last = new Base(1);
+ bindVM.items.push(last);
+ for(var i = 0; i < item_count; i++) {
+ last = new Incrementor(last);
+ bindVM.items.push(last);
+ }
+
+ var bind_add = timer();
+
+ // Change the base item
+ for(var i = 100; i < 100 + change_range; i++) {
+ bindVM.items()[0].value(i);
+ }
+
+ var bind_update = timer();
+
+ // Add initial items to watchVM
+ last = new Base(1);
+ watchVM.items.push(last);
+ for(var i = 0; i < item_count; i++) {
+ last = new Incrementor(last);
+ watchVM.items.push(last);
+ }
+
+ var watch_add = timer();
+
+ // Change the base item
+ for(var i = 100; i < 100 + change_range; i++) {
+ watchVM.items()[0].value(i);
+ }
+
+ var watch_update = timer();
+
+ console.log("bind add: " + bind_add + ", update: " + bind_update);
+ console.log("watch add: " + watch_add + ", update: " + watch_update);
+ });
+
+});
23 test/bindings/init.js
@@ -0,0 +1,23 @@
+$(document).ready(function() {
+
+ module("Binding - Init");
+
+ test("calling initialization function on bind", function() {
+ var div = $('#testInit');
+
+ var testVM = {
+ build_dom: function(el) {
+ // Modify the element here
+ $(el).append($('<div>New div</div>'));
+ }
+ };
+
+ strictEqual(div.children().size(), 0, 'div should start with 0 children.');
+
+ ok.bind(testVM, 'init');
+
+ strictEqual(div.children().size(), 1, 'div should have 1 child after binding.');
+
+ });
+
+});
64 test/bindings/repeat.js
@@ -0,0 +1,64 @@
+$(document).ready(function() {
+
+ module("Binding - Repeat");
+
+ test("binding a repeater", function() {
+
+ var vm = {
+ products: ok.collection([])
+ };
+
+ var ul = $('#testRepeat ul');
+
+ strictEqual(ul.children().size(), 3, 'Container node has 3 elements before binding');
+
+ ok.bind(vm, 'repeat');
+
+ strictEqual(ul.children().size(), 0, 'Container node is empty while collection is empty');
+
+ vm.products.push({
+ name: 'Product name A',
+ price: ok.base(1)
+ });
+
+ strictEqual(ul.children().size(), 1, 'Container successfully updates after first item is added');
+
+ vm.products.push({
+ name: ok.base('Product name B'),
+ price: 5
+ });
+
+ strictEqual(ul.children().size(), 2, 'Container successfully updates after second item is added');
+ strictEqual(ul.children().last().find('h3').html(), 'Product name B', 'H3 of second item is accurate');
+ strictEqual(ul.children().first().find('span').html(), '1', 'price of first item is accurate');
+
+ vm.products()[1].name('Updated product name');
+
+ vm.products()[0].price(100);
+
+ strictEqual(ul.children().last().find('h3').html(), 'Updated product name', 'Updated H3 of second item is accurate');
+ strictEqual(ul.children().first().find('span').html(), '100', 'updated price of first item is accurate');
+
+ vm.products.push({
+ name: ok.base('Product name C'),
+ price: ok.base(27)
+ });
+
+ strictEqual(ul.children().last().find('h3').html(), 'Product name C', 'H3 of new item is accurate');
+
+ vm.products.splice(1, 1);
+
+ strictEqual(ul.children().size(), 2, 'Container successfully removed middle item');
+ strictEqual(ul.children().last().find('h3').html(), 'Product name C', 'H3 is still accurate after removing middle item');
+
+ vm.products([{
+ name: 'Final name',
+ price: 1337
+ }]);
+
+ strictEqual(ul.children().size(), 1, 'Setting an entirely new array updates the UI');
+ strictEqual(ul.children().last().find('h3').html(), 'Final name', 'H3 is Final name');
+
+ });
+
+});
48 test/bindings/submit.js
@@ -0,0 +1,48 @@
+$(document).ready(function() {
+
+ module("Binding - Submit");
+
+ test("reading and updating values", function() {
+ var input = $('#valueInput');
+
+ var valueVM = {
+ testValue: ok.base('one')
+ };
+
+ ok.bind(valueVM, 'value');
+
+ strictEqual(input.val(), 'one', 'binding sets value on dom node');
+
+ valueVM.testValue('two');
+
+ strictEqual(input.val(), 'two', 'updating bound value updates dom node');
+
+ ok.unbind('value');
+
+ valueVM.testValue('three');
+
+ strictEqual(input.val(), 'two', "dom nodes don't update after unbinding");
+ });
+
+ test("writing values from the dom", function() {
+ var input = $('#valueInput');
+
+ var valueVM = {
+ testValue: ok.base('preset')
+ };
+
+ input.val('');
+
+ strictEqual(input.val(), '', 'input box begins empty');
+
+ ok.bind(valueVM, 'value');
+
+ strictEqual(input.val(), 'preset', 'input box updates to vm value on bind');
+
+ input.val('written');
+ input.trigger('keyup');
+
+ strictEqual(valueVM.testValue(), 'written', 'input box edits change the vm values on blur');
+ });
+
+});
86 test/bindings/value.js
@@ -0,0 +1,86 @@
+$(document).ready(function() {
+
+ module("Binding - Value");
+
+ test("reading and updating values", function() {
+ var input = $('#valueInput');
+
+ var valueVM = {
+ testValue: ok.base('one')
+ };
+
+ ok.bind(valueVM, 'value');
+
+ strictEqual(input.val(), 'one', 'binding sets value on dom node');
+
+ valueVM.testValue('two');
+
+ strictEqual(input.val(), 'two', 'updating bound value updates dom node');
+
+ ok.unbind('value');
+
+ valueVM.testValue('three');
+
+ strictEqual(input.val(), 'two', "dom nodes don't update after unbinding");
+ });
+
+ test("writing values from the dom", function() {
+ var input = $('#valueInput');
+
+ var valueVM = {
+ testValue: ok.base('preset')
+ };
+
+ input.val('');
+
+ strictEqual(input.val(), '', 'input box begins empty');
+
+ ok.bind(valueVM, 'value');
+
+ strictEqual(input.val(), 'preset', 'input box updates to vm value on bind');
+
+ input.val('written');
+ input.trigger('keyup');
+
+ strictEqual(valueVM.testValue(), 'written', 'input box edits change the vm values on blur');
+ });
+
+ test("checkboxes", function() {
+ expect(5);
+
+ var input = $('#valueCheck');
+
+ var valueVM = {
+ toggle: ok.base(true)
+ };
+
+ strictEqual(input.is(':checked'), false, 'checkbox is initially false');
+
+ ok.bind(valueVM, 'check');
+
+ strictEqual(input.is(':checked'), true, 'checkbox should be checked after binding.');
+
+ valueVM.toggle(false);
+
+ strictEqual(input.is(':checked'), false, 'checkbox should be unchecked after VM change.');
+
+ input.attr('checked', 'checked');
+ input.trigger('change');
+
+ stop();
+ setTimeout(function() {
+ strictEqual(valueVM.toggle(), true, 'VM should be true after checkbox click.');
+ input.removeAttr('checked');
+ input.trigger('change');
+ start();
+ }, 1);
+
+ stop();
+ setTimeout(function() {
+ strictEqual(valueVM.toggle(), false, 'VM should be false after another checkbox click.');
+ start();
+ }, 10);
+
+ });
+
+});
602 test/collection.regression.js
@@ -0,0 +1,602 @@
+$(document).ready(function() {
+
+ var lastRequest = null;
+ var sync = Backbone.sync;
+
+ var a, b, c, d, e, col, otherCol;
+
+ module("Backbone.Collection", {
+
+ setup: function() {
+ a = new Backbone.Model({id: 3, label: 'a'});
+ b = new Backbone.Model({id: 2, label: 'b'});
+ c = new Backbone.Model({id: 1, label: 'c'});
+ d = new Backbone.Model({id: 0, label: 'd'});
+ e = null;
+ col = new Backbone.Collection([a,b,c,d]);
+ otherCol = new Backbone.Collection();
+
+ Backbone.sync = function(method, model, options) {
+ lastRequest = {
+ method: method,
+ model: model,
+ options: options
+ };
+ };
+ },
+
+ teardown: function() {
+ Backbone.sync = sync;
+ }
+
+ });
+
+ test("Collection: new and sort", function() {
+ equal(col.first(), a, "a should be first");
+ equal(col.last(), d, "d should be last");
+ col.comparator = function(a, b) {
+ return a.id > b.id ? -1 : 1;
+ };
+ col.sort();
+ equal(col.first(), a, "a should be first");
+ equal(col.last(), d, "d should be last");
+ col.comparator = function(model) { return model.id; };
+ col.sort();
+ equal(col.first(), d, "d should be first");
+ equal(col.last(), a, "a should be last");
+ equal(col.length, 4);
+ });
+
+ test("Collection: get, getByCid", function() {
+ equal(col.get(0), d);
+ equal(col.get(2), b);
+ equal(col.getByCid(col.first().cid), col.first());
+ });
+
+ test("Collection: get with non-default ids", function() {
+ var col = new Backbone.Collection();
+ var MongoModel = Backbone.Model.extend({
+ idAttribute: '_id'
+ });
+ var model = new MongoModel({_id: 100});
+ col.push(model);
+ equal(col.get(100), model);
+ model.set({_id: 101});
+ equal(col.get(101), model);
+ });
+
+ test("Collection: update index when id changes", function() {
+ var col = new Backbone.Collection();
+ col.add([
+ {id : 0, name : 'one'},
+ {id : 1, name : 'two'}
+ ]);
+ var one = col.get(0);
+ equal(one.get('name'), 'one');
+ one.set({id : 101});
+ equal(col.get(0), null);
+ equal(col.get(101).get('name'), 'one');
+ });
+
+ test("Collection: at", function() {
+ equal(col.at(2), c);
+ });
+
+ test("Collection: pluck", function() {
+ equal(col.pluck('label').join(' '), 'a b c d');
+ });
+
+ test("Collection: add", function() {
+ var added, opts, secondAdded;
+ added = opts = secondAdded = null;
+ e = new Backbone.Model({id: 10, label : 'e'});
+ otherCol.add(e);
+ otherCol.bind('add', function() {
+ secondAdded = true;
+ });
+ col.bind('add', function(model, collection, options){
+ added = model.get('label');
+ equal(options.index, 4);
+ opts = options;
+ });
+ col.add(e, {amazing: true});
+ equal(added, 'e');
+ equal(col.length, 5);
+ equal(col.last(), e);
+ equal(otherCol.length, 1);
+ equal(secondAdded, null);
+ ok(opts.amazing);
+
+ var f = new Backbone.Model({id: 20, label : 'f'});
+ var g = new Backbone.Model({id: 21, label : 'g'});
+ var h = new Backbone.Model({id: 22, label : 'h'});
+ var atCol = new Backbone.Collection([f, g, h]);
+ equal(atCol.length, 3);
+ atCol.add(e, {at: 1});
+ equal(atCol.length, 4);
+ equal(atCol.at(1), e);
+ equal(atCol.last(), h);
+ });
+
+ test("Collection: add multiple models", function() {
+ var col = new Backbone.Collection([{at: 0}, {at: 1}, {at: 9}]);
+ col.add([{at: 2}, {at: 3}, {at: 4}, {at: 5}, {at: 6}, {at: 7}, {at: 8}], {at: 2});
+ for (var i = 0; i <= 5; i++) {
+ equal(col.at(i).get('at'), i);
+ }
+ });
+
+ test("Collection: add; at should have preference over comparator", function() {
+ var Col = Backbone.Collection.extend({
+ comparator: function(a,b) {
+ return a.id > b.id ? -1 : 1;
+ }
+ });
+
+ var col = new Col([{id: 2}, {id: 3}]);
+ col.add(new Backbone.Model({id: 1}), {at: 1});
+
+ equal(col.pluck('id').join(' '), '3 1 2');
+ });
+
+ test("Collection: can't add model to collection twice", function() {
+ var col = new Backbone.Collection([{id: 1}, {id: 2}, {id: 1}, {id: 2}, {id: 3}]);
+ equal(col.pluck('id').join(' '), '1 2 3');
+ });
+
+ test("Collection: can't add different model with same id to collection twice", function() {
+ var col = new Backbone.Collection;
+ col.unshift({id: 101});
+ col.add({id: 101});
+ equal(col.length, 1);
+ });
+
+ test("Collection: merge in duplicate models with {merge: true}", function() {
+ var col = new Backbone.Collection;
+ col.add([{id: 1, name: 'Moe'}, {id: 2, name: 'Curly'}, {id: 3, name: 'Larry'}]);
+ col.add({id: 1, name: 'Moses'});
+ equal(col.first().get('name'), 'Moe');
+ col.add({id: 1, name: 'Moses'}, {merge: true});
+ equal(col.first().get('name'), 'Moses');
+ });
+
+ test("Collection: add model to multiple collections", function() {
+ var counter = 0;
+ var e = new Backbone.Model({id: 10, label : 'e'});
+ e.bind('add', function(model, collection) {
+ counter++;
+ equal(e, model);
+ if (counter > 1) {
+ equal(collection, colF);
+ } else {
+ equal(collection, colE);
+ }
+ });
+ var colE = new Backbone.Collection([]);
+ colE.bind('add', function(model, collection) {
+ equal(e, model);
+ equal(colE, collection);
+ });
+ var colF = new Backbone.Collection([]);
+ colF.bind('add', function(model, collection) {
+ equal(e, model);
+ equal(colF, collection);
+ });
+ colE.add(e);
+ equal(e.collection, colE);
+ colF.add(e);
+ equal(e.collection, colE);
+ });
+
+ test("Collection: add model with parse", function() {
+ var Model = Backbone.Model.extend({
+ parse: function(obj) {
+ obj.value += 1;
+ return obj;
+ }
+ });
+
+ var Col = Backbone.Collection.extend({model: Model});
+ var col = new Col;
+ col.add({value: 1}, {parse: true});
+ equal(col.at(0).get('value'), 2);
+ });
+
+ test("Collection: add model to collection with sort()-style comparator", function() {
+ var col = new Backbone.Collection;
+ col.comparator = function(a, b) {
+ return a.get('name') < b.get('name') ? -1 : 1;
+ };
+ var tom = new Backbone.Model({name: 'Tom'});
+ var rob = new Backbone.Model({name: 'Rob'});
+ var tim = new Backbone.Model({name: 'Tim'});
+ col.add(tom);
+ col.add(rob);
+ col.add(tim);
+ equal(col.indexOf(rob), 0);
+ equal(col.indexOf(tim), 1);
+ equal(col.indexOf(tom), 2);
+ });
+
+ test("Collection: comparator that depends on `this`", function() {
+ var col = new Backbone.Collection;
+ col.negative = function(num) {
+ return -num;
+ };
+ col.comparator = function(a) {
+ return this.negative(a.id);
+ };
+ col.add([{id: 1}, {id: 2}, {id: 3}]);
+ equal(col.pluck('id').join(' '), '3 2 1');
+ });
+
+ test("Collection: remove", function() {
+ var removed = null;
+ var otherRemoved = null;
+ col.bind('remove', function(model, col, options) {
+ removed = model.get('label');
+ equal(options.index, 3);
+ });
+ otherCol.bind('remove', function(model, col, options) {
+ otherRemoved = true;
+ });
+ col.remove(d);
+ equal(removed, 'd');
+ equal(col.length, 3);
+ equal(col.first(), a);
+ equal(otherRemoved, null);
+ });
+
+ test("Collection: shift and pop", function() {
+ var col = new Backbone.Collection([{a: 'a'}, {b: 'b'}, {c: 'c'}]);
+ equal(col.shift().get('a'), 'a');
+ equal(col.pop().get('c'), 'c');
+ });
+
+ test("Collection: events are unbound on remove", function() {
+ var counter = 0;
+ var dj = new Backbone.Model();
+ var emcees = new Backbone.Collection([dj]);
+ emcees.bind('change', function(){ counter++; });
+ dj.set({name : 'Kool'});
+ equal(counter, 1);
+ emcees.reset([]);
+ equal(dj.collection, undefined);
+ dj.set({name : 'Shadow'});
+ equal(counter, 1);
+ });
+
+ test("Collection: remove in multiple collections", function() {
+ var modelData = {
+ id : 5,
+ title : 'Othello'
+ };
+ var passed = false;
+ var e = new Backbone.Model(modelData);
+ var f = new Backbone.Model(modelData);
+ f.bind('remove', function() {
+ passed = true;
+ });
+ var colE = new Backbone.Collection([e]);
+ var colF = new Backbone.Collection([f]);
+ ok(e != f);
+ ok(colE.length == 1);
+ ok(colF.length == 1);
+ colE.remove(e);
+ equal(passed, false);
+ ok(colE.length == 0);
+ colF.remove(e);
+ ok(colF.length == 0);
+ equal(passed, true);
+ });
+
+ test("Collection: remove same model in multiple collection", function() {
+ var counter = 0;
+ var e = new Backbone.Model({id: 5, title: 'Othello'});
+ e.bind('remove', function(model, collection) {
+ counter++;
+ equal(e, model);
+ if (counter > 1) {
+ equal(collection, colE);
+ } else {
+ equal(collection, colF);
+ }
+ });
+ var colE = new Backbone.Collection([e]);
+ colE.bind('remove', function(model, collection) {
+ equal(e, model);
+ equal(colE, collection);
+ });
+ var colF = new Backbone.Collection([e]);
+ colF.bind('remove', function(model, collection) {
+ equal(e, model);
+ equal(colF, collection);
+ });
+ equal(colE, e.collection);
+ colF.remove(e);
+ ok(colF.length == 0);
+ ok(colE.length == 1);
+ equal(counter, 1);
+ equal(colE, e.collection);
+ colE.remove(e);
+ equal(null, e.collection);
+ ok(colE.length == 0);
+ equal(counter, 2);
+ });
+
+ test("Collection: model destroy removes from all collections", function() {
+ var e = new Backbone.Model({id: 5, title: 'Othello'});
+ e.sync = function(method, model, options) { options.success({}); };
+ var colE = new Backbone.Collection([e]);
+ var colF = new Backbone.Collection([e]);
+ e.destroy();
+ ok(colE.length == 0);
+ ok(colF.length == 0);
+ equal(undefined, e.collection);
+ });
+
+ test("Colllection: non-persisted model destroy removes from all collections", function() {
+ var e = new Backbone.Model({title: 'Othello'});
+ e.sync = function(method, model, options) { throw "should not be called"; };
+ var colE = new Backbone.Collection([e]);
+ var colF = new Backbone.Collection([e]);
+ e.destroy();
+ ok(colE.length == 0);
+ ok(colF.length == 0);
+ equal(undefined, e.collection);
+ });
+
+ test("Collection: fetch", function() {
+ col.fetch();
+ equal(lastRequest.method, 'read');
+ equal(lastRequest.model, col);
+ equal(lastRequest.options.parse, true);
+
+ col.fetch({parse: false});
+ equal(lastRequest.options.parse, false);
+ });
+
+ test("Collection: create", function() {
+ var model = col.create({label: 'f'}, {wait: true});
+ equal(lastRequest.method, 'create');
+ equal(lastRequest.model, model);
+ equal(model.get('label'), 'f');
+ equal(model.collection, col);
+ });
+
+ test("Collection: create enforces validation", function() {
+ var ValidatingModel = Backbone.Model.extend({
+ validate: function(attrs) {
+ return "fail";
+ }
+ });
+ var ValidatingCollection = Backbone.Collection.extend({
+ model: ValidatingModel
+ });
+ var col = new ValidatingCollection();
+ equal(col.create({"foo":"bar"}), false);
+ });
+
+ test("Collection: a failing create runs the error callback", function() {
+ var ValidatingModel = Backbone.Model.extend({
+ validate: function(attrs) {
+ return "fail";
+ }
+ });
+ var ValidatingCollection = Backbone.Collection.extend({
+ model: ValidatingModel
+ });
+ var flag = false;
+ var callback = function(model, error) { flag = true; };
+ var col = new ValidatingCollection();
+ col.create({"foo":"bar"}, { error: callback });
+ equal(flag, true);
+ });
+
+ test("collection: initialize", function() {
+ var Collection = Backbone.Collection.extend({
+ initialize: function() {
+ this.one = 1;
+ }
+ });
+ var coll = new Collection;
+ equal(coll.one, 1);
+ });
+
+ test("Collection: toJSON", function() {
+ equal(JSON.stringify(col), '[{"id":3,"label":"a"},{"id":2,"label":"b"},{"id":1,"label":"c"},{"id":0,"label":"d"}]');
+ });
+
+ test("Collection: where", function() {
+ var coll = new Backbone.Collection([
+ {a: 1},
+ {a: 1},
+ {a: 1, b: 2},
+ {a: 2, b: 2},
+ {a: 3}
+ ]);
+ equal(coll.where({a: 1}).length, 3);
+ equal(coll.where({a: 2}).length, 1);
+ equal(coll.where({a: 3}).length, 1);
+ equal(coll.where({b: 1}).length, 0);
+ equal(coll.where({b: 2}).length, 2);
+ equal(coll.where({a: 1, b: 2}).length, 1);
+ });
+
+ test("Collection: Underscore methods", function() {
+ equal(col.map(function(model){ return model.get('label'); }).join(' '), 'a b c d');
+ equal(col.any(function(model){ return model.id === 100; }), false);
+ equal(col.any(function(model){ return model.id === 0; }), true);
+ equal(col.indexOf(b), 1);
+ equal(col.size(), 4);
+ equal(col.rest().length, 3);
+ ok(!_.include(col.rest()), a);
+ ok(!_.include(col.rest()), d);
+ ok(!col.isEmpty());
+ ok(!_.include(col.without(d)), d);
+ equal(col.max(function(model){ return model.id; }).id, 3);
+ equal(col.min(function(model){ return model.id; }).id, 0);
+ deepEqual(col.chain()
+ .filter(function(o){ return o.id % 2 === 0; })
+ .map(function(o){ return o.id * 2; })
+ .value(),
+ [4, 0]);
+ });
+
+ test("Collection: reset", function() {
+ var resetCount = 0;
+ var models = col.models;
+ col.bind('reset', function() { resetCount += 1; });
+ col.reset([]);
+ equal(resetCount, 1);
+ equal(col.length, 0);
+ equal(col.last(), null);
+ col.reset(models);
+ equal(resetCount, 2);
+ equal(col.length, 4);
+ equal(col.last(), d);
+ col.reset(_.map(models, function(m){ return m.attributes; }));
+ equal(resetCount, 3);
+ equal(col.length, 4);
+ ok(col.last() !== d);
+ ok(_.isEqual(col.last().attributes, d.attributes));
+ });
+
+ test("Collection: reset passes caller options", function() {
+ var Model = Backbone.Model.extend({
+ initialize: function(attrs, options) {
+ this.model_parameter = options.model_parameter;
+ }
+ });
+ var col = new (Backbone.Collection.extend({ model: Model }))();
+ col.reset([{ astring: "green", anumber: 1 }, { astring: "blue", anumber: 2 }], { model_parameter: 'model parameter' });
+ equal(col.length, 2);
+ col.each(function(model) {
+ equal(model.model_parameter, 'model parameter');
+ });
+ });
+
+ test("Collection: trigger custom events on models", function() {
+ var fired = null;
+ a.bind("custom", function() { fired = true; });
+ a.trigger("custom");
+ equal(fired, true);
+ });
+
+ test("Collection: add does not alter arguments", function(){
+ var attrs = {};
+ var models = [attrs];
+ new Backbone.Collection().add(models);
+ equal(models.length, 1);
+ ok(attrs === models[0]);
+ });
+
+ test("#714: access `model.collection` in a brand new model.", 2, function() {
+ var col = new Backbone.Collection;
+ var Model = Backbone.Model.extend({
+ set: function(attrs) {
+ equal(attrs.prop, 'value');
+ equal(this.collection, col);
+ return this;
+ }
+ });
+ col.model = Model;
+ col.create({prop: 'value'});
+ });
+
+ test("#574, remove its own reference to the .models array.", function() {
+ var col = new Backbone.Collection([
+ {id: 1}, {id: 2}, {id: 3}, {id: 4}, {id: 5}, {id: 6}
+ ]);
+ equal(col.length, 6);
+ col.remove(col.models);
+ equal(col.length, 0);
+ });
+
+ test("#861, adding models to a collection which do not pass validation", function() {
+ raises(function() {
+ var Model = Backbone.Model.extend({
+ validate: function(attrs) {
+ if (attrs.id == 3) return "id can't be 3";
+ }
+ });
+
+ var Collection = Backbone.Collection.extend({
+ model: Model
+ });
+
+ var col = new Collection;
+
+ col.add([{id: 1}, {id: 2}, {id: 3}, {id: 4}, {id: 5}, {id: 6}]);
+ }, function(e) {
+ return e.message === "Can't add an invalid model to a collection";
+ });
+ });
+
+ test("Collection: index with comparator", function() {
+ expect(4);
+ var counter = 0;
+ var col = new Backbone.Collection([{id: 2}, {id: 4}], {
+ comparator: function(model){ return model.id; }
+ }).on('add', function(model, colleciton, options){
+ if (model.id == 1) {
+ equal(options.index, 0);
+ equal(counter++, 0);
+ }
+ if (model.id == 3) {
+ equal(options.index, 2);
+ equal(counter++, 1);
+ }
+ });
+ col.add([{id: 3}, {id: 1}]);
+ });
+
+ test("Collection: throwing during add leaves consistent state", function() {
+ expect(4);
+ var col = new Backbone.Collection();
+ col.bind('test', function() { ok(false); });
+ col.model = Backbone.Model.extend({
+ validate: function(attrs){ if (!attrs.valid) return 'invalid'; }
+ });
+ var model = new col.model({id: 1, valid: true});
+ raises(function() { col.add([model, {id: 2}]); });
+ model.trigger('test');
+ ok(!col.getByCid(model.cid));
+ ok(!col.get(1));
+ equal(col.length, 0);
+ });
+
+ test("Collection: multiple copies of the same model", function() {
+ var col = new Backbone.Collection();
+ var model = new Backbone.Model();
+ col.add([model, model]);
+ equal(col.length, 1);
+ col.add([{id: 1}, {id: 1}]);
+ equal(col.length, 2);
+ equal(col.last().id, 1);
+ });
+
+ test("#964 - collection.get return in consistent", function() {
+ var c = new Backbone.Collection();
+ ok(c.get(null) === undefined);
+ ok(c.get() === undefined);
+ });
+
+ test("#1112 - passing options.model sets collection.model", function() {
+ var Model = Backbone.Model.extend({});
+ var c = new Backbone.Collection([{id: 1}], {model: Model});
+ ok(c.model === Model);
+ ok(c.at(0) instanceof Model);
+ });
+
+ test("null and undefined are invalid ids.", function() {
+ var model = new Backbone.Model({id: 1});
+ var collection = new Backbone.Collection([model]);
+ model.set({id: null});
+ ok(!collection.get('null'));
+ model.set({id: 1});
+ model.set({id: undefined});
+ ok(!collection.get('undefined'));
+ });
+
+});
68 test/collections.js
@@ -0,0 +1,68 @@
+$(document).ready(function() {
+
+ module("Collections");
+
+ test("creating a collection", function() {
+ var collection, a = ['abc', 123, true];
+ collection = ok.collection(a);
+ strictEqual(collection(), a, 'can get full array');
+ strictEqual(collection()[0], 'abc', 'can get first element');
+ strictEqual(collection()[1], 123, 'can get second element');
+ strictEqual(collection()[2], true, 'can get third element');
+ collection = ok.collection([0, 1, 2, 3, 4]);
+ strictEqual(collection().length, 5, 'collection can be replaced');
+ strictEqual(collection()[0], 0, 'collection can be replaced');
+ });
+
+ test("undefined collections", function() {
+ var collection = ok.collection();
+ strictEqual(typeof(collection()), 'undefined', 'collection can be instantiated without an array');
+ collection.push(1);
+ strictEqual(collection()[0], 1, 'array method access converts undefined collection to empty array')
+ });
+
+ test("array methods on collection object (pop, push, reverse, etc...)", function() {
+ var a = [], collection, i, ret;
+ for (i = 0; i < 100; i++) {
+ a.push(i);
+ }
+ collection = ok.collection(a);
+ ret = collection.pop();
+ strictEqual(ret, 99, '.pop returns correct value');
+ strictEqual(collection().length, 99, '.pop removes last value');
+ ret = collection.push('abc');
+ strictEqual(collection()[99], 'abc', '.push adds correct value to end');
+ strictEqual(ret, 100, '.push increases length');
+ collection.reverse();
+ strictEqual(collection()[0], 'abc', '.reverse reverses order');
+ strictEqual(collection()[99], 0, '.reverse reverses order');
+ ret = collection.shift();
+ strictEqual(ret, 'abc', '.shift returns correct value');
+ strictEqual(collection().length, 99, '.shift decreases length');
+ collection.sort();
+ strictEqual(collection()[0], 0, '.sort sorts by value');
+ strictEqual(collection()[98], 98, '.sort sorts by value');
+ collection.splice(10, 3, 'abc');
+ strictEqual(collection()[10], 'abc', '.splice inserts correct value at correct position');
+ strictEqual(collection()[11], 20, '.splice correctly removes elements');
+ collection.unshift('beginning');
+ strictEqual(collection()[0], 'beginning', '.unshift adds value to beginning of array');
+ strictEqual(collection().length, 98, '.unshift increases length of array');
+ });
+
+ test('tracking dependencies on collections', function() {
+ var collection = ok.collection([1, 2, 3]),
+ dependent = ok.dependent(function() {
+ var c = collection();
+ return c[c.length - 1];
+ });
+ strictEqual(collection().length, 3, 'collection has a length of 3');
+ strictEqual(collection()[0], 1, 'collection starts with 1');
+ strictEqual(collection()[2], 3, 'collection ends with 3');
+ strictEqual(dependent(), 3, 'dependent maps to last element in collection');
+ collection.push(4);
+ strictEqual(collection().length, 4, 'collection now has a length of 5');
+ strictEqual(dependent(), 4, 'dependent still maps to last element in collection');
+ });
+
+});
59 test/compiled.html
@@ -0,0 +1,59 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>OkayJS Test Suite</title>
+
+ <!-- QUnit (must go at top!) -->
+
+ <link rel="stylesheet" href="../support/qunit.css" type="text/css" media="screen" />
+ <script type="text/javascript" src="../support/qunit.js"></script>
+
+ <!-- Okay -->
+
+ <script src="../support/underscore-min.js"></script>
+ <script src="../support/zepto.min.js"></script>
+
+ <script src="../build/output/okay-latest.js"></script>
+
+ <!-- Tests -->
+
+ <script type="text/javascript" src="bases.js"></script>
+ <script type="text/javascript" src="collections.js"></script>
+ <script type="text/javascript" src="dependents.js"></script>
+ <script type="text/javascript" src="bindings.js"></script>
+ <script type="text/javascript" src="complex.js"></script>
+
+ <script type="text/javascript" src="bindings/value.js"></script>
+ <script type="text/javascript" src="bindings/repeat.js"></script>
+
+</head>
+<body>
+ <h1 id="qunit-header">OkayJS Test Suite</h1>
+ <h2 id="qunit-banner"></h2>
+ <h2 id="qunit-userAgent"></h2>
+ <ol id="qunit-tests"></ol>
+
+ <div id='testDom'>
+ <p id='testHtml' data-bind='html: testHtml'>html</p>
+ <p id='testVisible' data-bind='visible: testVisible'>visible</p>
+ <p id='namespace1' data-bind-namespace1='visible: visible'>namespace 1</p>
+ <p id='namespace2' data-bind-namespace2='visible: visible'>namespace 2</p>
+ </div>
+
+ <div id='testValue'>
+ <input id='valueInput' data-bind-value='value: testValue' />
+ </div>
+
+ <div id='testRepeat'>
+ <ul data-bind-repeat='repeat: { template: "productTemplate", collection: products }'><li>Test</li><li>test2</li><li>test3</li></ul>
+ </div>
+
+ <script id='productTemplate' type='text/html'>
+ <li>
+ <h3 data-bind='html: name'>Name</h3>
+ <p>$<span data-bind='html: price'>Price</span></p>
+ </li>
+ </script>
+
+</body>
+</html>
82 test/complex.js
@@ -0,0 +1,82 @@
+$(document).ready(function() {
+
+ module("Complex");
+
+ test("a long chain of inter-related dependencies", function() {
+ var users = ok.collection([]),
+ active_user = ok.base();
+
+ var active_email = ok.dependent(function() {
+ if (active_user() && active_user().emails()) {
+ return active_user().emails()[active_user().emails().length - 1];
+ }
+ return '';
+ });
+
+ var user_number = ok.dependent(function() {
+ if (active_user()) {
+ return users().indexOf(active_user());
+ }
+ else {
+ return -1;
+ }
+ });
+
+ function User() {
+ this.first_name = ok.base('');
+ this.last_name = ok.base('');
+ this.age = ok.base(26);
+
+ this.emails = ok.collection();
+
+ this.name = ok.dependent(function() {
+ if (this.first_name() || this.last_name()) {
+ return this.first_name() + ' ' + this.last_name();
+ }
+ else {
+ return 'un-named'
+ }
+ }, this);
+
+ users.push(this);
+ }
+
+ var user1 = new User();
+
+ strictEqual(user1.name(), 'un-named', 'user1 initializes name correctly');
+
+ user1.first_name('Hunter');
+ user1.last_name('Loftis');
+
+ strictEqual(user1.name(), 'Hunter Loftis', 'user1 calculates updated name correctly');
+
+ user1.emails.push('hunter@hunterloftis.com');
+ user1.emails.push('hunter@skookum.com');
+
+ strictEqual(user1.emails()[1], 'hunter@skookum.com', 'user2 updates email correctly');
+
+ var user2 = new User();
+ user2.first_name('Jim');
+ user2.last_name('Snodgrass');
+ user2.emails.push('jim@skookum.com');
+
+ strictEqual(users().length, 2, 'two users have been entered');
+ strictEqual(user_number(), -1, 'user_number finds no user yet');
+ strictEqual(active_email(), '', 'active_email starts as an empty string');
+
+ active_user(user1);
+
+ strictEqual(user_number(), 0, 'user_number finds the first user');
+ strictEqual(active_email(), 'hunter@skookum.com', 'active_email updates correctly');
+
+ active_user(user2);
+
+ strictEqual(user_number(), 1, 'user_number finds the first user');
+ strictEqual(active_email(), 'jim@skookum.com', 'active_email updates correctly');
+
+ //strictEqual(obj.dependentTriple(), 3, 'dependent initializes accurately');
+
+ });
+
+
+});
75 test/computed.js
@@ -0,0 +1,75 @@
+$(document).ready(function() {
+
+ module("Dependents");
+
+ test("calculations based on bases", function() {
+ var obj = {};
+ obj.base = ok.base(1);
+ obj.dependentTriple = ok.dependent(function() {
+ return this.base() * 3;
+ }, obj);
+ strictEqual(obj.dependentTriple(), 3, 'dependent initializes accurately');
+ obj.base(2);
+ strictEqual(obj.dependentTriple(), 6, 'dependent recomputes accurately');
+ });
+
+ test('subscribing to updates', function() {
+ var obj = {}, dependent;
+ obj.base = ok.base(1);
+ obj.dependentTriple = ok.dependent(function() {
+ return this.base() * 3;
+ }, obj);
+ obj.dependentTriple.subscribe(function(newdependent) {
+ dependent = newdependent;
+ });
+ dependent = obj.dependentTriple();
+ strictEqual(dependent, 3, 'dependent has correct starting base');
+ obj.base(2)
+ strictEqual(dependent, 6, 'subscription updates dependent automatically')
+ });
+
+ test('tracking dependencies on bases', function() {
+ var obj = {}, dependent, secondary;
+ obj.toggled = ok.base(false);
+ obj.first = ok.base('first');
+ obj.second = ok.base('second');
+ obj.dependent = ok.dependent(function() {
+ if (!this.toggled()) {
+ return this.first();
+ }
+ else {
+ return this.second();
+ }
+ }, obj);
+ obj.secondary = ok.dependent(function() {
+ return this.dependent() + '-secondary';
+ }, obj);
+ obj.dependent.subscribe(function(newdependent) {
+ dependent = newdependent;
+ });
+ obj.secondary.subscribe(function(newSecondary) {
+ secondary = newSecondary;
+ });
+ dependent = obj.dependent();
+ secondary = obj.secondary();
+ strictEqual(dependent, 'first', 'dependent evaluates accurately before dependency switch');
+ strictEqual(secondary, 'first-secondary', 'dependent based on another dependent evaluates accurately');
+ obj.toggled(true);
+ strictEqual(dependent, 'second', 'dependent evaluates accurately after dependency switch');
+ strictEqual(secondary, 'second-secondary', 'dependent dependent on another dependent updates automatically');
+ });
+
+ test('tracking dependencies on dependents', function() {
+ var base = ok.base(1),
+ dep1 = ok.dependent(function() {
+ return base() + 1;
+ }),
+ dep2 = ok.dependent(function() {
+ return dep1() + 1;
+ });
+ strictEqual(base(), 1, 'base evaluates to 1');
+ strictEqual(dep1(), 2, 'primary dependent evaluates to 2');
+ strictEqual(dep2(), 3, 'secondary dependent evaluates to 3');
+ });
+
+});
74 test/manual.html
@@ -0,0 +1,74 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>OkayJS Test Suite</title>
+
+ <!-- Okay -->
+
+ <script src="../support/underscore-min.js"></script>
+ <script src="../support/zepto.min.js"></script>
+
+ <script src="../src/ok.core.js"></script>
+
+ <script src="../src/dom/ok.dom.zepto.js"></script>
+
+ <script src="../src/bindings/ok.binding.visible.js"></script>
+ <script src="../src/bindings/ok.binding.html.js"></script>
+ <script src="../src/bindings/ok.binding.click.js"></script>
+ <script src="../src/bindings/ok.binding.submit.js"></script>
+ <script src="../src/bindings/ok.binding.value.js"></script>
+
+ <style>
+ .box {
+ border: solid 1px #ccc;
+ padding: 10px;
+ }
+ </style>
+
+</head>
+<body>
+
+ <div class='box'>
+ <p>These should all match each other</p>
+ <input data-bind='value: first' />
+ <input data-bind='value: first' />
+ <p data-bind='html: first'></p>
+ <p data-bind='visible: second'>Such a long string!</p>
+ </div>
+
+ <div class='box'>
+ <p>When you click on this, it should toggle visibility</p>
+ <a href='#' data-bind='click: third'>Click here</a>
+ <p data-bind='visible: fourth'>Now I'm visible!</p>
+ <p>This form should stop your submit</p>
+ <form data-bind='submit: interrupt'>
+ <input type='submit' value='try it' />
+ </form>
+ </div>
+
+ <script>
+ console.log("Loading...")
+ var vm = {
+ first: ok.base('test'),
+ fourth: ok.base(false)
+ };
+
+ vm.second = ok.dependent(function() {
+ return (this.first().length > 5);
+ }, vm);
+
+ vm.third = function(event) {
+ vm.fourth(!vm.fourth());
+ return false;
+ };
+
+ vm.interrupt = function(event) {
+ console.log("Interrupted form submit");
+ console.log("VM data: " + this.first());
+ }
+
+ ok.bind(vm);
+ </script>
+
+</body>
+</html>
813 test/model.regression.js
@@ -0,0 +1,813 @@
+$(document).ready(function() {
+
+ // Variable to catch the last request.
+ var lastRequest = null;
+ // Variable to catch ajax params.
+ var ajaxParams = null;
+ var sync = Backbone.sync;
+ var ajax = Backbone.ajax;
+ var urlRoot = null;
+
+ var proxy = Backbone.ViewModel.extend();
+ var klass = Backbone.Collection.extend({
+ url : function() { return '/collection'; }
+ });
+ var doc, collection;
+
+ module("Backbone.ViewModel", {
+
+ setup: function() {
+ doc = new proxy({
+ id : '1-the-tempest',
+ title : "The Tempest",
+ author : "Bill Shakespeare",
+ length : 123
+ });
+ collection = new klass();
+ collection.add(doc);
+
+ Backbone.sync = function(method, model, options) {
+ lastRequest = {
+ method: method,
+ model: model,
+ options: options
+ };
+ sync.apply(this, arguments);
+ };
+ Backbone.ajax = function(params) { ajaxParams = params; };
+ urlRoot = Backbone.ViewModel.prototype.urlRoot;
+ Backbone.ViewModel.prototype.urlRoot = '/';
+ },
+
+ teardown: function() {
+ Backbone.sync = sync;
+ Backbone.ajax = ajax;
+ Backbone.ViewModel.prototype.urlRoot = urlRoot;
+ }
+
+ });
+
+ test("Model: initialize", function() {
+ var Model = Backbone.ViewModel.extend({
+ initialize: function() {
+ this.one = 1;
+ equal(this.collection, collection);
+ }
+ });
+ var model = new Model({}, {collection: collection});
+ equal(model.one, 1);
+ equal(model.collection, collection);
+ });
+
+ test("Model: initialize with attributes and options", function() {
+ var Model = Backbone.ViewModel.extend({
+ initialize: function(attributes, options) {
+ this.one = options.one;
+ }
+ });
+ var model = new Model({}, {one: 1});
+ equal(model.one, 1);
+ });
+
+ test("Model: initialize with parsed attributes", function() {
+ var Model = Backbone.ViewModel.extend({
+ parse: function(obj) {
+ obj.value += 1;
+ return obj;
+ }
+ });
+ var model = new Model({value: 1}, {parse: true});
+ equal(model.get('value'), 2);
+ });
+
+ test("Model: url", function() {
+ doc.urlRoot = null;
+ equal(doc.url(), '/collection/1-the-tempest');
+ doc.collection.url = '/collection/';
+ equal(doc.url(), '/collection/1-the-tempest');
+ doc.collection = null;
+ raises(function() { doc.url(); });
+ doc.collection = collection;
+ });
+
+ test("Model: url when using urlRoot, and uri encoding", function() {
+ var Model = Backbone.ViewModel.extend({
+ urlRoot: '/collection'
+ });
+ var model = new Model();
+ equal(model.url(), '/collection');
+ model.set({id: '+1+'});
+ equal(model.url(), '/collection/%2B1%2B');
+ });
+
+ test("Model: url when using urlRoot as a function to determine urlRoot at runtime", function() {
+ var Model = Backbone.ViewModel.extend({
+ urlRoot: function() {
+ return '/nested/' + this.get('parent_id') + '/collection';
+ }
+ });
+
+ var model = new Model({parent_id: 1});
+ equal(model.url(), '/nested/1/collection');
+ model.set({id: 2});
+ equal(model.url(), '/nested/1/collection/2');
+ });
+
+ test("Model: clone", function() {
+ var a = new Backbone.ViewModel({ 'foo': 1, 'bar': 2, 'baz': 3});
+ var b = a.clone();
+ equal(a.get('foo'), 1);
+ equal(a.get('bar'), 2);
+ equal(a.get('baz'), 3);
+ equal(b.get('foo'), a.get('foo'), "Foo should be the same on the clone.");
+ equal(b.get('bar'), a.get('bar'), "Bar should be the same on the clone.");
+ equal(b.get('baz'), a.get('baz'), "Baz should be the same on the clone.");
+ a.set({foo : 100});
+ equal(a.get('foo'), 100);
+ equal(b.get('foo'), 1, "Changing a parent attribute does not change the clone.");
+ });
+
+ test("Model: isNew", function() {
+ var a = new Backbone.ViewModel({ 'foo': 1, 'bar': 2, 'baz': 3});
+ ok(a.isNew(), "it should be new");
+ a = new Backbone.ViewModel({ 'foo': 1, 'bar': 2, 'baz': 3, 'id': -5 });
+ ok(!a.isNew(), "any defined ID is legal, negative or positive");
+ a = new Backbone.ViewModel({ 'foo': 1, 'bar': 2, 'baz': 3, 'id': 0 });
+ ok(!a.isNew(), "any defined ID is legal, including zero");
+ ok( new Backbone.ViewModel({ }).isNew(), "is true when there is no id");
+ ok(!new Backbone.ViewModel({ 'id': 2 }).isNew(), "is false for a positive integer");
+ ok(!new Backbone.ViewModel({ 'id': -5 }).isNew(), "is false for a negative integer");
+ });
+
+ test("Model: get", function() {
+ equal(doc.get('title'), 'The Tempest');
+ equal(doc.get('author'), 'Bill Shakespeare');
+ });
+
+ test("Model: escape", function() {
+ equal(doc.escape('title'), 'The Tempest');
+ doc.set({audience: 'Bill & Bob'});
+ equal(doc.escape('audience'), 'Bill &amp; Bob');
+ doc.set({audience: 'Tim > Joan'});
+ equal(doc.escape('audience'), 'Tim &gt; Joan');
+ doc.set({audience: 10101});
+ equal(doc.escape('audience'), '10101');
+ doc.unset('audience');
+ equal(doc.escape('audience'), '');
+ });
+
+ test("Model: has", function() {
+ var a = new Backbone.ViewModel();
+ equal(a.has("name"), false);
+ _([true, "Truth!", 1, false, '', 0]).each(function(value) {
+ a.set({'name': value});
+ equal(a.has("name"), true);
+ });
+ a.unset('name');
+ equal(a.has('name'), false);
+ _([null, undefined]).each(function(value) {
+ a.set({'name': value});
+ equal(a.has("name"), false);
+ });
+ });
+
+ test("Model: set and unset", function() {
+ expect(8);
+ var a = new Backbone.ViewModel({id: 'id', foo: 1, bar: 2, baz: 3});
+ var changeCount = 0;
+ a.on("change:foo", function() { changeCount += 1; });
+ a.set({'foo': 2});
+ ok(a.get('foo') == 2, "Foo should have changed.");
+ ok(changeCount == 1, "Change count should have incremented.");
+ a.set({'foo': 2}); // set with value that is not new shouldn't fire change event
+ ok(a.get('foo') == 2, "Foo should NOT have changed, still 2");
+ ok(changeCount == 1, "Change count should NOT have incremented.");
+
+ a.validate = function(attrs) {
+ equal(attrs.foo, void 0, "don't ignore values when unsetting");
+ };
+ a.unset('foo');
+ equal(a.get('foo'), void 0, "Foo should have changed");
+ delete a.validate;
+ ok(changeCount == 2, "Change count should have incremented for unset.");
+
+ a.unset('id');
+ equal(a.id, undefined, "Unsetting the id should remove the id property.");
+ });
+
+ test("Model: multiple unsets", function() {
+ var i = 0;
+ var counter = function(){ i++; };
+ var model = new Backbone.ViewModel({a: 1});
+ model.on("change:a", counter);
+ model.set({a: 2});
+ model.unset('a');
+ model.unset('a');
+ equal(i, 2, 'Unset does not fire an event for missing attributes.');
+ });
+
+ test("Model: unset and changedAttributes", function() {
+ var model = new Backbone.ViewModel({a: 1});
+ model.unset('a', {silent: true});
+ var changedAttributes = model.changedAttributes();
+ ok('a' in changedAttributes, 'changedAttributes should contain unset properties');
+
+ changedAttributes = model.changedAttributes();
+ ok('a' in changedAttributes, 'changedAttributes should contain unset properties when running changedAttributes again after an unset.');
+ });
+
+ test("Model: using a non-default id attribute.", function() {
+ var MongoModel = Backbone.ViewModel.extend({idAttribute : '_id'});
+ var model = new MongoModel({id: 'eye-dee', _id: 25, title: 'Model'});
+ equal(model.get('id'), 'eye-dee');
+ equal(model.id, 25);
+ equal(model.isNew(), false);
+ model.unset('_id');
+ equal(model.id, undefined);
+ equal(model.isNew(), true);
+ });
+
+ test("Model: set an empty string", function() {
+ var model = new Backbone.ViewModel({name : "Model"});
+ model.set({name : ''});
+ equal(model.get('name'), '');
+ });
+
+ test("Model: clear", function() {
+ var changed;
+ var model = new Backbone.ViewModel({id: 1, name : "Model"});
+ model.on("change:name", function(){ changed = true; });
+ model.on("change", function() {
+ var changedAttrs = model.changedAttributes();
+ ok('name' in changedAttrs);
+ });
+ model.clear();
+ equal(changed, true);
+ equal(model.get('name'), undefined);
+ });
+
+ test("Model: defaults", function() {
+ var Defaulted = Backbone.ViewModel.extend({
+ defaults: {
+ "one": 1,
+ "two": 2
+ }
+ });
+ var model = new Defaulted({two: null});
+ equal(model.get('one'), 1);
+ equal(model.get('two'), null);
+ Defaulted = Backbone.ViewModel.extend({
+ defaults: function() {
+ return {
+ "one": 3,
+ "two": 4
+ };
+ }
+ });
+ var model = new Defaulted({two: null});
+ equal(model.get('one'), 3);
+ equal(model.get('two'), null);
+ });
+
+ test("Model: change, hasChanged, changedAttributes, previous, previousAttributes", function() {
+ var model = new Backbone.ViewModel({name : "Tim", age : 10});
+ equal(model.changedAttributes(), false);
+ model.on('change', function() {
+ ok(model.hasChanged('name'), 'name changed');
+ ok(!model.hasChanged('age'), 'age did not');
+ ok(_.isEqual(model.changedAttributes(), {name : 'Rob'}), 'changedAttributes returns the changed attrs');
+ equal(model.previous('name'), 'Tim');
+ ok(_.isEqual(model.previousAttributes(), {name : "Tim", age : 10}), 'previousAttributes is correct');
+ });
+ model.set({name : 'Rob'}, {silent : true});
+ equal(model.hasChanged(), true);
+ equal(model.hasChanged('name'), true);
+ model.change();
+ equal(model.get('name'), 'Rob');
+ });
+
+ test("Model: changedAttributes", function() {
+ var model = new Backbone.ViewModel({a: 'a', b: 'b'});
+ equal(model.changedAttributes(), false);
+ equal(model.changedAttributes({a: 'a'}), false);
+ equal(model.changedAttributes({a: 'b'}).a, 'b');
+ });
+
+ test("Model: change with options", function() {
+ var value;
+ var model = new Backbone.ViewModel({name: 'Rob'});
+ model.on('change', function(model, options) {
+ value = options.prefix + model.get('name');
+ });
+ model.set({name: 'Bob'}, {silent: true});
+ model.change({prefix: 'Mr. '});
+ equal(value, 'Mr. Bob');
+ model.set({name: 'Sue'}, {prefix: 'Ms. '});
+ equal(value, 'Ms. Sue');
+ });
+
+ test("Model: change after initialize", function () {
+ var changed = 0;
+ var attrs = {id: 1, label: 'c'};
+ var obj = new Backbone.ViewModel(attrs);
+ obj.on('change', function() { changed += 1; });
+ obj.set(attrs);
+ equal(changed, 0);
+ });
+
+ test("Model: save within change event", function () {
+ var model = new Backbone.ViewModel({firstName : "Taylor", lastName: "Swift"});
+ model.on('change', function () {
+ model.save();
+ ok(_.isEqual(lastRequest.model, model));
+ });
+ model.set({lastName: 'Hicks'});
+ });
+
+ test("Model: validate after save", function() {
+ var lastError, model = new Backbone.ViewModel();
+ model.validate = function(attrs) {
+ if (attrs.admin) return "Can't change admin status.";
+ };
+ model.sync = function(method, model, options) {
+ options.success.call(this, {admin: true});
+ };
+ model.save(null, {error: function(model, error) {
+ lastError = error;
+ }});
+
+ equal(lastError, "Can't change admin status.");
+ });
+
+ test("Model: isValid", function() {
+ var model = new Backbone.ViewModel({valid: true});
+ model.validate = function(attrs) {
+ if (!attrs.valid) return "invalid";
+ };
+ equal(model.isValid(), true);
+ equal(model.set({valid: false}), false);
+ equal(model.isValid(), true);
+ ok(model.set('valid', false, {silent: true}));
+ equal(model.isValid(), false);
+ });
+
+ test("Model: save", function() {
+ doc.save({title : "Henry V"});
+ equal(lastRequest.method, 'update');
+ ok(_.isEqual(lastRequest.model, doc));
+ });
+
+ test("Model: save in positional style", function() {
+ var model = new Backbone.ViewModel();
+ model.sync = function(method, model, options) {
+ options.success();
+ };
+ model.save('title', 'Twelfth Night');
+ equal(model.get('title'), 'Twelfth Night');
+ });
+
+
+
+ test("Model: fetch", function() {
+ doc.fetch();
+ equal(lastRequest.method, 'read');
+ ok(_.isEqual(lastRequest.model, doc));
+ });
+
+ test("Model: destroy", function() {
+ doc.destroy();
+ equal(lastRequest.method, 'delete');
+ ok(_.isEqual(lastRequest.model, doc));
+
+ var newModel = new Backbone.ViewModel;
+ equal(newModel.destroy(), false);
+ });
+
+ test("Model: non-persisted destroy", function() {
+ var a = new Backbone.ViewModel({ 'foo': 1, 'bar': 2, 'baz': 3});
+ a.sync = function() { throw "should not be called"; };
+ a.destroy();
+ ok(true, "non-persisted model should not call sync");
+ });
+
+ test("Model: validate", function() {
+ var lastError;
+ var model = new Backbone.ViewModel();
+ model.validate = function(attrs) {
+ if (attrs.admin != this.get('admin')) return "Can't change admin status.";
+ };
+ model.on('error', function(model, error) {
+ lastError = error;
+ });
+ var result = model.set({a: 100});
+ equal(result, model);
+ equal(model.get('a'), 100);
+ equal(lastError, undefined);
+ result = model.set({admin: true}, {silent: true});
+ equal(model.get('admin'), true);
+ result = model.set({a: 200, admin: false});
+ equal(lastError, "Can't change admin status.");
+ equal(result, false);
+ equal(model.get('a'), 100);
+ });
+
+ test("Model: validate on unset and clear", function() {
+ var error;
+ var model = new Backbone.ViewModel({name: "One"});
+ model.validate = function(attrs) {
+ if (!attrs.name) {
+ error = true;
+ return "No thanks.";
+ }
+ };
+ model.set({name: "Two"});
+ equal(model.get('name'), 'Two');
+ equal(error, undefined);
+ model.unset('name');
+ equal(error, true);
+ equal(model.get('name'), 'Two');
+ model.clear();
+ equal(model.get('name'), 'Two');
+ delete model.validate;
+ model.clear();
+ equal(model.get('name'), undefined);
+ });
+
+ test("Model: validate with error callback", function() {
+ var lastError, boundError;
+ var model = new Backbone.ViewModel();
+ model.validate = function(attrs) {
+ if (attrs.admin) return "Can't change admin status.";
+ };
+ var callback = function(model, error) {
+ lastError = error;
+ };
+ model.on('error', function(model, error) {
+ boundError = true;
+ });
+ var result = model.set({a: 100}, {error: callback});
+ equal(result, model);
+ equal(model.get('a'), 100);
+ equal(lastError, undefined);
+ equal(boundError, undefined);
+ result = model.set({a: 200, admin: true}, {error: callback});
+ equal(result, false);
+ equal(model.get('a'), 100);
+ equal(lastError, "Can't change admin status.");
+ equal(boundError, undefined);
+ });
+
+ test("Model: defaults always extend attrs (#459)", function() {
+ var Defaulted = Backbone.ViewModel.extend({
+ defaults: {one: 1},
+ initialize : function(attrs, opts) {
+ equal(this.attributes.one, 1);
+ }
+ });
+ var providedattrs = new Defaulted({});
+ var emptyattrs = new Defaulted();
+ });
+
+ test("Model: Inherit class properties", function() {
+ var Parent = Backbone.ViewModel.extend({
+ instancePropSame: function() {},
+ instancePropDiff: function() {}
+ }, {
+ classProp: function() {}
+ });
+ var Child = Parent.extend({
+ instancePropDiff: function() {}
+ });
+
+ var adult = new Parent;
+ var kid = new Child;
+
+ equal(Child.classProp, Parent.classProp);
+ notEqual(Child.classProp, undefined);
+
+ equal(kid.instancePropSame, adult.instancePropSame);
+ notEqual(kid.instancePropSame, undefined);
+
+ notEqual(Child.prototype.instancePropDiff, Parent.prototype.instancePropDiff);
+ notEqual(Child.prototype.instancePropDiff, undefined);
+ });
+
+ test("Model: Nested change events don't clobber previous attributes", function() {
+ var A = Backbone.ViewModel.extend({
+ initialize: function() {
+ this.on("change:state", function(a, newState) {
+ equal(a.previous('state'), undefined);
+ equal(newState, 'hello');
+ // Fire a nested change event.
+ this.set({ other: "whatever" });
+ });
+ }
+ });
+
+ var B = Backbone.ViewModel.extend({
+ initialize: function() {
+ this.get("a").on("change:state", function(a, newState) {
+ equal(a.previous('state'), undefined);
+ equal(newState, 'hello');
+ });
+ }
+ });
+
+ var a = new A();
+ var b = new B({a: a});
+ a.set({state: 'hello'});
+ });
+
+ test("hasChanged/set should use same comparison", function() {
+ expect(2);
+ var changed = 0, model = new Backbone.ViewModel({a: null});
+ model.on('change', function() {
+ ok(this.hasChanged('a'));
+ })
+ .on('change:a', function() {
+ changed++;
+ })
+ .set({a: undefined});
+ equal(changed, 1);
+ });
+
+ test("#582, #425, change:attribute callbacks should fire after all changes have occurred", 9, function() {
+ var model = new Backbone.ViewModel;
+
+ var assertion = function() {
+ equal(model.get('a'), 'a');
+ equal(model.get('b'), 'b');
+ 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'});
+ });
+
+ test("#871, set with attributes property", function() {
+ var model = new Backbone.ViewModel();
+ model.set({attributes: true});
+ ok(model.has('attributes'));
+ });
+