diff --git a/OpenNest.Desktop.Tests/DocumentTests.cs b/OpenNest.Desktop.Tests/DocumentTests.cs
new file mode 100644
index 00000000..f2dd838c
--- /dev/null
+++ b/OpenNest.Desktop.Tests/DocumentTests.cs
@@ -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);
+ }
+}
diff --git a/OpenNest.Desktop.Tests/TestNests.cs b/OpenNest.Desktop.Tests/TestNests.cs
new file mode 100644
index 00000000..a52d120a
--- /dev/null
+++ b/OpenNest.Desktop.Tests/TestNests.cs
@@ -0,0 +1,29 @@
+using OpenNest.CNC;
+using OpenNest.Geometry;
+
+namespace OpenNest.Desktop.Tests;
+
+internal static class TestNests
+{
+ /// A nest with one 48x96 plate holding one 10x10 square part.
+ 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;
+ }
+}
diff --git a/OpenNest.Desktop.Tests/Undo/ChangeScopeTests.cs b/OpenNest.Desktop.Tests/Undo/ChangeScopeTests.cs
new file mode 100644
index 00000000..372ffd9c
--- /dev/null
+++ b/OpenNest.Desktop.Tests/Undo/ChangeScopeTests.cs
@@ -0,0 +1,75 @@
+using OpenNest.Undo;
+
+namespace OpenNest.Desktop.Tests.Undo;
+
+public class ChangeScopeTests
+{
+ [Fact]
+ public void RootScope_EndsOnceAndCarriesChangesMergedFromNestedScopes()
+ {
+ var ends = new List();
+ 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);
+ }
+}
diff --git a/OpenNest.Desktop.Tests/Undo/NestFingerprintTests.cs b/OpenNest.Desktop.Tests/Undo/NestFingerprintTests.cs
new file mode 100644
index 00000000..e860c3dc
--- /dev/null
+++ b/OpenNest.Desktop.Tests/Undo/NestFingerprintTests.cs
@@ -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));
+ }
+}
diff --git a/OpenNest/Document.cs b/OpenNest.Desktop/Document.cs
similarity index 100%
rename from OpenNest/Document.cs
rename to OpenNest.Desktop/Document.cs
diff --git a/OpenNest.Desktop/Properties/AssemblyInfo.cs b/OpenNest.Desktop/Properties/AssemblyInfo.cs
new file mode 100644
index 00000000..60b99e4c
--- /dev/null
+++ b/OpenNest.Desktop/Properties/AssemblyInfo.cs
@@ -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")]
diff --git a/OpenNest/Undo/ChangeScope.cs b/OpenNest.Desktop/Undo/ChangeScope.cs
similarity index 100%
rename from OpenNest/Undo/ChangeScope.cs
rename to OpenNest.Desktop/Undo/ChangeScope.cs
diff --git a/OpenNest/Undo/NestFingerprint.cs b/OpenNest.Desktop/Undo/NestFingerprint.cs
similarity index 100%
rename from OpenNest/Undo/NestFingerprint.cs
rename to OpenNest.Desktop/Undo/NestFingerprint.cs
diff --git a/OpenNest/Undo/NestUndoState.cs b/OpenNest.Desktop/Undo/NestUndoState.cs
similarity index 100%
rename from OpenNest/Undo/NestUndoState.cs
rename to OpenNest.Desktop/Undo/NestUndoState.cs
diff --git a/docs/undo-redo.md b/docs/undo-redo.md
index 61525a91..44399651 100644
--- a/docs/undo-redo.md
+++ b/docs/undo-redo.md
@@ -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