From 38de7ab3a3b7dddd1f59c84e76fb03df3f96f3da Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 29 Oct 2026 18:40:49 -0400 Subject: [PATCH] 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. --- OpenNest.Desktop.Tests/DocumentTests.cs | 54 +++++++++++++ OpenNest.Desktop.Tests/TestNests.cs | 29 +++++++ .../Undo/ChangeScopeTests.cs | 75 +++++++++++++++++++ .../Undo/NestFingerprintTests.cs | 36 +++++++++ {OpenNest => OpenNest.Desktop}/Document.cs | 0 OpenNest.Desktop/Properties/AssemblyInfo.cs | 6 ++ .../Undo/ChangeScope.cs | 0 .../Undo/NestFingerprint.cs | 0 .../Undo/NestUndoState.cs | 0 docs/undo-redo.md | 4 +- 10 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 OpenNest.Desktop.Tests/DocumentTests.cs create mode 100644 OpenNest.Desktop.Tests/TestNests.cs create mode 100644 OpenNest.Desktop.Tests/Undo/ChangeScopeTests.cs create mode 100644 OpenNest.Desktop.Tests/Undo/NestFingerprintTests.cs rename {OpenNest => OpenNest.Desktop}/Document.cs (100%) create mode 100644 OpenNest.Desktop/Properties/AssemblyInfo.cs rename {OpenNest => OpenNest.Desktop}/Undo/ChangeScope.cs (100%) rename {OpenNest => OpenNest.Desktop}/Undo/NestFingerprint.cs (100%) rename {OpenNest => OpenNest.Desktop}/Undo/NestUndoState.cs (100%) 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