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); + } +}