From f93b0d6273659753429dfcd2550e9de212ce1acb Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 16 Oct 2026 12:31:05 -0400 Subject: [PATCH] feat(data): library save model Saving a nest drawing to the drawing library now has a Linux-testable model, DrawingLibrarySaveSession, ahead of the desktop dialog. Prefill starts from the drawing's name and customer (the nest's customer when the drawing has none), the nest's material, grade, thickness and units, and the linked revision's cut type and notes when the drawing came from this server's library, otherwise the operator's last-used cut type. Duplicates follow D4: active drawings whose normalized customer, name, material and grade and exact thickness, units and cut type match. The server only narrows (a blank filter means "any" there, and LIKE does not collapse whitespace), so every key is checked again on the client. The default is the linked drawing when it matches, else the only match; several matches require the operator to choose, and a linked drawing that no longer matches is offered but never the default, so a thickness change cannot silently rewrite it. Writes follow NestSaveSession: a new drawing's id is allocated before the upload, and a save that fails without a definite answer stays pending. The next save first looks for the revision it would have produced and adopts it when the package hash and details match; a not-stored new drawing is retried under the same id, never onto another drawing, and a revision someone else saved meanwhile is a conflict. A definite refusal (409, old server, invalid request) leaves nothing pending. On success the drawing's link points at the saved revision; nothing else in the nest copy changes (D2). RemoteDrawingLibrary now implements IDrawingLibraryStore, the read/write surface the session needs, so tests can use an in-memory store. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/DrawingLibrarySaveSession.cs | 405 ++++++++++++++ OpenNest.Data/IDrawingLibraryStore.cs | 23 + OpenNest.Data/RemoteDrawingLibrary.cs | 2 +- .../DrawingLibraryEndToEndTests.cs | 68 +++ .../Data/DrawingLibrarySaveSessionTests.cs | 527 ++++++++++++++++++ 5 files changed, 1024 insertions(+), 1 deletion(-) create mode 100644 OpenNest.Data/DrawingLibrarySaveSession.cs create mode 100644 OpenNest.Data/IDrawingLibraryStore.cs create mode 100644 OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs diff --git a/OpenNest.Data/DrawingLibrarySaveSession.cs b/OpenNest.Data/DrawingLibrarySaveSession.cs new file mode 100644 index 00000000..1ac6f6fb --- /dev/null +++ b/OpenNest.Data/DrawingLibrarySaveSession.cs @@ -0,0 +1,405 @@ +using System.Security.Cryptography; +using OpenNest; + +namespace OpenNest.Data; + +/// What one save to the drawing library did. +public enum DrawingLibrarySaveOutcome +{ + /// A new library drawing was stored as revision 1. + Created, + + /// The chosen library drawing gained the next revision. + Revised, + + /// The chosen library drawing already held this exact package and metadata. + Unchanged, +} + +/// The saved library revision; the nest drawing's link now points at it. +public sealed class DrawingLibrarySaveResult +{ + internal DrawingLibrarySaveResult(DrawingLibraryRecord saved, DrawingLibrarySaveOutcome outcome, bool recoveredEarlierSave) + { + Saved = saved; + Outcome = outcome; + RecoveredEarlierSave = recoveredEarlierSave; + } + + public DrawingLibraryRecord Saved { get; } + + public DrawingLibrarySaveOutcome Outcome { get; } + + /// + /// True when an earlier save whose reply was lost had in fact been stored, and it + /// differed from this request. That earlier save is the result; this request was not + /// sent, so the operator must save again to store anything changed since. + /// + public bool RecoveredEarlierSave { get; } +} + +/// +/// Active library drawings that a save could revise (D4): the ones whose normalized +/// customer, name, material and grade, and exact thickness, units and cut type equal the +/// metadata being saved, newest first, plus the drawing's own linked library drawing. +/// +public sealed class DrawingLibraryDuplicates +{ + internal DrawingLibraryDuplicates(DrawingLibraryRecord metadata, IReadOnlyList matches, + DrawingLibraryRecord? linked) + { + Metadata = metadata; + Matches = matches; + Linked = linked; + if (linked != null && matches.Any(m => m.Id == linked.Id)) + Default = matches.First(m => m.Id == linked.Id); + else if (matches.Count == 1) + Default = matches[0]; + } + + /// A copy of the metadata these duplicates were found for. + public DrawingLibraryRecord Metadata { get; } + + public IReadOnlyList Matches { get; } + + /// + /// The drawing's linked library drawing on this server when it is active, even if its + /// details no longer match; revising it is offered but is only the default when it matches. + /// + public DrawingLibraryRecord? Linked { get; } + + /// Every drawing that may be revised: the matches, then the linked drawing if it is not one. + public IReadOnlyList Choices => + Linked == null || Matches.Any(m => m.Id == Linked.Id) ? Matches : Matches.Append(Linked).ToList(); + + /// + /// The drawing to revise by default: the linked drawing when it matches, otherwise the + /// only match. Null means create a separate drawing when there is no match, or that the + /// operator must choose between several (). + /// + public DrawingLibraryRecord? Default { get; } + + /// Several drawings match and none is linked: the operator has to pick one, or a separate drawing. + public bool RequiresChoice => Default == null && Matches.Count > 1; + + /// True when these duplicates were found for exactly 's matching details. + public bool IsFor(DrawingLibraryRecord metadata) => SameDuplicateKey(Metadata, metadata); + + internal static bool SameDuplicateKey(DrawingLibraryRecord a, DrawingLibraryRecord b) => + SharedListNames.Key(a.Customer) == SharedListNames.Key(b.Customer) + && SharedListNames.Key(a.Name) == SharedListNames.Key(b.Name) + && SharedListNames.Key(a.MaterialName) == SharedListNames.Key(b.MaterialName) + && SharedListNames.Key(a.MaterialGrade) == SharedListNames.Key(b.MaterialGrade) + && DrawingLibraryRules.NormalizeThickness(a.Thickness) == DrawingLibraryRules.NormalizeThickness(b.Thickness) + && a.Units == b.Units + && a.CutType == b.CutType; +} + +/// +/// Saves one nest drawing to the server drawing library, as a new library drawing or as +/// the next revision of a chosen one (D4), and on success points the drawing's library +/// link at the saved revision. The nest's copy is otherwise unchanged (D2). +/// +/// A new drawing's id is allocated before the upload. When a save fails without a +/// definite answer it stays pending: the next save first checks whether it was stored, +/// adopts it if so, and otherwise retries a new drawing under the same id. A pending save +/// is never written onto a different library drawing. +/// +/// +public sealed class DrawingLibrarySaveSession +{ + /// Most library rows the duplicate check reads before giving up. + public const int MaxScannedDrawings = 10 * DrawingLibraryQuery.MaxLimit; + + private readonly IDrawingLibraryStore _library; + private PendingSave? _pending; + + public DrawingLibrarySaveSession(IDrawingLibraryStore library, string serverUrl, Nest nest, Drawing drawing) + { + _library = library ?? throw new ArgumentNullException(nameof(library)); + Nest = nest ?? throw new ArgumentNullException(nameof(nest)); + Drawing = drawing ?? throw new ArgumentNullException(nameof(drawing)); + ServerKey = ServerUrl.Key(serverUrl); + if (ServerKey.Length == 0) + throw new ArgumentException("A library save needs the nest server URL.", nameof(serverUrl)); + } + + public Nest Nest { get; } + + public Drawing Drawing { get; } + + /// The normalized server URL saved links are bound to. + public string ServerKey { get; } + + /// An earlier save failed without a definite answer; the next save checks it first. + public bool HasPendingSave => _pending != null; + + /// + /// Library details to start from: the drawing's name, its customer (or the nest's), the + /// nest's material and grade (or the drawing's when the nest has none), the nest's + /// thickness and units, and the linked revision's cut type and notes when the drawing + /// came from this server's library, otherwise . + /// + public DrawingLibraryRecord Prefill(CutType lastCutType, string savedBy) + { + var link = Drawing.Library is { } l && l.ServerKey == ServerKey ? l : null; + var material = !string.IsNullOrWhiteSpace(Nest.Material?.Name) ? Nest.Material : Drawing.Material; + return new DrawingLibraryRecord + { + Name = Drawing.Name ?? "", + Customer = !string.IsNullOrWhiteSpace(Drawing.Customer) ? Drawing.Customer : Nest.Customer ?? "", + MaterialName = material?.Name ?? "", + MaterialGrade = material?.Grade ?? "", + Thickness = DrawingLibraryRules.NormalizeThickness(double.IsFinite(Nest.Thickness) && Nest.Thickness > 0 ? Nest.Thickness : 0), + Units = Nest.Units, + CutType = link?.CutType ?? (Enum.IsDefined(lastCutType) ? lastCutType : CutType.Laser), + Notes = link?.Notes ?? "", + SavedBy = savedBy ?? "", + }; + } + + /// + /// Finds the active library drawings duplicates, reading + /// every page the server filters down to, and the drawing's linked library drawing. + /// + public async Task FindDuplicatesAsync(DrawingLibraryRecord metadata, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(metadata); + var snapshot = Copy(metadata); + var matches = new List(); + if (!string.IsNullOrWhiteSpace(snapshot.Name) && DrawingLibraryRules.GetMetadataError(snapshot) == null) + { + // The server matches customer, material and grade by the same normalized key. A + // blank one means "any" there, so every key is checked again here; the name search + // narrows by its longest word because LIKE does not collapse whitespace. + var offset = 0; + while (true) + { + var page = await _library.QueryAsync(new DrawingLibraryQuery + { + Search = LongestWord(snapshot.Name), + Customer = snapshot.Customer, + MaterialName = snapshot.MaterialName, + MaterialGrade = snapshot.MaterialGrade, + Thickness = snapshot.Thickness, + CutType = snapshot.CutType, + Sort = DrawingSortField.SavedAt, + Descending = true, + Offset = offset, + Limit = DrawingLibraryQuery.MaxLimit, + }, cancellationToken); + matches.AddRange(page.Items.Where(r => !r.Archived && DrawingLibraryDuplicates.SameDuplicateKey(r, snapshot) + && matches.All(m => m.Id != r.Id))); + offset += page.Items.Count; + if (page.Items.Count == 0 || offset >= page.Total) + break; + if (offset >= MaxScannedDrawings) + throw new IOException($"More than {MaxScannedDrawings} library drawings share this name's details. Narrow the name or customer before saving."); + } + } + + DrawingLibraryRecord? linked = null; + if (Drawing.Library is { } link && link.ServerKey == ServerKey + && await _library.GetAsync(link.Id, cancellationToken) is { Archived: false } current && current.Id == link.Id) + linked = matches.FirstOrDefault(m => m.Id == current.Id) ?? current; + + return new DrawingLibraryDuplicates(snapshot, matches, linked); + } + + /// + /// Saves (the drawing packaged in the nest's units) with + /// : as the next revision of at the + /// revision it was listed with, or as a new library drawing when it is null. A stale + /// revision raises and changes nothing. + /// + public async Task SaveAsync(DrawingLibraryRecord metadata, byte[] package, + DrawingLibraryRecord? revise, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(metadata); + ArgumentNullException.ThrowIfNull(package); + var error = DrawingLibraryRules.GetMetadataError(metadata) ?? DrawingLibraryRules.GetPackageError(package); + if (error != null) + throw new ArgumentException(error, nameof(metadata)); + if (metadata.Units != Nest.Units) + throw new ArgumentException("A library drawing is saved in its nest's units.", nameof(metadata)); + if (revise != null && (revise.Id == Guid.Empty || revise.Revision < 1)) + throw new ArgumentException("Choose a listed library drawing to revise.", nameof(revise)); + + var reuseId = Guid.Empty; + if (_pending != null) + { + var earlier = _pending; + var stored = await FindStoredAsync(earlier, cancellationToken); + if (stored != null) + { + _pending = null; + var sameRequest = earlier.Matches(metadata, package, revise); + return Finish(stored.Value.Record, stored.Value.Outcome, recoveredEarlierSave: !sameRequest); + } + + // Not stored: a new drawing may be retried, but only ever under the id it was given. + reuseId = earlier.ReviseId == Guid.Empty ? earlier.Id : Guid.Empty; + _pending = null; + } + + var request = Copy(metadata); + var pending = revise == null + ? new PendingSave(reuseId != Guid.Empty ? reuseId : Guid.NewGuid(), Guid.Empty, 0, request, package) + : new PendingSave(revise.Id, revise.Id, revise.Revision, request, package); + request.Id = pending.Id; + request.Revision = 0; + + DrawingLibraryRecord saved; + _pending = pending; + try + { + saved = revise == null + ? await _library.UploadAsync(request, package, cancellationToken) + : await _library.UpdatePackageAsync(revise.Id, request, package, revise.Revision, cancellationToken); + } + catch (Exception ex) when (ex is DrawingLibraryConflictException or ServerTooOldException or ArgumentException) + { + // A definite refusal: nothing was stored. Any other failure may have been stored + // before its reply was lost, so it stays pending for the next save to check. + _pending = null; + throw; + } + + if (saved.Id != pending.Id || saved.Revision < 1 || !pending.SameStored(saved)) + throw new IOException("The nest server returned a different library drawing than was saved. Save again to check what was stored."); + _pending = null; + var outcome = revise == null ? DrawingLibrarySaveOutcome.Created + : saved.Revision == revise.Revision ? DrawingLibrarySaveOutcome.Unchanged + : DrawingLibrarySaveOutcome.Revised; + return Finish(saved, outcome, recoveredEarlierSave: false); + } + + // The revision a pending save would have produced, when the server holds it with exactly + // the pending package and metadata; null when it was not stored. + private async Task<(DrawingLibraryRecord Record, DrawingLibrarySaveOutcome Outcome)?> FindStoredAsync( + PendingSave pending, CancellationToken cancellationToken) + { + var current = await _library.GetAsync(pending.Id, cancellationToken); + if (pending.ReviseId == Guid.Empty) + { + if (current == null) + return null; + var first = await RevisionAsync(current, 1, cancellationToken); + if (first == null || !pending.SameStored(first)) + { + _pending = null; + throw new IOException("Another library drawing already uses the id of the save that failed. Close this dialog and save again."); + } + return (first, DrawingLibrarySaveOutcome.Created); + } + + if (current == null) + { + _pending = null; + throw new IOException("The library drawing being revised no longer exists on the nest server."); + } + + if (current.Revision == pending.ExpectedRevision) + return pending.SameStored(current) ? (current, DrawingLibrarySaveOutcome.Unchanged) : null; + + var next = current.Revision > pending.ExpectedRevision + ? await RevisionAsync(current, pending.ExpectedRevision + 1, cancellationToken) + : null; + if (next != null && pending.SameStored(next)) + return (next, DrawingLibrarySaveOutcome.Revised); + + // Someone else saved the next revision; the pending save can never be stored now. + _pending = null; + throw new DrawingLibraryConflictException(current); + } + + private async Task RevisionAsync(DrawingLibraryRecord current, int revision, + CancellationToken cancellationToken) + { + if (revision < 1 || revision > current.Revision) + return null; + // Revisions are listed newest first, so revision r of n sits at offset n - r. + var page = await _library.GetRevisionsAsync(current.Id, current.Revision - revision, 1, cancellationToken); + var record = page?.Items.FirstOrDefault(); + return record != null && record.Id == current.Id && record.Revision == revision ? record : null; + } + + private DrawingLibrarySaveResult Finish(DrawingLibraryRecord saved, DrawingLibrarySaveOutcome outcome, + bool recoveredEarlierSave) + { + Drawing.Library = new DrawingLibraryLink(ServerKey, saved.Id, saved.Revision, saved.CutType, + saved.Thickness, saved.MaterialName, saved.MaterialGrade, saved.Units, saved.Notes, + saved.PackageSha256.ToLowerInvariant()); + return new DrawingLibrarySaveResult(saved, outcome, recoveredEarlierSave); + } + + private static string LongestWord(string name) => + name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + .OrderByDescending(word => word.Length) + .FirstOrDefault() ?? ""; + + 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, + Archived = record.Archived, + PackageSha256 = record.PackageSha256, + PackageSize = record.PackageSize, + SavedAt = record.SavedAt, + SavedBy = record.SavedBy, + }; + + private static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + private sealed class PendingSave + { + public PendingSave(Guid id, Guid reviseId, int expectedRevision, DrawingLibraryRecord metadata, byte[] package) + { + Id = id; + ReviseId = reviseId; + ExpectedRevision = expectedRevision; + Metadata = metadata; + Package = package.ToArray(); + PackageSha256 = Sha256(Package); + } + + public Guid Id { get; } + + /// The drawing being revised, or empty for a new drawing. + public Guid ReviseId { get; } + + public int ExpectedRevision { get; } + + public DrawingLibraryRecord Metadata { get; } + + public byte[] Package { get; } + + public string PackageSha256 { get; } + + /// True when the server's holds exactly this package and these details. + public bool SameStored(DrawingLibraryRecord record) => + string.Equals(record.PackageSha256, PackageSha256, StringComparison.OrdinalIgnoreCase) + && record.Name == Metadata.Name && record.Customer == Metadata.Customer + && record.MaterialName == Metadata.MaterialName && record.MaterialGrade == Metadata.MaterialGrade + && record.Thickness == DrawingLibraryRules.NormalizeThickness(Metadata.Thickness) + && record.Units == Metadata.Units && record.CutType == Metadata.CutType && record.Notes == Metadata.Notes; + + /// True when a new request asks for exactly this save again. + public bool Matches(DrawingLibraryRecord metadata, byte[] package, DrawingLibraryRecord? revise) => + (revise == null ? ReviseId == Guid.Empty : ReviseId == revise.Id && ExpectedRevision == revise.Revision) + && Package.AsSpan().SequenceEqual(package) + && metadata.Name == Metadata.Name && metadata.Customer == Metadata.Customer + && metadata.MaterialName == Metadata.MaterialName && metadata.MaterialGrade == Metadata.MaterialGrade + && DrawingLibraryRules.NormalizeThickness(metadata.Thickness) == DrawingLibraryRules.NormalizeThickness(Metadata.Thickness) + && metadata.Units == Metadata.Units && metadata.CutType == Metadata.CutType && metadata.Notes == Metadata.Notes; + } +} diff --git a/OpenNest.Data/IDrawingLibraryStore.cs b/OpenNest.Data/IDrawingLibraryStore.cs new file mode 100644 index 00000000..0814fac0 --- /dev/null +++ b/OpenNest.Data/IDrawingLibraryStore.cs @@ -0,0 +1,23 @@ +namespace OpenNest.Data; + +/// +/// The drawing library reads and writes that saving a nest drawing to the library uses. +/// is the only production implementation. +/// +public interface IDrawingLibraryStore : IDrawingLibrary +{ + /// The current record, including an archived one, or null for an unknown id. + Task GetAsync(Guid id, CancellationToken cancellationToken = default); + + /// One page of immutable revisions, newest first, or null for an unknown id. + Task GetRevisionsAsync(Guid id, int offset = 0, + int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default); + + /// Stores revision 1 under the caller-allocated record.Id. + Task UploadAsync(DrawingLibraryRecord record, byte[] package, + CancellationToken cancellationToken = default); + + /// Saves the next revision when is current. + Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package, + int expectedRevision, CancellationToken cancellationToken = default); +} diff --git a/OpenNest.Data/RemoteDrawingLibrary.cs b/OpenNest.Data/RemoteDrawingLibrary.cs index 8b1571e5..7e764de8 100644 --- a/OpenNest.Data/RemoteDrawingLibrary.cs +++ b/OpenNest.Data/RemoteDrawingLibrary.cs @@ -16,7 +16,7 @@ namespace OpenNest.Data; /// the library raises ; this client never falls back /// to another store. Other failures surface as . /// -public sealed class RemoteDrawingLibrary : IDrawingLibrary, IDisposable +public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable { private static readonly JsonSerializerOptions JsonOptions = new() { diff --git a/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs b/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs index 3e60bd34..21c4ff2d 100644 --- a/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs @@ -110,6 +110,74 @@ public sealed class DrawingLibraryEndToEndTests Assert.Equal(new[] { 2, 1 }, history!.Items.Select(r => r.Revision)); } + private static Nest SyntheticNest() => new("Synthetic job") + { + Units = Units.Inches, + Thickness = 0.25, + Material = new Material("Synthetic Steel", "Grade A"), + }; + + [Fact] + public async Task SaveSession_CreatesThenDefaultsToTheNextRevisionOfTheDuplicate() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var library = new RemoteDrawingLibrary(client, client.BaseAddress!.ToString()); + var nest = SyntheticNest(); + var drawing = SyntheticDrawing("Synthetic bracket", 2); + nest.Drawings.Add(drawing); + var first = new DrawingLibrarySaveSession(library, client.BaseAddress!.ToString(), nest, drawing); + var metadata = first.Prefill(CutType.Laser, "Synthetic operator"); + + var none = await first.FindDuplicatesAsync(metadata); + var created = await first.SaveAsync(metadata, DrawingPackage.Write(drawing, nest.Units), none.Default); + + // Another nest's copy, moved hole, same details: D4 offers revision 2 by default. + var otherNest = SyntheticNest(); + var copy = SyntheticDrawing("Synthetic bracket", 5); + otherNest.Drawings.Add(copy); + var second = new DrawingLibrarySaveSession(library, client.BaseAddress!.ToString(), otherNest, copy); + var again = second.Prefill(CutType.Laser, "Synthetic operator"); + var duplicates = await second.FindDuplicatesAsync(again); + var package = DrawingPackage.Write(copy, otherNest.Units); + var revised = await second.SaveAsync(again, package, duplicates.Default); + + Assert.Empty(none.Matches); + Assert.Equal((DrawingLibrarySaveOutcome.Created, 1), (created.Outcome, created.Saved.Revision)); + Assert.Equal(created.Saved.Id, duplicates.Default!.Id); + Assert.Equal((DrawingLibrarySaveOutcome.Revised, 2), (revised.Outcome, revised.Saved.Revision)); + Assert.Equal((created.Saved.Id, 2), (copy.Library!.Id, copy.Library.Revision)); + Assert.Equal((created.Saved.Id, 1), (drawing.Library!.Id, drawing.Library.Revision)); + Assert.Equal(package, await library.GetPackageAsync(created.Saved.Id)); + Assert.Equal(revised.Saved.PackageSha256, copy.Library.PackageSha256); + } + + [Fact] + public async Task SaveSession_StaleChoiceConflictsAndChangesNothing() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var library = new RemoteDrawingLibrary(client, client.BaseAddress!.ToString()); + var nest = SyntheticNest(); + var drawing = SyntheticDrawing("Synthetic bracket", 2); + nest.Drawings.Add(drawing); + var session = new DrawingLibrarySaveSession(library, client.BaseAddress!.ToString(), nest, drawing); + var metadata = session.Prefill(CutType.Laser, "Synthetic operator"); + await session.SaveAsync(metadata, DrawingPackage.Write(drawing, nest.Units), null); + var duplicates = await session.FindDuplicatesAsync(metadata); + var moved = DrawingPackage.Write(SyntheticDrawing("Synthetic bracket", 5), Units.Inches); + var current = await library.UpdatePackageAsync(duplicates.Default!.Id, metadata, moved, 1); + + var conflict = await Assert.ThrowsAsync(() => + session.SaveAsync(metadata, DrawingPackage.Write(SyntheticDrawing("Synthetic bracket", 6), Units.Inches), duplicates.Default)); + + Assert.Equal(2, conflict.Current.Revision); + Assert.False(session.HasPendingSave); + Assert.Equal(1, drawing.Library!.Revision); + Assert.Equal(current.PackageSha256, (await library.GetAsync(current.Id))!.PackageSha256); + Assert.Equal(moved, await library.GetPackageAsync(current.Id)); + } + [Fact] public async Task ArchiveRestoreAndMetadata_ThroughTheClient() { diff --git a/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs b/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs new file mode 100644 index 00000000..974dc48a --- /dev/null +++ b/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs @@ -0,0 +1,527 @@ +using System.Security.Cryptography; +using OpenNest.Data; +using OpenNest.Shapes; + +namespace OpenNest.Tests.Data; + +public class DrawingLibrarySaveSessionTests +{ + private const string Server = "http://library.test:8090"; + private static readonly byte[] PackageA = { (byte)'P', (byte)'K', 3, 4, 1 }; + private static readonly byte[] PackageB = { (byte)'P', (byte)'K', 3, 4, 2 }; + + private static Nest MatchingNest() => new("Synthetic job") + { + Units = Units.Inches, + Thickness = 0.25, + Customer = "Nest customer", + Material = new Material("Synthetic Steel", "Grade A"), + }; + + private static Drawing SyntheticDrawing(string name = "SYN-101") + { + var drawing = new RectangleShape { Name = name, Length = 4, Width = 2 }.GetDrawing(); + drawing.Customer = "Synthetic customer"; + drawing.Material = new Material("Drawing material", "Drawing grade"); + return drawing; + } + + private static DrawingLibraryRecord Metadata(string name = "SYN-101") => new() + { + Name = name, + Customer = "Synthetic customer", + MaterialName = "Synthetic Steel", + MaterialGrade = "Grade A", + Thickness = 0.25, + Units = Units.Inches, + CutType = CutType.Laser, + Notes = "Deburr", + SavedBy = "Synthetic operator", + }; + + private static DrawingLibrarySaveSession Session(FakeStore store, Drawing? drawing = null, Nest? nest = null) => + new(store, Server + "/", nest ?? MatchingNest(), drawing ?? SyntheticDrawing()); + + [Fact] + public void Prefill_TakesNameAndCustomerFromTheDrawing_AndMaterialThicknessUnitsFromTheNest() + { + var nest = MatchingNest(); + nest.Thickness = 0.250004; + var session = Session(new FakeStore(), nest: nest); + + var metadata = session.Prefill(CutType.Waterjet, "Synthetic operator"); + + Assert.Equal(("SYN-101", "Synthetic customer"), (metadata.Name, metadata.Customer)); + Assert.Equal(("Synthetic Steel", "Grade A"), (metadata.MaterialName, metadata.MaterialGrade)); + Assert.Equal((0.25, Units.Inches), (metadata.Thickness, metadata.Units)); + Assert.Equal((CutType.Waterjet, "", "Synthetic operator"), (metadata.CutType, metadata.Notes, metadata.SavedBy)); + Assert.Null(DrawingLibraryRules.GetMetadataError(metadata)); + } + + [Fact] + public void Prefill_FallsBackToTheNestCustomerAndDrawingMaterial_AndUsesALinkOnThisServer() + { + var nest = new Nest("Synthetic job") { Units = Units.Millimeters, Customer = "Nest customer", Material = new Material() }; + var drawing = SyntheticDrawing(); + drawing.Customer = " "; + drawing.Library = Link(Server, Guid.NewGuid(), 2, CutType.Plasma, "Linked notes"); + var other = SyntheticDrawing(); + other.Library = Link("http://other.test:8090", Guid.NewGuid(), 2, CutType.Plasma, "Other notes"); + + var linked = Session(new FakeStore(), drawing, nest).Prefill(CutType.Laser, ""); + var foreign = Session(new FakeStore(), other, nest).Prefill(CutType.Waterjet, ""); + + Assert.Equal("Nest customer", linked.Customer); + Assert.Equal(("Drawing material", "Drawing grade"), (linked.MaterialName, linked.MaterialGrade)); + Assert.Equal((0, Units.Millimeters), (linked.Thickness, linked.Units)); + Assert.Equal((CutType.Plasma, "Linked notes"), (linked.CutType, linked.Notes)); + Assert.Equal((CutType.Waterjet, ""), (foreign.CutType, foreign.Notes)); + } + + [Fact] + public async Task NoMatch_CreatesANewDrawingUnderAClientIdAndLinksIt() + { + var store = new FakeStore(); + store.Seed(Edit(Metadata("SYN-101"), r => { r.CutType = CutType.Plasma; }), PackageA); + store.Seed(Edit(Metadata("SYN-101"), r => { r.Thickness = 0.1875; }), PackageA); + store.Seed(Metadata("SYN-1010"), PackageA); + var drawing = SyntheticDrawing(); + var session = Session(store, drawing); + var metadata = Metadata(); + + var duplicates = await session.FindDuplicatesAsync(metadata); + var result = await session.SaveAsync(metadata, PackageA, duplicates.Default); + + Assert.Empty(duplicates.Matches); + Assert.Null(duplicates.Default); + Assert.False(duplicates.RequiresChoice); + Assert.Equal(DrawingLibrarySaveOutcome.Created, result.Outcome); + Assert.False(result.RecoveredEarlierSave); + var upload = Assert.Single(store.Uploads); + Assert.NotEqual(Guid.Empty, upload); + Assert.Equal((upload, 1), (result.Saved.Id, result.Saved.Revision)); + Assert.Equal((Server, upload, 1, Sha(PackageA)), (drawing.Library!.ServerKey, drawing.Library.Id, drawing.Library.Revision, drawing.Library.PackageSha256)); + Assert.Equal(("Synthetic Steel", "Grade A", 0.25, "Deburr"), (drawing.Library.MaterialName, drawing.Library.MaterialGrade, drawing.Library.Thickness, drawing.Library.Notes)); + Assert.False(session.HasPendingSave); + } + + [Fact] + public async Task OneMatch_IsTheDefault_AndSavesTheNextRevisionGuardedByItsRevision() + { + var store = new FakeStore(); + var match = store.Seed(Edit(Metadata(" syn-101 "), r => { r.Customer = "SYNTHETIC customer"; r.MaterialGrade = "grade a"; }), PackageA); + store.Revise(match.Id, PackageA); + var drawing = SyntheticDrawing(); + var session = Session(store, drawing); + + var duplicates = await session.FindDuplicatesAsync(Metadata()); + var result = await session.SaveAsync(Metadata(), PackageB, duplicates.Default); + + Assert.Equal(match.Id, Assert.Single(duplicates.Matches).Id); + Assert.Equal((match.Id, 2), (duplicates.Default!.Id, duplicates.Default.Revision)); + Assert.Equal(new[] { (match.Id, 2) }, store.Updates); + Assert.Empty(store.Uploads); + Assert.Equal((DrawingLibrarySaveOutcome.Revised, 3), (result.Outcome, result.Saved.Revision)); + Assert.Equal((match.Id, 3), (drawing.Library!.Id, drawing.Library.Revision)); + } + + [Fact] + public async Task SeveralMatches_RequireAChoice_AndArchivedOnesAreNotOffered() + { + var store = new FakeStore(); + var older = store.Seed(Metadata(), PackageA); + var newer = store.Seed(Metadata(), PackageB); + var archived = store.Seed(Metadata(), PackageA); + store.Archive(archived.Id); + var session = Session(store); + + var duplicates = await session.FindDuplicatesAsync(Metadata()); + var result = await session.SaveAsync(Metadata(), PackageB, duplicates.Matches.Single(m => m.Id == older.Id)); + + Assert.Equal(new[] { newer.Id, older.Id }, duplicates.Matches.Select(m => m.Id)); + Assert.Null(duplicates.Default); + Assert.True(duplicates.RequiresChoice); + Assert.Equal((older.Id, 2), (result.Saved.Id, result.Saved.Revision)); + } + + [Fact] + public async Task LinkedDrawing_IsTheDefaultWhenItMatches_AndOnlyOfferedWhenItDoesNot() + { + var store = new FakeStore(); + var first = store.Seed(Metadata(), PackageA); + var linkedMatch = store.Seed(Metadata(), PackageA); + var renamed = store.Seed(Metadata("SYN-OLD"), PackageA); + var matching = SyntheticDrawing(); + matching.Library = Link(Server, linkedMatch.Id, 1, CutType.Laser, ""); + var drifted = SyntheticDrawing(); + drifted.Library = Link(Server, renamed.Id, 1, CutType.Laser, ""); + + var linked = await Session(store, matching).FindDuplicatesAsync(Metadata()); + var offered = await Session(store, drifted).FindDuplicatesAsync(Metadata()); + + Assert.Equal(linkedMatch.Id, linked.Default!.Id); + Assert.False(linked.RequiresChoice); + Assert.Equal(2, linked.Choices.Count); + Assert.Equal(renamed.Id, offered.Linked!.Id); + Assert.Null(offered.Default); + Assert.True(offered.RequiresChoice); + Assert.Equal(new[] { linkedMatch.Id, first.Id, renamed.Id }, offered.Choices.Select(c => c.Id)); + } + + [Fact] + public async Task DuplicatesKnowWhichDetailsTheyWereFoundFor() + { + var session = Session(new FakeStore()); + var metadata = Metadata(); + + var duplicates = await session.FindDuplicatesAsync(metadata); + metadata.Notes = "Notes are not part of the match"; + + Assert.True(duplicates.IsFor(metadata)); + Assert.True(duplicates.IsFor(Edit(metadata, r => { r.Name = " syn-101"; }))); + Assert.False(duplicates.IsFor(Edit(metadata, r => { r.Thickness = 0.1875; }))); + Assert.False(duplicates.IsFor(Edit(metadata, r => { r.CutType = CutType.Plasma; }))); + } + + [Fact] + public async Task StaleRevision_Conflicts_ChangesNothing_AndLeavesNothingPending() + { + var store = new FakeStore(); + var match = store.Seed(Metadata(), PackageA); + var drawing = SyntheticDrawing(); + var session = Session(store, drawing); + var duplicates = await session.FindDuplicatesAsync(Metadata()); + store.Revise(match.Id, PackageB); + + var conflict = await Assert.ThrowsAsync(() => + session.SaveAsync(Metadata(), PackageA, duplicates.Default)); + + Assert.Equal(2, conflict.Current.Revision); + Assert.Equal(2, store.Current(match.Id).Revision); + Assert.Null(drawing.Library); + Assert.False(session.HasPendingSave); + } + + [Fact] + public async Task IdenticalRevision_IsUnchanged_AndStillLinks() + { + var store = new FakeStore(); + var match = store.Seed(Metadata(), PackageA); + var drawing = SyntheticDrawing(); + var session = Session(store, drawing); + + var result = await session.SaveAsync(Metadata(), PackageA, (await session.FindDuplicatesAsync(Metadata())).Default); + + Assert.Equal((DrawingLibrarySaveOutcome.Unchanged, 1), (result.Outcome, result.Saved.Revision)); + Assert.Equal((match.Id, 1), (drawing.Library!.Id, drawing.Library.Revision)); + } + + [Fact] + public async Task LostCreateReply_IsReconciledOnRetry_WithoutASecondUpload() + { + var store = new FakeStore { LoseNextReply = true }; + var drawing = SyntheticDrawing(); + var session = Session(store, drawing); + + await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageA, null)); + Assert.True(session.HasPendingSave); + Assert.Null(drawing.Library); + + var result = await session.SaveAsync(Metadata(), PackageA, null); + + var upload = Assert.Single(store.Uploads); + Assert.Equal((upload, 1, DrawingLibrarySaveOutcome.Created), (result.Saved.Id, result.Saved.Revision, result.Outcome)); + Assert.False(result.RecoveredEarlierSave); + Assert.Equal((upload, 1), (drawing.Library!.Id, drawing.Library.Revision)); + Assert.False(session.HasPendingSave); + } + + [Fact] + public async Task LostCreateReply_WithAChangedRetry_ReportsTheEarlierSaveInsteadOfWritingAgain() + { + var store = new FakeStore { LoseNextReply = true }; + var session = Session(store); + await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageA, null)); + + var result = await session.SaveAsync(Edit(Metadata(), r => { r.Notes = "Changed since"; }), PackageA, null); + + Assert.True(result.RecoveredEarlierSave); + Assert.Equal("Deburr", result.Saved.Notes); + Assert.Single(store.Uploads); + } + + [Fact] + public async Task CreateThatFailedBeforeStoring_RetriesUnderTheSameId() + { + var store = new FakeStore { FailNextRequest = true }; + var session = Session(store); + + await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageA, null)); + var result = await session.SaveAsync(Metadata(), PackageA, null); + + Assert.Equal(2, store.Uploads.Count); + Assert.Equal(store.Uploads[0], store.Uploads[1]); + Assert.Equal(store.Uploads[0], result.Saved.Id); + Assert.Equal(DrawingLibrarySaveOutcome.Created, result.Outcome); + } + + [Fact] + public async Task FailedCreateThenRevise_DoesNotCarryTheNewIdOntoAnotherDrawing() + { + var store = new FakeStore(); + var match = store.Seed(Metadata(), PackageA); + var session = Session(store); + store.FailNextRequest = true; + await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageB, null)); + + var result = await session.SaveAsync(Metadata(), PackageB, store.Current(match.Id)); + + Assert.Equal((match.Id, 2), (result.Saved.Id, result.Saved.Revision)); + Assert.Equal(1, store.Count); + } + + [Fact] + public async Task LostRevisionReply_IsReconciledOnRetry_WithoutASecondRevision() + { + var store = new FakeStore(); + var match = store.Seed(Metadata(), PackageA); + var drawing = SyntheticDrawing(); + var session = Session(store, drawing); + store.LoseNextReply = true; + + await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageB, match)); + store.Revise(match.Id, PackageA); + var result = await session.SaveAsync(Metadata(), PackageB, match); + + Assert.Single(store.Updates); + Assert.Equal((2, DrawingLibrarySaveOutcome.Revised), (result.Saved.Revision, result.Outcome)); + Assert.Equal(Sha(PackageB), result.Saved.PackageSha256); + Assert.Equal(2, drawing.Library!.Revision); + } + + [Fact] + public async Task RevisionThatFailedBeforeStoring_RetriesTheSameGuard() + { + var store = new FakeStore(); + var match = store.Seed(Metadata(), PackageA); + var session = Session(store); + store.FailNextRequest = true; + + await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageB, match)); + var result = await session.SaveAsync(Metadata(), PackageB, match); + + Assert.Equal(new[] { (match.Id, 1), (match.Id, 1) }, store.Updates); + Assert.Equal(2, result.Saved.Revision); + } + + [Fact] + public async Task RevisionThatFailedWhileSomeoneElseSaved_ConflictsAndClearsThePendingSave() + { + var store = new FakeStore(); + var match = store.Seed(Metadata(), PackageA); + var drawing = SyntheticDrawing(); + var session = Session(store, drawing); + store.FailNextRequest = true; + await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageB, match)); + store.Revise(match.Id, PackageA, notes: "Someone else"); + + await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageB, match)); + + Assert.False(session.HasPendingSave); + Assert.Null(drawing.Library); + Assert.Single(store.Updates); + } + + [Fact] + public async Task InvalidRequests_AreRefusedBeforeSending() + { + var store = new FakeStore(); + var session = Session(store); + + await Assert.ThrowsAsync(() => session.SaveAsync(Edit(Metadata(), r => { r.Name = " "; }), PackageA, null)); + await Assert.ThrowsAsync(() => session.SaveAsync(Edit(Metadata(), r => { r.Units = Units.Millimeters; }), PackageA, null)); + await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), new byte[] { 1, 2, 3, 4 }, null)); + + Assert.Empty(store.Uploads); + Assert.False(session.HasPendingSave); + } + + private static DrawingLibraryLink Link(string server, Guid id, int revision, CutType cutType, string notes) => + new(server, id, revision, cutType, 0.25, "Synthetic Steel", "Grade A", Units.Inches, notes, ""); + + private static DrawingLibraryRecord Edit(DrawingLibraryRecord source, Action change) + { + var copy = new DrawingLibraryRecord + { + Id = source.Id, + Revision = source.Revision, + Name = source.Name, + Customer = source.Customer, + MaterialName = source.MaterialName, + MaterialGrade = source.MaterialGrade, + Thickness = source.Thickness, + Units = source.Units, + CutType = source.CutType, + Notes = source.Notes, + Archived = source.Archived, + PackageSha256 = source.PackageSha256, + PackageSize = source.PackageSize, + SavedAt = source.SavedAt, + SavedBy = source.SavedBy, + }; + change(copy); + return copy; + } + + private static string Sha(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + /// An in-memory library that filters like the server and can fail one request either side of storing. + private sealed class FakeStore : IDrawingLibraryStore + { + private readonly Dictionary> _revisions = new(); + private DateTime _clock = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// Throw on the next write without storing anything. + public bool FailNextRequest { get; set; } + + /// Store the next write, then throw as though its reply was lost. + public bool LoseNextReply { get; set; } + + public List Uploads { get; } = new(); + + public List<(Guid Id, int ExpectedRevision)> Updates { get; } = new(); + + public int Count => _revisions.Count; + + public DrawingLibraryRecord Current(Guid id) => _revisions[id][^1]; + + public DrawingLibraryRecord Seed(DrawingLibraryRecord metadata, byte[] package) + { + var id = Guid.NewGuid(); + _revisions[id] = new List { Stored(metadata, id, 1, package, archived: false) }; + return Current(id); + } + + public void Revise(Guid id, byte[] package, string? notes = null) + { + var current = Current(id); + _revisions[id].Add(Stored(Edit(current, r => { r.Notes = notes ?? current.Notes; }), id, current.Revision + 1, package, archived: false)); + } + + public void Archive(Guid id) + { + var current = Current(id); + _revisions[id].Add(Stored(current, id, current.Revision + 1, null, archived: true, current.PackageSha256)); + } + + public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) + { + Assert.Null(query.GetValidationError()); + var search = query.NormalizedSearch; + var matches = _revisions.Values.Select(r => r[^1]) + .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, + }); + } + + public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + 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) + { + Uploads.Add(record.Id); + FailBeforeStoring(); + if (_revisions.TryGetValue(record.Id, out var existing)) + throw new DrawingLibraryConflictException(existing[^1]); + _revisions[record.Id] = new List { Stored(record, record.Id, 1, package, archived: false) }; + return Reply(Current(record.Id)); + } + + public Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package, + int expectedRevision, CancellationToken cancellationToken = default) + { + Updates.Add((id, expectedRevision)); + FailBeforeStoring(); + var current = Current(id); + if (current.Revision != expectedRevision || current.Archived) + throw new DrawingLibraryConflictException(current); + var next = Stored(record, id, current.Revision + 1, package, archived: false); + if (SameDetails(current, next) && current.PackageSha256 == next.PackageSha256) + return Reply(current); + _revisions[id].Add(next); + return Reply(next); + } + + private void FailBeforeStoring() + { + if (!FailNextRequest) + return; + FailNextRequest = false; + throw new HttpRequestException("Synthetic connection failure."); + } + + private Task Reply(DrawingLibraryRecord record) + { + if (!LoseNextReply) + return Task.FromResult(record); + LoseNextReply = false; + throw new IOException("Synthetic lost reply."); + } + + private DrawingLibraryRecord Stored(DrawingLibraryRecord metadata, Guid id, int revision, byte[]? package, + bool archived, string? sha = null) + { + _clock = _clock.AddMinutes(1); + var savedAt = _clock; + return Edit(metadata, r => + { + r.Id = id; + r.Revision = revision; + r.Thickness = DrawingLibraryRules.NormalizeThickness(metadata.Thickness); + r.Archived = archived; + r.PackageSha256 = sha ?? Sha(package!); + r.SavedAt = savedAt; + }); + } + + private static bool SameDetails(DrawingLibraryRecord a, DrawingLibraryRecord b) => + a.Name == b.Name && a.Customer == b.Customer && a.MaterialName == b.MaterialName + && a.MaterialGrade == b.MaterialGrade && a.Thickness == b.Thickness && a.Units == b.Units + && a.CutType == b.CutType && a.Notes == b.Notes; + + private static bool Filter(string filter, string value) => + string.IsNullOrWhiteSpace(filter) || SharedListNames.Key(filter) == SharedListNames.Key(value); + } +}