C# Undo/Redo System

close upC# Undo/Redo Systemclose up

Posted: February 18, 2025

Last Updated: February 18, 2025

This page goes over my method for implementing a C# undo/redo system using a state-based approach.

I decided to google “C# undo redo system” to get an idea of whether what I cooked up was in any way like other implementations that are out there. We all use Stacks to accomplish undo/redo functionality. That’s pretty much where the similarities end. All the other approaches I saw essentially store undo-able actions on the stack whereas I store states. They require that their actions (represented by a class) implement some interface specifying undo/redo functionality whereas I require that my undo-able objects implement some state saving/restoring logic.

Pros (state vs. action):

  • Control. I can better choose when to store things on the stack. With the action-storing method, it is more difficult to associate multiple actions to a single undo instance, whereas the state-based undo-redo allows me to make whatever changes I want and then push that interaction onto the stack, lumping all those changes together.
  • Context. My method allows multiple nested savable types to be easily grouped into a single interaction.
  • Existing Serialization. It is likely to work very easily if your undo-able object already has support for serialization.
  • Condensing States. Because my approach attempts to store states, then if we attempt to push a change that doesn’t affect the state (for example, adding 0 to a state value), then we can check if the new state and the old state are the same and refuse to push the new state if it is the same. This helps cut to the chase with our undo/redo.

Cons (state vs. action):

  • Heavyweight/Wasteful. Since I store the state of an object on the stack rather than the action that changed the object, there tends to be a lot of redundant information that is stored along with the part that changed.
  • Type Limitations. The current state-based implementation is currently limited by saving state as “object” or “string” types which could be limiting for some purposes (well, object should be good enough, but still).
  • Difficulty Defining State. State saving/restoring could become difficult for some objects.
  • Probably more

Neutral:

  • Similar boilerplate (in my opinion). I don’t have to make a new ICommand for every kind of change I want to make, but if I want to save data directly, I do have to implement an IChangeStackDataObject-derived data class.

At the end of the day, though, I think these two approaches are each good for different kinds of things. I just happen to like my approach because my main concern has been to group changes on a single object together through lots of different data changes which would be a little confusing to directly reverse. I will probably consider the action-based approach in the future, though. It’s much more lightweight and probably easier to apply across multiple different objects.

SECTIONS:

CODE:

IChangeStackSavable:

using System.Collections.Generic;

namespace Orcsune.Core.Builder {
    /// <summary>
    /// Defines an object that can be saved and restored
    /// from the ChangeStack. Defines multiple ways to
    /// save the object state.
    /// </summary>
    public interface IChangeStackSavable {
        string ToStringState();
        IChangeStackDataObject ToDataState();
        void FromSaveState(SaveState state);
        List<IChangeStackSavable> GatherSubsavables();
    }
    
    /// <summary>
    /// Extensions for invoking an IChangeStackSavable and
    /// getting it into a SaveState object to place on
    /// a ChangeStack.
    /// </summary>
    public static class IChangeStackSavableExtensions {
        public static SaveState ToPersonalSaveState(this IChangeStackSavable savable) {
            return new SaveState(
                savable.ToStringState(),
                savable.ToDataState()
            );
        }
        public static SaveState ToSaveStateDefault(this IChangeStackSavable savable) {
            // Get my personal save data
            SaveState saveState = savable.ToPersonalSaveState();
            foreach (IChangeStackSavable subsavable in savable.GatherSubsavables()){
                // saveState.Incorporate(subsavable.ToSaveStateDefault());
                saveState.Incorporate(subsavable.ToSaveStateDefault());
            }
            return saveState;
        }
    }
}

An interface defining objects that can be saved directly on the undo/redo stack. All classes or structs that implement this should be able to condense their needed state using either or both of ToStringState and ToDataState, and should be able to rebuild their needed state with an input SaveState.

  • ToStringState: Converts some or all data on the object into a savable state as a string.
  • ToDataState: Converts some or all data on the object into a savable state as a IChangeStackDataObject.
  • FromSaveState: Alters the object’s state to match that provided by SaveState.
  • GatherSubsavables: Collects all IChangeStackSavable under this one that should be packaged as part of the same state.

