You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/test/client/models/TreeModel.ts

227 lines
8.6 KiB

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