@eclipse-emfcloud/model-manager
Version:
Command-based model editing with undo/redo.
458 lines • 21 kB
JavaScript
"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