diff --git a/OpenNest.Data/IDrawingSetStore.cs b/OpenNest.Data/IDrawingSetStore.cs index a9fc2df0..b8872bd5 100644 --- a/OpenNest.Data/IDrawingSetStore.cs +++ b/OpenNest.Data/IDrawingSetStore.cs @@ -44,9 +44,18 @@ public interface IDrawingSetStore /// One release with its sets and nests, or null for an unknown id. Task GetReleaseAsync(Guid id, CancellationToken cancellationToken = default); - /// One page of releases with their sets and nests, newest first, optionally only those including a set or a nest. - Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, int offset = 0, - int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default); + /// + /// One page of releases with their sets and nests, newest first, optionally only those + /// including a set, a nest, or a drawing released on its own. + /// + Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, Guid? drawingId = null, + int offset = 0, int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default); + + /// + /// Raises when the server cannot record drawings released + /// on their own. A release asks before building its nests, since the record is posted last. + /// + Task EnsureDrawingReleasesSupportedAsync(CancellationToken cancellationToken = default); /// Undoes a release, all or nothing; a refusal raises . Task UndoReleaseAsync(Guid id, SetReleaseUndoRequest request, CancellationToken cancellationToken = default); diff --git a/OpenNest.Data/RemoteDrawingSetStore.cs b/OpenNest.Data/RemoteDrawingSetStore.cs index ccf3c986..80edfe42 100644 --- a/OpenNest.Data/RemoteDrawingSetStore.cs +++ b/OpenNest.Data/RemoteDrawingSetStore.cs @@ -21,6 +21,8 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable private const string ReleaseFeature = "releasing several drawing sets at once"; + private const string DrawingReleaseFeature = "releasing library drawings on their own"; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -173,11 +175,16 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable throw new ServerTooOldException(ReleaseFeature); if (response.StatusCode == HttpStatusCode.Conflict) throw new SetReleasePostConflictException(await ReadRelease(response, cancellationToken)); + // A server without drawings on their own refuses such a release as malformed. + if (response.StatusCode == HttpStatusCode.BadRequest && release.Drawings.Count > 0) + await EnsureDrawingReleasesSupportedAsync(cancellationToken); await EnsureSuccess(response, "record the release", cancellationToken); var stored = await ReadRelease(response, cancellationToken); if (stored.Id != release.Id || !stored.Sets.Select(s => (s?.SetId, s?.SetRevision, s?.Quantity)) .SequenceEqual(release.Sets.Select(s => ((Guid?)s.SetId, (int?)s.SetRevision, (int?)s.Quantity))) + || !stored.Drawings.Select(d => (d?.DrawingId, d?.Quantity)) + .SequenceEqual(release.Drawings.Select(d => ((Guid?)d.DrawingId, (int?)d.Quantity))) || !stored.Nests.Select(n => (n?.NestId, n?.Kind, n?.RevisionAfter)) .SequenceEqual(release.Nests.Select(n => ((Guid?)n.NestId, (SetReleaseNestKind?)n.Kind, (int?)n.RevisionAfter)))) throw new IOException("The nest server recorded a different release than was posted."); @@ -200,29 +207,47 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable return release; } - public async Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, int offset = 0, - int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) + public async Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, Guid? drawingId = null, + int offset = 0, int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) { if (setId == Guid.Empty) throw new ArgumentException("A drawing set id is required.", nameof(setId)); if (nestId == Guid.Empty) throw new ArgumentException("A nest id is required.", nameof(nestId)); + if (drawingId == Guid.Empty) throw new ArgumentException("A library drawing id is required.", nameof(drawingId)); ValidatePaging(offset, limit); var query = Paging(offset, limit); if (setId is not null) query += $"&setId={setId}"; if (nestId is not null) query += $"&nestId={nestId}"; + if (drawingId is not null) query += $"&drawingId={drawingId}"; using var response = await _httpClient.GetAsync(Url($"api/releases/query?{query}"), cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) - throw new ServerTooOldException(ReleaseFeature); + throw new ServerTooOldException(drawingId is null ? ReleaseFeature : DrawingReleaseFeature); + // A server without drawings on their own refuses the unknown drawingId parameter. + if (response.StatusCode == HttpStatusCode.BadRequest && drawingId is not null) + throw new ServerTooOldException(DrawingReleaseFeature); await EnsureSuccess(response, "read releases", cancellationToken); var page = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) ?? throw new IOException("Nest server returned an empty release page."); if (page.Items is null || !IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit) - || page.Items.Any(item => item is null || item.Sets is null || item.Nests is null + || page.Items.Any(item => item is null || item.Sets is null || item.Drawings is null || item.Nests is null || (setId is not null && item.Sets.All(s => s?.SetId != setId)) - || (nestId is not null && item.Nests.All(n => n?.NestId != nestId)))) + || (nestId is not null && item.Nests.All(n => n?.NestId != nestId)) + || (drawingId is not null && item.Drawings.All(d => d?.DrawingId != drawingId)))) throw new IOException("Nest server returned an invalid or unbounded release page."); return page; } + /// + /// Asks for releases of a drawing no release can name: a server from before drawings on + /// their own answers 400 for the unknown parameter (or 404 without releases at all). + /// + public async Task EnsureDrawingReleasesSupportedAsync(CancellationToken cancellationToken = default) + { + using var probe = await _httpClient.GetAsync(Url($"api/releases/query?drawingId={Guid.NewGuid()}&limit=1"), cancellationToken); + if (probe.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.BadRequest) + throw new ServerTooOldException(DrawingReleaseFeature); + await EnsureSuccess(probe, "check the release support", cancellationToken); + } + /// /// Undoes a release on the server, all or nothing. A refusal raises /// : the release was undone before, or nests @@ -377,6 +402,11 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable SetRevision = s.SetRevision, Quantity = s.Quantity, }).ToList(), + Drawings = release.Drawings.Select(d => new SetReleaseDrawing + { + DrawingId = d.DrawingId, + Quantity = d.Quantity, + }).ToList(), Nests = release.Nests.Select(n => new SetReleaseNest { NestId = n.NestId, @@ -442,7 +472,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable private static async Task ReadRelease(HttpResponseMessage response, CancellationToken cancellationToken) { var release = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); - if (release is null || release.Sets is null || release.Nests is null) + if (release is null || release.Sets is null || release.Drawings is null || release.Nests is null) throw new IOException("Nest server returned an empty set release record."); return release; } diff --git a/OpenNest.Data/SetReleaseNest.cs b/OpenNest.Data/SetReleaseNest.cs index c3f2da12..2b62a072 100644 --- a/OpenNest.Data/SetReleaseNest.cs +++ b/OpenNest.Data/SetReleaseNest.cs @@ -50,7 +50,8 @@ public sealed class SetReleaseNest public CutType CutType { get; set; } = CutType.Laser; /// - /// The drawing quantities this release added to the nest, per set. Empty for releases + /// The drawing quantities this release added to the nest, per set and for the drawings + /// released on their own. Empty for releases /// recorded before items were kept (schema v8). /// public IReadOnlyList Items { get; set; } = Array.Empty(); @@ -68,10 +69,11 @@ public sealed class SetReleaseNest public NestRecord? Nest { get; set; } } -/// A drawing quantity one set of a release added to one nest. +/// A drawing quantity one set of a release, or the drawing released on its own, added to one nest. public sealed class SetReleaseNestItem { - public Guid SetId { get; set; } + /// The set that asked for the quantity; null for a drawing released on its own. + public Guid? SetId { get; set; } public Guid DrawingId { get; set; } diff --git a/OpenNest.Data/SetReleasePlan.cs b/OpenNest.Data/SetReleasePlan.cs index 8572397a..b7945071 100644 --- a/OpenNest.Data/SetReleasePlan.cs +++ b/OpenNest.Data/SetReleasePlan.cs @@ -6,12 +6,21 @@ namespace OpenNest.Data; /// One set of a release plan: a set read from the server and how many kits of it. public sealed record SetReleasePlanLine(DrawingSetRecord Set, int Quantity); -/// How many of an item one set of the release asked for. -public sealed record SetReleaseItemSource(Guid SetId, int Quantity); +/// +/// One library drawing released on its own, outside any set: its current record, as read from +/// the library, and how many of it. +/// +public sealed record SetReleaseDrawingLine(DrawingLibraryRecord Drawing, int Quantity); + +/// +/// How many of an item one set of the release asked for, kits included; +/// is null for the drawing released on its own. +/// +public sealed record SetReleaseItemSource(Guid? SetId, int Quantity); /// /// One library drawing as released: its current record and the quantity summed over every -/// set of the release that lists it, kits included. +/// set of the release that lists it, kits included, and over the drawing released on its own. /// public sealed class SetReleaseItem { @@ -23,7 +32,10 @@ public sealed class SetReleaseItem Quantity = checked(sources.Sum(s => s.Quantity)); } - /// The member that first listed the drawing, in set order. + /// + /// The member that first listed the drawing, in set order; for a drawing that no set + /// lists, a member made from its line released on its own. + /// public DrawingSetMember Member { get; } /// @@ -32,7 +44,10 @@ public sealed class SetReleaseItem /// public DrawingLibraryRecord Drawing { get; } - /// Each set's share: its member quantity times its kits, in set order. + /// + /// Each set's share, its member quantity times its kits, in set order, then the quantity + /// released on its own (with no set). + /// public IReadOnlyList Sources { get; } /// The total over . @@ -100,9 +115,10 @@ public sealed class SetReleaseGroup } /// -/// How one or more drawing set revisions split into nests, before anything is downloaded or -/// created. Pure: it reads the members' joined drawing records only. A drawing listed by -/// several sets becomes one item with the quantities summed, each set's share kept. Items are +/// How drawing set revisions and library drawings released on their own split into nests, +/// before anything is downloaded or created. Pure: it reads the members' joined drawing +/// records and the drawings' records only. A drawing listed by several sets, or by a set and +/// on its own, becomes one item with the quantities summed, each share kept. Items are /// grouped by material name and grade (compared by ), /// thickness (rounded as the library stores it), units and cut type. Archived drawings are /// skipped unless included (S5). A group is refused when two of its drawings share a name, @@ -110,21 +126,26 @@ public sealed class SetReleaseGroup /// public sealed class SetReleasePlan { - private SetReleasePlan(IReadOnlyList lines, bool includeArchived, - IReadOnlyList groups, IReadOnlyList skippedArchived) + private SetReleasePlan(IReadOnlyList lines, IReadOnlyList drawings, + bool includeArchived, IReadOnlyList groups, IReadOnlyList skippedArchived) { Lines = lines; + Drawings = drawings; IncludeArchived = includeArchived; Groups = groups; SkippedArchived = skippedArchived; - var customers = lines.Select(l => (l.Set.Customer ?? "").Trim()).Distinct(StringComparer.Ordinal).ToList(); + var customers = lines.Select(l => l.Set.Customer).Concat(drawings.Select(d => d.Drawing.Customer)) + .Select(c => (c ?? "").Trim()).Distinct(StringComparer.Ordinal).ToList(); Customer = customers.Count == 1 ? customers[0] : ""; } /// The sets released and how many kits of each, as read from the server. public IReadOnlyList Lines { get; } - /// The customer every set names, or blank when they differ. + /// The library drawings released on their own and how many of each, as read from the library. + public IReadOnlyList Drawings { get; } + + /// The customer every set and every drawing on its own names, or blank when they differ. public string Customer { get; } public bool IncludeArchived { get; } @@ -135,7 +156,10 @@ public sealed class SetReleasePlan /// public IReadOnlyList Groups { get; } - /// Members left out because their drawing is archived and archived drawings were not included. + /// + /// Members left out because their drawing is archived and archived drawings were not + /// included; a drawing released on its own is listed as a member made from its line. + /// public IReadOnlyList SkippedArchived { get; } /// A set read from the server, so every member carries its drawing's current record. @@ -143,11 +167,22 @@ public sealed class SetReleasePlan Build(new[] { new SetReleasePlanLine(set, multiplier) }, includeArchived); /// Sets read from the server, so every member carries its drawing's current record. - public static SetReleasePlan Build(IReadOnlyList lines, bool includeArchived) + public static SetReleasePlan Build(IReadOnlyList lines, bool includeArchived) => + Build(lines, Array.Empty(), includeArchived); + + /// Sets read from the server, so every member carries its drawing's current record; may be empty. + /// Library drawings released on their own, as read from the library; may be empty. + public static SetReleasePlan Build(IReadOnlyList lines, IReadOnlyList drawings, + bool includeArchived) { ArgumentNullException.ThrowIfNull(lines); - if (lines.Count == 0 || lines.Count > SetReleaseRules.MaxSetLines) - throw new ArgumentException($"Release between 1 and {SetReleaseRules.MaxSetLines} sets.", nameof(lines)); + ArgumentNullException.ThrowIfNull(drawings); + if (lines.Count + drawings.Count == 0) + throw new ArgumentException("Release at least one set or drawing.", nameof(lines)); + if (lines.Count > SetReleaseRules.MaxSetLines) + throw new ArgumentException($"Release at most {SetReleaseRules.MaxSetLines} sets.", nameof(lines)); + if (drawings.Count > SetReleaseRules.MaxDrawingLines) + throw new ArgumentException($"Release at most {SetReleaseRules.MaxDrawingLines} drawings on their own.", nameof(drawings)); var seenSets = new HashSet(); foreach (var line in lines) { @@ -164,32 +199,47 @@ public sealed class SetReleasePlan throw new ArgumentException("A set member is missing its library drawing. Reload the set.", nameof(lines)); } - // One entry per drawing, in first-listed order; each set's share kept. + var seenDrawings = new HashSet(); + foreach (var line in drawings) + { + var drawing = line?.Drawing ?? throw new ArgumentException("Every drawing line needs a drawing.", nameof(drawings)); + if (line.Quantity < 1 || line.Quantity > SetReleaseRules.MaxDrawingQuantity) + throw new ArgumentOutOfRangeException(nameof(drawings), line.Quantity, + $"The quantity must be between 1 and {SetReleaseRules.MaxDrawingQuantity}."); + if (drawing.Id == Guid.Empty || drawing.Revision < 1) + throw new ArgumentException("Only a drawing read from the library can be released.", nameof(drawings)); + if (!seenDrawings.Add(drawing.Id)) + throw new ArgumentException($"Drawing '{drawing.Name}' is listed twice. Release it once with a larger quantity.", + nameof(drawings)); + } + + // One entry per drawing, in first-listed order (sets, then drawings on their own); each share kept. var skipped = new List(); var order = new List(); var entries = new Dictionary Sources)>(); - foreach (var line in lines) + var shares = lines.SelectMany(line => line.Set.Members.Select(member => + (Member: member, Share: new SetReleaseItemSource(line.Set.Id, checked(member.Quantity * line.Quantity))))) + .Concat(drawings.Select(line => + (Member: new DrawingSetMember { DrawingId = line.Drawing.Id, Quantity = line.Quantity, Drawing = line.Drawing }, + Share: new SetReleaseItemSource(null, line.Quantity)))); + foreach (var (member, share) in shares) { - foreach (var member in line.Set.Members) + if (member.Drawing!.Archived && !includeArchived) { - if (member.Drawing!.Archived && !includeArchived) - { - skipped.Add(member); - continue; - } + skipped.Add(member); + continue; + } - var share = new SetReleaseItemSource(line.Set.Id, checked(member.Quantity * line.Quantity)); - if (entries.TryGetValue(member.DrawingId, out var entry)) - { - entry.Sources.Add(share); - if (member.Drawing.Revision > entry.Drawing.Revision) - entries[member.DrawingId] = (entry.Member, member.Drawing, entry.Sources); - } - else - { - order.Add(member.DrawingId); - entries[member.DrawingId] = (member, member.Drawing, new List { share }); - } + if (entries.TryGetValue(member.DrawingId, out var entry)) + { + entry.Sources.Add(share); + if (member.Drawing.Revision > entry.Drawing.Revision) + entries[member.DrawingId] = (entry.Member, member.Drawing, entry.Sources); + } + else + { + order.Add(member.DrawingId); + entries[member.DrawingId] = (member, member.Drawing, new List { share }); } } @@ -211,7 +261,7 @@ public sealed class SetReleasePlan .ThenBy(g => g.Units) .ThenBy(g => g.CutType) .ToList(); - return new SetReleasePlan(lines.ToList(), includeArchived, groups, skipped); + return new SetReleasePlan(lines.ToList(), drawings.ToList(), includeArchived, groups, skipped); } /// diff --git a/OpenNest.Data/SetReleaseRecord.cs b/OpenNest.Data/SetReleaseRecord.cs index 9acda321..61c551ef 100644 --- a/OpenNest.Data/SetReleaseRecord.cs +++ b/OpenNest.Data/SetReleaseRecord.cs @@ -1,8 +1,9 @@ namespace OpenNest.Data; /// -/// One release of one or more drawing sets: the nests it made or merged into, one per -/// material group, with every member quantity multiplied by its set line's quantity. +/// One release of drawing sets, library drawings on their own, or both: the nests it made or +/// merged into, one per material group, with every member quantity multiplied by its set +/// line's quantity. /// Releases are append-only; undoing one restores or deletes its nests and marks it /// undone, it never removes the record. /// @@ -14,6 +15,12 @@ public sealed class SetReleaseRecord /// The sets released and how many times each, in the order posted. public IReadOnlyList Sets { get; set; } = Array.Empty(); + /// + /// Library drawings released on their own, outside any set, in the order posted. Empty for + /// a release of sets only and for every release recorded before schema v11. + /// + public IReadOnlyList Drawings { get; set; } = Array.Empty(); + public string Notes { get; set; } = ""; /// When the server stored the release. Server-computed. @@ -48,3 +55,14 @@ public sealed class SetReleaseLine /// The set's name at . Server-joined; ignored on post. public string SetName { get; set; } = ""; } + +/// One library drawing released on its own, outside any set, and how many of it. +public sealed class SetReleaseDrawing +{ + public Guid DrawingId { get; set; } + + public int Quantity { get; set; } = 1; + + /// The drawing's current library name. Server-joined; ignored on post. + public string DrawingName { get; set; } = ""; +} diff --git a/OpenNest.Data/SetReleaseRules.cs b/OpenNest.Data/SetReleaseRules.cs index 227576c2..ef16a5ad 100644 --- a/OpenNest.Data/SetReleaseRules.cs +++ b/OpenNest.Data/SetReleaseRules.cs @@ -3,8 +3,8 @@ using OpenNest; namespace OpenNest.Data; /// -/// Bounds shared by the set release client and server. Whether the set revisions, nests -/// and nest revisions exist, and whether a created nest already belongs to a release, is +/// Bounds shared by the set release client and server. Whether the set revisions, drawings, +/// nests and nest revisions exist, and whether a created nest already belongs to a release, is /// checked by the server. /// public static class SetReleaseRules @@ -15,11 +15,17 @@ public static class SetReleaseRules /// Most sets in one release. public const int MaxSetLines = 50; + /// Most drawings released on their own in one release, as many as one set may list. + public const int MaxDrawingLines = DrawingSetRules.MaxMembers; + + /// Most of one drawing released on its own, as for one set member. + public const int MaxDrawingQuantity = DrawingSetRules.MaxQuantity; + /// One nest per material group. public const int MaxNests = DrawingSetRules.MaxMembers; - /// Most drawing quantities recorded against one nest: every member of every set. - public const int MaxItemsPerNest = DrawingSetRules.MaxMembers * MaxSetLines; + /// Most drawing quantities recorded against one nest: every member of every set, and every drawing on its own. + public const int MaxItemsPerNest = DrawingSetRules.MaxMembers * MaxSetLines + MaxDrawingLines; /// The first rule breaks, or null when it may be posted. public static string? GetError(SetReleaseRecord? release) @@ -28,7 +34,8 @@ public static class SetReleaseRules return "Missing release."; var error = DrawingLibraryRules.TextError("releasedBy", release.ReleasedBy, DrawingLibraryRules.MaxTextLength) ?? DrawingLibraryRules.TextError("notes", release.Notes, DrawingLibraryRules.MaxNotesLength) - ?? SetsError(release.Sets); + ?? SetsError(release.Sets, release.Drawings) + ?? DrawingsError(release.Drawings); if (error is not null) return error; if (release.Nests is null || release.Nests.Count == 0) @@ -37,6 +44,7 @@ public static class SetReleaseRules return $"A release may have at most {MaxNests} nests."; var sets = release.Sets.Select(s => s.SetId).ToHashSet(); + var drawings = release.Drawings.Select(d => d.DrawingId).ToHashSet(); var seen = new HashSet(); for (var i = 0; i < release.Nests.Count; i++) { @@ -55,7 +63,7 @@ public static class SetReleaseRules : null) ?? (!Enum.IsDefined(nest.Units) ? $"nests[{i}].units must be a supported unit name." : null) ?? (!Enum.IsDefined(nest.CutType) ? $"nests[{i}].cutType must be Laser, Waterjet or Plasma." : null) - ?? ItemsError(i, nest.Items, sets); + ?? ItemsError(i, nest.Items, sets, drawings); if (error is not null) return error; } @@ -77,10 +85,13 @@ public static class SetReleaseRules return DrawingLibraryRules.TextError("undoneBy", request.UndoneBy, DrawingLibraryRules.MaxTextLength); } - private static string? SetsError(IReadOnlyList? lines) + // A release names at least one set or one drawing on its own. + private static string? SetsError(IReadOnlyList? lines, IReadOnlyList? drawings) { - if (lines is null || lines.Count == 0) - return "sets must list at least one set."; + if (lines is null) + return "sets must be a list."; + if (lines.Count == 0 && (drawings is null || drawings.Count == 0)) + return "sets or drawings must list at least one set or drawing."; if (lines.Count > MaxSetLines) return $"A release may have at most {MaxSetLines} sets."; @@ -103,6 +114,30 @@ public static class SetReleaseRules return null; } + private static string? DrawingsError(IReadOnlyList? drawings) + { + if (drawings is null) + return "drawings must be a list."; + if (drawings.Count > MaxDrawingLines) + return $"A release may have at most {MaxDrawingLines} drawings on their own."; + + var seen = new HashSet(); + for (var i = 0; i < drawings.Count; i++) + { + var drawing = drawings[i]; + if (drawing is null) + return $"drawings[{i}] is required."; + if (drawing.DrawingId == Guid.Empty) + return $"drawings[{i}].drawingId is required."; + if (!seen.Add(drawing.DrawingId)) + return $"drawings[{i}] repeats drawing {drawing.DrawingId}."; + if (drawing.Quantity < 1 || drawing.Quantity > MaxDrawingQuantity) + return $"drawings[{i}].quantity must be between 1 and {MaxDrawingQuantity}."; + } + + return null; + } + private static string? RevisionsError(int i, SetReleaseNest nest) { if (!Enum.IsDefined(nest.Kind)) @@ -116,26 +151,32 @@ public static class SetReleaseRules return nest.RevisionAfter > before ? null : $"nests[{i}].revisionAfter must be later than revisionBefore."; } - private static string? ItemsError(int i, IReadOnlyList? items, HashSet sets) + // An item with no set is the quantity of a drawing released on its own. + private static string? ItemsError(int i, IReadOnlyList? items, HashSet sets, + HashSet drawings) { if (items is null) return $"nests[{i}].items is required."; if (items.Count > MaxItemsPerNest) return $"nests[{i}] may record at most {MaxItemsPerNest} items."; - var seen = new HashSet<(Guid, Guid)>(); + var seen = new HashSet<(Guid?, Guid)>(); for (var j = 0; j < items.Count; j++) { var item = items[j]; var name = $"nests[{i}].items[{j}]"; if (item is null) return $"{name} is required."; - if (!sets.Contains(item.SetId)) + if (item.SetId is { } setId && !sets.Contains(setId)) return $"{name}.setId must be one of the release's sets."; if (item.DrawingId == Guid.Empty) return $"{name}.drawingId is required."; + if (item.SetId is null && !drawings.Contains(item.DrawingId)) + return $"{name} has no setId, so drawingId must be one of the release's drawings."; if (!seen.Add((item.SetId, item.DrawingId))) - return $"{name} repeats drawing {item.DrawingId} of set {item.SetId}."; + return item.SetId is null + ? $"{name} repeats drawing {item.DrawingId}." + : $"{name} repeats drawing {item.DrawingId} of set {item.SetId}."; if (item.DrawingRevision < 1) return $"{name}.drawingRevision must be a positive revision."; if (item.Quantity < 1) diff --git a/OpenNest.Data/SetReleaseSession.cs b/OpenNest.Data/SetReleaseSession.cs index 035c212a..e6bfbf74 100644 --- a/OpenNest.Data/SetReleaseSession.cs +++ b/OpenNest.Data/SetReleaseSession.cs @@ -133,7 +133,7 @@ public sealed class SetReleaseResult } /// -/// Releases one or more drawing sets: one nest per chosen group of a , +/// Releases drawing sets and library drawings on their own: one nest per chosen group of a , /// either new (uploaded as a ) or an existing draft of the same /// group that the release merges into, each built through /// from the items' exact library revisions, and only then recorded as one release (S7). @@ -146,7 +146,8 @@ public sealed class SetReleaseResult /// /// 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 +/// nothing, and so does a server too old to record drawings released on their own, which is +/// checked first. 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. @@ -238,6 +239,32 @@ public sealed class SetReleaseSession MergedInto = mergeInto.TryGetValue(g, out var draft) ? draft : null, }).ToList(); + // A server that cannot record drawings on their own would refuse the release after its + // nests were saved, so it is asked before anything is built. + if (plan.Drawings.Count > 0) + { + try + { + await _sets.EnsureDrawingReleasesSupportedAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return new SetReleaseResult(results, canceled: true, null, ""); + } + catch (ServerTooOldException ex) + { + Refuse(results[0], ex.Message); + return new SetReleaseResult(results, canceled: false, null, ""); + } + catch (Exception ex) when (ex is IOException or HttpRequestException or System.Text.Json.JsonException + or TimeoutException) + { + results[0].Outcome = SetReleaseGroupOutcome.Failed; + results[0].Error = $"Could not check that the nest server records drawings released on their own: {ex.Message}"; + return new SetReleaseResult(results, canceled: false, null, ""); + } + } + // Build every nest first: a refusal here leaves nothing on the server. for (var i = 0; i < results.Count; i++) { @@ -295,6 +322,11 @@ public sealed class SetReleaseSession SetRevision = l.Set.Revision, Quantity = l.Quantity, }).ToList(), + Drawings = plan.Drawings.Select(d => new SetReleaseDrawing + { + DrawingId = d.Drawing.Id, + Quantity = d.Quantity, + }).ToList(), Notes = notes ?? "", ReleasedBy = releasedBy ?? "", Nests = results.Select(r => new SetReleaseNest @@ -395,7 +427,7 @@ public sealed class SetReleaseSession 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."); + return Refuse(result, $"'{record.Name}' revision {record.Revision} is no longer in the library. Reload the set or drawing."); var package = await Task.Run(() => _readPackage(bytes), cancellationToken); var insert = LibraryDrawingInsert.Plan(nest, _serverUrl, record, package, item.Quantity); diff --git a/OpenNest.Data/SetReleaseText.cs b/OpenNest.Data/SetReleaseText.cs index 77232025..aaceb811 100644 --- a/OpenNest.Data/SetReleaseText.cs +++ b/OpenNest.Data/SetReleaseText.cs @@ -8,15 +8,22 @@ public static class SetReleaseText { /// /// Where a nest came from, such as "Released from set 'Kit A' revision 3 ×2 by J. Smith", - /// or "Merged from sets 'Kit A' revision 3 ×2 and 'Kit B' revision 1 ×1 ..." for a release - /// that added to rather than creating it. + /// "Released from drawing 'Bracket' ×4", or "Merged from sets 'Kit A' revision 3 ×2 and + /// 'Kit B' revision 1 ×1 ..." for a release that added to rather + /// than creating it. /// public static string Origin(SetReleaseRecord release, Guid nestId = default) { ArgumentNullException.ThrowIfNull(release); var merged = release.Nests?.FirstOrDefault(n => n.NestId == nestId)?.Kind == SetReleaseNestKind.Merged; var sets = release.Sets ?? Array.Empty(); - var text = $"{(merged ? "Merged" : "Released")} from {(sets.Count == 1 ? "set" : "sets")} {Sets(sets)}"; + var drawings = release.Drawings ?? Array.Empty(); + var sources = new List(); + if (sets.Count > 0) + sources.Add($"{(sets.Count == 1 ? "set" : "sets")} {Sets(sets)}"); + if (drawings.Count > 0) + sources.Add(Drawings(drawings)); + var text = $"{(merged ? "Merged" : "Released")} from {string.Join(" and ", sources)}"; if (release.ReleasedAt != default) text += $" on {release.ReleasedAt.ToLocalTime().ToString("d", CultureInfo.CurrentCulture)}"; if (!string.IsNullOrWhiteSpace(release.ReleasedBy)) @@ -34,6 +41,22 @@ public static class SetReleaseText : string.Join(", ", names.Take(names.Count - 1)) + " and " + names[^1]; } + /// + /// The drawings a release had on their own: "drawing 'Bracket' ×4", "drawings 'A' ×1 and + /// 'B' ×2", or "5 drawings" when there are more than three. + /// + public static string Drawings(IReadOnlyList drawings) + { + ArgumentNullException.ThrowIfNull(drawings); + if (drawings.Count > 3) + return $"{drawings.Count} drawings"; + var names = drawings.Select(d => $"'{d.DrawingName}' ×{d.Quantity}").ToList(); + var text = names.Count <= 1 + ? string.Concat(names) + : string.Join(", ", names.Take(names.Count - 1)) + " and " + names[^1]; + return $"{(drawings.Count == 1 ? "drawing" : "drawings")} {text}"; + } + /// /// How far the release's nests have got, such as "2 of 3 cut" or "1 of 2 cut, 1 deleted", /// or "Undone". Deleted nests are left out of the count. diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index 9f26da8e..372efb15 100644 --- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -22,7 +22,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items); Assert.Equal(before, NestDigest(factory.DatabasePath)); - Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -43,7 +43,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) { - Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(before, NestDigest(factory.DatabasePath)); // Two distinct blobs across four revisions: the bracket's notes edit and the // plate both reuse the bracket's first package. @@ -80,7 +80,7 @@ public sealed class DrawingLibraryDatabaseTests { using var factory = new ServerFactory(); using (new NestDatabase(factory.DatabasePath, pooling: false)) { } - Execute(factory.DatabasePath, "PRAGMA user_version=11"); + Execute(factory.DatabasePath, "PRAGMA user_version=12"); Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); } diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs index 8f9c11f7..7265f605 100644 --- a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs @@ -22,7 +22,7 @@ public sealed class DrawingSetDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items); Assert.Equal(before, Digest(factory.DatabasePath)); - Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -47,7 +47,7 @@ public sealed class DrawingSetDatabaseTests Assert.Equal(before, Digest(factory.DatabasePath)); Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing"); using var database = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } [Fact] diff --git a/OpenNest.Server.Tests/DrawingSetFixtures.cs b/OpenNest.Server.Tests/DrawingSetFixtures.cs index 93521b54..70613f98 100644 --- a/OpenNest.Server.Tests/DrawingSetFixtures.cs +++ b/OpenNest.Server.Tests/DrawingSetFixtures.cs @@ -34,12 +34,12 @@ internal static class DrawingSetFixtures return (await Upload(client, record, Package(name))).Id; } - /// Drops the v9 release tables, for fixtures that rebuild a schema older than v7. + /// Drops the v11 release tables, for fixtures that rebuild a schema older than v7. public const string DropReleaseTables = """ - DROP TABLE release_nest_items; DROP TABLE release_nests; DROP TABLE release_sets; DROP TABLE releases; + DROP TABLE release_drawings; DROP TABLE release_nest_items; DROP TABLE release_nests; DROP TABLE release_sets; DROP TABLE releases; """; - /// Puts back the v7/v8 release tables in place of the v9 ones, for fixtures that rebuild v7 or v8. + /// Puts back the v7/v8 release tables in place of the v11 ones, for fixtures that rebuild v7 or v8. public const string ReleaseTablesBackToV8 = DropReleaseTables + """ CREATE TABLE set_releases ( id TEXT NOT NULL PRIMARY KEY, diff --git a/OpenNest.Server.Tests/MaterialListTests.cs b/OpenNest.Server.Tests/MaterialListTests.cs index 5dd5fe97..fe4fe93c 100644 --- a/OpenNest.Server.Tests/MaterialListTests.cs +++ b/OpenNest.Server.Tests/MaterialListTests.cs @@ -24,7 +24,7 @@ public sealed class MaterialListTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Empty(upgraded.ListMaterials()); Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade)); Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName); diff --git a/OpenNest.Server.Tests/NestMaterialGradeTests.cs b/OpenNest.Server.Tests/NestMaterialGradeTests.cs index 8f58cb9a..0e04840b 100644 --- a/OpenNest.Server.Tests/NestMaterialGradeTests.cs +++ b/OpenNest.Server.Tests/NestMaterialGradeTests.cs @@ -74,7 +74,7 @@ public sealed class NestMaterialGradeTests Assert.Equal("Stainless", record.Material); Assert.Equal("", record.MaterialGrade); Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade); - Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static NestRecord Record(string name, string material, string grade) => new() diff --git a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs new file mode 100644 index 00000000..3c09519e --- /dev/null +++ b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs @@ -0,0 +1,342 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Data.Sqlite; +using OpenNest.Data; +using OpenNest.IO; +using OpenNest.Shapes; +using static OpenNest.Server.Tests.DrawingLibraryFixtures; +using static OpenNest.Server.Tests.DrawingSetFixtures; + +namespace OpenNest.Server.Tests; + +/// +/// Library drawings released on their own (schema v11): stored beside or instead of sets, +/// items without a set, the drawingId query filter, the v10 upgrade and the whole path +/// through the real clients, including undo. +/// +public sealed class SetReleaseDrawingTests +{ + // A release of drawings only: one nest per id, each holding the first drawing's quantity. + private static SetReleaseRecord DrawingRelease(Guid drawing, int quantity, params Guid[] nests) + { + var release = Release(Guid.Empty, 1, nests); + release.Sets = Array.Empty(); + release.Drawings = new[] { new SetReleaseDrawing { DrawingId = drawing, Quantity = quantity } }; + foreach (var nest in release.Nests) + nest.Items = new[] { new SetReleaseNestItem { DrawingId = drawing, DrawingRevision = 1, Quantity = quantity } }; + return release; + } + + [Fact] + public void DrawingsOnTheirOwn_AreStoredWithSetsAndItemsWithoutASet() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var bracket = AddDrawing(database, "Synthetic bracket"); + var plate = AddDrawing(database, "Synthetic plate"); + var kit = Set(members: (bracket, 1)); + database.InsertDrawingSet(kit.Id, kit); + var nest = AddNest(database); + var release = Release(kit.Id, 1, nest); + release.Drawings = new[] + { + new SetReleaseDrawing { DrawingId = plate, Quantity = 40, DrawingName = "ignored" }, + new SetReleaseDrawing { DrawingId = bracket, Quantity = 5 }, + }; + release.Nests[0].Items = new[] + { + new SetReleaseNestItem { SetId = kit.Id, DrawingId = bracket, DrawingRevision = 1, Quantity = 2 }, + new SetReleaseNestItem { DrawingId = bracket, DrawingRevision = 1, Quantity = 5 }, + new SetReleaseNestItem { DrawingId = plate, DrawingRevision = 1, Quantity = 40 }, + }; + + var stored = database.InsertRelease(release); + + Assert.Equal(new[] { (plate, 40, "Synthetic plate"), (bracket, 5, "Synthetic bracket") }, + stored.Drawings.Select(d => (d.DrawingId, d.Quantity, d.DrawingName))); + Assert.Equal(new[] { ((Guid?)kit.Id, bracket, 2), (null, bracket, 5), (null, plate, 40) }, + stored.Nests[0].Items.Select(i => (i.SetId, i.DrawingId, i.Quantity))); + Assert.Equal(release.Id, database.QueryReleases(null, null, 0, 100, drawingId: plate).Items.Single().Id); + Assert.Equal(release.Id, database.QueryReleases(kit.Id, null, 0, 100, drawingId: bracket).Items.Single().Id); + Assert.Empty(database.QueryReleases(null, null, 0, 100, drawingId: AddDrawing(database, "Never released")).Items); + Assert.Equal(2L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM release_drawings")); + } + + [Fact] + public void ReleaseOfDrawingsOnly_IsStored_AndUndoDeletesItsNest() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var drawing = AddDrawing(database); + var created = AddNest(database, "Created nest"); + var draft = AddNest(database, "Draft nest"); + var release = DrawingRelease(drawing, 12, created, draft).Merged(1, 1, Resave(database, draft)); + + var stored = database.InsertRelease(release); + var undone = database.UndoRelease(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic undoer" })!; + + Assert.Empty(stored.Sets); + Assert.Equal((drawing, 12), (stored.Drawings.Single().DrawingId, stored.Drawings.Single().Quantity)); + Assert.Equal(new SetReleaseNestUndo?[] { SetReleaseNestUndo.Deleted, SetReleaseNestUndo.Restored }, undone.Nests.Select(n => n.Undo)); + Assert.Null(database.Get(created)); + Assert.Equal(1, database.Get(draft)!.RestoredFromRevision); + Assert.Equal("Released from drawing 'Synthetic bracket' ×12 by Synthetic operator (undone)", + SetReleaseText.Origin(new SetReleaseRecord + { + Drawings = undone.Drawings, + Nests = undone.Nests, + ReleasedBy = undone.ReleasedBy, + UndoneAt = undone.UndoneAt, + })); + } + + [Fact] + public void Refusals_ForDrawingsLeaveNoState() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var drawing = AddDrawing(database); + var nest = AddNest(database); + + Assert.Contains("does not exist", Assert.Throws(() => + database.InsertRelease(DrawingRelease(Guid.NewGuid(), 1, nest))).Message); + var twice = DrawingRelease(drawing, 1, nest); + twice.Drawings = twice.Drawings.Concat(twice.Drawings).ToList(); + Assert.Contains("repeats drawing", Assert.Throws(() => database.InsertRelease(twice)).Message); + var stranger = DrawingRelease(drawing, 1, nest); + stranger.Nests[0].Items = new[] { new SetReleaseNestItem { DrawingId = Guid.NewGuid(), DrawingRevision = 1, Quantity = 1 } }; + Assert.Contains("one of the release's drawings", Assert.Throws(() => database.InsertRelease(stranger)).Message); + var tooMany = DrawingRelease(drawing, SetReleaseRules.MaxDrawingQuantity + 1, nest); + Assert.Throws(() => database.InsertRelease(tooMany)); + var nothing = DrawingRelease(drawing, 1, nest); + nothing.Drawings = Array.Empty(); + nothing.Nests[0].Items = Array.Empty(); + Assert.Contains("at least one set or drawing", Assert.Throws(() => database.InsertRelease(nothing)).Message); + + Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM releases")); + Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM release_drawings")); + } + + [Fact] + public void Version10Items_SurviveTheUpgrade_AndMayThenGoWithoutASet() + { + using var factory = new ServerFactory(); + var drawing = Guid.Empty; + var kit = Set(); + var releaseId = Guid.Empty; + using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) + { + drawing = AddDrawing(database); + kit = Set(members: (drawing, 1)); + database.InsertDrawingSet(kit.Id, kit); + var release = Release(kit.Id, 1, AddNest(database)); + release.Nests[0].Items = new[] { new SetReleaseNestItem { SetId = kit.Id, DrawingId = drawing, DrawingRevision = 1, Quantity = 2 } }; + releaseId = database.InsertRelease(release).Id; + } + // The v10 layout: no release_drawings, and every item names a set. + Execute(factory.DatabasePath, """ + DROP TABLE release_drawings; + CREATE TABLE items_v10 ( + releaseId TEXT NOT NULL, nestId TEXT NOT NULL, position INTEGER NOT NULL, setId TEXT NOT NULL, + drawingId TEXT NOT NULL, drawingRevision INTEGER NOT NULL, quantity INTEGER NOT NULL, + PRIMARY KEY (releaseId, nestId, position), UNIQUE (releaseId, nestId, setId, drawingId), + FOREIGN KEY (releaseId, nestId) REFERENCES release_nests(releaseId, nestId)); + INSERT INTO items_v10 SELECT * FROM release_nest_items; + DROP TABLE release_nest_items; + ALTER TABLE items_v10 RENAME TO release_nest_items; + PRAGMA user_version=10; + """); + + using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); + + Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + var kept = upgraded.GetRelease(releaseId)!; + Assert.Empty(kept.Drawings); + Assert.Equal(((Guid?)kit.Id, drawing, 2), (kept.Nests[0].Items.Single().SetId, kept.Nests[0].Items.Single().DrawingId, kept.Nests[0].Items.Single().Quantity)); + Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM pragma_table_info('release_nest_items') WHERE name='setId' AND \"notnull\"=0")); + Assert.Single(upgraded.InsertRelease(DrawingRelease(drawing, 3, AddNest(upgraded))).Drawings); + } + + [Fact] + public async Task Api_PostsDrawingsWithANullItemSet_AndFiltersByDrawing() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var drawing = await AddDrawing(client, "Synthetic gusset"); + var release = DrawingRelease(drawing, 7, await AddNest(client)); + + using var posted = await client.PostAsync("/api/releases", Json(JsonSerializer.Serialize(release, Options))); + + Assert.Equal(HttpStatusCode.OK, posted.StatusCode); + var json = JsonNode.Parse(await posted.Content.ReadAsStringAsync())!; + Assert.Empty(json["sets"]!.AsArray()); + Assert.Equal("Synthetic gusset", json["drawings"]![0]!["drawingName"]!.GetValue()); + Assert.Null(json["nests"]![0]!["items"]![0]!["setId"]); + var page = (await client.GetFromJsonAsync($"/api/releases/query?drawingId={drawing}", Options))!; + Assert.Equal(release.Id, page.Items.Single().Id); + Assert.Equal(0, (await client.GetFromJsonAsync($"/api/releases/query?drawingId={Guid.NewGuid()}", Options))!.Total); + foreach (var query in new[] { "drawingId=x", $"drawingId={Guid.Empty}", $"drawingId={drawing}&drawingId={drawing}" }) + Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/releases/query?{query}")).StatusCode); + + // A client from before drawings on their own sends no drawings at all. + var set = await Create(client, Set()); + var legacy = JsonNode.Parse(JsonSerializer.Serialize(Release(set.Id, 1, await AddNest(client)), Options))!.AsObject(); + legacy.Remove("drawings"); + using var old = await client.PostAsync("/api/releases", Json(legacy.ToJsonString())); + Assert.Equal(HttpStatusCode.OK, old.StatusCode); + } + + public static TheoryData RejectedDrawingReleases() + { + var valid = JsonSerializer.Serialize(DrawingRelease(DrawingA, 2, NestA), Options); + string With(Action edit) + { + var node = JsonNode.Parse(valid)!.AsObject(); + edit(node); + return node.ToJsonString(); + } + + return new TheoryData + { + { "no sets and no drawings", With(n => { n["drawings"] = new JsonArray(); n["nests"]![0]!["items"] = new JsonArray(); }) }, + { "null drawings", With(n => n["drawings"] = null) }, + { "unknown drawing", With(n => n["drawings"]![0]!["drawingId"] = Guid.NewGuid().ToString()) }, + { "empty drawing id", With(n => n["drawings"]![0]!["drawingId"] = Guid.Empty.ToString()) }, + { "zero quantity", With(n => n["drawings"]![0]!["quantity"] = 0) }, + { "quantity over the limit", With(n => n["drawings"]![0]!["quantity"] = SetReleaseRules.MaxDrawingQuantity + 1) }, + { "repeated drawing", With(n => n["drawings"]!.AsArray().Add(n["drawings"]![0]!.DeepClone())) }, + { "item of a drawing not released", With(n => n["nests"]![0]!["items"]![0]!["drawingId"] = Guid.NewGuid().ToString()) }, + { "repeated item", With(n => n["nests"]![0]!["items"]!.AsArray().Add(n["nests"]![0]!["items"]![0]!.DeepClone())) }, + }; + } + + private static readonly Guid NestA = Guid.Parse("00000000-0000-0000-0000-0000000000a2"); + private static readonly Guid DrawingA = Guid.Parse("00000000-0000-0000-0000-0000000000c2"); + + [Theory] + [MemberData(nameof(RejectedDrawingReleases))] + public async Task Api_InvalidDrawingRelease_IsRejectedWithNoState(string row, string json) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + await AddDrawing(client, edit: r => r.Id = DrawingA); + await AddNest(client, id: NestA); + + using var response = await client.PostAsync("/api/releases", Json(json)); + + Assert.True(response.StatusCode == HttpStatusCode.BadRequest, row + ": " + response.StatusCode); + Assert.Equal(0, (await client.GetFromJsonAsync("/api/releases/query", Options))!.Total); + } + + [Fact] + public async Task Client_ConfirmsSupport_PostsAndQueriesByDrawing() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var store = new RemoteDrawingSetStore(client, client.BaseAddress!.ToString()); + var drawing = await AddDrawing(client, "Synthetic gusset"); + var release = DrawingRelease(drawing, 9, await AddNest(client)); + + await store.EnsureDrawingReleasesSupportedAsync(); + var stored = await store.PostReleaseAsync(release); + + Assert.Equal(("Synthetic gusset", 9), (stored.Drawings.Single().DrawingName, stored.Drawings.Single().Quantity)); + Assert.Equal(release.Id, (await store.QueryReleasesAsync(drawingId: drawing)).Items.Single().Id); + Assert.Empty((await store.QueryReleasesAsync(drawingId: Guid.NewGuid())).Items); + var unknown = DrawingRelease(Guid.NewGuid(), 1, await AddNest(client)); + Assert.DoesNotContain("does not support", (await Assert.ThrowsAsync(() => store.PostReleaseAsync(unknown))).Message); + } + + [Fact] + public async Task Release_OfASetAndDrawingsOnTheirOwn_SharesNests_RecordsBoth_AndUndoes() + { + 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); + async Task Store(string name, double length, string material) + { + var record = new DrawingLibraryRecord + { + Id = Guid.NewGuid(), + Name = name, + Customer = "Synthetic customer", + MaterialName = material, + Thickness = 0.25, + Units = Units.Inches, + CutType = CutType.Laser, + SavedBy = "Synthetic operator", + }; + var drawing = new RectangleShape { Name = name, Length = length, Width = 2 }.GetDrawing(); + return await library.UploadAsync(record, DrawingPackage.Write(drawing, Units.Inches)); + } + var a = await Store("SYN-A", 4, "Synthetic Steel"); + var b = await Store("SYN-B", 5, "Synthetic Steel"); + var c = await Store("SYN-C", 6, "Synthetic Aluminum"); + var kit = await sets.GetAsync((await sets.CreateAsync(new DrawingSetRecord + { + Id = Guid.NewGuid(), + Name = "Synthetic kit", + Customer = "Synthetic customer", + Members = new[] { new DrawingSetMember { DrawingId = a.Id, Quantity = 2 } }, + })).Id); + var plan = SetReleasePlan.Build(new[] { new SetReleasePlanLine(kit!, 3) }, + new[] { new SetReleaseDrawingLine(a, 1), new SetReleaseDrawingLine(b, 4), new SetReleaseDrawingLine(c, 10) }, + 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); + }, + nest => + { + using var stream = new MemoryStream(); + new NestWriter(nest).Write(stream); + return stream.ToArray(); + }, + (group, _) => Task.FromResult(new Nest($"Synthetic loose {++number}") { Units = group.Units })); + + var result = await session.RunAsync(plan, plan.Groups, "Synthetic operator", ""); + + Assert.Equal("", result.ReleaseError); + var release = result.Release!; + Assert.Equal(new[] { (a.Id, 1, "SYN-A"), (b.Id, 4, "SYN-B"), (c.Id, 10, "SYN-C") }, + release.Drawings.Select(d => (d.DrawingId, d.Quantity, d.DrawingName))); + var steel = release.Nests.Single(n => n.MaterialName == "Synthetic Steel"); + Assert.Equal(new[] { ((Guid?)kit!.Id, a.Id, 6), (null, a.Id, 1), (null, b.Id, 4) }, + steel.Items.Select(i => (i.SetId, i.DrawingId, i.Quantity))); + Assert.Equal(new[] { release.Id }, (await sets.QueryReleasesAsync(drawingId: c.Id)).Items.Select(r => r.Id)); + Assert.Equal(new[] { release.Id }, (await sets.QueryReleasesAsync(setId: kit.Id)).Items.Select(r => r.Id)); + Assert.Equal("Released from set 'Synthetic kit' revision 1 ×3 and drawings 'SYN-A' ×1, 'SYN-B' ×4 and 'SYN-C' ×10", + SetReleaseText.Origin(new SetReleaseRecord { Sets = release.Sets, Drawings = release.Drawings })); + + var undone = await sets.UndoReleaseAsync(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic operator" }); + Assert.All(undone.Nests, n => Assert.Equal(SetReleaseNestUndo.Deleted, n.Undo)); + foreach (var group in result.Groups) + Assert.Null(await nests.GetMetadataAsync(group.Record!.Id)); + } + + private static object? Scalar(string path, string sql) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } + + private static void Execute(string path, string sql) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/OpenNest.Server.Tests/SetReleaseTests.cs b/OpenNest.Server.Tests/SetReleaseTests.cs index 88775970..db7afb90 100644 --- a/OpenNest.Server.Tests/SetReleaseTests.cs +++ b/OpenNest.Server.Tests/SetReleaseTests.cs @@ -48,7 +48,7 @@ public sealed class SetReleaseTests stored.Nests.Select(n => (n.NestId, n.Kind, n.RevisionBefore, n.RevisionAfter))); Assert.Equal(("Synthetic Steel 1", "Grade A", 0.1235, Units.Millimeters, CutType.Waterjet), (stored.Nests[1].MaterialName, stored.Nests[1].MaterialGrade, stored.Nests[1].Thickness, stored.Nests[1].Units, stored.Nests[1].CutType)); - Assert.Equal(new[] { (kit.Id, 2), (other.Id, 6) }, stored.Nests[1].Items.Select(i => (i.SetId, i.Quantity))); + Assert.Equal(new[] { ((Guid?)kit.Id, 2), (other.Id, 6) }, stored.Nests[1].Items.Select(i => (i.SetId, i.Quantity))); Assert.Empty(stored.Nests[0].Items); Assert.Equal(("Created nest", NestStatus.Draft, false), (stored.Nests[0].Nest!.Name, stored.Nests[0].Nest!.Status, stored.Nests[0].Nest!.Deleted)); Assert.Null(stored.Nests[0].Undo); @@ -294,7 +294,7 @@ public sealed class SetReleaseTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'set_release%' OR name LIKE 'ix_set_release%'")); var release = upgraded.GetRelease(releaseId)!; Assert.Equal(("Legacy notes", "Legacy operator", false), (release.Notes, release.ReleasedBy, release.IsUndone)); diff --git a/OpenNest.Server/NestDatabase.Releases.cs b/OpenNest.Server/NestDatabase.Releases.cs index cb4137e5..7985dce4 100644 --- a/OpenNest.Server/NestDatabase.Releases.cs +++ b/OpenNest.Server/NestDatabase.Releases.cs @@ -6,9 +6,10 @@ namespace OpenNest.Server; /// /// Release storage. releases records who released and whether it was undone; /// release_sets which set revisions were released and how many kits of each; +/// release_drawings which library drawings were released on their own and how many; /// release_nests the nests each release created or merged into, with the nest -/// revision before and after; release_nest_items the drawing quantities each set -/// added to each nest. A created nest belongs to that release only; a merged nest may be in +/// revision before and after; release_nest_items the drawing quantities each set, or +/// each drawing on its own (no set), added to each nest. A created nest belongs to that release only; a merged nest may be in /// several. Releases are append-only and read joined to each nest's current metadata. /// public sealed partial class NestDatabase @@ -106,6 +107,37 @@ public sealed partial class NestDatabase PRAGMA user_version=9; """; + // v10 -> v11. Releases may name library drawings on their own, and an item without a set + // is such a drawing's quantity. SQLite cannot drop NOT NULL from a column, so the item + // table is rebuilt with every row kept. IF NOT EXISTS keeps the step safe on a database + // rewound to v10. + private const string ReleaseUpgradeToV11 = """ + CREATE TABLE IF NOT EXISTS release_drawings ( + releaseId TEXT NOT NULL REFERENCES releases(id), + position INTEGER NOT NULL, + drawingId TEXT NOT NULL REFERENCES drawings(id), + quantity INTEGER NOT NULL, + PRIMARY KEY (releaseId, position), + UNIQUE (releaseId, drawingId)); + CREATE INDEX IF NOT EXISTS ix_release_drawings_drawing ON release_drawings(drawingId); + CREATE TABLE release_nest_items_v11 ( + releaseId TEXT NOT NULL, + nestId TEXT NOT NULL, + position INTEGER NOT NULL, + setId TEXT, + drawingId TEXT NOT NULL, + drawingRevision INTEGER NOT NULL, + quantity INTEGER NOT NULL, + PRIMARY KEY (releaseId, nestId, position), + UNIQUE (releaseId, nestId, setId, drawingId), + FOREIGN KEY (releaseId, nestId) REFERENCES release_nests(releaseId, nestId)); + INSERT INTO release_nest_items_v11 (releaseId, nestId, position, setId, drawingId, drawingRevision, quantity) + SELECT releaseId, nestId, position, setId, drawingId, drawingRevision, quantity FROM release_nest_items; + DROP TABLE release_nest_items; + ALTER TABLE release_nest_items_v11 RENAME TO release_nest_items; + PRAGMA user_version=11; + """; + private const string ReleaseSelect = "SELECT x.id, x.notes, x.releasedAt, x.releasedBy, x.undoneAt, x.undoneBy FROM releases x"; @@ -113,8 +145,8 @@ public sealed partial class NestDatabase string.Join(", ", RecordColumns.Split(',').Select(column => "n." + column.Trim())); /// - /// Records a release whose nests already exist. Set revisions must exist but need not be - /// current, and sets may be archived: the nests already exist, so a release is never + /// Records a release whose nests already exist. Set revisions and drawings released on + /// their own must exist but need not be current, and sets and drawings may be archived: the nests already exist, so a release is never /// refused for a state the operator cannot fix. A merged nest need not still be at the /// revision the release wrote. /// @@ -142,6 +174,12 @@ public sealed partial class NestDatabase } } + foreach (var drawing in release.Drawings) + { + if (GetDrawing(drawing.DrawingId) is null) + throw new ArgumentException($"Drawing {drawing.DrawingId} does not exist."); + } + foreach (var nest in release.Nests) { if (Get(nest.NestId) is null) @@ -176,9 +214,10 @@ public sealed partial class NestDatabase /// /// One page of releases, newest first, optionally only those that include - /// or . + /// , or + /// released on its own. /// - public SetReleasePage QueryReleases(Guid? setId, Guid? nestId, int offset, int limit) + public SetReleasePage QueryReleases(Guid? setId, Guid? nestId, int offset, int limit, Guid? drawingId = null) { if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset)); if (limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) throw new ArgumentOutOfRangeException(nameof(limit)); @@ -187,12 +226,15 @@ public sealed partial class NestDatabase filters.Add("EXISTS (SELECT 1 FROM release_sets s WHERE s.releaseId = x.id AND s.setId = $setId)"); if (nestId is not null) filters.Add("EXISTS (SELECT 1 FROM release_nests r WHERE r.releaseId = x.id AND r.nestId = $nestId)"); + if (drawingId is not null) + filters.Add("EXISTS (SELECT 1 FROM release_drawings d WHERE d.releaseId = x.id AND d.drawingId = $drawingId)"); var where = filters.Count == 0 ? "" : "WHERE " + string.Join(" AND ", filters); void AddFilters(SqliteCommand command) { if (setId is not null) command.Parameters.AddWithValue("$setId", setId.Value.ToString()); if (nestId is not null) command.Parameters.AddWithValue("$nestId", nestId.Value.ToString()); + if (drawingId is not null) command.Parameters.AddWithValue("$drawingId", drawingId.Value.ToString()); } lock (_sync) @@ -378,6 +420,23 @@ public sealed partial class NestDatabase command.ExecuteNonQuery(); } + command.Parameters.Clear(); + command.CommandText = """ + INSERT INTO release_drawings (releaseId, position, drawingId, quantity) + VALUES ($id, $position, $drawing, $quantity) + """; + command.Parameters.AddWithValue("$id", release.Id.ToString()); + position = command.Parameters.Add("$position", SqliteType.Integer); + var drawing = command.Parameters.Add("$drawing", SqliteType.Text); + quantity = command.Parameters.Add("$quantity", SqliteType.Integer); + for (var i = 0; i < release.Drawings.Count; i++) + { + position.Value = i; + drawing.Value = release.Drawings[i].DrawingId.ToString(); + quantity.Value = release.Drawings[i].Quantity; + command.ExecuteNonQuery(); + } + command.Parameters.Clear(); command.CommandText = """ INSERT INTO release_nests (releaseId, position, nestId, kind, revisionBefore, revisionAfter, @@ -431,7 +490,7 @@ public sealed partial class NestDatabase { var item = nest.Items[i]; position.Value = i; - setId.Value = item.SetId.ToString(); + setId.Value = (object?)item.SetId?.ToString() ?? DBNull.Value; drawingId.Value = item.DrawingId.ToString(); drawingRevision.Value = item.DrawingRevision; quantity.Value = item.Quantity; @@ -471,8 +530,9 @@ public sealed partial class NestDatabase return releases; } - // Called only under _sync. Reads the release's sets (named as at their revision) and its - // nests, each joined to its current metadata, deleted or not, with its items. + // Called only under _sync. Reads the release's sets (named as at their revision), its + // drawings on their own (named as now) and its nests, each joined to its current metadata, + // deleted or not, with its items. private void Complete(SetReleaseRecord release) { var id = release.Id.ToString(); @@ -499,6 +559,28 @@ public sealed partial class NestDatabase release.Sets = sets; } + using (var command = _connection.CreateCommand()) + { + command.CommandText = """ + SELECT d.drawingId, d.quantity, COALESCE(x.name, '') + FROM release_drawings d LEFT JOIN drawings x ON x.id = d.drawingId + WHERE d.releaseId=$id ORDER BY d.position + """; + command.Parameters.AddWithValue("$id", id); + var drawings = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + drawings.Add(new SetReleaseDrawing + { + DrawingId = Guid.Parse(reader.GetString(0)), + Quantity = reader.GetInt32(1), + DrawingName = reader.GetString(2), + }); + } + release.Drawings = drawings; + } + var items = new Dictionary>(); using (var command = _connection.CreateCommand()) { @@ -515,7 +597,7 @@ public sealed partial class NestDatabase items[nestId] = list = new List(); list.Add(new SetReleaseNestItem { - SetId = Guid.Parse(reader.GetString(1)), + SetId = reader.IsDBNull(1) ? null : Guid.Parse(reader.GetString(1)), DrawingId = Guid.Parse(reader.GetString(2)), DrawingRevision = reader.GetInt32(3), Quantity = reader.GetInt32(4), diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index 420ed94a..299da577 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -346,7 +346,7 @@ public sealed partial class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = "PRAGMA user_version"; var version = (long)command.ExecuteScalar()!; - if (version > 10) throw new InvalidOperationException("Database schema is newer than this server."); + if (version > 11) throw new InvalidOperationException("Database schema is newer than this server."); if (version == 0) { command.CommandText = """ @@ -425,6 +425,12 @@ public sealed partial class NestDatabase : IDisposable command.CommandText = MaterialSchema; command.ExecuteNonQuery(); } + if (version < 11) + { + // Releases may name library drawings on their own. + command.CommandText = ReleaseUpgradeToV11; + command.ExecuteNonQuery(); + } transaction.Commit(); } } diff --git a/OpenNest.Server/ReleaseRequest.cs b/OpenNest.Server/ReleaseRequest.cs index 5025df29..505a423c 100644 --- a/OpenNest.Server/ReleaseRequest.cs +++ b/OpenNest.Server/ReleaseRequest.cs @@ -13,7 +13,7 @@ namespace OpenNest.Server; /// internal static class ReleaseRequest { - private static readonly string[] QueryKeys = { "setId", "nestId", "offset", "limit" }; + private static readonly string[] QueryKeys = { "setId", "nestId", "drawingId", "offset", "limit" }; // Integer enum values are refused; names are additionally checked before deserializing. private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) @@ -23,10 +23,11 @@ internal static class ReleaseRequest }; public static bool TryParseQuery(IQueryCollection values, out Guid? setId, out Guid? nestId, - out int offset, out int limit, out string error) + out Guid? drawingId, out int offset, out int limit, out string error) { setId = null; nestId = null; + drawingId = null; offset = 0; limit = DrawingLibraryRules.DefaultRevisionLimit; foreach (var key in values.Keys) @@ -45,6 +46,7 @@ internal static class ReleaseRequest } if (!TryId(values, "setId", out setId, out error) || !TryId(values, "nestId", out nestId, out error) + || !TryId(values, "drawingId", out drawingId, out error) || !DrawingLibraryRequest.TryInteger(values, "offset", ref offset, out error) || !DrawingLibraryRequest.TryInteger(values, "limit", ref limit, out error)) return false; @@ -60,7 +62,8 @@ internal static class ReleaseRequest /// /// Reads and validates one release JSON object. sets and nests must be - /// present, enums must be single names, and joined or server-computed values are dropped. + /// present, drawings may be left out (clients from before drawings on their own), + /// enums must be single names, and joined or server-computed values are dropped. /// public static bool TryReadRelease(string? json, out SetReleaseRecord release, out string error) { @@ -85,6 +88,7 @@ internal static class ReleaseRequest return false; } + DrawingSetRequest.RemoveJoined(root, "drawings", "drawingName"); if (!DrawingSetRequest.RemoveJoined(root, "nests", "nest")) { error = "nests is required."; diff --git a/OpenNest.Server/ReleaseRoutes.cs b/OpenNest.Server/ReleaseRoutes.cs index eade8bd6..7d2fd7a2 100644 --- a/OpenNest.Server/ReleaseRoutes.cs +++ b/OpenNest.Server/ReleaseRoutes.cs @@ -3,7 +3,7 @@ using OpenNest.Data; namespace OpenNest.Server; /// -/// /api/releases: append-only records of drawing set releases into nests, and their +/// /api/releases: append-only records of releases of drawing sets and library drawings into nests, and their /// undo. A release is posted after every nest it names is uploaded; its id is allocated by /// the client, so a post whose reply was lost can be sent again (409 with the stored release). /// @@ -28,8 +28,9 @@ internal static class ReleaseRoutes }); app.MapGet("/api/releases/query", (HttpRequest request, NestDatabase db) => - ReleaseRequest.TryParseQuery(request.Query, out var setId, out var nestId, out var offset, out var limit, out var error) - ? Results.Ok(db.QueryReleases(setId, nestId, offset, limit)) + ReleaseRequest.TryParseQuery(request.Query, out var setId, out var nestId, out var drawingId, out var offset, + out var limit, out var error) + ? Results.Ok(db.QueryReleases(setId, nestId, offset, limit, drawingId)) : Results.BadRequest(error)); app.MapGet("/api/releases/{id:guid}", (Guid id, NestDatabase db) => diff --git a/OpenNest.Tests/Data/InMemoryDrawingSets.cs b/OpenNest.Tests/Data/InMemoryDrawingSets.cs index 36cfb19e..3ad589cb 100644 --- a/OpenNest.Tests/Data/InMemoryDrawingSets.cs +++ b/OpenNest.Tests/Data/InMemoryDrawingSets.cs @@ -116,6 +116,12 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore public int PostAttempts { get; private set; } public bool FailNextPost { get; set; } + /// Plays a server from before drawings released on their own. + public bool WithoutDrawingReleases { get; set; } + + /// Library drawing names joined onto stored releases, by id. + public Dictionary DrawingNames { get; } = new(); + public Task PostReleaseAsync(SetReleaseRecord release, CancellationToken cancellationToken = default) { PostAttempts++; @@ -137,6 +143,12 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore Quantity = s.Quantity, SetName = _revisions[s.SetId].First(r => r.Revision == s.SetRevision).Name, }).ToList(), + Drawings = release.Drawings.Select(d => new SetReleaseDrawing + { + DrawingId = d.DrawingId, + Quantity = d.Quantity, + DrawingName = DrawingNames.TryGetValue(d.DrawingId, out var name) ? name : "", + }).ToList(), Notes = release.Notes, ReleasedBy = release.ReleasedBy, ReleasedAt = new DateTime(2026, 10, 20, 15, 0, 0, DateTimeKind.Utc), @@ -149,12 +161,15 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore public Task GetReleaseAsync(Guid id, CancellationToken cancellationToken = default) => Task.FromResult(Releases.FirstOrDefault(r => r.Id == id)); - public Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, int offset = 0, int limit = 100, - CancellationToken cancellationToken = default) + public Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, Guid? drawingId = null, + int offset = 0, int limit = 100, CancellationToken cancellationToken = default) { + if (drawingId is not null && WithoutDrawingReleases) + throw new ServerTooOldException("releasing library drawings on their own"); var newest = Enumerable.Reverse(Releases .Where(r => setId is null || r.Sets.Any(s => s.SetId == setId)) .Where(r => nestId is null || r.Nests.Any(n => n.NestId == nestId)) + .Where(r => drawingId is null || r.Drawings.Any(d => d.DrawingId == drawingId)) .ToList()).ToList(); return Task.FromResult(new SetReleasePage { @@ -165,6 +180,11 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore }); } + public Task EnsureDrawingReleasesSupportedAsync(CancellationToken cancellationToken = default) => + WithoutDrawingReleases + ? Task.FromException(new ServerTooOldException("releasing library drawings on their own")) + : Task.CompletedTask; + /// Every undo request, in order. public List<(Guid Id, SetReleaseUndoRequest Request)> UndoRequests { get; } = new(); diff --git a/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs b/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs index e9d6bece..b37da5fb 100644 --- a/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs +++ b/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs @@ -50,6 +50,27 @@ public class RemoteDrawingSetStoreTests Assert.Contains("drawing sets", query.Message); } + [Fact] + public async Task ServerWithoutDrawingsOnTheirOwn_RefusesTheDrawingFilter_AndTheSupportCheck() + { + // A server from before drawings on their own refuses the unknown query parameter. + var handler = new StubHandler(request => request.RequestUri!.Query.Contains("drawingId=") + ? new HttpResponseMessage(HttpStatusCode.BadRequest) + : StubHandler.Json(new { items = Array.Empty(), total = 0, offset = 0, limit = 100 })); + using var store = Store(handler); + + var check = await Assert.ThrowsAsync(() => store.EnsureDrawingReleasesSupportedAsync()); + await Assert.ThrowsAsync(() => store.QueryReleasesAsync(drawingId: Guid.NewGuid())); + await store.QueryReleasesAsync(setId: Guid.NewGuid()); + + Assert.Contains("releasing library drawings on their own", check.Message); + Assert.Contains("api/releases/query?drawingId=", handler.Requests[0].RequestUri!.AbsoluteUri); + using var missing = Store(new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound))); + await Assert.ThrowsAsync(() => missing.EnsureDrawingReleasesSupportedAsync()); + using var current = Store(new StubHandler(_ => StubHandler.Json(new { items = Array.Empty(), total = 0, offset = 0, limit = 1 }))); + await current.EnsureDrawingReleasesSupportedAsync(); + } + [Fact] public async Task UnknownSet_OnASetAwareServer_IsNull() { diff --git a/OpenNest.Tests/Data/SetReleasePlanTests.cs b/OpenNest.Tests/Data/SetReleasePlanTests.cs index 4b98e974..99fb1101 100644 --- a/OpenNest.Tests/Data/SetReleasePlanTests.cs +++ b/OpenNest.Tests/Data/SetReleasePlanTests.cs @@ -112,6 +112,61 @@ public class SetReleasePlanTests Assert.Throws(() => SetReleasePlan.Build(Array.Empty(), false)); } + [Fact] + public void Build_OfDrawingsOnTheirOwn_GroupsThemWithSetMembers_SummingAShareWithNoSet() + { + var shared = Drawing("SYN-SHARED"); + var loose = Drawing("SYN-LOOSE"); + var aluminum = Drawing("SYN-AL", material: "Synthetic Aluminum"); + var set = Set((shared, 2)); + + var plan = SetReleasePlan.Build(new[] { new SetReleasePlanLine(set, 3) }, + new[] { new SetReleaseDrawingLine(loose, 7), new SetReleaseDrawingLine(shared, 4), new SetReleaseDrawingLine(aluminum, 100_000) }, + false); + + var steel = plan.Groups.Single(g => g.MaterialName == "Synthetic Steel"); + Assert.Equal(new[] { "SYN-SHARED", "SYN-LOOSE" }, steel.Items.Select(i => i.Drawing.Name)); + Assert.Equal(new[] { new SetReleaseItemSource(set.Id, 6), new SetReleaseItemSource(null, 4) }, steel.Items[0].Sources); + Assert.Equal((7, loose.Id), (steel.Items[1].Quantity, steel.Items[1].Member.DrawingId)); + Assert.Equal(100_000, plan.Groups.Single(g => g.MaterialName == "Synthetic Aluminum").TotalQuantity); + Assert.Equal(new[] { loose.Id, shared.Id, aluminum.Id }, plan.Drawings.Select(d => d.Drawing.Id)); + Assert.Equal("", plan.Customer); + } + + [Fact] + public void Build_OfDrawingsAlone_TakesTheirCustomer_AndTheSameRefusalsAsSets() + { + var archived = Drawing("SYN-OLD", archived: true); + var unassigned = Drawing("SYN-NOMAT", material: ""); + var plan = SetReleasePlan.Build(Array.Empty(), new[] + { + new SetReleaseDrawingLine(Drawing("SYN-A"), 1), + new SetReleaseDrawingLine(Drawing("SYN-A"), 2), + new SetReleaseDrawingLine(archived, 3), + new SetReleaseDrawingLine(unassigned, 4), + }, includeArchived: false); + + Assert.Equal("Synthetic customer", plan.Customer); + Assert.Contains("2 drawings in this group are named 'SYN-A'", plan.Groups[0].Refusal); + Assert.True(plan.Groups[^1].IsUnassigned); + Assert.Equal(new[] { (archived.Id, 3) }, plan.SkippedArchived.Select(m => (m.DrawingId, m.Quantity))); + } + + [Fact] + public void Build_RefusesADrawingTwice_OutOfRangeQuantities_AndADrawingNotReadFromTheLibrary() + { + var drawing = Drawing("SYN-A"); + var unsaved = Drawing("SYN-B"); + unsaved.Revision = 0; + SetReleasePlan Build(params SetReleaseDrawingLine[] lines) => SetReleasePlan.Build(Array.Empty(), lines, false); + + Assert.Throws(() => Build(new SetReleaseDrawingLine(drawing, 1), new SetReleaseDrawingLine(drawing, 2))); + Assert.Throws(() => Build(new SetReleaseDrawingLine(drawing, 0))); + Assert.Throws(() => Build(new SetReleaseDrawingLine(drawing, SetReleaseRules.MaxDrawingQuantity + 1))); + Assert.Throws(() => Build(new SetReleaseDrawingLine(unsaved, 1))); + Assert.Throws(() => Build()); + } + [Theory] [InlineData(0)] [InlineData(SetReleaseRules.MaxQuantity + 1)] diff --git a/OpenNest.Tests/Data/SetReleaseSessionTests.cs b/OpenNest.Tests/Data/SetReleaseSessionTests.cs index 9570adfe..293ac027 100644 --- a/OpenNest.Tests/Data/SetReleaseSessionTests.cs +++ b/OpenNest.Tests/Data/SetReleaseSessionTests.cs @@ -381,7 +381,7 @@ public class SetReleaseSessionTests Assert.Equal(new[] { (first.Id, 2), (second.Id, 1) }, release.Sets.Select(l => (l.SetId, l.Quantity))); var nestLine = Assert.Single(release.Nests); Assert.Equal((SetReleaseNestKind.Merged, (int?)3, 4), (nestLine.Kind, nestLine.RevisionBefore, nestLine.RevisionAfter)); - Assert.Equal(new[] { (first.Id, a.Id, 2), (second.Id, a.Id, 2), (first.Id, b.Id, 2) }, + Assert.Equal(new[] { ((Guid?)first.Id, a.Id, 2), (second.Id, a.Id, 2), (first.Id, b.Id, 2) }, nestLine.Items.Select(i => (i.SetId, i.DrawingId, i.Quantity))); } @@ -498,6 +498,65 @@ public class SetReleaseSessionTests Assert.Equal((NestStatus.Draft, "Synthetic Steel", "Grade A", 0.25), (query.Status, query.Material, query.MaterialGrade, query.Thickness)); } + [Fact] + public async Task Run_OfDrawingsOnTheirOwn_ChecksTheServer_BuildsTheirNests_AndRecordsThemWithoutASet() + { + var a = Stored("SYN-A"); + var b = Stored("SYN-B"); + var c = Stored("SYN-C", material: "Synthetic Aluminum"); + var set = Set((a, 2)); + var plan = SetReleasePlan.Build(new[] { new SetReleasePlanLine(set, 3) }, + new[] { new SetReleaseDrawingLine(a, 5), new SetReleaseDrawingLine(b, 1), new SetReleaseDrawingLine(c, 40) }, false); + + var result = await Session().RunAsync(plan, plan.Groups, "Synthetic operator", ""); + + Assert.Equal("", result.ReleaseError); + Assert.Equal(1, _sets.DrawingReleaseChecks); + var steel = result.Groups.Single(g => g.Group.MaterialName == "Synthetic Steel").Nest!; + Assert.Equal(new[] { ("SYN-A", 11), ("SYN-B", 1) }, steel.Drawings.OrderBy(d => d.Name).Select(d => (d.Name, d.Quantity.Required))); + var release = Assert.Single(_sets.Posted); + Assert.Equal((set.Id, 3), (release.Sets.Single().SetId, release.Sets.Single().Quantity)); + Assert.Equal(new[] { (a.Id, 5), (b.Id, 1), (c.Id, 40) }, release.Drawings.Select(d => (d.DrawingId, d.Quantity))); + Assert.Equal(new[] { ((Guid?)set.Id, a.Id, 6), (null, a.Id, 5), (null, b.Id, 1) }, + release.Nests.Single(n => n.MaterialName == "Synthetic Steel").Items.Select(i => (i.SetId, i.DrawingId, i.Quantity))); + Assert.Equal(new[] { ((Guid?)null, c.Id, 40) }, + release.Nests.Single(n => n.MaterialName == "Synthetic Aluminum").Items.Select(i => (i.SetId, i.DrawingId, i.Quantity))); + Assert.Null(SetReleaseRules.GetError(release)); + } + + [Fact] + public async Task Run_OfSetsOnly_DoesNotAskAboutDrawingsOnTheirOwn() + { + var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false); + + await Session().RunAsync(plan, plan.Groups, "", ""); + + Assert.Equal(0, _sets.DrawingReleaseChecks); + Assert.Empty(_sets.Posted.Single().Drawings); + } + + [Fact] + public async Task Run_OnAServerWithoutDrawingsOnTheirOwn_RefusesBeforeAnythingIsBuilt() + { + _sets.DrawingReleaseFailure = new ServerTooOldException("releasing library drawings on their own"); + var plan = SetReleasePlan.Build(Array.Empty(), new[] { new SetReleaseDrawingLine(Stored("SYN-A"), 2) }, false); + + var result = await Session().RunAsync(plan, plan.Groups, "", ""); + + var group = Assert.Single(result.Groups); + Assert.Equal(SetReleaseGroupOutcome.Refused, group.Outcome); + Assert.Contains("does not support releasing library drawings on their own", group.Error); + Assert.Empty(_created); + Assert.Empty(_nests.Uploaded); + Assert.Empty(_sets.Posted); + + _sets.DrawingReleaseFailure = new IOException("Synthetic outage"); + var failed = await Session().RunAsync(plan, plan.Groups, "", ""); + Assert.Equal(SetReleaseGroupOutcome.Failed, failed.Groups.Single().Outcome); + Assert.Contains("Synthetic outage", failed.Groups.Single().Error); + Assert.Empty(_nests.Uploaded); + } + private sealed class RecordingProgress(Action? onReport = null) : IProgress { public List Reports { get; } = new(); @@ -638,6 +697,7 @@ public class SetReleaseSessionTests Quantity = s.Quantity, SetName = "Synthetic kit", }).ToList(), + Drawings = release.Drawings, Nests = release.Nests, }; if (ConflictWithPosted) @@ -656,7 +716,18 @@ public class SetReleaseSessionTests public Task ArchiveAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetReleaseAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, Guid? drawingId = null, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + /// Thrown by the support check; null plays a server that records drawings on their own. + public Exception? DrawingReleaseFailure { get; set; } + + public int DrawingReleaseChecks { get; private set; } + + public Task EnsureDrawingReleasesSupportedAsync(CancellationToken cancellationToken = default) + { + DrawingReleaseChecks++; + return DrawingReleaseFailure is { } failure ? Task.FromException(failure) : Task.CompletedTask; + } public Task UndoReleaseAsync(Guid id, SetReleaseUndoRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } } diff --git a/OpenNest.Tests/Data/SetReleaseTextTests.cs b/OpenNest.Tests/Data/SetReleaseTextTests.cs index 25c4f5ba..87dd7833 100644 --- a/OpenNest.Tests/Data/SetReleaseTextTests.cs +++ b/OpenNest.Tests/Data/SetReleaseTextTests.cs @@ -53,6 +53,21 @@ public class SetReleaseTextTests Assert.Equal("Released from set 'Synthetic kit' revision 3 ×2 by Synthetic operator", text); } + [Fact] + public void Origin_NamesDrawingsOnTheirOwn_BesideSetsOrAlone() + { + SetReleaseDrawing Loose(string name, int quantity) => new() { DrawingName = name, Quantity = quantity }; + var set = new SetReleaseLine { SetName = "Synthetic kit", SetRevision = 3, Quantity = 2 }; + + Assert.Equal("Released from drawing 'SYN-A' ×4", + SetReleaseText.Origin(new SetReleaseRecord { Drawings = new[] { Loose("SYN-A", 4) } })); + Assert.Equal("Released from set 'Synthetic kit' revision 3 ×2 and drawings 'SYN-A' ×4 and 'SYN-B' ×1", + SetReleaseText.Origin(new SetReleaseRecord { Sets = new[] { set }, Drawings = new[] { Loose("SYN-A", 4), Loose("SYN-B", 1) } })); + Assert.Equal("drawings 'A' ×1, 'B' ×2 and 'C' ×3", + SetReleaseText.Drawings(new[] { Loose("A", 1), Loose("B", 2), Loose("C", 3) })); + Assert.Equal("4 drawings", SetReleaseText.Drawings(Enumerable.Range(1, 4).Select(i => Loose("D" + i, i)).ToList())); + } + [Theory] [InlineData(SetReleaseNestKind.Created, null, "Created")] [InlineData(SetReleaseNestKind.Created, SetReleaseNestUndo.Deleted, "Created; undo deleted it")]