Files
OpenNest/OpenNest.Tests/Data/SetReleaseSessionTests.cs
T
ajandClaude Opus 5.5 af6975b9f1 feat(data): add the drawing set client and release session
The desktop needs a cross-platform way to read and save sets and to turn
one into nests before any UI is built on it. RemoteDrawingSetStore covers
every /api/sets route with the library client's checks and conflict
handling. SetReleasePlan groups a set by material, grade, thickness, units
and cut type without I/O, so the release dialog can preview refusals
(duplicate names, drawings without material) before anything is created.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:48:23 -04:00

425 lines
20 KiB
C#

using System.Security.Cryptography;
using System.Text;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Data;
using OpenNest.Geometry;
using OpenNest.Shapes;
using static OpenNest.Tests.Data.SetReleasePlanTests;
namespace OpenNest.Tests.Data;
public class SetReleaseSessionTests
{
private const string Server = "http://library.test:8090";
private readonly FakeSets _sets = new();
private readonly FakeLibrary _library = new();
private readonly FakeNests _nests = new();
private readonly List<SetReleaseGroup> _created = new();
private DrawingLibraryRecord Stored(string name, string material = "Synthetic Steel", CutType cutType = CutType.Laser,
Units units = Units.Inches)
{
var record = Drawing(name, material: material, cutType: cutType, units: units);
var bytes = Encoding.UTF8.GetBytes($"package {record.Id}");
record.PackageSha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
_library.Packages[(record.Id, record.Revision)] = (bytes, units);
return record;
}
private SetReleaseSession Session(Func<SetReleaseGroup, CancellationToken, Task<Nest>>? createNest = null) =>
new(_sets, _library, _nests, Server + "/", ReadPackage, nest => Encoding.UTF8.GetBytes("nest " + nest.Name),
createNest ?? CreateNest);
private Task<Nest> CreateNest(SetReleaseGroup group, CancellationToken cancellationToken)
{
_created.Add(group);
return Task.FromResult(new Nest($"N{_created.Count}") { Units = group.Units, Material = new Material("Default", "", 0.1) });
}
private LibraryPackage ReadPackage(byte[] bytes)
{
var (_, units) = _library.Packages.Values.Single(p => p.Bytes.AsSpan().SequenceEqual(bytes));
var drawing = new RectangleShape { Name = "package name", Length = 4, Width = 2 }.GetDrawing();
drawing.CutPins.Add(new ContourCutPin(new Vector(4, 2), new LineLeadIn { Length = 0.25 }, new NoLeadOut(),
new NormalTab { Size = 0.03 }));
return new LibraryPackage(bytes, drawing, units);
}
[Fact]
public async Task Run_CreatesOneDraftNestPerGroupThenRecordsTheRelease()
{
var a = Stored("SYN-A");
var b = Stored("SYN-B");
var c = Stored("SYN-C", material: "Synthetic Aluminum", cutType: CutType.Waterjet);
var set = Set((a, 3), (b, 1), (c, 2));
var plan = SetReleasePlan.Build(set, 2, false);
var progress = new RecordingProgress();
var session = Session();
var result = await session.RunAsync(plan, plan.Groups, "Synthetic operator", "Synthetic notes", progress);
Assert.False(result.Canceled);
Assert.Equal("", result.ReleaseError);
Assert.Equal(2, result.Created.Count);
Assert.Equal(2, _nests.Uploaded.Count);
var steel = result.Groups.Single(g => g.Group.MaterialName == "Synthetic Steel");
var nest = steel.Nest!;
Assert.Equal(NestStatus.Draft, nest.Status);
Assert.Equal(("Synthetic Steel", "Grade A", 0.25), (nest.Material.Name, nest.Material.Grade, nest.Thickness));
Assert.Equal("Synthetic set customer", nest.Customer);
Assert.Equal(new[] { ("SYN-A", 6), ("SYN-B", 2) },
nest.Drawings.OrderBy(d => d.Name).Select(d => (d.Name, d.Quantity.Required)));
Assert.All(nest.Drawings, d => Assert.Equal(Server, d.Library!.ServerKey));
Assert.Equal(a.Revision, nest.Drawings.Single(d => d.Name == "SYN-A").Library!.Revision);
// Material matches the new nest by construction, so the library pins are kept.
Assert.All(nest.Drawings, d => Assert.Single(d.CutPins));
Assert.Equal(result.Groups.Select(g => g.Record!.Id), _nests.Uploaded.Select(u => u.Record.Id));
var uploaded = _nests.Uploaded.Single(u => u.Record.Id == steel.Record!.Id).Record;
Assert.Equal((NestStatus.Draft, "Synthetic Steel", "N2"), (uploaded.Status, uploaded.Material, uploaded.Name));
var release = Assert.Single(_sets.Posted);
Assert.Same(result.Release, _sets.Stored.Single());
Assert.Equal((set.Id, 4, 2, "Synthetic operator", "Synthetic notes"),
(release.SetId, release.SetRevision, release.Multiplier, release.ReleasedBy, release.Notes));
Assert.Equal(result.Groups.Select(g => g.Record!.Id), release.Nests.Select(n => n.NestId));
var aluminum = release.Nests.Single(n => n.MaterialName == "Synthetic Aluminum");
Assert.Equal(("Grade A", 0.25, Units.Inches, CutType.Waterjet),
(aluminum.MaterialGrade, aluminum.Thickness, aluminum.Units, aluminum.CutType));
Assert.Equal(new[]
{
(SetReleaseStage.Preparing, 0), (SetReleaseStage.Preparing, 1),
(SetReleaseStage.Uploading, 0), (SetReleaseStage.Uploading, 1), (SetReleaseStage.Recording, 2),
}, progress.Reports.Select(p => (p.Stage, p.Index)));
Assert.False(session.HasPendingRelease);
}
[Fact]
public async Task Run_OnlyChosenGroups_AreReleased()
{
var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1), (Stored("SYN-B", cutType: CutType.Plasma), 1)), 1, false);
var chosen = plan.Groups.Single(g => g.CutType == CutType.Plasma);
var result = await Session().RunAsync(plan, new[] { chosen }, "", "");
Assert.Same(chosen, Assert.Single(result.Groups).Group);
Assert.Equal(CutType.Plasma, Assert.Single(Assert.Single(_sets.Posted).Nests).CutType);
}
[Fact]
public async Task Run_PackageThatNoLongerMatchesItsRecord_RefusesBeforeAnythingIsUploaded()
{
var a = Stored("SYN-A");
var b = Stored("SYN-B", material: "Synthetic Aluminum");
// The drawing moved on after the set was read: the bytes no longer hash to the record.
var (bytes, units) = _library.Packages[(b.Id, b.Revision)];
_library.Packages[(b.Id, b.Revision)] = (bytes.Append((byte)1).ToArray(), units);
var plan = SetReleasePlan.Build(Set((a, 1), (b, 1)), 1, false);
var result = await Session().RunAsync(plan, plan.Groups, "", "");
var refused = result.Groups.Single(g => g.Group.MaterialName == "Synthetic Aluminum");
Assert.Equal(SetReleaseGroupOutcome.Refused, refused.Outcome);
Assert.Contains("does not match the library record", refused.Error);
Assert.Equal(SetReleaseGroupOutcome.NotStarted, result.Groups.Single(g => g != refused).Outcome);
Assert.All(result.Groups, g => Assert.Null(g.Nest));
Assert.Empty(_nests.Uploaded);
Assert.Empty(_sets.Posted);
Assert.Null(result.Release);
}
[Fact]
public async Task Run_MissingRevision_RefusesTheGroup()
{
var a = Stored("SYN-A");
_library.Packages.Clear();
var plan = SetReleasePlan.Build(Set((a, 1)), 1, false);
var result = await Session().RunAsync(plan, plan.Groups, "", "");
Assert.Equal(SetReleaseGroupOutcome.Refused, result.Groups[0].Outcome);
Assert.Contains("no longer in the library", result.Groups[0].Error);
Assert.Empty(_nests.Uploaded);
}
[Fact]
public async Task Run_FactoryNestInOtherUnits_RefusesTheGroup()
{
var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false);
var session = Session((_, _) => Task.FromResult(new Nest("N1") { Units = Units.Millimeters }));
var result = await session.RunAsync(plan, plan.Groups, "", "");
Assert.Equal(SetReleaseGroupOutcome.Refused, result.Groups[0].Outcome);
Assert.Contains("is in millimeters, not inches", result.Groups[0].Error);
Assert.Empty(_nests.Uploaded);
}
[Fact]
public async Task Run_DownloadFailure_FailsBeforeAnythingIsUploaded()
{
var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false);
_library.Failure = new IOException("Synthetic network failure.");
var result = await Session().RunAsync(plan, plan.Groups, "", "");
Assert.Equal(SetReleaseGroupOutcome.Failed, result.Groups[0].Outcome);
Assert.Contains("Synthetic network failure.", result.Groups[0].Error);
Assert.Empty(_nests.Uploaded);
Assert.Empty(_sets.Posted);
}
[Fact]
public async Task Run_UploadFailurePartWay_ReportsCreatedNestsAndRecordsNoRelease()
{
var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1), (Stored("SYN-B", material: "Synthetic Aluminum"), 1),
(Stored("SYN-C", cutType: CutType.Plasma), 1)), 1, false);
_nests.FailUploadNumber = 2;
var result = await Session().RunAsync(plan, plan.Groups, "", "");
Assert.Equal(new[] { SetReleaseGroupOutcome.Created, SetReleaseGroupOutcome.Failed, SetReleaseGroupOutcome.NotStarted },
result.Groups.Select(g => g.Outcome));
Assert.Equal(_nests.Uploaded[0].Record.Id, Assert.Single(result.Created).Record!.Id);
Assert.Contains("Synthetic upload failure.", result.Groups[1].Error);
Assert.Contains("It was not saved.", result.Groups[1].Error);
Assert.NotNull(result.Groups[1].Nest);
Assert.Null(result.Groups[2].Nest);
Assert.Empty(_sets.Posted);
Assert.Null(result.Release);
Assert.False(result.Canceled);
}
[Fact]
public async Task Run_UploadStoredBeforeItsReplyWasLost_CountsAsCreated()
{
var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false);
_nests.FailUploadNumber = 1;
_nests.StoreFailedUpload = true;
var result = await Session().RunAsync(plan, plan.Groups, "", "");
Assert.Equal(SetReleaseGroupOutcome.Created, result.Groups[0].Outcome);
Assert.Equal(_nests.Uploaded[0].Record.Id, Assert.Single(Assert.Single(_sets.Posted).Nests).NestId);
}
[Fact]
public async Task Run_UploadFailureThatCannotBeChecked_SaysTheNestMayExist()
{
var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false);
_nests.FailUploadNumber = 1;
_nests.MetadataFailure = new HttpRequestException("Synthetic offline.");
var result = await Session().RunAsync(plan, plan.Groups, "", "");
Assert.Equal(SetReleaseGroupOutcome.Failed, result.Groups[0].Outcome);
Assert.Contains("may have been saved as 'N1'", result.Groups[0].Error);
}
[Fact]
public async Task Run_CanceledDuringAnUpload_FinishesItThenStops()
{
var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1), (Stored("SYN-B", material: "Synthetic Aluminum"), 1)), 1, false);
using var cancel = new CancellationTokenSource();
var progress = new RecordingProgress(p =>
{
if (p.Stage == SetReleaseStage.Uploading)
cancel.Cancel();
});
var result = await Session().RunAsync(plan, plan.Groups, "", "", progress, cancel.Token);
Assert.True(result.Canceled);
Assert.Equal(new[] { SetReleaseGroupOutcome.Created, SetReleaseGroupOutcome.NotStarted }, result.Groups.Select(g => g.Outcome));
Assert.Single(_nests.Uploaded);
Assert.False(_nests.UploadTokens.Single().CanBeCanceled);
Assert.Empty(_sets.Posted);
}
[Fact]
public async Task Run_CanceledWhilePreparing_CreatesNothing()
{
var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false);
using var cancel = new CancellationTokenSource();
cancel.Cancel();
var result = await Session().RunAsync(plan, plan.Groups, "", "", null, cancel.Token);
Assert.True(result.Canceled);
Assert.Equal(SetReleaseGroupOutcome.NotStarted, result.Groups[0].Outcome);
Assert.Empty(_nests.Uploaded);
}
[Fact]
public async Task Run_ReleasePostFailure_KeepsTheNestsAndRetriesUnderTheSameId()
{
var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false);
_sets.Failures.Enqueue(new IOException("Synthetic post failure."));
var session = Session();
var result = await session.RunAsync(plan, plan.Groups, "", "");
Assert.Null(result.Release);
Assert.Contains("Synthetic post failure.", result.ReleaseError);
Assert.Single(result.Created);
Assert.True(session.HasPendingRelease);
var stored = await session.RetryReleaseAsync();
Assert.Equal(2, _sets.Posted.Count);
Assert.Equal(_sets.Posted[0].Id, _sets.Posted[1].Id);
Assert.Equal(_sets.Posted[0].Id, stored.Id);
Assert.False(session.HasPendingRelease);
await Assert.ThrowsAsync<InvalidOperationException>(() => session.RetryReleaseAsync());
}
[Fact]
public async Task Run_ReleaseAlreadyStoredUnderItsId_IsAdopted()
{
var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false);
_sets.ConflictWithPosted = true;
var result = await Session().RunAsync(plan, plan.Groups, "", "");
Assert.Equal(_sets.Posted.Single().Id, result.Release!.Id);
Assert.Equal("", result.ReleaseError);
}
[Fact]
public async Task Run_RejectsGroupsItCannotRelease()
{
var a = Stored("SYN-A");
var plan = SetReleasePlan.Build(Set((a, 1), (Stored("SYN-A"), 1), (Stored("SYN-B", cutType: CutType.Plasma), 1)), 1, false);
var other = SetReleasePlan.Build(Set((a, 1)), 1, false);
var refused = plan.Groups.Single(g => !g.CanRelease);
var session = Session();
await Assert.ThrowsAsync<ArgumentException>(() => session.RunAsync(plan, Array.Empty<SetReleaseGroup>(), "", ""));
await Assert.ThrowsAsync<ArgumentException>(() => session.RunAsync(plan, new[] { refused }, "", ""));
await Assert.ThrowsAsync<ArgumentException>(() => session.RunAsync(plan, other.Groups, "", ""));
await Assert.ThrowsAsync<ArgumentException>(() => session.RunAsync(plan, new[] { plan.Groups[0], plan.Groups[0] }, "", ""));
await Assert.ThrowsAsync<ArgumentException>(() => session.RunAsync(plan, new[] { plan.Groups[0] }, "", "bad\0notes"));
Assert.Empty(_created);
var releasable = plan.Groups.Where(g => g.CanRelease).ToList();
await session.RunAsync(plan, releasable, "", "");
await Assert.ThrowsAsync<InvalidOperationException>(() => session.RunAsync(plan, releasable, "", ""));
}
private sealed class RecordingProgress(Action<SetReleaseProgress>? onReport = null) : IProgress<SetReleaseProgress>
{
public List<SetReleaseProgress> Reports { get; } = new();
public void Report(SetReleaseProgress value)
{
Reports.Add(value);
onReport?.Invoke(value);
}
}
private sealed class FakeLibrary : IDrawingLibrary
{
public Dictionary<(Guid, int), (byte[] Bytes, Units Units)> Packages { get; } = new();
public Exception? Failure { get; set; }
public Task<byte[]?> GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (Failure != null)
throw Failure;
return Task.FromResult(Packages.TryGetValue((id, revision), out var package) ? package.Bytes : null);
}
public Task<DrawingLibraryPage> QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
private sealed class FakeNests : INestRepository
{
private readonly Dictionary<Guid, NestRecord> _stored = new();
public List<(byte[] File, NestRecord Record)> Uploaded { get; } = new();
public List<CancellationToken> UploadTokens { get; } = new();
public int FailUploadNumber { get; set; }
public bool StoreFailedUpload { get; set; }
public Exception? MetadataFailure { get; set; }
public Task<NestRecord> UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default)
{
UploadTokens.Add(ct);
var failing = UploadTokens.Count == FailUploadNumber;
var stored = new NestRecord
{
Id = record.Id,
Revision = 1,
Name = record.Name,
Status = record.Status,
Material = record.Material,
};
if (!failing || StoreFailedUpload)
{
Uploaded.Add((file, record));
_stored[record.Id] = stored;
}
return failing ? throw new IOException("Synthetic upload failure.") : Task.FromResult(stored);
}
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default)
{
if (MetadataFailure != null)
throw MetadataFailure;
return Task.FromResult(_stored.TryGetValue(id, out var record) ? record : null);
}
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotSupportedException();
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
}
private sealed class FakeSets : IDrawingSetStore
{
public List<SetReleaseRecord> Posted { get; } = new();
public List<SetReleaseRecord> Stored { get; } = new();
public Queue<Exception> Failures { get; } = new();
public bool ConflictWithPosted { get; set; }
public Task<SetReleaseRecord> PostReleaseAsync(Guid setId, SetReleaseRecord release, CancellationToken cancellationToken = default)
{
Posted.Add(release);
if (Failures.TryDequeue(out var failure))
throw failure;
var stored = new SetReleaseRecord
{
Id = release.Id,
SetId = setId,
SetRevision = release.SetRevision,
Multiplier = release.Multiplier,
SetName = "Synthetic kit",
Nests = release.Nests,
};
if (ConflictWithPosted)
throw new SetReleasePostConflictException(stored);
Stored.Add(stored);
return Task.FromResult(stored);
}
public Task<DrawingSetPage> QueryAsync(DrawingSetQuery query, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord?> GetAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetPage?> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord?> GetRevisionAsync(Guid id, int revision, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetPage?> GetSetsContainingAsync(Guid drawingId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord> CreateAsync(DrawingSetRecord record, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord> UpdateAsync(Guid id, DrawingSetRecord record, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord> ArchiveAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord> RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<SetReleasePage?> GetReleasesAsync(Guid setId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<SetReleaseRecord?> GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
}