From 28ee04a48054a961c4f0249bef02898b16d4ee42 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 20 Oct 2026 12:31:58 -0400 Subject: [PATCH] feat(nests): add a Draft nest status Nests released from drawing sets need a state before Quote: created but not yet nested or checked. Draft is appended to NestStatus (value 3) because the server still accepts integer statuses from existing callers, so existing numbers must not shift; NestStatuses.WorkflowOrder puts it first wherever the operator picks a status. Older desktops read Draft as Quote (the documented unknown-status fallback), and an older server refuses "draft", so the server must be upgraded before desktops that can set it. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/NestStatus.cs | 23 ++++++++++++ .../NestUploadValidationTests.cs | 3 ++ OpenNest.Server/NestMetadata.cs | 2 +- .../IO/NestStatusSerializationTests.cs | 35 +++++++++++++++++++ .../Forms/EditNestInfoFormTests.cs | 19 ++++++++++ OpenNest/Forms/EditNestInfoForm.cs | 2 +- OpenNest/Forms/SavedNestsForm.cs | 1 + docs/nest-file-format.md | 5 +-- docs/nest-storage.md | 6 ++-- 9 files changed, 90 insertions(+), 6 deletions(-) diff --git a/OpenNest.Core/NestStatus.cs b/OpenNest.Core/NestStatus.cs index 7dbde27a..c9c54f42 100644 --- a/OpenNest.Core/NestStatus.cs +++ b/OpenNest.Core/NestStatus.cs @@ -4,10 +4,33 @@ namespace OpenNest /// Shop-floor workflow state of a saved nest, persisted as a string in /// nest.json so unknown future values can fall back safely. /// + /// + /// New values go at the end: the nest server still accepts integer statuses from + /// existing callers, so existing values must keep their numbers. Lists shown to the + /// operator use , not declaration order. + /// public enum NestStatus { Quote, ToBeCut, HasBeenCut, + + /// + /// Created but not yet ready to quote or cut, such as a nest released from a + /// drawing set before it has been nested and checked. Comes before Quote. + /// + Draft, + } + + public static class NestStatuses + { + /// Every status in the order a nest moves through them. + public static readonly NestStatus[] WorkflowOrder = + { + NestStatus.Draft, + NestStatus.Quote, + NestStatus.ToBeCut, + NestStatus.HasBeenCut, + }; } } diff --git a/OpenNest.Server.Tests/NestUploadValidationTests.cs b/OpenNest.Server.Tests/NestUploadValidationTests.cs index 4256fe02..3667680a 100644 --- a/OpenNest.Server.Tests/NestUploadValidationTests.cs +++ b/OpenNest.Server.Tests/NestUploadValidationTests.cs @@ -65,6 +65,7 @@ public sealed class NestUploadValidationTests [InlineData("""{"name":"A","madeBy":null}""")] [InlineData("""{"name":"A","status":99}""")] [InlineData("""{"name":"A","status":"cancelled"}""")] + [InlineData("""{"name":"A","status":4}""")] public async Task Create_WithInvalidMetadata_Returns400AndStoresNothing(string metadata) { using var factory = new ServerFactory(); @@ -80,6 +81,8 @@ public sealed class NestUploadValidationTests [Theory] [InlineData("""{"name":"A","status":"toBeCut"}""", NestStatus.ToBeCut)] [InlineData("""{"name":"A","status":2}""", NestStatus.HasBeenCut)] + [InlineData("""{"name":"A","status":"draft"}""", NestStatus.Draft)] + [InlineData("""{"name":"A","status":3}""", NestStatus.Draft)] public async Task Create_WithDefinedStatus_StoresIt(string metadata, NestStatus expected) { using var factory = new ServerFactory(); diff --git a/OpenNest.Server/NestMetadata.cs b/OpenNest.Server/NestMetadata.cs index 9a7d53de..751a7ff3 100644 --- a/OpenNest.Server/NestMetadata.cs +++ b/OpenNest.Server/NestMetadata.cs @@ -53,7 +53,7 @@ internal static class NestMetadata if (record.Material is null) return "material must be a string."; if (record.Comments is null) return "comments must be a string."; if (record.MadeBy is null) return "madeBy must be a string."; - if (!Enum.IsDefined(record.Status)) return "status must be quote, toBeCut or hasBeenCut."; + if (!Enum.IsDefined(record.Status)) return "status must be draft, quote, toBeCut or hasBeenCut."; if (!double.IsFinite(record.Thickness)) return "thickness must be a finite number."; return null; } diff --git a/OpenNest.Tests/IO/NestStatusSerializationTests.cs b/OpenNest.Tests/IO/NestStatusSerializationTests.cs index a1a7a9ed..eeaadd9a 100644 --- a/OpenNest.Tests/IO/NestStatusSerializationTests.cs +++ b/OpenNest.Tests/IO/NestStatusSerializationTests.cs @@ -43,6 +43,41 @@ public class NestStatusSerializationTests Assert.Equal(NestStatus.HasBeenCut, loaded.Status); } + [Fact] + public void RoundTrip_Draft_PreservedAsName() + { + var nest = CreateMinimalNest(); + nest.Status = NestStatus.Draft; + + using var ms = new MemoryStream(); + new NestWriter(nest).Write(ms); + using var doc = JsonDocument.Parse(ReadNestJson(ms)); + ms.Position = 0; + var loaded = new NestReader(ms).Read(); + + Assert.Equal("Draft", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal(NestStatus.Draft, loaded.Status); + } + + [Fact] + public void WorkflowOrder_StartsWithDraftAndListsEveryStatusOnce() + { + Assert.Equal(NestStatus.Draft, NestStatuses.WorkflowOrder[0]); + Assert.Equal( + Enum.GetValues().OrderBy(s => s), + NestStatuses.WorkflowOrder.OrderBy(s => s)); + } + + [Fact] + public void ExistingStatusNumbers_AreUnchanged() + { + // The nest server still accepts integer statuses from existing callers. + Assert.Equal(0, (int)NestStatus.Quote); + Assert.Equal(1, (int)NestStatus.ToBeCut); + Assert.Equal(2, (int)NestStatus.HasBeenCut); + Assert.Equal(3, (int)NestStatus.Draft); + } + [Fact] public void Status_SerializedAsEnumString() { diff --git a/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs b/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs index bfd06286..be3235e2 100644 --- a/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs @@ -143,6 +143,25 @@ public class EditNestInfoFormTests }); } + [Fact] + public void StatusList_FollowsWorkflowOrder_AndSavesDraft() + { + RunSta(() => + { + var nest = AcceptanceNest(); + using var form = new EditNestInfoForm(); + form.LoadNestInfo(nest); + var statusBox = Assert.IsType(form.Controls.Find("statusBox", true).Single()); + + Assert.Equal(NestStatuses.WorkflowOrder, statusBox.Items.Cast()); + + form.Status = NestStatus.Draft; + form.SaveNestInfo(nest); + + Assert.Equal(NestStatus.Draft, nest.Status); + }); + } + [Fact] public void BlankName_DisablesOk() { diff --git a/OpenNest/Forms/EditNestInfoForm.cs b/OpenNest/Forms/EditNestInfoForm.cs index bccc8ccd..26f450bd 100644 --- a/OpenNest/Forms/EditNestInfoForm.cs +++ b/OpenNest/Forms/EditNestInfoForm.cs @@ -19,7 +19,7 @@ namespace OpenNest.Forms foreach (var name in PostProcessorMaterials.Names) materialBox.Items.Add(name); - foreach (var status in Enum.GetValues()) + foreach (var status in NestStatuses.WorkflowOrder) statusBox.Items.Add(status); statusBox.SelectedItem = NestStatus.Quote; diff --git a/OpenNest/Forms/SavedNestsForm.cs b/OpenNest/Forms/SavedNestsForm.cs index 09a353de..d7b6907e 100644 --- a/OpenNest/Forms/SavedNestsForm.cs +++ b/OpenNest/Forms/SavedNestsForm.cs @@ -542,6 +542,7 @@ public sealed class SavedNestsForm : Form private static string FormatStatus(NestStatus status) => status switch { + NestStatus.Draft => "Draft", NestStatus.Quote => "Quote", NestStatus.ToBeCut => "To Be Cut", NestStatus.HasBeenCut => "Has Been Cut", diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index 2b44ab32..77ee1011 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -8,9 +8,10 @@ opening and saving with an older application loses that state. `nest.json` carries the workflow fields shown in the nest info dialog: `name`, `customer`, `dateCreated`, `dateLastModified`, `notes`, `material`, -`thickness`, plus `status` (`"Quote"`, `"ToBeCut"`, `"HasBeenCut"`) and `madeBy`. +`thickness`, plus `status` (`"Draft"`, `"Quote"`, `"ToBeCut"`, `"HasBeenCut"`) and `madeBy`. Status and made-by are additive: files written before them load as Quote with an -empty maker, and an unrecognized status value likewise falls back to Quote. +empty maker, and an unrecognized status value likewise falls back to Quote. Builds +before `Draft` therefore open a Draft nest as Quote, and saving it there keeps Quote. ## Programs and placements diff --git a/docs/nest-storage.md b/docs/nest-storage.md index b746f836..d56e34c7 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -67,7 +67,9 @@ with enums serialized as strings (`JsonSerializerDefaults.Web` + `JsonStringEnumConverter(JsonNamingPolicy.CamelCase)`), matching the rest of `OpenNest.Data`'s local JSON stores. Uploaded metadata (POST and both PUTs) must carry string text fields (`null` is 400) and a defined `status`: a name, or for -existing callers its integer value 0–2; an undefined value is 400 and stores nothing. +existing callers its integer value (0 quote, 1 toBeCut, 2 hasBeenCut, 3 draft); an +undefined value is 400 and stores nothing. A server older than `draft` refuses it, +so upgrade the server before desktops that can set it. | Field | Type | Notes | |---|---|---| @@ -77,7 +79,7 @@ existing callers its integer value 0–2; an undefined value is 400 and stores n | `dateCreated` / `dateModified` | datetime | Set by the client from the nest's own metadata. | | `material` | string | | | `thickness` | number | | -| `status` | `"quote"` \| `"toBeCut"` \| `"hasBeenCut"` | Default `quote`. | +| `status` | `"draft"` \| `"quote"` \| `"toBeCut"` \| `"hasBeenCut"` | Default `quote`. | | `plateCount` / `partCount` | integer | Computed client-side at save time (non-cutoff parts only). | | `comments` | string | | | `madeBy` | string | |