From 2e2e9f7e96fa56d3f1a7211a777e90cb52f782ba Mon Sep 17 00:00:00 2001 From: Brad Dunbar Date: Tue, 12 Jun 2012 11:57:50 -0400 Subject: [PATCH] Use mock location to test Backbone.history. --- backbone.js | 24 +-- index.html | 2 +- test/router.js | 185 ++++++++---------- test/test-ender.html | 2 +- test/test-zepto.html | 2 +- test/test.html | 2 +- .../{underscore-1.3.1.js => underscore.js} | 180 +++++++++++------ 7 files changed, 221 insertions(+), 176 deletions(-) rename test/vendor/{underscore-1.3.1.js => underscore.js} (87%) diff --git a/backbone.js b/backbone.js index d16d2a5f..9a8f2571 100644 --- a/backbone.js +++ b/backbone.js @@ -954,9 +954,10 @@ // Handles cross-browser history management, based on URL fragments. If the // browser does not support `onhashchange`, falls back to polling. - var History = Backbone.History = function() { + var History = Backbone.History = function(options) { this.handlers = []; _.bindAll(this, 'checkUrl'); + this.location = options && options.location || root.location; }; // Cached regex for cleaning leading hashes and slashes . @@ -977,9 +978,8 @@ // Gets the true hash value. Cannot use location.hash directly due to bug // in Firefox where location.hash will always be decoded. - getHash: function(windowOverride) { - var loc = windowOverride ? windowOverride.location : window.location; - var match = loc.href.match(/#(.*)$/); + getHash: function(window) { + var match = (window || this).location.href.match(/#(.*)$/); return match ? match[1] : ''; }, @@ -988,7 +988,7 @@ getFragment: function(fragment, forcePushState) { if (fragment == null) { if (this._hasPushState || !this._wantsHashChange || forcePushState) { - fragment = window.location.pathname; + fragment = this.location.pathname; } else { fragment = this.getHash(); } @@ -1031,14 +1031,14 @@ // Determine if we need to change the base url, for a pushState link // opened by a non-pushState browser. this.fragment = fragment; - var loc = window.location; + var loc = this.location; var atRoot = (loc.pathname == this.options.root) && !loc.search; // If we've started off with a route from a `pushState`-enabled browser, // but we're currently in a browser that doesn't support it... if (this._wantsHashChange && this._wantsPushState && !this._hasPushState && !atRoot) { this.fragment = this.getFragment(null, true); - window.location.replace(this.options.root + window.location.search + '#' + this.fragment); + this.location.replace(this.options.root + this.location.search + '#' + this.fragment); // Return immediately as browser will do redirect to new url return true; @@ -1072,7 +1072,9 @@ // calls `loadUrl`, normalizing across the hidden iframe. checkUrl: function(e) { var current = this.getFragment(); - if (current == this.fragment && this.iframe) current = this.getFragment(this.getHash(this.iframe)); + if (current == this.fragment && this.iframe) { + current = this.getFragment(this.getHash(this.iframe)); + } if (current == this.fragment) return false; if (this.iframe) this.navigate(current); this.loadUrl() || this.loadUrl(this.getHash()); @@ -1115,7 +1117,7 @@ // fragment to store history. } else if (this._wantsHashChange) { this.fragment = frag; - this._updateHash(window.location, frag, options.replace); + this._updateHash(this.location, frag, options.replace); if (this.iframe && (frag != this.getFragment(this.getHash(this.iframe)))) { // Opening and closing the iframe tricks IE7 and earlier to push a history entry on hash-tag change. // When replace is true, we don't want this. @@ -1126,7 +1128,7 @@ // If you've told us that you explicitly don't want fallback hashchange- // based history, then `navigate` becomes a page refresh. } else { - return window.location.assign(fullFrag); + return this.location.assign(fullFrag); } if (options.trigger) this.loadUrl(fragment); }, @@ -1135,7 +1137,7 @@ // a new one to the browser history. _updateHash: function(location, fragment, replace) { if (replace) { - location.replace(location.toString().replace(/(javascript:|#).*$/, '') + '#' + fragment); + location.replace(location.href.replace(/(javascript:|#).*$/, '') + '#' + fragment); } else { location.hash = fragment; } diff --git a/index.html b/index.html index ed67826b..aa59f7f9 100644 --- a/index.html +++ b/index.html @@ -3805,7 +3805,7 @@ ActiveRecord::Base.include_root_in_json = false - + diff --git a/test/router.js b/test/router.js index 68106b4b..d1d0a5a4 100644 --- a/test/router.js +++ b/test/router.js @@ -1,6 +1,7 @@ $(document).ready(function() { var router = null; + var location = null; var lastRoute = null; var lastArgs = []; @@ -9,10 +10,33 @@ $(document).ready(function() { lastArgs = args; } + var Location = function(href) { + this.replace(href); + }; + + _.extend(Location.prototype, { + + replace: function(href) { + _.extend(this, _.pick($('', {href: href})[0], + 'href', + 'hash', + 'search', + 'fragment', + 'pathname' + )); + }, + + toString: function() { + return this.href; + } + + }); + module("Backbone.Router", { setup: function() { - Backbone.history = null; + location = new Location('http://example.com'); + Backbone.history = new Backbone.History({location: location}); router = new Router({testing: 101}); Backbone.history.interval = 9; Backbone.history.start({pushState: false}); @@ -101,24 +125,20 @@ $(document).ready(function() { equal(router.testing, 101); }); - asyncTest("Router: routes (simple)", 4, function() { - window.location.hash = 'search/news'; - setTimeout(function() { - equal(router.query, 'news'); - equal(router.page, undefined); - equal(lastRoute, 'search'); - equal(lastArgs[0], 'news'); - start(); - }, 10); + test("Router: routes (simple)", 4, function() { + location.replace('http://example.com#search/news'); + Backbone.history.checkUrl(); + equal(router.query, 'news'); + equal(router.page, undefined); + equal(lastRoute, 'search'); + equal(lastArgs[0], 'news'); }); - asyncTest("Router: routes (two part)", 2, function() { - window.location.hash = 'search/nyc/p10'; - setTimeout(function() { - equal(router.query, 'nyc'); - equal(router.page, '10'); - start(); - }, 10); + test("Router: routes (two part)", 2, function() { + location.replace('http://example.com#search/nyc/p10'); + Backbone.history.checkUrl(); + equal(router.query, 'nyc'); + equal(router.page, '10'); }); test("Router: routes via navigate", 2, function() { @@ -145,22 +165,12 @@ $(document).ready(function() { }); }); - test("Router: doesn't fire routes to the same place twice", 6, function() { - equal(router.count, 0); - router.navigate('counter', {trigger: true}); - equal(router.count, 1); - router.navigate('/counter', {trigger: true}); - router.navigate('/counter', {trigger: true}); - equal(router.count, 1); - router.navigate('search/counter', {trigger: true}); - router.navigate('counter', {trigger: true}); - equal(router.count, 2); - Backbone.history.stop(); - router.navigate('search/counter', {trigger: true}); - router.navigate('counter', {trigger: true}); - equal(router.count, 2); - Backbone.history.start(); - equal(router.count, 3); + test("loadUrl is not called for identical routes.", 0, function() { + Backbone.history.loadUrl = function(){ ok(false); }; + location.replace('http://example.com#route'); + Backbone.history.navigate('route'); + Backbone.history.navigate('/route'); + Backbone.history.navigate('/route'); }); test("Router: use implicit callback if none provided", 1, function() { @@ -169,73 +179,49 @@ $(document).ready(function() { equal(router.count, 1); }); - asyncTest("Router: routes via navigate with {replace: true}", 2, function() { - var historyLength = window.history.length; - router.navigate('search/manhattan/start_here'); - router.navigate('search/manhattan/then_here'); - router.navigate('search/manhattan/finally_here', {replace: true}); - - equal(window.location.hash, "#search/manhattan/finally_here"); - window.history.go(-1); - setTimeout(function() { - equal(window.location.hash, "#search/manhattan/start_here"); - start(); - }, 500); + test("Router: routes via navigate with {replace: true}", 1, function() { + location.replace('http://example.com#start_here'); + Backbone.history.checkUrl(); + location.replace = function(href) { + strictEqual(href, new Location('http://example.com#end_here').href); + }; + Backbone.history.navigate('end_here', {replace: true}); }); - asyncTest("Router: routes (splats)", 1, function() { - window.location.hash = 'splat/long-list/of/splatted_99args/end'; - setTimeout(function() { - equal(router.args, 'long-list/of/splatted_99args'); - start(); - }, 10); + test("Router: routes (splats)", 1, function() { + location.replace('http://example.com#splat/long-list/of/splatted_99args/end'); + Backbone.history.checkUrl(); + equal(router.args, 'long-list/of/splatted_99args'); }); - asyncTest("Router: routes (complex)", 3, function() { - window.location.hash = 'one/two/three/complex-part/four/five/six/seven'; - setTimeout(function() { - equal(router.first, 'one/two/three'); - equal(router.part, 'part'); - equal(router.rest, 'four/five/six/seven'); - start(); - }, 10); + test("Router: routes (complex)", 3, function() { + location.replace('http://example.com#one/two/three/complex-part/four/five/six/seven'); + Backbone.history.checkUrl(); + equal(router.first, 'one/two/three'); + equal(router.part, 'part'); + equal(router.rest, 'four/five/six/seven'); }); - asyncTest("Router: routes (query)", 5, function() { - window.location.hash = 'mandel?a=b&c=d'; - setTimeout(function() { - equal(router.entity, 'mandel'); - equal(router.queryArgs, 'a=b&c=d'); - equal(lastRoute, 'query'); - equal(lastArgs[0], 'mandel'); - equal(lastArgs[1], 'a=b&c=d'); - start(); - }, 10); + test("Router: routes (query)", 5, function() { + location.replace('http://example.com#mandel?a=b&c=d'); + Backbone.history.checkUrl(); + equal(router.entity, 'mandel'); + equal(router.queryArgs, 'a=b&c=d'); + equal(lastRoute, 'query'); + equal(lastArgs[0], 'mandel'); + equal(lastArgs[1], 'a=b&c=d'); }); - asyncTest("Router: routes (anything)", 1, function() { - window.location.hash = 'doesnt-match-a-route'; - setTimeout(function() { - equal(router.anything, 'doesnt-match-a-route'); - start(); - window.location.hash = ''; - }, 10); + test("Router: routes (anything)", 1, function() { + location.replace('http://example.com#doesnt-match-a-route'); + Backbone.history.checkUrl(); + equal(router.anything, 'doesnt-match-a-route'); }); - asyncTest("Router: fires event when router doesn't have callback on it", 1, function() { - try{ - var callbackFired = false; - var myCallback = function(){ callbackFired = true; }; - router.on("route:noCallback", myCallback); - window.location.hash = "noCallback"; - setTimeout(function(){ - equal(callbackFired, true); - start(); - window.location.hash = ''; - }, 10); - } catch (err) { - ok(false, "an exception was thrown trying to fire the router event with no router handler callback"); - } + test("Router: fires event when router doesn't have callback on it", 1, function() { + router.on("route:noCallback", function(){ ok(true); }); + location.replace('http://example.com#noCallback'); + Backbone.history.checkUrl(); }); test("#933, #908 - leading slash", 2, function() { @@ -265,17 +251,14 @@ $(document).ready(function() { equal(router.rest, 'has%20space'); }); - asyncTest("Router: correctly handles URLs with % (#868)", 3, function() { - window.location.hash = 'search/fat%3A1.5%25'; - setTimeout(function() { - window.location.hash = 'search/fat'; - setTimeout(function() { - equal(router.query, 'fat'); - equal(router.page, undefined); - equal(lastRoute, 'search'); - start(); - }, 50); - }, 50); + test("Router: correctly handles URLs with % (#868)", 3, function() { + location.replace('http://example.com#search/fat%3A1.5%25'); + Backbone.history.checkUrl(); + location.replace('http://example.com#search/fat'); + Backbone.history.checkUrl(); + equal(router.query, 'fat'); + equal(router.page, undefined); + equal(lastRoute, 'search'); }); }); diff --git a/test/test-ender.html b/test/test-ender.html index 10f179cc..3a10b186 100644 --- a/test/test-ender.html +++ b/test/test-ender.html @@ -7,7 +7,7 @@ - + diff --git a/test/test-zepto.html b/test/test-zepto.html index 8a86b044..31c0fc06 100644 --- a/test/test-zepto.html +++ b/test/test-zepto.html @@ -7,7 +7,7 @@ - + diff --git a/test/test.html b/test/test.html index 6f70c74b..2ce88db3 100644 --- a/test/test.html +++ b/test/test.html @@ -8,7 +8,7 @@ - + diff --git a/test/vendor/underscore-1.3.1.js b/test/vendor/underscore.js similarity index 87% rename from test/vendor/underscore-1.3.1.js rename to test/vendor/underscore.js index 208d4cd8..f6f7e2f2 100644 --- a/test/vendor/underscore-1.3.1.js +++ b/test/vendor/underscore.js @@ -1,4 +1,4 @@ -// Underscore.js 1.3.1 +// Underscore.js 1.3.3 // (c) 2009-2012 Jeremy Ashkenas, DocumentCloud Inc. // Underscore is freely distributable under the MIT license. // Portions of Underscore are inspired or borrowed from Prototype, @@ -62,7 +62,7 @@ } // Current version. - _.VERSION = '1.3.1'; + _.VERSION = '1.3.3'; // Collection Functions // -------------------- @@ -180,7 +180,7 @@ each(obj, function(value, index, list) { if (!(result = result && iterator.call(context, value, index, list))) return breaker; }); - return result; + return !!result; }; // Determine if at least one element in the object matches a truth test. @@ -224,7 +224,7 @@ // Return the maximum element or (element-based computation). _.max = function(obj, iterator, context) { - if (!iterator && _.isArray(obj)) return Math.max.apply(Math, obj); + if (!iterator && _.isArray(obj) && obj[0] === +obj[0]) return Math.max.apply(Math, obj); if (!iterator && _.isEmpty(obj)) return -Infinity; var result = {computed : -Infinity}; each(obj, function(value, index, list) { @@ -236,7 +236,7 @@ // Return the minimum element (or element-based computation). _.min = function(obj, iterator, context) { - if (!iterator && _.isArray(obj)) return Math.min.apply(Math, obj); + if (!iterator && _.isArray(obj) && obj[0] === +obj[0]) return Math.min.apply(Math, obj); if (!iterator && _.isEmpty(obj)) return Infinity; var result = {computed : Infinity}; each(obj, function(value, index, list) { @@ -250,19 +250,16 @@ _.shuffle = function(obj) { var shuffled = [], rand; each(obj, function(value, index, list) { - if (index == 0) { - shuffled[0] = value; - } else { - rand = Math.floor(Math.random() * (index + 1)); - shuffled[index] = shuffled[rand]; - shuffled[rand] = value; - } + rand = Math.floor(Math.random() * (index + 1)); + shuffled[index] = shuffled[rand]; + shuffled[rand] = value; }); return shuffled; }; // Sort the object's values by a criterion produced by an iterator. - _.sortBy = function(obj, iterator, context) { + _.sortBy = function(obj, val, context) { + var iterator = _.isFunction(val) ? val : function(obj) { return obj[val]; }; return _.pluck(_.map(obj, function(value, index, list) { return { value : value, @@ -270,6 +267,8 @@ }; }).sort(function(left, right) { var a = left.criteria, b = right.criteria; + if (a === void 0) return 1; + if (b === void 0) return -1; return a < b ? -1 : a > b ? 1 : 0; }), 'value'); }; @@ -299,26 +298,26 @@ }; // Safely convert anything iterable into a real, live array. - _.toArray = function(iterable) { - if (!iterable) return []; - if (iterable.toArray) return iterable.toArray(); - if (_.isArray(iterable)) return slice.call(iterable); - if (_.isArguments(iterable)) return slice.call(iterable); - return _.values(iterable); + _.toArray = function(obj) { + if (!obj) return []; + if (_.isArray(obj)) return slice.call(obj); + if (_.isArguments(obj)) return slice.call(obj); + if (obj.toArray && _.isFunction(obj.toArray)) return obj.toArray(); + return _.values(obj); }; // Return the number of elements in an object. _.size = function(obj) { - return _.toArray(obj).length; + return _.isArray(obj) ? obj.length : _.keys(obj).length; }; // Array Functions // --------------- // Get the first element of an array. Passing **n** will return the first N - // values in the array. Aliased as `head`. The **guard** check allows it to work - // with `_.map`. - _.first = _.head = function(array, n, guard) { + // values in the array. Aliased as `head` and `take`. The **guard** check + // allows it to work with `_.map`. + _.first = _.head = _.take = function(array, n, guard) { return (n != null) && !guard ? slice.call(array, 0, n) : array[0]; }; @@ -372,15 +371,17 @@ // Aliased as `unique`. _.uniq = _.unique = function(array, isSorted, iterator) { var initial = iterator ? _.map(array, iterator) : array; - var result = []; - _.reduce(initial, function(memo, el, i) { - if (0 == i || (isSorted === true ? _.last(memo) != el : !_.include(memo, el))) { - memo[memo.length] = el; - result[result.length] = array[i]; + var results = []; + // The `isSorted` flag is irrelevant if the array only contains two elements. + if (array.length < 3) isSorted = true; + _.reduce(initial, function (memo, value, index) { + if (isSorted ? _.last(memo) !== value || !memo.length : !_.include(memo, value)) { + memo.push(value); + results.push(array[index]); } return memo; }, []); - return result; + return results; }; // Produce an array that contains the union: each distinct element from all of @@ -403,7 +404,7 @@ // Take the difference between one array and a number of other arrays. // Only the elements present in just the first array will remain. _.difference = function(array) { - var rest = _.flatten(slice.call(arguments, 1)); + var rest = _.flatten(slice.call(arguments, 1), true); return _.filter(array, function(value){ return !_.include(rest, value); }); }; @@ -514,7 +515,7 @@ // it with the arguments supplied. _.delay = function(func, wait) { var args = slice.call(arguments, 2); - return setTimeout(function(){ return func.apply(func, args); }, wait); + return setTimeout(function(){ return func.apply(null, args); }, wait); }; // Defers a function, scheduling it to run after the current call stack has @@ -526,7 +527,7 @@ // Returns a function, that, when invoked, will only be triggered at most once // during a given window of time. _.throttle = function(func, wait) { - var context, args, timeout, throttling, more; + var context, args, timeout, throttling, more, result; var whenDone = _.debounce(function(){ more = throttling = false; }, wait); return function() { context = this; args = arguments; @@ -539,24 +540,27 @@ if (throttling) { more = true; } else { - func.apply(context, args); + result = func.apply(context, args); } whenDone(); throttling = true; + return result; }; }; // Returns a function, that, as long as it continues to be invoked, will not // be triggered. The function will be called after it stops being called for - // N milliseconds. - _.debounce = function(func, wait) { + // N milliseconds. If `immediate` is passed, trigger the function on the + // leading edge, instead of the trailing. + _.debounce = function(func, wait, immediate) { var timeout; return function() { var context = this, args = arguments; var later = function() { timeout = null; - func.apply(context, args); + if (!immediate) func.apply(context, args); }; + if (immediate && !timeout) func.apply(context, args); clearTimeout(timeout); timeout = setTimeout(later, wait); }; @@ -641,6 +645,15 @@ return obj; }; + // Return a copy of the object only containing the whitelisted properties. + _.pick = function(obj) { + var result = {}; + each(_.flatten(slice.call(arguments, 1)), function(key) { + if (key in obj) result[key] = obj[key]; + }); + return result; + }; + // Fill in a given object with default properties. _.defaults = function(obj) { each(slice.call(arguments, 1), function(source) { @@ -761,6 +774,7 @@ // Is a given array, string, or object empty? // An "empty" object has no enumerable own-properties. _.isEmpty = function(obj) { + if (obj == null) return true; if (_.isArray(obj) || _.isString(obj)) return obj.length === 0; for (var key in obj) if (_.has(obj, key)) return false; return true; @@ -807,6 +821,11 @@ return toString.call(obj) == '[object Number]'; }; + // Is a given object a finite number? + _.isFinite = function(obj) { + return _.isNumber(obj) && isFinite(obj); + }; + // Is the given value `NaN`? _.isNaN = function(obj) { // `NaN` is the only value for which `===` is not reflexive. @@ -868,6 +887,14 @@ return (''+string).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\//g,'/'); }; + // If the value of the named property is a function then invoke it; + // otherwise, return it. + _.result = function(object, property) { + if (object == null) return null; + var value = object[property]; + return _.isFunction(value) ? value.call(object) : value; + }; + // Add your own custom functions to the Underscore object, ensuring that // they're correctly added to the OOP wrapper as well. _.mixin = function(obj) { @@ -897,39 +924,72 @@ // guaranteed not to match. var noMatch = /.^/; + // Certain characters need to be escaped so that they can be put into a + // string literal. + var escapes = { + '\\': '\\', + "'": "'", + 'r': '\r', + 'n': '\n', + 't': '\t', + 'u2028': '\u2028', + 'u2029': '\u2029' + }; + + for (var p in escapes) escapes[escapes[p]] = p; + var escaper = /\\|'|\r|\n|\t|\u2028|\u2029/g; + var unescaper = /\\(\\|'|r|n|t|u2028|u2029)/g; + // Within an interpolation, evaluation, or escaping, remove HTML escaping // that had been previously added. var unescape = function(code) { - return code.replace(/\\\\/g, '\\').replace(/\\'/g, "'"); + return code.replace(unescaper, function(match, escape) { + return escapes[escape]; + }); }; // JavaScript micro-templating, similar to John Resig's implementation. // Underscore templating handles arbitrary delimiters, preserves whitespace, // and correctly escapes quotes within interpolated code. - _.template = function(str, data) { - var c = _.templateSettings; - var tmpl = 'var __p=[],print=function(){__p.push.apply(__p,arguments);};' + - 'with(obj||{}){__p.push(\'' + - str.replace(/\\/g, '\\\\') - .replace(/'/g, "\\'") - .replace(c.escape || noMatch, function(match, code) { - return "',_.escape(" + unescape(code) + "),'"; - }) - .replace(c.interpolate || noMatch, function(match, code) { - return "'," + unescape(code) + ",'"; - }) - .replace(c.evaluate || noMatch, function(match, code) { - return "');" + unescape(code).replace(/[\r\n\t]/g, ' ') + ";__p.push('"; - }) - .replace(/\r/g, '\\r') - .replace(/\n/g, '\\n') - .replace(/\t/g, '\\t') - + "');}return __p.join('');"; - var func = new Function('obj', '_', tmpl); - if (data) return func(data, _); - return function(data) { - return func.call(this, data, _); + _.template = function(text, data, settings) { + settings = _.defaults(settings || {}, _.templateSettings); + + // Compile the template source, taking care to escape characters that + // cannot be included in a string literal and then unescape them in code + // blocks. + var source = "__p+='" + text + .replace(escaper, function(match) { + return '\\' + escapes[match]; + }) + .replace(settings.escape || noMatch, function(match, code) { + return "'+\n_.escape(" + unescape(code) + ")+\n'"; + }) + .replace(settings.interpolate || noMatch, function(match, code) { + return "'+\n(" + unescape(code) + ")+\n'"; + }) + .replace(settings.evaluate || noMatch, function(match, code) { + return "';\n" + unescape(code) + "\n;__p+='"; + }) + "';\n"; + + // If a variable is not specified, place data values in local scope. + if (!settings.variable) source = 'with(obj||{}){\n' + source + '}\n'; + + source = "var __p='';" + + "var print=function(){__p+=Array.prototype.join.call(arguments, '')};\n" + + source + "return __p;\n"; + + var render = new Function(settings.variable || 'obj', '_', source); + if (data) return render(data, _); + var template = function(data) { + return render.call(this, data, _); }; + + // Provide the compiled function source as a convenience for build time + // precompilation. + template.source = 'function(' + (settings.variable || 'obj') + '){\n' + + source + '}'; + + return template; }; // Add a "chain" function, which will delegate to the wrapper.