(core) Moving client and common tests to core

Summary:
- Moved /test/client and /test/common to core.
- Moved two files (CircularArray and RecentItems) from app/common to core/app/common.
- Moved resetOrg test to gen-server.
- `testrun.sh` is now invoking common and client test from core.
- Added missing packages to core's package.json (and revealed underscore as it is used in the main app).
- Removed Coord.js as it is not used anywhere.

Test Plan: Existing tests

Reviewers: paulfitz

Reviewed By: paulfitz

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D3590
pull/19/head
Jarosław Sadziński 2 years ago
parent e06f0bc1d8
commit a52d56f613

1
.gitignore vendored

@ -50,6 +50,7 @@ build/Release
# Dependency directories
/node_modules/
jspm_packages/
/docs
# Optional npm cache directory
.npm

@ -0,0 +1,44 @@
/**
* Array-like data structure that lets you push elements to it, but holds only the last N of them.
*/
function CircularArray(maxLength) {
this.maxLength = maxLength;
this._data = [];
this._offset = 0;
}
/**
* @property {Number} - the number of items in the CircularArray.
*/
Object.defineProperty(CircularArray.prototype, "length", {
get: function() { return this._data.length; }
});
/**
* @param {Number} index - An index to fetch, between 0 and length - 1.
* @returns {Object} The item at the given index.
*/
CircularArray.prototype.get = function(index) {
return this._data[(this._offset + index) % this.maxLength];
};
/**
* @param {Object} item - An item to push onto the end of the CircularArray.
*/
CircularArray.prototype.push = function(item) {
if (this._data.length < this.maxLength) {
this._data.push(item);
} else {
this._data[this._offset] = item;
this._offset = (this._offset + 1) % this.maxLength;
}
};
/**
* Returns the entire content of CircularArray as a plain array.
*/
CircularArray.prototype.getArray = function() {
return this._data.slice(this._offset).concat(this._data.slice(0, this._offset));
};
module.exports = CircularArray;

@ -0,0 +1,50 @@
/**
* RecentItems maintains a list of maxCount most recently added items.
* If an existing item is added, it is moved to the end of the list.
*
* @constructor
* @param {Int} options.maxCount - The maximum number of objects that will be maintained.
* @param {Function} options.keyFunc - Function that returns a key identifying an item;
* If an item is added with an existing key, it replaces the previous item in the list but is
* moved to the end of the list. Defaults to the identity function.
* @param {Array} options.intialItems - A list of items to populate the list on initialization
*/
class RecentItems {
constructor(options) {
this._items = new Map();
this._maxCount = options.maxCount || 0;
this._keyFunc = options.keyFunc || (item => item);
if (options.intialItems) this.addItems(options.intialItems);
}
addItem(item) {
// Map maintains entries in the order of insertion, so by deleting and reinserting an entry,
// we move it to the end of the list.
this._items.delete(this._keyFunc(item));
this._items.set(this._keyFunc(item), item);
// Now that the list is correctly ordered we may need to remove the oldest entry which is
// the first item.
if (this._items.size > this._maxCount && this._maxCount !== 0) {
this._items.delete(this._items.keys().next().value);
}
}
addItems(items) {
items.forEach(item => {
this.addItem(item);
});
}
/**
* Returns a list of the current items in the map. The list is starts with oldest
* added item and ends with the most recently inserted.
*
* @returns {Array} A list of items.
*/
listItems() {
return Array.from(this._items.values());
}
}
module.exports = RecentItems;

@ -12,7 +12,9 @@
"install:python3": "buildtools/prepare_python3.sh",
"build:prod": "buildtools/build.sh",
"start:prod": "sandbox/run.sh",
"test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true NODE_PATH=_build:_build/stubs:_build/ext mocha -g ${GREP_TESTS:-''} _build/test/nbrowser/*.js _build/test/server/**/*.js _build/test/gen-server/**/*.js",
"test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true NODE_PATH=_build:_build/stubs:_build/ext mocha -g ${GREP_TESTS:-''} _build/test/common/*.js _build/test/client/*.js _build/test/nbrowser/*.js _build/test/server/**/*.js _build/test/gen-server/**/*.js",
"test:client": "GRIST_SESSION_COOKIE=grist_test_cookie NODE_PATH=_build:_build/stubs:_build/ext mocha _build/test/client/**/*.js",
"test:common": "GRIST_SESSION_COOKIE=grist_test_cookie NODE_PATH=_build:_build/stubs:_build/ext mocha _build/test/common/**/*.js",
"test:server": "GRIST_SESSION_COOKIE=grist_test_cookie NODE_PATH=_build:_build/stubs:_build/ext mocha _build/test/server/**/*.js _build/test/gen-server/**/*.js",
"test:smoke": "NODE_PATH=_build:_build/stubs:_build/ext mocha _build/test/nbrowser/Smoke.js",
"test:docker": "./test/test_under_docker.sh",
@ -41,6 +43,7 @@
"@types/fs-extra": "5.0.4",
"@types/image-size": "0.0.29",
"@types/js-yaml": "3.11.2",
"@types/jsdom": "16.2.14",
"@types/jsesc": "3.0.1",
"@types/jsonwebtoken": "7.2.8",
"@types/lodash": "4.14.117",
@ -66,7 +69,9 @@
"catw": "1.0.1",
"chai": "4.2.0",
"chai-as-promised": "7.1.1",
"chance": "1.0.16",
"esbuild-loader": "2.19.0",
"jsdom": "13.0.0",
"mocha": "5.2.0",
"mocha-webdriver": "0.2.9",
"moment-locales-webpack-plugin": "^1.2.0",
@ -147,6 +152,7 @@
"tmp": "0.0.33",
"ts-interface-checker": "1.0.2",
"typeorm": "0.2.18",
"underscore": "1.9.1",
"uuid": "3.3.2",
"winston": "2.4.5",
"ws": "6.1.0"

@ -0,0 +1,128 @@
var assert = require('chai').assert;
var Promise = require('bluebird');
var browserGlobals = require('app/client/lib/browserGlobals');
/**
* Set up browserGlobals to jsdom-mocked DOM globals and an empty document. Call this within test
* suites to set the temporary browserGlobals before the suite runs, and restore them afterwards.
*
* Note that his does nothing when running under the browser (i.e. native globals will be used).
* For one, jsdom doesn't work (at least not right away); more importantly, we want to be able to
* test actual browser behavior.
*/
function setTmpMochaGlobals() {
if (typeof window !== 'undefined') {
return;
}
/* global before, after */
const {JSDOM} = require('jsdom');
var prevGlobals;
before(function() {
const dom = new JSDOM("<!doctype html><html></html>");
// Include JQuery ($) as an available global. Surprising, but it works.
const jquery = require('jquery');
dom.window.$ = jquery(dom.window);
prevGlobals = browserGlobals.setGlobals(dom.window);
});
after(function() {
browserGlobals.setGlobals(prevGlobals);
});
}
exports.setTmpMochaGlobals = setTmpMochaGlobals;
/**
* Queries `el` for `selector` and resolves when `count` of found element is reached.
*
* @param {Element} el - DOM element to query
* @param {string} selector - Selector to find
* @param {number=} count - Optional count is the minimum number of elements to wait for. Defaults
* to 1.
* @returns {Promise} - NodeList of found elements whose `length` is at least `count`.
*/
function waitForSelectorAll(el, selector, count) {
assert(el.querySelectorAll, 'Must provide a DOMElement or HTMLElement');
count = count || 1;
var i;
return new Promise(function(resolve, reject) {
i = setInterval(function() {
var q = el.querySelectorAll(selector);
if (q.length >= count) {
clearInterval(i);
resolve(q);
}
}, 50);
})
.timeout(1000)
.catch(function(err) {
clearInterval(i);
throw new Error("couldn't find selector: " + selector);
});
}
exports.waitForSelectorAll = waitForSelectorAll;
/**
* Queries `el` for `selector` and returns when at least one is found.
*
* @param {Element} el - DOM element to query
* @param {string} selector - Selector to find
* @returns {Promise} - Node of found element.
*/
function waitForSelector(el, selector) {
return waitForSelectorAll(el, selector, 1)
.then(function(els) {
return els[0];
});
}
exports.waitForSelector = waitForSelector;
/**
* Queries `el` for `selector` and returns the last element in the NodeList.
*/
function querySelectorLast(el, selector) {
var rows = el.querySelectorAll(selector);
var last_row = rows && rows[rows.length - 1];
return last_row;
}
exports.querySelectorLast = querySelectorLast;
var SERVER_TIMEOUT = 250; // How long to wait for pending requests to resolve
var CLIENT_DELAY = 100; // How long to wait for browser to render the action
function appCommWaiter(app) {
return function(timeout, delay) {
return Promise.resolve(app.comm.waitForActiveRequests())
.timeout(timeout || SERVER_TIMEOUT)
.delay(delay || CLIENT_DELAY);
};
}
exports.appCommWaiter = appCommWaiter;
/*
*
* Takes and observable and returns a promise when the observable changes.
* it then unsubscribes from the observable
* @param {observable} observable - Selector to find
* @returns {Promise} - Node of found element.
*/
function waitForChange(observable, delay) {
var sub;
return new Promise(function(resolve, reject) {
sub = observable.subscribe(function(val) {
console.warn('observable changed: ' + val.toString());
resolve(val);
});
})
.timeout(delay)
.finally(function(){
sub.dispose();
});
}
exports.waitForChange = waitForChange;

@ -0,0 +1,170 @@
/* global describe, beforeEach, afterEach, it */
var assert = require('chai').assert;
var clientUtil = require('../clientUtil');
var dom = require('app/client/lib/dom');
var Layout = require('app/client/components/Layout');
describe('Layout', function() {
clientUtil.setTmpMochaGlobals();
var layout;
var sampleData = {
children: [{
children: [{
children: [{
leaf: 1
}, {
leaf: 2
}]
}, {
children: [{
children: [{
leaf: 3
}, {
leaf: 4
}]
}, {
leaf: 5
}]
}]
}, {
leaf: 6
}]
};
function createLeaf(leafId) {
return dom('div.layout_leaf_test', "#" + leafId);
}
beforeEach(function() {
layout = Layout.Layout.create(sampleData, createLeaf);
});
afterEach(function() {
layout.dispose();
layout = null;
});
function getClasses(node) {
return Array.prototype.slice.call(node.classList, 0).sort();
}
it("should generate same layout spec as it was built with", function() {
assert.deepEqual(layout.getLayoutSpec(), sampleData);
assert.deepEqual(layout.getAllLeafIds().sort(), [1, 2, 3, 4, 5, 6]);
});
it("should generate nested DOM structure", function() {
var rootBox = layout.rootElem.querySelector('.layout_box');
assert(rootBox);
assert.strictEqual(rootBox, layout.rootBox().dom);
assert.deepEqual(getClasses(rootBox), ["layout_box", "layout_last_child",
"layout_vbox"]);
var rows = rootBox.children;
assert.equal(rows.length, 2);
assert.equal(rows[0].children.length, 2);
assert.deepEqual(getClasses(rows[0]), ["layout_box", "layout_hbox"]);
assert.deepEqual(getClasses(rows[0].children[0]), ["layout_box", "layout_vbox"]);
assert.deepEqual(getClasses(rows[0].children[1]), ["layout_box", "layout_last_child",
"layout_vbox"]);
assert.equal(rows[1].children.length, 1);
assert.includeMembers(getClasses(rows[1]), ["layout_box", "layout_hbox",
"layout_last_child", "layout_leaf"]);
});
it("should correctly handle removing boxes", function() {
layout.getLeafBox(4).removeFromParent();
layout.getLeafBox(1).removeFromParent();
assert.deepEqual(layout.getAllLeafIds().sort(), [2, 3, 5, 6]);
assert.deepEqual(layout.getLayoutSpec(), {
children: [{
children: [{
leaf: 2
}, {
children: [{
leaf: 3
}, {
leaf: 5
}]
}]
}, {
leaf: 6
}]
});
// Here we get into a rare situation with a single child (to allow root box to be split
// vertically).
layout.getLeafBox(6).removeFromParent();
assert.deepEqual(layout.getLayoutSpec(), {
children: [{
children: [{
leaf: 2
}, {
children: [{
leaf: 3
}, {
leaf: 5
}]
}]
}]
});
assert.deepEqual(layout.getAllLeafIds().sort(), [2, 3, 5]);
// Here the special single-child box should collapse
layout.getLeafBox(2).removeFromParent();
assert.deepEqual(layout.getLayoutSpec(), {
children: [{
leaf: 3
}, {
leaf: 5
}]
});
layout.getLeafBox(3).removeFromParent();
assert.deepEqual(layout.getLayoutSpec(), {
leaf: 5
});
assert.deepEqual(layout.getAllLeafIds().sort(), [5]);
});
it("should correctly handle adding child and sibling boxes", function() {
// In this test, we'll build up the sample layout from scratch, trying to exercise all code
// paths.
layout = Layout.Layout.create({ leaf: 1 }, createLeaf);
assert.deepEqual(layout.getLayoutSpec(), { leaf: 1 });
assert.deepEqual(layout.getAllLeafIds().sort(), [1]);
function makeBox(leafId) {
return layout.buildLayoutBox({leaf: leafId});
}
assert.strictEqual(layout.rootBox(), layout.getLeafBox(1));
layout.getLeafBox(1).addSibling(makeBox(5), true);
assert.deepEqual(layout.getLayoutSpec(), {children: [{
children: [{ leaf: 1 }, { leaf: 5 }]
}]});
assert.notStrictEqual(layout.rootBox(), layout.getLeafBox(1));
// An extra little check to add a sibling to a vertically-split root (in which case the split
// is really a level lower, and that's where the sibling should be added).
layout.rootBox().addSibling(makeBox("foo"), true);
assert.deepEqual(layout.getLayoutSpec(), {children: [{
children: [{ leaf: 1 }, { leaf: 5 }, { leaf: "foo" }]
}]});
assert.deepEqual(layout.getAllLeafIds().sort(), [1, 5, "foo"]);
layout.getLeafBox("foo").dispose();
assert.deepEqual(layout.getAllLeafIds().sort(), [1, 5]);
layout.getLeafBox(1).parentBox().addSibling(makeBox(6), true);
layout.getLeafBox(5).addChild(makeBox(3), false);
layout.getLeafBox(3).addChild(makeBox(4), true);
layout.getLeafBox(1).addChild(makeBox(2), true);
assert.deepEqual(layout.getLayoutSpec(), sampleData);
assert.deepEqual(layout.getAllLeafIds().sort(), [1, 2, 3, 4, 5, 6]);
});
});

@ -0,0 +1,63 @@
import {assert} from 'chai';
import {MethodAccess} from 'app/client/components/WidgetFrame';
import {AccessLevel} from 'app/common/CustomWidget';
describe('WidgetFrame', function () {
it('should define access level per method', function () {
class SampleApi {
public none() {
return true;
}
public read_table() {
return true;
}
public full() {}
public notMentioned() {}
}
const checker = new MethodAccess<SampleApi>()
.require(AccessLevel.none, 'none')
.require(AccessLevel.read_table, 'read_table')
.require(AccessLevel.full, 'full');
const directTest = () => {
assert.isTrue(checker.check(AccessLevel.none, 'none'));
assert.isFalse(checker.check(AccessLevel.none, 'read_table'));
assert.isFalse(checker.check(AccessLevel.none, 'full'));
assert.isTrue(checker.check(AccessLevel.read_table, 'none'));
assert.isTrue(checker.check(AccessLevel.read_table, 'read_table'));
assert.isFalse(checker.check(AccessLevel.read_table, 'full'));
assert.isTrue(checker.check(AccessLevel.full, 'none'));
assert.isTrue(checker.check(AccessLevel.full, 'read_table'));
assert.isTrue(checker.check(AccessLevel.full, 'full'));
};
directTest();
// Check that for any other method, access is denied.
assert.isFalse(checker.check(AccessLevel.none, 'notMentioned'));
assert.isFalse(checker.check(AccessLevel.read_table, 'notMentioned'));
// Even though access is full, the method was not mentioned, so it should be denied.
assert.isFalse(checker.check(AccessLevel.full, 'notMentioned'));
// Now add a default rule.
checker.require(AccessLevel.none, '*');
assert.isTrue(checker.check(AccessLevel.none, 'notMentioned'));
assert.isTrue(checker.check(AccessLevel.read_table, 'notMentioned'));
assert.isTrue(checker.check(AccessLevel.full, 'notMentioned'));
directTest();
checker.require(AccessLevel.read_table, '*');
assert.isFalse(checker.check(AccessLevel.none, 'notMentioned'));
assert.isTrue(checker.check(AccessLevel.read_table, 'notMentioned'));
assert.isTrue(checker.check(AccessLevel.full, 'notMentioned'));
directTest();
checker.require(AccessLevel.full, '*');
assert.isFalse(checker.check(AccessLevel.none, 'notMentioned'));
assert.isFalse(checker.check(AccessLevel.read_table, 'notMentioned'));
assert.isTrue(checker.check(AccessLevel.full, 'notMentioned'));
directTest();
});
});

@ -0,0 +1,218 @@
/* global describe, beforeEach, before, after, it */
var _ = require('underscore');
var sinon = require('sinon');
var assert = require('chai').assert;
var ko = require('knockout');
var Mousetrap = require('app/client/lib/Mousetrap');
var commands = require('app/client/components/commands');
var clientUtil = require('../clientUtil');
describe('commands', function() {
clientUtil.setTmpMochaGlobals();
before(function() {
sinon.stub(Mousetrap, "bind");
sinon.stub(Mousetrap, "unbind");
});
after(function() {
Mousetrap.bind.restore();
Mousetrap.unbind.restore();
});
beforeEach(function() {
commands.init([{
group: "Foo",
commands: [{
name: "cmd1",
keys: ["Ctrl+a", "Ctrl+b"],
desc: "Command 1"
}, {
name: "cmd2",
keys: ["Ctrl+c"],
desc: "Command 2"
}, {
name: "cmd3",
keys: ["Ctrl+a"],
desc: "Command 1B"
}]
}]);
});
describe("activate", function() {
it("should invoke Mousetrap.bind/unbind", function() {
var obj = {};
var spy = sinon.spy();
var cmdGroup = commands.createGroup({ cmd1: spy }, obj, true);
sinon.assert.callCount(Mousetrap.bind, 2);
sinon.assert.calledWith(Mousetrap.bind, "ctrl+a");
sinon.assert.calledWith(Mousetrap.bind, "ctrl+b");
Mousetrap.bind.reset();
Mousetrap.unbind.reset();
commands.allCommands.cmd1.run();
sinon.assert.callCount(spy, 1);
sinon.assert.calledOn(spy, obj);
cmdGroup.activate(false);
sinon.assert.callCount(Mousetrap.bind, 0);
sinon.assert.callCount(Mousetrap.unbind, 2);
sinon.assert.calledWith(Mousetrap.unbind, "ctrl+a");
sinon.assert.calledWith(Mousetrap.unbind, "ctrl+b");
Mousetrap.bind.reset();
Mousetrap.unbind.reset();
commands.allCommands.cmd1.run();
sinon.assert.callCount(spy, 1);
cmdGroup.activate(true);
sinon.assert.callCount(Mousetrap.bind, 2);
sinon.assert.calledWith(Mousetrap.bind, "ctrl+a");
sinon.assert.calledWith(Mousetrap.bind, "ctrl+b");
sinon.assert.callCount(Mousetrap.unbind, 0);
commands.allCommands.cmd1.run();
sinon.assert.callCount(spy, 2);
cmdGroup.dispose();
sinon.assert.callCount(Mousetrap.unbind, 2);
sinon.assert.calledWith(Mousetrap.unbind, "ctrl+a");
sinon.assert.calledWith(Mousetrap.unbind, "ctrl+b");
commands.allCommands.cmd1.run();
sinon.assert.callCount(spy, 2);
});
/**
* For an object of the form { group1: { cmd1: sinon.spy() } }, goes through all spys, and
* returns a mapping of call counts: {'group1:cmd1': spyCallCount}.
*/
function getCallCounts(groups) {
var counts = {};
_.each(groups, function(group, grpName) {
_.each(group, function(cmdSpy, cmdName) {
counts[grpName + ":" + cmdName] = cmdSpy.callCount;
});
});
return counts;
}
/**
* Diffs two sets of call counts as produced by getCallCounts and returns the difference.
*/
function diffCallCounts(callCounts1, callCounts2) {
return _.chain(callCounts2).mapObject(function(count, name) {
return count - callCounts1[name];
})
.pick(function(count, name) {
return count > 0;
})
.value();
}
/**
* Invokes the given command, and makes sure the difference of call counts before and after is
* as expected.
*/
function assertCallCounts(groups, cmdOrFunc, expectedCounts) {
var before = getCallCounts(groups);
if (typeof cmdOrFunc === 'string') {
commands.allCommands[cmdOrFunc].run();
} else if (cmdOrFunc === null) {
// nothing
} else {
cmdOrFunc();
}
var after = getCallCounts(groups);
assert.deepEqual(diffCallCounts(before, after), expectedCounts);
}
it("should respect order of CommandGroups", function() {
var groups = {
group1: { cmd1: sinon.spy(), cmd3: sinon.spy() },
group2: { cmd1: sinon.spy(), cmd2: sinon.spy() },
group3: { cmd3: sinon.spy() },
};
var cmdGroup1 = commands.createGroup(groups.group1, null, true);
var cmdGroup2 = commands.createGroup(groups.group2, null, true);
var cmdGroup3 = commands.createGroup(groups.group3, null, false);
assertCallCounts(groups, 'cmd1', {'group2:cmd1': 1});
assertCallCounts(groups, 'cmd2', {'group2:cmd2': 1});
assertCallCounts(groups, 'cmd3', {'group1:cmd3': 1});
cmdGroup2.activate(false);
assertCallCounts(groups, 'cmd1', {'group1:cmd1': 1});
assertCallCounts(groups, 'cmd2', {});
assertCallCounts(groups, 'cmd3', {'group1:cmd3': 1});
cmdGroup3.activate(true);
cmdGroup1.activate(false);
assertCallCounts(groups, 'cmd1', {});
assertCallCounts(groups, 'cmd2', {});
assertCallCounts(groups, 'cmd3', {'group3:cmd3': 1});
cmdGroup2.activate(true);
assertCallCounts(groups, 'cmd1', {'group2:cmd1': 1});
assertCallCounts(groups, 'cmd2', {'group2:cmd2': 1});
assertCallCounts(groups, 'cmd3', {'group3:cmd3': 1});
});
it("should allow use of observable for activation flag", function() {
var groups = {
groupFoo: { cmd1: sinon.spy() },
};
var isActive = ko.observable(false);
commands.createGroup(groups.groupFoo, null, isActive);
assertCallCounts(groups, 'cmd1', {});
isActive(true);
assertCallCounts(groups, 'cmd1', {'groupFoo:cmd1': 1});
// Check that subsequent calls continue working.
assertCallCounts(groups, 'cmd1', {'groupFoo:cmd1': 1});
isActive(false);
assertCallCounts(groups, 'cmd1', {});
});
function getFuncForShortcut(shortcut) {
function argsIncludeShortcut(args) {
return Array.isArray(args[0]) ? _.contains(args[0], shortcut) : (args[0] === shortcut);
}
var b = _.findLastIndex(Mousetrap.bind.args, argsIncludeShortcut);
var u = _.findLastIndex(Mousetrap.unbind.args, argsIncludeShortcut);
if (b < 0) {
return null;
} else if (u < 0) {
return Mousetrap.bind.args[b][1];
} else if (Mousetrap.bind.getCall(b).calledBefore(Mousetrap.unbind.getCall(u))) {
return null;
} else {
return Mousetrap.bind.args[b][1];
}
}
it("should allow same keys used for different commands", function() {
// Both cmd1 and cmd3 use "Ctrl+a" shortcut, so cmd3 should win when group3 is active.
Mousetrap.bind.reset();
Mousetrap.unbind.reset();
var groups = {
group1: { cmd1: sinon.spy() },
group3: { cmd3: sinon.spy() },
};
var cmdGroup1 = commands.createGroup(groups.group1, null, true);
var cmdGroup3 = commands.createGroup(groups.group3, null, true);
assertCallCounts(groups, getFuncForShortcut('ctrl+a'), {'group3:cmd3': 1});
assertCallCounts(groups, getFuncForShortcut('ctrl+b'), {'group1:cmd1': 1});
cmdGroup3.activate(false);
assertCallCounts(groups, getFuncForShortcut('ctrl+a'), {'group1:cmd1': 1});
assertCallCounts(groups, getFuncForShortcut('ctrl+b'), {'group1:cmd1': 1});
cmdGroup1.activate(false);
assertCallCounts(groups, getFuncForShortcut('ctrl+a'), {});
assertCallCounts(groups, getFuncForShortcut('ctrl+b'), {});
cmdGroup3.activate(true);
assertCallCounts(groups, getFuncForShortcut('ctrl+a'), {'group3:cmd3': 1});
assertCallCounts(groups, getFuncForShortcut('ctrl+b'), {});
});
});
});

@ -0,0 +1,80 @@
var dom = require('app/client/lib/dom');
var kd = require('app/client/lib/koDom');
var kf = require('app/client/lib/koForm');
var Layout = require('app/client/components/Layout');
var LayoutEditor = require('app/client/components/LayoutEditor');
function createTestTab() {
return kf.topTab('Layout',
kf.label("Layout Editor")
);
}
exports.createTestTab = createTestTab;
var sampleData = {
children: [{
children: [{
children: [{
leaf: 1
}, {
leaf: 2
}, {
leaf: 7
}, {
leaf: 8
}]
}, {
children: [{
children: [{
leaf: 3
}, {
leaf: 4
}, {
leaf: 9
}, {
leaf: 10
}]
}, {
leaf: 5
}]
}]
}, {
leaf: 6
}]
};
function getMaxLeaf(spec) {
var maxChild = spec.children ? Math.max.apply(Math, spec.children.map(getMaxLeaf)) : -Infinity;
return Math.max(maxChild, spec.leaf || -Infinity);
}
function createLeaf(leafId) {
return dom('div.layout_leaf_test', "#" + leafId,
kd.toggleClass('layout_leaf_test_big', leafId % 2 === 0)
);
}
function createTestPane() {
var layout = Layout.Layout.create(sampleData, createLeaf);
var layoutEditor = LayoutEditor.LayoutEditor.create(layout);
var maxLeaf = getMaxLeaf(sampleData);
return dom('div',
dom.autoDispose(layoutEditor),
dom.autoDispose(layout),
dom('div',
dom('div.layout_new.pull-left', '+ Add New',
dom.on('mousedown', function(event) {
layoutEditor.dragInNewBox(event, ++maxLeaf);
return false;
})
),
dom('div.layout_trash.pull-right',
dom('span.glyphicon.glyphicon-trash')
),
dom('div.clearfix')
),
layout.rootElem
);
}
exports.createTestPane = createTestPane;

@ -0,0 +1,418 @@
import {ACIndex, ACIndexImpl, ACItem, ACResults, highlightNone} from 'app/client/lib/ACIndex';
import {nativeCompare} from 'app/common/gutil';
import {assert} from 'chai';
import * as fse from 'fs-extra';
import * as path from 'path';
import {fixturesRoot} from 'test/server/testUtils';
/**
* Set env ENABLE_TIMING_TESTS=1 to run the timing "tests". These don't assert anything but let
* you compare the performance of different implementations.
*/
const ENABLE_TIMING_TESTS = Boolean(process.env.ENABLE_TIMING_TESTS);
interface TestACItem extends ACItem {
text: string;
}
function makeItem(text: string): TestACItem {
return {text, cleanText: text.trim().toLowerCase()};
}
const colors: TestACItem[] = [
"Blue", "Dark Red", "Reddish", "Red", "Orange", "Yellow", "Radical Deep Green", "Bright Red"
].map(makeItem);
const rounds: TestACItem[] = [
"Round 1", "Round 2", "Round 3", "Round 4"
].map(makeItem);
const messy: TestACItem[] = [
"", " \t", " RED ", "123", "-5.6", "red", "read ", "Bread", "#red", "\nred\n#red\nred", "\n\n", "REDIS/1"
].map(makeItem);
describe('ACIndex', function() {
it('should find items with matching words', function() {
const items: ACItem[] = ["blue", "dark red", "reddish", "red", "orange", "yellow", "radical green"].map(
c => ({cleanText: c}));
const acIndex = new ACIndexImpl(items, 5);
assert.deepEqual(acIndex.search("red").items.map((item) => item.cleanText),
["red", "reddish", "dark red", "radical green", "blue"]);
});
it('should return first few items when search text is empty', function() {
let acResult = new ACIndexImpl(colors).search("");
assert.deepEqual(acResult.items, colors);
assert.deepEqual(acResult.selectIndex, -1);
acResult = new ACIndexImpl(colors, 3).search("");
assert.deepEqual(acResult.items, colors.slice(0, 3));
assert.deepEqual(acResult.selectIndex, -1);
acResult = new ACIndexImpl(rounds).search("");
assert.deepEqual(acResult.items, rounds);
assert.deepEqual(acResult.selectIndex, -1);
});
it('should ignore items with empty text', function() {
const acIndex = new ACIndexImpl(messy);
let acResult = acIndex.search("");
assert.deepEqual(acResult.items, messy.filter(t => t.cleanText));
assert.lengthOf(acResult.items, 9);
assert.deepEqual(acResult.selectIndex, -1);
acResult = acIndex.search("bread");
assert.deepEqual(acResult.items.map(i => i.text),
["Bread", " RED ", "123", "-5.6", "red", "read ", "#red", "\nred\n#red\nred", "REDIS/1"]);
assert.deepEqual(acResult.selectIndex, 0);
});
it('should find items with the most matching words, and order by best match', function() {
const acIndex = new ACIndexImpl(colors);
let acResult: ACResults<TestACItem>;
// Try a few cases with a single word.
acResult = acIndex.search("red");
assert.deepEqual(acResult.items.map(i => i.text),
["Red", "Reddish", "Dark Red", "Bright Red", "Radical Deep Green", "Blue", "Orange", "Yellow"]);
assert.deepEqual(acResult.selectIndex, 0);
acResult = acIndex.search("rex");
// In this case "Reddish" is as good as "Red", so comes first according to original order.
assert.deepEqual(acResult.items.map(i => i.text),
["Reddish", "Red", "Dark Red", "Bright Red", "Radical Deep Green", "Blue", "Orange", "Yellow"]);
assert.deepEqual(acResult.selectIndex, -1); // No great match.
acResult = acIndex.search("REDD");
// In this case "Reddish" is strictly better than "Red".
assert.deepEqual(acResult.items.map(i => i.text),
["Reddish", "Red", "Dark Red", "Bright Red", "Radical Deep Green", "Blue", "Orange", "Yellow"]);
assert.deepEqual(acResult.selectIndex, 0); // It's a good match.
// Try a few cases with multiple words.
acResult = acIndex.search("dark red");
assert.deepEqual(acResult.items.map(i => i.text),
["Dark Red", "Red", "Bright Red", "Reddish", "Radical Deep Green", "Blue", "Orange", "Yellow"]);
assert.deepEqual(acResult.selectIndex, 0);
acResult = acIndex.search("da re");
assert.deepEqual(acResult.items.map(i => i.text),
["Dark Red", "Radical Deep Green", "Reddish", "Red", "Bright Red", "Blue", "Orange", "Yellow"]);
assert.deepEqual(acResult.selectIndex, 0);
acResult = acIndex.search("red d");
assert.deepEqual(acResult.items.map(i => i.text),
["Dark Red", "Red", "Bright Red", "Reddish", "Radical Deep Green", "Blue", "Orange", "Yellow"]);
assert.deepEqual(acResult.selectIndex, -1);
acResult = acIndex.search("EXTRA DARK RED WORDS DON'T HURT");
assert.deepEqual(acResult.items.map(i => i.text),
["Dark Red", "Red", "Bright Red", "Radical Deep Green", "Reddish", "Blue", "Orange", "Yellow"]);
assert.deepEqual(acResult.selectIndex, -1);
// Try a few poor matches.
acResult = acIndex.search("a");
assert.deepEqual(acResult.items, colors);
acResult = acIndex.search("z");
assert.deepEqual(acResult.items, colors);
acResult = acIndex.search("RA");
assert.deepEqual(acResult.items.map(i => i.text),
["Radical Deep Green", "Reddish", "Red", "Dark Red", "Bright Red", "Blue", "Orange", "Yellow"]);
acResult = acIndex.search("RZ");
assert.deepEqual(acResult.items.map(i => i.text),
["Reddish", "Red", "Radical Deep Green", "Dark Red", "Bright Red", "Blue", "Orange", "Yellow"]);
});
it('should maintain order of equally good matches', function() {
const acIndex = new ACIndexImpl(rounds);
let acResult: ACResults<TestACItem>;
// Try a few cases with a single word.
acResult = acIndex.search("r");
assert.deepEqual(acResult.items, rounds);
acResult = acIndex.search("round 1");
assert.deepEqual(acResult.items, rounds);
acResult = acIndex.search("round 3");
// Round 3 is moved to the front; the rest are unchanged.
assert.deepEqual(acResult.items.map(i => i.text), ["Round 3", "Round 1", "Round 2", "Round 4"]);
});
it('should prefer items with words in a similar order to search text', function() {
const acIndex = new ACIndexImpl(colors);
let acResult: ACResults<TestACItem>;
// "r d" and "d r" prefer choices whose words are in the entered order.
acResult = acIndex.search("r d");
assert.deepEqual(acResult.items.slice(0, 2).map(i => i.text), ["Radical Deep Green", "Dark Red"]);
acResult = acIndex.search("d r");
assert.deepEqual(acResult.items.slice(0, 2).map(i => i.text), ["Dark Red", "Radical Deep Green"]);
// But a better match wins.
acResult = acIndex.search("de r");
assert.deepEqual(acResult.items.slice(0, 2).map(i => i.text), ["Radical Deep Green", "Dark Red"]);
});
it('should limit results to maxResults', function() {
const acIndex = new ACIndexImpl(colors, 3);
let acResult: ACResults<TestACItem>;
acResult = acIndex.search("red");
assert.deepEqual(acResult.items.map(i => i.text), ["Red", "Reddish", "Dark Red"]);
assert.deepEqual(acResult.selectIndex, 0);
acResult = acIndex.search("red d");
assert.deepEqual(acResult.items.map(i => i.text), ["Dark Red", "Red", "Bright Red"]);
assert.deepEqual(acResult.selectIndex, -1);
acResult = acIndex.search("g");
assert.deepEqual(acResult.items.map(i => i.text), ["Radical Deep Green", "Blue", "Dark Red"]);
assert.deepEqual(acResult.selectIndex, 0);
});
it('should split words on punctuation', function() {
// Same as `colors` but with extra punctuation
const punctColors: TestACItem[] = [
"$Blue$", "--Dark@#$%^&Red--", "(Reddish)", "]Red{", "**Orange", "-Yellow?!",
"_Radical ``Deep'' !!Green!!", "<Bright>=\"Red\""
].map(makeItem);
const acIndex = new ACIndexImpl(punctColors);
let acResult: ACResults<TestACItem>;
// Try a few cases with a single word.
acResult = acIndex.search("~red-");
assert.deepEqual(acResult.items.map(i => i.text), [
"]Red{", "--Dark@#$%^&Red--", "<Bright>=\"Red\"", "(Reddish)", "_Radical ``Deep'' !!Green!!",
"$Blue$", "**Orange", "-Yellow?!"]);
assert.deepEqual(acResult.selectIndex, 0);
acResult = acIndex.search("rex");
// In this case "Reddish" is as good as "Red", so comes first according to original order.
assert.deepEqual(acResult.items.map(i => i.text), [
"(Reddish)", "]Red{", "--Dark@#$%^&Red--", "<Bright>=\"Red\"", "_Radical ``Deep'' !!Green!!",
"$Blue$", "**Orange", "-Yellow?!"]);
assert.deepEqual(acResult.selectIndex, -1); // No great match.
acResult = acIndex.search("da-re");
assert.deepEqual(acResult.items.map(i => i.text), [
"--Dark@#$%^&Red--", "_Radical ``Deep'' !!Green!!", "(Reddish)", "]Red{", "<Bright>=\"Red\"",
"$Blue$", "**Orange", "-Yellow?!",
]);
assert.deepEqual(acResult.selectIndex, 0);
// Try a few poor matches.
acResult = acIndex.search("a");
assert.deepEqual(acResult.items, punctColors);
acResult = acIndex.search("z");
assert.deepEqual(acResult.items, punctColors);
});
it('should return an item to select when the match is good', function() {
const acIndex = new ACIndexImpl(rounds);
let acResult: ACResults<TestACItem>;
// Try a few cases with a single word.
acResult = acIndex.search("r");
assert.equal(acResult.selectIndex, 0);
assert.equal(acResult.items[0].text, "Round 1");
acResult = acIndex.search("round 2");
assert.equal(acResult.selectIndex, 0);
assert.equal(acResult.items[0].text, "Round 2");
acResult = acIndex.search("round X");
assert.equal(acResult.selectIndex, -1);
// We only suggest a selection when an item (or one of its words) starts with the search text.
acResult = acIndex.search("1");
assert.equal(acResult.selectIndex, 0);
const acIndex2 = new ACIndexImpl(messy);
acResult = acIndex2.search("#r");
assert.equal(acResult.selectIndex, 0);
assert.equal(acResult.items[0].text, "#red");
// Whitespace and case don't matter.
acResult = acIndex2.search("Red");
assert.equal(acResult.selectIndex, 0);
assert.equal(acResult.items[0].text, " RED ");
});
it('should return a useful highlight function', function() {
const acIndex = new ACIndexImpl(colors, 3);
let acResult: ACResults<TestACItem>;
// Here we split the items' (uncleaned) text with the returned highlightFunc. The values at
// odd-numbered indices should be the matching parts.
acResult = acIndex.search("red");
assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),
[["", "Red", ""], ["", "Red", "dish"], ["Dark ", "Red", ""]]);
// Partial matches are highlighted too.
acResult = acIndex.search("darn");
assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),
[["", "Dar", "k Red"], ["Radical ", "D", "eep Green"], ["Blue"]]);
// Empty search highlights nothing.
acResult = acIndex.search("");
assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),
[["Blue"], ["Dark Red"], ["Reddish"]]);
// Try some messier cases.
const acIndex2 = new ACIndexImpl(messy, 6);
acResult = acIndex2.search("#r");
assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),
[["#", "r", "ed"], [" ", "R", "ED "], ["", "r", "ed"], ["", "r", "ead "],
["\n", "r", "ed\n#", "r", "ed\n", "r", "ed"], ["", "R", "EDIS/1"]]);
acResult = acIndex2.search("read");
assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)), [
["", "read", " "], [" ", "RE", "D "], ["", "re", "d"], ["#", "re", "d"],
["\n", "re", "d\n#", "re", "d\n", "re", "d"], ["", "RE", "DIS/1"]]);
});
it('should highlight multi-byte unicode', function() {
const acIndex = new ACIndexImpl(['Lorem ipsum 𝌆 dolor sit ameͨ͆t.', "mañana", "Москва"].map(makeItem), 3);
const acResult: ACResults<TestACItem> = acIndex.search("mañ моск am");
assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),
[["", "Моск", "ва"], ["", "mañ", "ana"], ["Lorem ipsum 𝌆 dolor sit ", "am", "eͨ͆t."]]);
});
it('should match a brute-force scoring implementation', function() {
const acIndex1 = new ACIndexImpl(colors);
const acIndex2 = new BruteForceACIndexImpl(colors);
for (const text of ["RED", "blue", "a", "Z", "rea", "RZ", "da re", "re da", ""]) {
assert.deepEqual(acIndex1.search(text).items, acIndex2.search(text).items,
`different results for "${text}"`);
}
});
// See ENABLE_TIMING_TESTS flag on top of this file.
if (ENABLE_TIMING_TESTS) {
// Returns a list of many items, for checking performance.
async function getCities(): Promise<TestACItem[]> {
// Pick a file we have with 4k+ rows. First two columns are city,country.
// To create more items, we'll return "city N, country" combinations for N in [0, 25).
const filePath = path.resolve(fixturesRoot, 'export-csv/many-rows.csv');
const data = await fse.readFile(filePath, {encoding: 'utf8'});
const result: TestACItem[] = [];
for (const line of data.split("\n")) {
const [city, country] = line.split(",");
for (let i = 0; i < 25; i++) {
result.push(makeItem(`${city} ${i}, ${country}`));
}
}
return result;
}
// Repeat `func()` call `count` times, returning [msec per call, last return value].
function repeat<T>(count: number, func: () => T): [number, T] {
const start = Date.now();
let ret: T;
for (let i = 0; i < count; i++) {
ret = func();
}
const msecTaken = Date.now() - start;
return [msecTaken / count, ret!];
}
describe("timing", function() {
this.timeout(20000);
let items: TestACItem[];
before(async function() {
items = await getCities();
});
// tslint:disable:no-console
it('main algorithm', function() {
const [buildTime, acIndex] = repeat(10, () => new ACIndexImpl(items, 100));
console.log(`Time to build index (${items.length} items): ${buildTime} ms`);
const [searchTime, result] = repeat(10, () => acIndex.search("YORK"));
console.log(`Time to search index (${items.length} items): ${searchTime} ms`);
assert.equal(result.items[0].text, "York 0, United Kingdom");
assert.equal(result.items[75].text, "New York 0, United States");
});
it('brute-force algorithm', function() {
const [buildTime, acIndex] = repeat(10, () => new BruteForceACIndexImpl(items, 100));
console.log(`Time to build index (${items.length} items): ${buildTime} ms`);
const [searchTime, result] = repeat(10, () => acIndex.search("YORK"));
console.log(`Time to search index (${items.length} items): ${searchTime} ms`);
assert.equal(result.items[0].text, "York 0, United Kingdom");
assert.equal(result.items[75].text, "New York 0, United States");
});
});
}
});
// This is a brute force implementation of the same score-based search. It makes scoring logic
// easier to understand.
class BruteForceACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
constructor(private _allItems: Item[], private _maxResults: number = 50) {}
public search(searchText: string): ACResults<Item> {
const cleanedSearchText = searchText.trim().toLowerCase();
if (!cleanedSearchText) {
return {items: this._allItems.slice(0, this._maxResults), highlightFunc: highlightNone, selectIndex: -1};
}
const searchWords = cleanedSearchText.split(/\s+/);
// Each item consists of the item's score, item's index, and the item itself.
const matches: Array<[number, number, Item]> = [];
// Get a score for each item based on the amount of overlap with text.
for (let i = 0; i < this._allItems.length; i++) {
const item = this._allItems[i];
const score: number = getScore(item.cleanText, searchWords);
matches.push([score, i, item]);
}
// Sort the matches by score first, and then by item (searchText).
matches.sort((a, b) => nativeCompare(b[0], a[0]) || nativeCompare(a[1], b[1]));
const items = matches.slice(0, this._maxResults).map((m) => m[2]);
return {items, highlightFunc: highlightNone, selectIndex: -1};
}
}
// Scores text against an array of search words by adding the lengths of common prefixes between
// the search words and words in the text.
function getScore(text: string, searchWords: string[]) {
const textWords = text.split(/\s+/);
let score = 0;
for (let k = 0; k < searchWords.length; k++) {
const w = searchWords[k];
// Power term for bonus disambiguates scores that are otherwise identical, to prioritize
// earlier words appearing in earlier positions.
const wordScore = Math.max(...textWords.map((sw, i) => getWordScore(sw, w, Math.pow(2, -(i + k)))));
score += wordScore;
}
if (text.startsWith(searchWords.join(' '))) {
score += 1;
}
return score;
}
function getWordScore(searchedWord: string, word: string, bonus: number) {
if (searchedWord === word) { return word.length + 1 + bonus; }
while (word) {
if (searchedWord.startsWith(word)) { return word.length + bonus; }
word = word.slice(0, -1);
}
return 0;
}

@ -0,0 +1,46 @@
/* global describe, it */
var assert = require('chai').assert;
var sinon = require('sinon');
var Promise = require('bluebird');
var {Delay} = require('app/client/lib/Delay');
var clientUtil = require('../clientUtil');
const DELAY_MS = 50;
describe('Delay', function() {
clientUtil.setTmpMochaGlobals();
it("should set and clear timeouts", function() {
var spy1 = sinon.spy(), spy2 = sinon.spy(), spy3 = sinon.spy(), spy4 = sinon.spy();
var delay = Delay.create();
assert(!delay.isPending());
delay.schedule(DELAY_MS * 2, spy1);
assert(delay.isPending());
delay.cancel();
assert(!delay.isPending());
delay.schedule(DELAY_MS * 2, spy2);
return Promise.delay(DELAY_MS).then(function() {
delay.cancel();
assert(!delay.isPending());
delay.schedule(DELAY_MS * 2, spy3);
})
.delay(DELAY_MS).then(function() {
delay.schedule(DELAY_MS * 2, spy4, null, 1, 2);
})
.delay(DELAY_MS * 4).then(function() {
sinon.assert.notCalled(spy1);
sinon.assert.notCalled(spy2);
sinon.assert.notCalled(spy3);
sinon.assert.calledOnce(spy4);
sinon.assert.calledOn(spy4, null);
sinon.assert.calledWith(spy4, 1, 2);
assert(!delay.isPending());
});
});
});

@ -0,0 +1,55 @@
import { ImportSourceElement } from 'app/client/lib/ImportSourceElement';
import { createRpcLogger, PluginInstance } from 'app/common/PluginInstance';
import { FileListItem } from 'app/plugin/grist-plugin-api';
import { assert } from 'chai';
import { Rpc } from 'grain-rpc';
// assign console to logger to show logs
const logger = {};
describe("ImportSourceElement.importSourceStub#getImportSource()", function() {
it("should accept buffer for FileContent.content", async function() {
const plugin = createImportSourcePlugin({
getImportSource: () => (Promise.resolve({
item: {
kind: "fileList",
files: [{
content: new Uint8Array([1, 2]),
name: "MyFile"
}]
}
}))
});
const importSourceStub = ImportSourceElement.fromArray([plugin])[0].importSourceStub;
const res = await importSourceStub.getImportSource(0);
assert.equal((res!.item as FileListItem).files[0].name, "MyFile");
assert.deepEqual((res!.item as FileListItem).files[0].content, new Uint8Array([1, 2]));
});
});
// Helper that creates a plugin which contributes importSource.
function createImportSourcePlugin(importSource: any): PluginInstance {
const plugin = new PluginInstance({
id: "",
path: "",
manifest: {
components: {
safeBrowser: "index.html"
},
contributions: {
importSources: [{
label: "Importer",
importSource: {
component: "safeBrowser",
name: "importer"
}
}]
}
},
}, createRpcLogger(logger, "plugin instance"));
const rpc = new Rpc({logger: createRpcLogger(logger, 'rpc')});
rpc.setSendMessage((mssg: any) => rpc.receiveMessage(mssg));
rpc.registerImpl("importer", importSource);
plugin.rpc.registerForwarder("index.html", rpc);
return plugin;
}

@ -0,0 +1,120 @@
/* global describe, it, before */
const assert = require('chai').assert;
const ko = require('knockout');
const clientUtil = require('../clientUtil');
const ObservableMap = require('app/client/lib/ObservableMap');
describe('ObservableMap', function () {
clientUtil.setTmpMochaGlobals();
let factor, mapFunc, map, additive;
let obsKey1, obsKey2, obsValue1, obsValue2;
before(function () {
factor = ko.observable(2);
additive = 0;
mapFunc = ko.computed(() => {
let f = factor();
return function (key) {
return key * f + additive;
};
});
map = ObservableMap.create(mapFunc);
});
it('should keep track of items and update values on key updates', function () {
obsKey1 = ko.observable(1);
obsKey2 = ko.observable(2);
assert.isUndefined(map.get(1));
assert.isUndefined(map.get(2));
obsValue1 = map.add(obsKey1);
obsValue2 = map.add(obsKey2);
assert.equal(map.get(1).size, 1);
assert.equal(map.get(2).size, 1);
assert.equal(obsValue1(), 2);
assert.equal(obsValue2(), 4);
obsKey1(2);
assert.isUndefined(map.get(1));
assert.equal(map.get(2).size, 2);
assert.equal(obsValue1(), 4);
assert.equal(obsValue2(), 4);
});
it('should update all values if mapping function is updated', function () {
assert.equal(obsValue1(), 4);
assert.equal(obsValue2(), 4);
factor(3);
assert.equal(obsValue1(), 6);
assert.equal(obsValue2(), 6);
obsKey1(4);
obsKey2(5);
assert.equal(obsValue1(), 12);
assert.equal(obsValue2(), 15);
});
it('updateKeys should update values for that key, but not other values', function () {
additive = 7;
map.updateKeys([4]);
assert.equal(obsValue1(), 19);
assert.equal(obsValue2(), 15);
});
it('updateAll should update all values for all keys', function () {
additive = 8;
map.updateAll();
assert.equal(obsValue1(), 20);
assert.equal(obsValue2(), 23);
});
it('should remove items when they are disposed', function () {
let obsKey1 = ko.observable(6);
let obsKey2 = ko.observable(6);
assert.isUndefined(map.get(6));
let obsValue1 = map.add(obsKey1);
let obsValue2 = map.add(obsKey2);
assert(map.get(6).has(obsValue1));
assert(map.get(6).has(obsValue2));
assert.equal(map.get(6).size, 2);
obsValue1.dispose();
assert.isFalse(map.get(6).has(obsValue1));
assert.equal(map.get(6).size, 1);
obsValue2.dispose();
assert.isUndefined(map.get(6));
});
it('should unsubscribe from observables on disposal', function () {
assert.equal(obsValue1(), 20);
assert.equal(obsValue2(), 23);
map.dispose();
obsKey1(10);
obsKey2(11);
factor(3);
assert.equal(obsValue1(), 20);
assert.equal(obsValue2(), 23);
});
});

@ -0,0 +1,50 @@
/* global describe, it */
var assert = require('chai').assert;
var ko = require('knockout');
var clientUtil = require('../clientUtil');
var ObservableSet = require('app/client/lib/ObservableSet');
describe('ObservableSet', function() {
clientUtil.setTmpMochaGlobals();
it("should keep track of items", function() {
var set = ObservableSet.create();
assert.equal(set.count(), 0);
assert.deepEqual(set.all(), []);
var obs1 = ko.observable(true), val1 = { foo: 5 },
obs2 = ko.observable(false), val2 = { foo: 17 };
var sub1 = set.add(obs1, val1),
sub2 = set.add(obs2, val2);
assert.equal(set.count(), 1);
assert.deepEqual(set.all(), [val1]);
obs1(false);
assert.equal(set.count(), 0);
assert.deepEqual(set.all(), []);
obs2(true);
assert.equal(set.count(), 1);
assert.deepEqual(set.all(), [val2]);
obs1(true);
assert.equal(set.count(), 2);
assert.deepEqual(set.all(), [val1, val2]);
sub1.dispose();
assert.equal(set.count(), 1);
assert.deepEqual(set.all(), [val2]);
assert.equal(obs1.getSubscriptionsCount(), 0);
assert.equal(obs2.getSubscriptionsCount(), 1);
sub2.dispose();
assert.equal(set.count(), 0);
assert.deepEqual(set.all(), []);
assert.equal(obs1.getSubscriptionsCount(), 0);
assert.equal(obs2.getSubscriptionsCount(), 0);
});
});

@ -0,0 +1,56 @@
import {assert} from 'chai';
import {ColumnsToMap, mapColumnNames, mapColumnNamesBack} from 'app/plugin/grist-plugin-api';
describe('PluginApi', function () {
it('should map columns according to configuration', function () {
const columns: ColumnsToMap = ['Foo', {name: 'Bar', allowMultiple: true}, {name: 'Baz', optional: true}];
let mappings: any = {Foo: null, Bar: ['A', 'B'], Baz: null};
const record = {A: 1, B: 2, id: 1};
// When there are not mappings, it should return original data.
assert.deepEqual(
record,
mapColumnNames(record)
);
assert.deepEqual(
record,
mapColumnNamesBack(record)
);
// Foo is not mapped, should be null.
assert.isNull(
mapColumnNames(record, {
mappings,
columns,
})
);
assert.isNull(
mapColumnNames([record], {
mappings,
columns,
})
);
// Map Foo to A
mappings = {...mappings, Foo: 'A'};
// Should map as Foo is mapped
assert.deepEqual(mapColumnNames(record, {mappings, columns}), {id: 1, Foo: 1, Bar: [1, 2]});
assert.deepEqual(mapColumnNames([record], {mappings, columns}), [{id: 1, Foo: 1, Bar: [1, 2]}]);
assert.deepEqual(mapColumnNamesBack([{id: 1, Foo: 1, Bar: [1, 2]}], {mappings, columns}), [record]);
// Map Baz
mappings = {...mappings, Baz: 'B'};
assert.deepEqual(mapColumnNames(record, {mappings, columns}), {id: 1, Foo: 1, Bar: [1, 2], Baz: 2});
assert.deepEqual(mapColumnNames([record], {mappings, columns}), [{id: 1, Foo: 1, Bar: [1, 2], Baz: 2}]);
assert.deepEqual(mapColumnNamesBack([{id: 1, Foo: 1, Bar: [1, 2], Baz: 5}], {mappings, columns}),
[{id: 1, A: 1, B: 5}]);
});
it('should ignore when there are not mappings requested', function () {
const columns: ColumnsToMap|undefined = undefined;
const mappings: any = undefined;
const record = {A: 1, B: 2, id: 1};
assert.deepEqual(
mapColumnNames(record, {
mappings,
columns,
}),
record
);
});
});

@ -0,0 +1,348 @@
import { ClientScope } from 'app/client/components/ClientScope';
import { Disposable } from 'app/client/lib/dispose';
import { ClientProcess, SafeBrowser } from 'app/client/lib/SafeBrowser';
import { LocalPlugin } from 'app/common/plugin';
import { PluginInstance } from 'app/common/PluginInstance';
import { GristAPI, RPC_GRISTAPI_INTERFACE } from 'app/plugin/GristAPI';
import { Storage } from 'app/plugin/StorageAPI';
import { checkers } from 'app/plugin/TypeCheckers';
import { assert } from 'chai';
import { Rpc } from 'grain-rpc';
import { noop } from 'lodash';
import { basename } from 'path';
import * as sinon from 'sinon';
import * as clientUtil from 'test/client/clientUtil';
import * as tic from "ts-interface-checker";
import { createCheckers } from "ts-interface-checker";
import * as url from 'url';
clientUtil.setTmpMochaGlobals();
const LOG_RPC = false; // tslint:disable-line:prefer-const
// uncomment next line to turn on rpc logging
// LOG_RPC = true;
describe('SafeBrowser', function() {
let clientScope: any;
const sandbox = sinon.createSandbox();
let browserProcesses: Array<{path: string, proc: ClientProcess}> = [];
let disposeSpy: sinon.SinonSpy;
const cleanup: Array<() => void> = [];
beforeEach(function() {
const callPluginFunction = sinon.stub();
callPluginFunction
.withArgs('testing-plugin', 'unsafeNode', 'func1')
.callsFake( (...args) => 'From Russia ' + args[3][0] + "!");
callPluginFunction
.withArgs('testing-plugin', 'unsafeNode', 'funkyName')
.throws();
clientScope = new ClientScope();
browserProcesses = [];
sandbox.stub(SafeBrowser, 'createWorker').callsFake(createProcess);
sandbox.stub(SafeBrowser, 'createView').callsFake(createProcess);
sandbox.stub(PluginInstance.prototype, 'getRenderTarget').returns(noop);
disposeSpy = sandbox.spy(Disposable.prototype, 'dispose');
});
afterEach(function() {
sandbox.restore();
for (const cb of cleanup) { cb(); }
cleanup.splice(0);
});
it('should support rpc', async function() {
const {safeBrowser, pluginRpc} = createSafeBrowser('test_rpc');
const foo = pluginRpc.getStub<Foo>('grist@test_rpc', FooDescription);
await safeBrowser.activate();
assert.equal(await foo.foo('rpc test'), 'foo rpc test');
});
it("can stub view processes", async function() {
const {safeBrowser, pluginRpc} = createSafeBrowser('test_render');
const foo = pluginRpc.getStub<Foo>('grist@test_render_view', FooDescription);
await safeBrowser.activate();
assert.equal(await foo.foo('rpc test'), 'foo rpc test from test_render_view');
});
it('can forward rpc to a view process', async function() {
const {safeBrowser, pluginRpc} = createSafeBrowser("test_forward");
const foo = pluginRpc.getStub<Foo>('grist@test_forward', FooDescription);
await safeBrowser.activate();
assert.equal(await foo.foo("safeBrowser"), "foo safeBrowser from test_forward_view");
});
it('should forward messages', async function() {
const {safeBrowser, pluginRpc} = createSafeBrowser("test_messages");
const foo = pluginRpc.getStub<Foo>('foo@test_messages', FooDescription);
await safeBrowser.activate();
assert.equal(await foo.foo("safeBrowser"), "from message view");
});
it('should support disposing a rendered view', async function() {
const {safeBrowser, pluginRpc} = createSafeBrowser("test_dispose");
const foo = pluginRpc.getStub<Foo>('grist@test_dispose', FooDescription);
await safeBrowser.activate();
await foo.foo("safeBrowser");
assert.deepEqual(browserProcesses.map(p => p.path), ["test_dispose", "test_dispose_view1", "test_dispose_view2"]);
assert.equal(disposeSpy.calledOn(processByName("test_dispose_view1")!), true);
assert.equal(disposeSpy.calledOn(processByName("test_dispose_view2")!), false);
});
it('should dispose each process on deactivation', async function() {
const {safeBrowser, pluginRpc} = createSafeBrowser("test_dispose");
const foo = pluginRpc.getStub<Foo>('grist@test_dispose', FooDescription);
await safeBrowser.activate();
await foo.foo("safeBrowser");
await safeBrowser.deactivate();
assert.deepEqual(browserProcesses.map(p => p.path), ["test_dispose", "test_dispose_view1", "test_dispose_view2"]);
for (const {proc} of browserProcesses) {
assert.equal(disposeSpy.calledOn(proc), true);
}
});
// it('should allow calling unsafeNode functions', async function() {
// const {safeBrowser, pluginRpc} = createSafeBrowser("test_function_call");
// const rpc = (safeBrowser as any)._pluginInstance.rpc as Rpc;
// const foo = rpc.getStub<Foo>('grist@test_function_call', FooDescription);
// await safeBrowser.activate();
// assert.equal(await foo.foo('func1'), 'From Russia with love!');
// await assert.isRejected(foo.foo('funkyName'));
// });
it('should allow access to client scope interfaces', async function() {
const {safeBrowser, pluginRpc} = createSafeBrowser("test_client_scope");
const foo = pluginRpc.getStub<Foo>('grist@test_client_scope', FooDescription);
await safeBrowser.activate();
assert.equal(await foo.foo('green'), '#0f0');
});
it('should allow access to client scope interfaces from view', async function() {
const {safeBrowser, pluginRpc} = createSafeBrowser("test_client_scope_from_view");
const foo = pluginRpc.getStub<Foo>('grist@test_client_scope_from_view', FooDescription);
await safeBrowser.activate();
assert.equal(await foo.foo('red'), 'red#f00');
});
it('should have type-safe access to client scope interfaces', async function() {
const {safeBrowser, pluginRpc} = createSafeBrowser("test_client_scope_typed");
const foo = pluginRpc.getStub<Foo>('grist@test_client_scope_typed', FooDescription);
await safeBrowser.activate();
await assert.isRejected(foo.foo('test'), /is not a string/);
});
it('should allow creating a view process from grist', async function() {
const {safeBrowser, pluginRpc} = createSafeBrowser("test_view_process");
// let's call buildDom on test_rpc
const proc = safeBrowser.createViewProcess("test_rpc");
// rpc should work
const foo = pluginRpc.getStub<Foo>('grist@test_rpc', FooDescription);
assert.equal(await foo.foo('Santa'), 'foo Santa');
// now let's dispose
proc.dispose();
});
function createProcess(safeBrowser: SafeBrowser, _rpc: Rpc, src: string) {
const path: string = basename(url.parse(src).pathname!);
const rpc = new Rpc({logger: LOG_RPC ? {
// let's prepend path to the console 'info' and 'warn' channels
info: console.info.bind(console, path), // tslint:disable-line:no-console
warn: console.warn.bind(console, path), // tslint:disable-line:no-console
} : {}, sendMessage: _rpc.receiveMessage.bind(_rpc)});
_rpc.setSendMessage(msg => rpc.receiveMessage(msg));
const api = rpc.getStub<GristAPI>(RPC_GRISTAPI_INTERFACE, checkers.GristAPI);
function ready() {
rpc.processIncoming();
void rpc.sendReadyMessage();
}
// Start up the mock process for the plugin.
const proc = new ClientProcess(safeBrowser, _rpc);
PROCESSES[path]({rpc, api, ready });
browserProcesses.push({path, proc});
return proc;
}
// At the moment, only the .definition field matters for SafeBrowser.
const localPlugin: LocalPlugin = {
manifest: {
components: { safeBrowser: 'main' },
contributions: {}
},
id: "testing-plugin",
path: ""
};
function createSafeBrowser(mainPath: string): {safeBrowser: SafeBrowser, pluginRpc: Rpc} {
const pluginInstance = new PluginInstance(localPlugin, {});
const safeBrowser = new SafeBrowser(pluginInstance, clientScope, '', mainPath, {});
cleanup.push(() => safeBrowser.deactivate());
pluginInstance.rpc.registerForwarder(mainPath, safeBrowser);
return {safeBrowser, pluginRpc: pluginInstance.rpc};
}
function processByName(name: string): ClientProcess|undefined {
const procInfo = browserProcesses.find(p => (p.path === name));
return procInfo ? procInfo.proc : undefined;
}
});
/**
* A Dummy Api to contribute to.
*/
interface Foo {
foo(name: string): Promise<string>;
}
const FooDescription = createCheckers({
Foo: tic.iface([], {
foo: tic.func("string", tic.param("name", "string")),
})
}).Foo;
interface TestProcesses {
[s: string]: (grist: GristModule) => void;
}
/**
* This interface describes what exposes grist-plugin-api.ts to the plugin.
*/
interface GristModule {
rpc: Rpc;
api: GristAPI;
ready(): void;
}
/**
* The safeBrowser's script needed for test.
*/
const PROCESSES: TestProcesses = {
test_rpc: (grist: GristModule) => {
class MyFoo {
public async foo(name: string): Promise<string> {
return 'foo ' + name;
}
}
grist.rpc.registerImpl<Foo>('grist', new MyFoo(), FooDescription);
grist.ready();
},
async test_render(grist: GristModule) {
await grist.api.render('test_render_view', 'fullscreen');
grist.ready();
},
test_render_view(grist: GristModule) {
grist.rpc.registerImpl<Foo>('grist', {
foo: (name: string) => `foo ${name} from test_render_view`
});
grist.ready();
},
async test_forward(grist: GristModule) {
grist.rpc.registerImpl<Foo>('grist', {
foo: (name: string) => viewFoo.foo(name)
});
grist.api.render('test_forward_view', 'fullscreen'); // eslint-disable-line @typescript-eslint/no-floating-promises
const viewFoo = grist.rpc.getStub<Foo>('foo@test_forward_view', FooDescription);
grist.ready();
},
test_forward_view: (grist: GristModule) => {
grist.rpc.registerImpl<Foo>('foo', {
foo: async (name) => `foo ${name} from test_forward_view`
}, FooDescription);
grist.ready();
},
test_messages: (grist: GristModule) => {
grist.rpc.registerImpl<Foo>('foo', {
foo(name): Promise<string> {
return new Promise<string>(resolve => {
grist.rpc.once('message', resolve);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
grist.api.render('test_messages_view', 'fullscreen');
});
}
}, FooDescription);
grist.ready();
},
test_messages_view: (grist: GristModule) => {
// test if works even if grist.ready() called after postmessage ?
grist.ready();
// eslint-disable-next-line @typescript-eslint/no-floating-promises
grist.rpc.postMessageForward('test_messages', 'from message view');
},
test_dispose: (grist: GristModule) => {
class MyFoo {
public async foo(name: string): Promise<string> {
const id = await grist.api.render('test_dispose_view1', 'fullscreen');
await grist.api.render('test_dispose_view2', 'fullscreen');
await grist.api.dispose(id);
return "test";
}
}
grist.rpc.registerImpl<Foo>('grist', new MyFoo(), FooDescription);
grist.ready();
},
test_dispose_view1: (grist) => grist.ready(),
test_dispose_view2: (grist) => grist.ready(),
test_client_scope: (grist: GristModule) => {
class MyFoo {
public async foo(name: string): Promise<string> {
const stub = grist.rpc.getStub<Storage>('storage');
stub.setItem("red", "#f00");
stub.setItem("green", "#0f0");
stub.setItem("blue", "#00f");
return stub.getItem(name);
}
}
grist.rpc.registerImpl<Foo>('grist', new MyFoo(), FooDescription);
grist.ready();
},
test_client_scope_from_view: (grist: GristModule) => {
// hit linting limit for number of classes in a single file :-)
const myFoo = {
foo(name: string): Promise<string> {
return new Promise<string> (resolve => {
grist.rpc.once("message", (msg: any) => resolve(name + msg));
// eslint-disable-next-line @typescript-eslint/no-floating-promises
grist.api.render('view_client_scope', 'fullscreen');
});
}
};
grist.rpc.registerImpl<Foo>('grist', myFoo, FooDescription);
grist.ready();
},
test_client_scope_typed: (grist: GristModule) => {
const myFoo = {
foo(name: string): Promise<string> {
const stub = grist.rpc.getStub<any>('storage');
return stub.setItem(1); // this should be an error
}
};
grist.rpc.registerImpl<Foo>('grist', myFoo, FooDescription);
grist.ready();
},
view1: (grist: GristModule) => {
const myFoo = {
async foo(name: string): Promise<string> {
return `foo ${name} from view1`;
}
};
grist.rpc.registerImpl<Foo>('foo', myFoo, FooDescription);
grist.ready();
},
view2: (grist: GristModule) => {
grist.ready();
},
view_client_scope: async (grist: GristModule) => {
const stub = grist.rpc.getStub<Storage>('storage');
grist.ready();
stub.setItem("red", "#f00");
const result = await stub.getItem("red");
// eslint-disable-next-line @typescript-eslint/no-floating-promises
grist.rpc.postMessageForward("test_client_scope_from_view", result);
},
};

@ -0,0 +1,81 @@
import * as log from 'app/client/lib/log';
import {HistWindow, UrlState} from 'app/client/lib/UrlState';
import {assert} from 'chai';
import {dom} from 'grainjs';
import {popGlobals, pushGlobals} from 'grainjs/dist/cjs/lib/browserGlobals';
import {JSDOM} from 'jsdom';
import fromPairs = require('lodash/fromPairs');
import * as sinon from 'sinon';
describe('UrlState', function() {
const sandbox = sinon.createSandbox();
let mockWindow: HistWindow;
function pushState(state: any, title: any, href: string) {
mockWindow.location = new URL(href) as unknown as Location;
}
beforeEach(function() {
mockWindow = {
location: new URL('http://localhost:8080') as unknown as Location,
history: {pushState} as History,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => true,
};
// These grainjs browserGlobals are needed for using dom() in tests.
const jsdomDoc = new JSDOM("<!doctype html><html><body></body></html>");
pushGlobals(jsdomDoc.window);
sandbox.stub(log, 'debug');
});
afterEach(function() {
popGlobals();
sandbox.restore();
});
interface State {
[key: string]: string;
}
function encodeUrl(state: State, baseLocation: Location | URL): string {
const url = new URL(baseLocation.href);
for (const key of Object.keys(state)) { url.searchParams.set(key, state[key]); }
return url.href;
}
function decodeUrl(location: Location | URL): State {
const url = new URL(location.href);
return fromPairs(Array.from(url.searchParams.entries()));
}
function updateState(prevState: State, newState: State): State {
return {...prevState, ...newState};
}
function needPageLoad(prevState: State, newState: State): boolean {
return false;
}
async function delayPushUrl(prevState: State, newState: State): Promise<void> {
// no-op
}
it('should produce correct results with configProd', async function() {
mockWindow.location = new URL('https://example.com/?foo=A&bar=B') as unknown as Location;
const urlState = new UrlState<State>(mockWindow, {encodeUrl, decodeUrl, updateState, needPageLoad, delayPushUrl});
assert.deepEqual(urlState.state.get(), {foo: 'A', bar: 'B'});
const link = dom('a', urlState.setLinkUrl({bar: 'C'}));
assert.equal(link.getAttribute('href'), 'https://example.com/?foo=A&bar=C');
assert.equal(urlState.makeUrl({bar: "X"}), 'https://example.com/?foo=A&bar=X');
assert.equal(urlState.makeUrl({foo: 'F', bar: ""}), 'https://example.com/?foo=F&bar=');
await urlState.pushUrl({bar: 'X'});
assert.equal(mockWindow.location.href, 'https://example.com/?foo=A&bar=X');
assert.deepEqual(urlState.state.get(), {foo: 'A', bar: 'X'});
assert.equal(link.getAttribute('href'), 'https://example.com/?foo=A&bar=C');
await urlState.pushUrl({foo: 'F', baz: 'T'});
assert.equal(mockWindow.location.href, 'https://example.com/?foo=F&bar=X&baz=T');
assert.deepEqual(urlState.state.get(), {foo: 'F', bar: 'X', baz: 'T'});
assert.equal(link.getAttribute('href'), 'https://example.com/?foo=F&bar=C&baz=T');
});
});

@ -0,0 +1,146 @@
import {consolidateValues, sortByXValues, splitValuesByIndex} from 'app/client/lib/chartUtil';
import {assert} from 'chai';
import {Datum} from 'plotly.js';
describe('chartUtil', function() {
describe('sortByXValues', function() {
function sort(data: Datum[][]) {
const series = data.map((values) => ({values, label: 'X'}));
sortByXValues(series);
return series.map((s) => s.values);
}
it('should sort all series according to the first one', function() {
// Should handle simple and trivial cases.
assert.deepEqual(sort([]), []);
assert.deepEqual(sort([[2, 1, 3, 0.5]]), [[0.5, 1, 2, 3]]);
assert.deepEqual(sort([[], [], [], []]), [[], [], [], []]);
// All series should be sorted according to the first one.
assert.deepEqual(sort([[2, 1, 3, 0.5], ["a", "b", "c", "d"], [null, -1.1, "X", ['a'] as any]]),
[[0.5, 1, 2, 3], ["d", "b", "a", "c"], [['a'] as any, -1.1, null, "X"]]);
// If the first one is sorted, there should be no changes.
assert.deepEqual(sort([["a", "b", "c", "d"], [2, 1, 3, 0.5], [null, -1.1, "X", ['a'] as any]]),
[["a", "b", "c", "d"], [2, 1, 3, 0.5], [null, -1.1, "X", ['a'] as any]]);
// Should cope if the first series contains values of different type.
assert.deepEqual(sort([[null, -1.1, "X", ['a'] as any], [2, 1, 3, 0.5], ["a", "b", "c", "d"]]),
[[-1.1, null, ['a'] as any, "X"], [1, 2, 0.5, 3], ["b", "a", "d", "c"]]);
});
});
describe('splitValuesByIndex', function() {
it('should work correctly', function() {
splitValuesByIndex([{label: 'test', values: []}, {label: 'foo', values: []}], 0);
assert.deepEqual(splitValuesByIndex([
{label: 'foo', values: [['L', 'foo', 'bar'], ['L', 'baz']] as any},
{label: 'bar', values: ['santa', 'janus']}
], 0), [
{label: 'foo', values: ['foo', 'bar', 'baz']},
{label: 'bar', values: ['santa', 'santa', 'janus']}
]);
assert.deepEqual(splitValuesByIndex([
{label: 'bar', values: ['santa', 'janus']},
{label: 'foo', values: [['L', 'foo', 'bar'], ['L', 'baz']] as any},
], 1), [
{label: 'bar', values: ['santa', 'santa', 'janus']},
{label: 'foo', values: ['foo', 'bar', 'baz']},
]);
});
});
describe('consolidateValues', function() {
it('should add missing values', function() {
assert.deepEqual(
consolidateValues(
[
{values: []},
{values: []}
],
['A', 'B']
),
[
{values: ['A', 'B']},
{values: [0, 0]},
]
);
assert.deepEqual(
consolidateValues(
[
{values: ['A']},
{values: [3]}
],
['A', 'B']
),
[
{values: ['A', 'B']},
{values: [3, 0]},
]
);
assert.deepEqual(
consolidateValues(
[
{values: ['B']},
{values: [1]}
],
['A', 'B']
),
[
{values: ['A', 'B']},
{values: [0, 1]},
]
);
});
it('should keep redundant value', function() {
assert.deepEqual(
consolidateValues(
[
{values: ['A', 'A']},
{values: [1, 2]}
],
['A', 'B']
),
[
{values: ['A', 'A', 'B']},
{values: [1, 2, 0]},
]
);
assert.deepEqual(
consolidateValues(
[
{values: ['B', 'B']},
{values: [1, 2]}
],
['A', 'B']
),
[
{values: ['A', 'B', 'B']},
{values: [0, 1, 2]},
]
);
});
it('another case', function() {
assert.deepEqual(
consolidateValues(
[
{values: ['A', 'C']},
{values: [1, 2]},
],
['A', 'B', 'C', 'D']
),
[
{values: ['A', 'B', 'C', 'D']},
{values: [1, 0, 2, 0]},
]
);
});
});
});

@ -0,0 +1,195 @@
/* global describe, it, before, after */
var dispose = require('app/client/lib/dispose');
var bluebird = require('bluebird');
var {assert} = require('chai');
var sinon = require('sinon');
var clientUtil = require('../clientUtil');
var dom = require('app/client/lib/dom');
describe('dispose', function() {
clientUtil.setTmpMochaGlobals();
function Bar() {
this.dispose = sinon.spy();
this.destroy = sinon.spy();
}
describe("Disposable", function() {
it("should dispose objects passed to autoDispose", function() {
var bar = new Bar();
var baz = new Bar();
var container1 = dom('div', dom('span'));
var container2 = dom('div', dom('span'));
var cleanup = sinon.spy();
var stopListening = sinon.spy();
function Foo() {
this.bar = this.autoDispose(bar);
this.baz = this.autoDisposeWith('destroy', baz);
this.child1 = this.autoDispose(container1.appendChild(dom('div')));
this.child2 = container2.appendChild(dom('div'));
this.autoDisposeWith(dispose.emptyNode, container2);
this.autoDisposeCallback(cleanup);
this.stopListening = stopListening;
}
dispose.makeDisposable(Foo);
var foo = new Foo();
assert(!foo.isDisposed());
assert.equal(container1.children.length, 2);
assert.equal(container2.children.length, 2);
foo.dispose();
assert(foo.isDisposed());
assert.equal(bar.dispose.callCount, 1);
assert.equal(bar.destroy.callCount, 0);
assert.equal(baz.dispose.callCount, 0);
assert.equal(baz.destroy.callCount, 1);
assert.equal(stopListening.callCount, 1);
assert(bar.dispose.calledOn(bar));
assert(bar.dispose.calledWithExactly());
assert(baz.destroy.calledOn(baz));
assert(baz.destroy.calledWithExactly());
assert(cleanup.calledOn(foo));
assert(cleanup.calledWithExactly());
// Verify that disposal is called in reverse order of autoDispose calls.
assert(cleanup.calledBefore(baz.destroy));
assert(baz.destroy.calledBefore(bar.dispose));
assert(bar.dispose.calledBefore(stopListening));
// Verify that DOM children got removed: in the second case, the container should be
// emptied.
assert.equal(container1.children.length, 1);
assert.equal(container2.children.length, 0);
});
it('should call multiple registered autoDisposeCallbacks in reverse order', function() {
let spy = sinon.spy();
function Foo() {
this.autoDisposeCallback(() => {
spy(1);
});
this.autoDisposeCallback(() => {
spy(2);
});
}
dispose.makeDisposable(Foo);
var foo = new Foo(spy);
foo.autoDisposeCallback(() => {
spy(3);
});
foo.dispose();
assert(foo.isDisposed());
assert.equal(spy.callCount, 3);
assert.deepEqual(spy.firstCall.args, [3]);
assert.deepEqual(spy.secondCall.args, [2]);
assert.deepEqual(spy.thirdCall.args, [1]);
});
});
describe("create", function() {
// Capture console.error messages.
const consoleErrors = [];
const origConsoleError = console.error;
before(function() { console.error = (...args) => consoleErrors.push(args.map(x => ''+x)); });
after(function() { console.error = origConsoleError; });
it("should dispose partially constructed objects", function() {
var bar = new Bar();
var baz = new Bar();
function Foo(throwWhen) {
if (throwWhen === 0) { throw new Error("test-error1"); }
this.bar = this.autoDispose(bar);
if (throwWhen === 1) { throw new Error("test-error2"); }
this.baz = this.autoDispose(baz);
if (throwWhen === 2) { throw new Error("test-error3"); }
}
dispose.makeDisposable(Foo);
var foo;
// If we throw right away, no surprises, nothing gets called.
assert.throws(function() { foo = Foo.create(0); }, /test-error1/);
assert.strictEqual(foo, undefined);
assert.equal(bar.dispose.callCount, 0);
assert.equal(baz.dispose.callCount, 0);
// If we constructed one object, that one object should have gotten disposed.
assert.throws(function() { foo = Foo.create(1); }, /test-error2/);
assert.strictEqual(foo, undefined);
assert.equal(bar.dispose.callCount, 1);
assert.equal(baz.dispose.callCount, 0);
bar.dispose.resetHistory();
// If we constructed two objects, both should have gotten disposed.
assert.throws(function() { foo = Foo.create(2); }, /test-error3/);
assert.strictEqual(foo, undefined);
assert.equal(bar.dispose.callCount, 1);
assert.equal(baz.dispose.callCount, 1);
assert(baz.dispose.calledBefore(bar.dispose));
bar.dispose.resetHistory();
baz.dispose.resetHistory();
// If we don't throw, then nothing should get disposed until we call .dispose().
assert.doesNotThrow(function() { foo = Foo.create(3); });
assert(!foo.isDisposed());
assert.equal(bar.dispose.callCount, 0);
assert.equal(baz.dispose.callCount, 0);
foo.dispose();
assert(foo.isDisposed());
assert.equal(bar.dispose.callCount, 1);
assert.equal(baz.dispose.callCount, 1);
assert(baz.dispose.calledBefore(bar.dispose));
assert.deepEqual(consoleErrors[0], ['Error constructing %s:', 'Foo', 'Error: test-error1']);
assert.deepEqual(consoleErrors[1], ['Error constructing %s:', 'Foo', 'Error: test-error2']);
assert.deepEqual(consoleErrors[2], ['Error constructing %s:', 'Foo', 'Error: test-error3']);
assert.equal(consoleErrors.length, 3);
});
it("promised objects should resolve during normal creation", function() {
const bar = new Bar();
bar.marker = 1;
const barPromise = bluebird.Promise.resolve(bar);
function Foo() {
this.bar = this.autoDisposePromise(barPromise);
}
dispose.makeDisposable(Foo);
const foo = Foo.create();
return foo.bar.then(bar => {
assert.ok(bar.marker);
});
});
it("promised objects should resolve to null if owner is disposed", function() {
let resolveBar;
const barPromise = new bluebird.Promise(resolve => resolveBar = resolve);
function Foo() {
this.bar = this.autoDisposePromise(barPromise);
}
dispose.makeDisposable(Foo);
const foo = Foo.create();
const fooBar = foo.bar;
foo.dispose();
assert(foo.isDisposed);
assert(foo.bar === null);
const bar = new Bar();
resolveBar(bar);
return fooBar.then(bar => {
assert.isNull(bar);
});
});
});
});

@ -0,0 +1,396 @@
/* global describe, it, before, after */
var assert = require('chai').assert;
var sinon = require('sinon');
var Promise = require('bluebird');
var ko = require('knockout');
var dom = require('app/client/lib/dom');
var clientUtil = require('../clientUtil');
var G = require('app/client/lib/browserGlobals').get('DocumentFragment');
var utils = require('../../utils');
describe('dom', function() {
clientUtil.setTmpMochaGlobals();
describe("dom construction", function() {
it("should create elements with the right tag name, class and ID", function() {
var elem = dom('div', "Hello world");
assert.equal(elem.tagName, "DIV");
assert(!elem.className);
assert(!elem.id);
assert.equal(elem.textContent, "Hello world");
elem = dom('span#foo.bar.baz', "Hello world");
assert.equal(elem.tagName, "SPAN");
assert.equal(elem.className, "bar baz");
assert.equal(elem.id, "foo");
assert.equal(elem.textContent, "Hello world");
});
it("should set attributes", function() {
var elem = dom('a', { title: "foo", id: "bar" });
assert.equal(elem.title, "foo");
assert.equal(elem.id, "bar");
});
it("should set children", function() {
var elem = dom('div',
"foo", dom('a#a'),
[dom('a#b'), "bar", dom('a#c')],
dom.frag(dom('a#d'), "baz", dom('a#e')));
assert.equal(elem.childNodes.length, 8);
assert.equal(elem.childNodes[0].data, "foo");
assert.equal(elem.childNodes[1].id, "a");
assert.equal(elem.childNodes[2].id, "b");
assert.equal(elem.childNodes[3].data, "bar");
assert.equal(elem.childNodes[4].id, "c");
assert.equal(elem.childNodes[5].id, "d");
assert.equal(elem.childNodes[6].data, "baz");
assert.equal(elem.childNodes[7].id, "e");
});
it('should flatten nested arrays and arrays returned from functions', function() {
var values = ['apple', 'orange', ['banana', 'mango']];
var elem = dom('ul',
values.map(function(value, index) {
return dom('li', value);
}),
[
dom('li', 'pear'),
[
dom('li', 'peach'),
dom('li', 'cranberry'),
],
dom('li', 'date')
]
);
assert.equal(elem.outerHTML, "<ul><li>apple</li><li>orange</li>" +
"<li>bananamango</li><li>pear</li><li>peach</li><li>cranberry</li>" +
"<li>date</li></ul>");
elem = dom('ul',
function(innerElem) {
return [
dom('li', 'plum'),
dom('li', 'pomegranate')
];
},
function(innerElem) {
return function(moreInnerElem) {
return [
dom('li', 'strawberry'),
dom('li', 'blueberry')
];
};
}
);
assert.equal(elem.outerHTML, "<ul><li>plum</li><li>pomegranate</li>" +
"<li>strawberry</li><li>blueberry</li></ul>");
});
it("should append append values returned from functions except undefined", function() {
var elem = dom('div',
function(divElem) {
divElem.classList.add('yogurt');
return dom('div', 'sneakers');
},
dom('span', 'melon')
);
assert.equal(elem.classList[0], 'yogurt',
'function shold have applied new class to outer div');
assert.equal(elem.childNodes.length, 2);
assert.equal(elem.childNodes[0].innerHTML, "sneakers");
assert.equal(elem.childNodes[1].innerHTML, "melon");
elem = dom('div',
function(divElem) {
return undefined;
}
);
assert.equal(elem.childNodes.length, 0,
"undefined returned from a function should not be added to the DOM tree");
});
it('should not append nulls', function() {
var elem = dom('div',
[
"hello",
null,
"world",
null,
"jazz"
],
'hands',
null
);
assert.equal(elem.childNodes.length, 4,
"undefined returned from a function should not be added to the DOM tree");
assert.equal(elem.childNodes[0].data, "hello");
assert.equal(elem.childNodes[1].data, "world");
assert.equal(elem.childNodes[2].data, "jazz");
assert.equal(elem.childNodes[3].data, "hands");
});
});
utils.timing.describe("dom", function() {
var built, child;
before(function() {
child = dom('bar');
});
utils.timing.it(40, "should be fast", function() {
built = utils.repeat(100, function() {
return dom('div#id1.class1.class2', {disabled: 'disabled'},
'foo',
child,
['hello', 'world'],
function(elem) {
return 'test';
}
);
});
});
utils.timing.it(40, "should be fast", function() {
utils.repeat(100, function() {
dom('div#id1.class1.class2.class3');
dom('div#id1.class1.class2.class3');
dom('div#id1.class1.class2.class3');
dom('div#id1.class1.class2.class3');
dom('div#id1.class1.class2.class3');
});
});
after(function() {
assert.equal(built.getAttribute('disabled'), 'disabled');
assert.equal(built.tagName, 'DIV');
assert.equal(built.className, 'class1 class2');
assert.equal(built.childNodes.length, 5);
assert.equal(built.childNodes[0].data, 'foo');
assert.equal(built.childNodes[1], child);
assert.equal(built.childNodes[2].data, 'hello');
assert.equal(built.childNodes[3].data, 'world');
assert.equal(built.childNodes[4].data, 'test');
});
});
describe("dom.frag", function() {
it("should create DocumentFragments", function() {
var elem1 = dom.frag(["hello", "world"]);
assert(elem1 instanceof G.DocumentFragment);
assert.equal(elem1.childNodes.length, 2);
assert.equal(elem1.childNodes[0].data, "hello");
assert.equal(elem1.childNodes[1].data, "world");
var elem2 = dom.frag("hello", "world");
assert(elem2 instanceof G.DocumentFragment);
assert.equal(elem2.childNodes.length, 2);
assert.equal(elem2.childNodes[0].data, "hello");
assert.equal(elem2.childNodes[1].data, "world");
var elem3 = dom.frag(dom("div"), [dom("span"), "hello"], "world");
assert.equal(elem3.childNodes.length, 4);
assert.equal(elem3.childNodes[0].tagName, "DIV");
assert.equal(elem3.childNodes[1].tagName, "SPAN");
assert.equal(elem3.childNodes[2].data, "hello");
assert.equal(elem3.childNodes[3].data, "world");
});
});
describe("inlineable", function() {
it("should return a function suitable for use as dom argument", function() {
var ctx = {a:1}, b = dom('span'), c = {c:1};
var spy = sinon.stub().returns(c);
var inlinable = dom.inlineable(spy);
// When the first argument is a Node, then calling inlineable is the same as calling spy.
inlinable.call(ctx, b, c, 1, "asdf");
sinon.assert.calledOnce(spy);
sinon.assert.calledOn(spy, ctx);
sinon.assert.calledWithExactly(spy, b, c, 1, "asdf");
assert.strictEqual(spy.returnValues[0], c);
spy.reset();
spy.returns(c);
// When the first Node argument is omitted, then the call is deferred. Check that it works
// correctly.
var func = inlinable.call(ctx, c, 1, "asdf");
sinon.assert.notCalled(spy);
assert.equal(typeof func, 'function');
assert.deepEqual(spy.returnValues, []);
let r = func(b);
sinon.assert.calledOnce(spy);
sinon.assert.calledOn(spy, ctx);
sinon.assert.calledWithExactly(spy, b, c, 1, "asdf");
assert.deepEqual(r, c);
assert.strictEqual(spy.returnValues[0], c);
});
});
utils.timing.describe("dom.inlinable", function() {
var elem, spy, inlinableCounter, inlinableSpy, count = 0;
before(function() {
elem = dom('span');
spy = sinon.stub();
inlinableCounter = dom.inlinable(function(elem, a, b) {
count++;
});
inlinableSpy = dom.inlinable(spy);
});
utils.timing.it(25, "should be fast", function() {
utils.repeat(10000, function() {
inlinableCounter(1, "asdf")(elem);
inlinableCounter(1, "asdf")(elem);
inlinableCounter(1, "asdf")(elem);
inlinableCounter(1, "asdf")(elem);
inlinableCounter(1, "asdf")(elem);
});
inlinableSpy()(elem);
inlinableSpy(1)(elem);
inlinableSpy(1, "asdf")(elem);
inlinableSpy(1, "asdf", 56)(elem);
inlinableSpy(1, "asdf", 56, "hello")(elem);
});
after(function() {
assert.equal(count, 50000);
sinon.assert.callCount(spy, 5);
assert.deepEqual(spy.args[0], [elem]);
assert.deepEqual(spy.args[1], [elem, 1]);
assert.deepEqual(spy.args[2], [elem, 1, "asdf"]);
assert.deepEqual(spy.args[3], [elem, 1, "asdf", 56]);
assert.deepEqual(spy.args[4], [elem, 1, "asdf", 56, "hello"]);
});
});
describe("dom.defer", function() {
it("should call supplied function after the current call stack", function() {
var obj = {};
var spy1 = sinon.spy();
var spy2 = sinon.spy();
var div, span;
dom('div',
span = dom('span', dom.defer(spy1, obj)),
div = dom('div', spy2)
);
sinon.assert.calledOnce(spy2);
sinon.assert.calledWithExactly(spy2, div);
sinon.assert.notCalled(spy1);
return Promise.delay(0).then(function() {
sinon.assert.calledOnce(spy2);
sinon.assert.calledOnce(spy1);
assert(spy2.calledBefore(spy1));
sinon.assert.calledOn(spy1, obj);
sinon.assert.calledWithExactly(spy1, span);
});
});
});
describe("dom.onDispose", function() {
it("should call supplied function when an element is cleaned up", function() {
var obj = {};
var spy1 = sinon.spy();
var spy2 = sinon.spy();
var div, span;
div = dom('div',
span = dom('span', dom.onDispose(spy1, obj)),
dom.onDispose(spy2)
);
sinon.assert.notCalled(spy1);
sinon.assert.notCalled(spy2);
ko.virtualElements.emptyNode(div);
sinon.assert.notCalled(spy2);
sinon.assert.calledOnce(spy1);
sinon.assert.calledOn(spy1, obj);
sinon.assert.calledWithExactly(spy1, span);
ko.removeNode(div);
sinon.assert.calledOnce(spy1);
sinon.assert.calledOnce(spy2);
sinon.assert.calledOn(spy2, undefined);
sinon.assert.calledWithExactly(spy2, div);
});
});
describe("dom.autoDispose", function() {
it("should call dispose the supplied value when an element is cleaned up", function() {
var obj = { dispose: sinon.spy() };
var div = dom('div', dom.autoDispose(obj));
ko.cleanNode(div);
sinon.assert.calledOnce(obj.dispose);
sinon.assert.calledOn(obj.dispose, obj);
sinon.assert.calledWithExactly(obj.dispose);
});
});
describe("dom.findLastChild", function() {
it("should return last matching child", function() {
var el = dom('div', dom('div.a.b'), dom('div.b.c'), dom('div.c.d'));
assert.equal(dom.findLastChild(el, '.b').className, 'b c');
assert.equal(dom.findLastChild(el, '.f'), null);
assert.equal(dom.findLastChild(el, '.c.d').className, 'c d');
assert.equal(dom.findLastChild(el, '.b.a').className, 'a b');
function filter(elem) { return elem.classList.length === 2; }
assert.equal(dom.findLastChild(el, filter).className, 'c d');
});
});
describe("dom.findAncestor", function() {
var el1, el2, el3, el4;
before(function() {
el1 = dom('div.foo.bar',
el2 = dom('div.foo',
el3 = dom('div.baz')
),
el4 = dom('div.foo.bar2')
);
});
function assertSameElem(elem1, elem2) {
assert(elem1 === elem2, "Expected " + elem1 + " to be " + elem2);
}
it("should return the child itself if it matches", function() {
assertSameElem(dom.findAncestor(el3, null, '.baz'), el3);
assertSameElem(dom.findAncestor(el3, el3, '.baz'), el3);
});
it("should stop at the nearest match", function() {
assertSameElem(dom.findAncestor(el3, null, '.foo'), el2);
assertSameElem(dom.findAncestor(el3, el1, '.foo'), el2);
assertSameElem(dom.findAncestor(el3, el2, '.foo'), el2);
assertSameElem(dom.findAncestor(el3, el3, '.foo'), null);
});
it("should not go past container", function() {
assertSameElem(dom.findAncestor(el3, null, '.bar'), el1);
assertSameElem(dom.findAncestor(el3, el1, '.bar'), el1);
assertSameElem(dom.findAncestor(el3, el2, '.bar'), null);
assertSameElem(dom.findAncestor(el3, el3, '.bar'), null);
});
it("should fail if child is outside of container", function() {
assertSameElem(dom.findAncestor(el3, el4, '.foo'), null);
assertSameElem(dom.findAncestor(el2, el3, '.foo'), null);
});
it("should return null for no matches", function() {
assertSameElem(dom.findAncestor(el3, null, '.blah'), null);
assertSameElem(dom.findAncestor(el3, el1, '.blah'), null);
assertSameElem(dom.findAncestor(el3, el2, '.blah'), null);
assertSameElem(dom.findAncestor(el3, el3, '.blah'), null);
});
function filter(elem) { return elem.classList.length === 2; }
it("should handle a custom filter function", function() {
assertSameElem(dom.findAncestor(el3, null, filter), el1);
assertSameElem(dom.findAncestor(el3, el1, filter), el1);
assertSameElem(dom.findAncestor(el3, el2, filter), null);
assertSameElem(dom.findAncestor(el3, el3, filter), null);
assertSameElem(dom.findAncestor(el3, el4, filter), null);
});
});
});

@ -0,0 +1,69 @@
import {domAsync} from 'app/client/lib/domAsync';
import {assert} from 'chai';
import {dom} from 'grainjs';
import {G, popGlobals, pushGlobals} from 'grainjs/dist/cjs/lib/browserGlobals';
import {JSDOM} from 'jsdom';
import * as sinon from 'sinon';
describe('domAsync', function() {
beforeEach(function() {
// These grainjs browserGlobals are needed for using dom() in tests.
const jsdomDoc = new JSDOM("<!doctype html><html><body></body></html>");
pushGlobals(jsdomDoc.window);
});
afterEach(function() {
popGlobals();
});
it('should populate DOM once promises resolve', async function() {
let a: HTMLElement, b: HTMLElement, c: HTMLElement, d: HTMLElement;
const onError = sinon.spy();
const r1 = dom('button'), r2 = [dom('img'), dom('input')], r4 = dom('hr');
const promise1 = Promise.resolve(r1);
const promise2 = new Promise(r => setTimeout(r, 20)).then(() => r2);
const promise3 = Promise.reject(new Error("p3"));
const promise4 = new Promise(r => setTimeout(r, 20)).then(() => r4);
// A few elements get populated by promises.
G.document.body.appendChild(dom('div',
a = dom('span.a1', domAsync(promise1)),
b = dom('span.a2', domAsync(promise2)),
c = dom('span.a3', domAsync(promise3, onError)),
d = dom('span.a2', domAsync(promise4)),
));
// Initially, none of the content is there.
assert.lengthOf(a.children, 0);
assert.lengthOf(b.children, 0);
assert.lengthOf(c.children, 0);
assert.lengthOf(d.children, 0);
// Check that content appears as promises get resolved.
await promise1;
assert.deepEqual([...a.children], [r1]);
// Disposing an element will ensure that content does not get added to it.
dom.domDispose(d);
// Need to wait for promise2 for its results to appear.
assert.lengthOf(b.children, 0);
await promise2;
assert.deepEqual([...b.children], r2);
// Promise4's results should not appear because of domDispose.
await promise4;
assert.deepEqual([...d.children], []);
// A rejected promise should not produce content, but call the onError callback.
await promise3.catch(() => null);
assert.deepEqual([...c.children], []);
sinon.assert.calledOnce(onError);
sinon.assert.calledWithMatch(onError, {message: 'p3'});
assert.lengthOf(a.children, 1);
assert.lengthOf(b.children, 2);
assert.lengthOf(c.children, 0);
assert.lengthOf(d.children, 0);
});
});

@ -0,0 +1,372 @@
/* global describe, it */
var _ = require('underscore');
var assert = require('assert');
var ko = require('knockout');
var sinon = require('sinon');
var clientUtil = require('../clientUtil');
var koArray = require('app/client/lib/koArray');
describe('koArray', function() {
clientUtil.setTmpMochaGlobals();
it("should emit spliceChange events", function() {
var arr = koArray([1, 2, 3]);
var events = [];
// Whenever we get an event, push it to events.
['change', 'spliceChange'].forEach(function(type) {
arr.subscribe(function(data) {
events.push({ type: type, data: data });
}, null, type);
});
function expectSplice(start, num, deleted, options) {
assert.equal(events.length, 2);
var e = events.shift();
assert.equal(e.type, 'spliceChange');
assert.equal(e.data.start, start);
assert.equal(e.data.added, num);
assert.deepEqual(e.data.deleted, deleted);
e = events.shift();
assert.equal(e.type, 'change');
}
assert.deepEqual(arr.all(), [1, 2, 3]);
// push should work fine.
arr.push("foo");
expectSplice(3, 1, []);
arr.push("bar");
expectSplice(4, 1, []);
assert.deepEqual(arr.all(), [1, 2, 3, "foo", "bar"]);
assert.deepEqual(arr.peek(), [1, 2, 3, "foo", "bar"]);
assert.equal(arr.peekLength, 5);
// insertions via splice should work.
arr.splice(1, 0, "hello", "world");
expectSplice(1, 2, []);
assert.deepEqual(arr.all(), [1, "hello", "world", 2, 3, "foo", "bar"]);
// including using negative indices.
arr.splice(-6, 2, "blah");
expectSplice(1, 1, ["hello", "world"]);
assert.deepEqual(arr.all(), [1, "blah", 2, 3, "foo", "bar"]);
// slice should work but not emit anything.
assert.deepEqual(arr.slice(3, 5), [3, "foo"]);
assert.equal(events.length, 0);
// deletions using splice should work
arr.splice(-2, 1);
expectSplice(4, 0, ["foo"]);
assert.deepEqual(arr.all(), [1, "blah", 2, 3, "bar"]);
// including deletions to the end
arr.splice(1);
expectSplice(1, 0, ["blah", 2, 3, "bar"]);
assert.deepEqual(arr.all(), [1]);
// setting a new array should also produce a splice event.
var newValues = [4, 5, 6];
arr.assign(newValues);
expectSplice(0, 3, [1]);
// Check that koArray does not affect the array passed-in on assignment.
arr.push(7);
expectSplice(3, 1, []);
assert.deepEqual(newValues, [4, 5, 6]);
assert.deepEqual(arr.peek(), [4, 5, 6, 7]);
// We don't support various observableArray() methods. If we do start supporting them, we
// need to make sure they emit correct events.
assert.throws(function() { arr.pop(); }, Error);
assert.throws(function() { arr.remove("b"); }, Error);
});
it("should create dependencies when needed", function() {
var arr = koArray([1, 2, 3]);
var sum = ko.computed(function() {
return arr.all().reduce(function(sum, item) { return sum + item; }, 0);
});
var peekSum = ko.computed(function() {
return arr.peek().reduce(function(sum, item) { return sum + item; }, 0);
});
assert.equal(sum(), 6);
assert.equal(peekSum(), 6);
arr.push(10);
assert.equal(sum(), 16);
assert.equal(peekSum(), 6);
arr.splice(1, 1);
assert.equal(sum(), 14);
assert.equal(peekSum(), 6);
arr.splice(0);
assert.equal(sum(), 0);
assert.equal(peekSum(), 6);
});
describe("#arraySplice", function() {
it("should work similarly to splice", function() {
var arr = koArray([1, 2, 3]);
arr.arraySplice(1, 2, []);
assert.deepEqual(arr.peek(), [1]);
arr.arraySplice(1, 0, [10, 11]);
assert.deepEqual(arr.peek(), [1, 10, 11]);
arr.arraySplice(0, 0, [4, 5]);
assert.deepEqual(arr.peek(), [4, 5, 1, 10, 11]);
});
});
describe("#makeLiveIndex", function() {
it("should be kept valid", function() {
var arr = koArray([1, 2, 3]);
var index = arr.makeLiveIndex();
assert.equal(index(), 0);
index(-1);
assert.equal(index(), 0);
index(null);
assert.equal(index(), 0);
index(100);
assert.equal(index(), 2);
arr.splice(1, 1);
assert.deepEqual(arr.peek(), [1, 3]);
assert.equal(index(), 1);
arr.splice(0, 1, 5, 6, 7);
assert.deepEqual(arr.peek(), [5, 6, 7, 3]);
assert.equal(index(), 3);
arr.push(10);
arr.splice(2, 2);
assert.deepEqual(arr.peek(), [5, 6, 10]);
assert.equal(index(), 2);
arr.splice(2, 1);
assert.deepEqual(arr.peek(), [5, 6]);
assert.equal(index(), 1);
arr.splice(0, 2);
assert.deepEqual(arr.peek(), []);
assert.equal(index(), null);
arr.splice(0, 0, 1, 2, 3);
assert.deepEqual(arr.peek(), [1, 2, 3]);
assert.equal(index(), 0);
});
});
describe("#map", function() {
it("should map immediately and continuously", function() {
var arr = koArray([1, 2, 3]);
var mapped = arr.map(function(orig) { return orig * 10; });
assert.deepEqual(mapped.peek(), [10, 20, 30]);
arr.push(4);
assert.deepEqual(mapped.peek(), [10, 20, 30, 40]);
arr.splice(1, 1);
assert.deepEqual(mapped.peek(), [10, 30, 40]);
arr.splice(0, 1, 5, 6, 7);
assert.deepEqual(mapped.peek(), [50, 60, 70, 30, 40]);
arr.splice(2, 0, 2);
assert.deepEqual(mapped.peek(), [50, 60, 20, 70, 30, 40]);
arr.splice(1, 3);
assert.deepEqual(mapped.peek(), [50, 30, 40]);
arr.splice(0, 0, 1, 2, 3);
assert.deepEqual(mapped.peek(), [10, 20, 30, 50, 30, 40]);
arr.splice(3, 3);
assert.deepEqual(mapped.peek(), [10, 20, 30]);
// Check that `this` argument works correctly.
var foo = { test: function(orig) { return orig * 100; } };
var mapped2 = arr.map(function(orig) { return this.test(orig); }, foo);
assert.deepEqual(mapped2.peek(), [100, 200, 300]);
arr.splice(1, 0, 4, 5);
assert.deepEqual(mapped2.peek(), [100, 400, 500, 200, 300]);
});
});
describe("#syncMap", function() {
it("should keep two arrays in sync", function() {
var arr1 = koArray([1, 2, 3]);
var arr2 = koArray([4, 5, 6]);
var mapped = koArray();
mapped.syncMap(arr1);
assert.deepEqual(mapped.peek(), [1, 2, 3]);
arr1.splice(1, 1, 8, 9);
assert.deepEqual(mapped.peek(), [1, 8, 9, 3]);
mapped.syncMap(arr2, function(x) { return x * 10; });
assert.deepEqual(mapped.peek(), [40, 50, 60]);
arr1.splice(1, 1, 8, 9);
assert.deepEqual(mapped.peek(), [40, 50, 60]);
arr2.push(8, 9);
assert.deepEqual(mapped.peek(), [40, 50, 60, 80, 90]);
});
});
describe('#subscribeForEach', function() {
it('should call onAdd and onRemove callbacks', function() {
var arr1 = koArray([1, 2, 3]);
var seen = [];
function onAdd(x) { seen.push(["add", x]); }
function onRm(x) { seen.push(["rm", x]); }
var sub = arr1.subscribeForEach({ add: onAdd, remove: onRm });
assert.deepEqual(seen, [["add", 1], ["add", 2], ["add", 3]]);
seen = [];
arr1.push(4);
assert.deepEqual(seen, [["add", 4]]);
seen = [];
arr1.splice(1, 2);
assert.deepEqual(seen, [["rm", 2], ["rm", 3]]);
seen = [];
arr1.splice(0, 1, 5, 6);
assert.deepEqual(seen, [["rm", 1], ["add", 5], ["add", 6]]);
// If subscription is disposed, callbacks should no longer get called.
sub.dispose();
seen = [];
arr1.push(10);
assert.deepEqual(seen, []);
});
});
describe('#setAutoDisposeValues', function() {
it('should dispose elements when asked', function() {
var objects = _.range(5).map(function(n) { return { value: n, dispose: sinon.spy() }; });
var arr = koArray(objects.slice(0, 3)).setAutoDisposeValues();
// Just to check what's in the array to start with.
assert.equal(arr.all().length, 3);
assert.strictEqual(arr.at(0), objects[0]);
// Delete two elements: they should get disposed, but the remaining one should not.
var x = arr.splice(0, 2);
assert.equal(arr.all().length, 1);
assert.strictEqual(arr.at(0), objects[2]);
assert.equal(x.length, 2);
sinon.assert.calledOnce(x[0].dispose);
sinon.assert.calledOnce(x[1].dispose);
sinon.assert.notCalled(objects[2].dispose);
// Reassign: the remaining element should now also get disposed.
arr.assign(objects.slice(3, 5));
assert.equal(arr.all().length, 2);
assert.strictEqual(arr.at(0), objects[3]);
sinon.assert.calledOnce(objects[2].dispose);
sinon.assert.notCalled(objects[3].dispose);
sinon.assert.notCalled(objects[4].dispose);
// Dispose the entire array: previously assigned elements should be disposed.
arr.dispose();
sinon.assert.calledOnce(objects[3].dispose);
sinon.assert.calledOnce(objects[4].dispose);
// Check that elements disposed earlier haven't been disposed more than once.
sinon.assert.calledOnce(objects[0].dispose);
sinon.assert.calledOnce(objects[1].dispose);
sinon.assert.calledOnce(objects[2].dispose);
});
});
describe('syncedKoArray', function() {
it("should return array synced to the value of the observable", function() {
var arr1 = koArray(["1", "2", "3"]);
var arr2 = koArray(["foo", "bar"]);
var arr3 = ["hello", "world"];
var obs = ko.observable(arr1);
var combined = koArray.syncedKoArray(obs);
// The values match the array returned by the observable, but mapped using wrap().
assert.deepEqual(combined.all(), ["1", "2", "3"]);
// Changes to the array changes the synced array.
arr1.push("4");
assert.deepEqual(combined.all(), ["1", "2", "3", "4"]);
// Changing the observable changes the synced array; the value may be a plain array.
obs(arr3);
assert.deepEqual(combined.all(), ["hello", "world"]);
// Previously mapped observable array no longer affects the combined one. And of course
// modifying the non-observable array makes no difference either.
arr1.push("4");
arr3.splice(0, 1);
arr3.push("qwer");
assert.deepEqual(combined.all(), ["hello", "world"]);
// Test assigning again to a koArray.
obs(arr2);
assert.deepEqual(combined.all(), ["foo", "bar"]);
arr2.splice(0, 1);
assert.deepEqual(combined.all(), ["bar"]);
arr2.splice(0, 0, "this", "is", "a", "test");
assert.deepEqual(combined.all(), ["this", "is", "a", "test", "bar"]);
arr2.assign(["10", "20"]);
assert.deepEqual(combined.all(), ["10", "20"]);
// Check that only arr2 has a subscriber (not arr1), and that disposing unsubscribes from
// both the observable and the currently active array.
assert.equal(arr1.getObservable().getSubscriptionsCount(), 1);
assert.equal(arr2.getObservable().getSubscriptionsCount(), 2);
assert.equal(obs.getSubscriptionsCount(), 1);
combined.dispose();
assert.equal(obs.getSubscriptionsCount(), 0);
assert.equal(arr2.getObservable().getSubscriptionsCount(), 1);
});
it("should work with a mapper callback", function() {
var arr1 = koArray(["1", "2", "3"]);
var obs = ko.observable();
function wrap(value) { return "x" + value; }
var combined = koArray.syncedKoArray(obs, wrap);
assert.deepEqual(combined.all(), []);
obs(arr1);
assert.deepEqual(combined.all(), ["x1", "x2", "x3"]);
arr1.push("4");
assert.deepEqual(combined.all(), ["x1", "x2", "x3", "x4"]);
obs(["foo", "bar"]);
assert.deepEqual(combined.all(), ["xfoo", "xbar"]);
arr1.splice(1, 1);
obs(arr1);
arr1.splice(1, 1);
assert.deepEqual(combined.all(), ["x1", "x4"]);
});
});
describe("syncedMap", function() {
it("should associate state with each item and dispose it", function() {
var arr = koArray(["1", "2", "3"]);
var constructSpy = sinon.spy(), disposeSpy = sinon.spy();
var map = koArray.syncedMap(arr, (state, val) => {
constructSpy(val);
state.autoDisposeCallback(() => disposeSpy(val));
});
assert.deepEqual(constructSpy.args, [["1"], ["2"], ["3"]]);
assert.deepEqual(disposeSpy.args, []);
arr.splice(1, 0, "4", "5");
assert.deepEqual(arr.peek(), ["1", "4", "5", "2", "3"]);
assert.deepEqual(constructSpy.args, [["1"], ["2"], ["3"], ["4"], ["5"]]);
assert.deepEqual(disposeSpy.args, []);
arr.splice(0, 2);
assert.deepEqual(constructSpy.args, [["1"], ["2"], ["3"], ["4"], ["5"]]);
assert.deepEqual(disposeSpy.args, [["1"], ["4"]]);
map.dispose();
assert.deepEqual(constructSpy.args, [["1"], ["2"], ["3"], ["4"], ["5"]]);
assert.deepEqual(disposeSpy.args, [["1"], ["4"], ["2"], ["3"], ["5"]]);
});
});
});

@ -0,0 +1,97 @@
import koArray from 'app/client/lib/koArray';
import {createObsArray} from 'app/client/lib/koArrayWrap';
import {assert} from 'chai';
import {Holder} from 'grainjs';
import * as sinon from 'sinon';
function assertResetSingleCall(spy: sinon.SinonSpy, ...args: any[]): void {
sinon.assert.calledOnce(spy);
sinon.assert.calledOn(spy, undefined);
sinon.assert.calledWithExactly(spy, ...args);
spy.resetHistory();
}
describe('koArrayWrap', function() {
it('should map splice changes correctly', function() {
const kArr = koArray([1, 2, 3]);
const holder = Holder.create(null);
const gArr = createObsArray(holder, kArr);
assert.deepEqual(gArr.get(), [1, 2, 3]);
const spy = sinon.spy();
gArr.addListener(spy);
// Push to array.
kArr.push(4, 5);
assert.deepEqual(kArr.peek(), [1, 2, 3, 4, 5]);
assert.deepEqual(gArr.get(), [1, 2, 3, 4, 5]);
assertResetSingleCall(spy, gArr.get(), gArr.get(), {deleted: [], start: 3, numAdded: 2});
// Splice to remove and add.
kArr.splice(1, 1, 11, 12);
assert.deepEqual(kArr.peek(), [1, 11, 12, 3, 4, 5]);
assert.deepEqual(gArr.get(), [1, 11, 12, 3, 4, 5]);
assertResetSingleCall(spy, gArr.get(), gArr.get(), {start: 1, numAdded: 2, deleted: [2]});
// Splice to just remove.
kArr.splice(2, 2);
assert.deepEqual(kArr.peek(), [1, 11, 4, 5]);
assert.deepEqual(gArr.get(), [1, 11, 4, 5]);
assertResetSingleCall(spy, gArr.get(), gArr.get(), {start: 2, numAdded: 0, deleted: [12, 3]});
// Splice to just add.
kArr.splice(3, 0, 21, 22);
assert.deepEqual(kArr.peek(), [1, 11, 4, 21, 22, 5]);
assert.deepEqual(gArr.get(), [1, 11, 4, 21, 22, 5]);
assertResetSingleCall(spy, gArr.get(), gArr.get(), {start: 3, numAdded: 2, deleted: []});
// Splice to make empty.
kArr.splice(0);
assert.deepEqual(kArr.peek(), []);
assert.deepEqual(gArr.get(), []);
assertResetSingleCall(spy, gArr.get(), gArr.get(), {start: 0, numAdded: 0, deleted: [1, 11, 4, 21, 22, 5]});
// Unshift an empty array.
kArr.unshift(6, 7);
assert.deepEqual(kArr.peek(), [6, 7]);
assert.deepEqual(gArr.get(), [6, 7]);
assertResetSingleCall(spy, gArr.get(), gArr.get(), {start: 0, numAdded: 2, deleted: []});
});
it('should handle array assignment', function() {
const kArr = koArray([1, 2, 3]);
const holder = Holder.create(null);
const gArr = createObsArray(holder, kArr);
assert.deepEqual(gArr.get(), [1, 2, 3]);
const spy = sinon.spy();
gArr.addListener(spy);
// Set a new array.
kArr.assign([-1, -2]);
assert.deepEqual(kArr.peek(), [-1, -2]);
assert.deepEqual(gArr.get(), [-1, -2]);
assertResetSingleCall(spy, gArr.get(), gArr.get(), {start: 0, numAdded: 2, deleted: [1, 2, 3]});
});
it('should unsubscribe when disposed', function() {
const kArr = koArray([1, 2, 3]);
const holder = Holder.create(null);
const gArr = createObsArray(holder, kArr);
assert.deepEqual(gArr.get(), [1, 2, 3]);
const spy = sinon.spy();
gArr.addListener(spy);
kArr.push(4);
assertResetSingleCall(spy, gArr.get(), gArr.get(), {deleted: [], start: 3, numAdded: 1});
const countSubs = kArr.getObservable().getSubscriptionsCount();
holder.dispose();
assert.equal(gArr.isDisposed(), true);
assert.equal(kArr.getObservable().getSubscriptionsCount(), countSubs - 1);
kArr.push(5);
sinon.assert.notCalled(spy);
});
});

@ -0,0 +1,258 @@
/* global describe, it */
var assert = require('assert');
var ko = require('knockout');
var sinon = require('sinon');
var dom = require('app/client/lib/dom');
var kd = require('app/client/lib/koDom');
var koArray = require('app/client/lib/koArray');
var clientUtil = require('../clientUtil');
describe('koDom', function() {
clientUtil.setTmpMochaGlobals();
describe("simple properties", function() {
it("should update dynamically", function() {
var obs = ko.observable('bar');
var width = ko.observable(17);
var elem = dom('div',
kd.attr('a1', 'foo'),
kd.attr('a2', obs),
kd.attr('a3', function() { return "a3" + obs(); }),
kd.text(obs),
kd.style('width', function() { return width() + 'px'; }),
kd.toggleClass('isbar', function() { return obs() === 'bar'; }),
kd.cssClass(function() { return 'class' + obs(); }));
assert.equal(elem.getAttribute('a1'), 'foo');
assert.equal(elem.getAttribute('a2'), 'bar');
assert.equal(elem.getAttribute('a3'), 'a3bar');
assert.equal(elem.textContent, 'bar');
assert.equal(elem.style.width, '17px');
assert.equal(elem.className, 'isbar classbar');
obs('BAZ');
width('34');
assert.equal(elem.getAttribute('a1'), 'foo');
assert.equal(elem.getAttribute('a2'), 'BAZ');
assert.equal(elem.getAttribute('a3'), 'a3BAZ');
assert.equal(elem.textContent, 'BAZ');
assert.equal(elem.style.width, '34px');
assert.equal(elem.className, 'classBAZ');
obs('bar');
assert.equal(elem.className, 'isbar classbar');
});
});
describe("domData", function() {
it("should set domData and reflect observables", function() {
var foo = ko.observable(null);
var elem = dom('div',
kd.domData('foo', foo),
kd.domData('bar', 'BAR')
);
assert.equal(ko.utils.domData.get(elem, 'foo'), null);
assert.equal(ko.utils.domData.get(elem, 'bar'), 'BAR');
foo(123);
assert.equal(ko.utils.domData.get(elem, 'foo'), 123);
});
});
describe("scope", function() {
it("should handle any number of children", function() {
var obs = ko.observable();
var elem = dom('div', 'Hello',
kd.scope(obs, function(value) {
return value;
}),
'World');
assert.equal(elem.textContent, "HelloWorld");
obs("Foo");
assert.equal(elem.textContent, "HelloFooWorld");
obs([]);
assert.equal(elem.textContent, "HelloWorld");
obs(["Foo", "Bar"]);
assert.equal(elem.textContent, "HelloFooBarWorld");
obs(null);
assert.equal(elem.textContent, "HelloWorld");
obs([dom.frag("Foo", dom("span", "Bar")), dom("div", "Baz")]);
assert.equal(elem.textContent, "HelloFooBarBazWorld");
});
it("should cope with children getting removed outside", function() {
var obs = ko.observable();
var elem = dom('div', 'Hello', kd.scope(obs, function(v) { return v; }), 'World');
assert.equal(elem.innerHTML, 'Hello<!---->World');
obs(dom.frag(dom('div', 'Foo'), dom('div', 'Bar')));
assert.equal(elem.innerHTML, 'Hello<!----><div>Foo</div><div>Bar</div>World');
elem.removeChild(elem.childNodes[2]);
assert.equal(elem.innerHTML, 'Hello<!----><div>Bar</div>World');
obs(null);
assert.equal(elem.innerHTML, 'Hello<!---->World');
obs(dom.frag(dom('div', 'Foo'), dom('div', 'Bar')));
elem.removeChild(elem.childNodes[3]);
assert.equal(elem.innerHTML, 'Hello<!----><div>Foo</div>World');
obs(dom.frag(dom('div', 'Foo'), dom('div', 'Bar')));
assert.equal(elem.innerHTML, 'Hello<!----><div>Foo</div><div>Bar</div>World');
});
});
describe("maybe", function() {
it("should handle any number of children", function() {
var obs = ko.observable(0);
var elem = dom('div', 'Hello',
kd.maybe(function() { return obs() > 0; }, function() {
return dom("span", "Foo");
}),
kd.maybe(function() { return obs() > 1; }, function() {
return [dom("span", "Foo"), dom("span", "Bar")];
}),
"World");
assert.equal(elem.textContent, "HelloWorld");
obs(1);
assert.equal(elem.textContent, "HelloFooWorld");
obs(2);
assert.equal(elem.textContent, "HelloFooFooBarWorld");
obs(0);
assert.equal(elem.textContent, "HelloWorld");
});
it("should pass truthy value to content function", function() {
var obs = ko.observable(null);
var elem = dom('div', 'Hello', kd.maybe(obs, function(x) { return x; }), 'World');
assert.equal(elem.innerHTML, 'Hello<!---->World');
obs(dom('span', 'Foo'));
assert.equal(elem.innerHTML, 'Hello<!----><span>Foo</span>World');
obs(0); // Falsy values should destroy the content
assert.equal(elem.innerHTML, 'Hello<!---->World');
});
});
describe("foreach", function() {
it("should work with koArray", function() {
var model = koArray();
// Make sure the loop notices elements already in the model.
model.assign(["a", "b", "c"]);
var elem = dom('div', "[",
kd.foreach(model, function(item) {
return dom('span', ":", dom('span', kd.text(item)));
}),
"]"
);
assert.equal(elem.textContent, "[:a:b:c]");
// Delete all elements.
model.splice(0);
assert.equal(elem.textContent, "[]");
// Test push.
model.push("hello");
assert.equal(elem.textContent, "[:hello]");
model.push("world");
assert.equal(elem.textContent, "[:hello:world]");
// Test splice that replaces some elements with more.
model.splice(0, 1, "foo", "bar", "baz");
assert.equal(elem.textContent, "[:foo:bar:baz:world]");
// Test splice which removes some elements.
model.splice(-3, 2);
assert.equal(elem.textContent, "[:foo:world]");
// Test splice which adds some elements in the middle.
model.splice(1, 0, "test2", "test3");
assert.equal(elem.textContent, "[:foo:test2:test3:world]");
});
it("should work when items disappear from under it", function() {
var elements = [dom('span', 'a'), dom('span', 'b'), dom('span', 'c')];
var model = koArray();
model.assign(elements);
var elem = dom('div', '[', kd.foreach(model, function(item) { return item; }), ']');
assert.equal(elem.textContent, "[abc]");
// Plain splice out.
var removed = model.splice(1, 1);
assert.deepEqual(removed, [elements[1]]);
assert.deepEqual(model.peek(), [elements[0], elements[2]]);
assert.equal(elem.textContent, "[ac]");
// Splice it back in.
model.splice(1, 0, elements[1]);
assert.equal(elem.textContent, "[abc]");
// Now remove the element from DOM manually.
elem.removeChild(elements[1]);
assert.equal(elem.textContent, "[ac]");
assert.deepEqual(model.peek(), elements);
// Use splice again, and make sure it still does the right thing.
removed = model.splice(2, 1);
assert.deepEqual(removed, [elements[2]]);
assert.deepEqual(model.peek(), [elements[0], elements[1]]);
assert.equal(elem.textContent, "[a]");
removed = model.splice(0, 2);
assert.deepEqual(removed, [elements[0], elements[1]]);
assert.deepEqual(model.peek(), []);
assert.equal(elem.textContent, "[]");
});
it("should work when items are null", function() {
var model = koArray();
var elem = dom('div', '[',
kd.foreach(model, function(item) { return item && dom('span', item); }),
']');
assert.equal(elem.textContent, "[]");
model.splice(0, 0, "a", "b", "c");
assert.equal(elem.textContent, "[abc]");
var childCount = elem.childNodes.length;
model.splice(1, 1, null);
assert.equal(elem.childNodes.length, childCount - 1); // One child removed, non added.
assert.equal(elem.textContent, "[ac]");
model.splice(1, 0, "x");
assert.equal(elem.textContent, "[axc]");
model.splice(3, 0, "y");
assert.equal(elem.textContent, "[axyc]");
model.splice(1, 2);
assert.equal(elem.textContent, "[ayc]");
model.splice(0, 3);
assert.equal(elem.textContent, "[]");
});
it("should dispose subscribables for detached nodes", function() {
var obs = ko.observable("AAA");
var cb = sinon.spy(function(x) { return x; });
var data = koArray([ko.observable("foo"), ko.observable("bar")]);
var elem = dom('div', kd.foreach(data, function(item) {
return dom('div', kd.text(function() { return cb(item() + ":" + obs()); }));
}));
assert.equal(elem.innerHTML, '<!----><div>foo:AAA</div><div>bar:AAA</div>');
obs("BBB");
assert.equal(elem.innerHTML, '<!----><div>foo:BBB</div><div>bar:BBB</div>');
data.splice(1, 1);
assert.equal(elem.innerHTML, '<!----><div>foo:BBB</div>');
cb.resetHistory();
// Below is the core of the test: we are checking that the computed observable created for
// the second item of the array ("bar") does NOT trigger a call to cb.
obs("CCC");
assert.equal(elem.innerHTML, '<!----><div>foo:CCC</div>');
sinon.assert.calledOnce(cb);
sinon.assert.calledWith(cb, "foo:CCC");
});
});
});

@ -0,0 +1,48 @@
const {Scrolly} = require('app/client/lib/koDomScrolly');
const clientUtil = require('../clientUtil');
const G = require('app/client/lib/browserGlobals').get('window', '$');
const sinon = require('sinon');
const assert = require('assert');
/* global describe, it, after, before, beforeEach */
describe("koDomScrolly", function() {
clientUtil.setTmpMochaGlobals();
before(function(){
sinon.stub(Scrolly.prototype, 'scheduleUpdateSize');
});
beforeEach(function(){
Scrolly.prototype.scheduleUpdateSize.reset();
});
after(function(){
Scrolly.prototype.scheduleUpdateSize.restore();
});
it("should not remove other's resize handlers", function(){
let scrolly1 = createScrolly(),
scrolly2 = createScrolly();
G.$(G.window).trigger("resize");
let updateSpy = Scrolly.prototype.scheduleUpdateSize;
sinon.assert.called(updateSpy);
sinon.assert.calledOn(updateSpy, scrolly1);
sinon.assert.calledOn(updateSpy, scrolly2);
scrolly2.dispose();
updateSpy.reset();
G.$(G.window).trigger("resize");
assert.deepEqual(updateSpy.thisValues, [scrolly1]);
});
});
function createScrolly() {
// subscribe should return a disposable subscription.
const dispose = () => {};
const subscription = { dispose };
const data = {subscribe: () => subscription, all: () => []};
return new Scrolly(data);
}

@ -0,0 +1,277 @@
/* global describe, it */
var assert = require('chai').assert;
var ko = require('knockout');
var kf = require('app/client/lib/koForm');
var koArray = require('app/client/lib/koArray');
var clientUtil = require('../clientUtil');
var G = require('app/client/lib/browserGlobals').get('$');
describe('koForm', function() {
clientUtil.setTmpMochaGlobals();
function triggerInput(input, property, value) {
input[property] = value;
G.$(input).trigger('input');
}
function triggerChange(input, property, value) {
input[property] = value;
G.$(input).trigger('change');
}
function triggerClick(elem) {
G.$(elem).trigger('click');
}
describe("button", function() {
it("should call a function", function() {
var calls = 0;
var btn = kf.button(function() { calls++; }, 'Test');
triggerClick(btn);
triggerClick(btn);
triggerClick(btn);
assert.equal(calls, 3);
});
});
describe("checkButton", function() {
it("should bind an observable", function() {
var obs = ko.observable(false);
// Test observable->widget binding.
var btn = kf.checkButton(obs, "Test");
assert(!btn.classList.contains('active'));
obs(true);
assert(btn.classList.contains('active'));
btn = kf.checkButton(obs, "Test2");
assert(btn.classList.contains('active'));
obs(false);
assert(!btn.classList.contains('active'));
// Test widget->observable binding.
assert.equal(obs(), false);
triggerClick(btn);
assert.equal(obs(), true);
triggerClick(btn);
assert.equal(obs(), false);
});
});
describe("buttonSelect", function() {
it("should bind an observable", function() {
var obs = ko.observable('b');
var a, b, c;
kf.buttonSelect(obs,
a = kf.optionButton('a', 'Test A'),
b = kf.optionButton('b', 'Test B'),
c = kf.optionButton('c', 'Test C')
);
// Test observable->widget binding.
assert(!a.classList.contains('active'));
assert(b.classList.contains('active'));
assert(!c.classList.contains('active'));
obs('a');
assert(a.classList.contains('active'));
assert(!b.classList.contains('active'));
obs('c');
assert(!a.classList.contains('active'));
assert(!b.classList.contains('active'));
assert(c.classList.contains('active'));
// Test widget->observable binding.
assert.equal(obs(), 'c');
triggerClick(b);
assert.equal(obs(), 'b');
});
});
describe("checkbox", function() {
it("should bind an observable", function() {
var obs = ko.observable(false);
var check = kf.checkbox(obs, "Foo").querySelector('input');
// Test observable->widget binding.
assert.equal(check.checked, false);
obs(true);
assert.equal(check.checked, true);
check = kf.checkbox(obs, "Foo").querySelector('input');
assert.equal(check.checked, true);
obs(false);
assert.equal(check.checked, false);
// Test widget->observable binding.
triggerChange(check, 'checked', true);
assert.equal(obs(), true);
assert.equal(check.checked, true);
triggerChange(check, 'checked', false);
assert.equal(obs(), false);
assert.equal(check.checked, false);
});
});
describe("text", function() {
it("should bind an observable", function() {
var obs = ko.observable('hello');
var input = kf.text(obs).querySelector('input');
// Test observable->widget binding.
assert.equal(input.value, 'hello');
obs('world');
assert.equal(input.value, 'world');
// Test widget->observable binding.
triggerChange(input, 'value', 'foo');
assert.equal(obs(), 'foo');
});
});
describe("text debounce", function() {
it("should bind an observable", function() {
var obs = ko.observable('hello');
var input = kf.text(obs, {delay: 300}).querySelector('input');
// Test observable->widget binding.
assert.equal(input.value, 'hello');
obs('world');
assert.equal(input.value, 'world');
// Test widget->observable binding using interrupted by 'Enter' or loosing focus debounce.
triggerInput(input, 'value', 'bar');
assert.equal(input.value, 'bar');
// Ensure that observable value wasn't changed immediately
assert.equal(obs(), 'world');
// Simulate 'change' event (hitting 'Enter' or loosing focus)
triggerChange(input, 'value', 'bar');
// Ensure that observable value was changed on 'change' event
assert.equal(obs(), 'bar');
// Test widget->observable binding using debounce.
triggerInput(input, 'value', 'helloworld');
input.selectionStart = 3;
input.selectionEnd = 7;
assert.equal(input.value.substring(input.selectionStart, input.selectionEnd), 'lowo');
assert.equal(input.value, 'helloworld');
// Ensure that observable value wasn't changed immediately, needs to wait 300 ms
assert.equal(obs(), 'bar');
// Ensure that after delay value were changed
return clientUtil.waitForChange(obs, 350)
.then(() => {
assert.equal(obs(), 'helloworld');
assert.equal(input.value, 'helloworld');
// Ensure that selection is the same and cursor didn't jump to the end
assert.equal(input.value.substring(input.selectionStart, input.selectionEnd), 'lowo');
});
});
});
describe("numText", function() {
it("should bind an observable", function() {
var obs = ko.observable(1234);
var input = kf.numText(obs).querySelector('input');
// Test observable->widget binding.
assert.equal(input.value, '1234');
obs('-987.654');
assert.equal(input.value, '-987.654');
// Test widget->observable binding.
triggerInput(input, 'value', '-1.2');
assert.strictEqual(obs(), -1.2);
});
});
describe("select", function() {
it("should bind an observable", function() {
var obs = ko.observable("b");
var input = kf.select(obs, ["a", "b", "c"]).querySelector('select');
var options = Array.prototype.slice.call(input.querySelectorAll('option'), 0);
function selected() {
return options.map(function(option) { return option.selected; });
}
// Test observable->widget binding.
assert.deepEqual(selected(), [false, true, false]);
obs("a");
assert.deepEqual(selected(), [true, false, false]);
obs("c");
assert.deepEqual(selected(), [false, false, true]);
// Test widget->observable binding.
triggerChange(options[0], 'selected', true);
assert.deepEqual(selected(), [true, false, false]);
assert.equal(obs(), "a");
triggerChange(options[1], 'selected', true);
assert.deepEqual(selected(), [false, true, false]);
assert.equal(obs(), "b");
});
it("should work with option array of objects", function() {
var obs = ko.observable();
var foo = ko.observable('foo');
var bar = ko.observable('bar');
var values = koArray([
{ label: foo, value: 'a1' },
{ label: bar, value: 'b1' },
]);
var select = kf.select(obs, values);
var options = Array.from(select.querySelectorAll('option'));
assert.deepEqual(options.map(el => el.textContent), ['foo', 'bar']);
triggerChange(options[0], 'selected', true);
assert.equal(obs(), 'a1');
foo('foo2');
bar('bar2');
options = Array.from(select.querySelectorAll('option'));
assert.deepEqual(options.map(el => el.textContent), ['foo2', 'bar2']);
triggerChange(options[1], 'selected', true);
assert.equal(obs(), 'b1');
});
it("should store actual, non-stringified values", function() {
let obs = ko.observable();
let values = [
{ label: 'a', value: 1 },
{ label: 'b', value: '2' },
{ label: 'c', value: true },
{ label: 'd', value: { hello: 'world' } },
{ label: 'e', value: new Date() },
];
let options = Array.from(kf.select(obs, values).querySelectorAll('option'));
for (let i = 0; i < values.length; i++) {
triggerChange(options[i], 'selected', true);
assert.strictEqual(obs(), values[i].value);
}
});
it("should allow multi-select and save sorted values", function() {
let obs = ko.observable();
let foo = { foo: 'bar' };
let values = [{ label: 'a', value: foo }, 'd', { label: 'c', value: 1 }, 'b'];
let options = Array.from(
kf.select(obs, values, { multiple: true}).querySelectorAll('option'));
triggerChange(options[0], 'selected', true);
triggerChange(options[2], 'selected', true);
triggerChange(options[3], 'selected', true);
assert.deepEqual(obs(), [1, foo, 'b']);
});
});
});

@ -0,0 +1,126 @@
/* global describe, it */
var assert = require('assert');
var ko = require('knockout');
var sinon = require('sinon');
var koUtil = require('app/client/lib/koUtil');
describe('koUtil', function() {
describe("observableWithDefault", function() {
it("should be an observable with a default", function() {
var foo = ko.observable();
var bar1 = koUtil.observableWithDefault(foo, 'defaultValue');
var obj = { prop: 17 };
var bar2 = koUtil.observableWithDefault(foo, function() { return this.prop; }, obj);
assert.equal(bar1(), 'defaultValue');
assert.equal(bar2(), 17);
foo('hello');
assert.equal(bar1(), 'hello');
assert.equal(bar2(), 'hello');
obj.prop = 28;
foo(0);
assert.equal(bar1(), 'defaultValue');
assert.equal(bar2(), 28);
bar1('world');
assert.equal(foo(), 'world');
assert.equal(bar1(), 'world');
assert.equal(bar2(), 'world');
bar2('blah');
assert.equal(foo(), 'blah');
assert.equal(bar1(), 'blah');
assert.equal(bar2(), 'blah');
bar1(null);
assert.equal(foo(), null);
assert.equal(bar1(), 'defaultValue');
assert.equal(bar2(), 28);
});
});
describe('computedAutoDispose', function() {
function testAutoDisposeValue(pure) {
var obj = [{dispose: sinon.spy()}, {dispose: sinon.spy()}, {dispose: sinon.spy()}];
var which = ko.observable(0);
var computedBody = sinon.spy(function() { return obj[which()]; });
var foo = koUtil.computedAutoDispose({ read: computedBody, pure: pure });
// An important difference between pure and not is whether it is immediately evaluated.
assert.equal(computedBody.callCount, pure ? 0 : 1);
assert.strictEqual(foo(), obj[0]);
assert.equal(computedBody.callCount, 1);
which(1);
assert.strictEqual(foo(), obj[1]);
assert.equal(computedBody.callCount, 2);
assert.equal(obj[0].dispose.callCount, 1);
assert.equal(obj[1].dispose.callCount, 0);
// Another difference is whether changes cause immediate re-evaluation.
which(2);
assert.equal(computedBody.callCount, pure ? 2 : 3);
assert.equal(obj[1].dispose.callCount, pure ? 0 : 1);
foo.dispose();
assert.equal(obj[0].dispose.callCount, 1);
assert.equal(obj[1].dispose.callCount, 1);
assert.equal(obj[2].dispose.callCount, pure ? 0 : 1);
}
it("autoDisposeValue for pure computed should be pure", function() {
testAutoDisposeValue(true);
});
it("autoDisposeValue for non-pure computed should be non-pure", function() {
testAutoDisposeValue(false);
});
});
describe('computedBuilder', function() {
it("should create appropriate dependencies and dispose values", function() {
var index = ko.observable(0);
var foo = ko.observable('foo'); // used in the builder's constructor
var faz = ko.observable('faz'); // used in the builder's dispose
var obj = [{dispose: sinon.spy(() => faz())}, {dispose: sinon.spy(() => faz())}];
var builder = sinon.spy(function(i) { obj[i].foo = foo(); return obj[i]; });
// The built observable should depend on index(), should NOT depend on foo() or faz(), and
// returned values should get disposed.
var built = koUtil.computedBuilder(function() { return builder.bind(null, index()); });
assert.equal(builder.callCount, 1);
assert.strictEqual(built(), obj[0]);
assert.equal(built().foo, 'foo');
foo('bar');
assert.equal(builder.callCount, 1);
faz('baz');
assert.equal(builder.callCount, 1);
// Changing index should dispose the previous value and rebuild.
index(1);
assert.equal(obj[0].dispose.callCount, 1);
assert.equal(builder.callCount, 2);
assert.strictEqual(built(), obj[1]);
assert.equal(built().foo, 'bar');
// Changing foo() or faz() should continue to have no effect (i.e. disposing the previous
// value should not have created any dependencies.)
foo('foo');
assert.equal(builder.callCount, 2);
faz('faz');
assert.equal(builder.callCount, 2);
// Disposing the built observable should dispose the last returned value.
assert.equal(obj[1].dispose.callCount, 0);
built.dispose();
assert.equal(obj[1].dispose.callCount, 1);
});
});
});

@ -0,0 +1,48 @@
import {localStorageBoolObs, localStorageObs} from 'app/client/lib/localStorageObs';
import {assert} from 'chai';
import {setTmpMochaGlobals} from 'test/client/clientUtil';
describe('localStorageObs', function() {
setTmpMochaGlobals();
before(() => typeof localStorage !== 'undefined' ? localStorage.clear() : null);
it('should persist localStorageObs values', async function() {
const foo = localStorageObs('localStorageObs-foo');
const bar = localStorageObs('localStorageObs-bar');
assert.strictEqual(foo.get(), null);
foo.set("123");
bar.set("456");
assert.strictEqual(foo.get(), "123");
assert.strictEqual(bar.get(), "456");
// We can't really reload the window the way that the browser harness for test/client tests
// works, so just test in the same process with a new instance of these observables.
const foo2 = localStorageObs('localStorageObs-foo');
const bar2 = localStorageObs('localStorageObs-bar');
assert.strictEqual(foo2.get(), "123");
assert.strictEqual(bar2.get(), "456");
});
for (const defl of [false, true]) {
it(`should support localStorageBoolObs with default of ${defl}`, async function() {
const prefix = `localStorageBoolObs-${defl}`;
const foo = localStorageBoolObs(`${prefix}-foo`, defl);
const bar = localStorageBoolObs(`${prefix}-bar`, defl);
assert.strictEqual(foo.get(), defl);
assert.strictEqual(bar.get(), defl);
foo.set(true);
bar.set(false);
assert.strictEqual(foo.get(), true);
assert.strictEqual(bar.get(), false);
assert.strictEqual(localStorageBoolObs(`${prefix}-foo`, defl).get(), true);
assert.strictEqual(localStorageBoolObs(`${prefix}-bar`, defl).get(), false);
// If created with the opposite default value, it's not very intuitive: if its value matched
// the previous default value, then now it will be the opposite; if its value were flipped,
// then now it would stay flipped. So it'll match the new default value in either case.
assert.strictEqual(localStorageBoolObs(`${prefix}-foo`, !defl).get(), !defl);
assert.strictEqual(localStorageBoolObs(`${prefix}-bar`, !defl).get(), !defl);
});
}
});

@ -0,0 +1,179 @@
import { Sort } from 'app/common/SortSpec';
import { assert } from 'chai';
const { flipSort: flipColDirection, parseSortColRefs, reorderSortRefs } = Sort;
describe('sortUtil', function () {
it('should parse column expressions', function () {
assert.deepEqual(Sort.getColRef(1), 1);
assert.deepEqual(Sort.getColRef(-1), 1);
assert.deepEqual(Sort.getColRef('-1'), 1);
assert.deepEqual(Sort.getColRef('1'), 1);
assert.deepEqual(Sort.getColRef('1:emptyLast'), 1);
assert.deepEqual(Sort.getColRef('-1:emptyLast'), 1);
assert.deepEqual(Sort.getColRef('-1:emptyLast;orderByChoice'), 1);
assert.deepEqual(Sort.getColRef('1:emptyLast;orderByChoice'), 1);
});
it('should support finding', function () {
assert.equal(Sort.findCol([1, 2, 3], 1), 1);
assert.equal(Sort.findCol([1, 2, 3], '1'), 1);
assert.equal(Sort.findCol([1, 2, 3], '-1'), 1);
assert.equal(Sort.findCol([1, 2, 3], '1'), 1);
assert.equal(Sort.findCol(['1', 2, 3], 1), '1');
assert.equal(Sort.findCol(['1:emptyLast', 2, 3], 1), '1:emptyLast');
assert.equal(Sort.findCol([1, 2, 3], '1:emptyLast'), 1);
assert.equal(Sort.findCol([1, 2, 3], '-1:emptyLast'), 1);
assert.isUndefined(Sort.findCol([1, 2, 3], '6'));
assert.isUndefined(Sort.findCol([1, 2, 3], 6));
assert.equal(Sort.findColIndex([1, 2, 3], '6'), -1);
assert.equal(Sort.findColIndex([1, 2, 3], 6), -1);
assert.isTrue(Sort.contains([1, 2, 3], 1, Sort.ASC));
assert.isFalse(Sort.contains([-1, 2, 3], 1, Sort.ASC));
assert.isTrue(Sort.contains([-1, 2, 3], 1, Sort.DESC));
assert.isTrue(Sort.contains(['1', 2, 3], 1, Sort.ASC));
assert.isTrue(Sort.contains(['1:emptyLast', 2, 3], 1, Sort.ASC));
assert.isFalse(Sort.contains(['-1:emptyLast', 2, 3], 1, Sort.ASC));
assert.isTrue(Sort.contains(['-1:emptyLast', 2, 3], 1, Sort.DESC));
assert.isTrue(Sort.containsOnly([1], 1, Sort.ASC));
assert.isTrue(Sort.containsOnly([-1], 1, Sort.DESC));
assert.isFalse(Sort.containsOnly([1, 2], 1, Sort.ASC));
assert.isFalse(Sort.containsOnly([2, 1], 1, Sort.ASC));
assert.isFalse(Sort.containsOnly([2, 1], 1, Sort.DESC));
assert.isFalse(Sort.containsOnly([-1], 1, Sort.ASC));
assert.isFalse(Sort.containsOnly([1], 1, Sort.DESC));
assert.isTrue(Sort.containsOnly(['1:emptyLast'], 1, Sort.ASC));
assert.isFalse(Sort.containsOnly(['1:emptyLast', 2], 1, Sort.ASC));
assert.isTrue(Sort.containsOnly(['-1:emptyLast'], 1, Sort.DESC));
assert.isFalse(Sort.containsOnly(['-1:emptyLast'], 1, Sort.ASC));
assert.isFalse(Sort.containsOnly(['1:emptyLast'], 1, Sort.DESC));
});
it('should support swapping', function () {
assert.deepEqual(Sort.swapColRef(1, 2), 2);
assert.deepEqual(Sort.swapColRef(-1, 2), -2);
assert.deepEqual(Sort.swapColRef('1', 2), 2);
assert.deepEqual(Sort.swapColRef('-1', 2), -2);
assert.deepEqual(Sort.swapColRef('-1:emptyLast', 2), '-2:emptyLast');
});
it('should create column expressions', function () {
assert.deepEqual(Sort.setColDirection(2, Sort.ASC), 2);
assert.deepEqual(Sort.setColDirection(-2, Sort.ASC), 2);
assert.deepEqual(Sort.setColDirection(-2, Sort.DESC), -2);
assert.deepEqual(Sort.setColDirection('2', Sort.ASC), 2);
assert.deepEqual(Sort.setColDirection('-2', Sort.ASC), 2);
assert.deepEqual(Sort.setColDirection('-2:emptyLast', Sort.ASC), '2:emptyLast');
assert.deepEqual(Sort.setColDirection('2:emptyLast', Sort.ASC), '2:emptyLast');
assert.deepEqual(Sort.setColDirection(2, Sort.DESC), -2);
assert.deepEqual(Sort.setColDirection(-2, Sort.DESC), -2);
assert.deepEqual(Sort.setColDirection('2', Sort.DESC), -2);
assert.deepEqual(Sort.setColDirection('-2', Sort.DESC), -2);
assert.deepEqual(Sort.setColDirection('-2:emptyLast', Sort.DESC), '-2:emptyLast');
assert.deepEqual(Sort.setColDirection('2:emptyLast', Sort.DESC), '-2:emptyLast');
});
const empty = { emptyLast: false, orderByChoice: false, naturalSort: false };
it('should parse details', function () {
assert.deepEqual(Sort.specToDetails(2), { colRef: 2, direction: Sort.ASC });
assert.deepEqual(Sort.specToDetails(-2), { colRef: 2, direction: Sort.DESC });
assert.deepEqual(Sort.specToDetails('-2:emptyLast'),
{ ...empty, colRef: 2, direction: Sort.DESC, emptyLast: true });
assert.deepEqual(Sort.specToDetails('-2:emptyLast;orderByChoice'), {
...empty,
colRef: 2,
direction: Sort.DESC,
emptyLast: true,
orderByChoice: true,
});
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.ASC }), 2);
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.DESC }), -2);
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.ASC, emptyLast: true }), '2:emptyLast');
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.DESC, emptyLast: true }), '-2:emptyLast');
assert.deepEqual(
Sort.detailsToSpec({ colRef: 1, direction: Sort.DESC, emptyLast: true, orderByChoice: true }),
'-1:emptyLast;orderByChoice'
);
});
it('should parse names', function () {
const cols = new Map(Object.entries({ a: 1, id: 0 }));
assert.deepEqual(Sort.parseNames(['1'], cols), ['1']);
assert.deepEqual(Sort.parseNames(['0'], cols), ['0']);
assert.deepEqual(Sort.parseNames(['id'], cols), ['0']);
assert.deepEqual(Sort.parseNames(['-id'], cols), ['-0']);
assert.deepEqual(Sort.parseNames(['-1'], cols), ['-1']);
assert.deepEqual(Sort.parseNames(['a'], cols), ['1']);
assert.deepEqual(Sort.parseNames(['-a'], cols), ['-1']);
assert.deepEqual(Sort.parseNames(['a:flag'], cols), ['1:flag']);
assert.deepEqual(Sort.parseNames(['-a:flag'], cols), ['-1:flag']);
assert.deepEqual(Sort.parseNames(['-a:flag'], cols), ['-1:flag']);
assert.throws(() => Sort.parseNames(['-a:flag'], new Map()));
});
it('should produce correct results with flipColDirection', function () {
// Should flip given sortRef.
// Column direction should not matter
assert.deepEqual(flipColDirection([1, 2, 3], 3), [1, 2, -3]);
assert.deepEqual(flipColDirection([1, 2, -3], -3), [1, 2, 3]);
assert.deepEqual(flipColDirection([1], 1), [-1]);
assert.deepEqual(flipColDirection([8, -3, 2, 5, -7, -12, 33], -7), [8, -3, 2, 5, 7, -12, 33]);
assert.deepEqual(flipColDirection([5, 4, 9, -2, -3, -6, -1], 4), [5, -4, 9, -2, -3, -6, -1]);
assert.deepEqual(flipColDirection([-1, -2, -3], -2), [-1, 2, -3]);
// Should return original when sortRef not found.
assert.deepEqual(flipColDirection([1, 2, 3], 4), [1, 2, 3]);
assert.deepEqual(flipColDirection([], 8), []);
assert.deepEqual(flipColDirection([1], 4), [1]);
assert.deepEqual(flipColDirection([-1], 2), [-1]);
});
it('should produce correct results with parseSortColRefs', function () {
// Should parse correctly.
assert.deepEqual(parseSortColRefs('[1, 2, 3]'), [1, 2, 3]);
assert.deepEqual(parseSortColRefs('[]'), []);
assert.deepEqual(parseSortColRefs('[4, 12, -3, -2, -1, 18]'), [4, 12, -3, -2, -1, 18]);
// Should return empty array on parse failure.
assert.deepEqual(parseSortColRefs('3]'), []);
assert.deepEqual(parseSortColRefs('1, 2, 3'), []);
assert.deepEqual(parseSortColRefs('[12; 16; 18]'), []);
});
it('should produce correct results with reorderSortRefs', function () {
// Should reorder correctly.
assert.deepEqual(reorderSortRefs([1, 2, 3], 2, 1), [2, 1, 3]);
assert.deepEqual(reorderSortRefs([12, 2, -4, -5, 6, 8], -4, 8), [12, 2, -5, 6, -4, 8]);
assert.deepEqual(reorderSortRefs([15, 3, -4, 2, 18], 15, -4), [3, 15, -4, 2, 18]);
assert.deepEqual(reorderSortRefs([-12, 22, 1, 4], 1, 4), [-12, 22, 1, 4]);
assert.deepEqual(reorderSortRefs([1, 2, 3], 2, null), [1, 3, 2]);
assert.deepEqual(reorderSortRefs([4, 3, -2, 5, -8, -9], 3, null), [4, -2, 5, -8, -9, 3]);
assert.deepEqual(reorderSortRefs([-2, 8, -6, -5, 18], 8, 2), [8, -2, -6, -5, 18]);
// Should return original array with invalid input.
assert.deepEqual(reorderSortRefs([1, 2, 3], 2, 4), [1, 2, 3]);
assert.deepEqual(reorderSortRefs([-5, -4, 6], 3, null), [-5, -4, 6]);
});
it('should flip columns', function () {
assert.deepEqual(Sort.flipCol('1:emptyLast'), '-1:emptyLast');
assert.deepEqual(Sort.flipCol('-1:emptyLast'), '1:emptyLast');
assert.deepEqual(Sort.flipCol(2), -2);
assert.deepEqual(Sort.flipCol(-2), 2);
assert.deepEqual(Sort.flipSort([-2], 2), [2]);
assert.deepEqual(Sort.flipSort([2], 2), [-2]);
assert.deepEqual(Sort.flipSort([2, 1], 2), [-2, 1]);
assert.deepEqual(Sort.flipSort([-2, -1], 2), [2, -1]);
assert.deepEqual(Sort.flipSort(['-2:emptyLast', -1], 2), ['2:emptyLast', -1]);
assert.deepEqual(Sort.flipSort(['2:emptyLast', -1], 2), ['-2:emptyLast', -1]);
assert.deepEqual(Sort.flipSort(['2:emptyLast', -1], '2'), ['-2:emptyLast', -1]);
assert.deepEqual(Sort.flipSort(['2:emptyLast', -1], '-2'), ['-2:emptyLast', -1]);
assert.deepEqual(Sort.flipSort(['2:emptyLast', -1], '-2:emptyLast'), ['-2:emptyLast', -1]);
assert.deepEqual(Sort.flipSort(['2:emptyLast', -1], '2:emptyLast'), ['-2:emptyLast', -1]);
});
});

@ -0,0 +1,52 @@
import {hashFnv32a, simpleStringHash} from 'app/client/lib/textUtils';
import {assert} from 'chai';
describe('textUtils', function() {
it('hashFnv32a should produce correct hashes', function() {
// Test 32-bit for various strings
function check(s: string, expected: number) {
assert.equal(hashFnv32a(s), expected.toString(16).padStart(8, '0'));
}
// Based on https://github.com/sindresorhus/fnv1a/blob/053a8cb5a0f99212e71acb73a47823f26081b6e9/test.js
check((''), 2_166_136_261);
check(('h'), 3_977_000_791);
check(('he'), 1_547_363_254);
check(('hel'), 179_613_742);
check(('hell'), 477_198_310);
check(('hello'), 1_335_831_723);
check(('hello '), 3_801_292_497);
check(('hello w'), 1_402_552_146);
check(('hello wo'), 3_611_200_775);
check(('hello wor'), 1_282_977_583);
check(('hello worl'), 2_767_971_961);
check(('hello world'), 3_582_672_807);
check('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. ' +
'Aenean commodo ligula eget dolor. Aenean massa. ' +
'Cum sociis natoque penatibus et magnis dis parturient montes, ' +
'nascetur ridiculus mus. Donec quam felis, ultricies nec, ' +
'pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. ' +
'Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. ' +
'In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. ' +
'Nullam dictum felis eu pede mollis pretium. ' +
'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. ' +
'Aenean commodo ligula eget dolor. Aenean massa. ' +
'Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. ' +
'Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. ' +
'Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, ' +
'vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. ' +
'Nullam dictum felis eu pede mollis pretium. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. ' +
'Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient ' +
'montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. ' +
'Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. ' +
'In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium.',
2_964_896_417);
});
it('simpleStringHash should produce correct hashes', function() {
// Not based on anything, just need to know if it changes
assert.equal(simpleStringHash("hello"), "4f9f2cab3cfabf04ee7da04597168630");
});
});

@ -0,0 +1,180 @@
import {allInclusive, ColumnFilter} from 'app/client/models/ColumnFilter';
import {GristObjCode} from 'app/plugin/GristData';
import {CellValue} from 'app/common/DocActions';
import {assert} from 'chai';
const L = GristObjCode.List;
describe('ColumnFilter', function() {
it('should properly initialize from JSON spec', async function() {
let filter = new ColumnFilter('{ "excluded": ["Alice", "Bob"] }');
assert.isFalse(filter.includes('Alice'));
assert.isFalse(filter.includes('Bob'));
assert.isTrue(filter.includes('Carol'));
filter = new ColumnFilter('{ "included": ["Alice", "Bob"] }');
assert.isTrue(filter.includes('Alice'));
assert.isTrue(filter.includes('Bob'));
assert.isFalse(filter.includes('Carol'));
filter = new ColumnFilter('');
assert.isTrue(filter.includes('Alice'));
assert.isTrue(filter.includes('Bob'));
assert.isTrue(filter.includes('Carol'));
});
it('should allow adding and removing values to existing filter', async function() {
let filter = new ColumnFilter('{ "excluded": ["Alice", "Bob"] }');
assert.isFalse(filter.includes('Alice'));
assert.isFalse(filter.includes('Bob'));
assert.isTrue(filter.includes('Carol'));
filter.add('Alice');
filter.add('Carol');
assert.isTrue(filter.includes('Alice'));
assert.isFalse(filter.includes('Bob'));
assert.isTrue(filter.includes('Carol'));
filter.delete('Carol');
assert.isTrue(filter.includes('Alice'));
assert.isFalse(filter.includes('Bob'));
assert.isFalse(filter.includes('Carol'));
filter = new ColumnFilter('{ "included": ["Alice", "Bob"] }');
assert.isTrue(filter.includes('Alice'));
assert.isTrue(filter.includes('Bob'));
assert.isFalse(filter.includes('Carol'));
filter.delete('Alice');
filter.add('Carol');
assert.isFalse(filter.includes('Alice'));
assert.isTrue(filter.includes('Bob'));
assert.isTrue(filter.includes('Carol'));
});
it('should generate an all-inclusive filter from empty string or null', async function() {
const filter = new ColumnFilter('');
const defaultJson = filter.makeFilterJson();
assert.equal(defaultJson, allInclusive);
filter.clear();
assert.equal(filter.makeFilterJson(), '{"included":[]}');
filter.selectAll();
assert.equal(filter.makeFilterJson(), defaultJson);
// Check that the string 'null' initializes properly
assert.equal(new ColumnFilter('null').makeFilterJson(), allInclusive);
});
it('should generate a proper FilterFunc and JSON string', async function() {
const data = ['Carol', 'Alice', 'Bar', 'Bob', 'Alice', 'Baz'];
const filterJson = '{"included":["Alice","Bob"]}';
const filter = new ColumnFilter(filterJson);
assert.equal(filter.makeFilterJson(), filterJson);
assert.deepEqual(data.filter(filter.filterFunc.get()), ['Alice', 'Bob', 'Alice']);
assert.isFalse(filter.hasChanged()); // `hasChanged` compares to the original JSON used to initialize ColumnFilter
filter.add('Carol');
assert.equal(filter.makeFilterJson(), '{"included":["Alice","Bob","Carol"]}');
assert.deepEqual(data.filter(filter.filterFunc.get()), ['Carol', 'Alice', 'Bob', 'Alice']);
assert.isTrue(filter.hasChanged());
filter.delete('Alice');
assert.equal(filter.makeFilterJson(), '{"included":["Bob","Carol"]}');
assert.deepEqual(data.filter(filter.filterFunc.get()), ['Carol', 'Bob']);
assert.isTrue(filter.hasChanged());
filter.selectAll();
assert.equal(filter.makeFilterJson(), '{"excluded":[]}');
assert.deepEqual(data.filter(filter.filterFunc.get()), data);
assert.isTrue(filter.hasChanged());
filter.add('Alice');
assert.equal(filter.makeFilterJson(), '{"excluded":[]}');
assert.deepEqual(data.filter(filter.filterFunc.get()), data);
assert.isTrue(filter.hasChanged());
filter.delete('Alice');
assert.equal(filter.makeFilterJson(), '{"excluded":["Alice"]}');
assert.deepEqual(data.filter(filter.filterFunc.get()), ['Carol', 'Bar', 'Bob', 'Baz']);
assert.isTrue(filter.hasChanged());
filter.clear();
assert.equal(filter.makeFilterJson(), '{"included":[]}');
assert.deepEqual(data.filter(filter.filterFunc.get()), []);
assert.isTrue(filter.hasChanged());
filter.add('Alice');
assert.equal(filter.makeFilterJson(), '{"included":["Alice"]}');
assert.deepEqual(data.filter(filter.filterFunc.get()), ['Alice', 'Alice']);
assert.isTrue(filter.hasChanged());
filter.add('Bob');
assert.equal(filter.makeFilterJson(), '{"included":["Alice","Bob"]}');
assert.deepEqual(data.filter(filter.filterFunc.get()), ['Alice', 'Bob', 'Alice']);
assert.isFalse(filter.hasChanged()); // We're back to the same state, so `hasChanged()` should be false
});
it('should generate a proper FilterFunc for Choice List columns', async function() {
const data: CellValue[] = [[L, 'Alice', 'Carol'], [L, 'Alice', 'Bob'], [L, 'Bar'], [L, 'Bob'], null];
const filterJson = '{"included":["Alice","Bob"]}';
const filter = new ColumnFilter(filterJson, 'ChoiceList');
assert.equal(filter.makeFilterJson(), filterJson);
assert.deepEqual(data.filter(filter.filterFunc.get()),
[[L, 'Alice', 'Carol'], [L, 'Alice', 'Bob'], [L, 'Bob']]);
assert.isFalse(filter.hasChanged()); // `hasChanged` compares to the original JSON used to initialize ColumnFilter
filter.add('Bar');
assert.equal(filter.makeFilterJson(), '{"included":["Alice","Bar","Bob"]}');
assert.deepEqual(data.filter(filter.filterFunc.get()),
[[L, 'Alice', 'Carol'], [L, 'Alice', 'Bob'], [L, 'Bar'], [L, 'Bob']]);
assert.isTrue(filter.hasChanged());
filter.delete('Alice');
assert.equal(filter.makeFilterJson(), '{"included":["Bar","Bob"]}');
assert.deepEqual(data.filter(filter.filterFunc.get()),
[[L, 'Alice', 'Bob'], [L, 'Bar'], [L, 'Bob']]);
assert.isTrue(filter.hasChanged());
filter.selectAll();
assert.equal(filter.makeFilterJson(), '{"excluded":[]}');
assert.deepEqual(data.filter(filter.filterFunc.get()), data);
assert.isTrue(filter.hasChanged());
filter.add('Alice');
assert.equal(filter.makeFilterJson(), '{"excluded":[]}');
assert.deepEqual(data.filter(filter.filterFunc.get()), data);
assert.isTrue(filter.hasChanged());
filter.delete('Alice');
assert.equal(filter.makeFilterJson(), '{"excluded":["Alice"]}');
assert.deepEqual(data.filter(filter.filterFunc.get()),
[[L, 'Alice', 'Carol'], [L, 'Alice', 'Bob'], [L, 'Bar'], [L, 'Bob'], null]);
assert.isTrue(filter.hasChanged());
filter.clear();
assert.equal(filter.makeFilterJson(), '{"included":[]}');
assert.deepEqual(data.filter(filter.filterFunc.get()), []);
assert.isTrue(filter.hasChanged());
filter.add('Alice');
assert.equal(filter.makeFilterJson(), '{"included":["Alice"]}');
assert.deepEqual(data.filter(filter.filterFunc.get()),
[[L, 'Alice', 'Carol'], [L, 'Alice', 'Bob']]);
assert.isTrue(filter.hasChanged());
filter.add('Bob');
assert.equal(filter.makeFilterJson(), '{"included":["Alice","Bob"]}');
assert.deepEqual(data.filter(filter.filterFunc.get()),
[[L, 'Alice', 'Carol'], [L, 'Alice', 'Bob'], [L, 'Bob']]);
assert.isFalse(filter.hasChanged()); // We're back to the same state, so `hasChanged()` should be false
});
});

@ -0,0 +1,22 @@
import {getTimeFromNow} from 'app/client/models/HomeModel';
import {assert} from 'chai';
import moment from 'moment';
describe("HomeModel", function() {
describe("getTimeFromNow", function() {
it("should give good summary of time that just passed", function() {
const t = moment().subtract(10, 's');
assert.equal(getTimeFromNow(t.toISOString()), 'a few seconds ago');
});
it("should gloss over times slightly in future", function() {
const t = moment().add(2, 's');
assert.equal(getTimeFromNow(t.toISOString()), 'a few seconds ago');
});
it("should not gloss over times further in future", function() {
const t = moment().add(2, 'minutes');
assert.equal(getTimeFromNow(t.toISOString()), 'in 2 minutes');
});
});
});

@ -0,0 +1,226 @@
import { TableData } from "app/client/models/TableData";
import { find, fixIndents, fromTableData, TreeItemRecord, TreeNodeRecord } from "app/client/models/TreeModel";
import { nativeCompare } from "app/common/gutil";
import { assert } from "chai";
import flatten = require("lodash/flatten");
import noop = require("lodash/noop");
import sinon = require("sinon");
const buildDom = noop as any;
interface TreeRecord { indentation: number; id: number; name: string; pagePos: number; }
// builds a tree model from ['A0', 'B1', ...] where 'A0' reads {id: 'A', indentation: 0}. Spy on
function simpleArray(array: string[]) {
return array.map((s: string, id: number) => ({ id, name: s[0], indentation: Number(s[1]), pagePos: id }));
}
function toSimpleArray(records: TreeRecord[]) {
return records.map((rec) => rec.name + rec.indentation);
}
// return ['a', ['b']] if item has name 'a' and one children with name 'b'.
function toArray(item: any) {
const name = item.storage.records[item.index].name;
const children = flatten(item.children().get().map(toArray));
return children.length ? [name, children] : [name];
}
function toJson(model: any) {
return JSON.stringify(flatten(model.children().get().map(toArray)));
}
function findItems(model: TreeNodeRecord, names: string[]) {
return names.map(name => findItem(model, name));
}
function findItem(model: TreeNodeRecord, name: string) {
return find(model, (item: TreeItemRecord) => item.storage.records[item.index].name === name)!;
}
function testActions(records: TreeRecord[], actions: {update?: TreeRecord[], remove?: TreeRecord[]}) {
const update = actions.update || [];
const remove = actions.remove || [];
if (remove.length) {
const ids = remove.map(rec => rec.id);
records = records.filter(rec => !ids.includes(rec.id));
}
if (update.length) {
// In reality, the handling of pagePos is done by the sandbox (see relabeling.py, which is
// quite complicated to handle updates of large tables efficiently). Here we simulate it in a
// very simple way. The important property is that new pagePos values equal to existing ones
// are inserted immediately before the existing ones.
const map = new Map(update.map(rec => [rec.id, rec]));
const newRecords = update.map(rec => ({...rec, pagePos: rec.pagePos ?? Infinity}));
newRecords.push(...records.filter(rec => !map.has(rec.id)));
newRecords.sort((a, b) => nativeCompare(a.pagePos, b.pagePos));
records = newRecords.map((rec, i) => ({...rec, pagePos: i}));
}
return toSimpleArray(records);
}
describe('TreeModel', function() {
let table: any;
let sendActionsSpy: any;
let records: TreeRecord[];
before(function() {
table = sinon.createStubInstance(TableData);
table.getRecords.callsFake(() => records);
sendActionsSpy = sinon.spy(TreeNodeRecord.prototype, 'sendActions');
});
after(function() {
sendActionsSpy.restore();
});
afterEach(function() {
sendActionsSpy.resetHistory();
});
it('fixIndent should work correctly', function() {
function fix(items: string[]) {
const recs = items.map((item, id) => ({id, indentation: Number(item[1]), name: item[0], pagePos: id}));
return fixIndents(recs).map((rec) => rec.name + rec.indentation);
}
assert.deepEqual(fix(["A0", "B2"]), ["A0", "B1"]);
assert.deepEqual(fix(["A0", "B3", "C3"]), ["A0", "B1", "C2"]);
assert.deepEqual(fix(["A3", "B1"]), ["A0", "B1"]);
// should not change when indentation is already correct
assert.deepEqual(fix(['A0', 'B1', 'C0', 'D1', 'E2', 'F0']), ['A0', 'B1', 'C0', 'D1', 'E2', 'F0']);
});
describe("fromTableData", function() {
it('should build correct model', function() {
records = simpleArray(['A0', 'B1', 'C0', 'D1', 'E2', 'F0']);
const model = fromTableData(table, buildDom);
assert.equal(toJson(model), JSON.stringify(['A', ['B'], 'C', ['D', ['E']], 'F']));
});
it('should build correct model even with gaps in indentation', function() {
records = simpleArray(['A0', 'B3', 'C3']);
const model = fromTableData(table, buildDom);
assert.equal(toJson(model), JSON.stringify(['A', ['B', ['C']]]));
});
it('should sort records', function() {
records = simpleArray(['A0', 'B1', 'C0', 'D1', 'E2', 'F0']);
// let's shuffle records
records = [2, 3, 5, 1, 4, 0].map(i => records[i]);
// check that it's shuffled
assert.deepEqual(toSimpleArray(records), ['C0', 'D1', 'F0', 'B1', 'E2', 'A0']);
const model = fromTableData(table, buildDom);
assert.equal(toJson(model), JSON.stringify(['A', ['B'], 'C', ['D', ['E']], 'F']));
});
it('should reuse item from optional oldModel', function() {
// create a model
records = simpleArray(['A0', 'B1', 'C0']);
const oldModel = fromTableData(table, buildDom);
assert.deepEqual(oldModel.storage.records.map(r => r.id), [0, 1, 2]);
const items = findItems(oldModel, ['A', 'B', 'C']);
// create a new model with overlap in ids
records = simpleArray(['A0', 'B0', 'C1', 'D0']);
const model = fromTableData(table, buildDom, oldModel);
assert.deepEqual(model.storage.records.map(r => r.id), [0, 1, 2, 3]);
// item with same ids should be the same
assert.deepEqual(findItems(model, ['A', 'B', 'C']), items);
// new model is correct
assert.equal(toJson(model), JSON.stringify(['A', 'B', ['C'], 'D']));
});
});
describe("TreeNodeRecord", function() {
it("removeChild(...) should work properly", async function() {
records = simpleArray(['A0', 'B1', 'C0', 'D1', 'E2', 'F0']);
const model = fromTableData(table, buildDom);
await model.removeChild(model.children().get()[1]);
const [C, D, E] = [2, 3, 4].map(i => records[i]);
const actions = sendActionsSpy.getCall(0).args[0];
assert.deepEqual(actions, {remove: [C, D, E]});
assert.deepEqual(testActions(records, actions), ['A0', 'B1', 'F0']);
});
describe("insertBefore", function() {
it("should insert before a child properly", async function() {
records = simpleArray(['A0', 'B1', 'C0', 'D1', 'E2', 'F0']);
const model = fromTableData(table, buildDom);
const F = model.children().get()[2];
const C = model.children().get()[1];
await model.insertBefore(F, C);
const actions = sendActionsSpy.getCall(0).args[0];
assert.deepEqual(actions, {update: [{...records[5], pagePos: 2}]});
assert.deepEqual(testActions(records, actions), ['A0', 'B1', 'F0', 'C0', 'D1', 'E2']);
});
it("should insert as last child correctly", async function() {
records = simpleArray(['A0', 'B1', 'C0', 'D1', 'E2', 'F0']);
const model = fromTableData(table, buildDom);
const B = findItem(model, 'B');
await model.insertBefore(B, null);
let actions = sendActionsSpy.getCall(0).args[0];
assert.deepEqual(actions, {update: [{...records[1], indentation: 0, pagePos: null}]});
assert.deepEqual(testActions(records, actions), ['A0', 'C0', 'D1', 'E2', 'F0', 'B0']);
// handle case when the last child has chidlren
const C = model.children().get()[1];
await C.insertBefore(B, null);
actions = sendActionsSpy.getCall(1).args[0];
assert.deepEqual(actions, {update: [{...records[1], indentation: 1, pagePos: 5}]});
assert.deepEqual(testActions(records, actions), ['A0', 'C0', 'D1', 'E2', 'B1', 'F0']);
});
it("should insert into a child correctly", async function() {
records = simpleArray(['A0', 'B1', 'C0', 'D1', 'E2', 'F0']);
const model = fromTableData(table, buildDom);
const A = model.children().get()[0];
const F = model.children().get()[2];
await A.insertBefore(F, null);
const actions = sendActionsSpy.getCall(0).args[0];
assert.deepEqual(actions, {update: [{...records[5], indentation: 1, pagePos: 2}]});
assert.deepEqual(testActions(records, actions), ['A0', 'B1', 'F1', 'C0', 'D1', 'E2']);
});
it("should insert item with nested children correctly", async function() {
records = simpleArray(['A0', 'B1', 'C0', 'D1', 'E2', 'F0']);
const model = fromTableData(table, buildDom);
const D = model.children().get()[1].children().get()[0];
await model.insertBefore(D, null);
const actions = sendActionsSpy.getCall(0).args[0];
assert.deepEqual(actions, {update: [{...records[3], indentation: 0, pagePos: null},
{...records[4], indentation: 1, pagePos: null}]});
assert.deepEqual(testActions(records, actions), ['A0', 'B1', 'C0', 'F0', 'D0', 'E1']);
});
});
});
});

@ -0,0 +1,362 @@
import * as log from 'app/client/lib/log';
import {HistWindow, UrlState} from 'app/client/lib/UrlState';
import {getLoginUrl, UrlStateImpl} from 'app/client/models/gristUrlState';
import {IGristUrlState} from 'app/common/gristUrls';
import {assert} from 'chai';
import {dom} from 'grainjs';
import {popGlobals, pushGlobals} from 'grainjs/dist/cjs/lib/browserGlobals';
import {JSDOM} from 'jsdom';
import clone = require('lodash/clone');
import merge = require('lodash/merge');
import omit = require('lodash/omit');
import * as sinon from 'sinon';
function assertResetCall(spy: sinon.SinonSpy, ...args: any[]): void {
sinon.assert.calledOnce(spy);
sinon.assert.calledWithExactly(spy, ...args);
spy.resetHistory();
}
describe('gristUrlState', function() {
let mockWindow: HistWindow;
// TODO add a test case where org is set, but isSingleOrg is false.
const prod = new UrlStateImpl({gristConfig: {org: undefined, baseDomain: '.example.com', pathOnly: false}});
const dev = new UrlStateImpl({gristConfig: {org: undefined, pathOnly: true}});
const single = new UrlStateImpl({gristConfig: {org: 'mars', singleOrg: 'mars', pathOnly: false}});
const custom = new UrlStateImpl({gristConfig: {org: 'mars', baseDomain: '.example.com'}});
function pushState(state: any, title: any, href: string) {
mockWindow.location = new URL(href) as unknown as Location;
}
const sandbox = sinon.createSandbox();
beforeEach(function() {
mockWindow = {
location: new URL('http://localhost:8080') as unknown as Location,
history: {pushState} as History,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => true,
};
// These grainjs browserGlobals are needed for using dom() in tests.
const jsdomDoc = new JSDOM("<!doctype html><html><body></body></html>");
pushGlobals(jsdomDoc.window);
sandbox.stub(log, 'debug');
});
afterEach(function() {
popGlobals();
sandbox.restore();
});
it('should decode state in URLs correctly', function() {
assert.deepEqual(prod.decodeUrl(new URL('http://localhost:8080')), {});
assert.deepEqual(prod.decodeUrl(new URL('http://localhost:8080/ws/12')), {ws: 12});
assert.deepEqual(prod.decodeUrl(new URL('http://localhost:8080/o/foo/ws/12/')), {org: 'foo', ws: 12});
assert.deepEqual(prod.decodeUrl(new URL('http://localhost:8080/o/foo/doc/bar/p/5')),
{org: 'foo', doc: 'bar', docPage: 5});
assert.deepEqual(dev.decodeUrl(new URL('http://localhost:8080')), {});
assert.deepEqual(dev.decodeUrl(new URL('http://localhost:8080/ws/12')), {ws: 12});
assert.deepEqual(dev.decodeUrl(new URL('http://localhost:8080/o/foo/ws/12/')), {org: 'foo', ws: 12});
assert.deepEqual(dev.decodeUrl(new URL('http://localhost:8080/o/foo/doc/bar/p/5')),
{org: 'foo', doc: 'bar', docPage: 5});
assert.deepEqual(single.decodeUrl(new URL('http://localhost:8080')), {org: 'mars'});
assert.deepEqual(single.decodeUrl(new URL('http://localhost:8080/ws/12')), {org: 'mars', ws: 12});
assert.deepEqual(single.decodeUrl(new URL('http://localhost:8080/o/foo/ws/12/')), {org: 'foo', ws: 12});
assert.deepEqual(single.decodeUrl(new URL('http://localhost:8080/o/foo/doc/bar/p/5')),
{org: 'foo', doc: 'bar', docPage: 5});
assert.deepEqual(prod.decodeUrl(new URL('https://bar.example.com')), {org: 'bar'});
assert.deepEqual(prod.decodeUrl(new URL('https://bar.example.com/ws/12/')), {org: 'bar', ws: 12});
assert.deepEqual(prod.decodeUrl(new URL('https://foo.example.com/o/baz/ws/12')), {org: 'baz', ws: 12});
assert.deepEqual(prod.decodeUrl(new URL('https://foo.example.com/')), {org: 'foo'});
assert.deepEqual(dev.decodeUrl(new URL('https://bar.example.com')), {});
assert.deepEqual(dev.decodeUrl(new URL('https://bar.example.com/ws/12/')), {ws: 12});
assert.deepEqual(dev.decodeUrl(new URL('https://foo.example.com/o/baz/ws/12')), {org: 'baz', ws: 12});
assert.deepEqual(dev.decodeUrl(new URL('https://foo.example.com/')), {});
assert.deepEqual(single.decodeUrl(new URL('https://bar.example.com')), {org: 'mars'});
assert.deepEqual(single.decodeUrl(new URL('https://bar.example.com/ws/12/')), {org: 'mars', ws: 12});
assert.deepEqual(single.decodeUrl(new URL('https://foo.example.com/o/baz/ws/12')), {org: 'baz', ws: 12});
assert.deepEqual(single.decodeUrl(new URL('https://foo.example.com/')), {org: 'mars'});
// Trash page
assert.deepEqual(prod.decodeUrl(new URL('https://bar.example.com/p/trash')), {org: 'bar', homePage: 'trash'});
// Billing routes
assert.deepEqual(prod.decodeUrl(new URL('https://bar.example.com/o/baz/billing')),
{org: 'baz', billing: 'billing'});
});
it('should decode query strings in URLs correctly', function() {
assert.deepEqual(prod.decodeUrl(new URL('https://bar.example.com?billingPlan=a')),
{org: 'bar', params: {billingPlan: 'a'}});
assert.deepEqual(prod.decodeUrl(new URL('https://foo.example.com/o/baz/ws/12?billingPlan=b')),
{org: 'baz', ws: 12, params: {billingPlan: 'b'}});
assert.deepEqual(prod.decodeUrl(new URL('https://bar.example.com/o/foo/doc/bar/p/5?billingPlan=e')),
{org: 'foo', doc: 'bar', docPage: 5, params: {billingPlan: 'e'}});
});
it('should encode state in URLs correctly', function() {
const localBase = new URL('http://localhost:8080');
const hostBase = new URL('https://bar.example.com');
assert.equal(prod.encodeUrl({}, hostBase), 'https://bar.example.com/');
assert.equal(prod.encodeUrl({org: 'foo'}, hostBase), 'https://foo.example.com/');
assert.equal(prod.encodeUrl({ws: 12}, hostBase), 'https://bar.example.com/ws/12/');
assert.equal(prod.encodeUrl({org: 'foo', ws: 12}, hostBase), 'https://foo.example.com/ws/12/');
assert.equal(dev.encodeUrl({ws: 12}, hostBase), 'https://bar.example.com/ws/12/');
assert.equal(dev.encodeUrl({org: 'foo', ws: 12}, hostBase), 'https://bar.example.com/o/foo/ws/12/');
assert.equal(single.encodeUrl({ws: 12}, hostBase), 'https://bar.example.com/ws/12/');
assert.equal(single.encodeUrl({org: 'foo', ws: 12}, hostBase), 'https://bar.example.com/o/foo/ws/12/');
assert.equal(prod.encodeUrl({ws: 12}, localBase), 'http://localhost:8080/ws/12/');
assert.equal(prod.encodeUrl({org: 'foo', ws: 12}, localBase), 'http://localhost:8080/o/foo/ws/12/');
assert.equal(prod.encodeUrl({org: 'foo', doc: 'bar'}, localBase), 'http://localhost:8080/o/foo/doc/bar');
assert.equal(prod.encodeUrl({org: 'foo', doc: 'bar', docPage: 2}, localBase),
'http://localhost:8080/o/foo/doc/bar/p/2');
assert.equal(dev.encodeUrl({ws: 12}, localBase), 'http://localhost:8080/ws/12/');
assert.equal(dev.encodeUrl({org: 'foo', ws: 12}, localBase), 'http://localhost:8080/o/foo/ws/12/');
assert.equal(dev.encodeUrl({org: 'foo', doc: 'bar'}, localBase), 'http://localhost:8080/o/foo/doc/bar');
assert.equal(single.encodeUrl({ws: 12}, localBase), 'http://localhost:8080/ws/12/');
assert.equal(single.encodeUrl({org: 'foo', ws: 12}, localBase), 'http://localhost:8080/o/foo/ws/12/');
assert.equal(single.encodeUrl({org: 'foo', doc: 'bar'}, localBase), 'http://localhost:8080/o/foo/doc/bar');
// homePage values, including the "Trash" page
assert.equal(prod.encodeUrl({homePage: 'trash'}, localBase), 'http://localhost:8080/p/trash');
assert.equal(prod.encodeUrl({homePage: 'all'}, localBase), 'http://localhost:8080/');
assert.equal(prod.encodeUrl({homePage: 'workspace', ws: 12}, localBase), 'http://localhost:8080/ws/12/');
// Billing routes
assert.equal(prod.encodeUrl({org: 'baz', billing: 'billing'}, hostBase),
'https://baz.example.com/billing');
});
it('should encode state in billing URLs correctly', function() {
const hostBase = new URL('https://bar.example.com');
assert.equal(prod.encodeUrl({params: {billingPlan: 'a'}}, hostBase),
'https://bar.example.com/?billingPlan=a');
assert.equal(prod.encodeUrl({ws: 12, params: {billingPlan: 'b'}}, hostBase),
'https://bar.example.com/ws/12/?billingPlan=b');
assert.equal(prod.encodeUrl({org: 'foo', doc: 'bar', docPage: 5, params: {billingPlan: 'e'}}, hostBase),
'https://foo.example.com/doc/bar/p/5?billingPlan=e');
});
describe('custom-domain', function() {
it('should encode state in URLs correctly', function() {
const localBase = new URL('http://localhost:8080');
const hostBase = new URL('https://www.martian.com');
assert.equal(custom.encodeUrl({}, hostBase), 'https://www.martian.com/');
assert.equal(custom.encodeUrl({org: 'foo'}, hostBase), 'https://foo.example.com/');
assert.equal(custom.encodeUrl({ws: 12}, hostBase), 'https://www.martian.com/ws/12/');
assert.equal(custom.encodeUrl({org: 'foo', ws: 12}, hostBase), 'https://foo.example.com/ws/12/');
assert.equal(custom.encodeUrl({ws: 12}, localBase), 'http://localhost:8080/ws/12/');
assert.equal(custom.encodeUrl({org: 'foo', ws: 12}, localBase), 'http://localhost:8080/o/foo/ws/12/');
assert.equal(custom.encodeUrl({org: 'foo', doc: 'bar'}, localBase), 'http://localhost:8080/o/foo/doc/bar');
assert.equal(custom.encodeUrl({org: 'foo', doc: 'bar', docPage: 2}, localBase),
'http://localhost:8080/o/foo/doc/bar/p/2');
assert.equal(custom.encodeUrl({org: 'baz', billing: 'billing'}, hostBase),
'https://baz.example.com/billing');
});
it('should encode state in billing URLs correctly', function() {
const hostBase = new URL('https://www.martian.com');
assert.equal(custom.encodeUrl({params: {billingPlan: 'a'}}, hostBase),
'https://www.martian.com/?billingPlan=a');
assert.equal(custom.encodeUrl({ws: 12, params: {billingPlan: 'b'}}, hostBase),
'https://www.martian.com/ws/12/?billingPlan=b');
assert.equal(custom.encodeUrl({org: 'foo', doc: 'bar', docPage: 5, params: {billingPlan: 'e'}}, hostBase),
'https://foo.example.com/doc/bar/p/5?billingPlan=e');
});
});
it('should produce correct results with prod config', async function() {
mockWindow.location = new URL('https://bar.example.com/ws/10/') as unknown as Location;
const state = UrlState.create(null, mockWindow, prod);
const loadPageSpy = sandbox.spy(mockWindow, '_urlStateLoadPage');
assert.deepEqual(state.state.get(), {org: 'bar', ws: 10});
const link = dom('a', state.setLinkUrl({ws: 4}));
assert.equal(link.getAttribute('href'), 'https://bar.example.com/ws/4/');
assert.equal(state.makeUrl({ws: 4}), 'https://bar.example.com/ws/4/');
assert.equal(state.makeUrl({ws: undefined}), 'https://bar.example.com/');
assert.equal(state.makeUrl({org: 'mars'}), 'https://mars.example.com/');
assert.equal(state.makeUrl({org: 'mars', doc: 'DOC', docPage: 5}), 'https://mars.example.com/doc/DOC/p/5');
// If we change workspace, that stays on the same page, so no call to loadPageSpy.
await state.pushUrl({ws: 17});
sinon.assert.notCalled(loadPageSpy);
assert.equal(mockWindow.location.href, 'https://bar.example.com/ws/17/');
assert.deepEqual(state.state.get(), {org: 'bar', ws: 17});
assert.equal(link.getAttribute('href'), 'https://bar.example.com/ws/4/');
// Loading a doc loads a new page, for now. TODO: this is expected to change ASAP, in which
// case loadPageSpy should essentially never get called.
// To simulate the loadState() on the new page, we call loadState() manually here.
await state.pushUrl({doc: 'baz'});
assertResetCall(loadPageSpy, 'https://bar.example.com/doc/baz');
state.loadState();
assert.equal(mockWindow.location.href, 'https://bar.example.com/doc/baz');
assert.deepEqual(state.state.get(), {org: 'bar', doc: 'baz'});
assert.equal(link.getAttribute('href'), 'https://bar.example.com/ws/4/');
await state.pushUrl({org: 'foo', ws: 12});
assertResetCall(loadPageSpy, 'https://foo.example.com/ws/12/');
state.loadState();
assert.equal(mockWindow.location.href, 'https://foo.example.com/ws/12/');
assert.deepEqual(state.state.get(), {org: 'foo', ws: 12});
assert.equal(state.makeUrl({ws: 4}), 'https://foo.example.com/ws/4/');
});
it('should produce correct results with single-org config', async function() {
mockWindow.location = new URL('https://example.com/ws/10/') as unknown as Location;
const state = UrlState.create(null, mockWindow, single);
const loadPageSpy = sandbox.spy(mockWindow, '_urlStateLoadPage');
assert.deepEqual(state.state.get(), {org: 'mars', ws: 10});
const link = dom('a', state.setLinkUrl({ws: 4}));
assert.equal(link.getAttribute('href'), 'https://example.com/ws/4/');
assert.equal(state.makeUrl({ws: undefined}), 'https://example.com/');
assert.equal(state.makeUrl({org: 'AB', doc: 'DOC', docPage: 5}), 'https://example.com/o/AB/doc/DOC/p/5');
await state.pushUrl({doc: 'baz'});
assertResetCall(loadPageSpy, 'https://example.com/doc/baz');
state.loadState();
assert.equal(mockWindow.location.href, 'https://example.com/doc/baz');
assert.deepEqual(state.state.get(), {org: 'mars', doc: 'baz'});
assert.equal(link.getAttribute('href'), 'https://example.com/ws/4/');
await state.pushUrl({org: 'foo'});
assertResetCall(loadPageSpy, 'https://example.com/o/foo/');
state.loadState();
assert.equal(mockWindow.location.href, 'https://example.com/o/foo/');
assert.deepEqual(state.state.get(), {org: 'foo'});
assert.equal(link.getAttribute('href'), 'https://example.com/o/foo/ws/4/');
});
it('should produce correct results with custom config', async function() {
mockWindow.location = new URL('https://example.com/ws/10/') as unknown as Location;
const state = UrlState.create(null, mockWindow, custom);
const loadPageSpy = sandbox.spy(mockWindow, '_urlStateLoadPage');
assert.deepEqual(state.state.get(), {org: 'mars', ws: 10});
const link = dom('a', state.setLinkUrl({ws: 4}));
assert.equal(link.getAttribute('href'), 'https://example.com/ws/4/');
assert.equal(state.makeUrl({ws: undefined}), 'https://example.com/');
assert.equal(state.makeUrl({org: 'ab-cd', doc: 'DOC', docPage: 5}), 'https://ab-cd.example.com/doc/DOC/p/5');
await state.pushUrl({doc: 'baz'});
assertResetCall(loadPageSpy, 'https://example.com/doc/baz');
state.loadState();
assert.equal(mockWindow.location.href, 'https://example.com/doc/baz');
assert.deepEqual(state.state.get(), {org: 'mars', doc: 'baz'});
assert.equal(link.getAttribute('href'), 'https://example.com/ws/4/');
await state.pushUrl({org: 'foo'});
assertResetCall(loadPageSpy, 'https://foo.example.com/');
state.loadState();
assert.equal(mockWindow.location.href, 'https://foo.example.com/');
// This test assumes gristConfig doesn't depend on the request, which is no longer the case,
// so some behavior isn't tested here, and this whole suite is a poor reflection of reality.
});
it('should support an update function to pushUrl and makeUrl', async function() {
mockWindow.location = new URL('https://bar.example.com/doc/DOC/p/5') as unknown as Location;
const state = UrlState.create(null, mockWindow, prod) as UrlState<IGristUrlState>;
await state.pushUrl({params: {style: 'light', linkParameters: {foo: 'A', bar: 'B'}}});
assert.equal(mockWindow.location.href, 'https://bar.example.com/doc/DOC/p/5?style=light&foo_=A&bar_=B');
state.loadState(); // changing linkParameters requires a page reload
assert.equal(state.makeUrl((prevState) => merge({}, prevState, {params: {style: 'full'}})),
'https://bar.example.com/doc/DOC/p/5?style=full&foo_=A&bar_=B');
assert.equal(state.makeUrl((prevState) => { const s = clone(prevState); delete s.params?.style; return s; }),
'https://bar.example.com/doc/DOC/p/5?foo_=A&bar_=B');
assert.equal(state.makeUrl((prevState) =>
merge(omit(prevState, 'params.style', 'params.linkParameters.foo'),
{params: {linkParameters: {baz: 'C'}}})),
'https://bar.example.com/doc/DOC/p/5?bar_=B&baz_=C');
assert.equal(state.makeUrl((prevState) =>
merge(omit(prevState, 'params.style'), {docPage: 44, params: {linkParameters: {foo: 'X'}}})),
'https://bar.example.com/doc/DOC/p/44?foo_=X&bar_=B');
await state.pushUrl(prevState => omit(prevState, 'params'));
assert.equal(mockWindow.location.href, 'https://bar.example.com/doc/DOC/p/5');
});
describe('login-urls', function() {
const originalWindow = (global as any).window;
after(() => {
(global as any).window = originalWindow;
});
function setWindowLocation(href: string) {
(global as any).window = {location: {href}};
}
it('getLoginUrl should return appropriate login urls', function() {
setWindowLocation('http://localhost:8080');
assert.equal(getLoginUrl(), 'http://localhost:8080/login?next=%2F');
setWindowLocation('https://docs.getgrist.com/');
assert.equal(getLoginUrl(), 'https://docs.getgrist.com/login?next=%2F');
setWindowLocation('https://foo.getgrist.com?foo=1&bar=2#baz');
assert.equal(getLoginUrl(), 'https://foo.getgrist.com/login?next=%2F%3Ffoo%3D1%26bar%3D2%23baz');
setWindowLocation('https://example.com');
assert.equal(getLoginUrl(), 'https://example.com/login?next=%2F');
});
it('getLoginUrl should encode redirect url in next param', function() {
setWindowLocation('http://localhost:8080/o/docs/foo');
assert.equal(getLoginUrl(), 'http://localhost:8080/o/docs/login?next=%2Ffoo');
setWindowLocation('https://docs.getgrist.com/RW25C4HAfG/Test-Document');
assert.equal(getLoginUrl(), 'https://docs.getgrist.com/login?next=%2FRW25C4HAfG%2FTest-Document');
});
it('getLoginUrl should include query params and hashes in next param', function() {
setWindowLocation('https://foo.getgrist.com/Y5g3gBaX27D/With-Hash/p/1/#a1.s8.r2.c23');
assert.equal(
getLoginUrl(),
'https://foo.getgrist.com/login?next=%2FY5g3gBaX27D%2FWith-Hash%2Fp%2F1%2F%23a1.s8.r2.c23'
);
setWindowLocation('https://example.com/rHz46S3F77DF/With-Params?compare=RW25C4HAfG');
assert.equal(
getLoginUrl(),
'https://example.com/login?next=%2FrHz46S3F77DF%2FWith-Params%3Fcompare%3DRW25C4HAfG'
);
setWindowLocation('https://example.com/rHz46S3F77DF/With-Params?compare=RW25C4HAfG#a1.s8.r2.c23');
assert.equal(
getLoginUrl(),
'https://example.com/login?next=%2FrHz46S3F77DF%2FWith-Params%3Fcompare%3DRW25C4HAfG%23a1.s8.r2.c23'
);
});
it('getLoginUrl should skip encoding redirect url on signed-out page', function() {
setWindowLocation('http://localhost:8080/o/docs/signed-out');
assert.equal(getLoginUrl(), 'http://localhost:8080/o/docs/login?next=%2F');
setWindowLocation('https://docs.getgrist.com/signed-out');
assert.equal(getLoginUrl(), 'https://docs.getgrist.com/login?next=%2F');
});
});
});

@ -0,0 +1,216 @@
/* global describe, it */
var assert = require('assert');
var ko = require('knockout');
var modelUtil = require('app/client/models/modelUtil');
var sinon = require('sinon');
describe('modelUtil', function() {
describe("fieldWithDefault", function() {
it("should be an observable with a default", function() {
var foo = modelUtil.createField('foo');
var bar = modelUtil.fieldWithDefault(foo, 'defaultValue');
assert.equal(bar(), 'defaultValue');
foo('test');
assert.equal(bar(), 'test');
bar('hello');
assert.equal(bar(), 'hello');
assert.equal(foo(), 'hello');
foo('');
assert.equal(bar(), 'defaultValue');
assert.equal(foo(), '');
});
it("should exhibit specific behavior when used as a jsonObservable", function() {
var custom = modelUtil.createField('custom');
var common = ko.observable('{"foo": 2, "bar": 3}');
var combined = modelUtil.fieldWithDefault(custom, function() { return common(); });
combined = modelUtil.jsonObservable(combined);
assert.deepEqual(combined(), {"foo": 2, "bar": 3});
// Once the custom object is defined, the common object is not read.
combined({"foo": 20});
assert.deepEqual(combined(), {"foo": 20});
// Setting the custom object to be undefined should make read return the common object again.
combined(undefined);
assert.deepEqual(combined(), {"foo": 2, "bar": 3});
// Setting a property with an undefined custom object should initially copy all defaults from common.
combined(undefined);
combined.prop('foo')(50);
assert.deepEqual(combined(), {"foo": 50, "bar": 3});
// Once the custom object is defined, changes to common should not affect the combined read value.
common('{"bar": 60}');
combined.prop('foo')(70);
assert.deepEqual(combined(), {"foo": 70, "bar": 3});
});
});
describe("jsonObservable", function() {
it("should auto parse and stringify", function() {
var str = ko.observable();
var obj = modelUtil.jsonObservable(str);
assert.deepEqual(obj(), {});
str('{"foo": 1, "bar": "baz"}');
assert.deepEqual(obj(), {foo: 1, bar: "baz"});
obj({foo: 2, baz: "bar"});
assert.equal(str(), '{"foo":2,"baz":"bar"}');
obj.update({foo: 17, bar: null});
assert.equal(str(), '{"foo":17,"baz":"bar","bar":null}');
});
it("should support saving", function() {
var str = ko.observable('{"foo": 1, "bar": "baz"}');
var saved = null;
str.saveOnly = function(value) { saved = value; };
var obj = modelUtil.jsonObservable(str);
obj.saveOnly({foo: 2});
assert.equal(saved, '{"foo":2}');
assert.equal(str(), '{"foo": 1, "bar": "baz"}');
assert.deepEqual(obj(), {"foo": 1, "bar": "baz"});
obj.update({"hello": "world"});
obj.save();
assert.equal(saved, '{"foo":1,"bar":"baz","hello":"world"}');
assert.equal(str(), '{"foo":1,"bar":"baz","hello":"world"}');
assert.deepEqual(obj(), {"foo":1, "bar":"baz", "hello":"world"});
obj.setAndSave({"hello": "world"});
assert.equal(saved, '{"hello":"world"}');
assert.equal(str(), '{"hello":"world"}');
assert.deepEqual(obj(), {"hello":"world"});
});
it("should support property observables", function() {
var str = ko.observable('{"foo": 1, "bar": "baz"}');
var saved = null;
str.saveOnly = function(value) { saved = value; };
var obj = modelUtil.jsonObservable(str);
var foo = obj.prop("foo"), hello = obj.prop("hello");
assert.equal(foo(), 1);
assert.equal(hello(), undefined);
obj.update({"foo": 17});
assert.equal(foo(), 17);
assert.equal(hello(), undefined);
foo(18);
assert.equal(str(), '{"foo":18,"bar":"baz"}');
hello("world");
assert.equal(saved, null);
assert.equal(str(), '{"foo":18,"bar":"baz","hello":"world"}');
assert.deepEqual(obj(), {"foo":18, "bar":"baz", "hello":"world"});
foo.setAndSave(20);
assert.equal(saved, '{"foo":20,"bar":"baz","hello":"world"}');
assert.equal(str(), '{"foo":20,"bar":"baz","hello":"world"}');
assert.deepEqual(obj(), {"foo":20, "bar":"baz", "hello":"world"});
});
});
describe("objObservable", function() {
it("should support property observables", function() {
var objObs = ko.observable({"foo": 1, "bar": "baz"});
var obj = modelUtil.objObservable(objObs);
var foo = obj.prop("foo"), hello = obj.prop("hello");
assert.equal(foo(), 1);
assert.equal(hello(), undefined);
obj.update({"foo": 17});
assert.equal(foo(), 17);
assert.equal(hello(), undefined);
foo(18);
hello("world");
assert.deepEqual(obj(), {"foo":18, "bar":"baz", "hello":"world"});
});
});
it("should support customComputed", function() {
var obs = ko.observable("hello");
var spy = sinon.spy();
var cs = modelUtil.customComputed({
read: () => obs(),
save: (val) => spy(val)
});
// Check that customComputed auto-updates when the underlying value changes.
assert.equal(cs(), "hello");
assert.equal(cs.isSaved(), true);
obs("world2");
assert.equal(cs(), "world2");
assert.equal(cs.isSaved(), true);
// Check that it can be set to something else, and will stop auto-updating.
cs("foo");
assert.equal(cs(), "foo");
assert.equal(cs.isSaved(), false);
obs("world");
assert.equal(cs(), "foo");
assert.equal(cs.isSaved(), false);
// Check that revert works.
cs.revert();
assert.equal(cs(), "world");
assert.equal(cs.isSaved(), true);
// Check that setting to the underlying value is same as revert.
cs("foo");
assert.equal(cs.isSaved(), false);
cs("world");
assert.equal(cs.isSaved(), true);
// Check that save calls the save function.
cs("foo");
assert.equal(cs(), "foo");
assert.equal(cs.isSaved(), false);
return cs.save()
.then(() => {
sinon.assert.calledOnce(spy);
sinon.assert.calledWithExactly(spy, "foo");
// Once saved, the observable should revert.
assert.equal(cs(), "world");
assert.equal(cs.isSaved(), true);
spy.resetHistory();
// Check that saveOnly works similarly to save().
return cs.saveOnly("foo2");
})
.then(() => {
sinon.assert.calledOnce(spy);
sinon.assert.calledWithExactly(spy, "foo2");
assert.equal(cs(), "world");
assert.equal(cs.isSaved(), true);
spy.resetHistory();
// Check that saving the underlying value does NOT call save().
return cs.saveOnly("world");
})
.then(() => {
sinon.assert.notCalled(spy);
assert.equal(cs(), "world");
assert.equal(cs.isSaved(), true);
spy.resetHistory();
return cs.saveOnly("bar");
})
.then(() => {
assert.equal(cs(), "world");
assert.equal(cs.isSaved(), true);
sinon.assert.calledOnce(spy);
sinon.assert.calledWithExactly(spy, "bar");
// If save() updated the underlying value, the customComputed should see it.
obs("bar");
assert.equal(cs(), "bar");
assert.equal(cs.isSaved(), true);
});
});
});

@ -0,0 +1,430 @@
/* global describe, it, beforeEach */
var _ = require('underscore');
var assert = require('chai').assert;
var sinon = require('sinon');
var rowset = require('app/client/models/rowset');
describe('rowset', function() {
describe('RowListener', function() {
it('should translate events to callbacks', function() {
var src = rowset.RowSource.create(null);
src.getAllRows = function() { return [1, 2, 3]; };
var lis = rowset.RowListener.create(null);
sinon.spy(lis, "onAddRows");
sinon.spy(lis, "onRemoveRows");
sinon.spy(lis, "onUpdateRows");
lis.subscribeTo(src);
assert.deepEqual(lis.onAddRows.args, [[[1, 2, 3]]]);
lis.onAddRows.resetHistory();
src.trigger('rowChange', 'add', [5, 6]);
src.trigger('rowChange', 'remove', [6, 1]);
src.trigger('rowChange', 'update', [3, 5]);
assert.deepEqual(lis.onAddRows.args, [[[5, 6]]]);
assert.deepEqual(lis.onRemoveRows.args, [[[6, 1]]]);
assert.deepEqual(lis.onUpdateRows.args, [[[3, 5]]]);
});
it('should support subscribing to multiple sources', function() {
var src1 = rowset.RowSource.create(null);
src1.getAllRows = function() { return [1, 2, 3]; };
var src2 = rowset.RowSource.create(null);
src2.getAllRows = function() { return ["a", "b", "c"]; };
var lis = rowset.RowListener.create(null);
sinon.spy(lis, "onAddRows");
sinon.spy(lis, "onRemoveRows");
sinon.spy(lis, "onUpdateRows");
lis.subscribeTo(src1);
lis.subscribeTo(src2);
assert.deepEqual(lis.onAddRows.args, [[[1, 2, 3]], [["a", "b", "c"]]]);
src1.trigger('rowChange', 'update', [2, 3]);
src2.trigger('rowChange', 'remove', ["b"]);
assert.deepEqual(lis.onUpdateRows.args, [[[2, 3]]]);
assert.deepEqual(lis.onRemoveRows.args, [[["b"]]]);
lis.onAddRows.resetHistory();
lis.unsubscribeFrom(src1);
src1.trigger('rowChange', 'add', [4]);
src2.trigger('rowChange', 'add', ["d"]);
assert.deepEqual(lis.onAddRows.args, [[["d"]]]);
});
});
describe('MappedRowSource', function() {
it('should map row identifiers', function() {
var src = rowset.RowSource.create(null);
src.getAllRows = function() { return [1, 2, 3]; };
var mapped = rowset.MappedRowSource.create(null, src, r => "X" + r);
assert.deepEqual(mapped.getAllRows(), ["X1", "X2", "X3"]);
var changeSpy = sinon.spy(), notifySpy = sinon.spy();
mapped.on('rowChange', changeSpy);
mapped.on('rowNotify', notifySpy);
src.trigger('rowChange', 'add', [4, 5, 6]);
src.trigger('rowNotify', [2, 3, 4], 'hello');
src.trigger('rowNotify', rowset.ALL, 'world');
src.trigger('rowChange', 'remove', [1, 5]);
src.trigger('rowChange', 'update', [4, 2]);
assert.deepEqual(changeSpy.args[0], ['add', ['X4', 'X5', 'X6']]);
assert.deepEqual(changeSpy.args[1], ['remove', ['X1', 'X5']]);
assert.deepEqual(changeSpy.args[2], ['update', ['X4', 'X2']]);
assert.deepEqual(changeSpy.callCount, 3);
assert.deepEqual(notifySpy.args[0], [['X2', 'X3', 'X4'], 'hello']);
assert.deepEqual(notifySpy.args[1], [rowset.ALL, 'world']);
assert.deepEqual(notifySpy.callCount, 2);
});
});
function suiteFilteredRowSource(FilteredRowSourceClass) {
it('should only forward matching rows', function() {
var src = rowset.RowSource.create(null);
src.getAllRows = function() { return [1, 2, 3]; };
// Filter for only rows that are even numbers.
var filtered = FilteredRowSourceClass.create(null, function(r) { return r % 2 === 0; });
filtered.subscribeTo(src);
assert.deepEqual(Array.from(filtered.getAllRows()), [2]);
var spy = sinon.spy(), notifySpy = sinon.spy();
filtered.on('rowChange', spy);
filtered.on('rowNotify', notifySpy);
src.trigger('rowChange', 'add', [4, 5, 6]);
src.trigger('rowChange', 'add', [7]);
src.trigger('rowNotify', [2, 3, 4], 'hello');
src.trigger('rowNotify', rowset.ALL, 'world');
src.trigger('rowChange', 'remove', [1, 5]);
src.trigger('rowChange', 'remove', [2, 3, 6]);
assert.deepEqual(spy.args[0], ['add', [4, 6]]);
// Nothing for the middle 'add' and 'remove'.
assert.deepEqual(spy.args[1], ['remove', [2, 6]]);
assert.equal(spy.callCount, 2);
assert.deepEqual(notifySpy.args[0], [[2, 4], 'hello']);
assert.deepEqual(notifySpy.args[1], [rowset.ALL, 'world']);
assert.equal(notifySpy.callCount, 2);
assert.deepEqual(Array.from(filtered.getAllRows()), [4]);
});
it('should translate updates to adds or removes if needed', function() {
var src = rowset.RowSource.create(null);
src.getAllRows = function() { return [1, 2, 3]; };
var includeSet = new Set([2, 3, 6]);
// Filter for only rows that are in includeMap.
var filtered = FilteredRowSourceClass.create(null, function(r) { return includeSet.has(r); });
filtered.subscribeTo(src);
assert.deepEqual(Array.from(filtered.getAllRows()), [2, 3]);
var spy = sinon.spy();
filtered.on('rowChange', spy);
src.trigger('rowChange', 'add', [4, 5]);
assert.equal(spy.callCount, 0);
includeSet.add(4);
includeSet.delete(2);
src.trigger('rowChange', 'update', [3, 2, 4, 5]);
assert.equal(spy.callCount, 3);
assert.deepEqual(spy.args[0], ['remove', [2]]);
assert.deepEqual(spy.args[1], ['update', [3]]);
assert.deepEqual(spy.args[2], ['add', [4]]);
spy.resetHistory();
src.trigger('rowChange', 'update', [1]);
assert.equal(spy.callCount, 0);
});
}
describe('BaseFilteredRowSource', () => {
suiteFilteredRowSource(rowset.BaseFilteredRowSource);
});
describe('FilteredRowSource', () => {
suiteFilteredRowSource(rowset.FilteredRowSource);
// One extra test case for FilteredRowSource.
it('should support changing the filter function', function() {
var src = rowset.RowSource.create(null);
src.getAllRows = function() { return [1, 2, 3, 4, 5]; };
var includeSet = new Set([2, 3, 6]);
// Filter for only rows that are in includeMap.
var filtered = rowset.FilteredRowSource.create(null, function(r) { return includeSet.has(r); });
filtered.subscribeTo(src);
assert.deepEqual(Array.from(filtered.getAllRows()), [2, 3]);
var spy = sinon.spy();
filtered.on('rowChange', spy);
includeSet.add(4);
includeSet.delete(2);
filtered.updateFilter(function(r) { return includeSet.has(r); });
assert.equal(spy.callCount, 2);
assert.deepEqual(spy.args[0], ['remove', [2]]);
assert.deepEqual(spy.args[1], ['add', [4]]);
assert.deepEqual(Array.from(filtered.getAllRows()), [3, 4]);
spy.resetHistory();
includeSet.add(5);
includeSet.add(17);
includeSet.delete(3);
filtered.refilterRows([2, 4, 5, 17]);
// 3 is still in because we didn't ask to refilter it. 17 is still out because it's not in
// any original source.
assert.deepEqual(Array.from(filtered.getAllRows()), [3, 4, 5]);
assert.equal(spy.callCount, 1);
assert.deepEqual(spy.args[0], ['add', [5]]);
});
});
describe('RowGrouping', function() {
it('should add/remove/notify rows in the correct group', function() {
var src = rowset.RowSource.create(null);
src.getAllRows = function() { return ["a", "b", "c"]; };
var groups = {a: 1, b: 2, c: 2, d: 1, e: 3, f: 3};
var grouping = rowset.RowGrouping.create(null, function(r) { return groups[r]; });
grouping.subscribeTo(src);
var group1 = grouping.getGroup(1), group2 = grouping.getGroup(2);
assert.deepEqual(Array.from(group1.getAllRows()), ["a"]);
assert.deepEqual(Array.from(group2.getAllRows()), ["b", "c"]);
var lis1 = sinon.spy(), lis2 = sinon.spy(), nlis1 = sinon.spy(), nlis2 = sinon.spy();
group1.on('rowChange', lis1);
group2.on('rowChange', lis2);
group1.on('rowNotify', nlis1);
group2.on('rowNotify', nlis2);
src.trigger('rowChange', 'add', ["d", "e", "f"]);
assert.deepEqual(lis1.args, [['add', ["d"]]]);
assert.deepEqual(lis2.args, []);
src.trigger('rowNotify', ["a", "e"], "foo");
src.trigger('rowNotify', rowset.ALL, "bar");
assert.deepEqual(nlis1.args, [[["a"], "foo"], [rowset.ALL, "bar"]]);
assert.deepEqual(nlis2.args, [[rowset.ALL, "bar"]]);
lis1.resetHistory();
lis2.resetHistory();
src.trigger('rowChange', 'remove', ["a", "b", "d", "e"]);
assert.deepEqual(lis1.args, [['remove', ["a", "d"]]]);
assert.deepEqual(lis2.args, [['remove', ["b"]]]);
assert.deepEqual(Array.from(group1.getAllRows()), []);
assert.deepEqual(Array.from(group2.getAllRows()), ["c"]);
assert.deepEqual(Array.from(grouping.getGroup(3).getAllRows()), ["f"]);
});
it('should translate updates to adds or removes if needed', function() {
var src = rowset.RowSource.create(null);
src.getAllRows = function() { return ["a", "b", "c", "d", "e"]; };
var groups = {a: 1, b: 2, c: 2, d: 1, e: 3, f: 3};
var grouping = rowset.RowGrouping.create(null, function(r) { return groups[r]; });
var group1 = grouping.getGroup(1), group2 = grouping.getGroup(2);
grouping.subscribeTo(src);
assert.deepEqual(Array.from(group1.getAllRows()), ["a", "d"]);
assert.deepEqual(Array.from(group2.getAllRows()), ["b", "c"]);
var lis1 = sinon.spy(), lis2 = sinon.spy();
group1.on('rowChange', lis1);
group2.on('rowChange', lis2);
_.extend(groups, {a: 2, b: 3, e: 1});
src.trigger('rowChange', 'update', ["a", "b", "d", "e"]);
assert.deepEqual(lis1.args, [['remove', ['a']], ['update', ['d']], ['add', ['e']]]);
assert.deepEqual(lis2.args, [['remove', ['b']], ['add', ['a']]]);
lis1.resetHistory();
lis2.resetHistory();
src.trigger('rowChange', 'update', ["a", "b", "d", "e"]);
assert.deepEqual(lis1.args, [['update', ['d', 'e']]]);
assert.deepEqual(lis2.args, [['update', ['a']]]);
});
});
describe('SortedRowSet', function() {
var src, order, sortedSet, sortedArray;
beforeEach(function() {
src = rowset.RowSource.create(null);
src.getAllRows = function() { return ["a", "b", "c", "d", "e"]; };
order = {a: 4, b: 0, c: 1, d: 2, e: 3};
sortedSet = rowset.SortedRowSet.create(null, function(a, b) { return order[a] - order[b]; });
sortedArray = sortedSet.getKoArray();
});
it('should sort on first subscribe', function() {
assert.deepEqual(sortedArray.peek(), []);
sortedSet.subscribeTo(src);
assert.deepEqual(sortedArray.peek(), ["b", "c", "d", "e", "a"]);
});
it('should maintain sort on adds and removes', function() {
sortedSet.subscribeTo(src);
var lis = sinon.spy();
sortedArray.subscribe(lis, null, 'spliceChange');
_.extend(order, {p: 2.5, q: 3.5});
// Small changes (currently < 2 elements) trigger individual splice events.
src.trigger('rowChange', 'add', ['p', 'q']);
assert.deepEqual(sortedArray.peek(), ["b", "c", "d", "p", "e", "q", "a"]);
assert.equal(lis.callCount, 2);
assert.equal(lis.args[0][0].added, 1);
assert.equal(lis.args[1][0].added, 1);
lis.resetHistory();
src.trigger('rowChange', 'remove', ["a", "c"]);
assert.deepEqual(sortedArray.peek(), ["b", "d", "p", "e", "q"]);
assert.equal(lis.callCount, 2);
assert.deepEqual(lis.args[0][0].deleted, ["a"]);
assert.deepEqual(lis.args[1][0].deleted, ["c"]);
// Bigger changes trigger full array reassignment.
lis.resetHistory();
src.trigger('rowChange', 'remove', ['d', 'e', 'q']);
assert.deepEqual(sortedArray.peek(), ["b", "p"]);
assert.equal(lis.callCount, 1);
lis.resetHistory();
src.trigger('rowChange', 'add', ['a', 'c', 'd', 'e', 'q']);
assert.deepEqual(sortedArray.peek(), ["b", "c", "d", "p", "e", "q", "a"]);
assert.equal(lis.callCount, 1);
});
it('should maintain sort on updates', function() {
var lis = sinon.spy();
sortedArray.subscribe(lis, null, 'spliceChange');
sortedSet.subscribeTo(src);
assert.deepEqual(sortedArray.peek(), ["b", "c", "d", "e", "a"]);
assert.equal(lis.callCount, 1);
assert.equal(lis.args[0][0].added, 5);
// Small changes (currently < 2 elements) trigger individual splice events.
lis.resetHistory();
_.extend(order, {"b": 1.5, "a": 2.5});
src.trigger('rowChange', 'update', ["b", "a"]);
assert.deepEqual(sortedArray.peek(), ["c", "b", "d", "a", "e"]);
assert.equal(lis.callCount, 4);
assert.deepEqual(lis.args[0][0].deleted, ["b"]);
assert.deepEqual(lis.args[1][0].deleted, ["a"]);
assert.deepEqual(lis.args[2][0].added, 1);
assert.deepEqual(lis.args[3][0].added, 1);
// Bigger changes trigger full array reassignment.
lis.resetHistory();
_.extend(order, {"b": 0, "a": 5, "c": 6});
src.trigger('rowChange', 'update', ["c", "b", "a"]);
assert.deepEqual(sortedArray.peek(), ["b", "d", "e", "a", "c"]);
assert.equal(lis.callCount, 1);
assert.deepEqual(lis.args[0][0].added, 5);
});
it('should not splice on irrelevant changes', function() {
var lis = sinon.spy();
sortedArray.subscribe(lis, null, 'spliceChange');
sortedSet.subscribeTo(src);
assert.deepEqual(sortedArray.peek(), ["b", "c", "d", "e", "a"]);
// Changes that don't affect the order do not cause splices.
lis.resetHistory();
src.trigger('rowChange', 'update', ["d"]);
src.trigger('rowChange', 'update', ["a", "b", "c"]);
assert.deepEqual(sortedArray.peek(), ["b", "c", "d", "e", "a"]);
assert.equal(lis.callCount, 0);
});
it('should pass on rowNotify events', function() {
var lis = sinon.spy(), spy = sinon.spy();
sortedSet.subscribeTo(src);
assert.deepEqual(sortedArray.peek(), ["b", "c", "d", "e", "a"]);
sortedArray.subscribe(lis, null, 'spliceChange');
sortedSet.on('rowNotify', spy);
src.trigger('rowNotify', ["b", "e"], "hello");
src.trigger('rowNotify', rowset.ALL, "world");
assert.equal(lis.callCount, 0);
assert.deepEqual(spy.args, [[['b', 'e'], 'hello'], [rowset.ALL, 'world']]);
});
it('should allow changing compareFunc', function() {
sortedSet.subscribeTo(src);
assert.deepEqual(sortedArray.peek(), ["b", "c", "d", "e", "a"]);
var lis = sinon.spy();
sortedArray.subscribe(lis, null, 'spliceChange');
// Replace the compare function with its negation.
sortedSet.updateSort(function(a, b) { return order[b] - order[a]; });
assert.equal(lis.callCount, 1);
assert.deepEqual(lis.args[0][0].added, 5);
assert.deepEqual(sortedArray.peek(), ["a", "e", "d", "c", "b"]);
});
it('should defer sorting while paused', function() {
var sortCalled = false;
assert.deepEqual(sortedArray.peek(), []);
sortedSet.updateSort(function(a, b) { sortCalled = true; return order[a] - order[b]; });
sortCalled = false;
var lis = sinon.spy();
sortedArray.subscribe(lis, null, 'spliceChange');
// Check that our little setup catching sort calls works; then reset.
sortedSet.subscribeTo(src);
assert.equal(sortCalled, true);
assert.equal(lis.callCount, 1);
sortedSet.unsubscribeFrom(src);
sortCalled = false;
lis.resetHistory();
// Now pause, do a bunch of operations, and check that sort has not been called.
function checkNoEffect() {
assert.equal(sortCalled, false);
assert.equal(lis.callCount, 0);
}
sortedSet.pause(true);
// Note that the initial order is ["b", "c", "d", "e", "a"]
sortedSet.subscribeTo(src);
checkNoEffect();
_.extend(order, {p: 2.5, q: 3.5});
src.trigger('rowChange', 'add', ['p', 'q']);
checkNoEffect(); // But we should now expect b,c,d,p,e,q,a
src.trigger('rowChange', 'remove', ["q", "c"]);
checkNoEffect(); // But we should now expect b,d,p,e,a
_.extend(order, {"b": 2.7, "a": 1});
src.trigger('rowChange', 'update', ["b", "a"]);
checkNoEffect(); // But we should now expect a,d,p,b,e
sortedSet.updateSort(function(a, b) { sortCalled = true; return order[b] - order[a]; });
checkNoEffect(); // We should expect a reversal: e,b,p,d,a
// rowNotify events should still be passed through.
var spy = sinon.spy();
sortedSet.on('rowNotify', spy);
src.trigger('rowNotify', ["p", "e"], "hello");
assert.deepEqual(spy.args[0], [['p', 'e'], 'hello']);
checkNoEffect();
// Now unpause, check that things get updated, and that the result is correct.
sortedSet.pause(false);
assert.equal(sortCalled, true);
assert.equal(lis.callCount, 1);
assert.deepEqual(sortedArray.peek(), ["e", "b", "p", "d", "a"]);
});
});
});

@ -0,0 +1,28 @@
/* global describe, it */
var assert = require('chai').assert;
var rowuid = require('app/client/models/rowuid');
describe('rowuid', function() {
it('should combine and split tableRefs with rowId', function() {
function verify(tableRef, rowId) {
var u = rowuid.combine(tableRef, rowId);
assert.equal(rowuid.tableRef(u), tableRef);
assert.equal(rowuid.rowId(u), rowId);
assert.equal(rowuid.toString(u), tableRef + ":" + rowId);
}
// Simple case.
verify(4, 17);
// With 0 for one or both of the parts.
verify(0, 17);
verify(1, 0);
verify(0, 0);
// Test with values close to the upper limits
verify(rowuid.MAX_TABLES - 1, 17);
verify(1234, rowuid.MAX_ROWS - 1);
verify(rowuid.MAX_TABLES - 1, rowuid.MAX_ROWS - 1);
});
});

@ -0,0 +1,306 @@
// This is from http://www.shortcutworld.com/shortcuts.php?l=en&p=win&application=Excel_2010
exports.shortcuts = function() {
return [{
group: 'Navigate inside worksheets',
shortcuts: [
"Left,Up,Right,Down: Move one cell up, down, left, or right in a worksheet.",
"PageDown,PageUp: Move one screen down / one screen up in a worksheet.",
"Alt+PageDown,Alt+PageUp: Move one screen to the right / to the left in a worksheet.",
"Tab,Shift+Tab: Move one cell to the right / to the left in a worksheet.",
"Ctrl+Left,Ctrl+Up,Ctrl+Right,Ctrl+Down: Move to the edge of next data region (cells that contains data)",
"Home: Move to the beginning of a row in a worksheet.",
"Ctrl+Home: Move to the beginning of a worksheet.",
"Ctrl+End: Move to the last cell with content on a worksheet.",
"Ctrl+f: Display the Find and Replace dialog box (with Find selected).",
"Ctrl+h: Display the Find and Replace dialog box (with Replace selected).",
"Shift+F4: Repeat last find.",
"Ctrl+g,F5: Display the 'Go To' dialog box.",
"Ctrl+Left,Ctrl+Right: Inside a cell: Move one word to the left / to the right.",
"Home,End: Inside a cell: Move to the beginning / to the end of a cell entry.",
"Alt+Down: Display the AutoComplete list e.g. in cell with dropdowns or autofilter.",
"End: Turn 'End' mode on. In End mode, press arrow keys to move to the next nonblank cell in the same column or row as the active cell. From here use arrow keys to move by blocks of data, home to move to last cell, or enter to move to the last cell to the right.",
]
}, {
group: 'Select cells',
shortcuts: [
"Shift+Space: Select the entire row.",
"Ctrl+Space: Select the entire column.",
"Ctrl+Shift+*: (asterisk) Select the current region around the active cell.",
"Ctrl+a,Ctrl+Shift+Space: Select the entire worksheet or the data-containing area. Pressing Ctrl+a a second time then selects entire worksheet.",
"Ctrl+Shift+PageUp: Select the current and previous sheet in a workbook.",
"Ctrl+Shift+o: Select all cells with comments.",
"Shift+Left,Shift+Up,Shift+Right,Shift+Down: Extend the selection by one cell.",
"Ctrl+Shift+Left,Ctrl+Shift+Up,Ctrl+Shift+Right,Ctrl+Shift+Down: Extend the selection to the last cell with content in row or column.",
"Shift+PageDown,Shift+PageUp: Extend the selection down one screen /up one screen.",
"Shift+Home: Extend the selection to the beginning of the row.",
"Ctrl+Shift+Home: Extend the selection to the beginning of the worksheet.",
"Ctrl+Shift+End: Extend the selection to the last used cell on the worksheet (lower-right corner).",
]
}, {
group: 'Manage Active Selections',
shortcuts: [
"F8: Turn on extension of selection with arrow keys without having to keep pressing Shift.",
"Shift+F8: Add another (adjacent or non-adjacent) range of cells to the selection. Use arrow keys and Shift+arrow keys to add to selection.",
"Shift+Backspace: Select only the active cell when multiple cells are selected.",
"Ctrl+Backspace: Show active cell within selection.",
"Ctrl+.: (period) Move clockwise to the next corner of the selection.",
"Enter,Shift+Enter: Move active cell down / up in a selection.",
"Tab,Shift+Tab: Move active cell right / left in a selection.",
"Ctrl+Alt+Right,Ctrl+Alt+Left: Move to the right / to the left between non-adjacent selections (with multiple ranges selected).",
"Esc: Cancel Selection.",
]
}, {
group: 'Select inside cells',
shortcuts: [
"Shift+Left,Shift+Right: Select or unselect one character to the left / to the right.",
"Ctrl+Shift+Left,Ctrl+Shift+Right: Select or unselect one word to the left / to the right.",
"Shift+Home,Shift+End: Select from the insertion point to the beginning / to the end of the cell.",
]
}, {
group: "Undo / Redo Shortcuts",
shortcuts: [
"Ctrl+z: Undo last action (multiple levels).",
"Ctrl+y: Redo last action (multiple levels).",
]
}, {
group: "Work with Clipboard",
shortcuts: [
"Ctrl+c: Copy contents of selected cells.",
"Ctrl+x: Cut contents of selected cells.",
"Ctrl+v: Paste content from clipboard into selected cell.",
"Ctrl+Alt+v: If data exists in clipboard: Display the Paste Special dialog box.",
"Ctrl+Shift+Plus: If data exists in clipboard: Display the Insert dialog box to insert blank cells.",
]
}, {
group: "Edit Inside Cells",
shortcuts: [
"F2: Edit the active cell with cursor at end of the line.",
"Alt+Enter: Start a new line in the same cell.",
"Enter: Complete a cell entry and move down in the selection. With multiple cells selected: fill cell range with current cell.",
"Shift+Enter: Complete a cell entry and move up in the selection.",
"Tab,Shift+Tab: Complete a cell entry and move to the right / to the left in the selection.",
"Esc: Cancel a cell entry.",
"Backspace: Delete the character to the left of the insertion point, or delete the selection.",
"Del: Delete the character to the right of the insertion point, or delete the selection.",
"Ctrl+Del: Delete text to the end of the line.",
"Ctrl+;: (semicolon) Insert current date.",
"Ctrl+Shift+:: Insert current time.",
"Ctrl+t: Show all content as standard numbers. (So 14:15 becomes 14.25 etc for the entire file) To undo press Ctrl + t again",
]
}, {
group: "Edit Active or Selected Cells",
shortcuts: [
"Ctrl+d: Fill complete cell down (Copy above cell).",
"Ctrl+r: Fill complete cell to the right (Copy cell from the left).",
"Ctrl+\": Fill cell values down and edit (Copy above cell values).",
"Ctrl+': (apostrophe) Fill cell formulas down and edit (Copy above cell formulas).",
"Ctrl+l: Insert a table (display Create Table dialog box).",
"Ctrl+-: Delete Cell/Row/Column Menu, or do the action with row/column selected",
"Ctrl+Shift+Plus: Insert Cell/Row/Column Menu, or do the action with row/column selected",
"Shift+F2: Insert / Edit a cell comment.",
"Shift+f10 m: Delete comment.",
"Alt+F1: Create and insert chart with data in current range as embedded Chart Object.",
"F11: Create and insert chart with data in current range in a separate Chart sheet.",
"Ctrl+k: Insert a hyperlink.",
"Enter: (in a cell with a hyperlink) Activate a hyperlink.",
]
}, {
group: "Hide and Show Elements",
shortcuts: [
"Ctrl+9: Hide the selected rows.",
"Ctrl+Shift+9: Unhide any hidden rows within the selection.",
"Ctrl+0: Hide the selected columns.",
"Ctrl+Shift+0: Unhide any hidden columns within the selection*.",
"Ctrl+`: (grave accent) Alternate between displaying cell values and displaying cell formulas. Accent grave /not a quotation mark.",
"Alt+Shift+Right: Group rows or columns.",
"Alt+Shift+Left: Ungroup rows or columns.",
"Ctrl+6: Alternate between hiding and displaying objects.",
"Ctrl+8: Display or hides the outline symbols.",
"Ctrl+6: Alternate between hiding objects, displaying objects, and displaying placeholders for objects.",
]
}, {
group: "Adjust Column Width and Row Height",
shortcuts: [
"Alt+o c a: Adjust Column width to fit content. Select complete column with Ctrl+Space first, otherwise column adjusts to content of current cell). Remember Format, Column Adjust.",
"Alt+o c w: Adjust Columns width to specific value: Option, Cow, width",
"Alt+o r a: Adjust Row height to fit content: Option, Row, Adjust",
"Alt+o r e: Adjust Row height to specific value: Option, Row, Height",
]
}, {
group: "Format Cells",
shortcuts: [
"Ctrl+1: Format cells dialog.",
"Ctrl+b, Ctrl+2: Apply or remove bold formatting.",
"Ctrl+i, Ctrl+3: Apply or remove italic formatting.",
"Ctrl+u, Ctrl+4: Apply or remove an underline.",
"Ctrl+5: Apply or remove strikethrough formatting.",
"Ctrl+Shift+f: Display the Format Cells with Fonts Tab active. Press tab 3x to get to font-size. Used to be Ctrl+Shift+p, but that seems just get to the Font Tab in 2010.",
"Alt+': (apostrophe / single quote) Display the Style dialog box.",
]
}, {
group: "Number Formats",
shortcuts: [
"Ctrl+Shift+$: Apply the Currency format with two decimal places.",
"Ctrl+Shift+~: Apply the General number format.",
"Ctrl+Shift+%: Apply the Percentage format with no decimal places.",
"Ctrl+Shift+#: Apply the Date format with the day, month, and year.",
"Ctrl+Shift+@: Apply the Time format with the hour and minute, and indicate A.M. or P.M.",
"Ctrl+Shift+!: Apply the Number format with two decimal places, thousands separator, and minus sign (-) for negative values.",
"Ctrl+Shift+^: Apply the Scientific number format with two decimal places.",
"F4: Repeat last formatting action: Apply previously applied Cell Formatting to a different Cell",
]
}, {
group: "Apply Borders to Cells",
shortcuts: [
"Ctrl+Shift+&: Apply outline border from cell or selection",
"Ctrl+Shift+_: (underscore) Remove outline borders from cell or selection",
"Ctrl+1: Access border menu in 'Format Cell' dialog. Once border was selected, it will show up directly on the next Ctrl+1",
"Alt+t: Set top border",
"Alt+b: Set bottom Border",
"Alt+l: Set left Border",
"Alt+r: Set right Border",
"Alt+d: Set diagonal and down border",
"Alt+u: Set diagonal and up border",
]
}, {
group: "Align Cells",
shortcuts: [
"Alt+h a r: Align Right",
"Alt+h a c: Align Center",
"Alt+h a l: Align Left",
]
}, {
group: "Formulas",
shortcuts: [
"=: Start a formula.",
"Alt+=: Insert the AutoSum formula.",
"Shift+F3: Display the Insert Function dialog box.",
"Ctrl+a: Display Formula Window after typing formula name.",
"Ctrl+Shift+a: Insert Arguments in formula after typing formula name. .",
"Shift+F3: Insert a function into a formula .",
"Ctrl+Shift+Enter: Enter a formula as an array formula.",
"F4: After typing cell reference (e.g. =E3) makes reference absolute (=$E$4)",
"F9: Calculate all worksheets in all open workbooks.",
"Shift+F9: Calculate the active worksheet.",
"Ctrl+Alt+F9: Calculate all worksheets in all open workbooks, regardless of whether they have changed since the last calculation.",
"Ctrl+Alt+Shift+F9: Recheck dependent formulas, and then calculates all cells in all open workbooks, including cells not marked as needing to be calculated.",
"Ctrl+Shift+u: Toggle expand or collapse formula bar.",
"Ctrl+`: Toggle Show formula in cell instead of values",
]
}, {
group: "Names",
shortcuts: [
"Ctrl+F3: Define a name or dialog.",
"Ctrl+Shift+F3: Create names from row and column labels.",
"F3: Paste a defined name into a formula.",
]
}, {
group: "Manage Multipe Worksheets",
shortcuts: [
"Shift+F11,Alt+Shift+F1: Insert a new worksheet in current workbook.",
"Ctrl+PageDown,Ctrl+PageUp: Move to the next / previous worksheet in current workbook.",
"Shift+Ctrl+PageDown,Shift+Ctrl+PageUp: Select the current and next sheet(s) / select and previous sheet(s).",
"Alt+o h r: Rename current worksheet (format, sheet, rename)",
"Alt+e l: Delete current worksheet (Edit, delete)",
"Alt+e m: Move current worksheet (Edit, move)",
]
}, {
group: "Manage Multiple Workbooks",
shortcuts: [
"F6,Shift+F6: Move to the next pane / previous pane in a workbook that has been split.",
"Ctrl+F4: Close the selected workbook window.",
"Ctrl+n: Create a new blank workbook (Excel File)",
"Ctrl+Tab,Ctrl+Shift+Tab: Move to next / previous workbook window.",
"Alt+Space: Display the Control menu for Main Excel window.",
"Ctrl+F9: Minimize current workbook window to an icon. Also restores ('un-maximizes') all workbook windows.",
"Ctrl+F10: Maximize or restores the selected workbook window.",
"Ctrl+F7: Move Workbook Windows which are not maximized.",
"Ctrl+F8: Perform size command for workbook windows which are not maximzed.",
"Alt+F4: Close Excel.",
]
}, {
group: "Various Excel Features",
shortcuts: [
"Ctrl+o: Open File.",
"Ctrl+s: Save the active file with its current file name, location, and file format.",
"F12: Display the Save As dialog box.",
"F10, Alt: Turn key tips on or off.",
"Ctrl+p: Print File (Opens print menu).",
"F1: Display the Excel Help task pane.",
"F7: Display the Spelling dialog box.",
"Shift+F7: Display the Thesaurus dialog box.",
"Alt+F8: Display the Macro dialog box.",
"Alt+F11: Open the Visual Basic Editor to create Macros.",
]
}, {
group: "Work with the Excel Ribbon",
shortcuts: [
"Ctrl+F1: Minimize or restore the Ribbon.s",
"Alt,F10: Select the active tab of the Ribbon and activate the access keys. Press either of these keys again to move back to the document and cancel the access keys. and then arrow left or arrow right",
"Shift+F10: Display the shortcut menu for the selected command.",
"Space,Enter: Activate the selected command or control in the Ribbon, Open the selected menu or gallery in the Ribbon..",
"Enter: Finish modifying a value in a control in the Ribbon, and move focus back to the document.",
"F1: Get help on the selected command or control in the Ribbon. (If no Help topic is associated with the selected command, the Help table of contents for that program is shown instead.)",
]
}, {
group: "Data Forms",
shortcuts: [
"Tab,Shift+Tab: Move to the next / previous field which can be edited.",
"Enter,Shift+Enter: Move to the first field in the next / previous record.",
"PageDown,PageUp: Move to the same field 10 records forward / back.",
"Ctrl+PageDown: Move to a new record.",
"Ctrl+PageUp: Move to the first record.",
"Home,End: Move to the beginning / end of a field.",
]
}, {
group: "Pivot Tables",
shortcuts: [
"Left,Up,Right,Down: Navigate inside Pivot tables.",
"Home,End: Select the first / last visible item in the list.",
"Alt+c: Move the selected field into the Column area.",
"Alt+d: Move the selected field into the Data area.",
"Alt+l: Display the PivotTable Field dialog box.",
"Alt+p: Move the selected field into the Page area.",
"Alt+r: Move the selected field into the Row area.",
"Ctrl+Shift+*: (asterisk) Select the entire PivotTable report.",
"Alt+Down: Display the list for the current field in a PivotTable report.",
"Alt+Down: Display the list for the current page field in a PivotChart report.",
"Enter: Display the selected item.",
"Space: Select or clear a check box in the list.",
"Ctrl+Tab Ctrl+Shift+Tab: select the PivotTable toolbar.",
"Down,Up: After 'Enter', on a field button: select the area you want to move the selected field to.",
"Alt+Shift+Right: Group selected PivotTable items.",
"Alt+Shift+Left: Ungroup selected PivotTable items.",
]
}, {
group: "Dialog Boxes",
shortcuts: [
"Left,Up,Right,Down: Move between options in the active drop-down list box or between some options in a group of options.",
"Ctrl+Tab,Ctrl+Shift+Tab: Switch to the next/ previous tab in dialog box.",
"Space: In a dialog box: perform the action for the selected button, or select/clear a check box.",
"Tab,Shift+Tab: Move to the next / previous option.",
"a ... z: Move to an option in a drop-down list box starting with the letter",
"Alt+a ... Alt+z: Select an option, or select or clear a check box.",
"Alt+Down: Open the selected drop-down list box.",
"Enter: Perform the action assigned to the default command button in the dialog box.",
"Esc: Cancel the command and close the dialog box.",
]
}, {
group: "Auto Filter",
shortcuts: [
"Alt+Down: On the field with column head, display the AutoFilter list for the current column .",
"Down,Up: Select the next item / previous item in the AutoFilter list.",
"Alt+Up: Close the AutoFilter list for the current column.",
"Home,End: Select the first item / last item in the AutoFilter list.",
"Enter: Filter the list by using the selected item in the AutoFilter list.",
"Ctrl+Shift+L: Apply filter on selected column headings.",
]
}, {
group: "Work with Smart Art Graphics",
shortcuts: [
"Left,Up,Right,Down: Select elements.",
"Esc: Remove Focus from Selection.",
"F2: Edit Selection Text in if possible (in formula bar).",
]
}];
};

@ -0,0 +1,133 @@
// From https://support.google.com/docs/answer/181110?hl=en
exports.shortcuts = function() {
return [{
group: "Common actions",
shortcuts: [
"Ctrl + Space: Select column ",
"Shift + Space: Select row",
"⌘ + A, ⌘ + Shift + Space : Select all",
"⌘ + Shift + Backspace: Hide background over selected cells ",
"⌘ + Z: Undo",
"⌘ + Y, ⌘ + Shift + Z, Fn + F4 : Redo",
"⌘ + F: Find",
"⌘ + Shift + H: Find and replace",
"⌘ + Enter: Fill range",
"⌘ + D: Fill down ",
"⌘ + R: Fill right",
"⌘ + S: Save; Every change is saved automatically in Drive",
"⌘ + O: Open",
"⌘ + P: Print ",
"⌘ + C: Copy",
"⌘ + X: Cut ",
"⌘ + V: Paste ",
"⌘ + Shift + V: Paste values only ",
"⌘ + /: Show common keyboard shortcuts",
"Ctrl + Shift + F: Compact controls",
"⌘ + Shift + K: Input tools on/off (available in spreadsheets in non-Latin languages)",
"⌘ + Option + Shift + K: Select input tools",
]
}, {
group: "Cell formatting",
shortcuts: [
"⌘ + B: Bold",
"⌘ + U: Underline ",
"⌘ + I: Italic",
"Option + Shift + 5: Strikethrough ",
"⌘ + Shift + E: Center align",
"⌘ + Shift + L: Left align",
"⌘ + Shift + R: Right align ",
"Option + Shift + 1: Apply top border",
"Option + Shift + 2: Apply right border",
"Option + Shift + 3: Apply bottom border ",
"Option + Shift + 4: Apply left border ",
"Option + Shift + 6: Remove borders",
"Option + Shift + 7: Apply outer border",
"⌘ + K: Insert link ",
"⌘ + Shift + ;: Insert time ",
"⌘ + ;: Insert date ",
"⌘ + Shift + 1: Format as decimal ",
"⌘ + Shift + 2: Format as time",
"⌘ + Shift + 3: Format as date",
"⌘ + Shift + 4: Format as currency",
"⌘ + Shift + 5: Format as percentage",
"⌘ + Shift + 6: Format as exponent",
"⌘ + \\: Clear formatting",
]
}, {
group: "Spreadsheet navigation",
shortcuts: [
"Home, Fn + Left: Move to beginning of row",
"⌘ + Home, ⌘ + Fn + Left: Move to beginning of sheet",
"End, Fn + Right: Move to end of row",
"⌘ + End, ⌘ + Fn + Right: Move to end of sheet",
"⌘ + Backspace: Scroll to active cell ",
"⌘ + Shift + PageDown, ⌘ + Shift + Fn + Down: Move to next sheet",
"⌘ + Shift + PageUp, ⌘ + Shift + Fn + Up: Move to previous sheet",
"Option + Shift + K: Display list of sheets",
"Option + Enter: Open hyperlink",
"Ctrl + ⌘ + Shift + M: Move focus out of spreadsheet ",
"Option + Shift + Q: Move to quicksum (when a range of cells is selected) ",
"Ctrl+⌘ +E Ctrl+⌘ +P: Move focus to popup (for links, bookmarks, and images)",
"Ctrl + ⌘ + R: Open drop-down menu on filtered cell",
"⌘ + Option + Shift + G: Open revision history ",
"Shift + Esc: Open chat inside the spreadsheet",
"⌘ + Esc, Shift + Esc: Close drawing editor",
]
}, {
group: "Notes and comments",
shortcuts: [
"Shift + Fn + F2: Insert/edit note",
"⌘ + Option + M: Insert/edit comment ",
"⌘ + Option + Shift + A: Open comment discussion thread",
"Ctrl+⌘ +E Ctrl+⌘ +C: Enter current comment ",
"Ctrl+⌘ +N Ctrl+⌘ +C: Move to next comment",
"Ctrl+⌘ +P Ctrl+⌘ +C: Move to previous comment",
]
}, {
group: "Menus",
shortcuts: [
"Ctrl + Option + F: File menu ",
"Ctrl + Option + E: Edit menu ",
"Ctrl + Option + V: View menu ",
"Ctrl + Option + I: Insert menu ",
"Ctrl + Option + O: Format menu ",
"Ctrl + Option + D: Data menu ",
"Ctrl + Option + T: Tools menu",
"Ctrl + Option + M: Form menu (present when the spreadsheet is connected to a form) ",
"Ctrl + Option + N: Add-ons menu (present in the new Google Sheets)",
"Ctrl + Option + H: Help menu ",
"Ctrl + Option + A: Accessibility menu (present when screen reader support is enabled) ",
"Option + Shift + S: Sheet menu(copy, delete, and other sheet actions) ",
"⌘ + Shift + \\: Context menu",
]
}, {
group: "Insert or delete rows or columns (via opening menu)",
shortcuts: [
"Ctrl+Option+I R: Insert row above",
"Ctrl+Option+I W: Insert row below",
"Ctrl+Option+I C: Insert column to the left ",
"Ctrl+Option+I G: Insert column to the right",
"Ctrl+Option+E D: Delete row",
"Ctrl+Option+E E: Delete column ",
]
}, {
group: "Formulas",
shortcuts: [
"Ctrl + ~: Show all formulas ",
"⌘ + Shift + Enter: Insert array formula",
"⌘ + E: Collapse an expanded array formula",
"Shift + Fn + F1: Show/hide formula help (when entering a formula) ",
]
}, {
group: "Screen reader support",
shortcuts: [
"⌘ + Option + Z: Enable screen reader support",
"⌘ + Option + Shift + C: Read column ",
"⌘ + Option + Shift + R: Read row",
]
}];
};

@ -0,0 +1,32 @@
import {checkName} from 'app/client/ui/AccountPage';
import {assert} from 'chai';
describe("AccountPage", function() {
describe("isValidName", function() {
it("should detect invalid name", function() {
assert.equal(checkName('santa'), true);
assert.equal(checkName('_santa'), true);
assert.equal(checkName("O'Neil"), true);
assert.equal(checkName("Emily"), true);
assert.equal(checkName("santa(2)"), true);
assert.equal(checkName("Dr. noname"), true);
assert.equal(checkName("santa-klaus"), true);
assert.equal(checkName("Noémie"), true);
assert.equal(checkName("张伟"), true);
assert.equal(checkName(',,__()'), false);
assert.equal(checkName('<foo>'), false);
assert.equal(checkName('<foo>'), false);
assert.equal(checkName('(bar)'), false);
assert.equal(checkName('foo <baz>'), false);
assert.equal(checkName('-foo'), false);
assert.equal(checkName("'foo"), false);
assert.equal(checkName(' Bob'), false);
assert.equal(checkName('='), false);
assert.equal(checkName('santa='), false);
});
});
});

@ -0,0 +1,37 @@
/* global describe, it */
import { timezoneOptionsImpl } from "app/client/widgets/TZAutocomplete";
import { assert } from "chai";
import * as momentTimezone from 'moment-timezone';
describe('DocumentSettings', function() {
describe("timezoneOptionsImpl", function() {
it("should return zones in correct order", function() {
// let's test ordering of zones at time the test was written (Tue Jul 18 12:04:56.641 2017)
const now = 1500393896641;
assert.deepEqual(timezoneOptionsImpl(now, [
"Pacific/Marquesas",
"US/Aleutian",
"America/Juneau",
"America/Anchorage",
"Antarctica/Mawson",
"Asia/Calcutta",
"Asia/Colombo",
"Africa/Accra",
"Antarctica/Casey"
], momentTimezone).map(({label}) => label), [
"(GMT-09:30) Pacific/Marquesas",
"(GMT-09:00) US/Aleutian",
"(GMT-08:00) America/Anchorage",
"(GMT-08:00) America/Juneau",
"(GMT+00:00) Africa/Accra",
"(GMT+05:00) Antarctica/Mawson",
"(GMT+05:30) Asia/Calcutta",
"(GMT+05:30) Asia/Colombo",
"(GMT+11:00) Antarctica/Casey"
]);
});
});
});

@ -0,0 +1,18 @@
import {getInitials} from 'app/client/ui/UserImage';
import {assert} from 'chai';
describe('AppModel', function() {
describe('getInitials', function() {
it('should extract initials', () => {
assert.equal(getInitials({name: "Foo Bar"}), "FB");
assert.equal(getInitials({name: " foo bar cat"}), "fb");
assert.equal(getInitials({name: " foo-bar cat"}), "fc");
assert.equal(getInitials({name: "foo-bar"}), "f");
assert.equal(getInitials({name: " Something"}), "S");
assert.equal(getInitials({name: " Something", email: 'test@...'}), "S");
assert.equal(getInitials({name: "", email: 'test@...'}), "t");
assert.equal(getInitials({name: " ", email: 'test@...'}), "t");
assert.equal(getInitials({email: 'something@example.com'}), "s");
});
});
});

@ -0,0 +1,144 @@
import {emptyPermissionSet, PartialPermissionSet,
summarizePermissions, summarizePermissionSet} from 'app/common/ACLPermissions';
import {makePartialPermissions, parsePermissions, permissionSetToText} from 'app/common/ACLPermissions';
import {mergePartialPermissions, mergePermissions} from 'app/common/ACLPermissions';
import {assert} from 'chai';
describe("ACLPermissions", function() {
const empty = emptyPermissionSet();
it('should convert short permissions to permissionSet', function() {
assert.deepEqual(parsePermissions('all'),
{ read: "allow", create: "allow", update: "allow", delete: "allow", schemaEdit: "allow" });
assert.deepEqual(parsePermissions('none'),
{ read: "deny", create: "deny", update: "deny", delete: "deny", schemaEdit: "deny" });
assert.deepEqual(parsePermissions('all'), parsePermissions('+CRUDS'));
assert.deepEqual(parsePermissions('none'), parsePermissions('-CRUDS'));
assert.deepEqual(parsePermissions('+R'), {...empty, read: "allow"});
assert.deepEqual(parsePermissions('-R'), {...empty, read: "deny"});
assert.deepEqual(parsePermissions('+S'), {...empty, schemaEdit: "allow"});
assert.deepEqual(parsePermissions(''), empty);
assert.deepEqual(parsePermissions('+CUD-R'),
{create: "allow", update: "allow", delete: "allow", read: "deny", schemaEdit: ""});
assert.deepEqual(parsePermissions('-R+CUD'),
{create: "allow", update: "allow", delete: "allow", read: "deny", schemaEdit: ""});
assert.deepEqual(parsePermissions('+R-CUD'),
{create: "deny", update: "deny", delete: "deny", read: "allow", schemaEdit: ""});
assert.deepEqual(parsePermissions('-CUD+R'),
{create: "deny", update: "deny", delete: "deny", read: "allow", schemaEdit: ""});
assert.throws(() => parsePermissions('R'), /Invalid permissions specification "R"/);
assert.throws(() => parsePermissions('x'), /Invalid permissions specification "x"/);
assert.throws(() => parsePermissions('-R\n'), /Invalid permissions specification "-R\\n"/);
});
it('should convert permissionSets to short string', function() {
assert.equal(permissionSetToText({read: "allow"}), '+R');
assert.equal(permissionSetToText({read: "deny"}), '-R');
assert.equal(permissionSetToText({schemaEdit: "allow"}), '+S');
assert.equal(permissionSetToText({}), '');
assert.equal(permissionSetToText({create: "allow", update: "allow", delete: "allow", read: "deny"}), '+CUD-R');
assert.equal(permissionSetToText({create: "deny", update: "deny", delete: "deny", read: "allow"}), '+R-CUD');
assert.equal(permissionSetToText(parsePermissions('+CRUDS')), 'all');
assert.equal(permissionSetToText(parsePermissions('-CRUDS')), 'none');
});
it('should allow merging PermissionSets', function() {
function mergeDirect(a: string, b: string) {
const aParsed = parsePermissions(a);
const bParsed = parsePermissions(b);
return permissionSetToText(mergePermissions([aParsed, bParsed], ([_a, _b]) => _a || _b));
}
testMerge(mergeDirect);
});
it('should allow merging PermissionSets via PartialPermissionSet', function() {
// In practice, we work with more generalized PartialPermissionValues. Ensure that this
// pathway produces the same results.
function mergeViaPartial(a: string, b: string) {
const aParsed = parsePermissions(a);
const bParsed = parsePermissions(b);
return permissionSetToText(mergePartialPermissions(aParsed, bParsed));
}
testMerge(mergeViaPartial);
});
function testMerge(merge: (a: string, b: string) => string) {
assert.equal(merge("+R", "-R"), "+R");
assert.equal(merge("+C-D", "+CDS-RU"), "+CS-RUD");
assert.equal(merge("all", "+R-CUDS"), "all");
assert.equal(merge("none", "-R+CUDS"), "none");
assert.equal(merge("all", "none"), "all");
assert.equal(merge("none", "all"), "none");
assert.equal(merge("", "+RU-CD"), "+RU-CD");
assert.equal(merge("-S", "+RU-CD"), "+RU-CDS");
}
it('should merge PartialPermissionSets', function() {
function merge(a: Partial<PartialPermissionSet>, b: Partial<PartialPermissionSet>): PartialPermissionSet {
return mergePartialPermissions({...empty, ...a}, {...empty, ...b});
}
// Combining single bits.
assert.deepEqual(merge({read: 'allow'}, {read: 'deny'}), {...empty, read: 'allow'});
assert.deepEqual(merge({read: 'deny'}, {read: 'allow'}), {...empty, read: 'deny'});
assert.deepEqual(merge({read: 'mixed'}, {read: 'deny'}), {...empty, read: 'mixed'});
assert.deepEqual(merge({read: 'mixed'}, {read: 'allow'}), {...empty, read: 'mixed'});
assert.deepEqual(merge({read: 'allowSome'}, {read: 'allow'}), {...empty, read: 'allow'});
assert.deepEqual(merge({read: 'allowSome'}, {read: 'allowSome'}), {...empty, read: 'allowSome'});
assert.deepEqual(merge({read: 'allowSome'}, {read: 'deny'}), {...empty, read: 'mixed'});
assert.deepEqual(merge({read: 'allowSome'}, {read: 'denySome'}), {...empty, read: 'mixed'});
assert.deepEqual(merge({read: 'denySome'}, {read: 'deny'}), {...empty, read: 'deny'});
assert.deepEqual(merge({read: 'denySome'}, {read: 'denySome'}), {...empty, read: 'denySome'});
assert.deepEqual(merge({read: 'denySome'}, {read: 'allow'}), {...empty, read: 'mixed'});
assert.deepEqual(merge({read: 'denySome'}, {read: 'allowSome'}), {...empty, read: 'mixed'});
// Combining multiple bits.
assert.deepEqual(merge(
{read: 'allowSome', create: 'allow', update: 'denySome', delete: 'deny'},
{read: 'deny', create: 'denySome', update: 'deny', delete: 'denySome', schemaEdit: 'deny'}
),
{read: 'mixed', create: 'allow', update: 'deny', delete: 'deny', schemaEdit: 'deny'}
);
assert.deepEqual(merge(makePartialPermissions(parsePermissions("all")), parsePermissions("+U-D")),
{read: 'allowSome', create: 'allowSome', update: 'allow', delete: 'mixed', schemaEdit: 'allowSome'}
);
assert.deepEqual(merge(parsePermissions("+U-D"), makePartialPermissions(parsePermissions("all"))),
{read: 'allowSome', create: 'allowSome', update: 'allow', delete: 'deny', schemaEdit: 'allowSome'}
);
});
it ('should allow summarization of permission sets', function() {
assert.deepEqual(summarizePermissionSet(parsePermissions("+U-D")), 'mixed');
assert.deepEqual(summarizePermissionSet(parsePermissions("+U+D")), 'allow');
assert.deepEqual(summarizePermissionSet(parsePermissions("-U-D")), 'deny');
assert.deepEqual(summarizePermissionSet(parsePermissions("-U-D")), 'deny');
assert.deepEqual(summarizePermissionSet(parsePermissions("none")), 'deny');
assert.deepEqual(summarizePermissionSet(parsePermissions("all")), 'allow');
assert.deepEqual(summarizePermissionSet(parsePermissions("")), 'mixed');
assert.deepEqual(summarizePermissionSet(parsePermissions("+CRUDS")), 'allow');
assert.deepEqual(summarizePermissionSet(parsePermissions("-CRUDS")), 'deny');
assert.deepEqual(summarizePermissionSet({...empty, read: 'allow', update: 'allowSome'}), 'allow');
assert.deepEqual(summarizePermissionSet({...empty, read: 'allowSome', update: 'allow'}), 'allow');
assert.deepEqual(summarizePermissionSet({...empty, read: 'allowSome', update: 'allowSome'}), 'allow');
assert.deepEqual(summarizePermissionSet({...empty, read: 'allow', update: 'denySome'}), 'mixed');
assert.deepEqual(summarizePermissionSet({...empty, read: 'denySome', update: 'allowSome'}), 'mixed');
assert.deepEqual(summarizePermissionSet({...empty, read: 'denySome', update: 'deny'}), 'deny');
});
it ('should allow summarization of permissions', function() {
assert.deepEqual(summarizePermissions(['allow', 'deny']), 'mixed');
assert.deepEqual(summarizePermissions(['allow', 'allow']), 'allow');
assert.deepEqual(summarizePermissions(['deny', 'allow']), 'mixed');
assert.deepEqual(summarizePermissions(['deny', 'deny']), 'deny');
assert.deepEqual(summarizePermissions(['allow']), 'allow');
assert.deepEqual(summarizePermissions(['deny']), 'deny');
assert.deepEqual(summarizePermissions([]), 'mixed');
assert.deepEqual(summarizePermissions(['allow', 'allow', 'deny']), 'mixed');
assert.deepEqual(summarizePermissions(['allow', 'allow', 'allow']), 'allow');
});
});

@ -0,0 +1,144 @@
import {AsyncCreate, asyncOnce, mapGetOrSet} from 'app/common/AsyncCreate';
import {assert} from 'chai';
import * as sinon from 'sinon';
describe('AsyncCreate', function() {
it('should call create func on first use and after failure', async function() {
const createFunc = sinon.stub();
const cp = new AsyncCreate(createFunc);
sinon.assert.notCalled(createFunc);
const value = {hello: 'world'};
createFunc.returns(Promise.resolve(value));
// Check that .get() calls the createFunc and returns the expected value.
assert.strictEqual(await cp.get(), value);
sinon.assert.calledOnce(createFunc);
createFunc.resetHistory();
// Subsequent calls return the cached value.
assert.strictEqual(await cp.get(), value);
sinon.assert.notCalled(createFunc);
// After clearing, .get() calls createFunc again. We'll make this one fail.
cp.clear();
createFunc.returns(Promise.reject(new Error('fake-error1')));
await assert.isRejected(cp.get(), /fake-error1/);
sinon.assert.calledOnce(createFunc);
createFunc.resetHistory();
// After failure, subsequent calls try again.
createFunc.returns(Promise.reject(new Error('fake-error2')));
await assert.isRejected(cp.get(), /fake-error2/);
sinon.assert.calledOnce(createFunc);
createFunc.resetHistory();
// While a createFunc() is pending we do NOT call it again.
createFunc.returns(Promise.reject(new Error('fake-error3')));
await Promise.all([
assert.isRejected(cp.get(), /fake-error3/),
assert.isRejected(cp.get(), /fake-error3/),
]);
sinon.assert.calledOnce(createFunc); // Called just once here.
createFunc.resetHistory();
});
it('asyncOnce should call func once and after failure', async function() {
const createFunc = sinon.stub();
let onceFunc = asyncOnce(createFunc);
sinon.assert.notCalled(createFunc);
const value = {hello: 'world'};
createFunc.returns(Promise.resolve(value));
// Check that .get() calls the createFunc and returns the expected value.
assert.strictEqual(await onceFunc(), value);
sinon.assert.calledOnce(createFunc);
createFunc.resetHistory();
// Subsequent calls return the cached value.
assert.strictEqual(await onceFunc(), value);
sinon.assert.notCalled(createFunc);
// Create a new onceFunc. We'll make this one fail.
onceFunc = asyncOnce(createFunc);
createFunc.returns(Promise.reject(new Error('fake-error1')));
await assert.isRejected(onceFunc(), /fake-error1/);
sinon.assert.calledOnce(createFunc);
createFunc.resetHistory();
// After failure, subsequent calls try again.
createFunc.returns(Promise.reject(new Error('fake-error2')));
await assert.isRejected(onceFunc(), /fake-error2/);
sinon.assert.calledOnce(createFunc);
createFunc.resetHistory();
// While a createFunc() is pending we do NOT call it again.
createFunc.returns(Promise.reject(new Error('fake-error3')));
await Promise.all([
assert.isRejected(onceFunc(), /fake-error3/),
assert.isRejected(onceFunc(), /fake-error3/),
]);
sinon.assert.calledOnce(createFunc); // Called just once here.
createFunc.resetHistory();
});
describe("mapGetOrSet", function() {
it('should call create func on first use and after failure', async function() {
const createFunc = sinon.stub();
const amap = new Map<string, any>();
createFunc.callsFake(async (key: string) => ({myKey: key.toUpperCase()}));
// Check that mapGetOrSet() calls the createFunc and returns the expected value.
assert.deepEqual(await mapGetOrSet(amap, "foo", createFunc), {myKey: "FOO"});
assert.deepEqual(await mapGetOrSet(amap, "bar", createFunc), {myKey: "BAR"});
sinon.assert.calledTwice(createFunc);
createFunc.resetHistory();
// Subsequent calls return the cached value.
assert.deepEqual(await mapGetOrSet(amap, "foo", createFunc), {myKey: "FOO"});
assert.deepEqual(await mapGetOrSet(amap, "bar", createFunc), {myKey: "BAR"});
sinon.assert.notCalled(createFunc);
// Calls to plain .get() also return the cached value.
assert.deepEqual(await amap.get("foo"), {myKey: "FOO"});
assert.deepEqual(await amap.get("bar"), {myKey: "BAR"});
sinon.assert.notCalled(createFunc);
// After clearing, .get() returns undefined. (The usual Map behavior.)
amap.delete("foo");
assert.strictEqual(await amap.get("foo"), undefined);
// After clearing, mapGetOrSet() calls createFunc again. We'll make this one fail.
createFunc.callsFake((key: string) => Promise.reject(new Error('fake-error1-' + key)));
await assert.isRejected(mapGetOrSet(amap, "foo", createFunc), /fake-error1-foo/);
assert.strictEqual(await amap.get("foo"), undefined);
sinon.assert.calledOnce(createFunc);
createFunc.resetHistory();
// Other keys should be unaffected.
assert.deepEqual(await mapGetOrSet(amap, "bar", createFunc), {myKey: "BAR"});
assert.deepEqual(await amap.get("bar"), {myKey: "BAR"});
sinon.assert.notCalled(createFunc);
// After failure, subsequent calls try again.
createFunc.callsFake((key: string) => Promise.reject(new Error('fake-error2-' + key)));
await assert.isRejected(mapGetOrSet(amap, "foo", createFunc), /fake-error2-foo/);
sinon.assert.calledOnce(createFunc);
createFunc.resetHistory();
// While a createFunc() is pending we do NOT call it again.
createFunc.callsFake((key: string) => Promise.reject(new Error('fake-error3-' + key)));
amap.delete("bar");
await Promise.all([
assert.isRejected(mapGetOrSet(amap, "foo", createFunc), /fake-error3-foo/),
assert.isRejected(mapGetOrSet(amap, "bar", createFunc), /fake-error3-bar/),
assert.isRejected(mapGetOrSet(amap, "foo", createFunc), /fake-error3-foo/),
assert.isRejected(mapGetOrSet(amap, "bar", createFunc), /fake-error3-bar/),
]);
sinon.assert.calledTwice(createFunc); // Called just twice, once for each value.
createFunc.resetHistory();
});
});
});

@ -0,0 +1,21 @@
import {BigInt} from 'app/common/BigInt';
import {assert} from 'chai';
import {times} from 'lodash';
describe('BigInt', function() {
it('should represent and convert various numbers correctly', function() {
assert.strictEqual(new BigInt(16, [0xF, 0xA], +1).toString(16), "af");
assert.strictEqual(new BigInt(16, [0xA, 0xF], -1).toString(16), "-fa");
assert.strictEqual(new BigInt(16, [0xF, 0xF], +1).toString(10), "255");
assert.strictEqual(new BigInt(16, [0xF, 0xF], -1).toString(10), "-255");
assert.strictEqual(new BigInt(10, times(20, () => 5), 1).toString(10), "55555555555555555555");
assert.strictEqual(new BigInt(100, times(20, () => 5), 1).toString(10),
"505050505050505050505050505050505050505");
assert.strictEqual(new BigInt(1000, times(20, () => 5), 1).toString(10),
"5005005005005005005005005005005005005005005005005005005005");
assert.strictEqual(new BigInt(0x10000, [0xABCD, 0x1234, 0xF0F0, 0x5678], -1).toString(16),
"-5678f0f01234abcd");
});
});

@ -0,0 +1,244 @@
/* global describe, before, it */
var assert = require('assert');
var BinaryIndexedTree = require('app/common/BinaryIndexedTree');
describe("BinaryIndexedTree", function() {
describe('#leastSignificantOne', function() {
it("should only keep the least significant one", function() {
assert.equal(BinaryIndexedTree.leastSignificantOne(1), 1);
assert.equal(BinaryIndexedTree.leastSignificantOne(6), 2);
assert.equal(BinaryIndexedTree.leastSignificantOne(15), 1);
assert.equal(BinaryIndexedTree.leastSignificantOne(16), 16);
assert.equal(BinaryIndexedTree.leastSignificantOne(0), 0);
});
});
describe('#stripLeastSignificantOne', function() {
it("should strip the least significant one", function() {
assert.equal(BinaryIndexedTree.stripLeastSignificantOne(1), 0);
assert.equal(BinaryIndexedTree.stripLeastSignificantOne(6), 4);
assert.equal(BinaryIndexedTree.stripLeastSignificantOne(15), 14);
assert.equal(BinaryIndexedTree.stripLeastSignificantOne(16), 0);
assert.equal(BinaryIndexedTree.stripLeastSignificantOne(0), 0);
assert.equal(BinaryIndexedTree.stripLeastSignificantOne(24), 16);
});
});
describe('#mostSignificantOne', function() {
it("should keep the most significant one", function() {
assert.equal(BinaryIndexedTree.mostSignificantOne(1), 1);
assert.equal(BinaryIndexedTree.mostSignificantOne(6), 4);
assert.equal(BinaryIndexedTree.mostSignificantOne(15), 8);
assert.equal(BinaryIndexedTree.mostSignificantOne(16), 16);
assert.equal(BinaryIndexedTree.mostSignificantOne(24), 16);
assert.equal(BinaryIndexedTree.mostSignificantOne(0), 0);
});
});
describe('#cumulToValues', function() {
it("should convert cumulative array to regular values", function() {
assert.deepEqual(BinaryIndexedTree.cumulToValues([1, 3, 6, 10]), [1, 2, 3, 4]);
assert.deepEqual(BinaryIndexedTree.cumulToValues([1, 3, 6, 10, 15, 21]), [1, 2, 3, 4, 5, 6]);
assert.deepEqual(BinaryIndexedTree.cumulToValues([]), []);
});
});
describe('#valuesToCumul', function() {
it("should convert value array to cumulative array", function() {
assert.deepEqual(BinaryIndexedTree.valuesToCumul([1, 2, 3, 4]), [1, 3, 6, 10]);
assert.deepEqual(BinaryIndexedTree.valuesToCumul([1, 2, 3, 4, 5, 6]), [1, 3, 6, 10, 15, 21]);
assert.deepEqual(BinaryIndexedTree.valuesToCumul([]), []);
});
});
//----------------------------------------------------------------------
// Test array of length 25.
var data1 = [47, 17, 28, 96, 10, 2, 11, 43, 7, 94, 37, 81, 75, 2, 33, 57, 68, 71, 68, 86, 27, 44, 64, 41, 23];
// Test array of length 64.
var data2 = [722, 106, 637, 881, 752, 940, 989, 295, 344, 716, 283, 609, 482, 268, 884, 782, 628, 778, 442, 456, 171, 821, 346, 367, 12, 46, 582, 164, 876, 421, 749, 357, 586, 319, 847, 79, 649, 353, 545, 353, 609, 865, 229, 476, 697, 579, 109, 935, 412, 286, 701, 712, 288, 45, 990, 176, 775, 143, 187, 241, 721, 691, 162, 460];
var cdata1, cdata2; // Cumulative versions.
function dumbGetCumulativeValue(array, index) {
for (var i = 0, x = 0; i <= index; i++) {
x += array[i];
}
return x;
}
/*
function dumbGetIndex(array, cumulValue) {
for (var i = 0, x = 0; i <= array.length && x <= cumulValue; i++) {
x += array[i];
}
return i;
}
*/
before(function() {
cdata1 = data1.map(function(value, i) { return dumbGetCumulativeValue(data1, i); });
cdata2 = data2.map(function(value, i) { return dumbGetCumulativeValue(data2, i); });
});
describe('BinaryIndexedTree class', function() {
it("should construct trees with zeroes", function() {
var bit = new BinaryIndexedTree();
assert.equal(bit.size(), 0);
bit.fillFromValues([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
var bit2 = new BinaryIndexedTree(10);
assert.deepEqual(bit, bit2);
});
it("should convert from cumulative array and back", function() {
var bit = new BinaryIndexedTree();
bit.fillFromCumulative(cdata1);
assert.equal(bit.size(), 25);
assert.deepEqual(bit.toCumulativeArray(), cdata1);
assert.deepEqual(bit.toValueArray(), data1);
bit.fillFromCumulative([]);
assert.equal(bit.size(), 0);
assert.deepEqual(bit.toCumulativeArray(), []);
assert.deepEqual(bit.toValueArray(), []);
bit.fillFromCumulative(cdata2);
assert.equal(bit.size(), 64);
assert.deepEqual(bit.toCumulativeArray(), cdata2);
assert.deepEqual(bit.toValueArray(), data2);
});
it("should convert from value array and back", function() {
var bit = new BinaryIndexedTree();
bit.fillFromValues(data1);
assert.equal(bit.size(), 25);
assert.deepEqual(bit.toCumulativeArray(), cdata1);
assert.deepEqual(bit.toValueArray(), data1);
bit.fillFromValues([]);
assert.equal(bit.size(), 0);
assert.deepEqual(bit.toCumulativeArray(), []);
assert.deepEqual(bit.toValueArray(), []);
bit.fillFromValues(data2);
assert.equal(bit.size(), 64);
assert.deepEqual(bit.toCumulativeArray(), cdata2);
assert.deepEqual(bit.toValueArray(), data2);
bit.fillFromValues([1, 2, 3, 4, 5]);
assert.equal(bit.size(), 5);
assert.deepEqual(bit.toCumulativeArray(), [1, 3, 6, 10, 15]);
assert.deepEqual(bit.toValueArray(), [1, 2, 3, 4, 5]);
});
it("should compute individual and cumulative values", function() {
var i, bit = new BinaryIndexedTree();
bit.fillFromValues(data1);
assert.equal(bit.size(), 25);
for (i = 0; i < 25; i++) {
assert.equal(bit.getValue(i), data1[i]);
assert.equal(bit.getCumulativeValue(i), cdata1[i]);
assert.equal(bit.getSumTo(i), cdata1[i] - data1[i]);
}
assert.equal(bit.getTotal(), data1.reduce(function(a, b) { return a + b; }));
bit.fillFromValues(data2);
assert.equal(bit.size(), 64);
for (i = 0; i < 64; i++) {
assert.equal(bit.getValue(i), data2[i]);
assert.equal(bit.getCumulativeValue(i), cdata2[i]);
assert.equal(bit.getSumTo(i), cdata2[i] - data2[i]);
}
assert.equal(bit.getTotal(), data2.reduce(function(a, b) { return a + b; }));
});
it("should compute cumulative range values", function() {
var i, bit = new BinaryIndexedTree();
bit.fillFromValues(data1);
assert.equal(bit.getCumulativeValueRange(0, data1.length),
bit.getCumulativeValue(data1.length-1));
for(i = 1; i < 25; i++) {
assert.equal(bit.getCumulativeValueRange(i, 25),
cdata1[24] - cdata1[i-1]);
}
for(i = 24; i >= 0; i-- ){
assert.equal(bit.getCumulativeValueRange(0, i+1), cdata1[i]);
}
bit.fillFromValues(data2);
assert.equal(bit.getCumulativeValueRange(0, 64),
bit.getCumulativeValue(63));
for(i = 1; i < 64; i++) {
assert.equal(bit.getCumulativeValueRange(i, 64),
cdata2[63] - cdata2[i-1]);
}
for(i = 63; i >= 0; i-- ){
assert.equal(bit.getCumulativeValueRange(0, i+1), cdata2[i]);
}
});
it("should search by cumulative value", function() {
var bit = new BinaryIndexedTree();
bit.fillFromValues([1, 2, 3, 4]);
assert.equal(bit.getIndex(-1), 0);
assert.equal(bit.getIndex(0), 0);
assert.equal(bit.getIndex(1), 0);
assert.equal(bit.getIndex(2), 1);
assert.equal(bit.getIndex(3), 1);
assert.equal(bit.getIndex(4), 2);
assert.equal(bit.getIndex(5), 2);
assert.equal(bit.getIndex(6), 2);
assert.equal(bit.getIndex(7), 3);
assert.equal(bit.getIndex(8), 3);
assert.equal(bit.getIndex(9), 3);
assert.equal(bit.getIndex(10), 3);
assert.equal(bit.getIndex(11), 4);
bit.fillFromValues(data1);
// data1 is [47,17,28,96,10,2,11,43,7,94,37,81,75,2,33,57,68,71,68,86,27,44,64,41,23];
assert.equal(bit.getIndex(0), 0);
assert.equal(bit.getIndex(1), 0);
assert.equal(bit.getIndex(46.9), 0);
assert.equal(bit.getIndex(47), 0);
assert.equal(bit.getIndex(63), 1);
assert.equal(bit.getIndex(64), 1);
assert.equal(bit.getIndex(64.1), 2);
assert.equal(bit.getIndex(bit.getCumulativeValue(5)), 5);
assert.equal(bit.getIndex(bit.getCumulativeValue(20)), 20);
assert.equal(bit.getIndex(bit.getCumulativeValue(24)), 24);
assert.equal(bit.getIndex(1000000), 25);
});
it("should support add and set", function() {
var i, bit = new BinaryIndexedTree(4);
bit.setValue(1, 2);
assert.deepEqual(bit.toValueArray(), [0, 2, 0, 0]);
bit.setValue(3, 4);
assert.deepEqual(bit.toValueArray(), [0, 2, 0, 4]);
bit.setValue(0, 1);
assert.deepEqual(bit.toValueArray(), [1, 2, 0, 4]);
bit.addValue(2, 1);
assert.deepEqual(bit.toValueArray(), [1, 2, 1, 4]);
bit.addValue(2, 1);
assert.deepEqual(bit.toValueArray(), [1, 2, 2, 4]);
bit.addValue(2, 1);
assert.deepEqual(bit.toValueArray(), [1, 2, 3, 4]);
bit.fillFromValues(data1);
for (i = 0; i < data1.length; i++) {
bit.addValue(i, -data1[i]);
}
assert.deepEqual(bit.toValueArray(), data1.map(function() { return 0; }));
bit.fillFromValues(data1);
for (i = data1.length - 1; i >= 0; i--) {
bit.addValue(i, data1[i]);
}
assert.deepEqual(bit.toValueArray(), data1.map(function(x) { return 2*x; }));
});
});
});

@ -0,0 +1,70 @@
import {DocumentSettings} from 'app/common/DocumentSettings';
import {createParserRaw} from 'app/common/ValueParser';
import {assert} from 'chai';
const parser = createParserRaw("ChoiceList", {}, {} as DocumentSettings);
function testParse(input: string, expected?: string[]) {
const result = parser.cleanParse(input);
if (expected) {
assert.deepEqual(result, ["L", ...expected], input);
} else {
assert.isNull(result, input);
}
}
describe('ChoiceListParser', function() {
it('should handle empty values', function() {
testParse("");
testParse(" ");
testParse(" , ");
testParse(",,,");
testParse(" , , , ");
testParse("[]");
testParse('[""]');
testParse('["", null, null, ""]');
testParse('""');
});
it('should parse JSON', function() {
testParse("[1]", ["1"]);
testParse('["a"]', ["a"]);
testParse('["a", "aa"]', ["a", "aa"]);
testParse(' ["a", "aa"] ', ["a", "aa"]);
testParse("[0, 1, 2]", ["0", "1", "2"]);
testParse('[0, 1, 2, "a", "b", "c"]', ["0", "1", "2", "a", "b", "c"]);
// Remove nulls and empty strings
testParse('["a", null, "aa", "", null]', ["a", "aa"]);
// Format nested JSON arrays and objects with formatDecoded
testParse('[0, 1, 2, "a", "b", "c", ["d", "x", "y, z"], [["e"], "f"], {"g": ["h"]}]',
["0", "1", "2", "a", "b", "c", 'd, x, "y, z"', '[["e"], "f"]', '{"g": ["h"]}']);
// These are valid JSON but they're not arrays so _parseJSON doesn't touch them
testParse('null', ["null"]);
testParse('123', ["123"]);
testParse('"123"', ["123"]);
testParse('"abc"', ["abc"]);
});
it('should parse CSVs', function() {
testParse('"a", "aa"', ["a", "aa"]);
testParse('"a", aa', ["a", "aa"]);
testParse(' " a " , aa', ["a", "aa"]);
testParse('a, aa', ["a", "aa"]);
testParse('a,aa', ["a", "aa"]);
testParse('a,aa b c', ["a", "aa b c"]);
testParse(' "a", "aa" ', ["a", "aa"]);
testParse("0, 1, 2", ["0", "1", "2"]);
testParse('0, 1, 2, "a", "b", "c"', ["0", "1", "2", "a", "b", "c"]);
testParse('"a", null, "aa", "", null', ["a", "null", "aa", "null"]);
});
it('should split on newlines', function() {
testParse('a,b \r\n c,d \n e \n\n\n f \n \n\n \n g', ["a", "b", "c", "d", "e", "f", "g"]);
});
});

@ -0,0 +1,38 @@
/* global describe, it */
var assert = require('assert');
var CircularArray = require('app/common/CircularArray');
describe("CircularArray", function() {
it("should lose old items", function() {
var c = new CircularArray(5);
assert.equal(c.maxLength, 5);
assert.equal(c.length, 0);
c.push("a");
assert.equal(c.get(0), "a");
c.push("b");
c.push("c");
assert.equal(c.length, 3);
assert.equal(c.get(2), "c");
assert.deepEqual(c.getArray(), ["a", "b", "c"]);
c.push("d");
c.push("e");
assert.equal(c.length, 5);
assert.equal(c.get(4), "e");
assert.deepEqual(c.getArray(), ["a", "b", "c", "d", "e"]);
c.push("f");
assert.equal(c.length, 5);
assert.equal(c.get(0), "b");
assert.equal(c.get(4), "f");
assert.deepEqual(c.getArray(), ["b", "c", "d", "e", "f"]);
c.push("g");
c.push("h");
c.push("i");
c.push("j");
assert.equal(c.length, 5);
assert.equal(c.get(0), "f");
assert.equal(c.get(4), "j");
assert.deepEqual(c.getArray(), ["f", "g", "h", "i", "j"]);
assert.equal(c.maxLength, 5);
});
});

@ -0,0 +1,33 @@
import {fromTableDataAction, TableDataAction, toTableDataAction} from 'app/common/DocActions';
import {assert} from 'chai';
describe('DocActions', function() {
it('should convert correctly with toTableDataAction', () => {
const colValues = {id: [2, 4, 6], foo: ["a", "b", "c"], bar: [false, "y", null]};
assert.deepEqual(toTableDataAction("Hello", colValues),
['TableData', "Hello", [2, 4, 6],
{ foo: ["a", "b", "c"], bar: [false, "y", null] }]);
// Make sure colValues that was passed-in didn't get changed.
assert.deepEqual(colValues,
{id: [2, 4, 6], foo: ["a", "b", "c"], bar: [false, "y", null]});
assert.deepEqual(toTableDataAction("Foo", {id: []}), ['TableData', "Foo", [], {}]);
});
it('should convert correctly with fromTableDataAction', () => {
const tableData: TableDataAction = ['TableData', "Hello", [2, 4, 6],
{ foo: ["a", "b", "c"], bar: [false, "y", null] }];
assert.deepEqual(fromTableDataAction(tableData),
{id: [2, 4, 6], foo: ["a", "b", "c"], bar: [false, "y", null]});
// Make sure tableData itself is unchanged.
assert.deepEqual(tableData, ['TableData', "Hello", [2, 4, 6],
{ foo: ["a", "b", "c"], bar: [false, "y", null] }]);
assert.deepEqual(fromTableDataAction(['TableData', "Foo", [], {}]), {id: []});
});
});

@ -0,0 +1,110 @@
import {InactivityTimer} from 'app/common/InactivityTimer';
import {delay} from 'bluebird';
import {assert} from 'chai';
import * as sinon from 'sinon';
describe("InactivityTimer", function() {
let spy: sinon.SinonSpy, timer: InactivityTimer;
beforeEach(() => {
spy = sinon.spy();
timer = new InactivityTimer(spy, 100);
});
it("if no activity, should trigger when time elapses after ping", async function() {
timer.ping();
assert(spy.callCount === 0);
await delay(150);
assert.equal(spy.callCount, 1);
});
it("disableUntilFinish should clear timeout, and set it back after promise resolved", async function() {
timer.ping();
timer.disableUntilFinish(delay(100)); // eslint-disable-line @typescript-eslint/no-floating-promises
await delay(150);
assert.equal(spy.callCount, 0);
await delay(100);
assert.equal(spy.callCount, 1);
});
it("should not trigger during async monitoring", async function() {
timer.disableUntilFinish(delay(300)); // eslint-disable-line @typescript-eslint/no-floating-promises
// do not triggers after a ping
timer.ping();
await delay(150);
assert.equal(spy.callCount, 0);
// nor after an async monitored call
timer.disableUntilFinish(delay(0)); // eslint-disable-line @typescript-eslint/no-floating-promises
await delay(150);
assert.equal(spy.callCount, 0);
// finally triggers callback
await delay(150);
assert.equal(spy.callCount, 1);
});
it("should support disabling", async function() {
timer.disable();
assert.equal(timer.isEnabled(), false);
// While disabled, ping doesn't trigger anything.
timer.ping();
assert.equal(timer.isScheduled(), false);
await delay(200);
assert.equal(spy.callCount, 0);
// When enabled, it triggers as usual.
timer.enable();
assert.equal(timer.isEnabled(), true);
assert.equal(timer.isScheduled(), true);
await delay(150);
assert.equal(spy.callCount, 1);
spy.resetHistory();
// When enabled, ping and disableUntilFinish both trigger the callback.
timer.disableUntilFinish(delay(50)).catch(() => null);
timer.disableUntilFinish(delay(150)).catch(() => null);
await delay(100);
assert.equal(spy.callCount, 0);
assert.equal(timer.isScheduled(), false);
await delay(100);
assert.equal(timer.isScheduled(), true);
assert.equal(spy.callCount, 0);
await delay(100);
assert.equal(spy.callCount, 1);
spy.resetHistory();
// When disabled, nothing is triggered.
timer.disableUntilFinish(delay(50)).catch(() => null);
timer.disableUntilFinish(delay(150)).catch(() => null);
await delay(100);
assert.equal(spy.callCount, 0);
assert.equal(timer.isEnabled(), true);
assert.equal(timer.isScheduled(), false);
timer.disable();
timer.ping();
timer.disableUntilFinish(delay(150)).catch(() => null);
assert.equal(timer.isEnabled(), false);
assert.equal(timer.isScheduled(), false);
// Nothing called even after disableUntilFinished have resumed.
await delay(200);
assert.equal(spy.callCount, 0);
assert.equal(timer.isScheduled(), false);
// Re-enabling will schedule after a new delay.
timer.enable();
assert.equal(timer.isEnabled(), true);
assert.equal(timer.isScheduled(), true);
await delay(50);
assert.equal(spy.callCount, 0);
await delay(150);
assert.equal(spy.callCount, 1);
assert.equal(timer.isEnabled(), true);
assert.equal(timer.isScheduled(), false);
});
});

@ -0,0 +1,77 @@
import {KeyedMutex} from 'app/common/KeyedMutex';
import {delay} from 'bluebird';
import {assert} from 'chai';
describe('KeyedMutex', function() {
it('orders actions correctly', async function() {
const m = new KeyedMutex();
let v1: number = 0;
let v2: number = 0;
const fastAdd2 = m.acquire('2').then(unlock => {
v2++;
unlock();
});
const slowDouble2 = m.acquire('2').then(async unlock => {
await delay(1000);
v2 *= 2;
unlock();
});
assert.equal(m.size, 1);
const slowAdd1 = m.acquire('1').then(async unlock => {
await delay(500);
v1++;
unlock();
});
const immediateDouble1 = m.acquire('1').then(unlock => {
v1 *= 2;
unlock();
});
assert.equal(m.size, 2);
await Promise.all([slowAdd1, immediateDouble1]);
assert.equal(m.size, 1);
assert.equal(v1, 2);
assert.equal(v2, 1);
await Promise.all([fastAdd2, slowDouble2]);
assert.equal(m.size, 0);
assert.equal(v1, 2);
assert.equal(v2, 2);
});
it('runs operations exclusively', async function() {
const m = new KeyedMutex();
let v1: number = 0;
let v2: number = 0;
const fastAdd2 = m.runExclusive('2', async () => {
v2++;
});
const slowDouble2 = m.runExclusive('2', async () => {
await delay(1000);
v2 *= 2;
});
assert.equal(m.size, 1);
const slowAdd1 = m.runExclusive('1', async () => {
await delay(500);
v1++;
});
const immediateDouble1 = m.runExclusive('1', async () => {
v1 *= 2;
});
assert.equal(m.size, 2);
await Promise.all([slowAdd1, immediateDouble1]);
assert.equal(m.size, 1);
assert.equal(v1, 2);
assert.equal(v2, 1);
await Promise.all([fastAdd2, slowDouble2]);
assert.equal(m.size, 0);
assert.equal(v1, 2);
assert.equal(v2, 2);
});
});

@ -0,0 +1,127 @@
/* global describe, it */
var assert = require('assert');
var MemBuffer = require('app/common/MemBuffer');
function repeat(str, n) {
return new Array(n+1).join(str);
}
describe("MemBuffer", function() {
describe('#reserve', function() {
it("should reserve exponentially", function() {
var mbuf = new MemBuffer();
assert.equal(mbuf.size(), 0);
var str = "";
var lastRes = mbuf.reserved();
var countReallocs = 0;
// Append 1 char at a time, 1000 times, and make sure we don't have more than 10 reallocs.
for (var i = 0; i < 1000; i++) {
var ch = 'a'.charCodeAt(0) + (i % 10);
str += String.fromCharCode(ch);
mbuf.writeUint8(ch);
assert.equal(mbuf.size(), i + 1);
assert.equal(mbuf.toString(), str);
assert.ok(mbuf.reserved() >= mbuf.size());
// Count reallocs.
if (mbuf.reserved() != lastRes) {
lastRes = mbuf.reserved();
countReallocs++;
}
}
assert.ok(countReallocs < 10 && countReallocs >= 2);
});
it("should not realloc when it can move data", function() {
var mbuf = new MemBuffer();
mbuf.writeString(repeat("x", 100));
assert.equal(mbuf.size(), 100);
assert.ok(mbuf.reserved() >= 100 && mbuf.reserved() < 200);
// Consume 99 characters, and produce 99 more, and the buffer shouldn't keep being reused.
var cons = mbuf.makeConsumer();
var value = mbuf.readString(cons, 99);
mbuf.consume(cons);
assert.equal(value, repeat("x", 99));
assert.equal(mbuf.size(), 1);
var prevBuffer = mbuf.buffer;
mbuf.writeString(repeat("y", 99));
assert.strictEqual(mbuf.buffer, prevBuffer);
assert.equal(mbuf.size(), 100);
assert.ok(mbuf.reserved() >= 100 && mbuf.reserved() < 200);
// Consume the whole buffer, and produce a new one, and it's still being reused.
cons = mbuf.makeConsumer();
value = mbuf.readString(cons, 100);
mbuf.consume(cons);
assert.equal(value, "x" + repeat("y", 99));
assert.equal(mbuf.size(), 0);
mbuf.writeString(repeat("z", 100));
assert.strictEqual(mbuf.buffer, prevBuffer);
assert.equal(mbuf.size(), 100);
assert.equal(mbuf.toString(), repeat("z", 100));
// But if we produce enough new data (twice should do), it should have to realloc.
mbuf.writeString(repeat("w", 100));
assert.notStrictEqual(mbuf.buffer, prevBuffer);
assert.equal(mbuf.size(), 200);
assert.equal(mbuf.toString(), repeat("z", 100) + repeat("w", 100));
});
});
describe('#write', function() {
it("should append to the buffer", function() {
var mbuf = new MemBuffer();
mbuf.writeString("a");
mbuf.writeString(repeat("x", 100));
assert.equal(mbuf.toString(), "a" + repeat("x", 100));
var y = repeat("y", 10000);
mbuf.writeString(y);
assert.equal(mbuf.toString(), "a" + repeat("x", 100) + y);
});
});
describe('#consume', function() {
it("should remove from start of buffer", function() {
var mbuf = new MemBuffer();
mbuf.writeString(repeat("x", 90));
mbuf.writeString(repeat("y", 10));
assert.equal(mbuf.toString(), repeat("x", 90) + repeat("y", 10));
var cons = mbuf.makeConsumer();
assert.equal(mbuf.readString(cons, 1), "x");
assert.equal(mbuf.readString(cons, 90), repeat("x", 89) + "y");
mbuf.consume(cons);
assert.equal(mbuf.toString(), repeat("y", 9));
// Trying to read past the end should throw.
assert.throws(function() {
mbuf.readString(cons, 10);
}, function(err) {
assert.ok(err.needMoreData);
return true;
});
// Should leave the buffer empty if consume to the end.
assert.equal(mbuf.readString(cons, 9), repeat("y", 9));
mbuf.consume(cons);
assert.equal(mbuf.size(), 0);
});
it("should read large strings", function() {
var mbuf = new MemBuffer();
var y = repeat("y", 10000);
mbuf.writeString(y);
var cons = mbuf.makeConsumer();
assert.equal(mbuf.readString(cons, 10000), y);
mbuf.consume(cons);
assert.equal(mbuf.size(), 0);
});
});
});

@ -0,0 +1,115 @@
import {buildNumberFormat} from 'app/common/NumberFormat';
import {assert} from 'chai';
describe("NumberFormat", function() {
const defaultDocSettings = {
locale: 'en-US'
};
it("should convert Grist options into Intr.NumberFormat", function() {
assert.ownInclude(buildNumberFormat({}, defaultDocSettings).resolvedOptions(), {
minimumFractionDigits: 0,
maximumFractionDigits: 10,
style: 'decimal',
useGrouping: false,
});
assert.ownInclude(buildNumberFormat({numMode: 'decimal'}, defaultDocSettings).resolvedOptions(), {
minimumFractionDigits: 0,
maximumFractionDigits: 3,
style: 'decimal',
useGrouping: true,
});
assert.ownInclude(buildNumberFormat({numMode: 'percent'}, defaultDocSettings).resolvedOptions(), {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
// style: 'percent', // In node v14.17.0 style is 'decimal' (unclear why)
// so we check final formatting instead in this case.
useGrouping: true,
});
assert.equal(buildNumberFormat({numMode: 'percent'}, defaultDocSettings).format(0.5), '50%');
assert.ownInclude(buildNumberFormat({numMode: 'currency'}, defaultDocSettings).resolvedOptions(), {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
style: 'currency',
useGrouping: true,
currency: 'USD',
});
assert.ownInclude(buildNumberFormat({numMode: 'scientific'}, defaultDocSettings).resolvedOptions(), {
minimumFractionDigits: 0,
maximumFractionDigits: 3,
style: 'decimal',
// notation: 'scientific', // Should be set, but node doesn't support it until node 12.
});
// Ensure we don't hit errors when max digits is less than the min (which could be implicit).
assert.ownInclude(buildNumberFormat({numMode: 'currency', maxDecimals: 1}, defaultDocSettings).resolvedOptions(),
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
assert.ownInclude(
buildNumberFormat({numMode: 'currency', decimals: 0, maxDecimals: 1}, defaultDocSettings).resolvedOptions(),
{ minimumFractionDigits: 0, maximumFractionDigits: 1 });
assert.ownInclude(buildNumberFormat({decimals: 5}, defaultDocSettings).resolvedOptions(),
{ minimumFractionDigits: 5, maximumFractionDigits: 10 });
assert.ownInclude(buildNumberFormat({decimals: 15}, defaultDocSettings).resolvedOptions(),
{ minimumFractionDigits: 15, maximumFractionDigits: 15 });
});
it('should clamp min/max decimals to valid values', function() {
assert.ownInclude(buildNumberFormat({}, defaultDocSettings).resolvedOptions(),
{ minimumFractionDigits: 0, maximumFractionDigits: 10 });
assert.ownInclude(buildNumberFormat({decimals: 5}, defaultDocSettings).resolvedOptions(),
{ minimumFractionDigits: 5, maximumFractionDigits: 10 });
assert.ownInclude(buildNumberFormat({maxDecimals: 5}, defaultDocSettings).resolvedOptions(),
{ minimumFractionDigits: 0, maximumFractionDigits: 5 });
assert.ownInclude(buildNumberFormat({decimals: -10, maxDecimals: 50}, defaultDocSettings).resolvedOptions(),
{ minimumFractionDigits: 0, maximumFractionDigits: 20 });
assert.ownInclude(buildNumberFormat({decimals: 21, maxDecimals: 1}, defaultDocSettings).resolvedOptions(),
{ minimumFractionDigits: 20, maximumFractionDigits: 20 });
assert.ownInclude(buildNumberFormat({numMode: 'currency', maxDecimals: 1}, defaultDocSettings).resolvedOptions(),
{ minimumFractionDigits: 2, maximumFractionDigits: 2 }); // Currency overrides the minimum
});
it('should convert locales to local currency', function() {
assert.ownInclude(buildNumberFormat({numMode: 'currency'}, {locale: 'fr-BE'}).resolvedOptions(), {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
style: 'currency',
useGrouping: true,
currency: 'EUR',
});
assert.ownInclude(buildNumberFormat({numMode: 'currency'}, {locale: 'en-NZ'}).resolvedOptions(), {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
style: 'currency',
useGrouping: true,
currency: 'NZD',
});
assert.ownInclude(buildNumberFormat({numMode: 'currency'}, {locale: 'de-CH'}).resolvedOptions(), {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
style: 'currency',
useGrouping: true,
currency: 'CHF',
});
assert.ownInclude(buildNumberFormat({numMode: 'currency'}, {locale: 'es-AR'}).resolvedOptions(), {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
style: 'currency',
useGrouping: true,
currency: 'ARS',
});
assert.ownInclude(buildNumberFormat({numMode: 'currency'}, {locale: 'zh-TW'}).resolvedOptions(), {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
style: 'currency',
useGrouping: true,
currency: 'TWD',
});
assert.ownInclude(buildNumberFormat({numMode: 'currency'}, {locale: 'en-AU'}).resolvedOptions(), {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
style: 'currency',
useGrouping: true,
currency: 'AUD',
});
});
});

@ -0,0 +1,392 @@
import {getCurrency, locales} from 'app/common/Locales';
import {NumMode, parseNumMode} from 'app/common/NumberFormat';
import NumberParse from 'app/common/NumberParse';
import {assert} from 'chai';
import * as _ from 'lodash';
describe("NumberParse", function() {
let parser = new NumberParse("en", "USD");
function check(str: string, expected: number | null) {
const parsed = parser.parse(str);
assert.equal(parsed?.result ?? null, expected);
}
it("can do basic parsing", function() {
check("123", 123);
check("-123", -123);
check("-123.456", -123.456);
check("-1.234e56", -1.234e56);
check("1.234e-56", 1.234e-56);
check("(1.234e56)", -1.234e56);
check("($1.23)", -1.23);
check("($ 1.23)", -1.23);
check("$ 1.23", 1.23);
check("$1.23", 1.23);
check("12.34%", 0.1234);
check("1,234,567.89", 1234567.89);
check(".89", .89);
check(".89000", .89);
check("0089", 89);
// The digit separator is ',' but spaces are always removed anyway
check("1 234 567.89", 1234567.89);
assert.equal(parser.parse(""), null);
check(" ", null);
check("()", null);
check(" ( ) ", null);
check(" (,) ", null);
check(" (.) ", null);
check(",", null);
check(",.", null);
check(".,", null);
check(",,,", null);
check("...", null);
check(".", null);
check("%", null);
check("$", null);
check("(ABC)", null);
check("ABC", null);
check("USD", null);
check("NaN", null);
check("NAN", null);
check("nan", null);
// Currency symbol can only appear once
check("$$1.23", null);
// Other currency symbols not allowed
check("USD 1.23", null);
check("€ 1.23", null);
check("£ 1.23", null);
check("$ 1.23", 1.23);
// Parentheses represent negative numbers,
// so the number inside can't also be negative or 0
check("(0)", null);
check("(-1.23)", null);
check("(1.23)", -1.23);
check("-1.23", -1.23);
// Only one % allowed
check("12.34%%", null);
check("12.34%", 0.1234);
});
it("can handle different minus sign positions", function() {
parser = new NumberParse("fy", "EUR");
let formatter = Intl.NumberFormat("fy", {style: "currency", currency: "EUR"});
assert.isTrue(parser.currencyEndsInMinusSign);
// Note the '-' is at the end
assert.equal(formatter.format(-1), "€ 1,00-");
// The parser can handle this, it also allows the '-' in the beginning as usual
check("€ 1,00-", -1);
check("€ -1,00", -1);
check("-€ 1,00", -1);
// But it's only allowed at the end for currency amounts, to match the formatter
check("1,00-", null);
check("-1,00", -1);
// By contrast, this locale doesn't put '-' at the end so the parser doesn't allow that
parser = new NumberParse("en", "USD");
formatter = Intl.NumberFormat("en", {style: "currency", currency: "USD"});
assert.isFalse(parser.currencyEndsInMinusSign);
assert.equal(formatter.format(-1), "-$1.00");
check("-$1.00", -1);
check("$-1.00", -1);
check("$1.00-", null);
check("-1.00", -1);
check("1.00-", null);
});
it("can handle different separators", function() {
let formatter = Intl.NumberFormat("en", {useGrouping: true});
assert.equal(formatter.format(123456789.123), "123,456,789.123");
parser = new NumberParse("en", "USD");
assert.equal(parser.digitGroupSeparator, ",");
assert.equal(parser.digitGroupSeparatorCurrency, ",");
assert.equal(parser.decimalSeparator, ".");
check("123,456,789.123", 123456789.123);
// The typical separator is ',' but spaces are always removed anyway
check("123 456 789.123", 123456789.123);
// There must be at least two digits after the separator
check("123,456", 123456);
check("12,34,56", 123456);
check("1,2,3,4,5,6", null);
check("123,,456", null);
check("1,234", 1234);
check("123,4", null);
// This locale uses 'opposite' separators to the above, i.e. ',' and '.' have swapped roles
formatter = Intl.NumberFormat("de-AT", {useGrouping: true, currency: "EUR", style: "currency"});
assert.equal(formatter.format(123456789.123), '€ 123.456.789,12');
// But only for currency amounts! Non-currency amounts use NBSP (non-breaking space) for the digit separator
formatter = Intl.NumberFormat("de-AT", {useGrouping: true});
assert.equal(formatter.format(123456789.123), '123 456 789,123');
parser = new NumberParse("de-AT", "EUR");
assert.equal(parser.digitGroupSeparator, " ");
assert.equal(parser.digitGroupSeparatorCurrency, ".");
assert.equal(parser.decimalSeparator, ",");
check("€ 123.456.789,123", 123456789.123);
check("€ 123 456 789,123", 123456789.123);
// The parser allows the currency separator for non-currency amounts
check(" 123.456.789,123", 123456789.123);
check(" 123 456 789,123", 123456789.123); // normal space
check(" 123 456 789,123", 123456789.123); // NBSP
formatter = Intl.NumberFormat("en-ZA", {useGrouping: true});
assert.equal(formatter.format(123456789.123), '123 456 789,123');
parser = new NumberParse("en-ZA", "ZAR");
assert.equal(parser.digitGroupSeparator, " ");
assert.equal(parser.digitGroupSeparatorCurrency, " ");
assert.equal(parser.decimalSeparator, ",");
// ',' is the official decimal separator of this locale,
// but in general '.' will also work as long as it's not the digit separator.
check("123 456 789,123", 123456789.123);
check("123 456 789.123", 123456789.123);
});
it("returns basic info about formatting options for a single string", function() {
parser = new NumberParse("en", "USD");
assert.isNull(parser.parse(""));
assert.isNull(parser.parse("a b"));
const defaultOptions = {
isCurrency: false,
isParenthesised: false,
hasDigitGroupSeparator: false,
isScientific: false,
isPercent: false,
};
assert.deepEqual(parser.parse("1"),
{result: 1, cleaned: "1", options: defaultOptions});
assert.deepEqual(parser.parse("$1"),
{result: 1, cleaned: "1", options: {...defaultOptions, isCurrency: true}});
assert.deepEqual(parser.parse("100%"),
{result: 1, cleaned: "100", options: {...defaultOptions, isPercent: true}});
assert.deepEqual(parser.parse("1,000"),
{result: 1000, cleaned: "1000", options: {...defaultOptions, hasDigitGroupSeparator: true}});
assert.deepEqual(parser.parse("1E2"),
{result: 100, cleaned: "1e2", options: {...defaultOptions, isScientific: true}});
assert.deepEqual(parser.parse("$1,000"),
{result: 1000, cleaned: "1000", options: {...defaultOptions, isCurrency: true, hasDigitGroupSeparator: true}});
});
it("guesses formatting options", function() {
parser = new NumberParse("en", "USD");
assert.deepEqual(parser.guessOptions([]), {});
assert.deepEqual(parser.guessOptions([""]), {});
assert.deepEqual(parser.guessOptions([null]), {});
assert.deepEqual(parser.guessOptions(["", null]), {});
assert.deepEqual(parser.guessOptions(["abc"]), {});
assert.deepEqual(parser.guessOptions(["1"]), {});
assert.deepEqual(parser.guessOptions(["1", "", null, "abc"]), {});
assert.deepEqual(parser.guessOptions(["$1,000"]), {numMode: "currency", decimals: 0});
assert.deepEqual(parser.guessOptions(["1,000%"]), {numMode: "percent"});
assert.deepEqual(parser.guessOptions(["1,000"]), {numMode: "decimal"});
assert.deepEqual(parser.guessOptions(["1E2"]), {numMode: "scientific"});
// Choose the most common mode when there are several candidates
assert.deepEqual(parser.guessOptions(["$1", "$2", "3%"]), {numMode: "currency", decimals: 0});
assert.deepEqual(parser.guessOptions(["$1", "2%", "3%"]), {numMode: "percent"});
assert.deepEqual(parser.guessOptions(["(2)"]), {numSign: 'parens'});
assert.deepEqual(parser.guessOptions(["(2)", "3"]), {numSign: 'parens'});
// If we see a negative number not surrounded by parens, assume that other parens mean something else
assert.deepEqual(parser.guessOptions(["(2)", "-3"]), {});
assert.deepEqual(parser.guessOptions(["($2)"]), {numSign: 'parens', numMode: "currency", decimals: 0});
// Guess 'decimal' (i.e. with thousands separators) even if most numbers don't have separators
assert.deepEqual(parser.guessOptions(["1", "10", "100", "1,000"]), {numMode: "decimal"});
// For USD, currencies are formatted with minimum 2 decimal places by default,
// so if the data doesn't have that many decimals we have to explicitly specify the number of decimals, default 0.
// The number of digits for other currencies is defaultNumDecimalsCurrency, tested a bit further down.
assert.deepEqual(parser.guessOptions(["$1"]), {numMode: "currency", decimals: 0});
assert.deepEqual(parser.guessOptions(["$1.2"]), {numMode: "currency", decimals: 0});
assert.deepEqual(parser.guessOptions(["$1.23"]), {numMode: "currency"});
assert.deepEqual(parser.guessOptions(["$1.234"]), {numMode: "currency"});
// Otherwise decimal places are guessed based on trailing zeroes
assert.deepEqual(parser.guessOptions(["$1.0"]), {numMode: "currency", decimals: 1});
assert.deepEqual(parser.guessOptions(["$1.00"]), {numMode: "currency", decimals: 2});
assert.deepEqual(parser.guessOptions(["$1.000"]), {numMode: "currency", decimals: 3});
assert.deepEqual(parser.guessOptions(["1E2"]), {numMode: "scientific"});
assert.deepEqual(parser.guessOptions(["1.3E2"]), {numMode: "scientific"});
assert.deepEqual(parser.guessOptions(["1.34E2"]), {numMode: "scientific"});
assert.deepEqual(parser.guessOptions(["1.0E2"]), {numMode: "scientific", decimals: 1});
assert.deepEqual(parser.guessOptions(["1.30E2"]), {numMode: "scientific", decimals: 2});
assert.equal(parser.defaultNumDecimalsCurrency, 2);
parser = new NumberParse("en", "TND");
assert.equal(parser.defaultNumDecimalsCurrency, 3);
parser = new NumberParse("en", "ZMK");
assert.equal(parser.defaultNumDecimalsCurrency, 0);
});
// Nice mixture of numbers of different sizes and containing all digits
const numbers = [
..._.range(1, 12),
..._.range(3, 20).map(n => Math.pow(3, n)),
..._.range(10).map(n => Math.pow(10, -n) * 1234560798),
];
numbers.push(...numbers.map(n => -n));
numbers.push(...numbers.map(n => 1 / n));
numbers.push(0); // added at the end because of the division just before
// Formatter to compare numbers that only differ because of floating point precision errors
const basicFormatter = Intl.NumberFormat("en", {
maximumSignificantDigits: 15,
useGrouping: false,
});
// All values supported by parseNumMode
const numModes: Array<NumMode | undefined> = ['currency', 'decimal', 'percent', 'scientific', undefined];
// Generate a test suite for every supported locale
for (const locale of locales) {
describe(`with ${locale.code} locale (${locale.name})`, function() {
const currency = getCurrency(locale.code);
beforeEach(() => {
parser = new NumberParse(locale.code, currency);
});
it("has sensible parser attributes", function() {
// These don't strictly need to have length 1, but it's nice to know
assert.lengthOf(parser.percentageSymbol, 1);
assert.lengthOf(parser.minusSign, 1);
assert.lengthOf(parser.decimalSeparator, 1);
// These *do* need to be a single character since the regex uses `[]`.
assert.lengthOf(parser.digitGroupSeparator, 1);
// This is the only symbol that's allowed to be empty
assert.include([0, 1], parser.digitGroupSeparatorCurrency.length);
assert.isNotEmpty(parser.exponentSeparator);
assert.isNotEmpty(parser.currencySymbol);
const symbols = [
parser.percentageSymbol,
parser.minusSign,
parser.decimalSeparator,
parser.digitGroupSeparator,
parser.exponentSeparator,
parser.currencySymbol,
...parser.digitsMap.keys(),
];
// All the symbols must be distinct
assert.equal(symbols.length, new Set(symbols).size);
// The symbols mustn't contain characters that the parser removes (e.g. spaces)
// or they won't be replaced correctly.
// The digit group separators are OK because they're removed anyway, and often the separator is a space.
// Currency is OK because it gets removed before these characters.
for (const symbol of symbols) {
if (![
parser.digitGroupSeparator,
parser.digitGroupSeparatorCurrency,
parser.currencySymbol,
].includes(symbol)) {
assert.equal(symbol, symbol.replace(NumberParse.removeCharsRegex, "REMOVED"));
}
}
// Decimal and digit separators have to be different.
// We checked digitGroupSeparator already with the Set above,
// but not digitGroupSeparatorCurrency because it can equal digitGroupSeparator.
assert.notEqual(parser.decimalSeparator, parser.digitGroupSeparator);
assert.notEqual(parser.decimalSeparator, parser.digitGroupSeparatorCurrency);
for (const key of parser.digitsMap.keys()) {
assert.lengthOf(key, 1);
assert.lengthOf(parser.digitsMap.get(key)!, 1);
}
});
it("can parse formatted numbers", function() {
for (const numMode of numModes) {
const formatter = Intl.NumberFormat(locale.code, {
...parseNumMode(numMode, currency),
maximumFractionDigits: 15,
maximumSignificantDigits: 15,
});
for (const num of numbers) {
const fnum = formatter.format(num);
const formattedNumbers = [fnum];
if (num > 0 && fnum[0] === "0") {
// E.g. test that '.5' is parsed as '0.5'
formattedNumbers.push(fnum.substring(1));
}
if (num < 0) {
formattedNumbers.push(`(${formatter.format(-num)})`);
}
for (const formatted of formattedNumbers) {
const parsed = parser.parse(formatted)?.result;
// Fast check, particularly to avoid formatting the numbers
// Makes the tests about 1.5s/30% faster.
if (parsed === num) {
continue;
}
try {
assert.exists(parsed);
assert.equal(
basicFormatter.format(parsed!),
basicFormatter.format(num),
);
} catch (e) {
// Handy information for understanding failures
// tslint:disable-next-line:no-console
console.log({
num, formatted, parsed, numMode, parser,
parts: formatter.formatToParts(num),
formattedChars: [...formatted].map(char => ({
char,
// To see invisible characters, e.g. RTL/LTR marks
codePoint: char.codePointAt(0),
codePointHex: char.codePointAt(0)!.toString(16),
})),
formatterOptions: formatter.resolvedOptions(),
});
throw e;
}
}
}
}
});
});
}
});

@ -0,0 +1,33 @@
import {LocalPlugin} from 'app/common/plugin';
import * as clientUtil from 'test/client/clientUtil';
import * as sinon from 'sinon';
import {assert} from 'chai';
import * as browserGlobals from 'app/client/lib/browserGlobals';
const G: any = browserGlobals.get('$');
import {PluginInstance} from 'app/common/PluginInstance';
describe("PluginInstance", function() {
clientUtil.setTmpMochaGlobals();
it("can manages render target", function() {
const plugin = new PluginInstance({manifest: {contributions: {}}} as LocalPlugin, {});
assert.throws(() => plugin.getRenderTarget(2), /Unknown render target.*/);
assert.doesNotThrow(() => plugin.getRenderTarget("fullscreen"));
const renderTarget1 = sinon.spy();
const renderTarget2 = sinon.spy();
const el1 = G.$('<h1>el1</h1>');
const el2 = G.$('<h1>el2</h1>');
const handle1 = plugin.addRenderTarget(renderTarget1);
plugin.getRenderTarget(handle1)(el1, {});
sinon.assert.calledWith(renderTarget1, el1, {});
plugin.removeRenderTarget(handle1);
assert.throw(() => plugin.getRenderTarget(handle1));
const handle2 = plugin.addRenderTarget(renderTarget2);
plugin.getRenderTarget(handle2)(el2 as HTMLElement, {});
sinon.assert.calledWith(renderTarget2, el2, {});
});
});

@ -0,0 +1,94 @@
/* global describe, it */
var assert = require('chai').assert;
var RecentItems = require('app/common/RecentItems');
describe('RecentItems', function() {
let simpleList = ['foo', 'bar', 'baz'];
let objList = [
{ name: 'foo', path: '/foo' },
{ name: 'bar', path: '/bar' },
{ name: 'baz', path: '/baz' },
];
describe("listItems", function() {
it("should return a valid list", function() {
let recentItems = new RecentItems({
intialItems: simpleList
});
assert.deepEqual(recentItems.listItems(), ['foo', 'bar', 'baz']);
});
it("should return a valid list given a keyFunc", function() {
let recentItems = new RecentItems({
intialItems: objList,
keyFunc: item => item.path
});
assert.deepEqual(recentItems.listItems(), [
{ name: 'foo', path: '/foo' },
{ name: 'bar', path: '/bar' },
{ name: 'baz', path: '/baz' },
]);
});
it("should produce a list of objects with unique keys", function() {
let recentItems = new RecentItems({
intialItems: [
{ name: 'foo', path: '/foo' },
{ name: 'bar', path: '/bar' },
{ name: 'foo', path: '/foo' },
{ name: 'baz', path: '/baz' },
{ name: 'foobar', path: '/foo' },
],
keyFunc: item => item.path
});
assert.deepEqual(recentItems.listItems(), [
{ name: 'bar', path: '/bar' },
{ name: 'baz', path: '/baz' },
{ name: 'foobar', path: '/foo' }
]);
let recentItems2 = new RecentItems({
intialItems: simpleList,
});
assert.deepEqual(recentItems2.listItems(), ['foo', 'bar', 'baz']);
for(let i = 0; i < 30; i++) {
recentItems2.addItems(simpleList);
}
assert.deepEqual(recentItems2.listItems(), ['foo', 'bar', 'baz']);
});
it("should produce a list with the correct max length", function() {
let recentItems = new RecentItems({
intialItems: objList,
maxCount: 2,
keyFunc: item => item.path
});
assert.deepEqual(recentItems.listItems(), [
{ name: 'bar', path: '/bar' },
{ name: 'baz', path: '/baz' }
]);
recentItems.addItem({ name: 'foo', path: '/foo' });
assert.deepEqual(recentItems.listItems(), [
{ name: 'baz', path: '/baz' },
{ name: 'foo', path: '/foo' }
]);
recentItems.addItem({name: 'BAZ', path: '/baz'});
assert.deepEqual(recentItems.listItems(), [
{ name: 'foo', path: '/foo' },
{ name: 'BAZ', path: '/baz' }
]);
let recentItems2 = new RecentItems({
intialItems: simpleList,
maxCount: 10
});
let alphabet = "abcdefghijklmnopqrstuvwxyz".split("");
recentItems2.addItems(alphabet);
assert.deepEqual(recentItems2.listItems(), 'qrstuvwxyz'.split(""));
recentItems2.addItem('a');
assert.deepEqual(recentItems2.listItems(), 'rstuvwxyza'.split(""));
recentItems2.addItem('r');
assert.deepEqual(recentItems2.listItems(), 'stuvwxyzar'.split(""));
});
});
});

@ -0,0 +1,170 @@
import {delay} from 'app/common/delay';
import {RefCountMap} from 'app/common/RefCountMap';
import {assert} from 'chai';
import * as sinon from 'sinon';
function assertResetSingleCall(spy: sinon.SinonSpy, context: any, ...args: any[]): void {
sinon.assert.calledOnce(spy);
sinon.assert.calledOn(spy, context);
sinon.assert.calledWithExactly(spy, ...args);
spy.resetHistory();
}
describe("RefCountMap", function() {
it("should dispose items when ref-count returns to 0", function() {
const create = sinon.stub().callsFake((key) => key.toUpperCase());
const dispose = sinon.spy();
const m = new RefCountMap<string, string>({create, dispose, gracePeriodMs: 0});
const subFoo1 = m.use("foo");
assert.strictEqual(subFoo1.get(), "FOO");
assertResetSingleCall(create, null, "foo");
const subBar1 = m.use("bar");
assert.strictEqual(subBar1.get(), "BAR");
assertResetSingleCall(create, null, "bar");
const subFoo2 = m.use("foo");
assert.strictEqual(subFoo2.get(), "FOO");
sinon.assert.notCalled(create);
// Now dispose one by one.
subFoo1.dispose();
sinon.assert.notCalled(dispose);
subBar1.dispose();
assertResetSingleCall(dispose, null, "bar", "BAR");
// An extra subscription increases refCount, so subFoo2.dispose will not yet dispose it.
const subFoo3 = m.use("foo");
assert.strictEqual(subFoo3.get(), "FOO");
sinon.assert.notCalled(create);
subFoo2.dispose();
sinon.assert.notCalled(dispose);
subFoo3.dispose();
assertResetSingleCall(dispose, null, "foo", "FOO");
});
it("should respect the grace period", async function() {
const create = sinon.stub().callsFake((key) => key.toUpperCase());
const dispose = sinon.spy();
const m = new RefCountMap<string, string>({create, dispose, gracePeriodMs: 60});
const subFoo1 = m.use("foo");
assert.strictEqual(subFoo1.get(), "FOO");
assertResetSingleCall(create, null, "foo");
const subBar1 = m.use("bar");
assert.strictEqual(subBar1.get(), "BAR");
assertResetSingleCall(create, null, "bar");
// Disposal is not immediate, we have some time.
subFoo1.dispose();
subBar1.dispose();
sinon.assert.notCalled(dispose);
// Wait a bit and add more usage to one of the keys.
await delay(30);
const subFoo2 = m.use("foo");
assert.strictEqual(subFoo2.get(), "FOO");
sinon.assert.notCalled(create);
// Grace period hasn't expired yet, so dispose isn't called yet.
sinon.assert.notCalled(dispose);
// Now wait for the grace period to end.
await delay(40);
// Ensure that bar's disposal has run now, but not foo's.
assertResetSingleCall(dispose, null, "bar", "BAR");
// Dispose the second usage, and wait for the full grace period.
subFoo2.dispose();
await delay(70);
assertResetSingleCall(dispose, null, "foo", "FOO");
});
it("should dispose immediately on clear", async function() {
const create = sinon.stub().callsFake((key) => key.toUpperCase());
const dispose = sinon.spy();
const m = new RefCountMap<string, string>({create, dispose, gracePeriodMs: 0});
const subFoo1 = m.use("foo");
const subBar1 = m.use("bar");
const subFoo2 = m.use("foo");
m.dispose();
assert.equal(dispose.callCount, 2);
assert.deepEqual(dispose.args, [["foo", "FOO"], ["bar", "BAR"]]);
dispose.resetHistory();
// Should be a no-op to dispose subscriptions after RefCountMap is disposed.
subFoo1.dispose();
subFoo2.dispose();
subBar1.dispose();
sinon.assert.notCalled(dispose);
// It should not be a matter of gracePeriod, but make sure by waiting a bit.
await delay(30);
sinon.assert.notCalled(dispose);
});
it("should be safe to purge a key", async function() {
const create = sinon.stub().callsFake((key) => key.toUpperCase());
const dispose = sinon.spy();
const m = new RefCountMap<string, string>({create, dispose, gracePeriodMs: 0});
const subFoo1 = m.use("foo");
const subBar1 = m.use("bar");
const subFoo2 = m.use("foo");
m.purgeKey("foo");
assertResetSingleCall(dispose, null, "foo", "FOO");
m.purgeKey("bar");
assertResetSingleCall(dispose, null, "bar", "BAR");
// The tricky case is when a new "foo" key is created after the purge.
const subFooNew1 = m.use("foo");
const subBarNew1 = m.use("bar");
// Should be a no-op to dispose purged subscriptions.
subFoo1.dispose();
subFoo2.dispose();
sinon.assert.notCalled(dispose);
// A new subscription with the same key should get disposed though.
subFooNew1.dispose();
assertResetSingleCall(dispose, null, "foo", "FOO");
subBarNew1.dispose();
assertResetSingleCall(dispose, null, "bar", "BAR");
// Still a no-op to dispose old purged subscriptions.
subBar1.dispose();
sinon.assert.notCalled(dispose);
// Ensure there are no scheduled disposals due to some other bug.
await delay(30);
sinon.assert.notCalled(dispose);
});
it("should not dispose a re-created key on timeout after purge", async function() {
const create = sinon.stub().callsFake((key) => key.toUpperCase());
const dispose = sinon.spy();
const m = new RefCountMap<string, string>({create, dispose, gracePeriodMs: 60});
const subFoo1 = m.use("foo");
subFoo1.dispose(); // This schedules a disposal in 20ms
m.purgeKey("foo"); // This should purge immediately AND unset the scheduled disposal
assertResetSingleCall(dispose, null, "foo", "FOO");
await delay(20);
const subFoo2 = m.use("foo"); // Should not be affected by the scheduled disposal.
await delay(100); // "foo" stays beyond grace period, since it's being used.
sinon.assert.notCalled(dispose);
subFoo2.dispose(); // Once disposed, it stays for grace period
await delay(20);
sinon.assert.notCalled(dispose);
await delay(100); // And gets disposed after it.
assertResetSingleCall(dispose, null, "foo", "FOO");
});
});

@ -0,0 +1,29 @@
import {typedCompare} from 'app/common/SortFunc';
import {assert} from 'chai';
import {format} from 'util';
describe('SortFunc', function() {
it('should be transitive for values of different types', function() {
const values = [
-10, 0, 2, 10.5,
null,
["a"], ["b"], ["b", 1], ["b", 1, 2], ["b", 1, "10"], ["c"],
"10.5", "2", "a",
undefined as any,
];
// Check that sorting works as expected (the values above are already sorted).
const sorted = values.slice(0);
sorted.sort(typedCompare);
assert.deepEqual(sorted, values);
// Check comparisons between each possible pair of values above.
for (let i = 0; i < values.length; i++) {
assert.equal(typedCompare(values[i], values[i]), 0, `Expected ${format(values[i])} == ${format(values[i])}`);
for (let j = i + 1; j < values.length; j++) {
assert.equal(typedCompare(values[i], values[j]), -1, `Expected ${format(values[i])} < ${format(values[j])}`);
assert.equal(typedCompare(values[j], values[i]), 1, `Expected ${format(values[j])} > ${format(values[i])}`);
}
}
});
});

@ -0,0 +1,59 @@
import {StringUnion} from 'app/common/StringUnion';
import {assert} from 'chai';
describe('StringUnion', function() {
// Create Dog type
const Dog = StringUnion(
"bulldog",
"poodle",
"greyhound"
);
type Dog = typeof Dog.type;
// Create Cat type
const Cat = StringUnion(
"siamese",
"sphynx",
"bengal"
);
type Cat = typeof Cat.type;
it('should provide check and guard functions', function() {
let dog: Dog;
let cat: Cat;
const greyhound = "greyhound";
const bengal = "bengal";
const giraffe = "giraffe";
// Use Dog check function.
dog = Dog.check(greyhound);
assert.equal(dog, greyhound);
assert.doesNotThrow(() => { dog = Dog.check(greyhound); });
assert.throws(() => { dog = Dog.check(bengal); },
`Value '"bengal"' is not assignable to type '"bulldog" | "poodle" | "greyhound"'`);
assert.throws(() => { dog = Dog.check(giraffe); },
`Value '"giraffe"' is not assignable to type '"bulldog" | "poodle" | "greyhound"'`);
// Use Cat check function.
cat = Cat.check(bengal);
assert.equal(cat, bengal);
assert.doesNotThrow(() => { cat = Cat.check(bengal); });
assert.throws(() => { cat = Cat.check(greyhound); },
`Value '"greyhound"' is not assignable to type '"siamese" | "sphynx" | "bengal"'`);
assert.throws(() => { cat = Cat.check(giraffe); },
`Value '"giraffe"' is not assignable to type '"siamese" | "sphynx" | "bengal"'`);
// Use Dog guard function.
assert.isTrue(Dog.guard(greyhound));
assert.isFalse(Dog.guard(bengal));
assert.isFalse(Dog.guard(giraffe));
// Use Cat guard function.
assert.isTrue(Cat.guard(bengal));
assert.isFalse(Cat.guard(greyhound));
assert.isFalse(Cat.guard(giraffe));
});
});

@ -0,0 +1,259 @@
import {CellValue, TableDataAction} from 'app/common/DocActions';
import {TableData} from 'app/common/TableData';
import {assert} from 'chai';
import {unzip, zipObject} from 'lodash';
describe('TableData', function() {
const sampleData: TableDataAction = ["TableData", "Foo", [1, 4, 5, 7], {
city: ['New York', 'Boston', 'Boston', 'Seattle'],
state: ['NY', 'MA', 'MA', 'WA'],
amount: [5, 4, "NA", 2],
bool: [true, true, false, false],
}];
// Transpose the given matrix. If empty, it's considered to consist of 0 rows and
// colArray.length columns, so that the transpose has colArray.length empty rows.
function transpose<T>(matrix: T[][], colArray: any[]): T[][] {
return matrix.length > 0 ? unzip(matrix) : colArray.map(c => []);
}
function verifyTableData(t: TableData, colIds: string[], data: CellValue[][]): void {
const idIndex = colIds.indexOf('id');
assert(idIndex !== -1, "verifyTableData expects 'id' column");
const rowIds: number[] = data.map(row => row[idIndex]) as number[];
assert.strictEqual(t.numRecords(), data.length);
assert.sameMembers(t.getColIds(), colIds);
assert.deepEqual(t.getSortedRowIds(), rowIds);
assert.sameMembers(Array.from(t.getRowIds()), rowIds);
const transposed = transpose(data, colIds);
// Verify data using .getValue()
assert.deepEqual(rowIds.map(r => colIds.map(c => t.getValue(r, c))), data);
// Verify data using getRowPropFunc()
assert.deepEqual(colIds.map(c => rowIds.map(t.getRowPropFunc(c)!)), transposed);
// Verify data using getRecord()
const expRecords = data.map((row, i) => zipObject(colIds, row));
assert.deepEqual(rowIds.map(r => t.getRecord(r)) as any, expRecords);
// Verify data using getRecords().
assert.sameDeepMembers(t.getRecords(), expRecords);
// Verify data using getColValues().
const rawOrderedData = t.getRowIds().map(r => data[rowIds.indexOf(r)]);
const rawOrderedTransposed = transpose(rawOrderedData, colIds);
assert.deepEqual(colIds.map(c => t.getColValues(c)), rawOrderedTransposed);
}
it('should start out empty and support loadData', function() {
const t = new TableData('Foo', null, {city: 'Text', state: 'Text', amount: 'Numeric', bool: 'Bool'});
assert.equal(t.tableId, 'Foo');
assert.isFalse(t.isLoaded);
verifyTableData(t, ["id", "city", "state", "amount", "bool"], []);
t.loadData(sampleData);
assert.isTrue(t.isLoaded);
verifyTableData(t, ["id", "city", "state", "amount", "bool"], [
[1, 'New York', 'NY', 5, true],
[4, 'Boston', 'MA', 4, true],
[5, 'Boston', 'MA', "NA", false],
[7, 'Seattle', 'WA', 2, false],
]);
});
it('should start out with data from constructor', function() {
const t = new TableData('Foo', sampleData, {city: 'Text', state: 'Text', amount: 'Numeric', bool: 'Bool'});
assert.equal(t.tableId, 'Foo');
assert.isTrue(t.isLoaded);
verifyTableData(t, ["id", "city", "state", "amount", "bool"], [
[1, 'New York', 'NY', 5, true],
[4, 'Boston', 'MA', 4, true],
[5, 'Boston', 'MA', "NA", false],
[7, 'Seattle', 'WA', 2, false],
]);
});
it('should support filterRecords and filterRowIds', function() {
const t = new TableData('Foo', sampleData, {city: 'Text', state: 'Text', amount: 'Numeric', bool: 'Bool'});
assert.deepEqual(t.filterRecords({state: 'MA'}), [
{id: 4, city: 'Boston', state: 'MA', amount: 4, bool: true},
{id: 5, city: 'Boston', state: 'MA', amount: 'NA', bool: false}]);
assert.deepEqual(t.filterRowIds({state: 'MA'}), [4, 5]);
// After removing and re-adding a record, indices change, but filter behavior should not.
// Notice sameDeepMembers() below, rather than deepEqual(), since order is not guaranteed.
t.dispatchAction(["RemoveRecord", "Foo", 4]);
t.dispatchAction(["AddRecord", "Foo", 4, {city: 'BOSTON', state: 'MA'}]);
verifyTableData(t, ["id", "city", "state", "amount", "bool"], [
[1, 'New York', 'NY', 5, true],
[4, 'BOSTON', 'MA', 0, false],
[5, 'Boston', 'MA', "NA", false],
[7, 'Seattle', 'WA', 2, false],
]);
assert.deepEqual(t.filterRecords({city: 'BOSTON', amount: 0.0}), [
{id: 4, city: 'BOSTON', state: 'MA', amount: 0, bool: false}]);
assert.deepEqual(t.filterRowIds({city: 'BOSTON', amount: 0.0}), [4]);
assert.sameDeepMembers(t.filterRecords({state: 'MA'}), [
{id: 4, city: 'BOSTON', state: 'MA', amount: 0, bool: false},
{id: 5, city: 'Boston', state: 'MA', amount: 'NA', bool: false}]);
assert.sameDeepMembers(t.filterRowIds({state: 'MA'}), [4, 5]);
assert.deepEqual(t.filterRecords({city: 'BOSTON', state: 'NY'}), []);
assert.deepEqual(t.filterRowIds({city: 'BOSTON', state: 'NY'}), []);
assert.sameDeepMembers(t.filterRecords({}), [
{id: 1, city: 'New York', state: 'NY', amount: 5, bool: true},
{id: 4, city: 'BOSTON', state: 'MA', amount: 0, bool: false},
{id: 5, city: 'Boston', state: 'MA', amount: 'NA', bool: false},
{id: 7, city: 'Seattle', state: 'WA', amount: 2, bool: false},
]);
assert.sameDeepMembers(t.filterRowIds({}), [1, 4, 5, 7]);
});
it('should support findMatchingRow', function() {
const t = new TableData('Foo', sampleData, {city: 'Text', state: 'Text', amount: 'Numeric', bool: 'Bool'});
assert.equal(t.findMatchingRowId({state: 'MA'}), 4);
assert.equal(t.findMatchingRowId({state: 'MA', bool: false}), 5);
assert.equal(t.findMatchingRowId({city: 'Boston', state: 'MA', bool: true}), 4);
assert.equal(t.findMatchingRowId({city: 'BOSTON', state: 'NY'}), 0);
assert.equal(t.findMatchingRowId({statex: 'MA'}), 0);
assert.equal(t.findMatchingRowId({id: 7}), 7);
assert.equal(t.findMatchingRowId({}), 1);
});
it('should allow getRowPropFunc to be used before loadData', function() {
// This tests a potential bug when getRowPropFunc is saved from before loadData() is called.
const t = new TableData('Foo', null, {city: 'Text', state: 'Text', amount: 'Numeric', bool: 'Bool'});
verifyTableData(t, ["id", "city", "state", "amount", "bool"], []);
assert.isFalse(t.isLoaded);
const getters = ["id", "city", "state", "amount", "bool"].map(c => t.getRowPropFunc(c)!);
t.loadData(sampleData);
assert.isTrue(t.isLoaded);
assert.deepEqual(t.getSortedRowIds().map(r => getters.map(getter => getter(r))), [
[1, 'New York', 'NY', 5, true],
[4, 'Boston', 'MA', 4, true],
[5, 'Boston', 'MA', "NA", false],
[7, 'Seattle', 'WA', 2, false],
]);
});
it('should handle Add/RemoveRecord', function() {
const t = new TableData('Foo', sampleData, {city: 'Text', state: 'Text', amount: 'Numeric', bool: 'Bool'});
t.dispatchAction(["RemoveRecord", "Foo", 4]);
verifyTableData(t, ["id", "city", "state", "amount", "bool"], [
[1, 'New York', 'NY', 5, true],
[5, 'Boston', 'MA', "NA", false],
[7, 'Seattle', 'WA', 2, false],
]);
t.dispatchAction(["RemoveRecord", "Foo", 7]);
verifyTableData(t, ["id", "city", "state", "amount", "bool"], [
[1, 'New York', 'NY', 5, true],
[5, 'Boston', 'MA', "NA", false],
]);
t.dispatchAction(["AddRecord", "Foo", 4, {city: 'BOSTON', state: 'MA', amount: 4, bool: true}]);
verifyTableData(t, ["id", "city", "state", "amount", "bool"], [
[1, 'New York', 'NY', 5, true],
[4, 'BOSTON', 'MA', 4, true],
[5, 'Boston', 'MA', "NA", false],
]);
t.dispatchAction(["BulkAddRecord", "Foo", [8, 9], {
city: ['X', 'Y'], state: ['XX', 'YY'], amount: [0.1, 0.2], bool: [null, true]
}]);
verifyTableData(t, ["id", "city", "state", "amount", "bool"], [
[1, 'New York', 'NY', 5, true],
[4, 'BOSTON', 'MA', 4, true],
[5, 'Boston', 'MA', "NA", false],
[8, 'X', 'XX', 0.1, null],
[9, 'Y', 'YY', 0.2, true],
]);
t.dispatchAction(["BulkRemoveRecord", "Foo", [1, 4, 9]]);
verifyTableData(t, ["id", "city", "state", "amount", "bool"], [
[5, 'Boston', 'MA', "NA", false],
[8, 'X', 'XX', 0.1, null],
]);
});
it('should handle UpdateRecord', function() {
const t = new TableData('Foo', sampleData, {city: 'Text', state: 'Text', amount: 'Numeric', bool: 'Bool'});
t.dispatchAction(["UpdateRecord", "Foo", 4, {city: 'BOSTON', amount: 0.1}]);
verifyTableData(t, ["id", "city", "state", "amount", "bool"], [
[1, 'New York', 'NY', 5, true],
[4, 'BOSTON', 'MA', 0.1, true],
[5, 'Boston', 'MA', "NA", false],
[7, 'Seattle', 'WA', 2, false],
]);
t.dispatchAction(["BulkUpdateRecord", "Foo", [1, 7], {
city: ['X', 'Y'], state: ['XX', 'YY'], amount: [0.1, 0.2], bool: [null, true]
}]);
verifyTableData(t, ["id", "city", "state", "amount", "bool"], [
[1, 'X', 'XX', 0.1, null],
[4, 'BOSTON', 'MA', 0.1, true],
[5, 'Boston', 'MA', "NA", false],
[7, 'Y', 'YY', 0.2, true],
]);
});
it('should work correctly after AddColumn', function() {
const t = new TableData('Foo', sampleData, {city: 'Text', state: 'Text', amount: 'Numeric', bool: 'Bool'});
t.dispatchAction(["AddColumn", "Foo", "foo", {type: "Text", isFormula: false, formula: ""}]);
verifyTableData(t, ["id", "city", "state", "amount", "bool", "foo"], [
[1, 'New York', 'NY', 5, true, ""],
[4, 'Boston', 'MA', 4, true, ""],
[5, 'Boston', 'MA', "NA", false, ""],
[7, 'Seattle', 'WA', 2, false, ""],
]);
t.dispatchAction(["UpdateRecord", "Foo", 4, {city: 'BOSTON', foo: "hello"}]);
verifyTableData(t, ["id", "city", "state", "amount", "bool", "foo"], [
[1, 'New York', 'NY', 5, true, ""],
[4, 'BOSTON', 'MA', 4, true, "hello"],
[5, 'Boston', 'MA', "NA", false, ""],
[7, 'Seattle', 'WA', 2, false, ""],
]);
t.dispatchAction(["AddRecord", "Foo", 8, { city: 'X', state: 'XX' }]);
verifyTableData(t, ["id", "city", "state", "amount", "bool", "foo"], [
[1, 'New York', 'NY', 5, true, ""],
[4, 'BOSTON', 'MA', 4, true, "hello"],
[5, 'Boston', 'MA', "NA", false, ""],
[7, 'Seattle', 'WA', 2, false, ""],
[8, 'X', 'XX', 0, false, ""],
]);
});
it('should work correctly after RenameColumn', function() {
const t = new TableData('Foo', sampleData, {city: 'Text', state: 'Text', amount: 'Numeric', bool: 'Bool'});
t.dispatchAction(["RenameColumn", "Foo", "city", "ciudad"]);
verifyTableData(t, ["id", "ciudad", "state", "amount", "bool"], [
[1, 'New York', 'NY', 5, true ],
[4, 'Boston', 'MA', 4, true ],
[5, 'Boston', 'MA', "NA", false ],
[7, 'Seattle', 'WA', 2, false ],
]);
t.dispatchAction(["UpdateRecord", "Foo", 4, {ciudad: 'BOSTON', state: "XX"}]);
verifyTableData(t, ["id", "ciudad", "state", "amount", "bool"], [
[1, 'New York', 'NY', 5, true ],
[4, 'BOSTON', 'XX', 4, true ],
[5, 'Boston', 'MA', "NA", false ],
[7, 'Seattle', 'WA', 2, false ],
]);
t.dispatchAction(["AddRecord", "Foo", 8, { ciudad: 'X', state: 'XX' }]);
verifyTableData(t, ["id", "ciudad", "state", "amount", "bool"], [
[1, 'New York', 'NY', 5, true ],
[4, 'BOSTON', 'XX', 4, true ],
[5, 'Boston', 'MA', "NA", false ],
[7, 'Seattle', 'WA', 2, false ],
[8, 'X', 'XX', 0, false ],
]);
});
});

@ -0,0 +1,185 @@
import {DocumentSettings} from 'app/common/DocumentSettings';
import {NumberFormatOptions} from 'app/common/NumberFormat';
import {parseDateTime} from 'app/common/parseDate';
import {createFormatter, DateTimeFormatOptions} from "app/common/ValueFormatter";
import {assert} from 'chai';
const defaultDocSettings = {
locale: 'en-US'
};
const dateNumber = parseDateTime("2020-10-31 12:34:56", {});
describe("ValueFormatter", function() {
describe("DateFormatter", function() {
function check(expected: string, dateFormat?: string) {
for (const value of [dateNumber, ["d", dateNumber], ["D", dateNumber, "UTC"]]) {
const actual = createFormatter("Date", {dateFormat}, defaultDocSettings).formatAny(value);
assert.equal(actual, expected, String(value));
}
}
it("should format dates", function() {
check("31/10/2020", "DD/MM/YYYY");
check("10/31/2020", "MM/DD/YYYY");
check("2020-10-31"); // ISO by default
});
});
describe("DateTimeFormatter", function() {
function check(expected: string, options: DateTimeFormatOptions, timezone: string = "UTC") {
for (const value of [dateNumber, ["d", dateNumber], ["D", dateNumber, timezone]]) {
const actual = createFormatter(`DateTime:${timezone}`, options, defaultDocSettings).formatAny(value);
assert.equal(actual, expected, String(value));
}
}
it("should format datetimes", function() {
check("31/10/2020 12:34:56", {dateFormat: "DD/MM/YYYY", timeFormat: "HH:mm:ss"});
check("10/31/2020 12:34", {dateFormat: "MM/DD/YYYY", timeFormat: "HH:mm"});
check("2020-10-31 12:34pm", {}); // default formats
check("31/10/2020 08:34:56", {dateFormat: "DD/MM/YYYY", timeFormat: "HH:mm:ss"}, 'America/New_York');
check("10/31/2020 08:34", {dateFormat: "MM/DD/YYYY", timeFormat: "HH:mm"}, 'America/New_York');
check("2020-10-31 8:34am", {}, 'America/New_York'); // default formats
});
});
describe("NumericFormatter", function() {
function fmt(options: NumberFormatOptions, value: number, docSettings: DocumentSettings) {
return createFormatter("Numeric", options, docSettings).formatAny(value);
}
function checkDefault(options: NumberFormatOptions, value: number, expected: string) {
assert.equal(fmt(options, value, defaultDocSettings), expected);
}
it("should support plain format", function() {
checkDefault({}, 0, '0');
checkDefault({}, NaN, 'NaN');
checkDefault({}, Infinity, '∞');
checkDefault({}, -Infinity, '-∞');
checkDefault({}, 0.67, '0.67');
checkDefault({}, -1234.56, '-1234.56');
checkDefault({}, -121e+25, '-1210000000000000000000000000');
checkDefault({}, 1.015e-8, '0.0000000102'); // maxDecimals defaults to 10 here.
});
it('should support min/max decimals', function() {
checkDefault({decimals: 2, maxDecimals: 4}, 12, '12.00');
checkDefault({decimals: 2, maxDecimals: 4}, -1.00015, '-1.0002');
checkDefault({decimals: 2, maxDecimals: 6}, -1.00015, '-1.00015');
checkDefault({decimals: 6, maxDecimals: 6}, -1.00015, '-1.000150');
checkDefault({decimals: 6, maxDecimals: 0}, -1.00015, '-1.000150');
checkDefault({decimals: 0, maxDecimals: 2}, 12.0001, '12');
checkDefault({decimals: 0, maxDecimals: 2}, 12.001, '12');
checkDefault({decimals: 0, maxDecimals: 2}, 12.005, '12.01');
checkDefault({maxDecimals: 8}, 1.015e-8, '0.00000001');
checkDefault({maxDecimals: 7}, 1.015e-8, '0');
// Out-of-range values get clamped.
checkDefault({decimals:-2, maxDecimals:3}, -1.2345, "-1.235");
checkDefault({decimals:-2, maxDecimals:-3}, -1.2345, "-1");
});
it('should support thousand separators', function() {
checkDefault({numMode: 'decimal', decimals: 4}, 1000000, '1,000,000.0000');
checkDefault({numMode: 'decimal'}, -1234.56, '-1,234.56');
checkDefault({numMode: 'decimal'}, -121e+25, '-1,210,000,000,000,000,000,000,000,000');
checkDefault({numMode: 'decimal'}, 0.1234567, '0.123'); // maxDecimals defaults to 3 here
checkDefault({numMode: 'decimal'}, 1.015e-8, '0');
checkDefault({numMode: 'decimal', maxDecimals: 10}, 1.015e-8, '0.0000000102');
});
it('should support currency mode', function() {
// Test currency formatting with default doc settings (locale: 'en-US').
checkDefault({numMode: 'currency'}, 1000000, '$1,000,000.00');
checkDefault({numMode: 'currency', decimals: 4}, 1000000, '$1,000,000.0000');
checkDefault({numMode: 'currency'}, -1234.565, '-$1,234.57');
checkDefault({numMode: 'currency'}, -121e+25, '-$1,210,000,000,000,000,000,000,000,000.00');
checkDefault({numMode: 'currency'}, 0.1234567, '$0.12'); // maxDecimals defaults to 2 here
checkDefault({numMode: 'currency', maxDecimals: 0}, 12.34567, '$12.35');
checkDefault({numMode: 'currency', decimals: 0, maxDecimals: 0}, 12.34567, '$12');
checkDefault({numMode: 'currency'}, 1.015e-8, '$0.00');
checkDefault({numMode: 'currency', maxDecimals: 10}, 1.015e-8, '$0.0000000102');
checkDefault({numMode: 'currency'}, -1.015e-8, '-$0.00');
// Test currency formatting with custom locales.
assert.equal(fmt({numMode: 'currency'}, 1000000, {locale: 'es-ES'}), '1.000.000,00 €');
assert.equal(fmt({numMode: 'currency', decimals: 4}, 1000000, {locale: 'en-NZ'}), '$1,000,000.0000');
assert.equal(fmt({numMode: 'currency'}, -1234.565, {locale: 'de-CH'}), 'CHF-1234.57');
assert.equal(fmt({numMode: 'currency'}, -121e+25, {locale: 'es-AR'}),
'-$ 1.210.000.000.000.000.000.000.000.000,00');
assert.equal(fmt({numMode: 'currency'}, 0.1234567, {locale: 'fr-BE'}), '0,12 €');
assert.equal(fmt({numMode: 'currency', maxDecimals: 0}, 12.34567, {locale: 'en-GB'}), '£12.35');
assert.equal(fmt({numMode: 'currency', decimals: 0, maxDecimals: 0}, 12.34567, {locale: 'en-IE'}), '€12');
assert.equal(fmt({numMode: 'currency'}, 1.015e-8, {locale: 'en-ZA'}), 'R 0,00');
assert.equal(fmt({numMode: 'currency', maxDecimals: 10}, 1.015e-8, {locale: 'en-CA'}), '$0.0000000102');
assert.equal(fmt({numMode: 'currency'}, -1.015e-8, {locale: 'nl-BE'}), '€ -0,00');
// Test currency formatting with custom currency AND locales (e.g. column-specific currency setting).
assert.equal(fmt({numMode: 'currency'}, 1000000, {locale: 'es-ES', currency: 'USD'}), '1.000.000,00 $');
assert.equal(
fmt({numMode: 'currency', decimals: 4}, 1000000, {locale: 'en-NZ', currency: 'JPY'}),
'¥1,000,000.0000');
assert.equal(fmt({numMode: 'currency'}, -1234.565, {locale: 'de-CH', currency: 'JMD'}), '$-1234.57');
assert.equal(
fmt({numMode: 'currency'}, -121e+25, {locale: 'es-AR', currency: 'GBP'}),
'-£ 1.210.000.000.000.000.000.000.000.000,00');
assert.equal(fmt({numMode: 'currency'}, 0.1234567, {locale: 'fr-BE', currency: 'GBP'}), '0,12 £');
assert.equal(fmt({numMode: 'currency', maxDecimals: 0}, 12.34567, {locale: 'en-GB', currency: 'USD'}), '$12.35');
assert.equal(
fmt({numMode: 'currency', decimals: 0, maxDecimals: 0}, 12.34567, {locale: 'en-IE', currency: 'SGD'}),
'$12');
assert.equal(fmt({numMode: 'currency'}, 1.015e-8, {locale: 'en-ZA', currency: 'HKD'}), '$0,00');
assert.equal(
fmt({numMode: 'currency', maxDecimals: 10}, 1.015e-8, {locale: 'en-CA', currency: 'RUB'}),
'₽0.0000000102');
assert.equal(fmt({numMode: 'currency'}, -1.015e-8, {locale: 'nl-BE', currency: 'USD'}), '$ -0,00');
});
it('should support percentages', function() {
checkDefault({numMode: 'percent'}, 0.5, '50%');
checkDefault({numMode: 'percent'}, -0.15, '-15%');
checkDefault({numMode: 'percent'}, 0.105, '11%');
checkDefault({numMode: 'percent', maxDecimals: 5}, 0.105, '10.5%');
checkDefault({numMode: 'percent', decimals: 5}, 0.105, '10.50000%');
checkDefault({numMode: 'percent', maxDecimals: 2}, 1.2345, '123.45%');
checkDefault({numMode: 'percent'}, -1234.567, '-123,457%'); // maxDecimals defaults to 0 here
checkDefault({numMode: 'percent'}, 1.015e-8, '0%');
checkDefault({numMode: 'percent', maxDecimals: 10}, 1.015e-8, '0.000001015%');
});
it('should support parentheses for negative numbers', function() {
checkDefault({numSign: 'parens', numMode: 'decimal'}, -1234.56, '(1,234.56)');
checkDefault({numSign: 'parens', numMode: 'decimal'}, +1234.56, ' 1,234.56 ');
checkDefault({numSign: 'parens', numMode: 'decimal'}, -121e+25, '(1,210,000,000,000,000,000,000,000,000)');
checkDefault({numSign: 'parens', numMode: 'decimal'}, 0.1234567, ' 0.123 ');
checkDefault({numSign: 'parens', numMode: 'decimal'}, 1.015e-8, ' 0 ');
checkDefault({numSign: 'parens', numMode: 'currency'}, -1234.565, '($1,234.57)');
checkDefault({numSign: 'parens', numMode: 'currency'}, -121e+20, '($12,100,000,000,000,000,000,000.00)');
checkDefault({numSign: 'parens', numMode: 'currency'}, 121e+20, ' $12,100,000,000,000,000,000,000.00 ');
checkDefault({numSign: 'parens', numMode: 'currency'}, 1.015e-8, ' $0.00 ');
checkDefault({numSign: 'parens', numMode: 'currency'}, -1.015e-8, '($0.00)');
checkDefault({numSign: 'parens'}, -1234.56, '(1234.56)');
checkDefault({numSign: 'parens'}, +1234.56, ' 1234.56 ');
checkDefault({numSign: 'parens', numMode: 'percent'}, -0.1234, '(12%)');
checkDefault({numSign: 'parens', numMode: 'percent'}, +0.1234, ' 12% ');
});
it('should support scientific mode', function() {
checkDefault({numMode: 'scientific'}, 0.5, '5E-1');
checkDefault({numMode: 'scientific'}, -0.15, '-1.5E-1');
checkDefault({numMode: 'scientific'}, -1234.56, '-1.235E3');
checkDefault({numMode: 'scientific'}, +1234.56, '1.235E3');
checkDefault({numMode: 'scientific'}, 1.015e-8, '1.015E-8');
checkDefault({numMode: 'scientific', maxDecimals: 10}, 1.015e-8, '1.015E-8');
checkDefault({numMode: 'scientific', decimals: 10}, 1.015e-8, '1.0150000000E-8');
checkDefault({numMode: 'scientific', maxDecimals: 2}, 1.015e-8, '1.02E-8');
checkDefault({numMode: 'scientific', maxDecimals: 1}, 1.015e-8, '1E-8');
checkDefault({numMode: 'scientific'}, -121e+25, '-1.21E27');
});
});
});

@ -0,0 +1,241 @@
import {arrayRepeat} from 'app/common/gutil';
import {guessColInfo, guessColInfoForImports, GuessResult} from 'app/common/ValueGuesser';
import {assert} from 'chai';
const defaultDocSettings = {
locale: 'en-US'
};
function check(values: Array<string | null>, expectedResult: GuessResult) {
const result = guessColInfo(values, defaultDocSettings, "America/New_York");
assert.deepEqual(result, expectedResult);
}
describe("ValueGuesser", function() {
it("should guess booleans and numbers correctly", function() {
check(
["true", "false"],
{
values: [true, false],
colInfo: {type: 'Bool'},
},
);
// 1 and 0 in a boolean column would be converted to true and false,
// but they're guessed as numbers, not booleans
check(
["1", "0"],
{
values: [1, 0],
colInfo: {type: 'Numeric'},
},
);
// Even here, guessing booleans would be sensible, but the original values would be lost
// if the user didn't like the guess and converted boolean column was converted back to Text.
// Also note that when we fallback to Text without any parsing, guessColInfo doesn't return any values,
// as sending them back to the data engine would be wasteful.
check(
["true", "false", "1", "0"],
{colInfo: {type: 'Text'}},
);
// Now that 90% if the values are straightforward booleans, it guesses Bool
// "0" is still not parsed by guessColInfo as it's trying to be lossless.
// However, it will actually be converted in Python by Bool.do_convert,
// so this is a small way information can still be lost.
check(
[...arrayRepeat(9, "true"), "0"],
{
values: [...arrayRepeat(9, true), "0"],
colInfo: {type: 'Bool'},
},
);
// If there are blank values ("" or null) then leave them as text,
// because the data engine would convert them to false which would lose info.
check(
["true", ""],
{colInfo: {type: 'Text'}},
);
check(
["false", null],
{colInfo: {type: 'Text'}},
);
});
it("should handle formatted numbers", function() {
check(
["0.0", "1.0"],
{
values: [0, 1],
colInfo: {type: "Numeric", widgetOptions: {decimals: 1}},
}
);
check(
["$1.00"],
{
values: [1],
colInfo: {type: "Numeric", widgetOptions: {numMode: "currency", decimals: 2}},
}
);
check(
["$1"],
{
values: [1],
colInfo: {type: "Numeric", widgetOptions: {numMode: "currency", decimals: 0}},
}
);
// Inconsistent number of decimal places
check(
["$1", "$1.00"],
{colInfo: {type: 'Text'}},
);
// Inconsistent use of currency
check(
["1.00", "$1.00"],
{colInfo: {type: 'Text'}},
);
check(
["500", "6000"],
{
values: [500, 6000],
colInfo: {type: "Numeric"},
}
);
check(
["500", "6,000"],
{
values: [500, 6000],
colInfo: {type: "Numeric", widgetOptions: {numMode: "decimal"}},
}
);
// Inconsistent use of thousands separators
check(
["5000", "6,000"],
{colInfo: {type: 'Text'}},
);
});
it("should guess dates and datetimes correctly", function() {
check(
["1970-01-21", null, ""],
{
// The number represents 1970-01-21 parsed to a timestamp.
// null and "" are converted to null.
values: [20 * 24 * 60 * 60, null, null],
colInfo: {
type: 'Date',
widgetOptions: {
dateFormat: "YYYY-MM-DD",
timeFormat: "",
isCustomDateFormat: false,
isCustomTimeFormat: true,
},
},
},
);
check(
["1970-01-01 05:00:00"],
{
// 05:00 in the given timezone is 10:00 in UTC
values: [10 * 60 * 60],
colInfo: {
// "America/New_York" is the timezone given by `check`
type: 'DateTime:America/New_York',
widgetOptions: {
dateFormat: "YYYY-MM-DD",
timeFormat: "HH:mm:ss",
isCustomDateFormat: false,
isCustomTimeFormat: false,
},
},
},
);
// A mixture of Date and DateTime cannot be guessed as either, fallback to Text
check(
[
"1970-01-01",
"1970-01-01",
"1970-01-01",
"1970-01-01 05:00:00",
],
{colInfo: {type: 'Text'}},
);
});
it("should require 90% of values to be parsed", function() {
// 90% of the strings can be parsed to numbers, so guess Numeric.
check(
[...arrayRepeat(9, "12"), "foo"],
{
values: [...arrayRepeat(9, 12), "foo"],
colInfo: {type: 'Numeric'},
},
);
// Less than 90% are numbers, so fallback to Text
check(
[...arrayRepeat(8, "12"), "foo"],
{colInfo: {type: 'Text'}},
);
// Same as the previous two checks but with a bunch of blanks
check(
[...arrayRepeat(9, "12"), "foo", ...arrayRepeat(90, "")],
{
values: [...arrayRepeat(9, 12), "foo", ...arrayRepeat(90, null)],
colInfo: {type: 'Numeric'},
},
);
check(
[...arrayRepeat(8, "12"), "foo", ...arrayRepeat(90, "")],
{colInfo: {type: 'Text'}},
);
// Just a bunch of blanks and text, no numbers or anything
check(
[...arrayRepeat(100, null), "foo", "bar"],
{colInfo: {type: 'Text'}},
);
});
describe("guessColInfoForImports", function() {
// Prepare dummy docData; just the minimum to satisfy the code that uses it.
const docData: any = {
docSettings: () => defaultDocSettings,
docInfo: () => ({timezone: 'America/New_York'}),
};
it("should guess empty column when all cells are empty", function() {
assert.deepEqual(guessColInfoForImports([null, "", "", null], docData), {
values: [null, "", "", null],
colMetadata: {type: 'Any', isFormula: true, formula: ''}
});
});
it("should do proper numeric format guessing for a mix of number/string types", function() {
assert.deepEqual(guessColInfoForImports([-5.5, "1,234.6", null, 0], docData), {
values: [-5.5, 1234.6, null, 0],
colMetadata: {type: 'Numeric', widgetOptions: '{"numMode":"decimal"}'}
});
});
it("should not guess empty column when values are not actually empty", function() {
assert.deepEqual(guessColInfoForImports([null, 0, "", false], docData), {
values: [null, 0, "", false],
colMetadata: {type: 'Text'}
});
});
it("should do no guessing for object values", function() {
assert.deepEqual(guessColInfoForImports(["test", ['L' as any, 1]], docData), {
values: ["test", ['L' as any, 1]]
});
});
});
});

@ -0,0 +1,619 @@
/* global describe, it */
var _ = require('underscore');
var assert = require('chai').assert;
var gutil = require('app/common/gutil');
var utils = require('../utils');
/**
* Set env ENABLE_TIMING_TESTS=1 to run the timing tests.
* These tests rely on mocha's reported timings to allow you to compare the performance of
* different implementations.
*/
var ENABLE_TIMING_TESTS = Boolean(process.env.ENABLE_TIMING_TESTS);
//----------------------------------------------------------------------
// Following recommendations such as here:
// http://stackoverflow.com/questions/7032550/javascript-insert-an-array-inside-another-array
// However, this won't work for large arrToInsert because .apply has a limit on length of args.
function spliceApplyConcat(target, start, arrToInsert) {
target.splice.apply(target, [start, 0].concat(arrToInsert));
return target;
}
//----------------------------------------------------------------------
// Seems like could be faster, but disturbingly mutates the last argument.
// However, this won't work for large arrToInsert because .apply has a limit on length of args.
function spliceApplyUnshift(target, start, arrToInsert) {
var spliceArgs = arrToInsert;
spliceArgs.unshift(start, 0);
try {
target.splice.apply(target, spliceArgs);
} finally {
spliceArgs.splice(0, 2);
}
return target;
}
//----------------------------------------------------------------------
// This is from the same stackoverflow answer, but builds a new array instead of mutating target.
function nonSpliceUsingSlice(target, start, arrToInsert) {
return target.slice(0, start).concat(arrToInsert, target.slice(start));
}
//----------------------------------------------------------------------
// A simple manual implementation, that performs reasonably well in all environments.
function spliceManualWithTailCopy(target, start, arrToInsert) {
var insLen = arrToInsert.length;
if (insLen === 1) {
target.splice(start, 0, arrToInsert[0]);
} else if (insLen > 1) {
var i, len, tail = target.slice(start);
for (i = 0; i < insLen; i++, start++) {
target[start] = arrToInsert[i];
}
for (i = 0, len = tail.length; i < len; i++, start++) {
target[start] = tail[i];
}
}
return target;
}
//----------------------------------------------------------------------
function spliceCopyWithTail(helpers) {
var copyForward = helpers.copyForward;
return function(target, start, arrToInsert) {
var tail = target.slice(start), insLen = arrToInsert.length;
copyForward(target, start, arrToInsert, 0, insLen);
copyForward(target, start + insLen, tail, 0, tail.length);
return target;
};
}
//----------------------------------------------------------------------
// This implementation avoids creating a copy of the tail, but fills in the array
// non-contiguously.
function spliceFwdBackCopy(helpers) {
var copyForward = helpers.copyForward,
copyBackward = helpers.copyBackward;
return function(target, start, arrayToInsert) {
var count = arrayToInsert.length;
copyBackward(target, start + count, target, start, target.length - start);
copyForward(target, start, arrayToInsert, 0, count);
return target;
};
}
//----------------------------------------------------------------------
// This implementation tries to be smarter by avoiding allocations, appending to the array
// contiguously, then filling in the gap.
function spliceAppendCopy(helpers) {
var appendFunc = helpers.append,
copyForward = helpers.copyForward,
copyBackward = helpers.copyBackward;
return function(target, start, arrToInsert) {
var origLen = target.length;
var tailLen = origLen - start;
var insLen = arrToInsert.length;
if (insLen > tailLen) {
appendFunc(target, arrToInsert, tailLen, insLen - tailLen);
appendFunc(target, target, start, tailLen);
copyForward(target, start, arrToInsert, 0, tailLen);
} else {
appendFunc(target, target, origLen - insLen, insLen);
copyBackward(target, start + insLen, target, start, tailLen - insLen);
copyForward(target, start, arrToInsert, 0, insLen);
}
return target;
};
}
//----------------------------------------------------------------------
// This implementation only appends, but requires splicing out the tail from the original.
// It is consistently slower on Node.
function spliceAppendOnly(helpers) {
var appendFunc = helpers.append;
return function(target, start, arrToInsert) {
var tail = target.splice(start, target.length);
appendFunc(target, arrToInsert, 0, arrToInsert.length);
appendFunc(target, tail, 0, tail.length);
return target;
};
}
//----------------------------------------------------------------------
// COPY-FORWARD FUNCTIONS
//----------------------------------------------------------------------
var copyForward = {
gutil: gutil.arrayCopyForward,
copyForward1: function(toArray, toStart, fromArray, fromStart, count) {
for (var end = toStart + count; toStart < end; ++toStart, ++fromStart) {
toArray[toStart] = fromArray[fromStart];
}
},
copyForward8: function(toArray, toStart, fromArray, fromStart, count) {
var end = toStart + count;
for (var xend = end - 7; toStart < xend; fromStart += 8, toStart += 8) {
toArray[toStart] = fromArray[fromStart];
toArray[toStart+1] = fromArray[fromStart+1];
toArray[toStart+2] = fromArray[fromStart+2];
toArray[toStart+3] = fromArray[fromStart+3];
toArray[toStart+4] = fromArray[fromStart+4];
toArray[toStart+5] = fromArray[fromStart+5];
toArray[toStart+6] = fromArray[fromStart+6];
toArray[toStart+7] = fromArray[fromStart+7];
}
for (; toStart < end; ++fromStart, ++toStart) {
toArray[toStart] = fromArray[fromStart];
}
},
copyForward64: function(toArray, toStart, fromArray, fromStart, count) {
var end = toStart + count;
for (var xend = end - 63; toStart < xend; fromStart += 64, toStart += 64) {
toArray[toStart]=fromArray[fromStart]; toArray[toStart+1]=fromArray[fromStart+1];
toArray[toStart+2]=fromArray[fromStart+2]; toArray[toStart+3]=fromArray[fromStart+3];
toArray[toStart+4]=fromArray[fromStart+4]; toArray[toStart+5]=fromArray[fromStart+5];
toArray[toStart+6]=fromArray[fromStart+6]; toArray[toStart+7]=fromArray[fromStart+7];
toArray[toStart+8]=fromArray[fromStart+8]; toArray[toStart+9]=fromArray[fromStart+9];
toArray[toStart+10]=fromArray[fromStart+10]; toArray[toStart+11]=fromArray[fromStart+11];
toArray[toStart+12]=fromArray[fromStart+12]; toArray[toStart+13]=fromArray[fromStart+13];
toArray[toStart+14]=fromArray[fromStart+14]; toArray[toStart+15]=fromArray[fromStart+15];
toArray[toStart+16]=fromArray[fromStart+16]; toArray[toStart+17]=fromArray[fromStart+17];
toArray[toStart+18]=fromArray[fromStart+18]; toArray[toStart+19]=fromArray[fromStart+19];
toArray[toStart+20]=fromArray[fromStart+20]; toArray[toStart+21]=fromArray[fromStart+21];
toArray[toStart+22]=fromArray[fromStart+22]; toArray[toStart+23]=fromArray[fromStart+23];
toArray[toStart+24]=fromArray[fromStart+24]; toArray[toStart+25]=fromArray[fromStart+25];
toArray[toStart+26]=fromArray[fromStart+26]; toArray[toStart+27]=fromArray[fromStart+27];
toArray[toStart+28]=fromArray[fromStart+28]; toArray[toStart+29]=fromArray[fromStart+29];
toArray[toStart+30]=fromArray[fromStart+30]; toArray[toStart+31]=fromArray[fromStart+31];
toArray[toStart+32]=fromArray[fromStart+32]; toArray[toStart+33]=fromArray[fromStart+33];
toArray[toStart+34]=fromArray[fromStart+34]; toArray[toStart+35]=fromArray[fromStart+35];
toArray[toStart+36]=fromArray[fromStart+36]; toArray[toStart+37]=fromArray[fromStart+37];
toArray[toStart+38]=fromArray[fromStart+38]; toArray[toStart+39]=fromArray[fromStart+39];
toArray[toStart+40]=fromArray[fromStart+40]; toArray[toStart+41]=fromArray[fromStart+41];
toArray[toStart+42]=fromArray[fromStart+42]; toArray[toStart+43]=fromArray[fromStart+43];
toArray[toStart+44]=fromArray[fromStart+44]; toArray[toStart+45]=fromArray[fromStart+45];
toArray[toStart+46]=fromArray[fromStart+46]; toArray[toStart+47]=fromArray[fromStart+47];
toArray[toStart+48]=fromArray[fromStart+48]; toArray[toStart+49]=fromArray[fromStart+49];
toArray[toStart+50]=fromArray[fromStart+50]; toArray[toStart+51]=fromArray[fromStart+51];
toArray[toStart+52]=fromArray[fromStart+52]; toArray[toStart+53]=fromArray[fromStart+53];
toArray[toStart+54]=fromArray[fromStart+54]; toArray[toStart+55]=fromArray[fromStart+55];
toArray[toStart+56]=fromArray[fromStart+56]; toArray[toStart+57]=fromArray[fromStart+57];
toArray[toStart+58]=fromArray[fromStart+58]; toArray[toStart+59]=fromArray[fromStart+59];
toArray[toStart+60]=fromArray[fromStart+60]; toArray[toStart+61]=fromArray[fromStart+61];
toArray[toStart+62]=fromArray[fromStart+62]; toArray[toStart+63]=fromArray[fromStart+63];
}
for (; toStart < end; ++fromStart, ++toStart) {
toArray[toStart] = fromArray[fromStart];
}
}
};
//----------------------------------------------------------------------
// COPY-BACKWARD FUNCTIONS
//----------------------------------------------------------------------
var copyBackward = {
gutil: gutil.arrayCopyBackward,
copyBackward1: function(toArray, toStart, fromArray, fromStart, count) {
for (var i = toStart + count - 1, j = fromStart + count - 1; i >= toStart; --i, --j) {
toArray[i] = fromArray[j];
}
},
copyBackward8: function(toArray, toStart, fromArray, fromStart, count) {
var i = toStart + count - 1, j = fromStart + count - 1;
for (var xStart = toStart + 7; i >= xStart; i -= 8, j -= 8) {
toArray[i] = fromArray[j];
toArray[i-1] = fromArray[j-1];
toArray[i-2] = fromArray[j-2];
toArray[i-3] = fromArray[j-3];
toArray[i-4] = fromArray[j-4];
toArray[i-5] = fromArray[j-5];
toArray[i-6] = fromArray[j-6];
toArray[i-7] = fromArray[j-7];
}
for ( ; i >= toStart; --i, --j) {
toArray[i] = fromArray[j];
}
},
copyBackward64: function(toArray, toStart, fromArray, fromStart, count) {
var i = toStart + count - 1, j = fromStart + count - 1;
for (var xStart = toStart + 63; i >= xStart; i -= 64, j -= 64) {
toArray[i]=fromArray[j]; toArray[i-1]=fromArray[j-1];
toArray[i-2]=fromArray[j-2]; toArray[i-3]=fromArray[j-3];
toArray[i-4]=fromArray[j-4]; toArray[i-5]=fromArray[j-5];
toArray[i-6]=fromArray[j-6]; toArray[i-7]=fromArray[j-7];
toArray[i-8]=fromArray[j-8]; toArray[i-9]=fromArray[j-9];
toArray[i-10]=fromArray[j-10]; toArray[i-11]=fromArray[j-11];
toArray[i-12]=fromArray[j-12]; toArray[i-13]=fromArray[j-13];
toArray[i-14]=fromArray[j-14]; toArray[i-15]=fromArray[j-15];
toArray[i-16]=fromArray[j-16]; toArray[i-17]=fromArray[j-17];
toArray[i-18]=fromArray[j-18]; toArray[i-19]=fromArray[j-19];
toArray[i-20]=fromArray[j-20]; toArray[i-21]=fromArray[j-21];
toArray[i-22]=fromArray[j-22]; toArray[i-23]=fromArray[j-23];
toArray[i-24]=fromArray[j-24]; toArray[i-25]=fromArray[j-25];
toArray[i-26]=fromArray[j-26]; toArray[i-27]=fromArray[j-27];
toArray[i-28]=fromArray[j-28]; toArray[i-29]=fromArray[j-29];
toArray[i-30]=fromArray[j-30]; toArray[i-31]=fromArray[j-31];
toArray[i-32]=fromArray[j-32]; toArray[i-33]=fromArray[j-33];
toArray[i-34]=fromArray[j-34]; toArray[i-35]=fromArray[j-35];
toArray[i-36]=fromArray[j-36]; toArray[i-37]=fromArray[j-37];
toArray[i-38]=fromArray[j-38]; toArray[i-39]=fromArray[j-39];
toArray[i-40]=fromArray[j-40]; toArray[i-41]=fromArray[j-41];
toArray[i-42]=fromArray[j-42]; toArray[i-43]=fromArray[j-43];
toArray[i-44]=fromArray[j-44]; toArray[i-45]=fromArray[j-45];
toArray[i-46]=fromArray[j-46]; toArray[i-47]=fromArray[j-47];
toArray[i-48]=fromArray[j-48]; toArray[i-49]=fromArray[j-49];
toArray[i-50]=fromArray[j-50]; toArray[i-51]=fromArray[j-51];
toArray[i-52]=fromArray[j-52]; toArray[i-53]=fromArray[j-53];
toArray[i-54]=fromArray[j-54]; toArray[i-55]=fromArray[j-55];
toArray[i-56]=fromArray[j-56]; toArray[i-57]=fromArray[j-57];
toArray[i-58]=fromArray[j-58]; toArray[i-59]=fromArray[j-59];
toArray[i-60]=fromArray[j-60]; toArray[i-61]=fromArray[j-61];
toArray[i-62]=fromArray[j-62]; toArray[i-63]=fromArray[j-63];
}
for ( ; i >= toStart; --i, --j) {
toArray[i] = fromArray[j];
}
}
};
//----------------------------------------------------------------------
// APPEND FUNCTIONS.
//----------------------------------------------------------------------
var append = {
gutil: gutil.arrayAppend,
append1: function(toArray, fromArray, fromStart, count) {
var end = fromStart + count;
for (var i = fromStart; i < end; i++) {
toArray.push(fromArray[i]);
}
},
appendCopy1: function(toArray, fromArray, fromStart, count) {
if (count === 1) {
toArray.push(fromArray[fromStart]);
} else if (count > 1) {
var len = toArray.length;
toArray.length = len + count;
copyForward.copyForward1(toArray, len, fromArray, fromStart, count);
}
},
append8: function(toArray, fromArray, fromStart, count) {
var end = fromStart + count;
for (var xend = end - 7; fromStart < xend; fromStart += 8) {
toArray.push(
fromArray[fromStart],
fromArray[fromStart + 1],
fromArray[fromStart + 2],
fromArray[fromStart + 3],
fromArray[fromStart + 4],
fromArray[fromStart + 5],
fromArray[fromStart + 6],
fromArray[fromStart + 7]);
}
for ( ; fromStart < end; ++fromStart) {
toArray.push(fromArray[fromStart]);
}
},
append64: function(toArray, fromArray, fromStart, count) {
var end = fromStart + count;
for (var xend = end - 63; fromStart < xend; fromStart += 64) {
toArray.push(
fromArray[fromStart], fromArray[fromStart + 1],
fromArray[fromStart + 2], fromArray[fromStart + 3],
fromArray[fromStart + 4], fromArray[fromStart + 5],
fromArray[fromStart + 6], fromArray[fromStart + 7],
fromArray[fromStart + 8], fromArray[fromStart + 9],
fromArray[fromStart + 10], fromArray[fromStart + 11],
fromArray[fromStart + 12], fromArray[fromStart + 13],
fromArray[fromStart + 14], fromArray[fromStart + 15],
fromArray[fromStart + 16], fromArray[fromStart + 17],
fromArray[fromStart + 18], fromArray[fromStart + 19],
fromArray[fromStart + 20], fromArray[fromStart + 21],
fromArray[fromStart + 22], fromArray[fromStart + 23],
fromArray[fromStart + 24], fromArray[fromStart + 25],
fromArray[fromStart + 26], fromArray[fromStart + 27],
fromArray[fromStart + 28], fromArray[fromStart + 29],
fromArray[fromStart + 30], fromArray[fromStart + 31],
fromArray[fromStart + 32], fromArray[fromStart + 33],
fromArray[fromStart + 34], fromArray[fromStart + 35],
fromArray[fromStart + 36], fromArray[fromStart + 37],
fromArray[fromStart + 38], fromArray[fromStart + 39],
fromArray[fromStart + 40], fromArray[fromStart + 41],
fromArray[fromStart + 42], fromArray[fromStart + 43],
fromArray[fromStart + 44], fromArray[fromStart + 45],
fromArray[fromStart + 46], fromArray[fromStart + 47],
fromArray[fromStart + 48], fromArray[fromStart + 49],
fromArray[fromStart + 50], fromArray[fromStart + 51],
fromArray[fromStart + 52], fromArray[fromStart + 53],
fromArray[fromStart + 54], fromArray[fromStart + 55],
fromArray[fromStart + 56], fromArray[fromStart + 57],
fromArray[fromStart + 58], fromArray[fromStart + 59],
fromArray[fromStart + 60], fromArray[fromStart + 61],
fromArray[fromStart + 62], fromArray[fromStart + 63]
);
}
for ( ; fromStart < end; ++fromStart) {
toArray.push(fromArray[fromStart]);
}
},
appendSlice64: function(toArray, fromArray, fromStart, count) {
var end = fromStart + count;
for ( ; fromStart < end; fromStart += 64) {
Array.prototype.push.apply(toArray, fromArray.slice(fromStart, Math.min(fromStart + 64, end)));
}
}
};
//----------------------------------------------------------------------
var helpers1 = {
copyForward: copyForward.copyForward1,
copyBackward: copyBackward.copyBackward1,
append: append.append1,
};
var helpers8 = {
copyForward: copyForward.copyForward8,
copyBackward: copyBackward.copyBackward8,
append: append.append8,
};
var helpers64 = {
copyForward: copyForward.copyForward64,
copyBackward: copyBackward.copyBackward64,
append: append.append64,
};
var allArraySpliceFuncs = {
spliceApplyConcat: spliceApplyConcat,
spliceApplyUnshift: spliceApplyUnshift,
nonSpliceUsingSlice: nonSpliceUsingSlice,
spliceGutil: gutil.arraySplice,
spliceManualWithTailCopy: spliceManualWithTailCopy,
spliceCopyWithTail1: spliceCopyWithTail(helpers1),
spliceCopyWithTail8: spliceCopyWithTail(helpers8),
spliceCopyWithTail64: spliceCopyWithTail(helpers64),
spliceFwdBackCopy1: spliceFwdBackCopy(helpers1),
spliceFwdBackCopy8: spliceFwdBackCopy(helpers8),
spliceFwdBackCopy64: spliceFwdBackCopy(helpers64),
spliceAppendCopy1: spliceAppendCopy(helpers1),
spliceAppendCopy8: spliceAppendCopy(helpers8),
spliceAppendCopy64: spliceAppendCopy(helpers64),
spliceAppendOnly1: spliceAppendOnly(helpers1),
spliceAppendOnly8: spliceAppendOnly(helpers8),
spliceAppendOnly64: spliceAppendOnly(helpers64),
};
var timedArraySpliceFuncs = {
// The following two naive implementations cannot cope with large arrays, and raise
// "RangeError: Maximum call stack size exceeded".
//spliceApplyConcat: spliceApplyConcat,
//spliceApplyUnshift: spliceApplyUnshift,
// This isn't a real splice, it doesn't modify the array.
//nonSpliceUsingSlice: nonSpliceUsingSlice,
// The implementations commented out below are the slower ones.
spliceGutil: gutil.arraySplice,
spliceManualWithTailCopy: spliceManualWithTailCopy,
spliceCopyWithTail1: spliceCopyWithTail(helpers1),
//spliceCopyWithTail8: spliceCopyWithTail(helpers8),
//spliceCopyWithTail64: spliceCopyWithTail(helpers64),
//spliceFwdBackCopy1: spliceFwdBackCopy(helpers1),
//spliceFwdBackCopy8: spliceFwdBackCopy(helpers8),
//spliceFwdBackCopy64: spliceFwdBackCopy(helpers64),
spliceAppendCopy1: spliceAppendCopy(helpers1),
spliceAppendCopy8: spliceAppendCopy(helpers8),
spliceAppendCopy64: spliceAppendCopy(helpers64),
//spliceAppendOnly1: spliceAppendOnly(helpers1),
//spliceAppendOnly8: spliceAppendOnly(helpers8),
//spliceAppendOnly64: spliceAppendOnly(helpers64),
};
//----------------------------------------------------------------------
describe("array copy functions", function() {
it("copyForward should copy correctly", function() {
_.each(copyForward, function(copyFunc, name) {
var data = _.range(10000);
copyFunc(data, 0, data, 1, 9999);
copyFunc(data, 0, data, 1, 9999);
assert.equal(data[0], 2);
assert.equal(data[1], 3);
assert.equal(data[9996], 9998);
assert.equal(data[9997], 9999);
assert.equal(data[9998], 9999);
assert.equal(data[9999], 9999);
});
});
it("copyBackward should copy correctly", function() {
_.each(copyBackward, function(copyFunc, name) {
var data = _.range(10000);
copyFunc(data, 1, data, 0, 9999);
copyFunc(data, 1, data, 0, 9999);
assert.equal(data[0], 0);
assert.equal(data[1], 0);
assert.equal(data[2], 0);
assert.equal(data[3], 1);
assert.equal(data[9998], 9996);
assert.equal(data[9999], 9997);
});
});
it("arrayAppend should append correctly", function() {
_.each(append, function(appendFunc, name) {
var out = [];
var data = _.range(20000);
appendFunc(out, data, 100, 1);
appendFunc(out, data, 100, 1000);
appendFunc(out, data, 100, 10000);
assert.deepEqual(out.slice(0, 4), [100, 100, 101, 102]);
assert.deepEqual(out.slice(1000, 1004), [1099, 100, 101, 102]);
assert.deepEqual(out.slice(11000), [10099]);
});
});
// See ENABLE_TIMING_TESTS flag on top of this file.
if (ENABLE_TIMING_TESTS) {
describe("timing", function() {
var a1m = _.range(1000000);
describe("copyForward", function() {
var reps = 40;
_.each(copyForward, function(copyFunc, name) {
var b1m = a1m.slice(0);
it(name, function() {
utils.repeat(reps, copyFunc, b1m, 0, b1m, 1, 999999);
// Make sure it actually worked. These checks shouldn't affect timings much.
assert.deepEqual(b1m.slice(0, 10), _.range(reps, reps + 10));
assert.equal(b1m[999999-reps-1], 999998);
assert.equal(b1m[999999-reps], 999999);
assert.deepEqual(b1m.slice(1000000-reps), _.times(reps, _.constant(999999)));
});
});
});
describe("copyBackward", function() {
var reps = 40;
_.each(copyBackward, function(copyFunc, name) {
var b1m = a1m.slice(0);
it(name, function() {
utils.repeat(reps, copyFunc, b1m, 1, b1m, 0, 999999);
// Make sure it actually worked. These checks shouldn't affect timings much.
assert.deepEqual(b1m.slice(0, reps), _.times(reps, _.constant(0)));
assert.equal(b1m[reps], 0);
assert.equal(b1m[reps + 1], 1);
assert.deepEqual(b1m.slice(999990), _.range(999990-reps, 1000000-reps));
});
});
});
describe("append", function() {
var data = _.range(1000000);
function chunkedAppend(appendFunc, data, chunk) {
var out = [];
var count = data.length / chunk;
for (var i = 0; i < count; i++) {
appendFunc(out, data, i * chunk, chunk);
}
return out;
}
_.each(append, function(appendFunc, name) {
it(name, function() {
var out1 = chunkedAppend(appendFunc, data, 1);
var out2 = chunkedAppend(appendFunc, data, 1000);
var out3 = chunkedAppend(appendFunc, data, 1000000);
// Make sure it actually worked. Keep the checks short to avoid affecting timings.
assert.deepEqual(out1.slice(0, 10), data.slice(0, 10));
assert.deepEqual(out1.slice(data.length - 10), data.slice(data.length - 10));
assert.deepEqual(out2.slice(0, 10), data.slice(0, 10));
assert.deepEqual(out2.slice(data.length - 10), data.slice(data.length - 10));
assert.deepEqual(out3.slice(0, 10), data.slice(0, 10));
assert.deepEqual(out3.slice(data.length - 10), data.slice(data.length - 10));
});
});
});
});
}
});
describe('arraySplice', function() {
// Make sure all our functions produce the same results as spliceApplyConcat for simple cases.
var refSpliceFunc = spliceApplyConcat;
it("all candidate functions should be correct for simpler cases", function() {
_.each(allArraySpliceFuncs, function(spliceFunc, name) {
var a10 = _.range(10), a100 = _.range(100);
function checkSpliceFunc(target, start, arrToInsert) {
assert.deepEqual(spliceFunc(target.slice(0), start, arrToInsert),
refSpliceFunc(target.slice(0), start, arrToInsert),
"splice function incorrect for " + name);
}
checkSpliceFunc(a10, 5, a100);
checkSpliceFunc(a100, 50, a10);
checkSpliceFunc(a100, 90, a10);
checkSpliceFunc(a100, 0, a10);
checkSpliceFunc(a100, 100, a10);
checkSpliceFunc(a10, 0, a100);
checkSpliceFunc(a10, 10, a100);
checkSpliceFunc(a10, 1, a10);
checkSpliceFunc(a10, 5, a10);
checkSpliceFunc(a10, 5, []);
assert.deepEqual(spliceFunc(a10.slice(0), 5, a10),
[0, 1, 2, 3, 4, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 5, 6, 7, 8, 9]);
});
});
// See ENABLE_TIMING_TESTS flag on top of this file.
if (ENABLE_TIMING_TESTS) {
describe("timing", function() {
var a1 = _.range(1);
var a1k = _.range(1000);
var a1m = _.range(1000000);
describe("insert-one", function() {
_.each(timedArraySpliceFuncs, function(spliceFunc, name) {
var b1m = a1m.slice(0);
it(name, function() {
utils.repeat(40, spliceFunc, b1m, 500000, a1);
});
});
});
describe("insert-1k", function() {
_.each(timedArraySpliceFuncs, function(spliceFunc, name) {
var b1m = a1m.slice(0);
it(name, function() {
utils.repeat(40, spliceFunc, b1m, 500000, a1k);
});
});
});
describe("insert-1m", function() {
_.each(timedArraySpliceFuncs, function(spliceFunc, name) {
var b1m = a1m.slice(0);
it(name, function() {
utils.repeat(4, spliceFunc, b1m, 500000, a1m);
});
});
});
});
}
});

@ -0,0 +1,49 @@
import * as csvFormat from 'app/common/csvFormat';
import {assert} from 'chai';
describe('csvFormat', function() {
it('should encode/decode csv values correctly', function() {
function verify(plain: string, encoded: string) {
assert.equal(csvFormat.csvEncodeCell(plain), encoded);
assert.equal(csvFormat.csvDecodeCell(encoded), plain);
}
verify("hello world", "hello world");
verify(`Commas,, galore, `, `"Commas,, galore, "`);
verify(`"Quote" 'me,', ""please!""`, `"""Quote"" 'me,', """"please!"""""`);
verify(` sing"le `, `" sing""le "`);
verify(``, ``);
verify(`""`, `""""""`);
verify(`\t\n'\`\\`, `"\t\n'\`\\"`);
// The exact interpretation of invalid encodings isn't too important, but should include most
// of the value and not throw exceptions.
assert.equal(csvFormat.csvDecodeCell(`invalid"e\ncoding `), `invalid"e\ncoding`);
assert.equal(csvFormat.csvDecodeCell(`"invalid"e`), `invalid"e`);
});
it('should encode/decode csv rows correctly', function() {
function verify(plain: string[], encoded: string, prettier: boolean) {
assert.equal(csvFormat.csvEncodeRow(plain, {prettier}), encoded);
assert.deepEqual(csvFormat.csvDecodeRow(encoded), plain);
}
verify(["hello", "world"], "hello,world", false);
verify(["hello", "world"], "hello, world", true);
verify(["hello ", " world"], `"hello "," world"`, false);
verify(["hello ", " world"], `"hello ", " world"`, true);
verify([' '], `" "`, false);
verify(['', ''], `,`, false);
verify(['', ' ', ''], `, " ", `, true);
verify([
"Commas,, galore, ",
`"Quote" 'me,', ""please!""`,
` sing"le `,
' ',
'',
], `"Commas,, galore, ","""Quote"" 'me,', """"please!"""""," sing""le "," ",`, false);
verify(['Medium', 'Very high', `with, comma*=~!|more`, `asdf\nsdf`],
`Medium, Very high, "with, comma*=~!|more", "asdf\nsdf"`, true);
// The exact interpretation of invalid encodings isn't too important, but should include most
// of the value and not throw exceptions.
assert.deepEqual(csvFormat.csvDecodeRow(`invalid"e\ncoding,","`),
['invalid"e\ncoding,', '']);
});
});

@ -0,0 +1,15 @@
import {getTableTitle} from 'app/common/ActiveDocAPI';
import {assert} from 'chai';
describe('getTableTitle', function() {
it('should construct correct table titles', async function() {
function check(groupByColLabels: string[] | null, expected: string) {
assert.equal(getTableTitle({title: "My Table", groupByColLabels, colIds: []}), expected);
}
check(null, "My Table");
check([], "My Table [Totals]");
check(["A"], "My Table [by A]");
check(["A", "B"], "My Table [by A, B]");
});
});

@ -0,0 +1,23 @@
import {parseFirstUrlPart} from 'app/common/gristUrls';
import {assert} from 'chai';
describe('gristUrls', function() {
describe('parseFirstUrlPart', function() {
it('should strip out matching tag', function() {
assert.deepEqual(parseFirstUrlPart('o', '/o/foo/bar?x#y'), {value: 'foo', path: '/bar?x#y'});
assert.deepEqual(parseFirstUrlPart('o', '/o/foo?x#y'), {value: 'foo', path: '/?x#y'});
assert.deepEqual(parseFirstUrlPart('o', '/o/foo#y'), {value: 'foo', path: '/#y'});
assert.deepEqual(parseFirstUrlPart('o', '/o/foo'), {value: 'foo', path: '/'});
});
it('should pass unchanged non-matching path or tag', function() {
assert.deepEqual(parseFirstUrlPart('xxx', '/o/foo/bar?x#y'), {path: '/o/foo/bar?x#y'});
assert.deepEqual(parseFirstUrlPart('o', '/O/foo/bar?x#y'), {path: '/O/foo/bar?x#y'});
assert.deepEqual(parseFirstUrlPart('o', '/bar?x#y'), {path: '/bar?x#y'});
assert.deepEqual(parseFirstUrlPart('o', '/o/?x#y'), {path: '/o/?x#y'});
assert.deepEqual(parseFirstUrlPart('o', '/#y'), {path: '/#y'});
assert.deepEqual(parseFirstUrlPart('o', ''), {path: ''});
});
});
});

@ -0,0 +1,264 @@
/* global describe, it */
var assert = require('chai').assert;
var gutil = require('app/common/gutil');
var _ = require('underscore');
describe('gutil', function() {
describe("mapToObject", function() {
it("should produce an object with all keys", function() {
assert.deepEqual(gutil.mapToObject(["foo", "bar", "baz"], function(value, i) {
return [value.toUpperCase(), i];
}), {
"foo": ["FOO", 0],
"bar": ["BAR", 1],
"baz": ["BAZ", 2]
});
assert.deepEqual(gutil.mapToObject(["foo", "bar", "baz"], function() {}), {
"foo": undefined,
"bar": undefined,
"baz": undefined,
});
});
it("should work on an empty array", function() {
var countCalls = 0;
assert.deepEqual(gutil.mapToObject([], function() { countCalls++; }), {});
assert.equal(countCalls, 0);
});
it("should override values for duplicate keys", function() {
assert.deepEqual(gutil.mapToObject(["foo", "bar", "foo"], function(val, i) { return i; }),
{ "foo": 2, "bar": 1 });
});
});
describe('multiCompareFunc', function() {
var firstName = {
0: 'John',
1: 'John',
2: 'John',
3: 'John',
4: 'Johnson',
5: 'Johnson',
};
var lastName = {
0: 'Smith',
1: 'Smith',
2: 'Smith',
3: 'Smithy',
4: 'Smithy',
5: 'Smith',
};
var age = {
0: 20,
1: 30,
2: 21,
3: 31,
4: 40,
5: 50,
};
it('should do single comparisons', function() {
var sort1 = [_.propertyOf(firstName)];
var compareA = gutil.multiCompareFunc(sort1, [gutil.nativeCompare], [1]);
var compareD = gutil.multiCompareFunc(sort1, [gutil.nativeCompare], [-1]);
assert.equal(compareA(0, 1), 0); // John == John
assert.equal(compareD(0, 1), 0);
assert.isBelow(compareA(0, 4), 0); // John < Johnson if ascending
assert.isAbove(compareA(4, 0), 0);
assert.isAbove(compareD(0, 4), 0); // John > Johnson if descending
assert.isBelow(compareD(4, 0), 0);
});
it('should do multiple comparisons', function() {
var sort2 = [_.propertyOf(firstName), _.propertyOf(lastName)];
var sort3 = [_.propertyOf(firstName), _.propertyOf(lastName), _.propertyOf(age)];
var compare2 = gutil.multiCompareFunc(sort2, [gutil.nativeCompare, gutil.nativeCompare], [1, 1]);
var compare3 = gutil.multiCompareFunc(sort3,
[gutil.nativeCompare, gutil.nativeCompare, gutil.nativeCompare], [1, 1, -1]);
assert.equal(compare2(0, 1), 0); // John Smith, 20 = John Smith, 30
assert.equal(compare2(1, 2), 0); // John Smith, 30 = John Smith, 21
assert.isBelow(compare2(0, 3), 0); // John Smith < John Smithy
assert.isBelow(compare2(0, 4), 0); // John Smith < Johnson Smithy
assert.isBelow(compare2(0, 5), 0); // John Smith < Johnson Smith
assert.isAbove(compare3(0, 1), 0); // John Smith, 20 > John Smith, 30 (age descending)
assert.isBelow(compare3(1, 2), 0); // John Smith, 30 < John Smith, 21
assert.isBelow(compare3(0, 3), 0); // John Smith, 20 < John Smithy, 31
assert.isBelow(compare3(0, 4), 0); // John Smith, 20 < Johnson Smithy, 40
assert.isBelow(compare3(3, 4), 0); // John Smithy, 20 < Johnson Smithy, 40
assert.isAbove(compare3(4, 5), 0); // Johnson Smithy > Johnson Smith
});
});
describe("deepExtend", function() {
var sample = {
a: 1,
b: "hello",
c: [1, 2, 3],
d: { e: 1, f: 2 }
};
it("should copy recursively", function() {
assert.deepEqual(gutil.deepExtend({}, {}), {});
assert.deepEqual(gutil.deepExtend({}, sample), sample);
assert.deepEqual(gutil.deepExtend({}, sample, {}), sample);
assert.deepEqual(gutil.deepExtend({}, sample, sample), sample);
assert.deepEqual(gutil.deepExtend({}, sample, {a: 2}).a, 2);
assert.deepEqual(gutil.deepExtend({}, sample, {d: {g: 3}}).d, {e:1, f:2, g:3});
assert.deepEqual(gutil.deepExtend({c: [4, 5, 6, 7], d: {g: 3}}, sample).d, {e:1, f:2, g:3});
assert.deepEqual(gutil.deepExtend({c: [4, 5, 6, 7], d: {g: 3}}, sample).c, [1, 2, 3, 7]);
});
});
describe("maxsplit", function() {
it("should respect maxNumSplits parameter", function() {
assert.deepEqual(gutil.maxsplit("foo bar baz", " ", 0), ["foo bar baz"]);
assert.deepEqual(gutil.maxsplit("foo bar baz", " ", 1), ["foo", "bar baz"]);
assert.deepEqual(gutil.maxsplit("foo bar baz", " ", 2), ["foo", "bar", "baz"]);
assert.deepEqual(gutil.maxsplit("foo bar baz", " ", 3), ["foo", "bar", "baz"]);
assert.deepEqual(gutil.maxsplit("foo<x>bar<x>baz", "<x>", 1), ["foo", "bar<x>baz"]);
});
});
describe("arrayInsertBefore", function() {
it("should insert before the given nextValue", function() {
var array = ["foo", "bar", "baz"];
gutil.arrayInsertBefore(array, "asdf", "foo");
assert.deepEqual(array, ["asdf", "foo", "bar", "baz"]);
gutil.arrayInsertBefore(array, "hello", "baz");
assert.deepEqual(array, ["asdf", "foo", "bar", "hello", "baz"]);
gutil.arrayInsertBefore(array, "zoo", "unknown");
assert.deepEqual(array, ["asdf", "foo", "bar", "hello", "baz", "zoo"]);
});
});
describe("popFromMap", function() {
it("should return the value for the popped key", function() {
var map = new Map([["foo", 1], ["bar", 2], ["baz", 3]]);
assert.equal(gutil.popFromMap(map, "bar"), 2);
assert.deepEqual(Array.from(map), [["foo", 1], ["baz", 3]]);
assert.strictEqual(gutil.popFromMap(map, "unknown"), undefined);
assert.deepEqual(Array.from(map), [["foo", 1], ["baz", 3]]);
});
});
describe("isSubset", function() {
it("should determine the subset relationship for Sets", function() {
let sEmpty = new Set(),
sFoo = new Set([1]),
sBar = new Set([2, 3]),
sBaz = new Set([1, 2, 3]);
assert.isTrue(gutil.isSubset(sEmpty, sFoo));
assert.isFalse(gutil.isSubset(sFoo, sEmpty));
assert.isTrue(gutil.isSubset(sFoo, sBaz));
assert.isFalse(gutil.isSubset(sFoo, sBar));
assert.isTrue(gutil.isSubset(sBar, sBaz));
assert.isTrue(gutil.isSubset(sBar, sBar));
assert.isTrue(gutil.isSubset(sBaz, sBaz));
assert.isFalse(gutil.isSubset(sBaz, sBar));
});
});
describe("growMatrix", function() {
it("should grow the matrix to the desired size", function() {
let matrix = [["a", 1], ["b", 2], ["c", 3]];
assert.deepEqual(gutil.growMatrix(matrix, 4, 4),
[["a", 1, "a", 1],
["b", 2, "b", 2],
["c", 3, "c", 3],
["a", 1, "a", 1]]);
assert.deepEqual(gutil.growMatrix(matrix, 3, 4),
[["a", 1, "a", 1],
["b", 2, "b", 2],
["c", 3, "c", 3]]);
assert.deepEqual(gutil.growMatrix(matrix, 6, 2),
[["a", 1],
["b", 2],
["c", 3],
["a", 1],
["b", 2],
["c", 3]]);
});
});
describe("sortedScan", function() {
it("should callback on the correct items for simple arrays", function() {
const a = [1, 2, 4, 5, 7, 8, 9, 10, 11, 15, 17];
const b = [2, 3, 4, 5, 9, 11, 19];
// Run the scan function, allowing it to populate callArgs.
let callArgs = [];
gutil.sortedScan(a, b, (ai, bi) => { callArgs.push([ai, bi]); });
assert.deepEqual(callArgs,
[[1, null], [2, 2], [null, 3], [4, 4],
[5, 5], [7, null], [8, null], [9, 9],
[10, null], [11, 11], [15, null], [17, null],
[null, 19]]);
});
it("should callback on the correct items for object arrays", function() {
const a = [{ id: 1, fruit: 'apple' },
{ id: 2, fruit: 'banana' },
{ id: 4, fruit: 'orange' },
{ id: 5, fruit: 'peach' },
{ id: 6, fruit: 'plum' }];
const b = [{ id: 2, fruit: 'apple' },
{ id: 3, fruit: 'avocado' },
{ id: 4, fruit: 'peach' },
{ id: 6, fruit: 'pear' },
{ id: 9, fruit: 'plum' },
{ id: 10, fruit: 'raspberry' }];
// Run the scan function.
let fruitArgs = [];
gutil.sortedScan(a, b, (ai, bi) => {
fruitArgs.push([ai ? ai.fruit : '', bi ? bi.fruit : '']);
}, item => item.id);
assert.deepEqual(fruitArgs,
[['apple', ''], ['banana', 'apple'], ['', 'avocado'],
['orange', 'peach'], ['peach', ''], ['plum', 'pear'],
['', 'plum'], ['', 'raspberry']]);
// Run the scan function again, using fruit as the key.
let idArgs = [];
gutil.sortedScan(a, b, (ai, bi) => {
idArgs.push([ai ? ai.id : 0, bi ? bi.id : 0]);
}, item => item.fruit);
assert.deepEqual(idArgs,
[[1, 2], [0, 3], [2, 0], [4, 0],
[5, 4], [0, 6], [6, 9], [0, 10]]);
});
});
describe("isEmail", function() {
it("should distinguish valid and invalid emails", function() {
// Reference: https://blogs.msdn.microsoft.com/testing123/2009/02/06/email-address-test-cases/
assert.isTrue(gutil.isEmail('email@domain.com'));
assert.isTrue(gutil.isEmail('e-mail_123@domain.com'));
assert.isTrue(gutil.isEmail('email@subdomain.do-main.com'));
assert.isTrue(gutil.isEmail('firstname+lastname@domain.com'));
assert.isTrue(gutil.isEmail('email@domain.co.jp'));
assert.isFalse(gutil.isEmail('plainaddress'));
assert.isFalse(gutil.isEmail('@domain.com'));
assert.isFalse(gutil.isEmail('email@domain@domain.com'));
assert.isFalse(gutil.isEmail('.email@domain.com'));
assert.isFalse(gutil.isEmail('email.@domain.com'));
assert.isFalse(gutil.isEmail('email..email@domain.com'));
assert.isFalse(gutil.isEmail('あいうえお@domain.com'));
assert.isFalse(gutil.isEmail('email@domain'));
});
});
});

@ -0,0 +1,157 @@
import {delay} from 'app/common/delay';
import * as gutil from 'app/common/gutil';
import {assert} from 'chai';
import {Observable} from 'grainjs';
import * as ko from 'knockout';
import * as sinon from 'sinon';
describe('gutil2', function() {
describe('waitObs', function() {
it('should resolve promise when predicate matches', async function() {
const obs: ko.Observable<number|null> = ko.observable<number|null>(null);
const promise1 = gutil.waitObs(obs, (val) => Boolean(val));
const promise2 = gutil.waitObs(obs, (val) => (val === null));
const promise3 = gutil.waitObs(obs, (val) => (val! > 20));
const spy1 = sinon.spy(), spy2 = sinon.spy(), spy3 = sinon.spy();
const done = Promise.all([
promise1.then((val) => { spy1(); assert.strictEqual(val, 17); }),
promise2.then((val) => { spy2(); assert.strictEqual(val, null); }),
promise3.then((val) => { spy3(); assert.strictEqual(val, 30); }),
]);
await delay(1);
obs(17);
await delay(1);
obs(30);
await delay(1);
await done;
sinon.assert.callOrder(spy2, spy1, spy3);
});
});
describe('waitGrainObs', function() {
it('should resolve promise when predicate matches', async function() {
const obs = Observable.create<number|null>(null, null);
const promise1 = gutil.waitGrainObs(obs, (val) => Boolean(val));
const promise2 = gutil.waitGrainObs(obs, (val) => (val === null));
const promise3 = gutil.waitGrainObs(obs, (val) => (val! > 20));
const spy1 = sinon.spy(), spy2 = sinon.spy(), spy3 = sinon.spy();
const done = Promise.all([
promise1.then((val) => { spy1(); assert.strictEqual(val, 17); }),
promise2.then((val) => { spy2(); assert.strictEqual(val, null); }),
promise3.then((val) => { spy3(); assert.strictEqual(val, 30); }),
]);
await delay(1);
obs.set(17);
await delay(1);
obs.set(30);
await delay(1);
await done;
sinon.assert.callOrder(spy2, spy1, spy3);
});
});
describe('PromiseChain', function() {
it('should resolve promises in order', async function() {
const chain = new gutil.PromiseChain();
const spy1 = sinon.spy(), spy2 = sinon.spy(), spy3 = sinon.spy();
const done = Promise.all([
chain.add(() => delay(30).then(spy1).then(() => 1)),
chain.add(() => delay(20).then(spy2).then(() => 2)),
chain.add(() => delay(10).then(spy3).then(() => 3)),
]);
assert.deepEqual(await done, [1, 2, 3]);
sinon.assert.callOrder(spy1, spy2, spy3);
});
it('should skip pending callbacks, but not new callbacks, on error', async function() {
const chain = new gutil.PromiseChain();
const spy1 = sinon.spy(), spy2 = sinon.spy(), spy3 = sinon.spy();
let res1: any, res2: any, res3: any;
await assert.isRejected(Promise.all([
res1 = chain.add(() => delay(30).then(spy1).then(() => { throw new Error('Err1'); })),
res2 = chain.add(() => delay(20).then(spy2)),
res3 = chain.add(() => delay(10).then(spy3)),
]), /Err1/);
// Check that already-scheduled callbacks did not get called.
sinon.assert.calledOnce(spy1);
sinon.assert.notCalled(spy2);
sinon.assert.notCalled(spy3);
spy1.resetHistory();
// Ensure skipped add() calls return a rejection.
await assert.isRejected(res1, /^Err1/);
await assert.isRejected(res2, /^Skipped due to an earlier error/);
await assert.isRejected(res3, /^Skipped due to an earlier error/);
// New promises do get scheduled.
await assert.isRejected(Promise.all([
res1 = chain.add(() => delay(1).then(spy1).then(() => 17)),
res2 = chain.add(() => delay(1).then(spy2).then(() => { throw new Error('Err2'); })),
res3 = chain.add(() => delay(1).then(spy3)),
]), /Err2/);
sinon.assert.callOrder(spy1, spy2);
sinon.assert.notCalled(spy3);
// Check the return values of add() calls.
assert.strictEqual(await res1, 17);
await assert.isRejected(res2, /^Err2/);
await assert.isRejected(res3, /^Skipped due to an earlier error/);
});
});
describe("isLongerThan", function() {
it('should work correctly', async function() {
assert.equal(await gutil.isLongerThan(delay(200), 100), true);
assert.equal(await gutil.isLongerThan(delay(10), 100), false);
// A promise that throws before the timeout, causes the returned promise to resolve to false.
const errorObj = {};
let promise = delay(10).then(() => { throw errorObj; });
assert.equal(await gutil.isLongerThan(promise, 100), false);
await assert.isRejected(promise);
// A promise that throws after the timeout, causes the returned promise to resolve to true.
promise = delay(200).then(() => { throw errorObj; });
assert.equal(await gutil.isLongerThan(promise, 100), true);
await assert.isRejected(promise);
});
});
describe("isValidHex", function() {
it('should work correctly', async function() {
assert.equal(gutil.isValidHex('#FF00FF'), true);
assert.equal(gutil.isValidHex('#FF00FFF'), false);
assert.equal(gutil.isValidHex('#FF0'), false);
assert.equal(gutil.isValidHex('#FF00'), false);
assert.equal(gutil.isValidHex('FF00FF'), false);
assert.equal(gutil.isValidHex('#FF00FG'), false);
});
});
describe("pruneArray", function() {
function check<T>(arr: T[], indexes: number[], expect: T[]) {
gutil.pruneArray(arr, indexes);
assert.deepEqual(arr, expect);
}
it('should remove correct elements', function() {
check(['a', 'b', 'c'], [], ['a', 'b', 'c']);
check(['a', 'b', 'c'], [0], ['b', 'c']);
check(['a', 'b', 'c'], [1], ['a', 'c']);
check(['a', 'b', 'c'], [2], ['a', 'b']);
check(['a', 'b', 'c'], [0, 1], ['c']);
check(['a', 'b', 'c'], [0, 2], ['b']);
check(['a', 'b', 'c'], [1, 2], ['a']);
check(['a', 'b', 'c'], [0, 1, 2], []);
check([], [], []);
check(['a'], [], ['a']);
check(['a'], [0], []);
});
});
});

@ -0,0 +1,169 @@
/* global describe, it */
var assert = require('chai').assert;
var marshal = require('app/common/marshal');
var MemBuffer = require('app/common/MemBuffer');
describe("marshal", function() {
function binStringToArray(binaryString) {
var a = new Uint8Array(binaryString.length);
for (var i = 0; i < binaryString.length; i++) {
a[i] = binaryString.charCodeAt(i);
}
return a;
}
function arrayToBinString(array) {
return String.fromCharCode.apply(String, array);
}
var samples = [
[null, 'N'],
[1, 'i\x01\x00\x00\x00'],
[1000000, 'i@B\x0f\x00'],
[-123456, 'i\xc0\x1d\xfe\xff'],
[1.23, 'g\xae\x47\xe1\x7a\x14\xae\xf3\x3f', 2],
[-625e-4, 'g\x00\x00\x00\x00\x00\x00\xb0\xbf', 2],
[12.34, 'f\x0512.34', 0],
[6.02e23, 'f\x086.02e+23', 0],
[true, 'T'],
[false, 'F'],
[MemBuffer.stringToArray('Hello world'), 's\x0b\x00\x00\x00Hello world'],
['Résumé', 's\x08\x00\x00\x00R\xc3\xa9sum\xc3\xa9'],
[[1, 2, 3],
'[\x03\x00\x00\x00i\x01\x00\x00\x00i\x02\x00\x00\x00i\x03\x00\x00\x00'],
[{'This': 4, 'is': 0, 'a': MemBuffer.stringToArray('test')},
'{s\x04\x00\x00\x00Thisi\x04\x00\x00\x00s\x01\x00\x00\x00as\x04\x00\x00\x00tests\x02\x00\x00\x00isi\x00\x00\x00\x000'],
];
describe('basic data structures', function() {
it("should serialize correctly", function() {
var m0 = new marshal.Marshaller({ stringToBuffer: true, version: 0 });
var m2 = new marshal.Marshaller({ stringToBuffer: true, version: 2 });
for (var i = 0; i < samples.length; i++) {
var value = samples[i][0];
var expected = binStringToArray(samples[i][1]);
var version = samples[i].length === 3 ? samples[i][2] : 0;
var currentMarshaller = version >= 2 ? m2 : m0;
currentMarshaller.marshal(value);
var marshalled = currentMarshaller.dump();
assert.deepEqual(marshalled, expected,
"Wrong serialization of " + JSON.stringify(value) +
"\n actual: " + escape(arrayToBinString(marshalled)) + "\n" +
"\n expected: " + escape(arrayToBinString(expected)));
}
});
it("should deserialize correctly", function() {
var m = new marshal.Unmarshaller();
var values = [];
m.on('value', function(val) { values.push(val); });
for (var i = 0; i < samples.length; i++) {
values.length = 0;
var expected = samples[i][0];
m.push(binStringToArray(samples[i][1]));
assert.strictEqual(values.length, 1);
var value = values[0];
if (typeof expected === 'string') {
// This tests marshals JS strings to Python strings, but unmarshalls to Uint8Arrays. So
// when the source is a string, we need to tweak the returned value for comparison.
value = MemBuffer.arrayToString(value);
}
assert.deepEqual(value, expected);
}
});
it("should support stringToBuffer and bufferToString", function() {
var mY = new marshal.Marshaller({ stringToBuffer: true });
var mN = new marshal.Marshaller({ stringToBuffer: false });
var uY = new marshal.Unmarshaller({ bufferToString: true });
var uN = new marshal.Unmarshaller({ bufferToString: false });
var helloBuf = MemBuffer.stringToArray("hello");
function passThrough(m, u, value) {
var ret = null;
u.on('value', function(v) { ret = v; });
m.marshal(value);
u.push(m.dump());
return ret;
}
// No conversion, no change.
assert.deepEqual(passThrough(mN, uN, "hello"), "hello");
assert.deepEqual(passThrough(mN, uN, helloBuf), helloBuf);
// If convert to strings on the way back, then see all strings.
assert.deepEqual(passThrough(mN, uY, "hello"), "hello");
assert.deepEqual(passThrough(mN, uY, helloBuf), "hello");
// If convert to buffers on the way forward, and no conversion back, then see all buffers.
assert.deepEqual(passThrough(mY, uN, "hello"), helloBuf);
assert.deepEqual(passThrough(mY, uN, helloBuf), helloBuf);
// If convert to buffers on the way forward, and to strings back, then see all strings.
assert.deepEqual(passThrough(mY, uY, "hello"), "hello");
assert.deepEqual(passThrough(mY, uY, helloBuf), "hello");
});
});
function mkbuf(arg) { return new Uint8Array(arg); }
function dumps(codeStr, value) {
var m = new marshal.Marshaller();
m.marshal(marshal.wrap(codeStr, value));
return m.dump();
}
describe('int64', function() {
it("should serialize 32-bit values correctly", function() {
assert.deepEqual(dumps('INT64', 0x7FFFFFFF), mkbuf([73, 255, 255, 255, 127, 0, 0, 0, 0]));
assert.deepEqual(dumps('INT64', -0x80000000), mkbuf([73, 0, 0, 0, 128, 255, 255, 255, 255]));
// TODO: larger values fail now, but of course it's better to fix, and change this test.
assert.throws(function() { dumps('INT64', 0x7FFFFFFF+1); }, /int64/);
assert.throws(function() { dumps('INT64', -0x80000000-1); }, /int64/);
});
it("should deserialize 32-bit values correctly", function() {
assert.strictEqual(marshal.loads([73, 255, 255, 255, 127, 0, 0, 0, 0]), 0x7FFFFFFF);
assert.strictEqual(marshal.loads([73, 0, 0, 0, 128, 255, 255, 255, 255]), -0x80000000);
// Can be verified in Python with: marshal.loads("".join(chr(r) for r in [73, 255, ...]))
assert.strictEqual(marshal.loads([73, 255, 255, 255, 127, 255, 255, 255, 255]), -0x80000001);
assert.strictEqual(marshal.loads([73, 0, 0, 0, 128, 0, 0, 0, 0]), 0x80000000);
// Be sure to test with low and high 32-bit words being positive or negative. Note that
// integers that are too large to be safely represented are currently returned as strings.
assert.strictEqual(marshal.loads([73, 1, 2, 3, 190, 4, 5, 6, 200]), '-4033530898337824255');
assert.strictEqual(marshal.loads([73, 1, 2, 3, 190, 4, 5, 6, 20]), '1442846248544698881');
assert.strictEqual(marshal.loads([73, 1, 2, 3, 90, 4, 5, 6, 200]), '-4033530900015545855');
assert.strictEqual(marshal.loads([73, 1, 2, 3, 90, 4, 5, 6, 20]), '1442846246866977281');
});
});
describe('interned strings', function() {
it("should parse interned strings correctly", function() {
var testData = '{t\x03\x00\x00\x00aaat\x03\x00\x00\x00bbbR\x01\x00\x00\x00R\x00\x00\x00\x000';
assert.deepEqual(marshal.loads(binStringToArray(testData)),
{ 'aaa': MemBuffer.stringToArray('bbb'),
'bbb': MemBuffer.stringToArray('aaa')
});
});
});
describe('longs', function() {
// This is generated as [991**i for i in xrange(10)] + [-678**i for i in xrange(10)].
// Note how overly large values currently get stringified.
const sampleData = [1, 991, 982081, 973242271, 964483090561, 955802742745951,
'947200518061237441', '938675713398686304031', '930227631978098127294721',
'921855583290295244149068511',
-1, -678, -459684, -311665752, -211309379856, -143267759542368, '-97135540969725504',
'-65857896777473891712', '-44651654015127298580736', '-30273821422256308437739008'];
const serialized = "[\x14\x00\x00\x00i\x01\x00\x00\x00i\xdf\x03\x00\x00iA\xfc\x0e\x00i\x9f\x7f\x02:I\x81\x08\xac\x8f\xe0\x00\x00\x00I_\xeb\xf4*Le\x03\x00I\xc1$\x1bJ\xda!%\rl\x05\x00\x00\x00\x1fG&>\x130\xf0\x15.\x03l\x06\x00\x00\x00\x01Q@\x17n\x1b\x84m\xbbO\x18\x00l\x06\x00\x00\x00\xdf\x123\x03\x86/\xd0r4(Q_i\xff\xff\xff\xffiZ\xfd\xff\xffi\\\xfc\xf8\xffi\xa8[l\xedI\xf0\xbe\xfa\xcc\xce\xff\xff\xffI\xa0\xaf\x15\xe0\xb2}\xff\xffI\xc0!oy\xbd\xe7\xa6\xfel\xfb\xff\xff\xff\x80\x1dYG\xc1\x00\xb2\x0f9\x00l\xfa\xff\xff\xff\x00!Rv\x9f\x00p\x11I\x17\x01\x00l\xfa\xff\xff\xff\x00f\xda]\x8c'\xa3.\xb2+!\x03";
it("should deserialize arbitrarily long integers correctly", function() {
assert.deepEqual(marshal.loads(binStringToArray(serialized)), sampleData);
});
});
});

@ -0,0 +1,448 @@
/* global describe, it */
import {guessDateFormat, guessDateFormats, parseDate, parseDateStrict, parseDateTime} from 'app/common/parseDate';
import {assert} from 'chai';
import * as moment from 'moment-timezone';
const today = new Date();
const year = today.getUTCFullYear();
const month = String(today.getUTCMonth() + 1).padStart(2, '0');
/**
* Assert that parseDate and parseDateStrict parse `input` correctly,
* returning a date that looks like expectedDateStr in ISO format.
* parseDate should always produce a parsed date from `input`.
* parseDateStrict should return at most one date, i.e. the formats it tries shouldn't allow ambiguity.
*
* fallback=true indicates the date cannot be parsed strictly with the given format
* so parseDate has to fallback to another format and parseDateStrict gives no results.
*
* Otherwise, parseDateStrict should return a result
* unless no dateFormat is given in which case it may or may not.
*/
function testParse(dateFormat: string|null, input: string, expectedDateStr: string, fallback: boolean = false) {
assertDateEqual(parseDate(input, dateFormat ? {dateFormat} : {}), expectedDateStr);
const strict = new Set<number>();
parseDateStrict(input, dateFormat, strict);
assert.include([0, 1], strict.size);
// fallback=true indicates the date cannot be parsed strictly with the given format
// so it has to fallback to another format.
if (fallback) {
assert.isEmpty(strict);
} else if (dateFormat) {
assert.equal(strict.size, 1);
}
if (strict.size) {
const strictParsed = [...strict][0];
assertDateEqual(strictParsed, expectedDateStr);
assertDateEqual(parseDateTime(input, dateFormat ? {dateFormat} : {})!, expectedDateStr);
}
}
function assertDateEqual(parsed: number|null, expectedDateStr: string) {
const formatted = parsed === null ? null : new Date(parsed * 1000).toISOString().slice(0, 10);
assert.equal(formatted, expectedDateStr);
}
function testTimeParse(input: string, expectedUTCTimeStr: string | null, timezone?: string) {
const parsed1 = parseDateTime('1993-04-02T' + input,
{timeFormat: 'Z', timezone, dateFormat: 'YYYY-MM-DD'}) || null;
const parsed2 = parseDate('1993-04-02', {time: input, timeFormat: 'UNUSED', timezone});
for (const parsed of [parsed1, parsed2]) {
if (expectedUTCTimeStr === null) {
assert.isNull(parsed);
return;
}
const output = new Date(parsed! * 1000).toISOString().slice(11, 19);
assert.equal(output, expectedUTCTimeStr, `testTimeParse(${input}, ${timezone})`);
}
}
function testDateTimeParse(
date: string, time: string, expectedUTCTimeStr: string | null, timezone: string, dateFormat?: string
) {
const parsed1 = parseDateTime(date + ' ' + time,
{timeFormat: 'Z', timezone, dateFormat: dateFormat || 'YYYY-MM-DD'}) || null;
// This is for testing the combination of date and time which is important when daylight savings is involved
const parsed2 = parseDate(date, {time, timeFormat: 'UNUSED', timezone, dateFormat});
for (const parsed of [parsed1, parsed2]) {
if (expectedUTCTimeStr === null) {
assert.isNull(parsed);
return;
}
const output = new Date(parsed! * 1000).toISOString().slice(0, 19).replace("T", " ");
assert.equal(output, expectedUTCTimeStr);
}
}
function testDateTimeStringParse(
dateTime: string, expectedUTCTimeStr: string | null, dateFormat: string, timezone?: string,
) {
const parsed = parseDateTime(dateTime, {timezone, dateFormat});
if (expectedUTCTimeStr === null) {
assert.isUndefined(parsed);
return;
}
const output = new Date(parsed! * 1000).toISOString().slice(0, 19).replace("T", " ");
assert.equal(output, expectedUTCTimeStr);
}
describe('parseDate', function() {
this.timeout(5000);
it('should allow parsing common date formats', function() {
testParse(null, 'November 18th, 1994', '1994-11-18');
testParse(null, 'nov 18 1994', '1994-11-18');
testParse(null, '11-18-94', '1994-11-18');
testParse(null, '11-18-1994', '1994-11-18');
testParse(null, '1994-11-18', '1994-11-18');
testParse(null, 'November 18, 1994', '1994-11-18');
testParse('DD/MM/YY', '18/11/94', '1994-11-18');
// fallback format is used because 18 is not a valid month
testParse('MM/DD/YY', '18/11/94', '1994-11-18', true);
testParse(null, '18/11/94', '1994-11-18');
testParse(null, '12/11/94', '1994-12-11');
testParse('DD/MM/YY', '12/11/94', '1994-11-12');
testParse('MM/DD/YY', '11/12/94', '1994-11-12');
testParse(null, '25', `${year}-${month}-25`);
testParse(null, '10', `${year}-${month}-10`);
testParse('DD/MM/YY', '10', `${year}-${month}-10`);
testParse('DD/MM/YY', '3/4', `${year}-04-03`);
// Separators in the format should not affect the parsing (for better or worse).
testParse('YY-DD/MM', '3/4', `${year}-04-03`);
testParse('YY/DD-MM', '3/4', `${year}-04-03`);
testParse('MM/DD/YY', '3/4', `${year}-03-04`);
testParse('YY/MM/DD', '3/4', `${year}-03-04`);
testParse(null, '3/4', `${year}-03-04`);
// Single number gets parse according to the most specific item in the format string.
testParse('DD', '10', `${year}-${month}-10`);
testParse('DD/MM', '10', `${year}-${month}-10`);
testParse('MM', '10', `${year}-10-01`);
testParse('MM/YY', '10', `${year}-10-01`);
testParse('MMM', '10', `${year}-10-01`);
testParse('YY', '10', `2010-01-01`);
testParse('YYYY', '10', `2010-01-01`);
testParse('YY', '05', `2005-01-01`);
testParse('YY', '5', `${year}-05-01`, true); // Not a valid year, so falls back to "M" format
testParse('YYYY', '1910', `1910-01-01`);
testParse('YY', '3/4', `${year}-03-04`, true); // Falls back to another format
testParse('DD/MM', '3/4', `${year}-04-03`);
testParse('MM/YY', '3/04', `2004-03-01`);
testParse('MM/YY', '3/4', `${year}-03-04`, true); // Not a valid year, so falls back to "M/D" format
testParse(null, '4/2/93', '1993-04-02');
testParse(null, '04-02-1993', '1993-04-02');
testParse(null, '4-02-93', '1993-04-02');
testParse(null, 'April 2nd, 1993', '1993-04-02');
testParse('DD MMM YY', '15-Jan 99', '1999-01-15');
testParse('DD MMM YYYY', '15-Jan 1999', '1999-01-15');
testParse('DD MMM', '15-Jan 1999', '1999-01-15');
testParse('MMMM Do, YYYY', 'April 2nd, 1993', '1993-04-02');
testParse('MMM Do YYYY', 'Apr 2nd 1993', `1993-04-02`);
testParse('Do MMMM YYYY', '2nd April 1993', `1993-04-02`);
testParse('Do MMM YYYY', '2nd Apr 1993', `1993-04-02`);
testParse('MMMM D, YYYY', 'April 2, 1993', '1993-04-02');
testParse('MMM D YYYY', 'Apr 2 1993', `1993-04-02`);
testParse('D MMMM YYYY', '2 April 1993', `1993-04-02`);
testParse('D MMM YYYY', '2 Apr 1993', `1993-04-02`);
testParse('MMMM Do, ', 'April 2nd, 1993', '1993-04-02');
testParse('MMM Do ', 'Apr 2nd 1993', `1993-04-02`);
testParse('Do MMMM ', '2nd April 1993', `1993-04-02`);
testParse('Do MMM ', '2nd Apr 1993', `1993-04-02`);
testParse('MMMM D, ', 'April 2, 1993', '1993-04-02');
testParse('MMM D ', 'Apr 2 1993', `1993-04-02`);
testParse('D MMMM ', '2 April 1993', `1993-04-02`);
testParse('D MMM ', '2 Apr 1993', `1993-04-02`);
testParse('MMMM Do, ', 'April 2nd', `${year}-04-02`);
testParse('MMM Do ', 'Apr 2nd', `${year}-04-02`);
testParse('Do MMMM ', '2nd April', `${year}-04-02`);
testParse('Do MMM ', '2nd Apr', `${year}-04-02`);
testParse('MMMM D, ', 'April 2', `${year}-04-02`);
testParse('MMM D ', 'Apr 2', `${year}-04-02`);
testParse('D MMMM ', '2 April', `${year}-04-02`);
testParse('D MMM ', '2 Apr', `${year}-04-02`);
// Test the combination of Do and YY, which was buggy at one point.
testParse('MMMM Do, YY', 'April 2nd, 93', '1993-04-02');
testParse('MMM Do, YY', 'Apr 2nd, 93', '1993-04-02');
testParse('Do MMMM YY', '2nd April 93', `1993-04-02`);
testParse('Do MMM YY', '2nd Apr 93', `1993-04-02`);
testParse(' D MMM ', ' 2 Apr ', `${year}-04-02`);
testParse('D MMM', ' 2 Apr ', `${year}-04-02`);
testParse(' D MMM ', '2 Apr', `${year}-04-02`);
testParse(null, ' 11-18-94 ', '1994-11-18');
testParse(' DD MM YY', '18/11/94', '1994-11-18');
});
it('should allow parsing common date-time formats', function() {
// These are the test cases from before.
testTimeParse('22:18:04', '22:18:04');
testTimeParse('8pm', '20:00:00');
testTimeParse('22:18:04', '22:18:04', 'UTC');
testTimeParse('22:18:04', '03:18:04', 'America/New_York');
testTimeParse('22:18:04', '06:18:04', 'America/Los_Angeles');
testTimeParse('22:18:04', '13:18:04', 'Japan');
// Weird time formats are no longer parsed
// testTimeParse('HH-mm', '1-15', '01:15:00');
// testTimeParse('ss mm HH', '4 23 3', '03:23:04');
// The current behavior parses any standard-like format (with HH:MM:SS components in the usual
// order) regardless of the format requested.
// Test a few variations of spelling AM/PM.
for (const [am, pm] of [['A', ' p'], [' am', 'pM'], ['AM', ' PM']]) {
testTimeParse('1', '01:00:00');
testTimeParse('1' + am, '01:00:00');
testTimeParse('1' + pm, '13:00:00');
testTimeParse('22', '22:00:00');
testTimeParse('22' + am, '22:00:00'); // Best guess for 22am/22pm is 22:00.
testTimeParse('22' + pm, '22:00:00');
testTimeParse('0', '00:00:00');
testTimeParse('0' + am, '00:00:00');
testTimeParse('0' + pm, '00:00:00');
testTimeParse('12', '12:00:00'); // 12:00 is more likely 12pm than 12am
testTimeParse('12' + am, '00:00:00');
testTimeParse('12' + pm, '12:00:00');
testTimeParse('9:8', '09:08:00');
testTimeParse('9:8' + am, '09:08:00');
testTimeParse('9:8' + pm, '21:08:00');
testTimeParse('09:08', '09:08:00');
testTimeParse('09:08' + am, '09:08:00');
testTimeParse('09:08' + pm, '21:08:00');
testTimeParse('21:59', '21:59:00');
testTimeParse('21:59' + am, '21:59:00');
testTimeParse('21:59' + pm, '21:59:00');
testTimeParse('10:18:04', '10:18:04');
testTimeParse('10:18:04' + am, '10:18:04');
testTimeParse('10:18:04' + pm, '22:18:04');
testTimeParse('22:18:04', '22:18:04');
testTimeParse('22:18:04' + am, '22:18:04');
testTimeParse('22:18:04' + pm, '22:18:04');
testTimeParse('12:18:04', '12:18:04');
testTimeParse('12:18:04' + am, '00:18:04');
testTimeParse('12:18:04' + pm, '12:18:04');
testTimeParse('908', '09:08:00');
testTimeParse('0910', '09:10:00');
testTimeParse('2112', '21:12:00');
}
// Tests with time zones.
testTimeParse('09:08', '09:08:00', 'UTC');
testTimeParse('09:08', '14:08:00', 'America/New_York');
testTimeParse('09:08', '00:08:00', 'Japan');
testTimeParse('09:08 Z', '09:08:00');
testTimeParse('09:08z', '09:08:00');
testTimeParse('09:08 UT', '09:08:00');
testTimeParse('09:08 UTC', '09:08:00');
testTimeParse('09:08-05', '14:08:00');
testTimeParse('09:08-5', '14:08:00');
testTimeParse('09:08-0500', '14:08:00');
testTimeParse('09:08-05:00', '14:08:00');
testTimeParse('09:08-500', '14:08:00');
testTimeParse('09:08-5:00', '14:08:00');
testTimeParse('09:08+05', '04:08:00');
testTimeParse('09:08+5', '04:08:00');
testTimeParse('09:08+0500', '04:08:00');
testTimeParse('09:08+5:00', '04:08:00');
testTimeParse('09:08+05:00', '04:08:00');
});
it('should handle timezone abbreviations', function() {
// New York can be abbreviated as EDT or EST depending on the time of year for daylight savings.
// We ignore the abbreviation so it's parsed the same whichever is used.
// However the parsed UTC time depends on the date.
testDateTimeParse('2020-02-02', '09:45 edt', '2020-02-02 14:45:00', 'America/New_York');
testDateTimeParse('2020-10-10', '09:45 edt', '2020-10-10 13:45:00', 'America/New_York');
testDateTimeParse('2020-02-02', '09:45 est', '2020-02-02 14:45:00', 'America/New_York');
testDateTimeParse('2020-10-10', '09:45 est', '2020-10-10 13:45:00', 'America/New_York');
// Spaces and case shouldn't matter.
testDateTimeParse('2020-10-10', '09:45 EST', '2020-10-10 13:45:00', 'America/New_York');
testDateTimeParse('2020-10-10', '09:45EST', '2020-10-10 13:45:00', 'America/New_York');
testDateTimeParse('2020-10-10', '09:45EDT', '2020-10-10 13:45:00', 'America/New_York');
// Testing that AEDT is rejected in the New York timezone even though it ends with EDT which is valid.
testTimeParse('09:45:00 aedt', null, 'America/New_York');
testTimeParse('09:45:00AEDT', null, 'America/New_York');
testTimeParse('09:45:00 aedt', '23:45:00', 'Australia/ACT');
testTimeParse('09:45:00AEDT', '23:45:00', 'Australia/ACT');
// Testing multiple abbreviations of US/Pacific
testDateTimeParse('2020-02-02', '09:45 PST', null, 'America/New_York');
testDateTimeParse('2020-02-02', '09:45 PST', '2020-02-02 17:45:00', 'US/Pacific');
testDateTimeParse('2020-10-10', '09:45 PST', '2020-10-10 16:45:00', 'US/Pacific');
testDateTimeParse('2020-02-02', '09:45 PDT', '2020-02-02 17:45:00', 'US/Pacific');
testDateTimeParse('2020-10-10', '09:45 PDT', '2020-10-10 16:45:00', 'US/Pacific');
// PWT and PPT are some obscure abbreviations apparently used at some time and thus supported by moment
testDateTimeParse('2020-10-10', '09:45 PWT', '2020-10-10 16:45:00', 'US/Pacific');
testDateTimeParse('2020-10-10', '09:45 PPT', '2020-10-10 16:45:00', 'US/Pacific');
// POT is not valid
testDateTimeParse('2020-10-10', '09:45 POT', null, 'US/Pacific');
// Both these timezones have CST and CDT, but not COT.
// The timezones are far apart so the parsed UTC times are too.
testTimeParse('09:45 CST', '01:45:00', 'Asia/Shanghai');
testTimeParse('09:45 CDT', '01:45:00', 'Asia/Shanghai');
testTimeParse('09:45 CST', '15:45:00', 'Canada/Central');
testTimeParse('09:45 CDT', '15:45:00', 'Canada/Central');
testTimeParse('09:45 COT', null, 'Asia/Shanghai');
testTimeParse('09:45 COT', null, 'Canada/Central');
});
it('should parse datetime strings', function() {
for (const separator of [' ', 'T']) {
for (let tz of ['Z', 'UTC', '+00:00', '-00', '']) {
for (const tzSeparator of ['', ' ']) {
tz = tzSeparator + tz;
let expected = '2020-03-04 12:34:56';
testDateTimeStringParse(
` 2020-03-04${separator}12:34:56${tz} `, expected, 'YYYY-MM-DD'
);
testDateTimeStringParse(
` 03-04-2020${separator}12:34:56${tz} `, expected, 'MM/DD/YYYY'
);
testDateTimeStringParse(
` 04-03-20${separator}12:34:56${tz} `, expected, 'DD-MM-YY'
);
testDateTimeStringParse(
` 2020-03-04${separator}12:34:56${tz} `, expected, '',
);
expected = '2020-03-04 12:34:00';
testDateTimeStringParse(
` 04-03-20${separator}12:34${tz} `, expected, 'DD-MM-YY'
);
}
}
}
});
it('should handle datetimes as formatted by moment', function() {
for (const date of ['2020-02-03', '2020-06-07', '2020-10-11']) { // different months for daylight savings
const dateTime = date + ' 12:34:56';
const utcMoment = moment.tz(dateTime, 'UTC');
for (const dateFormat of ['DD/MM/YY', 'MM/DD/YY']) {
for (const tzFormat of ['z', 'Z']) { // abbreviation (z) vs +/-HH:MM (Z)
assert.isTrue(utcMoment.isValid());
for (const tzName of moment.tz.names()) {
const tzMoment = moment.tz(utcMoment, tzName);
const formattedTime = tzMoment.format('HH:mm:ss ' + tzFormat);
const formattedDate = tzMoment.format(dateFormat);
testDateTimeParse(formattedDate, formattedTime, dateTime, tzName, dateFormat);
}
}
}
}
});
it('should be flexible in parsing the preferred format', function() {
for (const format of ['DD-MM-YYYY', 'DD-MM-YY', 'DD-MMM-YYYY', 'DD-MMM-YY']) {
testParse(format, '1/2/21', '2021-02-01');
testParse(format, '01/02/2021', '2021-02-01');
testParse(format, '1-02-21', '2021-02-01');
}
for (const format of ['MM-DD-YYYY', 'MM-DD-YY', 'MMM-DD-YYYY', 'MMM-DD-YY']) {
testParse(format, '1/2/21', '2021-01-02');
testParse(format, '01/02/2021', '2021-01-02');
testParse(format, '1-02-21', '2021-01-02');
}
for (const format of ['YY-MM-DD', 'YYYY-MM-DD', 'YY-MMM-DD', 'YYYY-MMM-DD']) {
testParse(format, '01/2/3', '2001-02-03');
testParse(format, '2001/02/03', '2001-02-03');
testParse(format, '01-02-03', '2001-02-03');
testParse(format, '10/11', `${year}-10-11`);
testParse(format, '2/3', `${year}-02-03`);
testParse(format, '12', `${year}-${month}-12`);
}
testParse('DD MMM YYYY', '1 FEB 2021', '2021-02-01');
testParse('DD MMM YYYY', '1-feb-21', '2021-02-01');
testParse('DD MMM YYYY', '1/2/21', '2021-02-01');
testParse('DD MMM YYYY', '01/02/2021', '2021-02-01');
testParse('DD MMM YYYY', '1-02-21', '2021-02-01');
testParse('DD MMM YYYY', '1 2', `${year}-02-01`);
testParse('DD MMM YYYY', '1 feb', `${year}-02-01`);
testParse('DD MMM', '1 FEB 2021', '2021-02-01');
testParse('DD MMM', '1-feb-2021', '2021-02-01');
testParse('DD MMM', '1/2/2021', '2021-02-01');
testParse('DD MMM', '01/02/2021', '2021-02-01');
testParse('DD MMM', '1-02-2021', '2021-02-01');
testParse('DD MMM', '1 2 2021', `2021-02-01`);
testParse('DD MMM', '1 feb 2021', `2021-02-01`);
});
it('should support underscores as separators', async function() {
testParse('DD_MM_YY', '3/4', `${year}-04-03`);
testParse('DD_MM_YY', '3_4', `${year}-04-03`);
testParse('DD_MM_YY', '3_4_98', `1998-04-03`);
testParse('DD/MM/YY', '3_4_98', `1998-04-03`);
});
it('should interpret two-digit years as bootstrap datepicker does', function() {
const yy = year % 100;
// These checks are expected to work as long as today's year is between 2021 and 2088.
testParse('MM-DD-YY', `1/2/${yy}`, `20${yy}-01-02`);
testParse('MM-DD-YY', `1/2/${yy + 9}`, `20${yy + 9}-01-02`);
testParse('MM-DD-YY', `1/2/${yy + 11}`, `19${yy + 11}-01-02`);
// These should work until 2045 (after that 55 would be interpreted as 2055).
testParse('MM-DD-YY', `1/2/00`, `2000-01-02`);
testParse('MM-DD-YY', `1/2/08`, `2008-01-02`);
testParse('MM-DD-YY', `1/2/20`, `2020-01-02`);
testParse('MM-DD-YY', `1/2/30`, `2030-01-02`);
testParse('MM-DD-YY', `1/2/55`, `1955-01-02`);
testParse('MM-DD-YY', `1/2/79`, `1979-01-02`);
testParse('MM-DD-YY', `1/2/98`, `1998-01-02`);
});
describe('guessDateFormat', function() {
it('should guess date formats', function() {
// guessDateFormats with an *s* shows all the equally likely guesses.
// It's only directly used in tests, just to reveal the inner workings.
// guessDateFormat picks one of those formats which is actually used in type conversion etc.
// ISO YYYY-MM-DD is king
assert.deepEqual(guessDateFormats(["2020-01-02"]), ["YYYY-MM-DD"]);
assert.deepEqual(guessDateFormat(["2020-01-02"]), "YYYY-MM-DD");
// Some ambiguous dates
assert.deepEqual(guessDateFormats(["01/01/2020"]), ["DD/MM/YYYY", "MM/DD/YYYY"]);
assert.deepEqual(guessDateFormats(["01/02/03"]), ['DD/MM/YY', 'MM/DD/YY', 'YY/MM/DD']);
assert.deepEqual(guessDateFormats(["01-01-2020"]), ["DD-MM-YYYY", "MM-DD-YYYY"]);
assert.deepEqual(guessDateFormats(["01-02-03"]), ['DD-MM-YY', 'MM-DD-YY', 'YY-MM-DD']);
assert.deepEqual(guessDateFormat(["01/01/2020"]), "MM/DD/YYYY");
assert.deepEqual(guessDateFormat(["01/02/03"]), 'YY/MM/DD');
assert.deepEqual(guessDateFormat(["01-01-2020"]), "MM-DD-YYYY");
assert.deepEqual(guessDateFormat(["01-02-03"]), 'YY-MM-DD');
// Ambiguous date with only two parts
assert.deepEqual(guessDateFormats(["01/02"]), ["DD/MM", "MM/DD", "YY/MM"]);
assert.deepEqual(guessDateFormat(["01/02"]), "YY/MM");
// First date is ambiguous, second date makes the guess unambiguous.
assert.deepEqual(guessDateFormats(["01/01/2020", "20/01/2020"]), ["DD/MM/YYYY"]);
assert.deepEqual(guessDateFormats(["01/01/2020", "01/20/2020"]), ["MM/DD/YYYY"]);
assert.deepEqual(guessDateFormat(["01/01/2020", "20/01/2020"]), "DD/MM/YYYY");
assert.deepEqual(guessDateFormat(["01/01/2020", "01/20/2020"]), "MM/DD/YYYY");
// Not a date at all, guess YYYY-MM-DD as the default.
assert.deepEqual(guessDateFormats(["foo bar"]), null);
assert.deepEqual(guessDateFormat(["foo bar"]), "YYYY-MM-DD");
});
});
});

@ -0,0 +1,220 @@
/**
* Do some timing of promises, as well as of nextTick and setTimeout, so that we have an idea of
* how long different things take.
*
* To see actual timings, comment out the console.log inside the `log` function below.
*/
/* global describe, it, before */
var assert = require('chai').assert;
var bluebird = require('bluebird');
// Disable longStackTraces, which seem to be enabled in the browser by default.
bluebird.config({ longStackTraces: false });
function log(message) {
//console.log(message);
}
/**
* Measurement helpers. Usage:
* var start = startTimer();
* ...
* var usec = usecElapsed(start); // Returns microseconds.
*/
var startTimer, usecElapsed;
if (typeof process !== 'undefined' && typeof process.hrtime !== 'undefined') {
startTimer = function() {
return process.hrtime();
};
usecElapsed = function(start) {
var elapsed = process.hrtime(start);
return elapsed[0] * 1000000 + elapsed[1] / 1000;
};
} else {
startTimer = function() {
return Date.now();
};
usecElapsed = function(start) {
var elapsedMs = (Date.now() - start);
return elapsedMs * 1000;
};
}
/**
* Helper to run timing tests. Adds a test case to run the given function, possibly multiple
* times, and check the timing value that it returns.
*
* Example:
* describe("myClass", function() {
* timeIt("myFunc", { reps: 3, expectedUs: 100, fudgeFactor: 4}, myFunc);
* });
* Produces:
* myFunc should take ~100us (up to x4) [got 123us]: 316ms
* Notes:
* - The number at the end isn't very meaningful (includes repetitions and measurements).
* - Fudge factors should be pretty large, since tests often take shorter or longer depending
* on platform, system load, etc.
*
* @param {Number} options.reps - Run the test this many times and check the min value.
* @param {Number} options.expectedUs - Expected number of microseconds to receive from func.
* @param {Number} options.fudgeFactor - It's fine if the test takes this factor longer or shorter.
* @param {Number} options.noLowerBound - don't test for being too fast.
* @param {Function} func - Will call func(reportUs), where reportUs is a function that should be
* called with the test measurement when func is done.
* @return {Function} Function that takes a `done` callback and calls it when all is done.
*/
function timeIt(name, options, func) {
var reps = options.reps || 1;
var fudgeFactor = options.fudgeFactor || 1;
var expectedUs = options.expectedUs;
var noLowerBound = options.noLowerBound;
var test = it(name + " should take ~" + expectedUs + "us (up to x" + fudgeFactor + ")",
function(done) {
var n = 0;
var minTimeUs = Infinity;
function iteration(timeUs) {
try {
minTimeUs = Math.min(minTimeUs, timeUs);
if (n++ < reps) {
func(next);
return;
}
log("Ran test " + n + " times, min time " + minTimeUs);
assert(minTimeUs <= expectedUs * fudgeFactor,
"Time of " + minTimeUs + "us is longer than expected (" + expectedUs + ") " +
"by more than fudge factor of " + fudgeFactor);
if (!noLowerBound) {
assert(minTimeUs >= expectedUs / fudgeFactor,
"Time of " + minTimeUs + "us is shorter than expected (" + expectedUs + ") " +
"by more than fudge factor of " + fudgeFactor);
}
tackOnMeasuredTime(test, minTimeUs);
done();
} catch (err) {
tackOnMeasuredTime(test, minTimeUs);
done(err);
}
}
function next(timeUs) {
setTimeout(iteration, 0, timeUs);
}
next(Infinity);
});
}
function tackOnMeasuredTime(test, timeUs) {
// Output the measured time as 123.1, or 0.0005 when small
var str = timeUs > 10 ? timeUs.toFixed(0) : timeUs.toPrecision(2);
test.title = test.title.replace(/( \[got [^]]*us\])?$/, " [got " + str + "us]");
}
describe("promises", function() {
// These are normally skipped. They are not really tests of our code, but timings to help
// understand how long different things take. Because of global state affecting tests (e.g.
// longStackTraces setting, async_hooks affecting timings), it doesn't work well to run these as
// part of the full test suite. Instead, they can be run manually using
//
// ENABLE_TIMING_TESTS=1 bin/mocha test/common/promises.ts
//
// (Note that things in mocha.opts, such as report-why-tests-hang, affect them and may need to
// be commented out to see accurate timings.)
//
before(function() {
if (!process.env.ENABLE_TIMING_TESTS) {
this.skip();
}
});
function test(arg) {
return arg + 2;
}
timeIt("simple calls", { reps: 3, expectedUs: 0.005, fudgeFactor: 10, noLowerBound: true },
function(reportUs) {
var iterations = 10000000;
var start = startTimer();
var value = 0;
for (var i = 0; i < iterations; i++) {
value = test(value);
}
var us = usecElapsed(start) / iterations;
assert.equal(value, iterations * 2);
log("Direct calls took " + us + " us / iteration");
reportUs(us);
});
function testPromiseLib(promiseLib, libName, setupFunc, timingOptions) {
var iterations = timingOptions.iters;
timeIt(libName + " chain", timingOptions, function(reportUs) {
setupFunc();
var start = startTimer();
var chain = promiseLib.resolve(0);
for (var i = 0; i < iterations; i++) {
chain = chain.then(test);
}
var chainDone = false;
chain.then(function(value) {
var us = usecElapsed(start) / iterations;
chainDone = true;
assert.equal(value, iterations * 2);
log(libName + " promise chain took " + us + " us / iteration");
reportUs(us);
});
assert.equal(chainDone, false);
});
}
// Measure bluebird with and without longStackSupport. If switching promise libraries, we could
// add similar timings here to compare performance. E.g. Q is nearly two orders of magnitude
// slower than bluebird.
var isNode = Boolean(process.version);
testPromiseLib(bluebird, 'bluebird (no long traces)',
// Sadly, no way to turn off bluebird.longStackTraces, so just do this test first.
function() {
assert.isFalse(bluebird.hasLongStackTraces(), "longStackTraces should be off");
},
{ iters: 20000, reps: 3, expectedUs: isNode ? 0.3 : 1, fudgeFactor: 8});
// TODO: with bluebird 3, we can no longer switch between having and not having longStackTraces.
// We'd have to measure it in two different test runs. For now, can run this test with
// BLUEBIRD_DEBUG=1 environment variable.
//testPromiseLib(bluebird, 'bluebird (with long traces)',
// function() { bluebird.longStackTraces(); },
// { iters: 20000, reps: 3, expectedUs: isNode ? 0.3 : 1, fudgeFactor: 8});
function testRepeater(repeaterFunc, name, timingOptions) {
var iterations = timingOptions.iters;
timeIt("timing of " + name, timingOptions, function(reportUs) {
var count = 0;
function step() {
if (count < iterations) {
repeaterFunc(step);
count++;
} else {
var us = usecElapsed(start) / iterations;
assert.equal(count, iterations);
log(name + " took " + us + " us / iteration (" + iterations + " iterations)");
reportUs(us);
}
}
var start = startTimer();
step();
});
}
if (process.maxTickDepth) {
// Probably running under Node
testRepeater(process.nextTick, "process.nextTick",
{ iters: process.maxTickDepth*9/10, reps: 20, expectedUs: 0.1, fudgeFactor: 4 });
}
if (typeof setImmediate !== 'undefined') {
testRepeater(setImmediate, "setImmediate",
{ iters: 100, reps: 10, expectedUs: 2.0, fudgeFactor: 4 });
}
});

@ -0,0 +1,53 @@
import * as roles from 'app/common/roles';
import {assert} from 'chai';
describe('roles', function() {
describe('getStrongestRole', function() {
it('should return the strongest role', function() {
assert.equal(roles.getStrongestRole(roles.OWNER, roles.EDITOR), roles.OWNER);
assert.equal(roles.getStrongestRole(roles.OWNER, roles.VIEWER, null), roles.OWNER);
assert.equal(roles.getStrongestRole(roles.EDITOR, roles.VIEWER), roles.EDITOR);
assert.equal(roles.getStrongestRole(roles.VIEWER), roles.VIEWER);
assert.equal(roles.getStrongestRole(roles.VIEWER, roles.GUEST), roles.VIEWER);
assert.equal(roles.getStrongestRole(roles.OWNER, roles.GUEST), roles.OWNER);
assert.equal(roles.getStrongestRole(null, roles.GUEST), roles.GUEST);
assert.equal(roles.getStrongestRole(null, roles.EDITOR), roles.EDITOR);
assert.equal(roles.getStrongestRole(roles.EDITOR, roles.EDITOR, roles.EDITOR), roles.EDITOR);
assert.equal(roles.getStrongestRole(roles.EDITOR, roles.OWNER, roles.EDITOR), roles.OWNER);
assert.equal(roles.getStrongestRole(null, null, roles.EDITOR, roles.VIEWER, roles.EDITOR), roles.EDITOR);
assert.equal(roles.getStrongestRole(null, null, null), null);
assert.throws(() => roles.getStrongestRole(undefined as any, roles.EDITOR), /Invalid role undefined/);
assert.throws(() => roles.getStrongestRole(undefined as any, null), /Invalid role undefined/);
assert.throws(() => roles.getStrongestRole(undefined as any, undefined), /Invalid role undefined/);
assert.throws(() => roles.getStrongestRole('XXX' as any, roles.EDITOR), /Invalid role XXX/);
assert.throws(() => roles.getStrongestRole('XXX' as any, null), /Invalid role XXX/);
assert.throws(() => roles.getStrongestRole('XXX' as any, 'YYY'), /Invalid role XXX/);
assert.throws(() => roles.getStrongestRole(), /No roles given/);
});
});
describe('getWeakestRole', function() {
it('should return the weakest role', function() {
assert.equal(roles.getWeakestRole(roles.OWNER, roles.EDITOR), roles.EDITOR);
assert.equal(roles.getWeakestRole(roles.OWNER, roles.VIEWER, null), null);
assert.equal(roles.getWeakestRole(roles.EDITOR, roles.VIEWER), roles.VIEWER);
assert.equal(roles.getWeakestRole(roles.VIEWER), roles.VIEWER);
assert.equal(roles.getWeakestRole(roles.VIEWER, roles.GUEST), roles.GUEST);
assert.equal(roles.getWeakestRole(roles.OWNER, roles.GUEST), roles.GUEST);
assert.equal(roles.getWeakestRole(null, roles.EDITOR), null);
assert.equal(roles.getWeakestRole(roles.EDITOR, roles.EDITOR, roles.EDITOR), roles.EDITOR);
assert.equal(roles.getWeakestRole(roles.EDITOR, roles.OWNER, roles.EDITOR), roles.EDITOR);
assert.equal(roles.getWeakestRole(null, null, roles.EDITOR, roles.VIEWER, roles.EDITOR), null);
assert.equal(roles.getWeakestRole(roles.OWNER, roles.OWNER), roles.OWNER);
assert.throws(() => roles.getWeakestRole(undefined as any, roles.EDITOR), /Invalid role undefined/);
assert.throws(() => roles.getWeakestRole(undefined as any, null), /Invalid role undefined/);
assert.throws(() => roles.getWeakestRole(undefined as any, undefined), /Invalid role undefined/);
assert.throws(() => roles.getWeakestRole('XXX' as any, roles.EDITOR), /Invalid role XXX/);
assert.throws(() => roles.getWeakestRole('XXX' as any, null), /Invalid role XXX/);
assert.throws(() => roles.getWeakestRole('XXX' as any, 'YYY'), /Invalid role XXX/);
assert.throws(() => roles.getWeakestRole(), /No roles given/);
});
});
});

@ -0,0 +1,121 @@
/* global describe, it, before, after */
var _ = require('underscore');
var assert = require('assert');
var Chance = require('chance');
var utils = require('../utils');
var marshal = require('app/common/marshal');
/**
* This test measures the complete encoding/decoding time of several ways to serialize an array of
* data. This is intended both to choose a good serialization format, and to optimize its
* implementation. This test is supposed to work both in Node and in browsers.
*/
describe("Serialization", function() {
function marshalV0(data) {
var m = new marshal.Marshaller({stringToBuffer: true, version: 0});
m.marshal(data);
return m.dump();
}
function marshalV2(data) {
var m = new marshal.Marshaller({stringToBuffer: true, version: 2});
m.marshal(data);
return m.dump();
}
function unmarshal(buffer) {
var m = new marshal.Unmarshaller({bufferToString: true});
var value;
m.on('value', function(v) { value = v; });
m.push(buffer);
m.removeAllListeners();
return value;
}
var encoders = {
"marshal_v0": {enc: marshalV0, dec: unmarshal},
"marshal_v2": {enc: marshalV2, dec: unmarshal},
"json": {enc: JSON.stringify, dec: JSON.parse},
};
describe("correctness", function() {
var data;
before(function() {
// Generate an array of random data using the Chance module
var chance = new Chance(1274323391); // seed is arbitrary
data = {
'floats1k': chance.n(chance.floating, 1000),
'strings1k': chance.n(chance.string, 1000),
};
});
_.each(encoders, function(encoder, name) {
it(name, function() {
assert.deepEqual(encoder.dec(encoder.enc(data.floats1k)), data.floats1k);
assert.deepEqual(encoder.dec(encoder.enc(data.strings1k)), data.strings1k);
});
});
});
utils.timing.describe("timings", function() {
var data, encoded = {}, results = {};
before(function() {
this.timeout(10000);
// Generate an array of random data using the Chance module
var chance = new Chance(1274323391); // seed is arbitrary
data = {
'floats100k': chance.n(chance.floating, 100000),
'strings100k': chance.n(chance.string, 100000),
};
// And prepare an encoded version for each encoder so that we can time decoding.
_.each(data, function(values, key) {
_.each(encoders, function(encoder, name) {
encoded[key + ":" + name] = encoder.enc(values);
});
});
});
function test_encode(name, key, expectedMs) {
utils.timing.it(expectedMs, "encodes " + key + " with " + name, function() {
utils.repeat(5, encoders[name].enc, data[key]);
});
}
function test_decode(name, key, expectedMs) {
utils.timing.it(expectedMs, "decodes " + key + " with " + name, function() {
var ret = utils.repeat(5, encoders[name].dec, encoded[key + ":" + name]);
results[key + ":" + name] = ret;
});
}
after(function() {
// Verify the results of decoding tests outside the timed test case.
_.each(results, function(result, keyName) {
var key = keyName.split(":")[0];
assert.deepEqual(result, data[key], "wrong result decoding " + keyName);
});
});
// Note that these tests take quite a bit longer when running ALL tests than when running them
// separately, so the expected times are artificially inflated below to let them pass. This
// may be because memory allocation is slower due to memory fragmentation. Just running gc()
// before the tests doesn't remove the discrepancy.
// Also note that the expected time needs to be high enough for both node and browser.
test_encode('marshal_v0', 'floats100k', 1600);
test_decode('marshal_v0', 'floats100k', 600);
test_encode('marshal_v0', 'strings100k', 1000);
test_decode('marshal_v0', 'strings100k', 800);
test_encode('marshal_v2', 'floats100k', 160);
test_decode('marshal_v2', 'floats100k', 160);
test_encode('marshal_v2', 'strings100k', 1000);
test_decode('marshal_v2', 'strings100k', 800);
test_encode('json', 'floats100k', 120);
test_decode('json', 'floats100k', 120);
test_encode('json', 'strings100k', 80);
test_decode('json', 'strings100k', 80);
});
});

@ -0,0 +1,168 @@
/* global describe, it */
var assert = require('assert');
var gutil = require('app/common/gutil');
var _ = require('underscore');
var utils = require('../utils');
// Uncomment to see logs
function log(messages) {
//console.log.apply(console, messages);
}
/**
* Compares performance of underscore.sortedIndex and gutil.sortedIndex on ranges of the
* given array.
* @param {array} arr - array to call sortedIndex on
* @param {function} keyFunc - a sort key function used to sort the array
* @param {function} cmp - a compare function used to sort the array
* @param {object} object - object of settings for utils.time
* @param {string} msg - helpful message to display with time results
**/
function benchmarkSortedIndex(arr, keyFunc, cmp, options, msg) {
var t1, t2;
var currArray = [], currSearchElems = [];
var sortedArr = _.sortBy(arr, keyFunc);
var compareFunc = gutil.multiCompareFunc([keyFunc], [cmp], [true]);
function testUnderscore(arr, searchElems) {
searchElems.forEach(function(i) { _.sortedIndex(arr, i, keyFunc); });
}
function testGutil(arr, searchElems) {
searchElems.forEach(function(i) { gutil.sortedIndex(arr, i, compareFunc); });
}
// TODO: Write a library function that does this for loop stuff b/c its largely the same
// across the 3 benchmark functions. This is kind of messy to abstract b/c of issues
// with array sorting side effects and function context.
for(var p = 1; 2 * currArray.length <= arr.length; p++) {
log(['==========================================================']);
currArray = sortedArr.slice(0, Math.pow(2, p));
currSearchElems = arr.slice(0, Math.pow(2, p));
log(['Calling sortedIndex', currArray.length, 'times averaged over', options.iters,
'iterations |', msg]);
t1 = utils.time(testUnderscore, null, [currArray, currSearchElems], options);
t2 = utils.time(testGutil, null, [currArray, currSearchElems], options);
log(["Underscore.sortedIndex:", t1, 'ms.', 'Avg time per call:', t1/currArray.length]);
log(["gutil.sortedIndex :", t2, 'ms.', 'Avg time per call:', t2/currArray.length]);
}
}
/**
* Compares performance of sorting using 1-key, 2-key, ... (keys.length)-key comparison
* functions on ranges of the given array.
* @param {array} arr - array to sort
* @param {function array} keys - array of sort key functions
* @param {function array} cmps - array of compare functions parallel to keys
* @param {boolean array} asc - array of booleans denoting asc/descending. This is largely
irrelevant to performance
* @param {object} object - object of settings for utils.time
* @param {string} msg - helpful message to display with time results
**/
function benchmarkMultiCompareSort(arr, keys, cmps, asc, options, msg) {
var elapsed;
var compareFuncs = [], currArray = [];
for(var l = 0; l < keys.length; l++) {
compareFuncs.push(gutil.multiCompareFunc(keys.slice(0, l+1), cmps.slice(0, l+1), asc.slice(0, l+1)));
}
for(var p = 1; 2 * currArray.length <= arr.length; p++) {
currArray = arr.slice(0, Math.pow(2, p));
log(['==========================================================']);
log(['Sorting', currArray.length, 'elements averaged over', options.iters,
'iterations |', msg]);
for(var i = 0; i < compareFuncs.length; i++) {
elapsed = utils.time(Array.prototype.sort, currArray, [compareFuncs[i]], options);
log([(i+1) + "-key compare sort took: ", elapsed, 'ms']);
}
}
}
/**
* Compares performance of Array.sort, Array.sort with a gutilMultiCompareFunc(on 1-key), and
* Underscore's sort function on ranges of the given array.
* @param {array} arr - array to sort
* @param {function} compareKey - compare function to use for sorting
* @param {function} keyFunc - key function used to construct a compare function for sorting with
Array.sort
* @param {object} object - object of settings for utils.time
* @param {string} msg - helpful message to display with time results
**/
function benchmarkNormalSort(arr, compareFunc, keyFunc, options, msg) {
var t1, t2, t3;
var currArray = [];
var gutilCompare = gutil.multiCompareFunc([keyFunc], [compareFunc], [true]);
for (var p = 1; 2 * currArray.length <= arr.length; p++) {
log(['==========================================================']);
currArray = arr.slice(0, Math.pow(2, p));
log(['Sorting', currArray.length, 'elements averaged over', options.iters,
'iterations |', msg]);
t1 = utils.time(Array.prototype.sort, currArray, [compareFunc], options);
t2 = utils.time(Array.prototype.sort, currArray, [gutilCompare], options);
t3 = utils.time(_.sortBy, null, [currArray, keyFunc], options);
log(['Array.sort with compare func :', t1]);
log(['Array.sort with constructed multicompare func:', t2]);
log(['Underscore sort :', t3]);
}
}
describe('Performance tests', function() {
var maxPower = 10; // tweak as needed
var options = {'iters': 10, 'avg': true};
var timeout = 5000000; // arbitrary
var length = Math.pow(2, maxPower);
// sample data to do our sorting on. generating these random lists can take a while...
var nums = utils.genItems('floating', length, {min:0, max:length});
var people = utils.genPeople(length);
var strings = utils.genItems('string', length, {length:10});
describe('Benchmark test for gutil.sortedIndex', function() {
it('should be close to underscore.sortedIndex\'s performance', function() {
this.timeout(timeout);
benchmarkSortedIndex(nums, _.identity, gutil.nativeCompare, options,
'Sorted index benchmark on numbers');
benchmarkSortedIndex(strings, _.identity, gutil.nativeCompare, options,
'Sorted index benchmark on strings');
assert(true);
});
});
describe('Benchmarks for various sorting', function() {
var peopleKeys = [_.property('last'), _.property('first'), _.property('age'),
_.property('year'), _.property('month'), _.property('day')];
var cmp1 = [gutil.nativeCompare, gutil.nativeCompare, gutil.nativeCompare, gutil.nativeCompare,
gutil.nativeCompare, gutil.nativeCompare];
var stringKeys = [_.identity, function (x) { return x.length; },
function (x) { return x[0]; } ];
var cmp2 = [gutil.nativeCompare, gutil.nativeCompare, gutil.nativeCompare];
var numKeys = [_.identity, utils.mod(2), utils.mod(3), utils.mod(5)];
var cmp3 = numKeys.map(function() { return gutil.nativeCompare; });
var asc = [1, 1, -1, 1, 1]; // bools for ascending/descending in multicompare
it('should be close to _.sortBy with only 1 compare key', function() {
this.timeout(timeout);
benchmarkNormalSort(strings, gutil.nativeCompare, _.identity, options,
'Regular sort test on string array');
benchmarkNormalSort(people, function(a, b) { return a.age - b.age; }, _.property('age'),
options, 'Regular sort test on people array using age as sort key');
benchmarkNormalSort(nums, gutil.nativeCompare, _.identity, options,
'Regular sort test on number array');
assert(true);
});
it('should have consistent performance when no tie breakers are needed', function() {
this.timeout(timeout);
benchmarkMultiCompareSort(strings, stringKeys, cmp2, asc, options, 'Consistency test on string array');
benchmarkMultiCompareSort(nums, numKeys, cmp3, asc, options, 'Consistency test on number array');
assert(true);
});
it('should scale linearly in the number of compare keys used', function() {
this.timeout(timeout);
benchmarkMultiCompareSort(people, peopleKeys, cmp1, asc, options, 'Linear scaling test on people array');
assert(true);
});
});
});

@ -0,0 +1,23 @@
/* global describe, it */
var assert = require('assert');
var {timeFormat} = require('app/common/timeFormat');
describe('timeFormat', function() {
var date = new Date(2014, 3, 4, 22, 28, 16, 123);
it("should format date", function() {
assert.equal(timeFormat("Y", date), "20140404");
assert.equal(timeFormat("D", date), "2014-04-04");
});
it("should format time", function() {
assert.equal(timeFormat("T", date), "22:28:16");
assert.equal(timeFormat("T + M", date), "22:28:16 + 123");
});
it("should format date and time", function() {
assert.equal(timeFormat("A", date), "2014-04-04 22:28:16.123");
});
});

@ -0,0 +1,97 @@
import {assert} from 'chai';
import {tsvDecode, tsvEncode} from 'app/common/tsvFormat';
const sampleData = [
['plain value', 'plain value'],
['quotes "inside" hello', 'quotes "inside" hello'],
['"half" quotes', '"half" quotes'],
['half "quotes"', 'half "quotes"'],
['"full quotes"', '"full quotes"'],
['"extra" "quotes"', '"extra" "quotes"'],
['"has" ""double"" quotes"', '"has" ""double"" quotes"'],
['"more ""double""', '"more ""double""'],
['tab\tinside', 'tab\tinside'],
['\ttab first', '\ttab first'],
['tab last\t', 'tab last\t'],
[' space first', ' space first'],
['space last ', 'space last '],
['\nnewline first', '\nnewline first'],
['newline last\n', 'newline last\n'],
['newline\ninside', 'newline\ninside'],
['"tab\tinside quotes outside"', '"tab\tinside quotes outside"'],
['"tab"\tbetween "quoted"', '"tab"\tbetween "quoted"'],
['"newline\ninside quotes outside"', '"newline\ninside quotes outside"'],
['"newline"\nbetween "quoted"', '"newline"\nbetween "quoted"'],
['"', '"'],
['""', '""'],
// A few special characters on their own that should work correctly.
['', ' ', '\t', '\n', "'", "\\"],
// Some non-string values
[0, 1, false, true, undefined, null, Number.NaN],
];
// This is the encoding produced by Excel (latest version on Mac as of March 2017).
const sampleEncoded = `plain value\tplain value
quotes "inside" hello\tquotes "inside" hello
"half" quotes\t"half" quotes
half "quotes"\thalf "quotes"
"full quotes"\t"full quotes"
"extra" "quotes"\t"extra" "quotes"
"has" ""double"" quotes"\t"has" ""double"" quotes"
"more ""double""\t"more ""double""
"tab\tinside"\t"tab\tinside"
"\ttab first"\t"\ttab first"
"tab last\t"\t"tab last\t"
space first\t space first
space last \tspace last ` /* the trailing space is intentional */ + `
"\nnewline first"\t"\nnewline first"
"newline last\n"\t"newline last\n"
"newline\ninside"\t"newline\ninside"
"""tab\tinside quotes outside"""\t"""tab\tinside quotes outside"""
"""tab""\tbetween ""quoted"""\t"""tab""\tbetween ""quoted"""
"""newline\ninside quotes outside"""\t"""newline\ninside quotes outside"""
"""newline""\nbetween ""quoted"""\t"""newline""\nbetween ""quoted"""
"\t"
""\t""
\t \t"\t"\t"\n"\t'\t\\
0\t1\tfalse\ttrue\t\t\tNaN`;
const sampleDecoded = [
['plain value', 'plain value'],
['quotes "inside" hello', 'quotes "inside" hello'],
['half quotes', 'half quotes'], // not what was encoded, but matches Excel
['half "quotes"', 'half "quotes"'],
['full quotes', 'full quotes'], // not what was encoded, but matches Excel
['extra "quotes"', 'extra "quotes"'], // not what was encoded, but matches Excel
['has ""double"" quotes"', 'has ""double"" quotes"'], // not what was encoded, but matches Excel
['more "double"\tmore ""double""'], // not what was encoded, but matches Excel
['tab\tinside', 'tab\tinside'],
['\ttab first', '\ttab first'],
['tab last\t', 'tab last\t'],
[' space first', ' space first'],
['space last ', 'space last '],
['\nnewline first', '\nnewline first'],
['newline last\n', 'newline last\n'],
['newline\ninside', 'newline\ninside'],
['"tab\tinside quotes outside"', '"tab\tinside quotes outside"'],
['"tab"\tbetween "quoted"', '"tab"\tbetween "quoted"'],
['"newline\ninside quotes outside"', '"newline\ninside quotes outside"'],
['"newline"\nbetween "quoted"', '"newline"\nbetween "quoted"'],
['\t'], // not what was encoded, but matches Excel
['', ''], // not what was encoded, but matches Excel
// A few special characters on their own that should work correctly.
['', ' ', '\t', '\n', "'", "\\"],
// All values get parsed as strings.
['0', '1', 'false', 'true', '', '', 'NaN'],
];
describe('tsvFormat', function() {
it('should encode tab-separated values as Excel does', function() {
assert.deepEqual(tsvEncode(sampleData), sampleEncoded);
});
it('should decode tab-separated values as Excel does', function() {
assert.deepEqual(tsvDecode(sampleEncoded), sampleDecoded);
});
});

@ -0,0 +1,169 @@
/* global location, describe, it, afterEach, after */
var _ = require('underscore');
var Chance = require('chance');
var assert = require('chai').assert;
function mod(r) { return function(x) { return x%r; }; }
exports.mod = mod;
/**
* Runs the given function for the specified number of iterations and returns the total time taken.
* This function has no side effects.
* @param {Function} func - function to apply
* @param {object} context - this
* @param {Array} args - array of arguments to apply on the function
* @param {Integer} options.iters - number of iterations to apply the given function
* @param {Boolean} options.avg - if true, return the avg iteration time, else return the total time
*/
function time(func, context, args, options) {
console.assert(options.iters > 0, "Number of iterations must be greater than 0");
var start, copy;
var elapsed = 0;
// Apply the function on a copy of the context on each iteration to avoid side effects
for (var i = 0; i < options.iters; i++) {
copy = _.clone(context);
start = Date.now();
func.apply(copy, args);
elapsed += Date.now() - start;
}
if (options.avg) return elapsed/options.iters;
else return elapsed;
}
exports.time = time;
/**
* Repeats running the given function on the given arguments count times, returning the last
* result.
*/
function repeat(count, func, varArgs) {
var ret, args = Array.prototype.slice.call(arguments, 2);
for (var i = 0; i < count; i++) {
ret = func.apply(null, args);
}
return ret;
}
exports.repeat = repeat;
/**
* Defines a test suite for running timing tests. See documentation for exports.timing.
*/
function timingDescribe(desc, func) {
// If under Node, non-empty ENABLE_TIMING_TESTS environment variable turns on the timing tests.
// If under the Browser, we look for 'timing=1' among URL params, set by test/browser.js.
var enableTimingTests = (process.browser ?
(location.search.substr(1).split("&").indexOf("timing=1") !== -1) :
process.env.ENABLE_TIMING_TESTS);
function body() {
func();
// We collect the tests, then check if any of them exceeded the expected timing. We do it in
// one pass in after() (rather than in afterEach()) to allow them all to run, since it's
// useful to see all their timings.
var testsToCheck = [];
afterEach(function() {
testsToCheck.push(this.currentTest);
});
after(function() {
testsToCheck.forEach(function(test) {
if (test.expectedDuration) {
assert.isBelow(test.duration, test.expectedDuration * 1.5, "Test took longer than expected");
}
});
});
}
if (enableTimingTests) {
return describe(desc, body);
} else {
return describe.skip(desc + " (skipping timing test)", body);
}
}
/**
* Defines a test case for a timing test. This should be used in place of it() for timing test
* cases created inside utils.timing.describe(). See documentation for exports.timing.
*/
function timingTest(expectedMs, desc, testFunc) {
var test = it(desc + " (exp ~" + expectedMs + "ms)", testFunc);
test.slow(expectedMs * 1.5);
test.timeout(expectedMs * 5 + 2000);
test.expectedDuration = expectedMs;
}
/**
* To write timing tests, the following pattern is recommended:
*
* (1) Use utils.timing.describe() in place of describe().
* (2) Use utils.timing.it() in place of it(). It takes an extra first parameter with the number
* of expected milliseconds. The test will fail if it takes more than 1.5x longer.
* (3) Place only the code to be timed in utils.timing.it(), and do all setup in before() and all
* non-trivial post-test assertions in after().
*
* These tests only run when ENABLE_TIMING_TESTS environment variable is non-empty. It enables
* timing tests both under Node and running in the browser under Selenium. To enable timing tests
* in the browser when running /test.html manually, go to /test.html?timing=1.
*/
exports.timing = {
describe: timingDescribe,
it: timingTest
};
// Dummy object used for tests
function TestPerson(last, first, age, year, month, day) {
this.last = last;
this.first = first;
this.age = age;
this.year = year;
this.month = month;
this.day = day;
}
/**
* Returns a list of randomly generated TestPersons.
* @param {integer} num - length of people list to return
*/
function genPeople(num, seed) {
if (typeof seed === 'undefined') seed = 0;
var ageOpts = {min: 0, max: 90};
var monthOpts = {min:1, max:12};
var dayOpts = {min:1, max:30};
var people = [];
var chance = new Chance(seed);
for (var i = 0; i < num; i++) {
people.push(new TestPerson(chance.last(),
chance.first(),
chance.integer(ageOpts),
parseInt(chance.year()),
chance.integer(monthOpts),
chance.integer(dayOpts)
));
}
return people;
}
exports.genPeople = genPeople;
/**
* Generates a list of items denoted by the given chanceFunc string.
* Ex : genItems('integers', 10, {min:0, max:20}) generates a list of 10 integers between 0 and 20
* : genItems('string', 10, {length: 6}) generates a list of 10 strings of length 6
* @param {string} chanceFunc - string name of a chance.js function
* @param {integer} num - length of item list to return
* @param {object} options - object denoting options for the given chance.js function
*/
function genItems(chanceFunc, num, options, seed) {
if (typeof seed === 'undefined') seed = 0;
console.assert(typeof new Chance()[chanceFunc] === 'function');
var chance = new Chance(seed);
var items = [];
for (var i = 0; i < num; i++) {
items.push(chance[chanceFunc](options));
}
return items;
}
exports.genItems = genItems;

@ -386,6 +386,15 @@
resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.11.2.tgz#699ad86054cc20043c30d66a6fcde30bbf5d3d5e"
integrity sha512-JRDtMPEqXrzfuYAdqbxLot1GvAr/QvicIZAnOAigZaj8xVMhuSJTg/xsv9E1TvyL+wujYhRLx9ZsQ0oFOSmwyA==
"@types/jsdom@16.2.14":
version "16.2.14"
resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-16.2.14.tgz#26fe9da6a8870715b154bb84cd3b2e53433d8720"
integrity sha512-6BAy1xXEmMuHeAJ4Fv4yXKwBDTGTOseExKE3OaHiNycdHdZw59KfYzrt0DkDluvwmik1HRt6QS7bImxUmpSy+w==
dependencies:
"@types/node" "*"
"@types/parse5" "*"
"@types/tough-cookie" "*"
"@types/jsesc@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/jsesc/-/jsesc-3.0.1.tgz#ed1720ae08eae2f64341452e1693a84324029d99"
@ -462,6 +471,13 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.66.tgz#dd035d409df322acc83dff62a602f12a5783bbb3"
integrity sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw==
"@types/parse5@*":
version "7.0.0"
resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-7.0.0.tgz#8b412a0a4461c84d6280a372bfa8c57a418a06bd"
integrity sha512-f2SeAxumolBmhuR62vNGTsSAvdz/Oj0k682xNrcKJ4dmRnTPODB74j6CPoNPzBPTHsu7Y7W7u93Mgp8Ovo8vWw==
dependencies:
parse5 "*"
"@types/pidusage@2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/pidusage/-/pidusage-2.0.1.tgz#45eb309be947dcfa177957ef662ce2a0a2311d48"
@ -539,6 +555,11 @@
resolved "https://registry.yarnpkg.com/@types/tmp/-/tmp-0.0.33.tgz#1073c4bc824754ae3d10cfab88ab0237ba964e4d"
integrity sha1-EHPEvIJHVK49EM+riKsCN7qWTk0=
"@types/tough-cookie@*":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397"
integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==
"@types/underscore@*":
version "1.11.0"
resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.11.0.tgz#bb33549f8f89957fdf959c16e4c1d0eaa5bf985d"
@ -719,6 +740,11 @@ JSONStream@^1.0.3:
jsonparse "^1.2.0"
through ">=2.2.7 <3"
abab@^2.0.0:
version "2.0.6"
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==
abbrev@1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
@ -744,6 +770,14 @@ accepts@~1.3.5:
mime-types "~2.1.24"
negotiator "0.6.2"
acorn-globals@^4.3.0:
version "4.3.4"
resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-4.3.4.tgz#9fa1926addc11c97308c4e66d7add0d40c3272e7"
integrity sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A==
dependencies:
acorn "^6.0.1"
acorn-walk "^6.0.1"
acorn-import-assertions@^1.7.6:
version "1.8.0"
resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz#ba2b5939ce62c238db6d93d81c9b111b29b855e9"
@ -758,6 +792,11 @@ acorn-node@^1.2.0, acorn-node@^1.3.0, acorn-node@^1.5.2:
acorn-walk "^7.0.0"
xtend "^4.0.2"
acorn-walk@^6.0.1:
version "6.2.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-6.2.0.tgz#123cb8f3b84c2171f1f7fb252615b1c78a6b1a8c"
integrity sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==
acorn-walk@^7.0.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
@ -768,6 +807,11 @@ acorn@^5.2.1:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e"
integrity sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==
acorn@^6.0.1, acorn@^6.0.2:
version "6.4.2"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6"
integrity sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==
acorn@^7.0.0:
version "7.4.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
@ -946,6 +990,11 @@ argparse@^1.0.7:
dependencies:
sprintf-js "~1.0.2"
array-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93"
integrity sha512-H3LU5RLiSsGXPhN+Nipar0iR0IofH+8r89G2y1tBKxQ/agagKyAjhkAFDRBfodP2caPrNKHpAWNIM/c9yeL7uA==
array-filter@~0.0.0:
version "0.0.1"
resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-0.0.1.tgz#7da8cf2e26628ed732803581fd21f67cacd2eeec"
@ -1246,6 +1295,11 @@ browser-pack@^6.0.1:
through2 "^2.0.0"
umd "^3.0.0"
browser-process-hrtime@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626"
integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==
browser-resolve@^1.11.0, browser-resolve@^1.7.0:
version "1.11.3"
resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.3.tgz#9b7cbb3d0f510e4cb86bdbd796124d28b5890af6"
@ -1577,6 +1631,11 @@ chalk@^4.1.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
chance@1.0.16:
version "1.0.16"
resolved "https://registry.yarnpkg.com/chance/-/chance-1.0.16.tgz#bd61912716b0010c3dca8e3948a960efcaa7bb1b"
integrity sha512-2bgDHH5bVfAXH05SPtjqrsASzZ7h90yCuYT2z4mkYpxxYvJXiIydBFzVieVHZx7wLH1Ag2Azaaej2/zA1XUrNQ==
check-error@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
@ -2010,6 +2069,18 @@ crypto-random-string@^2.0.0:
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
cssom@0.3.x, cssom@^0.3.4:
version "0.3.8"
resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a"
integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==
cssstyle@^1.1.1:
version "1.4.0"
resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-1.4.0.tgz#9d31328229d3c565c61e586b02041a28fccdccf1"
integrity sha512-GBrLZYZ4X4x6/QEoBnIrqb8B/f5l4+8me2dkom/j1Gtbxy0kBv6OGzKuAsGM75bkGwGAFkt56Iwg28S3XTZgSA==
dependencies:
cssom "0.3.x"
csv-generate@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/csv-generate/-/csv-generate-2.2.2.tgz#c37808c5f3ead2deec940794073dd32d492adfd1"
@ -2054,6 +2125,15 @@ dashdash@^1.12.0:
dependencies:
assert-plus "^1.0.0"
data-urls@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-1.1.0.tgz#15ee0582baa5e22bb59c77140da8f9c76963bbfe"
integrity sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ==
dependencies:
abab "^2.0.0"
whatwg-mimetype "^2.2.0"
whatwg-url "^7.0.0"
dayjs@^1.8.34:
version "1.10.6"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.6.tgz#288b2aa82f2d8418a6c9d4df5898c0737ad02a63"
@ -2130,6 +2210,11 @@ deep-is@^0.1.3:
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
deep-is@~0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
defer-to-connect@^1.0.1:
version "1.1.3"
resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591"
@ -2254,6 +2339,13 @@ domain-browser@~1.1.0:
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.1.7.tgz#867aa4b093faa05f1de08c06f4d7b21fdf8698bc"
integrity sha1-hnqksJP6oF8d4IwG9NeyH9+GmLw=
domexception@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90"
integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==
dependencies:
webidl-conversions "^4.0.2"
dot-prop@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.2.0.tgz#c34ecc29556dc45f1f4c22697b6f4904e0cc4fcb"
@ -2395,6 +2487,11 @@ enhanced-resolve@^5.9.3:
graceful-fs "^4.2.4"
tapable "^2.2.0"
entities@^4.3.0:
version "4.3.1"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.3.1.tgz#c34062a94c865c322f9d67b4384e4169bcede6a4"
integrity sha512-o4q/dYJlmyjP2zfnaWDUC6A3BQFmVTX+tZPezK7k0GLSU9QYCauscf5Y+qcEPzKL+EixVouYDgLQK5H9GrLpkg==
env-paths@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-1.0.0.tgz#4168133b42bb05c38a35b1ae4397c8298ab369e0"
@ -2606,6 +2703,18 @@ escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
escodegen@^1.11.0:
version "1.14.3"
resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503"
integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==
dependencies:
esprima "^4.0.1"
estraverse "^4.2.0"
esutils "^2.0.2"
optionator "^0.8.1"
optionalDependencies:
source-map "~0.6.1"
eslint-scope@5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
@ -2614,7 +2723,7 @@ eslint-scope@5.1.1:
esrecurse "^4.3.0"
estraverse "^4.1.1"
esprima@^4.0.0:
esprima@^4.0.0, esprima@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
@ -2626,7 +2735,7 @@ esrecurse@^4.3.0:
dependencies:
estraverse "^5.2.0"
estraverse@^4.1.1:
estraverse@^4.1.1, estraverse@^4.2.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
@ -2636,6 +2745,11 @@ estraverse@^5.2.0:
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
etag@~1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
@ -2768,6 +2882,11 @@ fast-json-stable-stringify@^2.0.0:
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
fast-levenshtein@~2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
fast-safe-stringify@^2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743"
@ -3349,6 +3468,13 @@ hmac-drbg@^1.0.0:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
html-encoding-sniffer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz#e70d84b94da53aa375e11fe3a351be6642ca46f8"
integrity sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw==
dependencies:
whatwg-encoding "^1.0.1"
htmlescape@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351"
@ -3433,7 +3559,7 @@ iconv-lite@0.4.23:
dependencies:
safer-buffer ">= 2.1.2 < 3"
iconv-lite@^0.4.4:
iconv-lite@0.4.24, iconv-lite@^0.4.4:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@ -3856,6 +3982,38 @@ jsbn@~0.1.0:
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
jsdom@13.0.0:
version "13.0.0"
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-13.0.0.tgz#f1df2411b714a4e08d1bdc343c0a0889c688210f"
integrity sha512-Kmq4ASMNkgpY+YufE322EnIKoiz0UWY2DRkKlU7d5YrIW4xiVRhWFrZV1fr6w/ZNxQ50wGAH5gGRzydgnmkkvw==
dependencies:
abab "^2.0.0"
acorn "^6.0.2"
acorn-globals "^4.3.0"
array-equal "^1.0.0"
cssom "^0.3.4"
cssstyle "^1.1.1"
data-urls "^1.0.1"
domexception "^1.0.1"
escodegen "^1.11.0"
html-encoding-sniffer "^1.0.2"
nwsapi "^2.0.9"
parse5 "5.1.0"
pn "^1.1.0"
request "^2.88.0"
request-promise-native "^1.0.5"
saxes "^3.1.3"
symbol-tree "^3.2.2"
tough-cookie "^2.4.3"
w3c-hr-time "^1.0.1"
w3c-xmlserializer "^1.0.0"
webidl-conversions "^4.0.2"
whatwg-encoding "^1.0.5"
whatwg-mimetype "^2.2.0"
whatwg-url "^7.0.0"
ws "^6.1.0"
xml-name-validator "^3.0.0"
jsesc@3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.0.2.tgz#bb8b09a6597ba426425f2e4a07245c3d00b9343e"
@ -4042,6 +4200,14 @@ lazystream@^1.0.0:
dependencies:
readable-stream "^2.0.5"
levn@~0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"
integrity sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==
dependencies:
prelude-ls "~1.1.2"
type-check "~0.3.2"
lie@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
@ -4192,6 +4358,11 @@ lodash.once@^4.0.0:
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==
lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==
lodash.union@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
@ -4207,6 +4378,11 @@ lodash@4.17.15, lodash@^4.17.14, lodash@^4.17.15:
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
lodash@^4.17.19:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
log-symbols@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4"
@ -4773,6 +4949,11 @@ number-is-nan@^1.0.0:
resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
nwsapi@^2.0.9:
version "2.2.1"
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.1.tgz#10a9f268fbf4c461249ebcfe38e359aa36e2577c"
integrity sha512-JYOWTeFoS0Z93587vRJgASD5Ut11fYl5NyihP3KrYBvMe1FRRs6RN7m20SA/16GM4P6hTnZjT+UmDOt38UeXNg==
oauth-sign@~0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
@ -4854,6 +5035,18 @@ optimist@^0.6.1:
minimist "~0.0.1"
wordwrap "~0.0.2"
optionator@^0.8.1:
version "0.8.3"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"
integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==
dependencies:
deep-is "~0.1.3"
fast-levenshtein "~2.0.6"
levn "~0.3.0"
prelude-ls "~1.1.2"
type-check "~0.3.2"
word-wrap "~1.2.3"
os-browserify@~0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
@ -4980,6 +5173,18 @@ parse5-htmlparser2-tree-adapter@^6.0.0:
dependencies:
parse5 "^6.0.1"
parse5@*:
version "7.0.0"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.0.0.tgz#51f74a5257f5fcc536389e8c2d0b3802e1bfa91a"
integrity sha512-y/t8IXSPWTuRZqXc0ajH/UwDj4mnqLEbSttNbThcFhGrZuOyoyvNBO85PBp2jQa55wY9d07PBNjsK8ZP3K5U6g==
dependencies:
entities "^4.3.0"
parse5@5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2"
integrity sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==
parse5@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
@ -5178,6 +5383,11 @@ plotly.js-basic-dist@2.13.2:
resolved "https://registry.yarnpkg.com/plotly.js-basic-dist/-/plotly.js-basic-dist-2.13.2.tgz#7ee2386ed7f255afac54305458cda2dc274da08b"
integrity sha512-6sXQv3agrwHPot3gtX+UH5VyuTPsMfddigamBAEhHoJXsnAEo5DYiWS6WDnpsD7FZDsDtd7YkBZiymt4eMD7cQ==
pn@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==
pngjs@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
@ -5224,6 +5434,11 @@ postgres-interval@^1.1.0:
dependencies:
xtend "^4.0.0"
prelude-ls@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==
prepend-http@^1.0.1:
version "1.0.4"
resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
@ -5551,6 +5766,22 @@ registry-url@^5.0.0:
dependencies:
rc "^1.2.8"
request-promise-core@1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.4.tgz#3eedd4223208d419867b78ce815167d10593a22f"
integrity sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==
dependencies:
lodash "^4.17.19"
request-promise-native@^1.0.5:
version "1.0.9"
resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.9.tgz#e407120526a5efdc9a39b28a5679bf47b9d9dc28"
integrity sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==
dependencies:
request-promise-core "1.1.4"
stealthy-require "^1.1.1"
tough-cookie "^2.3.3"
request@^2.45.0, request@^2.88.0:
version "2.88.2"
resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
@ -5694,6 +5925,13 @@ sax@>=0.6.0, sax@^1.2.4:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
saxes@^3.1.3:
version "3.1.11"
resolved "https://registry.yarnpkg.com/saxes/-/saxes-3.1.11.tgz#d59d1fd332ec92ad98a2e0b2ee644702384b1c5b"
integrity sha512-Ydydq3zC+WYDJK1+gRxRapLIED9PWeSuuS41wqyoRmzvhhh9nc+QQrVMKJYzJFULazeGhzSV0QleN2wD3boh2g==
dependencies:
xmlchars "^2.1.1"
saxes@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d"
@ -5945,7 +6183,7 @@ source-map-support@~0.5.20:
buffer-from "^1.0.0"
source-map "^0.6.0"
source-map@^0.6.0, source-map@^0.6.1:
source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
@ -6005,6 +6243,11 @@ statuses@~1.4.0:
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==
stealthy-require@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"
integrity sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==
stream-browserify@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b"
@ -6239,6 +6482,11 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
symbol-tree@^3.2.2:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
syntax-error@^1.1.1:
version "1.4.0"
resolved "https://registry.yarnpkg.com/syntax-error/-/syntax-error-1.4.0.tgz#2d9d4ff5c064acb711594a3e3b95054ad51d907c"
@ -6444,7 +6692,7 @@ touch@^3.1.0:
dependencies:
nopt "~1.0.10"
tough-cookie@~2.5.0:
tough-cookie@^2.3.3, tough-cookie@^2.4.3, tough-cookie@~2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
@ -6452,6 +6700,13 @@ tough-cookie@~2.5.0:
psl "^1.1.28"
punycode "^2.1.1"
tr46@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
integrity sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==
dependencies:
punycode "^2.1.0"
"traverse@>=0.3.0 <0.4":
version "0.3.9"
resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9"
@ -6489,6 +6744,13 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0:
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
type-check@~0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"
integrity sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==
dependencies:
prelude-ls "~1.1.2"
type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
@ -6589,6 +6851,11 @@ undefsafe@^2.0.2:
dependencies:
debug "^2.2.0"
underscore@1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.1.tgz#06dce34a0e68a7babc29b365b8e74b8925203961"
integrity sha512-5/4etnCkd9c8gwgowi5/om/mYO5ajCaOgdzj/oW+0eQV9WxKBDZw5+ycmKmeaTXjInS/W0BzpGLo2xR2aBwZdg==
underscore@>=1.8.3:
version "1.12.1"
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e"
@ -6758,6 +7025,22 @@ vm-browserify@~0.0.1:
dependencies:
indexof "0.0.1"
w3c-hr-time@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd"
integrity sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==
dependencies:
browser-process-hrtime "^1.0.0"
w3c-xmlserializer@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-1.1.2.tgz#30485ca7d70a6fd052420a3d12fd90e6339ce794"
integrity sha512-p10l/ayESzrBMYWRID6xbuCKh2Fp77+sA0doRuGn4tTIMrrZVeqfpKjXHY+oDh3K4nLdPgNwMTVP6Vp4pvqbNg==
dependencies:
domexception "^1.0.1"
webidl-conversions "^4.0.2"
xml-name-validator "^3.0.0"
watchpack@^2.3.1:
version "2.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
@ -6766,6 +7049,11 @@ watchpack@^2.3.1:
glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2"
webidl-conversions@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
webpack-cli@4.10.0:
version "4.10.0"
resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-4.10.0.tgz#37c1d69c8d85214c5a65e589378f53aec64dab31"
@ -6835,6 +7123,27 @@ webpack@5.73.0:
watchpack "^2.3.1"
webpack-sources "^3.2.3"
whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0"
integrity sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==
dependencies:
iconv-lite "0.4.24"
whatwg-mimetype@^2.2.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf"
integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==
whatwg-url@^7.0.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06"
integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==
dependencies:
lodash.sortby "^4.7.0"
tr46 "^1.0.1"
webidl-conversions "^4.0.2"
which-boxed-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
@ -6903,6 +7212,11 @@ winston@2.4.5:
isstream "0.1.x"
stack-trace "0.0.x"
word-wrap@~1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
wordwrap@~0.0.2:
version "0.0.3"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
@ -6957,6 +7271,13 @@ ws@6.1.0:
dependencies:
async-limiter "~1.0.0"
ws@^6.1.0:
version "6.2.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.2.tgz#dd5cdbd57a9979916097652d78f1cc5faea0c32e"
integrity sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==
dependencies:
async-limiter "~1.0.0"
ws@^7.3.1:
version "7.4.4"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.4.tgz#383bc9742cb202292c9077ceab6f6047b17f2d59"
@ -6986,6 +7307,11 @@ xml-encryption@^0.11.0:
xmldom "~0.1.15"
xpath "0.0.27"
xml-name-validator@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
xml2js@^0.4.0, xml2js@^0.4.17:
version "0.4.23"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
@ -7006,7 +7332,7 @@ xmlbuilder@~2.2.0:
dependencies:
lodash-node "~2.4.1"
xmlchars@^2.2.0:
xmlchars@^2.1.1, xmlchars@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==

Loading…
Cancel
Save