@eclipse-emfcloud/model-manager
Version:
Command-based model editing with undo/redo.
935 lines • 97.7 kB
JavaScript
"use strict";
// *****************************************************************************
// 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
// *****************************************************************************
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 });
/* eslint-disable @typescript-eslint/no-non-null-assertion */
const chai_1 = __importStar(require("chai"));
const chai_like_1 = __importDefault(require("chai-like"));
const sinon_1 = __importDefault(require("sinon"));
const sinon_chai_1 = __importDefault(require("sinon-chai"));
const core_1 = require("../../core");
const core_command_stack_impl_1 = require("../core-command-stack-impl");
chai_1.default.use(chai_like_1.default);
chai_1.default.use(sinon_chai_1.default);
chai_like_1.default.extend({
match: (o) => o instanceof Map,
assert: (o, expected) => {
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 = {
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) => id;
describe('StackEntry', () => {
let command1;
let command2;
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 core_command_stack_impl_1.StackEntry(command1, []);
(0, chai_1.expect)(entry.command, 'no command').to.be.equal(command1);
});
it('provides access to its editing contexts', () => {
const entry = new core_command_stack_impl_1.StackEntry(command1, [context1, context2]);
(0, chai_1.expect)(entry.editingContexts, 'missing context 1').to.include(context1);
(0, chai_1.expect)(entry.editingContexts, 'missing context 2').to.include(context2);
});
describe('isPurgeable', () => {
let entry;
beforeEach(() => {
entry = new core_command_stack_impl_1.StackEntry(command1, [context1, context2]);
});
it('is not purgeable', () => {
(0, chai_1.expect)(entry.isPurgeable, 'should not be purgeable').to.be.false;
entry.editingContexts.delete(context1);
(0, chai_1.expect)(entry.isPurgeable, 'still should not be purgeable').to.be.false;
});
it('is purgeable', () => {
entry.editingContexts.delete(context1);
entry.editingContexts.delete(context2);
(0, chai_1.expect)(entry.isPurgeable, 'should be purgeable').to.be.true;
});
});
describe('linking of entries', () => {
let command3;
let command4;
let entry1;
let entry2;
let entry3;
let entry4;
beforeEach(() => {
command3 = new TestCommand('c');
command4 = new TestCommand('d');
entry1 = new core_command_stack_impl_1.StackEntry(command1, [context1]);
entry2 = new core_command_stack_impl_1.StackEntry(command2, [context2]);
entry3 = new core_command_stack_impl_1.StackEntry(command3, [context1]);
entry4 = new core_command_stack_impl_1.StackEntry(command4, [context2]);
entry1.push(entry2);
});
it('can push next at end of the chain', () => {
entry2.push(entry3);
(0, chai_1.expect)(entry2.next, 'wrong next').to.equal(entry3);
(0, chai_1.expect)(entry3.next, 'end of chain has a next').not.to.exist;
(0, chai_1.expect)(entry3.previous, 'end of chain has wrong previous').to.equal(entry2);
});
it('can insert within the chain', () => {
entry1.push(entry4);
(0, chai_1.expect)(entry1.next, 'wrong next').to.equal(entry4);
(0, chai_1.expect)(entry4.next, 'inserted entry has wrong next').to.equal(entry2);
(0, chai_1.expect)(entry4.previous, 'inserted entry has wrong previous').to.equal(entry1);
(0, chai_1.expect)(entry2.previous, 'end of chain has wrong previous').to.equal(entry4);
});
describe('cycle detection', () => {
it('cannot create a one-cycle', () => {
(0, chai_1.expect)(() => entry2.push(entry2), 'should have thrown cycle precondition failure').to.throw();
});
it('cannot create an indirect cycle', () => {
entry3.push(entry2);
(0, chai_1.expect)(() => entry2.push(entry3), 'should have thrown cycle precondition failure').to.throw();
});
});
it('can pop the end of the chain', () => {
entry2.pop();
(0, chai_1.expect)(entry1.next, 'previous entry still references the popped entry')
.not.to.exist;
(0, chai_1.expect)(entry2.previous, 'popped entry still references its previous').not
.to.exist;
});
it('can pop from within the chain', () => {
entry2.push(entry3);
entry2.pop();
(0, chai_1.expect)(entry1.next, 'previous entry references wrong next').to.equal(entry3);
(0, chai_1.expect)(entry3.previous, 'next entry references wrong previous').to.equal(entry1);
(0, chai_1.expect)(entry2.next, 'popped entry still references its next').not.to
.exist;
(0, chai_1.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', () => {
(0, chai_1.expect)(entry1.nextIn(context1), 'wrong next in same context').to.equal(entry3);
(0, chai_1.expect)(entry1.nextIn(context2), 'wrong next in different context').to.equal(entry2);
});
it('finds previous in the editing context', () => {
(0, chai_1.expect)(entry4.previousIn(context2), 'wrong previous in same context').to.equal(entry2);
(0, chai_1.expect)(entry4.previousIn(context1), 'wrong previous in different context').to.equal(entry3);
});
});
});
describe('editing context management', () => {
let entry;
beforeEach(() => {
entry = new core_command_stack_impl_1.StackEntry(command1, [context1, context2]);
});
it('matches a context', () => {
(0, chai_1.expect)(entry.hasContext(context2), 'does not have the context').to.be
.true;
});
it('does not match a context', () => {
entry.editingContexts.delete(context2);
(0, chai_1.expect)(entry.hasContext(context2), 'has the context').to.be.false;
});
it('removes a context', () => {
(0, chai_1.expect)(entry.editingContexts, 'context not found').to.include(context2);
entry.removeContext(context2);
(0, chai_1.expect)(entry.editingContexts, 'context still found').not.to.include(context2);
});
});
describe('merge', () => {
let entry1;
let entry2;
beforeEach(() => {
entry1 = new core_command_stack_impl_1.StackEntry(command1, [context1]);
entry2 = new core_command_stack_impl_1.StackEntry(command2, [context2]);
});
it('merges the editing contexts', () => {
const merged = entry1.merge(entry2);
(0, chai_1.expect)(merged, 'wrong entry returned').to.equal(entry1);
(0, chai_1.expect)(merged.editingContexts, 'contexts not merged').to.include(context2);
(0, chai_1.expect)(merged.editingContexts, 'contexts removed').to.include(context1);
});
it('merges the commands', () => {
const merged = entry1.merge(entry2);
(0, chai_1.expect)(merged, 'wrong entry returned').to.equal(entry1);
(0, chai_1.expect)((0, core_1.isCompoundCommand)(merged.command), 'command is not a compound').to
.be.true;
const compound = merged.command;
(0, chai_1.expect)(compound.getCommands(), 'commands not merged').to.be.deep.equal([
command1,
command2,
]);
});
});
});
describe('AppendableCompoundCommand', () => {
let command1;
let command2;
let compound;
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 core_command_stack_impl_1.AppendableCompoundCommand('test', command1);
});
it('is created in the executed state', () => {
return (0, chai_1.expect)(compound.canUndo(() => ({})), 'command was not executed').to.eventually.be.true;
});
it('can be appended', () => {
compound.append(command2);
(0, chai_1.expect)(compound.getCommands(), 'command not appended').to.include(command2);
});
it('cannot append when undone', async () => {
await compound.undo(() => ({}));
(0, chai_1.expect)(() => compound.append(command2), 'should have thrown precondition failure').to.throw();
});
describe('append() utility', () => {
it('appends nothing', () => {
const result = (0, core_command_stack_impl_1.append)(command2);
(0, chai_1.expect)(result, 'append should have been a no-op').to.equal(command2);
});
it('creates a new appendable compound', () => {
const result = (0, core_command_stack_impl_1.append)(command1, command2);
(0, chai_1.expect)(result, 'wrong kind of compound').to.be.instanceOf(core_command_stack_impl_1.AppendableCompoundCommand);
const asCompound = result;
(0, chai_1.expect)(asCompound.getCommands(), 'wrong nested commands').to.be.deep.equal([command1, command2]);
});
it('appends to an existing compound', () => {
const result = (0, core_command_stack_impl_1.append)(compound, command2);
(0, chai_1.expect)(result, 'wrong compound').to.be.equal(compound);
(0, chai_1.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;
let command2;
let command3;
let compound;
let stack;
let sandbox;
beforeEach(() => {
sandbox = sinon_1.default.createSandbox();
command1 = new TestCommand('a');
command2 = new TestCommand('b');
stack = new core_command_stack_impl_1.CoreCommandStackImpl(workingCopyManager);
});
afterEach(() => {
sandbox.restore();
});
describe('With One EditingContext', () => {
describe('execute', () => {
it('executes a command', async () => {
const delta = await stack.execute(command1, context1);
(0, chai_1.expect)(command1, 'command not executed').to.be.like({
wasExecuted: true,
});
(0, chai_1.expect)(delta, 'no delta returned').to.exist;
(0, chai_1.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);
(0, chai_1.expect)(undo, 'wrong undo command').to.equal(command1);
await stack.execute(command2, context1);
const undo2 = stack.getUndoCommand(context1);
(0, chai_1.expect)(undo2, 'wrong undo command').to.equal(command2);
});
it('requires an editing context', () => {
return (0, chai_1.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 (0, chai_1.expect)(stack.canRedo(context1), 'redo stack not flushed').to
.eventually.be.false;
(0, chai_1.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);
(0, chai_1.expect)(command2, 'command not undone').to.be.like({ wasUndone: true });
(0, chai_1.expect)(delta, 'no delta returned').to.exist;
(0, chai_1.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);
(0, chai_1.expect)(redo, 'wrong redo command').to.equal(command2);
await stack.undo(context1);
const redo2 = stack.getRedoCommand(context1);
(0, chai_1.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);
(0, chai_1.expect)(command1, 'command not redone').to.be.like({ wasRedone: true });
(0, chai_1.expect)(command2, 'command was redone').to.be.like({
wasRedone: false,
wasUndone: true,
});
(0, chai_1.expect)(delta, 'no delta returned').to.exist;
(0, chai_1.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);
(0, chai_1.expect)(undo, 'wrong undo command').to.equal(command1);
await stack.redo(context1);
const undo2 = stack.getUndoCommand(context1);
(0, chai_1.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);
(0, chai_1.expect)(command2, 'command not executed').to.be.like({
wasExecuted: true,
});
(0, chai_1.expect)(delta, 'no delta returned').to.exist;
(0, chai_1.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);
(0, chai_1.expect)(undo, 'wrong undo command').not.to.equal(command1);
const delta = await stack.undo(context1);
(0, chai_1.expect)(delta, 'no delta returned').to.exist;
(0, chai_1.expect)(delta?.get(command2)).to.be.like([{ op: 'remove', path: 'b' }]);
(0, chai_1.expect)(delta?.get(command1)).to.be.like([{ op: 'remove', path: 'a' }]);
});
it('requires a command to append to', () => {
return (0, chai_1.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 (0, chai_1.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);
(0, chai_1.expect)(redo, 'no undo command').to.exist;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
(0, chai_1.expect)((0, core_1.isCompoundCommand)(redo), 'undo command is not a compound').to.be
.true;
const compound = redo;
(0, chai_1.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 (0, chai_1.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);
(0, chai_1.expect)(undo2, 'wrong undo command').to.equal(undo);
const delta = await stack.undo(context1);
(0, chai_1.expect)(delta, 'no delta returned').to.exist;
(0, chai_1.expect)(delta?.get(newCommand)).to.be.like([
{ op: 'remove', path: 'c' },
]);
(0, chai_1.expect)(delta?.get(command2)).to.be.like([{ op: 'remove', path: 'b' }]);
(0, chai_1.expect)(delta?.get(command1)).to.be.like([{ op: 'remove', path: 'a' }]);
});
describe('returns partial deltas when applicable', () => {
const obtrudeResult = (cmd, op) => {
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 (0, chai_1.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 (0, chai_1.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 (0, chai_1.expect)(stack.canExecute(command1, context1), 'cannot execute').to
.eventually.be.true;
});
it('is not executable by reason of the command', async () => {
await (0, chai_1.expect)(stack.canExecute(command1, context1), 'cannot execute').to
.eventually.be.true;
await stack.execute(command1, context1);
await (0, chai_1.expect)(stack.canExecute(command1, context1), 'can still execute')
.to.eventually.be.false;
});
it('is not executable by reason of the contexts', async () => {
await (0, chai_1.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 (0, chai_1.expect)(stack.canUndo(context1), 'cannot undo').to.eventually.be
.true;
});
it('is not undoable by reason of itself', async () => {
await stack.undo(context1);
await (0, chai_1.expect)(stack.canUndo(context1), 'cannot undo').to.eventually.be
.true;
await stack.undo(context1);
await (0, chai_1.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 (0, chai_1.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 (0, chai_1.expect)(stack.canRedo(context1), 'cannot redo').to.eventually.be
.true;
});
it('is not redoable by reason of itself', async () => {
await stack.redo(context1);
await (0, chai_1.expect)(stack.canRedo(context1), 'cannot redo').to.eventually.be
.true;
await stack.redo(context1);
await (0, chai_1.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 (0, chai_1.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);
(0, chai_1.expect)(undo, 'wrong undo command').to.be.equal(command2);
await stack.undo(context1);
const undo2 = stack.getUndoCommand(context1);
(0, chai_1.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);
(0, chai_1.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);
(0, chai_1.expect)(redo, 'wrong redo command').to.be.equal(command1);
await stack.redo(context1);
const redo2 = stack.getRedoCommand(context1);
(0, chai_1.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);
(0, chai_1.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);
(0, chai_1.expect)(redo, 'got a redo command').not.to.exist;
});
it('clears the undo stack', () => {
stack.flush(context1);
const undo = stack.getUndoCommand(context1);
(0, chai_1.expect)(undo, 'got an undo command').not.to.exist;
});
it('returns purged commands', () => {
const purged = stack.flush(context1);
(0, chai_1.expect)(purged).to.be.an('array').of.length(2);
(0, chai_1.expect)(purged).to.include(command1).and.to.include(command2);
});
it('empties the stack', () => {
stack.flush(context1);
(0, chai_1.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 core_command_stack_impl_1.AppendableCompoundCommand('test', ...[command1, command2, command3]);
stack = new core_command_stack_impl_1.CoreCommandStackImpl(workingCopyManager);
});
describe('execute', () => {
it('executes a command', async () => {
await stack.execute(command1, context1, context2);
(0, chai_1.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);
(0, chai_1.expect)(undo1, 'wrong undo command').to.equal(command1);
const undo2 = stack.getUndoCommand(context2);
(0, chai_1.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 (0, chai_1.expect)(stack.canRedo(context1), 'redo stack of context 1 not flushed').to.eventually.be.false;
await (0, chai_1.expect)(stack.canRedo(context2), 'redo stack of context 2 not flushed').to.eventually.be.false;
(0, chai_1.expect)(stack.getRedoCommand(context1), 'redo command exists in context 1').not.to.exist;
(0, chai_1.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 (0, chai_1.expect)(stack.canRedo(context1), 'redo stack of context 1 not flushed').to.eventually.be.false;
await (0, chai_1.expect)(stack.canRedo(context2), 'redo stack of context 2 was not also flushed for dependencies').to.eventually.be.false;
(0, chai_1.expect)(stack.getRedoCommand(context1), 'redo command exists in context 1').not.to.exist;
(0, chai_1.expect)(stack.getRedoCommand(context2), 'redo command exists in context 2').not.to.exist;
(0, chai_1.expect)(stack, 'commands not purged as expected').to.be.like({
_top: {
_command: newCommand,
_previous: {
_command: command1,
},
},
});
(0, chai_1.expect)(stack['_top'].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 (0, chai_1.expect)(stack.canRedo(context2), 'redo stack of context 2 not flushed').to.eventually.be.false;
await (0, chai_1.expect)(stack.canRedo(context1), 'redo stack of context 1 was flushed too much').to.eventually.be.true;
(0, chai_1.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);
(0, chai_1.expect)(callback).to.have.been.calledThrice;
(0, chai_1.expect)(callback).to.have.been.calledWith(context1, 'executed', command1);
(0, chai_1.expect)(callback).to.have.been.calledWith(context1, 'executed', command2);
(0, chai_1.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);
(0, chai_1.expect)(callback).to.have.been.calledTwice;
(0, chai_1.expect)(callback).to.have.been.calledWith(context1, 'undone', command2);
(0, chai_1.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);
(0, chai_1.expect)(callback).to.have.been.calledTwice;
(0, chai_1.expect)(callback).to.have.been.calledWith(context1, 'redone', command2);
(0, chai_1.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);
(0, chai_1.expect)(callback).to.have.been.calledOnce;
(0, chai_1.expect)(callback).to.have.been.calledWith(context1, 'executed', command1);
callback.resetHistory();
// Closing again is idempotent
sub.close();
await stack.execute(command2, context1);
(0, chai_1.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);
(0, chai_1.expect)(callback1).to.have.been.calledOnce;
(0, chai_1.expect)(callback2).to.have.been.calledTwice;
(0, chai_1.expect)(callback1).to.have.been.calledWith(context1, 'executed', command1);
(0, chai_1.expect)(callback2).to.have.been.calledWith(context1, 'executed', command1);
(0, chai_1.expect)(callback2).to.have.been.calledWith(context1, 'undone', command1);
});
});
describe('getEditingContexts', () => {
it('contexts that have been edited', async () => {
let editingContexts = stack.getEditingContexts();
(0, chai_1.expect)(editingContexts, 'should have no editing contexts').to.be.empty;
await stack.execute(command1, context1);
editingContexts = stack.getEditingContexts();
(0, chai_1.expect)(editingContexts, 'should have exactly context 1')
.to.be.include(context1)
.and.have.length(1);
await stack.execute(command2, context2);
editingContexts = stack.getEditingContexts();
(0, chai_1.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();
(0, chai_1.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();
(0, chai_1.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();
(0, chai_1.expect)(editingContexts, 'should not have context1').not.to.include(context1);
(0, chai_1.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);
(0, chai_1.expect)(command2, 'command not undone').to.be.like({ wasUndone: true });
await (0, chai_1.expect)(stack.canUndo(context1), 'cannot undo context1 again').to
.eventually.be.true;
await (0, chai_1.expect)(stack.canUndo(context2), 'can undo context2 again').to
.eventually.be.false;
});
it('undoes a command in context2', async () => {
await stack.undo(context2);
(0, chai_1.expect)(command2, 'command not undone').to.be.like({ wasUndone: true });
await (0, chai_1.expect)(stack.canUndo(context1), 'cannot undo context1 again').to
.eventually.be.true;
await (0, chai_1.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);
(0, chai_1.expect)(redo1, 'wrong redo command').to.equal(command2);
const redo2 = stack.getRedoCommand(context2);
(0, chai_1.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);
(0, chai_1.expect)(callback).to.have.been.calledThrice;
(0, chai_1.expect)(callback).to.have.been.calledWith(context2, 'undone', command2);
(0, chai_1.expect)(callback).to.have.been.calledWith(context1, 'undone', command2);
(0, chai_1.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 (0, chai_1.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 (0, chai_1.expect)(stack.canUndo(context2, false), 'can undo dependent command').to.eventually.be.false;
});
it('throws on violated dependency precondition', () => {
return (0, chai_1.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');
(0, chai_1.expect)(analysis.canUndo, 'can undo').to.be.false;
(0, chai_1.expect)(analysis.hasDependencies, 'should not have dependencies').to.be
.false;
(0, chai_1.expect)(analysis.summary).to.equal("There is no command to undo in context 'no-such-context'.");
(0, chai_1.expect)(analysis.contexts).to.deep.equal({ 'no-such-context': false });
});
it('analyze undo without dependencies', async () => {
const analysis = await stack.analyzeUndo(context1);
(0, chai_1.expect)(analysis.canUndo, 'cannot undo without dependencies').to.be.true;
(0, chai_1.expect)(analysis.hasDependencies, 'should not have dependencies').to.be
.false;
(0, chai_1.expect)(analysis.summary).to.match(/^The undo command .* is undoable\.$/);
(0, chai_1.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);
(0, chai_1.expect)(analysis.canUndo, 'can undo without dependencies').to.be.false;
(0, chai_1.expect)(analysis.hasDependencies, 'should not have dependencies').to.be
.false;
(0, chai_1.expect)(analysis.summary).to.match(/^The undo command .* is not undoable\.$/);
(0, chai_1.expect)(analysis.contexts).to.deep.equal({ [context1]: false });
});
it('analyze undo with dependencies', async () => {
const analysis = await stack.analyzeUndo(context2);
(0, chai_1.expect)(analysis.canUndo, 'cannot undo dependencies').to.be.true;
(0, chai_1.expect)(analysis.hasDependencies, 'should have dependencies').to.be.true;
(0, chai_1.expect)(analysis.summary).to.match(/^The undo command .* is undoable\.$/);
(0, chai_1.expect)(analysis.contexts).to.exist;
(0, chai_1.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);
(0, chai_1.expect)(analysis.canUndo, 'can undo').to.be.false;
(0, chai_1.expect)(analysis.hasDependencies, 'should have dependencies').to.be.true;
(0, chai_1.expect)(analysis.summary).to.match(/^The undo command .* because it is not itself undoable\.$/);
(0, chai_1.expect)(analysis.contexts).to.exist;
(0, chai_1.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);
(0, chai_1.expect)(analysis.canUndo, 'can undo').to.be.false;
(0, chai_1.expect)(analysis.hasDependencies, 'should have dependencies').to.be.true;
(0, chai_1.expect)(analysis.summary).to.match(/^The undo command .* is not undoable because its dependency '.*' is not undoable\.$/);
(0, chai_1.expect)(analysis.contexts).to.exist;
(0, chai_1.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);
(0, chai_1.expect)(analysis.canUndo, 'can undo').to.be.false;
(0, chai_1.expect)(analysis.hasDependencies, 'should have dependencies').to.be.true;
(0, chai_1.expect)(analysis.summary).to.match(/^The undo command .* is not undoable because it is not itself undoable and its dependency '.*' is not undoable\.$/);
(0, chai_1.expect)(analysis.contexts).to.exist;
(0, chai_1.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);
(0, chai_1.expect)(analysis.canUndo, 'can undo').to.be.false;
(0, chai_1.expect)(analysis.hasDependencies, 'should have dependencies').to.be.true;
(0, chai_1.expect)(analysis.summary).to.match(/^The undo command .* is not undoable because its dependencies '.*' are not undoable\.$/);
(0, chai_1.expect)(analysis.contexts).to.exist;
(0, chai_1.expect)(analysis.contexts).to.be.like({
[context1]: false,
[context2]: false,
[context3]: true,
});
});
it('undoes and then redoes dependencies as