Initial content commit of travel sample app, with maps.
Change-Id: I057b2b2514cb63bbd965ee72d96e0c3a698693d4
diff --git a/.gitignore b/.gitignore
index b7e2fde..21261d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,4 @@
-/.v23
\ No newline at end of file
+/.v23
+/ifc
+/node_modules
+/server-root
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..411db13
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,27 @@
+Copyright (c) 2015 The Vanadium Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ca463ca
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,58 @@
+PATH := node_modules/.bin:$(PATH)
+PATH := $(PATH):$(V23_ROOT)/third_party/cout/node/bin
+
+.DEFAULT_GOAL := all
+
+port ?= 1058
+
+js_files := $(shell find src -name "*.js")
+server_static := $(patsubst src/static/%,server-root/%,$(wildcard src/static/*))
+tests := $(patsubst %.js,%,$(shell find test -name "*.js"))
+
+out_dirs := ifc server-root node_modules
+
+.DELETE_ON_ERROR:
+
+.PHONY: all
+all: static js
+ @true
+
+.PHONY: static
+static: $(server_static)
+
+.PHONY: js
+js: server-root/bundle.js
+
+ifc: src/ifc/*
+ @VDLPATH=src vdl generate -lang=javascript -js-out-dir=. ifc
+
+node_modules: package.json
+ @npm prune
+ @npm install
+ @npm install $(V23_ROOT)/release/javascript/core/ #TODO: remove
+ @touch node_modules # if npm does nothing, we don't want to keep trying
+
+server-root:
+ @mkdir server-root
+
+server-root/bundle.js: ifc node_modules $(js_files) | server-root
+ browserify --debug src/index.js 1> $@
+
+$(server_static): server-root/%: src/static/% | server-root
+ @cp $< $@
+ @echo "Copying static file $<"
+
+.PHONY: test
+test: $(tests)
+
+.PHONY: $(tests)
+$(tests): test/%: test/%.js test/* mocks/* ifc node_modules $(js_files)
+ @tape $<
+
+.PHONY: start
+start: all
+ @static server-root -p $(port)
+
+.PHONY: clean
+clean:
+ rm -rf $(out_dirs)
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
new file mode 100644
index 0000000..2a25bc8
--- /dev/null
+++ b/mocks/google-maps.js
@@ -0,0 +1,38 @@
+var $ = require('../src/util/jquery')
+var defineClass = require('../src/util/define-class')
+
+var ControlPosition = {
+ TOP_LEFT: 'tl',
+ TOP_CENTER: 'tc'
+};
+
+var ControlPanel = defineClass({
+ init: function(parent) {
+ this.$ = $('<div>');
+ this.$.appendTo(parent);
+ },
+
+ publics: {
+ push: function(child) {
+ this.$.append(child);
+ }
+ }
+});
+
+module.exports = {
+ Map: function(canvas) {
+ this.controls = {};
+ this.controls[ControlPosition.TOP_CENTER] = new ControlPanel(canvas);
+ this.controls[ControlPosition.TOP_LEFT] = new ControlPanel(canvas);
+ },
+ LatLng: function(){},
+ ControlPosition: ControlPosition,
+
+ places: {
+ SearchBox: function(){}
+ },
+
+ event: {
+ addListener: function(){}
+ }
+};
\ No newline at end of file
diff --git a/mocks/vanadium-wrapper.js b/mocks/vanadium-wrapper.js
new file mode 100644
index 0000000..b69b6ce
--- /dev/null
+++ b/mocks/vanadium-wrapper.js
@@ -0,0 +1,7 @@
+var $ = require('../src/util/jquery');
+
+module.exports = {
+ init: function(){
+ return $.Deferred().promise();
+ }
+};
\ No newline at end of file
diff --git a/mocks/vanadium.js b/mocks/vanadium.js
new file mode 100644
index 0000000..815df95
--- /dev/null
+++ b/mocks/vanadium.js
@@ -0,0 +1,47 @@
+var defineClass = require('../src/util/define-class');
+
+var MockRuntime = defineClass({
+ publics: {
+ on: function(event, handler) {
+ if (event == 'crash')
+ this.crash.add(handler);
+ },
+ fireCrash: function(err) {
+ this.crash(err);
+ }
+ },
+
+ events: {
+ crash: 'private'
+ }
+});
+
+var MockVanadium = defineClass({
+ init: function(t) {
+ this.t = t;
+ },
+
+ publics: {
+ init: function(config, callback) {
+ this.t.ok(config, 'has config');
+ this.callback = callback;
+ },
+
+ finishInit: function(err, runtime) {
+ this.callback(err, runtime);
+ }
+ },
+
+ statics: {
+ vlog: {
+ levels: {
+ INFO: 'info'
+ }
+ }
+ }
+});
+
+module.exports = {
+ MockRuntime: MockRuntime,
+ MockVanadium: MockVanadium
+};
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7ed8b4c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "travel",
+ "version": "0.1.0",
+ "decription": "Distributed travel planner Vanadium sample application.",
+ "scripts": {
+ "test": "make test"
+ },
+ "devDependencies": {
+ "browserify": "^10.2.4",
+ "jsdom": "^3.1.2",
+ "node-static": "^0.7.6",
+ "tape": "^4.0.0"
+ },
+ "dependencies": {
+ "global": "^4.3.0",
+ "jquery": "^2.1.4",
+ "uuid": "^2.0.1"
+ }
+}
diff --git a/src/components/maps.js b/src/components/maps.js
new file mode 100644
index 0000000..3e52c98
--- /dev/null
+++ b/src/components/maps.js
@@ -0,0 +1,80 @@
+var global = require('global');
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+var strings = require('../strings')();
+var Messages = require('./messages');
+
+var Widget = defineClass({
+ publics: {
+ clearMarkers: function() {
+ var markers = this.markers;
+ this.markers = [];
+ $.each(markers, function(i, marker) {
+ marker.setMap(null);
+ });
+ },
+
+ message: function(message) {
+ this.messages.push(message);
+ }
+ },
+
+ constants: ['$'],
+
+ // https://developers.google.com/maps/documentation/javascript/tutorial
+ init: function(maps) {
+ maps = maps || global.google.maps;
+ var widget = this;
+
+ this.$ = $('<div>').addClass('map-canvas');
+
+ this.markers = [];
+ this.messages = new Messages();
+
+ var config = {
+ zoom: 11,
+ center: new maps.LatLng(37.4184, -122.0880) //Googleplex
+ };
+
+ var map = new maps.Map(this.$[0], config);
+
+ // https://developers.google.com/maps/documentation/javascript/examples/map-geolocation
+ if (global.navigator && global.navigator.geolocation) {
+ global.navigator.geolocation.getCurrentPosition(function(position) {
+ map.setCenter(new maps.LatLng(position.coords.latitude,
+ position.coords.longitude));
+ });
+ }
+
+ var controls = map.controls;
+
+ var $searchBox = $('<input>')
+ .attr('type', 'text')
+ .attr('placeholder', strings['Search']);
+ var txtSearchBox = $searchBox[0];
+ controls[maps.ControlPosition.TOP_LEFT].push(txtSearchBox);
+
+ controls[maps.ControlPosition.TOP_CENTER].push(this.messages.$[0]);
+
+ var searchBox = new maps.places.SearchBox(txtSearchBox);
+
+ maps.event.addListener(map, 'bounds_changed', function() {
+ searchBox.setBounds(map.getBounds());
+ });
+
+ maps.event.addListener(searchBox, 'places_changed', function() {
+ var places = searchBox.getPlaces();
+ if (places.length == 1) {
+ var place = places[0];
+ widget.markers.push(new maps.Marker({
+ map: map,
+ title: place.name,
+ position: place.geometry.location
+ }));
+ }
+ });
+ }
+});
+
+module.exports = Widget;
diff --git a/src/components/message.js b/src/components/message.js
new file mode 100644
index 0000000..a96e7cd
--- /dev/null
+++ b/src/components/message.js
@@ -0,0 +1,55 @@
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+var INFO = 'INFO';
+var ERROR = 'ERROR';
+
+module.exports = {
+ INFO: INFO,
+ ERROR: ERROR,
+
+ info: function(text) {
+ return {
+ type: INFO,
+ text: text
+ };
+ },
+
+ error: function(text) {
+ return {
+ type: ERROR,
+ text: text
+ };
+ },
+
+ Message: defineClass({
+ publics: {
+ setType: function(type) {
+ if (type == INFO) {
+ this.$.attr('class', 'info');
+ } else if (type == ERROR) {
+ this.$.attr('class', 'error');
+ } else {
+ throw 'Invalid message type ' + type;
+ }
+ },
+
+ setText: function(text) {
+ this.$.text(text);
+ },
+
+ set: function(message) {
+ this.setType(message.type);
+ this.setText(message.text);
+ }
+ },
+
+ constants: ['$'],
+
+ init: function(initial) {
+ this.$ = $('<li>');
+ if (initial)
+ this.set(initial);
+ }
+ })
+};
diff --git a/src/components/messages.js b/src/components/messages.js
new file mode 100644
index 0000000..55eef83
--- /dev/null
+++ b/src/components/messages.js
@@ -0,0 +1,45 @@
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+var message = require('./message');
+
+var Messages = defineClass({
+ publics: {
+ push: function(messageData) {
+ var messageObject = new message.Message(messageData);
+ /*
+ * Implementation notes: slideDown won't work properly (won't be able to
+ * calculate goal height) unless the element is in the DOM tree prior
+ * to the call, so we hide first, attach, and then animate. slideDown
+ * implicitly shows the element.
+ *
+ * Similarly, we use animate rather than fadeIn because fadeIn implicitly
+ * hides the element upon completion, resulting in an abrupt void in the
+ * element flow. Instead, we want to keep the element taking up space
+ * while invisible until we've collapsed the height via slideUp.
+ */
+ messageObject.$.hide();
+ this.$.append(messageObject.$);
+ messageObject.$
+ .slideDown(Messages.slideDown)
+ .delay(Messages.ttl)
+ .animate({ opacity: 0 }, Messages.fade)
+ .slideUp(Messages.slideUp, function() {
+ messageObject.$.remove();
+ });
+ }
+ },
+
+ constants: ['$'],
+
+ init: function() {
+ this.$ = $('<ul>').addClass('messages');
+ }
+});
+
+Messages.slideDown = 150;
+Messages.ttl = 9000;
+Messages.fade = 1000;
+Messages.slideUp = 300;
+
+module.exports = Messages;
\ No newline at end of file
diff --git a/src/debug.js b/src/debug.js
new file mode 100644
index 0000000..97c15f5
--- /dev/null
+++ b/src/debug.js
@@ -0,0 +1,8 @@
+var global = require('global');
+
+/**
+ * Global variable exports for console debug.
+ */
+module.exports = function(app) {
+ global.travel = app;
+};
\ No newline at end of file
diff --git a/src/identity.js b/src/identity.js
new file mode 100644
index 0000000..762fdb4
--- /dev/null
+++ b/src/identity.js
@@ -0,0 +1,29 @@
+'use strict';
+
+var uuid = require('uuid');
+
+module.exports = Identity;
+
+function Identity(accountName) {
+ this.username = extractUsername(accountName);
+ this.deviceType = 'desktop';
+ this.deviceId = uuid.v4();
+
+ this.deviceName = this.deviceType + '_' + this.deviceId;
+ this.entityName = this.username + '/' + this.deviceName;
+};
+
+function autoUsername() {
+ return uuid.v4();
+}
+
+function extractUsername(accountName) {
+ if (!accountName || accountName === 'unknown')
+ return autoUsername();
+
+ var parts = accountName.split('/');
+ if (parts[0] !== 'dev.v.io' || parts[1] !== 'u')
+ return accountName;
+
+ return parts[2];
+}
diff --git a/src/ifc/ops.vdl b/src/ifc/ops.vdl
new file mode 100644
index 0000000..9a646ab
--- /dev/null
+++ b/src/ifc/ops.vdl
@@ -0,0 +1,50 @@
+package ifc
+
+const (
+ Read = "R"
+ Write = "W"
+ Collaborate = "C"
+)
+
+// Stub multicast RPCs to mock SyncBase storage.
+// TODO: allow multiple trips (e.g. multiple planned trips).
+type TravelSync interface {
+ // Gets the current trip.
+ Get() (Trip | error) { Read }
+
+ // Pushes a trip plan to the server instance, optionally with a notification
+ // message (ex. "X has accepted Y's destination proposal.").
+ // To simplify the API, this is the sole API through which the trip plan may
+ // actually be altered.
+ UpdatePlan(plan TripPlan, message string) error { Write }
+
+ // Pushes the current trip status to the server instance, leaving the trip
+ // plan unchanged.
+ UpdateStatus(status TravellerStatus) error { Write }
+
+ // Posts a suggestion to add a destination to the trip plan.
+ SuggestDestinationAddition(
+ destination Destination, at TripStatus, message string) (
+ SuggestionId | error) { Collaborate }
+
+ // Posts a suggestion to add a waypoint to the trip plan.
+ SuggestWaypointAddition(
+ waypoint Waypoint, at TripStatus, message string) (
+ SuggestionId | error) { Collaborate }
+
+ // Posts a suggestion to remove a waypoint or destination from the trip plan.
+ SuggestRemoval(at TripStatus, message string) (
+ SuggestionId | error) { Collaborate }
+
+ // Comments on an existing suggestion.
+ Comment(suggestion SuggestionId, message string) error { Collaborate }
+
+ // Deletes an existing suggestion.
+ DeleteSuggestion(
+ suggestion SuggestionId, message string) error { Collaborate }
+}
+
+type Travel interface {
+ TravelSync
+ // TODO: casting if warranted
+}
diff --git a/src/ifc/types.vdl b/src/ifc/types.vdl
new file mode 100644
index 0000000..22329b5
--- /dev/null
+++ b/src/ifc/types.vdl
@@ -0,0 +1,45 @@
+package ifc
+
+type Location complex64
+type Waypoint Location
+type LegId int16
+type WaypointId int16
+type SuggestionId int16
+
+type Destination struct {
+ // TODO; may or may not have an embedded Waypoint
+}
+
+/*
+ * A leg of travel. Each leg has one destination of significance but may be
+ * routed through any number of waypoints.
+ */
+type Leg struct {
+ Waypoints []Waypoint
+ // Logically, the last waypoint. However, it contains metadata that other
+ // waypoints do not (see Destination struct).
+ Destination Destination
+ // TODO: timeline data
+}
+
+type TripPlan []Leg
+
+type TripStatus struct {
+ CurrentLeg LegId
+ // ID of the next waypoint on the current leg. The leg destination is
+ // considered the last waypoint.
+ NextWaypoint WaypointId
+}
+
+/*
+ * Status actually varies by agent/user.
+ */
+type TravellerStatus struct {
+ TripStatus TripStatus
+ Location Location
+}
+
+type Trip struct {
+ Plan TripPlan
+ Status map[string]TravellerStatus
+}
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..e304925
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,8 @@
+var $ = require('./util/jquery');
+var Travel = require('./travel');
+var debug = require('./debug');
+
+//http://api.jquery.com/ready/
+$(function() {
+ debug(new Travel());
+});
\ No newline at end of file
diff --git a/src/static/index.css b/src/static/index.css
new file mode 100644
index 0000000..358500e
--- /dev/null
+++ b/src/static/index.css
@@ -0,0 +1,39 @@
+body {
+ margin: 0;
+ font-family: Arial, sans-serif;
+}
+
+.map-canvas {
+ width: 100%;
+ height: 100%;
+}
+
+ul.messages {
+ width: 30%;
+ min-width: 10em;
+ list-style: none;
+}
+
+.messages li {
+ color: #FFF;
+ background-color: rgba(0, 0, 0, .6);
+ font-size: 10pt;
+ padding: 3px 3px 3px 1em;
+ border-radius: 4px;
+ margin-bottom: 3px;
+ text-indent: -.5em;
+}
+
+.messages li:before {
+ font-weight: bold;
+}
+
+.messages li.info:before {
+ content: "i ";
+ color: #77F;
+}
+
+.messages li.error:before {
+ content: "x ";
+ color: red;
+}
diff --git a/src/static/index.html b/src/static/index.html
new file mode 100644
index 0000000..2f50623
--- /dev/null
+++ b/src/static/index.html
@@ -0,0 +1,13 @@
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Google Travel</title>
+ <link rel="stylesheet" type="text/css" href="index.css">
+ <script type="text/javascript"
+ src="https://maps.googleapis.com/maps/api/js?key=AIzaSyAQCvKWEWcSuQE2DSjVbvMKETSgF6S9i1k&signed_in=true&libraries=places">
+ </script>
+ </head>
+ <body>
+ <script type="text/javascript" src="/bundle.js"></script>
+ </body>
+</html>
diff --git a/src/strings.js b/src/strings.js
new file mode 100644
index 0000000..76bb6bb
--- /dev/null
+++ b/src/strings.js
@@ -0,0 +1,5 @@
+module.exports = function() {
+ return {
+ 'Search': 'Search'
+ };
+};
\ No newline at end of file
diff --git a/src/travel.js b/src/travel.js
new file mode 100644
index 0000000..7f4f8b9
--- /dev/null
+++ b/src/travel.js
@@ -0,0 +1,52 @@
+var $ = require('./util/jquery');
+
+var message = require('./components/message');
+var vanadiumWrapperDefault = require('./vanadium-wrapper');
+
+var defineClass = require('./util/define-class');
+
+var Maps = require('./components/maps');
+var TravelSync = require('./travelsync');
+var Identity = require('./identity');
+
+var strings = require('./strings')(/* TODO: locale */);
+
+var Travel = defineClass({
+ publics: {
+ error: function (err) {
+ this.maps.message(message.error(err.toString()));
+ },
+
+ info: function (info) {
+ this.maps.message(message.info(info));
+ }
+ },
+
+ init: function (opts) {
+ opts = opts || {};
+ var vanadiumWrapper = opts.vanadiumWrapper || vanadiumWrapperDefault;
+ var travel = this;
+
+ this.sync = new TravelSync();
+
+ var reportError = $.proxy(this, 'error')
+
+ vanadiumWrapper.init(opts.vanadium).then(
+ function(wrapper) {
+ var identity = new Identity(wrapper.getAccountName());
+ identity.mountName = makeMountName(identity);
+ travel.sync.start(identity.mountName, wrapper).fail(reportError);
+ }, reportError);
+
+ this.maps = new Maps(opts.maps);
+ var $domRoot = opts.domRoot? $(opts.domRoot) : $('body');
+ $domRoot.append(travel.maps.$);
+ }
+});
+
+function makeMountName(id) {
+ // TODO: first-class app-wide rather than siloed by account
+ return 'users/' + id.username + '/travel/' + id.deviceName;
+}
+
+module.exports = Travel;
diff --git a/src/travelsync.js b/src/travelsync.js
new file mode 100644
index 0000000..293c755
--- /dev/null
+++ b/src/travelsync.js
@@ -0,0 +1,44 @@
+var defineClass = require('./util/define-class');
+
+var vdlTravel = require('../ifc');
+
+var TravelSync = defineClass({
+ events: ['onMessage', 'onPlanUpdate', 'onStatusUpdate'],
+ init: function() {
+ this.tripPlan = [];
+ this.tripStatus = {};
+
+ // TODO: sync initial state
+ this.server = new vdlTravel.TravelSync();
+
+ var travelSync = this;
+ this.server.get = function(ctx, serverCall) {
+ return {
+ Plan: travelSync.tripPlan,
+ Status: travelSync.tripStatus
+ };
+ };
+
+ this.server.updatePlan = function(ctx, serverCall, plan, message) {
+ travelSync.tripPlan = plan;
+ travelSync.onPlanUpdate(plan);
+ travelSync.onMessage(message);
+ };
+
+ this.server.updateStatus = function(ctx, serverCall, status) {
+ travelSync.tripStatus = status;
+ travelSync.onStatusUpdate(status);
+ };
+ },
+ publics: {
+ start: function(mountName, v) {
+ return v.server(mountName, this.server);
+ },
+ pushTrip: function() {
+ },
+ pushStatus: function() {
+ }
+ }
+});
+
+module.exports = TravelSync;
diff --git a/src/util/define-class.js b/src/util/define-class.js
new file mode 100644
index 0000000..2a48279
--- /dev/null
+++ b/src/util/define-class.js
@@ -0,0 +1,115 @@
+var $ = require('./jquery');
+
+/**
+ * <p>Plays a similar role to other npm private encapsulation facilities, but
+ * exposes private members on `this` via per-instance bindings. A class
+ * definition can contain the following members:
+ * <ul>
+ * <li><code>init</code>: constructor/initializer function for an instance. It
+ * will be called when the class is instantiated via <code>new</code>. Fields
+ * can be initialized in this function. Private functions and events can also
+ * be defined within this function.
+ * <li><code>privates</code>: map of private functions or private static
+ * constants, with access to other members via <code>this</code>. These
+ * members are not publicly visible. This is equivalent to associating these
+ * members explicitly within <code>init</code>.
+ * <li><code>publics</code>: map of public functions, with access to other
+ * members via <code>this</code>. These members are publicly visible.
+ * <li><code>constants</code>: list of names of instance constants initialized
+ * in <code>init</code> to be exposed.
+ * <li><code>statics</code>: map of public static constants, accessible from
+ * the private context, the public context, and on the constructor function.
+ * <li><code>events</code>: list of event names, some of which can actually be
+ * a singleton map with the event name and a string of flags, or a map of
+ * event names to flags. Flags are those to
+ * <a href="https://api.jquery.com/jQuery.Callbacks/">jQuery Callbacks</a>,
+ * plus the "private" flag, which hides the event from the public interface
+ * entirely, and the "public" flag, which exposes the event trigger to the
+ * public interface.
+ * </ul>
+ *
+ * <p>Care should be taken not to be tempted to declare instance constants
+ * within <code>private</code>, as any instantiations done on the initial
+ * values is done at class definition time rather than class instantiation
+ * time. (As such, using that mechanism to declare private static constants does
+ * work.)
+ */
+module.exports = function defineClass(def) {
+ var constructor = function() {
+ var pthis = $.extend({}, def.privates, def.publics, def.statics);
+ var ifc = this;
+
+ if (def.events) {
+ if ($.isArray(def.events)) {
+ $.each(def.events, function(i, event) {
+ if ($.type(event) === 'string') {
+ defineEvent(pthis, ifc, event);
+ } else {
+ defineEventsFromObject(pthis, ifc, event);
+ }
+ });
+ } else {
+ defineEventsFromObject(pthis, ifc, def.events);
+ }
+ }
+
+ if (def.statics)
+ $.extend(ifc, def.statics);
+
+ if (def.init)
+ def.init.apply(pthis, arguments);
+
+ if (def.publics)
+ polyProxy(ifc, pthis, def.publics);
+
+ if (def.constants) {
+ $.each(def.constants, function(i, constant) {
+ ifc[constant] = pthis[constant];
+ });
+ }
+ };
+
+ if (def.statics)
+ $.extend(constructor, def.statics);
+
+ return constructor;
+};
+
+function polyProxy(proxy, context, members) {
+ $.each(members, function(name, member) {
+ proxy[name] = $.proxy(member, context);
+ });
+ return proxy;
+}
+
+function filterProxy(proxy, context, nameFilter) {
+ $.each(context, function(name, member) {
+ if (nameFilter(name))
+ proxy[name] = $.proxy(member, context);
+ });
+ return proxy;
+}
+
+function defineEvent(pthis, ifc, name, flags) {
+ var dispatcher = $.Callbacks(flags);
+ //Use polyProxy on function that fires to add the callable syntactic sugar
+ var callableDispatcher = pthis[name] =
+ polyProxy($.proxy(dispatcher, 'fire'), dispatcher, dispatcher);
+
+ if (flags && flags.indexOf('private') > -1)
+ return;
+
+ if (flags && flags.indexOf('public') > -1) {
+ ifc[name] = callableDispatcher;
+ } else {
+ ifc[name] = filterProxy({}, dispatcher, function(name) {
+ return name != 'fire' && name != 'fireWith';
+ });
+ }
+}
+
+function defineEventsFromObject(pthis, ifc, events) {
+ $.each(events, function(event, flags) {
+ defineEvent(pthis, ifc, event, flags);
+ });
+}
diff --git a/src/util/jquery.js b/src/util/jquery.js
new file mode 100644
index 0000000..18118cd
--- /dev/null
+++ b/src/util/jquery.js
@@ -0,0 +1,10 @@
+var jq = require('jquery');
+var window = require('global/window');
+
+if (window.document) {
+ module.exports = jq;
+} else {
+ var jsdom = require('jsdom').jsdom;
+ window = jsdom().parentWindow;
+ module.exports = jq(window);
+}
\ No newline at end of file
diff --git a/src/vanadium-wrapper.js b/src/vanadium-wrapper.js
new file mode 100644
index 0000000..2774a7b
--- /dev/null
+++ b/src/vanadium-wrapper.js
@@ -0,0 +1,80 @@
+var $ = require('./util/jquery');
+
+var vanadiumDefault = require('vanadium');
+var defineClass = require('./util/define-class');
+
+var VanadiumWrapper = defineClass({
+ init: function(runtime) {
+ this.runtime = runtime;
+ runtime.on('crash', this.crash);
+ },
+
+ publics: {
+ getAccountName: function() {
+ return this.runtime.accountName;
+ },
+
+ /**
+ * @param endpoint Vanadium name
+ * @returns a promise resolving to a client or rejecting with an error.
+ */
+ client: function(endpoint) {
+ var client = this.runtime.newClient();
+ var async = $.Deferred();
+ client.bindTo(this.runtime.getContext(), endpoint, function(err, client) {
+ if (err)
+ async.reject(err);
+ else
+ async.resolve(client);
+ });
+
+ return async.promise();
+ },
+
+ /**
+ * @param endpoint Vanadium name
+ * @param server object implementing server APIs
+ * @returns a promise resolving to void or rejecting with an error.
+ */
+ server: function(endpoint, server, callback) {
+ var async = $.Deferred();
+ this.runtime.newServer().serve(endpoint, server, function(err) {
+ if (err)
+ async.reject(err);
+ else
+ async.resolve();
+ });
+ return async.promise();
+ }
+ },
+
+ events: {
+ crash: 'memory'
+ }
+});
+
+module.exports = {
+ /**
+ * @param vanadium optional vanadium override
+ * @returns a promise resolving to a VanadiumWrapper or rejecting with an error.
+ */
+ init: function(vanadium) {
+ vanadium = vanadium || vanadiumDefault;
+
+ var config = {
+ logLevel: vanadium.vlog.levels.INFO,
+ appName: 'Google Travel'
+ };
+
+ var async = $.Deferred();
+
+ vanadium.init(config, function(err, runtime) {
+ if (err)
+ async.reject(err);
+ else
+ async.resolve(new VanadiumWrapper(runtime));
+ });
+
+ return async.promise();
+ }
+};
diff --git a/test/components/maps.js b/test/components/maps.js
new file mode 100644
index 0000000..e578bf0
--- /dev/null
+++ b/test/components/maps.js
@@ -0,0 +1,28 @@
+var test = require('tape');
+
+var $ = require('../../src/util/jquery');
+var defineClass = require('../../src/util/define-class');
+
+var Maps = require('../../src/components/maps');
+var message = require ('../../src/components/message');
+
+var mockMaps = require('../../mocks/google-maps');
+
+test('message display', function(t) {
+ var maps = new Maps(mockMaps);
+
+ var $messages = $('.messages', maps.$);
+ t.ok($messages.length, 'message display exists');
+ t.equals($messages.children().length, 0, 'message display is empty');
+
+ maps.message(message.info('Test message.'));
+
+ var $messageItem = $messages.children();
+ t.equals($messageItem.length, 1, 'message display shows 1 message');
+ t.equals($messageItem.text(), 'Test message.',
+ 'message displays message text');
+
+ t.end();
+});
+
+module.exports = mockMaps;
\ No newline at end of file
diff --git a/test/components/message.js b/test/components/message.js
new file mode 100644
index 0000000..3105f1b
--- /dev/null
+++ b/test/components/message.js
@@ -0,0 +1,26 @@
+var test = require('tape');
+var $ = require('../../src/util/jquery');
+
+var message = require('../../src/components/message');
+
+test('init', function(t) {
+ t.ok(new message.Message(), 'default instantiation');
+ t.end();
+});
+
+test('dom', function(t) {
+ var msg = new message.Message(message.info('Hello, world!'));
+ t.equal(msg.$.length, 1, 'unique element');
+ t.equal(msg.$[0].tagName, 'LI', 'tag name');
+ t.assert(msg.$.hasClass('info'), 'class info');
+ t.equal(msg.$.text(), 'Hello, world!', 'text');
+
+ msg.setType(message.ERROR);
+ t.notOk(msg.$.hasClass('info'), 'class not info');
+ t.assert(msg.$.hasClass('error'), 'class error');
+
+ msg.setText('hi');
+ t.equal(msg.$.text(), 'hi', 'text update');
+
+ t.end();
+});
\ No newline at end of file
diff --git a/test/identity.js b/test/identity.js
new file mode 100644
index 0000000..adb82de
--- /dev/null
+++ b/test/identity.js
@@ -0,0 +1,53 @@
+'use strict';
+
+var test = require('tape');
+
+var Identity = require('../src/identity');
+
+function verifyAutoAccountName(t, n) {
+ t.assert(n.length > 1, 'auto-generated username is nontrivial');
+}
+
+test('auto-generated username from unknown', function(t) {
+ var a = new Identity('unknown').username,
+ b = new Identity('unknown').username;
+ verifyAutoAccountName(t, a);
+ verifyAutoAccountName(t, b);
+ t.notEqual(b, a, 'auto-generated username is unique');
+ t.end();
+});
+
+function testAutoExtract(t, r) {
+ var n = new Identity(r).username;
+ verifyAutoAccountName(t, n);
+ t.not(n, r);
+ t.end();
+}
+
+test('extract username from undefined', function(t) {
+ testAutoExtract(t);
+});
+
+test('extract username from null', function(t) {
+ testAutoExtract(t, null);
+});
+
+test('extract username from "false"', function(t) {
+ t.equals(new Identity('false').username, 'false',
+ '"false" string literal should pass as a username');
+ t.end();
+});
+
+var testAccountName = 'dev.v.io/u/joeuser@google.com/chrome';
+
+test('init', function(t) {
+ var i = new Identity(testAccountName);
+ t.equals(i.username, 'joeuser@google.com',
+ 'should extract a username from a dev.v.io account name');
+ var expectedPrefix = 'joeuser@google.com/desktop_';
+ t.assert(i.entityName.slice(0, expectedPrefix.length) == expectedPrefix,
+ 'entityName starts with expected prefix');
+ t.assert(i.entityName.length > expectedPrefix.length,
+ 'entityName is longer than expected prefix');
+ t.end();
+});
\ No newline at end of file
diff --git a/test/travel.js b/test/travel.js
new file mode 100644
index 0000000..88eae30
--- /dev/null
+++ b/test/travel.js
@@ -0,0 +1,57 @@
+var test = require('tape');
+
+var $ = require('../src/util/jquery');
+var Travel = require('../src/travel');
+
+var mockMaps = require('../mocks/google-maps');
+var mockVanadiumWrapper = require('../mocks/vanadium-wrapper');
+
+function cleanDom() {
+ $('body').empty();
+}
+
+test('init', function(t) {
+ new Travel({
+ maps: mockMaps
+ });
+ t.end();
+ cleanDom();
+});
+
+test('message display', function(t) {
+ var travel = new Travel({
+ vanadiumWrapper: mockVanadiumWrapper,
+ maps: mockMaps
+ });
+
+ var $messages = $('.messages');
+ t.ok($messages.length, 'message display exists');
+ t.equals($messages.children().length, 0, 'message display is empty');
+
+ travel.info('Test message.');
+
+ var $messageItem = $messages.children();
+ t.equals($messageItem.length, 1, 'message display shows 1 message');
+ t.equals($messageItem.text(), 'Test message.',
+ 'message displays message text');
+
+ t.end();
+ cleanDom();
+});
+
+test('domRoot', function(t) {
+ var $root = $('<div>');
+ var root = $root[0];
+ $('body').append($root);
+
+ new Travel({
+ maps: mockMaps,
+ vanadiumWrapper: mockVanadiumWrapper,
+ domRoot: root
+ });
+
+ t.ok($root.children().length, 'app parented to given root');
+
+ t.end();
+ cleanDom();
+});
\ No newline at end of file
diff --git a/test/travelsync.js b/test/travelsync.js
new file mode 100644
index 0000000..dd0575a
--- /dev/null
+++ b/test/travelsync.js
@@ -0,0 +1,8 @@
+var test = require('tape');
+
+var TravelSync = require('../src/travelsync');
+
+test('init', function(t) {
+ t.ok(new TravelSync(), 'initializes');
+ t.end();
+});
\ No newline at end of file
diff --git a/test/util/define-class.js b/test/util/define-class.js
new file mode 100644
index 0000000..743d84c
--- /dev/null
+++ b/test/util/define-class.js
@@ -0,0 +1,150 @@
+var test = require('tape');
+
+var $ = require('../../src/util/jquery');
+var defineClass = require('../../src/util/define-class');
+
+test('trivial', function(t) {
+ var TestClass = defineClass({});
+ t.ok(new TestClass(), 'class instantiates');
+ t.end();
+});
+
+test('defineClass', function(t) {
+ var TestClass = defineClass({
+ init: function(value) {
+ this.value = value;
+ this.greeting = 'Hello';
+ },
+ privates: {
+ getMessage: function() {
+ return this.greeting + ', ' + this.value + '!';
+ }
+ },
+ publics: {
+ toString: function() {
+ this.stringQueried(this.value);
+ this.stringQueriedOnce(this.value);
+ return this.getMessage();
+ }
+ },
+ constants: ['greeting'],
+ events: ['stringQueried', {stringQueriedOnce: 'once'}]
+ });
+
+ var testInstance = new TestClass('world');
+
+ t.ok(testInstance, 'instance instantiated');
+
+ var queried = 0, queriedOnce = 0;
+ testInstance.stringQueried.add(function(value) {
+ t.equal(value, 'world', 'event argument');
+ queried++;
+ });
+ testInstance.stringQueriedOnce.add(function(value) {
+ t.equal(value, 'world', 'event argument');
+ queriedOnce++;
+ });
+
+ t.notOk(testInstance.stringQueried.fired(), 'event not fired');
+
+ t.equal(testInstance.greeting, 'Hello', 'public constant accessible');
+ t.equal(testInstance.toString(), 'Hello, world!', 'public member accessible');
+
+ t.assert(testInstance.stringQueried.fired(), 'event fired');
+
+ t.equal(queried, 1, 'event fired');
+ t.equal(queriedOnce, 1, 'once event fired');
+
+ testInstance.toString();
+ t.equal(queried, 2, 'event fired again');
+ t.equal(queriedOnce, 1, 'once event not fired again');
+
+ t.notOk(testInstance.getMessage, 'private member not accessible');
+ t.notOk(testInstance.value, 'instance field not accessible');
+ t.notOk(testInstance.stringQueried.fire, 'event fire not accessible');
+ t.notOk(testInstance.stringQueried.fireWith, 'event fireWith not accessible');
+
+ t.end();
+});
+
+test('events object', function(t) {
+ var TestClass = defineClass({
+ init: function() {
+ this.privateFires = this.publicFires = 0;
+
+ var self = this;
+ this.privateEvent.add(function() {
+ self.privateFires++;
+ });
+ this.publicEvent.add(function() {
+ self.publicFires++;
+ });
+ },
+
+ publics: {
+ getPrivateFires: function() {
+ return this.privateFires;
+ },
+
+ getPublicFires: function() {
+ return this.publicFires;
+ },
+
+ trigger: function() {
+ this.triggerOnce.fire();
+ this.privateEvent();
+ this.publicEvent();
+ }
+ },
+ events: {
+ triggerOnce: 'once',
+ privateEvent: 'private',
+ publicEvent: 'public'
+ }
+ });
+
+ var testInstance = new TestClass();
+ var count = 0;
+ testInstance.triggerOnce.add(function() {
+ count++;
+ });
+
+ testInstance.trigger();
+ t.equal(count, 1, 'event fired');
+ testInstance.trigger();
+ t.equal(count, 1, 'event not fired again');
+
+ t.notOk(testInstance.privateEvent, 'private event not accessible');
+ t.equal(testInstance.getPrivateFires(), 2, 'private event fired twice');
+ t.equal(testInstance.getPublicFires(), 2, 'public event fired twice');
+
+ t.notOk($.isFunction(testInstance.triggerOnce), 'normal event not callable');
+ t.ok($.isFunction(testInstance.publicEvent), 'public event callable');
+ testInstance.publicEvent();
+ t.equal(testInstance.getPublicFires(), 3, 'public event fired thrice');
+
+ t.end();
+});
+
+test('statics', function(t) {
+ var TestClass = defineClass({
+ publics: {
+ getValue: function() {
+ return this.CONSTANT;
+ }
+ },
+
+ statics: {
+ CONSTANT: 42
+ }
+ });
+
+ t.equal(TestClass.CONSTANT, 42, 'public static access');
+
+ var testInstance = new TestClass();
+
+ t.equal(testInstance.CONSTANT, 42, 'public access');
+ t.equal(testInstance.getValue(), 42, 'private access');
+
+ t.end();
+});
diff --git a/test/util/jquery.js b/test/util/jquery.js
new file mode 100644
index 0000000..bffb00b
--- /dev/null
+++ b/test/util/jquery.js
@@ -0,0 +1,8 @@
+var test = require('tape');
+
+var jquery = require('../../src/util/jquery');
+
+test('load on server', function(t) {
+ t.ok(jquery.each, 'jquery has an each function');
+ t.end();
+});
diff --git a/test/vanadium-wrapper.js b/test/vanadium-wrapper.js
new file mode 100644
index 0000000..43d1427
--- /dev/null
+++ b/test/vanadium-wrapper.js
@@ -0,0 +1,60 @@
+var test = require('tape');
+
+var $ = require('../src/util/jquery');
+var defineClass = require('../src/util/define-class');
+
+var vanadiumWrapper = require('../src/vanadium-wrapper');
+
+var vanadiumMocks = require('../mocks/vanadium');
+var MockVanadium = vanadiumMocks.MockVanadium;
+var MockRuntime = vanadiumMocks.MockRuntime;
+
+function setUpCrashTest(t) {
+ var mockVanadium = new MockVanadium(t);
+ var mockRuntime = new MockRuntime();
+
+ var context = {
+ bindCrashHandler: function(err) {
+ var self = this;
+ self.vanadiumWrapper.crash.add(function(err) {
+ self.crashErr = err;
+ });
+ },
+ crash: function(err) {
+ mockRuntime.fireCrash(err);
+ }
+ };
+
+ vanadiumWrapper.init(mockVanadium).then(
+ function(v) {
+ context.vanadiumWrapper = v;
+ },
+ function(err) {
+ t.fail('init error');
+ });
+
+ mockVanadium.finishInit(null, mockRuntime);
+
+ return context;
+}
+
+test('crashBefore', function(t) {
+ var crashTest = setUpCrashTest(t);
+
+ crashTest.crash('I lost the game.');
+ crashTest.bindCrashHandler();
+ t.equal(crashTest.crashErr, 'I lost the game.');
+
+ t.end();
+});
+
+test('crashAfter', function(t) {
+ var crashTest = setUpCrashTest(t);
+ crashTest.bindCrashHandler();
+ t.notOk(crashTest.crashErr, 'no crash yet');
+
+ crashTest.crash('I lost the game.');
+ t.equal(crashTest.crashErr, 'I lost the game.');
+
+ t.end();
+});
\ No newline at end of file