I have also defined a couple of extension methods for IChangeStackSavables that provide a default way of packing up IChangeStackSavables into SaveStates. Calling ToSaveStateDefault eventually invokes ToStringState, ToDataState, and GatherSubsavables.

 

IChangeStackDataObject:

using System.Collections.Generic;

namespace Orcsune.Core.Builder {
    public interface IChangeStackDataObject {
        bool DataEquals(object other);
    }

    public class IChangeStackDataObjectEqualityComparer : IEqualityComparer<IChangeStackDataObject>
    {
        public bool Equals(IChangeStackDataObject x, IChangeStackDataObject y)
        {
            if (x == null && y == null) { return true; }
            else if (x == null || y == null) { return false; }
            return x.DataEquals(y);
        }

        public int GetHashCode(IChangeStackDataObject obj)
        {
            throw new System.NotImplementedException();
        }
    }
}
An interface defining just the output of a ToDataState call from an IChangeStackSavable. Because I don’t want to impose too many restrictions on what types are savable, I opted to have this “general-purpose” object that enforced some kind of equality comparison for non-serialized data states. The IChangeStackDataObjectEqualityComparer is just an IEqualityComparer that attempts to invoke the DataEquals call of IChangeStackDataObjects.
  • DataEquals: A method that should compare the equality of two non-null IChangeStackDataObjects.

SaveState:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Orcsune.Core.Extensions;

namespace Orcsune.Core.Builder {

    /// <summary>
    /// Represents a single state in the ChangeStack that can
    /// be compared against other SaveStates for equality
    /// and created from and deserialize to a variety of data inputs.
    /// </summary>
    [System.Serializable]
    public class SaveState : IEquatable<SaveState> {
        public List<string> serializedStates;
        public List<IChangeStackDataObject> dataStates;
        public List<SaveState> subStates;

        public SaveState() {
            serializedStates = new List<string>();
            dataStates = new List<IChangeStackDataObject>();
            subStates = new List<SaveState>();
        }
        public SaveState(string stringState, IChangeStackDataObject dataState) {
            serializedStates = new List<string>() {stringState};
            dataStates = new List<IChangeStackDataObject>() {dataState};
            subStates = new List<SaveState>();
        }

        public bool Equals(SaveState other)
        {
            bool temp = serializedStates.SequenceEqual(other.serializedStates) &&
                    dataStates.SequenceEqual(other.dataStates, new IChangeStackDataObjectEqualityComparer()) &&
                    subStates.SequenceEqual(other.subStates);
            return temp;
        }

        public SaveState Incorporate(SaveState otherState) {
            subStates.Add(otherState);
            return this;
        }
    }
}

SaveState just represents a single element on the ChangeStack. It is the grouping of a single IChangeStackSavable object’s saved state as well as the SaveStates of all IChangeStackSavables collected during GatherSubsavables. The SaveState is made up of 3 lists: the IChangeStackSavable‘s string data outputs, it’s IChangeStackDataObject data outputs, and the SaveStates of all subsavables. Equality against other SaveStates is checked by comparing the lists. Note how the comparison between IChangeStackDataObjects uses the IChangeStackDataObjectEqualityComparer.

ChangeStack:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Orcsune.Core.Extensions;

namespace Orcsune.Core.Builder {
    public class ChangeStack : IEnumerable {
        private Stack<SaveState> undoStack;
        private Stack<SaveState> redoStack;

        public SaveState top {
            get => undoStack == null || undoStack.Count == 0 ? default : undoStack.Peek();
        }

        public ChangeStack() {
            undoStack = new Stack<SaveState>();
            redoStack = new Stack<SaveState>();
        }
        public ChangeStack(Stack<SaveState> undo, Stack<SaveState> redo) {
            undoStack = undo.CopyStack<SaveState>();
            redoStack = redo.CopyStack<SaveState>();
        }

