UNPKG

@eclipse-emfcloud/model-manager

Version:

Command-based model editing with undo/redo.

935 lines 97.7 kB
"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