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, Func>? renest = null) => new(_sets, _library, _nests, Server + "/", ReadPackage, nest => Encoding.UTF8.GetBytes("nest " + nest.Name), createNest ?? CreateNest, bytes => _nests.Archives[Encoding.UTF8.GetString(bytes)], renest); // A draft on the fake server holding `existing` (copied in from the library) on one placed sheet. private (NestRecord Record, Nest Nest) Draft(DrawingLibraryRecord existing, int quantity = 2, int revision = 3) { var nest = new Nest("Draft 7") { Units = Units.Inches, Material = new Material("Synthetic Steel", "Grade A", 0.28), Thickness = 0.25, Status = NestStatus.Draft, }; var package = ReadPackage(_library.Packages[(existing.Id, existing.Revision)].Bytes); LibraryDrawingInsert.Plan(nest, Server, existing, package, quantity).Apply(); nest.CreatePlate().Parts.Add(new Part(nest.Drawings.Single())); var record = new NestRecord { Id = Guid.NewGuid(), Revision = revision, Name = nest.Name, Material = "Synthetic Steel", MaterialGrade = "Grade A", Thickness = 0.25, Status = NestStatus.Draft, }; _nests.AddDraft(record, nest); return (record, nest); } 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()); var line = Assert.Single(release.Sets); Assert.Equal((set.Id, 4, 2, "Synthetic operator", "Synthetic 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)); 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, "", "")); } [Fact] public async Task Run_OfTwoSets_MergesIntoADraft_ClearsItsSheets_NestsIt_AndRecordsTheMerge() { var a = Stored("SYN-A"); var b = Stored("SYN-B"); var first = Set((a, 1), (b, 1)); var second = Set((a, 2)); var (draft, _) = Draft(a); var plan = SetReleasePlan.Build(new[] { new SetReleasePlanLine(first, 2), new SetReleasePlanLine(second, 1) }, false); var group = Assert.Single(plan.Groups); var platesSeen = -1; var session = Session(renest: (nest, _) => { platesSeen = nest.Plates.Count; nest.CreatePlate().Parts.Add(new Part(nest.Drawings.First())); return Task.FromResult(new SetReleaseRenest(true, "2 copies did not fit.")); }); var result = await session.RunAsync(plan, plan.Groups, "Synthetic operator", "", mergeInto: new Dictionary { [group] = draft }); Assert.Equal("", result.ReleaseError); var merged = Assert.Single(result.Created); Assert.Same(draft, merged.MergedInto); Assert.Equal((draft.Id, 4), (merged.Record!.Id, merged.Record.Revision)); Assert.Empty(_created); Assert.Empty(_nests.Uploaded); Assert.Equal(new[] { (draft.Id, 3) }, _nests.Merges.Select(m => (m.Id, m.ExpectedRevision))); Assert.Equal(0, platesSeen); var nestAfter = merged.Nest!; Assert.Equal(new[] { ("SYN-A", 6), ("SYN-B", 2) }, nestAfter.Drawings.OrderBy(d => d.Name).Select(d => (d.Name, d.Quantity.Required))); Assert.Single(nestAfter.Plates); Assert.Equal((true, "2 copies did not fit."), (merged.Renest!.Nested, merged.Renest.Note)); Assert.Contains("2 copies did not fit.", merged.Warnings); var release = Assert.Single(_sets.Posted); Assert.Equal(new[] { (first.Id, 2), (second.Id, 1) }, release.Sets.Select(l => (l.SetId, l.Quantity))); var nestLine = Assert.Single(release.Nests); Assert.Equal((SetReleaseNestKind.Merged, (int?)3, 4), (nestLine.Kind, nestLine.RevisionBefore, nestLine.RevisionAfter)); Assert.Equal(new[] { (first.Id, a.Id, 2), (second.Id, a.Id, 2), (first.Id, b.Id, 2) }, nestLine.Items.Select(i => (i.SetId, i.DrawingId, i.Quantity))); } [Fact] public async Task Run_MergeIntoADraftWithAnotherRevisionOfADrawing_IsRefusedBeforeAnythingIsSaved() { var older = Stored("SYN-A"); var (draft, _) = Draft(older); var newer = Stored("SYN-A"); newer.Id = older.Id; newer.Revision = older.Revision + 1; _library.Packages[(newer.Id, newer.Revision)] = (Encoding.UTF8.GetBytes($"package {newer.Id} r{newer.Revision}"), Units.Inches); newer.PackageSha256 = Convert.ToHexString(SHA256.HashData(_library.Packages[(newer.Id, newer.Revision)].Bytes)).ToLowerInvariant(); var plan = SetReleasePlan.Build(Set((newer, 1), (Stored("SYN-NEW"), 1)), 1, false); var result = await Session().RunAsync(plan, plan.Groups, "", "", mergeInto: new Dictionary { [plan.Groups[0]] = draft }); var group = Assert.Single(result.Groups); Assert.Equal(SetReleaseGroupOutcome.Refused, group.Outcome); Assert.Contains("Update Drawings", group.Error); Assert.Empty(_nests.Merges); Assert.Empty(_sets.Posted); } [Fact] public async Task Run_MergeIntoADraftSavedMeanwhile_FailsThatGroup_AndRecordsNoRelease() { var a = Stored("SYN-A"); var (draft, nest) = Draft(a); var plan = SetReleasePlan.Build(Set((a, 1)), 1, false); _nests.SaveBeforeNextMerge = true; var result = await Session().RunAsync(plan, plan.Groups, "", "", mergeInto: new Dictionary { [plan.Groups[0]] = draft }); var group = Assert.Single(result.Groups); Assert.Equal(SetReleaseGroupOutcome.Failed, group.Outcome); Assert.Contains("Someone saved it since you chose it (now revision 4)", group.Error); Assert.Empty(_sets.Posted); Assert.Null(result.Release); } [Fact] public async Task Run_MergeChecks_RefuseAWrongTargetBeforeStarting() { var a = Stored("SYN-A"); var (draft, _) = Draft(a); var plan = SetReleasePlan.Build(Set((a, 1), (Stored("SYN-B", material: "Synthetic Aluminum"), 1)), 1, false); var noReader = new SetReleaseSession(_sets, _library, _nests, Server, ReadPackage, n => Array.Empty(), CreateNest); await Assert.ThrowsAsync(() => Session().RunAsync(plan, new[] { plan.Groups[0] }, "", "", mergeInto: new Dictionary { [plan.Groups[1]] = draft })); await Assert.ThrowsAsync(() => Session().RunAsync(plan, plan.Groups, "", "", mergeInto: new Dictionary { [plan.Groups[0]] = draft, [plan.Groups[1]] = draft })); await Assert.ThrowsAsync(() => noReader.RunAsync(plan, plan.Groups, "", "", mergeInto: new Dictionary { [plan.Groups[0]] = draft })); // A draft of the aluminum group's material is refused once downloaded. var wrong = await Session().RunAsync(plan, plan.Groups, "", "", mergeInto: new Dictionary { [plan.Groups.Single(g => g.MaterialName == "Synthetic Aluminum")] = draft }); Assert.Contains(wrong.Groups, g => g.Outcome == SetReleaseGroupOutcome.Refused && g.Error.Contains("is not Synthetic Aluminum")); Assert.Empty(_nests.Merges); } [Fact] public async Task Run_WhenNestingAfterReleaseFails_StillReleasesTheNestUnnested() { var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false); var session = Session(renest: (nest, _) => { nest.CreatePlate(); throw new InvalidOperationException("Synthetic engine failure."); }); var result = await session.RunAsync(plan, plan.Groups, "", ""); var created = Assert.Single(result.Created); Assert.Equal((false, "Auto Nest failed: Synthetic engine failure."), (created.Renest!.Nested, created.Renest.Note)); Assert.Empty(created.Nest!.Plates); Assert.Single(_sets.Posted); } [Fact] public async Task Run_CanceledWhileNestingAfterRelease_UploadsNothing() { var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1), (Stored("SYN-B", cutType: CutType.Plasma), 1)), 1, false); using var cancel = new CancellationTokenSource(); var progress = new RecordingProgress(); var session = Session(renest: (_, token) => { cancel.Cancel(); token.ThrowIfCancellationRequested(); return Task.FromResult(new SetReleaseRenest(true, "")); }); var result = await session.RunAsync(plan, plan.Groups, "", "", progress, cancel.Token); Assert.True(result.Canceled); Assert.Equal(new[] { (SetReleaseStage.Preparing, 0), (SetReleaseStage.Nesting, 0) }, progress.Reports.Select(p => (p.Stage, p.Index))); Assert.All(result.Groups, g => Assert.Equal(SetReleaseGroupOutcome.NotStarted, g.Outcome)); Assert.Empty(_nests.Uploaded); Assert.Empty(_sets.Posted); } [Fact] public async Task FindDrafts_AsksForDraftsOfTheGroup() { var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false); await SetReleasePlan.FindDraftsAsync(_nests, plan.Groups[0]); var query = Assert.Single(_nests.Queries); Assert.Equal((NestStatus.Draft, "Synthetic Steel", "Grade A", 0.25), (query.Status, query.Material, query.MaterialGrade, query.Thickness)); } 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); } /// Nests a test reader returns, keyed by their (fake) archive text. public Dictionary Archives { get; } = new(); private readonly Dictionary<(Guid, int), byte[]> _revisions = new(); public List<(Guid Id, int ExpectedRevision)> Merges { get; } = new(); public List Queries { get; } = new(); public bool SaveBeforeNextMerge { get; set; } public void AddDraft(NestRecord record, Nest nest) { var key = $"draft {record.Id} r{record.Revision}"; Archives[key] = nest; _revisions[(record.Id, record.Revision)] = Encoding.UTF8.GetBytes(key); _stored[record.Id] = record; } public Task GetRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default) => Task.FromResult(_revisions.TryGetValue((id, revisionNumber), out var bytes) ? bytes : null); public Task UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision, CancellationToken cancellationToken = default) { var current = _stored[id]; if (SaveBeforeNextMerge) { SaveBeforeNextMerge = false; current = _stored[id] = new NestRecord { Id = id, Name = current.Name, Revision = current.Revision + 1, Status = NestStatus.Draft }; _revisions[(id, current.Revision)] = Encoding.UTF8.GetBytes("someone else's save"); } if (current.Revision != expectedRevision) throw new IOException("Could not update the nest: HTTP 409 Conflict."); Merges.Add((id, expectedRevision)); var saved = new NestRecord { Id = id, Name = record.Name, Revision = current.Revision + 1, Status = record.Status }; _stored[id] = saved; _revisions[(id, saved.Revision)] = nestFile; return Task.FromResult(saved); } public Task QueryAsync(NestQuery query, CancellationToken ct = default) { Queries.Add(query); return Task.FromResult(new NestPage { Items = Array.Empty(), Limit = query.Limit }); } public Task GetFileAsync(Guid id, CancellationToken ct = default) => Task.FromResult(_stored.TryGetValue(id, out var record) && _revisions.TryGetValue((id, record.Revision), out var bytes) ? bytes : null); public Task> ListAsync(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(SetReleaseRecord release, CancellationToken cancellationToken = default) { Posted.Add(release); if (Failures.TryDequeue(out var failure)) throw failure; var stored = new SetReleaseRecord { Id = release.Id, 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) 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 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(); } }