diff --git a/OpenNest.Data/IDrawingSetStore.cs b/OpenNest.Data/IDrawingSetStore.cs new file mode 100644 index 00000000..a1bbe844 --- /dev/null +++ b/OpenNest.Data/IDrawingSetStore.cs @@ -0,0 +1,51 @@ +namespace OpenNest.Data; + +/// +/// Drawing sets and their releases on the nest server (/api/sets). Kept apart from +/// so browsing and inserting drawings do not depend on sets. +/// is the only production implementation. +/// +public interface IDrawingSetStore +{ + /// One bounded page of sets matching the query, without their members. + Task QueryAsync(DrawingSetQuery query, CancellationToken cancellationToken = default); + + /// + /// The current record, including an archived one, with every member joined to its + /// drawing's current library record; null for an unknown id. + /// + Task GetAsync(Guid id, CancellationToken cancellationToken = default); + + /// One page of immutable revisions without members, newest first, or null for an unknown id. + Task GetRevisionsAsync(Guid id, int offset = 0, + int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default); + + /// One revision with its members, or null when the set or revision is unknown. + Task GetRevisionAsync(Guid id, int revision, CancellationToken cancellationToken = default); + + /// Sets whose current revision contains the drawing, by name, or null for an unknown drawing. + Task GetSetsContainingAsync(Guid drawingId, int offset = 0, + int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default); + + /// Stores revision 1 under the caller-allocated record.Id. + Task CreateAsync(DrawingSetRecord record, CancellationToken cancellationToken = default); + + /// Saves the next revision when is current. + Task UpdateAsync(Guid id, DrawingSetRecord record, int expectedRevision, + CancellationToken cancellationToken = default); + + Task ArchiveAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default); + + Task RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default); + + /// Records a release whose nests already exist, under the caller-allocated release.Id. + Task PostReleaseAsync(Guid setId, SetReleaseRecord release, + CancellationToken cancellationToken = default); + + /// One page of the set's releases with their nests, newest first, or null for an unknown set. + Task GetReleasesAsync(Guid setId, int offset = 0, + int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default); + + /// The release a nest came from, or null when it came from none (or is unknown). + Task GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default); +} diff --git a/OpenNest.Data/RemoteDrawingLibrary.cs b/OpenNest.Data/RemoteDrawingLibrary.cs index 213d02fd..2d01a7ad 100644 --- a/OpenNest.Data/RemoteDrawingLibrary.cs +++ b/OpenNest.Data/RemoteDrawingLibrary.cs @@ -329,11 +329,17 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable } } -/// The nest server predates the drawing library (it answered HTTP 404). +/// The nest server predates the drawing library or another feature (it answered HTTP 404). public sealed class ServerTooOldException : IOException { public ServerTooOldException() - : base("This nest server does not support the drawing library (HTTP 404). Update the nest server to this OpenNest version.") + : this("the drawing library") + { + } + + /// names what is missing, for example "drawing sets". + public ServerTooOldException(string feature) + : base($"This nest server does not support {feature} (HTTP 404). Update the nest server to this OpenNest version.") { } } diff --git a/OpenNest.Data/RemoteDrawingSetStore.cs b/OpenNest.Data/RemoteDrawingSetStore.cs new file mode 100644 index 00000000..f80f8eee --- /dev/null +++ b/OpenNest.Data/RemoteDrawingSetStore.cs @@ -0,0 +1,455 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenNest.Data; + +/// +/// Talks to the drawing sets on the nest server (/api/sets). Sets and releases +/// travel as JSON and are checked against and +/// before sending. A server without sets raises +/// ; other failures surface as . +/// +public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable +{ + private const string Feature = "drawing sets"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + private readonly HttpClient _httpClient; + private readonly bool _ownsClient; + private readonly Uri _baseUri; + + public RemoteDrawingSetStore(string baseUrl) + : this(new HttpClient(), baseUrl) + { + _ownsClient = true; + } + + /// For tests and hosts that pool HttpClient instances. + public RemoteDrawingSetStore(HttpClient httpClient, string baseUrl) + { + _httpClient = httpClient; + _baseUri = ServerUrl.ParseBase(baseUrl, nameof(baseUrl)); + } + + private Uri Url(string relative) => new(_baseUri, relative); + + public async Task QueryAsync(DrawingSetQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + var error = query.GetValidationError(); + if (error is not null) + throw new ArgumentException(error, nameof(query)); + + using var response = await _httpClient.GetAsync(Url("api/sets/query?" + BuildQueryString(query)), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + throw new ServerTooOldException(Feature); + await EnsureSuccess(response, "browse drawing sets", cancellationToken); + var page = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) + ?? throw new IOException("Nest server returned an empty drawing set page."); + if (page.Items is null || page.Items.Count > query.Limit || page.Total < 0 || page.Items.Any(item => item is null)) + throw new IOException($"Nest server returned an invalid drawing set page for a page limited to {query.Limit}."); + return page; + } + + public async Task GetAsync(Guid id, CancellationToken cancellationToken = default) + { + ValidateId(id); + var record = await GetRecordAsync($"api/sets/{id}", $"read drawing set {id}", cancellationToken); + if (record is null) + return null; + if (record.Id != id || !HasJoinedMembers(record)) + throw new IOException("Nest server returned a different or incomplete drawing set."); + return record; + } + + public async Task GetRevisionsAsync(Guid id, int offset = 0, + int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) + { + ValidateId(id); + var page = await GetPageAsync($"api/sets/{id}/revisions", offset, limit, $"read drawing set history {id}", cancellationToken); + if (page is not null && (page.Items.Any(item => item.Revision < 1 || item.Id != id) + || page.Items.Zip(page.Items.Skip(1)).Any(pair => pair.First.Revision <= pair.Second.Revision))) + throw new IOException("Nest server returned an invalid drawing set history page."); + return page; + } + + public async Task GetRevisionAsync(Guid id, int revision, CancellationToken cancellationToken = default) + { + ValidateId(id); + ValidateRevision(revision); + var record = await GetRecordAsync($"api/sets/{id}/revisions/{revision.ToString(CultureInfo.InvariantCulture)}", + $"read drawing set {id} revision {revision}", cancellationToken); + if (record is null) + return null; + if (record.Id != id || record.Revision != revision || !HasJoinedMembers(record)) + throw new IOException("Nest server returned a different or incomplete drawing set revision."); + return record; + } + + public async Task GetSetsContainingAsync(Guid drawingId, int offset = 0, + int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) + { + if (drawingId == Guid.Empty) throw new ArgumentException("A library drawing id is required.", nameof(drawingId)); + return await GetPageAsync($"api/drawings/{drawingId}/sets", offset, limit, + $"list the sets using library drawing {drawingId}", cancellationToken); + } + + /// + /// Stores revision 1 under record.Id, which the caller allocates first so an + /// ambiguous failure can be reconciled by id. An existing id raises + /// with the stored record. + /// + public async Task CreateAsync(DrawingSetRecord record, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + ValidateId(record.Id); + ValidateSet(record); + using var response = await _httpClient.PostAsync(Url("api/sets"), + JsonContent.Create(ForSave(record), options: JsonOptions), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + throw new ServerTooOldException(Feature); + if (response.StatusCode == HttpStatusCode.Conflict) + throw new DrawingSetSaveConflictException(await ReadSet(response, cancellationToken)); + await EnsureSuccess(response, $"save drawing set '{record.Name}'", cancellationToken); + var saved = await ReadSet(response, cancellationToken); + if (saved.Id != record.Id || saved.Revision != 1 || !SameMembers(saved, record)) + throw new IOException("The nest server returned a different drawing set than was saved. Reload the set before saving again."); + return saved; + } + + public async Task UpdateAsync(Guid id, DrawingSetRecord record, int expectedRevision, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + ValidateId(id); + ValidateRevision(expectedRevision); + ValidateSet(record); + if (record.Id != Guid.Empty && record.Id != id) + throw new ArgumentException("The set id does not match the set being saved.", nameof(record)); + using var request = Guarded(HttpMethod.Put, $"api/sets/{id}", expectedRevision); + request.Content = JsonContent.Create(ForSave(record), options: JsonOptions); + var saved = await SendGuarded(request, id, expectedRevision, $"save drawing set {id}", cancellationToken); + if (!SameMembers(saved, record)) + throw new IOException("The nest server stored different set members. Reload the set before saving again."); + return saved; + } + + public Task ArchiveAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => + SetArchivedAsync(id, expectedRevision, "archive", cancellationToken); + + public Task RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => + SetArchivedAsync(id, expectedRevision, "restore", cancellationToken); + + /// + /// Records a release under release.Id, allocated by the caller so a post whose + /// reply was lost can be sent again. A release already holding that id, or one of the + /// nests, raises with that release. + /// + public async Task PostReleaseAsync(Guid setId, SetReleaseRecord release, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(release); + ValidateId(setId); + if (release.Id == Guid.Empty) throw new ArgumentException("A release id is required.", nameof(release)); + if (release.SetId != Guid.Empty && release.SetId != setId) + throw new ArgumentException("The release names a different set.", nameof(release)); + var error = SetReleaseRules.GetError(release); + if (error is not null) + throw new ArgumentException(error, nameof(release)); + + using var response = await _httpClient.PostAsync(Url($"api/sets/{setId}/releases"), + JsonContent.Create(ForPost(release, setId), options: JsonOptions), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + await ThrowIfServerTooOld(cancellationToken); + throw new IOException($"Drawing set {setId} no longer exists on the nest server."); + } + if (response.StatusCode == HttpStatusCode.Conflict) + throw new SetReleasePostConflictException(await ReadRelease(response, cancellationToken)); + await EnsureSuccess(response, $"record the release of drawing set {setId}", cancellationToken); + var stored = await ReadRelease(response, cancellationToken); + if (stored.Id != release.Id || stored.SetId != setId || stored.SetRevision != release.SetRevision + || !stored.Nests.Select(n => n?.NestId).SequenceEqual(release.Nests.Select(n => (Guid?)n.NestId))) + throw new IOException("The nest server recorded a different release than was posted."); + return stored; + } + + public async Task GetReleasesAsync(Guid setId, int offset = 0, + int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) + { + ValidateId(setId); + ValidatePaging(offset, limit); + using var response = await _httpClient.GetAsync(Url($"api/sets/{setId}/releases?{Paging(offset, limit)}"), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + await ThrowIfServerTooOld(cancellationToken); + return null; + } + await EnsureSuccess(response, $"read the releases of drawing set {setId}", cancellationToken); + var page = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) + ?? throw new IOException("Nest server returned an empty release page."); + if (page.Items is null || !IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit) + || page.Items.Any(item => item is null || item.SetId != setId || item.Nests is null)) + throw new IOException("Nest server returned an invalid or unbounded release page."); + return page; + } + + public async Task GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default) + { + if (nestId == Guid.Empty) throw new ArgumentException("A nest id is required.", nameof(nestId)); + using var response = await _httpClient.GetAsync(Url($"api/nests/{nestId}/release"), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + await ThrowIfServerTooOld(cancellationToken); + return null; + } + await EnsureSuccess(response, $"read the release of nest {nestId}", cancellationToken); + var release = await ReadRelease(response, cancellationToken); + if (release.Nests.All(n => n?.NestId != nestId)) + throw new IOException("Nest server returned a release that does not contain the nest."); + return release; + } + + private async Task SetArchivedAsync(Guid id, int expectedRevision, string action, + CancellationToken cancellationToken) + { + ValidateId(id); + ValidateRevision(expectedRevision); + using var request = Guarded(HttpMethod.Post, $"api/sets/{id}/{action}", expectedRevision); + var changed = await SendGuarded(request, id, expectedRevision, $"{action} drawing set {id}", cancellationToken); + if (changed.Archived != (action == "archive")) + throw new IOException($"Nest server did not {action} drawing set {id}. Reload the set."); + return changed; + } + + private HttpRequestMessage Guarded(HttpMethod method, string relative, int expectedRevision) + { + var request = new HttpRequestMessage(method, Url(relative)); + request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\"")); + return request; + } + + private async Task SendGuarded(HttpRequestMessage request, Guid id, int expectedRevision, + string action, CancellationToken cancellationToken) + { + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + await ThrowIfServerTooOld(cancellationToken); + throw new IOException($"Drawing set {id} no longer exists on the nest server."); + } + if (response.StatusCode == HttpStatusCode.Conflict) + throw new DrawingSetSaveConflictException(await ReadSet(response, cancellationToken)); + await EnsureSuccess(response, action, cancellationToken); + var saved = await ReadSet(response, cancellationToken); + if (saved.Id != id || saved.Revision < expectedRevision) + throw new IOException("Nest server returned an invalid drawing set revision. Reload the set before saving again."); + return saved; + } + + private async Task GetRecordAsync(string relative, string action, CancellationToken cancellationToken) + { + using var response = await _httpClient.GetAsync(Url(relative), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + await ThrowIfServerTooOld(cancellationToken); + return null; + } + await EnsureSuccess(response, action, cancellationToken); + return await ReadSet(response, cancellationToken); + } + + private async Task GetPageAsync(string relative, int offset, int limit, string action, + CancellationToken cancellationToken) + { + ValidatePaging(offset, limit); + using var response = await _httpClient.GetAsync(Url($"{relative}?{Paging(offset, limit)}"), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + await ThrowIfServerTooOld(cancellationToken); + return null; + } + await EnsureSuccess(response, action, cancellationToken); + var page = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) + ?? throw new IOException("Nest server returned an empty drawing set page."); + if (page.Items is null || !IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit) + || page.Items.Any(item => item is null)) + throw new IOException("Nest server returned an invalid or unbounded drawing set page."); + return page; + } + + // A 404 for one set or nest is ambiguous; the query route exists on every set-aware server. + private async Task ThrowIfServerTooOld(CancellationToken cancellationToken) + { + using var probe = await _httpClient.GetAsync(Url("api/sets/query?limit=1"), cancellationToken); + if (probe.StatusCode == HttpStatusCode.NotFound) + throw new ServerTooOldException(Feature); + } + + private static bool IsBoundedPage(int count, int total, int pageOffset, int pageLimit, int offset, int limit) => + count <= limit && pageOffset == offset && pageLimit == limit && total >= 0 + && count <= System.Math.Max(0L, (long)total - offset); + + private static bool HasJoinedMembers(DrawingSetRecord record) => + record.Members is not null + && record.Members.All(m => m is not null && m.Drawing is not null && m.Drawing.Id == m.DrawingId); + + private static bool SameMembers(DrawingSetRecord saved, DrawingSetRecord sent) => + saved.Members is not null + && saved.Members.Select(m => (m?.DrawingId, m?.Quantity)) + .SequenceEqual(sent.Members.Select(m => ((Guid?)m.DrawingId, (int?)m.Quantity))); + + // The joined drawings are ignored by the server; leaving them out keeps the body small. + private static DrawingSetRecord ForSave(DrawingSetRecord record) => new() + { + Id = record.Id, + Name = record.Name, + Customer = record.Customer, + Notes = record.Notes, + SavedBy = record.SavedBy, + Members = record.Members.Select(m => new DrawingSetMember { DrawingId = m.DrawingId, Quantity = m.Quantity }).ToList(), + }; + + private static SetReleaseRecord ForPost(SetReleaseRecord release, Guid setId) => new() + { + Id = release.Id, + SetId = setId, + SetRevision = release.SetRevision, + Multiplier = release.Multiplier, + Notes = release.Notes, + ReleasedBy = release.ReleasedBy, + Nests = release.Nests.Select(n => new SetReleaseNest + { + NestId = n.NestId, + MaterialName = n.MaterialName, + MaterialGrade = n.MaterialGrade, + Thickness = n.Thickness, + Units = n.Units, + CutType = n.CutType, + }).ToList(), + }; + + private static void ValidateSet(DrawingSetRecord record) + { + var error = DrawingSetRules.GetError(record); + if (error is not null) + throw new ArgumentException(error, nameof(record)); + } + + private static void ValidateId(Guid id) + { + if (id == Guid.Empty) throw new ArgumentException("A drawing set id is required.", nameof(id)); + } + + private static void ValidateRevision(int revision) + { + if (revision < 1) throw new ArgumentOutOfRangeException(nameof(revision)); + } + + private static void ValidatePaging(int offset, int limit) + { + if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset)); + if (limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) throw new ArgumentOutOfRangeException(nameof(limit)); + } + + private static string Paging(int offset, int limit) => + $"offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"; + + private static string BuildQueryString(DrawingSetQuery query) + { + var text = new StringBuilder() + .Append("search=").Append(Uri.EscapeDataString(query.NormalizedSearch)) + .Append("&sort=").Append(JsonNamingPolicy.CamelCase.ConvertName(query.Sort.ToString())) + .Append("&order=").Append(query.Descending ? "desc" : "asc") + .Append("&offset=").Append(query.Offset.ToString(CultureInfo.InvariantCulture)) + .Append("&limit=").Append(query.Limit.ToString(CultureInfo.InvariantCulture)); + if (!string.IsNullOrWhiteSpace(query.Customer)) + text.Append("&customer=").Append(Uri.EscapeDataString(query.Customer)); + if (query.IncludeArchived) + text.Append("&includeArchived=true"); + return text.ToString(); + } + + private static async Task ReadSet(HttpResponseMessage response, CancellationToken cancellationToken) + { + var record = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return record ?? throw new IOException("Nest server returned an empty drawing set record."); + } + + private static async Task ReadRelease(HttpResponseMessage response, CancellationToken cancellationToken) + { + var release = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + if (release is null || release.Nests is null) + throw new IOException("Nest server returned an empty set release record."); + return release; + } + + private static async Task EnsureSuccess(HttpResponseMessage response, string action, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + return; + + var detail = ""; + try + { + detail = await response.Content.ReadAsStringAsync(cancellationToken); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException) + { + // The status code is the useful part; the body is best-effort. + } + + if (detail.Length > 200) + detail = detail[..200] + "…"; + throw new IOException( + $"Could not {action} on the nest server: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".TrimEnd()); + } + + public void Dispose() + { + if (_ownsClient) + _httpClient.Dispose(); + } +} + +/// A guarded set write found a newer or different current state; nothing was changed. +public sealed class DrawingSetSaveConflictException : IOException +{ + public DrawingSetSaveConflictException(DrawingSetRecord current) + : base(current.Archived + ? $"Drawing set '{current.Name}' is archived or was changed (current revision {current.Revision}). Reload before saving." + : $"Drawing set '{current.Name}' was changed by someone else (current revision {current.Revision}). Reload before saving.") + { + Current = current; + } + + /// The set's current server record. + public DrawingSetRecord Current { get; } +} + +/// +/// A release already holds the posted release id or one of its nests; nothing was recorded. +/// When has the posted id, an earlier post whose reply was lost was stored. +/// +public sealed class SetReleasePostConflictException : IOException +{ + public SetReleasePostConflictException(SetReleaseRecord current) + : base($"Release {current.Id} of set '{current.SetName}' already records this release or one of its nests.") + { + Current = current; + } + + /// The release that holds the id or nest. + public SetReleaseRecord Current { get; } +} diff --git a/OpenNest.Data/SetReleasePlan.cs b/OpenNest.Data/SetReleasePlan.cs new file mode 100644 index 00000000..b1d082c4 --- /dev/null +++ b/OpenNest.Data/SetReleasePlan.cs @@ -0,0 +1,212 @@ +using System.Globalization; +using OpenNest; + +namespace OpenNest.Data; + +/// One set member as released: its drawing's current record and the multiplied quantity. +public sealed class SetReleaseItem +{ + internal SetReleaseItem(DrawingSetMember member, int quantity) + { + Member = member; + Drawing = member.Drawing!; + Quantity = quantity; + } + + public DrawingSetMember Member { get; } + + /// The drawing's current library record, as joined by the server when the set was read. + public DrawingLibraryRecord Drawing { get; } + + /// The member quantity times the release multiplier. + public int Quantity { get; } + + /// The drawing is archived and was ticked back in. + public bool Archived => Drawing.Archived; +} + +/// +/// The members that can share one nest: the same material name, grade, thickness, units +/// and cut type (S3). Each group becomes one nest when the set is released. +/// +public sealed class SetReleaseGroup +{ + internal SetReleaseGroup(string key, DrawingLibraryRecord first, bool unassigned, + IReadOnlyList items, IReadOnlyList warnings, string refusal) + { + Key = key; + MaterialName = (first.MaterialName ?? "").Trim(); + MaterialGrade = (first.MaterialGrade ?? "").Trim(); + Thickness = DrawingLibraryRules.NormalizeThickness(first.Thickness); + Units = first.Units; + CutType = first.CutType; + IsUnassigned = unassigned; + Items = items; + Warnings = warnings; + Refusal = refusal; + } + + /// Identifies the group across rebuilds of the plan (for example after the multiplier changes). + public string Key { get; } + + /// The material as the group's first member spells it. + public string MaterialName { get; } + + public string MaterialGrade { get; } + + /// Thickness in , rounded to four decimals. + public double Thickness { get; } + + public Units Units { get; } + + public CutType CutType { get; } + + /// + /// The members whose drawings have no material or no thickness. They cannot be released + /// until the library drawings are given one, so the group is always refused. + /// + public bool IsUnassigned { get; } + + /// The members, in set order. + public IReadOnlyList Items { get; } + + /// Sum of the released quantities. + public int TotalQuantity => Items.Sum(i => i.Quantity); + + /// Operator-facing notes that do not stop the release. + public IReadOnlyList Warnings { get; } + + /// Why this group cannot be released; empty when it can. + public string Refusal { get; } + + public bool CanRelease => Refusal.Length == 0; +} + +/// +/// How a drawing set revision splits into nests, before anything is downloaded or created. +/// Pure: it reads the members' joined drawing records only. Members are grouped by +/// material name and grade (compared by ), thickness +/// (rounded as the library stores it), units and cut type, and every quantity is +/// multiplied. Archived drawings are skipped unless included (S5). A group is refused when +/// two of its drawings share a name, because a nest keys drawings by name (S6). +/// +public sealed class SetReleasePlan +{ + private SetReleasePlan(DrawingSetRecord set, int multiplier, bool includeArchived, + IReadOnlyList groups, IReadOnlyList skippedArchived) + { + Set = set; + Multiplier = multiplier; + IncludeArchived = includeArchived; + Groups = groups; + SkippedArchived = skippedArchived; + } + + /// The set revision being released, as read from the server. + public DrawingSetRecord Set { get; } + + public int Multiplier { get; } + + public bool IncludeArchived { get; } + + /// + /// Groups ordered by material, grade, thickness, units and cut type, with the + /// unassigned group last. + /// + public IReadOnlyList Groups { get; } + + /// Members left out because their drawing is archived and archived drawings were not included. + public IReadOnlyList SkippedArchived { get; } + + /// A set read from the server, so every member carries its drawing's current record. + public static SetReleasePlan Build(DrawingSetRecord set, int multiplier, bool includeArchived) + { + ArgumentNullException.ThrowIfNull(set); + if (multiplier < 1 || multiplier > SetReleaseRules.MaxMultiplier) + throw new ArgumentOutOfRangeException(nameof(multiplier), multiplier, + $"The multiplier must be between 1 and {SetReleaseRules.MaxMultiplier}."); + if (set.Id == Guid.Empty || set.Revision < 1) + throw new ArgumentException("Only a set read from the server can be released.", nameof(set)); + var members = set.Members ?? throw new ArgumentException("The set has no member list.", nameof(set)); + if (members.Any(m => m?.Drawing is null || m.Drawing.Id != m.DrawingId)) + throw new ArgumentException("A set member is missing its library drawing. Reload the set.", nameof(set)); + + var skipped = new List(); + var items = new List<(string Key, bool Unassigned, SetReleaseItem Item)>(); + foreach (var member in members) + { + if (member.Drawing!.Archived && !includeArchived) + { + skipped.Add(member); + continue; + } + + var unassigned = IsUnassigned(member.Drawing); + items.Add((unassigned ? UnassignedKey : GroupKey(member.Drawing), unassigned, + new SetReleaseItem(member, member.Quantity * multiplier))); + } + + var groups = items + .GroupBy(i => i.Key, StringComparer.Ordinal) + .Select(g => BuildGroup(g.Key, g.First().Unassigned, g.Select(i => i.Item).ToList())) + .OrderBy(g => g.IsUnassigned) + .ThenBy(g => SharedListNames.Key(g.MaterialName), StringComparer.Ordinal) + .ThenBy(g => SharedListNames.Key(g.MaterialGrade), StringComparer.Ordinal) + .ThenBy(g => g.Thickness) + .ThenBy(g => g.Units) + .ThenBy(g => g.CutType) + .ToList(); + return new SetReleasePlan(set, multiplier, includeArchived, groups, skipped); + } + + /// The group key of a drawing that has a material and thickness. + public static string GroupKey(DrawingLibraryRecord drawing) + { + ArgumentNullException.ThrowIfNull(drawing); + return string.Join('|', + SharedListNames.Key(drawing.MaterialName), + SharedListNames.Key(drawing.MaterialGrade), + DrawingLibraryRules.NormalizeThickness(drawing.Thickness).ToString("0.####", CultureInfo.InvariantCulture), + drawing.Units.ToString(), + drawing.CutType.ToString()); + } + + /// The key of the group holding every drawing without a material or thickness. + public const string UnassignedKey = "unassigned"; + + private static bool IsUnassigned(DrawingLibraryRecord drawing) => + SharedListNames.Key(drawing.MaterialName).Length == 0 + || !(DrawingLibraryRules.NormalizeThickness(drawing.Thickness) > 0); + + private static SetReleaseGroup BuildGroup(string key, bool unassigned, IReadOnlyList items) + { + var warnings = items.Where(i => i.Archived) + .Select(i => $"'{i.Drawing.Name}' is archived in the library.") + .ToList(); + + var refusal = ""; + if (unassigned) + { + refusal = $"{Names(items)} {(items.Count == 1 ? "has" : "have")} no material or thickness. " + + "Set them on the library drawings, then release again."; + } + else + { + // Matches LibraryDrawingInsert, which refuses a second drawing with the same name. + var duplicate = items.GroupBy(i => i.Drawing.Name, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1); + if (duplicate != null) + refusal = $"{duplicate.Count()} drawings in this group are named '{duplicate.Key}'. " + + "A nest keys drawings by name, so rename all but one in the library first."; + } + + return new SetReleaseGroup(key, items[0].Drawing, unassigned, items, warnings, refusal); + } + + private static string Names(IReadOnlyList items) + { + var names = items.Take(3).Select(i => $"'{i.Drawing.Name}'").ToList(); + if (items.Count > 3) + names.Add($"{items.Count - 3} more"); + return string.Join(", ", names); + } +} diff --git a/OpenNest.Data/SetReleaseSession.cs b/OpenNest.Data/SetReleaseSession.cs new file mode 100644 index 00000000..25b299f0 --- /dev/null +++ b/OpenNest.Data/SetReleaseSession.cs @@ -0,0 +1,399 @@ +using OpenNest; + +namespace OpenNest.Data; + +public enum SetReleaseGroupOutcome +{ + /// The group's nest is on the server; is its record. + Created, + + /// The group could not be built; nothing was uploaded for any group. + Refused, + + /// Building or uploading the group failed; says how. + Failed, + + /// An earlier group stopped the release, or it was canceled, before this group was reached. + NotStarted, +} + +/// What happened to one chosen group of a release. +public sealed class SetReleaseGroupResult +{ + internal SetReleaseGroupResult(SetReleaseGroup group) + { + Group = group; + } + + public SetReleaseGroup Group { get; } + + public SetReleaseGroupOutcome Outcome { get; internal set; } = SetReleaseGroupOutcome.NotStarted; + + /// + /// The nest as built, held so a created one can be opened without downloading it again. + /// Set for a created nest and for one whose upload failed; null otherwise. + /// + public Nest? Nest { get; internal set; } + + /// The server record of a created nest: its id and revision. + public NestRecord? Record { get; internal set; } + + /// Why the group was refused or failed; empty otherwise. + public string Error { get; internal set; } = ""; + + /// The plan's warnings plus any the library inserts raised. + public IReadOnlyList Warnings { get; internal set; } = Array.Empty(); +} + +public enum SetReleaseStage +{ + /// Downloading the group's drawings and building its nest; nothing is uploaded yet. + Preparing, + + /// Uploading the group's nest. + Uploading, + + /// Every nest is uploaded; the release record is being posted. + Recording, +} + +/// Where a release has got to. is null while recording. +public sealed class SetReleaseProgress +{ + public SetReleaseProgress(SetReleaseStage stage, int index, int count, SetReleaseGroup? group) + { + Stage = stage; + Index = index; + Count = count; + Group = group; + } + + public SetReleaseStage Stage { get; } + + /// Zero-based position of among the chosen groups. + public int Index { get; } + + public int Count { get; } + + public SetReleaseGroup? Group { get; } +} + +/// Every chosen group's outcome, and the release record when all of them were created. +public sealed class SetReleaseResult +{ + internal SetReleaseResult(IReadOnlyList groups, bool canceled, + SetReleaseRecord? release, string releaseError) + { + Groups = groups; + Canceled = canceled; + Release = release; + ReleaseError = releaseError; + } + + /// The chosen groups in plan order. + public IReadOnlyList Groups { get; } + + /// The nests now on the server, whether or not the release was recorded. + public IReadOnlyList Created => + Groups.Where(g => g.Outcome == SetReleaseGroupOutcome.Created).ToList(); + + /// Cancellation stopped the release between groups. + public bool Canceled { get; } + + /// The stored release; null unless every chosen group was created and the post succeeded. + public SetReleaseRecord? Release { get; } + + /// + /// Why posting the release failed after every nest was created; empty otherwise. + /// posts the same release again. + /// + public string ReleaseError { get; } +} + +/// +/// Releases a drawing set: one new nest per chosen group of a , +/// each built through from the members' exact library +/// revisions, uploaded as a , and only then recorded as one +/// release (S7). +/// +/// Every group is built before any is uploaded, so a refusal (a package that no longer +/// matches its record, units the new nest does not use, an insert refusal) creates +/// nothing. Uploads then run in order; the first failure stops them, and cancellation is +/// honoured between uploads, never during one. Nests already uploaded stay as ordinary +/// nests and are listed in the result; the release is posted only when every chosen group +/// was created. Nothing is rolled back. +/// +/// +public sealed class SetReleaseSession +{ + private readonly IDrawingSetStore _sets; + private readonly IDrawingLibrary _library; + private readonly INestRepository _nests; + private readonly string _serverUrl; + private readonly Func _readPackage; + private readonly Func _writeNest; + private readonly Func> _createNest; + private bool _started; + private SetReleaseRecord? _pendingRelease; + + /// Reads a downloaded library package (the desktop uses DrawingPackage.Read). + /// Serializes a nest archive for upload (the desktop uses NestWriter). + /// + /// Makes an empty nest for a group, named and set up as File > New would, in the group's + /// units. The session then sets its material, grade, thickness, customer and status. + /// + public SetReleaseSession(IDrawingSetStore sets, IDrawingLibrary library, INestRepository nests, string serverUrl, + Func readPackage, Func writeNest, + Func> createNest) + { + _sets = sets ?? throw new ArgumentNullException(nameof(sets)); + _library = library ?? throw new ArgumentNullException(nameof(library)); + _nests = nests ?? throw new ArgumentNullException(nameof(nests)); + _readPackage = readPackage ?? throw new ArgumentNullException(nameof(readPackage)); + _writeNest = writeNest ?? throw new ArgumentNullException(nameof(writeNest)); + _createNest = createNest ?? throw new ArgumentNullException(nameof(createNest)); + if (ServerUrl.Key(serverUrl).Length == 0) + throw new ArgumentException("A set release needs the nest server URL.", nameof(serverUrl)); + _serverUrl = serverUrl; + } + + /// Every nest was created but the release was not recorded; may post it. + public bool HasPendingRelease => _pendingRelease != null; + + /// Releases , all from . Call once per session. + public async Task RunAsync(SetReleasePlan plan, IReadOnlyCollection groups, + string releasedBy, string notes, IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(groups); + if (groups.Count == 0) + throw new ArgumentException("Choose at least one group to release.", nameof(groups)); + if (groups.Any(g => g is null || !plan.Groups.Contains(g)) || groups.Distinct().Count() != groups.Count) + throw new ArgumentException("Every group must come from the plan, once.", nameof(groups)); + if (groups.FirstOrDefault(g => !g.CanRelease) is { } refused) + throw new ArgumentException(refused.Refusal, nameof(groups)); + var textError = DrawingLibraryRules.TextError("releasedBy", releasedBy ?? "", DrawingLibraryRules.MaxTextLength) + ?? DrawingLibraryRules.TextError("notes", notes ?? "", DrawingLibraryRules.MaxNotesLength); + if (textError is not null) + throw new ArgumentException(textError); + if (_started) + throw new InvalidOperationException("This release session has already run."); + _started = true; + + var results = plan.Groups.Where(groups.Contains).Select(g => new SetReleaseGroupResult(g)).ToList(); + + // Build every nest first: a refusal here leaves nothing on the server. + for (var i = 0; i < results.Count; i++) + { + var result = results[i]; + progress?.Report(new SetReleaseProgress(SetReleaseStage.Preparing, i, results.Count, result.Group)); + bool ready; + try + { + ready = await PrepareAsync(plan, result, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + DiscardBuilt(results); + return new SetReleaseResult(results, canceled: true, null, ""); + } + catch (Exception ex) when (ex is IOException or HttpRequestException or InvalidDataException + or System.Text.Json.JsonException or TimeoutException) + { + result.Outcome = SetReleaseGroupOutcome.Failed; + result.Error = $"Could not prepare the {Describe(result.Group)} nest: {ex.Message}"; + ready = false; + } + + if (!ready) + { + DiscardBuilt(results); + return new SetReleaseResult(results, canceled: false, null, ""); + } + } + + for (var i = 0; i < results.Count; i++) + { + if (cancellationToken.IsCancellationRequested) + { + DiscardBuilt(results); + return new SetReleaseResult(results, canceled: true, null, ""); + } + + progress?.Report(new SetReleaseProgress(SetReleaseStage.Uploading, i, results.Count, results[i].Group)); + if (!await UploadAsync(results[i])) + { + DiscardBuilt(results); + return new SetReleaseResult(results, canceled: false, null, ""); + } + } + + _pendingRelease = new SetReleaseRecord + { + Id = Guid.NewGuid(), + SetId = plan.Set.Id, + SetRevision = plan.Set.Revision, + Multiplier = plan.Multiplier, + Notes = notes ?? "", + ReleasedBy = releasedBy ?? "", + Nests = results.Select(r => new SetReleaseNest + { + NestId = r.Record!.Id, + MaterialName = r.Group.MaterialName, + MaterialGrade = r.Group.MaterialGrade, + Thickness = r.Group.Thickness, + Units = r.Group.Units, + CutType = r.Group.CutType, + }).ToList(), + }; + progress?.Report(new SetReleaseProgress(SetReleaseStage.Recording, results.Count, results.Count, null)); + try + { + return new SetReleaseResult(results, canceled: false, await PostPendingAsync(CancellationToken.None), ""); + } + catch (Exception ex) when (ex is IOException or HttpRequestException or System.Text.Json.JsonException + or OperationCanceledException) + { + return new SetReleaseResult(results, canceled: false, null, + $"Every nest was created, but the release was not recorded: {ex.Message}"); + } + } + + /// + /// Posts the release again after , under the + /// same id, so a post that was in fact stored is not recorded twice. + /// + public Task RetryReleaseAsync(CancellationToken cancellationToken = default) + { + if (_pendingRelease == null) + throw new InvalidOperationException("There is no unrecorded release to post."); + return PostPendingAsync(cancellationToken); + } + + // Builds the group's nest in memory; false when the group is refused. + private async Task PrepareAsync(SetReleasePlan plan, SetReleaseGroupResult result, CancellationToken cancellationToken) + { + var group = result.Group; + var nest = await _createNest(group, cancellationToken) + ?? throw new InvalidOperationException("The nest factory returned no nest."); + if (nest.Units != group.Units) + return Refuse(result, $"The new nest for {Describe(group)} is in {Name(nest.Units)}, not {Name(group.Units)}."); + + var current = nest.Material; + nest.Material = current != null && SharedListNames.Key(current.Name) == SharedListNames.Key(group.MaterialName) + && SharedListNames.Key(current.Grade) == SharedListNames.Key(group.MaterialGrade) + ? new Material(group.MaterialName, group.MaterialGrade, current.Density) + : new Material(group.MaterialName, group.MaterialGrade); + nest.Thickness = group.Thickness; + nest.Status = NestStatus.Draft; + if (!string.IsNullOrWhiteSpace(plan.Set.Customer)) + nest.Customer = plan.Set.Customer; + + var warnings = group.Warnings.ToList(); + foreach (var item in group.Items) + { + var record = item.Drawing; + var bytes = await _library.GetRevisionPackageAsync(record.Id, record.Revision, cancellationToken); + if (bytes == null) + return Refuse(result, $"'{record.Name}' revision {record.Revision} is no longer in the library. Reload the set."); + + var package = await Task.Run(() => _readPackage(bytes), cancellationToken); + var insert = LibraryDrawingInsert.Plan(nest, _serverUrl, record, package, item.Quantity); + if (insert.IsRefused) + return Refuse(result, insert.Refusal); + + warnings.AddRange(insert.Warnings); + insert.Apply(); + } + + result.Nest = nest; + result.Warnings = warnings; + return true; + } + + // True when the nest is on the server. A failed upload is checked by its id, which no + // other client can hold, so a nest stored before its reply was lost is still reported. + private async Task UploadAsync(SetReleaseGroupResult result) + { + var nest = result.Nest!; + var id = Guid.NewGuid(); + Exception failure; + try + { + var archive = await Task.Run(() => _writeNest(nest)); + var saved = await _nests.UploadAsync(archive, NestRecordFactory.FromNest(nest, id, archive.LongLength), + CancellationToken.None); + if (saved.Id == id) + return Created(result, saved); + failure = new IOException($"The nest server stored it under a different id ({saved.Id})."); + } + catch (Exception ex) + { + failure = ex; + } + + string stored; + try + { + if (await _nests.GetMetadataAsync(id, CancellationToken.None) is { } found && found.Id == id) + return Created(result, found); + stored = "It was not saved."; + } + catch (Exception ex) when (ex is IOException or HttpRequestException or System.Text.Json.JsonException + or OperationCanceledException) + { + stored = $"It may have been saved as '{nest.Name}' (id {id}); check Open from Database before releasing again."; + } + + result.Outcome = SetReleaseGroupOutcome.Failed; + result.Error = $"Could not upload the {Describe(result.Group)} nest: {failure.Message} {stored}"; + return false; + } + + private async Task PostPendingAsync(CancellationToken cancellationToken) + { + var release = _pendingRelease!; + SetReleaseRecord stored; + try + { + stored = await _sets.PostReleaseAsync(release.SetId, release, cancellationToken); + } + catch (SetReleasePostConflictException conflict) when (conflict.Current.Id == release.Id) + { + // An earlier post whose reply was lost was stored. + stored = conflict.Current; + } + + _pendingRelease = null; + return stored; + } + + private static bool Created(SetReleaseGroupResult result, NestRecord record) + { + result.Outcome = SetReleaseGroupOutcome.Created; + result.Record = record; + return true; + } + + private static bool Refuse(SetReleaseGroupResult result, string reason) + { + result.Outcome = SetReleaseGroupOutcome.Refused; + result.Error = reason; + return false; + } + + // Only nests on the server, or whose upload failed, are kept for the caller to show. + private static void DiscardBuilt(IEnumerable results) + { + foreach (var result in results.Where(r => r.Outcome is SetReleaseGroupOutcome.NotStarted or SetReleaseGroupOutcome.Refused)) + result.Nest = null; + } + + private static string Describe(SetReleaseGroup group) => + string.IsNullOrWhiteSpace(group.MaterialGrade) + ? $"{group.MaterialName} {group.Thickness:0.####} {group.CutType.ToString().ToLowerInvariant()}" + : $"{group.MaterialName} {group.MaterialGrade} {group.Thickness:0.####} {group.CutType.ToString().ToLowerInvariant()}"; + + private static string Name(Units units) => units.ToString().ToLowerInvariant(); +} diff --git a/OpenNest.Server.Tests/DrawingSetClientTests.cs b/OpenNest.Server.Tests/DrawingSetClientTests.cs new file mode 100644 index 00000000..9dd590fb --- /dev/null +++ b/OpenNest.Server.Tests/DrawingSetClientTests.cs @@ -0,0 +1,109 @@ +using OpenNest.Data; +using static OpenNest.Server.Tests.DrawingSetFixtures; + +namespace OpenNest.Server.Tests; + +/// The real against the production set and release routes. +public sealed class DrawingSetClientTests +{ + [Fact] + public async Task Sets_RoundTripThroughEveryRoute() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var store = new RemoteDrawingSetStore(client, client.BaseAddress!.ToString()); + var a = await AddDrawing(client, "Synthetic A"); + var b = await AddDrawing(client, "Synthetic B"); + + var created = await store.CreateAsync(Set(members: [(a, 2)])); + var read = await store.GetAsync(created.Id); + read!.Members = read.Members.Append(new DrawingSetMember { DrawingId = b, Quantity = 5 }).ToList(); + read.Notes = "Synthetic revised notes"; + var updated = await store.UpdateAsync(created.Id, read, read.Revision); + var archived = await store.ArchiveAsync(created.Id, updated.Revision); + var restored = await store.RestoreAsync(created.Id, archived.Revision); + + Assert.Equal((1, 2, 3, 4), (created.Revision, updated.Revision, archived.Revision, restored.Revision)); + Assert.Equal("Synthetic A", read.Members[0].Drawing!.Name); + Assert.Equal(new[] { (a, 2), (b, 5) }, MembersOf(updated)); + Assert.True(archived.Archived); + Assert.False(restored.Archived); + + var history = await store.GetRevisionsAsync(created.Id, 1, 2); + Assert.Equal(new[] { 3, 2 }, history!.Items.Select(r => r.Revision)); + Assert.Equal(4, history.Total); + var first = await store.GetRevisionAsync(created.Id, 1); + Assert.Equal(new[] { (a, 2) }, MembersOf(first!)); + Assert.Equal("Synthetic B", (await store.GetAsync(created.Id))!.Members[1].Drawing!.Name); + + var usedIn = await store.GetSetsContainingAsync(b); + Assert.Equal(created.Id, Assert.Single(usedIn!.Items).Id); + var page = await store.QueryAsync(new DrawingSetQuery { Search = "revised", Customer = " synthetic CUSTOMER" }); + Assert.Equal(created.Id, Assert.Single(page.Items).Id); + Assert.Empty((await store.QueryAsync(new DrawingSetQuery { Customer = "Someone else" })).Items); + } + + [Fact] + public async Task Writes_ThatConflict_CarryTheCurrentRecord() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var store = new RemoteDrawingSetStore(client, client.BaseAddress!.ToString()); + var created = await store.CreateAsync(Set()); + var renamed = Set("Synthetic renamed", created.Id); + await store.UpdateAsync(created.Id, renamed, 1); + + var stale = await Assert.ThrowsAsync( + () => store.UpdateAsync(created.Id, Set("Synthetic stale", created.Id), 1)); + var duplicate = await Assert.ThrowsAsync(() => store.CreateAsync(Set("Second", created.Id))); + var restoreActive = await Assert.ThrowsAsync(() => store.RestoreAsync(created.Id, 2)); + + Assert.All(new[] { stale, duplicate, restoreActive }, conflict => + Assert.Equal(("Synthetic renamed", 2), (conflict.Current.Name, conflict.Current.Revision))); + } + + [Fact] + public async Task UnknownIds_ReadAsNull() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var store = new RemoteDrawingSetStore(client, client.BaseAddress!.ToString()); + var set = await store.CreateAsync(Set()); + + Assert.Null(await store.GetAsync(Guid.NewGuid())); + Assert.Null(await store.GetRevisionAsync(set.Id, 2)); + Assert.Null(await store.GetRevisionsAsync(Guid.NewGuid())); + Assert.Null(await store.GetSetsContainingAsync(Guid.NewGuid())); + Assert.Null(await store.GetReleasesAsync(Guid.NewGuid())); + Assert.Null(await store.GetReleaseForNestAsync(Guid.NewGuid())); + var unknown = Set(); + await Assert.ThrowsAsync(() => store.UpdateAsync(unknown.Id, unknown, 1)); + } + + [Fact] + public async Task Releases_PostReadAndRefuseANestTwice() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var store = new RemoteDrawingSetStore(client, client.BaseAddress!.ToString()); + var set = await store.CreateAsync(Set()); + var steel = await AddNest(client, "Synthetic steel nest"); + var aluminum = await AddNest(client, "Synthetic aluminum nest"); + var release = Release(1, steel, aluminum); + + var stored = await store.PostReleaseAsync(set.Id, release); + var repost = await Assert.ThrowsAsync(() => store.PostReleaseAsync(set.Id, release)); + var reuse = await Assert.ThrowsAsync(() => store.PostReleaseAsync(set.Id, Release(1, steel))); + + Assert.Equal((release.Id, set.Id, "Synthetic kit", 2), (stored.Id, stored.SetId, stored.SetName, stored.Multiplier)); + Assert.Equal(new[] { steel, aluminum }, stored.Nests.Select(n => n.NestId)); + Assert.Equal(release.Id, repost.Current.Id); + Assert.Equal(release.Id, reuse.Current.Id); + + var page = await store.GetReleasesAsync(set.Id); + var listed = Assert.Single(page!.Items); + Assert.Equal(new[] { "Synthetic steel nest", "Synthetic aluminum nest" }, listed.Nests.Select(n => n.Nest!.Name)); + Assert.Equal(release.Id, (await store.GetReleaseForNestAsync(aluminum))!.Id); + await Assert.ThrowsAsync(() => store.PostReleaseAsync(Guid.NewGuid(), Release(1, Guid.NewGuid()))); + } +} diff --git a/OpenNest.Server.Tests/SetReleaseEndToEndTests.cs b/OpenNest.Server.Tests/SetReleaseEndToEndTests.cs new file mode 100644 index 00000000..7c9ee8f6 --- /dev/null +++ b/OpenNest.Server.Tests/SetReleaseEndToEndTests.cs @@ -0,0 +1,118 @@ +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Data; +using OpenNest.Geometry; +using OpenNest.IO; +using OpenNest.Shapes; + +namespace OpenNest.Server.Tests; + +/// +/// The whole release path: code-built drawings in two materials are packaged into the +/// library, collected into a set, released ×2 through the real clients, and the nests and +/// release record are read back from the production routes. +/// +public sealed class SetReleaseEndToEndTests +{ + private static Drawing SyntheticDrawing(string name, double length) + { + var drawing = new RectangleShape { Name = name, Length = length, Width = 2 }.GetDrawing(); + drawing.CutPins.Add(new ContourCutPin(new Vector(length, 2), new LineLeadIn { Length = 0.25 }, new NoLeadOut(), + new NormalTab { Size = 0.03 })); + return drawing; + } + + private static async Task Store(RemoteDrawingLibrary library, string name, double length, + string material, CutType cutType) + { + var record = new DrawingLibraryRecord + { + Id = Guid.NewGuid(), + Name = name, + Customer = "Synthetic customer", + MaterialName = material, + MaterialGrade = "Grade A", + Thickness = 0.25, + Units = Units.Inches, + CutType = cutType, + SavedBy = "Synthetic operator", + }; + return await library.UploadAsync(record, DrawingPackage.Write(SyntheticDrawing(name, length), Units.Inches)); + } + + private static byte[] Write(Nest nest) + { + using var stream = new MemoryStream(); + new NestWriter(nest).Write(stream); + return stream.ToArray(); + } + + private static Nest Read(byte[] archive) + { + using var stream = new MemoryStream(archive, writable: false); + return new NestReader(stream).Read(); + } + + [Fact] + public async Task Release_MakesOneDraftNestPerMaterialAndRecordsThem() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var url = client.BaseAddress!.ToString(); + using var library = new RemoteDrawingLibrary(client, url); + using var sets = new RemoteDrawingSetStore(client, url); + using var nests = new RemoteNestRepository(client, url); + var a = await Store(library, "SYN-A", 4, "Synthetic Steel", CutType.Laser); + var b = await Store(library, "SYN-B", 5, "Synthetic Steel", CutType.Laser); + var c = await Store(library, "SYN-C", 6, "Synthetic Aluminum", CutType.Waterjet); + var created = await sets.CreateAsync(new DrawingSetRecord + { + Id = Guid.NewGuid(), + Name = "Synthetic kit", + Customer = "Synthetic set customer", + Members = new[] { (a, 3), (b, 1), (c, 2) } + .Select(m => new DrawingSetMember { DrawingId = m.Item1.Id, Quantity = m.Item2 }).ToList(), + }); + + var set = await sets.GetAsync(created.Id); + var plan = SetReleasePlan.Build(set!, 2, includeArchived: false); + var number = 0; + var session = new SetReleaseSession(sets, library, nests, url, + bytes => + { + var contents = DrawingPackage.Read(bytes); + return new LibraryPackage(bytes, contents.Drawing, contents.Units, contents.Warnings); + }, + Write, + (group, _) => Task.FromResult(new Nest($"Synthetic release {++number}") { Units = group.Units })); + + var result = await session.RunAsync(plan, plan.Groups, "Synthetic operator", "Synthetic release notes"); + + Assert.Equal("", result.ReleaseError); + Assert.Equal(2, result.Created.Count); + foreach (var group in result.Groups) + { + var stored = Read((await nests.GetFileAsync(group.Record!.Id))!); + Assert.Equal(NestStatus.Draft, (await nests.GetMetadataAsync(group.Record.Id))!.Status); + Assert.Equal((NestStatus.Draft, group.Group.MaterialName, 0.25, "Synthetic set customer"), + (stored.Status, stored.Material.Name, stored.Thickness, stored.Customer)); + foreach (var item in group.Group.Items) + { + var drawing = stored.Drawings.Single(d => d.Name == item.Drawing.Name); + Assert.Equal(item.Member.Quantity * 2, drawing.Quantity.Required); + Assert.Equal((item.Drawing.Id, 1, url.TrimEnd('/')), (drawing.Library!.Id, drawing.Library.Revision, drawing.Library.ServerKey)); + Assert.Single(drawing.CutPins); + } + } + var steel = result.Groups.Single(g => g.Group.MaterialName == "Synthetic Steel"); + Assert.Equal(new[] { "SYN-A", "SYN-B" }, steel.Group.Items.Select(i => i.Drawing.Name)); + + var releases = await sets.GetReleasesAsync(created.Id); + var release = Assert.Single(releases!.Items); + Assert.Equal((result.Release!.Id, 1, 2, "Synthetic release notes"), + (release.Id, release.SetRevision, release.Multiplier, release.Notes)); + Assert.Equal(result.Groups.Select(g => g.Record!.Id), release.Nests.Select(n => n.NestId)); + Assert.All(release.Nests, n => Assert.Equal(NestStatus.Draft, n.Nest!.Status)); + Assert.Equal(CutType.Waterjet, release.Nests.Single(n => n.MaterialName == "Synthetic Aluminum").CutType); + Assert.Equal(release.Id, (await sets.GetReleaseForNestAsync(steel.Record!.Id))!.Id); + } +} diff --git a/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs b/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs new file mode 100644 index 00000000..56943553 --- /dev/null +++ b/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class RemoteDrawingSetStoreTests +{ + private static RemoteDrawingSetStore Store(StubHandler handler) => + new(new HttpClient(handler), "http://server:8090/base/"); + + private static DrawingSetRecord Set(Guid? drawing = null) => new() + { + Id = Guid.NewGuid(), + Name = "Synthetic kit", + Members = new[] { new DrawingSetMember { DrawingId = drawing ?? Guid.NewGuid(), Quantity = 2 } }, + }; + + [Fact] + public async Task QueryAsync_SendsEscapedFilters() + { + var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty(), total = 0, offset = 5, limit = 5 })); + using var store = Store(handler); + + await store.QueryAsync(new DrawingSetQuery + { + Search = " 50%_ ", + Customer = "A&B", + IncludeArchived = true, + Sort = DrawingSetSortField.Customer, + Descending = false, + Offset = 5, + Limit = 5, + }); + + Assert.Equal( + "http://server:8090/base/api/sets/query?search=50%25_&sort=customer&order=asc&offset=5&limit=5&customer=A%26B&includeArchived=true", + handler.Requests.Single().RequestUri!.AbsoluteUri); + } + + [Fact] + public async Task OldServer_RaisesServerTooOld() + { + using var store = Store(new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound))); + + var query = await Assert.ThrowsAsync(() => store.QueryAsync(new DrawingSetQuery())); + await Assert.ThrowsAsync(() => store.GetAsync(Guid.NewGuid())); + await Assert.ThrowsAsync(() => store.GetReleaseForNestAsync(Guid.NewGuid())); + Assert.Contains("drawing sets", query.Message); + } + + [Fact] + public async Task UnknownSet_OnASetAwareServer_IsNull() + { + var handler = new StubHandler(request => request.RequestUri!.AbsolutePath.EndsWith("/api/sets/query") + ? StubHandler.Json(new { items = Array.Empty(), total = 0, offset = 0, limit = 1 }) + : new HttpResponseMessage(HttpStatusCode.NotFound)); + using var store = Store(handler); + + Assert.Null(await store.GetAsync(Guid.NewGuid())); + Assert.EndsWith("api/sets/query?limit=1", handler.Requests[1].RequestUri!.AbsoluteUri); + } + + [Fact] + public async Task InvalidSet_IsRefusedBeforeSending() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("Nothing may be sent.")); + using var store = Store(handler); + var duplicate = Set(); + duplicate.Members = duplicate.Members.Concat(duplicate.Members).ToList(); + + await Assert.ThrowsAsync(() => store.CreateAsync(duplicate)); + await Assert.ThrowsAsync(() => store.PostReleaseAsync(Guid.NewGuid(), + new SetReleaseRecord { Id = Guid.NewGuid(), SetRevision = 1, Multiplier = 0 })); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task CreateAsync_SendsMembersWithoutJoinedDrawingsAndChecksTheReply() + { + var drawing = Guid.NewGuid(); + var set = Set(drawing); + set.Members[0].Drawing = new DrawingLibraryRecord { Id = drawing, Name = "Synthetic joined" }; + var handler = new StubHandler(_ => StubHandler.Json(new + { + id = set.Id, + revision = 1, + name = set.Name, + members = new[] { new { drawingId = drawing, quantity = 3 } }, + })); + using var store = Store(handler); + + await Assert.ThrowsAsync(() => store.CreateAsync(set)); + Assert.DoesNotContain("Synthetic joined", handler.RequestBodies.Single()); + } + + [Fact] + public async Task PostReleaseAsync_ReplyForOtherNests_IsRefused() + { + var setId = Guid.NewGuid(); + var release = new SetReleaseRecord + { + Id = Guid.NewGuid(), + SetRevision = 1, + Multiplier = 1, + Nests = new[] { new SetReleaseNest { NestId = Guid.NewGuid(), MaterialName = "Synthetic Steel" } }, + }; + var handler = new StubHandler(_ => StubHandler.Json(new + { + id = release.Id, + setId, + setRevision = 1, + nests = new[] { new { nestId = Guid.NewGuid() } }, + })); + using var store = Store(handler); + + await Assert.ThrowsAsync(() => store.PostReleaseAsync(setId, release)); + } +} diff --git a/OpenNest.Tests/Data/SetReleasePlanTests.cs b/OpenNest.Tests/Data/SetReleasePlanTests.cs new file mode 100644 index 00000000..faaf55b3 --- /dev/null +++ b/OpenNest.Tests/Data/SetReleasePlanTests.cs @@ -0,0 +1,172 @@ +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class SetReleasePlanTests +{ + internal static DrawingLibraryRecord Drawing(string name, string material = "Synthetic Steel", string grade = "Grade A", + double thickness = 0.25, Units units = Units.Inches, CutType cutType = CutType.Laser, bool archived = false) => new() + { + Id = Guid.NewGuid(), + Revision = 2, + Name = name, + Customer = "Synthetic customer", + MaterialName = material, + MaterialGrade = grade, + Thickness = thickness, + Units = units, + CutType = cutType, + Archived = archived, + }; + + internal static DrawingSetRecord Set(params (DrawingLibraryRecord Drawing, int Quantity)[] members) => new() + { + Id = Guid.NewGuid(), + Revision = 4, + Name = "Synthetic kit", + Customer = "Synthetic set customer", + Members = members.Select(m => new DrawingSetMember { DrawingId = m.Drawing.Id, Quantity = m.Quantity, Drawing = m.Drawing }).ToList(), + }; + + [Fact] + public void Build_GroupsByNormalizedMaterialGradeThicknessUnitsAndCutType() + { + var a = Drawing("SYN-A"); + var sameSpelledApart = Drawing("SYN-B", material: " synthetic STEEL ", grade: "grade a", thickness: 0.25004); + var otherGrade = Drawing("SYN-C", grade: "Grade B"); + var otherCut = Drawing("SYN-D", cutType: CutType.Waterjet); + var otherThickness = Drawing("SYN-E", thickness: 0.1875); + var otherUnits = Drawing("SYN-F", units: Units.Millimeters); + + var plan = SetReleasePlan.Build( + Set((a, 1), (sameSpelledApart, 1), (otherGrade, 1), (otherCut, 1), (otherThickness, 1), (otherUnits, 1)), 1, false); + + Assert.Equal(5, plan.Groups.Count); + var first = plan.Groups.Single(g => g.Items.Any(i => i.Drawing == a)); + Assert.Equal(new[] { "SYN-A", "SYN-B" }, first.Items.Select(i => i.Drawing.Name)); + Assert.Equal(("Synthetic Steel", "Grade A", 0.25, Units.Inches, CutType.Laser), + (first.MaterialName, first.MaterialGrade, first.Thickness, first.Units, first.CutType)); + Assert.All(new[] { otherGrade, otherCut, otherThickness, otherUnits }, + d => Assert.Single(plan.Groups.Single(g => g.Items.Any(i => i.Drawing == d)).Items)); + Assert.All(plan.Groups, g => Assert.True(g.CanRelease)); + Assert.Equal(plan.Groups.Select(g => g.Key).Distinct().Count(), plan.Groups.Count); + } + + [Fact] + public void Build_OrdersGroupsByMaterialThenGradeThenThickness() + { + var plan = SetReleasePlan.Build(Set( + (Drawing("SYN-1", material: "Synthetic Steel", thickness: 0.5), 1), + (Drawing("SYN-2", material: "Synthetic Aluminum"), 1), + (Drawing("SYN-3", material: "Synthetic Steel", thickness: 0.25), 1), + (Drawing("SYN-4", material: "Synthetic Steel", grade: "Grade 0", thickness: 0.75), 1)), 1, false); + + Assert.Equal(new[] { "SYN-2", "SYN-4", "SYN-3", "SYN-1" }, plan.Groups.Select(g => g.Items[0].Drawing.Name)); + } + + [Fact] + public void Build_MultipliesEveryQuantity() + { + var plan = SetReleasePlan.Build(Set((Drawing("SYN-A"), 3), (Drawing("SYN-B"), 1)), 4, false); + + var group = Assert.Single(plan.Groups); + Assert.Equal(new[] { 12, 4 }, group.Items.Select(i => i.Quantity)); + Assert.Equal(16, group.TotalQuantity); + Assert.Equal(4, plan.Multiplier); + } + + [Theory] + [InlineData(0)] + [InlineData(SetReleaseRules.MaxMultiplier + 1)] + public void Build_RejectsMultiplierOutOfRange(int multiplier) + { + Assert.Throws(() => SetReleasePlan.Build(Set((Drawing("SYN-A"), 1)), multiplier, false)); + } + + [Fact] + public void Build_SkipsArchivedDrawingsUnlessIncluded() + { + var active = Drawing("SYN-A"); + var archived = Drawing("SYN-B", archived: true); + var set = Set((active, 1), (archived, 2)); + + var skipping = SetReleasePlan.Build(set, 1, includeArchived: false); + var including = SetReleasePlan.Build(set, 1, includeArchived: true); + + Assert.Equal(new[] { archived.Id }, skipping.SkippedArchived.Select(m => m.DrawingId)); + Assert.Equal(new[] { "SYN-A" }, Assert.Single(skipping.Groups).Items.Select(i => i.Drawing.Name)); + Assert.Empty(Assert.Single(skipping.Groups).Warnings); + + Assert.Empty(including.SkippedArchived); + var group = Assert.Single(including.Groups); + Assert.Equal(new[] { false, true }, group.Items.Select(i => i.Archived)); + Assert.Contains("'SYN-B' is archived", Assert.Single(group.Warnings)); + Assert.True(group.CanRelease); + } + + [Fact] + public void Build_OnlyArchivedMembers_HasNoGroups() + { + var plan = SetReleasePlan.Build(Set((Drawing("SYN-A", archived: true), 1)), 1, false); + + Assert.Empty(plan.Groups); + Assert.Single(plan.SkippedArchived); + } + + [Fact] + public void Build_RefusesAGroupWithDuplicateDrawingNames() + { + var plan = SetReleasePlan.Build(Set( + (Drawing("SYN-A"), 1), (Drawing("SYN-A"), 2), (Drawing("SYN-A", cutType: CutType.Plasma), 1), (Drawing("syn-a"), 1)), 1, false); + + var laser = plan.Groups.Single(g => g.CutType == CutType.Laser); + Assert.False(laser.CanRelease); + Assert.Contains("2 drawings in this group are named 'SYN-A'", laser.Refusal); + // The same name in another group, or differently cased, nests separately. + Assert.True(plan.Groups.Single(g => g.CutType == CutType.Plasma).CanRelease); + } + + [Fact] + public void Build_PutsDrawingsWithoutMaterialOrThicknessInOneRefusedGroupLast() + { + var plan = SetReleasePlan.Build(Set( + (Drawing("SYN-NOMAT", material: " "), 1), + (Drawing("SYN-A"), 1), + (Drawing("SYN-NOTHICK", thickness: 0, units: Units.Millimeters), 1)), 1, false); + + Assert.Equal(2, plan.Groups.Count); + var unassigned = plan.Groups[^1]; + Assert.True(unassigned.IsUnassigned); + Assert.Equal(SetReleasePlan.UnassignedKey, unassigned.Key); + Assert.Equal(new[] { "SYN-NOMAT", "SYN-NOTHICK" }, unassigned.Items.Select(i => i.Drawing.Name)); + Assert.False(unassigned.CanRelease); + Assert.Contains("no material or thickness", unassigned.Refusal); + Assert.False(plan.Groups[0].IsUnassigned); + } + + [Fact] + public void Build_RequiresASetReadFromTheServer() + { + var unsaved = Set((Drawing("SYN-A"), 1)); + unsaved.Revision = 0; + var unjoined = Set((Drawing("SYN-A"), 1)); + unjoined.Members[0].Drawing = null; + var mismatched = Set((Drawing("SYN-A"), 1)); + mismatched.Members[0].DrawingId = Guid.NewGuid(); + + Assert.Throws(() => SetReleasePlan.Build(unsaved, 1, false)); + Assert.Throws(() => SetReleasePlan.Build(unjoined, 1, false)); + Assert.Throws(() => SetReleasePlan.Build(mismatched, 1, false)); + } + + [Fact] + public void GroupKey_IsStableAcrossSpellingAndRebuilds() + { + var drawing = Drawing("SYN-A"); + var respelled = Drawing("SYN-B", material: "SYNTHETIC steel", grade: " grade a", thickness: 0.250001); + + Assert.Equal(SetReleasePlan.GroupKey(drawing), SetReleasePlan.GroupKey(respelled)); + Assert.Equal(SetReleasePlan.Build(Set((drawing, 1)), 1, false).Groups[0].Key, + SetReleasePlan.Build(Set((drawing, 1)), 7, false).Groups[0].Key); + } +} diff --git a/OpenNest.Tests/Data/SetReleaseSessionTests.cs b/OpenNest.Tests/Data/SetReleaseSessionTests.cs new file mode 100644 index 00000000..3522f5e7 --- /dev/null +++ b/OpenNest.Tests/Data/SetReleaseSessionTests.cs @@ -0,0 +1,424 @@ +using System.Security.Cryptography; +using System.Text; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Data; +using OpenNest.Geometry; +using OpenNest.Shapes; +using static OpenNest.Tests.Data.SetReleasePlanTests; + +namespace OpenNest.Tests.Data; + +public class SetReleaseSessionTests +{ + private const string Server = "http://library.test:8090"; + + private readonly FakeSets _sets = new(); + private readonly FakeLibrary _library = new(); + private readonly FakeNests _nests = new(); + private readonly List _created = new(); + + private DrawingLibraryRecord Stored(string name, string material = "Synthetic Steel", CutType cutType = CutType.Laser, + Units units = Units.Inches) + { + var record = Drawing(name, material: material, cutType: cutType, units: units); + var bytes = Encoding.UTF8.GetBytes($"package {record.Id}"); + record.PackageSha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + _library.Packages[(record.Id, record.Revision)] = (bytes, units); + return record; + } + + private SetReleaseSession Session(Func>? createNest = null) => + new(_sets, _library, _nests, Server + "/", ReadPackage, nest => Encoding.UTF8.GetBytes("nest " + nest.Name), + createNest ?? CreateNest); + + private Task CreateNest(SetReleaseGroup group, CancellationToken cancellationToken) + { + _created.Add(group); + return Task.FromResult(new Nest($"N{_created.Count}") { Units = group.Units, Material = new Material("Default", "", 0.1) }); + } + + private LibraryPackage ReadPackage(byte[] bytes) + { + var (_, units) = _library.Packages.Values.Single(p => p.Bytes.AsSpan().SequenceEqual(bytes)); + var drawing = new RectangleShape { Name = "package name", Length = 4, Width = 2 }.GetDrawing(); + drawing.CutPins.Add(new ContourCutPin(new Vector(4, 2), new LineLeadIn { Length = 0.25 }, new NoLeadOut(), + new NormalTab { Size = 0.03 })); + return new LibraryPackage(bytes, drawing, units); + } + + [Fact] + public async Task Run_CreatesOneDraftNestPerGroupThenRecordsTheRelease() + { + var a = Stored("SYN-A"); + var b = Stored("SYN-B"); + var c = Stored("SYN-C", material: "Synthetic Aluminum", cutType: CutType.Waterjet); + var set = Set((a, 3), (b, 1), (c, 2)); + var plan = SetReleasePlan.Build(set, 2, false); + var progress = new RecordingProgress(); + var session = Session(); + + var result = await session.RunAsync(plan, plan.Groups, "Synthetic operator", "Synthetic notes", progress); + + Assert.False(result.Canceled); + Assert.Equal("", result.ReleaseError); + Assert.Equal(2, result.Created.Count); + Assert.Equal(2, _nests.Uploaded.Count); + + var steel = result.Groups.Single(g => g.Group.MaterialName == "Synthetic Steel"); + var nest = steel.Nest!; + Assert.Equal(NestStatus.Draft, nest.Status); + Assert.Equal(("Synthetic Steel", "Grade A", 0.25), (nest.Material.Name, nest.Material.Grade, nest.Thickness)); + Assert.Equal("Synthetic set customer", nest.Customer); + Assert.Equal(new[] { ("SYN-A", 6), ("SYN-B", 2) }, + nest.Drawings.OrderBy(d => d.Name).Select(d => (d.Name, d.Quantity.Required))); + Assert.All(nest.Drawings, d => Assert.Equal(Server, d.Library!.ServerKey)); + Assert.Equal(a.Revision, nest.Drawings.Single(d => d.Name == "SYN-A").Library!.Revision); + // Material matches the new nest by construction, so the library pins are kept. + Assert.All(nest.Drawings, d => Assert.Single(d.CutPins)); + Assert.Equal(result.Groups.Select(g => g.Record!.Id), _nests.Uploaded.Select(u => u.Record.Id)); + var uploaded = _nests.Uploaded.Single(u => u.Record.Id == steel.Record!.Id).Record; + Assert.Equal((NestStatus.Draft, "Synthetic Steel", "N2"), (uploaded.Status, uploaded.Material, uploaded.Name)); + + var release = Assert.Single(_sets.Posted); + Assert.Same(result.Release, _sets.Stored.Single()); + Assert.Equal((set.Id, 4, 2, "Synthetic operator", "Synthetic notes"), + (release.SetId, release.SetRevision, release.Multiplier, release.ReleasedBy, release.Notes)); + Assert.Equal(result.Groups.Select(g => g.Record!.Id), release.Nests.Select(n => n.NestId)); + var aluminum = release.Nests.Single(n => n.MaterialName == "Synthetic Aluminum"); + Assert.Equal(("Grade A", 0.25, Units.Inches, CutType.Waterjet), + (aluminum.MaterialGrade, aluminum.Thickness, aluminum.Units, aluminum.CutType)); + + Assert.Equal(new[] + { + (SetReleaseStage.Preparing, 0), (SetReleaseStage.Preparing, 1), + (SetReleaseStage.Uploading, 0), (SetReleaseStage.Uploading, 1), (SetReleaseStage.Recording, 2), + }, progress.Reports.Select(p => (p.Stage, p.Index))); + Assert.False(session.HasPendingRelease); + } + + [Fact] + public async Task Run_OnlyChosenGroups_AreReleased() + { + var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1), (Stored("SYN-B", cutType: CutType.Plasma), 1)), 1, false); + var chosen = plan.Groups.Single(g => g.CutType == CutType.Plasma); + + var result = await Session().RunAsync(plan, new[] { chosen }, "", ""); + + Assert.Same(chosen, Assert.Single(result.Groups).Group); + Assert.Equal(CutType.Plasma, Assert.Single(Assert.Single(_sets.Posted).Nests).CutType); + } + + [Fact] + public async Task Run_PackageThatNoLongerMatchesItsRecord_RefusesBeforeAnythingIsUploaded() + { + var a = Stored("SYN-A"); + var b = Stored("SYN-B", material: "Synthetic Aluminum"); + // The drawing moved on after the set was read: the bytes no longer hash to the record. + var (bytes, units) = _library.Packages[(b.Id, b.Revision)]; + _library.Packages[(b.Id, b.Revision)] = (bytes.Append((byte)1).ToArray(), units); + var plan = SetReleasePlan.Build(Set((a, 1), (b, 1)), 1, false); + + var result = await Session().RunAsync(plan, plan.Groups, "", ""); + + var refused = result.Groups.Single(g => g.Group.MaterialName == "Synthetic Aluminum"); + Assert.Equal(SetReleaseGroupOutcome.Refused, refused.Outcome); + Assert.Contains("does not match the library record", refused.Error); + Assert.Equal(SetReleaseGroupOutcome.NotStarted, result.Groups.Single(g => g != refused).Outcome); + Assert.All(result.Groups, g => Assert.Null(g.Nest)); + Assert.Empty(_nests.Uploaded); + Assert.Empty(_sets.Posted); + Assert.Null(result.Release); + } + + [Fact] + public async Task Run_MissingRevision_RefusesTheGroup() + { + var a = Stored("SYN-A"); + _library.Packages.Clear(); + var plan = SetReleasePlan.Build(Set((a, 1)), 1, false); + + var result = await Session().RunAsync(plan, plan.Groups, "", ""); + + Assert.Equal(SetReleaseGroupOutcome.Refused, result.Groups[0].Outcome); + Assert.Contains("no longer in the library", result.Groups[0].Error); + Assert.Empty(_nests.Uploaded); + } + + [Fact] + public async Task Run_FactoryNestInOtherUnits_RefusesTheGroup() + { + var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false); + var session = Session((_, _) => Task.FromResult(new Nest("N1") { Units = Units.Millimeters })); + + var result = await session.RunAsync(plan, plan.Groups, "", ""); + + Assert.Equal(SetReleaseGroupOutcome.Refused, result.Groups[0].Outcome); + Assert.Contains("is in millimeters, not inches", result.Groups[0].Error); + Assert.Empty(_nests.Uploaded); + } + + [Fact] + public async Task Run_DownloadFailure_FailsBeforeAnythingIsUploaded() + { + var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false); + _library.Failure = new IOException("Synthetic network failure."); + + var result = await Session().RunAsync(plan, plan.Groups, "", ""); + + Assert.Equal(SetReleaseGroupOutcome.Failed, result.Groups[0].Outcome); + Assert.Contains("Synthetic network failure.", result.Groups[0].Error); + Assert.Empty(_nests.Uploaded); + Assert.Empty(_sets.Posted); + } + + [Fact] + public async Task Run_UploadFailurePartWay_ReportsCreatedNestsAndRecordsNoRelease() + { + var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1), (Stored("SYN-B", material: "Synthetic Aluminum"), 1), + (Stored("SYN-C", cutType: CutType.Plasma), 1)), 1, false); + _nests.FailUploadNumber = 2; + + var result = await Session().RunAsync(plan, plan.Groups, "", ""); + + Assert.Equal(new[] { SetReleaseGroupOutcome.Created, SetReleaseGroupOutcome.Failed, SetReleaseGroupOutcome.NotStarted }, + result.Groups.Select(g => g.Outcome)); + Assert.Equal(_nests.Uploaded[0].Record.Id, Assert.Single(result.Created).Record!.Id); + Assert.Contains("Synthetic upload failure.", result.Groups[1].Error); + Assert.Contains("It was not saved.", result.Groups[1].Error); + Assert.NotNull(result.Groups[1].Nest); + Assert.Null(result.Groups[2].Nest); + Assert.Empty(_sets.Posted); + Assert.Null(result.Release); + Assert.False(result.Canceled); + } + + [Fact] + public async Task Run_UploadStoredBeforeItsReplyWasLost_CountsAsCreated() + { + var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false); + _nests.FailUploadNumber = 1; + _nests.StoreFailedUpload = true; + + var result = await Session().RunAsync(plan, plan.Groups, "", ""); + + Assert.Equal(SetReleaseGroupOutcome.Created, result.Groups[0].Outcome); + Assert.Equal(_nests.Uploaded[0].Record.Id, Assert.Single(Assert.Single(_sets.Posted).Nests).NestId); + } + + [Fact] + public async Task Run_UploadFailureThatCannotBeChecked_SaysTheNestMayExist() + { + var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false); + _nests.FailUploadNumber = 1; + _nests.MetadataFailure = new HttpRequestException("Synthetic offline."); + + var result = await Session().RunAsync(plan, plan.Groups, "", ""); + + Assert.Equal(SetReleaseGroupOutcome.Failed, result.Groups[0].Outcome); + Assert.Contains("may have been saved as 'N1'", result.Groups[0].Error); + } + + [Fact] + public async Task Run_CanceledDuringAnUpload_FinishesItThenStops() + { + var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1), (Stored("SYN-B", material: "Synthetic Aluminum"), 1)), 1, false); + using var cancel = new CancellationTokenSource(); + var progress = new RecordingProgress(p => + { + if (p.Stage == SetReleaseStage.Uploading) + cancel.Cancel(); + }); + + var result = await Session().RunAsync(plan, plan.Groups, "", "", progress, cancel.Token); + + Assert.True(result.Canceled); + Assert.Equal(new[] { SetReleaseGroupOutcome.Created, SetReleaseGroupOutcome.NotStarted }, result.Groups.Select(g => g.Outcome)); + Assert.Single(_nests.Uploaded); + Assert.False(_nests.UploadTokens.Single().CanBeCanceled); + Assert.Empty(_sets.Posted); + } + + [Fact] + public async Task Run_CanceledWhilePreparing_CreatesNothing() + { + var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false); + using var cancel = new CancellationTokenSource(); + cancel.Cancel(); + + var result = await Session().RunAsync(plan, plan.Groups, "", "", null, cancel.Token); + + Assert.True(result.Canceled); + Assert.Equal(SetReleaseGroupOutcome.NotStarted, result.Groups[0].Outcome); + Assert.Empty(_nests.Uploaded); + } + + [Fact] + public async Task Run_ReleasePostFailure_KeepsTheNestsAndRetriesUnderTheSameId() + { + var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false); + _sets.Failures.Enqueue(new IOException("Synthetic post failure.")); + var session = Session(); + + var result = await session.RunAsync(plan, plan.Groups, "", ""); + + Assert.Null(result.Release); + Assert.Contains("Synthetic post failure.", result.ReleaseError); + Assert.Single(result.Created); + Assert.True(session.HasPendingRelease); + + var stored = await session.RetryReleaseAsync(); + + Assert.Equal(2, _sets.Posted.Count); + Assert.Equal(_sets.Posted[0].Id, _sets.Posted[1].Id); + Assert.Equal(_sets.Posted[0].Id, stored.Id); + Assert.False(session.HasPendingRelease); + await Assert.ThrowsAsync(() => session.RetryReleaseAsync()); + } + + [Fact] + public async Task Run_ReleaseAlreadyStoredUnderItsId_IsAdopted() + { + var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false); + _sets.ConflictWithPosted = true; + + var result = await Session().RunAsync(plan, plan.Groups, "", ""); + + Assert.Equal(_sets.Posted.Single().Id, result.Release!.Id); + Assert.Equal("", result.ReleaseError); + } + + [Fact] + public async Task Run_RejectsGroupsItCannotRelease() + { + var a = Stored("SYN-A"); + var plan = SetReleasePlan.Build(Set((a, 1), (Stored("SYN-A"), 1), (Stored("SYN-B", cutType: CutType.Plasma), 1)), 1, false); + var other = SetReleasePlan.Build(Set((a, 1)), 1, false); + var refused = plan.Groups.Single(g => !g.CanRelease); + var session = Session(); + + await Assert.ThrowsAsync(() => session.RunAsync(plan, Array.Empty(), "", "")); + await Assert.ThrowsAsync(() => session.RunAsync(plan, new[] { refused }, "", "")); + await Assert.ThrowsAsync(() => session.RunAsync(plan, other.Groups, "", "")); + await Assert.ThrowsAsync(() => session.RunAsync(plan, new[] { plan.Groups[0], plan.Groups[0] }, "", "")); + await Assert.ThrowsAsync(() => session.RunAsync(plan, new[] { plan.Groups[0] }, "", "bad\0notes")); + Assert.Empty(_created); + + var releasable = plan.Groups.Where(g => g.CanRelease).ToList(); + await session.RunAsync(plan, releasable, "", ""); + await Assert.ThrowsAsync(() => session.RunAsync(plan, releasable, "", "")); + } + + private sealed class RecordingProgress(Action? onReport = null) : IProgress + { + public List Reports { get; } = new(); + + public void Report(SetReleaseProgress value) + { + Reports.Add(value); + onReport?.Invoke(value); + } + } + + private sealed class FakeLibrary : IDrawingLibrary + { + public Dictionary<(Guid, int), (byte[] Bytes, Units Units)> Packages { get; } = new(); + public Exception? Failure { get; set; } + + public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (Failure != null) + throw Failure; + return Task.FromResult(Packages.TryGetValue((id, revision), out var package) ? package.Bytes : null); + } + + public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } + + private sealed class FakeNests : INestRepository + { + private readonly Dictionary _stored = new(); + + public List<(byte[] File, NestRecord Record)> Uploaded { get; } = new(); + public List UploadTokens { get; } = new(); + public int FailUploadNumber { get; set; } + public bool StoreFailedUpload { get; set; } + public Exception? MetadataFailure { get; set; } + + public Task UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) + { + UploadTokens.Add(ct); + var failing = UploadTokens.Count == FailUploadNumber; + var stored = new NestRecord + { + Id = record.Id, + Revision = 1, + Name = record.Name, + Status = record.Status, + Material = record.Material, + }; + if (!failing || StoreFailedUpload) + { + Uploaded.Add((file, record)); + _stored[record.Id] = stored; + } + + return failing ? throw new IOException("Synthetic upload failure.") : Task.FromResult(stored); + } + + public Task GetMetadataAsync(Guid id, CancellationToken ct = default) + { + if (MetadataFailure != null) + throw MetadataFailure; + return Task.FromResult(_stored.TryGetValue(id, out var record) ? record : null); + } + + public Task> ListAsync(CancellationToken ct = default) => throw new NotSupportedException(); + public Task QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); + } + + private sealed class FakeSets : IDrawingSetStore + { + public List Posted { get; } = new(); + public List Stored { get; } = new(); + public Queue Failures { get; } = new(); + public bool ConflictWithPosted { get; set; } + + public Task PostReleaseAsync(Guid setId, SetReleaseRecord release, CancellationToken cancellationToken = default) + { + Posted.Add(release); + if (Failures.TryDequeue(out var failure)) + throw failure; + var stored = new SetReleaseRecord + { + Id = release.Id, + SetId = setId, + SetRevision = release.SetRevision, + Multiplier = release.Multiplier, + SetName = "Synthetic kit", + Nests = release.Nests, + }; + if (ConflictWithPosted) + throw new SetReleasePostConflictException(stored); + Stored.Add(stored); + return Task.FromResult(stored); + } + + public Task QueryAsync(DrawingSetQuery query, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetRevisionAsync(Guid id, int revision, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetSetsContainingAsync(Guid drawingId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task CreateAsync(DrawingSetRecord record, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task UpdateAsync(Guid id, DrawingSetRecord record, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task ArchiveAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetReleasesAsync(Guid setId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } +} diff --git a/docs/nest-storage.md b/docs/nest-storage.md index 528bd3a3..24252d7a 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -409,6 +409,44 @@ status and deletion are always current, and ignored on post. at most 200 characters, `thickness` finite and at least 0 (stored rounded to 4 decimals), and `units` and `cutType` single enum names. +#### Client and release session + +`OpenNest.Data.RemoteDrawingSetStore` is the client for every route above. It +checks `DrawingSetRules`/`SetReleaseRules` before sending, raises +`ServerTooOldException` on a server without sets, `DrawingSetSaveConflictException` +or `SetReleasePostConflictException` (carrying the current record) on 409, and +refuses replies whose id, revision, members or nests differ from what was sent. + +`SetReleasePlan.Build(set, multiplier, includeArchived)` splits a set read from +the server into groups without any I/O. A group is the members whose drawings +share material name and grade (by `SharedListNames.Key`), thickness (rounded to +4 decimals), units and cut type; quantities are multiplied. Archived drawings +are skipped unless included. A group is refused when two of its drawings share a +name (compared ordinally, as a nest keys drawings), and every drawing without a +material or a positive thickness goes to one refused "unassigned" group, listed last. + +`SetReleaseSession.RunAsync` releases the chosen releasable groups in plan order: + +1. **Build every group first.** For each group the caller's factory makes an + empty nest in the group's units; the session sets material, grade, + thickness, the set's customer (when it has one) and `Draft`, downloads each + member's exact listed revision and inserts it through `LibraryDrawingInsert`. + A refusal (package hash no longer matching its record, a missing revision, a + nest in other units, an insert refusal) or a download failure stops here, so + nothing is uploaded. The factory may already have allocated nest numbers. +2. **Upload in order** under client-allocated ids, never cancelling an upload in + flight; cancellation is checked between uploads. A failed upload is checked by + its id, so one stored before its reply was lost still counts as created; the + first real failure stops the rest. +3. **Post the release** only when every chosen group was created, under a + client-allocated id. When the post fails, the nests stay and + `RetryReleaseAsync` posts the same id again; a 409 naming that id is adopted + as the stored release. + +The result lists each group's outcome (`Created`, `Refused`, `Failed`, +`NotStarted`), the created nests' records and in-memory `Nest` objects (so the +desktop can open them without downloading), and the release or why it was not recorded. + Schema v7 adds `drawing_set_revisions` (every revision's details and archived flag), `drawing_set_members` (each revision's members by position, unique per drawing), `drawing_sets` (each set's current revision), `set_releases` and