diff --git a/OpenNest.Core/NestStatus.cs b/OpenNest.Core/NestStatus.cs
index c9c54f42..dc150740 100644
--- a/OpenNest.Core/NestStatus.cs
+++ b/OpenNest.Core/NestStatus.cs
@@ -32,5 +32,15 @@ namespace OpenNest
NestStatus.ToBeCut,
NestStatus.HasBeenCut,
};
+
+ /// The name shown to the operator, such as "To Be Cut".
+ public static string DisplayName(NestStatus status) => status switch
+ {
+ NestStatus.Draft => "Draft",
+ NestStatus.Quote => "Quote",
+ NestStatus.ToBeCut => "To Be Cut",
+ NestStatus.HasBeenCut => "Has Been Cut",
+ _ => status.ToString(),
+ };
}
}
diff --git a/OpenNest.Data/NestDefaults.cs b/OpenNest.Data/NestDefaults.cs
index c94c11df..5c94a7d9 100644
--- a/OpenNest.Data/NestDefaults.cs
+++ b/OpenNest.Data/NestDefaults.cs
@@ -186,6 +186,28 @@ public sealed class NestDefaults
};
}
+ ///
+ /// These defaults in : plate size and spacings are converted
+ /// (25.4 mm per inch) and rounded to four decimals; the quadrant is kept. Returns a copy
+ /// even when the units already match.
+ ///
+ public NestDefaults ConvertedTo(Units units)
+ {
+ var factor = units == Units ? 1.0 : units == Units.Millimeters ? MillimetersPerInch : 1 / MillimetersPerInch;
+ double Convert(double value) => System.Math.Round(value * factor, 4, MidpointRounding.AwayFromZero);
+ return new NestDefaults
+ {
+ Units = units,
+ Size = new Size(Convert(Size.Width), Convert(Size.Length)),
+ Quadrant = Quadrant,
+ PartSpacing = Convert(PartSpacing),
+ EdgeSpacing = new Spacing(Convert(EdgeSpacing.Left), Convert(EdgeSpacing.Bottom),
+ Convert(EdgeSpacing.Right), Convert(EdgeSpacing.Top)),
+ };
+ }
+
+ private const double MillimetersPerInch = 25.4;
+
public void ApplyTo(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
diff --git a/OpenNest.Data/SetReleaseSession.cs b/OpenNest.Data/SetReleaseSession.cs
index 25b299f0..3687da8c 100644
--- a/OpenNest.Data/SetReleaseSession.cs
+++ b/OpenNest.Data/SetReleaseSession.cs
@@ -108,6 +108,10 @@ public sealed class SetReleaseResult
/// posts the same release again.
///
public string ReleaseError { get; }
+
+ /// This result once has recorded .
+ public SetReleaseResult WithRelease(SetReleaseRecord release) =>
+ new(Groups, Canceled, release ?? throw new ArgumentNullException(nameof(release)), "");
}
///
diff --git a/OpenNest.Data/SetReleaseText.cs b/OpenNest.Data/SetReleaseText.cs
new file mode 100644
index 00000000..89e80693
--- /dev/null
+++ b/OpenNest.Data/SetReleaseText.cs
@@ -0,0 +1,62 @@
+using System.Globalization;
+using OpenNest;
+
+namespace OpenNest.Data;
+
+/// Operator-facing wording for set releases, shared by the release views and Nest Info.
+public static class SetReleaseText
+{
+ /// Where a nest came from, such as "Released from set 'Kit A' revision 3 ×2 by J. Smith".
+ public static string Origin(SetReleaseRecord release)
+ {
+ ArgumentNullException.ThrowIfNull(release);
+ var text = $"Released from set '{release.SetName}' revision {release.SetRevision} ×{release.Multiplier}";
+ if (release.ReleasedAt != default)
+ text += $" on {release.ReleasedAt.ToLocalTime().ToString("d", CultureInfo.CurrentCulture)}";
+ return string.IsNullOrWhiteSpace(release.ReleasedBy) ? text : $"{text} by {release.ReleasedBy}";
+ }
+
+ ///
+ /// How far the release's nests have got, such as "2 of 3 cut" or "1 of 2 cut, 1 deleted".
+ /// Deleted nests are left out of the count.
+ ///
+ public static string Progress(SetReleaseRecord release)
+ {
+ ArgumentNullException.ThrowIfNull(release);
+ var nests = release.Nests ?? Array.Empty();
+ var deleted = nests.Count(IsDeleted);
+ var active = nests.Count - deleted;
+ if (active == 0)
+ return nests.Count == 1 ? "Its nest was deleted" : "All nests deleted";
+
+ var cut = nests.Count(n => !IsDeleted(n) && n.Nest?.Status == NestStatus.HasBeenCut);
+ var text = $"{cut} of {active} cut";
+ return deleted == 0 ? text : $"{text}, {deleted} deleted";
+ }
+
+ /// The state of one nest of a release: its status name, or "Deleted".
+ public static string NestState(SetReleaseNest nest)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+ if (IsDeleted(nest))
+ return "Deleted";
+ return nest.Nest is { } record ? NestStatuses.DisplayName(record.Status) : "";
+ }
+
+ /// The material group a release nest was made for, such as "Carbon Steel A36 0.25 in laser".
+ public static string Group(SetReleaseNest nest)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+ var parts = new[]
+ {
+ nest.MaterialName,
+ nest.MaterialGrade,
+ $"{nest.Thickness.ToString("0.####", CultureInfo.CurrentCulture)} {UnitsHelper.GetShortString(nest.Units)}",
+ nest.CutType.ToString().ToLowerInvariant(),
+ };
+ return string.Join(" ", parts.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p.Trim()));
+ }
+
+ // A nest the server no longer joins is as gone as a deleted one.
+ private static bool IsDeleted(SetReleaseNest nest) => nest.Nest is null || nest.Nest.Deleted;
+}
diff --git a/OpenNest.Tests/Data/InMemoryDrawingSets.cs b/OpenNest.Tests/Data/InMemoryDrawingSets.cs
index 954c7558..632b270a 100644
--- a/OpenNest.Tests/Data/InMemoryDrawingSets.cs
+++ b/OpenNest.Tests/Data/InMemoryDrawingSets.cs
@@ -111,14 +111,54 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore
});
}
- public Task PostReleaseAsync(Guid setId, SetReleaseRecord release, CancellationToken cancellationToken = default) =>
- throw new NotSupportedException();
+ /// Stored releases, oldest first; tests may add ones with joined nest metadata.
+ public List Releases { get; } = new();
+ public int PostAttempts { get; private set; }
+ public bool FailNextPost { get; set; }
- public Task GetReleasesAsync(Guid setId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) =>
- throw new NotSupportedException();
+ public Task PostReleaseAsync(Guid setId, SetReleaseRecord release, CancellationToken cancellationToken = default)
+ {
+ PostAttempts++;
+ if (FailNextPost)
+ {
+ FailNextPost = false;
+ throw new IOException("Synthetic release post failure.");
+ }
+ if (Releases.FirstOrDefault(r => r.Id == release.Id) is { } existing)
+ throw new SetReleasePostConflictException(existing);
+
+ var stored = new SetReleaseRecord
+ {
+ Id = release.Id,
+ SetId = setId,
+ SetRevision = release.SetRevision,
+ SetName = _revisions[setId].First(r => r.Revision == release.SetRevision).Name,
+ Multiplier = release.Multiplier,
+ Notes = release.Notes,
+ ReleasedBy = release.ReleasedBy,
+ ReleasedAt = new DateTime(2026, 10, 20, 15, 0, 0, DateTimeKind.Utc),
+ Nests = release.Nests.ToList(),
+ };
+ Releases.Add(stored);
+ return Task.FromResult(stored);
+ }
+
+ public Task GetReleasesAsync(Guid setId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default)
+ {
+ if (!_revisions.ContainsKey(setId))
+ return Task.FromResult(null);
+ var newest = Enumerable.Reverse(Releases.Where(r => r.SetId == setId).ToList()).ToList();
+ return Task.FromResult(new SetReleasePage
+ {
+ Items = newest.Skip(offset).Take(limit).ToList(),
+ Total = newest.Count,
+ Offset = offset,
+ Limit = limit,
+ });
+ }
public Task GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default) =>
- throw new NotSupportedException();
+ Task.FromResult(Releases.FirstOrDefault(r => r.Nests.Any(n => n.NestId == nestId)));
private Task SetArchived(Guid id, int expectedRevision, bool archived)
{
diff --git a/OpenNest.Tests/Data/SetReleaseTextTests.cs b/OpenNest.Tests/Data/SetReleaseTextTests.cs
new file mode 100644
index 00000000..5c73a37a
--- /dev/null
+++ b/OpenNest.Tests/Data/SetReleaseTextTests.cs
@@ -0,0 +1,80 @@
+using OpenNest.Data;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Data;
+
+public class SetReleaseTextTests
+{
+ private static SetReleaseNest Nest(NestStatus status, bool deleted = false, bool joined = true) => new()
+ {
+ NestId = Guid.NewGuid(),
+ MaterialName = "Synthetic Steel",
+ MaterialGrade = "Grade A",
+ Thickness = 0.25,
+ Units = Units.Inches,
+ CutType = CutType.Waterjet,
+ Nest = joined ? new NestRecord { Name = "N1", Status = status, Deleted = deleted } : null,
+ };
+
+ [Fact]
+ public void Progress_CountsCutNestsAndLeavesDeletedOnesOut()
+ {
+ Assert.Equal("2 of 3 cut", SetReleaseText.Progress(new SetReleaseRecord
+ {
+ Nests = new[] { Nest(NestStatus.HasBeenCut), Nest(NestStatus.HasBeenCut), Nest(NestStatus.Draft) },
+ }));
+ Assert.Equal("0 of 1 cut, 2 deleted", SetReleaseText.Progress(new SetReleaseRecord
+ {
+ Nests = new[] { Nest(NestStatus.ToBeCut), Nest(NestStatus.HasBeenCut, deleted: true), Nest(NestStatus.Quote, joined: false) },
+ }));
+ Assert.Equal("All nests deleted", SetReleaseText.Progress(new SetReleaseRecord
+ {
+ Nests = new[] { Nest(NestStatus.Draft, deleted: true), Nest(NestStatus.Draft, deleted: true) },
+ }));
+ }
+
+ [Fact]
+ public void NestState_AndGroup_DescribeOneNest()
+ {
+ Assert.Equal("To Be Cut", SetReleaseText.NestState(Nest(NestStatus.ToBeCut)));
+ Assert.Equal("Deleted", SetReleaseText.NestState(Nest(NestStatus.ToBeCut, deleted: true)));
+ Assert.Equal("Synthetic Steel Grade A 0.25 in waterjet", SetReleaseText.Group(Nest(NestStatus.Draft)));
+ }
+
+ [Fact]
+ public void Origin_NamesTheSetRevisionMultiplierAndWho()
+ {
+ var text = SetReleaseText.Origin(new SetReleaseRecord
+ {
+ SetName = "Synthetic kit",
+ SetRevision = 3,
+ Multiplier = 2,
+ ReleasedBy = "Synthetic operator",
+ });
+
+ Assert.Equal("Released from set 'Synthetic kit' revision 3 ×2 by Synthetic operator", text);
+ }
+
+ [Fact]
+ public void NestDefaults_ConvertedTo_ScalesPlateDefaultsBetweenUnits()
+ {
+ var inches = new NestDefaults
+ {
+ Units = Units.Inches,
+ Size = new Size(60, 120),
+ Quadrant = 3,
+ PartSpacing = 0.125,
+ EdgeSpacing = new Spacing(0.5, 0.25, 0.5, 0.25),
+ };
+
+ var millimeters = inches.ConvertedTo(Units.Millimeters);
+ var back = millimeters.ConvertedTo(Units.Inches);
+
+ Assert.Equal((Units.Millimeters, 1524.0, 3048.0, 3, 3.175), (millimeters.Units, millimeters.Size.Width,
+ millimeters.Size.Length, millimeters.Quadrant, millimeters.PartSpacing));
+ Assert.Equal((12.7, 6.35, 12.7, 6.35), (millimeters.EdgeSpacing.Left, millimeters.EdgeSpacing.Bottom,
+ millimeters.EdgeSpacing.Right, millimeters.EdgeSpacing.Top));
+ Assert.Equal((60.0, 120.0, 0.125), (back.Size.Width, back.Size.Length, back.PartSpacing));
+ Assert.NotSame(inches, inches.ConvertedTo(Units.Inches));
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs b/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs
new file mode 100644
index 00000000..beb5cd5e
--- /dev/null
+++ b/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs
@@ -0,0 +1,330 @@
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Windows.Forms;
+using OpenNest.Data;
+using OpenNest.Forms;
+using OpenNest.IO;
+using OpenNest.Shapes;
+using OpenNest.Tests.Data;
+using DrawingSetBrowser = OpenNest.Controls.DrawingSetBrowser;
+
+namespace OpenNest.WinForms.Tests.Forms;
+
+public class SetReleaseFormsTests
+{
+ private const string Server = "http://library.test:8090";
+ private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(60);
+
+ [Fact]
+ public void Release_CreatesOneDraftNestPerTickedGroup_OpensEach_AndRecordsTheRelease()
+ {
+ StaTestThread.Run(
+ () =>
+ {
+ var library = new PackageLibrary();
+ var a = library.Add("SYN-A", "Synthetic Steel");
+ var b = library.Add("SYN-B", "Synthetic Steel");
+ var c = library.Add("SYN-C", "Synthetic Aluminum");
+ var old = library.Add("SYN-OLD", "Synthetic Steel", archived: true);
+ var sets = new InMemoryDrawingSets();
+ var set = CreateSet(sets, (a, 2), (b, 1), (c, 4), (old, 1));
+ var nests = new MemoryNests();
+ var opened = new List<(Nest Nest, NestRecord Record)>();
+ var questions = new List();
+
+ using var form = new ReleaseSetForm(sets, library, nests, Server, set, (nest, record) => opened.Add((nest, record)));
+ form.ShowMessage = (text, _, _) => { questions.Add(text); return DialogResult.OK; };
+ var created = 0;
+ form.CreateNest = (group, _) => Task.FromResult(new Nest($"N26-{++created:D4}") { Units = group.Units });
+ form.Show();
+ var grid = Field(form, "groupGrid");
+ var skipped = Field