Adding sync tests with mocked SyncBase (wrapper)
Change-Id: If18992dfda2eceb191c62fd7d19583cb69e6b402
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
index 6318d52..65639be 100644
--- a/mocks/google-maps.js
+++ b/mocks/google-maps.js
@@ -5,6 +5,39 @@
var $ = require('../src/util/jquery');
var defineClass = require('../src/util/define-class');
+var maps;
+
+var PLACES = {
+ GATEWAY_ARCH: {
+ coords: {
+ latitude: 38.6,
+ longitude: -90.2
+ },
+ placeId: '5TL0U15'
+ },
+ GRAND_CANYON: {
+ coords: {
+ latitude: 36.1,
+ longitude: -112.1
+ },
+ placeId: '6R4NDC4NY0N'
+ },
+ GOLDEN_GATE: {
+ coords: {
+ latitude: 37.8,
+ longitude: -122.5
+ },
+ placeId: '60LD3N64T3'
+ },
+ SPACE_NEEDLE: {
+ coords: {
+ latitude: 47.6,
+ longitude: -122.3
+ },
+ placeId: '5P4C3N33DL3'
+ }
+};
+
var ControlPosition = {
LEFT_CENTER: 'lc',
LEFT_TOP: 'lt',
@@ -12,16 +45,80 @@
TOP_LEFT: 'tl'
};
-var ControlPanel = defineClass({
- init: function(parent) {
- this.$ = $('<div>');
- this.$.appendTo(parent);
- },
+var GeocoderStatus = {
+ OK: 0,
+ ERROR: 1
+};
+var PlacesServiceStatus = {
+ OK: 0,
+ ZERO_RESULTS: 1
+};
+
+var TravelMode = {
+ DRIVING: 0
+};
+
+var ControlPanel = defineClass({
publics: {
push: function(child) {
this.$.append(child);
}
+ },
+
+ init: function(parent) {
+ this.$ = $('<div>');
+ this.$.appendTo(parent);
+ }
+});
+
+var DirectionsRenderer = defineClass({
+ publics: {
+ setMap: function(){},
+ toString: function() { return 'mock DirectionsRenderer'; }
+ }
+});
+
+var DirectionsService = defineClass({
+ publics: {
+ route: function(){},
+ toString: function() { return 'mock DirectionsService'; }
+ }
+});
+
+function geoResolver(location) {
+ var result;
+ $.each(maps.places.corpus, function() {
+ if (location.lat() === this.coords.latitude &&
+ location.lng() === this.coords.longitude) {
+ result = placeResult(this);
+ return false;
+ }
+ });
+ return result;
+}
+
+var Geocoder = defineClass({
+ publics: {
+ geocode: function(request, callback) {
+ var self = this;
+ process.nextTick(function() {
+ var results = [];
+
+ var output = self.resolver(request.location);
+ if (output) {
+ results.push(output);
+ }
+
+ callback(results, output? GeocoderStatus.OK : GeocoderStatus.ERROR);
+ });
+ },
+
+ toString: function() { return 'mock Geocoder'; }
+ },
+
+ init: function(resolver) {
+ this.resolver = resolver || geoResolver;
}
});
@@ -40,9 +137,47 @@
}
});
+var LatLng = defineClass({
+ publics: {
+ lat: function() {
+ return this.latitude;
+ },
+
+ lng: function() {
+ return this.longitude;
+ },
+
+ toString: function() { return 'mock LatLng (' +
+ this.lat() + ', ' + this.lng() + ')'; }
+ },
+
+ init: function(lat, lng) {
+ this.latitude = lat;
+ this.longitude = lng;
+ }
+});
+
+var LatLngBounds = defineClass({
+ publics: {
+ contains: function(){},
+ extend: function(){},
+ toSpan: function(){},
+
+ toString: function() { return 'mock LatLngBounds'; }
+ }
+});
+
var Map = defineClass({
publics: {
- getBounds: function(){},
+ getBounds: function(){
+ return new LatLngBounds();
+ },
+
+ setCenter: function(){},
+ panTo: function(){},
+ fitBounds: function(){},
+
+ setOptions: function(){},
registerInfoWindow: function(wnd) {
this.infoWindows.push(wnd);
@@ -76,6 +211,10 @@
});
this.infoWindows = [];
+
+ if (maps.onNewMap) {
+ maps.onNewMap(this.ifc);
+ }
}
});
@@ -92,47 +231,106 @@
},
setMap: function(map) {
- this.map = map;
+ var old = this.map;
+ if (old !== map) {
+ this.map = map;
+ this.onMapChange(map, old);
+ }
},
getMap: function() {
return this.map;
},
+ getPlace: function() {
+ return this.place;
+ },
+
setTitle: function(){},
toString: function() { return 'mock Marker'; }
},
events: {
- click: 'public'
+ click: 'public',
+ onMapChange: ''
},
init: function(opts) {
$.extend(this, opts);
+
+ if (maps.onNewMarker) {
+ maps.onNewMarker(this.ifc);
+ }
+ }
+});
+
+function placeResult(data) {
+ return {
+ geometry: {
+ location: new LatLng(data.coords.latitude, data.coords.longitude)
+ },
+ 'place_id': data.placeId
+ };
+}
+
+var PlacesService = defineClass({
+ publics: {
+ getDetails: function(request, callback){
+ $.each(maps.places.corpus, function() {
+ if (request.placeId === this.placeId) {
+ callback(placeResult(this), PlacesServiceStatus.OK);
+ return false;
+ }
+ });
+
+ callback(null, PlacesServiceStatus.ZERO_RESULTS);
+ },
+
+ toString: function() { return 'mock PlacesService'; }
}
});
var SearchBox = defineClass({
publics: {
setBounds: function(){},
+
+ getPlaces: function() {
+ return this.places;
+ },
+
toString: function() { return 'mock SearchBox'; }
},
+ privates: {
+ mockResults: function(places) {
+ this.places = places.map(placeResult);
+ this['places_changed']();
+ }
+ },
+
events: {
'places_changed': 'public'
+ },
+
+ init: function(input) {
+ $(input).data('mockResults', this.mockResults);
}
});
-module.exports = {
+maps = {
ControlPosition: ControlPosition,
- DirectionsService: function(){},
+ DirectionsRenderer: DirectionsRenderer,
+ DirectionsService: DirectionsService,
DirectionsStatus: {},
- Geocoder: function(){},
+ Geocoder: Geocoder,
+ GeocoderStatus: GeocoderStatus,
InfoWindow: InfoWindow,
- LatLng: function(){},
+ LatLng: LatLng,
+ LatLngBounds: LatLngBounds,
Map: Map,
Marker: Marker,
+ TravelMode: TravelMode,
event: {
addListener: function(instance, eventName, handler){
@@ -149,10 +347,15 @@
},
places: {
- PlacesService: function(){},
+ corpus: PLACES,
+
+ PlacesService: PlacesService,
+ PlacesServiceStatus: PlacesServiceStatus,
SearchBox: SearchBox,
mockPlaceResult: {
geometry: {}
}
}
-};
\ No newline at end of file
+};
+
+module.exports = maps;
\ No newline at end of file
diff --git a/mocks/navigator.js b/mocks/navigator.js
new file mode 100644
index 0000000..8d2309a
--- /dev/null
+++ b/mocks/navigator.js
@@ -0,0 +1,31 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var defineClass = require('../src/util/define-class');
+
+var MockGeolocation = defineClass({
+ publics: {
+ getCurrentPosition: function(callback) {
+ this.onResolvePosition.add(callback);
+ },
+
+ resolvePosition: function(position) {
+ this.onResolvePosition(position);
+ }
+ },
+
+ events: {
+ onResolvePosition: 'once'
+ }
+});
+
+var MockNavigator = defineClass({
+ constants: [ 'geolocation' ],
+
+ init: function() {
+ this.geolocation = new MockGeolocation();
+ }
+});
+
+module.exports = MockNavigator;
\ No newline at end of file
diff --git a/mocks/syncbase-wrapper.js b/mocks/syncbase-wrapper.js
new file mode 100644
index 0000000..a8368a1
--- /dev/null
+++ b/mocks/syncbase-wrapper.js
@@ -0,0 +1,241 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+require('es6-shim');
+
+var $ = require('../src/util/jquery');
+var defineClass = require('../src/util/define-class');
+
+//All periods are expressed in milliseconds.
+var SYNC_LOOP_PERIOD = 50;
+var WATCH_LOOP_PERIOD = 50;
+
+var syncgroups = {};
+
+function update(a, b) {
+ $.each(a, function(k, v) {
+ if (k !== 'value' && k !== 'version') {
+ var bv = b[k];
+ if (bv) {
+ update(v, bv);
+ } else {
+ b[k] = $.extend(true, {}, v);
+ }
+ }
+ });
+
+ if (a.version > b.version) {
+ b.value = a.value;
+ b.version = a.version;
+ }
+}
+
+function sync(a, b, prefixes) {
+ $.each(prefixes, function() {
+ var suba = recursiveGet(a, this);
+ var subb = recursiveGet(b, this);
+
+ if (suba && subb) {
+ update(suba, subb);
+ update(subb, suba);
+ } else if (!suba) {
+ recursiveCopy(a, this, subb);
+ } else if (!subb) {
+ recursiveCopy(b, this, suba);
+ }
+ });
+}
+
+function syncLoop() {
+ $.each(syncgroups, function() {
+ var prev;
+ this.forEach(function(sb) {
+ if (prev) {
+ sync(prev, sb, this.prefixes);
+ }
+
+ prev = sb;
+ }, this);
+ });
+
+ setTimeout(syncLoop, SYNC_LOOP_PERIOD);
+}
+process.nextTick(syncLoop);
+
+function advanceVersion(node) {
+ if (node.version === undefined) {
+ node.version = 0;
+ } else {
+ node.version++;
+ }
+}
+
+function recursiveCreate(node, key) { //it's recursive in spirit
+ $.each(key, function() {
+ var child = node[this];
+ if (!child) {
+ child = node[this] = {};
+ }
+ node = child;
+ });
+
+ return node;
+}
+
+function recursiveSet(node, key, value) {
+ node = recursiveCreate(node, key);
+
+ node.value = value;
+ advanceVersion(node);
+}
+
+function recursiveCopy(node, key, content) {
+ $.extend(true, recursiveCreate(node, key), content);
+}
+
+function recursiveGet(node, key) {
+ $.each(key, function() {
+ if (!node) {
+ return false;
+ }
+ node = node[this];
+ });
+ return node;
+}
+
+function recursiveDelete(node, key) {
+ if (key) {
+ node = recursiveGet(node, key);
+ }
+
+ if (node) {
+ delete node.value;
+ advanceVersion(node);
+ $.each(node, function(key, value) {
+ if (key !== 'version') {
+ recursiveDelete(value);
+ }
+ });
+ }
+}
+
+function extractData(repo) {
+ var data;
+ $.each(repo, function(k, v) {
+ if (k === 'value') {
+ if (typeof data === 'object') {
+ if (v !== undefined) {
+ data._ = v;
+ }
+ } else {
+ data = v;
+ }
+ } else if (k !== 'version') {
+ var value = extractData(v);
+ if (value !== undefined) {
+ if (data === undefined) {
+ data = {};
+ } else if (typeof data !== 'object') {
+ data = { _: data };
+ }
+ data[k] = value;
+ }
+ }
+ });
+
+ return data;
+}
+
+var MockSyncbaseWrapper = defineClass({
+ statics: {
+ /**
+ * SLA for a write to a mocked Syncbase instance to be reflected by synced
+ * instances. This is actually based on the size of the SyncGroups with the
+ * current mock implementation--roughly n * SYNC_LOOP_SLA--but let's express
+ * it as a constant for simplicity.
+ */
+ SYNC_SLA: 250 //ms
+ },
+
+ publics: {
+ batch: function(fn) {
+ var ops = {
+ put: this.put,
+ delete: this.delete
+ };
+
+ fn.call(ops, ops);
+ return Promise.resolve();
+ },
+
+ put: function(k, v) {
+ recursiveSet(this.repo, k, v);
+ return Promise.resolve();
+ },
+
+ delete: function(k) {
+ recursiveDelete(this.repo, k);
+ return Promise.resolve();
+ },
+
+ getData: function() {
+ return extractData(this.repo) || {};
+ },
+
+ syncGroup: function(sgAdmin, name) {
+ var repo = this.repo;
+
+ var sgp = {
+ buildSpec: function(prefixes) {
+ return prefixes;
+ },
+
+ join: function() {
+ var sgKey = sgAdmin + '$' + name;
+ var sg = syncgroups[sgKey];
+ sg.add(repo);
+ return Promise.resolve(sgp);
+ },
+
+ joinOrCreate: function(spec) {
+ var sgKey = sgAdmin + '$' + name;
+ var sg = syncgroups[sgKey];
+ if (!sg) {
+ sg = syncgroups[sgKey] = new Set();
+ }
+
+ sg.prefixes = spec;
+ sg.add(repo);
+
+ return Promise.resolve(sgp);
+ }
+ };
+
+ return sgp;
+ },
+
+ refresh: function() {
+ this.onUpdate(this.getData());
+ }
+ },
+
+ events: {
+ onError: 'memory',
+ onUpdate: 'memory'
+ },
+
+ init: function() {
+ var self = this;
+
+ this.repo = {};
+
+ function watchLoop() {
+ self.refresh();
+ setTimeout(watchLoop, WATCH_LOOP_PERIOD);
+ }
+ process.nextTick(watchLoop);
+ }
+});
+
+module.exports = MockSyncbaseWrapper;
diff --git a/mocks/vanadium-wrapper.js b/mocks/vanadium-wrapper.js
index fa9eade..ae54acb 100644
--- a/mocks/vanadium-wrapper.js
+++ b/mocks/vanadium-wrapper.js
@@ -2,10 +2,65 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
+require('es6-shim');
+
var Deferred = require('vanadium/src/lib/deferred');
+var defineClass = require('../src/util/define-class');
+var $ = require('../src/util/jquery');
+
+var MockWrapper = defineClass({
+ publics: {
+ getAccountName: function() {
+ return this.accountName;
+ },
+
+ server: function(endpoint, server) {
+ return this.endpointResolver(endpoint, server);
+ },
+
+ syncbase: function(endpoint) {
+ return this.server(endpoint);
+ },
+
+ setPermissions: function() {
+ return Promise.resolve();
+ }
+ },
+
+ events: {
+ onCrash: 'public',
+ onError: 'public'
+ },
+
+ /**
+ * @param provider callback that receives the endpoint name and possibly a
+ * Vanadium server implementation, and returns a promise to a mock service.
+ * This callback is called once per server or syncbase call.
+ */
+ init: function(props, endpointResolver) {
+ $.extend(this, props);
+ this.endpointResolver = endpointResolver;
+ }
+});
module.exports = {
- init: function(){
+ init: function() {
return new Deferred().promise;
+ },
+
+ newInstance: function() {
+ var wrapper;
+ var init = new Deferred();
+
+ return {
+ finishInit: function(props, endpointResolver) {
+ wrapper = new MockWrapper(props, endpointResolver);
+ init.resolve(wrapper);
+ },
+
+ init: function() {
+ return init.promise;
+ }
+ };
}
};
\ No newline at end of file
diff --git a/src/components/map-widget.js b/src/components/map-widget.js
index 40b643e..b89c528 100644
--- a/src/components/map-widget.js
+++ b/src/components/map-widget.js
@@ -390,14 +390,14 @@
}
},
- centerOnCurrentLocation: function() {
+ centerOnCurrentLocation: function(navigator) {
var self = this;
var maps = this.maps;
var map = this.map;
// https://developers.google.com/maps/documentation/javascript/examples/map-geolocation
- if (global.navigator && global.navigator.geolocation) {
- global.navigator.geolocation.getCurrentPosition(function(position) {
+ if (navigator && navigator.geolocation) {
+ navigator.geolocation.getCurrentPosition(function(position) {
var latLng = new maps.LatLng(
position.coords.latitude, position.coords.longitude);
map.setCenter(latLng);
@@ -496,9 +496,11 @@
var self = this;
var maps = opts.maps || global.google.maps;
+ var navigator = opts.navigator || global.navigator;
+
this.maps = maps;
this.navigator = opts.navigator || global.navigator;
- this.geocoder = new maps.Geocoder();
+ this.geocoder = opts.geocoder || new maps.Geocoder();
this.directionsService = new maps.DirectionsService();
this.$ = $('<div>').addClass('map-canvas');
@@ -522,7 +524,7 @@
self.onBoundsChange(map.getBounds());
});
- this.centerOnCurrentLocation();
+ this.centerOnCurrentLocation(navigator);
}
});
diff --git a/src/travel.js b/src/travel.js
index 7dc275d..5ce1c2e 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -80,13 +80,19 @@
var Travel = defineClass({
publics: {
dump: function() {
- this.sync.getData().then(function(data) {
+ return this.sync.getData().then(function(data) {
debug.log(data);
+ return data;
}, function(err) {
console.error(err);
+ throw err;
});
},
+ status: function() {
+ return this.sync.status;
+ },
+
error: function (err) {
this.messages.push(Message.error(err));
},
@@ -99,6 +105,10 @@
}));
},
+ getActiveTripId: function() {
+ return this.sync.getActiveTripId();
+ },
+
invite: function(recipient) {
var self = this;
@@ -553,7 +563,7 @@
op: function() {
this.messages.push(new Message({
type: Message.INFO,
- html: strings.status(JSON.stringify(this.sync.status, null, 2))
+ html: strings.status(JSON.stringify(this.status(), null, 2))
}));
}
}
diff --git a/src/travelsync.js b/src/travelsync.js
index d2d363e..23367c0 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -195,19 +195,29 @@
}
}),
+ manageWrite: function(promise) {
+ var writes = this.writes;
+ writes.add(promise);
+ promise.then(function() {
+ writes.delete(promise);
+ }, function() {
+ writes.delete(promise);
+ });
+ },
+
batch: function(fn) {
- this.startSyncbase.then(function(syncbase) {
+ this.manageWrite(this.startSyncbase.then(function(syncbase) {
return syncbase.batch(fn);
- }).catch(this.onError);
+ }).catch(this.onError));
},
nonBatched: function(fn) {
var self = this; //not really necessary but semantically correct
var fnArgs = Array.prototype.slice.call(arguments, 1);
- this.startSyncbase.then(function(syncbase) {
+ this.manageWrite(this.startSyncbase.then(function(syncbase) {
fnArgs.splice(0, 0, syncbase);
return fn.apply(self, fnArgs);
- }).catch(this.onError);
+ }).catch(this.onError));
},
handleDestinationAdd: function (destination) {
@@ -316,7 +326,15 @@
},
unmarshal: function(x) {
- return x && JSON.parse(x);
+ if (!x) {
+ return x;
+ }
+
+ if (typeof x === 'object') {
+ throw new TypeError('Unexpected persisted object ' + JSON.stringify(x));
+ }
+
+ return JSON.parse(x);
},
truncateDestinations: function(targetLength) {
@@ -440,10 +458,6 @@
});
if (this.destRecords.length > ids.length) {
- /* TODO(rosswang): There is an edge case where this happens due to
- * user interaction even though normally pulls are blocked while
- * writes are outstanding. This can probably also happen on startup.
- * Debug this or better yet make it go away. */
this.truncateDestinations(ids.length);
}
}
@@ -604,7 +618,13 @@
},
processUpdates: function(data) {
- this.processTrips(data.user && data.user.tripMetadata, data.trips);
+ /* Although SyncbaseWrapper gates on something similar, we may block on
+ * SyncBase initialization and don't want initial pulls overwriting local
+ * updates queued for writing. We could actually do it here only, but
+ * having it in SyncbaseWrapper as well is semantically correct. */
+ if (!this.writes.size) {
+ this.processTrips(data.user && data.user.tripMetadata, data.trips);
+ }
},
hasValidUpstream: function() {
@@ -720,6 +740,7 @@
this.destRecords = [];
this.status = {};
this.joinedTrips = new Set();
+ this.writes = new Set();
this.server = new vdlTravel.TravelSync();
var startRpc = prereqs.then(this.serve);
diff --git a/src/util/define-class.js b/src/util/define-class.js
index eede6b6..0873408 100644
--- a/src/util/define-class.js
+++ b/src/util/define-class.js
@@ -78,14 +78,14 @@
$.extend(ifc, def.statics);
}
- if (def.init) {
- def.init.apply(pthis, arguments);
- }
-
if (def.publics) {
polyBind(ifc, pthis, def.publics, true);
}
+ if (def.init) {
+ def.init.apply(pthis, arguments);
+ }
+
if (def.constants) {
$.each(def.constants, function(i, constant) {
ifc[constant] = pthis[constant];
diff --git a/src/vanadium-wrapper/index.js b/src/vanadium-wrapper/index.js
index e136879..06962dd 100644
--- a/src/vanadium-wrapper/index.js
+++ b/src/vanadium-wrapper/index.js
@@ -7,6 +7,7 @@
var SyncbaseWrapper = require('./syncbase-wrapper');
+//ms
var NAME_TTL = 5000;
var NAME_REFRESH = 2500;
diff --git a/src/vanadium-wrapper/syncbase-wrapper.js b/src/vanadium-wrapper/syncbase-wrapper.js
index 9b7664e..382bd83 100644
--- a/src/vanadium-wrapper/syncbase-wrapper.js
+++ b/src/vanadium-wrapper/syncbase-wrapper.js
@@ -384,13 +384,13 @@
// Start the watch loop to periodically poll for changes from sync.
// TODO(rosswang): Remove this once we have client watch.
- this.watchLoop = function() {
+ function watchLoop() {
if (!self.pull.current) {
self.refresh().catch(self.onError);
}
- setTimeout(self.watchLoop, 500);
- };
- process.nextTick(self.watchLoop);
+ setTimeout(watchLoop, 500);
+ }
+ process.nextTick(watchLoop);
}
});
diff --git a/test/travel.js b/test/travel.js
index ab8bd84..01ee827 100644
--- a/test/travel.js
+++ b/test/travel.js
@@ -2,29 +2,54 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
+require('es6-shim');
+
var test = require('tape');
+var uuid = require('uuid');
+
var $ = require('../src/util/jquery');
var Travel = require('../src/travel');
var mockMaps = require('../mocks/google-maps');
+var MockNavigator = require('../mocks/navigator');
+var MockSyncbaseWrapper = require('../mocks/syncbase-wrapper');
var mockVanadiumWrapper = require('../mocks/vanadium-wrapper');
+var PLACES = mockMaps.places.corpus;
+
+//All SLAs are expressed in milliseconds.
+var UI_SLA = 50;
+/**
+ * Syncbase doesn't yet provide us any notification that the first sync after
+ * joining the initial sync groups has happened. This SLA is currently based on
+ * a similar timeout in the Travel app, though in the future if that logic gets
+ * smarter we can shrink it to the sync SLA.
+ *
+ * Set to 2500 for real testing, 250 for watch.
+ */
+var STABLE_SLA = 2500;
+var SYNC_SLA = MockSyncbaseWrapper.SYNC_SLA;
+
function cleanDom() {
$('body').empty();
}
-test('domRoot', function(t) {
+function newDomRoot() {
var $root = $('<div>');
- var root = $root[0];
$('body').append($root);
+ return $root;
+}
+
+test('domRoot', function(t) {
+ var $root = newDomRoot();
/* jshint -W031 */ //top-level application
new Travel({
maps: mockMaps,
vanadiumWrapper: mockVanadiumWrapper,
- domRoot: root,
- syncbase: 'dummy'
+ syncbase: 'dummy',
+ domRoot: $root[0]
});
/* jshint +W031 */
@@ -54,4 +79,347 @@
t.equals($($messageItems[1]).text(), 'Test message.',
'message displays message text');
t.end();
-});
\ No newline at end of file
+ cleanDom();
+});
+
+//TODO(rosswang): find a better way. If we settle on this, restore afterwards
+function failOnError(t) {
+ console.error = function(err) {
+ t.error(err);
+ };
+}
+
+function handleMarkerMapSet(map, old) {
+ if (map) {
+ map.markers.add(this);
+ }
+ if (old) {
+ old.markers.delete(this);
+ }
+}
+
+mockMaps.onNewMarker = function(marker) {
+ marker.onMapChange.add(handleMarkerMapSet);
+ handleMarkerMapSet.call(marker, marker.getMap());
+};
+
+function startInstance(t, testCase, opts, user) {
+ return new Promise(function(resolve, reject) {
+ testCase.$domRoot = newDomRoot();
+
+ mockMaps.onNewMap = function(map) {
+ testCase.map = map;
+ map.markers = new Set();
+ };
+
+ var vanadiumWrapper = mockVanadiumWrapper.newInstance();
+ var syncbase = uuid.v4();
+
+ var travel = testCase.travel = new Travel($.extend({
+ maps: mockMaps,
+ vanadiumWrapper: vanadiumWrapper,
+ syncbase: syncbase,
+ domRoot: testCase.$domRoot[0]
+ }, opts));
+
+ var syncbaseStarted;
+
+ vanadiumWrapper.finishInit({
+ accountName: 'dev.v.io/u/' + user + '@foogle.com/chrome'
+ }, function(endpoint) {
+ if (endpoint === syncbase) {
+ syncbaseStarted = true;
+ return Promise.resolve(new MockSyncbaseWrapper());
+ } else {
+ return Promise.resolve();
+ }
+ });
+
+ setTimeout(afterSInit, UI_SLA);
+
+ function afterSInit() {
+ t.assert(syncbaseStarted, 'syncbase started');
+
+ var $messages = $('.messages ul').children();
+ t.equals($($messages[0]).text(), 'Connected to all services.',
+ 'all services connected');
+
+ resolve(travel);
+ }
+ });
+}
+
+function startWithGeo(t, testCase, user, origin) {
+ return new Promise(function(resolve, reject) {
+ var mockNavigator = new MockNavigator();
+
+ var travel = startInstance(t, testCase, { navigator: mockNavigator }, user)
+ .then(function() {
+ mockNavigator.geolocation.resolvePosition({
+ coords: origin.coords
+ });
+
+ setTimeout(afterLocate, UI_SLA);
+ }).catch(reject);
+
+ function afterLocate() {
+ resolve(travel);
+ }
+ });
+}
+
+var instances = {
+ alice: {
+ d1: {}, //desktop 1
+ d2: {},
+ d3: {}
+ },
+
+ bob: {
+ d1: {},
+ d2: {}
+ }
+};
+
+var ad1 = instances.alice.d1;
+var ad2 = instances.alice.d2;
+var ad3 = instances.alice.d3;
+var bd1 = instances.bob.d1;
+var bd2 = instances.bob.d2;
+
+test('startup', function(t) {
+ failOnError(t);
+
+ timeoutify(startWithGeo(t, ad1, 'alice', PLACES.GOLDEN_GATE)
+ .then(function() {
+ t.equal(ad1.map.markers.size, 1, 'one marker');
+
+ t.equal(ad1.map.markers.values().next().value.getPlace().placeId,
+ PLACES.GOLDEN_GATE.placeId, 'marker set to current location');
+ t.comment('waiting to verify stable state');
+ }), t, afterStable, STABLE_SLA);
+
+ function afterStable() {
+ t.end();
+ }
+});
+
+function timeoutify(promise, t, callback, delay) {
+ promise.then(function() {
+ setTimeout(callback, delay);
+ }, function(err) {
+ t.error(err);
+ t.end();
+ });
+}
+
+function simplifyPlace(p) {
+ return {
+ lat: p.location.lat(),
+ lng: p.location.lng(),
+ id: p.placeId
+ };
+}
+
+function assertSameSingletonMarkers(t, instanceA, instanceB) {
+ var p1 = instanceA.map.markers.values().next().value.getPlace();
+ var p2 = instanceB.map.markers.values().next().value.getPlace();
+ t.deepEqual(simplifyPlace(p2), simplifyPlace(p1), 'markers synced');
+}
+
+test('two devices', function(t) {
+ failOnError(t);
+
+ timeoutify(startWithGeo(t, ad2, 'alice', PLACES.SPACE_NEEDLE),
+ t, afterSync, SYNC_SLA);
+
+ function afterSync() {
+ t.equal(ad2.map.markers.size, 1, 'still 1 marker after sync');
+ assertSameSingletonMarkers(t, ad1, ad2);
+ t.equal(ad2.travel.getActiveTripId(), ad1.travel.getActiveTripId(),
+ 'trips synced');
+ t.end();
+ }
+});
+
+function addDestination(t, instance, data) {
+ return new Promise(function(resolve, reject) {
+ var oldMarkerCount = instance.map.markers.size;
+
+ instance.$domRoot.find('.mini-search .add-bn').click();
+ setTimeout(afterClick, UI_SLA);
+
+ function afterClick() {
+ var $inputs = instance.$domRoot.find('.mini-search input');
+ var $focused = $inputs.filter(':focus');
+ t.ok($focused.length, 'mini-search input focused');
+
+ /* Actually, the wrong input will be focused because the code focuses on
+ * the :visible one, which requires CSS that we're not importing at test
+ * time. */
+ $inputs.data('mockResults')([data]);
+
+ t.equal(instance.map.markers.size, oldMarkerCount + 1, 'new marker');
+
+ resolve();
+ }
+ });
+}
+
+test('new destination', function(t) {
+ failOnError(t);
+
+ timeoutify(addDestination(t, ad1, PLACES.GATEWAY_ARCH),
+ t, afterSync, SYNC_SLA);
+
+ function afterSync() {
+ t.equal(ad2.map.markers.size, 2, 'new marker on synced instance');
+
+ t.end();
+ }
+});
+
+test('third device (established trip on other two)', function(t) {
+ failOnError(t);
+
+ timeoutify(startInstance(t, ad3, {}, 'alice').then(function() {
+ t.comment('waiting to verify stable state');
+ }), t, afterSync, STABLE_SLA);
+
+ function afterSync() {
+ t.equal(ad3.map.markers.size, 2, 'two markers on synced instance');
+ t.end();
+ }
+});
+
+test('new user', function(t) {
+ failOnError(t);
+
+ timeoutify(Promise.all([
+ startWithGeo(t, bd1, 'bob', PLACES.GOLDEN_GATE),
+ startWithGeo(t, bd2, 'bob', PLACES.SPACE_NEEDLE)
+ ]), t, afterSync, SYNC_SLA);
+
+ function afterSync() {
+ t.equal(bd1.map.markers.size, 1, 'one marker (no sync with Alice)');
+ assertSameSingletonMarkers(t, bd1, bd2);
+ t.end();
+ }
+});
+
+function getMessage(instance, index) {
+ var $messageItems = instance.$domRoot.find('.messages ul').children();
+ if (index < 0) {
+ index = $messageItems.length + index;
+ }
+ return $($messageItems[index]);
+}
+
+function invite(senderInstance, recipientUser) {
+ senderInstance.$domRoot.find('.send input')
+ .prop('value', '/invite ' + recipientUser + '@foogle.com')
+ .trigger(new $.Event('keydown', { which: 13 }));
+}
+
+test('join established trip', function(t) {
+ failOnError(t);
+
+ invite(ad2, 'bob');
+
+ t.equal(getMessage(ad2, 1).text(),
+ 'Inviting bob@foogle.com to join the trip...',
+ 'local invite message');
+
+ setTimeout(afterInvite1, SYNC_SLA);
+
+ var $invite;
+
+ function afterInvite1() {
+ $.each(instances.alice, function() {
+ t.equal(getMessage(this, -1).find('.text').text(),
+ 'alice@foogle.com invited bob@foogle.com to join the trip.',
+ 'trip invite message');
+ });
+
+ $.each(instances.bob, function() {
+ t.equal(getMessage(this, -1).find('.text').text(),
+ 'alice@foogle.com has invited you to join a trip. Accept / Decline',
+ 'recipient invite message');
+ });
+
+ t.equal(bd1.map.markers.size, 1, 'still no sync with Alice');
+
+ $invite = getMessage(bd1, -1);
+ $invite.find('a[name=decline]').click();
+
+ setTimeout(afterDecline, UI_SLA);
+ }
+
+ function afterDecline() {
+ t.equal($invite.text(),
+ 'Declined invite from alice@foogle.com to join a trip.',
+ 'local decline message');
+
+ setTimeout(afterDeclineSync, SYNC_SLA);
+ }
+
+ function afterDeclineSync() {
+ t.equal(getMessage(bd2, -1).text(),
+ 'alice@foogle.com has invited you to join a trip. (Expired)',
+ 'user decline message');
+
+ invite(ad2, 'bob');
+
+ setTimeout(afterInvite2, SYNC_SLA);
+ }
+
+ function afterInvite2() {
+ $invite = getMessage(bd2, 2);
+ $invite.find('a[name=accept]').click();
+
+ setTimeout(afterAccept, UI_SLA);
+ }
+
+ function afterAccept() {
+ t.equal($invite.text(),
+ 'Accepted invite from alice@foogle.com to join a trip.',
+ 'local accept message');
+
+ setTimeout(afterAcceptSync, SYNC_SLA);
+ }
+
+ function afterAcceptSync() {
+ t.equal(getMessage(bd1, 2).text(),
+ 'alice@foogle.com has invited you to join a trip. (Expired)',
+ 'user accept message');
+
+ $.each(instances.bob, function() {
+ t.equal(this.map.markers.size, 2, 'synced with Alice');
+ });
+
+ t.end();
+ }
+});
+
+test('new destination from collaborator', function(t) {
+ failOnError(t);
+
+ timeoutify(addDestination(t, bd1, PLACES.GRAND_CANYON),
+ t, afterSync, SYNC_SLA);
+
+ function afterSync() {
+ $.each(['alice', 'bob'], function() {
+ $.each(instances[this], function() {
+ t.equal(this.map.markers.size, 3,
+ 'destination added to all synced instances');
+ });
+ });
+
+ t.end();
+ }
+});
+
+test('teardown', function(t) {
+ t.end();
+ process.exit(); //required to terminate timeouts
+});
diff --git a/test/util/define-class.js b/test/util/define-class.js
index e3eeeac..980d654 100644
--- a/test/util/define-class.js
+++ b/test/util/define-class.js
@@ -326,3 +326,24 @@
t.end();
});
+
+test('constructor ifc', function(t) {
+ var TestClass = defineClass({
+ publics: {
+ getValue: function() {
+ return 'hi';
+ }
+ },
+
+ init: function() {
+ t.equal(this.ifc.getValue(), 'hi',
+ 'public methods bound before constructor');
+ }
+ });
+
+ /* jshint -W031 */ //testing in-constructor visibility
+ new TestClass();
+ /* jshint +W031 */
+
+ t.end();
+});