        public ChangeStack Copy() {
            return new ChangeStack(undoStack, redoStack);
        }

        public int undoStackCount { get => undoStack.Count; }
        public int redoStackCount { get => redoStack.Count; }

        public void Clear() {
            undoStack.Clear();
            redoStack.Clear();
        }

        public void DeleteTop() {
            undoStack.TryPop(out SaveState state);
        }

        public SaveState Top() {
            if (undoStack.Count > 0) { return undoStack.Peek(); }
            return null;
        }

        /// <summary>
        /// Same functionality as TryPushState except the redoStack
        /// is cleared if the new state is successfully placed on the
        /// undoStack.
        /// </summary>
        /// <param name="savable">Object to try and save.</param>
        /// <returns></returns>
        public bool PushNewInteraction(IChangeStackSavable savable) {
            bool success = TryPushUndoState(savable);
            if (success) {
                redoStack.Clear();
            }
            return success;
        }
        /// <summary>
        /// Adds a different savable to the ChangeStack.
        /// If the new save state is the same as the previous save state,
        /// then the new state is not saved and we return false.
        /// If the new save state is different from the previous state,
        /// then the new state is saved and we return true.
        /// </summary>
        /// <param name="savable">The object to try and save.</param>
        /// <returns>Bool describing whether a new state was added to the stack.</returns>
        public bool TryPushUndoState(IChangeStackSavable savable) {
            SaveState saveState = savable.ToSaveStateDefault();
            return TryPushUndoState(saveState);
        }
        public bool TryPushUndoState(SaveState saveState) {
            if (undoStack.Count > 0 && saveState.Equals(undoStack.Peek())) {
                return false;
            } else {
                undoStack.Push(saveState);
                return true;
            }
        }

        public bool ApplyTopUndo(IChangeStackSavable savable) {
            // Do nothing if nothing in undoStack
            if (undoStack.Count == 0) {
                return false;
            }
            // ONLY peek if only 1 thing left in stack
            // DO NOT place the undone thing on the redoStack.
            else if (undoStack.Count >= 1) {
                SaveState readState = undoStack.Peek();
                savable.FromSaveState(readState);
            }
            return true;
        }
        /// <summary>
        /// undoStack contains up to the CURRENT version of the
        /// savable. To undo, we must pop of that current version
        /// and deserialize from the new top of the stack.
        /// Make sure to pop the old top onto the redo stack.
        /// </summary>
        /// <param name="savable">Savable to modify to the old save state.</param>
        /// <returns></returns>
        public bool Undo(IChangeStackSavable savable) {
            // Do nothing if nothing in undoStack
            if (undoStack.Count == 0) {
                return false;
            }
            // ONLY peek if only 1 thing left in stack
            // DO NOT place the undone thing on the redoStack.
            else if (undoStack.Count == 1) {
                SaveState readState = undoStack.Peek();
                savable.FromSaveState(readState);
            }
            // Otherwise, do a normal undo:
            // pop, peek, change savable, add to redo stack
            else {
                SaveState oldTop = undoStack.Pop();
                SaveState readState = undoStack.Peek();
                savable.FromSaveState(readState);
                redoStack.Push(oldTop);
            }
            return true;
        }
        /// <summary>
        /// Redoes a previously undone action.
        /// </summary>
        /// <param name="savable">Savable to modify to the old save state.</param>
        /// <returns></returns>
        public bool Redo(IChangeStackSavable savable) {
            if (redoStack.Count == 0) {
                return false;
            }
            else {
                SaveState readState = redoStack.Pop();
                savable.FromSaveState(readState);
                // Can place directly on undo stack again
                undoStack.Push(readState);
            }
            return true;
        }

        public IEnumerator GetEnumerator()
        {
            return undoStack.GetEnumerator();
        }
    }
}

