mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-04 10:11:37 -05:00
A server Auto Nest showed only "Nesting on the nest server" for however long the job took, and Stop could take a whole 20-second heartbeat to reach the worker. Now the desktop shows the same Best plan so far strip a local run does, and a job that ends while OpenNest is in the background says so. - Worker: RecordingNestingEngine taps each solve's progress. NestJobReporter stores only the latest stage and plan, at most once every two seconds, so a chatty engine cannot flood SQLite. Each round also reads the cancel flag, so Stop reaches the worker within one interval (about 2 s in the Docker smoke test). - Server: schema v16 adds per-job revision, progress, plan and planRevision columns, which the upgrade adds only where missing. PUT .../progress and .../plan renew the lease like a heartbeat. GET .../events is Server-Sent Events, woken by an in-process change signal: plan and job events with revision ids, keep-alives, Last-Event-ID resume, and the final state always sent before the stream ends. GET .../plan serves the polling fallback. Progress goes over HTTP because the worker never opens the database (another host, NFS). - Desktop: NestJobWatch follows the stream. When the stream drops it polls once and reconnects, and it polls throughout when the server has no stream. RemoteNestPlanPreview draws worker plans from the client's own request, including cutout-trial frame proxies. ServerNestNotice shows a tray balloon (a toast on Windows 10/11) when the job ends while OpenNest is not in front. A real toast would need an AUMID shortcut that Velopack does not promise. Clicking the balloon brings the review forward; nothing is kept without Keep. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
5.8 KiB
C#
131 lines
5.8 KiB
C#
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Engine.Jobs.Remote;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest.Engine.Tests.Jobs;
|
|
|
|
/// <summary>
|
|
/// The nest server worker's live progress: the recording engine passes each solve's progress
|
|
/// on, and a plan sent over the wire is drawn on the desktop as the same sheets a local run
|
|
/// shows in its best-plan-so-far strip.
|
|
/// </summary>
|
|
public class RemoteNestProgressTests
|
|
{
|
|
private sealed class Collect : IProgress<NestJobProgress>
|
|
{
|
|
public readonly List<NestJobProgress> Reports = new();
|
|
|
|
public void Report(NestJobProgress value)
|
|
{
|
|
lock (Reports)
|
|
Reports.Add(value);
|
|
}
|
|
}
|
|
|
|
private static NestPipelineRequest Request(string engine)
|
|
{
|
|
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 NestItem { Drawing = new Drawing("bracket", TestDrawingFactory.Rectangle(10, 20)), Quantity = 9 },
|
|
new NestItem { Drawing = new Drawing("ell", TestDrawingFactory.LShape()), Quantity = 6, Priority = 1 },
|
|
],
|
|
NestStockBuilder.FromTemplate(template, null),
|
|
new NestJobOptions(maxPlates: 5));
|
|
}
|
|
|
|
private static IEnumerable<(string Name, double X, double Y, double Rotation)> Poses(IEnumerable<ProposedPlate> sheets) =>
|
|
sheets.SelectMany(s => s.Parts.Select(p => (p.BaseDrawing.Name, p.Location.X, p.Location.Y, p.Rotation)));
|
|
|
|
[Theory]
|
|
[InlineData("Rectangles")]
|
|
[InlineData("Default")]
|
|
public void AWirePlanDrawsAsTheLocalPlanSheets(string engine)
|
|
{
|
|
var request = Request(engine);
|
|
var local = new Collect();
|
|
NestPipeline.Run(request, local);
|
|
var plans = local.Reports.Where(r => r.Stage == NestJobStage.PlanImproved && r.PlanSheets != null).ToList();
|
|
Assert.NotEmpty(plans);
|
|
|
|
var preview = new RemoteNestPlanPreview(request);
|
|
foreach (var plan in plans)
|
|
{
|
|
var sheets = preview.Sheets(RemoteNestPlan.From(plan.Plan).ToJson());
|
|
Assert.NotNull(sheets);
|
|
Assert.Equal(plan.PlanSheets.Select(s => (s.Stock.Id, s.Quantity)), sheets.Select(s => (s.Stock.Id, s.Quantity)));
|
|
Assert.Equal(Poses(plan.PlanSheets), Poses(sheets));
|
|
// Detached drawings: the caller's are never touched.
|
|
Assert.All(sheets.SelectMany(s => s.Parts), p => Assert.DoesNotContain(request.Items, i => ReferenceEquals(i.Drawing, p.BaseDrawing)));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ACutoutTrialPlanDrawsItsFrameProxies()
|
|
{
|
|
// A frame whose window takes the strap: the trial's plans name a frame proxy, which the
|
|
// preview bundles on the desktop the first time it sees one.
|
|
var frame = TestDrawingFactory.Rectangle(20, 20);
|
|
frame.Codes.Add(new OpenNest.CNC.RapidMove(4, 3));
|
|
frame.Codes.Add(new OpenNest.CNC.LinearMove(16, 3));
|
|
frame.Codes.Add(new OpenNest.CNC.LinearMove(16, 17));
|
|
frame.Codes.Add(new OpenNest.CNC.LinearMove(4, 17));
|
|
frame.Codes.Add(new OpenNest.CNC.LinearMove(4, 3));
|
|
var request = new NestPipelineRequest("Default",
|
|
[
|
|
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 2 },
|
|
new NestItem { Drawing = new Drawing("strap", TestDrawingFactory.Rectangle(13, 1.25)), Quantity = 2 },
|
|
],
|
|
new[] { new NestPlateStock("sheet", new Size(48, 48), 2, 0.25) });
|
|
var tap = new Collect();
|
|
NestPipeline.Run(new RecordingNestingEngine(NestingEngineRegistry.Create("Default")) { Progress = tap }, "Default", request);
|
|
var proxyPlans = tap.Reports.Where(r => r.Stage == NestJobStage.PlanImproved && r.Plan != null
|
|
&& r.Plan.Plates.Any(p => p.Placements.Any(x => x.PartId.StartsWith("__cutout-proxy-")))).ToList();
|
|
Assert.NotEmpty(proxyPlans);
|
|
|
|
var preview = new RemoteNestPlanPreview(request);
|
|
var sheets = preview.Sheets(RemoteNestPlan.From(proxyPlans[^1].Plan).ToJson());
|
|
|
|
Assert.NotNull(sheets);
|
|
Assert.Equal(proxyPlans[^1].Plan.Plates.Sum(p => p.Placements.Count), sheets.Sum(s => s.Quantity * s.Parts.Count));
|
|
}
|
|
|
|
[Fact]
|
|
public void UnreadableOrForeignPlansDrawNothing()
|
|
{
|
|
var preview = new RemoteNestPlanPreview(Request("Rectangles"));
|
|
Assert.Null(preview.Sheets(""));
|
|
Assert.Null(preview.Sheets("not json"));
|
|
Assert.Null(preview.Sheets("""{"version":99,"plan":{"plates":[]}}"""));
|
|
var stranger = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
|
[new NestJobPlateResult(0, Request("Rectangles").Stock[0], [new NestJobPlacement("part-7", 0, 1, 1, 0)])],
|
|
[], []);
|
|
Assert.Null(preview.Sheets(RemoteNestPlan.From(stranger).ToJson()));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheRecorderPassesEachSolvesProgressOn()
|
|
{
|
|
var request = Request("Rectangles");
|
|
var tap = new Collect();
|
|
var recorder = new RecordingNestingEngine(NestingEngineRegistry.Create(request.EngineName)) { Progress = tap };
|
|
|
|
NestPipeline.Run(recorder, request.EngineName, request);
|
|
|
|
Assert.Contains(tap.Reports, r => r.Stage == NestJobStage.PlanImproved && r.Plan != null);
|
|
// Before the pipeline draws it: the worker sends placements, not parts.
|
|
Assert.All(tap.Reports, r => Assert.Null(r.PlanSheets));
|
|
}
|
|
|
|
[Fact]
|
|
public void AProgressReportSurvivesTheWire()
|
|
{
|
|
var report = new NestJobProgress(NestJobStage.PlateCommitted, "stock-1", 2, 3, 41);
|
|
var back = RemoteNestProgress.FromJson(RemoteNestProgress.From(report).ToJson())!.ToProgress();
|
|
|
|
Assert.Equal(report, back);
|
|
Assert.Null(RemoteNestProgress.FromJson(""));
|
|
Assert.Null(RemoteNestProgress.FromJson("{\"version\":2}"));
|
|
}
|
|
}
|