From 5eb4360db4dccc1fcd6501d82a67e690ce837d9f Mon Sep 17 00:00:00 2001 From: arnobl <1052622+arnobl@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:29:13 +0200 Subject: [PATCH 1/4] feat(command): memento decorator to reduce boilerplate code --- eslint.config.mjs | 1 + src/api/command/Memento.ts | 93 ++++++++++++++++ src/api/command/ModifiableCommand.ts | 2 +- src/api/command/Selective.ts | 2 +- src/api/history/LinearHistory.ts | 12 ++- src/api/history/LinearHistoryBase.ts | 6 +- src/api/history/TreeHistory.ts | 8 +- src/api/history/Undoable.ts | 4 +- src/impl/binding/BindingImpl.ts | 3 +- src/impl/command/CommandBase.ts | 5 +- src/impl/command/UndoableCommand.ts | 9 +- src/impl/command/library/Redo.ts | 4 +- src/impl/command/library/RedoNTimes.ts | 10 +- src/impl/command/library/SetProperties.ts | 7 +- src/impl/command/library/SetProperty.ts | 6 +- src/impl/command/library/TransferArrayItem.ts | 4 +- src/impl/command/library/Undo.ts | 4 +- src/impl/command/library/UndoNTimes.ts | 10 +- src/impl/history/LinearHistoryImpl.ts | 41 ++++--- src/impl/history/TreeHistoryImpl.ts | 39 ++++--- src/interacto.ts | 1 + test/binding/UndoRedoBindings.test.ts | 2 +- test/command/CommandAsync.test.ts | 1 - test/command/StubCmd.ts | 4 - test/command/library/SetProperty.test.ts | 6 +- test/command/memento.test.ts | 85 +++++++++++++++ test/command/selective.test.ts | 10 +- test/history/LinearHistory.test.ts | 93 +++++++++------- test/history/TreeHistory.test.ts | 101 ++++++++++-------- 29 files changed, 411 insertions(+), 162 deletions(-) create mode 100644 src/api/command/Memento.ts create mode 100644 test/command/memento.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index ffeb313b..474847db 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -261,6 +261,7 @@ export default [...[].concat( }, rules: { ...jest.configs['flat/all'].rules, + "no-void": "off", "no-new": "off", "no-underscore-dangle": "off", "no-duplicate-imports": "off", diff --git a/src/api/command/Memento.ts b/src/api/command/Memento.ts new file mode 100644 index 00000000..5a601ed7 --- /dev/null +++ b/src/api/command/Memento.ts @@ -0,0 +1,93 @@ +/* + * This file is part of Interacto. + * Interacto is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * Interacto is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with Interacto. If not, see . + */ + +import {CommandBase} from "../../impl/command/CommandBase"; + +const INTERACTO_MEMENTO: unique symbol = Symbol("interacto-cmd-memento"); + +interface MementoMetadata { + [INTERACTO_MEMENTO]?: Map; +} + +/** + * The Interacto decorator to mark one command's property as a property from which a memento must be created. + * Cannot check the type of the property here (requires reflect-metadata we do not want to install). + * @param target - The targeted command. + * @param propertyName - The name of the property the decorator targets. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export function Memento(target: unknown, propertyName: string): void { + if (!(target instanceof CommandBase)) { + // eslint-disable-next-line no-console + console.error("The @Memento decorator currently operates on Interacto commands only"); + return; + } + + // Getting the object cache related to the modifiable symbol + const symbolValues: unknown = (target.constructor as MementoMetadata)[INTERACTO_MEMENTO]; + const map = symbolValues instanceof Map ? symbolValues as Map : new Map(); + + // Adding the property in this cache + const tpropertyName = propertyName as keyof CommandBase; + map.set(propertyName, target[tpropertyName]); + (target.constructor as MementoMetadata)[INTERACTO_MEMENTO] = map; +} + +/** + * Retrieves the subset of attributes from an {@link Undoable} instance that are + * considered being the memento of the object (i.e., annotated with `@Memento`) + * @param obj - The object from which to extract memento properties. + * @returns Partial - An object (extracted from `obj`) containing only the properties that are + * declared as being the memento. + */ +function getMementoProperties(obj: T): Partial { + const modifiableAttributes: Partial = {}; + const mementoProps: unknown = (obj.constructor as MementoMetadata)[INTERACTO_MEMENTO]; + + if (mementoProps instanceof Map) { + for (const [key, value] of mementoProps.entries()) { + const tkey = key as keyof T; + modifiableAttributes[tkey] = value as T[keyof T]; + } + } + return modifiableAttributes; +} + +/** + * Retrieves the subset of attributes from an {@link Undoable} instance that are + * considered being the memento of the object (i.e., annotated with `@Memento`) + * @param obj - The object from which to restore the values using the stored memento properties. + */ +export function restoreMementoProperties(obj: T): void { + for (const [propName, propMementoValue] of Object.entries(getMementoProperties(obj))) { + const typedPropName = propName as keyof T; + obj[typedPropName] = propMementoValue as T[keyof T]; + } +} + +/** + * Retrieves the subset of attributes from an {@link Undoable} instance that are + * considered being the memento of the object (i.e., annotated with `@Memento`) + * @param obj - The object from which to restore the values using the stored memento properties. + */ +export function createMementoProperties(obj: T): void { + const mementoProps: unknown = (obj.constructor as MementoMetadata)[INTERACTO_MEMENTO]; + + if (mementoProps instanceof Map) { + for (const propName of mementoProps.keys()) { + const typedPropName = propName as keyof T; + mementoProps.set(propName, obj[typedPropName]); + } + } +} diff --git a/src/api/command/ModifiableCommand.ts b/src/api/command/ModifiableCommand.ts index 45850385..c9001c67 100644 --- a/src/api/command/ModifiableCommand.ts +++ b/src/api/command/ModifiableCommand.ts @@ -108,7 +108,7 @@ export function getModifiableCmdAttributes(obj: T): Partial< const tkey = key as keyof T; const type = typeof obj[tkey]; - if (type === "string" || type === "number" || type === "boolean") { + if (type === "string" || type === "number" || type === "boolean" || type === "bigint") { modifiableAttributes[tkey] = obj[tkey]; } else { // eslint-disable-next-line no-console diff --git a/src/api/command/Selective.ts b/src/api/command/Selective.ts index 744316dc..a2afed93 100644 --- a/src/api/command/Selective.ts +++ b/src/api/command/Selective.ts @@ -91,7 +91,7 @@ export function isCmdSelective(obj: T): boolean { * done using the === operator. * @returns True if the given object is selective and its selective property has as value the given `value` */ -export function hasSelectiveValue( +export function hasSelectiveValue( obj: object, value: V, eqFn: ((v1: V, v2: V) => boolean) = (v1, v2) => v1 === v2): boolean { const res = getSelectiveValue(obj); diff --git a/src/api/history/LinearHistory.ts b/src/api/history/LinearHistory.ts index bcc56d8b..513a4bf2 100644 --- a/src/api/history/LinearHistory.ts +++ b/src/api/history/LinearHistory.ts @@ -27,9 +27,9 @@ export abstract class LinearHistory implements LinearHistoryBase { public abstract clear(): void; - public abstract undo(): void; + public abstract undo(): Promise | void; - public abstract redo(): void; + public abstract redo(): Promise | void; /** * @returns The stack of saved undoable objects. @@ -95,7 +95,7 @@ export abstract class LinearHistory implements LinearHistoryBase { * The second one contains the matching commands in the redo stack. * Each undoable is associated with its index in its undo/redo stack. */ - public abstract getSelectiveOf (key: T, eqFn?: (v1: T, v2: T) => boolean): + public abstract getSelectiveOf (key: T, eqFn?: (v1: T, v2: T) => boolean): [undos: Array<[undoable: Undoable, index: number]>, redos: Array<[undoable: Undoable, index: number]>]; /** @@ -106,8 +106,9 @@ export abstract class LinearHistory implements LinearHistoryBase { * Index size()[0] is the most recent undoable elements (corresponds to getLastUndo()) * @param index - The index in the undo stack to undo up to it. * If not valid, the method does nothing. + * @returns nothing or a promise (a chain of promises in fact) if the undo process is async. */ - public abstract undoUpTo(index: number): void; + public abstract undoUpTo(index: number): Promise | void; /** * Redoes the last undoable objects up to go to the provided index. @@ -117,8 +118,9 @@ export abstract class LinearHistory implements LinearHistoryBase { * Index size()[0] is the most recent undoable elements (corresponds to getLastRedo()) * @param index - The index in the redo stack to redo up to it. * If not valid, the method does nothing. + * @returns nothing or a promise (a chain of promises in fact) if the undo process is async. */ - public abstract redoUpTo(index: number): void; + public abstract redoUpTo(index: number): Promise | void; /** * Gets all the unique selective objects of this history. diff --git a/src/api/history/LinearHistoryBase.ts b/src/api/history/LinearHistoryBase.ts index 27f179e9..2bd47cc6 100644 --- a/src/api/history/LinearHistoryBase.ts +++ b/src/api/history/LinearHistoryBase.ts @@ -22,13 +22,15 @@ import type {Observable} from "rxjs"; export interface LinearHistoryBase { /** * Undoes the last undoable object. + * @returns nothing or a promise if the undo process is async. */ - undo(): void; + undo(): Promise | void; /** * Redoes the last undoable object. + * @returns nothing or a promise if the undo process is async. */ - redo(): void; + redo(): Promise | void; /** * Removes all the undoable objects of the collector. diff --git a/src/api/history/TreeHistory.ts b/src/api/history/TreeHistory.ts index 3d27fbee..70ef4508 100644 --- a/src/api/history/TreeHistory.ts +++ b/src/api/history/TreeHistory.ts @@ -54,12 +54,12 @@ export interface TreeHistoryNode { /** * Undoes the undoable object of this node. */ - undo(): void; + undo(): Promise | void; /** * Redoes the undoable object of this node. */ - redo(): void; + redo(): Promise | void; } /** @@ -215,11 +215,11 @@ export abstract class TreeHistory implements LinearHistoryBase { public abstract getLastUndoMessage(): string | undefined; - public abstract redo(): void; + public abstract redo(): Promise | void; public abstract redosObservable(): Observable; - public abstract undo(): void; + public abstract undo(): Promise | void; public abstract undosObservable(): Observable; diff --git a/src/api/history/Undoable.ts b/src/api/history/Undoable.ts index eed88de3..4078836b 100644 --- a/src/api/history/Undoable.ts +++ b/src/api/history/Undoable.ts @@ -32,12 +32,12 @@ export interface Undoable { /** * Cancels the command. */ - undo(): void; + undo(): Promise | void; /** * Redoes the canceled command. */ - redo(): void; + redo(): Promise | void; /** * @returns The name of the history command. diff --git a/src/impl/binding/BindingImpl.ts b/src/impl/binding/BindingImpl.ts index 82a4e4e9..e698b30e 100644 --- a/src/impl/binding/BindingImpl.ts +++ b/src/impl/binding/BindingImpl.ts @@ -317,7 +317,8 @@ implements Binding { private cancelContinuousWithEffectsCmd(cmd: C): void { if (isUndoableType(cmd)) { - cmd.undo(); + // eslint-disable-next-line no-void + void cmd.undo(); if (this.logCmd) { this.logger.logCmdMsg("Command undone", cmd.constructor.name); } diff --git a/src/impl/command/CommandBase.ts b/src/impl/command/CommandBase.ts index 4dc18dcb..8e1c2c1d 100644 --- a/src/impl/command/CommandBase.ts +++ b/src/impl/command/CommandBase.ts @@ -13,6 +13,7 @@ */ import type {Command, CmdStatus} from "../../api/command/Command"; +import {createMementoProperties} from "../../api/command/Memento"; /** * The base implementation class for coding UI commands. @@ -44,7 +45,9 @@ export abstract class CommandBase implements Command { * This is the goal of the operation that should be overridden. * This operator is called a single time before the first execution of the command. */ - protected createMemento(): void {} + protected createMemento(): void { + createMementoProperties(this); + } public execute(): Promise | boolean { let ok: boolean; diff --git a/src/impl/command/UndoableCommand.ts b/src/impl/command/UndoableCommand.ts index 990fa917..76892a7b 100644 --- a/src/impl/command/UndoableCommand.ts +++ b/src/impl/command/UndoableCommand.ts @@ -14,6 +14,7 @@ import {CommandBase} from "./CommandBase"; import type {Undoable, UndoableSnapshot} from "../../api/history/Undoable"; +import {restoreMementoProperties} from "../../api/command/Memento"; /** * The base class for undoable UI commands. @@ -68,7 +69,11 @@ export abstract class UndoableCommand ex return this === undoable; } - public abstract redo(): void; + public redo(): Promise | void { + return this.execution(); + } - public abstract undo(): void; + public undo(): Promise | void { + restoreMementoProperties(this); + } } diff --git a/src/impl/command/library/Redo.ts b/src/impl/command/library/Redo.ts index f9700a68..3b063620 100644 --- a/src/impl/command/library/Redo.ts +++ b/src/impl/command/library/Redo.ts @@ -31,7 +31,7 @@ export class Redo extends CommandBase { return this.history.getLastRedo() !== undefined; } - protected execution(): void { - this.history.redo(); + protected execution(): Promise | void { + return this.history.redo(); } } diff --git a/src/impl/command/library/RedoNTimes.ts b/src/impl/command/library/RedoNTimes.ts index 542dbc70..c34c85f7 100644 --- a/src/impl/command/library/RedoNTimes.ts +++ b/src/impl/command/library/RedoNTimes.ts @@ -34,9 +34,15 @@ export class RedoNTimes extends CommandBase { return this.history.getRedo().length >= this.numberOfRedos; } - protected execution(): void { + protected execution(): Promise | void { + let chain = Promise.resolve(); for (let i = 0; i < this.numberOfRedos; i++) { - this.history.redo(); + const res = this.history.redo(); + if (res instanceof Promise) { + // eslint-disable-next-line @typescript-eslint/promise-function-async + chain = chain.then(() => res); + } } + return chain; } } diff --git a/src/impl/command/library/SetProperties.ts b/src/impl/command/library/SetProperties.ts index c7016398..762b0710 100644 --- a/src/impl/command/library/SetProperties.ts +++ b/src/impl/command/library/SetProperties.ts @@ -59,13 +59,14 @@ export class SetProperties extends UndoableCommand { protected execution(): void {} - public redo(): void { + public override redo(): void { for (const cmd of this.compositeCmds) { - cmd.redo(); + // eslint-disable-next-line no-void + void cmd.redo(); } } - public undo(): void { + public override undo(): void { for (const cmd of this.compositeCmds) { cmd.undo(); } diff --git a/src/impl/command/library/SetProperty.ts b/src/impl/command/library/SetProperty.ts index d907a279..0f4ff588 100644 --- a/src/impl/command/library/SetProperty.ts +++ b/src/impl/command/library/SetProperty.ts @@ -45,11 +45,7 @@ export class SetProperty extends UndoableCommand { this.obj[this.prop] = this.newvalue; } - public redo(): void { - this.execution(); - } - - public undo(): void { + public override undo(): void { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.obj[this.prop] = this.mementoValue!; } diff --git a/src/impl/command/library/TransferArrayItem.ts b/src/impl/command/library/TransferArrayItem.ts index 44ef541f..cb74f726 100644 --- a/src/impl/command/library/TransferArrayItem.ts +++ b/src/impl/command/library/TransferArrayItem.ts @@ -78,7 +78,7 @@ export class TransferArrayItem extends UndoableCommand { return this.cmdName; } - public redo(): void { + public override redo(): void { const elt = this._srcArray[this._srcIndex]; if (elt !== undefined) { this._srcArray.splice(this._srcIndex, 1); @@ -86,7 +86,7 @@ export class TransferArrayItem extends UndoableCommand { } } - public undo(): void { + public override undo(): void { const elt = this._tgtArray[this._tgtIndex]; if (elt !== undefined) { this._tgtArray.splice(this._tgtIndex, 1); diff --git a/src/impl/command/library/Undo.ts b/src/impl/command/library/Undo.ts index d8cdf0e8..ad8e7f1d 100644 --- a/src/impl/command/library/Undo.ts +++ b/src/impl/command/library/Undo.ts @@ -31,7 +31,7 @@ export class Undo extends CommandBase { return this.history.getLastUndo() !== undefined; } - protected execution(): void { - this.history.undo(); + protected execution(): Promise | void { + return this.history.undo(); } } diff --git a/src/impl/command/library/UndoNTimes.ts b/src/impl/command/library/UndoNTimes.ts index 73eca7a8..dfff1aa2 100644 --- a/src/impl/command/library/UndoNTimes.ts +++ b/src/impl/command/library/UndoNTimes.ts @@ -34,9 +34,15 @@ export class UndoNTimes extends CommandBase { return this.history.getUndo().length >= this.numberOfUndos; } - protected execution(): void { + protected execution(): Promise | void { + let chain = Promise.resolve(); for (let i = 0; i < this.numberOfUndos; i++) { - this.history.undo(); + const res = this.history.undo(); + if (res instanceof Promise) { + // eslint-disable-next-line @typescript-eslint/promise-function-async + chain = chain.then(() => res); + } } + return chain; } } diff --git a/src/impl/history/LinearHistoryImpl.ts b/src/impl/history/LinearHistoryImpl.ts index 2d305edf..6263f70b 100644 --- a/src/impl/history/LinearHistoryImpl.ts +++ b/src/impl/history/LinearHistoryImpl.ts @@ -98,7 +98,8 @@ export class LinearHistoryImpl extends LinearHistory { const lastRedo = this.redos.at(-1); if (this.considersEqualCmds && lastRedo !== undefined && lastRedo.equals(undoable)) { - this.redo(); + // eslint-disable-next-line no-void + void this.redo(); } else { this.undos.push(undoable); this.undoPublisher.next(undoable); @@ -108,12 +109,12 @@ export class LinearHistoryImpl extends LinearHistory { } } - public undo(): void { + public undo(): Promise | void { const undoable = this.undos.pop(); if (undoable !== undefined) { try { - undoable.undo(); + return undoable.undo(); } finally { this.redos.push(undoable); this.undoPublisher.next(this.getLastUndo()); @@ -121,14 +122,15 @@ export class LinearHistoryImpl extends LinearHistory { this.publishSize(); } } + return undefined; } - public redo(): void { + public redo(): Promise | void { const undoable = this.redos.pop(); if (undoable !== undefined) { try { - undoable.redo(); + return undoable.redo(); } finally { this.undos.push(undoable); this.undoPublisher.next(undoable); @@ -136,6 +138,7 @@ export class LinearHistoryImpl extends LinearHistory { this.publishSize(); } } + return undefined; } public getLastUndoMessage(): string | undefined { @@ -201,7 +204,7 @@ export class LinearHistoryImpl extends LinearHistory { this.sizePublisher.next(this.size()); } - public getSelectiveOf (obj: T, eqFn?: (v1: T, v2: T) => boolean): + public getSelectiveOf (obj: T, eqFn?: (v1: T, v2: T) => boolean): [undos: Array<[undoable: Undoable, index: number]>, redos: Array<[undoable: Undoable, index: number]>] { const fnFilter = (undoable: Undoable): boolean => hasSelectiveValue(undoable, obj, eqFn); const fnMap = (undoable: Undoable, index: number): [undoable: Undoable, index: number] => [undoable, index]; @@ -215,28 +218,42 @@ export class LinearHistoryImpl extends LinearHistory { ]; } - public undoUpTo(index: number): void { + public undoUpTo(index: number): Promise | void { if (index < 0 || index >= this.undos.length) { - return; + return undefined; } const count = this.undos.length - index; + let chain: Promise = Promise.resolve(); for (let i = 0; i < count; i++) { - this.undo(); + const res = this.undo(); + if (res instanceof Promise) { + // eslint-disable-next-line @typescript-eslint/promise-function-async + chain = chain.then(() => res); + } } + + return chain; } - public redoUpTo(index: number): void { + public redoUpTo(index: number): Promise | void { if (index < 0 || index >= this.redos.length) { - return; + return undefined; } const count = this.redos.length - index; + let chain: Promise = Promise.resolve(); for (let i = 0; i < count; i++) { - this.redo(); + const res = this.redo(); + if (res instanceof Promise) { + // eslint-disable-next-line @typescript-eslint/promise-function-async + chain = chain.then(() => res); + } } + + return chain; } public getAllSelectiveObjects(): ReadonlySet { diff --git a/src/impl/history/TreeHistoryImpl.ts b/src/impl/history/TreeHistoryImpl.ts index 73eb3112..41ab0dee 100644 --- a/src/impl/history/TreeHistoryImpl.ts +++ b/src/impl/history/TreeHistoryImpl.ts @@ -51,15 +51,15 @@ class TreeHistoryNodeImpl implements TreeHistoryNode { this.cacheVisualSnap = undoable.getVisualSnapshot(); } - public undo(): void { + public undo(): Promise | void { if (this.parent !== undefined) { this.parent.lastChildUndone = this; } - this.undoable.undo(); + return this.undoable.undo(); } - public redo(): void { - this.undoable.redo(); + public redo(): Promise | void { + return this.undoable.redo(); } public get visualSnapshot(): UndoableSnapshot { @@ -333,7 +333,8 @@ export class TreeHistoryImpl extends TreeHistory { */ private proceedModifiedNode(node: TreeHistoryNode): void { // Executing the cloned undoable object - node.undoable.redo(); + // eslint-disable-next-line no-void + void node.undoable.redo(); // Must refresh the visual snapshot cache if (node.undoable instanceof UndoableCommand) { node.undoable.refreshCache(); @@ -350,7 +351,8 @@ export class TreeHistoryImpl extends TreeHistory { private addClonedSubtree(node: TreeHistoryNode): void { for (const child of node.children) { this.proceedModifiedNode(child); - this.undo(); + // eslint-disable-next-line no-void + void this.undo(); this.addClonedSubtree(child); } } @@ -390,7 +392,8 @@ export class TreeHistoryImpl extends TreeHistory { private goToFromRoot(id: number): void { const undoables = this.gatherToRoot(this.undoableNodes[id]); for (const undoable of undoables) { - undoable.redo(); + // eslint-disable-next-line no-void + void undoable.redo(); } } @@ -415,39 +418,47 @@ export class TreeHistoryImpl extends TreeHistory { i++; } + // Ignoring async for the moment for (let j = pathSrc.length - 1; j > i; j--) { - pathSrc[j]?.undo(); + // eslint-disable-next-line no-void + void pathSrc[j]?.undo(); } if (i < pathSrc.length) { - pathSrc[i]?.undo(); + // eslint-disable-next-line no-void + void pathSrc[i]?.undo(); } // to then redo the target path to the targeted node for (let j = i; j < pathTo.length; j++) { - pathTo[j]?.redo(); + // eslint-disable-next-line no-void + void pathTo[j]?.redo(); } } - public redo(): void { + public redo(): Promise | void { const node = this.currentNode.lastChildUndone; if (node !== undefined) { - node.undoable.redo(); + const res = node.undoable.redo(); this._currentNode = node; this.addToPath(); this.undoPublisher.next(node.undoable); this.redoPublisher.next(this.getLastRedo()); + return res; } + return undefined; } - public undo(): void { + public undo(): Promise | void { if (this.currentNode !== this.root) { const currentUndoable = this.currentNode.undoable; - this.currentNode.undo(); + const res = this.currentNode.undo(); this._currentNode = this.currentNode.parent ?? this.root; this.addToPath(); this.undoPublisher.next(this.getLastUndo()); this.redoPublisher.next(currentUndoable); + return res; } + return undefined; } public getPositions(): Map { diff --git a/src/interacto.ts b/src/interacto.ts index b1f368b9..fdad306a 100644 --- a/src/interacto.ts +++ b/src/interacto.ts @@ -29,6 +29,7 @@ export * from "./api/binding/BindingsObserver"; export * from "./api/binding/VisitorBinding"; export * from "./api/checker/Checker"; export * from "./api/command/Command"; +export * from "./api/command/Memento"; export * from "./api/command/ModifiableCommand"; export * from "./api/command/Selective"; export * from "./api/fsm/ConcurrentFSM"; diff --git a/test/binding/UndoRedoBindings.test.ts b/test/binding/UndoRedoBindings.test.ts index 126a95d2..492f0723 100644 --- a/test/binding/UndoRedoBindings.test.ts +++ b/test/binding/UndoRedoBindings.test.ts @@ -93,7 +93,7 @@ describe("test history redo bindings", () => { throw new Error("err"); }); bindings.cmdhistory.add(undoable); - bindings.cmdhistory.undo(); + void bindings.cmdhistory.undo(); }); test("undo/redo: redo crash caught in binding", () => { diff --git a/test/command/CommandAsync.test.ts b/test/command/CommandAsync.test.ts index 3f9befbb..81f62cf4 100644 --- a/test/command/CommandAsync.test.ts +++ b/test/command/CommandAsync.test.ts @@ -91,7 +91,6 @@ describe("testing async commands and bindings", () => { describe("async command alone", () => { test("when promise not ended command not ended", () => { jest.useFakeTimers(); - // eslint-disable-next-line no-void void cmd.execute(); expect(cmd.getStatus()).toBe("created"); diff --git a/test/command/StubCmd.ts b/test/command/StubCmd.ts index 5b52855f..6c968f0b 100644 --- a/test/command/StubCmd.ts +++ b/test/command/StubCmd.ts @@ -18,10 +18,6 @@ export class ExampleUndoableCmd extends UndoableCommand { protected execution(): Promise | void { return undefined; } - - public redo(): void {} - - public undo(): void {} } export class StubCmd extends CommandBase { diff --git a/test/command/library/SetProperty.test.ts b/test/command/library/SetProperty.test.ts index 3327bb31..866aafdc 100644 --- a/test/command/library/SetProperty.test.ts +++ b/test/command/library/SetProperty.test.ts @@ -70,7 +70,7 @@ describe("using a set property command", () => { obj.foo = 2; await cmd.execute(); cmd.undo(); - cmd.redo(); + void cmd.redo(); expect(obj.foo).toBe(3); }); @@ -116,7 +116,7 @@ describe("using a set property command", () => { obj.foo2 = "fooo22"; await cmd.execute(); cmd.undo(); - cmd.redo(); + void cmd.redo(); expect(obj.foo2).toBe("yolo"); }); @@ -159,7 +159,7 @@ describe("using a set property command", () => { obj.bar = obj3; await cmd.execute(); cmd.undo(); - cmd.redo(); + void cmd.redo(); expect(obj.bar).toBe(obj2); }); diff --git a/test/command/memento.test.ts b/test/command/memento.test.ts new file mode 100644 index 00000000..c236bc8c --- /dev/null +++ b/test/command/memento.test.ts @@ -0,0 +1,85 @@ +/* + * This file is part of Interacto. + * Interacto is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * Interacto is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with Interacto. If not, see . + */ + +import {ExampleUndoableCmd} from "./StubCmd"; +import {Memento} from "../../src/interacto"; +import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; + +class MementoCmd1 extends ExampleUndoableCmd { + @Memento + public x: number; + + @Memento + public y: number; + + public newX: number; + + public constructor(x: number, y: number) { + super(); + this.x = x; + this.y = y; + this.newX = 0; + } + + public override execution(): void { + this.x = 10; + this.y = 20; + } +} + +describe("using a command have memento decorators", () => { + let cmd1: MementoCmd1; + + beforeEach(() => { + cmd1 = new MementoCmd1(1, 2); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test("command execution works as expected", () => { + void cmd1.execute(); + expect(cmd1.x).toBe(10); + expect(cmd1.y).toBe(20); + expect(cmd1.newX).toBe(0); + }); + + test("command undo uses automated memento to restore values", () => { + void cmd1.execute(); + void cmd1.undo(); + expect(cmd1.x).toBe(1); + expect(cmd1.y).toBe(2); + expect(cmd1.newX).toBe(0); + }); + + test("command undo/redo works as expected", () => { + void cmd1.execute(); + void cmd1.undo(); + void cmd1.redo(); + expect(cmd1.x).toBe(10); + expect(cmd1.y).toBe(20); + expect(cmd1.newX).toBe(0); + }); + + test("command undo/redo/undo uses automated memento to restore values", () => { + void cmd1.execute(); + void cmd1.undo(); + void cmd1.redo(); + void cmd1.undo(); + expect(cmd1.x).toBe(1); + expect(cmd1.y).toBe(2); + expect(cmd1.newX).toBe(0); + }); +}); diff --git a/test/command/selective.test.ts b/test/command/selective.test.ts index c8cf5162..381370b1 100644 --- a/test/command/selective.test.ts +++ b/test/command/selective.test.ts @@ -178,8 +178,8 @@ describe("using a selective command", () => { history.add(cmd1); history.add(new ExampleUndoableCmd()); history.add(cmd3); - history.undo(); - history.undo(); + void history.undo(); + void history.undo(); const res = history.getSelectiveOf(key); expect(res).toHaveLength(2); expect(res[0]).toHaveLength(2); @@ -191,8 +191,8 @@ describe("using a selective command", () => { test("works when full undo", () => { history.add(cmd2); history.add(cmd1); - history.undo(); - history.undo(); + void history.undo(); + void history.undo(); const res = history.getSelectiveOf(key); expect(res).toHaveLength(2); expect(res[0]).toHaveLength(0); @@ -242,7 +242,7 @@ describe("using a selective command", () => { cmd2 = new CmdSelective2("foo"); history.add(cmd2); history.add(cmd1); - history.undo(); + void history.undo(); const res = [...history.getAllSelectiveObjects()]; expect(res).toHaveLength(2); expect(res).toContain(cmd1.key); diff --git a/test/history/LinearHistory.test.ts b/test/history/LinearHistory.test.ts index 946ecc40..9c9b4b85 100644 --- a/test/history/LinearHistory.test.ts +++ b/test/history/LinearHistory.test.ts @@ -46,36 +46,36 @@ describe("using an linear history", () => { test("history called", () => { history.add(undoable); - history.undo(); + void history.undo(); expect(undoable.undo).toHaveBeenCalledTimes(1); }); test("undoUpTo does nothing if negative index", () => { history.add(undoable); - history.undoUpTo(-1); + void history.undoUpTo(-1); expect(history.getLastUndo()).toBe(undoable); expect(undoable.undo).not.toHaveBeenCalled(); }); test("redoUpTo does nothing if negative index", () => { history.add(undoable); - history.undo(); - history.redoUpTo(-1); + void history.undo(); + void history.redoUpTo(-1); expect(history.getLastRedo()).toBe(undoable); expect(undoable.redo).not.toHaveBeenCalled(); }); test("undoUpTo does nothing if too high index", () => { history.add(undoable); - history.undoUpTo(1); + void history.undoUpTo(1); expect(history.getLastUndo()).toBe(undoable); expect(undoable.undo).not.toHaveBeenCalled(); }); test("redoUpTo does nothing if too high index", () => { history.add(undoable); - history.undo(); - history.redoUpTo(1); + void history.undo(); + void history.redoUpTo(1); expect(history.getLastRedo()).toBe(undoable); expect(undoable.redo).not.toHaveBeenCalled(); }); @@ -83,7 +83,7 @@ describe("using an linear history", () => { test("undoUpTo undoes correctly with index 0", () => { history.add(undoable); history.add(undoable2); - history.undoUpTo(0); + void history.undoUpTo(0); expect(history.getLastUndo()).toBeUndefined(); expect(undoable.undo).toHaveBeenCalledTimes(1); expect(undoable2.undo).toHaveBeenCalledTimes(1); @@ -92,9 +92,9 @@ describe("using an linear history", () => { test("redoUpTo undoes correctly with index 0", () => { history.add(undoable); history.add(undoable2); - history.undo(); - history.undo(); - history.redoUpTo(0); + void history.undo(); + void history.undo(); + void history.redoUpTo(0); expect(history.getLastRedo()).toBeUndefined(); expect(undoable.redo).toHaveBeenCalledTimes(1); expect(undoable2.redo).toHaveBeenCalledTimes(1); @@ -103,7 +103,7 @@ describe("using an linear history", () => { test("undoUpTo undoes correctly", () => { history.add(undoable); history.add(undoable2); - history.undoUpTo(1); + void history.undoUpTo(1); expect(history.getLastUndo()).toBe(undoable); expect(undoable.undo).not.toHaveBeenCalled(); expect(undoable2.undo).toHaveBeenCalledTimes(1); @@ -112,9 +112,9 @@ describe("using an linear history", () => { test("redoUpTo undoes correctly", () => { history.add(undoable); history.add(undoable2); - history.undo(); - history.undo(); - history.redoUpTo(1); + void history.undo(); + void history.undo(); + void history.redoUpTo(1); expect(history.getLastRedo()).toBe(undoable2); expect(undoable2.redo).not.toHaveBeenCalled(); expect(undoable.redo).toHaveBeenCalledTimes(1); @@ -122,14 +122,14 @@ describe("using an linear history", () => { test("size history", () => { history.add(undoable); - history.undo(); + void history.undo(); expect(history.size()).toStrictEqual([0, 1]); }); test("history and redo called", () => { history.add(undoable); - history.undo(); - history.redo(); + void history.undo(); + void history.redo(); expect(undoable.undo).toHaveBeenCalledTimes(1); expect(undoable.redo).toHaveBeenCalledTimes(1); expect(history.size()).toStrictEqual([1, 0]); @@ -137,20 +137,20 @@ describe("using an linear history", () => { test("redo not called", () => { history.add(undoable); - history.redo(); + void history.redo(); expect(undoable.redo).not.toHaveBeenCalled(); expect(history.size()).toStrictEqual([1, 0]); }); test("history ok when empty", () => { - history.undo(); + void history.undo(); expect(history.getLastUndo()).toBeUndefined(); }); test("get last history ok on add", () => { history.add(undoable); - history.undo(); - history.redo(); + void history.undo(); + void history.redo(); expect(undoable.redo).toHaveBeenCalledTimes(1); expect(history.getLastUndo()).toBe(undoable); }); @@ -270,7 +270,7 @@ describe("using an linear history", () => { test("getLastRedoOKOnRedo", () => { history.add(undoable); - history.undo(); + void history.undo(); expect(history.getLastRedo()).toStrictEqual(undoable); }); @@ -311,20 +311,20 @@ describe("using an linear history", () => { test("getLastRedoMessageOK", () => { history.add(undoable); - history.undo(); + void history.undo(); expect(history.getLastRedoMessage()).toBe("undoredomsg"); }); test("lastOrEmptyRedoMessage OK", () => { history.add(undoable); - history.undo(); + void history.undo(); expect(history.getLastOrEmptyRedoMessage()).toBe("undoredomsg"); }); test("clear", () => { history.add(undoable); history.add(undoable2); - history.undo(); + void history.undo(); history.clear(); expect(history.getLastRedo()).toBeUndefined(); expect(history.getLastUndo()).toBeUndefined(); @@ -357,7 +357,12 @@ describe("using an linear history", () => { test("one add one history", () => { testScheduler.run(helpers => { const {cold, expectObservable} = helpers; - cold("-a-b", {"a": () => history.add(undoable), "b": () => history.undo()}).subscribe(v => { + cold("-a-b", { + "a": () => history.add(undoable), + "b": () => { + void history.undo(); + } + }).subscribe(v => { v(); }); @@ -375,8 +380,12 @@ describe("using an linear history", () => { cold("-a-b-c-d-e", { "a": () => history.add(undoable), "b": () => history.add(undoable2), - "c": () => history.undo(), - "d": () => history.redo(), + "c": () => { + void history.undo(); + }, + "d": () => { + void history.redo(); + }, "e": () => history.clear() }).subscribe(v => v()); @@ -413,7 +422,7 @@ describe("using an linear history", () => { history.setSizeMax(10); history.add(undoable); history.add(undoable2); - history.undo(); + void history.undo(); testScheduler.run(helpers => { const {cold, expectObservable} = helpers; @@ -433,7 +442,7 @@ describe("using an linear history", () => { history.setSizeMax(10); history.add(undoable); history.add(undoable2); - history.undo(); + void history.undo(); testScheduler.run(helpers => { const {cold, expectObservable} = helpers; @@ -455,8 +464,8 @@ describe("using an linear history", () => { history.add(undoable2); history.add(mock()); history.add(mock()); - history.undo(); - history.undo(); + void history.undo(); + void history.undo(); history.setSizeMax(1); expect(history.size()).toStrictEqual([1, 0]); @@ -469,7 +478,9 @@ describe("using an linear history", () => { history.add(undoable); history.add(undoable2); - expect(() => history.undo()).toThrow(new Error("err")); + expect(() => { + void history.undo(); + }).toThrow(new Error("err")); expect(history.getUndo()).toHaveLength(1); expect(history.getRedo()).toHaveLength(1); }); @@ -481,8 +492,10 @@ describe("using an linear history", () => { history.add(undoable); history.add(undoable2); - history.undo(); - expect(() => history.redo()).toThrow(new Error("err2")); + void history.undo(); + expect(() => { + void history.redo(); + }).toThrow(new Error("err2")); expect(history.getUndo()).toHaveLength(2); expect(history.getRedo()).toHaveLength(0); }); @@ -514,8 +527,8 @@ describe("using an linear history", () => { test("does a redo if equal command", () => { // A *B C D - history.undo(); - history.undo(); + void history.undo(); + void history.undo(); undoableC.equals = jest.fn(() => true); history.add(undoableE); @@ -525,8 +538,8 @@ describe("using an linear history", () => { test("clears redos if not equal command", () => { // A *B C D - history.undo(); - history.undo(); + void history.undo(); + void history.undo(); undoableC.equals = jest.fn(() => false); history.add(undoableE); diff --git a/test/history/TreeHistory.test.ts b/test/history/TreeHistory.test.ts index 4442598f..85b06235 100644 --- a/test/history/TreeHistory.test.ts +++ b/test/history/TreeHistory.test.ts @@ -64,7 +64,7 @@ describe("using a tree-based history", () => { }); test("history does nothing", () => { - history.undo(); + void history.undo(); expect(history.size()).toBe(0); expect(history.currentNode).toBe(history.root); }); @@ -175,7 +175,12 @@ describe("using a tree-based history", () => { test("one add one history", () => { testScheduler.run(helpers => { const {cold, expectObservable} = helpers; - cold("-a-b", {"a": () => history.add(undoable0), "b": () => history.undo()}).subscribe(v => { + cold("-a-b", { + "a": () => history.add(undoable0), + "b": () => { + void history.undo(); + } + }).subscribe(v => { v(); }); @@ -192,8 +197,12 @@ describe("using a tree-based history", () => { const {cold, expectObservable} = helpers; cold("-a-b-c", { "a": () => history.add(undoable0), - "b": () => history.undo(), - "c": () => history.redo() + "b": () => { + void history.undo(); + }, + "c": () => { + void history.redo(); + } }).subscribe(v => { v(); }); @@ -232,7 +241,9 @@ describe("using a tree-based history", () => { cold("-a-b-c-d", { "a": () => history.add(undoable0), "b": () => history.add(undoable1), - "c": () => history.undo(), + "c": () => { + void history.undo(); + }, "d": () => history.add(undoable2) }).subscribe(v => { v(); @@ -314,7 +325,7 @@ describe("using a tree-based history", () => { }); test("history works", () => { - history.undo(); + void history.undo(); expect(history.size()).toBe(1); expect(history.undoableNodes[0]).toBeDefined(); expect(history.currentNode).toBe(history.root); @@ -325,7 +336,7 @@ describe("using a tree-based history", () => { const toRedos = new Array(); const redosStream = history.redosObservable().subscribe((e: Undoable | undefined) => toRedos.push(e)); - history.undo(); + void history.undo(); redosStream.unsubscribe(); expect(toRedos).toHaveLength(1); @@ -350,10 +361,10 @@ describe("using a tree-based history", () => { const redosStream = history.redosObservable().subscribe((e: Undoable | undefined) => redos.push(e)); history.add(undoable1); - history.undo(); - history.undo(); - history.redo(); - history.redo(); + void history.undo(); + void history.undo(); + void history.redo(); + void history.redo(); undosStream.unsubscribe(); redosStream.unsubscribe(); @@ -372,32 +383,32 @@ describe("using a tree-based history", () => { }); test("redo does nothing", () => { - history.redo(); + void history.redo(); expect(history.undoableNodes).toHaveLength(1); expect(history.undoableNodes[0]).toBeDefined(); expect(undoable0.redo).not.toHaveBeenCalledTimes(1); }); test("get last redoable when one element and has a redo", () => { - history.undo(); + void history.undo(); expect(history.getLastRedo()).toBe(undoable0); }); test("get last redoable message when one element and ahs a redo", () => { undoable0.getUndoName.mockReturnValue("fooo"); - history.undo(); + void history.undo(); expect(history.getLastRedoMessage()).toBe("fooo"); }); test("get last redoable message or empty when one element and ahs a redo", () => { undoable0.getUndoName.mockReturnValue("barr"); - history.undo(); + void history.undo(); expect(history.getLastOrEmptyRedoMessage()).toBe("barr"); }); test("history redo works", () => { - history.undo(); - history.redo(); + void history.undo(); + void history.redo(); expect(history.undoableNodes).toHaveLength(1); expect(history.undoableNodes[0]).toBeDefined(); expect(history.undoableNodes[0]?.undoable).toBe(undoable0); @@ -406,7 +417,7 @@ describe("using a tree-based history", () => { }); test("history new command, creates a branch", () => { - history.undo(); + void history.undo(); history.add(undoable1); expect(history.undoableNodes).toHaveLength(2); expect(history.undoableNodes[0]?.undoable).toBe(undoable0); @@ -443,7 +454,7 @@ describe("using a tree-based history", () => { }); test("go to undoable1 from root", () => { - history.undo(); + void history.undo(); history.goTo(0); expect(history.currentNode.undoable).toBe(undoable0); @@ -468,7 +479,7 @@ describe("using a tree-based history", () => { }); test("go to undoable1 from undoable2", () => { - history.undo(); + void history.undo(); history.add(undoable1); history.goTo(0); @@ -631,10 +642,10 @@ describe("using a tree-based history", () => { // 3 4 history.add(undoable0); history.add(undoable1); - history.undo(); + void history.undo(); history.add(undoable2); history.add(undoable3); - history.undo(); + void history.undo(); history.add(undoable4); }); @@ -743,7 +754,7 @@ describe("using a tree-based history", () => { test("get last redoable when moving to 4 and history", () => { history.goTo(4); - history.undo(); + void history.undo(); expect(history.getLastRedo()).toBe(undoable4); }); @@ -761,7 +772,7 @@ describe("using a tree-based history", () => { test("get last redoable when moving to 4 and history and delete 4", () => { history.goTo(4); - history.undo(); + void history.undo(); history.deleteFrom(4); expect(history.getLastRedo()).toBe(undoable3); expect(history.size()).toBe(4); @@ -770,14 +781,14 @@ describe("using a tree-based history", () => { test("get last redoable message when moving to 2 and history", () => { undoable2.getUndoName.mockReturnValue("fooo2"); history.goTo(2); - history.undo(); + void history.undo(); expect(history.getLastRedoMessage()).toBe("fooo2"); }); test("get last redoable or empty message when moving to 3 and history", () => { undoable3.getUndoName = (): string => "fooo4"; history.goTo(3); - history.undo(); + void history.undo(); expect(history.getLastRedoMessage()).toBe("fooo4"); }); @@ -905,28 +916,28 @@ describe("using a tree-based history", () => { history.add(undoable1); history.add(undoable2); history.add(undoable3); - history.undo(); + void history.undo(); history.add(undoable4); - history.undo(); - history.undo(); + void history.undo(); + void history.undo(); history.add(undoable5); - history.undo(); + void history.undo(); history.add(undoable6); history.add(undoable7); - history.undo(); - history.undo(); - history.undo(); + void history.undo(); + void history.undo(); + void history.undo(); history.add(undoable8); history.add(undoable9); - history.undo(); + void history.undo(); history.add(undoable10); - history.undo(); + void history.undo(); history.add(undoable11); - history.undo(); + void history.undo(); history.add(undoable12); history.goTo(5); history.add(undoable13); - history.undo(); + void history.undo(); history.add(undoable14); }); @@ -1042,12 +1053,12 @@ describe("using a tree-based history", () => { test("does not create a new branch", () => { history.add(undoableC); history.add(undoableD); - history.undo(); - history.undo(); + void history.undo(); + void history.undo(); history.add(undoableF); // A *B - C D // \ F - history.undo(); + void history.undo(); undoableC.equals = jest.fn(() => true); history.add(undoableE); @@ -1058,13 +1069,13 @@ describe("using a tree-based history", () => { test("does not create a new branch, other config", () => { history.add(undoableF); - history.undo(); + void history.undo(); history.add(undoableC); history.add(undoableD); // A *B - F // \ C D - history.undo(); - history.undo(); + void history.undo(); + void history.undo(); undoableC.equals = jest.fn(() => true); history.add(undoableE); @@ -1140,7 +1151,7 @@ describe("using a tree-based history", () => { test("creates a branch with correct undoable objects", () => { history.add(new CmdModifiableDouble()); - history.undo(); + void history.undo(); history.add(new CmdModifiableDouble3()); // A - B - C|D* history.deleteNode(1); @@ -1266,7 +1277,7 @@ describe("using a tree-based history", () => { test("can modify the lastest modifiable command and re-execute the tree undoables that follow", () => { history.add(new StubUndoableCmd()); - history.undo(); + void history.undo(); history.add(new CmdModifiableDouble()); // A - B - C // \ D* From 245befeaf5e427e167b41bc0cf0fef5c04d2054a Mon Sep 17 00:00:00 2001 From: arnobl <1052622+arnobl@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:36:40 +0200 Subject: [PATCH 2/4] feat(command): memento decorator to reduce boilerplate code --- src/api/command/Memento.ts | 5 ++- test/command/memento.test.ts | 60 ++++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/api/command/Memento.ts b/src/api/command/Memento.ts index 5a601ed7..5b84778f 100644 --- a/src/api/command/Memento.ts +++ b/src/api/command/Memento.ts @@ -38,9 +38,8 @@ export function Memento(target: unknown, propertyName: string): void { const symbolValues: unknown = (target.constructor as MementoMetadata)[INTERACTO_MEMENTO]; const map = symbolValues instanceof Map ? symbolValues as Map : new Map(); - // Adding the property in this cache - const tpropertyName = propertyName as keyof CommandBase; - map.set(propertyName, target[tpropertyName]); + // Adding the property in this cache (setting its value to undefined for the moment) + map.set(propertyName, undefined); (target.constructor as MementoMetadata)[INTERACTO_MEMENTO] = map; } diff --git a/test/command/memento.test.ts b/test/command/memento.test.ts index c236bc8c..1db65239 100644 --- a/test/command/memento.test.ts +++ b/test/command/memento.test.ts @@ -14,7 +14,11 @@ import {ExampleUndoableCmd} from "./StubCmd"; import {Memento} from "../../src/interacto"; -import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; +import {beforeEach, describe, expect, test} from "@jest/globals"; + +interface Data { + text: string; +} class MementoCmd1 extends ExampleUndoableCmd { @Memento @@ -38,17 +42,40 @@ class MementoCmd1 extends ExampleUndoableCmd { } } -describe("using a command have memento decorators", () => { +class MementoCmd2 extends ExampleUndoableCmd { + public data: Data; + + public newTxt: string; + + public constructor(data: Data, newTxt: string) { + super(); + this.data = data; + this.newTxt = newTxt; + console.log("1data value", this.data); + } + + @Memento + public get txt(): string { + console.log("data value", this.data); + return this.data.text; + } + + public set txt(value: string) { + this.data.text = value; + } + + public override execution(): void { + this.txt = this.newTxt; + } +} + +describe("using a command that has memento decorators", () => { let cmd1: MementoCmd1; beforeEach(() => { cmd1 = new MementoCmd1(1, 2); }); - afterEach(() => { - jest.clearAllMocks(); - }); - test("command execution works as expected", () => { void cmd1.execute(); expect(cmd1.x).toBe(10); @@ -83,3 +110,24 @@ describe("using a command have memento decorators", () => { expect(cmd1.newX).toBe(0); }); }); + +describe("using a command that has a memento decorator on a getter", () => { + let cmd1: MementoCmd2; + + beforeEach(() => { + cmd1 = new MementoCmd2({ + text: "foo" + }, "bar"); + }); + + test("command execution works as expected", () => { + void cmd1.execute(); + expect(cmd1.data.text).toBe("bar"); + }); + + test("command undo uses automated memento to restore values", () => { + void cmd1.execute(); + void cmd1.undo(); + expect(cmd1.data.text).toBe("foo"); + }); +}); From a58561ac02bf11ac2069ffbbde20f0942b587961 Mon Sep 17 00:00:00 2001 From: arnobl <1052622+arnobl@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:38:19 +0200 Subject: [PATCH 3/4] feat(command): memento decorator to reduce boilerplate code --- test/command/memento.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/command/memento.test.ts b/test/command/memento.test.ts index 1db65239..22700769 100644 --- a/test/command/memento.test.ts +++ b/test/command/memento.test.ts @@ -51,12 +51,10 @@ class MementoCmd2 extends ExampleUndoableCmd { super(); this.data = data; this.newTxt = newTxt; - console.log("1data value", this.data); } @Memento public get txt(): string { - console.log("data value", this.data); return this.data.text; } From 3c59e8a709e91c4383997f2807bc01f7af7be5d1 Mon Sep 17 00:00:00 2001 From: arnobl <1052622+arnobl@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:02:47 +0200 Subject: [PATCH 4/4] feat(command): memento decorator to reduce boilerplate code --- src/api/command/Memento.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/api/command/Memento.ts b/src/api/command/Memento.ts index 5b84778f..cb9edfc4 100644 --- a/src/api/command/Memento.ts +++ b/src/api/command/Memento.ts @@ -12,8 +12,6 @@ * along with Interacto. If not, see . */ -import {CommandBase} from "../../impl/command/CommandBase"; - const INTERACTO_MEMENTO: unique symbol = Symbol("interacto-cmd-memento"); interface MementoMetadata { @@ -28,9 +26,9 @@ interface MementoMetadata { */ // eslint-disable-next-line @typescript-eslint/naming-convention export function Memento(target: unknown, propertyName: string): void { - if (!(target instanceof CommandBase)) { + if (!(target instanceof Object)) { // eslint-disable-next-line no-console - console.error("The @Memento decorator currently operates on Interacto commands only"); + console.error("The @Memento decorator currently operates on objects only"); return; }