@eclipse-emfcloud/model-manager
Version:
Command-based model editing with undo/redo.
1,505 lines (1,239 loc) • 83.4 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 type { Operation } from 'fast-json-patch';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import type {
Command,
CompoundCommand,
EditingContext,
MaybePromise,
SimpleCommand,
} from '../../core';
import { isCompoundCommand, append as normalAppend } from '../../core';
import {
append,
AppendableCompoundCommand,
CoreCommandStackImpl,
getModelIds,
StackEntry,
WorkingCopyManager,
} from '../core-command-stack-impl';
chai.use(chaiLike);
chai.use(sinonChai);
chaiLike.extend({
match: (o: unknown) => o instanceof Map,
assert: (o: Map<unknown, unknown>, expected: Map<unknown, unknown>) => {
for (const [key, value] of expected.entries()) {
if (o.size !== expected.size) {
return false;
}
if (o.get(key) !== value) {
return false;
}
}
return true;
},
});
const workingCopyManager: WorkingCopyManager = {
isOpen: () => true,
open: () => undefined,
commit: () => undefined,
cancel: () => undefined,
getModel: () => ({}),
getWorkingCopy: () => ({}),
createFollowUpCommand: () => undefined,
};
/**
* An `EditingContext` constructor to abstract its implementation
* as a mere string identifier.
*/
const editingContext = (id: string): EditingContext => id;
describe('StackEntry', () => {
let command1: Command;
let command2: Command;
const context1 = editingContext('context.1');
const context2 = editingContext('context.2');
beforeEach(() => {
command1 = new TestCommand('a');
command2 = new TestCommand('b');
});
it('provides access to its command', () => {
const entry = new StackEntry(command1, []);
expect(entry.command, 'no command').to.be.equal(command1);
});
it('provides access to its editing contexts', () => {
const entry = new StackEntry(command1, [context1, context2]);
expect(entry.editingContexts, 'missing context 1').to.include(context1);
expect(entry.editingContexts, 'missing context 2').to.include(context2);
});
describe('isPurgeable', () => {
let entry: StackEntry;
beforeEach(() => {
entry = new StackEntry(command1, [context1, context2]);
});
it('is not purgeable', () => {
expect(entry.isPurgeable, 'should not be purgeable').to.be.false;
entry.editingContexts.delete(context1);
expect(entry.isPurgeable, 'still should not be purgeable').to.be.false;
});
it('is purgeable', () => {
entry.editingContexts.delete(context1);
entry.editingContexts.delete(context2);
expect(entry.isPurgeable, 'should be purgeable').to.be.true;
});
});
describe('linking of entries', () => {
let command3: Command;
let command4: Command;
let entry1: StackEntry;
let entry2: StackEntry;
let entry3: StackEntry;
let entry4: StackEntry;
beforeEach(() => {
command3 = new TestCommand('c');
command4 = new TestCommand('d');
entry1 = new StackEntry(command1, [context1]);
entry2 = new StackEntry(command2, [context2]);
entry3 = new StackEntry(command3, [context1]);
entry4 = new StackEntry(command4, [context2]);
entry1.push(entry2);
});
it('can push next at end of the chain', () => {
entry2.push(entry3);
expect(entry2.next, 'wrong next').to.equal(entry3);
expect(entry3.next, 'end of chain has a next').not.to.exist;
expect(entry3.previous, 'end of chain has wrong previous').to.equal(
entry2
);
});
it('can insert within the chain', () => {
entry1.push(entry4);
expect(entry1.next, 'wrong next').to.equal(entry4);
expect(entry4.next, 'inserted entry has wrong next').to.equal(entry2);
expect(entry4.previous, 'inserted entry has wrong previous').to.equal(
entry1
);
expect(entry2.previous, 'end of chain has wrong previous').to.equal(
entry4
);
});
describe('cycle detection', () => {
it('cannot create a one-cycle', () => {
expect(
() => entry2.push(entry2),
'should have thrown cycle precondition failure'
).to.throw();
});
it('cannot create an indirect cycle', () => {
entry3.push(entry2);
expect(
() => entry2.push(entry3),
'should have thrown cycle precondition failure'
).to.throw();
});
});
it('can pop the end of the chain', () => {
entry2.pop();
expect(entry1.next, 'previous entry still references the popped entry')
.not.to.exist;
expect(entry2.previous, 'popped entry still references its previous').not
.to.exist;
});
it('can pop from within the chain', () => {
entry2.push(entry3);
entry2.pop();
expect(entry1.next, 'previous entry references wrong next').to.equal(
entry3
);
expect(entry3.previous, 'next entry references wrong previous').to.equal(
entry1
);
expect(entry2.next, 'popped entry still references its next').not.to
.exist;
expect(entry2.previous, 'popped entry still references its previous').not
.to.exist;
});
describe('navigating editing context histories', () => {
beforeEach(() => {
entry2.push(entry3);
entry3.push(entry4);
});
it('finds next in the editing context', () => {
expect(entry1.nextIn(context1), 'wrong next in same context').to.equal(
entry3
);
expect(
entry1.nextIn(context2),
'wrong next in different context'
).to.equal(entry2);
});
it('finds previous in the editing context', () => {
expect(
entry4.previousIn(context2),
'wrong previous in same context'
).to.equal(entry2);
expect(
entry4.previousIn(context1),
'wrong previous in different context'
).to.equal(entry3);
});
});
});
describe('editing context management', () => {
let entry: StackEntry;
beforeEach(() => {
entry = new StackEntry(command1, [context1, context2]);
});
it('matches a context', () => {
expect(entry.hasContext(context2), 'does not have the context').to.be
.true;
});
it('does not match a context', () => {
entry.editingContexts.delete(context2);
expect(entry.hasContext(context2), 'has the context').to.be.false;
});
it('removes a context', () => {
expect(entry.editingContexts, 'context not found').to.include(context2);
entry.removeContext(context2);
expect(entry.editingContexts, 'context still found').not.to.include(
context2
);
});
});
describe('merge', () => {
let entry1: StackEntry;
let entry2: StackEntry;
beforeEach(() => {
entry1 = new StackEntry(command1, [context1]);
entry2 = new StackEntry(command2, [context2]);
});
it('merges the editing contexts', () => {
const merged = entry1.merge(entry2);
expect(merged, 'wrong entry returned').to.equal(entry1);
expect(merged.editingContexts, 'contexts not merged').to.include(
context2
);
expect(merged.editingContexts, 'contexts removed').to.include(context1);
});
it('merges the commands', () => {
const merged = entry1.merge(entry2);
expect(merged, 'wrong entry returned').to.equal(entry1);
expect(isCompoundCommand(merged.command), 'command is not a compound').to
.be.true;
const compound = merged.command as CompoundCommand;
expect(compound.getCommands(), 'commands not merged').to.be.deep.equal([
command1,
command2,
]);
});
});
});
describe('AppendableCompoundCommand', () => {
let command1: TestCommand;
let command2: TestCommand;
let compound: AppendableCompoundCommand;
beforeEach(async () => {
command1 = new TestCommand('a');
command2 = new TestCommand('b');
// Mark the commands executed as they would be in application scenarios
await command1.execute();
await command2.execute();
compound = new AppendableCompoundCommand('test', command1);
});
it('is created in the executed state', () => {
return expect(
compound.canUndo(() => ({})),
'command was not executed'
).to.eventually.be.true;
});
it('can be appended', () => {
compound.append(command2);
expect(compound.getCommands(), 'command not appended').to.include(command2);
});
it('cannot append when undone', async () => {
await compound.undo(() => ({}));
expect(
() => compound.append(command2),
'should have thrown precondition failure'
).to.throw();
});
describe('append() utility', () => {
it('appends nothing', () => {
const result = append(command2);
expect(result, 'append should have been a no-op').to.equal(command2);
});
it('creates a new appendable compound', () => {
const result = append(command1, command2);
expect(result, 'wrong kind of compound').to.be.instanceOf(
AppendableCompoundCommand
);
const asCompound = result as CompoundCommand;
expect(
asCompound.getCommands(),
'wrong nested commands'
).to.be.deep.equal([command1, command2]);
});
it('appends to an existing compound', () => {
const result = append(compound, command2);
expect(result, 'wrong compound').to.be.equal(compound);
expect(compound.getCommands(), 'wrong nested commands').to.be.deep.equal([
command1,
command2,
]);
});
});
});
describe('CoreCommandStackImpl', () => {
const context1 = editingContext('test.c1');
const context2 = editingContext('test.c2');
let command1: TestCommand;
let command2: TestCommand;
let command3: TestCommand;
let compound: AppendableCompoundCommand;
let stack: CoreCommandStackImpl;
let sandbox: sinon.SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
command1 = new TestCommand('a');
command2 = new TestCommand('b');
stack = new CoreCommandStackImpl(workingCopyManager);
});
afterEach(() => {
sandbox.restore();
});
describe('With One EditingContext', () => {
describe('execute', () => {
it('executes a command', async () => {
const delta = await stack.execute(command1, context1);
expect(command1, 'command not executed').to.be.like({
wasExecuted: true,
});
expect(delta, 'no delta returned').to.exist;
expect(delta?.get(command1), 'bad delta returned').to.be.like([
{ op: 'add', path: 'a' },
]);
});
it('puts the command on the undo stack', async () => {
await stack.execute(command1, context1);
const undo = stack.getUndoCommand(context1);
expect(undo, 'wrong undo command').to.equal(command1);
await stack.execute(command2, context1);
const undo2 = stack.getUndoCommand(context1);
expect(undo2, 'wrong undo command').to.equal(command2);
});
it('requires an editing context', () => {
return expect(
stack.execute(command1),
'should have thrown precondition failure'
).to.eventually.be.rejected;
});
it('flushes the redo stack', async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1);
await stack.undo(context1);
const newCommand = new TestCommand('c');
await stack.execute(newCommand, context1);
await expect(stack.canRedo(context1), 'redo stack not flushed').to
.eventually.be.false;
expect(stack.getRedoCommand(context1), 'redo command exists').not.to
.exist;
});
});
describe('undo', () => {
beforeEach(async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1);
});
it('undoes a command', async () => {
const delta = await stack.undo(context1);
expect(command2, 'command not undone').to.be.like({ wasUndone: true });
expect(delta, 'no delta returned').to.exist;
expect(delta?.get(command2), 'bad delta returned').to.be.like([
{ op: 'remove', path: 'b' },
]);
});
it('puts the command on the redo stack', async () => {
await stack.undo(context1);
const redo = stack.getRedoCommand(context1);
expect(redo, 'wrong redo command').to.equal(command2);
await stack.undo(context1);
const redo2 = stack.getRedoCommand(context1);
expect(redo2, 'wrong redo command').to.equal(command1);
});
});
describe('redo', () => {
beforeEach(async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1);
await stack.undo(context1);
await stack.undo(context1);
});
it('redoes a command', async () => {
const delta = await stack.redo(context1);
expect(command1, 'command not redone').to.be.like({ wasRedone: true });
expect(command2, 'command was redone').to.be.like({
wasRedone: false,
wasUndone: true,
});
expect(delta, 'no delta returned').to.exist;
expect(delta?.get(command1), 'bad delta returned').to.be.like([
{ op: 'add', path: 'a' },
]);
});
it('puts the command on the undo stack', async () => {
await stack.redo(context1);
const undo = stack.getUndoCommand(context1);
expect(undo, 'wrong undo command').to.equal(command1);
await stack.redo(context1);
const undo2 = stack.getUndoCommand(context1);
expect(undo2, 'wrong undo command').to.equal(command2);
});
});
describe('executeAndAppend', async () => {
beforeEach(() => {
return stack.execute(command1, context1);
});
it('executes a command', async () => {
const delta = await stack.executeAndAppend(context1, command2);
expect(command2, 'command not executed').to.be.like({
wasExecuted: true,
});
expect(delta, 'no delta returned').to.exist;
expect(delta?.get(command2), 'bad delta returned').to.be.like([
{ op: 'add', path: 'b' },
]);
});
it('undoes together with original command', async () => {
await stack.executeAndAppend(context1, command2);
const undo = stack.getUndoCommand(context1);
expect(undo, 'wrong undo command').not.to.equal(command1);
const delta = await stack.undo(context1);
expect(delta, 'no delta returned').to.exist;
expect(delta?.get(command2)).to.be.like([{ op: 'remove', path: 'b' }]);
expect(delta?.get(command1)).to.be.like([{ op: 'remove', path: 'a' }]);
});
it('requires a command to append to', () => {
return expect(
stack.executeAndAppend(context2, command2, context1),
'should have thrown precondition violation'
).to.eventually.be.rejected;
});
it('cannot append to an undone command', async () => {
await stack.execute(command2, context1);
await stack.undo(context1);
await expect(
stack.executeAndAppend(context1, command1),
'should have thrown precondition violation'
).to.eventually.be.rejected;
});
it('cannot sneakily append to an undone command', async () => {
await stack.executeAndAppend(context1, command2);
await stack.undo(context1);
const redo = stack.getRedoCommand(context1);
expect(redo, 'no undo command').to.exist;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
expect(isCompoundCommand(redo!), 'undo command is not a compound').to.be
.true;
const compound = redo as CompoundCommand;
expect(
() => compound.append(new TestCommand('c')),
'should have thrown precondition violation'
).to.throw();
});
it('can append to a redone command', async () => {
await stack.undo(context1);
await stack.redo(context1);
const delta = stack.executeAndAppend(context1, command2);
await expect(delta, 'no delta returned').to.eventually.exist;
});
it('can append again', async () => {
await stack.executeAndAppend(context1, command2);
const undo = stack.getUndoCommand(context1);
const newCommand = new TestCommand('c');
await stack.executeAndAppend(context1, newCommand);
const undo2 = stack.getUndoCommand(context1);
expect(undo2, 'wrong undo command').to.equal(undo);
const delta = await stack.undo(context1);
expect(delta, 'no delta returned').to.exist;
expect(delta?.get(newCommand)).to.be.like([
{ op: 'remove', path: 'c' },
]);
expect(delta?.get(command2)).to.be.like([{ op: 'remove', path: 'b' }]);
expect(delta?.get(command1)).to.be.like([{ op: 'remove', path: 'a' }]);
});
describe('returns partial deltas when applicable', () => {
const obtrudeResult = (cmd: Command, op: keyof Command) => {
const stub = sandbox.stub(cmd, op).callsFake((...args) => {
stub.wrappedMethod(...args);
return undefined;
});
};
beforeEach(async () => {
sandbox.stub(workingCopyManager, 'isOpen').callsFake(() => false);
await stack.executeAndAppend(context1, command2);
});
it('undo', () => {
// Instrument the appended command to return no delta on undo
obtrudeResult(command2, 'undo');
return expect(
stack.undo(context1),
'a partial delta was not returned by undo'
).to.eventually.exist;
});
it('redo', async () => {
await stack.undo(context1);
// Instrument the appended command to return no delta on redo
obtrudeResult(command2, 'redo');
await expect(
stack.redo(context1),
'a partial delta was not returned by redo'
).to.eventually.exist;
});
});
});
describe('canExecute', () => {
beforeEach(() => {
sandbox.stub(workingCopyManager, 'isOpen').callsFake(() => false);
});
it('is executable', () => {
return expect(stack.canExecute(command1, context1), 'cannot execute').to
.eventually.be.true;
});
it('is not executable by reason of the command', async () => {
await expect(stack.canExecute(command1, context1), 'cannot execute').to
.eventually.be.true;
await stack.execute(command1, context1);
await expect(stack.canExecute(command1, context1), 'can still execute')
.to.eventually.be.false;
});
it('is not executable by reason of the contexts', async () => {
await expect(stack.canExecute(command1), 'can execute').to.eventually.be
.false;
});
});
describe('canUndo', () => {
beforeEach(async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1);
});
it('is undoable', () => {
return expect(stack.canUndo(context1), 'cannot undo').to.eventually.be
.true;
});
it('is not undoable by reason of itself', async () => {
await stack.undo(context1);
await expect(stack.canUndo(context1), 'cannot undo').to.eventually.be
.true;
await stack.undo(context1);
await expect(stack.canUndo(context1), 'can still undo').to.eventually.be
.false;
});
it('is not undoable by reason of the command', async () => {
// Undo the next command, thus making it non-undoable
await command2.undo();
await expect(stack.canUndo(context1), 'can undo').to.eventually.be
.false;
});
});
describe('canRedo', () => {
beforeEach(async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1);
await stack.undo(context1);
await stack.undo(context1);
});
it('is redoable', () => {
return expect(stack.canRedo(context1), 'cannot redo').to.eventually.be
.true;
});
it('is not redoable by reason of itself', async () => {
await stack.redo(context1);
await expect(stack.canRedo(context1), 'cannot redo').to.eventually.be
.true;
await stack.redo(context1);
await expect(stack.canRedo(context1), 'can still redo').to.eventually.be
.false;
});
it('is not redoable by reason of the command', async () => {
// Redo the next command, thus making it non-redoable
await command1.redo();
await expect(stack.canRedo(context1), 'can redo').to.eventually.be
.false;
});
});
describe('getUndoCommand', () => {
beforeEach(async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1);
});
it('is the next undo command', async () => {
const undo = stack.getUndoCommand(context1);
expect(undo, 'wrong undo command').to.be.equal(command2);
await stack.undo(context1);
const undo2 = stack.getUndoCommand(context1);
expect(undo2, 'wrong undo command').to.be.equal(command1);
});
it('is undefined', async () => {
await stack.undo(context1);
await stack.undo(context1);
const undo = stack.getUndoCommand(context1);
expect(undo, 'got an undo command').not.to.exist;
});
});
describe('getRedoCommand', () => {
beforeEach(async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1);
await stack.undo(context1);
await stack.undo(context1);
});
it('is the next redo command', async () => {
const redo = stack.getRedoCommand(context1);
expect(redo, 'wrong redo command').to.be.equal(command1);
await stack.redo(context1);
const redo2 = stack.getRedoCommand(context1);
expect(redo2, 'wrong redo command').to.be.equal(command2);
});
it('is undefined', async () => {
await stack.redo(context1);
await stack.redo(context1);
const redo = stack.getRedoCommand(context1);
expect(redo, 'got a redo command').not.to.exist;
});
});
describe('flush', () => {
beforeEach(async () => {
// We have commands on the undo stack and on the redo stack
await stack.execute(command1, context1);
await stack.execute(command2, context1);
await stack.undo(context1);
});
it('clears the redo stack', () => {
stack.flush(context1);
const redo = stack.getRedoCommand(context1);
expect(redo, 'got a redo command').not.to.exist;
});
it('clears the undo stack', () => {
stack.flush(context1);
const undo = stack.getUndoCommand(context1);
expect(undo, 'got an undo command').not.to.exist;
});
it('returns purged commands', () => {
const purged = stack.flush(context1);
expect(purged).to.be.an('array').of.length(2);
expect(purged).to.include(command1).and.to.include(command2);
});
it('empties the stack', () => {
stack.flush(context1);
expect(stack).to.be.like({
_top: undefined,
_undoEntries: new Map(),
_redoEntries: new Map(),
});
});
});
});
describe('With Multiple EditingContexts', () => {
beforeEach(() => {
command1 = new TestCommand('a');
command2 = new TestCommand('b');
command3 = new TestCommand('c', 'test-model-id-3');
compound = new AppendableCompoundCommand(
'test',
...[command1, command2, command3]
);
stack = new CoreCommandStackImpl(workingCopyManager);
});
describe('execute', () => {
it('executes a command', async () => {
await stack.execute(command1, context1, context2);
expect(command1, 'command not executed').to.be.like({
wasExecuted: true,
});
});
it('puts the command on the undo stack of all contexts', async () => {
await stack.execute(command1, context1, context2);
const undo1 = stack.getUndoCommand(context1);
expect(undo1, 'wrong undo command').to.equal(command1);
const undo2 = stack.getUndoCommand(context2);
expect(undo2, 'wrong undo command').to.equal(command1);
});
it('flushes all redo stacks', async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1, context2);
await stack.undo(context1);
const newCommand = new TestCommand('c');
await stack.execute(newCommand, context1, context2);
await expect(
stack.canRedo(context1),
'redo stack of context 1 not flushed'
).to.eventually.be.false;
await expect(
stack.canRedo(context2),
'redo stack of context 2 not flushed'
).to.eventually.be.false;
expect(
stack.getRedoCommand(context1),
'redo command exists in context 1'
).not.to.exist;
expect(
stack.getRedoCommand(context2),
'redo command exists in context 2'
).not.to.exist;
});
it('flushes one redo stack with dependencies', async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1, context2);
await stack.execute(new TestCommand('c'), context2);
await stack.undo(context2);
await stack.undo(context1);
const newCommand = new TestCommand('d');
await stack.execute(newCommand, context1);
await expect(
stack.canRedo(context1),
'redo stack of context 1 not flushed'
).to.eventually.be.false;
await expect(
stack.canRedo(context2),
'redo stack of context 2 was not also flushed for dependencies'
).to.eventually.be.false;
expect(
stack.getRedoCommand(context1),
'redo command exists in context 1'
).not.to.exist;
expect(
stack.getRedoCommand(context2),
'redo command exists in context 2'
).not.to.exist;
expect(stack, 'commands not purged as expected').to.be.like({
_top: {
_command: newCommand,
_previous: {
_command: command1,
},
},
});
expect(
(stack['_top'] as StackEntry).previous?.previous,
'too much on the stack'
).not.to.exist;
});
it('flushes portion of one redo stack in common with another', async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1, context2);
await stack.undo(context2);
await stack.undo(context1);
const newCommand = new TestCommand('c');
await stack.execute(newCommand, context2);
await expect(
stack.canRedo(context2),
'redo stack of context 2 not flushed'
).to.eventually.be.false;
await expect(
stack.canRedo(context1),
'redo stack of context 1 was flushed too much'
).to.eventually.be.true;
expect(
stack.getRedoCommand(context1),
'wrong redo command in context 1'
).to.equal(command1);
});
});
describe('command stack notifications', () => {
it('on execute', async () => {
const callback = sandbox.stub();
const sub = stack.subscribe();
sub.onContextChanged = callback;
await stack.execute(command1, context1);
await stack.execute(command2, context1, context2);
expect(callback).to.have.been.calledThrice;
expect(callback).to.have.been.calledWith(
context1,
'executed',
command1
);
expect(callback).to.have.been.calledWith(
context1,
'executed',
command2
);
expect(callback).to.have.been.calledWith(
context2,
'executed',
command2
);
});
it('on undo', async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1, context2);
const callback = sandbox.stub();
const sub = stack.subscribe();
sub.onContextChanged = callback;
await stack.undo(context1);
expect(callback).to.have.been.calledTwice;
expect(callback).to.have.been.calledWith(context1, 'undone', command2);
expect(callback).to.have.been.calledWith(context2, 'undone', command2);
});
it('on redo', async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1, context2);
await stack.undo(context1);
const callback = sandbox.stub();
const sub = stack.subscribe();
sub.onContextChanged = callback;
await stack.redo(context1);
expect(callback).to.have.been.calledTwice;
expect(callback).to.have.been.calledWith(context1, 'redone', command2);
expect(callback).to.have.been.calledWith(context2, 'redone', command2);
});
it('close', async () => {
const callback = sandbox.stub();
const sub = stack.subscribe();
sub.onContextChanged = callback;
await stack.execute(command1, context1);
sub.close();
await stack.undo(context1);
await stack.redo(context1);
expect(callback).to.have.been.calledOnce;
expect(callback).to.have.been.calledWith(
context1,
'executed',
command1
);
callback.resetHistory();
// Closing again is idempotent
sub.close();
await stack.execute(command2, context1);
expect(callback).not.to.have.been.called;
});
it('multiple subs', async () => {
const callback1 = sandbox.stub();
const sub1 = stack.subscribe(context1);
sub1.onContextChanged = callback1;
const callback2 = sandbox.stub();
const sub2 = stack.subscribe(context1);
sub2.onContextChanged = callback2;
await stack.execute(command1, context1);
sub1.close();
await stack.undo(context1);
expect(callback1).to.have.been.calledOnce;
expect(callback2).to.have.been.calledTwice;
expect(callback1).to.have.been.calledWith(
context1,
'executed',
command1
);
expect(callback2).to.have.been.calledWith(
context1,
'executed',
command1
);
expect(callback2).to.have.been.calledWith(context1, 'undone', command1);
});
});
describe('getEditingContexts', () => {
it('contexts that have been edited', async () => {
let editingContexts = stack.getEditingContexts();
expect(editingContexts, 'should have no editing contexts').to.be.empty;
await stack.execute(command1, context1);
editingContexts = stack.getEditingContexts();
expect(editingContexts, 'should have exactly context 1')
.to.be.include(context1)
.and.have.length(1);
await stack.execute(command2, context2);
editingContexts = stack.getEditingContexts();
expect(editingContexts, 'should additionally have context 2')
.to.be.include(context2)
.and.have.length(2);
await stack.undo(context2);
await stack.undo(context1);
editingContexts = stack.getEditingContexts();
expect(editingContexts, 'should have exactly context1 and context2')
.to.have.members([context1, context2])
.and.have.length(2);
});
it('flushed but dirty context', async () => {
await stack.execute(command1, context1);
stack.markSaved(context1);
// Make context1 dirty
await stack.execute(command2, context1, context2);
// Flush it
stack.flush(context1);
const editingContexts = stack.getEditingContexts();
expect(editingContexts, 'should have exactly context1 and context2')
.to.have.members([context1, context2])
.and.have.length(2);
});
it('flushed and saved context', async () => {
await stack.execute(command2, context1, context2);
// Flush context 1
stack.flush(context1);
// Save it
stack.markSaved(context1);
const editingContexts = stack.getEditingContexts();
expect(editingContexts, 'should not have context1').not.to.include(
context1
);
expect(editingContexts, 'missing context2').to.include(context2);
});
});
describe('undo', () => {
beforeEach(async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1, context2);
});
it('undoes a command in context1', async () => {
await stack.undo(context1);
expect(command2, 'command not undone').to.be.like({ wasUndone: true });
await expect(stack.canUndo(context1), 'cannot undo context1 again').to
.eventually.be.true;
await expect(stack.canUndo(context2), 'can undo context2 again').to
.eventually.be.false;
});
it('undoes a command in context2', async () => {
await stack.undo(context2);
expect(command2, 'command not undone').to.be.like({ wasUndone: true });
await expect(stack.canUndo(context1), 'cannot undo context1 again').to
.eventually.be.true;
await expect(stack.canUndo(context2), 'can undo context2 again').to
.eventually.be.false;
});
it('puts the command on all redo stacks', async () => {
await stack.undo(context1);
const redo1 = stack.getRedoCommand(context1);
expect(redo1, 'wrong redo command').to.equal(command2);
const redo2 = stack.getRedoCommand(context2);
expect(redo2, 'wrong redo command').to.equal(command2);
});
it('notifies', async () => {
const callback = sandbox.stub();
const sub = stack.subscribe();
sub.onContextChanged = callback;
await stack.undo(context2);
await stack.undo(context1);
expect(callback).to.have.been.calledThrice;
expect(callback).to.have.been.calledWith(context2, 'undone', command2);
expect(callback).to.have.been.calledWith(context1, 'undone', command2);
expect(callback).to.have.been.calledWith(context1, 'undone', command1);
});
});
describe('undo dependencies', () => {
beforeEach(async () => {
await stack.execute(command1, context1, context2);
await stack.execute(command2, context1);
});
it('cannot undo a command that is not next in all its contexts', async () => {
// Should always be able to undo the last command in the temporal order
await expect(stack.canUndo(context1, false), 'cannot undo last command')
.to.eventually.be.true;
// But this one needs the other context to have undone first
await expect(
stack.canUndo(context2, false),
'can undo dependent command'
).to.eventually.be.false;
});
it('throws on violated dependency precondition', () => {
return expect(
stack.undo(context2, false),
'should have thrown precondition failure'
).to.eventually.be.rejected;
});
it('analyze undo without undo command', async () => {
const analysis = await stack.analyzeUndo('no-such-context');
expect(analysis.canUndo, 'can undo').to.be.false;
expect(analysis.hasDependencies, 'should not have dependencies').to.be
.false;
expect(analysis.summary).to.equal(
"There is no command to undo in context 'no-such-context'."
);
expect(analysis.contexts).to.deep.equal({ 'no-such-context': false });
});
it('analyze undo without dependencies', async () => {
const analysis = await stack.analyzeUndo(context1);
expect(analysis.canUndo, 'cannot undo without dependencies').to.be.true;
expect(analysis.hasDependencies, 'should not have dependencies').to.be
.false;
expect(analysis.summary).to.match(
/^The undo command .* is undoable\.$/
);
expect(analysis.contexts).to.deep.equal({ [context1]: true });
});
it('analyze undo without dependencies - not undoable', async () => {
sandbox.stub(command2, 'canUndo').returns(Promise.resolve(false));
const analysis = await stack.analyzeUndo(context1);
expect(analysis.canUndo, 'can undo without dependencies').to.be.false;
expect(analysis.hasDependencies, 'should not have dependencies').to.be
.false;
expect(analysis.summary).to.match(
/^The undo command .* is not undoable\.$/
);
expect(analysis.contexts).to.deep.equal({ [context1]: false });
});
it('analyze undo with dependencies', async () => {
const analysis = await stack.analyzeUndo(context2);
expect(analysis.canUndo, 'cannot undo dependencies').to.be.true;
expect(analysis.hasDependencies, 'should have dependencies').to.be.true;
expect(analysis.summary).to.match(
/^The undo command .* is undoable\.$/
);
expect(analysis.contexts).to.exist;
expect(analysis.contexts).to.be.like({
[context1]: true,
[context2]: true,
});
});
it('analyze undo with dependencies - not undoable', async () => {
sandbox.stub(command1, 'canUndo').returns(Promise.resolve(false));
const analysis = await stack.analyzeUndo(context2);
expect(analysis.canUndo, 'can undo').to.be.false;
expect(analysis.hasDependencies, 'should have dependencies').to.be.true;
expect(analysis.summary).to.match(
/^The undo command .* because it is not itself undoable\.$/
);
expect(analysis.contexts).to.exist;
expect(analysis.contexts).to.be.like({
[context1]: true,
[context2]: false,
});
});
it('analyze undo with dependencies - dependency not undoable', async () => {
sandbox.stub(command2, 'canUndo').returns(Promise.resolve(false));
const analysis = await stack.analyzeUndo(context2);
expect(analysis.canUndo, 'can undo').to.be.false;
expect(analysis.hasDependencies, 'should have dependencies').to.be.true;
expect(analysis.summary).to.match(
/^The undo command .* is not undoable because its dependency '.*' is not undoable\.$/
);
expect(analysis.contexts).to.exist;
expect(analysis.contexts).to.be.like({
[context1]: false,
[context2]: true,
});
});
it('analyze undo with dependencies - it and a dependency not undoable', async () => {
sandbox.stub(command2, 'canUndo').returns(Promise.resolve(false));
sandbox.stub(command1, 'canUndo').returns(Promise.resolve(false));
const analysis = await stack.analyzeUndo(context2);
expect(analysis.canUndo, 'can undo').to.be.false;
expect(analysis.hasDependencies, 'should have dependencies').to.be.true;
expect(analysis.summary).to.match(
/^The undo command .* is not undoable because it is not itself undoable and its dependency '.*' is not undoable\.$/
);
expect(analysis.contexts).to.exist;
expect(analysis.contexts).to.be.like({
[context1]: false,
[context2]: false,
});
});
it('analyze undo with dependencies - multiple dependencies not undoable', async () => {
stack.flush(context1);
stack.flush(context2);
const context3 = editingContext('new.context');
command1 = new TestCommand('a');
command2 = new TestCommand('b');
const command3 = new TestCommand('c');
await stack.execute(command1, context1, context3);
await stack.execute(command2, context1, context2);
await stack.execute(command3, context1);
sandbox.stub(command2, 'canUndo').returns(Promise.resolve(false));
sandbox.stub(command3, 'canUndo').returns(Promise.resolve(false));
const analysis = await stack.analyzeUndo(context3);
expect(analysis.canUndo, 'can undo').to.be.false;
expect(analysis.hasDependencies, 'should have dependencies').to.be.true;
expect(analysis.summary).to.match(
/^The undo command .* is not undoable because its dependencies '.*' are not undoable\.$/
);
expect(analysis.contexts).to.exist;
expect(analysis.contexts).to.be.like({
[context1]: false,
[context2]: false,
[context3]: true,
});
});
it('undoes and then redoes dependencies as a unit', async () => {
const undoCommand1 = sandbox
.spy(command1, 'undo')
.named('undo command1');
const undoCommand2 = sandbox
.spy(command2, 'undo')
.named('undo command2');
const redoCommand1 = sandbox
.spy(command1, 'redo')
.named('redo command1');
const redoCommand2 = sandbox
.spy(command2, 'redo')
.named('redo command2');
await expect(
stack.canUndo(context2),
'should be able to undo with dependencies'
).to.eventually.be.true;
await expect(
stack.undo(context2),
'should not have thrown precondition failure'
).to.eventually.be.fulfilled;
expect(command1.wasUndone).to.be.true;
expect(command2.wasUndone).to.be.true;
sinon.assert.callOrder(undoCommand2, undoCommand1);
// Then redoing either context on its own is trivial (no dependencies)
await expect(stack.canRedo(context1, false)).to.eventually.be.true;
await expect(stack.canRedo(context2, false)).to.eventually.be.true;
await expect(stack.redo(context2, false)).to.eventually.be.fulfilled;
expect(command1.wasRedone).to.be.true;
expect(command2.wasRedone).to.be.true;
sinon.assert.callOrder(redoCommand1, redoCommand2);
});
});
describe('redo', () => {
beforeEach(async () => {
await stack.execute(command1, context1, context2);
await stack.execute(command2, context1);
await stack.undo(context1);
await stack.undo(context1);
});
it('redoes a command in context1', async () => {
await stack.redo(context1);
expect(command1, 'command not redone').to.be.like({ wasRedone: true });
await expect(stack.canRedo(context1), 'cannot redo context1 again').to
.eventually.be.true;
await expect(stack.canRedo(context2), 'can redo context2 again').to
.eventually.be.false;
});
it('redoes a command in context2', async () => {
await stack.redo(context2);
expect(command1, 'command not redone').to.be.like({ wasRedone: true });
await expect(stack.canRedo(context1), 'cannot redo context1 again').to
.eventually.be.true;
await expect(stack.canRedo(context2), 'can redo context2 again').to
.eventually.be.false;
});
it('puts the command on all undo stacks', async () => {
await stack.redo(context1);
const undo1 = stack.getUndoCommand(context1);
expect(undo1, 'wrong undo command').to.equal(command1);
const undo2 = stack.getUndoCommand(context2);
expect(undo2, 'wrong undo command').to.equal(command1);
});
it('notifies', async () => {
const callback = sandbox.stub();
const sub = stack.subscribe();
sub.onContextChanged = callback;
await stack.redo(context2);
await stack.redo(context1);
expect(callback).to.have.been.calledThrice;
expect(callback).to.have.been.calledWith(context1, 'redone', command1);
expect(callback).to.have.been.calledWith(context2, 'redone', command1);
expect(callback).to.have.been.calledWith(context1, 'redone', command2);
});
});
describe('redo dependencies', () => {
beforeEach(async () => {
await stack.execute(command1, context1);
await stack.execute(command2, context1, context2);
await stack.undo(context1);
await stack.undo(context1);
});
it('cannot redo a command that is not next in all its contexts', async () => {
await expect(stack.canRedo(context1, false), 'cannot redo context1').to
.eventually.be.true;
// But this one needs the other context to have redone first
await expect(
stack.canRedo(context2, false),
'can redo dependent command'
).to.eventually.be.false;
});
it('throws on violated dependency precondition', () => {
return expect(
stack.redo(context2, false),
'should have thrown precondition failure'
).to.eventually.be.rejected;
});
it('analyze redo without redo command', async () => {
const analysis = await stack.analyzeRedo('no-such-context');
expect(analysis.canRedo, 'can redo').to.be.false;
expect(analysis.hasDependencies, 'should not have dependencies').to.be
.false;
expect(analysis.summary).to.equal(
"There is no command to redo in context 'no-such-context'."
);
expect(analysis.contexts).to.deep.equal({ 'no-such-context': false });
});
it('analyze redo without dependencies', async () => {
const analysis = await stack.analyzeRedo(context1);
expect(analysis.canRedo, 'cannot redo without dependencies').to.be.true;
expect(analysis.hasDependencies, 'should not have dependencies').to.be
.false;
expect(analysis.summary).to.match(
/^The redo command .* is redoable\.$/
);
expect(analysis.contexts).to.deep.equal({ [context1]: true });
});
it('analyze redo without dependencies - not redoable', async () => {
sandbox.stub(command1, 'canRedo').returns(Promise.resolve(false));
const analysis = await stack.analyzeRedo(context1);
expect(analysis.canRedo, 'can redo without dependencies').to.be.false;
expect(analysis.hasDependencies, 'should not have dependencies').to.be
.false;
expect(analysis.summary).to.match(
/^The redo command .* is not redoable\.$/
);
expect(analysis.contexts).to.deep.equal({ [context1]: false });
});
it('analyze redo with dependencies', async () => {
const analysis = await stack.analyzeRedo(context2);
expect(analysis.canRedo, 'cannot redo dependencies').to.be.true;
expect(analysis.hasDependencies, 'should have dependencies').to.be.true;
expect(analysis.summary).to.match(
/^The redo command .* is redoable\.$/
);
expect(analysis.contexts).to.exist;
expect(analysis.contexts).to.be.like({
[context1]: true,
[context2]: true,
});
});
it('analyze redo with dependencies - not redoable', async () => {
sandbox.stub(command2, 'canRedo').returns(Promise.resolve(false));
const analysis = await stack.analyzeRedo(context2);
expect(analysis.canRedo, 'can redo').to.be.false;
expect(analysis.hasDependencies, 'should have dependencies').to.be.true;
expect(analysis.summary).to.match(
/^The redo command .* because it is not itself redoable\.$/
);
expect(analysis.contexts).to.exist;
expect(analysis.contexts).to.be.like({
[context1]: true,
[context2]: false,
});
});
it('analyze redo with dependencies - dependency not redoable', async () => {
sandbox.stub(command1, 'canRedo').returns(Promise.resolve(false));
const analysis = await stack.analyzeRedo(context2);
expect(analysis.canRedo, 'can redo').to.be.false;
expect(analysis.hasDependencies, 'should have dependencies').to.be.true;
expect(analysis.summary).to.match(
/^The redo command .* is not redoable because its dependency '.*' is not redoable\.$/
);
expect(analysis.contexts).to.exist;
expect(analysis.contexts).to.be.like({
[context1]: false,
[context2]: true,
});
});
it('analyze redo with dependencies - it and a dependency not redoable', async () => {
sandbox.stub(comm