The ChangeStack is a fairly typical 2-stack setup with one to track undos and another to track redos. Most of the logic concerns the undo functionality. Because we store states instead of actions, there is a little funkiness because we should never completely empty out the undoStack. In other words, the first element in the undoStack should just be the originally recorded state of the object. The main entrypoint methods are PushNewInteraction, Undo, and Redo.

EXAMPLES:

Let’s start with some object called TestObject whose states we want to be able to undo and redo.

TestObject:

using Orcsune.Core.Builder;

namespace Orcsune.Core.Test {
    public class TestObject : IChangeStackSavable, IUndoRedo
    {
        // A data object representing some values in the TestObject
        // This could have just been serialized to a string, but
        // I wanted to give an example of both ToDataState and ToStringState.
        private class StackSavable : IChangeStackDataObject
        {
            public int intVal;
            public float floatVal;
            public int[]? numbers;
            public StackSavable(TestObject input) {
                intVal = input.myInt;
                floatVal = input.myFloat;
                if (input.luckyNumbers == null) {
                    numbers = null;
                } else {
                    numbers = new int[input.luckyNumbers.Length];
                    Array.Copy(input.luckyNumbers, numbers, input.luckyNumbers.Length);
                }
            }
            // IChangeStackDataObject method
            public bool DataEquals(object other)
            {
                if (other == null) { return false; }
                StackSavable casted = other as StackSavable;
                if (casted != null) {
                    return  intVal == casted.intVal &&
                            floatVal == casted.floatVal &&
                            ((numbers == null && casted.numbers == null) ||
                            (numbers!=null && casted.numbers!=null && numbers.SequenceEqual(casted.numbers)));
                }
                return false;
            }
        }

        private int myInt;
        private float myFloat;
        private string myString;
        private int[]? luckyNumbers;

        public int intVal { get => myInt; set => myInt = value; }
        public float floatVal { get => myFloat; set => myFloat = value; }
        public string stringVal { get => myString; set => myString = value; }

        private Random random;

        public TestObject(int i, float f, string s, params int[] lucky) {
            myInt = i;
            myFloat = f;
            myString = s;
            luckyNumbers = lucky == null ? Array.Empty<int>() : lucky;
            random = new Random();
            PushInteraction();
        }

        ChangeStack changeStack = new ChangeStack();

        public override string ToString()
        {
            return $"TestObject({intVal}, {floatVal}, {stringVal}, [{String.Join(',',luckyNumbers)}])";
        }

        public int GetLuckyNumber() {
            return luckyNumbers == null ? -1 : luckyNumbers[random.NextInt64(luckyNumbers.Length)];
        }
        
        // IChangeStackSavable method
        public IChangeStackDataObject ToDataState()
        {
            return new StackSavable(this);
        }
        // IChangeStackSavable method
        public string ToStringState()
        {
            return myString;
        }
        // IChangeStackSavable method
        public void FromSaveState(SaveState state)
        {
            myString = state.serializedStates[0];
            StackSavable data = (StackSavable)state.dataStates[0];
            myInt = data.intVal;
            myFloat = data.floatVal;
            if (data.numbers == null) {
                luckyNumbers = null;
            } else {
                luckyNumbers = new int[data.numbers.Length];
                Array.Copy(data.numbers, luckyNumbers, data.numbers.Length);
            }
        }
        // IChangeStackSavable method
        public List<IChangeStackSavable> GatherSubsavables()
        {
            return new List<IChangeStackSavable>();
        }
        
        // IUndoRedo methods
        public void PushInteraction() => changeStack.PushNewInteraction(this);
        public void RedoInteraction() => changeStack.Redo(this);
        public void UndoInteraction() => changeStack.Undo(this);
    }
}

TestObject contains a few data fields for an int, float, and string as well as an array of ints called ‘luckyNumbers’. The implementation of ToStringState simply returns the value of the string field. ToDataState returns a TestObject.StackSavable object which implements IChangeStackDataObject whose DataEquals method compares the data values in each object to check if they are equal. It was certainly possible (and likely easier) to just serialize ALL the data into a string and return it in ToStringState, but I wanted to give an example of ToDataState. FromSaveState just takes in a SaveState and unpacks the data values from it.

