Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions UndoRedo.Test/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,94 @@
Assert.AreEqual(-1, newStack.CurrentPosition);
}

private static readonly DateTimeOffset SavedAt = new(2020, 1, 2, 3, 4, 5, TimeSpan.FromHours(10));

[TestMethod]
public async Task JsonSerializer_SerializeDeserialize_PreservesSaveBoundaryTimestamp()
{
// Arrange
JsonUndoRedoSerializer serializer = new();
byte[] data = await serializer.SerializeAsync(
[new TestSerializableCommand("X")],
0,
[new SaveBoundary(0, "Saved", SavedAt)]).ConfigureAwait(false);

Check warning on line 581 in UndoRedo.Test/SerializationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_UndoRedo&issues=AaDmHlskHLoqijiDDWx0&open=AaDmHlskHLoqijiDDWx0&pullRequest=117

// Act
UndoRedoStackState state = await serializer.DeserializeAsync(data).ConfigureAwait(false);

Check warning on line 584 in UndoRedo.Test/SerializationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_UndoRedo&issues=AaDmHlskHLoqijiDDWx1&open=AaDmHlskHLoqijiDDWx1&pullRequest=117

// Assert
Assert.AreEqual(SavedAt, state.SaveBoundaries[0].Timestamp, "The timestamp must be when the save was made, not when it was loaded");
}

[TestMethod]
public async Task UndoRedoService_SaveLoadState_PreservesSaveBoundaryTimestamp()
{
// Arrange
UndoRedoService stack = CreateService();
stack.SetSerializer(new JsonUndoRedoSerializer());
stack.Execute(new TestSerializableCommand("X"));
stack.MarkAsSaved("Saved");
DateTimeOffset savedAt = stack.SaveBoundaries[0].Timestamp;
byte[] data = await stack.SaveStateAsync().ConfigureAwait(false);

Check warning on line 599 in UndoRedo.Test/SerializationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_UndoRedo&issues=AaDmHlskHLoqijiDDWx3&open=AaDmHlskHLoqijiDDWx3&pullRequest=117

// Rewrite the saved timestamp to a fixed past time, so a load that restamps it cannot match by chance
System.Text.Json.Nodes.JsonNode root = System.Text.Json.Nodes.JsonNode.Parse(data)!;
System.Text.Json.Nodes.JsonObject boundary = root["saveBoundaries"]![0]!.AsObject();
Assert.AreEqual(savedAt, boundary["timestamp"]!.GetValue<DateTimeOffset>(), "The save boundary timestamp should be written");
boundary["timestamp"] = SavedAt;
data = System.Text.Encoding.UTF8.GetBytes(root.ToJsonString());

UndoRedoService reloaded = CreateService();
reloaded.SetSerializer(new JsonUndoRedoSerializer());

// Act
bool success = await reloaded.LoadStateAsync(data).ConfigureAwait(false);

Check warning on line 612 in UndoRedo.Test/SerializationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_UndoRedo&issues=AaDmHlskHLoqijiDDWx4&open=AaDmHlskHLoqijiDDWx4&pullRequest=117

// Assert
Assert.IsTrue(success);
Assert.AreEqual(SavedAt, reloaded.SaveBoundaries[0].Timestamp);
Assert.IsFalse(reloaded.HasUnsavedChanges, "The restored boundary should still mark the saved position");
}

[TestMethod]
public async Task JsonSerializer_DeserializeSaveBoundaryWithoutTimestamp_UsesLoadTime()
{
// Arrange: data written before save boundary timestamps were read back may not carry one
JsonUndoRedoSerializer serializer = new();
byte[] data = System.Text.Encoding.UTF8.GetBytes(
"""{"commands":[],"currentPosition":-1,"saveBoundaries":[{"position":-1,"description":"d"}],"formatVersion":"json-v1.0"}""");
DateTimeOffset before = DateTimeOffset.Now;

// Act
UndoRedoStackState state = await serializer.DeserializeAsync(data).ConfigureAwait(false);

Check warning on line 630 in UndoRedo.Test/SerializationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_UndoRedo&issues=AaDmHlskHLoqijiDDWx2&open=AaDmHlskHLoqijiDDWx2&pullRequest=117

// Assert
Assert.AreEqual("d", state.SaveBoundaries[0].Description);
Assert.IsGreaterThanOrEqualTo(before, state.SaveBoundaries[0].Timestamp);
}

