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