diff --git a/OpenNest.IO/Bom/BomImportGroups.cs b/OpenNest.IO/Bom/BomImportGroups.cs index e295256..14e14ab 100644 --- a/OpenNest.IO/Bom/BomImportGroups.cs +++ b/OpenNest.IO/Bom/BomImportGroups.cs @@ -22,7 +22,7 @@ public sealed class BomImportGroup public IReadOnlyList Parts { get; } - /// Sum of the rows' quantities; a blank quantity counts as 0. + /// Sum of the rows' quantities. public int TotalQty => Parts.Sum(p => p.Qty ?? 0); public string Key => BomImportGroups.Key(Material, Thickness); diff --git a/OpenNest.IO/Bom/BomImportRows.cs b/OpenNest.IO/Bom/BomImportRows.cs index a127905..005c6f3 100644 --- a/OpenNest.IO/Bom/BomImportRows.cs +++ b/OpenNest.IO/Bom/BomImportRows.cs @@ -11,7 +11,8 @@ public static class BomImportRows /// /// Returns one row per BOM item, in BOM order. Every row with a file /// name gets the drawing it matches in , - /// 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. /// public static List Build(List 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, diff --git a/OpenNest.IO/Bom/BomImportSummary.cs b/OpenNest.IO/Bom/BomImportSummary.cs index 34d37fb..a2aecc7 100644 --- a/OpenNest.IO/Bom/BomImportSummary.cs +++ b/OpenNest.IO/Bom/BomImportSummary.cs @@ -7,18 +7,27 @@ namespace OpenNest.IO.Bom; public static class BomImportSummary { /// - /// 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)". /// public static string Describe(IEnumerable 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 { $"{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); } diff --git a/OpenNest.IO/Bom/BomPartRow.cs b/OpenNest.IO/Bom/BomPartRow.cs index 9c2dd53..015c007 100644 --- a/OpenNest.IO/Bom/BomPartRow.cs +++ b/OpenNest.IO/Bom/BomPartRow.cs @@ -1,11 +1,14 @@ using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; namespace OpenNest.IO.Bom; /// Whether a BOM row can be imported, and if not, why. public enum BomRowStatus { - /// Drawing found, material and thickness set: the row is imported. + /// Drawing found and every value set: the row is imported. Ready, /// Drawing found but the material is blank. @@ -14,6 +17,9 @@ public enum BomRowStatus /// Drawing found but the thickness is blank, zero or negative. NeedsThickness, + /// Drawing found but the quantity is below 1. + NeedsQuantity, + /// No drawing file matches the row's file name. NoDrawing, @@ -24,21 +30,55 @@ public enum BomRowStatus /// /// 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 +/// for that value and for the status. /// -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; } + /// The quantity the BOM gave, unchanged by edits; null when blank. + public int? BomQty { get; set; } + + /// + /// The quantity to import. Set it only to a value accepted by + /// , or use . + /// Setting it clears . + /// + public int? Qty + { + get => qty; + set + { + QtyAssumed = false; + Set(ref qty, value); + } + } + + /// True while Qty is the 1 used for a blank BOM quantity. + 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); + } /// The matched drawing file, or null when none was found. 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 /// public bool IsEditable => Status is not (BomRowStatus.NoFileName or BomRowStatus.NoDrawing); + /// + /// Sets Qty from typed text when + /// accepts it; otherwise leaves the row unchanged and returns false. + /// + 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(ref T field, T value, [CallerMemberName] string name = null) + { + if (EqualityComparer.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)); } diff --git a/OpenNest.IO/Bom/BomQuantity.cs b/OpenNest.IO/Bom/BomQuantity.cs new file mode 100644 index 0000000..ed3b9e0 --- /dev/null +++ b/OpenNest.IO/Bom/BomQuantity.cs @@ -0,0 +1,29 @@ +using System.Globalization; + +namespace OpenNest.IO.Bom; + +/// Reads a part quantity the operator typed in the import dialog. +public static class BomQuantity +{ + /// + /// Accepts a whole number of at least 1, with optional surrounding + /// spaces. Rejects blanks, signs, decimals, exponents, group + /// separators and values above . + /// + 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; + } +} diff --git a/OpenNest.Tests/Bom/BomImportFlowTests.cs b/OpenNest.Tests/Bom/BomImportFlowTests.cs new file mode 100644 index 0000000..e428a9b --- /dev/null +++ b/OpenNest.Tests/Bom/BomImportFlowTests.cs @@ -0,0 +1,83 @@ +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.IO; +using OpenNest.IO.Bom; + +namespace OpenNest.Tests.Bom; + +/// +/// BOM items to rows to an operator edit to groups to the created nest, +/// the same path the import dialog takes. +/// +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 { 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(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 { 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")); + } +} diff --git a/OpenNest.Tests/Bom/BomImportGroupsTests.cs b/OpenNest.Tests/Bom/BomImportGroupsTests.cs index 98548a4..cb3464e 100644 --- a/OpenNest.Tests/Bom/BomImportGroupsTests.cs +++ b/OpenNest.Tests/Bom/BomImportGroupsTests.cs @@ -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); diff --git a/OpenNest.Tests/Bom/BomImportRowsTests.cs b/OpenNest.Tests/Bom/BomImportRowsTests.cs index fdedb09..e440545 100644 --- a/OpenNest.Tests/Bom/BomImportRowsTests.cs +++ b/OpenNest.Tests/Bom/BomImportRowsTests.cs @@ -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 { 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() { diff --git a/OpenNest.Tests/Bom/BomImportSummaryTests.cs b/OpenNest.Tests/Bom/BomImportSummaryTests.cs index b159622..1a8ca46 100644 --- a/OpenNest.Tests/Bom/BomImportSummaryTests.cs +++ b/OpenNest.Tests/Bom/BomImportSummaryTests.cs @@ -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, }; } diff --git a/OpenNest.Tests/Bom/BomPartRowTests.cs b/OpenNest.Tests/Bom/BomPartRowTests.cs index 7c96b7d..80175d8 100644 --- a/OpenNest.Tests/Bom/BomPartRowTests.cs +++ b/OpenNest.Tests/Bom/BomPartRowTests.cs @@ -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().Select(BomPartRow.Describe) ); } + + private static BomPartRow ReadyRow() => + new() + { + FileName = "PT01", + DxfPath = "/d/PT01.dxf", + Material = "Steel", + Thickness = 0.25, + Qty = 1, + }; + + private static List Changes(BomPartRow row) + { + var names = new List(); + row.PropertyChanged += (_, e) => names.Add(e.PropertyName); + return names; + } } diff --git a/OpenNest.Tests/Bom/BomQuantityTests.cs b/OpenNest.Tests/Bom/BomQuantityTests.cs new file mode 100644 index 0000000..712f362 --- /dev/null +++ b/OpenNest.Tests/Bom/BomQuantityTests.cs @@ -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); + } +}