using OpenNest.Converters; using OpenNest.Data; using OpenNest.Geometry; using OpenNest.IO; using OpenNest.IO.Bom; using OpenNest.Tests.Data; namespace OpenNest.Tests.Bom; /// /// BOM rows to library drawings to a prefilled set: the drawings a BOM puts in a set, /// and how each one is matched to the library (reuse, new revision, new drawing). /// public sealed class BomSetImportTests : IDisposable { private readonly string folder = Path.Combine(Path.GetTempPath(), "bom-set-" + Guid.NewGuid().ToString("N")); public BomSetImportTests() { Directory.CreateDirectory(folder); } public void Dispose() { if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); } [Fact] public void Build_SumsRowsOfOneDrawing_KeepsRowsWithoutDetails_AndSkipsTheRestWithAReason() { WriteSquare("PT01", 2); WriteSquare("PT02", 3); WriteSquare("PT03", 4); File.WriteAllText(Path.Combine(folder, "BAD.dxf"), "not a drawing"); var rows = BomImportRows.Build(new List { Item(1, "PT01", 2, "Steel", 0.25), Item(2, "PT02", 3, null, null), Item(3, "pt01.dxf", 5, " steel ", 0.25), Item(4, "PT01", 1, "Steel", 0.5), Item(5, "MISSING", 1, "Steel", 0.25), Item(6, null, 1, "Steel", 0.25), Item(7, "PT03", 0, "Steel", 0.25), Item(8, "BAD", 1, "Steel", 0.25), }, folder); var result = BomSetDrawings.Build(rows, Units.Inches); Assert.Equal(new[] { ("PT01", "Steel", 0.25, 7L), ("PT02", "", 0.0, 3L), ("PT01", "Steel", 0.5, 1L) }, result.Drawings.Select(d => (d.Name, d.Material, d.Thickness, d.Quantity))); Assert.Equal("PT01 (items 1, 3)", result.Drawings[0].Label); Assert.True(result.Drawings[1].NeedsDetails); Assert.False(result.Drawings[0].NeedsDetails); Assert.Equal(new[] { ("MISSING", "no drawing file found"), ("Item 6", "no file name"), ("PT03", "quantity is below 1") }, result.Skipped.Take(3).Select(s => (s.Name, s.Reason))); var bad = result.Skipped[3]; Assert.Equal("BAD", bad.Name); Assert.StartsWith("could not import the drawing", bad.Reason); // One file with two thicknesses is imported once. Assert.Same(result.Drawings[0].Drawing, result.Drawings[2].Drawing); } [Fact] public void GeometryKey_IgnoresTheFolder_ButNotGeometryOrUnits() { WriteSquare("PT01", 2); var rows = BomImportRows.Build(new List { Item(1, "PT01", 1, "Steel", 0.25) }, folder); var first = BomSetDrawings.Build(rows, Units.Inches).Drawings.Single().Package; var second = BomSetDrawings.Build(rows, Units.Inches).Drawings.Single().Package; var moved = Path.Combine(folder, "moved"); Directory.CreateDirectory(moved); File.Copy(Path.Combine(folder, "PT01.dxf"), Path.Combine(moved, "PT01.dxf")); var elsewhere = BomSetDrawings.Build(BomImportRows.Build( new List { Item(1, "PT01", 1, "Steel", 0.25) }, moved), Units.Inches).Drawings.Single().Package; // An unchanged file imports with the same entity ids, so the same folder gives the // same package; another folder records another source path, so only the key matches. Assert.Equal(first, second); Assert.NotEqual(first, elsewhere); Assert.Equal(DrawingPackage.GeometryKey(first), DrawingPackage.GeometryKey(elsewhere)); Assert.NotEqual(DrawingPackage.GeometryKey(first), DrawingPackage.GeometryKey(Square(3))); Assert.NotEqual(DrawingPackage.GeometryKey(first), DrawingPackage.GeometryKey(BomSetDrawings.Build(rows, Units.Millimeters).Drawings.Single().Package)); } [Fact] public async Task Plan_ReusesALibraryDrawingImportedFromTheSameFileBefore() { var earlier = Drawings(("PT01", 2)).Single(); var library = new InMemoryDrawingLibrary(); var stored = library.Seed("PT01", "Steel", 0.25, earlier.Package, customer: "Synthetic Co"); var plan = await DrawingSetImport.PlanAsync(library, Items(Drawings(("PT01", 2))), DrawingPackage.GeometryKey); var entry = Assert.Single(plan.Entries); Assert.Equal(DrawingSetImportAction.Reuse, entry.Action); Assert.Same(stored, entry.Existing); Assert.Equal(new[] { (stored.Id, 1) }, library.Downloads); Assert.False(plan.HasWrites); } [Fact] public async Task Plan_ReusesSameGeometry_RevisesChangedGeometry_AndCreatesTheRest_WithoutWriting() { var drawings = Drawings(("SAME", 2), ("CHANGED", 3), ("NEW", 4), ("OTHER-CUSTOMER", 5), ("ARCHIVED", 6)); var library = new InMemoryDrawingLibrary(); var same = library.Seed("SAME", "Steel", 0.25, drawings[0].Package, customer: "Synthetic Co"); var changed = library.Seed("changed", "STEEL", 0.25, Square(9), customer: "synthetic co"); library.Seed("OTHER-CUSTOMER", "Steel", 0.25, drawings[3].Package, customer: "Someone Else"); library.Seed("ARCHIVED", "Steel", 0.25, drawings[4].Package, customer: "Synthetic Co", archived: true); var plan = await DrawingSetImport.PlanAsync(library, Items(drawings), DrawingPackage.GeometryKey); Assert.Equal(new[] { DrawingSetImportAction.Reuse, DrawingSetImportAction.Revise, DrawingSetImportAction.Create, DrawingSetImportAction.Create, DrawingSetImportAction.Create, }, plan.Entries.Select(e => e.Action)); Assert.Same(same, plan.Entries[0].Existing); Assert.Same(changed, plan.Entries[1].Existing); Assert.Equal("In the library (revision 1)", plan.Entries[0].Describe()); Assert.Equal("New revision 2 (geometry changed)", plan.Entries[1].Describe()); Assert.Equal("New library drawing", plan.Entries[2].Describe()); Assert.True(plan.HasWrites); Assert.Equal((3, 1), (plan.Count(DrawingSetImportAction.Create), plan.Count(DrawingSetImportAction.Revise))); Assert.Contains("- CHANGED (item 2): revision 1 to 2", plan.DescribeWrites()); Assert.Empty(library.Writes); } [Fact] public async Task Plan_SkipsAmbiguousMatches_TwinsAndQuantitiesOutOfRange() { var drawings = Drawings(("AMBIGUOUS", 2), ("TWIN", 3)); var library = new InMemoryDrawingLibrary(); library.Seed("AMBIGUOUS", "Steel", 0.25, Square(7), customer: "Synthetic Co"); library.Seed("AMBIGUOUS", "Steel", 0.25, Square(8), customer: "Synthetic Co"); var items = Items(drawings); items.Add(new DrawingSetImportItem("twin again", Metadata(drawings[1]), drawings[1].Package, 1)); items.Add(new DrawingSetImportItem("too many", Metadata(drawings[0], name: "BIG"), drawings[0].Package, DrawingSetRules.MaxQuantity + 1L)); var plan = await DrawingSetImport.PlanAsync(library, items, DrawingPackage.GeometryKey); Assert.Equal(new[] { DrawingSetImportAction.Skip, DrawingSetImportAction.Create, DrawingSetImportAction.Skip, DrawingSetImportAction.Skip, }, plan.Entries.Select(e => e.Action)); Assert.Contains("2 library drawings have these details", plan.Entries[0].Reason); Assert.Contains("same name and details as TWIN (item 2)", plan.Entries[2].Reason); Assert.Contains("quantity", plan.Entries[3].Reason); } [Fact] public async Task Apply_WritesOnlyNewDrawingsAndRevisions_AndPrefillsTheSetWithBomQuantities() { var drawings = Drawings(("SAME", 2), ("CHANGED", 3), ("NEW", 4)); var library = new InMemoryDrawingLibrary(); var same = library.Seed("SAME", "Steel", 0.25, drawings[0].Package, customer: "Synthetic Co"); var changed = library.Seed("CHANGED", "Steel", 0.25, Square(9), customer: "Synthetic Co"); var sets = new InMemoryDrawingSets(); var edit = new DrawingSetEditSession(sets, null, "synthetic"); var plan = await DrawingSetImport.PlanAsync(library, Items(drawings, quantities: new[] { 2L, 6L, 1L }), DrawingPackage.GeometryKey); var written = await plan.ApplyAsync(library, edit); Assert.Equal(new[] { ("update", changed.Id), ("upload", plan.Entries[2].NewId) }, library.Writes); Assert.Equal(new[] { changed.Id, plan.Entries[2].NewId }, written.Select(r => r.Id)); var revised = library.Get(changed.Id); Assert.Equal((2, "Seeded notes", "synthetic operator"), (revised.Revision, revised.Notes, revised.SavedBy)); Assert.Equal(InMemoryDrawingLibrary.Sha(drawings[1].Package), revised.PackageSha256); var created = library.Get(plan.Entries[2].NewId); Assert.Equal(("NEW", "Synthetic Co", "Steel", 0.25), (created.Name, created.Customer, created.MaterialName, created.Thickness)); Assert.Equal(new[] { (same.Id, 2), (changed.Id, 6), (created.Id, 1) }, edit.Members.Select(m => (m.DrawingId, m.Quantity))); Assert.Empty(sets.Creates); } [Fact] public async Task Apply_StopsAtAFailedWrite_LeavesTheSetUntouched_AndImportingAgainReusesWhatWasStored() { var drawings = Drawings(("FIRST", 2), ("SECOND", 3)); var library = new InMemoryDrawingLibrary { FailWrite = 1 }; var edit = new DrawingSetEditSession(new InMemoryDrawingSets(), null, "synthetic"); var plan = await DrawingSetImport.PlanAsync(library, Items(drawings), DrawingPackage.GeometryKey); var failure = await Assert.ThrowsAsync(() => plan.ApplyAsync(library, edit)); Assert.Equal("FIRST", Assert.Single(failure.Written).Name); Assert.Contains("Already stored: FIRST", failure.Message); Assert.Empty(edit.Members); Assert.Single(library.Current); library.FailWrite = null; var again = await DrawingSetImport.PlanAsync(library, Items(drawings), DrawingPackage.GeometryKey); Assert.Equal(new[] { DrawingSetImportAction.Reuse, DrawingSetImportAction.Create }, again.Entries.Select(e => e.Action)); } [Fact] public async Task Plan_DrawingsWithoutMaterialOrThickness_StillImport() { WriteSquare("BARE", 2); var rows = BomImportRows.Build(new List { Item(1, "BARE", 4, null, null) }, folder); var library = new InMemoryDrawingLibrary(); var edit = new DrawingSetEditSession(new InMemoryDrawingSets(), null, "synthetic"); var plan = await DrawingSetImport.PlanAsync(library, Items(BomSetDrawings.Build(rows, Units.Inches).Drawings), DrawingPackage.GeometryKey); await plan.ApplyAsync(library, edit); var member = Assert.Single(edit.Members); Assert.Equal(("", 0.0, 4), (member.Drawing!.MaterialName, member.Drawing.Thickness, member.Quantity)); } private IReadOnlyList Drawings(params (string Name, double Size)[] files) { var items = new List(); foreach (var (name, size) in files) { WriteSquare(name, size); items.Add(Item(items.Count + 1, name, 1, "Steel", 0.25)); } return BomSetDrawings.Build(BomImportRows.Build(items, folder), Units.Inches).Drawings; } // What the desktop's Import BOM dialog sends for each drawing. private static List Items(IEnumerable drawings, long[]? quantities = null) => drawings.Select((d, i) => new DrawingSetImportItem(d.Label, Metadata(d), d.Package, quantities?[i] ?? d.Quantity)) .ToList(); private static DrawingLibraryRecord Metadata(BomSetDrawing drawing, string? name = null) => new() { Name = name ?? drawing.Name, Customer = "Synthetic Co", MaterialName = drawing.Material, Thickness = drawing.Thickness, Units = Units.Inches, CutType = CutType.Laser, Settings = DrawingNestingSettings.FromDrawing(drawing.Drawing), SavedBy = "synthetic operator", }; private static BomItem Item(int itemNum, string? fileName, int? qty, string? material, double? thickness) => new() { ItemNum = itemNum, FileName = fileName, Qty = qty, Material = material, Thickness = thickness, }; private byte[] Square(double size) { var name = "SQUARE-" + size; WriteSquare(name, size); var rows = BomImportRows.Build(new List { Item(1, name, 1, "Steel", 0.25) }, folder); return BomSetDrawings.Build(rows, Units.Inches).Drawings.Single().Package; } private void WriteSquare(string name, double size) { var shape = new Shape(); shape.Entities.Add(new Line(new Vector(0, 0), new Vector(size, 0))); shape.Entities.Add(new Line(new Vector(size, 0), new Vector(size, size))); shape.Entities.Add(new Line(new Vector(size, size), new Vector(0, size))); shape.Entities.Add(new Line(new Vector(0, size), new Vector(0, 0))); Dxf.ExportProgram(ConvertGeometry.ToProgram(shape), Path.Combine(folder, name + ".dxf")); } }