diff --git a/OpenNest.Data/LibraryDrawingInsert.cs b/OpenNest.Data/LibraryDrawingInsert.cs new file mode 100644 index 00000000..46a97ec5 --- /dev/null +++ b/OpenNest.Data/LibraryDrawingInsert.cs @@ -0,0 +1,204 @@ +using System.Globalization; +using System.Security.Cryptography; +using OpenNest; + +namespace OpenNest.Data; + +/// A downloaded library package as the caller read it (with DrawingPackage.Read). +public sealed class LibraryPackage +{ + public LibraryPackage(byte[] bytes, Drawing drawing, Units units, IReadOnlyList? warnings = null) + { + ArgumentNullException.ThrowIfNull(bytes); + ArgumentNullException.ThrowIfNull(drawing); + Sha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + Drawing = drawing; + Units = units; + Warnings = warnings ?? Array.Empty(); + } + + /// Lower-case hex SHA-256 of the downloaded bytes. + public string Sha256 { get; } + + /// The package's drawing; the insert adopts this instance as the nest's copy. + public Drawing Drawing { get; } + + public Units Units { get; } + + public IReadOnlyList Warnings { get; } +} + +public enum LibraryInsertKind +{ + /// The library drawing becomes a new nest drawing. + AddDrawing, + + /// The nest already holds this exact revision; only its quantity grows. + AddQuantity, + + /// Nothing may change; says why. + Refused, +} + +/// +/// Decides how one library drawing revision enters a nest, before anything changes: +/// a new exact copy carrying its library link, more quantity on the copy already there, +/// or a refusal. Material, grade or thickness that differ from the nest still allow the +/// add, but the drawing's cut pins are dropped (D3). Different units, a different revision +/// of the same drawing, or another drawing with the same name refuse it. +/// +public sealed class LibraryDrawingInsert +{ + private bool _applied; + + private LibraryDrawingInsert(Nest nest, LibraryInsertKind kind, Drawing? drawing, int quantity, + bool keepPins, IReadOnlyList warnings, string refusal) + { + Nest = nest; + Kind = kind; + Drawing = drawing; + Quantity = quantity; + KeepPins = keepPins; + Warnings = warnings; + Refusal = refusal; + } + + public Nest Nest { get; } + + public LibraryInsertKind Kind { get; } + + /// The new copy () or the existing one; null when refused. + public Drawing? Drawing { get; } + + /// The quantity added to the nest's required count. + public int Quantity { get; } + + /// + /// For a new copy, true when the library drawing's cut pins may be used in this nest: + /// the library material, grade and thickness match the nest's. An added quantity + /// leaves the existing copy as it is. + /// + public bool KeepPins { get; } + + /// Operator-facing notes to show before the add completes. + public IReadOnlyList Warnings { get; } + + /// Why nothing may change; empty unless is . + public string Refusal { get; } + + public bool IsRefused => Kind == LibraryInsertKind.Refused; + + public static LibraryDrawingInsert Plan(Nest nest, string serverUrl, DrawingLibraryRecord record, + LibraryPackage package, int quantity) + { + ArgumentNullException.ThrowIfNull(nest); + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(package); + ArgumentOutOfRangeException.ThrowIfLessThan(quantity, 1); + var serverKey = ServerUrl.Key(serverUrl); + if (serverKey.Length == 0) + throw new ArgumentException("A library insert needs the server the drawing came from.", nameof(serverUrl)); + + var label = Describe(record); + if (!string.Equals(package.Sha256, record.PackageSha256, StringComparison.OrdinalIgnoreCase)) + return Refuse(nest, $"The downloaded package for {label} does not match the library record. Try again."); + if (package.Units != record.Units) + return Refuse(nest, $"The package for {label} is drawn in {UnitName(package.Units)}, " + + $"but its library record says {UnitName(record.Units)}."); + if (package.Units != nest.Units) + return Refuse(nest, $"{label} is drawn in {UnitName(package.Units)} and this nest is in " + + $"{UnitName(nest.Units)}. Converting units is not supported."); + + var link = new DrawingLibraryLink(serverKey, record.Id, record.Revision, record.CutType, + record.Thickness, record.MaterialName, record.MaterialGrade, record.Units, record.Notes, + record.PackageSha256.ToLowerInvariant()); + + var sameRevision = nest.Drawings.FirstOrDefault(d => d.Library?.IsSameRevision(link) == true); + if (sameRevision != null) + return new LibraryDrawingInsert(nest, LibraryInsertKind.AddQuantity, sameRevision, quantity, + keepPins: false, Array.Empty(), ""); + + var otherRevision = nest.Drawings.FirstOrDefault(d => d.Library is { } l + && l.Id == link.Id && string.Equals(l.ServerKey, link.ServerKey, StringComparison.Ordinal)); + if (otherRevision != null) + return Refuse(nest, $"This nest already uses revision {otherRevision.Library!.Revision} of this " + + $"library drawing as '{otherRevision.Name}'. Use Update Drawings to change its revision."); + + // DrawingCollection is keyed by name, so a second drawing with this name would be lost. + var sameName = nest.Drawings.FirstOrDefault(d => string.Equals(d.Name, record.Name, StringComparison.Ordinal)); + if (sameName != null) + return Refuse(nest, $"This nest already has a drawing named '{sameName.Name}' " + + $"({DescribeExisting(sameName)}), so {label} was not added. Rename one of them first."); + + var differences = Differences(nest, record); + var warnings = package.Warnings.ToList(); + if (differences.Count > 0) + warnings.Add($"{label} differs from this nest: {string.Join("; ", differences)}. The drawing is " + + "added, but its drawing cut pins are dropped and the nest's cutting settings apply."); + + var drawing = package.Drawing; + drawing.Name = record.Name; + drawing.Customer = record.Customer; + drawing.Material = new Material(record.MaterialName, record.MaterialGrade, drawing.Material?.Density ?? 0); + drawing.Quantity.Required = quantity; + drawing.Library = link; + return new LibraryDrawingInsert(nest, LibraryInsertKind.AddDrawing, drawing, quantity, + keepPins: differences.Count == 0, warnings, ""); + } + + /// Changes the planned nest; call at most once, and only for an insert that is not refused. + public void Apply() + { + if (IsRefused) + throw new InvalidOperationException(Refusal); + if (_applied) + throw new InvalidOperationException("This library insert was already applied."); + + if (Kind == LibraryInsertKind.AddQuantity) + { + if (!Nest.Drawings.Any(d => ReferenceEquals(d, Drawing))) + throw new InvalidOperationException($"'{Drawing!.Name}' is no longer in this nest."); + Drawing!.Quantity.Required += Quantity; + } + else if (!Nest.Drawings.Add(Drawing!)) + { + throw new InvalidOperationException($"This nest already has a drawing named '{Drawing!.Name}'."); + } + + _applied = true; + } + + private static LibraryDrawingInsert Refuse(Nest nest, string reason) => + new(nest, LibraryInsertKind.Refused, null, 0, keepPins: false, Array.Empty(), reason); + + private static List Differences(Nest nest, DrawingLibraryRecord record) + { + var differences = new List(); + var material = nest.Material ?? new Material(); + if (SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name)) + differences.Add($"material {Text(record.MaterialName)} (nest {Text(material.Name)})"); + if (SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade)) + differences.Add($"grade {Text(record.MaterialGrade)} (nest {Text(material.Grade)})"); + var thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness); + var nestThickness = DrawingLibraryRules.NormalizeThickness(nest.Thickness); + if (thickness != nestThickness) + differences.Add($"thickness {Number(thickness)} (nest {Number(nestThickness)})"); + return differences; + } + + private static string Describe(DrawingLibraryRecord record) => + string.IsNullOrWhiteSpace(record.Customer) + ? $"library drawing '{record.Name}' revision {record.Revision}" + : $"library drawing '{record.Name}' ({record.Customer}) revision {record.Revision}"; + + private static string DescribeExisting(Drawing drawing) => + drawing.Library is { } link + ? $"library revision {link.Revision} from {link.ServerKey}" + : "not from the drawing library"; + + private static string Text(string? value) => string.IsNullOrWhiteSpace(value) ? "not set" : value.Trim(); + + private static string Number(double value) => value.ToString("0.####", CultureInfo.CurrentCulture); + + private static string UnitName(Units units) => units.ToString().ToLowerInvariant(); +} diff --git a/OpenNest.Data/ServerUrl.cs b/OpenNest.Data/ServerUrl.cs index 0b4d25a1..06f3de01 100644 --- a/OpenNest.Data/ServerUrl.cs +++ b/OpenNest.Data/ServerUrl.cs @@ -3,6 +3,12 @@ namespace OpenNest.Data; /// Base-address validation shared by the nest server's HTTP clients. internal static class ServerUrl { + /// + /// The key that binds saved nests and library links to a server: the address trimmed, + /// without trailing slashes. Two settings that differ only that way name the same server. + /// + public static string Key(string? url) => (url ?? "").Trim().TrimEnd('/'); + /// /// Returns the absolute http(s) base address with exactly one trailing slash, so /// relative combines replace no path segment. Throws for any other address. diff --git a/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs b/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs new file mode 100644 index 00000000..ec14d9ca --- /dev/null +++ b/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs @@ -0,0 +1,235 @@ +using System.Security.Cryptography; +using OpenNest.Data; +using OpenNest.Shapes; + +namespace OpenNest.Tests.Data; + +public class LibraryDrawingInsertTests +{ + private const string Server = "http://library.test:8090"; + private static readonly Guid LibraryId = Guid.Parse("3f1e2d4c-5b6a-4987-8a1b-2c3d4e5f6a7b"); + private static readonly byte[] PackageBytes = { (byte)'P', (byte)'K', 3, 4, 1, 2 }; + + private static Nest MatchingNest() => new("Synthetic job") + { + Units = Units.Inches, + Thickness = 0.25, + Material = new Material("Synthetic Steel", "Grade A"), + }; + + private static DrawingLibraryRecord Record(int revision = 3, string name = "SYN-101", byte[]? package = null) => new() + { + Id = LibraryId, + Revision = revision, + Name = name, + Customer = "Synthetic customer", + MaterialName = "Synthetic Steel", + MaterialGrade = "Grade A", + Thickness = 0.25, + Units = Units.Inches, + CutType = CutType.Plasma, + Notes = "Deburr", + PackageSha256 = Convert.ToHexString(SHA256.HashData(package ?? PackageBytes)).ToLowerInvariant(), + }; + + private static LibraryPackage Package(Units units = Units.Inches, IReadOnlyList? warnings = null) + { + var drawing = new RectangleShape { Name = "old package name", Length = 4, Width = 2 }.GetDrawing(); + drawing.Material = new Material("Package material", "Package grade", 0.284); + drawing.Customer = "Package customer"; + return new LibraryPackage(PackageBytes, drawing, units, warnings); + } + + [Fact] + public void Matching_AddsAnExactCopyCarryingTheLinkAndKeepsPins() + { + var nest = MatchingNest(); + var package = Package(); + + var insert = LibraryDrawingInsert.Plan(nest, Server + "/", Record(), package, 5); + + Assert.Equal(LibraryInsertKind.AddDrawing, insert.Kind); + Assert.True(insert.KeepPins); + Assert.Empty(insert.Warnings); + Assert.Empty(nest.Drawings); + + insert.Apply(); + + var drawing = Assert.Single(nest.Drawings); + Assert.Same(package.Drawing, drawing); + Assert.Equal(("SYN-101", "Synthetic customer", 5), (drawing.Name, drawing.Customer, drawing.Quantity.Required)); + Assert.Equal(("Synthetic Steel", "Grade A", 0.284), (drawing.Material.Name, drawing.Material.Grade, drawing.Material.Density)); + var link = drawing.Library!; + Assert.Equal( + (Server, LibraryId, 3, CutType.Plasma, 0.25, "Synthetic Steel", "Grade A", Units.Inches, "Deburr", Record().PackageSha256), + (link.ServerKey, link.Id, link.Revision, link.CutType, link.Thickness, link.MaterialName, link.MaterialGrade, + link.Units, link.Notes, link.PackageSha256)); + } + + [Fact] + public void SpacingCaseAndThicknessRounding_StillMatch() + { + var nest = MatchingNest(); + nest.Material = new Material(" synthetic STEEL ", "grade a"); + nest.Thickness = 0.250004; + + var insert = LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1); + + Assert.True(insert.KeepPins); + Assert.Empty(insert.Warnings); + } + + [Theory] + [InlineData("Other Steel", "Grade A", 0.25, "material Synthetic Steel (nest Other Steel)")] + [InlineData("Synthetic Steel", "Grade B", 0.25, "grade Grade A (nest Grade B)")] + [InlineData("Synthetic Steel", "Grade A", 0.1875, "thickness 0.25 (nest 0.1875)")] + [InlineData("", "", 0.0, "material Synthetic Steel (nest not set)")] + public void Mismatch_AddsButDropsPinsAndNamesTheDifference(string material, string grade, double thickness, string expected) + { + var nest = MatchingNest(); + nest.Material = new Material(material, grade); + nest.Thickness = thickness; + + var insert = LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 2); + + Assert.Equal(LibraryInsertKind.AddDrawing, insert.Kind); + Assert.False(insert.KeepPins); + var warning = Assert.Single(insert.Warnings); + Assert.Contains(expected, warning); + Assert.Contains("'SYN-101'", warning); + Assert.Contains("cut pins are dropped", warning); + insert.Apply(); + Assert.Single(nest.Drawings); + } + + [Fact] + public void EveryDifference_IsNamed() + { + var nest = new Nest("Other job") { Units = Units.Inches, Thickness = 0.1875, Material = new Material("Other Steel", "Grade B") }; + + var warning = Assert.Single(LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1).Warnings); + + Assert.Contains("material Synthetic Steel (nest Other Steel)", warning); + Assert.Contains("grade Grade A (nest Grade B)", warning); + Assert.Contains("thickness 0.25 (nest 0.1875)", warning); + } + + [Fact] + public void PackageWarnings_AreShownBeforeTheAdd() + { + var insert = LibraryDrawingInsert.Plan(MatchingNest(), Server, Record(), + Package(warnings: new[] { "reader note" }), 1); + + Assert.Equal(new[] { "reader note" }, insert.Warnings); + Assert.True(insert.KeepPins); + } + + [Fact] + public void DifferentUnits_AreRefused() + { + var nest = MatchingNest(); + nest.Units = Units.Millimeters; + + var insert = LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1); + + AssertRefused(insert, nest, "inches", "millimeters"); + } + + [Fact] + public void PackageDisagreeingWithItsRecord_IsRefused() + { + var nest = MatchingNest(); + + AssertRefused(LibraryDrawingInsert.Plan(nest, Server, Record(), Package(Units.Millimeters), 1), nest, "library record says inches"); + AssertRefused(LibraryDrawingInsert.Plan(nest, Server, Record(package: new byte[] { 9 }), Package(), 1), nest, "does not match"); + } + + [Fact] + public void SameRevisionAlreadyInTheNest_AddsQuantityOnly() + { + var nest = MatchingNest(); + LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 4).Apply(); + var existing = Assert.Single(nest.Drawings); + + var insert = LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 3); + + Assert.Equal(LibraryInsertKind.AddQuantity, insert.Kind); + Assert.Same(existing, insert.Drawing); + insert.Apply(); + Assert.Same(existing, Assert.Single(nest.Drawings)); + Assert.Equal(7, existing.Quantity.Required); + } + + [Fact] + public void SameRevisionRenamedInTheNest_StillAddsQuantity() + { + var nest = MatchingNest(); + var first = LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1); + first.Drawing!.Name = "renamed"; + first.Apply(); + + var insert = LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1); + + Assert.Equal(LibraryInsertKind.AddQuantity, insert.Kind); + } + + [Fact] + public void OtherRevisionOfTheSameDrawing_IsRefused() + { + var nest = MatchingNest(); + var first = LibraryDrawingInsert.Plan(nest, Server, Record(revision: 2), Package(), 1); + first.Drawing!.Name = "renamed"; + first.Apply(); + + AssertRefused(LibraryDrawingInsert.Plan(nest, Server, Record(revision: 3), Package(), 1), nest, + "revision 2", "'renamed'", "Update Drawings"); + } + + [Fact] + public void DifferentDrawingWithTheSameName_IsRefusedNamingBoth() + { + var nest = MatchingNest(); + nest.Drawings.Add(new RectangleShape { Name = "SYN-101", Length = 1, Width = 1 }.GetDrawing()); + + AssertRefused(LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1), nest, + "'SYN-101' (not from the drawing library)", "library drawing 'SYN-101' (Synthetic customer) revision 3"); + } + + [Fact] + public void SameRevisionFromAnotherServer_IsADifferentDrawing() + { + var nest = MatchingNest(); + LibraryDrawingInsert.Plan(nest, "http://other.test:8090", Record(), Package(), 1).Apply(); + + AssertRefused(LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1), nest, + "library revision 3 from http://other.test:8090"); + } + + [Fact] + public void Apply_RunsOnce() + { + var insert = LibraryDrawingInsert.Plan(MatchingNest(), Server, Record(), Package(), 1); + insert.Apply(); + + Assert.Throws(insert.Apply); + } + + [Fact] + public void Plan_RefusesMissingInputs() + { + Assert.Throws(() => LibraryDrawingInsert.Plan(MatchingNest(), Server, Record(), Package(), 0)); + Assert.Throws(() => LibraryDrawingInsert.Plan(MatchingNest(), " / ", Record(), Package(), 1)); + } + + private static void AssertRefused(LibraryDrawingInsert insert, Nest nest, params string[] fragments) + { + var before = nest.Drawings.Select(d => (d, d.Quantity.Required)).ToList(); + + Assert.True(insert.IsRefused); + Assert.Null(insert.Drawing); + foreach (var fragment in fragments) + Assert.Contains(fragment, insert.Refusal); + Assert.Throws(insert.Apply); + Assert.Equal(before, nest.Drawings.Select(d => (d, d.Quantity.Required)).ToList()); + } +}