@itwin/core-backend
Version:
iTwin.js backend components
878 lines • 41.4 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
if (value !== null && value !== void 0) {
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
var dispose, inner;
if (async) {
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
dispose = value[Symbol.asyncDispose];
}
if (dispose === void 0) {
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
dispose = value[Symbol.dispose];
if (async) inner = dispose;
}
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
env.stack.push({ value: value, dispose: dispose, async: async });
}
else if (async) {
env.stack.push({ async: true });
}
return value;
};
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
return function (env) {
function fail(e) {
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
env.hasError = true;
}
var r, s = 0;
function next() {
while (r = env.stack.pop()) {
try {
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
if (r.dispose) {
var result = r.dispose.call(r.value);
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
}
else s |= 1;
}
catch (e) {
fail(e);
}
}
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
if (env.hasError) throw env.error;
}
return next();
};
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
});
import { assert, expect } from "chai";
import { BeDuration, Guid, Id64, IModelStatus, OpenMode } from "@itwin/core-bentley";
import { LineSegment3d, Point3d, YawPitchRollAngles } from "@itwin/core-geometry";
import { Code, ColorByName, DomainOptions, GeometryStreamBuilder, IModel, IModelError, SubCategoryAppearance, TxnAction, } from "@itwin/core-common";
import { _nativeDb, ChannelControl, IModelJsFs, PhysicalModel, setMaxEntitiesPerEvent, SpatialCategory, StandaloneDb, } from "../../core-backend";
import { IModelTestUtils, TestElementDrivesElement, TestPhysicalObject } from "../IModelTestUtils";
import { IModelNative } from "../../internal/NativePlatform";
import { EntityClass, SchemaItemKey, SchemaKey } from "@itwin/ecschema-metadata";
/// cspell:ignore accum
describe("TxnManager", () => {
let imodel;
let roImodel;
let props;
let testFileName;
const performUpgrade = (pathname) => {
const nativeDb = new IModelNative.platform.DgnDb();
const upgradeOptions = {
domain: DomainOptions.Upgrade,
schemaLockHeld: true,
};
nativeDb.openIModel(pathname, OpenMode.ReadWrite, upgradeOptions);
nativeDb.deleteAllTxns();
nativeDb.closeFile();
};
beforeEach(async () => {
IModelTestUtils.registerTestBimSchema();
// make a unique name for the output file so this test can be run in parallel
testFileName = IModelTestUtils.prepareOutputFile("TxnManager", `${Guid.createValue()}.bim`);
const seedFileName = IModelTestUtils.resolveAssetFile("test.bim");
const schemaFileName = IModelTestUtils.resolveAssetFile("TestBim.ecschema.xml");
IModelJsFs.copySync(seedFileName, testFileName);
performUpgrade(testFileName);
imodel = StandaloneDb.openFile(testFileName, OpenMode.ReadWrite);
await imodel.importSchemas([schemaFileName]); // will throw an exception if import fails
imodel.channels.addAllowedChannel(ChannelControl.sharedChannelName);
const builder = new GeometryStreamBuilder();
builder.appendGeometry(LineSegment3d.create(Point3d.createZero(), Point3d.create(5, 0, 0)));
props = {
classFullName: "TestBim:TestPhysicalObject",
model: PhysicalModel.insert(imodel, IModel.rootSubjectId, "TestModel"),
category: SpatialCategory.insert(imodel, IModel.dictionaryId, "MySpatialCategory", new SubCategoryAppearance({ color: ColorByName.darkRed })),
code: Code.createEmpty(),
intProperty: 100,
placement: {
origin: new Point3d(1, 2, 0),
angles: new YawPitchRollAngles(),
},
geom: builder.geometryStream,
};
imodel.saveChanges("schema change");
imodel[_nativeDb].deleteAllTxns();
roImodel = StandaloneDb.openFile(testFileName, OpenMode.Readonly);
});
afterEach(() => {
roImodel.close();
imodel.close();
IModelJsFs.removeSync(testFileName);
});
function makeEntity(id, classFullName) {
const classId = imodel[_nativeDb].classNameToId(classFullName);
expect(Id64.isValid(classId)).to.be.true;
return { id, classId };
}
function physicalModelEntity(id) {
return makeEntity(id, "BisCore:PhysicalModel");
}
function physicalObjectEntity(id) {
return makeEntity(id, "TestBim:TestPhysicalObject");
}
function spatialCategoryEntity(id) {
return makeEntity(id, "BisCore:SpatialCategory");
}
function subCategoryEntity(categoryId) {
return makeEntity(IModel.getDefaultSubCategoryId(categoryId), "BisCore:SubCategory");
}
it("TxnManager", async () => {
const models = imodel.models;
const elements = imodel.elements;
const modelId = props.model;
const cleanup = [];
let model = models.getModel(modelId);
assert.isUndefined(model.geometryGuid, "geometryGuid starts undefined");
assert.isDefined(await imodel.schemaContext.getSchemaItem(new SchemaItemKey("TestPhysicalObject", new SchemaKey("TestBim", 1, 0, 0)), EntityClass), "TestPhysicalObject is present");
const txns = imodel.txns;
assert.isFalse(txns.hasPendingTxns);
const change1Msg = "change 1";
const change2Msg = "change 2";
let beforeUndo = 0;
let afterUndo = 0;
let undoAction = TxnAction.None;
cleanup.push(txns.onBeforeUndoRedo.addListener(() => beforeUndo++));
cleanup.push(txns.onAfterUndoRedo.addListener((isUndo) => {
afterUndo++;
undoAction = isUndo ? TxnAction.Reverse : TxnAction.Reinstate;
}));
let elementId = elements.insertElement(props);
assert.isFalse(txns.isRedoPossible);
assert.isFalse(txns.isUndoPossible);
assert.isTrue(txns.hasUnsavedChanges);
assert.isFalse(txns.hasPendingTxns);
imodel.saveChanges(change1Msg);
assert.isFalse(txns.hasUnsavedChanges);
assert.isTrue(txns.hasPendingTxns);
assert.isTrue(txns.hasLocalChanges);
expect(imodel[_nativeDb].getCurrentTxnId()).not.equal(roImodel[_nativeDb].getCurrentTxnId());
roImodel[_nativeDb].restartDefaultTxn();
expect(imodel[_nativeDb].getCurrentTxnId()).equal(roImodel[_nativeDb].getCurrentTxnId());
const classId = imodel[_nativeDb].classNameToId(props.classFullName);
assert.isTrue(Id64.isValid(classId));
const class2 = imodel[_nativeDb].classIdToName(classId);
assert.equal(class2, props.classFullName);
model = models.getModel(modelId);
assert.isDefined(model.geometryGuid);
txns.reverseSingleTxn();
assert.isFalse(txns.hasPendingTxns, "should not have pending txns if they all are reversed");
assert.isFalse(txns.hasLocalChanges);
txns.reinstateTxn();
assert.isTrue(txns.hasPendingTxns, "now there should be pending txns again");
assert.isTrue(txns.hasLocalChanges);
beforeUndo = afterUndo = 0; // reset this for tests below
model = models.getModel(modelId);
assert.isDefined(model.geometryGuid);
const guid1 = model.geometryGuid;
let element = elements.getElement(elementId);
assert.equal(element.intProperty, 100, "int property should be 100");
assert.isTrue(txns.isUndoPossible); // we have an undoable Txn, but nothing undone.
assert.equal(change1Msg, txns.getUndoString());
assert.equal(IModelStatus.Success, txns.reverseSingleTxn());
model = models.getModel(modelId);
assert.isUndefined(model.geometryGuid, "geometryGuid undefined after undo");
assert.isTrue(txns.isRedoPossible);
assert.equal(change1Msg, txns.getRedoString());
assert.equal(beforeUndo, 1);
assert.equal(afterUndo, 1);
assert.equal(undoAction, TxnAction.Reverse);
assert.throws(() => elements.getElementProps(elementId), IModelError, "element not found");
assert.throws(() => elements.getElement(elementId), IModelError);
assert.equal(IModelStatus.Success, txns.reinstateTxn());
model = models.getModel(modelId);
assert.equal(model.geometryGuid, guid1, "geometryGuid should return redo");
assert.isTrue(txns.isUndoPossible);
assert.isFalse(txns.isRedoPossible);
assert.equal(beforeUndo, 2);
assert.equal(afterUndo, 2);
assert.equal(undoAction, TxnAction.Reinstate);
element = elements.getElement(elementId);
element.intProperty = 200;
element.update();
imodel.saveChanges(change2Msg);
model = models.getModel(modelId);
assert.equal(model.geometryGuid, guid1, "geometryGuid should not update with no geometry changes");
element = elements.getElement(elementId);
assert.equal(element.intProperty, 200, "int property should be 200");
assert.equal(txns.getTxnDescription(txns.queryPreviousTxnId(txns.getCurrentTxnId())), change2Msg);
assert.equal(IModelStatus.Success, txns.reverseSingleTxn());
element = elements.getElement(elementId);
assert.equal(element.intProperty, 100, "int property should be 100");
// make sure abandon changes works.
element.delete();
assert.throws(() => elements.getElement(elementId), IModelError);
imodel.abandonChanges(); //
element = elements.getElement(elementId); // should be back now.
elements.insertElement(props); // create a new element
imodel.saveChanges(change2Msg);
model = models.getModel(modelId);
assert.isDefined(model.geometryGuid);
assert.notEqual(model.geometryGuid, guid1, "geometryGuid should update with adds");
elementId = elements.insertElement(props); // create a new element
assert.isTrue(txns.hasUnsavedChanges);
assert.equal(IModelStatus.Success, txns.reverseSingleTxn());
assert.isFalse(txns.hasUnsavedChanges);
assert.throws(() => elements.getElement(elementId), IModelError); // reversing a txn with pending uncommitted changes should abandon them.
assert.equal(IModelStatus.Success, txns.reinstateTxn());
assert.throws(() => elements.getElement(elementId), IModelError); // doesn't come back, wasn't committed
// verify multi-txn operations are undone/redone together
const el1 = elements.insertElement(props);
imodel.saveChanges("step 1");
txns.beginMultiTxnOperation();
assert.equal(1, txns.getMultiTxnOperationDepth());
const el2 = elements.insertElement(props);
imodel.saveChanges("step 2");
const el3 = elements.insertElement(props);
imodel.saveChanges("step 3");
txns.endMultiTxnOperation();
assert.equal(0, txns.getMultiTxnOperationDepth());
assert.equal(IModelStatus.Success, txns.reverseSingleTxn());
assert.throws(() => elements.getElement(el2), IModelError);
assert.throws(() => elements.getElement(el3), IModelError);
elements.getElement(el1);
assert.equal(IModelStatus.Success, txns.reverseSingleTxn());
assert.throws(() => elements.getElement(el1), IModelError);
assert.equal(IModelStatus.Success, txns.reinstateTxn());
assert.throws(() => elements.getElement(el2), IModelError);
assert.throws(() => elements.getElement(el3), IModelError);
elements.getElement(el1);
assert.equal(IModelStatus.Success, txns.reinstateTxn());
elements.getElement(el1);
elements.getElement(el2);
elements.getElement(el3);
assert.equal(IModelStatus.Success, txns.cancelTo(txns.queryFirstTxnId()));
assert.isFalse(txns.hasUnsavedChanges);
assert.isFalse(txns.hasPendingTxns);
assert.isFalse(txns.hasLocalChanges);
model = models.getModel(modelId);
assert.isUndefined(model.geometryGuid, "undo all, geometryGuid goes back to undefined");
const modifyId = elements.insertElement(props);
imodel.saveChanges("check guid changes");
model = models.getModel(modelId);
const guid2 = model.geometryGuid;
const toModify = elements.getElement(modifyId);
toModify.placement.origin.x += 1;
toModify.placement.origin.y += 1;
toModify.update();
const saveUpdateMsg = "save update to modify guid";
imodel.saveChanges(saveUpdateMsg);
model = models.getModel(modelId);
assert.notEqual(guid2, model.geometryGuid, "update placement should change guid");
const lastMod = models.queryLastModifiedTime(modelId);
await BeDuration.wait(300); // we update the lastMod below, make sure it will be different by waiting .3 seconds
const guid3 = model.geometryGuid;
models.updateGeometryGuid(modelId);
model = models.getModel(modelId);
assert.notEqual(guid3, model.geometryGuid, "update model should change guid");
const lastMod2 = models.queryLastModifiedTime(modelId);
assert.notEqual(lastMod, lastMod2);
// imodel.saveChanges("update geometry guid");
// Deleting a geometric element updates model's GeometryGuid; deleting any element updates model's LastMod.
await BeDuration.wait(300); // for lastMod...
const guid4 = model.geometryGuid;
toModify.delete();
const deleteTxnMsg = "save deletion of element";
imodel.saveChanges(deleteTxnMsg);
assert.throws(() => elements.getElement(modifyId));
model = models.getModel(modelId);
expect(model.geometryGuid).not.to.equal(guid4);
const lastMod3 = models.queryLastModifiedTime(modelId);
expect(lastMod3).not.to.equal(lastMod2);
assert.isTrue(txns.isUndoPossible);
// test restarting the session, which should truncate undo history
txns.restartSession();
assert.isFalse(txns.isUndoPossible);
assert.equal("", txns.getUndoString());
assert.isFalse(txns.isRedoPossible);
assert.isFalse(txns.hasUnsavedChanges);
assert.isTrue(txns.hasPendingTxns); // these are from the previous session
cleanup.forEach((drop) => drop());
});
class EventAccumulator {
inserted = [];
updated = [];
deleted = [];
numValidates = 0;
numApplyChanges = 0;
_numBeforeUndo = 0;
_numAfterUndo = 0;
_cleanup = [];
constructor(mgr) {
this._cleanup.push(mgr.onEndValidation.addListener(() => {
++this.numValidates;
}));
this._cleanup.push(mgr.onCommit.addListener(() => {
this.clearChanges();
}));
this._cleanup.push(mgr.onChangesApplied.addListener(() => {
++this.numApplyChanges;
}));
this._cleanup.push(mgr.onBeforeUndoRedo.addListener(() => {
expect(this._numBeforeUndo).to.equal(this._numAfterUndo);
++this._numBeforeUndo;
}));
this._cleanup.push(mgr.onAfterUndoRedo.addListener(() => {
++this._numAfterUndo;
expect(this._numAfterUndo).to.equal(this._numBeforeUndo);
}));
}
[Symbol.dispose]() {
for (const cleanup of this._cleanup)
cleanup();
this._cleanup.length = 0;
}
static test(txns, event, func) {
const env_1 = { stack: [], error: void 0, hasError: false };
try {
const accum = __addDisposableResource(env_1, new EventAccumulator(txns), false);
accum.listen(event);
func(accum);
}
catch (e_1) {
env_1.error = e_1;
env_1.hasError = true;
}
finally {
__disposeResources(env_1);
}
}
static testElements(iModel, func) {
this.test(iModel.txns, iModel.txns.onElementsChanged, func);
}
static testModels(iModel, func) {
this.test(iModel.txns, iModel.txns.onModelsChanged, func);
}
listen(evt) {
this._cleanup.push(evt.addListener((changes) => {
this.copyArray(changes, "inserted");
this.copyArray(changes, "updated");
this.copyArray(changes, "deleted");
}));
}
copyArray(changes, propName) {
const iterNames = { inserted: "inserts", updated: "updates", deleted: "deletes" };
const iterName = iterNames[propName];
const entities = changes[iterName];
const dest = this[propName];
for (const entity of entities)
dest.push({ ...entity });
}
expectNumValidations(expected) {
expect(this.numValidates).to.equal(expected);
}
expectNumApplyChanges(expected) {
expect(this.numApplyChanges).to.equal(expected);
}
expectNumUndoRedo(expected) {
expect(this._numBeforeUndo).to.equal(this._numAfterUndo);
expect(this._numBeforeUndo).to.equal(expected);
}
expectChanges(expected) {
this.expect(expected.inserted, "inserted");
this.expect(expected.updated, "updated");
this.expect(expected.deleted, "deleted");
}
expect(expected, propName) {
expect(this[propName]).to.deep.equal(expected ?? []);
}
clearChanges() {
this.inserted.length = 0;
this.updated.length = 0;
this.deleted.length = 0;
}
}
it("dispatches events when elements change", async () => {
const elements = imodel.elements;
let id1;
let id2;
EventAccumulator.testElements(imodel, (accum) => {
id1 = elements.insertElement(props);
id2 = elements.insertElement(props);
imodel.saveChanges("2 inserts");
accum.expectNumValidations(1);
accum.expectChanges({ inserted: [physicalObjectEntity(id1), physicalObjectEntity(id2)] });
});
await BeDuration.wait(10); // we rely on updating the lastMod of the newly inserted element, make sure it will be different
let elem1;
let elem2;
EventAccumulator.testElements(imodel, (accum) => {
elem1 = elements.getElement(id1);
elem2 = elements.getElement(id2);
elem1.intProperty = 200;
elem1.update();
elem2.intProperty = 200;
elem2.update();
imodel.saveChanges("2 updates");
accum.expectNumValidations(1);
accum.expectChanges({ updated: [physicalObjectEntity(id1), physicalObjectEntity(id2)] });
});
EventAccumulator.testElements(imodel, (accum) => {
elem1.delete();
elem2.delete();
imodel.saveChanges("2 deletes");
accum.expectNumValidations(1);
accum.expectChanges({ deleted: [physicalObjectEntity(id1), physicalObjectEntity(id2)] });
});
// Undo
EventAccumulator.testElements(imodel, (accum) => {
imodel.txns.reverseSingleTxn();
accum.expectNumUndoRedo(1);
accum.expectChanges({ inserted: [physicalObjectEntity(id1), physicalObjectEntity(id2)] });
accum.expectNumApplyChanges(1);
accum.expectNumValidations(0);
});
EventAccumulator.testElements(imodel, (accum) => {
imodel.txns.reverseSingleTxn();
accum.expectNumUndoRedo(1);
accum.expectChanges({ updated: [physicalObjectEntity(id1), physicalObjectEntity(id2)] });
});
EventAccumulator.testElements(imodel, (accum) => {
imodel.txns.reverseSingleTxn();
accum.expectNumUndoRedo(1);
accum.expectChanges({ deleted: [physicalObjectEntity(id1), physicalObjectEntity(id2)] });
});
// Redo
EventAccumulator.testElements(imodel, (accum) => {
imodel.txns.reinstateTxn();
accum.expectNumUndoRedo(1);
accum.expectChanges({ inserted: [physicalObjectEntity(id1), physicalObjectEntity(id2)] });
});
EventAccumulator.testElements(imodel, (accum) => {
imodel.txns.reinstateTxn();
accum.expectNumUndoRedo(1);
accum.expectChanges({ updated: [physicalObjectEntity(id1), physicalObjectEntity(id2)] });
});
EventAccumulator.testElements(imodel, (accum) => {
imodel.txns.reinstateTxn();
accum.expectNumUndoRedo(1);
accum.expectChanges({ deleted: [physicalObjectEntity(id1), physicalObjectEntity(id2)] });
accum.expectNumApplyChanges(1);
accum.expectNumValidations(0);
});
// Undo all
EventAccumulator.testElements(imodel, (accum) => {
imodel.txns.reverseTxns(3);
accum.expectNumValidations(0);
accum.expectNumUndoRedo(1);
accum.expectNumApplyChanges(3);
// We received 3 separate "elements changed" events - one for each txn - and just concatenated the lists.
accum.expectChanges({
inserted: [physicalObjectEntity(id1), physicalObjectEntity(id2)],
updated: [physicalObjectEntity(id1), physicalObjectEntity(id2)],
deleted: [physicalObjectEntity(id1), physicalObjectEntity(id2)],
});
});
// Redo all
EventAccumulator.testElements(imodel, (accum) => {
imodel.txns.reinstateTxn();
accum.expectNumValidations(0);
accum.expectNumUndoRedo(1);
accum.expectNumApplyChanges(3);
// We received 3 separate "elements changed" events - one for each txn - and just concatenated the lists.
accum.expectChanges({
inserted: [physicalObjectEntity(id1), physicalObjectEntity(id2)],
updated: [physicalObjectEntity(id1), physicalObjectEntity(id2)],
deleted: [physicalObjectEntity(id1), physicalObjectEntity(id2)],
});
});
EventAccumulator.testElements(imodel, (accum) => {
const elemId1 = imodel.elements.insertElement(props);
const catId = SpatialCategory.insert(imodel, IModel.dictionaryId, Guid.createValue(), new SubCategoryAppearance({ color: ColorByName.green }));
const elemId2 = imodel.elements.insertElement(props);
imodel.saveChanges("2 physical elems and 1 spatial category");
accum.expectNumValidations(1);
accum.expectChanges({
inserted: [
physicalObjectEntity(elemId1),
spatialCategoryEntity(catId),
subCategoryEntity(catId),
physicalObjectEntity(elemId2),
],
});
});
});
it("dispatches events when models change", async () => {
const existingModelId = props.model;
let newModelId;
EventAccumulator.testModels(imodel, (accum) => {
newModelId = PhysicalModel.insert(imodel, IModel.rootSubjectId, Guid.createValue());
imodel.saveChanges("1 insert");
accum.expectNumValidations(1);
accum.expectChanges({ inserted: [physicalModelEntity(newModelId)] });
});
EventAccumulator.testModels(roImodel, (accum) => {
roImodel[_nativeDb].restartDefaultTxn();
accum.expectChanges({ inserted: [physicalModelEntity(newModelId)] });
});
await BeDuration.wait(10); // we rely on updating the lastMod of the newly inserted element, make sure it will be different
// NB: Updates to existing models never produce events. I don't think I want to change that as part of this PR.
let newModel;
EventAccumulator.testModels(imodel, (accum) => {
newModel = imodel.models.getModel(newModelId);
const newModelProps = newModel.toJSON();
newModelProps.isNotSpatiallyLocated = newModel.isSpatiallyLocated;
imodel.models.updateModel(newModelProps);
imodel.models.updateGeometryGuid(existingModelId);
imodel.saveChanges("1 update");
accum.expectNumValidations(1);
accum.expectChanges({});
});
EventAccumulator.testModels(imodel, (accum) => {
imodel.elements.insertElement(props);
imodel.saveChanges("insert 1 geometric element");
accum.expectNumValidations(1);
accum.expectChanges({});
});
EventAccumulator.testModels(imodel, (accum) => {
newModel.delete();
imodel.saveChanges("1 delete");
accum.expectNumValidations(1);
accum.expectChanges({ deleted: [physicalModelEntity(newModelId)] });
accum.expectNumApplyChanges(0);
});
EventAccumulator.testModels(roImodel, (accum) => {
roImodel[_nativeDb].restartDefaultTxn();
accum.expectChanges({ deleted: [physicalModelEntity(newModelId)] });
});
// Undo
EventAccumulator.testModels(imodel, (accum) => {
imodel.txns.reverseSingleTxn();
accum.expectNumUndoRedo(1);
accum.expectNumApplyChanges(1);
accum.expectChanges({ inserted: [physicalModelEntity(newModelId)] });
});
EventAccumulator.testModels(imodel, (accum) => {
imodel.txns.reverseSingleTxn();
accum.expectNumUndoRedo(1);
accum.expectNumApplyChanges(1);
accum.expectChanges({});
});
EventAccumulator.testModels(imodel, (accum) => {
imodel.txns.reverseSingleTxn();
accum.expectNumUndoRedo(1);
accum.expectNumApplyChanges(1);
accum.expectChanges({});
});
EventAccumulator.testModels(imodel, (accum) => {
imodel.txns.reverseSingleTxn();
accum.expectNumUndoRedo(1);
accum.expectNumApplyChanges(1);
accum.expectChanges({ deleted: [physicalModelEntity(newModelId)] });
});
// Redo
EventAccumulator.testModels(imodel, (accum) => {
for (let i = 0; i < 4; i++)
imodel.txns.reinstateTxn();
accum.expectNumUndoRedo(4);
accum.expectNumApplyChanges(4);
accum.expectChanges({ inserted: [physicalModelEntity(newModelId)], deleted: [physicalModelEntity(newModelId)] });
});
});
it("dispatches events when geometry guids change", () => {
const modelId = props.model;
const test = (func) => {
const model = imodel.models.getModel(modelId);
const prevGuid = model.geometryGuid;
let newGuid;
let numEvents = 0;
let dropListener = imodel.txns.onModelGeometryChanged.addListener((changes) => {
expect(numEvents).to.equal(0);
++numEvents;
expect(changes.length).to.equal(1);
expect(changes[0].id).to.equal(modelId);
newGuid = changes[0].guid;
expect(newGuid).not.to.equal(prevGuid);
});
const expectEvent = func(model);
imodel.saveChanges("");
expect(numEvents).to.equal(expectEvent ? 1 : 0);
dropListener();
if (!expectEvent)
return;
dropListener = imodel.txns.onModelGeometryChanged.addListener((changes) => {
++numEvents;
expect(changes.length).to.equal(1);
expect(changes[0].id).to.equal(modelId);
expect(changes[0].guid).to.equal(prevGuid);
});
imodel.txns.reverseSingleTxn();
expect(numEvents).to.equal(2);
dropListener();
dropListener = imodel.txns.onModelGeometryChanged.addListener((changes) => {
++numEvents;
expect(changes.length).to.equal(1);
expect(changes[0].id).to.equal(modelId);
expect(changes[0].guid).to.equal(newGuid);
});
imodel.txns.reinstateTxn();
expect(numEvents).to.equal(3);
dropListener();
};
test(() => {
imodel.models.updateGeometryGuid(modelId);
return true;
});
test((model) => {
model.geometryGuid = Guid.createValue();
model.update();
return false;
});
let newElemId;
test(() => {
newElemId = imodel.elements.insertElement(props);
return true;
});
test(() => {
const elem = imodel.elements.getElement(newElemId);
elem.userLabel = "not a geometric change";
elem.intProperty = 42;
elem.update();
return false;
});
test(() => {
const elem = imodel.elements.getElement(newElemId);
elem.placement.origin.x += 10;
elem.update();
return true;
});
test(() => {
imodel.elements.deleteElement(newElemId);
return true;
});
imodel.saveChanges();
// now test that all the changes we just made are seen by the readonly connection when we call `restartDefaultTxn`
let numRoEvents = 0;
const guid1 = imodel.models.getModel(modelId).geometryGuid;
const dropper = roImodel.txns.onModelGeometryChanged.addListener((changes) => {
++numRoEvents;
expect(changes.length).to.equal(1);
expect(changes[0].id).to.equal(modelId);
expect(changes[0].guid).to.equal(guid1);
});
roImodel[_nativeDb].restartDefaultTxn();
expect(numRoEvents).equal(4);
dropper();
});
it("dispatches events in batches", async () => {
function entityCount(entities) {
let count = 0;
for (const _entity of entities)
++count;
return count;
}
const test = (numChangesExpected, func) => {
const numChanged = [];
const prevMax = setMaxEntitiesPerEvent(2);
const dropListener = imodel.txns.onElementsChanged.addListener((changes) => {
const numEntities = entityCount(changes.inserts) + entityCount(changes.updates) + entityCount(changes.deletes);
numChanged.push(numEntities);
expect(numEntities).least(1);
expect(numEntities <= 2).to.be.true;
});
func();
imodel.saveChanges("");
dropListener();
setMaxEntitiesPerEvent(prevMax);
expect(numChanged.length).to.equal(Math.ceil(numChangesExpected / 2));
for (let i = 0; i < numChanged.length - 1; i++)
expect(numChanged[i]).to.equal(2);
if (numChangesExpected > 0)
expect(numChanged[numChanged.length - 1]).to.equal(0 === numChangesExpected % 2 ? 2 : 1);
};
let elemId1;
test(1, () => {
elemId1 = imodel.elements.insertElement(props);
});
let elemId2;
test(2, () => {
elemId2 = imodel.elements.insertElement(props);
imodel.elements.deleteElement(elemId1);
});
await BeDuration.wait(10); // we rely on updating the lastMod of the newly inserted element, make sure it will be different
let elemId3;
test(3, () => {
elemId1 = imodel.elements.insertElement(props);
elemId3 = imodel.elements.insertElement(props);
const elem2 = imodel.elements.getElement(elemId2);
elem2.intProperty = 321;
elem2.update();
});
test(4, () => {
imodel.elements.deleteElement(elemId1);
imodel.elements.deleteElement(elemId2);
imodel.elements.deleteElement(elemId3);
imodel.elements.insertElement(props);
});
});
it("change propagation should leave txn empty", async () => {
const elements = imodel.elements;
// Insert elements root, child and dependency between them
const rootProps = { ...props, intProperty: 0 };
const rootId = elements.insertElement(rootProps);
const childProps = { ...props, intProperty: 10 };
const childId = elements.insertElement(childProps);
const relationship = TestElementDrivesElement.create(imodel, rootId, childId);
relationship.property1 = "Root drives child";
relationship.insert();
imodel.saveChanges("Inserted root, child element and dependency");
await BeDuration.wait(10); // we rely on updating the lastMod of the newly inserted element, make sure it will be different
// Setup dependency handler to update childElement
let handlerCalled = false;
const dropListener = TestPhysicalObject.allInputsHandled.addListener((id) => {
handlerCalled = true;
assert.equal(id, childId);
const childEl = elements.getElement(childId);
assert.equal(childEl.intProperty, 10, "int property should be 10");
childEl.intProperty += 10;
childEl.update();
});
// Validate state
const txns = imodel.txns;
assert.isFalse(txns.hasUnsavedChanges);
assert.isTrue(txns.hasPendingTxns);
assert.isTrue(txns.hasLocalChanges);
// Update rootElement and saveChanges
const rootEl = elements.getElement(rootId);
rootEl.intProperty += 10;
rootEl.update();
imodel.saveChanges("Updated root");
// Validate state
assert.isTrue(handlerCalled);
assert.isFalse(txns.hasUnsavedChanges, "should not have unsaved changes");
assert.isTrue(txns.hasPendingTxns);
assert.isTrue(txns.hasLocalChanges);
// Cleanup
dropListener();
});
// This bug occurred in one of the authoring apps. This test reproduced the problem, and now serves as a regression test.
it("doesn't crash when reversing a single txn that inserts a model and a contained element while geometric model tracking is enabled", () => {
imodel[_nativeDb].setGeometricModelTrackingEnabled(true);
const model = PhysicalModel.insert(imodel, IModel.rootSubjectId, Guid.createValue());
expect(Id64.isValidId64(model)).to.be.true;
const elem = imodel.elements.insertElement({ ...props, model });
expect(Id64.isValidId64(elem)).to.be.true;
imodel.saveChanges("insert model and element");
imodel.txns.reverseSingleTxn();
imodel[_nativeDb].setGeometricModelTrackingEnabled(false);
});
it("get local changes", async () => {
const elements = imodel.elements;
const txns = imodel.txns;
const el1 = elements.insertElement(props);
elements.insertElement(props);
// Should not return any changes
assert.deepEqual(Array.from(txns.queryLocalChanges({ includeUnsavedChanges: false })), []);
// Should return change that are not saved yet
const e0 = [
{
changeType: "inserted",
classFullName: "TestBim:TestPhysicalObject",
id: "0x40",
},
{
changeType: "inserted",
classFullName: "TestBim:TestPhysicalObject",
id: "0x3f",
},
];
assert.deepEqual(Array.from(txns.queryLocalChanges({ includeUnsavedChanges: true })), e0);
// Saved changes cause change propagation
imodel.saveChanges("2 inserts");
const e1 = [
{
changeType: "inserted",
classFullName: "TestBim:TestPhysicalObject",
id: "0x40",
},
{
changeType: "inserted",
classFullName: "TestBim:TestPhysicalObject",
id: "0x3f",
},
{
changeType: "updated",
classFullName: "BisCore:PhysicalModel",
id: "0x3c",
},
];
assert.deepEqual(Array.from(txns.queryLocalChanges({ includeUnsavedChanges: false })), e1);
assert.deepEqual(Array.from(txns.queryLocalChanges({ includeUnsavedChanges: true })), e1);
// delete the element.
elements.deleteElement(el1);
// Delete element (0x40) should never show up as it was inserted/deleted locally
const e3 = [
{
changeType: "inserted",
classFullName: "TestBim:TestPhysicalObject",
id: "0x40",
},
{
changeType: "updated",
classFullName: "BisCore:PhysicalModel",
id: "0x3c",
},
];
assert.deepEqual(Array.from(txns.queryLocalChanges({ includeUnsavedChanges: true })), e3);
// Saved changes
imodel.saveChanges("1 deleted");
assert.deepEqual(Array.from(txns.queryLocalChanges({ includeUnsavedChanges: false })), e3);
assert.deepEqual(Array.from(txns.queryLocalChanges({ includeUnsavedChanges: true })), e3);
});
describe("deleteAllTxns", () => {
it("deletes pending and/or unsaved changes", () => {
expect(imodel.txns.hasLocalChanges).to.be.false;
expect(imodel.txns.hasPendingTxns).to.be.false;
expect(imodel.txns.hasUnsavedChanges).to.be.false;
imodel.elements.insertElement(props);
expect(imodel.txns.hasLocalChanges).to.be.true;
expect(imodel.txns.hasPendingTxns).to.be.false;
expect(imodel.txns.hasUnsavedChanges).to.be.true;
imodel.txns.deleteAllTxns();
expect(imodel.txns.hasLocalChanges).to.be.false;
imodel.elements.insertElement(props);
imodel.saveChanges();
expect(imodel.txns.hasLocalChanges).to.be.true;
expect(imodel.txns.hasPendingTxns).to.be.true;
expect(imodel.txns.hasUnsavedChanges).to.be.false;
imodel.txns.deleteAllTxns();
expect(imodel.txns.hasLocalChanges).to.be.false;
imodel.elements.insertElement(props);
imodel.saveChanges();
imodel.elements.insertElement(props);
expect(imodel.txns.hasLocalChanges).to.be.true;
expect(imodel.txns.hasPendingTxns).to.be.true;
expect(imodel.txns.hasUnsavedChanges).to.be.true;
imodel.txns.deleteAllTxns();
expect(imodel.txns.hasLocalChanges).to.be.false;
});
it("clears undo/redo history", () => {
expect(imodel.txns.isRedoPossible).to.be.false;
expect(imodel.txns.isUndoPossible).to.be.false;
imodel.elements.insertElement(props);
imodel.saveChanges();
expect(imodel.txns.isUndoPossible).to.be.true;
imodel.txns.deleteAllTxns();
expect(imodel.txns.isUndoPossible).to.be.false;
imodel.elements.insertElement(props);
imodel.saveChanges();
imodel.txns.reverseSingleTxn();
expect(imodel.txns.isRedoPossible).to.be.true;
imodel.txns.deleteAllTxns();
expect(imodel.txns.isRedoPossible).to.be.false;
});
});
});
//# sourceMappingURL=TxnManager.test.js.map