[TestMethod]
public void UndoRedoService_RestoreFromState_PreservesSaveBoundaryTimestamp()
{
// Arrange
UndoRedoStackState state = new(
[new TestSerializableCommand("X")],
0,
[new SaveBoundary(0, "Saved", SavedAt)],
"1.0",
DateTime.UtcNow);
UndoRedoService stack = CreateService();

// Act
bool success = stack.RestoreFromState(state);

// Assert
Assert.IsTrue(success);
Assert.AreEqual(SavedAt, stack.SaveBoundaries[0].Timestamp);
Assert.AreEqual("Saved", stack.SaveBoundaries[0].Description);
Assert.IsFalse(stack.HasUnsavedChanges);
}

private const string MalformedAssemblyName = "malformed assembly name";
private const string InvalidVersion = "invalid assembly version";
private const string NotACommand = "serializable type that is not a command";
Expand Down
18 changes: 18 additions & 0 deletions UndoRedo.Test/UndoRedoStackTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,24 @@ public async Task UndoToSaveBoundary_BoundaryHeldAcrossTrim_UndoesToSavedState()
Assert.IsFalse(stack.HasUnsavedChanges, "The stack should be at the save point");
}

[TestMethod]
public void SaveBoundary_StackTrimmed_KeepsTimestamp()
{
// Arrange
UndoRedoService stack = new(new StackManager(), new SaveBoundaryManager(), new CommandMerger(), UndoRedoOptions.Create(maxStackSize: 2));
stack.Execute(new DelegateCommand("A", () => { }, () => { }));
stack.Execute(new DelegateCommand("B", () => { }, () => { }));
stack.MarkAsSaved();
DateTimeOffset savedAt = stack.SaveBoundaries[0].Timestamp;

// Act
stack.Execute(new DelegateCommand("C", () => { }, () => { })); // Trims A, shifting the save point down

// Assert
Assert.AreEqual(0, stack.SaveBoundaries[0].Position);
Assert.AreEqual(savedAt, stack.SaveBoundaries[0].Timestamp, "Trimming must not restamp the save point");
}

