From 2a5c3555829da51f55abd810a828c73b420316d3 Mon Sep 17 00:00:00 2001 From: Caio Cunha Date: Sun, 24 Mar 2013 22:18:10 -0300 Subject: [PATCH] feat($q): added support to promise notification It is now possible to notify a promise through deferred.notify() method. Notifications are useful to provide a way to send progress information to promise holders. --- src/ng/q.js | 48 ++++++- test/ng/qSpec.js | 317 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 345 insertions(+), 20 deletions(-) diff --git a/src/ng/q.js b/src/ng/q.js index 994ecd8ddbec..fe05b37f5659 100644 --- a/src/ng/q.js +++ b/src/ng/q.js @@ -199,7 +199,7 @@ function qFactory(nextTick, exceptionHandler) { var callback; for (var i = 0, ii = callbacks.length; i < ii; i++) { callback = callbacks[i]; - value.then(callback[0], callback[1]); + value.then(callback[0], callback[1], callback[2]); } }); } @@ -212,8 +212,25 @@ function qFactory(nextTick, exceptionHandler) { }, + notify: function(progress) { + if (pending) { + var callbacks = pending; + + if (pending.length) { + nextTick(function() { + var callback; + for (var i = 0, ii = callbacks.length; i < ii; i++) { + callback = callbacks[i]; + callback[2](progress); + } + }); + } + } + }, + + promise: { - then: function(callback, errback) { + then: function(callback, errback, progressback) { var result = defer(); var wrappedCallback = function(value) { @@ -234,10 +251,18 @@ function qFactory(nextTick, exceptionHandler) { } }; + var wrappedProgressback = function(progress) { + try { + result.notify((progressback || defaultCallback)(progress)); + } catch(e) { + exceptionHandler(e); + } + }; + if (pending) { - pending.push([wrappedCallback, wrappedErrback]); + pending.push([wrappedCallback, wrappedErrback, wrappedProgressback]); } else { - value.then(wrappedCallback, wrappedErrback); + value.then(wrappedCallback, wrappedErrback, wrappedProgressback); } return result.promise; @@ -359,7 +384,7 @@ function qFactory(nextTick, exceptionHandler) { * @param {*} value Value or a promise * @returns {Promise} Returns a promise of the passed value or promise */ - var when = function(value, callback, errback) { + var when = function(value, callback, errback, progressback) { var result = defer(), done; @@ -381,15 +406,26 @@ function qFactory(nextTick, exceptionHandler) { } }; + var wrappedProgressback = function(progress) { + try { + return (progressback || defaultCallback)(progress); + } catch (e) { + exceptionHandler(e); + } + }; + nextTick(function() { ref(value).then(function(value) { if (done) return; done = true; - result.resolve(ref(value).then(wrappedCallback, wrappedErrback)); + result.resolve(ref(value).then(wrappedCallback, wrappedErrback, wrappedProgressback)); }, function(reason) { if (done) return; done = true; result.resolve(wrappedErrback(reason)); + }, function(progress) { + if (done) return; + result.notify(wrappedProgressback(progress)); }); }); diff --git a/test/ng/qSpec.js b/test/ng/qSpec.js index 8fc5c50a4d10..316f8add68a1 100644 --- a/test/ng/qSpec.js +++ b/test/ng/qSpec.js @@ -43,7 +43,7 @@ describe('q', function() { return map(sliceArgs(args), _argToString).join(','); } - // Help log invocation of success(), always() and error() + // Help log invocation of success(), always(), progress() and error() function _logInvocation(funcName, args, returnVal, throwReturnVal) { var logPrefix = funcName + '(' + _argumentsToString(args) + ')'; if (throwReturnVal) { @@ -91,6 +91,22 @@ describe('q', function() { } } + /** + * Creates a callback that logs its invocation in `log`. + * + * @param {(number|string)} name Suffix for 'progress' name. e.g. progress(1) => progress + * @param {*=} returnVal Value that the callback should return. If unspecified, the passed in + * value is returned. + * @param {boolean=} throwReturnVal If true, the `returnVal` will be thrown rather than returned. + */ + function progress(name, returnVal, throwReturnVal) { + var returnValDefined = (arguments.length >= 2); + name = 'progress' + (name || ''); + return function() { + return _logInvocation(name, arguments, (returnValDefined ? returnVal : arguments[0]), throwReturnVal); + } + } + /** * Creates a callback that logs its invocation in `log`. * @@ -126,6 +142,13 @@ describe('q', function() { } + /** helper for synchronous notification of deferred */ + function syncNotify(deferred, progress) { + deferred.notify(progress); + mockNextTick.flush(); + } + + /** converts the `log` to a '; '-separated string */ function logStr() { return log.join('; '); @@ -377,6 +400,114 @@ describe('q', function() { }); + describe('notify', function() { + it('should execute all progress callbacks in the registration order', + function() { + promise.then(success(1), error(1), progress(1)); + promise.then(success(2), error(2), progress(2)); + expect(logStr()).toBe(''); + + deferred.notify('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('progress1(foo)->foo; progress2(foo)->foo'); + }); + + + it('should do nothing if a promise was previously resolved', function() { + promise.then(success(1), error(1), progress(1)); + expect(logStr()).toBe(''); + + deferred.resolve('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('success1(foo)->foo'); + + log = []; + deferred.notify('bar'); + expect(mockNextTick.queue.length).toBe(0); + expect(logStr()).toBe(''); + }); + + + it('should do nothing if a promise was previously rejected', function() { + promise.then(success(1), error(1), progress(1)); + expect(logStr()).toBe(''); + + deferred.reject('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('error1(foo)->reject(foo)'); + + log = []; + deferred.reject('bar'); + deferred.resolve('baz'); + deferred.notify('qux') + expect(mockNextTick.queue.length).toBe(0); + expect(logStr()).toBe(''); + + promise.then(success(2), error(2), progress(2)); + expect(logStr()).toBe(''); + mockNextTick.flush(); + expect(logStr()).toBe('error2(foo)->reject(foo)'); + }); + + + it('should not apply any special treatment to promises passed to notify', function() { + var deferred2 = defer(); + promise.then(success(), error(), progress()); + + deferred.notify(deferred2.promise); + mockNextTick.flush(); + expect(logStr()).toBe('progress({})->{}'); + }); + + + it('should call the progress callbacks in the next turn', function() { + promise.then(success(), error(), progress(1)); + promise.then(success(), error(), progress(2)); + expect(logStr()).toBe(''); + + deferred.notify('foo'); + expect(logStr()).toBe(''); + + mockNextTick.flush(); + expect(logStr()).toBe('progress1(foo)->foo; progress2(foo)->foo'); + }); + + + it('should ignore notifications sent out in the same turn before listener registration', + function() { + deferred.notify('foo'); + promise.then(success(), error(), progress(1)); + expect(logStr()).toBe(''); + expect(mockNextTick.queue).toEqual([]); + }); + + + it('should support non-bound execution', function() { + var notify = deferred.notify; + promise.then(success(), error(), progress()); + notify('detached'); + mockNextTick.flush(); + expect(logStr()).toBe('progress(detached)->detached'); + }); + + + it("should not save and re-emit progress notifications between ticks", function () { + promise.then(success(1), error(1), progress(1)); + deferred.notify('foo'); + deferred.notify('bar'); + mockNextTick.flush(); + expect(logStr()).toBe('progress1(foo)->foo; progress1(bar)->bar'); + + log = []; + promise.then(success(2), error(2), progress(2)); + deferred.notify('baz'); + mockNextTick.flush(); + expect(logStr()).toBe('progress1(baz)->baz; progress2(baz)->baz'); + }); + + }); + + describe('promise', function() { it('should have a then method', function() { expect(typeof promise.then).toBe('function'); @@ -388,7 +519,7 @@ describe('q', function() { describe('then', function() { - it('should allow registration of a success callback without an errback ' + + it('should allow registration of a success callback without an errback or progressback ' + 'and resolve', function() { promise.then(success()); syncResolve(deferred, 'foo'); @@ -404,7 +535,15 @@ describe('q', function() { }); - it('should allow registration of an errback without a success callback and ' + + it('should allow registration of a success callback without an progressback and notify', + function() { + promise.then(success()); + syncNotify(deferred, 'doing'); + expect(logStr()).toBe(''); + }); + + + it('should allow registration of an errback without a success or progress callback and ' + ' reject', function() { promise.then(null, error()); syncReject(deferred, 'oops!'); @@ -420,12 +559,44 @@ describe('q', function() { }); + it('should allow registration of an errback without a progress callback and notify', + function() { + promise.then(null, error()); + syncNotify(deferred, 'doing'); + expect(logStr()).toBe(''); + }); + + + it('should allow registration of an progressback without a success or error callback and ' + + 'notify', function() { + promise.then(null, null, progress()); + syncNotify(deferred, 'doing'); + expect(logStr()).toBe('progress(doing)->doing'); + }); + + + it('should allow registration of an progressback without a success callback and resolve', + function() { + promise.then(null, null, progress()); + syncResolve(deferred, 'done'); + expect(logStr()).toBe(''); + }); + + + it('should allow registration of an progressback without a error callback and reject', + function() { + promise.then(null, null, progress()); + syncReject(deferred, 'oops!'); + expect(logStr()).toBe(''); + }); + + it('should resolve all callbacks with the original value', function() { - promise.then(success('A', 'aVal'), error()); - promise.then(success('B', 'bErr', true), error()); - promise.then(success('C', q.reject('cReason')), error()); - promise.then(success('D', q.reject('dReason'), true), error()); - promise.then(success('E', 'eVal'), error()); + promise.then(success('A', 'aVal'), error(), progress()); + promise.then(success('B', 'bErr', true), error(), progress()); + promise.then(success('C', q.reject('cReason')), error(), progress()); + promise.then(success('D', q.reject('dReason'), true), error(), progress()); + promise.then(success('E', 'eVal'), error(), progress()); expect(logStr()).toBe(''); syncResolve(deferred, 'yup'); @@ -438,10 +609,10 @@ describe('q', function() { it('should reject all callbacks with the original reason', function() { - promise.then(success(), error('A', 'aVal')); - promise.then(success(), error('B', 'bEr', true)); - promise.then(success(), error('C', q.reject('cReason'))); - promise.then(success(), error('D', 'dVal')); + promise.then(success(), error('A', 'aVal'), progress()); + promise.then(success(), error('B', 'bEr', true), progress()); + promise.then(success(), error('C', q.reject('cReason')), progress()); + promise.then(success(), error('D', 'dVal'), progress()); expect(logStr()).toBe(''); syncReject(deferred, 'noo!'); @@ -449,6 +620,23 @@ describe('q', function() { }); + it('should notify all callbacks with the original value', function() { + promise.then(success(), error(), progress('A', 'aVal')); + promise.then(success(), error(), progress('B', 'bErr', true)); + promise.then(success(), error(), progress('C', q.reject('cReason'))); + promise.then(success(), error(), progress('C_reject', q.reject('cRejectReason'), true)); + promise.then(success(), error(), progress('Z', 'the end!')); + + expect(logStr()).toBe(''); + syncNotify(deferred, 'yup'); + expect(log).toEqual(['progressA(yup)->aVal', + 'progressB(yup)->throw(bErr)', + 'progressC(yup)->{}', + 'progressC_reject(yup)->throw({})', + 'progressZ(yup)->the end!']); + }); + + it('should propagate resolution and rejection between dependent promises', function() { promise.then(success(1, 'x'), error('1')). then(success(2, 'y', true), error('2')). @@ -466,6 +654,23 @@ describe('q', function() { }); + it('should propagate notification between dependent promises', function() { + promise.then(success(), error(), progress(1, 'a')). + then(success(), error(), progress(2, 'b')). + then(success(), error(), progress(3, 'c')). + then(success(), error(), progress(4)). + then(success(), error(), progress(5)); + + expect(logStr()).toBe(''); + syncNotify(deferred, 'wait'); + expect(log).toEqual(['progress1(wait)->a', + 'progress2(a)->b', + 'progress3(b)->c', + 'progress4(c)->c', + 'progress5(c)->c']); + }); + + it('should reject a derived promise if an exception is thrown while resolving its parent', function() { promise.then(success(1, 'oops', true), error(1)). @@ -484,6 +689,18 @@ describe('q', function() { }); + it('should stop notification propagation in case of error', function() { + promise.then(success(), error(), progress(1)). + then(success(), error(), progress(2, 'ops!', true)). + then(success(), error(), progress(3)); + + expect(logStr()).toBe(''); + syncNotify(deferred, 'wait'); + expect(log).toEqual(['progress1(wait)->wait', + 'progress2(wait)->throw(ops!)']); + }); + + it('should call success callback in the next turn even if promise is already resolved', function() { deferred.resolve('done!'); @@ -744,6 +961,18 @@ describe('q', function() { }); + describe('notification', function() { + it('should call the progressback when the value is a promise and gets notified', + function() { + q.when(deferred.promise, success(), error(), progress()); + mockNextTick.flush(); + expect(logStr()).toBe(''); + syncNotify(deferred, 'notification'); + expect(logStr()).toBe('progress(notification)->notification'); + }); + }); + + describe('optional callbacks', function() { it('should not require success callback and propagate resolution', function() { q.when('hi', null, error()).then(success(2), error()); @@ -775,6 +1004,16 @@ describe('q', function() { mockNextTick.flush(); expect(logStr()).toBe('error2(sorry)->reject(sorry)'); }); + + + it('should not require progressback and propagate notification', function() { + q.when(deferred.promise). + then(success(), error(), progress()); + mockNextTick.flush(); + expect(logStr()).toBe(''); + syncNotify(deferred, 'notification'); + expect(logStr()).toBe('progress(notification)->notification'); + }); }); @@ -838,9 +1077,10 @@ describe('q', function() { it('should call success callback only once even if the original promise gets fullfilled ' + 'multiple times', function() { var evilPromise = { - then: function(success, error) { + then: function(success, error, progress) { evilPromise.success = success; evilPromise.error = error; + evilPromise.progress = progress; } }; @@ -863,9 +1103,10 @@ describe('q', function() { it('should call errback only once even if the original promise gets fullfilled multiple ' + 'times', function() { var evilPromise = { - then: function(success, error) { + then: function(success, error, progress) { evilPromise.success = success; evilPromise.error = error; + evilPromise.progress = progress; } }; @@ -879,6 +1120,29 @@ describe('q', function() { evilPromise.success('take this'); expect(logStr()).toBe('error(failed)->reject(failed)'); }); + + + it('should not call progressback after promise gets fullfilled, even if original promise ' + + 'gets notified multiple times', function() { + var evilPromise = { + then: function(success, error, progress) { + evilPromise.success = success; + evilPromise.error = error; + evilPromise.progress = progress; + } + }; + + q.when(evilPromise, success(), error(), progress()); + mockNextTick.flush(); + expect(logStr()).toBe(''); + evilPromise.progress('notification'); + evilPromise.success('ok'); + mockNextTick.flush(); + expect(logStr()).toBe('progress(notification)->notification; success(ok)->ok'); + + evilPromise.progress('muhaha'); + expect(logStr()).toBe('progress(notification)->notification; success(ok)->ok'); + }); }); }); @@ -921,6 +1185,21 @@ describe('q', function() { }); + it('should not forward notifications from individual promises to the combined promise', + function() { + var deferred1 = defer(), + deferred2 = defer(); + + q.all([promise, deferred1.promise, deferred2.promise]).then(success(), error(), progress()); + expect(logStr()).toBe(''); + deferred.notify('x'); + deferred2.notify('y'); + expect(logStr()).toBe(''); + mockNextTick.flush(); + expect(logStr()).toBe(''); + }); + + it('should ignore multiple resolutions of an (evil) array promise', function() { var evilPromise = { then: function(success, error) { @@ -1064,6 +1343,16 @@ describe('q', function() { }); + it('should log exceptions throw in a progressack and stop propagation, but shoud NOT reject ' + + 'the promise', function() { + promise.then(success(), error(), progress(1, 'failed', true)).then(null, error(1), progress(2)); + syncNotify(deferred, '10%'); + expect(logStr()).toBe('progress1(10%)->throw(failed)'); + expect(mockExceptionLogger.log).toEqual(['failed']); + log = []; + syncResolve(deferred, 'ok'); + expect(logStr()).toBe('success(ok)->ok'); + }); });