@eclipse-emfcloud/model-service
Version:
Model service framework
992 lines • 58.3 kB
JavaScript
"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.