From 53db543d6b3b9457feedf39df7a369101a6adc26 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 13 Oct 2026 17:05:56 -0400 Subject: [PATCH] fix(io): read a malformed removedClamps value without failing the nest The optional per-plate removedClamps list was deserialized as a typed List, so a string or a list of numbers threw JsonException and the whole nest failed to open. It now reads leniently: a non-array value reads as none removed and non-string entries are skipped. The saved zones load either way, so a removed clamp's area is never silently re-blocked or lost. PM 9f3d676b-8a1c-4d9a-b877-b5e01e70c3f0 --- .../ClampRailPersistenceTests.cs | 40 +++++++++++++++++++ OpenNest.IO/NestFormat.cs | 40 ++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/OpenNest.IO.Tests/ClampRailPersistenceTests.cs b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs index 7b924acb..76995d2a 100644 --- a/OpenNest.IO.Tests/ClampRailPersistenceTests.cs +++ b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs @@ -246,6 +246,46 @@ public class ClampRailPersistenceTests Assert.Equal(7, plate.ClampZones.Count); } + // Slice 6 task 6.4 (Slice 3b quality review): optional removal fields read leniently + // and are only written when there is something to say. + + private static MemoryStream RemovedClamp3Nest() + { + var nest = new Nest("railed"); + nest.PlateDefaults.Size = new Size(48, 120); + nest.SetClampRail(Machine()); + var plate = nest.CreatePlate(); + plate.SetClampRail(Machine(), new[] { "clamp-3" }); + return Write(nest); + } + + private static MemoryStream WithPlateProperty(MemoryStream file, string name, string json) => + RewriteNestJson(file, text => + { + var root = JsonNode.Parse(text)!; + root["plates"]![0]![name] = JsonNode.Parse(json); + return root.ToJsonString(); + }); + + // PM 9f3d676b: a wrongly shaped removedClamps value must not fail the whole nest. + [Theory] + [InlineData("\"clamp-3\"", false)] + [InlineData("[-1]", false)] + [InlineData("{\"id\": \"clamp-3\"}", false)] + [InlineData("[-1, \"clamp-3\"]", true)] + public void MalformedRemovedClampsLoadWithoutFailingTheNest(string value, bool keepsRemoval) + { + var tampered = WithPlateProperty(RemovedClamp3Nest(), "removedClamps", value); + + var plate = Assert.Single(new NestReader(tampered).Read().Plates); + + // The saved zones always load, so the removed clamp's area is never re-blocked or lost. + Assert.Equal(7, plate.ClampZones.Count); + Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-3"); + Assert.Equal(keepsRemoval, plate.RemovedClamps.Contains("clamp-3")); + Assert.Equal(keepsRemoval, plate.ClampRail != null); + } + /// Removes a named property wherever it appears in the document, simulating a /// key an older writer never emitted. Rebuilds via so the result /// is always valid JSON regardless of whether the property was first, last or only one diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index a08b6893..3a124839 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -119,6 +119,41 @@ namespace OpenNest.IO } } + /// + /// Reads an optional list of strings without failing the nest: a non-array value + /// reads as an empty list and non-string entries are skipped. + /// + public sealed class LenientStringListConverter : JsonConverter> + { + public override List Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + using var document = JsonDocument.ParseValue(ref reader); + var list = new List(); + if (document.RootElement.ValueKind != JsonValueKind.Array) + return list; + foreach (var element in document.RootElement.EnumerateArray()) + if (element.ValueKind == JsonValueKind.String) + list.Add(element.GetString()); + return list; + } + + public override void Write( + Utf8JsonWriter writer, + List value, + JsonSerializerOptions options + ) + { + writer.WriteStartArray(); + foreach (var item in value) + writer.WriteStringValue(item); + writer.WriteEndArray(); + } + } + public record ClampRailDto { /// Clamp extent along the origin-side edge (X). @@ -172,8 +207,11 @@ namespace OpenNest.IO /// /// Rail clamp ids removed from this sheet as a last resort (D8). Missing or empty - /// means none were removed; already excludes them. + /// means none were removed; already excludes them. Read + /// leniently: a wrongly shaped value reads as none removed, non-string entries + /// are skipped, and the saved zones load either way. /// + [JsonConverter(typeof(LenientStringListConverter))] public List RemovedClamps { get; init; } = new(); }