mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 13:51:06 -04:00
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:
1 parent
44666b289d
commit
af6975b9f1
11 files changed
+2104
-2
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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.")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user