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