From 0ff65e6fd62af47337dcdfaccf8f8b8bdc9f7608 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Sun, 17 Aug 2014 22:20:58 -0400 Subject: [PATCH] Thenable interface and conformance test case This has been carefully balanced between the requirements of Promises/A+ and the abilitiy for implementations to provide their own flexible use cases for a polymorphic API; it will evolve as needed until the first release. The conformance test cases do pass properly---I will be comitting an implementation that makes use of the conformance tests. --- src/then/Thenable.js | 143 ++++++++++++++++++ test/then/ThenableTestConform.js | 243 +++++++++++++++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 src/then/Thenable.js create mode 100644 test/then/ThenableTestConform.js diff --git a/src/then/Thenable.js b/src/then/Thenable.js new file mode 100644 index 0000000..f07b0d3 --- /dev/null +++ b/src/then/Thenable.js @@ -0,0 +1,143 @@ +/** + * Promises/A+ "thenable" type + * + * Copyright (C) 2014 Mike Gerwitz + * + * This file is part of jsTonic. + * + * jstonic is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * There are a lot of words here, and very little code; to ensure that you + * get the most from your implementation of this interface, be sure to also + * run the Thenable conformance tests on it. + */ + +let Interface = require( 'easejs' ).Interface; + + +/** + * Any type defining a #then method with two parameters for, respectfully, + * asynchronous request fulfillment and rejection + * + * This formalizes "thenable" as defined in Promises/A+ in §2.2, but + * decoupled from the requirements of a "promise". When the term "thenable" + * is used, it refers to the Promises/A+ specification---any function or + * object with a `then` method; when `Thenable` (with a capital 'T') is + * used, it refers to this interface or an object of type `Thenable`. + * + * The distinction between a `Thenable` and a promise is intentional: a + * `Thenable` has certain guarantees ensuring that it can be used + * predictably (and polymorphically) as part of a promise-like pattern; it + * says nothing of how a `Thenable` is resolved (fulfilled or rejected), for + * instance. + * + * `Thenable`s are *not* required to be stateful and may therefore be + * implemented deterministically; that is---a `Thenable` need not keep track + * of prior invocations to prevent duplicate callback applications (indeed, + * this specification discourages stateful `Thenable`s). This can be + * rationalized in a number of ways, all conforming to the language and + * spirit of the Promises/A+ specification: + * + * 1. Section 1.2 defines a "thenable" as: "an object or function that + * defines a `then` method"; this is a very broad definition that does + * not even impose parameter requirements (but `Thenable` does); + * + * 2. Section 1.1 defines a "promise" as: "an object or function with a + * `then` method that *conforms to this specification*" (emphasis + * added); note that 1.2 has no requirement to conform to any + * specification; + * + * 3. States are imposed upon "promises" by §2.1---these states are what + * prevent invoking `then` callbacks multiple times, because + * Promises/A+ requires that a promise (a) cannot change state once + * resolved; and (b) cannot change its value; from this we infer that + * `Thenable`s need not manage such state; + * + * 4. Paragraphs 2.2.{2,3}.3 do require that each callback "not be called + * more than once", but this requirement is for a `then` method that + * conforms to the Promises/A+ specification---a requirement imposed + * not on a thenable, but instead upon a promise (by §1); and + * + * 5. Paragraph 2.3.3.3 describes invoking an arbitrary thenable, making + * no mention of state, and instead relying on the state of the promise + * being resolved, which wraps the result of the thenable. Indeed, the + * opening paragraphs of §2.3 include: "It also allows the + * Promises/A+ implementations to "assimilate" nonconformant + * implementations with reasonable `then` methods". `Thenable` ensures + * that such methods are reasonable through its specification and, + * notably, parameter requirements (which are compatible with 2.3.3.3). + * + * `Thenable` therefore permits arbitrary implementations---possibly + * entirely disjoint from the concept of promises---while still allowing + * them to work with the A+ implementation by imposing a *subset* of + * requirements of the Promises/A+ specification. This is important, since + * it would be ideal for a consumer of a promise to instead accept anything + * of type Thenable, confident that the callbacks will be invoked + * consistently and as intended, without having to worry about any + * particular promise implementation (thereby successfully remaining + * decoupled). + * + * The asynchronous requirement (imposed by the stack requirements in + * Promises/A+) of `Thenable` exists because it does not make much sense to + * use a synchronous thenable---otherwise, no `then` method would be needed, + * and the code should be written in a synchronous style; it is dangerous + * for code to be written in an asynchronous style and to then be executed + * synchronously, as it may have disastrous effects on system responsiveness + * (consider UI updates that use `then` as an implicit opportunity to + * repaint). + * + * Of course, this interface specification must be followed in order for + * such a point to be valid; just be cognisant of the implementations that + * you accept in your own software. This interface will accept any thenable + * as described by Promises/A+---even plain objects with a `then` + * method---through GNU ease.js' interop support, when the object in + * question is not an instance of an ease.js class. + * + * For examples---and to test your own Thenable implementations---refer to + * the conformance tests. + */ +module.exports = Interface( 'Thenable', +{ + /** + * Access current or eventual value or reason + * + * Both callbacks are optional and shall be either ignored or rejected + * if they are not functions, and shall be silently ignored if + * missing (length of argument list is less than two), `undefined` or + * `null`. Only one of either ONFULFILLED or ONREJECTED shall ever be + * invoked for a given value/reason pair. + * + * The callbacks must be executed after the call stack has cleared and + * contains only calls to platform code---in practice, this means + * executing after a setTimeout, process.nextTick, or something similar. + * They must be invoked as functions with no `this` value. + * + * This interface decouples thenables from the concept of a promise + * (contrary to Promises/A+ §2.2); therefore, the return value + * must return another Thenable, but any more specific implementation + * details are undefined; specific implementations may impose additional + * requirements. + * + * This method may be called multiple times for any `Thenable`, in which + * case all callbacks must be enqueued for invocation in the order that + * their respective `then` was called (Promises/A+ §2.2.6). + * + * @param {function(*)} onFulfilled invoked upon value fulfillment + * @param {function(*)} onRejected invoked upon value rejection/failure + * + * @return {Thenable} another Thenable object + */ + then: [ 'onFulfilled', 'onRejected' ], +} ); + diff --git a/test/then/ThenableTestConform.js b/test/then/ThenableTestConform.js new file mode 100644 index 0000000..5fb3018 --- /dev/null +++ b/test/then/ThenableTestConform.js @@ -0,0 +1,243 @@ +/** + * Tests for conforming Thenable implementation + * + * Copyright (C) 2014 Mike Gerwitz + * + * This file is part of jsTonic. + * + * jstonic is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +let Class = require( 'easejs' ).Class, + Sut = require( '../../' ).then.Thenable, + expect = require( 'chai' ).expect; + + +module.exports = ( ctor, accept, reject ) => +{ + describe( 'conforming to Thenable', () => + { + it( 'implements Thenable', () => + expect( Class.isA( Sut, ctor() ) ).to.be.true ); + + + describe( '#then', () => _thenTests( ctor, accept, reject ) ); + + + /** + * Promises/A+ 2.2.4. + * + * For detailed rationale on why this is imposed on a simple + * thenable, see the Thenable interface. + */ + describe( 'call stack', () => + describe( 'is allowed to clear', () => + { + let stack_ctor = ( type, done ) => + { + let cleared = false; + + let chk = () => + { + expect( cleared ).to.be.true; + done(); + }; + + // ensure the stack clears + setTimeout( () => cleared = true, 0 ); + + // if the callback is invoked asynchronously, then it + // will run after the above timeout callback + type( ctor().then( + type === accept && chk, + type === reject && chk ) ); + }; + + it( 'before invoking fulfillment callback', done => + stack_ctor( accept, done ) ); + + it( 'before invoking rejection callback', done => + stack_ctor( reject, done ) ); + } ) ); + } ); +}; + + +function _thenTests( ctor, accept, reject ) +{ + let sut; + + // these methods just make it easier to work into a lisp-style + // procedure + let accept_sut = () => accept( sut ), + reject_sut = () => reject( sut ); + + beforeEach( () => + { + sut = ctor(); + } ); + + + it( 'returns another Thenable', () => + expect( Class.isA( Sut, ctor().then( null, null ) ) ) + .to.be.true ); + + + describe( 'callbacks', () => + { + /** + * The Promises/A+ specification states that non-function + * arguments must be "ignored"; this is a bit ambiguous, since + * ignoring can involve any number of things, including throwing + * an exception (and ignoring the values as a consequence of + * aborting the call). + * + * We will allow the implementation to do whatever it chooses, + * provided that the most definitive indications of intentional + * omission are permitted without error. + * + * See Promises/A+ §2.2.1. + */ + describe( 'params', () => + { + it( 'permit omitted callbacks', () => + expect( () => ctor().then() ) + .to.not.throw( Error ) ); + + it( 'permit undefined callbacks', () => + expect( () => ctor().then( undefined, undefined ) ) + .to.not.throw( Error ) ); + + it( 'permit null callbacks', () => + expect( () => ctor().then( null, null ) ) + .to.not.throw( Error ) ); + } ); + + + it( 'trigger fulfillment on accept', done => + accept_sut( sut.then( ()=>done() ) ) ); + + it( 'trigger rejection on reject', done => + reject_sut( sut.then( null, ()=>done() ) ) ); + + + /** + * An implementation that invokes both callbacks for a given + * value/reason pair (that is---either an accept or reject) is + * implying that the request both succeeded and failed; since we + * have no intent on supporting superimposed states---and it's + * rational to assume that the consumer of the implementation + * has no such intent either---such an implementation should be + * considered to be in error. + * + * This will obviously not catch cases that may require certain + * types of input (e.g. a bug in specific portions of the + * logic), but will hopefully help to point out fatal design + * flaws, and prevent the need to duplicate such obvious tests + * for every `Thenable` implementation. + */ + describe( 'are not both called for a value/reason pair', () => + { + /** + * This implementation attempts to determine, broadly, + * whether both callbacks were invoked. Doing so is not + * fool-proof, since `Thenable` mandates asynchrony, but we + * have no idea when the asynchronous event will be + * scheduled. Here, we assume that the callbacks are + * scheduled immediately after the invocation of `accept` or + * `reject`, and that scheduling our own callback would + * enqueue it after any `Thenable` callbacks. This may or + * may not be true. + */ + let postchk = ( f, done ) => + { + let ok = true, + cf = () => ok = false; + + f( ctor().then( + ( f === reject ) && cf, + ( f === accept ) && cf ) ); + + setTimeout( () => { + expect( ok ).to.be.true; + done(); + }, 0 ); + }; + + it( 'when fulfilled', done => + postchk( accept, done ) ); + + it( 'when rejected', done => + postchk( reject, done ) ); + } ); + + + /** + * Promises/A+ §2.2.5. + */ + describe( 'calling context', () => + { + let no_context = ( () => this )(); + + let expect_no_c = done => + () => expect( this ).to.equal( no_context ) + && done(); + + it( 'invokes fulfilled callback without context', done => + accept_sut( sut + .then( expect_no_c( ()=>done() ), null ) ) ); + + it( 'invokes reject callback without context', done => + reject_sut( sut + .then( null, expect_no_c( ()=>done() ) ) ) ); + } ); + } ); + + + describe( 'invoked multiple times', () => + /** + * There may be more than one observer interested in the results + * of a computation; Promises/A+ §2.2.6 states that each + * callback should be invoked in the order of their respective + * `then` calls. + */ + describe( 'will invoke in order of respective #then', () => + { + let c = ( chk, i ) => { + expect( chk[ i-1 ] ).to.be.undefined; + chk[ i ] = undefined; + }; + + let multi_ctor = done => + { + let chk = [ 0, 1, 2 ]; + + chk.forEach( i => + { + let f = c( chk, i ); + sut.then( f, f ); + } ); + + sut.then( ()=>done(), ()=>done() ); + return sut; + }; + + it( 'all fulfillment callbacks upon accept', done => + accept_sut( multi_ctor( done ) ) ); + + it( 'all rejection callbacks upon reject', done => + reject_sut( multi_ctor( done ) ) ); + } ) ); +} +