From 87672543cd947b87f03318f6b9de658d6b89007d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 24 Oct 2026 14:54:07 -0400 Subject: [PATCH] feat(desktop): import a BOM into a drawing set Drawing Sets > More > Import BOM reads a BOM and its drawing folder, matches each drawing to the library by its D4 identity and geometry (reuse, new revision, or new drawing) and opens the set editor with a new set prefilled with the BOM quantities. Reading only queries the library; Create Set lists the uploads and revisions and asks before storing them, and the set is stored only by the editor's Save. Parts with no material or thickness still import and are flagged for Edit Drawing. DrawingPackage.GeometryKey compares geometry independent of the fresh entity ids each DXF import assigns. In Database mode File > Import BOM opens this flow instead of creating nests directly; File mode is unchanged. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/DrawingSetImport.cs | 319 ++++++++++++++++ OpenNest.IO/Bom/BomImportRows.cs | 14 + OpenNest.IO/Bom/BomSetDrawings.cs | 192 ++++++++++ OpenNest.IO/DrawingPackage.cs | 49 +++ OpenNest.Tests/Bom/BomSetImportTests.cs | 272 ++++++++++++++ OpenNest.Tests/Data/InMemoryDrawingLibrary.cs | 161 +++++++++ .../Forms/DrawingSetFormsTests.cs | 5 +- .../Forms/ImportBomToSetFormTests.cs | 221 ++++++++++++ .../OpenNest.WinForms.Tests.csproj | 1 + .../Controls/DrawingSetBrowser.Designer.cs | 17 +- OpenNest/Controls/DrawingSetBrowser.cs | 50 ++- OpenNest/Forms/BomImportForm.cs | 7 +- OpenNest/Forms/DrawingSetsForm.cs | 12 + OpenNest/Forms/ImportBomToSetForm.Designer.cs | 339 ++++++++++++++++++ OpenNest/Forms/ImportBomToSetForm.cs | 320 +++++++++++++++++ OpenNest/Forms/MainForm.File.cs | 17 +- docs/nest-file-format.md | 7 + docs/nest-storage.md | 54 ++- scripts/windows-acceptance.json | 13 +- 19 files changed, 2055 insertions(+), 15 deletions(-) create mode 100644 OpenNest.Data/DrawingSetImport.cs create mode 100644 OpenNest.IO/Bom/BomSetDrawings.cs create mode 100644 OpenNest.Tests/Bom/BomSetImportTests.cs create mode 100644 OpenNest.Tests/Data/InMemoryDrawingLibrary.cs create mode 100644 OpenNest.WinForms.Tests/Forms/ImportBomToSetFormTests.cs create mode 100644 OpenNest/Forms/ImportBomToSetForm.Designer.cs create mode 100644 OpenNest/Forms/ImportBomToSetForm.cs diff --git a/OpenNest.Data/DrawingSetImport.cs b/OpenNest.Data/DrawingSetImport.cs new file mode 100644 index 00000000..ed296b8e --- /dev/null +++ b/OpenNest.Data/DrawingSetImport.cs @@ -0,0 +1,319 @@ +using System.Security.Cryptography; + +namespace OpenNest.Data; + +/// A drawing to import into a set: its library details, package and quantity per set. +public sealed class DrawingSetImportItem +{ + public DrawingSetImportItem(string label, DrawingLibraryRecord metadata, byte[] package, long quantity) + { + Label = label ?? ""; + Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); + Package = package ?? throw new ArgumentNullException(nameof(package)); + Quantity = quantity; + } + + /// How the operator knows the drawing, such as "PT01 (items 3, 7)". + public string Label { get; } + + /// The details a new library drawing gets; customer, name, material, grade, thickness, units and cut type are its identity (D4). + public DrawingLibraryRecord Metadata { get; } + + /// The canonical drawing package, compared by geometry with the library's. + public byte[] Package { get; } + + public long Quantity { get; } +} + +/// What an import does with one drawing. +public enum DrawingSetImportAction +{ + /// An active library drawing with the same identity holds the same geometry: it is used as it is. + Reuse, + + /// One active library drawing has the same identity but other geometry: it gains the next revision. + Revise, + + /// No active library drawing has this identity: a new one is uploaded. + Create, + + /// The drawing cannot be imported; says why. + Skip, +} + +/// One planned drawing: the action and the library drawing it uses or revises. +public sealed class DrawingSetImportEntry +{ + internal DrawingSetImportEntry(DrawingSetImportItem item, DrawingSetImportAction action, + DrawingLibraryRecord? existing, string reason, Guid newId = default) + { + Item = item; + Action = action; + Existing = existing; + Reason = reason; + NewId = newId; + } + + public DrawingSetImportItem Item { get; } + + public DrawingSetImportAction Action { get; } + + /// The library drawing reused or revised, as listed when planned. + public DrawingLibraryRecord? Existing { get; } + + /// Why a skipped drawing is left out; empty otherwise. + public string Reason { get; } + + /// The id a created drawing is uploaded under, allocated when planned. + public Guid NewId { get; } + + /// The operator-facing account of the action, such as "New revision 4 (geometry changed)". + public string Describe() => Action switch + { + DrawingSetImportAction.Reuse => $"In the library (revision {Existing!.Revision})", + DrawingSetImportAction.Revise => $"New revision {Existing!.Revision + 1} (geometry changed)", + DrawingSetImportAction.Create => "New library drawing", + _ => "Not imported: " + Reason, + }; +} + +/// A library write failed part-way; lists what was stored before it. +public sealed class DrawingSetImportException : IOException +{ + internal DrawingSetImportException(string message, IReadOnlyList written, Exception inner) + : base(message, inner) + { + Written = written; + } + + public IReadOnlyList Written { get; } +} + +/// +/// Imports drawings (for example a BOM's) into a new drawing set through the library. +/// only reads: it matches each drawing to the active library +/// drawings with its identity (D4: normalized customer, name, material and grade, exact +/// thickness, units and cut type) and compares geometry. Same identity and +/// geometry: reuse the drawing. Same identity, other geometry: the next revision of that +/// drawing. No match: a new drawing. Several matches with none holding the geometry are +/// skipped, as are invalid items and a second item with the identity of an earlier one. +/// +/// is the only step that writes, and only the uploads and +/// revisions the plan lists; the caller confirms them first. It then adds every +/// imported drawing to a , which stores nothing +/// until the operator saves the set. A failed write stops the import with the drawings +/// already stored listed; planning the same drawings again reuses them. +/// +/// +public sealed class DrawingSetImport +{ + private DrawingSetImport(IReadOnlyList entries) + { + Entries = entries; + } + + /// Every item, in the order given. + public IReadOnlyList Entries { get; } + + public IEnumerable Imported => Entries.Where(e => e.Action != DrawingSetImportAction.Skip); + + public int Count(DrawingSetImportAction action) => Entries.Count(e => e.Action == action); + + /// True when applying stores anything in the library. + public bool HasWrites => Entries.Any(e => e.Action is DrawingSetImportAction.Create or DrawingSetImportAction.Revise); + + /// + /// A key equal for packages with the same geometry (OpenNest.IO.DrawingPackage.GeometryKey): + /// a file read from another folder records another path, and older packages carry random + /// entity ids, so equal hashes are not required. It may + /// throw for an unreadable package. + /// + public static async Task PlanAsync(IDrawingLibrary library, IEnumerable items, + Func geometryKey, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(library); + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(geometryKey); + + var entries = new List(); + var planned = new List(); + foreach (var item in items) + { + var reason = DrawingLibraryRules.GetMetadataError(item.Metadata) + ?? DrawingLibraryRules.GetPackageError(item.Package) + ?? (item.Quantity < 1 || item.Quantity > DrawingSetRules.MaxQuantity + ? $"quantity {item.Quantity:N0} is outside 1 to {DrawingSetRules.MaxQuantity:N0}" + : null); + var twin = planned.FirstOrDefault(p => DrawingLibraryDuplicates.SameDuplicateKey(p.Metadata, item.Metadata)); + if (reason == null && twin != null) + reason = $"same name and details as {twin.Label}; give one of them another material or thickness"; + if (reason != null) + { + entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Skip, null, reason)); + continue; + } + + planned.Add(item); + var matches = await DrawingLibraryDuplicates.FindMatchesAsync(library, item.Metadata, cancellationToken); + var same = await FindSameGeometryAsync(library, item, matches, geometryKey, cancellationToken); + if (same != null) + entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Reuse, same, "")); + else if (matches.Count == 1) + entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Revise, matches[0], "")); + else if (matches.Count > 1) + entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Skip, null, + $"{matches.Count} library drawings have these details; add the right one in the set editor")); + else + entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Create, null, "", Guid.NewGuid())); + } + + return new DrawingSetImport(entries); + } + + // The first match holding the item's geometry: by package hash, otherwise by downloading + // the match's current package and comparing geometry keys. + private static async Task FindSameGeometryAsync(IDrawingLibrary library, + DrawingSetImportItem item, IReadOnlyList matches, Func geometryKey, + CancellationToken cancellationToken) + { + if (matches.Count == 0) + return null; + var hash = Sha256(item.Package); + var same = matches.FirstOrDefault(m => string.Equals(m.PackageSha256, hash, StringComparison.OrdinalIgnoreCase)); + if (same != null) + return same; + + var key = geometryKey(item.Package); + foreach (var match in matches) + { + var stored = await library.GetRevisionPackageAsync(match.Id, match.Revision, cancellationToken); + if (stored == null) + continue; + try + { + if (geometryKey(stored) == key) + return match; + } + catch (InvalidDataException) + { + // An unreadable stored package is not the same geometry; revising replaces it. + } + } + + return null; + } + + /// The library changes makes, one line each, for the operator to confirm. + public string DescribeWrites() + { + var lines = new List(); + var created = Entries.Where(e => e.Action == DrawingSetImportAction.Create).ToList(); + var revised = Entries.Where(e => e.Action == DrawingSetImportAction.Revise).ToList(); + if (created.Count > 0) + { + lines.Add($"{created.Count} new library drawing{(created.Count == 1 ? "" : "s")}:"); + lines.AddRange(created.Select(e => "- " + e.Item.Label)); + } + + if (revised.Count > 0) + { + if (lines.Count > 0) + lines.Add(""); + lines.Add($"{revised.Count} new revision{(revised.Count == 1 ? "" : "s")} of library drawings whose geometry changed:"); + lines.AddRange(revised.Select(e => $"- {e.Item.Label}: revision {e.Existing!.Revision} to {e.Existing.Revision + 1}")); + } + + return string.Join(Environment.NewLine, lines); + } + + /// + /// Stores the planned uploads and revisions, in order, then adds every imported drawing + /// to at its quantity. A revision is guarded by the revision it + /// was planned against and keeps that drawing's details, notes and settings. When a + /// write fails, is left unchanged and a + /// lists what was stored before it. + /// + public async Task> ApplyAsync(IDrawingLibraryStore library, + DrawingSetEditSession edit, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(library); + ArgumentNullException.ThrowIfNull(edit); + + var written = new List(); + var members = new List<(DrawingLibraryRecord Record, int Quantity)>(); + foreach (var entry in Imported) + { + DrawingLibraryRecord record; + try + { + record = entry.Action switch + { + DrawingSetImportAction.Create => await library.UploadAsync(Created(entry), entry.Item.Package, cancellationToken), + DrawingSetImportAction.Revise => await library.UpdatePackageAsync(entry.Existing!.Id, + Revised(entry.Existing, entry.Item.Metadata.SavedBy), entry.Item.Package, entry.Existing.Revision, cancellationToken), + _ => entry.Existing!, + }; + } + catch (Exception ex) when (ex is IOException or HttpRequestException or TaskCanceledException) + { + var stored = written.Count == 0 + ? "Nothing was stored in the library." + : $"Already stored: {string.Join(", ", written.Select(r => r.Name))}. Importing again reuses them."; + throw new DrawingSetImportException( + $"Could not store '{entry.Item.Label}' in the drawing library: {ex.Message}{Environment.NewLine}{stored}", + written, ex); + } + + var expectedId = entry.Action == DrawingSetImportAction.Create ? entry.NewId : entry.Existing!.Id; + if (record.Id != expectedId) + throw new DrawingSetImportException( + $"The nest server returned a different library drawing than '{entry.Item.Label}'. Import again to check what was stored.", + written, new IOException("Unexpected drawing id.")); + if (entry.Action != DrawingSetImportAction.Reuse) + written.Add(record); + members.Add((record, (int)entry.Item.Quantity)); + } + + foreach (var (record, quantity) in members) + edit.AddDrawings(new[] { record }, quantity); + return written; + } + + private static DrawingLibraryRecord Created(DrawingSetImportEntry entry) + { + var record = Copy(entry.Item.Metadata); + record.Id = entry.NewId; + record.Revision = 0; + record.Thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness); + record.Archived = false; + return record; + } + + // The identity already matches; keep the stored spelling, notes and settings. + private static DrawingLibraryRecord Revised(DrawingLibraryRecord existing, string savedBy) + { + var record = Copy(existing); + record.Revision = 0; + record.SavedBy = savedBy; + return record; + } + + private static DrawingLibraryRecord Copy(DrawingLibraryRecord record) => new() + { + Id = record.Id, + Revision = record.Revision, + Name = record.Name, + Customer = record.Customer, + MaterialName = record.MaterialName, + MaterialGrade = record.MaterialGrade, + Thickness = record.Thickness, + Units = record.Units, + CutType = record.CutType, + Notes = record.Notes, + Settings = record.Settings, + Archived = record.Archived, + SavedBy = record.SavedBy, + }; + + private static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); +} diff --git a/OpenNest.IO/Bom/BomImportRows.cs b/OpenNest.IO/Bom/BomImportRows.cs index 005c6f3e..234b9151 100644 --- a/OpenNest.IO/Bom/BomImportRows.cs +++ b/OpenNest.IO/Bom/BomImportRows.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.IO; namespace OpenNest.IO.Bom; @@ -41,4 +43,16 @@ public static class BomImportRows return rows; } + + /// + /// The job a BOM file is for: its file name without the extension or a trailing + /// " BOM" ("Frame A BOM.xlsx" is "Frame A"). + /// + public static string JobName(string bomPath) + { + var name = Path.GetFileNameWithoutExtension(bomPath ?? ""); + if (name.EndsWith(" BOM", StringComparison.OrdinalIgnoreCase)) + name = name.Substring(0, name.Length - 4).TrimEnd(); + return name; + } } diff --git a/OpenNest.IO/Bom/BomSetDrawings.cs b/OpenNest.IO/Bom/BomSetDrawings.cs new file mode 100644 index 00000000..9b8592f9 --- /dev/null +++ b/OpenNest.IO/Bom/BomSetDrawings.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace OpenNest.IO.Bom; + +/// +/// One drawing a BOM puts in a drawing set: a drawing file with one material and +/// thickness, its BOM rows' total quantity and its canonical library package. +/// +public sealed class BomSetDrawing +{ + internal BomSetDrawing( + string name, + string material, + double thickness, + long quantity, + string dxfPath, + IReadOnlyList rows, + Drawing drawing, + byte[] package + ) + { + Name = name; + Material = material; + Thickness = thickness; + Quantity = quantity; + DxfPath = dxfPath; + Rows = rows; + Drawing = drawing; + Package = package; + } + + /// The drawing file's name without its extension. + public string Name { get; } + + /// The BOM material, trimmed; blank when the BOM has none. + public string Material { get; } + + /// The BOM thickness, or 0 when the BOM has none. + public double Thickness { get; } + + /// The total quantity of the rows that name this drawing. + public long Quantity { get; } + + public string DxfPath { get; } + + /// The BOM rows this drawing came from, in BOM order. + public IReadOnlyList Rows { get; } + + public Drawing Drawing { get; } + + /// The drawing as a canonical . + public byte[] Package { get; } + + /// True when the drawing still needs a material or thickness before it can be released. + public bool NeedsDetails => Material.Length == 0 || Thickness <= 0; + + /// "PT01 (items 3, 7)", or just the name when the rows have no item numbers. + public string Label + { + get + { + var items = Rows.Where(r => r.ItemNum != null).Select(r => r.ItemNum.Value.ToString()).ToList(); + return items.Count == 0 + ? Name + : $"{Name} (item{(items.Count == 1 ? "" : "s")} {string.Join(", ", items)})"; + } + } +} + +/// A BOM row that does not reach the set, and why. +public sealed record BomSetSkip(BomPartRow Row, string Name, string Reason); + +/// +/// The drawings a BOM puts in a drawing set and the rows it leaves out. Unlike +/// Create Nests, rows with no material or thickness are kept: the set editor and +/// Edit Drawing fix them before release. +/// +public sealed class BomSetDrawings +{ + private BomSetDrawings(IReadOnlyList drawings, IReadOnlyList skipped) + { + Drawings = drawings; + Skipped = skipped; + } + + /// The drawings in the order their first row appears in the BOM. + public IReadOnlyList Drawings { get; } + + public IReadOnlyList Skipped { get; } + + /// + /// Imports each drawing file named by once and packages it in + /// . Rows naming the same file with the same material + /// (ignoring case) and thickness become one drawing needing their total quantity. + /// Rows with no file name, no drawing file or a quantity below 1, and files that + /// fail to import, are skipped with a reason. + /// + public static BomSetDrawings Build(IEnumerable rows, Units units) + { + ArgumentNullException.ThrowIfNull(rows); + var skipped = new List(); + var kept = new List(); + foreach (var row in rows) + { + switch (row.Status) + { + case BomRowStatus.NoFileName: + skipped.Add(new BomSetSkip(row, RowName(row), "no file name")); + break; + case BomRowStatus.NoDrawing: + skipped.Add(new BomSetSkip(row, RowName(row), "no drawing file found")); + break; + case BomRowStatus.NeedsQuantity: + skipped.Add(new BomSetSkip(row, RowName(row), "quantity is below 1")); + break; + default: + kept.Add(row); + break; + } + } + + var drawings = new List(); + var imported = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); + var groups = kept.GroupBy(r => ( + Path: r.DxfPath, + Material: (r.Material ?? "").Trim().ToUpperInvariant(), + Thickness: ThicknessOf(r) + )); + foreach (var group in groups) + { + var first = group.First(); + if (!imported.TryGetValue(first.DxfPath, out var file)) + { + file = Import(first.DxfPath, units); + imported[first.DxfPath] = file; + } + + if (file.Error != null) + { + skipped.AddRange(group.Select(r => new BomSetSkip(r, RowName(r), file.Error))); + continue; + } + + drawings.Add( + new BomSetDrawing( + Path.GetFileNameWithoutExtension(first.DxfPath), + (first.Material ?? "").Trim(), + group.Key.Thickness, + group.Sum(r => (long)(r.Qty ?? 1)), + first.DxfPath, + group.ToList(), + file.Drawing, + file.Package + ) + ); + } + + return new BomSetDrawings(drawings, skipped); + } + + private static (Drawing, byte[], string) Import(string path, Units units) + { + if (!File.Exists(path)) + return (null, null, "drawing file not found"); + try + { + var drawing = CadImporter.ImportDrawing(path); + return (drawing, DrawingPackage.Write(drawing, units), null); + } + catch (Exception ex) + { + return (null, null, $"could not import the drawing: {ex.Message}"); + } + } + + private static double ThicknessOf(BomPartRow row) => + row.Thickness is double t && double.IsFinite(t) && t > 0 ? t : 0; + + private static string RowName(BomPartRow row) + { + if (!string.IsNullOrWhiteSpace(row.FileName)) + return row.FileName.Trim(); + if (row.ItemNum != null) + return $"Item {row.ItemNum}"; + return string.IsNullOrWhiteSpace(row.Description) ? "(unnamed row)" : row.Description.Trim(); + } +} diff --git a/OpenNest.IO/DrawingPackage.cs b/OpenNest.IO/DrawingPackage.cs index 47d5a14b..fb7784c0 100644 --- a/OpenNest.IO/DrawingPackage.cs +++ b/OpenNest.IO/DrawingPackage.cs @@ -3,9 +3,11 @@ using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; namespace OpenNest.IO { @@ -70,6 +72,53 @@ namespace OpenNest.IO } } + /// + /// A key that is equal for packages holding the same geometry: the SHA-256 of the + /// package's entries with the source file path left out and every GUID (entity ids + /// and the references to them) replaced by its order of first appearance. An unchanged + /// DXF now imports with the same entity ids, but from another folder it records another + /// path, and packages saved before stable ids carry random ones, so their hashes differ + /// while this key does not. + /// + public static string GeometryKey(byte[] package) + { + ArgumentNullException.ThrowIfNull(package); + var text = new StringBuilder(); + try + { + using var archive = new ZipArchive(new MemoryStream(package, writable: false), ZipArchiveMode.Read); + foreach (var entry in archive.Entries.OrderBy(e => e.FullName, StringComparer.Ordinal)) + { + var content = ReadText(entry); + if (entry.FullName == "nest.json" && JsonNode.Parse(content) is JsonObject root) + { + foreach (var drawing in (root["drawings"] as JsonArray ?? new JsonArray()).OfType()) + (drawing["source"] as JsonObject)?.Remove("path"); + content = root.ToJsonString(); + } + + text.Append(entry.FullName).Append('\n').Append(content).Append('\n'); + } + } + catch (Exception ex) when (ex is JsonException || (ex is IOException && ex is not InvalidDataException)) + { + throw new InvalidDataException("The drawing package is not a readable .nest archive.", ex); + } + + var ids = new Dictionary(StringComparer.OrdinalIgnoreCase); + var normalized = GuidPattern.Replace(text.ToString(), match => + { + if (!ids.TryGetValue(match.Value, out var index)) + ids[match.Value] = index = ids.Count; + return "#" + index; + }); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant(); + } + + private static readonly Regex GuidPattern = new( + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + // Checks the archive's own declaration before the lenient nest reader sees it: that // reader defaults unknown units and drops empty plates, which a package must not hide. private static Units CheckShape(byte[] package) diff --git a/OpenNest.Tests/Bom/BomSetImportTests.cs b/OpenNest.Tests/Bom/BomSetImportTests.cs new file mode 100644 index 00000000..5faa86f7 --- /dev/null +++ b/OpenNest.Tests/Bom/BomSetImportTests.cs @@ -0,0 +1,272 @@ +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")); + } +} diff --git a/OpenNest.Tests/Data/InMemoryDrawingLibrary.cs b/OpenNest.Tests/Data/InMemoryDrawingLibrary.cs new file mode 100644 index 00000000..95edf76e --- /dev/null +++ b/OpenNest.Tests/Data/InMemoryDrawingLibrary.cs @@ -0,0 +1,161 @@ +using System.Security.Cryptography; +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +/// +/// An in-memory drawing library server: revisions, If-Match conflicts, archive, the +/// query filters the server applies, a write log and a switch that fails the next write. +/// Linked into OpenNest.WinForms.Tests as well. +/// +internal sealed class InMemoryDrawingLibrary : IDrawingLibraryStore +{ + private readonly Dictionary> _revisions = new(); + private readonly Dictionary _packages = new(); + private DateTime _clock = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// Fail the write with this index (0 is the next write) without storing it. + public int? FailWrite { get; set; } + + /// Every write requested, in order: ("upload" | "update", id). + public List<(string Kind, Guid Id)> Writes { get; } = new(); + + public IEnumerable Current => _revisions.Values.Select(r => r[^1]); + + public DrawingLibraryRecord Get(Guid id) => _revisions[id][^1]; + + /// Stores revision 1 directly, as though another operator had saved it. + public DrawingLibraryRecord Seed(string name, string material, double thickness, byte[] package, + string customer = "", Units units = Units.Inches, CutType cutType = CutType.Laser, bool archived = false) + { + var id = Guid.NewGuid(); + var record = Stored(new DrawingLibraryRecord + { + Name = name, + Customer = customer, + MaterialName = material, + Thickness = thickness, + Units = units, + CutType = cutType, + Notes = "Seeded notes", + }, id, 1, package); + record.Archived = archived; + _revisions[id] = new List { record }; + return record; + } + + public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) + { + var search = query.NormalizedSearch; + var matches = Current + .Where(r => query.IncludeArchived || !r.Archived) + .Where(r => search.Length == 0 || new[] { r.Name, r.Customer, r.Notes } + .Any(text => text.Contains(search, StringComparison.OrdinalIgnoreCase))) + .Where(r => Filter(query.Customer, r.Customer) && Filter(query.MaterialName, r.MaterialName) + && Filter(query.MaterialGrade, r.MaterialGrade)) + .Where(r => query.Thickness is not { } t || r.Thickness == DrawingLibraryRules.NormalizeThickness(t)) + .Where(r => query.CutType is not { } c || r.CutType == c) + .OrderByDescending(r => r.SavedAt) + .ToList(); + return Task.FromResult(new DrawingLibraryPage + { + Items = matches.Skip(query.Offset).Take(query.Limit).ToList(), + Total = matches.Count, + Offset = query.Offset, + Limit = query.Limit, + }); + } + + /// Every package download requested: (id, revision). + public List<(Guid Id, int Revision)> Downloads { get; } = new(); + + public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) + { + Downloads.Add((id, revision)); + var record = _revisions.TryGetValue(id, out var list) ? list.FirstOrDefault(r => r.Revision == revision) : null; + return Task.FromResult(record != null && _packages.TryGetValue(record.PackageSha256, out var package) + ? package.ToArray() + : null); + } + + public Task GetAsync(Guid id, CancellationToken cancellationToken = default) => + Task.FromResult(_revisions.TryGetValue(id, out var list) ? list[^1] : null); + + public Task GetRevisionsAsync(Guid id, int offset = 0, + int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) + { + if (!_revisions.TryGetValue(id, out var list)) + return Task.FromResult(null); + var newestFirst = Enumerable.Reverse(list).ToList(); + return Task.FromResult(new DrawingLibraryPage + { + Items = newestFirst.Skip(offset).Take(limit).ToList(), + Total = list.Count, + Offset = offset, + Limit = limit, + }); + } + + public Task UploadAsync(DrawingLibraryRecord record, byte[] package, + CancellationToken cancellationToken = default) + { + Write("upload", record.Id); + if (_revisions.TryGetValue(record.Id, out var existing)) + throw new DrawingLibraryConflictException(existing[^1]); + _revisions[record.Id] = new List { Stored(record, record.Id, 1, package) }; + return Task.FromResult(Get(record.Id)); + } + + public Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package, + int expectedRevision, CancellationToken cancellationToken = default) + { + Write("update", id); + var current = Get(id); + if (current.Revision != expectedRevision || current.Archived) + throw new DrawingLibraryConflictException(current); + var next = Stored(record, id, current.Revision + 1, package); + _revisions[id].Add(next); + return Task.FromResult(next); + } + + public Task UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public static string Sha(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + private void Write(string kind, Guid id) + { + var index = Writes.Count; + Writes.Add((kind, id)); + if (FailWrite == index) + throw new HttpRequestException("Synthetic connection failure."); + } + + private DrawingLibraryRecord Stored(DrawingLibraryRecord metadata, Guid id, int revision, byte[] package) + { + _clock = _clock.AddMinutes(1); + _packages[Sha(package)] = package.ToArray(); + return new DrawingLibraryRecord + { + Id = id, + Revision = revision, + Name = metadata.Name, + Customer = metadata.Customer, + MaterialName = metadata.MaterialName, + MaterialGrade = metadata.MaterialGrade, + Thickness = DrawingLibraryRules.NormalizeThickness(metadata.Thickness), + Units = metadata.Units, + CutType = metadata.CutType, + Notes = metadata.Notes, + Settings = metadata.Settings, + SavedBy = metadata.SavedBy, + PackageSha256 = Sha(package), + PackageSize = package.Length, + SavedAt = _clock, + }; + } + + private static bool Filter(string filter, string value) => + string.IsNullOrWhiteSpace(filter) || SharedListNames.Key(filter) == SharedListNames.Key(value); +} diff --git a/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs b/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs index 55822a26..1b79b533 100644 --- a/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs +++ b/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs @@ -340,13 +340,14 @@ public class DrawingSetFormsTests Assert.Equal(3, sets.Current.Single().Revision); PumpUntil(() => archive.Text == "&Archive", "the restored set's menu"); - // Nothing selected: only New Set stays enabled. + // Nothing selected: only New Set and More's Import BOM stay enabled. Field(browser, "showArchivedCheckBox").Checked = false; PumpUntil(() => grid.Rows.Count == 1, "the active set"); grid.CurrentCell = null; Assert.True(Field