(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

128
test/client/clientUtil.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

418
test/client/lib/ACIndex.ts Normal file
View File

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

46
test/client/lib/Delay.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

195
test/client/lib/dispose.js Normal file
View File

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

396
test/client/lib/dom.js Normal file
View File

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

View File

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

372
test/client/lib/koArray.js Normal file
View File

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

View File

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

258
test/client/lib/koDom.js Normal file
View File

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

View File

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

277
test/client/lib/koForm.js Normal file
View File

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

126
test/client/lib/koUtil.js Normal file
View File

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

View File

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

179
test/client/lib/sortUtil.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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