using System.Net; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using OpenNest.Api; using OpenNest.Data; using OpenNest.Engine; using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Remote; using OpenNest.Geometry; using OpenNest.Server.Worker; namespace OpenNest.Server.Tests; /// /// Server nesting over HTTP: the desktop's client submits a real job package, the worker /// solves it through the in-memory server, and the desktop replays the result against its own /// drawings. Queue rules themselves are pinned by . /// public class NestJobApiTests { private static OpenNest.CNC.Program Rectangle(double width, double length) { var program = new OpenNest.CNC.Program(); program.MoveTo(0, 0); program.LineTo(width, 0); program.LineTo(width, length); program.LineTo(0, length); program.LineTo(0, 0); return program; } private static NestPipelineRequest Request(string engine = "Rectangles") { var template = new Plate(48, 96) { EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25), PartSpacing = 0.25 }; return new NestPipelineRequest(engine, new[] { new NestItem { Drawing = new Drawing("bracket", Rectangle(10, 20)), Quantity = 9 }, new NestItem { Drawing = new Drawing("gusset", Rectangle(6.125, 6.125)), Quantity = 14, Priority = 2 }, }, NestStockBuilder.FromTemplate(template, null), new NestJobOptions(maxPlates: 5)); } private static (RemoteNestJobs Jobs, NestJobWorker Worker) Connect(ServerFactory factory) { var http = factory.CreateClient(); var jobs = new RemoteNestJobs(http, http.BaseAddress!.ToString()); return (jobs, new NestJobWorker(jobs, NullLogger.Instance)); } private static NestJobSubmission Describe(NestPipelineRequest request) => new() { Title = "N26-0042", EngineName = request.EngineName, SubmittedBy = "aj@shop-pc" }; [Fact] public async Task TheWorkerSolvesASubmittedJobAndTheDesktopKeepsTheSameNest() { using var factory = new ServerFactory(); var (jobs, worker) = Connect(factory); var request = Request(); var submission = new RemoteNestSubmission(request); var job = await jobs.SubmitAsync(Describe(request), NestJobPackage.Write(request)); Assert.Equal(NestJobState.Queued, job.State); Assert.Equal("N26-0042", job.Title); Assert.True(await worker.RunOnceAsync(CancellationToken.None)); Assert.False(await worker.RunOnceAsync(CancellationToken.None)); var done = (await jobs.GetAsync(job.Id))!; Assert.Equal(NestJobState.Succeeded, done.State); var result = submission.Complete(NestSolveTranscript.FromJson(await jobs.GetResultAsync(job.Id))); Assert.True(result.IsValid, string.Join("; ", result.Violations)); Assert.Empty(result.Unplaced); var parts = result.Plates.SelectMany(p => Enumerable.Repeat(p.Parts, p.Quantity)).SelectMany(p => p).ToList(); Assert.Equal(9, parts.Count(p => ReferenceEquals(p.BaseDrawing, request.Items[0].Drawing))); Assert.Equal(14, parts.Count(p => ReferenceEquals(p.BaseDrawing, request.Items[1].Drawing))); // The same nest a local run proposes. var local = NestPipeline.Run(request); Assert.Equal(local.Plates.SelectMany(p => p.Parts).Select(p => (p.BaseDrawing.Name, p.Location.X, p.Location.Y)), result.Plates.SelectMany(p => p.Parts).Select(p => (p.BaseDrawing.Name, p.Location.X, p.Location.Y))); } [Fact] public void APackageReadsBackAsTheSameRequest() { var request = Request("Default"); var shared = request.Items[0].Drawing; request = request with { Items = request.Items.Append(new NestItem { Drawing = shared, Quantity = 0 }).ToArray() }; var back = NestJobPackage.Read(NestJobPackage.Write(request, Units.Millimeters)); Assert.Equal("Default", back.EngineName); Assert.Equal(request.Items.Select(i => (i.Drawing.Name, i.Quantity, i.Priority)), back.Items.Select(i => (i.Drawing.Name, i.Quantity, i.Priority))); Assert.Same(back.Items[0].Drawing, back.Items[2].Drawing); Assert.Equal(request.Stock.Select(s => s.Id), back.Stock.Select(s => s.Id)); Assert.Equal(5, back.Options!.MaxPlates); Assert.Throws(() => NestJobPackage.Read(Encoding.UTF8.GetBytes("not a zip"))); } [Fact] public async Task APlugInEngineFailsTheJobWithAReason() { using var factory = new ServerFactory(); var (jobs, worker) = Connect(factory); var request = Request() with { EngineName = "MyPlugIn" }; var job = await jobs.SubmitAsync(Describe(request), NestJobPackage.Write(request)); await worker.RunOnceAsync(CancellationToken.None); var failed = (await jobs.GetAsync(job.Id))!; Assert.Equal(NestJobState.Failed, failed.State); Assert.Contains("MyPlugIn", failed.Error); await Assert.ThrowsAsync(() => jobs.GetResultAsync(job.Id)); } [Fact] public async Task AnUnreadablePackageFailsTheJob() { using var factory = new ServerFactory(); var (jobs, worker) = Connect(factory); var job = await jobs.SubmitAsync(new NestJobSubmission { EngineName = "Default" }, Encoding.UTF8.GetBytes("junk")); await worker.RunOnceAsync(CancellationToken.None); var failed = (await jobs.GetAsync(job.Id))!; Assert.Equal(NestJobState.Failed, failed.State); Assert.Contains("could not be read", failed.Error); } [Fact] public async Task CancellingOverHttpStopsAQueuedJobAndFlagsARunningOne() { using var factory = new ServerFactory(); var (jobs, _) = Connect(factory); var request = Request(); var running = await jobs.SubmitAsync(Describe(request), NestJobPackage.Write(request)); var queued = await jobs.SubmitAsync(Describe(request), NestJobPackage.Write(request)); Assert.Equal(1, queued.QueuePosition); var claimed = await jobs.ClaimAsync("w1"); Assert.Equal(running.Id, claimed!.Id); Assert.Null(await jobs.ClaimAsync("w2")); Assert.Equal(NestJobState.Cancelled, (await jobs.CancelAsync(queued.Id))!.State); Assert.True((await jobs.CancelAsync(running.Id))!.CancelRequested); Assert.True((await jobs.HeartbeatAsync(running.Id, "w1"))!.CancelRequested); Assert.Null(await jobs.HeartbeatAsync(running.Id, "w2")); Assert.True(await jobs.ConfirmCancelledAsync(running.Id, "w1")); Assert.Equal(NestJobState.Cancelled, (await jobs.GetAsync(running.Id))!.State); Assert.False(await jobs.CompleteAsync(running.Id, "w1", "{}")); } [Fact] public async Task UnknownJobsAndBadRequestsAreRefused() { using var factory = new ServerFactory(); var http = factory.CreateClient(); var (jobs, _) = Connect(factory); Assert.Null(await jobs.GetAsync(Guid.NewGuid())); Assert.Null(await jobs.CancelAsync(Guid.NewGuid())); Assert.Null(await jobs.ClaimAsync("w1")); Assert.Equal(HttpStatusCode.NotFound, (await http.GetAsync($"api/nest-jobs/{Guid.NewGuid()}/package")).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await http.PostAsync("api/nest-jobs", new StringContent("{}", Encoding.UTF8, "application/json"))).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await http.PostAsync("api/nest-jobs/claim", new StringContent("{}", Encoding.UTF8, "application/json"))).StatusCode); using var noEngine = new MultipartFormDataContent { { new StringContent("{\"title\":\"x\"}", Encoding.UTF8, "application/json"), "metadata" }, { new ByteArrayContent(new byte[] { 1 }), "file", "job.zip" }, }; Assert.Equal(HttpStatusCode.BadRequest, (await http.PostAsync("api/nest-jobs", noEngine)).StatusCode); var request = Request(); var job = await jobs.SubmitAsync(Describe(request), NestJobPackage.Write(request)); var early = await http.GetAsync($"api/nest-jobs/{job.Id}/result"); Assert.Equal(HttpStatusCode.Conflict, early.StatusCode); } }