(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
This commit is contained in:
Jarosław Sadziński
2022-08-18 23:08:39 +02:00
parent e06f0bc1d8
commit a52d56f613
78 changed files with 11700 additions and 7 deletions

View File

@@ -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');
});
});

144
test/common/AsyncCreate.ts Normal file
View File

@@ -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();
});
});
});

21
test/common/BigInt.ts Normal file
View File

@@ -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");
});
});

View File

@@ -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; }));
});
});
});

View File

@@ -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"]);
});
});

View File

@@ -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);
});
});

33
test/common/DocActions.ts Normal file
View File

@@ -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: []});
});
});

View File

@@ -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);
});
});

77
test/common/KeyedMutex.ts Normal file
View File

@@ -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);
});
});

127
test/common/MemBuffer.js Normal file
View File

@@ -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);
});
});
});

115
test/common/NumberFormat.ts Normal file
View File

@@ -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',
});
});
});

392
test/common/NumberParse.ts Normal file
View File

@@ -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;
}
}
}
}
});
});
}
});

View File

@@ -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, {});
});
});

View File

@@ -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(""));
});
});
});

170
test/common/RefCountMap.ts Normal file
View File

@@ -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");
});
});

29
test/common/SortFunc.ts Normal file
View File

@@ -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])}`);
}
}
});
});

View File

@@ -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));
});
});

259
test/common/TableData.ts Normal file
View File

@@ -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 ],
]);
});
});

View File

@@ -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');
});
});
});

241
test/common/ValueGuesser.ts Normal file
View File

@@ -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]]
});
});
});
});

619
test/common/arraySplice.js Normal file
View File

@@ -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);
});
});
});
});
}
});

49
test/common/csvFormat.ts Normal file
View File

@@ -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,', '']);
});
});

View File

@@ -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]");
});
});

23
test/common/gristUrls.ts Normal file
View File

@@ -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: ''});
});
});
});

264
test/common/gutil.js Normal file
View File

@@ -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'));
});
});
});

157
test/common/gutil2.ts Normal file
View File

@@ -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], []);
});
});
});

169
test/common/marshal.js Normal file
View File

@@ -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);
});
});
});

448
test/common/parseDate.ts Normal file
View File

@@ -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");
});
});
});

220
test/common/promises.js Normal file
View File

@@ -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 });
}
});

53
test/common/roles.ts Normal file
View File

@@ -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/);
});
});
});

View File

@@ -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);
});
});

168
test/common/sortTiming.js Normal file
View File

@@ -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);
});
});
});

23
test/common/timeFormat.js Normal file
View File

@@ -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");
});
});

97
test/common/tsvFormat.ts Normal file
View File

@@ -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);
});
});