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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-20 12:31:58 -04:00
1 parent 1b323e2380
commit 28ee04a480
9 files changed
+90 -6

No files matched your search

+23
View File
@@ -4,10 +4,33 @@ namespace OpenNest
/// Shop-floor workflow state of a saved nest, persisted as a string in
/// <c>nest.json</c> so unknown future values can fall back safely.
/// </summary>
/// <remarks>
/// 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 <see cref="NestStatuses.WorkflowOrder"/>, not declaration order.
/// </remarks>
public enum NestStatus
{
Quote,
ToBeCut,
HasBeenCut,
/// <summary>
/// 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.
/// </summary>
Draft,
}
public static class NestStatuses
{
/// <summary>Every status in the order a nest moves through them.</summary>
public static readonly NestStatus[] WorkflowOrder =
{
NestStatus.Draft,
NestStatus.Quote,
NestStatus.ToBeCut,
NestStatus.HasBeenCut,
};
}
}
@@ -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();
+1 -1
View File
@@ -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;
}
@@ -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<NestStatus>().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()
{
@@ -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<ComboBox>(form.Controls.Find("statusBox", true).Single());
Assert.Equal(NestStatuses.WorkflowOrder, statusBox.Items.Cast<NestStatus>());
form.Status = NestStatus.Draft;
form.SaveNestInfo(nest);
Assert.Equal(NestStatus.Draft, nest.Status);
});
}
[Fact]
public void BlankName_DisablesOk()
{
+1 -1
View File
@@ -19,7 +19,7 @@ namespace OpenNest.Forms
foreach (var name in PostProcessorMaterials.Names)
materialBox.Items.Add(name);
foreach (var status in Enum.GetValues<NestStatus>())
foreach (var status in NestStatuses.WorkflowOrder)
statusBox.Items.Add(status);
statusBox.SelectedItem = NestStatus.Quote;
+1
View File
@@ -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",
+3 -2
View File
@@ -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
+4 -2
View File
@@ -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 | |