feat(data): add the drawing set client and release session

The desktop needs a cross-platform way to read and save sets and to turn
one into nests before any UI is built on it. RemoteDrawingSetStore covers
every /api/sets route with the library client's checks and conflict
handling. SetReleasePlan groups a set by material, grade, thickness, units
and cut type without I/O, so the release dialog can preview refusals
(duplicate names, drawings without material) before anything is created.

SetReleaseSession builds every group's nest through LibraryDrawingInsert
before uploading any, so a refusal leaves nothing on the server; uploads
then never cancel mid-flight, a lost reply is reconciled by the nest's
client-allocated id, and the release is posted only when every chosen
nest exists, with a same-id retry when that post fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-20 13:48:23 -04:00
1 parent 44666b289d
commit af6975b9f1
11 files changed
+2104 -2

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
namespace OpenNest.Data;
/// <summary>
/// Drawing sets and their releases on the nest server (<c>/api/sets</c>). Kept apart from
/// <see cref="IDrawingLibrary"/> so browsing and inserting drawings do not depend on sets.
/// <see cref="RemoteDrawingSetStore"/> is the only production implementation.
/// </summary>
public interface IDrawingSetStore
{
/// <summary>One bounded page of sets matching the query, without their members.</summary>
Task<DrawingSetPage> QueryAsync(DrawingSetQuery query, CancellationToken cancellationToken = default);
/// <summary>
/// The current record, including an archived one, with every member joined to its
/// drawing's current library record; null for an unknown id.
/// </summary>
Task<DrawingSetRecord?> GetAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>One page of immutable revisions without members, newest first, or null for an unknown id.</summary>
Task<DrawingSetPage?> GetRevisionsAsync(Guid id, int offset = 0,
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default);
/// <summary>One revision with its members, or null when the set or revision is unknown.</summary>
Task<DrawingSetRecord?> GetRevisionAsync(Guid id, int revision, CancellationToken cancellationToken = default);
/// <summary>Sets whose current revision contains the drawing, by name, or null for an unknown drawing.</summary>
Task<DrawingSetPage?> GetSetsContainingAsync(Guid drawingId, int offset = 0,
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default);
/// <summary>Stores revision 1 under the caller-allocated <c>record.Id</c>.</summary>
Task<DrawingSetRecord> CreateAsync(DrawingSetRecord record, CancellationToken cancellationToken = default);
/// <summary>Saves the next revision when <paramref name="expectedRevision"/> is current.</summary>
Task<DrawingSetRecord> UpdateAsync(Guid id, DrawingSetRecord record, int expectedRevision,
CancellationToken cancellationToken = default);
Task<DrawingSetRecord> ArchiveAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default);
Task<DrawingSetRecord> RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default);
/// <summary>Records a release whose nests already exist, under the caller-allocated <c>release.Id</c>.</summary>
Task<SetReleaseRecord> PostReleaseAsync(Guid setId, SetReleaseRecord release,
CancellationToken cancellationToken = default);
/// <summary>One page of the set's releases with their nests, newest first, or null for an unknown set.</summary>
Task<SetReleasePage?> GetReleasesAsync(Guid setId, int offset = 0,
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default);
/// <summary>The release a nest came from, or null when it came from none (or is unknown).</summary>
Task<SetReleaseRecord?> GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default);
}
+8 -2
View File
@@ -329,11 +329,17 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
}
}
/// <summary>The nest server predates the drawing library (it answered HTTP 404).</summary>
/// <summary>The nest server predates the drawing library or another feature (it answered HTTP 404).</summary>
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")
{
}
/// <summary><paramref name="feature"/> names what is missing, for example "drawing sets".</summary>
public ServerTooOldException(string feature)
: base($"This nest server does not support {feature} (HTTP 404). Update the nest server to this OpenNest version.")
{
}
}
+455
View File
@@ -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;
/// <summary>
/// Talks to the drawing sets on the nest server (<c>/api/sets</c>). Sets and releases
/// travel as JSON and are checked against <see cref="DrawingSetRules"/> and
/// <see cref="SetReleaseRules"/> before sending. A server without sets raises
/// <see cref="ServerTooOldException"/>; other failures surface as <see cref="IOException"/>.
/// </summary>
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;
}
/// <summary>For tests and hosts that pool HttpClient instances.</summary>
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<DrawingSetPage> 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<DrawingSetPage>(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<DrawingSetRecord?> 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<DrawingSetPage?> 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<DrawingSetRecord?> 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<DrawingSetPage?> 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);
}
/// <summary>
/// Stores revision 1 under <c>record.Id</c>, which the caller allocates first so an
/// ambiguous failure can be reconciled by id. An existing id raises
/// <see cref="DrawingSetSaveConflictException"/> with the stored record.
/// </summary>
public async Task<DrawingSetRecord> 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<DrawingSetRecord> 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<DrawingSetRecord> ArchiveAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
SetArchivedAsync(id, expectedRevision, "archive", cancellationToken);
public Task<DrawingSetRecord> RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
SetArchivedAsync(id, expectedRevision, "restore", cancellationToken);
/// <summary>
/// Records a release under <c>release.Id</c>, 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 <see cref="SetReleasePostConflictException"/> with that release.
/// </summary>
public async Task<SetReleaseRecord> 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<SetReleasePage?> 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<SetReleasePage>(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<SetReleaseRecord?> 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<DrawingSetRecord> 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<DrawingSetRecord> 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<DrawingSetRecord?> 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<DrawingSetPage?> 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<DrawingSetPage>(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<DrawingSetRecord> ReadSet(HttpResponseMessage response, CancellationToken cancellationToken)
{
var record = await response.Content.ReadFromJsonAsync<DrawingSetRecord>(JsonOptions, cancellationToken);
return record ?? throw new IOException("Nest server returned an empty drawing set record.");
}
private static async Task<SetReleaseRecord> ReadRelease(HttpResponseMessage response, CancellationToken cancellationToken)
{
var release = await response.Content.ReadFromJsonAsync<SetReleaseRecord>(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();
}
}
/// <summary>A guarded set write found a newer or different current state; nothing was changed.</summary>
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;
}
/// <summary>The set's current server record.</summary>
public DrawingSetRecord Current { get; }
}
/// <summary>
/// A release already holds the posted release id or one of its nests; nothing was recorded.
/// When <see cref="Current"/> has the posted id, an earlier post whose reply was lost was stored.
/// </summary>
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;
}
/// <summary>The release that holds the id or nest.</summary>
public SetReleaseRecord Current { get; }
}
+212
View File
@@ -0,0 +1,212 @@
using System.Globalization;
using OpenNest;
namespace OpenNest.Data;
/// <summary>One set member as released: its drawing's current record and the multiplied quantity.</summary>
public sealed class SetReleaseItem
{
internal SetReleaseItem(DrawingSetMember member, int quantity)
{
Member = member;
Drawing = member.Drawing!;
Quantity = quantity;
}
public DrawingSetMember Member { get; }
/// <summary>The drawing's current library record, as joined by the server when the set was read.</summary>
public DrawingLibraryRecord Drawing { get; }
/// <summary>The member quantity times the release multiplier.</summary>
public int Quantity { get; }
/// <summary>The drawing is archived and was ticked back in.</summary>
public bool Archived => Drawing.Archived;
}
/// <summary>
/// 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.
/// </summary>
public sealed class SetReleaseGroup
{
internal SetReleaseGroup(string key, DrawingLibraryRecord first, bool unassigned,
IReadOnlyList<SetReleaseItem> items, IReadOnlyList<string> 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;
}
/// <summary>Identifies the group across rebuilds of the plan (for example after the multiplier changes).</summary>
public string Key { get; }
/// <summary>The material as the group's first member spells it.</summary>
public string MaterialName { get; }
public string MaterialGrade { get; }
/// <summary>Thickness in <see cref="Units"/>, rounded to four decimals.</summary>
public double Thickness { get; }
public Units Units { get; }
public CutType CutType { get; }
/// <summary>
/// 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.
/// </summary>
public bool IsUnassigned { get; }
/// <summary>The members, in set order.</summary>
public IReadOnlyList<SetReleaseItem> Items { get; }
/// <summary>Sum of the released quantities.</summary>
public int TotalQuantity => Items.Sum(i => i.Quantity);
/// <summary>Operator-facing notes that do not stop the release.</summary>
public IReadOnlyList<string> Warnings { get; }
/// <summary>Why this group cannot be released; empty when it can.</summary>
public string Refusal { get; }
public bool CanRelease => Refusal.Length == 0;
}
/// <summary>
/// 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 <see cref="SharedListNames.Key"/>), 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).
/// </summary>
public sealed class SetReleasePlan
{
private SetReleasePlan(DrawingSetRecord set, int multiplier, bool includeArchived,
IReadOnlyList<SetReleaseGroup> groups, IReadOnlyList<DrawingSetMember> skippedArchived)
{
Set = set;
Multiplier = multiplier;
IncludeArchived = includeArchived;
Groups = groups;
SkippedArchived = skippedArchived;
}
/// <summary>The set revision being released, as read from the server.</summary>
public DrawingSetRecord Set { get; }
public int Multiplier { get; }
public bool IncludeArchived { get; }
/// <summary>
/// Groups ordered by material, grade, thickness, units and cut type, with the
/// unassigned group last.
/// </summary>
public IReadOnlyList<SetReleaseGroup> Groups { get; }
/// <summary>Members left out because their drawing is archived and archived drawings were not included.</summary>
public IReadOnlyList<DrawingSetMember> SkippedArchived { get; }
/// <param name="set">A set read from the server, so every member carries its drawing's current record.</param>
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<DrawingSetMember>();
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);
}
/// <summary>The group key of a drawing that has a material and thickness.</summary>
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());
}
/// <summary>The key of the group holding every drawing without a material or thickness.</summary>
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<SetReleaseItem> 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<SetReleaseItem> 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);
}
}
+399
View File
@@ -0,0 +1,399 @@
using OpenNest;
namespace OpenNest.Data;
public enum SetReleaseGroupOutcome
{
/// <summary>The group's nest is on the server; <see cref="SetReleaseGroupResult.Record"/> is its record.</summary>
Created,
/// <summary>The group could not be built; nothing was uploaded for any group.</summary>
Refused,
/// <summary>Building or uploading the group failed; <see cref="SetReleaseGroupResult.Error"/> says how.</summary>
Failed,
/// <summary>An earlier group stopped the release, or it was canceled, before this group was reached.</summary>
NotStarted,
}
/// <summary>What happened to one chosen group of a release.</summary>
public sealed class SetReleaseGroupResult
{
internal SetReleaseGroupResult(SetReleaseGroup group)
{
Group = group;
}
public SetReleaseGroup Group { get; }
public SetReleaseGroupOutcome Outcome { get; internal set; } = SetReleaseGroupOutcome.NotStarted;
/// <summary>
/// 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.
/// </summary>
public Nest? Nest { get; internal set; }
/// <summary>The server record of a created nest: its id and revision.</summary>
public NestRecord? Record { get; internal set; }
/// <summary>Why the group was refused or failed; empty otherwise.</summary>
public string Error { get; internal set; } = "";
/// <summary>The plan's warnings plus any the library inserts raised.</summary>
public IReadOnlyList<string> Warnings { get; internal set; } = Array.Empty<string>();
}
public enum SetReleaseStage
{
/// <summary>Downloading the group's drawings and building its nest; nothing is uploaded yet.</summary>
Preparing,
/// <summary>Uploading the group's nest.</summary>
Uploading,
/// <summary>Every nest is uploaded; the release record is being posted.</summary>
Recording,
}
/// <summary>Where a release has got to. <see cref="Group"/> is null while recording.</summary>
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; }
/// <summary>Zero-based position of <see cref="Group"/> among the chosen groups.</summary>
public int Index { get; }
public int Count { get; }
public SetReleaseGroup? Group { get; }
}
/// <summary>Every chosen group's outcome, and the release record when all of them were created.</summary>
public sealed class SetReleaseResult
{
internal SetReleaseResult(IReadOnlyList<SetReleaseGroupResult> groups, bool canceled,
SetReleaseRecord? release, string releaseError)
{
Groups = groups;
Canceled = canceled;
Release = release;
ReleaseError = releaseError;
}
/// <summary>The chosen groups in plan order.</summary>
public IReadOnlyList<SetReleaseGroupResult> Groups { get; }
/// <summary>The nests now on the server, whether or not the release was recorded.</summary>
public IReadOnlyList<SetReleaseGroupResult> Created =>
Groups.Where(g => g.Outcome == SetReleaseGroupOutcome.Created).ToList();
/// <summary>Cancellation stopped the release between groups.</summary>
public bool Canceled { get; }
/// <summary>The stored release; null unless every chosen group was created and the post succeeded.</summary>
public SetReleaseRecord? Release { get; }
/// <summary>
/// Why posting the release failed after every nest was created; empty otherwise.
/// <see cref="SetReleaseSession.RetryReleaseAsync"/> posts the same release again.
/// </summary>
public string ReleaseError { get; }
}
/// <summary>
/// Releases a drawing set: one new nest per chosen group of a <see cref="SetReleasePlan"/>,
/// each built through <see cref="LibraryDrawingInsert"/> from the members' exact library
/// revisions, uploaded as a <see cref="NestStatus.Draft"/>, and only then recorded as one
/// release (S7).
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed class SetReleaseSession
{
private readonly IDrawingSetStore _sets;
private readonly IDrawingLibrary _library;
private readonly INestRepository _nests;
private readonly string _serverUrl;
private readonly Func<byte[], LibraryPackage> _readPackage;
private readonly Func<Nest, byte[]> _writeNest;
private readonly Func<SetReleaseGroup, CancellationToken, Task<Nest>> _createNest;
private bool _started;
private SetReleaseRecord? _pendingRelease;
/// <param name="readPackage">Reads a downloaded library package (the desktop uses <c>DrawingPackage.Read</c>).</param>
/// <param name="writeNest">Serializes a nest archive for upload (the desktop uses <c>NestWriter</c>).</param>
/// <param name="createNest">
/// 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.
/// </param>
public SetReleaseSession(IDrawingSetStore sets, IDrawingLibrary library, INestRepository nests, string serverUrl,
Func<byte[], LibraryPackage> readPackage, Func<Nest, byte[]> writeNest,
Func<SetReleaseGroup, CancellationToken, Task<Nest>> 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;
}
/// <summary>Every nest was created but the release was not recorded; <see cref="RetryReleaseAsync"/> may post it.</summary>
public bool HasPendingRelease => _pendingRelease != null;
/// <summary>Releases <paramref name="groups"/>, all from <paramref name="plan"/>. Call once per session.</summary>
public async Task<SetReleaseResult> RunAsync(SetReleasePlan plan, IReadOnlyCollection<SetReleaseGroup> groups,
string releasedBy, string notes, IProgress<SetReleaseProgress>? 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}");
}
}
/// <summary>
/// Posts the release again after <see cref="SetReleaseResult.ReleaseError"/>, under the
/// same id, so a post that was in fact stored is not recorded twice.
/// </summary>
public Task<SetReleaseRecord> 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<bool> 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<bool> 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<SetReleaseRecord> 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<SetReleaseGroupResult> 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();
}
@@ -0,0 +1,109 @@
using OpenNest.Data;
using static OpenNest.Server.Tests.DrawingSetFixtures;
namespace OpenNest.Server.Tests;
/// <summary>The real <see cref="RemoteDrawingSetStore"/> against the production set and release routes.</summary>
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<DrawingSetSaveConflictException>(
() => store.UpdateAsync(created.Id, Set("Synthetic stale", created.Id), 1));
var duplicate = await Assert.ThrowsAsync<DrawingSetSaveConflictException>(() => store.CreateAsync(Set("Second", created.Id)));
var restoreActive = await Assert.ThrowsAsync<DrawingSetSaveConflictException>(() => 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<IOException>(() => 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<SetReleasePostConflictException>(() => store.PostReleaseAsync(set.Id, release));
var reuse = await Assert.ThrowsAsync<SetReleasePostConflictException>(() => 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<IOException>(() => store.PostReleaseAsync(Guid.NewGuid(), Release(1, Guid.NewGuid())));
}
}
@@ -0,0 +1,118 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Data;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Shapes;
namespace OpenNest.Server.Tests;
/// <summary>
/// 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.
/// </summary>
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<DrawingLibraryRecord> 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);
}
}
@@ -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<object>(), 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<ServerTooOldException>(() => store.QueryAsync(new DrawingSetQuery()));
await Assert.ThrowsAsync<ServerTooOldException>(() => store.GetAsync(Guid.NewGuid()));
await Assert.ThrowsAsync<ServerTooOldException>(() => 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<object>(), 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<ArgumentException>(() => store.CreateAsync(duplicate));
await Assert.ThrowsAsync<ArgumentException>(() => 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<IOException>(() => 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<IOException>(() => store.PostReleaseAsync(setId, release));
}
}
+172
View File
@@ -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<ArgumentOutOfRangeException>(() => 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<ArgumentException>(() => SetReleasePlan.Build(unsaved, 1, false));
Assert.Throws<ArgumentException>(() => SetReleasePlan.Build(unjoined, 1, false));
Assert.Throws<ArgumentException>(() => 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);
}
}
@@ -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<SetReleaseGroup> _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<SetReleaseGroup, CancellationToken, Task<Nest>>? createNest = null) =>
new(_sets, _library, _nests, Server + "/", ReadPackage, nest => Encoding.UTF8.GetBytes("nest " + nest.Name),
createNest ?? CreateNest);
private Task<Nest> 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<InvalidOperationException>(() => 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<ArgumentException>(() => session.RunAsync(plan, Array.Empty<SetReleaseGroup>(), "", ""));
await Assert.ThrowsAsync<ArgumentException>(() => session.RunAsync(plan, new[] { refused }, "", ""));
await Assert.ThrowsAsync<ArgumentException>(() => session.RunAsync(plan, other.Groups, "", ""));
await Assert.ThrowsAsync<ArgumentException>(() => session.RunAsync(plan, new[] { plan.Groups[0], plan.Groups[0] }, "", ""));
await Assert.ThrowsAsync<ArgumentException>(() => 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<InvalidOperationException>(() => session.RunAsync(plan, releasable, "", ""));
}
private sealed class RecordingProgress(Action<SetReleaseProgress>? onReport = null) : IProgress<SetReleaseProgress>
{
public List<SetReleaseProgress> 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<byte[]?> 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<DrawingLibraryPage> QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
private sealed class FakeNests : INestRepository
{
private readonly Dictionary<Guid, NestRecord> _stored = new();
public List<(byte[] File, NestRecord Record)> Uploaded { get; } = new();
public List<CancellationToken> UploadTokens { get; } = new();
public int FailUploadNumber { get; set; }
public bool StoreFailedUpload { get; set; }
public Exception? MetadataFailure { get; set; }
public Task<NestRecord> 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<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default)
{
if (MetadataFailure != null)
throw MetadataFailure;
return Task.FromResult(_stored.TryGetValue(id, out var record) ? record : null);
}
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotSupportedException();
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> 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<SetReleaseRecord> Posted { get; } = new();
public List<SetReleaseRecord> Stored { get; } = new();
public Queue<Exception> Failures { get; } = new();
public bool ConflictWithPosted { get; set; }
public Task<SetReleaseRecord> 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<DrawingSetPage> QueryAsync(DrawingSetQuery query, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord?> GetAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetPage?> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord?> GetRevisionAsync(Guid id, int revision, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetPage?> GetSetsContainingAsync(Guid drawingId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord> CreateAsync(DrawingSetRecord record, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord> UpdateAsync(Guid id, DrawingSetRecord record, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord> ArchiveAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord> RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<SetReleasePage?> GetReleasesAsync(Guid setId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<SetReleaseRecord?> GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
}
+38
View File
@@ -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