feat(io): workbook part-quantity reader and cut-only import coverage

PartQuantityReader parses a Parts worksheet (Part Name / Qty Required)
with exact-name matching and strict rejection of invalid, fractional,
negative, or duplicate quantities. Cut-only import drops case-insensitive
ETCH/SCRIBE mark layers before geometry so bend detection cannot
regenerate them. Tests cover both.
This commit is contained in:
aj
2026-09-21 10:05:39 -04:00
parent f34b3c4449
commit 6f38c11383
3 changed files with 218 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
using ACadSharp;
using ACadSharp.IO;
using CSMath;
using OpenNest.CNC;
using CadLayer = ACadSharp.Tables.Layer;
using CadLine = ACadSharp.Entities.Line;
namespace OpenNest.IO.Tests;
public class CutOnlyImportTests
{
[Theory]
[InlineData("ETCH")]
[InlineData("Scribe")]
[InlineData("sCrIbE")]
public void CutOnlyImportDropsMarksBeforeGeometryAndDoesNotRegenerateBends(string layer)
{
var path = Path.Combine(Path.GetTempPath(), $"opennest-cut-only-{Guid.NewGuid()}.dxf");
try
{
var doc = new CadDocument();
var points = new[]
{
new XYZ(0, 0, 0),
new XYZ(10, 0, 0),
new XYZ(10, 10, 0),
new XYZ(0, 10, 0),
};
for (var i = 0; i < points.Length; i++)
doc.Entities.Add(new CadLine(points[i], points[(i + 1) % points.Length]));
// Marks outside the perimeter must not affect nesting bounds or become cut paths.
doc.Entities.Add(
new CadLine(new XYZ(-5, 5, 0), new XYZ(15, 5, 0)) { Layer = new CadLayer(layer) }
);
doc.Entities.Add(
new CadLine(new XYZ(0, 5, 0), new XYZ(10, 5, 0))
{
Layer = new CadLayer("BEND"),
LineType = new ACadSharp.Tables.LineType("CENTER"),
}
);
DxfWriter.Write(path, doc, false);
var drawing = CadImporter.ImportDrawing(
path,
new CadImportOptions { DetectBends = false, Quantity = 3 }
);
Assert.Empty(drawing.Bends);
Assert.Equal(4, drawing.SourceEntities.Count);
Assert.Equal(3, drawing.Quantity.Required);
Assert.Equal(100, drawing.Area, 6);
Assert.DoesNotContain(
drawing.Program.Codes.OfType<LinearMove>(),
m => m.Layer == LayerType.Scribe
);
var job = new NestJob(
new[] { DrawingJobMapper.FromDrawing("part", drawing, 3) },
new[] { new NestPlateStock("sheet", new OpenNest.Geometry.Size(30, 30), 1, 0.3) }
);
var result = new FixedStrategyNestingEngine("Strip").Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(3, Assert.Single(result.Fulfillment).Placed);
}
finally
{
File.Delete(path);
}
}
}
@@ -0,0 +1,89 @@
using ClosedXML.Excel;
using OpenNest.IO.Bom;
namespace OpenNest.IO.Tests;
public class PartQuantityReaderTests
{
[Fact]
public void ReadsExactNamesAndQuantitiesIncludingZeroDemand()
{
WithWorkbook(
sheet =>
{
sheet.Cell(2, 1).Value = "Part 01";
sheet.Cell(2, 2).Value = 58;
sheet.Cell(3, 1).Value = "Skeleton";
sheet.Cell(3, 2).Value = 0;
},
path =>
{
var result = PartQuantityReader.Read(path);
Assert.Equal(58, result["Part 01"]);
Assert.Equal(0, result["Skeleton"]);
Assert.Equal(2, result.Count);
}
);
}
[Theory]
[InlineData("1.5")]
[InlineData("-1")]
[InlineData("NaN")]
[InlineData("")]
[InlineData("2147483648")]
public void RejectsInvalidQuantityInsteadOfDefaultingOrTruncating(string value)
{
WithWorkbook(
sheet =>
{
sheet.Cell(2, 1).Value = "Part";
sheet.Cell(2, 2).Value = value;
},
path => Assert.Throws<InvalidDataException>(() => PartQuantityReader.Read(path))
);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void RejectsDuplicatePartsOrMissingQuantityHeader(bool duplicate)
{
WithWorkbook(
sheet =>
{
sheet.Cell(2, 1).Value = "Part";
sheet.Cell(2, 2).Value = 1;
if (duplicate)
{
sheet.Cell(3, 1).Value = "Part";
sheet.Cell(3, 2).Value = 2;
}
else
sheet.Cell(1, 2).Value = "Unrecognized";
},
path => Assert.Throws<InvalidDataException>(() => PartQuantityReader.Read(path))
);
}
private static void WithWorkbook(Action<IXLWorksheet> prepare, Action<string> check)
{
var path = Path.Combine(Path.GetTempPath(), $"opennest-quantities-{Guid.NewGuid()}.xlsx");
try
{
using (var workbook = new XLWorkbook())
{
var sheet = workbook.AddWorksheet("Parts");
sheet.Cell(1, 1).Value = "Part Name";
sheet.Cell(1, 2).Value = "Qty Required";
prepare(sheet);
workbook.SaveAs(path);
}
check(path);
}
finally
{
File.Delete(path);
}
}
}