@eclipse-emfcloud/model-service
Version:
Model service framework
374 lines (326 loc) • 12 kB
text/typescript
// *****************************************************************************
// 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
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import chai, { expect } from 'chai';
import chaiLike from 'chai-like';
import chaiThings from 'chai-things';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import {
Command,
CommandStack,
CompoundCommandImpl,
CoreModelManager,
createModelPatchCommand,
createModelUpdaterCommand,
} from '@eclipse-emfcloud/model-manager';
import { WorkingCopyManager } from '@eclipse-emfcloud/model-manager/lib/impl/core-command-stack-impl';
import {
addOperations,
addOrReplaceOperations,
} from '@eclipse-emfcloud/trigger-engine';
import { getValueByPointer, Operation } from 'fast-json-patch';
import { cloneDeep } from 'lodash';
import { ModelTrigger } from '../../api/model-trigger';
import {
createModelServiceModelManager,
ModelManagerSubscription,
ModelServiceModelManager,
} from '../model-service-model-manager';
chai.use(chaiLike);
chai.use(chaiThings);
chai.use(sinonChai);
const alice = {
name: 'Alice',
id: '88BB5693-D51C-4BAE-9CC0-197E51304390',
registeredOn: '19-07-2023',
};
const bob = { name: 'Bob', id: 'A713B4E9-E5D5-4F23-8FF2-FD39800A08D5' };
const modelAImage = {
name: 'Customer Data',
lastRegistration: alice.registeredOn,
customers: [alice],
};
const keyboard = { sku: '11342720', name: 'keyboard' };
const mouse = { sku: '11855065', name: 'mouse' };
const aliceKeyboard = {
customer: alice.id,
sku: keyboard.sku,
date: '24-07-2023',
};
const bobMouse = { customer: bob.id, sku: mouse.sku };
const modelBImage = {
name: 'Business Data',
lastOrder: aliceKeyboard.date,
products: [keyboard, mouse],
orders: [aliceKeyboard],
};
type ModelA = typeof modelAImage;
type ModelB = typeof modelBImage;
type Customer = ModelA['customers'][number];
type Order = ModelB['orders'][number];
const MODEL_A_ID = 'test://38BCC304-652F-4C27-A225-81BF6C8EDCA8/model';
const MODEL_B_ID = 'test://4CE1EB0C-99AA-4ABE-B531-7618ED1174DC/model';
const MODEL_A2_ID = 'test://7D0A76E0-BB74-4F99-9FEF-D2A66874D785/model';
describe('ModelServiceModelManager', () => {
let modelManager: ModelServiceModelManager<string>;
let commandStack: CommandStack;
const getModelA = () => modelManager.getModel<ModelA>(MODEL_A_ID)!;
const getModelB = () => modelManager.getModel<ModelB>(MODEL_B_ID)!;
beforeEach(() => {
const modelA = cloneDeep(modelAImage);
const modelB = cloneDeep(modelBImage);
modelManager = createModelServiceModelManager();
modelManager.setModel(MODEL_A_ID, modelA);
modelManager.setModel(MODEL_B_ID, modelB);
modelManager.triggerEngine.addModelTrigger(customerRegistration);
modelManager.triggerEngine.addModelTrigger(orderDate);
commandStack = modelManager.getCommandStack('test');
});
describe('multiple models', () => {
it('multiple models triggered', async () => {
const addBob = createModelUpdaterCommand<string, ModelA>(
'Add Bob',
MODEL_A_ID,
(model) => {
model.customers.push(cloneDeep(bob) as Customer);
}
);
const orderMouse = createModelUpdaterCommand<string, ModelB>(
'Order Mouse',
MODEL_B_ID,
(model) => {
model.orders.push(cloneDeep(bobMouse) as Order);
}
);
const commandResult = await commandStack.execute(
new CompoundCommandImpl('Add Bob and Order Mouse', addBob, orderMouse)
);
// Assertions on models
expect(getModelA().customers).to.contain.an.item.like({
...bob,
registeredOn: '24-07-2023',
});
expect(getModelB().orders).to.contain.an.item.like({
...bobMouse,
date: '24-07-2023',
});
// Assertions on command result
expect(commandResult).to.exist;
const triggerCommands = Array.from(commandResult!.entries()).filter(
([key, _]) => key.label === 'Apply triggers'
);
// One per model
expect(triggerCommands).to.have.length.at.least(2);
expect(triggerCommands).to.contain.an.item.like([
{ modelId: MODEL_A_ID },
[
{ op: 'add', path: '/customers/1/registeredOn' },
{ op: 'test' },
{ op: 'replace', path: '/lastRegistration' },
],
]);
// The ModelB trigger didn't have to update /lastOrder
expect(triggerCommands).to.contain.an.item.like([
{ modelId: MODEL_B_ID },
[{ op: 'add', path: '/orders/1/date' }],
]);
});
});
describe('change notifications', () => {
it('notification includes trigger changes', async () => {
const sub = modelManager.subscribe(MODEL_A_ID);
const modelChanged = sinon.stub();
sub.onModelChanged = modelChanged;
const addBob = createModelUpdaterCommand<string, ModelA>(
'Add Bob',
MODEL_A_ID,
(model) => {
model.customers.push(cloneDeep(bob) as Customer);
}
);
await commandStack.execute(addBob);
sinon.assert.calledOnceWithMatch(modelChanged, MODEL_A_ID, getModelA(), [
// The initial addition of the object
{
op: 'add',
path: '/customers/1',
value: { name: 'Bob', id: 'A713B4E9-E5D5-4F23-8FF2-FD39800A08D5' },
},
// The trigger setting the registeredOn property of the new object
{ op: 'add', path: '/customers/1/registeredOn', value: '24-07-2023' },
sinon.match({ op: 'test' }),
{
op: 'replace',
path: '/lastRegistration',
value: '24-07-2023',
},
]);
});
});
describe('load notifications', () => {
let modelA2: ModelA;
beforeEach(() => {
modelA2 = cloneDeep(modelAImage);
});
it('notifies on model added', () => {
const sub: ModelManagerSubscription = modelManager.subscribe();
const modelLoaded = sinon.stub();
sub.onModelLoaded = modelLoaded;
modelManager.setModel(MODEL_A2_ID, modelA2);
sinon.assert.calledOnceWithExactly(modelLoaded, MODEL_A2_ID);
});
});
describe('unload notifications', () => {
let modelA2: ModelA;
beforeEach(() => {
modelA2 = cloneDeep(modelAImage);
modelManager.setModel(MODEL_A2_ID, modelA2);
});
it('notifies on model removed', () => {
const sub: ModelManagerSubscription = modelManager.subscribe();
const modelUnloaded = sinon.stub();
sub.onModelUnloaded = modelUnloaded;
const removed = modelManager.removeModel(MODEL_A2_ID);
sinon.assert.calledOnceWithExactly(modelUnloaded, MODEL_A2_ID, removed);
});
it("doesn't notify on absent model", () => {
modelManager.removeModel(MODEL_A2_ID);
const sub: ModelManagerSubscription = modelManager.subscribe();
const modelUnloaded = sinon.stub();
sub.onModelUnloaded = modelUnloaded;
modelManager.removeModel(MODEL_A2_ID);
sinon.assert.notCalled(modelUnloaded);
});
});
describe('Corner Cases', () => {
let applyTriggers: (
commandResult: Map<Command, Operation[]> | undefined
) => Promise<Command | undefined>;
let exposedCoreModelManager: CoreModelManager<string> & {
_modelStore: WorkingCopyManager;
applyTriggers(
workingCopyManager: WorkingCopyManager,
commandResult: Map<Command, Operation[]> | undefined
): Promise<Command | undefined>;
};
let exposedModelManager: ModelServiceModelManager<string> & {
delegate: typeof exposedCoreModelManager;
};
beforeEach(() => {
exposedModelManager =
modelManager as unknown as typeof exposedModelManager;
exposedCoreModelManager = exposedModelManager.delegate;
const workingCopyManager = exposedCoreModelManager._modelStore;
workingCopyManager.open([MODEL_A_ID, MODEL_A2_ID, MODEL_B_ID]); // Need it open to test triggers with it
applyTriggers = (commandResult) =>
exposedCoreModelManager.applyTriggers(
workingCopyManager,
commandResult
);
});
it('correctly applies triggers to undefined command results', async () => {
const result = applyTriggers(undefined);
return expect(result).eventually.not.to.exist;
});
it('correctly processes triggers for result of command that has no model', async () => {
const delta1: Operation[] = [
{ op: 'add', path: '/orders/0', value: aliceKeyboard },
];
const badCommand = createModelPatchCommand(
'test',
MODEL_B_ID,
() => delta1
);
sinon.stub(badCommand, 'modelId').value(undefined);
getModelA().customers.push(cloneDeep(bob) as typeof alice);
const delta2: Operation[] = [
{ op: 'add', path: '/customers/1', value: bob },
];
const goodCommand = createModelPatchCommand(
'test',
MODEL_A_ID,
() => delta2
);
const triggeringResults = new Map<Command, Operation[]>([
[badCommand, delta1],
[goodCommand, delta2],
]);
const result = await applyTriggers(triggeringResults);
expect(result).to.exist;
expect(result).to.be.like({
label: 'Apply triggers',
modelId: MODEL_A_ID,
state: 'ready',
});
});
it('tolerates CompoundCommands in the triggering command-result map', async () => {
const delta1: Operation[] = [
{ op: 'add', path: '/orders/0', value: aliceKeyboard },
];
const badCommand = new CompoundCommandImpl('test');
getModelA().customers.push(cloneDeep(bob) as typeof alice);
const delta2: Operation[] = [
{ op: 'add', path: '/customers/1', value: bob },
];
const goodCommand = createModelPatchCommand(
'test',
MODEL_A_ID,
() => delta2
);
const triggeringResults = new Map<Command, Operation[]>([
[badCommand, delta1],
[goodCommand, delta2],
]);
const result = await applyTriggers(triggeringResults);
expect(result).to.exist;
expect(result).to.be.like({
label: 'Apply triggers',
modelId: MODEL_A_ID,
state: 'ready',
});
});
});
});
function set(model: object, path: string, value: unknown): Operation {
const op = getValueByPointer(model, path) === undefined ? 'add' : 'replace';
return { op, path, value };
}
/** A trigger that initializes the registration date of a newly added customer. */
const customerRegistration: ModelTrigger<string, ModelA> = {
getPatch: async (modelId, model, delta) => {
const op = addOperations(delta)[0];
if (modelId == MODEL_A_ID && op && /^\/customers\/\d+$/.test(op.path)) {
return [
set(model, `${op.path}/registeredOn`, '24-07-2023'),
set(model, '/lastRegistration', '24-07-2023'),
];
}
return undefined;
},
};
/** A trigger that initializes the date of a newly added order. */
const orderDate: ModelTrigger<string, ModelB> = {
getPatch: (modelId, model, delta) => {
const op = addOrReplaceOperations(delta)[0];
if (op && modelId == MODEL_B_ID && /^\/orders\/\d+$/.test(op.path)) {
return [
set(model, `${op.path}/date`, '24-07-2023'),
set(model, '/lastOrder', '24-07-2023'),
];
}
return undefined;
},
};