diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 5611150d..233f4a82 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -1,2 +1,276 @@ /*global bane:false, when:false*/ +function Mopidy(settings) { + var mopidy = this; + + mopidy._webSocket = null; + mopidy._pendingRequests = {}; + mopidy._backoffDelayMin = 1000; + mopidy._backoffDelayMax = 64000; + mopidy._backoffDelay = mopidy._backoffDelayMin; + + mopidy._settings = settings || {}; + mopidy._settings.webSocketUrl = + mopidy._settings.webSocketUrl || + "ws://" + document.location.host + "/mopidy/ws/"; + if (mopidy._settings.autoConnect !== false) { + mopidy._settings.autoConnect = true; + } + + bane.createEventEmitter(mopidy); + mopidy._delegateEvents(); + + if (mopidy._settings.autoConnect) { + mopidy._connect(); + } +} + +Mopidy.prototype._delegateEvents = function () { + var mopidy = this; + + // Remove existing event handlers + mopidy.off("websocket:close"); + mopidy.off("websocket:error"); + mopidy.off("websocket:incomingMessage"); + mopidy.off("websocket:open"); + mopidy.off("state:offline"); + + // Register basic set of event handlers + mopidy.on("websocket:close", mopidy._cleanup); + mopidy.on("websocket:error", mopidy._handleWebSocketError); + mopidy.on("websocket:incomingMessage", mopidy._handleMessage); + mopidy.on("websocket:open", mopidy._resetBackoffDelay); + mopidy.on("websocket:open", mopidy._getApiSpec); + mopidy.on("state:offline", mopidy._reconnect); +}; + +Mopidy.prototype._connect = function () { + var mopidy = this; + + if (mopidy._webSocket) { + if (mopidy._webSocket.readyState === WebSocket.OPEN) { + return; + } else { + mopidy._webSocket.close(); + } + } + + mopidy._webSocket = mopidy._settings.webSocket || + new WebSocket(mopidy._settings.webSocketUrl); + + mopidy._webSocket.onclose = function (close) { + mopidy.emit("websocket:close", close); + }; + + mopidy._webSocket.onerror = function (error) { + mopidy.emit("websocket:error", error); + }; + + mopidy._webSocket.onopen = function () { + mopidy.emit("websocket:open"); + }; + + mopidy._webSocket.onmessage = function (message) { + mopidy.emit("websocket:incomingMessage", message); + }; +}; + +Mopidy.prototype._cleanup = function (closeEvent) { + var mopidy = this; + + Object.keys(mopidy._pendingRequests).forEach(function (requestId) { + var resolver = mopidy._pendingRequests[requestId]; + delete mopidy._pendingRequests[requestId]; + resolver.reject({ + message: "WebSocket closed", + closeEvent: closeEvent + }); + }); + + mopidy.emit("state:offline"); +}; + +Mopidy.prototype._reconnect = function () { + var mopidy = this; + + mopidy.emit("reconnectionPending", { + timeToAttempt: mopidy._backoffDelay + }); + + setTimeout(function () { + mopidy.emit("reconnecting"); + mopidy._connect(); + }, mopidy._backoffDelay); + + mopidy._backoffDelay = mopidy._backoffDelay * 2; + if (mopidy._backoffDelay > mopidy._backoffDelayMax) { + mopidy._backoffDelay = mopidy._backoffDelayMax; + } +}; + +Mopidy.prototype._resetBackoffDelay = function () { + var mopidy = this; + + mopidy._backoffDelay = mopidy._backoffDelayMin; +}; + +Mopidy.prototype._handleWebSocketError = function (error) { + console.warn("WebSocket error:", error.stack || error); +}; + +Mopidy.prototype._send = function (message) { + var mopidy = this; + var deferred = when.defer(); + + switch (mopidy._webSocket.readyState) { + case WebSocket.CONNECTING: + deferred.resolver.reject({ + message: "WebSocket is still connecting" + }); + break; + case WebSocket.CLOSING: + deferred.resolver.reject({ + message: "WebSocket is closing" + }); + break; + case WebSocket.CLOSED: + deferred.resolver.reject({ + message: "WebSocket is closed" + }); + break; + default: + message.jsonrpc = "2.0"; + message.id = mopidy._nextRequestId(); + this._pendingRequests[message.id] = deferred.resolver; + this._webSocket.send(JSON.stringify(message)); + mopidy.emit("websocket:outgoingMessage", message); + } + + return deferred.promise; +}; + +Mopidy.prototype._nextRequestId = (function () { + var lastUsed = -1; + return function () { + lastUsed += 1; + return lastUsed; + }; +}()); + +Mopidy.prototype._handleMessage = function (message) { + var mopidy = this; + + try { + var data = JSON.parse(message.data); + if (data.hasOwnProperty("id")) { + mopidy._handleResponse(data); + } else if (data.hasOwnProperty("event")) { + mopidy._handleEvent(data); + } else { + console.warn( + "Unknown message type received. Message was: " + + message.data); + } + } catch (error) { + if (error instanceof SyntaxError) { + console.warn( + "WebSocket message parsing failed. Message was: " + + message.data); + } else { + throw error; + } + } +}; + +Mopidy.prototype._handleResponse = function (responseMessage) { + var mopidy = this; + + if (!mopidy._pendingRequests.hasOwnProperty(responseMessage.id)) { + console.warn( + "Unexpected response received. Message was:", responseMessage); + return; + } + var resolver = mopidy._pendingRequests[responseMessage.id]; + delete mopidy._pendingRequests[responseMessage.id]; + + if (responseMessage.hasOwnProperty("result")) { + resolver.resolve(responseMessage.result); + } else if (responseMessage.hasOwnProperty("error")) { + resolver.reject(responseMessage.error); + console.warn("Server returned error:", responseMessage.error); + } else { + resolver.reject({ + message: "Response without 'result' or 'error' received", + data: {response: responseMessage} + }); + console.warn( + "Response without 'result' or 'error' received. Message was:", + responseMessage); + } +}; + +Mopidy.prototype._handleEvent = function (eventMessage) { + var mopidy = this; + + var type = eventMessage.event; + var data = eventMessage; + delete data.event; + + mopidy.emit("event:" + mopidy._snakeToCamel(type), data); +}; + +Mopidy.prototype._getApiSpec = function () { + var mopidy = this; + + mopidy._send({method: "core.describe"}) + .then(mopidy._createApi.bind(mopidy), mopidy._handleWebSocketError) + .then(null, mopidy._handleWebSocketError); +}; + +Mopidy.prototype._createApi = function (methods) { + var mopidy = this; + + var caller = function (method) { + return function () { + var params = Array.prototype.slice.call(arguments); + return mopidy._send({ + method: method, + params: params + }); + }; + }; + + var getPath = function (fullName) { + var path = fullName.split("."); + if (path.length >= 1 && path[0] === "core") { + path = path.slice(1); + } + return path; + }; + + var createObjects = function (objPath) { + var parentObj = mopidy; + objPath.forEach(function (objName) { + objName = mopidy._snakeToCamel(objName); + parentObj[objName] = parentObj[objName] || {}; + parentObj = parentObj[objName]; + }); + return parentObj; + }; + + var createMethod = function (fullMethodName) { + var methodPath = getPath(fullMethodName); + var methodName = mopidy._snakeToCamel(methodPath.slice(-1)[0]); + var object = createObjects(methodPath.slice(0, -1)); + object[methodName] = caller(fullMethodName); + }; + + Object.keys(methods).forEach(createMethod); + mopidy.emit("state:online"); +}; + +Mopidy.prototype._snakeToCamel = function (name) { + return name.replace(/(_[a-z])/g, function (match) { + return match.toUpperCase().replace("_", ""); + }); +}; diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index aa53dd42..112a2506 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -1,4 +1,648 @@ -/*global when:false, buster:false, assert:false, refute:false, Mopidy:false*/ +/*global buster:false, assert:false, refute:false, when:false, Mopidy:false*/ buster.testCase("Mopidy", { + setUp: function () { + // Sinon.JS doesn't manage to stub PhantomJS' WebSocket implementation, + // so we replace it with a dummy temporarily. + var fakeWebSocket = function () { + return { + send: function () {}, + close: function () {} + }; + }; + fakeWebSocket.CONNECTING = 0; + fakeWebSocket.OPEN = 1; + fakeWebSocket.CLOSING = 2; + fakeWebSocket.CLOSED = 3; + this.realWebSocket = WebSocket; + window.WebSocket = fakeWebSocket; + + this.webSocketConstructorStub = this.stub(window, "WebSocket"); + + this.webSocket = { + close: this.stub(), + send: this.stub() + }; + this.mopidy = new Mopidy({webSocket: this.webSocket}); + }, + + tearDown: function () { + window.WebSocket = this.realWebSocket; + }, + + "constructor": { + "connects when autoConnect is true": function () { + new Mopidy({autoConnect: true}); + + assert.calledOnceWith(this.webSocketConstructorStub, + "ws://" + document.location.host + "/mopidy/ws/"); + }, + + "does not connect when autoConnect is false": function () { + new Mopidy({autoConnect: false}); + + refute.called(this.webSocketConstructorStub); + }, + + "does not connect when passed a WebSocket": function () { + new Mopidy({webSocket: {}}); + + refute.called(this.webSocketConstructorStub); + } + }, + + "._connect": { + "does nothing when the WebSocket is open": function () { + this.webSocket.readyState = WebSocket.OPEN; + var mopidy = new Mopidy({webSocket: this.webSocket}); + + mopidy._connect(); + + refute.called(this.webSocket.close); + refute.called(this.webSocketConstructorStub); + } + }, + + "WebSocket events": { + "emits 'websocket:close' when connection is closed": function () { + var spy = this.spy(); + this.mopidy.off("websocket:close"); + this.mopidy.on("websocket:close", spy); + + var closeEvent = {}; + this.webSocket.onclose(closeEvent); + + assert.calledOnceWith(spy, closeEvent); + }, + + "emits 'websocket:error' when errors occurs": function () { + var spy = this.spy(); + this.mopidy.off("websocket:error"); + this.mopidy.on("websocket:error", spy); + + var errorEvent = {}; + this.webSocket.onerror(errorEvent); + + assert.calledOnceWith(spy, errorEvent); + }, + + "emits 'websocket:incomingMessage' when a message arrives": function () { + var spy = this.spy(); + this.mopidy.off("websocket:incomingMessage"); + this.mopidy.on("websocket:incomingMessage", spy); + + var messageEvent = {data: "this is a message"}; + this.webSocket.onmessage(messageEvent); + + assert.calledOnceWith(spy, messageEvent); + }, + + "emits 'websocket:open' when connection is opened": function () { + var spy = this.spy(); + this.mopidy.off("websocket:open"); + this.mopidy.on("websocket:open", spy); + + this.webSocket.onopen(); + + assert.calledOnceWith(spy); + } + }, + + "._cleanup": { + setUp: function () { + this.mopidy.off("state:offline"); + }, + + "is called on 'websocket:close' event": function () { + var closeEvent = {}; + var stub = this.stub(this.mopidy, "_cleanup"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:close", closeEvent); + + assert.calledOnceWith(stub, closeEvent); + }, + + "rejects all pending requests": function (done) { + var closeEvent = {}; + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + + var promise1 = this.mopidy._send({method: "foo"}); + var promise2 = this.mopidy._send({method: "bar"}); + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 2); + + this.mopidy._cleanup(closeEvent); + + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + when.join(promise1, promise2).then(done(function () { + assert(false, "Promises should be rejected"); + }), done(function (error) { + assert.equals(error.message, "WebSocket closed"); + assert.same(error.closeEvent, closeEvent); + })); + }, + + "emits 'state:offline' event when done": function () { + var spy = this.spy(); + this.mopidy.on("state:offline", spy); + + this.mopidy._cleanup({}); + + assert.calledOnceWith(spy); + } + }, + + "._reconnect": { + "is called when the state changes to offline": function () { + var stub = this.stub(this.mopidy, "_reconnect"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("state:offline"); + + assert.calledOnceWith(stub); + }, + + "tries to connect after an increasing backoff delay": function () { + var clock = this.useFakeTimers(); + var connectStub = this.stub(this.mopidy, "_connect"); + var pendingSpy = this.spy(); + this.mopidy.on("reconnectionPending", pendingSpy); + var reconnectingSpy = this.spy(); + this.mopidy.on("reconnecting", reconnectingSpy); + + refute.called(connectStub); + + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 1000}); + clock.tick(0); + refute.called(connectStub); + clock.tick(1000); + assert.calledOnceWith(reconnectingSpy); + assert.calledOnce(connectStub); + + pendingSpy.reset(); + reconnectingSpy.reset(); + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 2000}); + assert.calledOnce(connectStub); + clock.tick(0); + assert.calledOnce(connectStub); + clock.tick(1000); + assert.calledOnce(connectStub); + clock.tick(1000); + assert.calledOnceWith(reconnectingSpy); + assert.calledTwice(connectStub); + + pendingSpy.reset(); + reconnectingSpy.reset(); + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 4000}); + assert.calledTwice(connectStub); + clock.tick(0); + assert.calledTwice(connectStub); + clock.tick(2000); + assert.calledTwice(connectStub); + clock.tick(2000); + assert.calledOnceWith(reconnectingSpy); + assert.calledThrice(connectStub); + }, + + "tries to connect at least about once per minute": function () { + var clock = this.useFakeTimers(); + var connectStub = this.stub(this.mopidy, "_connect"); + var pendingSpy = this.spy(); + this.mopidy.on("reconnectionPending", pendingSpy); + this.mopidy._backoffDelay = this.mopidy._backoffDelayMax; + + refute.called(connectStub); + + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000}); + clock.tick(0); + refute.called(connectStub); + clock.tick(64000); + assert.calledOnce(connectStub); + + pendingSpy.reset(); + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000}); + assert.calledOnce(connectStub); + clock.tick(0); + assert.calledOnce(connectStub); + clock.tick(64000); + assert.calledTwice(connectStub); + } + }, + + "._resetBackoffDelay": { + "is called on 'websocket:open' event": function () { + var stub = this.stub(this.mopidy, "_resetBackoffDelay"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:open"); + + assert.calledOnceWith(stub); + }, + + "resets the backoff delay to the minimum value": function () { + this.mopidy._backoffDelay = this.mopidy._backoffDelayMax; + + this.mopidy._resetBackoffDelay(); + + assert.equals(this.mopidy._backoffDelay, + this.mopidy._backoffDelayMin); + } + }, + + "._handleWebSocketError": { + "is called on 'websocket:error' event": function () { + var error = {}; + var stub = this.stub(this.mopidy, "_handleWebSocketError"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:error", error); + + assert.calledOnceWith(stub, error); + }, + + "without stack logs the error to the console": function () { + var stub = this.stub(console, "warn"); + var error = {}; + + this.mopidy._handleWebSocketError(error); + + assert.calledOnceWith(stub, "WebSocket error:", error); + }, + + "with stack logs the error to the console": function () { + var stub = this.stub(console, "warn"); + var error = {stack: "foo"}; + + this.mopidy._handleWebSocketError(error); + + assert.calledOnceWith(stub, "WebSocket error:", error.stack); + } + }, + + "._send": { + "adds JSON-RPC fields to the message": function () { + this.stub(this.mopidy, "_nextRequestId").returns(1); + var stub = this.stub(JSON, "stringify"); + + this.mopidy._send({method: "foo"}); + + assert.calledOnceWith(stub, { + jsonrpc: "2.0", + id: 1, + method: "foo" + }); + }, + + "adds a resolver to the pending requests queue": function () { + this.stub(this.mopidy, "_nextRequestId").returns(1); + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + + this.mopidy._send({method: "foo"}); + + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1); + assert.isFunction(this.mopidy._pendingRequests[1].resolve); + }, + + "sends message on the WebSocket": function () { + refute.called(this.mopidy._webSocket.send); + + this.mopidy._send({method: "foo"}); + + assert.calledOnce(this.mopidy._webSocket.send); + }, + + "emits a 'websocket:outgoingMessage' event": function () { + var spy = this.spy(); + this.mopidy.on("websocket:outgoingMessage", spy); + this.stub(this.mopidy, "_nextRequestId").returns(1); + + this.mopidy._send({method: "foo"}); + + assert.calledOnceWith(spy, { + jsonrpc: "2.0", + id: 1, + method: "foo" + }); + }, + + "immediately rejects request if CONNECTING": function (done) { + this.mopidy._webSocket.readyState = WebSocket.CONNECTING; + + var promise = this.mopidy._send({method: "foo"}); + + refute.called(this.mopidy._webSocket.send); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals( + error.message, "WebSocket is still connecting"); + })); + }, + + "immediately rejects request if CLOSING": function (done) { + this.mopidy._webSocket.readyState = WebSocket.CLOSING; + + var promise = this.mopidy._send({method: "foo"}); + + refute.called(this.mopidy._webSocket.send); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals( + error.message, "WebSocket is closing"); + })); + }, + + "immediately rejects request if CLOSED": function (done) { + this.mopidy._webSocket.readyState = WebSocket.CLOSED; + + var promise = this.mopidy._send({method: "foo"}); + + refute.called(this.mopidy._webSocket.send); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals( + error.message, "WebSocket is closed"); + })); + } + }, + + "._nextRequestId": { + "returns an ever increasing ID": function () { + var base = this.mopidy._nextRequestId(); + assert.equals(this.mopidy._nextRequestId(), base + 1); + assert.equals(this.mopidy._nextRequestId(), base + 2); + assert.equals(this.mopidy._nextRequestId(), base + 3); + } + }, + + "._handleMessage": { + "is called on 'websocket:incomingMessage' event": function () { + var messageEvent = {}; + var stub = this.stub(this.mopidy, "_handleMessage"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:incomingMessage", messageEvent); + + assert.calledOnceWith(stub, messageEvent); + }, + + "passes JSON-RPC responses on to _handleResponse": function () { + var stub = this.stub(this.mopidy, "_handleResponse"); + var message = { + jsonrpc: "2.0", + id: 1, + result: null + }; + var messageEvent = {data: JSON.stringify(message)}; + + this.mopidy._handleMessage(messageEvent); + + assert.calledOnceWith(stub, message); + }, + + "passes events on to _handleEvent": function () { + var stub = this.stub(this.mopidy, "_handleEvent"); + var message = { + event: "track_playback_started", + track: {} + }; + var messageEvent = {data: JSON.stringify(message)}; + + this.mopidy._handleMessage(messageEvent); + + assert.calledOnceWith(stub, message); + }, + + "logs unknown messages": function () { + var stub = this.stub(console, "warn"); + var messageEvent = {data: JSON.stringify({foo: "bar"})}; + + this.mopidy._handleMessage(messageEvent); + + assert.calledOnceWith(stub, + "Unknown message type received. Message was: " + + messageEvent.data); + }, + + "logs JSON parsing errors": function () { + var stub = this.stub(console, "warn"); + var messageEvent = {data: "foobarbaz"}; + + this.mopidy._handleMessage(messageEvent); + + assert.calledOnceWith(stub, + "WebSocket message parsing failed. Message was: " + + messageEvent.data); + } + }, + + "._handleResponse": { + "logs unexpected responses": function () { + var stub = this.stub(console, "warn"); + var responseMessage = { + jsonrpc: "2.0", + id: 1337, + result: null + }; + + this.mopidy._handleResponse(responseMessage); + + assert.calledOnceWith(stub, + "Unexpected response received. Message was:", responseMessage); + }, + + "removes the matching request from the pending queue": function () { + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + this.mopidy._send({method: "bar"}); + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1); + + this.mopidy._handleResponse({ + jsonrpc: "2.0", + id: Object.keys(this.mopidy._pendingRequests)[0], + result: "baz" + }); + + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + }, + + "resolves requests which get results back": function (done) { + var promise = this.mopidy._send({method: "bar"}); + var responseResult = {}; + var responseMessage = { + jsonrpc: "2.0", + id: Object.keys(this.mopidy._pendingRequests)[0], + result: responseResult + }; + + this.mopidy._handleResponse(responseMessage); + promise.then(done(function (result) { + assert.equals(result, responseResult); + }), done(function () { + assert(false); + })); + }, + + "rejects and logs requests which get errors back": function (done) { + var stub = this.stub(console, "warn"); + var promise = this.mopidy._send({method: "bar"}); + var responseError = {message: "Error", data: {}}; + var responseMessage = { + jsonrpc: "2.0", + id: Object.keys(this.mopidy._pendingRequests)[0], + error: responseError + }; + + this.mopidy._handleResponse(responseMessage); + + assert.calledOnceWith(stub, + "Server returned error:", responseError); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals(error, responseError); + })); + }, + + "rejects and logs responses without result or error": function (done) { + var stub = this.stub(console, "warn"); + var promise = this.mopidy._send({method: "bar"}); + var responseMessage = { + jsonrpc: "2.0", + id: Object.keys(this.mopidy._pendingRequests)[0] + }; + + this.mopidy._handleResponse(responseMessage); + + assert.calledOnceWith(stub, + "Response without 'result' or 'error' received. Message was:", + responseMessage); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals( + error.message, + "Response without 'result' or 'error' received"); + assert.equals(error.data.response, responseMessage); + })); + } + }, + + "._handleEvent": { + "emits server side even on Mopidy object": function () { + var spy = this.spy(); + this.mopidy.on(spy); + var track = {}; + var message = { + event: "track_playback_started", + track: track + }; + + this.mopidy._handleEvent(message); + + assert.calledOnceWith(spy, + "event:trackPlaybackStarted", {track: track}); + } + }, + + "._getApiSpec": { + "is called on 'websocket:open' event": function () { + var stub = this.stub(this.mopidy, "_getApiSpec"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:open"); + + assert.calledOnceWith(stub); + }, + + "gets Api description from server and calls _createApi": function () { + var methods = {}; + var sendStub = this.stub(this.mopidy, "_send"); + sendStub.returns(when.resolve(methods)); + var _createApiStub = this.stub(this.mopidy, "_createApi"); + + this.mopidy._getApiSpec(); + + assert.calledOnceWith(sendStub, {method: "core.describe"}); + assert.calledOnceWith(_createApiStub, methods); + } + }, + + "._createApi": { + "can create an API with methods on the root object": function () { + refute.defined(this.mopidy.hello); + refute.defined(this.mopidy.hi); + + this.mopidy._createApi({ + hello: { + description: "Says hello", + params: [] + }, + hi: { + description: "Says hi", + params: [] + } + }); + + assert.isFunction(this.mopidy.hello); + assert.isFunction(this.mopidy.hi); + }, + + "can create an API with methods on a sub-object": function () { + refute.defined(this.mopidy.hello); + + this.mopidy._createApi({ + "hello.world": { + description: "Says hello to the world", + params: [] + } + }); + + assert.defined(this.mopidy.hello); + assert.isFunction(this.mopidy.hello.world); + }, + + "strips off 'core' from method paths": function () { + refute.defined(this.mopidy.hello); + + this.mopidy._createApi({ + "core.hello.world": { + description: "Says hello to the world", + params: [] + } + }); + + assert.defined(this.mopidy.hello); + assert.isFunction(this.mopidy.hello.world); + }, + + "converts snake_case to camelCase": function () { + refute.defined(this.mopidy.mightyGreetings); + + this.mopidy._createApi({ + "mighty_greetings.hello_world": { + description: "Says hello to the world", + params: [] + } + }); + + assert.defined(this.mopidy.mightyGreetings); + assert.isFunction(this.mopidy.mightyGreetings.helloWorld); + }, + + "triggers 'state:online' event when API is ready for use": function () { + var spy = this.spy(); + this.mopidy.on("state:online", spy); + + this.mopidy._createApi({}); + + assert.calledOnceWith(spy); + } + } });