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 _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>? createNest = null) => new(_sets, _library, _nests, Server + "/", ReadPackage, nest => Encoding.UTF8.GetBytes("nest " + nest.Name), createNest ?? CreateNest); private Task 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(() => 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(() => session.RunAsync(plan, Array.Empty(), "", "")); await Assert.ThrowsAsync(() => session.RunAsync(plan, new[] { refused }, "", "")); await Assert.ThrowsAsync(() => session.RunAsync(plan, other.Groups, "", "")); await Assert.ThrowsAsync(() => session.RunAsync(plan, new[] { plan.Groups[0], plan.Groups[0] }, "", "")); await Assert.ThrowsAsync(() => 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(() => session.RunAsync(plan, releasable, "", "")); } private sealed class RecordingProgress(Action? onReport = null) : IProgress { public List 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 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 QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class FakeNests : INestRepository { private readonly Dictionary _stored = new(); public List<(byte[] File, NestRecord Record)> Uploaded { get; } = new(); public List UploadTokens { get; } = new(); public int FailUploadNumber { get; set; } public bool StoreFailedUpload { get; set; } public Exception? MetadataFailure { get; set; } public Task 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 GetMetadataAsync(Guid id, CancellationToken ct = default) { if (MetadataFailure != null) throw MetadataFailure; return Task.FromResult(_stored.TryGetValue(id, out var record) ? record : null); } public Task> ListAsync(CancellationToken ct = default) => throw new NotSupportedException(); public Task QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotSupportedException(); public Task GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); public Task UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException(); public Task 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 Posted { get; } = new(); public List Stored { get; } = new(); public Queue Failures { get; } = new(); public bool ConflictWithPosted { get; set; } public Task 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 QueryAsync(DrawingSetQuery query, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetRevisionAsync(Guid id, int revision, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetSetsContainingAsync(Guid drawingId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task CreateAsync(DrawingSetRecord record, CancellationToken cancellationToken = default) => throw new NotSupportedException(); 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(); } }