UNPKG

@itwin/core-backend

Version:
878 lines • 41.4 kB
/*--------------------------------------------------------------------------------------------- * 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