(core) Moving client and common tests to core

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

Test Plan: Existing tests

Reviewers: paulfitz

Reviewed By: paulfitz

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D3590
This commit is contained in:
Jarosław Sadziński
2022-08-18 23:08:39 +02:00
parent e06f0bc1d8
commit a52d56f613
78 changed files with 11700 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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