TestObjectCollection:

using Orcsune.Core.Builder;

namespace Orcsune.Core.Test {
    public class TestObjectCollection : IChangeStackSavable, IUndoRedo
    {
        List<TestObject> testObjects;
        ChangeStack changeStack;

        public TestObjectCollection() {
            testObjects = new List<TestObject>();
            changeStack = new ChangeStack();
            PushInteraction();
        }
        public override string ToString()
        {
            return $"TestObjectCollection([\n{String.Join('\n',testObjects)}\n])";
        }

        // Note how this includes a call to PushInteraction
        // to interface with undo/redo immediately
        public void AddTestObject(TestObject newObject) {
            testObjects.Add(newObject);
            PushInteraction();
        }
        public void RemoveTestObject(TestObject newObject) {
            testObjects.Remove(newObject);
            PushInteraction();
        }

        // IChangeStackSavable
        public void FromSaveState(SaveState state)
        {
            // Sync number of TestObjects
            int desiredAmount = state.subStates.Count;
            while (testObjects.Count > desiredAmount) {
                testObjects.RemoveAt(testObjects.Count-1);
            }
            while (testObjects.Count < desiredAmount) {
                testObjects.Add(new TestObject(0,0,""));
            }
            // Sync their states
            for (int i = 0; i < state.subStates.Count; i++) {
                testObjects[i].FromSaveState(state.subStates[i]);
            }
        }
        public List<IChangeStackSavable> GatherSubsavables() => testObjects.Cast<IChangeStackSavable>().ToList();
        public IChangeStackDataObject ToDataState() => null;
        public string ToStringState() => null;

        // IUndoRedo
        public void PushInteraction() => changeStack.PushNewInteraction(this);
        public void RedoInteraction() => changeStack.Redo(this);
        public void UndoInteraction() => changeStack.Undo(this);
    }
}

TestObjectCollection begins to show the potential benefits of this approach. We have some collection of TestObjects and are able to reuse the IChangeStackSavable logic on them and incorporate their own states into the state of TestObjectCollection. If the collection itself were to change or one of the TestObject elements were to change, this could be reversed by an undo action.

Tests:

TestRandomInts
void TestRandomInts() {
    Console.WriteLine("TestRandomInts ------------------------------------");
    TestObject to = new TestObject(1, 2.3f, "four", 5, 6, 7, 42);
    int numVals = 4;
    int initVal = to.intVal;
    int[] randomRetrieved = new int[numVals];
    Console.WriteLine($"Initial TestObject: {to}");
    for (int i = 0; i < numVals; i++) {
        // Get a random lucky number, track it for test
        randomRetrieved[i] = to.GetLuckyNumber();
        if (to.intVal == randomRetrieved[i]) { randomRetrieved[i] += 1; }
        to.intVal = randomRetrieved[i];
        to.PushInteraction();
        Console.WriteLine($"Now: {to}");
    }
    Console.WriteLine($"TestObject After Modifications: {to}");
    Console.WriteLine($"Undoing Modifications");
    for (int i = numVals-1; i >= 0; i--) {
        System.Diagnostics.Debug.Assert(randomRetrieved[i] == to.intVal, $"Retrieved lucky number {randomRetrieved[i]} (idx={i}) does not match object intVal {to.intVal}");
        to.UndoInteraction();
        Console.WriteLine($"Now: {to}");
    }
    Console.WriteLine($"Final TestObject: {to}");
    System.Diagnostics.Debug.Assert(initVal == to.intVal, $"Initial intVal {initVal} does not match object intVal {to.intVal}");
    Console.WriteLine("------------------------------------");
}

