fix(io): read a malformed removedClamps value without failing the nest

The optional per-plate removedClamps list was deserialized as a typed
List<string>, 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
This commit is contained in:
aj committed 2026-10-13 18:15:49 -04:00
1 parent 2b208c9b6a
commit 53db543d6b
2 files changed
+79 -1

No files matched your search

@@ -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);
}
/// <summary>Removes a named property wherever it appears in the document, simulating a
/// key an older writer never emitted. Rebuilds via <see cref="JsonNode"/> so the result
/// is always valid JSON regardless of whether the property was first, last or only one
+39 -1
View File
@@ -119,6 +119,41 @@ namespace OpenNest.IO
}
}
/// <summary>
/// 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.
/// </summary>
public sealed class LenientStringListConverter : JsonConverter<List<string>>
{
public override List<string> Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
)
{
using var document = JsonDocument.ParseValue(ref reader);
var list = new List<string>();
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<string> value,
JsonSerializerOptions options
)
{
writer.WriteStartArray();
foreach (var item in value)
writer.WriteStringValue(item);
writer.WriteEndArray();
}
}
public record ClampRailDto
{
/// <summary>Clamp extent along the origin-side edge (X).</summary>
@@ -172,8 +207,11 @@ namespace OpenNest.IO
/// <summary>
/// Rail clamp ids removed from this sheet as a last resort (D8). Missing or empty
/// means none were removed; <see cref="Clamps"/> already excludes them.
/// means none were removed; <see cref="Clamps"/> 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.
/// </summary>
[JsonConverter(typeof(LenientStringListConverter))]
public List<string> RemovedClamps { get; init; } = new();
}