Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,18 @@
{
"extends": "../../../../.jshintrc",
"node": true,
"predef": [
"suite",
"test",
"setup",
"teardown",
"suiteSetup",
"suiteTeardown",
"marionette",
"marionetteScriptFinished",
"require",
"define",
"requireApp",
"sinon"
]
}

View file

@ -0,0 +1,241 @@
marionette('Alarm', function() {
'use strict';
var assert = require('assert');
var $ = require('./lib/mquery');
var utils = require('./lib/utils');
var actions = new (require('./lib/actions'))({
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
var alarm = actions.alarm;
setup(function() {
actions.launch('alarm');
});
// PythonTests: functional/test_clock_switch_clock_type
test('Tapping the clock toggles between analog and digital', function() {
$('#analog-clock').tap();
assert.ok($('#digital-clock').displayed());
assert.ok($('#clock-day-date').displayed());
$('#digital-clock').tap();
assert.ok($('#analog-clock').displayed());
assert.ok($('#clock-day-date').displayed());
});
test('Deleting an alarm works between app launches', function() {
alarm.create();
alarm.openEditForm(0);
alarm.delete();
actions.restart();
assert.equal(alarm.list.length, 0);
});
// PythonTests: functional/test_clock_set_alarm
test('Blank "New Alarm" form mutates properly', function() {
alarm.openNewForm();
assert.equal($('#repeat-menu').text(), 'Never');
[
{ repeat: ['Monday'],
label: 'Mon' },
{ repeat: [],
label: 'Never' },
{ repeat: ['Monday', 'Tuesday'],
label: 'Mon, Tue' },
{ repeat: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday'],
label: 'Every day' },
{ repeat: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
label: 'Weekdays' }
].forEach(function(item) {
$('#repeat-select').val(item.repeat);
assert.equal($('#repeat-menu').text(), item.label);
});
var selectedOptions = {
snooze: '15 minutes',
sound: 'Cycle',
repeat: 'Weekdays'
};
assert.notEqual($('#sound-menu').text(), selectedOptions.sound);
$('#sound-select').val(selectedOptions.sound);
assert.equal($('#sound-menu').text(), selectedOptions.sound);
assert.notEqual($('#snooze-menu').text(), selectedOptions.snooze);
$('#snooze-select').val(selectedOptions.snooze);
assert.equal($('#snooze-menu').text(), selectedOptions.snooze);
alarm.saveForm();
alarm.openEditForm(0);
assert.equal($('#sound-menu').text(), selectedOptions.sound);
assert.equal($('#snooze-menu').text(), selectedOptions.snooze);
assert.equal($('#repeat-menu').text(), selectedOptions.repeat);
// Ensure that the time button shows the value of the time
// <select>, since updating the select does not automatically
// change the button unless our code makes it do so.
utils.assertStringContainsTime($('#time-select + button').text(),
utils.stringToDate($('#time-select').val()));
});
test('Volume control saves immediately when changed', function() {
// Even if we abort the alarm_edit form without saving, the volume
// should be saved.
alarm.openNewForm();
alarm.volumeInput.val(1);
alarm.cancelForm();
alarm.openNewForm();
assert.equal(parseInt(alarm.volumeInput.val(), 10), 1);
alarm.volumeInput.val(0);
alarm.cancelForm();
alarm.openNewForm();
assert.equal(parseInt(alarm.volumeInput.val(), 10), 0);
});
// PythonTests: functional/test_clock_add_alarm_multiple_times
// PythonTests: functional/test_clock_create_new_alarm
test('Multiple saved alarms show the proper data', function() {
var i;
var numAlarms = 3;
var createdAlarms = [];
// Create these alarms in descending order (latest first)
for (i = 0; i < numAlarms; i++) {
createdAlarms.push(alarm.create('Alarm ' + i, i + 20));
}
var alarms = alarm.list;
assert.equal(alarms.length, numAlarms);
alarms.reverse();
for (i = 0; i < numAlarms; i++) {
assert.ok(alarms[i].name === 'Alarm ' + i, 'Alarm name matches');
utils.assertStringContainsTime(
alarms[i].timeString, createdAlarms[i].time);
}
});
test('System statusbar shows an icon when an alarm is active', function() {
assert.equal(alarm.statusIcon, false);
alarm.create();
assert.equal(alarm.statusIcon, true);
alarm.toggle(0); // turn it off
assert.equal(alarm.statusIcon, false);
alarm.toggle(0); // turn it on
assert.equal(alarm.statusIcon, true);
alarm.remove(0); // delete the alarm
assert.equal(alarm.statusIcon, false);
assert.equal(alarm.list.length, 0);
});
// PythonTests: functional/test_clock_delete_alarm
test('New alarm form shows "New Alarm" title after deletion', function() {
alarm.create();
alarm.remove(0);
alarm.openNewForm();
assert.ok($('.new-alarm-title').displayed());
});
test('Update alarm name and time', function() {
var originalName = 'coffee break';
var originalTime = alarm.create(originalName).time;
var changedName = 'quitting time';
var changedTime = new Date(originalTime + 1000 * 60);
alarm.openEditForm(0);
assert.equal(alarm.nameField.val(), originalName);
utils.assertStringContainsTime(alarm.timeField.val(), originalTime);
alarm.nameField.val(changedName);
alarm.timeField.val(new Date(changedTime));
alarm.saveForm();
$.client.waitFor(function() {
return alarm.list[0].name === changedName;
}.bind(this));
utils.assertStringContainsTime(alarm.list[0].timeString, changedTime);
// Assert that the banner has been displayed.
alarm.waitForBanner();
});
// PythonTests: functional/test_clock_create_new_alarm
test('Toggle alarm state', function() {
alarm.create();
alarm.toggle(0);
assert(!alarm.list[0].enabled, 'Alarm is disabled after toggling');
assert(!$('#banner-countdown').displayed(),
'Banner is not displayed after disabling an alarm');
alarm.toggle(0);
assert(alarm.list[0].enabled, 'Alarm is re-enabled after toggling');
});
test('After snoozing, alarm should be canceled.', function() {
// Create an alarm and make it fire.
alarm.create();
alarm.fire(0, new Date(), function() {
// Click the snooze button.
var el = $('#ring-button-snooze');
try {
el.click();
} catch(e) {
// Marionette throws an error because the frame closes while
// handling the click event. This is expected.
}
});
// Fire the snooze alarm.
alarm.fire(0, new Date(), function() {
// Click the "stop" button
var el = $('#ring-button-stop');
try {
el.click();
} catch(e) {
// Marionette throws an error because the frame closes while
// handling the click event. This is expected.
}
}, 'snooze');
// The alarm must disable itself after the snooze fires.
$.client.waitFor(function() {
return !alarm.list[0].enabled;
}.bind(this));
// Make sure we can see the analog clock again.
$('#analog-clock').waitToAppear();
});
test('Fire an alarm', function() {
alarm.create();
alarm.fire(0, new Date(), function() {
// Click the "stop" button
var el = $('#ring-button-stop');
try {
el.click();
} catch(e) {
// Marionette throws an error because the frame closes while
// handling the click event. This is expected.
}
});
// The alarm must disable itself since it is not repeating.
$.client.waitFor(function() {
return !alarm.list[0].enabled;
}.bind(this));
// Make sure we can see the analog clock again.
$('#analog-clock').waitToAppear();
});
});

View file

@ -0,0 +1,22 @@
marionette('Endurance', function() {
'use strict';
var assert = require('assert');
var actions = new (require('./lib/actions'))({
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
setup(function() {
actions.launch('alarm');
});
// PythonTests: endurance/test_endurance_set_alarm
test('Set Alarm Endurance', function() {
var totalIterations = 3;
for (var iteration = 0; iteration < totalIterations; iteration++) {
actions.alarm.create('Iteration ' + iteration, iteration + 10);
}
assert.equal(actions.alarm.list.length, totalIterations);
});
});

View file

@ -0,0 +1,60 @@
marionette('Hour format', function() {
'use strict';
var assert = require('assert');
var $ = require('./lib/mquery');
var actions = new (require('./lib/actions'))({
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
var alarm = actions.alarm;
setup(function() {
actions.launch('alarm');
});
suite('12 hour format', function() {
test('Fire an alarm', function() {
var alarmInfo = alarm.create();
alarm.fire(0, alarmInfo.time, function() {
var expectedDatetime = format12Datetime(alarmInfo.time);
assert.equal($('#ring-clock-time').text(), expectedDatetime);
});
});
});
suite('24 hour format', function() {
setup(function() {
// Set as 24 hour format.
$.client.settings.set('locale.hour12', false);
});
test('Fire an alarm', function() {
var alarmInfo = alarm.create();
alarm.fire(0, alarmInfo.time, function() {
var expectedDatetime = format24Datetime(alarmInfo.time);
assert.equal($('#ring-clock-time').text(), expectedDatetime);
});
});
});
function format12Datetime(date) {
var minutes = date.getMinutes();
minutes = minutes < 10 ? (0 + String(minutes)) : minutes;
var hours = date.getHours();
var amPm = hours >= 12 ? 'PM' : 'AM';
if (hours === 0) {
hours = 12;
} else if (hours > 12) {
hours = hours - 12;
}
return hours + ':' + minutes + ' ' + amPm;
}
function format24Datetime(date) {
var minutes = date.getMinutes();
minutes = minutes < 10 ? (0 + String(minutes)) : minutes;
var hours = date.getHours();
hours = hours < 10 ? '0' + String(hours) :hours;
return hours + ':' + minutes;
}
});

View file

@ -0,0 +1,100 @@
/* global marionette */
'use strict';
var $ = require('./mquery');
var StopwatchActions = require('./stopwatch_actions');
var TimerActions = require('./timer_actions');
var AlarmActions = require('./alarm_actions');
function ClockAppActions(options) {
this._client = marionette.client({
profile: {
prefs: {
// we need to disable the keyboard to avoid intermittent failures on
// Travis (transitions might take longer to run and block UI).
'dom.mozInputMethod.enabled': false,
// NOTE: We used to require 'focusmanager.testmode=true' for
// B2G-Desktop, but Mulet and Device tests do not require it.
}
},
desiredCapabilities: options && options.desiredCapabilities
});
this.stopwatch = new StopwatchActions(this._client);
this.timer = new TimerActions(this._client);
this.alarm = new AlarmActions(this._client, this);
}
module.exports = ClockAppActions;
ClockAppActions.prototype = {
origin: 'app://clock.gaiamobile.org',
launch: function(tab) {
$.client = this._client;
this._client.apps.launch(this.origin);
this._client.apps.switchToApp(this.origin);
this._client.helper.waitForElement('#clock-view .visible');
if (tab && tab !== 'alarm') {
this.openTab(tab);
}
// Wait until everything has loaded.
this._client.waitFor(function() {
return !!this.currentPanelId;
}.bind(this));
},
restart: function(tab) {
this._client.apps.close(this.origin);
this.launch(tab);
},
get currentPanelId() {
var activePanel = $('.panel.active');
return activePanel && activePanel.data('panelId');
},
tapAndTransition: function(selector) {
var previousPanel = this.currentPanelId;
if (typeof selector === 'string') {
selector = this._client.findElement(selector);
}
selector.tap();
this._client.switchToShadowRoot();
var self = this;
this._client.waitFor(function() {
var searchTimeout = self._client.searchTimeout;
self._client.setSearchTimeout(0);
var test = this.currentPanelId &&
(previousPanel !== this.currentPanelId) &&
!$('.slide-in-right, .slide-in-left')[0];
self._client.setSearchTimeout(searchTimeout);
return test;
}.bind(this));
},
// Application Navigation
openTab: function(tabName) {
// Tap the button to open the tab
this.tapAndTransition({
alarm: '#alarm-tab',
timer: '#timer-tab',
stopwatch: '#stopwatch-tab'
}[tabName]);
$('[data-panel-id=' + tabName + ']')
.waitToAppear();
},
isButtonUsable: function(btn) {
return $(btn).attr('disabled') === 'false' && $(btn).displayed();
}
};

View file

@ -0,0 +1,216 @@
'use strict';
/* global marionetteScriptFinished */
var utils = require('./utils');
var $ = require('./mquery');
function AlarmActions(client, actions) {
this._client = client;
this.actions = actions;
}
module.exports = AlarmActions;
AlarmActions.prototype = {
openNewForm: function() {
if ($('#banner-countdown').displayed()) {
this.waitForBanner();
}
this.actions.tapAndTransition('#alarm-new');
},
openEditForm: function(idx) {
if ($('#banner-countdown').displayed()) {
this.waitForBanner();
}
this.actions.tapAndTransition(
'.alarm-cell:nth-child(' + (idx + 1) + ') .alarm-item');
},
toggle: function(idx) {
var alarmWasEnabled = this.list[idx].enabled;
$('.alarm-cell:nth-child(' + (idx + 1) + ') .alarmEnable span')
.tap();
this._client.waitFor(function() {
return this.list[idx].enabled !== alarmWasEnabled;
}.bind(this));
if (!alarmWasEnabled) {
this.waitForBanner();
}
var numAlarmsEnabled = this.list.filter(function(alarm) {
return alarm.enabled;
}).length;
// If the number of alarms transitioned from 0 <=> 1, ensure the
// system status bar updates before continuing.
if (numAlarmsEnabled === 1) {
this._client.waitFor(function() {
return this.statusIcon;
}.bind(this));
} else if (numAlarmsEnabled === 0) {
this._client.waitFor(function() {
return !this.statusIcon;
}.bind(this));
}
},
cancelForm: function() {
var header = this._client.findElement('gaia-header');
this._client.switchToShadowRoot(header);
var button = this._client.findElement('.action-button');
this.actions.tapAndTransition(button);
},
delete: function() {
this.actions.tapAndTransition('#alarm-delete');
},
saveForm: function() {
this.actions.tapAndTransition('#alarm-done');
},
get nameField() {
return $('#edit-alarm [name="alarm.label"]');
},
get timeField() {
return $('#time-select');
},
get volumeInput() {
return $('#alarm-volume-input');
},
fire: function(idx, date, attentionHandler, type) {
// Trigger a fake 'alarm' event:
this._client.executeScript(function(idx, date, type) {
// String to Date object.
date = new Date(date);
var id = document.querySelector(
'.alarm-cell:nth-child(' + (idx + 1) + ') .alarm-item').dataset.id;
var alarm = new CustomEvent('test-alarm', {
detail: {
id: parseInt(id, 10),
type: type || 'normal',
date: date
}
});
window.dispatchEvent(alarm);
}, [idx, date, type]);
if (attentionHandler) {
// Switch to the Attention Screen
this._client.switchToFrame();
this._client.switchToFrame($('iframe[name="_blank"]').el);
// Wait for the onring page is ready.
this._client.waitFor(function() {
if (this._client.findElement('html')
.getAttribute('class')
.indexOf('ready') !== -1) {
return true;
}
}.bind(this));
attentionHandler();
// Now switch back to the system, then to the clock.
this._client.switchToFrame();
this._client.apps.switchToApp(this.actions.origin);
}
},
remove: function(idx) {
var count = this.list.length;
this.openEditForm(idx);
this.actions.tapAndTransition('#alarm-delete');
this._client.waitFor(function() {
return this.list.length === count - 1;
}.bind(this));
},
create: function(name, minutesFromNow) {
name = name || 'Alarm';
if (!minutesFromNow) {
minutesFromNow = 20;
}
var originalAlarmCount = this.list.length;
var now = Date.now();
this.openNewForm();
this.nameField.val(name);
this.timeField.val(new Date(now + minutesFromNow));
var time = new Date();
time.setHours.apply(time, this.timeField.val().split(':'));
this.saveForm();
var alarmData = null;
this._client.waitFor(function() {
// Verify that the alarm created is in the list of alarms.
this.list.forEach(function(alarm) {
if (alarm.name === name && alarm.enabled &&
utils.stringContainsTime(alarm.timeString, time)) {
alarmData = alarm;
}
});
return !!alarmData;
}.bind(this));
this._client.waitFor(function() {
return this.list.length === originalAlarmCount + 1;
}.bind(this));
$('[data-panel-id="alarm"]').waitToAppear();
this.waitForBanner();
return {
name: alarmData.name,
time: time,
timeString: alarmData.timeString,
enabled: alarmData.enabled
};
},
get list() {
// For now, do this all in one executeScript() call, because the
// rendering logic for alarms wipes out all elements at once. It
// could cause us to read inconsistent state from the DOM.
return this._client.executeScript(function() {
var domItems = document.querySelectorAll('.alarm-cell');
var alarms = [];
for (var i = 0; i < domItems.length; i++) {
var item = domItems[i];
alarms.push({
name: item.querySelector('.label').textContent,
timeString: item.querySelector('.time').textContent,
enabled: item.querySelector('.input-enable').checked
});
}
return alarms;
});
},
// Status Checks
get statusIcon() {
this._client.setScriptTimeout(2000);
return this._client.executeAsyncScript(function() {
var req = navigator.mozSettings.createLock().get('alarm.enabled');
req.onsuccess = function() {
marionetteScriptFinished(req.result['alarm.enabled']);
};
});
},
waitForBanner: function() {
$('#banner-countdown')
.tap()
.waitToDisappear();
}
};

View file

@ -0,0 +1,265 @@
'use strict';
var Marionette = require('marionette-client');
/**
* mQuery allows you to query and modify the DOM through Marionette,
* in a style resembling jQuery. While jQuery is often heckled for
* being poor for designing large-scale apps, its quick DOM traversal
* and manipulation is hard to beat. For integration tests, which tend
* to require convenient, clear, mostly-read-only access to the DOM
* with fewer architectural concerns, a jQuery-like API fits well.
*
* Typical use is something like this:
*
* var $ = require('mquery');
*
* $.client = marionette.client();
*
* console.log($('.my-element').text());
* console.log($('#textbox').val());
*
* You can also set values in input fields like so:
*
* $('#textbox').val('foo');
*
* This also works with system <select> boxes and other inputs too.
*
* Architecturally, mQuery does something interesting: An item
* returned from mQuery is _both_ an array of elements _and_ a single
* Element at the same time. For instance, you can pass the result of
* $('.foo') directly to Marionette calls that require a
* Marionette.Element; similarly, since mQuery behaves like an array
* as well, you can also use this anywhere you previously used
* Marionette.findElements().
*
* While typical usage assigns $.client to your Marionette.Client
* instance, you can instead pass { client: myClient } as an option to
* mQuery if you'd prefer not to globally scope out your mQuery
* instances.
*
* Read the code to see what functions are implemented; many of the
* most commonly-used jQuery features are included. Add more if you
* find them useful. While this initially contains some
* half-implemented methods (e.g. ".css()" can only read values, not
* set them), the eventual goal is to match jQuery's API where it
* makes sense.
*/
var mQuery = function(selectorOrElement, opts) {
var client = (opts && opts.client) || mQuery.client;
var parent = (opts && opts.parent) || null;
if (!client) {
throw new Error(
'You must set mQuery.client to your Marionette client.');
}
var elements;
var selector;
if (typeof selectorOrElement === 'string') {
elements = (parent || mQuery.client).findElements(selectorOrElement);
selector = selectorOrElement;
} else if (Array.isArray(selectorOrElement)) {
elements = selectorOrElement;
} else if (selectorOrElement instanceof ElementSet) {
return selectorOrElement;
} else if (selectorOrElement instanceof Marionette.Element) {
elements = [selectorOrElement];
} else {
throw new Error('mQuery doesn\'t know how to handle ' + selectorOrElement);
}
return new ElementSet(elements, {
client: client,
selector: selector
});
};
module.exports = mQuery;
mQuery.client = null;
/**
* ElementSet acts as a lightweight wrapper over Marionette.Element,
* providing chainable, functional interface somewhat like jQuery.
*/
function ElementSet(elements, opts) {
this.client = opts.client;
this.selector = opts.selector;
this.el = elements[0] || null;
this.id = this.el && this.el.id; // Act like Marionette if we can.
for (var i = 0; i < elements.length; i++) {
this.push(elements[i]);
}
}
// Behave like an Array of Marionette.Element instances.
ElementSet.prototype = Object.create(Array.prototype);
// Behave like a single instance of Marionette.Element too.
Object.keys(Marionette.Element.prototype).forEach(function(funcName) {
var elementFn = Marionette.Element.prototype[funcName];
ElementSet.prototype[funcName] = function() {
return elementFn.apply(this.el, arguments);
};
});
// Behave like jQuery. (These are only getters, not setters though.)
ElementSet.prototype.attr = ElementSet.prototype.getAttribute;
ElementSet.prototype.css = ElementSet.prototype.cssProperty;
ElementSet.prototype.toString = function() {
return '<ElementSet: ' + this.length + '>';
};
// For the purposes of executeScript, behave like Marionette.Element.
ElementSet.prototype.toJSON = function() {
return { ELEMENT: this.id };
};
// Chainable helper methods.
ElementSet.prototype.waitToAppear = function() {
this.client.helper.waitForElement(this.el);
return this;
};
ElementSet.prototype.waitToDisappear = function() {
this.client.helper.waitForElementToDisappear(this.el);
return this;
};
function makeChainable(proto, funcName) {
var origFn = proto[funcName];
proto[funcName] = function() {
origFn.apply(this, arguments);
return this;
};
}
makeChainable(ElementSet.prototype, 'clear');
makeChainable(ElementSet.prototype, 'sendKeys');
ElementSet.prototype.tap = function() {
this.waitToAppear();
this.el.scriptWith(function(el) {
el.scrollIntoView(false);
});
this.el.tap();
return this;
};
ElementSet.prototype.find = function(selector) {
if (!this.el) {
throw new Error(
'Cannot call mQuery.find() when mQuery did not match any elements.');
}
return mQuery(selector, {
client: this.client,
parent: this.el
});
};
ElementSet.prototype.eq = function(idx) {
return mQuery(this[idx], {
client: this.client
});
};
ElementSet.prototype.height = function() {
if (!this.el) {
return null;
}
return this.el.size().height;
};
ElementSet.prototype.width = function() {
if (!this.el) {
return null;
}
return this.el.size().width;
};
ElementSet.prototype.parent = function() {
if (!this.el) {
return null;
}
return mQuery(this.client.executeScript(function(el) {
return el.parentElement;
}, [this.el]), {
client: this.client
});
};
ElementSet.prototype.data = function(attr, value) {
if (!this.el) {
return null;
}
if (arguments.length === 1) {
return this.client.executeScript(function(el, attr) {
return el.dataset[attr];
}, [this.el, attr]);
} else if (arguments.length === 2) {
return this.client.executeScript(function(el, attr, value) {
el.dataset[attr] = value;
}, [this.el, attr, value]);
} else {
return this.client.executeScript(function(el) {
return el.dataset;
}, [this.el]);
}
};
ElementSet.prototype.val = function(value) {
if (!this.el) {
return null;
}
var el = this.el;
if (!arguments.length) {
return el.getAttribute('value');
} else {
if (el.tagName().toLowerCase() === 'select') {
// Save the origin so that we know how to switch back from the
// system frame to the application frame.
var origin = this.client.executeScript(function() {
return window.location.origin;
});
this.client.executeScript(function(el) {
window.focus(); // Without this line, sometimes el.focus() does nothing.
el.focus();
}, [el]);
this.client.switchToFrame(); // Switch to the system frame.
var options = this.client.findElements(
'.value-selector-select-option-popup li');
var values = (!Array.isArray(value) ? [value] : value);
options.forEach(function(option) {
var itemText = option.findElement('label span').text();
var shouldBeSelected = (values.indexOf(itemText) !== -1);
var isSelected = (option.getAttribute('aria-selected') === 'true');
if (shouldBeSelected !== isSelected) {
option.scriptWith(function(el) {
el.scrollIntoView(false);
});
option.tap();
}
});
this.client.helper.waitForElement('.value-option-confirm').tap();
this.client.apps.switchToApp(origin);
} else {
this.client.forms.fill(el, value);
}
el.scriptWith(function(el) {
el.dispatchEvent(new CustomEvent('change'));
});
return this;
}
};

View file

@ -0,0 +1,40 @@
'use strict';
/* global KeyEvent */
var $ = require('./mquery');
exports.getSpinnerValue = function(el) {
el = $(el);
var offsetTop = /translateY\(-?(\d+)/.exec(el.attr('style'))[1];
var pickerItems = el.find('.picker-unit');
var itemHeight = pickerItems[0].size().height;
var index = Math.floor(parseInt(offsetTop, 10) / itemHeight);
return parseInt(pickerItems[index].text(), 10);
};
exports.setSpinnerValue = function(el, value) {
el = $(el);
var currentValue = exports.getSpinnerValue(el);
var targetValue = parseInt(value, 10);
var delta = targetValue - currentValue;
el.parent().scriptWith(function(el, delta) {
var count = Math.abs(delta);
for (var i = 0; i < count; i++) {
var evt = new CustomEvent('keypress');
evt.keyCode = delta > 0 ? KeyEvent.DOM_VK_DOWN : KeyEvent.DOM_VK_UP;
el.dispatchEvent(evt);
}
}, [delta]);
exports.waitForSpinStop(el);
};
exports.waitForSpinStop = function(el) {
var currentY, lastY;
$.client.waitFor(function() {
lastY = currentY;
currentY = el.location().y;
return currentY === lastY;
}, { interval: 200 });
};

View file

@ -0,0 +1,71 @@
'use strict';
var utils = require('./utils');
var $ = require('./mquery');
function StopwatchActions(client) {
this._client = client;
}
module.exports = StopwatchActions;
StopwatchActions.prototype = {
get duration() {
return utils.extractDuration($('.stopwatch-time').text());
},
get laps() {
return this._client.executeScript(function() {
var domItems = document.querySelectorAll('.lap-cell');
var laps = [];
for (var i = 0; i < domItems.length; i++) {
var item = domItems[i];
laps.push({
name: item.querySelector('.lap-name').textContent,
durationString: item.querySelector('.lap-duration').textContent
});
}
return laps;
}).map(function(lap) {
lap.duration = utils.extractDuration(lap.durationString);
return lap;
});
},
start: function() {
$('.stopwatch-start').tap();
this.advanceTime();
return this;
},
lap: function() {
$('.stopwatch-lap').tap();
this.advanceTime();
return this;
},
pause: function() {
$('.stopwatch-pause').tap();
return this;
},
reset: function() {
$('.stopwatch-reset').tap();
return this;
},
setMaxLaps: function(num) {
$('[data-panel-id="stopwatch"]').data('maxLaps', num);
return this;
},
advanceTime: function(milliseconds) {
milliseconds = milliseconds || 1;
var expectedDuration = this.duration + milliseconds;
this._client.waitFor(function() {
return this.duration >= expectedDuration;
}.bind(this));
return this;
}
};

View file

@ -0,0 +1,57 @@
'use strict';
var utils = require('./utils');
var $ = require('./mquery');
var pickerUtils = require('./picker');
function TimerActions(client) {
this._client = client;
}
module.exports = TimerActions;
TimerActions.prototype = {
get hours() { return pickerUtils.getSpinnerValue('.picker-hours'); },
get minutes() { return pickerUtils.getSpinnerValue('.picker-minutes'); },
set hours(value) { pickerUtils.setSpinnerValue('.picker-hours', value); },
set minutes(value) { pickerUtils.setSpinnerValue('.picker-minutes', value); },
get countdown() {
return utils.extractDuration($('#timer-time').text());
},
get sound() {
return $('#timer-sound').val();
},
start: function() {
$('#timer-create').tap();
// Wait until the timer has moved from its default state of 0:00
this._client.waitFor(function() {
return this.countdown > 0;
}.bind(this));
return this;
},
pause: function() {
$('#timer-pause').tap();
return this;
},
resume: function() {
$('#timer-start').tap();
return this;
},
cancel: function() {
$('#timer-cancel').tap();
$('#timer-create').waitToAppear();
return this;
},
advanceTime: function(milliseconds) {
return this._client.helper.wait(milliseconds);
}
};

View file

@ -0,0 +1,85 @@
'use strict';
var assert = require('assert');
/**
* Extract a duration from any point in the given string. See the
* inline assertions below for expected usage.
*
* @return Duration found in milliseconds, or null if none found.
*/
exports.extractDuration = function(str) {
var match = /(?:(\d+):)?(\d+):(\d+)(?:[:.](\d)?(\d)?(\d)?)?/i.exec(str);
if (!match) { return null; }
var factors = [60 * 60 * 1000, 60 * 1000, 1000, 100, 10, 1];
return match.slice(1).reduce(function(sum, value, i) {
return sum + ((value || 0) * factors[i]);
}, 0);
};
assert.equal(exports.extractDuration('It took 0:00:00.000'), 0);
assert.equal(exports.extractDuration('It took 00:00.1'), 100);
assert.equal(exports.extractDuration('It took 1:00:00.010'), 3600010);
assert.equal(exports.extractDuration('It took 1:05:10.050'), 3910050);
assert.equal(exports.extractDuration('It took 30:22:22.55'), 109342550);
/**
* Extract a time from any point in the given string. See the inline
* assertions below for expected usage.
*
* @return An object with { hours: N, minutes: N } in 24-hour format.
*/
function extractTime(str) {
var match = /\b([0-9]+):([0-9]+)\s*(am|pm|a\.m\.|p\.m\.)?/i.exec(str);
var hr = parseInt(match[1], 10);
var min = parseInt(match[2], 10);
var hasAmPm = !!match[3];
if (hasAmPm && /p/i.test(match[3])) {
if (hr < 12) {
hr += 12;
}
} else {
if (hr === 12) {
hr = 0;
}
}
return { hours: hr, minutes: min, hasAmPm: hasAmPm };
}
exports.assertStringContainsTime = function(str, date) {
var time = extractTime(str);
if (time.hasAmPm) {
assert.equal(time.hours, date.getHours());
} else {
assert.equal(time.hours % 12, date.getHours() % 12);
}
assert.equal(time.minutes, date.getMinutes());
};
exports.stringContainsTime = function(str, date) {
try {
exports.assertStringContainsTime(str, date);
return true;
} catch (e) {
return false;
}
};
exports.stringToDate = function(str) {
var time = extractTime(str);
var date = new Date();
date.setHours(time.hours);
date.setMinutes(time.minutes);
return date;
};
assert.deepEqual(extractTime('It is 12:30 PM.'),
{ hours: 12, minutes: 30, hasAmPm: true });
assert.deepEqual(extractTime('It is 12:30 AM.'),
{ hours: 0, minutes: 30, hasAmPm: true });
assert.deepEqual(extractTime('It is 15:22.'),
{ hours: 15, minutes: 22, hasAmPm: false });
assert.deepEqual(extractTime('It is 0:00.'),
{ hours: 0, minutes: 0, hasAmPm: false });
assert.deepEqual(extractTime('It is 6:00 p.m..'),
{ hours: 18, minutes: 0, hasAmPm: true });

View file

@ -0,0 +1,81 @@
marionette('Stopwatch', function() {
'use strict';
var assert = require('assert');
var actions = new (require('./lib/actions'))();
var stopwatch = actions.stopwatch;
setup(function() {
actions.launch('stopwatch');
});
test('Stopwatch Resetting', function() {
stopwatch
.start()
.lap()
.lap()
.pause();
assert.equal(actions.stopwatch.laps.length, 3);
// During pause, the reset button should be usable.
assert.ok(actions.isButtonUsable('.stopwatch-reset'));
stopwatch.reset();
// After reset, start should be usable, reset should not.
assert.ok(!actions.isButtonUsable('.stopwatch-reset'));
assert.ok(actions.isButtonUsable('.stopwatch-start'));
// Everything should be gone.
assert.equal(stopwatch.duration, 0);
assert.equal(stopwatch.laps.length, 0);
});
test('Stopwatch Advancement', function() {
assert.equal(stopwatch.duration, 0);
stopwatch.start();
stopwatch.advanceTime(1300);
assert.ok(!actions.isButtonUsable('.stopwatch-start'));
assert.ok(stopwatch.duration > 1200);
});
test('Lap advancement and ordering', function() {
stopwatch
.start()
.lap();
assert.equal(stopwatch.laps.length, 2);
assert.ok(stopwatch.laps[0].duration > 0);
assert.ok(stopwatch.laps[1].duration > 0);
var originalLaps = stopwatch.laps;
stopwatch.advanceTime(1300);
stopwatch.lap();
var newLaps = stopwatch.laps;
// New laps should be inserted first.
assert.ok(newLaps[0].duration < newLaps[1].duration);
// Old laps should preserve their state.
assert.ok(newLaps[1].duration >= originalLaps[0].duration);
assert.equal(newLaps[2].duration, originalLaps[1].duration);
});
test('Max Laps', function() {
var MAX = 5;
stopwatch
.setMaxLaps(MAX)
.start();
for (var i = 0; i < MAX - 1; i++) {
stopwatch.lap();
}
assert.ok(!actions.isButtonUsable('.stopwatch-lap'));
});
});

View file

@ -0,0 +1,70 @@
marionette('Timer', function() {
'use strict';
var assert = require('assert');
var actions = new (require('./lib/actions'))();
var timer = actions.timer;
setup(function() {
actions.launch('timer');
});
test('Basic Timer Operation', function() {
var durationMs = (6 * 60 + 40) * 60 * 1000;
timer.hours = 6;
timer.minutes = 40;
timer.start();
timer.advanceTime(500);
// Make sure the default sound is properly selected.
assert.equal(timer.sound, 'ac_awake.opus');
assert.ok(timer.countdown > durationMs - 5000);
assert.ok(timer.countdown <= durationMs);
// Switch away from the timer tab and back.
// The timer should still show an appropriate countdown.
actions.openTab('alarm');
actions.openTab('timer');
assert.ok(timer.countdown > durationMs - 10000);
assert.ok(timer.countdown <= durationMs);
});
test('Timer resume after app termination', function() {
var durationMs = 60 * 60 * 1000;
timer.hours = 1;
timer.minutes = 0;
timer.start();
timer.advanceTime(500);
actions.restart('timer');
assert.ok(timer.countdown < durationMs);
});
test('Timer Pausing and Restarting', function() {
timer.hours = 0;
timer.minutes = 20;
timer.start();
timer.pause();
var pausedValue = timer.countdown;
timer.advanceTime(1200);
// The timer should not advance while paused.
assert.equal(timer.countdown, pausedValue);
timer.resume();
timer.advanceTime(1200);
// The timer should advance when unpaused.
assert.ok(timer.countdown < pausedValue);
});
});