From 414479fc4e72924ea781608695f84f99a973737b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 24 Oct 2026 10:02:23 -0400 Subject: [PATCH] feat(library): edit a library drawing's details as a new revision Edit Settings becomes Edit Drawing: name, customer, material, grade, thickness and cut type are editable beside the nesting settings and notes. Units stay read-only. The save is a metadata revision with the same package, so nests keep the revision they hold, and the release refusal for drawings without material or thickness now has a fix. --- OpenNest.Data/DrawingLibraryEditSession.cs | 87 +++++-- .../Data/DrawingLibraryEditSessionTests.cs | 78 +++++- .../Forms/DrawingLibraryFormTests.cs | 65 ++++- OpenNest/Forms/DrawingLibraryForm.Designer.cs | 2 +- OpenNest/Forms/DrawingLibraryForm.cs | 8 +- .../Forms/EditLibraryDrawingForm.Designer.cs | 235 +++++++++++++++--- OpenNest/Forms/EditLibraryDrawingForm.cs | 31 ++- docs/nest-storage.md | 18 +- 8 files changed, 438 insertions(+), 86 deletions(-) diff --git a/OpenNest.Data/DrawingLibraryEditSession.cs b/OpenNest.Data/DrawingLibraryEditSession.cs index 07b66ba8..990a066f 100644 --- a/OpenNest.Data/DrawingLibraryEditSession.cs +++ b/OpenNest.Data/DrawingLibraryEditSession.cs @@ -1,10 +1,23 @@ namespace OpenNest.Data; /// -/// Edits one listed library drawing's notes and nesting settings in place: the save is a -/// metadata-only revision guarded by the revision it was listed with, so the package (and -/// its hash) stays the same. A stale revision raises -/// and changes nothing. +/// A library drawing's editable details. Units are not among them: they give the stored +/// geometry its meaning, so changing them would rescale the part. +/// +public sealed record DrawingLibraryDetails( + string Name, + string Customer, + string MaterialName, + string MaterialGrade, + double Thickness, + CutType CutType); + +/// +/// Edits one listed library drawing's details (name, customer, material, grade, thickness, +/// cut type), notes and nesting settings in place: the save is a metadata-only revision +/// guarded by the revision it was listed with, so the package (and its hash) stays the same +/// and nests keep the revision they hold. A stale revision raises +/// and changes nothing. /// public sealed class DrawingLibraryEditSession { @@ -26,42 +39,64 @@ public sealed class DrawingLibraryEditSession public string SavedBy { get; } - /// The record to save: the listed details with new notes and settings. - public DrawingLibraryRecord Build(string notes, DrawingNestingSettings settings) => new() + /// The listed record's details, the starting point of an edit. + public DrawingLibraryDetails Details => new(Listed.Name, Listed.Customer, Listed.MaterialName, + Listed.MaterialGrade, Listed.Thickness, Listed.CutType); + + /// + /// The record to save: the edited details (text trimmed, thickness rounded as stored), + /// the listed units, and the new notes and settings. + /// + public DrawingLibraryRecord Build(DrawingLibraryDetails details, string notes, DrawingNestingSettings settings) { - Id = Listed.Id, - Name = Listed.Name, - Customer = Listed.Customer, - MaterialName = Listed.MaterialName, - MaterialGrade = Listed.MaterialGrade, - Thickness = Listed.Thickness, - Units = Listed.Units, - CutType = Listed.CutType, - Notes = notes ?? "", - Settings = settings ?? throw new ArgumentNullException(nameof(settings)), - SavedBy = SavedBy, - }; + ArgumentNullException.ThrowIfNull(details); + return new DrawingLibraryRecord + { + Id = Listed.Id, + Name = (details.Name ?? "").Trim(), + Customer = (details.Customer ?? "").Trim(), + MaterialName = (details.MaterialName ?? "").Trim(), + MaterialGrade = (details.MaterialGrade ?? "").Trim(), + Thickness = double.IsFinite(details.Thickness) + ? DrawingLibraryRules.NormalizeThickness(details.Thickness) + : details.Thickness, + Units = Listed.Units, + CutType = details.CutType, + Notes = notes ?? "", + Settings = settings ?? throw new ArgumentNullException(nameof(settings)), + SavedBy = SavedBy, + }; + } /// The first rule the edit breaks, or null when it may be saved. - public string? GetError(string notes, DrawingNestingSettings settings) => - DrawingLibraryRules.GetMetadataError(Build(notes, settings)); + public string? GetError(DrawingLibraryDetails details, string notes, DrawingNestingSettings settings) => + DrawingLibraryRules.GetMetadataError(Build(details, notes, settings)); /// True when saving would change nothing. - public bool IsUnchanged(string notes, DrawingNestingSettings settings) => - (notes ?? "") == Listed.Notes && Equals(settings, Listed.Settings); + public bool IsUnchanged(DrawingLibraryDetails details, string notes, DrawingNestingSettings settings) => + SameDetails(Build(details, notes, settings), Listed) + && (notes ?? "") == Listed.Notes && Equals(settings, Listed.Settings); /// Saves the edit as the next revision; returns the stored record. - public async Task SaveAsync(string notes, DrawingNestingSettings settings, - CancellationToken cancellationToken = default) + public async Task SaveAsync(DrawingLibraryDetails details, string notes, + DrawingNestingSettings settings, CancellationToken cancellationToken = default) { - var record = Build(notes, settings); + var record = Build(details, notes, settings); var error = DrawingLibraryRules.GetMetadataError(record); if (error != null) - throw new ArgumentException(error, nameof(settings)); + throw new ArgumentException(error, nameof(details)); var saved = await _library.UpdateMetadataAsync(Listed.Id, record, Listed.Revision, cancellationToken); if (!string.Equals(saved.PackageSha256, Listed.PackageSha256, StringComparison.OrdinalIgnoreCase) + || !SameDetails(saved, record) || saved.Units != record.Units || !Equals(saved.Settings, record.Settings) || saved.Notes != record.Notes) throw new IOException("The nest server stored a different library drawing than was edited. Reload the library."); return saved; } + + // Compares a built (trimmed, rounded) record with another, as stored. + private static bool SameDetails(DrawingLibraryRecord built, DrawingLibraryRecord other) => + built.Name == other.Name.Trim() && built.Customer == other.Customer.Trim() + && built.MaterialName == other.MaterialName.Trim() && built.MaterialGrade == other.MaterialGrade.Trim() + && built.Thickness == DrawingLibraryRules.NormalizeThickness(other.Thickness) + && built.CutType == other.CutType; } diff --git a/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs b/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs index 33ab7c92..20ca6b79 100644 --- a/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs +++ b/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs @@ -29,9 +29,9 @@ public class DrawingLibraryEditSessionTests var session = new DrawingLibraryEditSession(store, listed, "Synthetic operator"); var settings = new DrawingNestingSettings { CutFirst = true }; - Assert.True(session.IsUnchanged("Calibration", new DrawingNestingSettings())); - Assert.False(session.IsUnchanged("Calibration", settings)); - var saved = await session.SaveAsync("Calibration", settings); + Assert.True(session.IsUnchanged(session.Details, "Calibration", new DrawingNestingSettings())); + Assert.False(session.IsUnchanged(session.Details, "Calibration", settings)); + var saved = await session.SaveAsync(session.Details, "Calibration", settings); var (id, expected, record) = Assert.Single(store.Updates); Assert.Equal((listed.Id, 4), (id, expected)); @@ -49,11 +49,68 @@ public class DrawingLibraryEditSessionTests var session = new DrawingLibraryEditSession(store, Listed(), ""); var invalid = new DrawingNestingSettings { StepAngle = -1 }; - Assert.NotNull(session.GetError("", invalid)); - await Assert.ThrowsAsync(() => session.SaveAsync("", invalid)); + Assert.NotNull(session.GetError(session.Details, "", invalid)); + await Assert.ThrowsAsync(() => session.SaveAsync(session.Details, "", invalid)); Assert.Empty(store.Updates); } + [Fact] + public async Task SaveAsync_SendsEditedDetailsTrimmedAsTheNextRevisionOfTheSamePackage() + { + var listed = Listed(); + var store = new RecordingStore(); + var session = new DrawingLibraryEditSession(store, listed, "Synthetic operator"); + var details = new DrawingLibraryDetails(" Test square B ", "Other customer", " Synthetic Stainless ", "304 ", + 0.1875, CutType.Waterjet); + + Assert.False(session.IsUnchanged(details, listed.Notes, listed.Settings)); + var saved = await session.SaveAsync(details, listed.Notes, listed.Settings); + + var (id, expected, record) = Assert.Single(store.Updates); + Assert.Equal((listed.Id, 4), (id, expected)); + Assert.Equal(("Test square B", "Other customer", "Synthetic Stainless", "304", 0.1875, CutType.Waterjet), + (record.Name, record.Customer, record.MaterialName, record.MaterialGrade, record.Thickness, record.CutType)); + // Units are never edited here: they give the stored geometry its meaning. + Assert.Equal(listed.Units, record.Units); + Assert.Equal(("Test square B", 0.1875), (saved.Name, saved.Thickness)); + } + + [Fact] + public void Details_StartFromTheListedRecordAndOnlyRealChangesCount() + { + var listed = Listed(); + var session = new DrawingLibraryEditSession(new RecordingStore(), listed, ""); + + Assert.Equal(new DrawingLibraryDetails(listed.Name, listed.Customer, listed.MaterialName, listed.MaterialGrade, + listed.Thickness, listed.CutType), session.Details); + // Surrounding spaces and sub-rounding thickness noise are not edits. + Assert.True(session.IsUnchanged(session.Details with { Name = " Test square ", Thickness = 0.250001 }, + listed.Notes, listed.Settings)); + Assert.False(session.IsUnchanged(session.Details with { MaterialGrade = "Grade B" }, listed.Notes, listed.Settings)); + } + + [Fact] + public async Task SaveAsync_RefusesABlankNameWithoutSending() + { + var store = new RecordingStore(); + var session = new DrawingLibraryEditSession(store, Listed(), ""); + var blank = session.Details with { Name = " " }; + + Assert.Equal("name is required.", session.GetError(blank, "", new DrawingNestingSettings())); + await Assert.ThrowsAsync(() => session.SaveAsync(blank, "", new DrawingNestingSettings())); + Assert.Empty(store.Updates); + } + + [Fact] + public async Task SaveAsync_RefusesAReplyWithOtherDetailsThanSent() + { + var store = new RecordingStore { DropDetails = true }; + var session = new DrawingLibraryEditSession(store, Listed(), ""); + + await Assert.ThrowsAsync(() => + session.SaveAsync(session.Details with { MaterialName = "Other" }, "", new DrawingNestingSettings())); + } + [Fact] public void ArchivedDrawings_CannotBeEdited() { @@ -64,6 +121,9 @@ public class DrawingLibraryEditSessionTests { public List<(Guid Id, int Expected, DrawingLibraryRecord Record)> Updates { get; } = new(); + /// Replies with the listed details instead of the sent ones. + public bool DropDetails { get; init; } + public Task UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision, CancellationToken cancellationToken = default) { @@ -72,7 +132,13 @@ public class DrawingLibraryEditSessionTests { Id = id, Revision = expectedRevision + 1, - Name = record.Name, + Name = DropDetails ? "Test square" : record.Name, + Customer = DropDetails ? "Synthetic customer" : record.Customer, + MaterialName = DropDetails ? "Synthetic Steel" : record.MaterialName, + MaterialGrade = DropDetails ? "Grade A" : record.MaterialGrade, + Thickness = DropDetails ? 0.25 : record.Thickness, + Units = record.Units, + CutType = DropDetails ? CutType.Laser : record.CutType, Notes = record.Notes, Settings = record.Settings, PackageSha256 = "ABC123", diff --git a/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs index e4ddec9f..f6e13d6b 100644 --- a/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs @@ -267,6 +267,57 @@ public class DrawingLibraryFormTests "The drawing library edit test did not complete."); } + [Fact] + public void EditDrawing_SavesNewDetailsAsANewRevision_KeepingUnits() + { + StaTestThread.Run( + () => + { + var library = new FakeLibrary(); + var record = library.Add("SYN-405", "", "", 0, Units.Inches, "Test square"); + var nest = MatchingNest(); + + using var form = new DrawingLibraryForm(library, Server, nest); + form.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text); + form.EditDrawing = edit => + { + using var editor = new EditLibraryDrawingForm(edit); + editor.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text); + editor.Show(); + var save = Field