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