diff --git a/OpenNest.Data/IDrawingSetStore.cs b/OpenNest.Data/IDrawingSetStore.cs index a1bbe844..a9fc2df0 100644 --- a/OpenNest.Data/IDrawingSetStore.cs +++ b/OpenNest.Data/IDrawingSetStore.cs @@ -39,13 +39,15 @@ public interface IDrawingSetStore Task RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default); /// Records a release whose nests already exist, under the caller-allocated release.Id. - Task PostReleaseAsync(Guid setId, SetReleaseRecord release, - CancellationToken cancellationToken = default); + Task PostReleaseAsync(SetReleaseRecord release, CancellationToken cancellationToken = default); - /// One page of the set's releases with their nests, newest first, or null for an unknown set. - Task GetReleasesAsync(Guid setId, int offset = 0, + /// 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); - /// The release a nest came from, or null when it came from none (or is unknown). - Task GetReleaseForNestAsync(Guid nestId, 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 f80f8eee..ccf3c986 100644 --- a/OpenNest.Data/RemoteDrawingSetStore.cs +++ b/OpenNest.Data/RemoteDrawingSetStore.cs @@ -9,7 +9,8 @@ using System.Text.Json.Serialization; namespace OpenNest.Data; /// -/// Talks to the drawing sets on the nest server (/api/sets). Sets and releases +/// Talks to the drawing sets and their releases on the nest server (/api/sets, +/// /api/releases). Sets and releases /// travel as JSON and are checked against and /// before sending. A server without sets raises /// ; other failures surface as . @@ -18,6 +19,8 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable { private const string Feature = "drawing sets"; + private const string ReleaseFeature = "releasing several drawing sets at once"; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -153,72 +156,105 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable /// /// Records a release under release.Id, allocated by the caller so a post whose - /// reply was lost can be sent again. A release already holding that id, or one of the - /// nests, raises with that release. + /// reply was lost can be sent again. A release already holding that id, or one of its + /// created nests, raises with that release. /// - public async Task PostReleaseAsync(Guid setId, SetReleaseRecord release, - CancellationToken cancellationToken = default) + public async Task PostReleaseAsync(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); + using var response = await _httpClient.PostAsync(Url("api/releases"), + JsonContent.Create(ForPost(release), options: JsonOptions), cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) - { - await ThrowIfServerTooOld(cancellationToken); - throw new IOException($"Drawing set {setId} no longer exists on the nest server."); - } + throw new ServerTooOldException(ReleaseFeature); if (response.StatusCode == HttpStatusCode.Conflict) throw new SetReleasePostConflictException(await ReadRelease(response, cancellationToken)); - await EnsureSuccess(response, $"record the release of drawing set {setId}", cancellationToken); + await EnsureSuccess(response, "record the release", 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))) + 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.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."); return stored; } - public async Task GetReleasesAsync(Guid setId, int offset = 0, - int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) + public async Task GetReleaseAsync(Guid id, CancellationToken cancellationToken = default) { - ValidateId(setId); - ValidatePaging(offset, limit); - using var response = await _httpClient.GetAsync(Url($"api/sets/{setId}/releases?{Paging(offset, limit)}"), cancellationToken); + if (id == Guid.Empty) throw new ArgumentException("A release id is required.", nameof(id)); + using var response = await _httpClient.GetAsync(Url($"api/releases/{id}"), cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) { - await ThrowIfServerTooOld(cancellationToken); + await ThrowIfReleasesUnsupported(cancellationToken); return null; } - await EnsureSuccess(response, $"read the releases of drawing set {setId}", cancellationToken); + await EnsureSuccess(response, $"read release {id}", cancellationToken); + var release = await ReadRelease(response, cancellationToken); + if (release.Id != id) + throw new IOException("Nest server returned a different release."); + return release; + } + + public async Task QueryReleasesAsync(Guid? setId = null, Guid? nestId = 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)); + ValidatePaging(offset, limit); + var query = Paging(offset, limit); + if (setId is not null) query += $"&setId={setId}"; + if (nestId is not null) query += $"&nestId={nestId}"; + using var response = await _httpClient.GetAsync(Url($"api/releases/query?{query}"), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + throw new ServerTooOldException(ReleaseFeature); + 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.SetId != setId || item.Nests is null)) + || page.Items.Any(item => item is null || item.Sets 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)))) throw new IOException("Nest server returned an invalid or unbounded release page."); return page; } - public async Task GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default) + /// + /// Undoes a release on the server, all or nothing. A refusal raises + /// : the release was undone before, or nests + /// not skipped have changed since it (listed, so the caller can skip them and try again). + /// + public async Task UndoReleaseAsync(Guid id, SetReleaseUndoRequest request, + 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 (id == Guid.Empty) throw new ArgumentException("A release id is required.", nameof(id)); + var error = SetReleaseRules.GetUndoError(request); + if (error is not null) + throw new ArgumentException(error, nameof(request)); + + using var response = await _httpClient.PostAsync(Url($"api/releases/{id}/undo"), + JsonContent.Create(request, options: JsonOptions), cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) { - await ThrowIfServerTooOld(cancellationToken); - return null; + await ThrowIfReleasesUnsupported(cancellationToken); + throw new IOException($"Release {id} no longer exists on the nest server."); } - 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; + if (response.StatusCode == HttpStatusCode.Conflict) + { + var conflict = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + if (conflict?.Release is null || conflict.ChangedNests is null) + throw new IOException("Nest server refused the undo without saying why."); + throw new SetReleaseUndoConflictException(conflict); + } + await EnsureSuccess(response, $"undo release {id}", cancellationToken); + var undone = await ReadRelease(response, cancellationToken); + if (undone.Id != id || !undone.IsUndone) + throw new IOException("Nest server did not report the release as undone."); + return undone; } private async Task SetArchivedAsync(Guid id, int expectedRevision, string action, @@ -290,6 +326,14 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable } // A 404 for one set or nest is ambiguous; the query route exists on every set-aware server. + // A server from before v9 has no /api/releases at all, so a 404 there is not "unknown release". + private async Task ThrowIfReleasesUnsupported(CancellationToken cancellationToken) + { + using var probe = await _httpClient.GetAsync(Url("api/releases/query?limit=1"), cancellationToken); + if (probe.StatusCode == HttpStatusCode.NotFound) + throw new ServerTooOldException(ReleaseFeature); + } + private async Task ThrowIfServerTooOld(CancellationToken cancellationToken) { using var probe = await _httpClient.GetAsync(Url("api/sets/query?limit=1"), cancellationToken); @@ -321,22 +365,30 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable Members = record.Members.Select(m => new DrawingSetMember { DrawingId = m.DrawingId, Quantity = m.Quantity }).ToList(), }; - private static SetReleaseRecord ForPost(SetReleaseRecord release, Guid setId) => new() + // Joined and server-computed values are ignored by the server; leaving them out keeps the body small. + private static SetReleaseRecord ForPost(SetReleaseRecord release) => new() { Id = release.Id, - SetId = setId, - SetRevision = release.SetRevision, - Multiplier = release.Multiplier, Notes = release.Notes, ReleasedBy = release.ReleasedBy, + Sets = release.Sets.Select(s => new SetReleaseLine + { + SetId = s.SetId, + SetRevision = s.SetRevision, + Quantity = s.Quantity, + }).ToList(), Nests = release.Nests.Select(n => new SetReleaseNest { NestId = n.NestId, + Kind = n.Kind, + RevisionBefore = n.RevisionBefore, + RevisionAfter = n.RevisionAfter, MaterialName = n.MaterialName, MaterialGrade = n.MaterialGrade, Thickness = n.Thickness, Units = n.Units, CutType = n.CutType, + Items = n.Items, }).ToList(), }; @@ -390,7 +442,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.Nests is null) + if (release is null || release.Sets is null || release.Nests is null) throw new IOException("Nest server returned an empty set release record."); return release; } @@ -439,13 +491,14 @@ public sealed class DrawingSetSaveConflictException : IOException } /// -/// A release already holds the posted release id or one of its nests; nothing was recorded. -/// When has the posted id, an earlier post whose reply was lost was stored. +/// A release already holds the posted release id or one of its created nests; nothing was +/// recorded. When has the posted id, an earlier post whose reply was +/// lost was stored. /// public sealed class SetReleasePostConflictException : IOException { public SetReleasePostConflictException(SetReleaseRecord current) - : base($"Release {current.Id} of set '{current.SetName}' already records this release or one of its nests.") + : base($"Release {current.Id} already records this release or one of its nests.") { Current = current; } @@ -453,3 +506,17 @@ public sealed class SetReleasePostConflictException : IOException /// The release that holds the id or nest. public SetReleaseRecord Current { get; } } + +/// The server refused to undo a release; nothing was changed. +public sealed class SetReleaseUndoConflictException : IOException +{ + public SetReleaseUndoConflictException(SetReleaseUndoConflict conflict) + : base(conflict.Reason == SetReleaseUndoRefusal.AlreadyUndone + ? "The release was already undone." + : $"{conflict.ChangedNests.Count} of the release's nests changed since it was made.") + { + Conflict = conflict; + } + + public SetReleaseUndoConflict Conflict { get; } +} diff --git a/OpenNest.Data/SetReleaseNest.cs b/OpenNest.Data/SetReleaseNest.cs index 47b74756..c3f2da12 100644 --- a/OpenNest.Data/SetReleaseNest.cs +++ b/OpenNest.Data/SetReleaseNest.cs @@ -2,11 +2,42 @@ using OpenNest; namespace OpenNest.Data; -/// One nest of a release and the material group it was made for. +/// How a release reached one of its nests. +public enum SetReleaseNestKind +{ + /// The release uploaded the nest as new. + Created, + + /// The release added its parts to an existing draft nest and renested it. + Merged, +} + +/// What undoing the release did to one of its nests. +public enum SetReleaseNestUndo +{ + /// A created nest was deleted (it can still be recovered). + Deleted, + + /// A merged nest was restored to . + Restored, + + /// The operator left the nest as it was, because it had changed since the release. + Skipped, +} + +/// One nest of a release, the material group it was made for, and what the release put in it. public sealed class SetReleaseNest { public Guid NestId { get; set; } + public SetReleaseNestKind Kind { get; set; } = SetReleaseNestKind.Created; + + /// The draft's revision the release merged into; null for a created nest. + public int? RevisionBefore { get; set; } + + /// The nest revision the release wrote. Undo acts only while the nest is still at it. + public int RevisionAfter { get; set; } + public string MaterialName { get; set; } = ""; public string MaterialGrade { get; set; } = ""; @@ -18,9 +49,34 @@ 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 + /// recorded before items were kept (schema v8). + /// + public IReadOnlyList Items { get; set; } = Array.Empty(); + + /// What undoing the release did to the nest; null while the release stands. Server-computed. + public SetReleaseNestUndo? Undo { get; set; } + + /// The nest revision the undo wrote; null when it wrote none. Server-computed. + public int? UndoRevision { get; set; } + /// /// The nest's current metadata, deleted or not, joined by the server when it reads a /// release, so status and deletion are always up to date. Ignored on post. /// public NestRecord? Nest { get; set; } } + +/// A drawing quantity one set of a release added to one nest. +public sealed class SetReleaseNestItem +{ + public Guid SetId { get; set; } + + public Guid DrawingId { get; set; } + + /// The library revision of the drawing that went into the nest. + public int DrawingRevision { get; set; } + + public int Quantity { get; set; } +} diff --git a/OpenNest.Data/SetReleasePage.cs b/OpenNest.Data/SetReleasePage.cs index f5cb4d15..20812cbc 100644 --- a/OpenNest.Data/SetReleasePage.cs +++ b/OpenNest.Data/SetReleasePage.cs @@ -1,11 +1,11 @@ namespace OpenNest.Data; -/// One bounded page of set releases, newest first, each with its nests. +/// One bounded page of set releases, newest first, each with its sets and nests. public sealed class SetReleasePage { public IReadOnlyList Items { get; init; } = Array.Empty(); - /// Number of releases of the set across all pages. + /// Number of matching releases across all pages. public int Total { get; init; } public int Offset { get; init; } diff --git a/OpenNest.Data/SetReleasePlan.cs b/OpenNest.Data/SetReleasePlan.cs index b1d082c4..d1e96c60 100644 --- a/OpenNest.Data/SetReleasePlan.cs +++ b/OpenNest.Data/SetReleasePlan.cs @@ -122,9 +122,9 @@ public sealed class SetReleasePlan public static SetReleasePlan Build(DrawingSetRecord set, int multiplier, bool includeArchived) { ArgumentNullException.ThrowIfNull(set); - if (multiplier < 1 || multiplier > SetReleaseRules.MaxMultiplier) + if (multiplier < 1 || multiplier > SetReleaseRules.MaxQuantity) throw new ArgumentOutOfRangeException(nameof(multiplier), multiplier, - $"The multiplier must be between 1 and {SetReleaseRules.MaxMultiplier}."); + $"The multiplier must be between 1 and {SetReleaseRules.MaxQuantity}."); 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)); diff --git a/OpenNest.Data/SetReleaseRecord.cs b/OpenNest.Data/SetReleaseRecord.cs index 166a8ca2..9acda321 100644 --- a/OpenNest.Data/SetReleaseRecord.cs +++ b/OpenNest.Data/SetReleaseRecord.cs @@ -1,34 +1,50 @@ namespace OpenNest.Data; /// -/// One release of a drawing set revision: the nests made from it, one per material group, -/// with every member quantity multiplied by . Releases are -/// append-only; a mistaken one is undone by deleting its nests, which then read as deleted. +/// 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. +/// Releases are append-only; undoing one restores or deletes its nests and marks it +/// undone, it never removes the record. /// public sealed class SetReleaseRecord { /// Allocated by the client, so an ambiguous post can be retried without a duplicate. public Guid Id { get; set; } - /// The set released. Taken from the route on post. - public Guid SetId { get; set; } - - /// The exact set revision released; it need not be the current one. - public int SetRevision { get; set; } - - /// The set's name at . Server-joined; ignored on post. - public string SetName { get; set; } = ""; - - public int Multiplier { get; set; } = 1; + /// The sets released and how many times each, in the order posted. + public IReadOnlyList Sets { get; set; } = Array.Empty(); public string Notes { get; set; } = ""; /// When the server stored the release. Server-computed. public DateTime ReleasedAt { get; set; } - /// Who released the set, as reported by the client. + /// Who released the sets, as reported by the client. public string ReleasedBy { get; set; } = ""; - /// The nests created by the release, in the order posted. + /// When the release was undone; null while it stands. Server-computed. + public DateTime? UndoneAt { get; set; } + + /// Who undid the release, as reported by the client. Server-computed. + public string UndoneBy { get; set; } = ""; + + /// The nests the release created or merged into, in the order posted. public IReadOnlyList Nests { get; set; } = Array.Empty(); + + public bool IsUndone => UndoneAt.HasValue; +} + +/// One set of a release: the exact revision released and how many kits of it. +public sealed class SetReleaseLine +{ + public Guid SetId { get; set; } + + /// The exact set revision released; it need not be the current one. + public int SetRevision { get; set; } + + /// How many kits: every member quantity is multiplied by this. + public int Quantity { get; set; } = 1; + + /// The set's name at . Server-joined; ignored on post. + public string SetName { get; set; } = ""; } diff --git a/OpenNest.Data/SetReleaseRules.cs b/OpenNest.Data/SetReleaseRules.cs index 8fa15c00..227576c2 100644 --- a/OpenNest.Data/SetReleaseRules.cs +++ b/OpenNest.Data/SetReleaseRules.cs @@ -3,27 +3,32 @@ using OpenNest; namespace OpenNest.Data; /// -/// Bounds shared by the set release client and server. Whether the set revision and -/// every nest exist, and whether a nest already belongs to a release, is checked by the server. +/// 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 +/// checked by the server. /// public static class SetReleaseRules { - public const int MaxMultiplier = 1000; + /// Most kits of one set in one release. + public const int MaxQuantity = 1000; - /// One nest per material group, so never more nests than a set has members. + /// Most sets in one release. + public const int MaxSetLines = 50; + + /// 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; + /// The first rule breaks, or null when it may be posted. public static string? GetError(SetReleaseRecord? release) { if (release is null) return "Missing release."; - if (release.SetRevision < 1) - return "setRevision must be a positive revision."; - if (release.Multiplier < 1 || release.Multiplier > MaxMultiplier) - return $"multiplier must be between 1 and {MaxMultiplier}."; var error = DrawingLibraryRules.TextError("releasedBy", release.ReleasedBy, DrawingLibraryRules.MaxTextLength) - ?? DrawingLibraryRules.TextError("notes", release.Notes, DrawingLibraryRules.MaxNotesLength); + ?? DrawingLibraryRules.TextError("notes", release.Notes, DrawingLibraryRules.MaxNotesLength) + ?? SetsError(release.Sets); if (error is not null) return error; if (release.Nests is null || release.Nests.Count == 0) @@ -31,6 +36,7 @@ public static class SetReleaseRules if (release.Nests.Count > MaxNests) return $"A release may have at most {MaxNests} nests."; + var sets = release.Sets.Select(s => s.SetId).ToHashSet(); var seen = new HashSet(); for (var i = 0; i < release.Nests.Count; i++) { @@ -41,17 +47,101 @@ public static class SetReleaseRules return $"nests[{i}].nestId is required."; if (!seen.Add(nest.NestId)) return $"nests[{i}] repeats nest {nest.NestId}."; - error = DrawingLibraryRules.TextError($"nests[{i}].materialName", nest.MaterialName, DrawingLibraryRules.MaxTextLength) + error = RevisionsError(i, nest) + ?? DrawingLibraryRules.TextError($"nests[{i}].materialName", nest.MaterialName, DrawingLibraryRules.MaxTextLength) ?? DrawingLibraryRules.TextError($"nests[{i}].materialGrade", nest.MaterialGrade, DrawingLibraryRules.MaxTextLength) ?? (!double.IsFinite(nest.Thickness) || nest.Thickness < 0 ? $"nests[{i}].thickness must be a finite number, zero or greater." : 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); + ?? (!Enum.IsDefined(nest.CutType) ? $"nests[{i}].cutType must be Laser, Waterjet or Plasma." : null) + ?? ItemsError(i, nest.Items, sets); if (error is not null) return error; } return null; } + + /// The first rule breaks, or null when it may be sent. + public static string? GetUndoError(SetReleaseUndoRequest? request) + { + if (request is null) + return "Missing undo request."; + if (request.SkipNests is null) + return "skipNests must be a list."; + if (request.SkipNests.Count > MaxNests) + return $"skipNests may list at most {MaxNests} nests."; + if (request.SkipNests.Contains(Guid.Empty)) + return "skipNests may not contain an empty id."; + return DrawingLibraryRules.TextError("undoneBy", request.UndoneBy, DrawingLibraryRules.MaxTextLength); + } + + private static string? SetsError(IReadOnlyList? lines) + { + if (lines is null || lines.Count == 0) + return "sets must list at least one set."; + if (lines.Count > MaxSetLines) + return $"A release may have at most {MaxSetLines} sets."; + + var seen = new HashSet(); + for (var i = 0; i < lines.Count; i++) + { + var line = lines[i]; + if (line is null) + return $"sets[{i}] is required."; + if (line.SetId == Guid.Empty) + return $"sets[{i}].setId is required."; + if (!seen.Add(line.SetId)) + return $"sets[{i}] repeats set {line.SetId}."; + if (line.SetRevision < 1) + return $"sets[{i}].setRevision must be a positive revision."; + if (line.Quantity < 1 || line.Quantity > MaxQuantity) + return $"sets[{i}].quantity must be between 1 and {MaxQuantity}."; + } + + return null; + } + + private static string? RevisionsError(int i, SetReleaseNest nest) + { + if (!Enum.IsDefined(nest.Kind)) + return $"nests[{i}].kind must be created or merged."; + if (nest.RevisionAfter < 1) + return $"nests[{i}].revisionAfter must be a positive revision."; + if (nest.Kind == SetReleaseNestKind.Created) + return nest.RevisionBefore is null ? null : $"nests[{i}].revisionBefore must be empty for a created nest."; + if (nest.RevisionBefore is not { } before || before < 1) + return $"nests[{i}].revisionBefore is required for a merged nest."; + return nest.RevisionAfter > before ? null : $"nests[{i}].revisionAfter must be later than revisionBefore."; + } + + private static string? ItemsError(int i, IReadOnlyList? items, HashSet sets) + { + 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)>(); + 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)) + return $"{name}.setId must be one of the release's sets."; + if (item.DrawingId == Guid.Empty) + return $"{name}.drawingId is required."; + if (!seen.Add((item.SetId, item.DrawingId))) + return $"{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) + return $"{name}.quantity must be at least 1."; + } + + return null; + } } diff --git a/OpenNest.Data/SetReleaseSession.cs b/OpenNest.Data/SetReleaseSession.cs index 3687da8c..56b24529 100644 --- a/OpenNest.Data/SetReleaseSession.cs +++ b/OpenNest.Data/SetReleaseSession.cs @@ -236,14 +236,24 @@ public sealed class SetReleaseSession _pendingRelease = new SetReleaseRecord { Id = Guid.NewGuid(), - SetId = plan.Set.Id, - SetRevision = plan.Set.Revision, - Multiplier = plan.Multiplier, + Sets = new[] + { + new SetReleaseLine { SetId = plan.Set.Id, SetRevision = plan.Set.Revision, Quantity = plan.Multiplier }, + }, Notes = notes ?? "", ReleasedBy = releasedBy ?? "", Nests = results.Select(r => new SetReleaseNest { NestId = r.Record!.Id, + Kind = SetReleaseNestKind.Created, + RevisionAfter = r.Record.Revision, + Items = r.Group.Items.Select(i => new SetReleaseNestItem + { + SetId = plan.Set.Id, + DrawingId = i.Drawing.Id, + DrawingRevision = i.Drawing.Revision, + Quantity = i.Quantity, + }).ToList(), MaterialName = r.Group.MaterialName, MaterialGrade = r.Group.MaterialGrade, Thickness = r.Group.Thickness, @@ -361,7 +371,7 @@ public sealed class SetReleaseSession SetReleaseRecord stored; try { - stored = await _sets.PostReleaseAsync(release.SetId, release, cancellationToken); + stored = await _sets.PostReleaseAsync(release, cancellationToken); } catch (SetReleasePostConflictException conflict) when (conflict.Current.Id == release.Id) { diff --git a/OpenNest.Data/SetReleaseText.cs b/OpenNest.Data/SetReleaseText.cs index 89e80693..463a73ff 100644 --- a/OpenNest.Data/SetReleaseText.cs +++ b/OpenNest.Data/SetReleaseText.cs @@ -6,23 +6,43 @@ namespace OpenNest.Data; /// Operator-facing wording for set releases, shared by the release views and Nest Info. public static class SetReleaseText { - /// Where a nest came from, such as "Released from set 'Kit A' revision 3 ×2 by J. Smith". - public static string Origin(SetReleaseRecord release) + /// + /// 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. + /// + public static string Origin(SetReleaseRecord release, Guid nestId = default) { ArgumentNullException.ThrowIfNull(release); - var text = $"Released from set '{release.SetName}' revision {release.SetRevision} ×{release.Multiplier}"; + 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)}"; if (release.ReleasedAt != default) text += $" on {release.ReleasedAt.ToLocalTime().ToString("d", CultureInfo.CurrentCulture)}"; - return string.IsNullOrWhiteSpace(release.ReleasedBy) ? text : $"{text} by {release.ReleasedBy}"; + if (!string.IsNullOrWhiteSpace(release.ReleasedBy)) + text += $" by {release.ReleasedBy}"; + return release.IsUndone ? $"{text} (undone)" : text; + } + + /// The sets of a release, such as "'Kit A' revision 3 ×2 and 'Kit B' revision 1 ×1". + public static string Sets(IReadOnlyList sets) + { + ArgumentNullException.ThrowIfNull(sets); + var names = sets.Select(s => $"'{s.SetName}' revision {s.SetRevision} ×{s.Quantity}").ToList(); + return names.Count <= 1 + ? string.Concat(names) + : string.Join(", ", names.Take(names.Count - 1)) + " and " + names[^1]; } /// - /// How far the release's nests have got, such as "2 of 3 cut" or "1 of 2 cut, 1 deleted". - /// Deleted nests are left out of the count. + /// 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. /// public static string Progress(SetReleaseRecord release) { ArgumentNullException.ThrowIfNull(release); + if (release.IsUndone) + return "Undone"; var nests = release.Nests ?? Array.Empty(); var deleted = nests.Count(IsDeleted); var active = nests.Count - deleted; diff --git a/OpenNest.Data/SetReleaseUndo.cs b/OpenNest.Data/SetReleaseUndo.cs new file mode 100644 index 00000000..cd4f6558 --- /dev/null +++ b/OpenNest.Data/SetReleaseUndo.cs @@ -0,0 +1,34 @@ +namespace OpenNest.Data; + +/// An undo request: who is undoing the release, and which changed nests to leave as they are. +public sealed class SetReleaseUndoRequest +{ + public string UndoneBy { get; set; } = ""; + + /// + /// Nests of the release to leave alone. Every other nest must still be at the revision + /// the release wrote, or nothing is undone. + /// + public IReadOnlyList SkipNests { get; set; } = Array.Empty(); +} + +public enum SetReleaseUndoRefusal +{ + /// The release was undone before. + AlreadyUndone, + + /// Nests not skipped have changed since the release; lists them. + NestsChanged, +} + +/// Why an undo was refused. Nothing was changed. +public sealed class SetReleaseUndoConflict +{ + public SetReleaseUndoRefusal Reason { get; set; } + + /// The release as it is now. + public SetReleaseRecord Release { get; set; } = new(); + + /// The current metadata of each nest that moved on since the release, deleted or not. + public IReadOnlyList ChangedNests { get; set; } = Array.Empty(); +} diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index 86da82b6..79361875 100644 --- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -4,6 +4,7 @@ using System.Text; using Microsoft.Data.Sqlite; using OpenNest.Data; using static OpenNest.Server.Tests.DrawingLibraryFixtures; +using static OpenNest.Server.Tests.DrawingSetFixtures; namespace OpenNest.Server.Tests; @@ -21,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(8L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -42,7 +43,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) { - Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(9L, 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. @@ -79,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=9"); + Execute(factory.DatabasePath, "PRAGMA user_version=10"); Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); } @@ -235,8 +236,8 @@ public sealed class DrawingLibraryDatabaseTests database.SetDeleted(a.Id, false, 3); } - Execute(path, """ - DROP TABLE set_release_nests; DROP TABLE set_releases; + Execute(path, $""" + {DropReleaseTables} DROP TABLE drawing_set_members; DROP TABLE drawing_sets; DROP TABLE drawing_set_revisions; DROP TABLE drawing_revisions; DROP TABLE drawings; DROP TABLE drawing_packages; PRAGMA user_version=3; """); diff --git a/OpenNest.Server.Tests/DrawingSetClientTests.cs b/OpenNest.Server.Tests/DrawingSetClientTests.cs index 9dd590fb..15aed715 100644 --- a/OpenNest.Server.Tests/DrawingSetClientTests.cs +++ b/OpenNest.Server.Tests/DrawingSetClientTests.cs @@ -74,14 +74,14 @@ public sealed class DrawingSetClientTests 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())); + Assert.Empty((await store.QueryReleasesAsync(setId: Guid.NewGuid())).Items); + Assert.Null(await store.GetReleaseAsync(Guid.NewGuid())); var unknown = Set(); await Assert.ThrowsAsync(() => store.UpdateAsync(unknown.Id, unknown, 1)); } [Fact] - public async Task Releases_PostReadAndRefuseANestTwice() + public async Task Releases_PostReadUndoAndRefuseACreatedNestTwice() { using var factory = new ServerFactory(); using var client = factory.CreateClient(); @@ -89,21 +89,39 @@ public sealed class DrawingSetClientTests 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 release = Release(set.Id, 1, steel, aluminum); - var stored = await store.PostReleaseAsync(set.Id, release); - var repost = await Assert.ThrowsAsync(() => store.PostReleaseAsync(set.Id, release)); - var reuse = await Assert.ThrowsAsync(() => store.PostReleaseAsync(set.Id, Release(1, steel))); + var stored = await store.PostReleaseAsync(release); + var repost = await Assert.ThrowsAsync(() => store.PostReleaseAsync(release)); + var reuse = await Assert.ThrowsAsync(() => store.PostReleaseAsync(Release(set.Id, 1, steel))); - Assert.Equal((release.Id, set.Id, "Synthetic kit", 2), (stored.Id, stored.SetId, stored.SetName, stored.Multiplier)); + Assert.Equal((release.Id, set.Id, "Synthetic kit", 2), (stored.Id, stored.Sets[0].SetId, stored.Sets[0].SetName, stored.Sets[0].Quantity)); 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); + var page = await store.QueryReleasesAsync(setId: set.Id); + var listed = Assert.Single(page.Items); Assert.Equal(new[] { "Synthetic steel nest", "Synthetic aluminum nest" }, listed.Nests.Select(n => n.Nest!.Name)); - Assert.Equal(release.Id, (await store.GetReleaseForNestAsync(aluminum))!.Id); - await Assert.ThrowsAsync(() => store.PostReleaseAsync(Guid.NewGuid(), Release(1, Guid.NewGuid()))); + Assert.Equal(release.Id, (await store.QueryReleasesAsync(nestId: aluminum)).Items.Single().Id); + Assert.Equal(release.Id, (await store.GetReleaseAsync(release.Id))!.Id); + await Assert.ThrowsAsync(() => store.PostReleaseAsync(Release(Guid.NewGuid(), 1, Guid.NewGuid()))); + + // Someone saves the steel nest after the release: undo refuses until it is skipped. + using var nests = new RemoteNestRepository(client, client.BaseAddress!.ToString()); + var record = (await nests.GetMetadataAsync(steel))!; + record.Comments = "Edited after the release"; + await nests.UpdateMetadataAsync(steel, record, 1); + var refused = await Assert.ThrowsAsync(() => + store.UndoReleaseAsync(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic undoer" })); + Assert.Equal((SetReleaseUndoRefusal.NestsChanged, steel), (refused.Conflict.Reason, refused.Conflict.ChangedNests.Single().Id)); + + var undone = await store.UndoReleaseAsync(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic undoer", SkipNests = new[] { steel } }); + + Assert.Equal(new SetReleaseNestUndo?[] { SetReleaseNestUndo.Skipped, SetReleaseNestUndo.Deleted }, undone.Nests.Select(n => n.Undo)); + Assert.Null(await nests.GetMetadataAsync(aluminum)); + var again = await Assert.ThrowsAsync(() => store.UndoReleaseAsync(release.Id, new SetReleaseUndoRequest())); + Assert.Equal(SetReleaseUndoRefusal.AlreadyUndone, again.Conflict.Reason); + await Assert.ThrowsAsync(() => store.UndoReleaseAsync(Guid.NewGuid(), new SetReleaseUndoRequest())); } } diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs index 79a4f3e9..03e2628b 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(8L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(9L, 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(8L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } [Fact] @@ -314,8 +314,8 @@ public sealed class DrawingSetDatabaseTests database.UpdateDrawing(drawing, edit, null, 1); } - Execute(path, """ - DROP TABLE set_release_nests; DROP TABLE set_releases; + Execute(path, $""" + {DropReleaseTables} DROP TABLE drawing_set_members; DROP TABLE drawing_sets; DROP TABLE drawing_set_revisions; PRAGMA user_version=6; """); return drawing; diff --git a/OpenNest.Server.Tests/DrawingSetFixtures.cs b/OpenNest.Server.Tests/DrawingSetFixtures.cs index e8155c58..93521b54 100644 --- a/OpenNest.Server.Tests/DrawingSetFixtures.cs +++ b/OpenNest.Server.Tests/DrawingSetFixtures.cs @@ -34,6 +34,36 @@ 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. + public const string DropReleaseTables = """ + 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. + public const string ReleaseTablesBackToV8 = DropReleaseTables + """ + CREATE TABLE set_releases ( + id TEXT NOT NULL PRIMARY KEY, + setId TEXT NOT NULL, + setRevision INTEGER NOT NULL, + multiplier INTEGER NOT NULL, + notes TEXT NOT NULL DEFAULT '', + releasedAt TEXT NOT NULL, + releasedBy TEXT NOT NULL DEFAULT '', + FOREIGN KEY (setId, setRevision) REFERENCES drawing_set_revisions(id, revision)); + CREATE TABLE set_release_nests ( + releaseId TEXT NOT NULL REFERENCES set_releases(id), + position INTEGER NOT NULL, + nestId TEXT NOT NULL REFERENCES nests(id), + materialName TEXT NOT NULL DEFAULT '', + materialGrade TEXT NOT NULL DEFAULT '', + thickness REAL NOT NULL, + units TEXT NOT NULL, + cutType TEXT NOT NULL, + PRIMARY KEY (releaseId, position)); + CREATE INDEX ix_set_releases_set ON set_releases(setId, releasedAt DESC); + CREATE UNIQUE INDEX ix_set_release_nests_nest ON set_release_nests(nestId); + """; + public static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json"); public static StringContent Json(DrawingSetRecord record) => Json(JsonSerializer.Serialize(record, Options)); @@ -68,17 +98,22 @@ internal static class DrawingSetFixtures return record.Id; } - /// A release of making one nest per id, each in its own material. - public static SetReleaseRecord Release(int setRevision, params Guid[] nests) => new() + /// + /// Two kits of of , creating one nest + /// per id (each at revision 1, as stores it), each + /// in its own material. + /// + public static SetReleaseRecord Release(Guid setId, int setRevision, params Guid[] nests) => new() { Id = Guid.NewGuid(), - SetRevision = setRevision, - Multiplier = 2, + Sets = new[] { new SetReleaseLine { SetId = setId, SetRevision = setRevision, Quantity = 2 } }, Notes = "Synthetic release", ReleasedBy = "Synthetic operator", Nests = nests.Select((nest, i) => new SetReleaseNest { NestId = nest, + Kind = SetReleaseNestKind.Created, + RevisionAfter = 1, MaterialName = "Synthetic Steel " + i, MaterialGrade = "Grade A", Thickness = 0.25, @@ -86,4 +121,20 @@ internal static class DrawingSetFixtures CutType = CutType.Laser, }).ToList(), }; + + /// Turns nest of into a merge from one revision to another. + public static SetReleaseRecord Merged(this SetReleaseRecord release, int index, int before, int after) + { + release.Nests[index].Kind = SetReleaseNestKind.Merged; + release.Nests[index].RevisionBefore = before; + release.Nests[index].RevisionAfter = after; + return release; + } + + /// Saves the nest again with new bytes, as an operator or a merge would, and returns the new revision. + public static int Resave(NestDatabase database, Guid nest, byte last = 9) + { + var current = database.Get(nest)!; + return database.Update(nest, current, new byte[] { 1, 2, last }, current.Revision)!.Revision; + } } diff --git a/OpenNest.Server.Tests/NestMaterialGradeTests.cs b/OpenNest.Server.Tests/NestMaterialGradeTests.cs index f8aa6ce6..58c98653 100644 --- a/OpenNest.Server.Tests/NestMaterialGradeTests.cs +++ b/OpenNest.Server.Tests/NestMaterialGradeTests.cs @@ -61,7 +61,8 @@ public sealed class NestMaterialGradeTests var id = Guid.NewGuid(); using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) database.Insert(id, Record("Legacy", "Stainless", "304"), [1]); - Execute(factory.DatabasePath, """ + Execute(factory.DatabasePath, $""" + {DrawingSetFixtures.ReleaseTablesBackToV8} ALTER TABLE nests DROP COLUMN materialGrade; ALTER TABLE nest_revisions DROP COLUMN materialGrade; PRAGMA user_version=7; @@ -73,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(8L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static NestRecord Record(string name, string material, string grade) => new() diff --git a/OpenNest.Server.Tests/SetReleaseEndToEndTests.cs b/OpenNest.Server.Tests/SetReleaseEndToEndTests.cs index 7c9ee8f6..e6ef9409 100644 --- a/OpenNest.Server.Tests/SetReleaseEndToEndTests.cs +++ b/OpenNest.Server.Tests/SetReleaseEndToEndTests.cs @@ -106,13 +106,22 @@ public sealed class SetReleaseEndToEndTests 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); + var releases = await sets.QueryReleasesAsync(setId: created.Id); + var release = Assert.Single(releases.Items); + var line = Assert.Single(release.Sets); Assert.Equal((result.Release!.Id, 1, 2, "Synthetic release notes"), - (release.Id, release.SetRevision, release.Multiplier, release.Notes)); + (release.Id, line.SetRevision, line.Quantity, 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.All(release.Nests, n => Assert.Equal((NestStatus.Draft, SetReleaseNestKind.Created, 1), (n.Nest!.Status, n.Kind, n.RevisionAfter))); Assert.Equal(CutType.Waterjet, release.Nests.Single(n => n.MaterialName == "Synthetic Aluminum").CutType); - Assert.Equal(release.Id, (await sets.GetReleaseForNestAsync(steel.Record!.Id))!.Id); + Assert.Equal(new[] { (a.Id, 6), (b.Id, 2) }, + release.Nests.Single(n => n.NestId == steel.Record!.Id).Items.Select(i => (i.DrawingId, i.Quantity))); + Assert.Equal(release.Id, (await sets.QueryReleasesAsync(nestId: steel.Record!.Id)).Items.Single().Id); + + // Undo deletes every nest the release created. + 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)); } } diff --git a/OpenNest.Server.Tests/SetReleaseTests.cs b/OpenNest.Server.Tests/SetReleaseTests.cs index f283f59f..9237f364 100644 --- a/OpenNest.Server.Tests/SetReleaseTests.cs +++ b/OpenNest.Server.Tests/SetReleaseTests.cs @@ -13,32 +13,47 @@ namespace OpenNest.Server.Tests; public sealed class SetReleaseTests { [Fact] - public void Release_OfAStaleRevision_IsStoredAndJoinsItsSetNameAndNests() + public void Release_OfSeveralSetsAtStaleRevisions_IsStoredWithNamesNestsAndItems() { using var factory = new ServerFactory(); using var database = new NestDatabase(factory.DatabasePath, pooling: false); var drawing = AddDrawing(database); - var set = Set("Kit as released", members: (drawing, 1)); - database.InsertDrawingSet(set.Id, set); - database.UpdateDrawingSet(set.Id, Set("Kit renamed later", set.Id, (drawing, 3)), 1); - var first = AddNest(database, "Nest one"); - var second = AddNest(database, "Nest two"); - var release = Release(1, first, second); + var kit = Set("Kit as released", members: (drawing, 1)); + var other = Set("Other kit", members: (drawing, 2)); + database.InsertDrawingSet(kit.Id, kit); + database.InsertDrawingSet(other.Id, other); + database.UpdateDrawingSet(kit.Id, Set("Kit renamed later", kit.Id, (drawing, 3)), 1); + var created = AddNest(database, "Created nest"); + var draft = AddNest(database, "Draft nest"); + var merged = Resave(database, draft); + var release = Release(kit.Id, 1, created, draft).Merged(1, before: 1, after: merged); + release.Sets = release.Sets.Append(new SetReleaseLine { SetId = other.Id, SetRevision = 1, Quantity = 3 }).ToList(); release.Nests[1].Thickness = 0.123456; release.Nests[1].Units = Units.Millimeters; release.Nests[1].CutType = CutType.Waterjet; + release.Nests[1].Items = new[] + { + new SetReleaseNestItem { SetId = kit.Id, DrawingId = drawing, DrawingRevision = 1, Quantity = 2 }, + new SetReleaseNestItem { SetId = other.Id, DrawingId = drawing, DrawingRevision = 1, Quantity = 6 }, + }; - var stored = database.InsertSetRelease(set.Id, release)!; + var stored = database.InsertRelease(release); - Assert.Equal((release.Id, set.Id, 1, "Kit as released", 2), (stored.Id, stored.SetId, stored.SetRevision, stored.SetName, stored.Multiplier)); - Assert.Equal(("Synthetic release", "Synthetic operator"), (stored.Notes, stored.ReleasedBy)); + Assert.Equal(release.Id, stored.Id); + Assert.Equal(new[] { (kit.Id, 1, 2, "Kit as released"), (other.Id, 1, 3, "Other kit") }, + stored.Sets.Select(s => (s.SetId, s.SetRevision, s.Quantity, s.SetName))); + Assert.Equal(("Synthetic release", "Synthetic operator", false), (stored.Notes, stored.ReleasedBy, stored.IsUndone)); Assert.NotEqual(default, stored.ReleasedAt); - Assert.Equal(new[] { first, second }, stored.Nests.Select(n => n.NestId)); + Assert.Equal(new[] { (created, SetReleaseNestKind.Created, (int?)null, 1), (draft, SetReleaseNestKind.Merged, 1, 2) }, + 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(("Nest one", NestStatus.Draft, false), (stored.Nests[0].Nest!.Name, stored.Nests[0].Nest!.Status, stored.Nests[0].Nest!.Deleted)); - Assert.Equal(release.Id, database.ReleaseForNest(second)!.Id); - Assert.Null(database.ReleaseForNest(AddNest(database))); + Assert.Equal(new[] { (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); + Assert.Equal(release.Id, database.GetRelease(release.Id)!.Id); + Assert.Null(database.GetRelease(Guid.NewGuid())); } [Fact] @@ -50,21 +65,21 @@ public sealed class SetReleaseTests database.InsertDrawingSet(set.Id, set); var cut = AddNest(database, "Cut nest"); var removed = AddNest(database, "Removed nest"); - database.InsertSetRelease(set.Id, Release(1, cut, removed)); + database.InsertRelease(Release(set.Id, 1, cut, removed)); var record = database.Get(cut)!; record.Status = NestStatus.HasBeenCut; database.UpdateMetadata(cut, record, 1); database.SetDeleted(removed, true, 1); - var nests = database.SetReleases(set.Id, 0, 100)!.Items.Single().Nests; + var nests = database.QueryReleases(set.Id, null, 0, 100).Items.Single().Nests; Assert.Equal((NestStatus.HasBeenCut, false), (nests[0].Nest!.Status, nests[0].Nest!.Deleted)); Assert.True(nests[1].Nest!.Deleted); - Assert.True(database.ReleaseForNest(removed)!.Nests[1].Nest!.Deleted); + Assert.True(database.QueryReleases(null, removed, 0, 100).Items.Single().Nests[1].Nest!.Deleted); } [Fact] - public void Releases_PageNewestFirstPerSetAndAcceptAnArchivedSet() + public void Query_FiltersBySetAndNest_NewestFirst_AndAcceptsAnArchivedSet() { using var factory = new ServerFactory(); using var database = new NestDatabase(factory.DatabasePath, pooling: false); @@ -75,23 +90,29 @@ public sealed class SetReleaseTests var ids = new List(); for (var i = 0; i < 3; i++) { - var release = Release(1, AddNest(database)); + var release = Release(set.Id, 1, AddNest(database)); ids.Add(release.Id); - database.InsertSetRelease(set.Id, release); + database.InsertRelease(release); Thread.Sleep(5); } - database.InsertSetRelease(other.Id, Release(1, AddNest(database))); + var shared = AddNest(database); + var onOther = Release(other.Id, 1, shared); + database.InsertRelease(onOther); + Thread.Sleep(5); database.SetDrawingSetArchived(set.Id, true, 1); - var afterArchive = Release(1, AddNest(database)); - Assert.NotNull(database.InsertSetRelease(set.Id, afterArchive)); - ids.Add(afterArchive.Id); + var merge = Release(set.Id, 1, shared).Merged(0, before: 1, after: Resave(database, shared)); + database.InsertRelease(merge); + ids.Add(merge.Id); - var page = database.SetReleases(set.Id, 1, 2)!; + var page = database.QueryReleases(set.Id, null, 1, 2); Assert.Equal(4, page.Total); Assert.Equal(new[] { ids[2], ids[1] }, page.Items.Select(r => r.Id)); - Assert.Single(database.SetReleases(other.Id, 0, 100)!.Items); - Assert.Null(database.SetReleases(Guid.NewGuid(), 0, 100)); - Assert.Throws(() => database.SetReleases(set.Id, 0, 201)); + Assert.Equal(new[] { onOther.Id }, database.QueryReleases(other.Id, null, 0, 100).Items.Select(r => r.Id)); + Assert.Equal(new[] { merge.Id, onOther.Id }, database.QueryReleases(null, shared, 0, 100).Items.Select(r => r.Id)); + Assert.Equal(new[] { merge.Id }, database.QueryReleases(set.Id, shared, 0, 100).Items.Select(r => r.Id)); + Assert.Equal(5, database.QueryReleases(null, null, 0, 100).Total); + Assert.Empty(database.QueryReleases(Guid.NewGuid(), null, 0, 100).Items); + Assert.Throws(() => database.QueryReleases(set.Id, null, 0, 201)); } [Fact] @@ -105,38 +126,194 @@ public sealed class SetReleaseTests var taken = AddNest(database); var deleted = AddNest(database); database.SetDeleted(deleted, true, 1); - var owner = Release(1, taken); - database.InsertSetRelease(set.Id, owner); + var owner = Release(set.Id, 1, taken); + database.InsertRelease(owner); - Assert.Null(database.InsertSetRelease(Guid.NewGuid(), Release(1, free))); - Assert.Contains("no revision 2", Assert.Throws(() => database.InsertSetRelease(set.Id, Release(2, free))).Message); - Assert.Contains("does not exist", Assert.Throws(() => database.InsertSetRelease(set.Id, Release(1, free, Guid.NewGuid()))).Message); - Assert.Contains("is deleted", Assert.Throws(() => database.InsertSetRelease(set.Id, Release(1, free, deleted))).Message); - Assert.Throws(() => database.InsertSetRelease(set.Id, Release(1, free, free))); - Assert.Throws(() => database.InsertSetRelease(set.Id, Release(1))); - var noId = Release(1, free); + Assert.Contains("does not exist", Assert.Throws(() => database.InsertRelease(Release(Guid.NewGuid(), 1, free))).Message); + Assert.Contains("no revision 2", Assert.Throws(() => database.InsertRelease(Release(set.Id, 2, free))).Message); + Assert.Contains("does not exist", Assert.Throws(() => database.InsertRelease(Release(set.Id, 1, free, Guid.NewGuid()))).Message); + Assert.Contains("is deleted", Assert.Throws(() => database.InsertRelease(Release(set.Id, 1, free, deleted))).Message); + Assert.Contains("no revision 3", Assert.Throws(() => database.InsertRelease(Release(set.Id, 1, free).Merged(0, 1, 3))).Message); + var after = Release(set.Id, 1, free); + after.Nests[0].RevisionAfter = 2; + Assert.Contains("no revision 2", Assert.Throws(() => database.InsertRelease(after)).Message); + Assert.Throws(() => database.InsertRelease(Release(set.Id, 1, free, free))); + Assert.Throws(() => database.InsertRelease(Release(set.Id, 1))); + var twice = Release(set.Id, 1, free); + twice.Sets = twice.Sets.Concat(twice.Sets).ToList(); + Assert.Throws(() => database.InsertRelease(twice)); + var noId = Release(set.Id, 1, free); noId.Id = Guid.Empty; - Assert.Throws(() => database.InsertSetRelease(set.Id, noId)); - var tooMany = Release(1, free); - tooMany.Multiplier = SetReleaseRules.MaxMultiplier + 1; - Assert.Throws(() => database.InsertSetRelease(set.Id, tooMany)); + Assert.Throws(() => database.InsertRelease(noId)); + var tooMany = Release(set.Id, 1, free); + tooMany.Sets[0].Quantity = SetReleaseRules.MaxQuantity + 1; + Assert.Throws(() => database.InsertRelease(tooMany)); + var strangerItem = Release(set.Id, 1, free); + strangerItem.Nests[0].Items = new[] { new SetReleaseNestItem { SetId = Guid.NewGuid(), DrawingId = Guid.NewGuid(), DrawingRevision = 1, Quantity = 1 } }; + Assert.Throws(() => database.InsertRelease(strangerItem)); - var claimed = Assert.Throws(() => database.InsertSetRelease(set.Id, Release(1, free, taken))); + var claimed = Assert.Throws(() => database.InsertRelease(Release(set.Id, 1, free, taken))); Assert.Equal(owner.Id, claimed.Current.Id); - var reused = Release(1, free); + var reused = Release(set.Id, 1, free); reused.Id = owner.Id; - Assert.Equal(owner.Id, Assert.Throws(() => database.InsertSetRelease(set.Id, reused)).Current.Id); + Assert.Equal(owner.Id, Assert.Throws(() => database.InsertRelease(reused)).Current.Id); - Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM set_releases")); - Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM set_release_nests")); - Assert.Null(database.ReleaseForNest(free)); + Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM releases")); + Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM release_sets")); + Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM release_nests")); + Assert.Empty(database.QueryReleases(null, free, 0, 100).Items); + } + + [Fact] + public void MergedNest_MayBeInSeveralReleases_ButACreatedNestOnlyInItsOwn() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var set = Set(); + database.InsertDrawingSet(set.Id, set); + var nest = AddNest(database); + database.InsertRelease(Release(set.Id, 1, nest)); + + database.InsertRelease(Release(set.Id, 1, nest).Merged(0, 1, Resave(database, nest))); + database.InsertRelease(Release(set.Id, 1, nest).Merged(0, 2, Resave(database, nest, 8))); + + Assert.Equal(3, database.QueryReleases(null, nest, 0, 100).Total); + } + + [Fact] + public void Undo_DeletesCreatedNests_AndRestoresMergedOnes_InOneStep() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var set = Set(); + database.InsertDrawingSet(set.Id, set); + var created = AddNest(database, "Created nest"); + var draft = AddNest(database, "Draft nest"); + var release = Release(set.Id, 1, created, draft).Merged(1, 1, Resave(database, draft)); + database.InsertRelease(release); + + var undone = database.UndoRelease(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic undoer" })!; + + Assert.True(undone.IsUndone); + Assert.Equal("Synthetic undoer", undone.UndoneBy); + Assert.Equal(new[] { (SetReleaseNestUndo.Deleted, (int?)2), (SetReleaseNestUndo.Restored, 3) }, + undone.Nests.Select(n => (n.Undo!.Value, n.UndoRevision))); + Assert.Null(database.Get(created)); + Assert.Equal(2, database.Get(created, deleted: true)!.Revision); + var restored = database.Get(draft)!; + Assert.Equal((3, (int?)1), (restored.Revision, restored.RestoredFromRevision)); + Assert.Equal(new byte[] { 1, 2, 3 }, database.GetFile(draft)); + Assert.Equal("Undone", SetReleaseText.Progress(database.GetRelease(release.Id)!)); + Assert.Null(database.UndoRelease(Guid.NewGuid(), new SetReleaseUndoRequest())); + } + + [Fact] + public void Undo_WhenANestChanged_ChangesNothing_UntilThatNestIsSkipped() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var set = Set(); + database.InsertDrawingSet(set.Id, set); + var edited = AddNest(database, "Edited later"); + var untouched = AddNest(database, "Untouched"); + var removed = AddNest(database, "Deleted later"); + var release = Release(set.Id, 1, edited, untouched, removed); + database.InsertRelease(release); + Resave(database, edited); + database.SetDeleted(removed, true, 1); + + var refused = Assert.Throws(() => + database.UndoRelease(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic undoer" })).Conflict; + + Assert.Equal(SetReleaseUndoRefusal.NestsChanged, refused.Reason); + Assert.Equal(new[] { (edited, 2, false), (removed, 2, true) }, refused.ChangedNests.Select(n => (n.Id, n.Revision, n.Deleted))); + Assert.False(database.GetRelease(release.Id)!.IsUndone); + Assert.NotNull(database.Get(untouched)); + + var skipped = database.UndoRelease(release.Id, new SetReleaseUndoRequest { SkipNests = new[] { edited, removed } })!; + + Assert.Equal(new SetReleaseNestUndo?[] { SetReleaseNestUndo.Skipped, SetReleaseNestUndo.Deleted, SetReleaseNestUndo.Skipped }, + skipped.Nests.Select(n => n.Undo)); + Assert.Null(skipped.Nests[0].UndoRevision); + Assert.Equal(2, database.Get(edited)!.Revision); + Assert.Null(database.Get(untouched)); + var again = Assert.Throws(() => database.UndoRelease(release.Id, new SetReleaseUndoRequest())); + Assert.Equal(SetReleaseUndoRefusal.AlreadyUndone, again.Conflict.Reason); + Assert.Throws(() => database.UndoRelease(Release(set.Id, 1).Id, new SetReleaseUndoRequest { SkipNests = new[] { Guid.Empty } })); + } + + [Fact] + public void Undo_OfReleasesSharingANest_GoesNewestFirst() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var set = Set(); + database.InsertDrawingSet(set.Id, set); + var nest = AddNest(database); + var first = Release(set.Id, 1, nest); + database.InsertRelease(first); + var second = Release(set.Id, 1, nest).Merged(0, 1, Resave(database, nest)); + database.InsertRelease(second); + var stranger = Release(set.Id, 1, AddNest(database)); + database.InsertRelease(stranger); + Assert.Contains("is not part", Assert.Throws(() => + database.UndoRelease(first.Id, new SetReleaseUndoRequest { SkipNests = stranger.Nests.Select(n => n.NestId).ToList() })).Message); + + Assert.Throws(() => database.UndoRelease(first.Id, new SetReleaseUndoRequest())); + database.UndoRelease(second.Id, new SetReleaseUndoRequest()); + // The later undo restored revision 1 as revision 3, which the first release still accepts. + var undone = database.UndoRelease(first.Id, new SetReleaseUndoRequest())!; + + Assert.Equal(SetReleaseNestUndo.Deleted, undone.Nests.Single().Undo); + Assert.Equal(4, database.Get(nest, deleted: true)!.Revision); + } + + [Fact] + public void Version8Releases_MigrateToOneSetLine_AndCreatedNests() + { + using var factory = new ServerFactory(); + var set = Set(); + Guid first, second; + using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) + { + database.InsertDrawingSet(set.Id, set); + first = AddNest(database, "First"); + second = AddNest(database, "Second"); + Resave(database, second); + } + var releaseId = Guid.NewGuid(); + Execute(factory.DatabasePath, $""" + {ReleaseTablesBackToV8} + INSERT INTO set_releases (id, setId, setRevision, multiplier, notes, releasedAt, releasedBy) + VALUES ('{releaseId}', '{set.Id}', 1, 4, 'Legacy notes', '2026-10-20T09:00:00.0000000-04:00', 'Legacy operator'); + INSERT INTO set_release_nests (releaseId, position, nestId, materialName, materialGrade, thickness, units, cutType) + VALUES ('{releaseId}', 0, '{first}', 'Synthetic Steel', 'A36', 0.25, 'Inches', 'Laser'), + ('{releaseId}', 1, '{second}', 'Synthetic Aluminum', '', 0.125, 'Millimeters', 'Waterjet'); + PRAGMA user_version=8; + """); + + using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); + + Assert.Equal(9L, 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)); + Assert.Equal((set.Id, 1, 4, "Synthetic kit"), (release.Sets.Single().SetId, release.Sets.Single().SetRevision, release.Sets.Single().Quantity, release.Sets.Single().SetName)); + Assert.Equal(new[] { (first, SetReleaseNestKind.Created, (int?)null, 1), (second, SetReleaseNestKind.Created, null, 1) }, + release.Nests.Select(n => (n.NestId, n.Kind, n.RevisionBefore, n.RevisionAfter))); + Assert.Equal((Units.Millimeters, CutType.Waterjet, 0.125), (release.Nests[1].Units, release.Nests[1].CutType, release.Nests[1].Thickness)); + Assert.All(release.Nests, n => Assert.Empty(n.Items)); + // The second nest was saved after its release, so undoing must leave it unless skipped. + var refused = Assert.Throws(() => upgraded.UndoRelease(releaseId, new SetReleaseUndoRequest())); + Assert.Equal(second, refused.Conflict.ChangedNests.Single().Id); } // Code-level ownership gate, as for the set operations. [Theory] - [InlineData("InsertSetRelease")] - [InlineData("SetReleases")] - [InlineData("ReleaseForNest")] + [InlineData("InsertRelease")] + [InlineData("GetRelease")] + [InlineData("QueryReleases")] + [InlineData("UndoRelease")] public async Task Concurrency_EveryReleaseOperationWaitsForTheSameMonitor(string operation) { using var factory = new ServerFactory(); @@ -145,16 +322,18 @@ public sealed class SetReleaseTests database.InsertDrawingSet(set.Id, set); var nest = AddNest(database); var next = AddNest(database); - database.InsertSetRelease(set.Id, Release(1, nest)); + var release = Release(set.Id, 1, nest); + database.InsertRelease(release); var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!; var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var worker = new Thread(() => completed.SetResult(Xunit.Record.Exception(() => { switch (operation) { - case "InsertSetRelease": database.InsertSetRelease(set.Id, Release(1, next)); break; - case "SetReleases": database.SetReleases(set.Id, 0, 100); break; - case "ReleaseForNest": database.ReleaseForNest(nest); break; + case "InsertRelease": database.InsertRelease(Release(set.Id, 1, next)); break; + case "GetRelease": database.GetRelease(release.Id); break; + case "QueryReleases": database.QueryReleases(set.Id, nest, 0, 100); break; + case "UndoRelease": database.UndoRelease(release.Id, new SetReleaseUndoRequest()); break; default: throw new InvalidOperationException($"Unknown test operation: {operation}"); } }))) @@ -184,6 +363,7 @@ public sealed class SetReleaseTests var set = Set(); database.InsertDrawingSet(set.Id, set); var nest = AddNest(database); + var release = Release(set.Id, 1, nest); var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!; var connection = (SqliteConnection)typeof(NestDatabase) .GetField("_connection", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!; @@ -200,9 +380,10 @@ public sealed class SetReleaseTests { Action[] operations = [ - () => database.InsertSetRelease(set.Id, Release(1, nest)), - () => database.SetReleases(set.Id, 0, 100), - () => database.ReleaseForNest(nest), + () => database.InsertRelease(release), + () => database.GetRelease(release.Id), + () => database.QueryReleases(set.Id, nest, 0, 100), + () => database.UndoRelease(release.Id, new SetReleaseUndoRequest()), ]; foreach (var operation in operations) { @@ -219,72 +400,105 @@ public sealed class SetReleaseTests } [Fact] - public async Task Api_PostsAndReadsReleasesWithCamelCaseEnums() + public async Task Api_PostsQueriesAndReadsReleasesWithCamelCaseEnums() { using var factory = new ServerFactory(); using var client = factory.CreateClient(); var set = await Create(client, Set()); var first = await AddNest(client, "Nest one"); var second = await AddNest(client, "Nest two"); - var release = Release(1, first, second); + var release = Release(set.Id, 1, first, second); release.Nests[1].Units = Units.Millimeters; release.Nests[1].CutType = CutType.Plasma; - using var posted = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(JsonSerializer.Serialize(release, Options))); + 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.Equal(set.Id.ToString(), json["setId"]!.GetValue()); - Assert.Equal("Synthetic kit", json["setName"]!.GetValue()); + Assert.Equal(set.Id.ToString(), json["sets"]![0]!["setId"]!.GetValue()); + Assert.Equal("Synthetic kit", json["sets"]![0]!["setName"]!.GetValue()); Assert.Equal("millimeters", json["nests"]![1]!["units"]!.GetValue()); Assert.Equal("plasma", json["nests"]![1]!["cutType"]!.GetValue()); + Assert.Equal("created", json["nests"]![1]!["kind"]!.GetValue()); Assert.Equal("draft", json["nests"]![0]!["nest"]!["status"]!.GetValue()); - var page = (await client.GetFromJsonAsync($"/api/sets/{set.Id}/releases", Options))!; + var page = (await client.GetFromJsonAsync($"/api/releases/query?setId={set.Id}", Options))!; Assert.Equal(release.Id, page.Items.Single().Id); Assert.Equal("Nest two", page.Items.Single().Nests[1].Nest!.Name); - var fromNest = (await client.GetFromJsonAsync($"/api/nests/{second}/release", Options))!; - Assert.Equal((release.Id, 2), (fromNest.Id, fromNest.Multiplier)); + var byNest = (await client.GetFromJsonAsync($"/api/releases/query?nestId={second}&limit=5", Options))!; + Assert.Equal((release.Id, 2), (byNest.Items.Single().Id, byNest.Items.Single().Sets.Single().Quantity)); + Assert.Equal(release.Id, (await client.GetFromJsonAsync($"/api/releases/{release.Id}", Options))!.Id); - Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/nests/{await AddNest(client)}/release")).StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/sets/{Guid.NewGuid()}/releases")).StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/sets/{set.Id}/releases?limit=0")).StatusCode); - using var unknownSet = await client.PostAsync($"/api/sets/{Guid.NewGuid()}/releases", - Json(JsonSerializer.Serialize(Release(1, await AddNest(client)), Options))); - Assert.Equal(HttpStatusCode.NotFound, unknownSet.StatusCode); + Assert.Equal(0, (await client.GetFromJsonAsync($"/api/releases/query?nestId={await AddNest(client)}", Options))!.Total); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/releases/{Guid.NewGuid()}")).StatusCode); + foreach (var query in new[] { "limit=0", "limit=201", "offset=-1", "setId=x", $"setId={Guid.Empty}", "nestId=1&nestId=2", "other=1" }) + Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/releases/query?{query}")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/sets/{set.Id}/releases")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/nests/{first}/release")).StatusCode); } [Fact] - public async Task Api_Conflicts_ReturnTheReleaseHoldingTheIdOrNest() + public async Task Api_Conflicts_ReturnTheReleaseHoldingTheIdOrCreatedNest() { using var factory = new ServerFactory(); using var client = factory.CreateClient(); var set = await Create(client, Set()); var taken = await AddNest(client); - var owner = Release(1, taken); - using (await client.PostAsync($"/api/sets/{set.Id}/releases", Json(JsonSerializer.Serialize(owner, Options)))) { } + var owner = Release(set.Id, 1, taken); + using (await client.PostAsync("/api/releases", Json(JsonSerializer.Serialize(owner, Options)))) { } - var reused = Release(1, await AddNest(client)); + var reused = Release(set.Id, 1, await AddNest(client)); reused.Id = owner.Id; - foreach (var body in new[] { Release(1, await AddNest(client), taken), reused }) + foreach (var body in new[] { Release(set.Id, 1, await AddNest(client), taken), reused }) { - using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(JsonSerializer.Serialize(body, Options))); + using var response = await client.PostAsync("/api/releases", Json(JsonSerializer.Serialize(body, Options))); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); Assert.Equal(owner.Id, (await response.Content.ReadFromJsonAsync(Options))!.Id); } - Assert.Equal(1, (await client.GetFromJsonAsync($"/api/sets/{set.Id}/releases", Options))!.Total); + Assert.Equal(1, (await client.GetFromJsonAsync($"/api/releases/query?setId={set.Id}", Options))!.Total); + } + + [Fact] + public async Task Api_Undo_ReturnsTheUndoneRelease_OrWhyNot() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var set = await Create(client, Set()); + var nest = await AddNest(client); + var release = Release(set.Id, 1, nest); + using (await client.PostAsync("/api/releases", Json(JsonSerializer.Serialize(release, Options)))) { } + + using var undone = await client.PostAsync($"/api/releases/{release.Id}/undo", Json("""{ "undoneBy": "Synthetic undoer" }""")); + Assert.Equal(HttpStatusCode.OK, undone.StatusCode); + var json = JsonNode.Parse(await undone.Content.ReadAsStringAsync())!; + Assert.Equal("Synthetic undoer", json["undoneBy"]!.GetValue()); + Assert.Equal("deleted", json["nests"]![0]!["undo"]!.GetValue()); + + using var again = await client.PostAsync($"/api/releases/{release.Id}/undo", Json("{}")); + Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); + Assert.Equal("alreadyUndone", JsonNode.Parse(await again.Content.ReadAsStringAsync())!["reason"]!.GetValue()); + Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsync($"/api/releases/{Guid.NewGuid()}/undo", Json("{}"))).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync($"/api/releases/{release.Id}/undo", Json("[1]"))).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync($"/api/releases/{release.Id}/undo", + Json($$"""{ "skipNests": ["{{Guid.Empty}}"] }"""))).StatusCode); } public static TheoryData RejectedReleases() { - var valid = JsonSerializer.Serialize(Release(1, NestA), Options); + var valid = JsonSerializer.Serialize(Release(SetA, 1, NestA), Options); string With(string property, JsonNode? value) { var node = JsonNode.Parse(valid)!.AsObject(); node[property] = value; return node.ToJsonString(); } + string WithSet(string property, JsonNode? value) + { + var node = JsonNode.Parse(valid)!.AsObject(); + node["sets"]![0]![property] = value; + return node.ToJsonString(); + } string WithNest(string property, JsonNode? value) { var node = JsonNode.Parse(valid)!.AsObject(); @@ -301,11 +515,14 @@ public sealed class SetReleaseTests return new TheoryData { { "empty id", With("id", Guid.Empty.ToString()) }, - { "other set id", With("setId", Guid.NewGuid().ToString()) }, - { "zero set revision", With("setRevision", 0) }, - { "unknown set revision", With("setRevision", 2) }, - { "zero multiplier", With("multiplier", 0) }, - { "multiplier over the limit", With("multiplier", 1001) }, + { "missing sets", Without("sets") }, + { "no sets", With("sets", new JsonArray()) }, + { "unknown set", WithSet("setId", Guid.NewGuid().ToString()) }, + { "zero set revision", WithSet("setRevision", 0) }, + { "unknown set revision", WithSet("setRevision", 2) }, + { "zero quantity", WithSet("quantity", 0) }, + { "quantity over the limit", WithSet("quantity", 1001) }, + { "repeated set", With("sets", new JsonArray(JsonNode.Parse(valid)!["sets"]![0]!.DeepClone(), JsonNode.Parse(valid)!["sets"]![0]!.DeepClone())) }, { "long notes", With("notes", new string('x', 4001)) }, { "null released by", With("releasedBy", null) }, { "missing nests", Without("nests") }, @@ -314,6 +531,11 @@ public sealed class SetReleaseTests { "repeated nest", With("nests", new JsonArray(JsonNode.Parse(valid)!["nests"]![0]!.DeepClone(), JsonNode.Parse(valid)!["nests"]![0]!.DeepClone())) }, { "unknown nest", WithNest("nestId", Guid.NewGuid().ToString()) }, { "empty nest id", WithNest("nestId", Guid.Empty.ToString()) }, + { "integer kind", WithNest("kind", 1) }, + { "unknown kind", WithNest("kind", "copied") }, + { "created with a before revision", WithNest("revisionBefore", 1) }, + { "merged without a before revision", WithNest("kind", "merged") }, + { "zero after revision", WithNest("revisionAfter", 0) }, { "integer units", WithNest("units", 0) }, { "unknown units", WithNest("units", "Feet") }, { "combined cut type", WithNest("cutType", "Laser, Plasma") }, @@ -321,12 +543,16 @@ public sealed class SetReleaseTests { "negative thickness", WithNest("thickness", -1) }, { "null material", WithNest("materialName", null) }, { "long grade", WithNest("materialGrade", new string('g', 201)) }, + { "null items", WithNest("items", null) }, + { "item of another set", WithNest("items", JsonNode.Parse($$"""[{ "setId": "{{Guid.NewGuid()}}", "drawingId": "{{Guid.NewGuid()}}", "drawingRevision": 1, "quantity": 1 }]""")) }, + { "item with no quantity", WithNest("items", JsonNode.Parse($$"""[{ "setId": "{{SetA}}", "drawingId": "{{Guid.NewGuid()}}", "drawingRevision": 1, "quantity": 0 }]""")) }, { "not an object", "[1]" }, { "malformed", "{" }, }; } private static readonly Guid NestA = Guid.Parse("00000000-0000-0000-0000-0000000000a1"); + private static readonly Guid SetA = Guid.Parse("00000000-0000-0000-0000-0000000000b1"); [Theory] [MemberData(nameof(RejectedReleases))] @@ -334,27 +560,33 @@ public sealed class SetReleaseTests { using var factory = new ServerFactory(); using var client = factory.CreateClient(); - var set = await Create(client, Set()); + await Create(client, Set(id: SetA)); await AddNest(client, id: NestA); - using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(json)); + 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/sets/{set.Id}/releases", Options))!.Total); + Assert.Equal(0, (await client.GetFromJsonAsync("/api/releases/query", Options))!.Total); } [Fact] - public async Task Api_JoinedNestInTheBody_IsIgnored() + public async Task Api_JoinedAndServerComputedValuesInTheBody_AreIgnored() { using var factory = new ServerFactory(); using var client = factory.CreateClient(); var set = await Create(client, Set()); - var node = JsonNode.Parse(JsonSerializer.Serialize(Release(1, await AddNest(client)), Options))!; + var node = JsonNode.Parse(JsonSerializer.Serialize(Release(set.Id, 1, await AddNest(client)), Options))!; node["nests"]![0]!["nest"] = new JsonObject { ["status"] = 99, ["name"] = 5 }; + node["nests"]![0]!["undo"] = 7; + node["sets"]![0]!["setName"] = 3; + node["undoneAt"] = "2026-10-21T09:00:00Z"; + node["undoneBy"] = 4; - using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(node.ToJsonString())); + using var response = await client.PostAsync("/api/releases", Json(node.ToJsonString())); Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var stored = (await response.Content.ReadFromJsonAsync(Options))!; + Assert.Equal((false, "Synthetic kit", (SetReleaseNestUndo?)null), (stored.IsUndone, stored.Sets[0].SetName, stored.Nests[0].Undo)); } private static object? Scalar(string path, string sql) @@ -365,4 +597,13 @@ public sealed class SetReleaseTests 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/DrawingSetRequest.cs b/OpenNest.Server/DrawingSetRequest.cs index 18569dc7..144e6755 100644 --- a/OpenNest.Server/DrawingSetRequest.cs +++ b/OpenNest.Server/DrawingSetRequest.cs @@ -1,7 +1,5 @@ using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using OpenNest; using OpenNest.Data; namespace OpenNest.Server; @@ -23,13 +21,6 @@ internal static class DrawingSetRequest PropertyNameCaseInsensitive = true, }; - // Integer enum values are refused; names are additionally checked before deserializing. - private static readonly JsonSerializerOptions ReleaseOptions = new(JsonSerializerDefaults.Web) - { - PropertyNameCaseInsensitive = true, - Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false) }, - }; - public static bool TryParseQuery(IQueryCollection values, out DrawingSetQuery query, out string error) { query = new DrawingSetQuery(); @@ -136,77 +127,16 @@ internal static class DrawingSetRequest return error.Length == 0; } - /// - /// Reads and validates one release JSON object. nests must be present, each - /// nest's units and cutType must be single names, and each nest's joined - /// nest is ignored. - /// - public static bool TryReadRelease(string? json, out SetReleaseRecord release, out string error) - { - release = new SetReleaseRecord(); - if (string.IsNullOrWhiteSpace(json)) - { - error = "Missing release."; - return false; - } - - try - { - if (JsonNode.Parse(json) is not JsonObject root) - { - error = "The release must be a JSON object."; - return false; - } - - if (!RemoveJoined(root, "nests", "nest")) - { - error = "nests is required."; - return false; - } - - // Enum converters also accept comma-separated combinations; allow single names only. - if (Property(root, "nests") is JsonArray nests) - { - for (var i = 0; i < nests.Count; i++) - { - if (nests[i] is not JsonObject nest) - continue; - if (Property(nest, "units") is { } units && !IsName(units)) - { - error = $"nests[{i}].units must be a supported unit name."; - return false; - } - - if (Property(nest, "cutType") is { } cutType && !IsName(cutType)) - { - error = $"nests[{i}].cutType must be Laser, Waterjet or Plasma."; - return false; - } - } - } - - release = root.Deserialize(ReleaseOptions) ?? release; - } - catch (JsonException) - { - error = "Invalid release JSON."; - return false; - } - - error = SetReleaseRules.GetError(release) ?? ""; - return error.Length == 0; - } - - private static JsonNode? Property(JsonObject node, string name) => + internal static JsonNode? Property(JsonObject node, string name) => node.FirstOrDefault(p => string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase)).Value; - private static bool IsName(JsonNode value) + internal static bool IsName(JsonNode value) where TEnum : struct, Enum => value is JsonValue text && text.TryGetValue(out var name) && DrawingLibraryRequest.TryEnumName(name, out _); // Drops the server-joined property from each object in the list, so a record read from // the server may be sent back; false when the list property is missing altogether. - private static bool RemoveJoined(JsonObject root, string list, string joined) + internal static bool RemoveJoined(JsonObject root, string list, string joined) { if (!root.Any(p => string.Equals(p.Key, list, StringComparison.OrdinalIgnoreCase))) return false; diff --git a/OpenNest.Server/DrawingSetRoutes.cs b/OpenNest.Server/DrawingSetRoutes.cs index 5542cac5..96748eed 100644 --- a/OpenNest.Server/DrawingSetRoutes.cs +++ b/OpenNest.Server/DrawingSetRoutes.cs @@ -4,8 +4,8 @@ using OpenNest.Data; namespace OpenNest.Server; /// -/// /api/sets: named, versioned lists of library drawings with quantities, and the -/// append-only records of their releases into nests. Both travel as JSON; existing-set +/// /api/sets: named, versioned lists of library drawings with quantities, as JSON +/// (their releases are ). Existing-set /// writes follow the drawing If-Match contract (428 missing, 400 malformed, 409 stale or /// wrong state with the current record). /// @@ -69,35 +69,6 @@ internal static class DrawingSetRoutes app.MapPost("/api/sets/{id:guid}/restore", (Guid id, HttpRequest request, NestDatabase db) => SetArchived(id, false, request, db)); - - // Posted after every nest is uploaded; the client allocates the release id. - app.MapPost("/api/sets/{id:guid}/releases", async (Guid id, HttpRequest request, NestDatabase db) => - { - var (release, error) = await ReadRelease(request); - if (error is not null) return error; - if (release!.Id == Guid.Empty) - return Results.BadRequest("id is required: allocate it before posting the release."); - if (release.SetId != Guid.Empty && release.SetId != id) - return Results.BadRequest("Release setId does not match the route."); - try - { - var stored = db.InsertSetRelease(id, release); - return stored is null ? Results.NotFound() : Results.Ok(stored); - } - catch (SetReleaseConflictException conflict) - { - return Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict); - } - catch (ArgumentException invalid) { return Results.BadRequest(invalid.Message); } - }); - - app.MapGet("/api/sets/{id:guid}/releases", (Guid id, HttpRequest request, NestDatabase db) => - !DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error) - ? Results.BadRequest(error) - : db.SetReleases(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound()); - - app.MapGet("/api/nests/{id:guid}/release", (Guid id, NestDatabase db) => - db.ReleaseForNest(id) is { } release ? Results.Ok(release) : Results.NotFound()); } private static IResult SetArchived(Guid id, bool archived, HttpRequest request, NestDatabase db) @@ -124,16 +95,7 @@ internal static class DrawingSetRoutes : (null, Results.BadRequest(invalid)); } - private static async Task<(SetReleaseRecord? Release, IResult? Error)> ReadRelease(HttpRequest request) - { - var (json, error) = await ReadJson(request, "release"); - if (error is not null) return (null, error); - return DrawingSetRequest.TryReadRelease(json, out var release, out var invalid) - ? (release, null) - : (null, Results.BadRequest(invalid)); - } - - private static async Task<(string? Json, IResult? Error)> ReadJson(HttpRequest request, string what) + internal static async Task<(string? Json, IResult? Error)> ReadJson(HttpRequest request, string what) { if (!request.HasJsonContentType()) return (null, Results.BadRequest($"Expected an application/json {what}.")); diff --git a/OpenNest.Server/NestDatabase.Releases.cs b/OpenNest.Server/NestDatabase.Releases.cs index d1010614..cb4137e5 100644 --- a/OpenNest.Server/NestDatabase.Releases.cs +++ b/OpenNest.Server/NestDatabase.Releases.cs @@ -4,15 +4,17 @@ using OpenNest.Data; namespace OpenNest.Server; /// -/// Set release storage. set_releases records which set revision was released and -/// how many times over; set_release_nests the nests it made, in order, with the -/// material group each was made for. A nest belongs to at most one release. Releases are -/// append-only and read joined to each nest's current metadata. +/// 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_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 +/// several. Releases are append-only and read joined to each nest's current metadata. /// public sealed partial class NestDatabase { - // Part of the v7 set schema. - private const string SetReleaseTables = """ + // The v7 layout. Still created by the v7 step so every database reaches v9 by one path. + private const string SetReleaseTablesV7 = """ CREATE TABLE set_releases ( id TEXT NOT NULL PRIMARY KEY, setId TEXT NOT NULL, @@ -36,20 +38,87 @@ public sealed partial class NestDatabase CREATE UNIQUE INDEX ix_set_release_nests_nest ON set_release_nests(nestId); """; - private const string ReleaseSelect = """ - SELECT x.id, x.setId, x.setRevision, x.multiplier, x.notes, x.releasedAt, x.releasedBy, COALESCE(v.name, '') - FROM set_releases x LEFT JOIN drawing_set_revisions v ON v.id = x.setId AND v.revision = x.setRevision + // v8 -> v9. A v7 release becomes one set line (its multiplier is the quantity) and its + // nests become created nests at their first revision, which is the one the release + // uploaded. Their item quantities were never recorded, so they have none. + private const string ReleaseUpgradeToV9 = """ + CREATE TABLE releases ( + id TEXT NOT NULL PRIMARY KEY, + notes TEXT NOT NULL DEFAULT '', + releasedAt TEXT NOT NULL, + releasedBy TEXT NOT NULL DEFAULT '', + undoneAt TEXT, + undoneBy TEXT NOT NULL DEFAULT ''); + CREATE TABLE release_sets ( + releaseId TEXT NOT NULL REFERENCES releases(id), + position INTEGER NOT NULL, + setId TEXT NOT NULL, + setRevision INTEGER NOT NULL, + quantity INTEGER NOT NULL, + PRIMARY KEY (releaseId, position), + UNIQUE (releaseId, setId), + FOREIGN KEY (setId, setRevision) REFERENCES drawing_set_revisions(id, revision)); + CREATE TABLE release_nests ( + releaseId TEXT NOT NULL REFERENCES releases(id), + position INTEGER NOT NULL, + nestId TEXT NOT NULL REFERENCES nests(id), + kind TEXT NOT NULL, + revisionBefore INTEGER, + revisionAfter INTEGER NOT NULL, + undo TEXT, + undoRevision INTEGER, + materialName TEXT NOT NULL DEFAULT '', + materialGrade TEXT NOT NULL DEFAULT '', + thickness REAL NOT NULL, + units TEXT NOT NULL, + cutType TEXT NOT NULL, + PRIMARY KEY (releaseId, position), + UNIQUE (releaseId, nestId)); + CREATE TABLE release_nest_items ( + 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)); + CREATE INDEX ix_releases_releasedAt ON releases(releasedAt DESC); + CREATE INDEX ix_release_sets_set ON release_sets(setId); + CREATE INDEX ix_release_nests_nest ON release_nests(nestId); + + INSERT INTO releases (id, notes, releasedAt, releasedBy) + SELECT id, notes, releasedAt, releasedBy FROM set_releases; + INSERT INTO release_sets (releaseId, position, setId, setRevision, quantity) + SELECT id, 0, setId, setRevision, multiplier FROM set_releases; + INSERT INTO release_nests (releaseId, position, nestId, kind, revisionBefore, revisionAfter, + materialName, materialGrade, thickness, units, cutType) + SELECT r.releaseId, r.position, r.nestId, 'Created', NULL, + COALESCE((SELECT MIN(v.revision) FROM nest_revisions v WHERE v.id = r.nestId), 1), + r.materialName, r.materialGrade, r.thickness, r.units, r.cutType + FROM set_release_nests r; + DROP INDEX ix_set_release_nests_nest; + DROP INDEX ix_set_releases_set; + DROP TABLE set_release_nests; + DROP TABLE set_releases; + PRAGMA user_version=9; """; + private const string ReleaseSelect = + "SELECT x.id, x.notes, x.releasedAt, x.releasedBy, x.undoneAt, x.undoneBy FROM releases x"; + private static readonly string JoinedNestColumns = string.Join(", ", RecordColumns.Split(',').Select(column => "n." + column.Trim())); /// - /// Records a release of . Returns null for an unknown set. The - /// set revision must exist but need not be current, and the set may be archived: the - /// nests already exist, so a release is never refused for a state the operator cannot fix. + /// 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 + /// refused for a state the operator cannot fix. A merged nest need not still be at the + /// revision the release wrote. /// - public SetReleaseRecord? InsertSetRelease(Guid setId, SetReleaseRecord release) + public SetReleaseRecord InsertRelease(SetReleaseRecord release) { ArgumentNullException.ThrowIfNull(release); if (release.Id == Guid.Empty) throw new ArgumentException("A release id is required."); @@ -60,17 +129,17 @@ public sealed partial class NestDatabase lock (_sync) { using var transaction = _connection.BeginTransaction(); - if (GetDrawingSet(setId) is null) return null; if (ReadRelease(release.Id) is { } existing) throw new SetReleaseConflictException(existing, "A release with this id already exists."); - using (var command = _connection.CreateCommand()) + + foreach (var line in release.Sets) { - command.Transaction = transaction; - command.CommandText = "SELECT 1 FROM drawing_set_revisions WHERE id=$id AND revision=$revision"; - command.Parameters.AddWithValue("$id", setId.ToString()); - command.Parameters.AddWithValue("$revision", release.SetRevision); - if (command.ExecuteScalar() is null) - throw new ArgumentException($"The set has no revision {release.SetRevision}."); + if (!Exists("SELECT 1 FROM drawing_set_revisions WHERE id=$id AND revision=$revision", line.SetId, line.SetRevision)) + { + throw new ArgumentException(GetDrawingSet(line.SetId) is null + ? $"Set {line.SetId} does not exist." + : $"Set {line.SetId} has no revision {line.SetRevision}."); + } } foreach (var nest in release.Nests) @@ -82,68 +151,208 @@ public sealed partial class NestDatabase : $"Nest {nest.NestId} is deleted."); } - if (ReleaseForNest(nest.NestId) is { } owner) - throw new SetReleaseConflictException(owner, $"Nest {nest.NestId} already belongs to a release."); + foreach (var revision in new[] { nest.RevisionBefore, nest.RevisionAfter }.OfType()) + { + if (!Exists("SELECT 1 FROM nest_revisions WHERE id=$id AND revision=$revision", nest.NestId, revision)) + throw new ArgumentException($"Nest {nest.NestId} has no revision {revision}."); + } + + // A created nest came from this release alone; only merges share a nest. + if (nest.Kind == SetReleaseNestKind.Created && ReleasesOfNest(nest.NestId) is [var owner, ..]) + throw new SetReleaseConflictException(ReadRelease(owner)!, $"Nest {nest.NestId} already belongs to a release."); } - WriteRelease(setId, release, transaction); + WriteRelease(release, transaction); transaction.Commit(); return ReadRelease(release.Id) ?? throw new InvalidOperationException("Release did not persist."); } } - /// One page of the set's releases, newest first, or null for an unknown set. - public SetReleasePage? SetReleases(Guid setId, int offset, int limit) + public SetReleaseRecord? GetRelease(Guid id) { - if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) - throw new ArgumentOutOfRangeException(nameof(limit)); + lock (_sync) + return ReadRelease(id); + } + + /// + /// One page of releases, newest first, optionally only those that include + /// or . + /// + public SetReleasePage QueryReleases(Guid? setId, Guid? nestId, int offset, int limit) + { + if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset)); + if (limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) throw new ArgumentOutOfRangeException(nameof(limit)); + var filters = new List(); + if (setId is not null) + 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)"); + 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()); + } + lock (_sync) { - if (GetDrawingSet(setId) is null) return null; using var count = _connection.CreateCommand(); - count.CommandText = "SELECT COUNT(*) FROM set_releases WHERE setId=$id"; - count.Parameters.AddWithValue("$id", setId.ToString()); + count.CommandText = $"SELECT COUNT(*) FROM releases x {where}"; + AddFilters(count); var total = checked((int)(long)count.ExecuteScalar()!); + using var command = _connection.CreateCommand(); command.CommandText = $""" - {ReleaseSelect} WHERE x.setId=$id + {ReleaseSelect} {where} ORDER BY x.releasedAt DESC, x.id DESC LIMIT $limit OFFSET $offset """; - command.Parameters.AddWithValue("$id", setId.ToString()); + AddFilters(command); command.Parameters.AddWithValue("$limit", limit); command.Parameters.AddWithValue("$offset", offset); var releases = ReadReleases(command); foreach (var release in releases) - release.Nests = ReadReleaseNests(release.Id); + Complete(release); return new SetReleasePage { Items = releases, Total = total, Offset = offset, Limit = limit }; } } - /// The release a nest, deleted or not, came from, or null when it came from none. - public SetReleaseRecord? ReleaseForNest(Guid nestId) + /// + /// Undoes a release in one transaction: each created nest is deleted and each merged nest + /// restored to the revision before the merge, except the nests the request skips. Every + /// other nest must still hold the revision the release wrote (at it, or restored to it); + /// otherwise nothing changes and lists the + /// nests that moved on. So releases sharing a nest are undone newest first. + /// Returns null for an unknown release. + /// + public SetReleaseRecord? UndoRelease(Guid id, SetReleaseUndoRequest request) { + ArgumentNullException.ThrowIfNull(request); + var error = SetReleaseRules.GetUndoError(request); + if (error is not null) + throw new ArgumentException(error); + lock (_sync) { - using var command = _connection.CreateCommand(); - command.CommandText = "SELECT releaseId FROM set_release_nests WHERE nestId=$id"; - command.Parameters.AddWithValue("$id", nestId.ToString()); - return command.ExecuteScalar() is string releaseId ? ReadRelease(Guid.Parse(releaseId)) : null; + using var transaction = _connection.BeginTransaction(); + var release = ReadRelease(id); + if (release is null) return null; + if (release.IsUndone) + throw new ReleaseUndoRefusedException(new SetReleaseUndoConflict + { + Reason = SetReleaseUndoRefusal.AlreadyUndone, + Release = release, + }); + + var skipped = request.SkipNests.ToHashSet(); + foreach (var skip in skipped) + { + if (release.Nests.All(n => n.NestId != skip)) + throw new ArgumentException($"Nest {skip} is not part of this release."); + } + + // A nest still holds what the release wrote when it is at that revision, or when its + // latest save restored it (such as undoing a later release that merged into it). + var expected = new Dictionary(); + var changed = new List(); + foreach (var nest in release.Nests.Where(n => !skipped.Contains(n.NestId))) + { + var current = Get(nest.NestId) ?? Get(nest.NestId, deleted: true); + if (current is { Deleted: false } + && (current.Revision == nest.RevisionAfter || current.RestoredFromRevision == nest.RevisionAfter)) + expected[nest.NestId] = current.Revision; + else + changed.Add(current ?? new NestRecord { Id = nest.NestId, Deleted = true }); + } + if (changed.Count > 0) + throw new ReleaseUndoRefusedException(new SetReleaseUndoConflict + { + Reason = SetReleaseUndoRefusal.NestsChanged, + Release = release, + ChangedNests = changed, + }); + + using var mark = _connection.CreateCommand(); + mark.Transaction = transaction; + mark.CommandText = "UPDATE release_nests SET undo=$undo, undoRevision=$revision WHERE releaseId=$id AND nestId=$nest"; + mark.Parameters.AddWithValue("$id", id.ToString()); + var undo = mark.Parameters.Add("$undo", SqliteType.Text); + var revision = mark.Parameters.Add("$revision", SqliteType.Integer); + var nestId = mark.Parameters.Add("$nest", SqliteType.Text); + foreach (var nest in release.Nests) + { + SetReleaseNestUndo outcome; + if (skipped.Contains(nest.NestId)) + { + outcome = SetReleaseNestUndo.Skipped; + } + else if (nest.Kind == SetReleaseNestKind.Created) + { + if (!MarkDeleted(nest.NestId, true, expected[nest.NestId], transaction)) + throw new InvalidOperationException($"Nest {nest.NestId} could not be deleted."); + outcome = SetReleaseNestUndo.Deleted; + } + else + { + if (!RestoreRevision(nest.NestId, nest.RevisionBefore!.Value, expected[nest.NestId], transaction)) + throw new InvalidOperationException($"Nest {nest.NestId} has no revision {nest.RevisionBefore}."); + outcome = SetReleaseNestUndo.Restored; + } + + undo.Value = outcome.ToString(); + revision.Value = outcome == SetReleaseNestUndo.Skipped ? DBNull.Value : checked(expected[nest.NestId] + 1); + nestId.Value = nest.NestId.ToString(); + mark.ExecuteNonQuery(); + } + + mark.Parameters.Clear(); + mark.CommandText = "UPDATE releases SET undoneAt=$at, undoneBy=$by WHERE id=$id"; + mark.Parameters.AddWithValue("$id", id.ToString()); + mark.Parameters.AddWithValue("$at", DateTime.Now.ToString("o")); + mark.Parameters.AddWithValue("$by", request.UndoneBy); + mark.ExecuteNonQuery(); + + transaction.Commit(); + return ReadRelease(id); } } + // Called only under _sync. The releases that include the nest, newest first. + private List ReleasesOfNest(Guid nestId) + { + using var command = _connection.CreateCommand(); + command.CommandText = """ + SELECT x.id FROM releases x JOIN release_nests r ON r.releaseId = x.id + WHERE r.nestId=$id ORDER BY x.releasedAt DESC, x.id DESC + """; + command.Parameters.AddWithValue("$id", nestId.ToString()); + var ids = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + ids.Add(Guid.Parse(reader.GetString(0))); + return ids; + } + + // Called only under _sync. + private bool Exists(string sql, Guid id, int revision) + { + using var command = _connection.CreateCommand(); + command.CommandText = sql; + command.Parameters.AddWithValue("$id", id.ToString()); + command.Parameters.AddWithValue("$revision", revision); + return command.ExecuteScalar() is not null; + } + // Called only under _sync with the caller's transaction. - private void WriteRelease(Guid setId, SetReleaseRecord release, SqliteTransaction transaction) + private void WriteRelease(SetReleaseRecord release, SqliteTransaction transaction) { using var command = _connection.CreateCommand(); command.Transaction = transaction; command.CommandText = """ - INSERT INTO set_releases (id, setId, setRevision, multiplier, notes, releasedAt, releasedBy) - VALUES ($id, $setId, $setRevision, $multiplier, $notes, $releasedAt, $releasedBy) + INSERT INTO releases (id, notes, releasedAt, releasedBy) + VALUES ($id, $notes, $releasedAt, $releasedBy) """; command.Parameters.AddWithValue("$id", release.Id.ToString()); - command.Parameters.AddWithValue("$setId", setId.ToString()); - command.Parameters.AddWithValue("$setRevision", release.SetRevision); - command.Parameters.AddWithValue("$multiplier", release.Multiplier); command.Parameters.AddWithValue("$notes", release.Notes); command.Parameters.AddWithValue("$releasedAt", DateTime.Now.ToString("o")); command.Parameters.AddWithValue("$releasedBy", release.ReleasedBy); @@ -151,12 +360,37 @@ public sealed partial class NestDatabase command.Parameters.Clear(); command.CommandText = """ - INSERT INTO set_release_nests (releaseId, position, nestId, materialName, materialGrade, thickness, units, cutType) - VALUES ($id, $position, $nest, $materialName, $materialGrade, $thickness, $units, $cutType) + INSERT INTO release_sets (releaseId, position, setId, setRevision, quantity) + VALUES ($id, $position, $set, $revision, $quantity) """; command.Parameters.AddWithValue("$id", release.Id.ToString()); var position = command.Parameters.Add("$position", SqliteType.Integer); + var setId = command.Parameters.Add("$set", SqliteType.Text); + var setRevision = command.Parameters.Add("$revision", SqliteType.Integer); + var quantity = command.Parameters.Add("$quantity", SqliteType.Integer); + for (var i = 0; i < release.Sets.Count; i++) + { + var line = release.Sets[i]; + position.Value = i; + setId.Value = line.SetId.ToString(); + setRevision.Value = line.SetRevision; + quantity.Value = line.Quantity; + command.ExecuteNonQuery(); + } + + command.Parameters.Clear(); + command.CommandText = """ + INSERT INTO release_nests (releaseId, position, nestId, kind, revisionBefore, revisionAfter, + materialName, materialGrade, thickness, units, cutType) + VALUES ($id, $position, $nest, $kind, $before, $after, + $materialName, $materialGrade, $thickness, $units, $cutType) + """; + command.Parameters.AddWithValue("$id", release.Id.ToString()); + position = command.Parameters.Add("$position", SqliteType.Integer); var nestId = command.Parameters.Add("$nest", SqliteType.Text); + var kind = command.Parameters.Add("$kind", SqliteType.Text); + var before = command.Parameters.Add("$before", SqliteType.Integer); + var after = command.Parameters.Add("$after", SqliteType.Integer); var materialName = command.Parameters.Add("$materialName", SqliteType.Text); var materialGrade = command.Parameters.Add("$materialGrade", SqliteType.Text); var thickness = command.Parameters.Add("$thickness", SqliteType.Real); @@ -167,6 +401,9 @@ public sealed partial class NestDatabase var nest = release.Nests[i]; position.Value = i; nestId.Value = nest.NestId.ToString(); + kind.Value = nest.Kind.ToString(); + before.Value = (object?)nest.RevisionBefore ?? DBNull.Value; + after.Value = nest.RevisionAfter; materialName.Value = nest.MaterialName; materialGrade.Value = nest.MaterialGrade; thickness.Value = DrawingLibraryRules.NormalizeThickness(nest.Thickness); @@ -174,6 +411,33 @@ public sealed partial class NestDatabase cutType.Value = nest.CutType.ToString(); command.ExecuteNonQuery(); } + + command.Parameters.Clear(); + command.CommandText = """ + INSERT INTO release_nest_items (releaseId, nestId, position, setId, drawingId, drawingRevision, quantity) + VALUES ($id, $nest, $position, $set, $drawing, $revision, $quantity) + """; + command.Parameters.AddWithValue("$id", release.Id.ToString()); + nestId = command.Parameters.Add("$nest", SqliteType.Text); + position = command.Parameters.Add("$position", SqliteType.Integer); + setId = command.Parameters.Add("$set", SqliteType.Text); + var drawingId = command.Parameters.Add("$drawing", SqliteType.Text); + var drawingRevision = command.Parameters.Add("$revision", SqliteType.Integer); + quantity = command.Parameters.Add("$quantity", SqliteType.Integer); + foreach (var nest in release.Nests) + { + nestId.Value = nest.NestId.ToString(); + for (var i = 0; i < nest.Items.Count; i++) + { + var item = nest.Items[i]; + position.Value = i; + setId.Value = item.SetId.ToString(); + drawingId.Value = item.DrawingId.ToString(); + drawingRevision.Value = item.DrawingRevision; + quantity.Value = item.Quantity; + command.ExecuteNonQuery(); + } + } } // Called only under _sync. @@ -184,7 +448,7 @@ public sealed partial class NestDatabase command.Parameters.AddWithValue("$id", releaseId.ToString()); var release = ReadReleases(command).FirstOrDefault(); if (release is not null) - release.Nests = ReadReleaseNests(release.Id); + Complete(release); return release; } @@ -197,50 +461,116 @@ public sealed partial class NestDatabase releases.Add(new SetReleaseRecord { Id = Guid.Parse(reader.GetString(0)), - SetId = Guid.Parse(reader.GetString(1)), - SetRevision = reader.GetInt32(2), - Multiplier = reader.GetInt32(3), - Notes = reader.GetString(4), - ReleasedAt = DateTime.Parse(reader.GetString(5)), - ReleasedBy = reader.GetString(6), - SetName = reader.GetString(7), + Notes = reader.GetString(1), + ReleasedAt = DateTime.Parse(reader.GetString(2)), + ReleasedBy = reader.GetString(3), + UndoneAt = reader.IsDBNull(4) ? null : DateTime.Parse(reader.GetString(4)), + UndoneBy = reader.GetString(5), }); } return releases; } - // Called only under _sync. Each nest joined to its current metadata, deleted or not. - private List ReadReleaseNests(Guid releaseId) + // 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. + private void Complete(SetReleaseRecord release) { - using var command = _connection.CreateCommand(); - command.CommandText = $""" - SELECT r.nestId, r.materialName, r.materialGrade, r.thickness, r.units, r.cutType, {JoinedNestColumns} - FROM set_release_nests r LEFT JOIN nests n ON n.id = r.nestId - WHERE r.releaseId=$id ORDER BY r.position - """; - command.Parameters.AddWithValue("$id", releaseId.ToString()); - var nests = new List(); - using var reader = command.ExecuteReader(); - while (reader.Read()) + var id = release.Id.ToString(); + using (var command = _connection.CreateCommand()) { - nests.Add(new SetReleaseNest + command.CommandText = """ + SELECT s.setId, s.setRevision, s.quantity, COALESCE(v.name, '') + FROM release_sets s LEFT JOIN drawing_set_revisions v ON v.id = s.setId AND v.revision = s.setRevision + WHERE s.releaseId=$id ORDER BY s.position + """; + command.Parameters.AddWithValue("$id", id); + var sets = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) { - NestId = Guid.Parse(reader.GetString(0)), - MaterialName = reader.GetString(1), - MaterialGrade = reader.GetString(2), - Thickness = reader.GetDouble(3), - Units = Enum.Parse(reader.GetString(4)), - CutType = Enum.Parse(reader.GetString(5)), - Nest = reader.IsDBNull(6) ? null : ReadRecord(reader, first: 6), - }); + sets.Add(new SetReleaseLine + { + SetId = Guid.Parse(reader.GetString(0)), + SetRevision = reader.GetInt32(1), + Quantity = reader.GetInt32(2), + SetName = reader.GetString(3), + }); + } + release.Sets = sets; + } + + var items = new Dictionary>(); + using (var command = _connection.CreateCommand()) + { + command.CommandText = """ + SELECT nestId, setId, drawingId, drawingRevision, quantity + FROM release_nest_items WHERE releaseId=$id ORDER BY nestId, position + """; + command.Parameters.AddWithValue("$id", id); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var nestId = Guid.Parse(reader.GetString(0)); + if (!items.TryGetValue(nestId, out var list)) + items[nestId] = list = new List(); + list.Add(new SetReleaseNestItem + { + SetId = Guid.Parse(reader.GetString(1)), + DrawingId = Guid.Parse(reader.GetString(2)), + DrawingRevision = reader.GetInt32(3), + Quantity = reader.GetInt32(4), + }); + } + } + + using (var command = _connection.CreateCommand()) + { + command.CommandText = $""" + SELECT r.nestId, r.kind, r.revisionBefore, r.revisionAfter, r.undo, r.undoRevision, + r.materialName, r.materialGrade, r.thickness, r.units, r.cutType, {JoinedNestColumns} + FROM release_nests r LEFT JOIN nests n ON n.id = r.nestId + WHERE r.releaseId=$id ORDER BY r.position + """; + command.Parameters.AddWithValue("$id", id); + var nests = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var nestId = Guid.Parse(reader.GetString(0)); + nests.Add(new SetReleaseNest + { + NestId = nestId, + Kind = Enum.Parse(reader.GetString(1)), + RevisionBefore = reader.IsDBNull(2) ? null : reader.GetInt32(2), + RevisionAfter = reader.GetInt32(3), + Undo = reader.IsDBNull(4) ? null : Enum.Parse(reader.GetString(4)), + UndoRevision = reader.IsDBNull(5) ? null : reader.GetInt32(5), + MaterialName = reader.GetString(6), + MaterialGrade = reader.GetString(7), + Thickness = reader.GetDouble(8), + Units = Enum.Parse(reader.GetString(9)), + CutType = Enum.Parse(reader.GetString(10)), + Items = items.TryGetValue(nestId, out var list) ? list : Array.Empty(), + Nest = reader.IsDBNull(11) ? null : ReadRecord(reader, first: 11), + }); + } + release.Nests = nests; } - return nests; } } /// A release post collided with an existing release; nothing was changed. public sealed class SetReleaseConflictException(SetReleaseRecord current, string message) : Exception(message) { - /// The release that already holds the id or the nest. + /// The release that already holds the id or the created nest. public SetReleaseRecord Current { get; } = current; } + +/// An undo was refused; nothing was changed. +public sealed class ReleaseUndoRefusedException(SetReleaseUndoConflict conflict) + : Exception(conflict.Reason == SetReleaseUndoRefusal.AlreadyUndone + ? "The release was already undone." + : "Nests of the release have changed since it was made.") +{ + public SetReleaseUndoConflict Conflict { get; } = conflict; +} diff --git a/OpenNest.Server/NestDatabase.Sets.cs b/OpenNest.Server/NestDatabase.Sets.cs index 8494c84d..b9ce54d5 100644 --- a/OpenNest.Server/NestDatabase.Sets.cs +++ b/OpenNest.Server/NestDatabase.Sets.cs @@ -39,7 +39,7 @@ public sealed partial class NestDatabase CREATE INDEX ix_drawing_sets_name ON drawing_sets(customerKey, nameKey); CREATE INDEX ix_drawing_sets_savedAt ON drawing_sets(savedAt DESC); CREATE INDEX ix_drawing_set_members_drawing ON drawing_set_members(drawingId); - {SetReleaseTables} + {SetReleaseTablesV7} PRAGMA user_version=7; """; diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index 5cada1d5..59879f50 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -223,49 +223,65 @@ public sealed partial class NestDatabase : IDisposable lock (_sync) { using var transaction = _connection.BeginTransaction(); - var current = Get(id); - if (current is null) return null; - if (current.Revision != expectedRevision) throw new RevisionConflictException(current); - NestRecord historical; - using (var command = _connection.CreateCommand()) - { - command.Transaction = transaction; - command.CommandText = $"SELECT {RecordColumns} FROM nest_revisions WHERE id=$id AND revision=$revision"; - command.Parameters.AddWithValue("$id", id.ToString()); - command.Parameters.AddWithValue("$revision", sourceRevision); - using var reader = command.ExecuteReader(); - if (!reader.Read()) return null; - historical = ReadRecord(reader); - } - // Restore is a deliberate new active revision, including when the bytes already match. - WriteRevision(id, historical, GetRevisionFile(id, sourceRevision)!, current.Revision, sourceRevision, transaction); + if (!RestoreRevision(id, sourceRevision, expectedRevision, transaction)) return null; transaction.Commit(); return Get(id); } } + // Called only under _sync with the caller's transaction (release undo shares one). + // False when the active nest or the revision is unknown. + private bool RestoreRevision(Guid id, int sourceRevision, int expectedRevision, SqliteTransaction transaction) + { + var current = Get(id); + if (current is null) return false; + if (current.Revision != expectedRevision) throw new RevisionConflictException(current); + NestRecord historical; + using (var command = _connection.CreateCommand()) + { + command.Transaction = transaction; + command.CommandText = $"SELECT {RecordColumns} FROM nest_revisions WHERE id=$id AND revision=$revision"; + command.Parameters.AddWithValue("$id", id.ToString()); + command.Parameters.AddWithValue("$revision", sourceRevision); + using var reader = command.ExecuteReader(); + if (!reader.Read()) return false; + historical = ReadRecord(reader); + } + // Restore is a deliberate new active revision, including when the bytes already match. + WriteRevision(id, historical, GetRevisionFile(id, sourceRevision)!, current.Revision, sourceRevision, transaction); + return true; + } + public NestRecord? SetDeleted(Guid id, bool deleted, int expectedRevision) { lock (_sync) { using var transaction = _connection.BeginTransaction(); - var current = Get(id, !deleted); - if (current is null) return null; - if (current.Revision != expectedRevision) throw new RevisionConflictException(current); - using var command = _connection.CreateCommand(); - command.Transaction = transaction; - command.CommandText = "UPDATE nests SET deleted=$deleted, revision=$revision, savedAt=$savedAt, restoredFromRevision=NULL WHERE id=$id"; - command.Parameters.AddWithValue("$id", id.ToString()); - command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0); - command.Parameters.AddWithValue("$revision", checked(current.Revision + 1)); - command.Parameters.AddWithValue("$savedAt", DateTime.Now.ToString("o")); - command.ExecuteNonQuery(); - Snapshot(id, transaction); + if (!MarkDeleted(id, deleted, expectedRevision, transaction)) return null; transaction.Commit(); return Get(id, deleted); } } + // Called only under _sync with the caller's transaction (release undo shares one). + // False when no nest is in the opposite state. + private bool MarkDeleted(Guid id, bool deleted, int expectedRevision, SqliteTransaction transaction) + { + var current = Get(id, !deleted); + if (current is null) return false; + if (current.Revision != expectedRevision) throw new RevisionConflictException(current); + using var command = _connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = "UPDATE nests SET deleted=$deleted, revision=$revision, savedAt=$savedAt, restoredFromRevision=NULL WHERE id=$id"; + command.Parameters.AddWithValue("$id", id.ToString()); + command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0); + command.Parameters.AddWithValue("$revision", checked(current.Revision + 1)); + command.Parameters.AddWithValue("$savedAt", DateTime.Now.ToString("o")); + command.ExecuteNonQuery(); + Snapshot(id, transaction); + return true; + } + public NestPage? History(Guid id, int offset, int limit, bool deleted = false) { if (offset < 0 || limit is < 1 or > 200) throw new ArgumentOutOfRangeException(nameof(limit)); @@ -325,7 +341,7 @@ public sealed partial class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = "PRAGMA user_version"; var version = (long)command.ExecuteScalar()!; - if (version > 8) throw new InvalidOperationException("Database schema is newer than this server."); + if (version > 9) throw new InvalidOperationException("Database schema is newer than this server."); if (version == 0) { command.CommandText = """ @@ -392,6 +408,12 @@ public sealed partial class NestDatabase : IDisposable command.CommandText = "PRAGMA user_version=8"; command.ExecuteNonQuery(); } + if (version < 9) + { + // Releases cover several sets and may merge into existing nests. + command.CommandText = ReleaseUpgradeToV9; + command.ExecuteNonQuery(); + } transaction.Commit(); } } diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index 8c57959b..e08e3b48 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -141,6 +141,7 @@ app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, i app.MapDrawingLibrary(); app.MapDrawingSets(); +app.MapReleases(); app.Run(); diff --git a/OpenNest.Server/ReleaseRequest.cs b/OpenNest.Server/ReleaseRequest.cs new file mode 100644 index 00000000..5025df29 --- /dev/null +++ b/OpenNest.Server/ReleaseRequest.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using OpenNest; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// Parses release requests with the drawing library's strictness: unknown or repeated query +/// parameters and enum numbers or combinations are rejected, and server-computed or joined +/// properties in a posted release are ignored, so a record read from the server may be sent back. +/// +internal static class ReleaseRequest +{ + private static readonly string[] QueryKeys = { "setId", "nestId", "offset", "limit" }; + + // Integer enum values are refused; names are additionally checked before deserializing. + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false) }, + }; + + public static bool TryParseQuery(IQueryCollection values, out Guid? setId, out Guid? nestId, + out int offset, out int limit, out string error) + { + setId = null; + nestId = null; + offset = 0; + limit = DrawingLibraryRules.DefaultRevisionLimit; + foreach (var key in values.Keys) + { + if (!QueryKeys.Contains(key, StringComparer.OrdinalIgnoreCase)) + { + error = $"Unknown query parameter '{key}'."; + return false; + } + + if (values[key].Count > 1) + { + error = $"Query parameter '{key}' was given more than once."; + return false; + } + } + + if (!TryId(values, "setId", out setId, out error) || !TryId(values, "nestId", out nestId, out error) + || !DrawingLibraryRequest.TryInteger(values, "offset", ref offset, out error) + || !DrawingLibraryRequest.TryInteger(values, "limit", ref limit, out error)) + return false; + + if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) + { + error = $"offset must be zero or more and limit between 1 and {DrawingLibraryRules.MaxRevisionLimit}."; + return false; + } + + return true; + } + + /// + /// 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. + /// + public static bool TryReadRelease(string? json, out SetReleaseRecord release, out string error) + { + release = new SetReleaseRecord(); + if (string.IsNullOrWhiteSpace(json)) + { + error = "Missing release."; + return false; + } + + try + { + if (JsonNode.Parse(json) is not JsonObject root) + { + error = "The release must be a JSON object."; + return false; + } + + if (!DrawingSetRequest.RemoveJoined(root, "sets", "setName")) + { + error = "sets is required."; + return false; + } + + if (!DrawingSetRequest.RemoveJoined(root, "nests", "nest")) + { + error = "nests is required."; + return false; + } + + Remove(root, "releasedAt", "undoneAt", "undoneBy"); + if (DrawingSetRequest.Property(root, "nests") is JsonArray nests) + { + for (var i = 0; i < nests.Count; i++) + { + if (nests[i] is not JsonObject nest) + continue; + Remove(nest, "undo", "undoRevision"); + error = NameError(nest, "units", $"nests[{i}].units must be a supported unit name.") + ?? NameError(nest, "cutType", $"nests[{i}].cutType must be Laser, Waterjet or Plasma.") + ?? NameError(nest, "kind", $"nests[{i}].kind must be created or merged.") + ?? ""; + if (error.Length > 0) + return false; + } + } + + release = root.Deserialize(Options) ?? release; + } + catch (JsonException) + { + error = "Invalid release JSON."; + return false; + } + + error = SetReleaseRules.GetError(release) ?? ""; + return error.Length == 0; + } + + public static bool TryReadUndo(string? json, out SetReleaseUndoRequest request, out string error) + { + request = new SetReleaseUndoRequest(); + try + { + if (string.IsNullOrWhiteSpace(json) || JsonNode.Parse(json) is not JsonObject root) + { + error = "The undo request must be a JSON object."; + return false; + } + + request = root.Deserialize(Options) ?? request; + } + catch (JsonException) + { + error = "Invalid undo JSON."; + return false; + } + + error = SetReleaseRules.GetUndoError(request) ?? ""; + return error.Length == 0; + } + + private static bool TryId(IQueryCollection values, string key, out Guid? id, out string error) + { + id = null; + error = ""; + if (!values.TryGetValue(key, out var text)) + return true; + if (!Guid.TryParse(text.ToString(), out var parsed) || parsed == Guid.Empty) + { + error = $"{key} must be an id."; + return false; + } + + id = parsed; + return true; + } + + private static string? NameError(JsonObject node, string property, string error) + where TEnum : struct, Enum => + DrawingSetRequest.Property(node, property) is { } value && !DrawingSetRequest.IsName(value) ? error : null; + + private static void Remove(JsonObject node, params string[] names) + { + foreach (var key in node.Select(p => p.Key) + .Where(k => names.Any(n => string.Equals(k, n, StringComparison.OrdinalIgnoreCase))).ToList()) + node.Remove(key); + } +} diff --git a/OpenNest.Server/ReleaseRoutes.cs b/OpenNest.Server/ReleaseRoutes.cs new file mode 100644 index 00000000..eade8bd6 --- /dev/null +++ b/OpenNest.Server/ReleaseRoutes.cs @@ -0,0 +1,56 @@ +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// /api/releases: append-only records of drawing set releases 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). +/// +internal static class ReleaseRoutes +{ + public static void MapReleases(this IEndpointRouteBuilder app) + { + app.MapPost("/api/releases", async (HttpRequest request, NestDatabase db) => + { + var (json, error) = await DrawingSetRoutes.ReadJson(request, "release"); + if (error is not null) return error; + if (!ReleaseRequest.TryReadRelease(json, out var release, out var invalid)) + return Results.BadRequest(invalid); + if (release.Id == Guid.Empty) + return Results.BadRequest("id is required: allocate it before posting the release."); + try { return Results.Ok(db.InsertRelease(release)); } + catch (SetReleaseConflictException conflict) + { + return Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict); + } + catch (ArgumentException rejected) { return Results.BadRequest(rejected.Message); } + }); + + 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)) + : Results.BadRequest(error)); + + app.MapGet("/api/releases/{id:guid}", (Guid id, NestDatabase db) => + db.GetRelease(id) is { } release ? Results.Ok(release) : Results.NotFound()); + + app.MapPost("/api/releases/{id:guid}/undo", async (Guid id, HttpRequest request, NestDatabase db) => + { + var (json, error) = await DrawingSetRoutes.ReadJson(request, "undo request"); + if (error is not null) return error; + if (!ReleaseRequest.TryReadUndo(json, out var undo, out var invalid)) + return Results.BadRequest(invalid); + try + { + var undone = db.UndoRelease(id, undo); + return undone is null ? Results.NotFound() : Results.Ok(undone); + } + catch (ReleaseUndoRefusedException conflict) + { + return Results.Json(conflict.Conflict, statusCode: StatusCodes.Status409Conflict); + } + catch (ArgumentException rejected) { return Results.BadRequest(rejected.Message); } + }); + } +} diff --git a/OpenNest.Tests/Data/InMemoryDrawingSets.cs b/OpenNest.Tests/Data/InMemoryDrawingSets.cs index 632b270a..fa3e8097 100644 --- a/OpenNest.Tests/Data/InMemoryDrawingSets.cs +++ b/OpenNest.Tests/Data/InMemoryDrawingSets.cs @@ -116,7 +116,7 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore public int PostAttempts { get; private set; } public bool FailNextPost { get; set; } - public Task PostReleaseAsync(Guid setId, SetReleaseRecord release, CancellationToken cancellationToken = default) + public Task PostReleaseAsync(SetReleaseRecord release, CancellationToken cancellationToken = default) { PostAttempts++; if (FailNextPost) @@ -130,10 +130,13 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore var stored = new SetReleaseRecord { Id = release.Id, - SetId = setId, - SetRevision = release.SetRevision, - SetName = _revisions[setId].First(r => r.Revision == release.SetRevision).Name, - Multiplier = release.Multiplier, + Sets = release.Sets.Select(s => new SetReleaseLine + { + SetId = s.SetId, + SetRevision = s.SetRevision, + Quantity = s.Quantity, + SetName = _revisions[s.SetId].First(r => r.Revision == s.SetRevision).Name, + }).ToList(), Notes = release.Notes, ReleasedBy = release.ReleasedBy, ReleasedAt = new DateTime(2026, 10, 20, 15, 0, 0, DateTimeKind.Utc), @@ -143,12 +146,17 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore return Task.FromResult(stored); } - public Task GetReleasesAsync(Guid setId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) + 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) { - if (!_revisions.ContainsKey(setId)) - return Task.FromResult(null); - var newest = Enumerable.Reverse(Releases.Where(r => r.SetId == setId).ToList()).ToList(); - return Task.FromResult(new SetReleasePage + 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)) + .ToList()).ToList(); + return Task.FromResult(new SetReleasePage { Items = newest.Skip(offset).Take(limit).ToList(), Total = newest.Count, @@ -157,8 +165,8 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore }); } - public Task GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default) => - Task.FromResult(Releases.FirstOrDefault(r => r.Nests.Any(n => n.NestId == nestId))); + public Task UndoReleaseAsync(Guid id, SetReleaseUndoRequest request, CancellationToken cancellationToken = default) => + throw new NotSupportedException("The in-memory store does not hold nests, so it cannot undo a release."); private Task SetArchived(Guid id, int expectedRevision, bool archived) { diff --git a/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs b/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs index 56943553..e9d6bece 100644 --- a/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs +++ b/OpenNest.Tests/Data/RemoteDrawingSetStoreTests.cs @@ -44,7 +44,9 @@ public class RemoteDrawingSetStoreTests var query = await Assert.ThrowsAsync(() => store.QueryAsync(new DrawingSetQuery())); await Assert.ThrowsAsync(() => store.GetAsync(Guid.NewGuid())); - await Assert.ThrowsAsync(() => store.GetReleaseForNestAsync(Guid.NewGuid())); + var releases = await Assert.ThrowsAsync(() => store.QueryReleasesAsync(nestId: Guid.NewGuid())); + await Assert.ThrowsAsync(() => store.GetReleaseAsync(Guid.NewGuid())); + Assert.Contains("several drawing sets", releases.Message); Assert.Contains("drawing sets", query.Message); } @@ -69,8 +71,13 @@ public class RemoteDrawingSetStoreTests duplicate.Members = duplicate.Members.Concat(duplicate.Members).ToList(); await Assert.ThrowsAsync(() => store.CreateAsync(duplicate)); - await Assert.ThrowsAsync(() => store.PostReleaseAsync(Guid.NewGuid(), - new SetReleaseRecord { Id = Guid.NewGuid(), SetRevision = 1, Multiplier = 0 })); + await Assert.ThrowsAsync(() => store.PostReleaseAsync(new SetReleaseRecord + { + Id = Guid.NewGuid(), + Sets = new[] { new SetReleaseLine { SetId = Guid.NewGuid(), SetRevision = 1, Quantity = 0 } }, + })); + await Assert.ThrowsAsync(() => store.UndoReleaseAsync(Guid.NewGuid(), + new SetReleaseUndoRequest { SkipNests = new[] { Guid.Empty } })); Assert.Empty(handler.Requests); } @@ -100,19 +107,17 @@ public class RemoteDrawingSetStoreTests var release = new SetReleaseRecord { Id = Guid.NewGuid(), - SetRevision = 1, - Multiplier = 1, - Nests = new[] { new SetReleaseNest { NestId = Guid.NewGuid(), MaterialName = "Synthetic Steel" } }, + Sets = new[] { new SetReleaseLine { SetId = setId, SetRevision = 1, Quantity = 1 } }, + Nests = new[] { new SetReleaseNest { NestId = Guid.NewGuid(), RevisionAfter = 1, MaterialName = "Synthetic Steel" } }, }; var handler = new StubHandler(_ => StubHandler.Json(new { id = release.Id, - setId, - setRevision = 1, - nests = new[] { new { nestId = Guid.NewGuid() } }, + sets = new[] { new { setId, setRevision = 1, quantity = 1 } }, + nests = new[] { new { nestId = Guid.NewGuid(), kind = "created", revisionAfter = 1 } }, })); using var store = Store(handler); - await Assert.ThrowsAsync(() => store.PostReleaseAsync(setId, release)); + await Assert.ThrowsAsync(() => store.PostReleaseAsync(release)); } } diff --git a/OpenNest.Tests/Data/SetReleasePlanTests.cs b/OpenNest.Tests/Data/SetReleasePlanTests.cs index faaf55b3..3ff41666 100644 --- a/OpenNest.Tests/Data/SetReleasePlanTests.cs +++ b/OpenNest.Tests/Data/SetReleasePlanTests.cs @@ -77,7 +77,7 @@ public class SetReleasePlanTests [Theory] [InlineData(0)] - [InlineData(SetReleaseRules.MaxMultiplier + 1)] + [InlineData(SetReleaseRules.MaxQuantity + 1)] public void Build_RejectsMultiplierOutOfRange(int multiplier) { Assert.Throws(() => SetReleasePlan.Build(Set((Drawing("SYN-A"), 1)), multiplier, false)); diff --git a/OpenNest.Tests/Data/SetReleaseSessionTests.cs b/OpenNest.Tests/Data/SetReleaseSessionTests.cs index 3522f5e7..b58822e7 100644 --- a/OpenNest.Tests/Data/SetReleaseSessionTests.cs +++ b/OpenNest.Tests/Data/SetReleaseSessionTests.cs @@ -81,9 +81,16 @@ public class SetReleaseSessionTests var release = Assert.Single(_sets.Posted); Assert.Same(result.Release, _sets.Stored.Single()); + var line = Assert.Single(release.Sets); Assert.Equal((set.Id, 4, 2, "Synthetic operator", "Synthetic notes"), - (release.SetId, release.SetRevision, release.Multiplier, release.ReleasedBy, release.Notes)); + (line.SetId, line.SetRevision, line.Quantity, release.ReleasedBy, release.Notes)); Assert.Equal(result.Groups.Select(g => g.Record!.Id), release.Nests.Select(n => n.NestId)); + Assert.All(release.Nests, n => Assert.Equal((SetReleaseNestKind.Created, (int?)null), (n.Kind, n.RevisionBefore))); + Assert.Equal(result.Groups.Select(g => g.Record!.Revision), release.Nests.Select(n => n.RevisionAfter)); + var steelItems = release.Nests.Single(n => n.NestId == steel.Record!.Id).Items; + Assert.Equal(new[] { (a.Id, a.Revision, 6), (b.Id, b.Revision, 2) }, + steelItems.OrderBy(i => i.Quantity).Reverse().Select(i => (i.DrawingId, i.DrawingRevision, i.Quantity))); + Assert.All(steelItems, i => Assert.Equal(set.Id, i.SetId)); 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)); @@ -389,7 +396,7 @@ public class SetReleaseSessionTests public Queue Failures { get; } = new(); public bool ConflictWithPosted { get; set; } - public Task PostReleaseAsync(Guid setId, SetReleaseRecord release, CancellationToken cancellationToken = default) + public Task PostReleaseAsync(SetReleaseRecord release, CancellationToken cancellationToken = default) { Posted.Add(release); if (Failures.TryDequeue(out var failure)) @@ -397,10 +404,13 @@ public class SetReleaseSessionTests var stored = new SetReleaseRecord { Id = release.Id, - SetId = setId, - SetRevision = release.SetRevision, - Multiplier = release.Multiplier, - SetName = "Synthetic kit", + Sets = release.Sets.Select(s => new SetReleaseLine + { + SetId = s.SetId, + SetRevision = s.SetRevision, + Quantity = s.Quantity, + SetName = "Synthetic kit", + }).ToList(), Nests = release.Nests, }; if (ConflictWithPosted) @@ -418,7 +428,8 @@ public class SetReleaseSessionTests public Task UpdateAsync(Guid id, DrawingSetRecord record, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task ArchiveAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task GetReleasesAsync(Guid setId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + 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 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 5c73a37a..04d953e8 100644 --- a/OpenNest.Tests/Data/SetReleaseTextTests.cs +++ b/OpenNest.Tests/Data/SetReleaseTextTests.cs @@ -46,15 +46,35 @@ public class SetReleaseTextTests { var text = SetReleaseText.Origin(new SetReleaseRecord { - SetName = "Synthetic kit", - SetRevision = 3, - Multiplier = 2, + Sets = new[] { new SetReleaseLine { SetName = "Synthetic kit", SetRevision = 3, Quantity = 2 } }, ReleasedBy = "Synthetic operator", }); Assert.Equal("Released from set 'Synthetic kit' revision 3 ×2 by Synthetic operator", text); } + [Fact] + public void Origin_OfAMergeOfSeveralSets_NamesThemAll_AndAnUndo() + { + var nest = Guid.NewGuid(); + var release = new SetReleaseRecord + { + Sets = new[] + { + new SetReleaseLine { SetName = "Kit A", SetRevision = 3, Quantity = 2 }, + new SetReleaseLine { SetName = "Kit B", SetRevision = 1, Quantity = 1 }, + new SetReleaseLine { SetName = "Kit C", SetRevision = 5, Quantity = 4 }, + }, + Nests = new[] { new SetReleaseNest { NestId = nest, Kind = SetReleaseNestKind.Merged } }, + UndoneAt = new DateTime(2026, 10, 21, 9, 0, 0, DateTimeKind.Utc), + }; + + Assert.Equal("Merged from sets 'Kit A' revision 3 ×2, 'Kit B' revision 1 ×1 and 'Kit C' revision 5 ×4 (undone)", + SetReleaseText.Origin(release, nest)); + Assert.StartsWith("Released from sets", SetReleaseText.Origin(release)); + Assert.Equal("Undone", SetReleaseText.Progress(release)); + } + [Fact] public void NestDefaults_ConvertedTo_ScalesPlateDefaultsBetweenUnits() { diff --git a/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs b/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs index beb5cd5e..1c4e4650 100644 --- a/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs +++ b/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs @@ -59,7 +59,7 @@ public class SetReleaseFormsTests Assert.Equal(steel.Record.Id, nests.Uploaded.Single(u => u.Name == steel.Nest.Name).Id); var release = Assert.Single(sets.Releases); - Assert.Equal((3, "Synthetic run", 2), (release.Multiplier, release.Notes, release.Nests.Count)); + Assert.Equal((3, "Synthetic run", 2), (release.Sets.Single().Quantity, release.Notes, release.Nests.Count)); Assert.Same(release, form.Result!.Release); Assert.StartsWith("Released: 2 nests", Field