UNPKG

@eclipse-emfcloud/model-manager

Version:

Command-based model editing with undo/redo.

1,505 lines (1,239 loc) 83.4 kB
// ***************************************************************************** // 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