UNPKG

@eclipse-emfcloud/model-service

Version:
992 lines 58.3 kB
"use strict"; // ***************************************************************************** // Copyright (C) 2023-2024 STMicroelectronics. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: MIT License which is // available at https://opensource.org/licenses/MIT. // // SPDX-License-Identifier: EPL-2.0 OR MIT // ***************************************************************************** var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const model_accessor_bus_1 = require("@eclipse-emfcloud/model-accessor-bus"); const model_manager_1 = require("@eclipse-emfcloud/model-manager"); const model_validation_1 = require("@eclipse-emfcloud/model-validation"); const trigger_engine_1 = require("@eclipse-emfcloud/trigger-engine"); const assert_1 = require("assert"); const chai_1 = __importStar(require("chai")); const chai_as_promised_1 = __importDefault(require("chai-as-promised")); const chai_like_1 = __importDefault(require("chai-like")); const lodash_1 = require("lodash"); const sinon_1 = __importDefault(require("sinon")); const sinon_chai_1 = __importDefault(require("sinon-chai")); const model_service_contribution_1 = require("../../api/model-service-contribution"); const hub_aware_accessor_provider_1 = require("../hub-aware-accessor-provider"); const model_hub_impl_1 = require("../model-hub-impl"); const model_service_model_manager_1 = require("../model-service-model-manager"); const model_trigger_engine_1 = require("../model-trigger-engine"); chai_1.default.use(sinon_chai_1.default); chai_1.default.use(chai_like_1.default); chai_1.default.use(chai_as_promised_1.default); const MODEL_A_ID = 'test.extA'; const MODEL_B_ID = 'test.extB'; const MODEL_C_ID = 'test.extC'; describe('ModelHubImpl', () => { let modelHub; let modelManager; const testContext = {}; let testContributionA; let testContributionB; let sandbox; /** * Create the model hub for a test. This emulates the initialization * of the hub expected to be implemented in a host application. * * @param contributions model service contributions to install in our test hub */ const createModelHub = (createMM, ...contributions) => { const theCreateMM = typeof createMM === 'function' ? createMM : model_manager_1.createModelManager; modelManager = theCreateMM(); const theContribs = createMM === undefined || typeof createMM === 'function' ? contributions : [createMM, ...contributions]; const validationService = new model_validation_1.ModelValidationServiceImpl(); const modelAccessorBus = new model_accessor_bus_1.ModelAccessorBusImpl(); modelHub = new model_hub_impl_1.ModelHubImpl(testContext, modelManager, validationService, modelAccessorBus); // First, add all contributions theContribs.forEach(modelHub.addModelServiceContribution.bind(modelHub)); // Then inject the hub into them theContribs.forEach((contrib) => contrib.setModelHub(modelHub)); }; const editA = async (value) => { const modelService = modelHub.getModelService('testContributionA'); (0, chai_1.expect)(modelService).to.exist; await modelService?.setValue('editorA', MODEL_A_ID, value); }; const undoA = () => modelHub.undo('editorA'); const redoA = () => modelHub.redo('editorA'); const editB = async (value) => { const modelService = modelHub.getModelService('testContributionA'); (0, chai_1.expect)(modelService).to.exist; await modelService?.setValue('editorB', MODEL_B_ID, value); }; const undoB = () => modelHub.undo('editorB'); const redoB = () => modelHub.redo('editorB'); const getModelA = () => modelHub.getModel(MODEL_A_ID); const getModelB = () => modelHub.getModel(MODEL_B_ID); beforeEach(() => { sandbox = sinon_1.default.createSandbox(); persistedModelA = (0, lodash_1.cloneDeep)(originalModelA); persistedModelB = (0, lodash_1.cloneDeep)(originalModelB); testContributionA = new TestContributionA(); testContributionB = new TestContributionB(); }); afterEach(() => { sandbox.restore(); }); it('load and get model', async () => { createModelHub(testContributionA); const modelA = await modelHub.getModel(MODEL_A_ID); (0, chai_1.expect)(modelA).to.be.deep.equal(originalModelA); }); it('load, get, save models', async () => { createModelHub(testContributionA, testContributionB); // load both models await (0, chai_1.expect)(getModelA()).to.eventually.be.deep.equal(originalModelA); await (0, chai_1.expect)(getModelB()).to.eventually.be.deep.equal(originalModelB); // Modify both models await editA(7); await editB(42); await modelHub.save('editorA'); // Model A was saved, model B is only modified in memory (0, chai_1.expect)(persistedModelA).to.be.deep.equal(await getModelA()); (0, chai_1.expect)(persistedModelB).to.be.deep.equal(originalModelB); await modelHub.save('editorB'); // Model A and B were saved (0, chai_1.expect)(persistedModelA).to.be.deep.equal(await getModelA()); (0, chai_1.expect)(persistedModelB).to.be.deep.equal(await getModelB()); // Modify and save both models together await editA(3); await editB(45); await modelHub.save('editorA', 'editorB'); (0, chai_1.expect)(persistedModelA).to.be.deep.equal(await getModelA()); (0, chai_1.expect)(persistedModelB).to.be.deep.equal(await getModelB()); }); it('save all models', async () => { createModelHub(testContributionA, testContributionB); // load both models await (0, chai_1.expect)(getModelA()).to.eventually.be.deep.equal(originalModelA); await (0, chai_1.expect)(getModelB()).to.eventually.be.deep.equal(originalModelB); // Modify and save all models await editA(7); await editB(42); await modelHub.save(); (0, chai_1.expect)(persistedModelA).to.be.deep.equal(await getModelA()); (0, chai_1.expect)(persistedModelB).to.be.deep.equal(await getModelB()); }); it('save after undo', async () => { createModelHub(testContributionA); await (0, chai_1.expect)(getModelA()).to.eventually.be.deep.equal(originalModelA); await editA(7); await modelHub.save('editorA'); (0, chai_1.expect)(persistedModelA).to.be.deep.equal(await getModelA()); // Undo the edit of model A await modelHub.undo('editorA'); // Now only the redo stack has knowledge of the model to save await modelHub.save('editorA'); (0, chai_1.expect)(persistedModelA).to.be.deep.equal(originalModelA); }); it('save after compound command', async () => { createModelHub(testContributionA, testContributionB); // load both models await (0, chai_1.expect)(getModelA()).to.eventually.be.deep.equal(originalModelA); await (0, chai_1.expect)(getModelB()).to.eventually.be.deep.equal(originalModelB); // Modify both models in a compound command const command = new model_manager_1.CompoundCommandImpl('Test Compound', updateModelA((model) => { model.value = 42; }), updateModelB((model) => { model.value = 42; })); await modelManager.getCommandStack('both').execute(command); await modelHub.save('both'); // Model A and B were both saved (0, chai_1.expect)(persistedModelA).to.be.deep.equal(await getModelA()); (0, chai_1.expect)(persistedModelB).to.be.deep.equal(await getModelB()); }); // This test is needed to cover the case of undefined key for a model // referenced by a command, where that model is not managed by the // hub's model manager (so poor programming practice in the client) it('save with a secret model', async () => { createModelHub(testContributionA, testContributionB); // load the models await getModelA(); await getModelB(); // Modify both models in a compound command const command = new model_manager_1.CompoundCommandImpl('Test Compound', updateModelA((model) => { model.value = 42; }), updateModelB((model) => { model.value = 42; })); await modelManager.getCommandStack('both').execute(command); // Now sneakily remove model B modelManager.removeModel(MODEL_B_ID); await modelHub.save('both'); // Model A was saved and B was resurrected by commit of the edit (0, chai_1.expect)(persistedModelA).to.be.deep.equal(await getModelA()); (0, chai_1.expect)(persistedModelB).to.be.deep.equal(await getModelB()); }); it('model editing', async () => { createModelHub(testContributionA, testContributionB); await modelHub.getModel(MODEL_A_ID); const subA = modelHub.subscribe(MODEL_A_ID); let lastChange; let lastModel; let lastModelId; subA.onModelChanged = (modelId, model, delta) => { lastChange = delta; lastModel = model; lastModelId = modelId; }; await editA(3); let patchOperations = lastChange?.filter((op) => op.op !== 'test'); (0, chai_1.expect)(patchOperations).to.be.an('array').of.length(1); if (patchOperations !== undefined) { (0, chai_1.expect)(patchOperations[0].op).to.be.equal('replace'); if ('value' in patchOperations[0]) { (0, chai_1.expect)(patchOperations[0].value).to.be.equal(3); } } (0, chai_1.expect)(lastModelId).to.be.equal(MODEL_A_ID); (0, chai_1.expect)(lastModel).to.be.equal(await getModelA()); await modelHub.undo('editorA'); patchOperations = lastChange?.filter((op) => op.op !== 'test'); (0, chai_1.expect)(patchOperations).to.be.an('array').of.length(1); if (patchOperations !== undefined) { (0, chai_1.expect)(patchOperations[0].op).to.be.equal('replace'); if ('value' in patchOperations[0]) { (0, chai_1.expect)(patchOperations[0].value).to.be.equal(5); } } (0, chai_1.expect)(lastModelId).to.be.equal(MODEL_A_ID); (0, chai_1.expect)(lastModel).to.be.equal(await getModelA()); await modelHub.redo('editorA'); patchOperations = lastChange?.filter((op) => op.op !== 'test'); (0, chai_1.expect)(patchOperations).to.be.an('array').of.length(1); if (patchOperations !== undefined) { (0, chai_1.expect)(patchOperations[0].op).to.be.equal('replace'); if ('value' in patchOperations[0]) { (0, chai_1.expect)(patchOperations[0].value).to.be.equal(3); } } (0, chai_1.expect)(lastModelId).to.be.equal(MODEL_A_ID); (0, chai_1.expect)(lastModel).to.be.equal(await getModelA()); }); it('model contributions', async () => { createModelHub(testContributionA, testContributionB); const modelA = await getModelA(); (0, chai_1.expect)(modelA).to.be.deep.equal(originalModelA); const modelB = await getModelB(); (0, chai_1.expect)(modelB).to.be.deep.equal(originalModelB); }); it('duplicate contributions', () => { createModelHub(testContributionA); try { modelHub.addModelServiceContribution(testContributionA); } catch (error) { // Expected error return; } (0, assert_1.fail)('Adding a duplicate contribution should throw an error'); }); it('validate models', async () => { createModelHub(testContributionA, testContributionB); const diagnostics = await modelHub.validateModels(MODEL_A_ID, MODEL_B_ID); (0, chai_1.expect)(diagnostics.severity).to.be.equal('ok'); await editA(12); const diagnosticA = await modelHub.validateModels(MODEL_A_ID); (0, chai_1.expect)(diagnosticA.severity).to.be.equal('error'); (0, chai_1.expect)(diagnosticA.source).to.be.equal('ValidatorA'); await editB(8); const diagnosticB = await modelHub.validateModels(MODEL_B_ID); (0, chai_1.expect)(diagnosticB.severity).to.be.equal('error'); (0, chai_1.expect)(diagnosticB.source).to.be.equal('ValidatorB'); const validationStateA = modelHub.getValidationState(MODEL_A_ID); const validationStateB = modelHub.getValidationState(MODEL_B_ID); (0, chai_1.expect)(validationStateA?.severity).to.be.equal('error'); (0, chai_1.expect)(validationStateA?.source).to.be.equal('ValidatorA'); (0, chai_1.expect)(validationStateB?.severity).to.be.equal('error'); (0, chai_1.expect)(validationStateB?.source).to.be.equal('ValidatorB'); }); it('validate all models', async () => { createModelHub(testContributionA, testContributionB); // We don't want live validation to precompute validation state for this test modelHub.liveValidation = false; let diagnostic = await modelHub.validateModels(); (0, chai_1.expect)(diagnostic).to.be.like({ severity: 'ok', source: '@eclipse-emfcloud/model-validation', }); await editA(12); diagnostic = await modelHub.validateModels(); (0, chai_1.expect)(diagnostic).to.be.like({ severity: 'error', source: 'ValidatorA' }); await editB(8); // We haven't yet validated B, so it won't be in the validation state let validationState = modelHub.getValidationState(); (0, chai_1.expect)(validationState).to.be.deep.equal(diagnostic); diagnostic = await modelHub.validateModels(); (0, chai_1.expect)(diagnostic).to.be.like({ severity: 'error', source: '', // No unique source children: [ { severity: 'error', source: 'ValidatorA' }, { severity: 'error', source: 'ValidatorB' }, ], }); // Test deep equality because iteration order should be stable (if otherwise indeterminate) validationState = modelHub.getValidationState(); (0, chai_1.expect)(validationState).to.be.deep.equal(diagnostic); }); it('validate all models (no models extant)', async () => { createModelHub(); // We don't want live validation to precompute validation state for this test modelHub.liveValidation = false; let validationState = modelHub.getValidationState(); (0, chai_1.expect)(validationState).not.to.exist; const diagnostics = await modelHub.validateModels(); (0, chai_1.expect)(diagnostics.severity).to.be.equal('ok'); // This is the unique case where running a validation cannot cache // the validation state because there are no models for which to // cache it validationState = modelHub.getValidationState(); (0, chai_1.expect)(validationState).not.to.exist; }); it('validate requested for unknown models', async () => { createModelHub(testContributionA, { id: 'testContributionQ', persistenceContribution: { canHandle: (modelId) => Promise.resolve(modelId.endsWith('.extQ')), loadModel: () => Promise.reject('None such'), saveModel: () => Promise.resolve(true), }, validationContribution: { getValidators: () => [], }, setModelManager: () => undefined, setValidationService: () => undefined, setModelHub: () => undefined, setModelAccessorBus: () => undefined, getModelService: () => ({}), }); // We don't want live validation to precompute validation state for this test modelHub.liveValidation = false; // Load a model that does exist and make it invalid await modelHub.getModel(MODEL_A_ID); await editA(12); const diagnostic = await modelHub.validateModels('test.extQ', MODEL_A_ID); (0, chai_1.expect)(diagnostic.severity).to.be.equal('error'); (0, chai_1.expect)(diagnostic.children).to.be.an('array').of.length(2); (0, chai_1.expect)(diagnostic.children?.[0].source).to.be.equal('@eclipse-emfcloud/model-service'); (0, chai_1.expect)(diagnostic.children?.[1].source).to.be.equal('ValidatorA'); }); it('dirty state', async () => { createModelHub(testContributionA); await getModelA(); (0, chai_1.expect)(modelHub.isDirty('editorA')).to.be.false; await editA(9); (0, chai_1.expect)(modelHub.isDirty('editorA')).to.be.true; // Undo/Redo should change the dirty state await modelHub.undo('editorA'); (0, chai_1.expect)(modelHub.isDirty('editorA')).to.be.false; await modelHub.redo('editorA'); (0, chai_1.expect)(modelHub.isDirty('editorA')).to.be.true; // Save the model; it shouldn't be dirty let saved = await modelHub.save('editorA'); (0, chai_1.expect)(modelHub.isDirty('editorA')).to.be.false; (0, chai_1.expect)(saved).to.be.true; // Try to save again saved = await modelHub.save('editorA'); (0, chai_1.expect)(saved).to.be.false; }); it('dirty state after flush', async () => { createModelHub(testContributionA); await getModelA(); (0, chai_1.expect)(modelHub.isDirty('editorA')).to.be.false; await editA(42); let changed = modelHub.flush('editorA'); (0, chai_1.expect)(modelHub.isDirty('editorA')).to.be.true; (0, chai_1.expect)(changed).to.be.true; // Try flushing again changed = modelHub.flush('editorA'); (0, chai_1.expect)(changed).to.be.false; }); it('dirty state notifications', async () => { createModelHub(testContributionA); const callback = sandbox.stub(); const sub = modelHub.subscribe(); sub.onModelDirtyState = callback; await getModelA(); (0, chai_1.expect)(callback).not.to.have.been.called; await editA(9); let modelA = await getModelA(); (0, chai_1.expect)(callback).to.have.been.calledOnce; (0, chai_1.expect)(callback).to.have.been.calledWith(MODEL_A_ID, modelA, true); callback.resetHistory(); // Undo/Redo should notify the dirty state await modelHub.undo('editorA'); modelA = await getModelA(); (0, chai_1.expect)(callback).to.have.been.calledOnce; (0, chai_1.expect)(callback).to.have.been.calledWith(MODEL_A_ID, modelA, false); callback.resetHistory(); await modelHub.redo('editorA'); modelA = await getModelA(); (0, chai_1.expect)(callback).to.have.been.calledOnce; (0, chai_1.expect)(callback).to.have.been.calledWith(MODEL_A_ID, modelA, true); callback.resetHistory(); // Save the model; it should notify await modelHub.save('editorA'); modelA = await getModelA(); (0, chai_1.expect)(callback).to.have.been.calledOnce; (0, chai_1.expect)(callback).to.have.been.calledWith(MODEL_A_ID, modelA, false); callback.resetHistory(); // Try to save again await modelHub.save('editorA'); (0, chai_1.expect)(callback).not.to.have.been.called; callback.resetHistory(); }); it('undo-redo', async () => { createModelHub(testContributionA); // Undo/redo without changes let changed = await modelHub.undo('editorA'); await (0, chai_1.expect)(getModelA()).to.eventually.be.deep.equal(originalModelA); (0, chai_1.expect)(changed).to.be.false; changed = await modelHub.redo('editorA'); await (0, chai_1.expect)(getModelA()).to.eventually.be.deep.equal(originalModelA); (0, chai_1.expect)(changed).to.be.false; // Modify the model, then undo/redo await editA(1); await (0, chai_1.expect)(getModelA()).to.eventually.have.property('value', 1); changed = await modelHub.undo('editorA'); await (0, chai_1.expect)(getModelA()).to.eventually.be.deep.equal(originalModelA); await (0, chai_1.expect)(getModelA()).to.eventually.have.property('value', 5); (0, chai_1.expect)(changed).to.be.true; changed = await modelHub.redo('editorA'); await (0, chai_1.expect)(getModelA()).to.eventually.have.property('value', 1); (0, chai_1.expect)(changed).to.be.true; modelHub.flush('editorA'); await modelHub.undo('editorA'); // Undo is no longer available after flush; value shouldn't change await (0, chai_1.expect)(getModelA()).to.eventually.have.property('value', 1); }); it('validation subscription', async () => { createModelHub(testContributionA, testContributionB); // We do not want live validation for this test modelHub.liveValidation = false; const subA = modelHub.subscribe(MODEL_A_ID); const subB = modelHub.subscribe(MODEL_B_ID); let diagA; let diagB; subA.onModelValidated = (_modelId, _model, diagnostic) => { diagA = diagnostic; }; subB.onModelValidated = (_modelId, _model, diagnostic) => { diagB = diagnostic; }; await modelHub.validateModels(MODEL_A_ID, MODEL_B_ID); (0, chai_1.expect)(diagA).to.not.be.undefined; (0, chai_1.expect)(diagB).to.not.be.undefined; if (diagA && diagB) { (0, chai_1.expect)(diagA.severity).to.be.equal('ok'); (0, chai_1.expect)(diagB.severity).to.be.equal('ok'); } // Close first subscription; subB should still work, but we shouldn't receive // changes from subA diagA = undefined; subA.close(); await modelHub.validateModels(MODEL_A_ID, MODEL_B_ID); (0, chai_1.expect)(diagA).to.be.undefined; (0, chai_1.expect)(diagB).to.not.be.undefined; if (diagB) { (0, chai_1.expect)(diagB.severity).to.be.equal('ok'); } }); it('live validation', async () => { createModelHub(model_service_model_manager_1.createModelServiceModelManager, testContributionA, testContributionB); (0, chai_1.expect)(modelHub.liveValidation, 'live validation must be on by default').to .be.true; const spy = sandbox.spy(modelHub, 'validateModels'); const sub = modelHub.subscribe(MODEL_A_ID, MODEL_B_ID); sub.onModelValidated = sandbox.stub(); await modelHub.getModel(MODEL_A_ID); await asyncsResolved(); (0, chai_1.expect)(sub.onModelValidated, 'model not validated on load').to.have.been.calledWithMatch(MODEL_A_ID, sinon_1.default.match.any, (0, model_validation_1.ok)()); (0, chai_1.expect)(spy).to.have.been.calledOnceWithExactly('test.extA'); spy.resetHistory(); await editA(12); await editB(8); await asyncsResolved(); (0, chai_1.expect)(sub.onModelValidated).to.have.been.calledWithMatch(MODEL_A_ID, sinon_1.default.match.any, { severity: 'error', source: 'ValidatorA', }); (0, chai_1.expect)(sub.onModelValidated).to.have.been.calledWithMatch(MODEL_B_ID, sinon_1.default.match.any, { severity: 'error', source: 'ValidatorB', }); (0, chai_1.expect)(spy).to.have.been.calledThrice; // B once for model load and once for change (0, chai_1.expect)(spy).to.have.been.calledWithExactly('test.extA'); (0, chai_1.expect)(spy).to.have.been.calledWithExactly('test.extB'); await undoA(); await undoB(); await asyncsResolved(); (0, chai_1.expect)(sub.onModelValidated).to.have.been.calledWithMatch(MODEL_A_ID, sinon_1.default.match.any, (0, model_validation_1.ok)()); (0, chai_1.expect)(sub.onModelValidated).to.have.been.calledWithMatch(MODEL_B_ID, sinon_1.default.match.any, (0, model_validation_1.ok)()); modelHub.liveValidation = false; sandbox.reset(); // Forget the history of calls await redoA(); await redoB(); await asyncsResolved(); (0, chai_1.expect)(sub.onModelValidated).not.to.have.been.called; (0, chai_1.expect)(spy).not.to.have.been.called; // try to turn it off again modelHub.liveValidation = false; (0, chai_1.expect)(modelHub.liveValidation).to.be.false; await undoA(); await undoB(); await asyncsResolved(); (0, chai_1.expect)(sub.onModelValidated).not.to.have.been.called; (0, chai_1.expect)(spy).not.to.have.been.called; }); it('live validation off', async () => { createModelHub(model_service_model_manager_1.createModelServiceModelManager, testContributionA, testContributionB); modelHub.liveValidation = false; const spy = sandbox.spy(modelHub, 'validateModels'); const sub = modelHub.subscribe(MODEL_A_ID, MODEL_B_ID); sub.onModelValidated = sandbox.stub(); await modelHub.getModel(MODEL_A_ID); await asyncsResolved(); (0, chai_1.expect)(sub.onModelValidated, 'model validated on load').not.to.have.been .called; (0, chai_1.expect)(spy).not.to.have.been.called; }); it('multiple subscriptions', async () => { createModelHub(testContributionA, testContributionB); const sub1 = modelHub.subscribe(MODEL_A_ID); const sub2 = modelHub.subscribe(MODEL_A_ID); let lastModelId1; let lastModel1; let lastChange1; let lastModelId2; let lastModel2; let lastChange2; sub1.onModelChanged = (modelId, model, delta) => { lastModelId1 = modelId; lastModel1 = model; lastChange1 = delta; }; sub2.onModelChanged = (modelId, model, delta) => { lastModelId2 = modelId; lastModel2 = model; lastChange2 = delta; }; await editA(7); (0, chai_1.expect)(lastModelId1).to.be.equal(lastModelId2); (0, chai_1.expect)(lastModel1).to.be.equal(lastModel2); (0, chai_1.expect)(lastChange1).to.be.equal(lastChange2); (0, chai_1.expect)(lastModelId1).to.be.equal(MODEL_A_ID); (0, chai_1.expect)(lastModel1).to.be.equal(await getModelA()); const change = lastChange1?.filter((op) => op.op !== 'test'); (0, chai_1.expect)(change).to.be.an('array').of.length(1); if (change !== undefined) { (0, chai_1.expect)(change[0].op).to.be.equal('replace'); if (change[0].op === 'replace') { (0, chai_1.expect)(change[0].value).to.be.equal(7); } } }); it('subscribe to multiple models', async () => { createModelHub(testContributionA, testContributionB); // We do not want live validation for this test modelHub.liveValidation = false; const sub = modelHub.subscribe(MODEL_A_ID, MODEL_B_ID); let onModelChanged; sub.onModelChanged = onModelChanged = sinon_1.default.stub(); let onModelValidated; sub.onModelValidated = onModelValidated = sinon_1.default.stub(); await editA(7); const modelA = await getModelA(); sinon_1.default.assert.calledWith(onModelChanged, MODEL_A_ID, modelA); await editB(42); const modelB = await getModelB(); sinon_1.default.assert.calledWith(onModelChanged, MODEL_B_ID, modelB); await modelHub.validateModels(MODEL_A_ID, MODEL_B_ID); sinon_1.default.assert.calledWith(onModelValidated, MODEL_A_ID, modelA); sinon_1.default.assert.calledWith(onModelValidated, MODEL_B_ID, modelB); }); it('subscribe to all models', async () => { createModelHub(testContributionA, testContributionB); // We do not want live validation for this test modelHub.liveValidation = false; const sub = modelHub.subscribe(); let onModelChanged; sub.onModelChanged = onModelChanged = sinon_1.default.stub(); let onModelValidated; sub.onModelValidated = onModelValidated = sinon_1.default.stub(); await editA(7); const modelA = await getModelA(); sinon_1.default.assert.calledWith(onModelChanged, MODEL_A_ID, modelA); await editB(42); const modelB = await getModelB(); sinon_1.default.assert.calledWith(onModelChanged, MODEL_B_ID, modelB); await modelHub.validateModels(MODEL_A_ID, MODEL_B_ID); sinon_1.default.assert.calledWith(onModelValidated, MODEL_A_ID, modelA); sinon_1.default.assert.calledWith(onModelValidated, MODEL_B_ID, modelB); }); it('subscription sees consistent model (T2CMN-274)', async () => { createModelHub(testContributionA, testContributionB); const sub = modelHub.subscribe(); let sawCorrectModelId = false; let sawCorrectModelObject = false; let sawExpectedModelChange = false; const gatherAssertions = async (modelId, model) => { sawCorrectModelId = modelId === MODEL_B_ID; const currentModelB = await getModelB(); sawCorrectModelObject = currentModelB === model; sawExpectedModelChange = currentModelB.value === 42; }; let assertions = Promise.reject(new Error('onModelChange not called')); sub.onModelChanged = (modelId, model) => (assertions = gatherAssertions(modelId, model)); await editB(42); await assertions; (0, chai_1.expect)(sawCorrectModelId, 'incorrect model ID in subscription call-back').to .be.true; (0, chai_1.expect)(sawCorrectModelObject, 'incorrect model retrieved from hub during subscription call-back').to.be.true; (0, chai_1.expect)(sawExpectedModelChange, 'model retrieved during call-back was not yet changed').to.be.true; }); it('close subscriptions', async () => { createModelHub(testContributionA); await getModelA(); const sub1 = modelHub.subscribe(MODEL_A_ID); const sub2 = modelHub.subscribe(MODEL_A_ID); try { sub1.close(); sub1.close(); sub2.close(); sub2.close(); } catch (error) { (0, assert_1.fail)("calling ModelServiceSubscription.close() multiple times shouldn't cause an error"); } }); it('close a universal subscription', async () => { createModelHub(testContributionA, testContributionB); await getModelA(); await getModelB(); const sub = modelHub.subscribe(); let onModelChanged; sub.onModelChanged = onModelChanged = sinon_1.default.stub(); let onModelValidated; sub.onModelValidated = onModelValidated = sinon_1.default.stub(); sub.close(); await editA(7); await editB(42); sinon_1.default.assert.notCalled(onModelChanged); await modelHub.validateModels(MODEL_A_ID, MODEL_B_ID); sinon_1.default.assert.notCalled(onModelValidated); }); it('empty subscriptions', async () => { createModelHub(testContributionA); await getModelA(); const emptySub = modelHub.subscribe(MODEL_A_ID); try { await modelHub.validateModels(MODEL_A_ID); await editA(13); } catch (error) { (0, assert_1.fail)("empty subscriptions shouldn't cause errors"); } emptySub.close(); }); it('non-existent model service', () => { createModelHub(testContributionA); const service = modelHub.getModelService(MODEL_B_ID); (0, chai_1.expect)(service).to.be.undefined; }); it('save undefined model', async () => { createModelHub(testContributionA); // Save a model that hasn't been loaded (yet) try { modelHub.save('editorA'); } catch (error) { (0, assert_1.fail)("saving a model that hasn't yet been loaded shouldn't throw an error"); } }); it('save unsupported model', async () => { createModelHub(testContributionA); modelManager.setModel('test.extQ', {}); await modelManager.getCommandStack('editorQ').execute(updateModel('Edit Q', 'test.extQ', (model) => { model.value = 42; })); // No registered contribution handles this try { await modelHub.save('editorQ'); } catch (error) { // Expected error return; } (0, assert_1.fail)("saving a model that isn't supported by any contribution should throw an error"); }); it('undefined validation state', () => { createModelHub(testContributionA); // Get validation state for a model that doesn't exist let diagnostic = modelHub.getValidationState(MODEL_B_ID); (0, chai_1.expect)(diagnostic).not.to.exist; // Get validation state for a model that hasn't been loaded yet diagnostic = modelHub.getValidationState(MODEL_A_ID); (0, chai_1.expect)(diagnostic).not.to.exist; }); describe('dispose', () => { it('the model hub', () => { createModelHub(testContributionA, testContributionB); modelHub.liveValidation = true; (0, chai_1.expect)(modelHub.isDisposed).to.be.false; modelHub.dispose(); (0, chai_1.expect)(modelHub.isDisposed).to.be.true; }); it('the model service contributions', () => { const disposeA = sandbox.stub().throws(); Object.assign(testContributionA, { dispose: disposeA }); const disposeB = sandbox.stub(); Object.assign(testContributionB, { dispose: disposeB }); const consoleStub = sandbox.stub(console, 'error'); createModelHub(testContributionA, testContributionB); modelHub.dispose(); (0, chai_1.expect)(disposeA).to.have.been.called; (0, chai_1.expect)(consoleStub).to.have.been.calledWithMatch('model-service/model-hub-impl:', /Uncaught exception.*/); (0, chai_1.expect)(disposeB).to.have.been.called; }); it('notifies subscribers', () => { const modelSub = modelHub.subscribe('test.extA'); const closeSpy = sandbox.spy(modelSub, 'close'); const hubSub = modelHub.subscribe(); const onDispose = sandbox.stub(); hubSub.onModelHubDisposed = onDispose; modelHub.dispose(); (0, chai_1.expect)(onDispose).to.have.been.called; (0, chai_1.expect)(closeSpy).to.have.been.called; }); }); it('no multiple concurrent lazy loading', async () => { createModelHub(new TestContributionC2()); modelHub.liveValidation = false; const setModelSpy = sandbox.spy(modelManager, 'setModel'); // initiate multiple concurrent accesses to the unloaded model, requiring lazy load const promises = [ modelHub.getModel('test.extC'), modelHub.getModel('test.extC'), modelHub.getModel('test.extC'), ]; const actual = await Promise.all(promises); (0, chai_1.expect)(actual).to.have.length.at.least(3); (0, chai_1.expect)(actual[0]).to.be.deep.equal({ type: 'modelC', id: 'testModelC', value: 1, }); (0, chai_1.expect)(actual[1]).to.equal(actual[0], 'not the same model instance'); (0, chai_1.expect)(actual[2]).to.equal(actual[0], 'not the same model instance'); (0, chai_1.expect)(modelManager.getModel('test.extC')).to.equal(actual[0]); // Nothing remains pending const pending = modelHub.pendingLoads; (0, chai_1.expect)(pending).to.be.empty; // Only one attempt to set the lazily loaded model (0, chai_1.expect)(setModelSpy).to.be.calledOnceWith('test.extC', actual[0]); }); describe('Model Accessors', () => { it('model accessor bus', () => { const spy = sandbox.spy(testContributionA, 'setModelAccessorBus'); createModelHub(testContributionA); const modelAccessorBus = modelHub.getModelAccessorBus(); (0, chai_1.expect)(modelAccessorBus).to.exist; (0, chai_1.expect)(spy).to.have.been.calledWithExactly(modelAccessorBus); }); it('registers model accessor provider', async () => { createModelHub(); const modelAccessorBus = modelHub.getModelAccessorBus(); const spy = sandbox.spy(modelAccessorBus, 'register'); modelHub.addModelServiceContribution(testContributionA); (0, chai_1.expect)(spy).to.have.been.calledTwice; (0, chai_1.expect)(spy).to.have.been.calledWith(modelAccessorProvider); (0, chai_1.expect)(spy).to.have.been.calledWith(hubAwareProvider); }); it('sets model hub to hub-aware provider', async () => { const spy = sandbox.spy(hubAwareProvider, 'setModelHub'); createModelHub(testContributionA); (0, chai_1.expect)(spy).to.have.been.calledOnceWith(modelHub); }); }); describe('Model Triggers', () => { let testContributionC; let addTriggerSpy; let adaptTriggerSpy; const getModelC = () => modelHub.getModel(MODEL_C_ID); const editC = async (id) => { // Ensure existence of the model before trying to edit it await getModelC(); const command = updateModelC((model) => { model.id = id; }); await modelManager.getCommandStack('editorC').execute(command); }; const undoC = () => modelHub.undo('editorC'); const redoC = () => modelHub.redo('editorC'); beforeEach(() => { persistedModelC = { ...originalModelC }; testContributionC = new TestContributionC(); addTriggerSpy = sandbox.spy(trigger_engine_1.TriggerEngineImpl.prototype, 'addTrigger'); adaptTriggerSpy = sandbox.spy(model_trigger_engine_1.ModelTriggerEngine.prototype, 'adaptTrigger'); createModelHub(model_service_model_manager_1.createModelServiceModelManager, testContributionC); }); it('registers triggers in order of provision', () => { (0, chai_1.expect)(adaptTriggerSpy).to.have.been.calledTwice; (0, chai_1.expect)(addTriggerSpy).to.have.been.calledTwice; const triggerWrapping = new Map(); for (let i = 0; i < adaptTriggerSpy.callCount; i++) { triggerWrapping.set(adaptTriggerSpy.args[i][0], adaptTriggerSpy.returnValues[i]); } const firstTrigger = addTriggerSpy.args[0][0]; const secondTrigger = addTriggerSpy.args[1][0]; (0, chai_1.expect)(firstTrigger).to.be.equal(triggerWrapping.get(triggerC)); (0, chai_1.expect)(secondTrigger).to.be.like(triggerWrapping.get(dummyTrigger)); }); it('applies triggers on execute', async () => { await editC('abcdefghijklmnopqrstuvwxyz'); const modelC = await getModelC(); (0, chai_1.expect)(modelC.value).to.equal(26); }); it('unless we have no trigger engine', async () => { // Create a hub without trigger engine createModelHub(model_manager_1.createModelManager, testContributionC); const oldValue = (await getModelC()).value; await editC('abcdefghijklmnopqrstuvwxyz'); const modelC = await getModelC(); (0, chai_1.expect)(modelC.value).to.equal(oldValue); }); it('applies triggers on executeAndAppend', async () => { // Load model C await getModelC(); // This doesn't hit the trigger let command = updateModelC((model) => { model.value = 42; }); await modelManager.getCommandStack('editorC').execute(command); await (0, chai_1.expect)(getModelC()).to.eventually.have.property('value', 42); // But this does command = updateModelC((model) => { model.id = 'abcdefghijklmnopqrstuvwxyz'; }); await modelManager.getCommandStack('editorC').executeAndAppend(command); await (0, chai_1.expect)(getModelC()).to.eventually.have.property('value', 26); }); it('applies triggers on undo', async function () { const oldValue = (await getModelC()).value; assumeThat(this, 'cannot detect change in value', () => oldValue !== 26); await editC('abcdefghijklmnopqrstuvwxyz'); const baseline = await getModelC(); assumeThat(this, 'execute case fails', () => baseline.value === 26); await undoC(); await (0, chai_1.expect)(getModelC()).to.eventually.have.property('value', oldValue); }); it('applies triggers on redo', async function () { const oldValue = (await getModelC()).value; assumeThat(this, 'cannot detect change in value', () => oldValue !== 26); await editC('abcdefghijklmnopqrstuvwxyz'); let baseline = await getModelC(); assumeThat(this, 'execute case fails', () => baseline.value === 26); await undoC(); baseline = await getModelC(); assumeThat(this, 'undo case fails', () => baseline.value === oldValue); await redoC(); await (0, chai_1.expect)(getModelC()).to.eventually.have.property('value', 26); }); }); describe('Model Load Subscriptions', () => { it('universal', async () => { createModelHub(model_service_model_manager_1.createModelServiceModelManager, testContributionA, testContributionB); const sub = modelHub.subscribe(); sub.onModelLoaded = sandbox.stub(); await modelHub.getModel('test.extA'); await asyncsResolved(); (0, chai_1.expect)(sub.onModelLoaded, 'model A load not notified').to.have.been.calledWith('test.extA'); await modelHub.getModel('test.extB'); await asyncsResolved(); (0, chai_1.expect)(sub.onModelLoaded, 'model B load not notified').to.have.been.calledWith('test.extB'); }); it('targeted', async () => { createModelHub(model_service_model_manager_1.createModelServiceModelManager, testContributionA, testContributionB); const sub = modelHub.subscribe('test.extA'); sub.onModelLoaded = sandbox.stub(); await modelHub.getModel('test.extA'); await asyncsResolved(); (0, chai_1.expect)(sub.onModelLoaded, 'model A load not notified').to.have.been.calledWith('test.extA'); await modelHub.getModel('test.extB'); await asyncsResolved(); (0, chai_1.expect)(sub.onModelLoaded, 'model B load notified').not.to.have.been.calledWith('test.extB'); }); it('close', async () => { createModelHub(model_service_model_manager_1.createModelServiceModelManager, testContributionA, testContributionB); const sub = modelHub.subscribe(); sub.onModelLoaded = sandbox.stub(); await modelHub.getModel('test.extA'); await asyncsResolved(); (0, chai_1.expect)(sub.onModelLoaded, 'model A load not notified').to.have.been.calledWith('test.extA'); sub.close(); await modelHub.getModel('test.extB'); await asyncsResolved(); (0, chai_1.expect)(sub.onModelLoaded, 'model B load notified').not.to.have.been.calledWith('test.extB'); }); it('exception', async () => { createModelHub(model_service_model_manager_1.createModelServiceModelManager, testContributionA, testContributionB); const sub = modelHub.subscribe(); sub.onModelLoaded = sandbox.stub().throws(); const consoleStub = sandbox.stub(console, 'error'); await modelHub.getModel('test.extA'); await asyncsResolved(); (0, chai_1.expect)(consoleStub, 'Error not logged').to.have.been.calledWith(sinon_1.default.match('model-service/model-hub-impl:'), sinon_1.default.match(/Uncaught exception.*/), sinon_1.default.match.instanceOf(Error)); }); it('model creation', async () => { createModelHub(model_service_model_manager_1.createModelServiceModelManager); const sub = modelHub.subscribe(); sub.onModelLoaded = sandbox.stub(); modelManager.setModel('test', { name: 'Test Model' }); (0, chai_1.expect)(sub.onModelLoaded, 'model creation not notified as load').to.have .been.called; }); }); describe('Model Unload Subscriptions', () => { it('universal', async () => { createModelHub(model_service_model_manager_1.createModelServiceModelManager, testContributionA, testContributionB); const modelA = await modelHub.getModel('test.extA'); await asyncsResolved(); const sub = modelHub.subscribe(); sub.onModelUnloaded = sandbox.stub(); modelManager.removeModel('test.extA'); (0, chai_1.expect)(sub.onModelUnloaded, 'model A unload not notified').to.have.been.calledWith('test.extA', modelA); }); it('targeted', async () => { createModelHub(model_service_model_manager_1.createModelServiceModelManager, testContributionA, testContributionB); const modelA = await modelHub.getModel('test.extA'); await modelHub.getModel('test.extB'); await asyncsResolved(); const sub = modelHub.subscribe('test.extA'); sub.onModelUnloaded = sandbox.stub(); modelManager.removeModel('test.extA'); (0, chai_1.expect)(sub.onModelUnloaded, 'model A unload not notified').to.have.been.calledWith('test.extA', modelA); modelManager.removeModel('test.extB'); (0, chai_1.expect)(sub.onModelUnloaded, 'model B unload notified').not.to.have.been.calledWithMatch('test.extB', sinon_1.default.match.any); }); it('exception', async () => { createModelHub(model_service_model_manager_1.createModelServiceModelManager, testContributionA, testContributionB); await modelHub.getModel('test.extA'); await asyncsResolved(); const sub = modelHub.subscribe(); sub.onModelUnloaded = sandbox.stub().throws(); const consoleStub = sandbox.stub(console, 'error'); modelManager.removeModel('test.extA'); (0, chai_1.expect)(consoleStub, 'Error not logged').to.have.been.calledWith(sinon_1.default.match('model-service/model-hub-impl:'), sinon_1.default.match(/Uncaught exception.*/), sinon_1.default.match.instanceOf(Error)); }); }); describe('Corner Cases', () => { let stack; beforeEach(async () => { // Force some unusual conditions just for these tests stack = modelManager.getCommandStack('editorA'); stack.canUndo = () => Promise.resolve(true); stack.canRedo = () => Promise.resolve(true); modelManager.getCommandStack = () => stack; const validationService = new model_validation_1.ModelValidationServiceImpl(); const modelAccessorBus = new model_accessor_bus_1.ModelAccessorBusImpl(); modelHub = new model_hub_impl_1.ModelHubImpl(testContext, modelManager, validationService, modelAccessorBus); }); it('undo returning an empty model diff', async () => { stack.undo = () => Promise.resolve(new Map()); const result = await modelHub.undo('editorA'); (0, chai_1.expect)(result).to.be.false; }); it('redo returning an empty model diff', async () => { stack.redo = () => Promise.resolve(new Map()); const result = await modelHub.redo('editorA'); (0, chai_1.