UNPKG

@eclipse-emfcloud/model-manager

Version:

Command-based model editing with undo/redo.

777 lines 34.3 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 }); const chai_1 = __importStar(require("chai")); const chai_as_promised_1 = __importDefault(require("chai-as-promised")); const chai_like_1 = __importDefault(require("chai-like")); const sinon_1 = __importDefault(require("sinon")); const sinon_chai_1 = __importDefault(require("sinon-chai")); const command_1 = require("../command"); chai_1.default.use(chai_like_1.default); chai_1.default.use(chai_as_promised_1.default); chai_1.default.use(sinon_chai_1.default); describe('Command-related functions', () => { describe('isCompoundCommand', () => { it('detects a CompoundCommand', async () => { const compound = new command_1.CompoundCommandImpl('test'); const isCompound = (0, command_1.isCompoundCommand)(compound); (0, chai_1.expect)(isCompound, 'compound command not detected').to.be.true; }); it('detects a simple (non-compound) Command', async () => { const leaf = new TestCommand('test'); const isCompound = (0, command_1.isCompoundCommand)(leaf); (0, chai_1.expect)(isCompound, 'simple command not detected').to.be.false; }); }); describe('append', () => { it('base is already a CompoundCommand', async () => { const compound = new command_1.CompoundCommandImpl('test'); const appended = new TestCommand('appended'); const result = (0, command_1.append)(compound, appended); (0, chai_1.expect)(result, 'compound command not appended').to.be.equal(compound); (0, chai_1.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 = (0, command_1.append)(base, appended); (0, chai_1.expect)(result, 'commands not compounded').to.be.instanceOf(command_1.CompoundCommandImpl); (0, chai_1.expect)(result).to.be.like({ _commands: [base, appended] }); }); it('is given nothing to append', async () => { const base = new TestCommand('test'); const result = (0, command_1.append)(base); (0, chai_1.expect)(result, 'command was unnecessarily replaced').to.be.equal(base); }); }); describe('groupByModelId', () => { it('group compound command results by model', async () => { const models = new Map([ ['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 command_1.CompoundCommandImpl('test', command1, command2); const compoundModel2 = new command_1.CompoundCommandImpl('test', command3); const topCompound = new command_1.CompoundCommandImpl('test', compoundModel1, compoundModel2); const result = await topCompound.execute(models.get.bind(models)); (0, chai_1.expect)(result).to.not.be.undefined; if (result !== undefined) { const groupedResult = (0, command_1.groupByModelId)(result); (0, chai_1.expect)(groupedResult.size).to.equal(2); } }); }); describe('isSimpleCommandWithResult', () => { it('is not', () => { const command = new TestCommand('test', 'model1'); const isIt = (0, command_1.isSimpleCommandWithResult)(command); (0, chai_1.expect)(isIt).to.be.false; }); it('is', () => { const command = new TestCommandWithResult('test', 'model1'); const isIt = (0, command_1.isSimpleCommandWithResult)(command); (0, chai_1.expect)(isIt).to.be.true; }); }); describe('unwrapReturnResult', () => { it('pending', () => { const command = new TestCommandWithResult('test', 'model1'); const unwrapped = (0, command_1.unwrapReturnResult)(command); (0, chai_1.expect)(unwrapped).to.be.undefined; }); it('failed', () => { const command = Object.assign(new TestCommand('test', 'model1'), { result: { status: 'failed', error: new Error('💣'), }, }); (0, chai_1.expect)(() => (0, command_1.unwrapReturnResult)(command)).to.throw('💣'); }); it('failed without detail', () => { const command = Object.assign(new TestCommand('test', 'model1'), { result: { status: 'failed', }, }); (0, chai_1.expect)(() => (0, command_1.unwrapReturnResult)(command)).to.throw(); }); it('ready', () => { const command = Object.assign(new TestCommand('test', 'model1'), { result: { status: 'ready', value: 'Hello, world!', }, }); const unwrapped = (0, command_1.unwrapReturnResult)(command); (0, chai_1.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 command_1.CompoundCommandImpl('test', command1, command2); const result = new Map(); result.set(command1, undefined); result.set(command2, []); result.set(compound, []); const groupedResult = (0, command_1.groupByModelId)(result); (0, chai_1.expect)(groupedResult.size).to.equal(1); (0, chai_1.expect)(groupedResult.get('model1')).to.be.undefined; (0, chai_1.expect)(groupedResult.get('model2')).to.be.an('array').that.is.empty; }); }); describe('CompoundCommandImpl', () => { let getModel; let commands; let compound; const forceAppend = (compound, ...commands) => { // Cannot append via the API, so shoehorn it compound._commands.push(...commands); }; beforeEach(() => { getModel = () => ({}); commands = [new AsyncTestCommand('a'), new AsyncTestCommand('b')]; compound = new command_1.CompoundCommandImpl('test', ...commands); }); it('has a label', () => { (0, chai_1.expect)(compound.label, 'wrong label').to.be.eq('test'); }); describe('execute', () => { let sandbox; beforeEach(() => { sandbox = sinon_1.default.createSandbox(); }); afterEach(() => { sandbox.restore(); }); it('executes the subcommands', async () => { await compound.execute(getModel); (0, chai_1.expect)(commands, 'commands not executed').to.be.like([ { wasExecuted: true }, { wasExecuted: true }, ]); }); it('collates the deltas', async () => { const delta = await compound.execute(getModel); (0, chai_1.expect)(delta, 'no delta returned').to.exist; (0, chai_1.expect)(delta?.get(commands[0]), 'wrong result for command a').to.be.like([ { op: 'add', path: 'a' }, ]); (0, chai_1.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 (0, chai_1.expect)(compound.execute(getModel)).to.eventually.have.rejected; (0, chai_1.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].failOn('undo'); const consoleError = sandbox.stub(console, 'error'); await (0, chai_1.expect)(compound.execute(getModel)).to.eventually.have.rejected; (0, chai_1.expect)(consoleError).to.have.been.calledWithMatch('model-manager/command:', 'Error in recovery of failed execute.'); (0, chai_1.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 (0, chai_1.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 (0, chai_1.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); (0, chai_1.expect)(commands, 'commands not undone').to.be.like([ { wasUndone: true }, { wasUndone: true }, ]); }); it('collates the deltas', async () => { const delta = await compound.undo(getModel); (0, chai_1.expect)(delta, 'no delta returned').to.exist; (0, chai_1.expect)(delta?.get(commands[0]), 'wrong result for command a').to.be.like([ { op: 'remove', path: 'a' }, ]); (0, chai_1.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].failOn('undo'); await (0, chai_1.expect)(compound.undo(getModel)).to.eventually.have.rejected; (0, chai_1.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 (0, chai_1.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 (0, chai_1.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); (0, chai_1.expect)(commands, 'commands not redone').to.be.like([ { wasRedone: true }, { wasRedone: true }, ]); }); it('collates the deltas', async () => { const delta = await compound.redo(getModel); (0, chai_1.expect)(delta, 'no delta returned').to.exist; (0, chai_1.expect)(delta?.get(commands[0]), 'wrong result for command a').to.be.like([ { op: 'add', path: 'a' }, ]); (0, chai_1.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 (0, chai_1.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 (0, chai_1.expect)(compound.redo(getModel), 'compound should not have redone') .to.eventually.be.rejected; }); }); }); describe('canExecute', () => { let sandbox; beforeEach(() => { sandbox = sinon_1.default.createSandbox(); }); afterEach(() => { sandbox.restore(); }); it('is true', () => { return (0, chai_1.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 command_1.CompoundCommandImpl('empty'); const result = await empty.canExecute(getModel); (0, chai_1.expect)(result, 'empty compound is executable').to.be.true; (0, chai_1.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 (0, chai_1.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 (0, chai_1.expect)(compound.canExecute(getModel), 'compound is executable') .to.eventually.be.false; }); }); describe('synchronous commands', () => { beforeEach(() => { commands = [new TestCommand('a'), new TestCommand('b')]; compound = new command_1.CompoundCommandImpl('test', ...commands); }); it('is false by reason of a nested command', () => { commands[1].execute(getModel); // Make one non-executable by executing it return (0, chai_1.expect)(compound.canExecute(getModel), 'compound is executable') .to.eventually.be.false; }); }); }); describe('canUndo', () => { let sandbox; beforeEach(() => { sandbox = sinon_1.default.createSandbox(); return compound.execute(getModel); }); afterEach(() => { sandbox.restore(); }); it('is true', () => { return (0, chai_1.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 (0, chai_1.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 (0, chai_1.expect)(compound.canUndo(getModel), 'compound is undoable').to .eventually.be.false; }); }); describe('synchronous commands', () => { beforeEach(() => { commands = [new TestCommand('a'), new TestCommand('b')]; compound = new command_1.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 (0, chai_1.expect)(compound.canUndo(getModel), 'compound is undoable').to .eventually.be.false; }); }); describe('no sub commands', () => { beforeEach(() => { sandbox = sinon_1.default.createSandbox(); compound = new command_1.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); (0, chai_1.expect)(result, 'compound is not undoable').to.be.true; (0, chai_1.expect)(spyConsoleDebug).to.have.been.calledWithMatch('model-manager/command:', `No constituent command for ${compound.label}.`); }); }); }); describe('canRedo', () => { let sandbox; beforeEach(async () => { sandbox = sinon_1.default.createSandbox(); await compound.execute(getModel); await compound.undo(getModel); }); afterEach(() => { sandbox.restore(); }); it('is true', () => { return (0, chai_1.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 (0, chai_1.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 (0, chai_1.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 command_1.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 (0, chai_1.expect)(compound.canRedo(getModel), 'compound is redoable').to .eventually.be.false; }); }); describe('no sub commands', () => { beforeEach(async () => { compound = new command_1.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); (0, chai_1.expect)(result, 'compound is not redoable').to.be.true; (0, chai_1.expect)(spyConsoleDebug).to.have.been.calledWithMatch('model-manager/command:', `No constituent command for ${compound.label}.`); }); }); }); describe('append', () => { let newCommand; beforeEach(() => { newCommand = new AsyncTestCommand('c'); }); it('is appendable', () => { compound.append(newCommand); (0, chai_1.expect)(compound, 'new command not appended').to.be.like({ _commands: [{ label: 'a' }, { label: 'b' }, { label: 'c' }], }); }); it('is frozen', async () => { await compound.execute(getModel); (0, chai_1.expect)(() => compound.append(newCommand), 'should have thrown precondition error').to.throw(); }); }); describe('getCommands', () => { it('returns the commands', () => { const list = compound.getCommands(); (0, chai_1.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); (0, chai_1.expect)(compound, 'command removed from the compound').to.be.like({ _commands: [{ label: 'a' }, { label: 'b' }], }); }); }); describe('nested compounds', () => { let nested; let nestedCompound; let sandbox; const obtrudeExecute = (cmd) => { 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 command_1.CompoundCommandImpl('nested', ...nested); compound.append(nestedCompound); sandbox = sinon_1.default.createSandbox(); }); afterEach(() => { sandbox.restore(); }); it('collates the nested deltas', async () => { const delta = await compound.execute(getModel); (0, chai_1.expect)(delta, 'no delta returned').to.exist; (0, chai_1.expect)(delta?.size, 'wrong number of deltas returned').to.be.eq(4); (0, chai_1.expect)(delta?.get(nested[0])).to.be.like([{ op: 'add', path: 'c' }]); (0, chai_1.expect)(delta?.get(nested[1])).to.be.like([{ op: 'add', path: 'd' }]); (0, chai_1.expect)(delta?.has(commands[0])).to.be.true; (0, chai_1.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); (0, chai_1.expect)(delta, 'a partial delta was not returned').to.exist; (0, chai_1.expect)(delta?.get(nested[1])).to.be.like([{ op: 'add', path: 'd' }]); (0, chai_1.expect)(Array.from(delta?.keys() ?? [])).to.include.members(commands); commands.forEach((cmd) => (0, chai_1.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); (0, chai_1.expect)(delta, 'a delta was returned').not.to.exist; nested.forEach((cmd) => (0, chai_1.expect)(cmd.execute).to.have.been.called); commands.forEach((cmd) => (0, chai_1.expect)(cmd.execute).to.have.been.called); }); }); describe('iteration', () => { it('empty compound', () => { const labels = new command_1.CompoundCommandImpl('test').map((cmd) => cmd.label); (0, chai_1.expect)(labels).to.eql([]); }); it('flat compound', () => { const compound = new command_1.CompoundCommandImpl('test', new TestCommand('a'), new TestCommand('b')); const labels = compound.map((cmd) => cmd.label); (0, chai_1.expect)(labels).to.eql(['a', 'b']); }); it('nested compound', () => { const compound = new command_1.CompoundCommandImpl('test', new command_1.CompoundCommandImpl('one', new TestCommand('a'), new TestCommand('b')), new TestCommand('c'), new command_1.CompoundCommandImpl('two', new TestCommand('d'), new command_1.CompoundCommandImpl('three', new TestCommand('e'), new TestCommand('f')), new TestCommand('g'))); const labels = compound.map((cmd) => cmd.label); (0, chai_1.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 command_1.CompoundCommandImpl('outer', commands[0], new command_1.CompoundCommandImpl('inner', ...commands.slice(1))); }); it('execute', async () => { await compound.execute(getModel); (0, chai_1.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); (0, chai_1.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); (0, chai_1.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].failOn('undo'); await (0, chai_1.expect)(compound.undo(getModel)).to.eventually.have.rejected; (0, chai_1.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) => { if (modelId === 'c' && countdown-- <= 0) { return undefined; } return {}; }; await (0, chai_1.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) => { if (modelId === 'a' && countdown-- <= 0) { return undefined; } return {}; }; // The exception from rewind isn't actually surfaced await (0, chai_1.expect)(compound.execute(getModel)).to.eventually.have.rejected; (0, chai_1.expect)(commands[1]).to.be.like({ wasUndone: true }); (0, chai_1.expect)(commands[0]).to.be.like({ wasUndone: false }); }); }); }); class TestCommand { constructor(label, modelId = '') { this.label = label; this.modelId = modelId; this.wasExecuted = false; this.wasUndone = false; this.wasRedone = false; } canExecute() { return !this.wasExecuted && !this.wasUndone && !this.wasRedone; } canUndo() { return this.wasExecuted || this.wasRedone; } canRedo() { return this.wasUndone; } execute() { 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() { if (!this.canUndo()) { throw new Error('cannot undo'); } this.wasExecuted = false; this.wasUndone = true; this.wasRedone = false; return [{ op: 'remove', path: this.label }]; } redo() { 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 { constructor(label, modelId = '') { this.label = label; this.modelId = modelId; this.wasExecuted = false; this.wasUndone = false; this.wasRedone = false; this.failed = false; } failOn(op) { this.failOnOp = op; } async canExecute() { this.maybeFailOn('canExecute'); return !this.wasExecuted && !this.wasUndone && !this.wasRedone; } async canUndo() { this.maybeFailOn('canUndo'); return this.wasExecuted || this.wasRedone; } async canRedo() { this.maybeFailOn('canRedo'); return this.wasUndone; } async execute() { 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() { 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() { 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' }]; } maybeFailOn(op) { if (this.failOnOp === op) { this.failed = true; this.failOnOp = undefined; throw new Error(`Failed on ${op} as directed.`); } } } class TestCommandWithResult extends TestCommand { constructor() { super(...arguments); this.result = { status: 'pending' }; } } //# sourceMappingURL=command.spec.js.map