/* Potential Output
TestRandomInts ------------------------------------
Initial TestObject: TestObject(1, 2.3, four, [5,6,7,42])
Now: TestObject(42, 2.3, four, [5,6,7,42])
Now: TestObject(5, 2.3, four, [5,6,7,42])
Now: TestObject(7, 2.3, four, [5,6,7,42])
Now: TestObject(5, 2.3, four, [5,6,7,42])
TestObject After Modifications: TestObject(5, 2.3, four, [5,6,7,42])
Undoing Modifications
Now: TestObject(7, 2.3, four, [5,6,7,42])
Now: TestObject(5, 2.3, four, [5,6,7,42])
Now: TestObject(42, 2.3, four, [5,6,7,42])
Now: TestObject(1, 2.3, four, [5,6,7,42])
Final TestObject: TestObject(1, 2.3, four, [5,6,7,42])
------------------------------------
*/
void TestStrings() {
    Console.WriteLine("TestStrings ------------------------------------");
    TestObject to = new TestObject(1, 2.3f, "four", 5, 6, 7, 42);
    string initVal = to.stringVal;
    string testString = "this is a test";
    Console.WriteLine($"Initial TestObject: {to}");
    to.stringVal = testString;
    to.PushInteraction();
    Console.WriteLine($"TestObject After Modifications: {to}");
    Console.WriteLine($"Undoing Modifications");
    System.Diagnostics.Debug.Assert(testString == to.stringVal, $"Test stringVal {testString} does not match object stringVal {to.stringVal}");
    to.UndoInteraction();
    Console.WriteLine($"Final TestObject: {to}");
    System.Diagnostics.Debug.Assert(initVal == to.stringVal, $"Initial stringVal {initVal} does not match object stringVal {to.stringVal}");
    Console.WriteLine("------------------------------------");
}

/* Potential Output
TestStrings ------------------------------------
Initial TestObject: TestObject(1, 2.3, four, [5,6,7,42])
TestObject After Modifications: TestObject(1, 2.3, this is a test, [5,6,7,42])
Undoing Modifications
Final TestObject: TestObject(1, 2.3, four, [5,6,7,42])
------------------------------------
*/
void TestCollection() {
    Console.WriteLine("TestCollection ------------------------------------");
    TestObject t1 = new TestObject(0, 0.5f, "zero", 1,2,3,4,5);
    TestObject t2 = new TestObject(1, 1.5f, "one", 1,2,3,4,5);
    TestObject t3 = new TestObject(2, 2.5f, "two", 1,2,3,4,5);
    TestObject t4 = new TestObject(3, 3.5f, "three", 1,2,3,4,5);
    TestObjectCollection tc = new TestObjectCollection();
    Console.WriteLine($"Initial TestObjectCollection: {tc}");
    Console.WriteLine($"Appending new TestObjects");
    tc.AddTestObject(t1);
    tc.AddTestObject(t2);
    tc.AddTestObject(t3);
    tc.AddTestObject(t4);
    Console.WriteLine($"TestObjectCollection Now: {tc}");
    Console.WriteLine($"Undoing Modifications");
    tc.UndoInteraction();
    Console.WriteLine($"TestObjectCollection Now: {tc}");
    tc.UndoInteraction();
    Console.WriteLine($"TestObjectCollection Now: {tc}");
    tc.UndoInteraction();
    Console.WriteLine($"TestObjectCollection Now: {tc}");
    tc.UndoInteraction();
    Console.WriteLine($"TestObjectCollection Now: {tc}");
    Console.WriteLine($"Redoing Modifications");
    tc.RedoInteraction();
    Console.WriteLine($"TestObjectCollection Now: {tc}");
    tc.RedoInteraction();
    Console.WriteLine($"TestObjectCollection Now: {tc}");
    tc.RedoInteraction();
    Console.WriteLine($"TestObjectCollection Now: {tc}");
    tc.RedoInteraction();
    Console.WriteLine($"TestObjectCollection Now: {tc}");
    Console.WriteLine("------------------------------------");
}