[TestMethod]
public async Task UndoToSaveBoundary_BoundaryRemovedByBranching_ReturnsFalse()
{
Expand Down
21 changes: 21 additions & 0 deletions UndoRedo/Models/SaveBoundary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,34 @@

namespace ktsu.UndoRedo;

using System.Text.Json.Serialization;

/// <summary>
/// Represents a save boundary in the undo/redo stack
/// </summary>
/// <param name="position">The position in the stack</param>
/// <param name="description">Optional description</param>
public sealed class SaveBoundary(int position, string? description = null)
{
/// <summary>
/// Recreates a save boundary that was created earlier, keeping when it was created
/// </summary>
/// <param name="position">The position in the stack</param>
/// <param name="description">Optional description</param>
/// <param name="timestamp">
/// When the save boundary was created, or <see langword="default"/> to use the current time, which is
/// also what saved data without a timestamp deserializes to
/// </param>
[JsonConstructor]
public SaveBoundary(int position, string? description, DateTimeOffset timestamp)
: this(position, description)
{
if (timestamp != default)
{
Timestamp = timestamp;
}
}

/// <summary>
/// Creates a copy of <paramref name="original"/> at a new position that is still the same save point,
/// so a caller holding the original can have it resolved to where the save point is now
Expand Down
9 changes: 9 additions & 0 deletions UndoRedo/Services/SaveBoundaryManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ public SaveBoundary CreateSaveBoundary(int position, string? description = null)
return saveBoundary;
}

/// <summary>
/// Adds a save boundary recreated from saved state, keeping the time it was originally created
/// </summary>
internal void RestoreSaveBoundary(SaveBoundary saveBoundary)
{
_saveBoundaries.Add(new SaveBoundary(saveBoundary.Position, saveBoundary.Description, saveBoundary.Timestamp));
_initialStateIsClean = false;
}

/// <inheritdoc />
public int CleanupInvalidBoundaries(int maxValidPosition)
{
Expand Down
13 changes: 11 additions & 2 deletions UndoRedo/Services/UndoRedoService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@

try
{
await _navigationProvider.NavigateToAsync(navigationContext!, cts.Token).ConfigureAwait(false);

Check warning on line 298 in UndoRedo/Services/UndoRedoService.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 298 in UndoRedo/Services/UndoRedoService.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 298 in UndoRedo/Services/UndoRedoService.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 298 in UndoRedo/Services/UndoRedoService.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 298 in UndoRedo/Services/UndoRedoService.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 298 in UndoRedo/Services/UndoRedoService.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 298 in UndoRedo/Services/UndoRedoService.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 298 in UndoRedo/Services/UndoRedoService.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 298 in UndoRedo/Services/UndoRedoService.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 298 in UndoRedo/Services/UndoRedoService.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.
}
#pragma warning disable CA1031 // Do not catch general exception types
catch (Exception)
Expand Down Expand Up @@ -415,10 +415,19 @@
_stackManager.MoveNext();
}

// Recreate save boundaries by creating them at the stored positions
// Recreate save boundaries at the stored positions. The built-in manager keeps each one's
// original timestamp; ISaveBoundaryManager has no member for that, so a custom manager
// creates them afresh.
foreach (SaveBoundary boundary in state.SaveBoundaries)
{
_saveBoundaryManager.CreateSaveBoundary(boundary.Position, boundary.Description);
if (_saveBoundaryManager is SaveBoundaryManager builtInManager)
{
builtInManager.RestoreSaveBoundary(boundary);
}
else
{
_saveBoundaryManager.CreateSaveBoundary(boundary.Position, boundary.Description);
}
}

return true;
Expand Down
15 changes: 10 additions & 5 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -689,15 +689,20 @@ public record ChangeMetadata(
Represents a save point in the command history.

```csharp
public record SaveBoundary(
int Position,
DateTime Timestamp,
string? Description = null);
public sealed class SaveBoundary(int position, string? description = null)
{
// Recreates an earlier save boundary, keeping when it was created
public SaveBoundary(int position, string? description, DateTimeOffset timestamp);

public int Position { get; }
public DateTimeOffset Timestamp { get; }
public string? Description { get; }
}
```

**Properties:**
- `Position`: Position in the command stack
- `Timestamp`: When the save boundary was created
- `Timestamp`: When the save boundary was created. It is kept through a save and load, `RestoreFromState` with the built-in `SaveBoundaryManager`, and stack trimming
- `Description`: Optional description

### ChangeVisualization
Expand Down
6 changes: 3 additions & 3 deletions docs/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ public class BinaryUndoRedoSerializer : IUndoRedoSerializer
foreach (var boundary in saveBoundaries)
{
writer.Write(boundary.Position);
writer.Write(boundary.Timestamp.ToBinary());
writer.Write(boundary.Timestamp.UtcTicks);
writer.Write(boundary.Description ?? string.Empty);
}

Expand Down Expand Up @@ -173,9 +173,9 @@ public class BinaryUndoRedoSerializer : IUndoRedoSerializer
for (int i = 0; i < boundaryCount; i++)
{
int position = reader.ReadInt32();
DateTime boundaryTime = DateTime.FromBinary(reader.ReadInt64());
DateTimeOffset boundaryTime = new(reader.ReadInt64(), TimeSpan.Zero);
string description = reader.ReadString();
boundaries.Add(new SaveBoundary(position, boundaryTime, description));
boundaries.Add(new SaveBoundary(position, description, boundaryTime));
}

return new UndoRedoStackState(commands, currentPosition, boundaries, version, timestamp);
Expand Down
Loading