UNPKG

@eclipse-emfcloud/model-manager

Version:

Command-based model editing with undo/redo.

458 lines 21 kB
"use strict"; // ***************************************************************************** // Copyright (C) 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 chai_1 = __importStar(require("chai")); const chai_as_promised_1 = __importDefault(require("chai-as-promised")); const deferred_compound_command_1 = require("../../core/deferred-compound-command"); const deferred_compound_command_impl_1 = require("../deferred-compound-command-impl"); const core_command_stack_impl_1 = require("../core-command-stack-impl"); const chai_like_1 = __importDefault(require("chai-like")); const sinon_1 = __importDefault(require("sinon")); const sinon_chai_1 = __importDefault(require("sinon-chai")); chai_1.default.use(chai_like_1.default); chai_1.default.use(chai_as_promised_1.default); chai_1.default.use(sinon_chai_1.default); describe('optimistic DeferredCompoundCommand', () => { let sandbox; let getModel; let commands; let compound; const provideCommands = () => commands; const provideCommandsAsync = async () => commands; const provideAsyncCommands = function* () { for (const cmd of commands) { const next = (async () => cmd)(); yield next; } }; const provideAsyncCommandsAsync = async function () { const result = []; for (const cmd of provideAsyncCommands()) { result.push(cmd); } return result; }; beforeEach(() => { sandbox = sinon_1.default.createSandbox(); getModel = () => ({}); commands = [new AsyncTestCommand('a'), new AsyncTestCommand('b')]; }); afterEach(() => { sandbox.restore(); }); it('no commands, yet', () => { compound = (0, deferred_compound_command_1.createDeferredCompoundCommand)('sync-sync', ['the-model'], provideCommands); (0, chai_1.expect)(compound.getCommands()).to.be.an.empty('array'); }); describe('execute', () => { it('sync provider, sync commands', async () => { const provider = sandbox.spy(provideCommands); compound = (0, deferred_compound_command_1.createDeferredCompoundCommand)('sync-sync', ['the-model'], provider); await compound.execute(getModel); (0, chai_1.expect)(commands, 'commands not executed').to.be.like([ { wasExecuted: true }, { wasExecuted: true }, ]); (0, chai_1.expect)(provider).to.have.been.calledWithExactly(getModel); }); it('async provider, sync commands', async () => { compound = (0, deferred_compound_command_1.createDeferredCompoundCommand)('async-sync', ['the-model'], provideCommandsAsync); await compound.execute(getModel); (0, chai_1.expect)(commands, 'commands not executed').to.be.like([ { wasExecuted: true }, { wasExecuted: true }, ]); }); it('sync provider, async commands', async () => { compound = (0, deferred_compound_command_1.createDeferredCompoundCommand)('sync-async', ['the-model'], provideAsyncCommands); await compound.execute(getModel); (0, chai_1.expect)(commands, 'commands not executed').to.be.like([ { wasExecuted: true }, { wasExecuted: true }, ]); }); it('async provider, async commands', async () => { compound = (0, deferred_compound_command_1.createDeferredCompoundCommand)('async-async', ['the-model'], provideAsyncCommandsAsync); await compound.execute(getModel); (0, chai_1.expect)(commands, 'commands not executed').to.be.like([ { wasExecuted: true }, { wasExecuted: true }, ]); }); }); describe('canExecute', () => { beforeEach(() => { compound = (0, deferred_compound_command_1.createDeferredCompoundCommand)('sync-sync', ['the-model'], provideCommands); }); it('is true', () => { return (0, chai_1.expect)(compound.canExecute(getModel), 'compound not executable').to .eventually.be.true; }); it('is false by reason of itself', async () => { await compound.execute(getModel); // Make it non-executable by executing it return (0, chai_1.expect)(compound.canExecute(getModel), 'compound is executable').to .eventually.be.false; }); it('uses supplied predicate', async () => { const predicate = sandbox.stub().returns(false); compound = (0, deferred_compound_command_1.createDeferredCompoundCommand)('sync-sync', ['the-model'], provideCommands, predicate); await (0, chai_1.expect)(compound.canExecute(getModel), 'compound is executable').to .eventually.be.false; (0, chai_1.expect)(predicate).to.have.been.calledWithExactly(getModel); }); }); describe('canUndo', () => { describe('with commands that can undo', () => { beforeEach(async () => { compound = (0, deferred_compound_command_1.createDeferredCompoundCommand)('sync-sync', ['the-model'], provideCommands); await compound.execute(getModel); }); it('is true', () => { return (0, chai_1.expect)(compound.canUndo(getModel), 'compound not undoable').to .eventually.be.true; }); it('is false by reason of itself', async () => { await compound.undo(getModel); // Make it non-undoable by undoing it return (0, chai_1.expect)(compound.canUndo(getModel), 'compound is executable').to .eventually.be.false; }); }); describe('without constituent commands', () => { beforeEach(async () => { sandbox = sinon_1.default.createSandbox(); compound = (0, deferred_compound_command_1.createDeferredCompoundCommand)('sync-sync', ['the-model'], () => []); await compound.execute(getModel); }); afterEach(() => { sandbox.restore(); }); it('is true', async () => { const spyConsoleDebug = sandbox.spy(console, 'debug'); const undoResult = await compound.canUndo(getModel); (0, chai_1.expect)(undoResult, 'compound not undoable').to.be.true; (0, chai_1.expect)(spyConsoleDebug, 'no debug log for no command').to.have.been.calledWithMatch('model-manager/command:', `No constituent command for ${compound.label}.`); }); it('is false by reason of itself', async () => { await compound.undo(getModel); // Make it non-undoable by undoing it (0, chai_1.expect)(compound.canUndo(getModel), 'compound is undoable').to.eventually .be.false; }); }); }); describe('canRedo', () => { describe('with commands that can redo', () => { beforeEach(() => { compound = (0, deferred_compound_command_1.createDeferredCompoundCommand)('sync-sync', ['the-model'], provideCommands); }); it('is false (not executed yet)', () => { return (0, chai_1.expect)(compound.canRedo(getModel), 'compound redoable').to .eventually.be.false; }); it('is false (executed but not undone)', async () => { await compound.execute(getModel); // Make it undoable by executing it return (0, chai_1.expect)(compound.canRedo(getModel), 'compound redoable').to .eventually.be.false; }); it('is true (executed and undone)', async () => { await compound.execute(getModel); // Make it undoable by executing it await compound.undo(getModel); // Make it redoable return (0, chai_1.expect)(compound.canRedo(getModel), 'compound is not redoable').to .eventually.be.true; }); }); describe('without constituent commands', () => { beforeEach(async () => { sandbox = sinon_1.default.createSandbox(); compound = (0, deferred_compound_command_1.createDeferredCompoundCommand)('sync-sync', ['the-model'], () => []); }); afterEach(() => { sandbox.restore(); }); it('is false (not executed yet)', async () => { (0, chai_1.expect)(compound.canRedo(getModel), 'compound redoable').to.eventually.be .false; }); it('is false (executed but not undone)', async () => { await compound.execute(getModel); // Make it undoable by executing it return (0, chai_1.expect)(compound.canRedo(getModel), 'compound redoable').to .eventually.be.false; }); it('is true (executed and undone)', async () => { await compound.execute(getModel); // Make it undoable by executing it await compound.undo(getModel); // Make it non-undoable by undoing it const spyConsoleDebug = sandbox.spy(console, 'debug'); const canRedoResult = await compound.canRedo(getModel); (0, chai_1.expect)(canRedoResult, 'compound not redoable').to.be.true; (0, chai_1.expect)(spyConsoleDebug, 'no debug log for no command').to.have.been.calledWithMatch('model-manager/command:', `No constituent command for ${compound.label}.`); }); }); }); describe('model scope', () => { beforeEach(() => { compound = (0, deferred_compound_command_1.createDeferredCompoundCommand)('sync-sync', ['the-model'], provideCommands); }); it('reports planned scope', () => { (0, chai_1.expect)((0, core_command_stack_impl_1.getModelIds)(compound)).to.be.deep.equal(['the-model']); }); it('includes statically added commands', () => { compound.append(new TestCommand('static', 'other-model')); (0, chai_1.expect)((0, core_command_stack_impl_1.getModelIds)(compound)).to.be.deep.equal([ 'the-model', 'other-model', ]); }); it('warns on scope expansion', async () => { const console_warn = sandbox.stub(console, 'warn'); sandbox.replace(commands[1], 'modelId', 'other-model'); (0, chai_1.expect)((0, core_command_stack_impl_1.getModelIds)(compound)).to.be.deep.equal(['the-model']); await compound.execute(getModel); (0, chai_1.expect)(console_warn).to.have.been.calledWithMatch('model-manager/deferred-compound-command-impl:', /.*expands the model scope.*/); }); it('is frozen after execution', async () => { await compound.execute(getModel); (0, chai_1.expect)(() => compound.append(new TestCommand('static', 'other-model'))).to.throw('Cannot append to executed compound'); }); }); describe('corner cases', () => { it('validation rejects provided commands', async () => { compound = new (class extends deferred_compound_command_impl_1.DeferredCompoundCommand { validate(command) { if (command.label === 'b') { return undefined; } return super.validate(command); } })('sync-sync', ['the-model'], provideCommands); await compound.execute(getModel); (0, chai_1.expect)(commands, 'rejected commands was executed').to.be.like([ { wasExecuted: true }, { wasExecuted: false }, ]); const children = compound.getCommands(); (0, chai_1.expect)(children.length).to.be.equal(1); (0, chai_1.expect)(children[0]).to.be.equal(commands[0]); }); }); describe('get model Ids with deferred command', () => { it('should return an array with the compound command model ids with deferred command', () => { const provider = sandbox.spy(provideCommands); const deferredCommand = (0, deferred_compound_command_1.createDeferredCompoundCommand)('random-deferred-command', ['test-model-id-4', 'test-model-id-5'], provider); commands = [ new AsyncTestCommand('a', 'test-model'), new AsyncTestCommand('b', 'test-model'), ]; compound = new core_command_stack_impl_1.AppendableCompoundCommand('test', ...commands); compound.append(deferredCommand); (0, chai_1.expect)((0, core_command_stack_impl_1.getModelIds)(compound)).to.be.eql([ 'test-model', 'test-model-id-4', 'test-model-id-5', ]); }); }); }); describe('strict DeferredCompoundCommand', () => { let sandbox; let getModel; let commands; let compound; const provideCommands = () => commands; beforeEach(() => { sandbox = sinon_1.default.createSandbox(); getModel = () => ({}); commands = [new AsyncTestCommand('a'), new AsyncTestCommand('b')]; }); afterEach(() => { sandbox.restore(); }); it('no commands, yet', () => { compound = (0, deferred_compound_command_1.createStrictDeferredCompoundCommand)('sync-sync', ['the-model'], provideCommands); (0, chai_1.expect)(compound.getCommands()).to.be.an.empty('array'); }); it('execute', async () => { compound = (0, deferred_compound_command_1.createStrictDeferredCompoundCommand)('sync-sync', ['the-model'], provideCommands); await compound.execute(getModel); (0, chai_1.expect)(commands, 'commands not executed').to.be.like([ { wasExecuted: true }, { wasExecuted: true }, ]); }); describe('canExecute', () => { beforeEach(() => { compound = (0, deferred_compound_command_1.createStrictDeferredCompoundCommand)('sync-sync', ['the-model'], provideCommands); }); it('is true', () => { return (0, chai_1.expect)(compound.canExecute(getModel), 'compound not executable').to .eventually.be.true; }); it('is false by reason of itself', async () => { await compound.execute(getModel); // Make it non-executable by executing it return (0, chai_1.expect)(compound.canExecute(getModel), 'compound is executable').to .eventually.be.false; }); it('is false by reason of some provided command', async () => { await commands[1].execute(getModel); // Make one non-executable by executing it return (0, chai_1.expect)(compound.canExecute(getModel), 'compound is executable').to .eventually.be.false; }); it('executable if no commands provided', async () => { const spyConsoleDebug = sandbox.spy(console, 'debug'); compound = (0, deferred_compound_command_1.createStrictDeferredCompoundCommand)('sync-sync', ['the-model'], () => []); const result = await compound.canExecute(getModel); (0, chai_1.expect)(result, 'compound is not executable').to.be.true; (0, chai_1.expect)(spyConsoleDebug).to.have.been.calledWithMatch('model-manager/command:', `No constituent command for ${compound.label}.`); }); }); it('prepares only once', async () => { const provider = sandbox.spy(provideCommands); compound = (0, deferred_compound_command_1.createStrictDeferredCompoundCommand)('sync-sync', ['the-model'], provider); await compound.canExecute(getModel); (0, chai_1.expect)(provider).to.have.been.calledOnce; await compound.execute(getModel); (0, chai_1.expect)(provider).to.have.been.calledOnce; }); }); // // Test fixtures // class TestCommand { constructor(label, modelId = '') { this.label = label; this.modelId = modelId; this.wasExecuted = false; this.wasUndone = false; this.wasRedone = false; } canExecute() { return !this.wasExecuted && !this.wasUndone && !this.wasRedone; } canUndo() { return this.wasExecuted || this.wasRedone; } canRedo() { return this.wasUndone; } execute() { if (!this.canExecute()) throw new Error('cannot execute'); this.wasExecuted = true; this.wasUndone = false; this.wasRedone = false; return [{ op: 'add', path: this.label, value: 'test-value' }]; } undo() { if (!this.canUndo()) throw new Error('cannot undo'); this.wasExecuted = false; this.wasUndone = true; this.wasRedone = false; return [{ op: 'remove', path: this.label }]; } redo() { if (!this.canRedo()) throw new Error('cannot redo'); this.wasExecuted = false; this.wasUndone = false; this.wasRedone = true; return [{ op: 'add', path: this.label, value: 'test-value' }]; } } class AsyncTestCommand { constructor(label, modelId = 'the-model') { this.label = label; this.modelId = modelId; this.wasExecuted = false; this.wasUndone = false; this.wasRedone = false; this.failed = false; } failOn(op) { this.failOnOp = op; } async canExecute() { this.maybeFailOn('canExecute'); return !this.wasExecuted && !this.wasUndone && !this.wasRedone; } async canUndo() { this.maybeFailOn('canUndo'); return this.wasExecuted || this.wasRedone; } async canRedo() { this.maybeFailOn('canRedo'); return this.wasUndone; } async execute() { if (!this.canExecute()) throw new Error('cannot execute'); this.maybeFailOn('execute'); this.wasExecuted = true; this.wasUndone = false; this.wasRedone = false; return [{ op: 'add', path: this.label, value: 'test-value' }]; } async undo() { if (!this.canUndo()) throw new Error('cannot undo'); this.maybeFailOn('undo'); this.wasExecuted = false; this.wasUndone = true; this.wasRedone = false; return [{ op: 'remove', path: this.label }]; } async redo() { if (!this.canRedo()) throw new Error('cannot redo'); this.maybeFailOn('redo'); this.wasExecuted = false; this.wasUndone = false; this.wasRedone = true; return [{ op: 'add', path: this.label, value: 'test-value' }]; } maybeFailOn(op) { if (this.failOnOp === op) { this.failed = true; this.failOnOp = undefined; throw new Error(`Failed on ${op} as directed.`); } } } //# sourceMappingURL=deferred-compound-command.spec.js.map