/* Potential Output
TestCollection ------------------------------------
Initial TestObjectCollection: TestObjectCollection([

])
Appending new TestObjects
TestObjectCollection Now: TestObjectCollection([
        TestObject(0, 0.5, zero, [1,2,3,4,5])
        TestObject(1, 1.5, one, [1,2,3,4,5])
        TestObject(2, 2.5, two, [1,2,3,4,5])
        TestObject(3, 3.5, three, [1,2,3,4,5])
])
Undoing Modifications
TestObjectCollection Now: TestObjectCollection([
        TestObject(0, 0.5, zero, [1,2,3,4,5])
        TestObject(1, 1.5, one, [1,2,3,4,5])
        TestObject(2, 2.5, two, [1,2,3,4,5])
])
TestObjectCollection Now: TestObjectCollection([
        TestObject(0, 0.5, zero, [1,2,3,4,5])
        TestObject(1, 1.5, one, [1,2,3,4,5])
])
TestObjectCollection Now: TestObjectCollection([
        TestObject(0, 0.5, zero, [1,2,3,4,5])
])
TestObjectCollection Now: TestObjectCollection([

])
Redoing Modifications
TestObjectCollection Now: TestObjectCollection([
        TestObject(0, 0.5, zero, [1,2,3,4,5])
])
TestObjectCollection Now: TestObjectCollection([
        TestObject(0, 0.5, zero, [1,2,3,4,5])
        TestObject(1, 1.5, one, [1,2,3,4,5])
])
TestObjectCollection Now: TestObjectCollection([
        TestObject(0, 0.5, zero, [1,2,3,4,5])
        TestObject(1, 1.5, one, [1,2,3,4,5])
        TestObject(2, 2.5, two, [1,2,3,4,5])
])
TestObjectCollection Now: TestObjectCollection([
        TestObject(0, 0.5, zero, [1,2,3,4,5])
        TestObject(1, 1.5, one, [1,2,3,4,5])
        TestObject(2, 2.5, two, [1,2,3,4,5])
        TestObject(3, 3.5, three, [1,2,3,4,5])
])
------------------------------------
*/
void TestCollectionChangeElement() {
    Console.WriteLine("TestCollectionChangeElement ------------------------------------");
    TestObject t1 = new TestObject(0, 0.5f, "zero", 1,2,3,4,5);
    TestObject t2 = new TestObject(1, 1.5f, "one", 1,2,3,4,5);
    TestObject t3 = new TestObject(2, 2.5f, "two", 1,2,3,4,5);
    TestObject t4 = new TestObject(3, 3.5f, "three", 1,2,3,4,5);
    TestObjectCollection tc = new TestObjectCollection();
    Console.WriteLine($"Initial TestObjectCollection: {tc}");
    Console.WriteLine($"Appending new TestObjects");
    tc.AddTestObject(t1);
    tc.AddTestObject(t2);
    tc.AddTestObject(t3);
    tc.AddTestObject(t4);
    Console.WriteLine($"TestObjectCollection Now: {tc}");

    Console.WriteLine($"Modifying first TestObject");
    t1.intVal = 4321;
    tc.PushInteraction();
    System.Diagnostics.Debug.Assert(4321 == tc.testObjects[0].intVal, $"Collection[0] intVal {tc.testObjects[0].intVal} does not match object value 4321");
    Console.WriteLine($"TestObjectCollection Now: {tc}");
    Console.WriteLine($"Undoing change.");
    tc.UndoInteraction();
    System.Diagnostics.Debug.Assert(0 == tc.testObjects[0].intVal, $"Collection[0] intVal {tc.testObjects[0].intVal} does not match object value 0");
    Console.WriteLine($"TestObjectCollection Now: {tc}");

    Console.WriteLine($"Modifying multiple TestObjects");
    t1.stringVal = "I'm object 1";
    t2.stringVal = "I'm object 2";
    t3.stringVal = "I'm object 3";
    t4.stringVal = "I'm object 4";
    tc.PushInteraction();
    Console.WriteLine($"TestObjectCollection Now: {tc}");
    System.Diagnostics.Debug.Assert(tc.testObjects[0].stringVal == "I'm object 1", $"Collection[0] stringVal {tc.testObjects[0].stringVal} does not match object value 'I'm object 1'");
    System.Diagnostics.Debug.Assert(tc.testObjects[1].stringVal == "I'm object 2", $"Collection[1] stringVal {tc.testObjects[1].stringVal} does not match object value 'I'm object 2'");
    System.Diagnostics.Debug.Assert(tc.testObjects[2].stringVal == "I'm object 3", $"Collection[2] stringVal {tc.testObjects[2].stringVal} does not match object value 'I'm object 3'");
    System.Diagnostics.Debug.Assert(tc.testObjects[3].stringVal == "I'm object 4", $"Collection[3] stringVal {tc.testObjects[3].stringVal} does not match object value 'I'm object 4'");
    Console.WriteLine($"Undoing changes");
    tc.UndoInteraction();
    Console.WriteLine($"TestObjectCollection Now: {tc}");
    System.Diagnostics.Debug.Assert(tc.testObjects[0].stringVal == "zero", $"Collection[0] stringVal {tc.testObjects[0].stringVal} does not match object value 'zero'");
    System.Diagnostics.Debug.Assert(tc.testObjects[1].stringVal == "one", $"Collection[1] stringVal {tc.testObjects[1].stringVal} does not match object value 'one'");
    System.Diagnostics.Debug.Assert(tc.testObjects[2].stringVal == "two", $"Collection[2] stringVal {tc.testObjects[2].stringVal} does not match object value 'two'");
    System.Diagnostics.Debug.Assert(tc.testObjects[3].stringVal == "three", $"Collection[3] stringVal {tc.testObjects[3].stringVal} does not match object value 'three'");
    Console.WriteLine("------------------------------------");
}

