@eclipse-emfcloud/model-manager
Version:
Command-based model editing with undo/redo.
999 lines (816 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 chai, { expect } from 'chai';
import chaiAsPromised from 'chai-as-promised';
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, SimpleCommand } from '../command';
import {
append,
CommandReturnResult,
CompoundCommandImpl,
FailedResult,
groupByModelId,
isCompoundCommand,
isSimpleCommandWithResult,
ReadyResult,
unwrapReturnResult,
} from '../command';
chai.use(chaiLike);
chai.use(chaiAsPromised);
chai.use(sinonChai);
describe('Command-related functions', () => {
describe('isCompoundCommand', () => {
it('detects a CompoundCommand', async () => {
const compound = new CompoundCommandImpl('test');
const isCompound = isCompoundCommand(compound);
expect(isCompound, 'compound command not detected').to.be.true;
});
it('detects a simple (non-compound) Command', async () => {
const leaf = new TestCommand('test');
const isCompound = isCompoundCommand(leaf);
expect(isCompound, 'simple command not detected').to.be.false;
});
});
describe('append', () => {
it('base is already a CompoundCommand', async () => {
const compound = new CompoundCommandImpl('test');
const appended = new TestCommand('appended');
const result = append(compound, appended);
expect(result, 'compound command not appended').to.be.equal(compound);
expect(result).to.be.like({ _commands: [appended] });
});
it('base is not a CompoundCommand', async () => {
const base = new TestCommand('test');
const appended = new TestCommand('appended');
const result = append(base, appended);
expect(result, 'commands not compounded').to.be.instanceOf(
CompoundCommandImpl
);
expect(result).to.be.like({ _commands: [base, appended] });
});
it('is given nothing to append', async () => {
const base = new TestCommand('test');
const result = append(base);
expect(result, 'command was unnecessarily replaced').to.be.equal(base);
});
});
describe('groupByModelId', () => {
it('group compound command results by model', async () => {
const models = new Map<string, object>([
['model1', { id: 'model1' }],
['model2', { id: 'model2' }],
]);
const command1 = new TestCommand('testProp1', 'model1');
const command2 = new TestCommand('testProp2', 'model1');
const command3 = new TestCommand('test', 'model2');
const compoundModel1 = new CompoundCommandImpl(
'test',
command1,
command2
);
const compoundModel2 = new CompoundCommandImpl('test', command3);
const topCompound = new CompoundCommandImpl(
'test',
compoundModel1,
compoundModel2
);
const result = await topCompound.execute(models.get.bind(models));
expect(result).to.not.be.undefined;
if (result !== undefined) {
const groupedResult = groupByModelId(result);
expect(groupedResult.size).to.equal(2);
}
});
});
describe('isSimpleCommandWithResult', () => {
it('is not', () => {
const command = new TestCommand('test', 'model1');
const isIt = isSimpleCommandWithResult(command);
expect(isIt).to.be.false;
});
it('is', () => {
const command = new TestCommandWithResult('test', 'model1');
const isIt = isSimpleCommandWithResult(command);
expect(isIt).to.be.true;
});
});
describe('unwrapReturnResult', () => {
it('pending', () => {
const command = new TestCommandWithResult('test', 'model1');
const unwrapped = unwrapReturnResult(command);
expect(unwrapped).to.be.undefined;
});
it('failed', () => {
const command = Object.assign(new TestCommand('test', 'model1'), {
result: {
status: 'failed',
error: new Error('💣'),
} satisfies FailedResult,
});
expect(() => unwrapReturnResult(command)).to.throw('💣');
});
it('failed without detail', () => {
const command = Object.assign(new TestCommand('test', 'model1'), {
result: {
status: 'failed',
} satisfies FailedResult,
});
expect(() => unwrapReturnResult(command)).to.throw();
});
it('ready', () => {
const command = Object.assign(new TestCommand('test', 'model1'), {
result: {
status: 'ready',
value: 'Hello, world!',
} satisfies ReadyResult<string>,
});
const unwrapped = unwrapReturnResult(command);
expect(unwrapped).to.be.eq('Hello, world!');
});
});
// This test ensures a wider test coverage, by crafting test cases that
// are not expected to happen in a more realistic scenario
it('additional corner cases', () => {
const command1 = new TestCommand('testProp2', 'model1');
const command2 = new TestCommand('test', 'model2');
const compound = new CompoundCommandImpl('test', command1, command2);
const result = new Map<Command, Operation[]>();
result.set(command1, undefined as unknown as Operation[]);
result.set(command2, []);
result.set(compound, []);
const groupedResult = groupByModelId(result);
expect(groupedResult.size).to.equal(1);
expect(groupedResult.get('model1')).to.be.undefined;
expect(groupedResult.get('model2')).to.be.an('array').that.is.empty;
});
});
describe('CompoundCommandImpl', () => {
let getModel: (modelId: string) => object | undefined;
let commands: Command[];
let compound: CompoundCommandImpl;
const forceAppend = (
compound: CompoundCommandImpl,
...commands: Command[]
): void => {
// Cannot append via the API, so shoehorn it
(compound as unknown as { _commands: Command[] })._commands.push(
...commands
);
};
beforeEach(() => {
getModel = () => ({});
commands = [new AsyncTestCommand('a'), new AsyncTestCommand('b')];
compound = new CompoundCommandImpl('test', ...commands);
});
it('has a label', () => {
expect(compound.label, 'wrong label').to.be.eq('test');
});
describe('execute', () => {
let sandbox: sinon.SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
it('executes the subcommands', async () => {
await compound.execute(getModel);
expect(commands, 'commands not executed').to.be.like([
{ wasExecuted: true },
{ wasExecuted: true },
]);
});
it('collates the deltas', async () => {
const delta = await compound.execute(getModel);
expect(delta, 'no delta returned').to.exist;
expect(delta?.get(commands[0]), 'wrong result for command a').to.be.like([
{ op: 'add', path: 'a' },
]);
expect(delta?.get(commands[1]), 'wrong result for command b').to.be.like([
{ op: 'add', path: 'b' },
]);
});
it('rewinds subcommands on failure', async () => {
const third = new AsyncTestCommand('c');
commands.push(third);
compound.append(third);
third.failOn('execute');
await expect(compound.execute(getModel)).to.eventually.have.rejected;
expect(commands, 'commands not rewound').to.be.like([
{ wasUndone: true, failed: false },
{ wasUndone: true, failed: false },
{ wasExecuted: false, failed: true },
]);
});
it('best-effort rewinds in case of rewind failure', async () => {
const third = new AsyncTestCommand('c');
commands.push(third);
compound.append(third);
third.failOn('execute');
(commands[1] as AsyncTestCommand).failOn('undo');
const consoleError = sandbox.stub(console, 'error');
await expect(compound.execute(getModel)).to.eventually.have.rejected;
expect(consoleError).to.have.been.calledWithMatch(
'model-manager/command:',
'Error in recovery of failed execute.'
);
expect(commands, 'commands not rewound').to.be.like([
{ wasUndone: true, failed: false },
{ wasUndone: false, failed: true },
{ wasExecuted: false, failed: true },
]);
});
describe('asserts the preconditions', () => {
it('is non-executable by itself', async () => {
// Make it non-executable
await compound.execute(getModel);
await expect(
compound.execute(getModel),
'compound should not have executed'
).to.eventually.be.rejected;
});
it('is non-executable by a nested command', async () => {
// Make it non-executable
await commands[1].execute(getModel);
await expect(
compound.execute(getModel),
'compound should not have executed'
).to.eventually.be.rejected;
});
});
});
describe('undo', () => {
beforeEach(() => {
return compound.execute(getModel);
});
it('undoes the subcommands', async () => {
await compound.undo(getModel);
expect(commands, 'commands not undone').to.be.like([
{ wasUndone: true },
{ wasUndone: true },
]);
});
it('collates the deltas', async () => {
const delta = await compound.undo(getModel);
expect(delta, 'no delta returned').to.exist;
expect(delta?.get(commands[0]), 'wrong result for command a').to.be.like([
{ op: 'remove', path: 'a' },
]);
expect(delta?.get(commands[1]), 'wrong result for command b').to.be.like([
{ op: 'remove', path: 'b' },
]);
});
it('rewinds subcommands on failure', async () => {
const third = new AsyncTestCommand('c');
await third.execute();
commands.push(third);
forceAppend(compound, third);
(commands[0] as AsyncTestCommand).failOn('undo');
await expect(compound.undo(getModel)).to.eventually.have.rejected;
expect(commands, 'commands not rewound').to.be.like([
{ wasUndone: false, failed: true },
{ wasRedone: true, failed: false },
{ wasRedone: true, failed: false },
]);
});
describe('asserts the preconditions', () => {
it('is non-undoable by itself', async () => {
// Make it non-undoable
await compound.undo(getModel);
await expect(compound.undo(getModel), 'compound should not have undone')
.to.eventually.be.rejected;
});
it('is non-undoable by a nested command', async () => {
// Make it non-undoable
await commands[0].undo(getModel);
await expect(compound.undo(getModel), 'compound should not have undone')
.to.eventually.be.rejected;
});
});
});
describe('redo', () => {
beforeEach(async () => {
await compound.execute(getModel);
await compound.undo(getModel);
});
it('redoes the subcommands', async () => {
await compound.redo(getModel);
expect(commands, 'commands not redone').to.be.like([
{ wasRedone: true },
{ wasRedone: true },
]);
});
it('collates the deltas', async () => {
const delta = await compound.redo(getModel);
expect(delta, 'no delta returned').to.exist;
expect(delta?.get(commands[0]), 'wrong result for command a').to.be.like([
{ op: 'add', path: 'a' },
]);
expect(delta?.get(commands[1]), 'wrong result for command b').to.be.like([
{ op: 'add', path: 'b' },
]);
});
describe('asserts the preconditions', () => {
it('is non-redoable by itself', async () => {
// Make it non-redoable
await compound.redo(getModel);
await expect(compound.redo(getModel), 'compound should not have redone')
.to.eventually.be.rejected;
});
it('is non-redoable by a nested command', async () => {
// Make it non-redoable
await commands[1].redo(getModel);
await expect(compound.redo(getModel), 'compound should not have redone')
.to.eventually.be.rejected;
});
});
});
describe('canExecute', () => {
let sandbox: sinon.SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
it('is true', () => {
return expect(compound.canExecute(getModel), 'compound not executable').to
.eventually.be.true;
});
it('is true with logs by reason of being an empty compound', async () => {
const spyConsoleDebug = sandbox.spy(console, 'debug');
const empty = new CompoundCommandImpl('empty');
const result = await empty.canExecute(getModel);
expect(result, 'empty compound is executable').to.be.true;
expect(spyConsoleDebug).to.have.been.calledWithMatch(
'model-manager/command:',
`No constituent command for ${empty.label}.`
);
});
it('is false by reason of itself', async () => {
await compound.execute(getModel); // Make it non-executable by executing it
return expect(compound.canExecute(getModel), 'compound is executable').to
.eventually.be.false;
});
describe('asynchronous commands', () => {
it('is false by reason of a nested command', async () => {
await commands[1].execute(getModel); // Make one non-executable by executing it
return expect(compound.canExecute(getModel), 'compound is executable')
.to.eventually.be.false;
});
});
describe('synchronous commands', () => {
beforeEach(() => {
commands = [new TestCommand('a'), new TestCommand('b')];
compound = new CompoundCommandImpl('test', ...commands);
});
it('is false by reason of a nested command', () => {
commands[1].execute(getModel); // Make one non-executable by executing it
return expect(compound.canExecute(getModel), 'compound is executable')
.to.eventually.be.false;
});
});
});
describe('canUndo', () => {
let sandbox: sinon.SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
return compound.execute(getModel);
});
afterEach(() => {
sandbox.restore();
});
it('is true', () => {
return expect(compound.canUndo(getModel), 'compound not undoable').to
.eventually.be.true;
});
it('is false by reason of itself', async () => {
// Make it non-undoable by undoing it
await compound.undo(getModel);
return expect(compound.canUndo(getModel), 'compound is undoable').to
.eventually.be.false;
});
describe('asynchronous commands', () => {
it('is false by reason of a nested command', async () => {
// Make one non-undoable by undoing it
await commands[0].undo(getModel);
return expect(compound.canUndo(getModel), 'compound is undoable').to
.eventually.be.false;
});
});
describe('synchronous commands', () => {
beforeEach(() => {
commands = [new TestCommand('a'), new TestCommand('b')];
compound = new CompoundCommandImpl('test', ...commands);
return compound.execute(getModel);
});
it('is false by reason of a nested command', () => {
commands[0].undo(getModel); // Make one non-undoable by executing it
return expect(compound.canUndo(getModel), 'compound is undoable').to
.eventually.be.false;
});
});
describe('no sub commands', () => {
beforeEach(() => {
sandbox = sinon.createSandbox();
compound = new CompoundCommandImpl('test');
return compound.execute(getModel);
});
afterEach(() => {
sandbox.restore();
});
it('is true even though no command', async () => {
const spyConsoleDebug = sandbox.spy(console, 'debug');
const result = await compound.canUndo(getModel);
expect(result, 'compound is not undoable').to.be.true;
expect(spyConsoleDebug).to.have.been.calledWithMatch(
'model-manager/command:',
`No constituent command for ${compound.label}.`
);
});
});
});
describe('canRedo', () => {
let sandbox: sinon.SinonSandbox;
beforeEach(async () => {
sandbox = sinon.createSandbox();
await compound.execute(getModel);
await compound.undo(getModel);
});
afterEach(() => {
sandbox.restore();
});
it('is true', () => {
return expect(compound.canRedo(getModel), 'compound not redoable').to
.eventually.be.true;
});
it('is false by reason of itself', async () => {
// Make it non-undoable by redoing it
await compound.redo(getModel);
return expect(compound.canRedo(getModel), 'compound is redoable').to
.eventually.be.false;
});
describe('asynchronous commands', () => {
it('is false by reason of a nested command', async () => {
// Make one non-undoable by redoing it
await commands[1].redo(getModel);
return expect(compound.canRedo(getModel), 'compound is redoable').to
.eventually.be.false;
});
});
describe('synchronous commands', () => {
beforeEach(async () => {
commands = [new TestCommand('a'), new TestCommand('b')];
compound = new CompoundCommandImpl('test', ...commands);
await compound.execute(getModel);
await compound.undo(getModel);
});
it('is false by reason of a nested command', () => {
// Make one non-redoable by redoing it
commands[1].redo(getModel);
return expect(compound.canRedo(getModel), 'compound is redoable').to
.eventually.be.false;
});
});
describe('no sub commands', () => {
beforeEach(async () => {
compound = new CompoundCommandImpl('test');
await compound.execute(getModel);
await compound.undo(getModel);
});
it('is true even though no command', async () => {
const spyConsoleDebug = sandbox.spy(console, 'debug');
const result = await compound.canRedo(getModel);
expect(result, 'compound is not redoable').to.be.true;
expect(spyConsoleDebug).to.have.been.calledWithMatch(
'model-manager/command:',
`No constituent command for ${compound.label}.`
);
});
});
});
describe('append', () => {
let newCommand: Command;
beforeEach(() => {
newCommand = new AsyncTestCommand('c');
});
it('is appendable', () => {
compound.append(newCommand);
expect(compound, 'new command not appended').to.be.like({
_commands: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
});
});
it('is frozen', async () => {
await compound.execute(getModel);
expect(
() => compound.append(newCommand),
'should have thrown precondition error'
).to.throw();
});
});
describe('getCommands', () => {
it('returns the commands', () => {
const list = compound.getCommands();
expect(list, 'wrong commands returned').to.be.like([
{ label: 'a' },
{ label: 'b' },
]);
});
it('returns a safe copy', () => {
const list = compound.getCommands();
list.splice(0, 1);
expect(compound, 'command removed from the compound').to.be.like({
_commands: [{ label: 'a' }, { label: 'b' }],
});
});
});
describe('nested compounds', () => {
let nested: Command[];
let nestedCompound: CompoundCommand;
let sandbox: sinon.SinonSandbox;
const obtrudeExecute = (cmd: Command) => {
const stub = sandbox.stub(cmd, 'execute').callsFake((...args) => {
stub.wrappedMethod.apply(cmd, args);
return undefined;
});
};
beforeEach(() => {
nested = [new AsyncTestCommand('c'), new AsyncTestCommand('d')];
nestedCompound = new CompoundCommandImpl('nested', ...nested);
compound.append(nestedCompound);
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
it('collates the nested deltas', async () => {
const delta = await compound.execute(getModel);
expect(delta, 'no delta returned').to.exist;
expect(delta?.size, 'wrong number of deltas returned').to.be.eq(4);
expect(delta?.get(nested[0])).to.be.like([{ op: 'add', path: 'c' }]);
expect(delta?.get(nested[1])).to.be.like([{ op: 'add', path: 'd' }]);
expect(delta?.has(commands[0])).to.be.true;
expect(delta?.has(commands[1])).to.be.true;
});
it('returns partial results', async () => {
obtrudeExecute(nested[0]);
commands.forEach((cmd) => sandbox.spy(cmd, 'execute'));
const delta = await compound.execute(getModel);
expect(delta, 'a partial delta was not returned').to.exist;
expect(delta?.get(nested[1])).to.be.like([{ op: 'add', path: 'd' }]);
expect(Array.from(delta?.keys() ?? [])).to.include.members(commands);
commands.forEach((cmd) => expect(cmd.execute).to.have.been.called);
});
it('returns undefined if no results', async () => {
nested.forEach(obtrudeExecute);
commands.forEach(obtrudeExecute);
const delta = await compound.execute(getModel);
expect(delta, 'a delta was returned').not.to.exist;
nested.forEach((cmd) => expect(cmd.execute).to.have.been.called);
commands.forEach((cmd) => expect(cmd.execute).to.have.been.called);
});
});
describe('iteration', () => {
it('empty compound', () => {
const labels = new CompoundCommandImpl('test').map((cmd) => cmd.label);
expect(labels).to.eql([]);
});
it('flat compound', () => {
const compound = new CompoundCommandImpl(
'test',
new TestCommand('a'),
new TestCommand('b')
);
const labels = compound.map((cmd) => cmd.label);
expect(labels).to.eql(['a', 'b']);
});
it('nested compound', () => {
const compound = new CompoundCommandImpl(
'test',
new CompoundCommandImpl(
'one',
new TestCommand('a'),
new TestCommand('b')
),
new TestCommand('c'),
new CompoundCommandImpl(
'two',
new TestCommand('d'),
new CompoundCommandImpl(
'three',
new TestCommand('e'),
new TestCommand('f')
),
new TestCommand('g')
)
);
const labels = compound.map((cmd) => cmd.label);
expect(labels).to.eql(['a', 'b', 'c', 'd', 'e', 'f', 'g']);
});
});
describe('Compounds of compounds', () => {
beforeEach(async () => {
commands = [
new AsyncTestCommand('a', 'a'),
new TestCommand('b'),
new AsyncTestCommand('c', 'c'),
];
compound = new CompoundCommandImpl(
'outer',
commands[0],
new CompoundCommandImpl('inner', ...commands.slice(1))
);
});
it('execute', async () => {
await compound.execute(getModel);
expect(commands, 'commands not executed').to.be.like([
{ wasExecuted: true },
{ wasExecuted: true },
{ wasExecuted: true },
]);
});
it('undo', async () => {
await compound.execute(getModel);
await compound.undo(getModel);
expect(commands, 'commands not undone').to.be.like([
{ wasUndone: true },
{ wasUndone: true },
{ wasUndone: true },
]);
});
it('redo', async () => {
await compound.execute(getModel);
await compound.undo(getModel);
await compound.redo(getModel);
expect(commands, 'commands not redone').to.be.like([
{ wasRedone: true },
{ wasRedone: true },
{ wasRedone: true },
]);
});
it('rewinds subcommands on failure', async () => {
await compound.execute(getModel);
const fourth = new AsyncTestCommand('d');
await fourth.execute();
commands.push(fourth);
forceAppend(compound, fourth);
(commands[0] as AsyncTestCommand).failOn('undo');
await expect(compound.undo(getModel)).to.eventually.have.rejected;
expect(commands, 'commands not rewound').to.be.like([
{ wasUndone: false, failed: true },
{ wasRedone: true },
{ wasRedone: true },
{ wasRedone: true },
]);
});
it('model AWOL on execute', async () => {
let countdown = 2;
getModel = (modelId: string) => {
if (modelId === 'c' && countdown-- <= 0) {
return undefined;
}
return {};
};
await expect(compound.execute(getModel)).to.eventually.have.rejectedWith(
'No model on which to execute'
);
});
it('model AWOL on rewind', async () => {
const bomb = new AsyncTestCommand('Boom!');
bomb.failOn('execute');
commands.push(bomb);
forceAppend(compound, bomb);
let countdown = 2;
getModel = (modelId: string) => {
if (modelId === 'a' && countdown-- <= 0) {
return undefined;
}
return {};
};
// The exception from rewind isn't actually surfaced
await expect(compound.execute(getModel)).to.eventually.have.rejected;
expect(commands[1]).to.be.like({ wasUndone: true });
expect(commands[0]).to.be.like({ wasUndone: 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(): Operation[] | undefined {
if (!this.canExecute()) {
throw new Error('cannot execute');
}
this.wasExecuted = true;
this.wasUndone = false;
this.wasRedone = false;
return [{ op: 'add', path: this.label, value: 'test-value' }];
}
undo(): Operation[] | undefined {
if (!this.canUndo()) {
throw new Error('cannot undo');
}
this.wasExecuted = false;
this.wasUndone = true;
this.wasRedone = false;
return [{ op: 'remove', path: this.label }];
}
redo(): Operation[] | undefined {
if (!this.canRedo()) {
throw new Error('cannot redo');
}
this.wasExecuted = false;
this.wasUndone = false;
this.wasRedone = true;
return [{ op: 'add', path: this.label, value: 'test-value' }];
}
}
class AsyncTestCommand implements SimpleCommand {
constructor(public readonly label: string, public readonly modelId = '') {}
public wasExecuted = false;
public wasUndone = false;
public wasRedone = false;
private failOnOp?: keyof SimpleCommand;
public failed = false;
failOn(op: keyof SimpleCommand): void {
this.failOnOp = op;
}
async canExecute(): Promise<boolean> {
this.maybeFailOn('canExecute');
return !this.wasExecuted && !this.wasUndone && !this.wasRedone;
}
async canUndo(): Promise<boolean> {
this.maybeFailOn('canUndo');
return this.wasExecuted || this.wasRedone;
}
async canRedo(): Promise<boolean> {
this.maybeFailOn('canRedo');
return this.wasUndone;
}
async execute(): Promise<Operation[] | undefined> {
if (!this.canExecute()) {
throw new Error('cannot execute');
}
this.maybeFailOn('execute');
this.wasExecuted = true;
this.wasUndone = false;
this.wasRedone = false;
return [{ op: 'add', path: this.label, value: 'test-value' }];
}
async undo(): Promise<Operation[] | undefined> {
if (!this.canUndo()) {
throw new Error('cannot undo');
}
this.maybeFailOn('undo');
this.wasExecuted = false;
this.wasUndone = true;
this.wasRedone = false;
return [{ op: 'remove', path: this.label }];
}
async redo(): Promise<Operation[] | undefined> {
if (!this.canRedo()) {
throw new Error('cannot redo');
}
this.maybeFailOn('redo');
this.wasExecuted = false;
this.wasUndone = false;
this.wasRedone = true;
return [{ op: 'add', path: this.label, value: 'test-value' }];
}
private maybeFailOn(op: keyof SimpleCommand): void {
if (this.failOnOp === op) {
this.failed = true;
this.failOnOp = undefined;
throw new Error(`Failed on ${op} as directed.`);
}
}
}
class TestCommandWithResult extends TestCommand {
result: CommandReturnResult<unknown> = { status: 'pending' };
}