mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
180
test/client/models/ColumnFilter.ts
Normal file
180
test/client/models/ColumnFilter.ts
Normal 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
|
||||
});
|
||||
});
|
||||
22
test/client/models/HomeModel.ts
Normal file
22
test/client/models/HomeModel.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
226
test/client/models/TreeModel.ts
Normal file
226
test/client/models/TreeModel.ts
Normal 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']);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
362
test/client/models/gristUrlState.ts
Normal file
362
test/client/models/gristUrlState.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
216
test/client/models/modelUtil.js
Normal file
216
test/client/models/modelUtil.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
430
test/client/models/rowset.js
Normal file
430
test/client/models/rowset.js
Normal 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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
28
test/client/models/rowuid.js
Normal file
28
test/client/models/rowuid.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user