/* Program Output
TestCollectionChangeElement ------------------------------------
Initial TestObjectCollection: TestObjectCollection([

])
Appending new TestObjects
TestObjectCollection Now: TestObjectCollection([
        TestObject(0, 0.5, zero, [1,2,3,4,5])
        TestObject(1, 1.5, one, [1,2,3,4,5])
        TestObject(2, 2.5, two, [1,2,3,4,5])
        TestObject(3, 3.5, three, [1,2,3,4,5])
])
Modifying first TestObject
TestObjectCollection Now: TestObjectCollection([
        TestObject(4321, 0.5, zero, [1,2,3,4,5])
        TestObject(1, 1.5, one, [1,2,3,4,5])
        TestObject(2, 2.5, two, [1,2,3,4,5])
        TestObject(3, 3.5, three, [1,2,3,4,5])
])
Undoing change.
TestObjectCollection Now: TestObjectCollection([
        TestObject(0, 0.5, zero, [1,2,3,4,5])
        TestObject(1, 1.5, one, [1,2,3,4,5])
        TestObject(2, 2.5, two, [1,2,3,4,5])
        TestObject(3, 3.5, three, [1,2,3,4,5])
])
Modifying multiple TestObjects
TestObjectCollection Now: TestObjectCollection([
        TestObject(0, 0.5, I'm object 1, [1,2,3,4,5])
        TestObject(1, 1.5, I'm object 2, [1,2,3,4,5])
        TestObject(2, 2.5, I'm object 3, [1,2,3,4,5])
        TestObject(3, 3.5, I'm object 4, [1,2,3,4,5])
])
Undoing changes
TestObjectCollection Now: TestObjectCollection([
        TestObject(0, 0.5, zero, [1,2,3,4,5])
        TestObject(1, 1.5, one, [1,2,3,4,5])
        TestObject(2, 2.5, two, [1,2,3,4,5])
        TestObject(3, 3.5, three, [1,2,3,4,5])
])
------------------------------------
*/

Leave a Reply