refactor(desktop): move Document and undo recording into OpenNest.Desktop

Document and Undo/ (ChangeScope, NestFingerprint, NestUndoState) use
only Core, Data, IO and Engine, so they move into the toolkit-neutral
library both desktop front ends share. Namespaces are unchanged, so the
WinForms app compiles as before; it keeps using the internal scope
factories and fingerprint through InternalsVisibleTo.

The scope, fingerprint and SaveAs/save-point rules now have Linux tests
in OpenNest.Desktop.Tests; before, only the Windows-only WinForms suite
covered them.
This commit is contained in:
aj committed 2026-10-29 18:40:49 -04:00
1 parent 0719476618
commit 38de7ab3a3
10 files changed
+202 -2

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
using OpenNest.IO;
using OpenNest.Undo;
namespace OpenNest.Desktop.Tests;
public sealed class DocumentTests : IDisposable
{
private readonly string directory =
Directory.CreateTempSubdirectory("opennest-desktop-document-").FullName;
public void Dispose() => Directory.Delete(directory, recursive: true);
[Fact]
public void SaveAs_WritesAReadableNestNamedAfterTheFile()
{
var document = new Document { Nest = TestNests.OneSquare() };
var path = Path.Combine(directory, "Renamed Job.nest");
document.SaveAs(path);
Assert.Equal("Renamed Job", document.Name);
Assert.Equal(path, document.LastSavePath);
Assert.True(document.HasSavePath);
var read = new NestReader(path).Read();
Assert.Equal("Renamed Job", read.Name);
Assert.Single(read.Plates);
Assert.Single(read.Plates[0].Parts);
}
[Fact]
public void SaveAs_MarksTheCurrentHistoryStateSaved()
{
var document = new Document { Nest = TestNests.OneSquare() };
document.History.Reset(new NestUndoState(NestSnapshot.Capture(document.Nest)));
document.Nest.Plates[0].Parts[0].Offset(1, 0);
document.History.Commit("Move", new NestUndoState(NestSnapshot.Capture(document.Nest)));
Assert.False(document.History.IsAtSavePoint);
document.SaveAs(Path.Combine(directory, "saved.nest"));
Assert.True(document.History.IsAtSavePoint);
}
[Fact]
public void Serialize_RoundTripsThroughTheNestReader()
{
var document = new Document { Nest = TestNests.OneSquare() };
using var stream = new MemoryStream(document.Serialize());
var read = new NestReader(stream).Read();
Assert.Equal(document.Nest.Plates[0].Parts.Count, read.Plates[0].Parts.Count);
}
}
+29
View File
@@ -0,0 +1,29 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Desktop.Tests;
internal static class TestNests
{
/// <summary>A nest with one 48x96 plate holding one 10x10 square part.</summary>
public static Nest OneSquare()
{
var program = new Program();
program.Codes.Add(new RapidMove(new Vector(0, 0)));
program.Codes.Add(new LinearMove(new Vector(10, 0)));
program.Codes.Add(new LinearMove(new Vector(10, 10)));
program.Codes.Add(new LinearMove(new Vector(0, 10)));
program.Codes.Add(new LinearMove(new Vector(0, 0)));
var drawing = new Drawing("square", program);
drawing.Quantity.Required = 1;
var plate = new Plate(new Size(48, 96));
plate.Parts.Add(new Part(drawing));
var nest = new Nest { Name = "test" };
nest.Drawings.Add(drawing);
nest.Plates.Add(plate);
return nest;
}
}
@@ -0,0 +1,75 @@
using OpenNest.Undo;
namespace OpenNest.Desktop.Tests.Undo;
public class ChangeScopeTests
{
[Fact]
public void RootScope_EndsOnceAndCarriesChangesMergedFromNestedScopes()
{
var ends = new List<ChangeScope>();
var root = ChangeScope.Root("Rotate", "key", ends.Add);
using (var nested = ChangeScope.Nested(root))
{
nested.MarkChanged();
nested.Complete();
Assert.Equal("Rotate", nested.Label);
Assert.Null(nested.CoalesceKey);
}
Assert.Empty(ends);
root.Complete();
root.Dispose();
root.Dispose();
var ended = Assert.Single(ends);
Assert.Same(root, ended);
Assert.True(ended.IsChanged);
Assert.True(ended.Succeeded);
Assert.Equal("key", ended.CoalesceKey);
}
[Fact]
public void IncompleteNestedScope_FailsTheWholeEntry()
{
ChangeScope ended = null!;
var root = ChangeScope.Root("Fill", null, scope => ended = scope);
using (ChangeScope.Nested(root))
{
// Left incomplete, as when the nested edit throws or is cancelled.
}
root.MarkChanged();
root.Complete();
root.Dispose();
Assert.True(ended.IsChanged);
Assert.False(ended.Succeeded);
}
[Fact]
public void RootScope_DisposedWithoutComplete_DoesNotSucceed()
{
ChangeScope ended = null!;
using (var root = ChangeScope.Root("Move", null, scope => ended = scope))
root.MarkChanged();
Assert.False(ended.Succeeded);
}
[Fact]
public void DetachedScope_RecordsNothingAndToleratesDispose()
{
var scope = ChangeScope.Detached();
Assert.False(scope.IsChanged);
scope.MarkChanged();
scope.Complete();
scope.Dispose();
Assert.True(scope.IsChanged);
Assert.Null(scope.Label);
}
}
@@ -0,0 +1,36 @@
using OpenNest.Undo;
namespace OpenNest.Desktop.Tests.Undo;
public class NestFingerprintTests
{
[Fact]
public void UnchangedNest_KeepsItsFingerprint()
{
var nest = TestNests.OneSquare();
Assert.Equal(NestFingerprint.Compute(nest), NestFingerprint.Compute(nest));
}
[Fact]
public void MovingAPart_ChangesTheFingerprint()
{
var nest = TestNests.OneSquare();
var before = NestFingerprint.Compute(nest);
nest.Plates[0].Parts[0].Offset(1, 0);
Assert.NotEqual(before, NestFingerprint.Compute(nest));
}
[Fact]
public void ChangingARequiredQuantity_ChangesTheFingerprint()
{
var nest = TestNests.OneSquare();
var before = NestFingerprint.Compute(nest);
nest.Drawings.First().Quantity.Required = 5;
Assert.NotEqual(before, NestFingerprint.Compute(nest));
}
}
File renamed without changes.
@@ -0,0 +1,6 @@
using System.Runtime.CompilerServices;
// The WinForms app drives undo recording through ChangeScope's internal root/nested factories
// and NestFingerprint.
[assembly: InternalsVisibleTo("OpenNest")]
[assembly: InternalsVisibleTo("OpenNest.Desktop.Tests")]
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2 -2
View File
@@ -10,10 +10,10 @@ once its entry point records it.
|---|---|
| History engine (states, cursor, limits, coalescing, save point) | `OpenNest.Data/UndoHistory.cs` |
| State codec | `OpenNest.IO/NestSnapshot.cs` |
| Recording scope, fingerprint, history state | `OpenNest/Undo/` |
| Recording scope, fingerprint, history state | `OpenNest.Desktop/Undo/` (shared with `OpenNest.Avalonia`) |
| Recording, restore, safety net | `OpenNest/Forms/EditNestForm.Undo.cs` |
| Menu, keys, guards | `OpenNest/Forms/MainForm.Undo.cs`, `MainForm.Guards.cs` (`IsUndoBusy`) |
| Tests | `OpenNest.WinForms.Tests/Forms/UndoRedoTests.cs`, `UndoRecordingTests.cs` |
| Tests | `OpenNest.WinForms.Tests/Forms/UndoRedoTests.cs`, `UndoRecordingTests.cs`; scope and fingerprint on Linux in `OpenNest.Desktop.Tests/Undo/` |
## What is undoable