@eclipse-emfcloud/model-manager
Version:
Command-based model editing with undo/redo.
642 lines (524 loc) • 18 kB
text/typescript
// *****************************************************************************
// 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
// *****************************************************************************
import chai, { expect } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import { Command, CompoundCommand, SimpleCommand } from '../../core/command';
import {
createDeferredCompoundCommand,
createStrictDeferredCompoundCommand,
} from '../../core/deferred-compound-command';
import { DeferredCompoundCommand } from '../deferred-compound-command-impl';
import {
AppendableCompoundCommand,
getModelIds,
} from '../core-command-stack-impl';
import type { Operation } from 'fast-json-patch';
import chaiLike from 'chai-like';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
chai.use(chaiLike);
chai.use(chaiAsPromised);
chai.use(sinonChai);
describe('optimistic DeferredCompoundCommand', () => {
let sandbox: sinon.SinonSandbox;
let getModel: (modelId: string) => object | undefined;
let commands: Command[];
let compound: CompoundCommand;
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.createSandbox();
getModel = () => ({});
commands = [new AsyncTestCommand('a'), new AsyncTestCommand('b')];
});
afterEach(() => {
sandbox.restore();
});
it('no commands, yet', () => {
compound = createDeferredCompoundCommand(
'sync-sync',
['the-model'],
provideCommands
);
expect(compound.getCommands()).to.be.an.empty('array');
});
describe('execute', () => {
it('sync provider, sync commands', async () => {
const provider = sandbox.spy(provideCommands);
compound = createDeferredCompoundCommand(
'sync-sync',
['the-model'],
provider
);
await compound.execute(getModel);
expect(commands, 'commands not executed').to.be.like([
{ wasExecuted: true },
{ wasExecuted: true },
]);
expect(provider).to.have.been.calledWithExactly(getModel);
});
it('async provider, sync commands', async () => {
compound = createDeferredCompoundCommand(
'async-sync',
['the-model'],
provideCommandsAsync
);
await compound.execute(getModel);
expect(commands, 'commands not executed').to.be.like([
{ wasExecuted: true },
{ wasExecuted: true },
]);
});
it('sync provider, async commands', async () => {
compound = createDeferredCompoundCommand(
'sync-async',
['the-model'],
provideAsyncCommands
);
await compound.execute(getModel);
expect(commands, 'commands not executed').to.be.like([
{ wasExecuted: true },
{ wasExecuted: true },
]);
});
it('async provider, async commands', async () => {
compound = createDeferredCompoundCommand(
'async-async',
['the-model'],
provideAsyncCommandsAsync
);
await compound.execute(getModel);
expect(commands, 'commands not executed').to.be.like([
{ wasExecuted: true },
{ wasExecuted: true },
]);
});
});
describe('canExecute', () => {
beforeEach(() => {
compound = createDeferredCompoundCommand(
'sync-sync',
['the-model'],
provideCommands
);
});
it('is true', () => {
return 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 expect(compound.canExecute(getModel), 'compound is executable').to
.eventually.be.false;
});
it('uses supplied predicate', async () => {
const predicate = sandbox.stub().returns(false);
compound = createDeferredCompoundCommand(
'sync-sync',
['the-model'],
provideCommands,
predicate
);
await expect(compound.canExecute(getModel), 'compound is executable').to
.eventually.be.false;
expect(predicate).to.have.been.calledWithExactly(getModel);
});
});
describe('canUndo', () => {
describe('with commands that can undo', () => {
beforeEach(async () => {
compound = createDeferredCompoundCommand(
'sync-sync',
['the-model'],
provideCommands
);
await compound.execute(getModel);
});
it('is true', () => {
return 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 expect(compound.canUndo(getModel), 'compound is executable').to
.eventually.be.false;
});
});
describe('without constituent commands', () => {
beforeEach(async () => {
sandbox = sinon.createSandbox();
compound = 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);
expect(undoResult, 'compound not undoable').to.be.true;
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
expect(compound.canUndo(getModel), 'compound is undoable').to.eventually
.be.false;
});
});
});
describe('canRedo', () => {
describe('with commands that can redo', () => {
beforeEach(() => {
compound = createDeferredCompoundCommand(
'sync-sync',
['the-model'],
provideCommands
);
});
it('is false (not executed yet)', () => {
return 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 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 expect(compound.canRedo(getModel), 'compound is not redoable').to
.eventually.be.true;
});
});
describe('without constituent commands', () => {
beforeEach(async () => {
sandbox = sinon.createSandbox();
compound = createDeferredCompoundCommand(
'sync-sync',
['the-model'],
() => []
);
});
afterEach(() => {
sandbox.restore();
});
it('is false (not executed yet)', async () => {
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 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);
expect(canRedoResult, 'compound not redoable').to.be.true;
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 = createDeferredCompoundCommand(
'sync-sync',
['the-model'],
provideCommands
);
});
it('reports planned scope', () => {
expect(getModelIds(compound)).to.be.deep.equal(['the-model']);
});
it('includes statically added commands', () => {
compound.append(new TestCommand('static', 'other-model'));
expect(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] as SimpleCommand, 'modelId', 'other-model');
expect(getModelIds(compound)).to.be.deep.equal(['the-model']);
await compound.execute(getModel);
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);
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 DeferredCompoundCommand {
protected validate(
command: Command<string>
): Command<string> | undefined {
if (command.label === 'b') {
return undefined;
}
return super.validate(command);
}
})('sync-sync', ['the-model'], provideCommands);
await compound.execute(getModel);
expect(commands, 'rejected commands was executed').to.be.like([
{ wasExecuted: true },
{ wasExecuted: false },
]);
const children = compound.getCommands();
expect(children.length).to.be.equal(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 = 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 AppendableCompoundCommand('test', ...commands);
compound.append(deferredCommand);
expect(getModelIds(compound)).to.be.eql([
'test-model',
'test-model-id-4',
'test-model-id-5',
]);
});
});
});
describe('strict DeferredCompoundCommand', () => {
let sandbox: sinon.SinonSandbox;
let getModel: (modelId: string) => object | undefined;
let commands: Command[];
let compound: CompoundCommand;
const provideCommands = () => commands;
beforeEach(() => {
sandbox = sinon.createSandbox();
getModel = () => ({});
commands = [new AsyncTestCommand('a'), new AsyncTestCommand('b')];
});
afterEach(() => {
sandbox.restore();
});
it('no commands, yet', () => {
compound = createStrictDeferredCompoundCommand(
'sync-sync',
['the-model'],
provideCommands
);
expect(compound.getCommands()).to.be.an.empty('array');
});
it('execute', async () => {
compound = createStrictDeferredCompoundCommand(
'sync-sync',
['the-model'],
provideCommands
);
await compound.execute(getModel);
expect(commands, 'commands not executed').to.be.like([
{ wasExecuted: true },
{ wasExecuted: true },
]);
});
describe('canExecute', () => {
beforeEach(() => {
compound = createStrictDeferredCompoundCommand(
'sync-sync',
['the-model'],
provideCommands
);
});
it('is true', () => {
return 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 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 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 = createStrictDeferredCompoundCommand(
'sync-sync',
['the-model'],
() => []
);
const result = await compound.canExecute(getModel);
expect(result, 'compound is not executable').to.be.true;
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 = createStrictDeferredCompoundCommand(
'sync-sync',
['the-model'],
provider
);
await compound.canExecute(getModel);
expect(provider).to.have.been.calledOnce;
await compound.execute(getModel);
expect(provider).to.have.been.calledOnce;
});
});
//
// Test fixtures
//
class TestCommand implements SimpleCommand {
constructor(public readonly label: string, public readonly modelId = '') {}
public wasExecuted = false;
public wasUndone = false;
public wasRedone = false;
canExecute(): boolean {
return !this.wasExecuted && !this.wasUndone && !this.wasRedone;
}
canUndo(): boolean {
return this.wasExecuted || this.wasRedone;
}
canRedo(): boolean {
return this.wasUndone;
}
execute(): Operation[] | undefined {
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(): Operation[] | undefined {
if (!this.canUndo()) throw new Error('cannot undo');
this.wasExecuted = false;
this.wasUndone = true;
this.wasRedone = false;
return [{ op: 'remove', path: this.label }];
}
redo(): Operation[] | undefined {
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 implements SimpleCommand {
constructor(
public readonly label: string,
public readonly modelId = 'the-model'
) {}
public wasExecuted = false;
public wasUndone = false;
public wasRedone = false;
private failOnOp?: keyof SimpleCommand;
public failed = false;
failOn(op: keyof SimpleCommand): void {
this.failOnOp = op;
}
async canExecute(): Promise<boolean> {
this.maybeFailOn('canExecute');
return !this.wasExecuted && !this.wasUndone && !this.wasRedone;
}
async canUndo(): Promise<boolean> {
this.maybeFailOn('canUndo');
return this.wasExecuted || this.wasRedone;
}
async canRedo(): Promise<boolean> {
this.maybeFailOn('canRedo');
return this.wasUndone;
}
async execute(): Promise<Operation[] | undefined> {
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(): Promise<Operation[] | undefined> {
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(): Promise<Operation[] | undefined> {
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' }];
}
private maybeFailOn(op: keyof SimpleCommand): void {
if (this.failOnOp === op) {
this.failed = true;
this.failOnOp = undefined;
throw new Error(`Failed on ${op} as directed.`);
}
}
}