diff --git a/src/app/app.js b/src/app/app.js index 8828e6ab..657ea08f 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -205,8 +205,8 @@ var app = angular.module('baw', }]) - .run(['$rootScope', '$location', '$route', '$http', 'Authenticator', 'AudioEvent', 'conf.paths', 'UserProfile', 'ngAudioEvents', '$url', - function ($rootScope, $location, $route, $http, Authenticator, AudioEvent, paths, UserProfile, ngAudioEvents, $url) { + .run(['$rootScope', '$location', '$route', '$http', 'Authenticator', 'AudioEvent', 'conf.paths', 'UserProfile', 'ngAudioEvents', '$url', "predictiveCache", "conf.constants", + function ($rootScope, $location, $route, $http, Authenticator, AudioEvent, paths, UserProfile, ngAudioEvents, $url, predictiveCache, constants) { // embed configuration for easy site-wide binding $rootScope.paths = paths; @@ -343,6 +343,9 @@ var app = angular.module('baw', $rootScope.downloadAnnotationLink = AudioEvent.csvLink(); + // set up predictive cache service + predictiveCache(constants.predictiveCache.profiles["Media cache ahead"]($location, paths)); + }]) .controller('AppCtrl', diff --git a/src/app/listen/listen.js b/src/app/listen/listen.js index 6150ab74..ca6d02b3 100644 --- a/src/app/listen/listen.js +++ b/src/app/listen/listen.js @@ -411,7 +411,7 @@ angular.module('bawApp.listen', ['decipher.tags', 'ui.bootstrap.typeahead']) return undefined; } - return moment($scope.model.media.recordedDate).add($scope.jumpToMinute, 'm').format("YYYY-MMM-DD, HH:mm:ss"); + return moment($scope.model.media.recordedDatep).add($scope.jumpToMinute, 'm').format("YYYY-MMM-DD, HH:mm:ss"); }; diff --git a/src/baw.configuration.tpl.js b/src/baw.configuration.tpl.js index a8eca4af..d1dca48e 100644 --- a/src/baw.configuration.tpl.js +++ b/src/baw.configuration.tpl.js @@ -116,9 +116,7 @@ angular.module('bawApp.configuration', ['url']) libraryItem: "/library/{recordingId}/audio_events/{audioEventId}" }, // general links for use in 's - links: { - - } + links: {} } }; @@ -229,7 +227,7 @@ angular.module('bawApp.configuration', ['url']) chrome: 30, safari: 5.1, opera: 23, - ios: 5.1, + ios: 5.1, android: 4.0 }, baseMessage: "Your current internet browser ({name}, version {version}) is {reason}.
Consider updating or try using
Google Chrome.", @@ -246,29 +244,61 @@ angular.module('bawApp.configuration', ['url']) }, predictiveCache: { - profiles: [ - { - name: "Media cache ahead", - match: "some url", - request: ["one url", "another url"], - progression: [ - function(data, previous) { - return previous + 30.0; - }, - function(data, previous) { - var next = previous + 30.0; - if (next >= data.max) { - return; - } - else { - return next; - } + profiles: { + "Media cache ahead": function bind($location, paths) { + // request additional bits of media based the duration of the original request + // do not make requests that would exceed the end of the recording + function mediaProgressor(previous, data) { + var media = data.responseData.data, + duration = media.commonParameters.endOffset - media.commonParameters.startOffset, + next = previous + duration, + max = media.recording.durationSeconds; + + if (next >= max) { + return; + } + else { + return next; } - ], - count: 10, - method: "HEAD" + } + + function formatMediaUrl(url, counters) { + return paths.api.root + url + .replace(/start_offset=[\.\d]+/, "start_offset=" + counters[0]) + .replace(/end_offset=[\.\d]+/, "end_offset=" + counters[1]); + } + return { + name: "Media cache ahead", + match: function (url, response) { + // match only if on listen page and request is for a media's json + if ($location.path().indexOf("/listen") === 0 && + /\/audio_recordings\/[\.\d]+\/media\.json.*/.test(url)) { + var so = response.config.params.start_offset; + var eo = response.config.params.end_offset; + + return so !== undefined && eo !== undefined ? [so, eo] : null; + } + return null; + }, + request: [ + // spectrogram + function (counters, data) { + return formatMediaUrl(data.responseData.data.available.image["png"].url, counters); + }, + // mp3 + function (counters, data) { + return formatMediaUrl(data.responseData.data.available.audio["mp3"].url, counters); + } + ], + progression: [ + mediaProgressor, mediaProgressor + ], + count: 10, + method: "HEAD", + progressive: true + }; } - ] + } } }); \ No newline at end of file diff --git a/src/components/services/angularjsRailsResource.js b/src/components/services/angularjsRailsResource.js index 356ccc66..bcd5fcfd 100644 --- a/src/components/services/angularjsRailsResource.js +++ b/src/components/services/angularjsRailsResource.js @@ -5,7 +5,8 @@ // MIT License // https://github.com/tpodom/angularjs-rails-resource -angular.module('rails', []) +angular + .module('rails', []) .constant('casingTransformers', (function () { /** * Old function worked via reference - deprecated diff --git a/src/components/services/predictiveCache.js b/src/components/services/predictiveCache.js index ae7491aa..08bf463e 100644 --- a/src/components/services/predictiveCache.js +++ b/src/components/services/predictiveCache.js @@ -6,18 +6,23 @@ angular function () { var _listeners = []; return { - listeners: function() { + listeners: function () { return _listeners; }, - request: function pciRequest(httpConfig) { + /*request: function pciRequest(httpConfig) { - _listeners.forEach(function) - return httpConfig; + },*/ + response: function pciResponse(response) { + + _listeners.forEach(function (listener) { + listener(response); + }); + + return response; } /* requestError: , - response: , responseError: */ }; @@ -25,95 +30,311 @@ angular ]) .factory( "predictiveCache", - ["$http", "predictiveCacheInterceptor", function ($http, predictiveCacheInterceptor) { + [ + "$http", + "predictiveCacheInterceptor", + "$q", + function ($http, predictiveCacheInterceptor, $q) { - var defaults = { - name: null, - match: null, - request: [], - progression: [], - count: 10, - method: "HEAD" - }; - var acceptableVerbs = ["GET", "HEAD", "POST", "PUT", "DELETE"]; - var defaultProgression = 1; - var unnamedProfiles = 0; + var defaults = { + name: null, + /** + * Match can be a regex or a function. + * @param: {string} url + * @returns {null|Array} matches + */ + match: null, + request: [], + progression: [], + count: 10, + method: "HEAD", + progressive: true + }; + var acceptableVerbs = ["GET", "HEAD", "POST", "PUT", "DELETE"]; + var defaultProgression = 1; + var unnamedProfiles = 0; - var profiles = {}; + var profiles = {}; - function validateProfile(settings) { - settings = angular.extend({}, defaults, settings); + function validateProfile(settings) { + settings = angular.extend({}, defaults, settings); - if (settings.name) { - if (!angular.isString(settings.name)) { - throw new Error("The provided name must be a string"); + if (settings.name) { + if (!angular.isString(settings.name)) { + throw new Error("The provided name must be a string"); + } + } + else { + unnamedProfiles++; + settings.name = "UnnamedProfile" + unnamedProfiles; } - } - else { - unnamedProfiles++; - settings.name = "UnnamedProfile" + unnamedProfiles; - } - if (settings.match) { - if (!(settings.match instanceof RegExp)) { - throw new Error("The value for match must be a regular expression"); + if (settings.match) { + if (!(settings.match instanceof RegExp) && !angular.isFunction(settings.match)) { + throw new Error("The value for match must be a regular expression or a function"); + } + } + else { + throw new Error("A value for match must be provided"); } - } - else { - throw new Error("A value for match must be provided"); - } - if (angular.isArray(settings.request) && settings.request.length > 0) { - var isStrings = settings.request.every(angular.isString); - if (!isStrings) { - throw new Error("requests must be an array of strings"); + function isStringOrFunction(value) { + return angular.isString(value) || angular.isFunction(value); } + + if (angular.isArray(settings.request) && settings.request.length > 0) { + var isStringsOrFunctions = settings.request.every(isStringOrFunction); + if (!isStringsOrFunctions) { + throw new Error("requests must be an array of strings or functions"); + } + } + else { + throw new Error("requests must be an array of strings or functions"); + } + + // http://stackoverflow.com/a/16046903 + //var numGroups = (new RegExp(settings.match.toString() + '|')).exec('').length - 1; + var isArray = angular.isArray(settings.progression), + isEmpty = isArray && settings.progression.length === 0, + isNumber = angular.isNumber(settings.progression) && !isArray, + isNumberFunctionArray = isArray && settings.progression.every(function (value) { + return angular.isFunction(value) || angular.isNumber(value); + }); + if (settings.progression === null || settings.progression === undefined) { + settings.progression = defaultProgression; + } + else if (isEmpty || !isNumber && !isNumberFunctionArray) { + throw new Error("progression must be an array of numbers/functions"); + } + + if (!angular.isNumber(settings.count) || settings.count < 0) { + throw new Error("count must be a positive integer"); + } + + if (acceptableVerbs.indexOf(settings.method) == -1) { + throw new Error("A valid http method is required"); + } + + if (settings.progressive !== true && settings.progressive !== false) { + throw new Error("progressive must be boolean"); + } + + return settings; } - else { - throw new Error("requests must be an array of strings"); - } - // http://stackoverflow.com/a/16046903 - //var numGroups = (new RegExp(settings.match.toString() + '|')).exec('').length - 1; - var isArray = angular.isArray(settings.progression), - isEmpty = isArray && settings.progression.length === 0, - isNumber = angular.isNumber(settings.progression) && !isArray, - isNumberFunctionArray = isArray && settings.progression.every(function (value) { - return angular.isFunction(value) || angular.isNumber(value); + /** + * Create a chain of promises that will actually cache the resources + * @param profile + * @param url + * @param match + */ + function createCacheRequests(profile, response, match) { + var url = response.config.url, + data = response.data; + + // parse matched values from regex + var base = [], + previous = []; + for (var m = 0; m < match.length; m++) { + // try and convert match to number, if fail, leave as is + var num = Number(match[m]), + value = isNaN(num) ? match[m] : num; + base[m] = previous[m] = value; + } + + // each capture group represents a parameter that may progress + var progressions = previous.map(function (value, index) { + var progression = defaultProgression; + if (profile.progression.length) { + progression = profile.progression[index]; + } + else if (profile.progression) { + progression = profile.progression; + } + + return progression; + }); + + function formatRequests(req, index) { + if (req.apply) { + return req.call( + undefined, + this, + {profile: profile, triggerUrl: url, responseData: data}); + } + else { + return req.format(this); + } + } + + function invokeHttp(url) { + console.debug("predictiveCache:promiseResolution enqueued " + url); + return $http({ + url: url, + method: profile.method + }).then(function (value) { + console.debug("predictiveCache:promiseResolution completed " + url); + return value; }); - if (settings.progression === null || settings.progression === undefined) { - settings.progression = defaultProgression; - } - else if (isEmpty || !isNumber && !isNumberFunctionArray) { - throw new Error("progression must be an array of numbers/functions"); - } + } + + // count is the number of times to progress + var commands = []; + count:for (var i = 0; i < profile.count; i++) { + + // apply each progression for each variable + var current = []; + for (var p = 0; p < progressions.length; p++) { + + // get the number or function to increment by + var progression = progressions[p], + prev = previous[p]; + + if (progression.call) { + current[p] = progression.call( + undefined, + prev, + {count: i, profile: profile, triggerUrl: url, responseData: data}); + } + else { + current[p] = prev + progression; + } + + // break the count loop early if any of the progressions fail + if (current[p] === undefined) { + break count; + } + } + + // apply the current values to the request strings + var currentCommands = profile.request.map(formatRequests, current); + + Array.prototype.push.apply(commands, currentCommands); + + // lastly, overwrite previous values for next loop + previous = current; + } + + // finally ready to issue http requests! + var promises; + if (profile.progressive) { + // make requests consecutively, in a progressive fashion + // but batch each progression's requests into groups + var requestsPerProgression = profile.request.length; + promises = commands.reduce(function (promiseChain, current, index, array) { + if (index % requestsPerProgression === 0) { + var batchCommands = array.slice(index, index + requestsPerProgression); + if (batchCommands.length !== 0) { + return promiseChain.then(function () { + //console.debug("execute next http requests", index / requestsPerProgression, batchCommands); + if (batchCommands === 1) { + return invokeHttp(batchCommands[0]); + } + else { + return $q.all(batchCommands.map(invokeHttp)); + } + }); + } + else { + return promiseChain; + } + } + else { + return promiseChain; + } + }, $q.when()); + } + else { + // just request everything at once + promises = $q.all(commands.map(invokeHttp)); + } - if (!angular.isNumber(settings.count) || settings.count < 0) { - throw new Error("count must be a positive integer"); + console.debug("predictiveCache:promiseResolution Promises enqueued"); + + return promises + .catch(function (error) { + console.error("predictiveCache:promiseResolution " + error, error); + }) + .finally(function () { + console.debug("predictiveCache:promiseResolution complete"); + }); } - if (acceptableVerbs.indexOf(settings.method) == -1) { - throw new Error("A valid http method is required"); + /** + * Allows a function to simulate the API of a RegExp + * @param {Object} response - the url to match against + * @param {RegExp|function} matcher - the object that determines a match + * @returns {null|Array} - the matches if present + */ + function isMatch(response, matcher) { + if (angular.isFunction(matcher)) { + var matches = matcher(response.config.url, response); + if (angular.isArray(matches) || matches === null) { + return matches; + } + + throw new Error("The match function must conform to the RegExp.match API"); + } + else if (matcher instanceof RegExp) { + var match = matcher.exec(response.config.url); + return match === null ? match : match.slice(1); + } + else { + throw new Error("The supplied matcher is neither a RegExp or a function!"); + } } - return settings; - } + /** + * Checks the url and returns a series of promises + * @returns {Promise} + * @param response + */ + function checkProfiles(response) { + return $q(function (resolve, reject) { + // initially determine if the url matches any of the profiles + var matches = []; + angular.forEach(profiles, function (p, key) { + var match = isMatch(response, p.match); + if (match) { + // then trigger async resolution + matches.push(createCacheRequests(p, response, match)); + } + }); - function createInterceptor() { - }; + if (matches.length > 0) { + resolve($q.all(matches)); + } + else { + reject(); + } + }); + } - return function predictiveCache(profile) { - if (!angular.isObject(profile)) { - throw new Error("A profile is required"); + /** + * The callback from the http interceptor + * @param url - the url from the config. + */ + function interceptorCallback(response) { + // knock it off to async ASAP so we don't block http stuff + checkProfiles(response); } - var settings = validateProfile(profile); + // setup callback + predictiveCacheInterceptor.listeners().push(interceptorCallback); - profiles[settings.name] = settings; + // return initializer + return function predictiveCache(profile) { + if (!angular.isObject(profile)) { + throw new Error("A profile is required"); + } - return settings; - }; - }]) - .config(['$httpProvider', function($httpProvider) { + var settings = validateProfile(profile); + + profiles[settings.name] = settings; + + return settings; + }; + }]) + .config(['$httpProvider', function ($httpProvider) { $httpProvider.interceptors.push("predictiveCacheInterceptor"); }]); \ No newline at end of file diff --git a/src/components/services/predictiveCache.spec.js b/src/components/services/predictiveCache.spec.js index f9b90baf..4dc9c7e1 100644 --- a/src/components/services/predictiveCache.spec.js +++ b/src/components/services/predictiveCache.spec.js @@ -1,25 +1,27 @@ describe("The predictiveCache service", function () { - var predictiveCache, - testProfile, + var testProfile, $httpProvider; beforeEach(module("bawApp.services", function (_$httpProvider_) { $httpProvider = _$httpProvider_; - })); - beforeEach(inject(function (_predictiveCache_) { - predictiveCache = _predictiveCache_; - + beforeEach(function () { testProfile = { name: "Media cache ahead", match: /google\.com\?page=(.*)&size=(.*)/, - request: ["one url", "another url"], + request: [ + function (args, data) { + var a = args[0], b = args[1]; + return "one " + a + " url" + b; + }, + "another {0} url {1}" + ], progression: [ 30.0, - function (data, previous) { + function (previous, data) { var next = previous + 30.0; - if (next >= data.max) { + if (data.responseData && next >= data.responseData.max) { return; } else { @@ -28,10 +30,10 @@ describe("The predictiveCache service", function () { } ], count: 10, - method: "HEAD" + method: "HEAD", + progressive: false }; - - })); + }); describe("The predictive cache http interceptor", function () { var predictiveCacheInterceptor, $httpBackend; @@ -43,7 +45,7 @@ describe("The predictiveCache service", function () { it("ensure the interceptor implements the expected methods", function () { expect(predictiveCacheInterceptor).toImplement({ - request: null, + response: null, listeners: null }); }); @@ -53,82 +55,347 @@ describe("The predictiveCache service", function () { expect(index >= 0).toBeTrue(); }); - it("ensures the request interceptor calls the correct method on every listener when a request is made", function () { - predictiveCacheInterceptor.listeners().push(); - spyOn(predictiveCache, "interceptorCallback"); + it("ensures the response interceptor calls the correct method on every listener when a request is made", function () { - var mockHttpConfig = { - url: "some fake url" + var listenerUrl = null, listenerDataSome = null; + predictiveCacheInterceptor.listeners().push(function (response) { + listenerUrl = response.config.url; + listenerDataSome = response.data.some; + }); + var listenerUrl2 = null, listenerDataSome2 = null; + predictiveCacheInterceptor.listeners().push(function (response) { + listenerUrl2 = response.config.url; + listenerDataSome2 = response.data.some; + }); + + var mockHttpResponse = { + config: { + url: "some fake url" + }, + data: { + some: "data" + } }; - predictiveCacheInterceptor.request(mockHttpConfig); + predictiveCacheInterceptor.response(mockHttpResponse); - expect(predictiveCache.interceptorCallback).toHaveBeenCalled(); - expect(predictiveCache.interceptorCallback).toHaveBeenCalledWith("some fake url"); + expect(listenerUrl).toBe("some fake url"); + expect(listenerUrl2).toBe("some fake url"); + expect(listenerDataSome).toBe("data"); + expect(listenerDataSome2).toBe("data"); }); - it("returns the http config unmodified", inject(["$http", function ($http) { - spyOn(predictiveCacheInterceptor, "request").and.callThrough(); + it("returns the response unmodified", inject(["$http", function ($http) { + spyOn(predictiveCacheInterceptor, "response").and.callThrough(); + + $httpBackend.expectGET("www.url.com").respond(200, {max: 30}); - $httpBackend.expectGET("www.url.com").respond(200); - var reqConfig; - $http.get("www.url.com").success(function(d,s,h,c,st) { - reqConfig = c; + var response; + $http.get("www.url.com").success(function (d, s, h, c, st) { + response = { + data: d, + status: s, + headers: h, + config: c, + statusText: st || "" + }; }); $httpBackend.flush(); - expect(predictiveCacheInterceptor.request).toHaveBeenCalled(); - var interceptorInput = predictiveCacheInterceptor.request.calls.argsFor(0); + expect(predictiveCacheInterceptor.response).toHaveBeenCalled(); + var interceptorInput = predictiveCacheInterceptor.response.calls.argsFor(0); - var e = JSON.stringify(interceptorInput[0]), - a = JSON.stringify(reqConfig); + var e = JSON.stringify(interceptorInput[0], undefined, 2), + a = JSON.stringify(response, undefined, 2); expect(a).toBe(e); }])); }); describe("The functionality of the cacher", function () { - var configuredProfile, $http, - exampleUrl = "http://www.google.com?page=1&size=10"; + var configuredProfile, + $http, + exampleUrl = "http://www.google.com?page=1&size=10", + httpEvents = [], + predictiveCache; + + beforeEach(function () { + module(function ($provide) { + $provide.factory("unitTestInterceptor", function () { + return { + request: function (httpConfig) { + httpEvents.push("ENQUEUE: " + httpConfig.url); + return httpConfig; + }, + response: function (response) { + httpEvents.push("COMPLETED: " + response.config.url); + return response; + } + }; + }); - beforeEach(inject(function ($injector) { - $httpBackend = $injector.get("$httpBackend"); + $httpProvider.interceptors.push("unitTestInterceptor"); + }); - // create .when for requests common to all tests + inject(function ($injector) { + $httpBackend = $injector.get("$httpBackend"); - // grab a reference to $http - $http = $injector.get("$http"); + // create .when for requests common to all tests - // set up the cache service (register everything) - configuredProfile = predictiveCache(testProfile); - })); + // grab a reference to $http + $http = $injector.get("$http"); + + // set up the cache service (register everything) + predictiveCache = $injector.get("predictiveCache"); + configuredProfile = predictiveCache(testProfile); + }); + + httpEvents = []; + }); afterEach(function () { - $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingExpectation(false); $httpBackend.verifyNoOutstandingRequest(); }); + function assertHttpEventsAreCorrect(expectedOrder) { + expect(httpEvents.length).toBe(expectedOrder.length); + + httpEvents.forEach(function (actualEvent, index) { + expect(actualEvent).toBe(expectedOrder[index]); + }); + } + it("makes the required requests when the url is matched", function () { + var expectedUrls = [ + ["one 31 url40", "another 31 url 40"], + ["one 61 url70", "another 61 url 70"], + ["one 91 url100", "another 91 url 100"], + ["one 121 url130", "another 121 url 130"], + ["one 151 url160", "another 151 url 160"], + ["one 181 url190", "another 181 url 190"], + ["one 211 url220", "another 211 url 220"], + ["one 241 url250", "another 241 url 250"], + ["one 271 url280", "another 271 url 280"], + ["one 301 url310", "another 301 url 310"] + ]; + + var expectedOrder = [ + "ENQUEUE: " + exampleUrl, + "COMPLETED: " + exampleUrl, + "ENQUEUE: one 31 url40", + "ENQUEUE: another 31 url 40", + "ENQUEUE: one 61 url70", + "ENQUEUE: another 61 url 70", + "ENQUEUE: one 91 url100", + "ENQUEUE: another 91 url 100", + "ENQUEUE: one 121 url130", + "ENQUEUE: another 121 url 130", + "ENQUEUE: one 151 url160", + "ENQUEUE: another 151 url 160", + "ENQUEUE: one 181 url190", + "ENQUEUE: another 181 url 190", + "ENQUEUE: one 211 url220", + "ENQUEUE: another 211 url 220", + "ENQUEUE: one 241 url250", + "ENQUEUE: another 241 url 250", + "ENQUEUE: one 271 url280", + "ENQUEUE: another 271 url 280", + "ENQUEUE: one 301 url310", + "ENQUEUE: another 301 url 310", + "COMPLETED: one 31 url40", + "COMPLETED: another 31 url 40", + "COMPLETED: one 61 url70", + "COMPLETED: another 61 url 70", + "COMPLETED: one 91 url100", + "COMPLETED: another 91 url 100", + "COMPLETED: one 121 url130", + "COMPLETED: another 121 url 130", + "COMPLETED: one 151 url160", + "COMPLETED: another 151 url 160", + "COMPLETED: one 181 url190", + "COMPLETED: another 181 url 190", + "COMPLETED: one 211 url220", + "COMPLETED: another 211 url 220", + "COMPLETED: one 241 url250", + "COMPLETED: another 241 url 250", + "COMPLETED: one 271 url280", + "COMPLETED: another 271 url 280", + "COMPLETED: one 301 url310", + "COMPLETED: another 301 url 310" + ]; $httpBackend.expectGET(exampleUrl).respond(200); - $httpBackend.expectHEAD("one url").respond(200); - $httpBackend.expectHEAD("another url").respond(200); + expectedUrls.forEach(function (pair) { + $httpBackend.expectHEAD(pair[0]).respond(200); + $httpBackend.expectHEAD(pair[1]).respond(200); + }); + $http.get(exampleUrl); $httpBackend.flush(); + + assertHttpEventsAreCorrect(expectedOrder); }); - }); - it("registers a new http interceptor when a profile is added", function () { - var interceptorCount = $httpProvider.interceptors.length; + it("correctly cancels an progression", function () { + var expectedUrls = [ + ["one 31 url40", "another 31 url 40"], + ["one 61 url70", "another 61 url 70"], + ["one 91 url100", "another 91 url 100"] + ]; + + $httpBackend.expectGET(exampleUrl).respond(200, {max: 101}); + expectedUrls.forEach(function (pair) { + $httpBackend.expectHEAD(pair[0]).respond(200); + $httpBackend.expectHEAD(pair[1]).respond(200); + }); + + $http.get(exampleUrl); + + $httpBackend.flush(); + }); + + it("correctly handles using match function", function () { + testProfile.match = function (url) { + if (/google\.com\?page=\d+&size=\d+/.test(url)) { + return [ + /page=([\.\d]+)/.exec(url)[1], + /size=([\.\d]+)/.exec(url)[1] + ]; + } + return null; + }; + configuredProfile = predictiveCache(testProfile); + var expectedUrls = [ + ["one 31 url40", "another 31 url 40"], + ["one 61 url70", "another 61 url 70"], + ["one 91 url100", "another 91 url 100"] + ]; + + $httpBackend.expectGET(exampleUrl).respond(200, {max: 101}); + expectedUrls.forEach(function (pair) { + $httpBackend.expectHEAD(pair[0]).respond(200); + $httpBackend.expectHEAD(pair[1]).respond(200); + }); + + $http.get(exampleUrl); + + $httpBackend.flush(); + }); + + it("correctly fails when a match function does not confirm to the RegExp API", function () { + testProfile.match = function (url) { + return; + }; + testProfile.progressions = [ + function() {}, + function() {} + ]; + + expect(function () { + configuredProfile = predictiveCache(testProfile); + $httpBackend.expectGET(exampleUrl).respond(200); + $http.get(exampleUrl); - var profile = predictiveCache(testProfile); + $httpBackend.flush(1); + }).toThrowError("The match function must conform to the RegExp.match API"); + }); - expect($httpProvider.interceptors.length).toBe(interceptorCount + 1); + it("correctly batches requests as a series of chained promised when progressive===true", function () { + testProfile.progressive = true; + configuredProfile = predictiveCache(testProfile); + var expectedUrls = [ + ["one 31 url40", "another 31 url 40"], + ["one 61 url70", "another 61 url 70"], + ["one 91 url100", "another 91 url 100"], + ["one 121 url130", "another 121 url 130"], + ["one 151 url160", "another 151 url 160"], + ["one 181 url190", "another 181 url 190"], + ["one 211 url220", "another 211 url 220"], + ["one 241 url250", "another 241 url 250"], + ["one 271 url280", "another 271 url 280"], + ["one 301 url310", "another 301 url 310"] + ]; + + var expectedOrder = [ + "ENQUEUE: " + exampleUrl, + "COMPLETED: " + exampleUrl, + "ENQUEUE: one 31 url40", "ENQUEUE: another 31 url 40", "COMPLETED: one 31 url40", "COMPLETED: another 31 url 40", + "ENQUEUE: one 61 url70", "ENQUEUE: another 61 url 70", "COMPLETED: one 61 url70", "COMPLETED: another 61 url 70", + "ENQUEUE: one 91 url100", "ENQUEUE: another 91 url 100", "COMPLETED: one 91 url100", "COMPLETED: another 91 url 100", + "ENQUEUE: one 121 url130", "ENQUEUE: another 121 url 130", "COMPLETED: one 121 url130", "COMPLETED: another 121 url 130", + "ENQUEUE: one 151 url160", "ENQUEUE: another 151 url 160", "COMPLETED: one 151 url160", "COMPLETED: another 151 url 160", + "ENQUEUE: one 181 url190", "ENQUEUE: another 181 url 190", "COMPLETED: one 181 url190", "COMPLETED: another 181 url 190", + "ENQUEUE: one 211 url220", "ENQUEUE: another 211 url 220", "COMPLETED: one 211 url220", "COMPLETED: another 211 url 220", + "ENQUEUE: one 241 url250", "ENQUEUE: another 241 url 250", "COMPLETED: one 241 url250", "COMPLETED: another 241 url 250", + "ENQUEUE: one 271 url280", "ENQUEUE: another 271 url 280", "COMPLETED: one 271 url280", "COMPLETED: another 271 url 280", + "ENQUEUE: one 301 url310", "ENQUEUE: another 301 url 310", "COMPLETED: one 301 url310", "COMPLETED: another 301 url 310" + ]; + $httpBackend.expectGET(exampleUrl).respond(200); + $http.get(exampleUrl); + + expectedUrls.forEach(function (pair, index) { + // expect two requests at a time to be issued + // two per progression + // so expect only two every flush! + //console.debug("expectations!", pair[0], pair[1]); + $httpBackend.expectHEAD(pair[0]).respond(200); + $httpBackend.expectHEAD(pair[1]).respond(200); + }); + + //console.debug("begin execution"); + //console.debug("flush!"); + + // promise resolution means the next all the requests are processed in a single + // flush whether we like it or not. + $httpBackend.flush(); + //console.debug("flush done!"); + + assertHttpEventsAreCorrect(expectedOrder); + }); + + it("handles negative/odd progressions", function () { + testProfile.progression = [ + function (previous, data) { + return Math.pow(previous, 2) - Math.pow(5, 2); + }, + -10 + ]; + testProfile.request = ["hello {0} {1}"]; + configuredProfile = predictiveCache(testProfile); + var expectedUrls = [ + ["hello -24 0"], + ["hello 551 -10"], + ["hello 303576 -20"], + ["hello 92158387751 -30"], + ["hello 8.493168432863667e+21 -40"], + ["hello 7.213391002899188e+43 -50"], + ["hello 5.203300976070695e+87 -60"], + ["hello 2.7074341047578252e+175 -70"], + ["hello Infinity -80"], + ["hello Infinity -90"] + ]; + + $httpBackend.expectGET(exampleUrl).respond(200); + expectedUrls.forEach(function (pair) { + $httpBackend.expectHEAD(pair[0]).respond(200); + }); + + $http.get(exampleUrl); + + $httpBackend.flush(); + }); }); describe("Validating the input profile", function () { + var predictiveCache; + beforeEach(inject(function (_predictiveCache_) { + predictiveCache = _predictiveCache_; + })); + + it("requires an object to function", function () { expect(function () { predictiveCache(); @@ -192,15 +459,21 @@ describe("The predictiveCache service", function () { }).toThrowError("A value for match must be provided"); }); - it("will fail if the supplied match regular expression is not a RegExp", function () { - testProfile.match = "test"; + it("will fail if the supplied match regular expression is not a RegExp or a function", function () { + testProfile.match = function () { + }; + var profile = predictiveCache(testProfile); + + testProfile.match = /test/; + profile = predictiveCache(testProfile); + testProfile.match = "test"; expect(function () { predictiveCache(testProfile); - }).toThrowError("The value for match must be a regular expression"); + }).toThrowError("The value for match must be a regular expression or a function"); }); - it("ensures the request value is an array of strings", function () { + it("ensures the request value is an array of strings or functions", function () { var failValues = [null, [], 33, [33]]; failValues.forEach(function (item) { @@ -208,10 +481,24 @@ describe("The predictiveCache service", function () { expect(function () { predictiveCache(testProfile); - }).toThrowError("requests must be an array of strings"); + }).toThrowError("requests must be an array of strings or functions"); }); }); + [ + {key: "an array of strings", in: ["hello", "world"]}, + {key: "an array of strings and functions", in: [angular.noop, "hello world"]}, + {key: "an array of functions", in: [angular.noop, angular.noop]} + ].forEach(function (requestTest) { + it("allows the request value to be " + requestTest.key, function () { + testProfile.request = requestTest.in; + + var profile = predictiveCache(testProfile); + expect(profile.request).toBe(requestTest.in); + }); + }); + + [ {key: "null", in: null, out: 1}, {key: "undefined", in: undefined, out: 1}, @@ -268,5 +555,24 @@ describe("The predictiveCache service", function () { expect(profile.count).toBe(10); }); + + it("ensures the progressive option strictly accepts booleans", function () { + testProfile.progressive = true; + var profile = predictiveCache(testProfile); + + testProfile.progressive = false; + profile = predictiveCache(testProfile); + + delete testProfile.progressive; + profile = predictiveCache(testProfile); + expect(profile.progressive).toBeTrue(); + + [null, 0, "false", [], {}, [false]].forEach(function (errorValue) { + expect(function () { + testProfile.progressive = errorValue; + predictiveCache(testProfile); + }).toThrowError("progressive must be boolean"); + }); + }); }); }); \ No newline at end of file