UNPKG

@eclipse-emfcloud/model-manager

Version:

Command-based model editing with undo/redo.

1,099 lines (908 loc) 31.1 kB
// ***************************************************************************** // 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 // ***************************************************************************** import chai, { expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { CanExecutePredicate, ModelPatchFunction, ModelUpdater, MultiPatchCommand, PatchCommand, createModelPatchCommand, createModelUpdaterCommand, createModelUpdaterCommandWithResult, } from '../patch-command'; import { fail } from 'assert'; import chaiLike from 'chai-like'; import { Operation } from 'fast-json-patch'; import cloneDeep from 'lodash/cloneDeep'; import { groupByModelId } from '../../core'; chai.use(chaiLike); chai.use(chaiAsPromised); chai.use(sinonChai); describe('PatchCommand', () => { let sandbox: sinon.SinonSandbox; beforeEach(() => { sandbox = sinon.createSandbox(); }); afterEach(() => { sandbox.restore(); }); it('Simple execute/undo/redo', async () => { const patch: Operation[] = [ { op: 'replace', path: '/id', value: 'updated-test-id', } as Operation, ]; const document = cloneDeep(testDocument); const expectedDocument = cloneDeep(testDocument); expectedDocument.id = 'updated-test-id'; const command = new PatchCommand('Test Command', 'document', patch); // Execute await expect(command.canExecute(document)).to.eventually.be.true; const executeResult = await command.execute(document); expect(document).to.deep.equal(expectedDocument); expect(executeResult).to.not.be.undefined; if (executeResult) { // Should contain a single 'replace' operation (and maybe some optional 'test' operations) const operations = executeResult.filter( (operation) => operation.op !== 'test' ); expect(operations).to.be.an('array').of.length(1); expect(operations[0].op).to.equal('replace'); } // Undo expect(command.canUndo(document)).to.be.true; command.undo(document); expect(document).to.deep.equal(testDocument); // Redo expect(command.canRedo(document)).to.be.true; command.redo(document); expect(document).to.deep.equal(expectedDocument); }); it('Invalid patch', async () => { const invalidPatch: Operation[] = [ { op: 'remove', path: '/wrong-id', }, ]; const document = cloneDeep(testDocument); const expectedDocument = cloneDeep(testDocument); const invalidCommand = new PatchCommand( 'Test Command', 'document', invalidPatch ); // Currently, we don't validate commands, so canExecute is always true await expect(invalidCommand.canExecute(document)).to.eventually.be.true; const executeResult = await invalidCommand.execute(document); expect(document).to.deep.equal(expectedDocument); expect(executeResult).to.not.be.undefined; expect(executeResult).to.be.an('array').empty; }); it('Command lifecycle', async () => { const patch: Operation[] = [ { op: 'replace', path: '/id', value: 'updated-test-id', } as Operation, ]; const document = cloneDeep(testDocument); const command = new PatchCommand('Test Command', 'document', patch); await expect(command.canExecute(document)).to.eventually.be.true; expect(command.canUndo(document)).to.be.false; expect(command.canRedo(document)).to.be.false; await command.execute(document); await expect(command.canExecute(document)).to.eventually.be.false; expect(command.canUndo(document)).to.be.true; expect(command.canRedo(document)).to.be.false; command.undo(document); await expect(command.canExecute(document)).to.eventually.be.false; expect(command.canUndo(document)).to.be.false; expect(command.canRedo(document)).to.be.true; command.redo(document); await expect(command.canExecute(document)).to.eventually.be.false; expect(command.canUndo(document)).to.be.true; expect(command.canRedo(document)).to.be.false; }); it('Command lifecycle (Invalid)', async () => { const patch: Operation[] = [ { op: 'replace', path: '/id', value: 'updated-test-id', } as Operation, ]; const document = cloneDeep(testDocument); const command = new PatchCommand('Test Command', 'document', patch); // Undo command before it is executed: throws an error let success = false; try { command.undo(document); success = true; } catch (error) { // Okay } if (success) { fail('Command should not be undoable'); } // Redo command before it is executed: throws an error success = false; try { command.redo(document); success = true; } catch (error) { // Okay } if (success) { fail('Command should not be redoable'); } await command.execute(document); // Execute command twice: throws an error success = false; try { await command.execute(document); success = true; } catch (error) { // Okay } if (success) { fail('Command should not be executable'); } // Redo command before it is undone: throws an error success = false; try { command.redo(document); success = true; } catch (error) { // Okay } if (success) { fail('Command should not be redoable'); } command.undo(document); // Execute command twice: throws an error (even after undo) success = false; try { await command.execute(document); success = true; } catch (error) { // Okay } if (success) { fail('Command should not be executable'); } // Undo command twice: throws an error success = false; try { command.undo(document); success = true; } catch (error) { // Okay } if (success) { fail('Command should not be undoable'); } }); describe('MultiPatchCommand', () => { it('1 Sub command', async () => { const patch: Operation[] = [ { op: 'replace', path: '/id', value: 'updated-test-id', } as Operation, ]; const document = cloneDeep(testDocument); const expectedDocument = cloneDeep(testDocument); expectedDocument.id = 'updated-test-id'; const command = new MultiPatchCommand('Test Multi Command', { modelId: 'test', patch, }); const getModel = (modelId: string) => modelId == 'test' ? document : undefined; // Execute await expect(command.canExecute(getModel)).to.eventually.be.true; const executeResult = await command.execute(getModel); expect(document).to.deep.equal(expectedDocument); expect(executeResult).to.not.be.undefined; if (executeResult) { const modelToPatch = groupByModelId(executeResult); expect(modelToPatch.size).to.equal(1); const resultPatch = modelToPatch.get('test'); expect(resultPatch).to.not.be.undefined; if (resultPatch !== undefined) { const operations = resultPatch.filter( (operation) => operation.op !== 'test' ); // Should contain a single 'replace' operation (and maybe some optional 'test' operations) expect(operations).to.be.an('array').of.length(1); expect(operations[0].op).to.equal('replace'); } } // Undo await expect(command.canUndo(getModel)).to.eventually.be.true; await command.undo(getModel); expect(document).to.deep.equal(testDocument); // Redo await expect(command.canRedo(getModel)).to.eventually.be.true; await command.redo(getModel); expect(document).to.deep.equal(expectedDocument); }); it('2 Sub commands', async () => { const patch1: Operation[] = [ { op: 'replace', path: '/id', value: 'updated-test-id', } as Operation, ]; const patch2: Operation[] = [ { op: 'add', path: '/children/-', value: { type: 'child-node', value: 890, valid: false, }, } as Operation, ]; const document1 = cloneDeep(testDocument); const document2 = cloneDeep(secondTestDocument); const expectedDocument1 = cloneDeep(testDocument); expectedDocument1.id = 'updated-test-id'; const expectedDocument2 = cloneDeep(secondTestDocument); expectedDocument2.children.push({ type: 'child-node', value: 890, valid: false, }); const command = new MultiPatchCommand( 'Test Multi Command', { modelId: 'document1', patch: patch1, }, { modelId: 'document2', patch: patch2, } ); const getModel = (modelId: string) => { switch (modelId) { case 'document1': return document1; case 'document2': return document2; default: return undefined; } }; // Execute // PatchCommand is synchronous, so MultiPatchCommand is, too await expect(command.canExecute(getModel)).to.eventually.be.true; const executeResult = await command.execute(getModel); expect(document1).to.deep.equal(expectedDocument1); expect(document2).to.deep.equal(expectedDocument2); expect(executeResult).to.not.be.undefined; if (executeResult) { const modelToPatch = groupByModelId(executeResult); expect(modelToPatch.size).to.equal(2); const resultPatch1 = modelToPatch.get('document1'); expect(resultPatch1).to.not.be.undefined; if (resultPatch1 !== undefined) { const operations = resultPatch1.filter( (operation) => operation.op !== 'test' ); // Should contain a single 'replace' operation (and maybe some optional 'test' operations) expect(operations).to.be.an('array').of.length(1); expect(operations[0].op).to.equal('replace'); } const resultPatch2 = modelToPatch.get('document2'); expect(resultPatch2).to.not.be.undefined; if (resultPatch2 !== undefined) { const operations = resultPatch2.filter( (operation) => operation.op !== 'test' ); // Should contain a single 'replace' operation (and maybe some optional 'test' operations) expect(operations).to.be.an('array').of.length(1); expect(operations[0].op).to.equal('add'); } } // Undo await expect(command.canUndo(getModel)).to.eventually.be.true; await command.undo(getModel); expect(document1).to.deep.equal(testDocument); expect(document2).to.deep.equal(secondTestDocument); // Redo await expect(command.canRedo(getModel)).to.eventually.be.true; await command.redo(getModel); expect(document1).to.deep.equal(expectedDocument1); expect(document2).to.deep.equal(expectedDocument2); }); it('2 Sub commands - 1 model', async () => { const patch1: Operation[] = [ { op: 'replace', path: '/id', value: 'updated-test-id', } as Operation, ]; const patch2: Operation[] = [ { op: 'add', path: '/children/-', value: { type: 'child-node', value: 90, valid: true, }, } as Operation, ]; const document = cloneDeep(testDocument); const expectedDocument = cloneDeep(testDocument); expectedDocument.id = 'updated-test-id'; expectedDocument.children.push({ type: 'child-node', value: 90, valid: true, }); const command = new MultiPatchCommand( 'Test Multi Command', { modelId: 'document', patch: patch1, }, { modelId: 'document', patch: patch2, } ); const getModel = (modelId: string) => modelId === 'document' ? document : undefined; // Execute await expect(command.canExecute(getModel)).to.eventually.be.true; const executeResult = await command.execute(getModel); expect(document).to.deep.equal(expectedDocument); expect(executeResult).to.not.be.undefined; if (executeResult) { const modelToPatch = groupByModelId(executeResult); expect(modelToPatch.size).to.equal(1); const resultPatch = modelToPatch.get('document'); expect(resultPatch).to.not.be.undefined; if (resultPatch !== undefined) { const operations = resultPatch.filter( (operation) => operation.op !== 'test' ); // Should contain one 'replace' operation and one 'add' operation // (and maybe some optional 'test' operations) expect(operations).to.be.an('array').of.length(2); expect(operations[0].op).to.equal('replace'); expect(operations[1].op).to.equal('add'); } } // Undo await expect(command.canUndo(getModel)).to.eventually.be.true; await command.undo(getModel); expect(document).to.deep.equal(testDocument); // Redo await expect(command.canRedo(getModel)).to.eventually.be.true; await command.redo(getModel); expect(document).to.deep.equal(expectedDocument); }); }); describe('createModelUpdaterCommand', () => { it('Simple execute/undo/redo', async () => { const updater: ModelUpdater<string, typeof testDocument> = ( workingCopy, modelId ) => { expect(modelId).to.be.equal('document'); workingCopy.id = 'updated-test-id'; }; const document = cloneDeep(testDocument); const expectedDocument = cloneDeep(testDocument); expectedDocument.id = 'updated-test-id'; const command = createModelUpdaterCommand( 'Test Command', 'document', updater ); // Execute await expect(command.canExecute(document)).to.eventually.be.true; const executeResult = await command.execute(document); expect(document).to.deep.equal(expectedDocument); expect(executeResult).to.not.be.undefined; if (executeResult) { // Should contain a single 'replace' operation (and maybe some optional 'test' operations) const operations = executeResult.filter( (operation) => operation.op !== 'test' ); expect(operations).to.be.an('array').of.length(1); expect(operations[0].op).to.equal('replace'); } // Undo expect(command.canUndo(document)).to.be.true; command.undo(document); expect(document).to.deep.equal(testDocument); // Redo expect(command.canRedo(document)).to.be.true; command.redo(document); expect(document).to.deep.equal(expectedDocument); }); it('Execute/undo/redo when modifying attribute to undefined', async () => { const updater: ModelUpdater< string, RecursiveTestDocumentWithUndefined > = (workingCopy, modelId) => { expect(modelId).to.be.equal('document'); workingCopy.label = undefined; if (workingCopy.children[0]) { workingCopy.children[0].value = undefined; } }; const document = cloneDeep(testDocument); const expectedDocument = (() => { const expectedDocument: RecursiveTestDocumentWithUndefined = cloneDeep(testDocument); expectedDocument.label = undefined; if (expectedDocument.children[0]) { expectedDocument.children[0].value = undefined; } return JSON.parse(JSON.stringify(expectedDocument)); })(); const command = createModelUpdaterCommand( 'Test Command setting undefined attributes', 'document', updater ); // Execute await expect(command.canExecute(document)).to.eventually.be.true; const executeResult = await command.execute(document); expect(document).to.deep.equal(expectedDocument); expect(executeResult).to.not.be.undefined; if (executeResult) { // Should contain two 'remove' operations (and maybe some optional 'test' operations) const operations = executeResult.filter( (operation) => operation.op !== 'test' ); expect(operations).to.be.an('array').of.length(2); expect(operations[0].op).to.equal('remove'); expect(operations[1].op).to.equal('remove'); } // Undo expect(command.canUndo(document)).to.be.true; command.undo(document); expect(document).to.deep.equal(testDocument); // Redo expect(command.canRedo(document)).to.be.true; command.redo(document); expect(document).to.deep.equal(expectedDocument); }); it('Execute/undo/redo when adding undefined value in array', async () => { const updater: ModelUpdater< string, RecursiveTestDocumentWithUndefined > = (workingCopy, modelId) => { expect(modelId).to.be.equal('document'); workingCopy.children.push(undefined); }; const document = cloneDeep(testDocument); const expectedDocument = (() => { const expectedDocument: RecursiveTestDocumentWithUndefined = cloneDeep(testDocument); expectedDocument.children.push(undefined); return JSON.parse(JSON.stringify(expectedDocument)); })(); const command = createModelUpdaterCommand( 'Test Command adding an undefined value in array', 'document', updater ); // Execute await expect(command.canExecute(document)).to.eventually.be.true; const executeResult = await command.execute(document); expect(document).to.deep.equal(expectedDocument); expect(executeResult).to.not.be.undefined; if (executeResult) { // Should contain one 'add' operations (and maybe some optional 'test' operations) const operations = executeResult.filter( (operation) => operation.op !== 'test' ); expect(operations).to.be.an('array').of.length(1); expect(operations[0].op).to.equal('add'); } // Undo expect(command.canUndo(document)).to.be.true; command.undo(document); expect(document).to.deep.equal(testDocument); // Redo expect(command.canRedo(document)).to.be.true; command.redo(document); expect(document).to.deep.equal(expectedDocument); }); it('Execute/undo/redo when modifying model to be recursive', async () => { const updater: ModelUpdater< string, RecursiveTestDocumentWithUndefined > = (workingCopy, modelId) => { expect(modelId).to.be.equal('document'); workingCopy.children.push(workingCopy); }; const document = cloneDeep(testDocument); const command = createModelUpdaterCommand( 'Test Command adding an undefined value in array', 'document', updater ); await expect(command.canExecute(document)).to.eventually.be.true; try { await command.execute(document); } catch (e) { // success return; } fail('execution should not be possible as the model is recursive'); }); it('Execute/undo/redo when modifying model to contain doubly linked references', async () => { const updater: ModelUpdater< string, RecursiveTestDocumentWithUndefined > = (workingCopy, modelId) => { expect(modelId).to.be.equal('document'); if (workingCopy.children[0] && workingCopy.children[1]) { workingCopy.children[0].link = workingCopy.children[1]; workingCopy.children[1].link = workingCopy.children[0]; } else { fail('Unexpected test document'); } }; const document = cloneDeep(testDocument); const command = createModelUpdaterCommand( 'Test Command adding an undefined value in array', 'document', updater ); await expect(command.canExecute(document)).to.eventually.be.true; try { await command.execute(document); } catch (e) { // success return; } fail('execution should not be possible as the model is doubly linked'); }); it('Execute/undo/redo empty change', async () => { const updater: ModelUpdater<string, typeof testDocument> = ( workingCopy, modelId ) => { expect(modelId).to.be.equal('document'); expect(workingCopy).to.be.equal(document); // Don't make any change. That's the point }; const document = cloneDeep(testDocument); const expectedDocument = cloneDeep(testDocument); const command = createModelUpdaterCommand( 'Empty Command', 'document', updater ); // Execute await expect(command.canExecute(document)).to.eventually.be.true; const executeResult = await command.execute(document); expect(document).to.deep.equal(expectedDocument); expect(executeResult).to.not.be.undefined; if (executeResult) { // Should contain a single 'replace' operation (and maybe some optional 'test' operations) const operations = executeResult.filter( (operation) => operation.op !== 'test' ); expect(operations).to.be.an('array').of.length(0); } // Undo expect(command.canUndo(document)).to.be.true; command.undo(document); expect(document).to.deep.equal(testDocument); // Redo expect(command.canRedo(document)).to.be.true; command.redo(document); expect(document).to.deep.equal(expectedDocument); }); describe('Options', () => { it('Non-executable', async () => { const updater: ModelUpdater<string, typeof testDocument> = ( model, modelId ) => { expect(model).to.be.equal(document); expect(modelId).to.be.equal('document'); }; const canExecute: CanExecutePredicate<string, typeof testDocument> = ( model, modelId ) => { expect(model).to.be.equal(document); expect(modelId).to.be.equal('document'); return Promise.resolve('test reason'); }; const document = cloneDeep(testDocument); const command = createModelUpdaterCommand( 'Test Command', 'document', updater, { canExecute } ); await expect(command.canExecute(document)).to.eventually.be.false; await expect(command.execute(document)).to.eventually.be.rejectedWith( /PatchCommand.*cannot be executed: test reason/ ); }); it('Strict undo predicate', async () => { const document = cloneDeep(testDocument); const updater: ModelUpdater<string, typeof testDocument> = ( workingCopy ) => { workingCopy.id = 'updated-id'; }; const command = createModelUpdaterCommand( 'Undo Preconditions', 'document', updater // Default mode is 'strict', so test that, too ); await command.execute(document); // Simulate an interfering change as from a trigger document.id = 'broken'; let thrown: unknown; try { await command.undo(document); } catch (error) { thrown = error; } expect(thrown) .to.be.an('Error') .with.property('message') .contains('Test operation failed'); }); it('Lax undo predicate', async () => { const debug = sandbox.stub(console, 'debug'); const document = cloneDeep(testDocument); const updater: ModelUpdater<string, typeof testDocument> = ( workingCopy ) => { workingCopy.id = 'updated-id'; }; const command = createModelUpdaterCommand( 'Undo Preconditions', 'document', updater, { preconditionsMode: 'lax' } ); await command.execute(document); // Simulate an interfering change as from a trigger document.id = 'broken'; await command.undo(document); expect(debug).to.have.been.calledWithMatch( 'model-manager/patch-command', 'Inapplicable undo/redo patch.', Error ); }); }); describe('With Result', () => { it('success', async () => { type Result = { message: string; }; const updaterWithResult: ModelUpdater< string, typeof testDocument, Result > = (workingCopy, modelId) => { expect(modelId).to.be.equal('document'); workingCopy.id = 'updated-test-id'; return { message: 'Hello, world.' }; }; const document = cloneDeep(testDocument); const expectedDocument = cloneDeep(testDocument); expectedDocument.id = 'updated-test-id'; const command = createModelUpdaterCommandWithResult( 'Test Command', 'document', updaterWithResult ); expect(command.result).to.be.like({ status: 'pending' }); // Execute await expect(command.canExecute(document)).to.eventually.be.true; await command.execute(document); expect(command.result).to.exist; expect(command.result).to.be.like({ status: 'ready', value: { message: 'Hello, world.' }, }); }); it('fails with Error', async () => { const updaterWithResult: ModelUpdater< string, typeof testDocument, unknown > = () => { throw new Error('💣'); }; const document = cloneDeep(testDocument); const expectedDocument = cloneDeep(testDocument); expectedDocument.id = 'updated-test-id'; const command = createModelUpdaterCommandWithResult( 'Test Command', 'document', updaterWithResult ); let error: Error | undefined; await expect(command.canExecute(document)).to.eventually.be.true; try { await command.execute(document); } catch (e) { error = e; } expect(command.result).to.exist; expect(command.result).to.be.like({ status: 'failed', error, }); }); it('fails with string', async () => { const updaterWithResult: ModelUpdater< string, typeof testDocument, unknown > = () => { throw '💣'; }; const document = cloneDeep(testDocument); const expectedDocument = cloneDeep(testDocument); expectedDocument.id = 'updated-test-id'; const command = createModelUpdaterCommandWithResult( 'Test Command', 'document', updaterWithResult ); let error: string | undefined; await expect(command.canExecute(document)).to.eventually.be.true; try { await command.execute(document); } catch (e) { error = e; } expect(command.result).to.exist; expect(command.result).to.be.like({ status: 'failed', error, }); }); }); }); describe('createModelPatchCommand', () => { it('Simple execute/undo/redo', async () => { const patchFunction: ModelPatchFunction<string, typeof testDocument> = ( workingCopy, modelId ) => { expect(modelId).to.be.equal('document'); expect(workingCopy).to.be.equal(document); return [ { op: 'replace', path: '/id', value: 'updated-test-id', }, ] as Operation[]; }; const document = cloneDeep(testDocument); const expectedDocument = cloneDeep(testDocument); expectedDocument.id = 'updated-test-id'; const command = createModelPatchCommand( 'Test Command', 'document', patchFunction ); // Execute await expect(command.canExecute(document)).to.eventually.be.true; const executeResult = await command.execute(document); expect(document).to.deep.equal(expectedDocument); expect(executeResult).to.not.be.undefined; if (executeResult) { // Should contain a single 'replace' operation (and maybe some optional 'test' operations) const operations = executeResult.filter( (operation) => operation.op !== 'test' ); expect(operations).to.be.an('array').of.length(1); expect(operations[0].op).to.equal('replace'); } // Undo expect(command.canUndo(document)).to.be.true; command.undo(document); expect(document).to.deep.equal(testDocument); // Redo expect(command.canRedo(document)).to.be.true; command.redo(document); expect(document).to.deep.equal(expectedDocument); }); }); }); const testDocument = { id: 'test-id', label: 'Test Document', children: [ { type: 'child-node', value: 13, valid: true, }, { type: 'child-node', value: 175, valid: false, }, { type: 'child-node', value: 1, valid: true, }, ], }; const secondTestDocument = { id: 'second-test-id', label: 'Second Test Document', children: [ { type: 'child-node', value: 130, valid: true, }, { type: 'child-node', value: 775, valid: false, }, ], }; /** * Used to test invalid modifications */ interface RecursiveTestDocumentWithUndefined { id: string; label?: string; value?: number; link?: RecursiveTestDocumentWithUndefined['children']; children: Array< | { type: string; value?: number; valid: boolean; link?: RecursiveTestDocumentWithUndefined['children'][0]; } | undefined | RecursiveTestDocumentWithUndefined >; }