feat(bom): validate edited part quantities

Prepare BOM rows for quantity editing:

- BomQuantity.TryParse accepts only a whole number of at least 1
  (surrounding spaces allowed; no sign, decimal, exponent, separator or
  overflow).
- BomPartRow keeps the BOM's own quantity (BomQty), refuses invalid
  typed quantities in TrySetQuantity, and raises PropertyChanged for
  Material, Thickness and Qty plus the status they affect, so a bound
  grid can refresh.
- A blank BOM quantity becomes 1, as it was imported before, but is now
  visible in the row and counted in the summary ("had no BOM quantity
  (1 used)"). A BOM quantity below 1 is no longer imported as 0: the
  row reads "Needs quantity" until the operator enters one.

BomImportFlowTests covers BOM items to rows to an edited quantity to the
group total and the created nest's required quantity. Mutation reds:
a zero floor, signed or decimal input, ignoring the quantity in the
status, not assuming 1 for blanks, setting unparsed text and dropping
the status notification each fail the Bom tests.
This commit is contained in:
aj committed 2026-10-05 17:56:35 -04:00
1 parent 0ac72fad70
commit 81d3253580
11 files changed
+376 -18

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ public sealed class BomImportGroup
public IReadOnlyList<BomPartRow> Parts { get; }
/// <summary>Sum of the rows' quantities; a blank quantity counts as 0.</summary>
/// <summary>Sum of the rows' quantities.</summary>
public int TotalQty => Parts.Sum(p => p.Qty ?? 0);
public string Key => BomImportGroups.Key(Material, Thickness);
+5 -2
View File
@@ -11,7 +11,8 @@ public static class BomImportRows
/// <summary>
/// Returns one row per BOM item, in BOM order. Every row with a file
/// name gets the drawing it matches in <paramref name="dxfFolder"/>,
/// whether or not its material and thickness are filled in.
/// whether or not its material and thickness are filled in. A blank
/// BOM quantity becomes 1, marked as assumed.
/// </summary>
public static List<BomPartRow> Build(List<BomItem> items, string dxfFolder)
{
@@ -25,7 +26,9 @@ public static class BomImportRows
{
ItemNum = item.ItemNum,
FileName = item.FileName,
Qty = item.Qty,
BomQty = item.Qty,
Qty = item.Qty ?? 1,
QtyAssumed = item.Qty == null,
Description = item.Description,
Material = item.Material,
Thickness = item.Thickness,
+11 -2
View File
@@ -7,18 +7,27 @@ namespace OpenNest.IO.Bom;
public static class BomImportSummary
{
/// <summary>
/// For example "12 ready, 2 need a thickness, 1 no drawing found".
/// For example "12 ready, 2 need a thickness, 1 no drawing found, 3 had
/// no BOM quantity (1 used)".
/// </summary>
public static string Describe(IEnumerable<BomPartRow> rows)
{
var counts = rows.GroupBy(r => r.Status).ToDictionary(g => g.Key, g => g.Count());
var list = rows.ToList();
var counts = list.GroupBy(r => r.Status).ToDictionary(g => g.Key, g => g.Count());
int Count(BomRowStatus status) => counts.TryGetValue(status, out var n) ? n : 0;
var parts = new List<string> { $"{Count(BomRowStatus.Ready)} ready" };
Add(parts, Count(BomRowStatus.NeedsMaterial), "needs a material", "need a material");
Add(parts, Count(BomRowStatus.NeedsThickness), "needs a thickness", "need a thickness");
Add(parts, Count(BomRowStatus.NeedsQuantity), "needs a quantity", "need a quantity");
Add(parts, Count(BomRowStatus.NoDrawing), "no drawing found", "no drawing found");
Add(parts, Count(BomRowStatus.NoFileName), "no file name", "no file name");
Add(
parts,
list.Count(r => r.IsEditable && r.QtyAssumed),
"had no BOM quantity (1 used)",
"had no BOM quantity (1 used)"
);
return string.Join(", ", parts);
}
+77 -7
View File
@@ -1,11 +1,14 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace OpenNest.IO.Bom;
/// <summary>Whether a BOM row can be imported, and if not, why.</summary>
public enum BomRowStatus
{
/// <summary>Drawing found, material and thickness set: the row is imported.</summary>
/// <summary>Drawing found and every value set: the row is imported.</summary>
Ready,
/// <summary>Drawing found but the material is blank.</summary>
@@ -14,6 +17,9 @@ public enum BomRowStatus
/// <summary>Drawing found but the thickness is blank, zero or negative.</summary>
NeedsThickness,
/// <summary>Drawing found but the quantity is below 1.</summary>
NeedsQuantity,
/// <summary>No drawing file matches the row's file name.</summary>
NoDrawing,
@@ -24,21 +30,55 @@ public enum BomRowStatus
/// <summary>
/// One BOM line as the import dialog shows and edits it: the values read
/// from the BOM, the drawing file it resolved to and whether it can be
/// imported.
/// imported. Changing Material, Thickness or Qty raises
/// <see cref="PropertyChanged"/> for that value and for the status.
/// </summary>
public class BomPartRow
public class BomPartRow : INotifyPropertyChanged
{
private string material;
private double? thickness;
private int? qty;
public event PropertyChangedEventHandler PropertyChanged;
public int? ItemNum { get; set; }
public string FileName { get; set; }
public int? Qty { get; set; }
/// <summary>The quantity the BOM gave, unchanged by edits; null when blank.</summary>
public int? BomQty { get; set; }
/// <summary>
/// The quantity to import. Set it only to a value accepted by
/// <see cref="BomQuantity.TryParse"/>, or use <see cref="TrySetQuantity"/>.
/// Setting it clears <see cref="QtyAssumed"/>.
/// </summary>
public int? Qty
{
get => qty;
set
{
QtyAssumed = false;
Set(ref qty, value);
}
}
/// <summary>True while Qty is the 1 used for a blank BOM quantity.</summary>
public bool QtyAssumed { get; set; }
public string Description { get; set; }
public string Material { get; set; }
public string Material
{
get => material;
set => Set(ref material, value);
}
public double? Thickness { get; set; }
public double? Thickness
{
get => thickness;
set => Set(ref thickness, value);
}
/// <summary>The matched drawing file, or null when none was found.</summary>
public string DxfPath { get; set; }
@@ -53,8 +93,10 @@ public class BomPartRow
return BomRowStatus.NoDrawing;
if (string.IsNullOrWhiteSpace(Material))
return BomRowStatus.NeedsMaterial;
if (Thickness is not double thickness || !double.IsFinite(thickness) || thickness <= 0)
if (Thickness is not double t || !double.IsFinite(t) || t <= 0)
return BomRowStatus.NeedsThickness;
if (Qty is not int q || q < 1)
return BomRowStatus.NeedsQuantity;
return BomRowStatus.Ready;
}
}
@@ -67,14 +109,42 @@ public class BomPartRow
/// </summary>
public bool IsEditable => Status is not (BomRowStatus.NoFileName or BomRowStatus.NoDrawing);
/// <summary>
/// Sets Qty from typed text when <see cref="BomQuantity.TryParse"/>
/// accepts it; otherwise leaves the row unchanged and returns false.
/// </summary>
public bool TrySetQuantity(string text)
{
if (!BomQuantity.TryParse(text, out var value))
return false;
Qty = value;
return true;
}
public static string Describe(BomRowStatus status) =>
status switch
{
BomRowStatus.Ready => "Ready",
BomRowStatus.NeedsMaterial => "Needs material",
BomRowStatus.NeedsThickness => "Needs thickness",
BomRowStatus.NeedsQuantity => "Needs quantity",
BomRowStatus.NoDrawing => "No drawing found",
BomRowStatus.NoFileName => "No file name",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
private void Set<T>(ref T field, T value, [CallerMemberName] string name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return;
field = value;
OnPropertyChanged(name);
OnPropertyChanged(nameof(Status));
OnPropertyChanged(nameof(StatusText));
}
private void OnPropertyChanged(string name) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
+29
View File
@@ -0,0 +1,29 @@
using System.Globalization;
namespace OpenNest.IO.Bom;
/// <summary>Reads a part quantity the operator typed in the import dialog.</summary>
public static class BomQuantity
{
/// <summary>
/// Accepts a whole number of at least 1, with optional surrounding
/// spaces. Rejects blanks, signs, decimals, exponents, group
/// separators and values above <see cref="int.MaxValue"/>.
/// </summary>
public static bool TryParse(string text, out int quantity)
{
if (
int.TryParse(
text,
NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite,
CultureInfo.CurrentCulture,
out quantity
)
&& quantity >= 1
)
return true;
quantity = 0;
return false;
}
}
+83
View File
@@ -0,0 +1,83 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.IO.Bom;
namespace OpenNest.Tests.Bom;
/// <summary>
/// BOM items to rows to an operator edit to groups to the created nest,
/// the same path the import dialog takes.
/// </summary>
public sealed class BomImportFlowTests : IDisposable
{
private readonly string folder = Path.Combine(
Path.GetTempPath(),
"bom-flow-" + Guid.NewGuid().ToString("N")
);
public BomImportFlowTests()
{
Directory.CreateDirectory(folder);
WriteSquare("PT01");
WriteSquare("PT02");
}
public void Dispose()
{
if (Directory.Exists(folder))
Directory.Delete(folder, recursive: true);
}
[Fact]
public void EditedQuantity_ReachesTheGroupTotalAndTheNestDrawing()
{
var rows = BomImportRows.Build(new List<BomItem> { Item("PT01", 2), Item("PT02", null) }, folder);
Assert.True(rows[0].TrySetQuantity("5"));
Assert.False(rows[1].TrySetQuantity("0"));
var group = Assert.Single(BomImportGroups.Build(rows));
Assert.Equal(6, group.TotalQty);
var nest = Assert.IsType<Nest>(BomNestBuilder.Build(group, Plate(), "Job", null).Nest);
Assert.Equal(
new[] { ("PT01", 5), ("PT02", 1) },
nest.Drawings.OrderBy(d => d.Name).Select(d => (d.Name, d.Quantity.Required))
);
}
[Fact]
public void BomQuantityBelowOne_KeepsThePartOutUntilTheOperatorFixesIt()
{
var rows = BomImportRows.Build(new List<BomItem> { Item("PT01", 0), Item("PT02", 3) }, folder);
Assert.Equal(new[] { "PT02" }, Assert.Single(BomImportGroups.Build(rows)).Parts.Select(p => p.FileName));
Assert.True(rows[0].TrySetQuantity("4"));
var group = Assert.Single(BomImportGroups.Build(rows));
Assert.Equal(7, group.TotalQty);
}
private static BomItem Item(string fileName, int? qty) =>
new()
{
FileName = fileName,
Qty = qty,
Material = "Stainless",
Thickness = 0.25,
};
private static BomGroupPlateSettings Plate() => new() { PlateWidth = 60, PlateLength = 120 };
private void WriteSquare(string name)
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(2, 0)));
shape.Entities.Add(new Line(new Vector(2, 0), new Vector(2, 2)));
shape.Entities.Add(new Line(new Vector(2, 2), new Vector(0, 2)));
shape.Entities.Add(new Line(new Vector(0, 2), new Vector(0, 0)));
Dxf.ExportProgram(ConvertGeometry.ToProgram(shape), Path.Combine(folder, name + ".dxf"));
}
}
+4 -2
View File
@@ -11,7 +11,7 @@ public class BomImportGroupsTests
{
Row("PT01", "Stainless", 0.25, qty: 2),
Row("PT02", "Aluminum", 0.125, qty: 1),
Row("PT03", "STAINLESS", 0.25, qty: null),
Row("PT03", "STAINLESS", 0.25, qty: 3),
Row("PT04", "Stainless", 0.125, qty: 4),
Row("PT05", "Aluminum", 0.0625, qty: 3),
};
@@ -24,7 +24,7 @@ public class BomImportGroupsTests
);
var stainless = groups[3];
Assert.Equal(new[] { "PT01", "PT03" }, stainless.Parts.Select(p => p.FileName));
Assert.Equal(2, stainless.TotalQty);
Assert.Equal(5, stainless.TotalQty);
Assert.Equal(BomImportGroups.Key("stainless", 0.25), stainless.Key);
}
@@ -43,6 +43,8 @@ public class BomImportGroupsTests
Row("PT04", "Stainless", null),
noDrawing,
Row("PT06", "Stainless", 0),
Row("PT07", "Stainless", 0.25, qty: 0),
Row("PT08", "Stainless", 0.25, qty: null),
};
var groups = BomImportGroups.Build(rows);
+19 -1
View File
@@ -54,7 +54,11 @@ public sealed class BomImportRowsTests : IDisposable
Assert.Equal("Gusset", rows[0].Description);
Assert.Equal(2, rows[0].Qty);
Assert.Null(rows[3].Qty);
Assert.Equal(2, rows[0].BomQty);
Assert.False(rows[0].QtyAssumed);
Assert.Equal(1, rows[3].Qty);
Assert.Null(rows[3].BomQty);
Assert.True(rows[3].QtyAssumed);
Assert.Equal("Stainless", rows[0].Material);
Assert.Equal(0.25, rows[0].Thickness);
}
@@ -113,6 +117,20 @@ public sealed class BomImportRowsTests : IDisposable
Assert.Equal(BomRowStatus.NeedsMaterial, rows[0].Status);
}
[Theory]
[InlineData(0)]
[InlineData(-3)]
public void Build_RowWithBomQuantityBelowOne_NeedsAQuantity(int bomQty)
{
Touch("PT01.dxf");
var rows = BomImportRows.Build(new List<BomItem> { Item("PT01", qty: bomQty) }, folder);
Assert.Equal(bomQty, rows[0].Qty);
Assert.False(rows[0].QtyAssumed);
Assert.Equal(BomRowStatus.NeedsQuantity, rows[0].Status);
}
[Fact]
public void Build_WithMissingFolder_FindsNoDrawings()
{
+19 -2
View File
@@ -16,14 +16,29 @@ public class BomImportSummaryTests
Row("PT05", "Steel", null),
Row("PT06", "Steel", 0.25, dxfPath: null),
Row(null, "Steel", 0.25),
Row("PT08", "Steel", 0.25, qty: 0),
};
Assert.Equal(
"2 ready, 1 needs a material, 2 need a thickness, 1 no drawing found, 1 no file name",
"2 ready, 1 needs a material, 2 need a thickness, 1 needs a quantity, 1 no drawing found, 1 no file name",
BomImportSummary.Describe(rows)
);
}
[Fact]
public void Describe_CountsImportableRowsWhoseQuantityWasAssumed()
{
var assumed = Row("PT01", "Steel", 0.25);
assumed.QtyAssumed = true;
var noDrawing = Row("PT02", "Steel", 0.25, dxfPath: null);
noDrawing.QtyAssumed = true;
Assert.Equal(
"1 ready, 1 no drawing found, 1 had no BOM quantity (1 used)",
BomImportSummary.Describe(new[] { assumed, noDrawing })
);
}
[Fact]
public void Describe_WithEveryRowReady_ListsOnlyTheReadyCount()
{
@@ -35,7 +50,8 @@ public class BomImportSummaryTests
string? fileName,
string? material,
double? thickness,
string? dxfPath = "/d/part.dxf"
string? dxfPath = "/d/part.dxf",
int qty = 1
) =>
new()
{
@@ -43,5 +59,6 @@ public class BomImportSummaryTests
Material = material,
Thickness = thickness,
DxfPath = dxfPath,
Qty = qty,
};
}
+93 -1
View File
@@ -30,12 +30,26 @@ public class BomPartRowTests
DxfPath = dxfPath,
Material = material,
Thickness = thickness,
Qty = 1,
};
Assert.Equal(expected, row.Status);
Assert.Equal(expected is not (BomRowStatus.NoFileName or BomRowStatus.NoDrawing), row.IsEditable);
}
[Theory]
[InlineData(null)]
[InlineData(0)]
[InlineData(-2)]
public void Status_NeedsAQuantityOfAtLeastOne(int? qty)
{
var row = ReadyRow();
row.Qty = qty;
Assert.Equal(BomRowStatus.NeedsQuantity, row.Status);
Assert.True(row.IsEditable);
}
[Fact]
public void Status_ChangesWhenTheOperatorFillsInAValue()
{
@@ -46,16 +60,94 @@ public class BomPartRowTests
Assert.Equal(BomRowStatus.NeedsThickness, row.Status);
row.Thickness = 0.25;
Assert.Equal(BomRowStatus.NeedsQuantity, row.Status);
Assert.True(row.TrySetQuantity("3"));
Assert.Equal(BomRowStatus.Ready, row.Status);
Assert.Equal("Ready", row.StatusText);
}
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("2.5")]
[InlineData("")]
[InlineData("abc")]
public void TrySetQuantity_RefusesInvalidTextAndKeepsTheQuantity(string text)
{
var row = ReadyRow();
row.Qty = 4;
row.QtyAssumed = true;
var changes = Changes(row);
Assert.False(row.TrySetQuantity(text));
Assert.Equal(4, row.Qty);
Assert.True(row.QtyAssumed);
Assert.Empty(changes);
}
[Fact]
public void TrySetQuantity_SetsTheQuantityAndReportsTheChange()
{
var row = ReadyRow();
row.QtyAssumed = true;
var changes = Changes(row);
Assert.True(row.TrySetQuantity(" 12 "));
Assert.Equal(12, row.Qty);
Assert.False(row.QtyAssumed);
Assert.Equal(new[] { "Qty", "Status", "StatusText" }, changes);
}
[Fact]
public void EditingMaterialOrThickness_ReportsTheValueAndTheStatus()
{
var row = ReadyRow();
var changes = Changes(row);
row.Material = "Aluminum";
row.Thickness = 0.125;
row.Thickness = 0.125;
Assert.Equal(
new[] { "Material", "Status", "StatusText", "Thickness", "Status", "StatusText" },
changes
);
}
[Fact]
public void Describe_NamesEveryStatus()
{
Assert.Equal(
new[] { "Ready", "Needs material", "Needs thickness", "No drawing found", "No file name" },
new[]
{
"Ready",
"Needs material",
"Needs thickness",
"Needs quantity",
"No drawing found",
"No file name",
},
Enum.GetValues<BomRowStatus>().Select(BomPartRow.Describe)
);
}
private static BomPartRow ReadyRow() =>
new()
{
FileName = "PT01",
DxfPath = "/d/PT01.dxf",
Material = "Steel",
Thickness = 0.25,
Qty = 1,
};
private static List<string?> Changes(BomPartRow row)
{
var names = new List<string?>();
row.PropertyChanged += (_, e) => names.Add(e.PropertyName);
return names;
}
}
+35
View File
@@ -0,0 +1,35 @@
using OpenNest.IO.Bom;
namespace OpenNest.Tests.Bom;
public class BomQuantityTests
{
[Theory]
[InlineData("1", 1)]
[InlineData("5", 5)]
[InlineData(" 7 ", 7)]
[InlineData("2147483647", int.MaxValue)]
public void TryParse_AcceptsWholeNumbersOfAtLeastOne(string text, int expected)
{
Assert.True(BomQuantity.TryParse(text, out var quantity));
Assert.Equal(expected, quantity);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("0")]
[InlineData("-1")]
[InlineData("+3")]
[InlineData("2.5")]
[InlineData("1e3")]
[InlineData("1,000")]
[InlineData("abc")]
[InlineData("2147483648")]
public void TryParse_RejectsEverythingElse(string? text)
{
Assert.False(BomQuantity.TryParse(text!, out var quantity));
Assert.Equal(0, quantity);
}
}