diff --git a/src/event/Evented.js b/src/event/Evented.js new file mode 100644 index 0000000..61c8b3f --- /dev/null +++ b/src/event/Evented.js @@ -0,0 +1,176 @@ +/** + * Trait providing 1-N message dispatch + * + * 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 . + */ + +var Trait = require( 'easejs' ).Trait, + isArray = require( '../std/Array' ).isArray; + + +/** + * Flexible event system augmentation + * + * The class mixing in this trait will be endowed with a complete event + * system with which events may be registered, hooked, and emitted. The + * callback scheduler may be overridden to alter the algorithm for invoking + * callbacks. + * + * The API is motivated by (and is a replacement for) Node.js' event emitter + * implementation. + */ +module.exports = Trait( 'Evented', +{ + /** + * Registered events and their callbacks + * @type {Object} + */ + _events: {}, + + + /** + * Defines a list of events by unique identifier + * + * If any of the given identifiers is already in use (or if duplicates + * are provided), an error will be thrown; this is to prevent + * accidentally mixing callbacks when an event is assumed to have not + * already been defined. + * + * @param {Array.} ids list of event ids + * @return {Evented} self + * + * @throws {TypeError} if an array is not provided + * @throws {Error} on duplicate id conflict + */ + 'protected defineEvents': function( ids ) + { + if ( !isArray( ids ) ) { + throw TypeError( "Expected array of event ids" ); + } + + this._mergeDfn( ids ); + return this; + }, + + + /** + * Merges provided event ids into list of available events + * + * If an id is already defined, an exception will be thrown. + * + * @param {Array.} ids event ids + * @return {undefined} + * + * @throws {Error} on duplicate id definition + */ + _mergeDfn: function( ids ) + { + var i = ids.length; + while ( i-- ) { + var id = ids[ i ]; + + if ( this._events[ id ] ) { + throw Error( "Duplicate definition of event `" + id + "'" ); + } + + // will contain each callback associated with this event + this._events[ ids[ i ] ] = []; + } + }, + + + /** + * Schedules all callbacks for defined event EV + * + * See documentation for the `scheduleCallbacks` method for information + * on the default callback scheduler. + * + * @param {string} ev defined event id + * @return {Evented} self + */ + 'protected emit': function( ev ) + { + if ( !( this._events[ ev ] ) ) { + throw Error( "Cannot emit undefined event `" + ev + "'" ); + } + + var args = [], + i = arguments.length; + + // store args in a way that v8 can optimize + while ( --i ) { + args[ i - 1 ] = arguments[ i ]; + } + + this.scheduleCallbacks( ev, this._events[ ev ], args ); + return this; + }, + + + /** + * Invoke all callbacks for the given event + * + * The default scheduler invokes the callbacks synchronously and in an + * undefined order (that is, the implementation may change at any time); + * this method can be overridden to provide a different scheduler that + * provides different guarantees. + * + * @param {string} ev defined event id + * @param {Array.} evc event callbacks (hooks) + * @param {Array} args argument list to apply to callbacks + * + * @return {Evented} self + */ + 'virtual protected scheduleCallbacks': function( ev, evc, args ) + { + var i = evc.length; + + while ( i-- ) { + evc[ i ].apply( null, args ); + } + + return this; + }, + + + /** + * Invoke callback when an event is emitted + * + * The event EV must be defined and CALLBACK must be a function. The + * context in which the callback is invoked (the value of `this') is + * undefined. + * + * @param {string} ev defined event id + * @param {Function} callback function to invoke when event is emitted + * + * @return {Evented} self + */ + on: function( ev, callback ) + { + if ( !( this._events[ ev ] ) ) { + throw Error( "Cannot hook undefined event `" + ev + "'" ); + } + else if ( typeof callback !== 'function' ) { + throw TypeError( "Event callback must be a function" ); + } + + this._events[ ev ].push( callback ); + return this; + } +} ); + diff --git a/src/std/Array.js b/src/std/Array.js index bd3eed2..2b8b833 100644 --- a/src/std/Array.js +++ b/src/std/Array.js @@ -40,7 +40,9 @@ module.exports = { */ _isArray: function( arr ) { - return ( Object.prototype.toString.call( arr ) === '[object Array]' ); + return ( + Object.prototype.toString.call( arr ) === '[object Array]' + ); }, }; diff --git a/test/event/EventedTest.js b/test/event/EventedTest.js new file mode 100644 index 0000000..2b88498 --- /dev/null +++ b/test/event/EventedTest.js @@ -0,0 +1,391 @@ +/** + * Tests trait providing 1-N message dispatch + * + * 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 . + */ + +var Sut = require( '../../' ).event.Evented, + Class = require( 'easejs' ).Class, + expect = require( 'chai' ).expect, + common = require( '../lib' ), + + EvStub = Class.use( Sut ).extend( + { + // defineEvents is protected + evDefineEvents: function( ids ) + { + return this.defineEvents( ids ); + }, + + // emit is protected + evEmit: function( ev ) + { + return this.emit.apply( this, arguments ); + } + } ); + + +describe( 'event.Evented', function() +{ + var stub; + + beforeEach( function() + { + stub = EvStub(); + } ); + + + describe( '#defineEvents', function() + { + /** + * We aren't going to bother testing every possibly value; let's + * just see if the most common case (probably) is caught. + */ + it( 'rejects non-array argument', function() + { + expect( function() + { + stub.evDefineEvents( 'string' ); + } ).to.throw( TypeError, "array" ); + } ); + + + it( 'accepts array argument', function() + { + expect( function() + { + stub.evDefineEvents( [] ); + } ).to.not.throw( TypeError ); + } ); + + + it( 'returns self', function() + { + expect( stub.evDefineEvents( [] ) ).to.equal( stub ); + } ); + + + /** + * This relies on functionality proven below + */ + it( 'defines each event in provided list', function() + { + var events = [ 'a', 'b' ]; + stub.evDefineEvents( events ); + + var i = events.length; + while ( i-- ) { + expect( function() + { + // will fail if event did not register + stub.on( events[ i ], function() {} ); + } ).to.not.throw( Error ); + } + } ); + + + /** + * While at first glance this may seem like an odd thing to want to + * do, this will come in useful when exposed via an API that defines + * events individually. + */ + it( 'merges definitions in multiple calls', function() + { + var evs = [ 'a', 'b' ]; + + // define each separately + for ( var i = 0; i < evs.length; i++ ) { + stub.evDefineEvents( [ evs[ i ] ] ); + + expect( function() + { + // will fail if event did not register + stub.on( evs[ i ], function() {} ); + } ).to.not.throw( Error ); + } + } ); + + + /** + * We do not want someone to declare an event thinking that the id + * is free, which risks mixing handlers from what is supposed to be + * two separate events. + */ + describe( 'throws exception on duplicate events', function() + { + it( 'in separate calls', function() + { + var name = 'ev'; + stub.evDefineEvents( [ name ] ); + + expect( function() + { + stub.evDefineEvents( [ name ] ); + } ).to.throw( Error, name ); + } ); + + + it( 'in a single call', function() + { + var dup = 'foo'; + + expect( function() + { + stub.evDefineEvents( [ 'a', dup, 'b', dup ] ); + } ).to.throw( Error, dup ); + } ); + } ); + } ); + + + describe( '#on', function() + { + it( 'does not allow hooking undeclared events', function() + { + var badevent = 'bazbar'; + + expect( function() + { + stub.on( badevent, function() {} ); + } ).to.throw( Error, badevent ); + } ); + + + it( 'allows hooking declared events', function() + { + var name = 'testev'; + stub.evDefineEvents( [ name ] ) + .on( name, function() {} ); + } ); + + + it( 'returns self', function() + { + var name = 'testev'; + + var ret = stub.evDefineEvents( [ name ] ) + .on( name, function() {} ); + + expect( ret ).to.equal( stub ); + } ); + + + it( 'requires that callback is a function', function() + { + var ev = 'foo'; + stub.evDefineEvents( [ ev ] ); + + // OK + expect( function() + { + stub.on( ev, function() {} ); + } ).to.not.throw( TypeError ); + + // bad + expect( function() + { + stub.on( ev, "kittens" ); + } ).to.throw( TypeError ); + } ); + } ); + + + describe( '#emit', function() + { + it( 'cannot emit undefined events', function() + { + var ev = 'unknown'; + + expect( function() + { + stub.evEmit( ev ); + } ).to.throw( Error, ev ); + } ); + + + it( 'invokes all callbacks attached with #on', function() + { + var ev = 'foo', + called = 0; + + stub.evDefineEvents( [ ev ] ) + .on( ev, function() + { + called++; + } ); + + expect( function() + { + stub.evEmit( ev ); + } ).to.not.throw( Error ); + + // make sure the callback was invoked only once + expect( called ).to.equal( 1 ); + } ); + + + /** + * It is courteous for event handler to *not* modify the values they + * are given. Otherwise, the emitter must take care to clone or + * encapsulate the values if this is a concern. + */ + it( 'passes all emit args by reference to callbacks', function( done ) + { + var ev = 'foo', + a = {}, + b = []; + + stub.evDefineEvents( [ ev ] ) + .on( ev, function( givena, givenb ) + { + expect( givena ).to.equal( a ); + expect( givenb ).to.equal( b ); + done(); + } ) + .evEmit( ev, a, b ); + } ); + + + /** + * The default event scheduler synchronously invokes all callbacks + * in an undefined order (that is: the order may change in the + * future). This is not desirable for all cases, but that's not the + * point here. + */ + it( 'invokes multiple callbacks synchronously', function() + { + var ev = 'foo', + called = [], + n = 2; + + stub.evDefineEvents( [ ev ] ); + + // add N callbacks for EV + for ( var i = 0; i < n; i++ ) { + ( function( i ) + { + stub.on( ev, function() + { + called[ i ] = true; + } ); + } )( i ); + } + + // trigger callbacks + stub.evEmit( ev ); + + // by now, we care about two things: that all callbacks were + // invoked and that they have been invoked by this point, + // meaning that they were done synchronously + while ( n-- ) { + expect( called[ n ] ).to.equal( true ); + } + } ); + + + /** + * When we emit an event, only the callbacks for that specific event + * should be invoked. + */ + it( 'does not mix callbacks from other events', function() + { + var ev = 'foo', + wrongev = 'bar'; + + stub.evDefineEvents( [ ev, wrongev ] ); + + var called = false; + stub.on( ev, function() { called = true; } ); + stub.on( wrongev, function() { throw Error( "Thar be demons" ); } ); + + // wrongev callback should *not* be called + stub.evEmit( ev ); + expect( called ).to.equal( true ); + } ); + + + /** + * It is common to leave events unhooked. + */ + it( 'does not fail when emitting unhooked events', function() + { + var ev = 'foo'; + expect( function() + { + // no hooks, but emit + stub.evDefineEvents( [ ev ] ).evEmit( ev ); + } ).to.not.throw( Error ); + } ); + + + it( 'returns self', function() + { + var ev = 'foo'; + expect( stub.evDefineEvents( [ ev ] ).evEmit( ev ) ) + .to.equal( stub ); + } ); + } ); + + + /** + * This is important to support stackable traits for custom schedulers. + */ + describe( '#scheduleCallbacks', function() + { + var ev = 'foo', + a = 'bar', + b = 'baz'; + + it( 'can be overridden by subtypes', function( done ) + { + common.checkOverride( EvStub, 'scheduleCallbacks', + function( given_ev, _, given_args ) + { + expect( given_ev ).to.equal( ev ); + expect( given_args ).to.deep.equal( [ a, b ] ); + done(); + } + )().evDefineEvents( [ ev ] ).evEmit( ev, a, b ); + } ); + + + /** + * The listeners are encapsulated within the trait, so they must be + * passed to us for processing. + */ + it( 'is provided with callbacks to invoke', function( done ) + { + var a = function() {}, + b = function() {}; + + common.checkOverride( EvStub, 'scheduleCallbacks', + function( _, evc, __ ) + { + expect( evc ).to.include( a ); + expect( evc ).to.include( b ); + done(); + } + )() + .evDefineEvents( [ ev ] ) + .on( ev, a ) + .on( ev, b ) + .evEmit( ev ); + } ); + } ); +} ); +