@eclipse-emfcloud/model-manager
Version:
Command-based model editing with undo/redo.
924 lines (805 loc) • 29.4 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2023-2024 STMicroelectronics.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: MIT License which is
// available at https://opensource.org/licenses/MIT.
//
// SPDX-License-Identifier: EPL-2.0 OR MIT
// *****************************************************************************
import { fail } from 'assert';
import Chai, { expect } from 'chai';
import { applyPatch, compare, Operation } from 'fast-json-patch';
import { cloneDeep } from 'lodash';
import Sinon from 'sinon';
import SinonChai from 'sinon-chai';
import type {
Command,
CompoundCommand,
CoreCommandStack,
EditingContext,
SimpleCommand,
} from '../../core';
import { append, createDeferredCompoundCommand } from '../../core';
import { createModelUpdaterCommand } from '../../patch';
import { WorkingCopyManager } from '../core-command-stack-impl';
import { CoreModelManagerImpl } from '../core-model-manager-impl';
Chai.use(SinonChai);
const editingContext = (id: string): EditingContext => id;
describe('CoreModelManagerImpl', () => {
let modelManager: CoreModelManagerImpl<string>;
const key1 = 'model1';
const key2 = 'model2';
const model1 = { foo1: 'foo1', bar1: 'bar1' };
const model2 = { foo2: 'foo2', bar2: 'bar2' };
let command1: Command;
let command2: Command;
let context1: string;
let context2: string;
let commandStack: CoreCommandStack;
const getModel1 = () => {
const result = modelManager.getModel(key1);
expect(result).to.exist;
return result as typeof model1;
};
const getModel2 = () => {
const result = modelManager.getModel(key2);
expect(result).to.exist;
return result as typeof model2;
};
beforeEach(() => {
modelManager = new CoreModelManagerImpl();
command1 = new TestCommand('command1', key1);
command2 = new TestCommand('command2', key2);
context1 = editingContext('context1');
context2 = editingContext('context2');
commandStack = modelManager.getCommandStack();
});
describe('register & retrieve & remove models', () => {
it('set & get models', () => {
modelManager.setModel(key1, model1);
modelManager.setModel(key2, model2);
const m1 = modelManager.getModel(key1);
expect(m1, 'model1 has not been set correctly').to.deep.equal(model1);
const m2 = modelManager.getModel(key2);
expect(m2, 'model2 has not been set correctly').to.deep.equal(model2);
});
it('register a model to an already existing key', () => {
try {
modelManager.setModel(key1, model1);
modelManager.setModel(key1, model2);
} catch (_err) {
//success
return;
}
fail('should have thrown an error as key1 already exists');
});
it('remove a model', () => {
modelManager.setModel(key1, model1);
modelManager.setModel(key2, model2);
const m1 = modelManager.removeModel(key1);
expect(m1, 'removed model is not the expected one').to.deep.equal(model1);
expect(
modelManager.getModel(key1),
'removed model should not be present anymore in the modelManager'
).to.be.undefined;
expect(
modelManager.getModel(key2),
'removing a model should not changed other registered models'
).to.deep.equal(model2);
});
it('remove a model not registered', () => {
modelManager.setModel(key1, model1);
const m2 = modelManager.removeModel(key2);
expect(m2, 'no model should be removed').to.be.undefined;
});
it('get a model ID', () => {
modelManager.setModel(key1, model1);
const _model = modelManager.getModel(key1);
expect(_model).to.exist;
const firstEdition = _model as typeof model1;
expect(modelManager.getModelId(firstEdition)).to.be.equal(key1);
modelManager.getCommandStack().execute(
createModelUpdaterCommand<string, typeof model1>(
'patch',
key1,
(model) => {
model.bar1 = 'New value';
}
),
context1
);
// Historical editions of models are remembered until they're GCed
expect(modelManager.getModelId(firstEdition)).to.be.equal(key1);
});
it('get model IDs', () => {
expect(modelManager.getModelIds(), 'should not have model IDs').to.eql(
[]
);
modelManager.setModel(key1, model1);
expect(modelManager.getModelIds(), 'should just have model ID 1')
.to.include(key1)
.and.have.length(1);
modelManager.setModel(key2, model2);
expect(modelManager.getModelIds(), 'should also have model ID 2')
.to.include(key2)
.and.have.length(2);
modelManager.removeModel(key1);
expect(modelManager.getModelIds(), 'should just have model ID 2')
.to.include(key2)
.and.have.length(1);
});
});
describe('manipulating the commandStack', () => {
it('get commandStack for editing the models', () => {
modelManager.setModel(key1, model1);
let commandStack = modelManager.getCommandStack();
expect(commandStack, 'the commandStack should be defined').to.not.be
.undefined;
modelManager.removeModel(key1);
commandStack = modelManager.getCommandStack();
expect(
commandStack,
'the commandStack should still be defined even when all models are removed'
).to.not.be.undefined;
});
it('get commandStack for editing the models when no registered model', () => {
const commandStack = modelManager.getCommandStack();
expect(
commandStack,
'the commandStack should be defined even when no model is registered'
).to.not.be.undefined;
});
});
describe('subscription to model changes', () => {
it('subscribe to an existing model changes', async () => {
let counter = 0;
modelManager.setModel(key1, model1);
modelManager.setModel(key2, model2);
const sub1 = modelManager.subscribe(key1);
const sub2 = modelManager.subscribe(key1);
const sub3 = modelManager.subscribe(key2);
sub1.onModelChanged = (key, model: Record<string, unknown>, delta) => {
expect(key).to.equal(key1);
expect(model).to.be.deep.equal(model1);
expect(model.command1).to.be.equal('test-value');
expect(delta).to.be.like([
{ op: 'add', path: 'command1', value: 'test-value' },
]);
counter++;
};
sub2.onModelChanged = (key, model: Record<string, unknown>, delta) => {
expect(key).to.equal(key1);
expect(model).to.be.deep.equal(model1);
expect(model.command1).to.be.equal('test-value');
expect(delta).to.be.like([
{ op: 'add', path: 'command1', value: 'test-value' },
]);
counter++;
};
sub3.onModelChanged = Sinon.spy();
await commandStack.execute(command1, context1);
expect(counter).to.be.equal(2);
expect(sub3.onModelChanged).to.be.not.called;
await commandStack.execute(command2, context2);
expect(sub3.onModelChanged).to.be.calledOnce;
});
it('unsubscribe to an existing model changes', async () => {
modelManager.setModel(key1, model1);
const sub1 = modelManager.subscribe(key1);
sub1.onModelChanged = Sinon.spy();
const sub2 = modelManager.subscribe(key1);
sub2.onModelChanged = Sinon.spy();
const sub3 = modelManager.subscribe(key2);
sub3.onModelChanged = Sinon.spy();
sub1.close();
sub2.close();
sub3.close();
await commandStack.execute(command1, context1);
expect(sub1.onModelChanged).to.be.not.called;
expect(sub2.onModelChanged).to.be.not.called;
expect(sub3.onModelChanged).to.be.not.called;
});
it('execute a command on closed subscriber for a specific model', async () => {
modelManager.setModel(key1, model1);
modelManager.setModel(key2, model1);
modelManager.subscribe(key1);
const sub2 = modelManager.subscribe(key2);
sub2.onModelChanged = Sinon.spy();
sub2.close();
await commandStack.execute(command1, context1);
expect(sub2.onModelChanged).to.not.be.called;
});
it('execute a command on closed subscriber for all models', async () => {
modelManager.setModel(key1, model1);
modelManager.setModel(key2, model1);
modelManager.subscribe();
const sub2 = modelManager.subscribe();
sub2.onModelChanged = Sinon.spy();
sub2.close();
await commandStack.execute(command1, context1);
expect(sub2.onModelChanged).to.not.be.called;
});
it('unsubscribe twice to same modelId', async () => {
modelManager.setModel(key1, model1);
const sub = modelManager.subscribe(key1);
sub.onModelChanged = Sinon.spy();
sub.close();
sub.close();
await commandStack.execute(command1, context1);
expect(sub.onModelChanged).to.not.be.called;
});
it('execute a command with undefined result w/o notification', async () => {
modelManager.setModel(key1, model1);
const commandUndefined = new TestCommandUndefined('undefined', key1);
const sub = modelManager.subscribe(key1);
sub.onModelChanged = Sinon.spy();
await commandStack.execute(commandUndefined, context1);
await commandStack.undo(context1);
await commandStack.redo(context1);
expect(sub.onModelChanged).to.not.be.called;
const commandUndefined2 = new TestCommandUndefined('undefined2', key1);
await commandStack.executeAndAppend(context1, commandUndefined2);
const sub2 = modelManager.subscribe(key1);
sub2.onModelChanged = Sinon.spy();
expect(sub2.onModelChanged).to.not.be.called;
});
it('subscribe to a model changes before having this model registered', async () => {
const sub = modelManager.subscribe(key2);
sub.onModelChanged = Sinon.spy();
modelManager.setModel(key2, model2);
await commandStack.execute(command2, context2);
expect(sub.onModelChanged).to.be.calledOnce;
});
it('subscribe to all existing model changes', async () => {
modelManager.setModel(key1, model1);
modelManager.setModel(key2, model2);
const sub = modelManager.subscribe();
sub.onModelChanged = Sinon.spy();
await commandStack.execute(command1, context1);
expect(sub.onModelChanged).to.be.calledOnce;
});
it('unsubscribe to all model changes w/o having any model registered', async () => {
const sub1 = modelManager.subscribe();
const sub2 = modelManager.subscribe();
sub1.onModelChanged = Sinon.spy();
sub2.onModelChanged = Sinon.spy();
sub1.close();
sub1.close();
sub2.close();
modelManager.setModel(key1, model1);
await commandStack.execute(command1, context1);
expect(sub1.onModelChanged).to.not.be.called;
expect(sub2.onModelChanged).to.not.be.called;
});
it('subscriber not notified when execute provides no delta', async () => {
modelManager.setModel(key1, model1);
const sub = modelManager.subscribe(key1);
sub.onModelChanged = Sinon.stub();
// Instrument the command to return no delta
command1.execute = () => undefined;
await commandStack.execute(command1, context1);
expect(sub.onModelChanged).not.to.be.called;
});
it('subscriber not notified when undo provides no delta', async () => {
modelManager.setModel(key1, model1);
await commandStack.execute(command1, context1);
const sub = modelManager.subscribe(key1);
sub.onModelChanged = Sinon.stub();
// Instrument the command to return no delta on undo
command1.undo = () => undefined;
await commandStack.undo(context1);
expect(sub.onModelChanged).not.to.be.called;
});
it('subscriber not notified when redo provides no delta', async () => {
modelManager.setModel(key1, model1);
await commandStack.execute(command1, context1);
await commandStack.undo(context1);
const sub = modelManager.subscribe(key1);
sub.onModelChanged = Sinon.stub();
// Instrument the command to return no delta on redo
command1.redo = () => undefined;
await commandStack.redo(context1);
expect(sub.onModelChanged).not.to.be.called;
});
it('subscriber without callback does not break notifications', async () => {
modelManager.setModel(key1, model1);
const blankSub = modelManager.subscribe(key1);
blankSub.onModelChanged = undefined;
const sub = modelManager.subscribe(key1);
sub.onModelChanged = Sinon.spy();
await commandStack.execute(command1, context1);
expect(sub.onModelChanged).to.be.called;
});
it('no subscription call-backs for unmanaged model', async () => {
// Instrument a compound command to invent a delta on an unmanaged model
const compoundCommand = append(command1, command2) as CompoundCommand;
// Sinon doesn't provide the wrapped method for async stub
const wrappedMethod = compoundCommand.execute;
Sinon.stub(compoundCommand, 'execute').callsFake(
async (...args: unknown[]) => {
const result = (await wrappedMethod.apply(
compoundCommand,
args
)) as Map<Command, Operation[]>;
result.set(
new TestCommand('interloper', 'interloper'),
// May as well be a nonsense "delta"
[{ op: 'test', path: '/modelIs', value: 'interloper' }]
);
return result;
}
);
modelManager.setModel(key1, model1);
modelManager.setModel(key2, model2);
const sub = modelManager.subscribe();
sub.onModelChanged = Sinon.spy();
await commandStack.execute(compoundCommand, context1);
expect(sub.onModelChanged).to.be.called;
expect(sub.onModelChanged).not.to.be.calledWithMatch(
'interloper',
Sinon.match.any,
Sinon.match.any
);
});
});
describe('Concurrency scenarios', () => {
beforeEach(() => {
modelManager.setModel(key1, cloneDeep(model1));
modelManager.setModel(key2, cloneDeep(model2));
});
it('single sequential model undo/redo', async () => {
await commandStack.execute(
new AsyncPatchCommand('1', key1, [
{ op: 'test', path: '/bar1', value: 'bar1' },
{ op: 'replace', path: '/bar1', value: 'New Value' },
]),
context1
);
await commandStack.execute(
new AsyncPatchCommand('2', key1, [
{ op: 'test', path: '/bar1', value: 'New Value' },
{ op: 'replace', path: '/bar1', value: '42' },
]),
context1
);
expect(getModel1()).to.be.like({
foo1: 'foo1',
bar1: '42',
});
await commandStack.undo(context1);
expect(getModel1()).to.be.like({
foo1: 'foo1',
bar1: 'New Value',
});
await commandStack.undo(context1);
expect(getModel1()).to.be.like({
foo1: 'foo1',
bar1: 'bar1',
});
await commandStack.redo(context1);
expect(getModel1()).to.be.like({
foo1: 'foo1',
bar1: 'New Value',
});
await commandStack.redo(context1);
expect(getModel1()).to.be.like({
foo1: 'foo1',
bar1: '42',
});
});
it('single stacked model undo/redo', async () => {
commandStack.execute(
new AsyncPatchCommand('1', key2, [
{ op: 'test', path: '/bar2', value: 'bar2' },
{ op: 'replace', path: '/bar2', value: 'New Value' },
]),
context1
);
await commandStack.execute(
new AsyncPatchCommand('2', key2, [
{ op: 'test', path: '/bar2', value: 'New Value' },
{ op: 'replace', path: '/bar2', value: '42' },
]),
context1
);
expect(getModel2()).to.be.like({
foo2: 'foo2',
bar2: '42',
});
await commandStack.undo(context1);
await commandStack.undo(context1);
expect(getModel2()).to.be.like({
foo2: 'foo2',
bar2: 'bar2',
});
await commandStack.redo(context1);
await commandStack.redo(context1);
expect(getModel2()).to.be.like({
foo2: 'foo2',
bar2: '42',
});
});
it('multiple sequential model undo/redo', async () => {
await commandStack.execute(
append(
new AsyncPatchCommand('1a', key1, [
{ op: 'test', path: '/bar1', value: 'bar1' },
{ op: 'replace', path: '/bar1', value: 'New Value' },
]),
new AsyncPatchCommand('1b', key2, [
{ op: 'test', path: '/bar2', value: 'bar2' },
{ op: 'replace', path: '/bar2', value: 'New Value 2' },
])
),
context1
);
await commandStack.execute(
append(
new AsyncPatchCommand('2a', key1, [
{ op: 'test', path: '/bar1', value: 'New Value' },
{ op: 'replace', path: '/bar1', value: '42' },
]),
new AsyncPatchCommand('2b', key2, [
{ op: 'test', path: '/bar2', value: 'New Value 2' },
{ op: 'replace', path: '/bar2', value: '19' },
])
),
context1
);
expect(getModel1()).to.be.like({
foo1: 'foo1',
bar1: '42',
});
expect(getModel2()).to.be.like({
foo2: 'foo2',
bar2: '19',
});
await commandStack.undo(context1);
expect(getModel1()).to.be.like({
foo1: 'foo1',
bar1: 'New Value',
});
expect(getModel2()).to.be.like({
foo2: 'foo2',
bar2: 'New Value 2',
});
await commandStack.undo(context1);
expect(getModel1()).to.be.like({
foo1: 'foo1',
bar1: 'bar1',
});
expect(getModel2()).to.be.like({
foo2: 'foo2',
bar2: 'bar2',
});
await commandStack.redo(context1);
expect(getModel1()).to.be.like({
foo1: 'foo1',
bar1: 'New Value',
});
expect(getModel2()).to.be.like({
foo2: 'foo2',
bar2: 'New Value 2',
});
await commandStack.redo(context1);
expect(getModel1()).to.be.like({
foo1: 'foo1',
bar1: '42',
});
expect(getModel2()).to.be.like({
foo2: 'foo2',
bar2: '19',
});
});
it('multiple stacked model undo/redo', async () => {
commandStack.execute(
append(
new AsyncPatchCommand('1a', key1, [
{ op: 'test', path: '/bar1', value: 'bar1' },
{ op: 'replace', path: '/bar1', value: 'New Value' },
]),
new AsyncPatchCommand('1b', key2, [
{ op: 'test', path: '/bar2', value: 'bar2' },
{ op: 'replace', path: '/bar2', value: 'New Value 2' },
])
),
context1
);
await commandStack.execute(
append(
new AsyncPatchCommand('2a', key1, [
{ op: 'test', path: '/bar1', value: 'New Value' },
{ op: 'replace', path: '/bar1', value: '42' },
]),
new AsyncPatchCommand('2b', key2, [
{ op: 'test', path: '/bar2', value: 'New Value 2' },
{ op: 'replace', path: '/bar2', value: '19' },
])
),
context1
);
expect(getModel1()).to.be.like({
foo1: 'foo1',
bar1: '42',
});
expect(getModel2()).to.be.like({
foo2: 'foo2',
bar2: '19',
});
await commandStack.undo(context1);
await commandStack.undo(context1);
expect(getModel1()).to.be.like({
foo1: 'foo1',
bar1: 'bar1',
});
expect(getModel2()).to.be.like({
foo2: 'foo2',
bar2: 'bar2',
});
await commandStack.redo(context1);
await commandStack.redo(context1);
expect(getModel1()).to.be.like({
foo1: 'foo1',
bar1: '42',
});
expect(getModel2()).to.be.like({
foo2: 'foo2',
bar2: '19',
});
});
});
describe('Edge Cases', () => {
let workingCopyManager: WorkingCopyManager;
beforeEach(() => {
workingCopyManager = (
modelManager as unknown as { _modelStore: WorkingCopyManager }
)._modelStore;
});
it('attempt to open working copy manager already open', () => {
workingCopyManager.open(['testId']);
expect(workingCopyManager.isOpen(['testId'])).to.be.true;
expect(() => workingCopyManager.open(['testId'])).to.throw(
'Already open'
);
});
it('attempt to get a working copy when not open', () => {
expect(workingCopyManager.isOpen(['testId'])).to.be.false;
expect(() => workingCopyManager.getWorkingCopy('foo')).to.throw(
'Not open'
);
});
it('attempt to get a working copy for nonexistent model', () => {
workingCopyManager.open(['testId']);
expect(() => workingCopyManager.getWorkingCopy('none such')).to.throw(
'Not open'
);
});
it('attempt to get a working copy for nonexistent model', () => {
workingCopyManager.open(['nonesuch']);
try {
expect(workingCopyManager.getWorkingCopy('nonesuch')).to.be.undefined;
} finally {
workingCopyManager.cancel(['nonesuch']);
}
});
it('commit a delta for non-open model', () => {
expect(workingCopyManager.isOpen(['nonesuch'])).to.be.false;
workingCopyManager.open([key1]);
expect(workingCopyManager.isOpen([key1])).to.be.true;
workingCopyManager.getWorkingCopy(key1);
const results = new Map<Command<string>, Operation[]>();
results.set(
new TestCommandUndefined('Working Copies Test', 'nonesuch'),
[]
);
try {
workingCopyManager.commit(results, ['nonesuch']);
expect(workingCopyManager.isOpen(['nonesuch'])).to.be.false;
expect(workingCopyManager.isOpen([key1])).to.be.true;
} finally {
workingCopyManager.cancel([key1]);
}
});
it('no-op deferred command', async () => {
modelManager.setModel(key1, model1);
modelManager.setModel(key2, model2);
// a deferred command that specifies 2 models, but only uses one.
const deferredCommand = createDeferredCompoundCommand(
'test command',
[key1, key2],
() => {
return [new TestCommand('test', key1)];
}
);
await commandStack.execute(deferredCommand, context1);
expect(workingCopyManager.isOpen([key1])).to.be.false;
expect(workingCopyManager.isOpen([key2])).to.be.false;
});
describe('concurrent working copy sets', () => {
const key3 = 'model3';
const key4 = 'model4';
const model3 = { foo3: 'foo3', bar3: 'bar3' };
const model4 = { foo4: 'foo4', bar4: 'bar4' };
beforeEach(() => {
(
[
[key1, model1],
[key2, model2],
[key3, model3],
[key4, model4],
] as const
).forEach(([key, model]) => modelManager.setModel(key, model));
});
afterEach(() => {
const modelIds = [key4, key3, key2, key1];
workingCopyManager.cancel(modelIds);
modelIds.forEach(modelManager.removeModel.bind(modelManager));
});
it('non-conflicting open', () => {
workingCopyManager.open([key1, key3]);
expect(workingCopyManager.isOpen([key1, key3])).to.be.true;
expect(workingCopyManager.isOpen([key1, key2, key3, key4])).to.be.true;
expect(workingCopyManager.isOpen([key2, key4])).to.be.false;
workingCopyManager.open([key2, key4]);
expect(workingCopyManager.isOpen([key2, key4])).to.be.true;
expect(workingCopyManager.isOpen([key1, key2, key3, key4])).to.be.true;
});
it('non-conflicting working copy cancel', () => {
workingCopyManager.open([key1, key3]);
workingCopyManager.open([key2, key4]);
expect(workingCopyManager.getWorkingCopy(key1)).to.be.like(model1);
expect(workingCopyManager.getWorkingCopy(key2)).to.be.like(model2);
expect(workingCopyManager.getWorkingCopy(key3)).to.be.like(model3);
expect(workingCopyManager.getWorkingCopy(key4)).to.be.like(model4);
workingCopyManager.cancel([key2, key4]);
expect(workingCopyManager.getWorkingCopy(key1)).to.be.like(model1);
expect(workingCopyManager.getWorkingCopy(key3)).to.be.like(model3);
expect(() => workingCopyManager.getWorkingCopy(key2)).to.throw();
expect(() => workingCopyManager.getWorkingCopy(key4)).to.throw();
});
it('working copy conflict', () => {
workingCopyManager.open([key1, key3]);
expect(workingCopyManager.getWorkingCopy(key1)).to.be.like(model1);
expect(workingCopyManager.getWorkingCopy(key3)).to.be.like(model3);
expect(() => workingCopyManager.open([key2, key3])).to.throw();
expect(workingCopyManager.isOpen([key1, key3])).to.be.true;
expect(workingCopyManager.isOpen([key2, key3])).to.be.true; // It's "some" semantics, not "every"
expect(workingCopyManager.isOpen([key2])).to.be.false;
});
});
});
});
class TestCommand implements SimpleCommand {
constructor(public readonly label: string, public readonly modelId = '') {}
public wasExecuted = false;
public wasUndone = false;
public wasRedone = false;
canExecute(): boolean {
return !this.wasExecuted && !this.wasUndone && !this.wasRedone;
}
canUndo(): boolean {
return this.wasExecuted || this.wasRedone;
}
canRedo(): boolean {
return this.wasUndone;
}
execute(model: object): Operation[] | undefined {
if (!this.canExecute()) {
throw new Error('cannot execute');
}
this.wasExecuted = true;
this.wasUndone = false;
this.wasRedone = false;
Object.defineProperty(model, this.label, {
value: 'test-value',
writable: true,
});
return [{ op: 'add', path: this.label, value: 'test-value' }];
}
undo(model: object): Operation[] | undefined {
if (!this.canUndo()) {
throw new Error('cannot undo');
}
this.wasExecuted = false;
this.wasUndone = true;
this.wasRedone = false;
Reflect.deleteProperty(model, this.label);
return [{ op: 'remove', path: this.label }];
}
redo(model: object): Operation[] | undefined {
if (!this.canRedo()) {
throw new Error('cannot redo');
}
this.wasExecuted = false;
this.wasUndone = false;
this.wasRedone = true;
Object.defineProperty(model, this.label, {
value: 'test-value',
writable: true,
});
return [{ op: 'add', path: this.label, value: 'test-value' }];
}
}
class TestCommandUndefined implements SimpleCommand {
constructor(public readonly label: string, public readonly modelId = '') {}
public wasExecuted = false;
public wasUndone = false;
public wasRedone = false;
canExecute(): boolean {
return !this.wasExecuted && !this.wasUndone && !this.wasRedone;
}
canUndo(): boolean {
return this.wasExecuted || this.wasRedone;
}
canRedo(): boolean {
return this.wasUndone;
}
execute(): Operation[] | undefined {
if (!this.canExecute()) {
throw new Error('cannot execute');
}
this.wasExecuted = true;
this.wasUndone = false;
this.wasRedone = false;
return undefined;
}
undo(): Operation[] | undefined {
if (!this.canUndo()) {
throw new Error('cannot undo');
}
this.wasExecuted = false;
this.wasUndone = true;
this.wasRedone = false;
return undefined;
}
redo(): Operation[] | undefined {
if (!this.canRedo()) {
throw new Error('cannot redo');
}
this.wasExecuted = false;
this.wasUndone = false;
this.wasRedone = true;
return undefined;
}
}
class AsyncPatchCommand implements SimpleCommand {
constructor(
public readonly label: string,
public readonly modelId: string,
private patch: Operation[]
) {}
async canExecute(): Promise<boolean> {
return this.canApplyAndReverse();
}
async canUndo(): Promise<boolean> {
return this.canApplyAndReverse();
}
async canRedo(): Promise<boolean> {
return this.canApplyAndReverse();
}
private async canApplyAndReverse(): Promise<boolean> {
return this.patch.length > 0;
}
execute(model: object): Promise<Operation[]> {
return this.applyAndReverse(model);
}
undo(model: object): Promise<Operation[]> {
return this.applyAndReverse(model);
}
redo(model: object): Promise<Operation[]> {
return this.applyAndReverse(model);
}
private async applyAndReverse(model: object): Promise<Operation[]> {
const baseline = cloneDeep(model);
applyPatch(model, this.patch);
const result = compare(baseline, model, true);
this.patch = compare(model, baseline);
return result;
}
}