(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

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