Files
OpenNest/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs
T

956 lines
41 KiB
C#

using System.Collections.Concurrent;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.Controls;
using OpenNest.Diagnostics;
using OpenNest.Forms;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests;
[CollectionDefinition("Overlap overlay", DisableParallelization = true)]
public class OverlapOverlayCollection;
[Collection("Overlap overlay")]
public class PlateOverlapOverlayTests
{
[Fact]
public void RestartAndCancelRejectLateCompletionsOnTheUiThread() => RunSta(() =>
{
using var run = new OverlayRun();
var uiThread = Environment.CurrentManagedThreadId;
run.View.OverlapStateChanged += (_, _) => Assert.Equal(uiThread, Environment.CurrentManagedThreadId);
var first = run.Start();
var old = run.Next();
var second = run.Start();
var current = run.Next();
Assert.True(old.Token.IsCancellationRequested);
old.Complete();
run.Pump(first);
Assert.Equal(OverlapCheckStatus.Checking, run.View.OverlapStatus);
current.Complete();
run.Pump(second);
Assert.Equal(OverlapCheckStatus.Current, run.View.OverlapStatus);
Assert.Single(run.View.OverlapReport.Pairs);
var canceled = run.Start();
var late = run.Next();
run.View.CancelOverlapCheck();
Assert.True(late.Token.IsCancellationRequested);
late.Complete();
run.Pump(canceled);
Assert.Equal(OverlapCheckStatus.Canceled, run.View.OverlapStatus);
Assert.Null(run.View.OverlapReport);
});
[Theory]
[InlineData("move")]
[InlineData("add")]
[InlineData("remove")]
[InlineData("replace")]
[InlineData("reorder")]
[InlineData("editor")]
[InlineData("plate")]
public void EditOrPlateSwitchRejectsPendingReport(string edit) => RunSta(() =>
{
using var run = new OverlayRun();
var task = run.Start();
var work = run.Next();
var plate = run.View.Plate;
switch (edit)
{
case "move": plate.Parts[0].Offset(1e-10, 0); break;
case "add": plate.Parts.Add(Rectangle()); break;
case "remove": plate.Parts.RemoveAt(0); break;
case "replace": plate.Parts[0] = Rectangle(); break;
case "reorder": (plate.Parts[0], plate.Parts[1]) = (plate.Parts[1], plate.Parts[0]); break;
case "editor": run.View.InvalidateOverlapCheck(); break;
case "plate": run.View.Plate = new Plate(); break;
}
if (edit != "move")
Assert.True(work.Token.IsCancellationRequested);
work.Complete();
run.Pump(task);
Assert.True(work.Token.IsCancellationRequested);
Assert.Null(run.View.OverlapReport);
Assert.Equal(edit == "plate" ? OverlapCheckStatus.NotChecked : OverlapCheckStatus.Stale,
run.View.OverlapStatus);
});
[Fact]
public void PaintChecksFreshnessEvenWhenDisplayIsOff() => RunSta(() =>
{
using var run = new OverlayRun();
run.Finish();
using var image = new Bitmap(240, 240);
using var graphics = Graphics.FromImage(image);
run.View.OverlapOverlay.Draw(graphics);
Assert.NotNull(run.View.OverlapOverlay.CachedPath);
run.View.OverlapDisplay = OverlapDisplayMode.Off;
run.View.Plate.Parts[0].Offset(1e-10, 0);
run.View.OverlapOverlay.Draw(graphics);
Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
Assert.Null(run.View.OverlapReport);
Assert.Null(run.View.OverlapOverlay.CachedPath);
});
[Fact]
public void DisplayPanZoomAndPreviewDoNotAnalyzeOrChangeCommittedParts() => RunSta(() =>
{
using var run = new OverlayRun();
var parts = run.View.Plate.Parts.ToArray();
var locations = parts.Select(p => p.Location).ToArray();
var programs = parts.Select(p => p.Program).ToArray();
var quantities = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
run.View.SelectAll();
var selection = run.View.SelectedParts.ToArray();
run.View.SetActiveParts(new List<Part> { Rectangle() });
run.View.OverlapDisplay = OverlapDisplayMode.Off;
run.Finish();
Assert.Equal(OverlapDisplayMode.Areas, run.View.OverlapDisplay);
Assert.Single(run.View.OverlapReport.Pairs); // Preview is not a third input.
var report = run.View.OverlapReport;
using var image = new Bitmap(240, 240);
using var graphics = Graphics.FromImage(image);
run.View.OverlapOverlay.Draw(graphics);
var path = run.View.OverlapOverlay.CachedPath;
Assert.Equal(FillMode.Winding, path.FillMode);
run.View.Pan(35, -20);
run.View.OverlapOverlay.Draw(graphics);
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
run.View.OverlapDisplay = OverlapDisplayMode.Off;
run.View.OverlapDisplay = OverlapDisplayMode.Areas;
run.View.OverlapOverlay.Draw(graphics);
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
run.View.ZoomToPoint(new Vector(), 2);
run.View.OverlapOverlay.Draw(graphics);
Assert.NotSame(path, run.View.OverlapOverlay.CachedPath);
Assert.Same(report, run.View.OverlapReport);
Assert.Equal(1, run.Calls);
Assert.Equal(parts, run.View.Plate.Parts.ToArray());
Assert.Equal(locations, parts.Select(p => p.Location));
Assert.Equal(programs, parts.Select(p => p.Program));
Assert.Equal(quantities, parts.Select(p => p.BaseDrawing.Quantity.Nested));
Assert.Equal(selection, run.View.SelectedParts);
});
[Fact]
public void TripleOverlapUsesOneFillAndGraphCoordinatesDoNotDoublePan() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.Plate.Parts.Clear();
run.View.Plate.Parts.Add(Rectangle());
run.View.Plate.Parts.Add(Rectangle(2));
run.View.Plate.Parts.Add(Rectangle(3));
run.Finish();
run.View.ZoomToPoint(new Vector(), 20);
run.View.SetOrigin(60, 220);
using var image = new Bitmap(300, 300);
using var graphics = Graphics.FromImage(image);
graphics.Clear(Color.White);
graphics.TranslateTransform(60, 220);
var transform = graphics.Transform.Elements;
run.View.OverlapOverlay.Draw(graphics);
Assert.Equal(transform, graphics.Transform.Elements); // Label restores graph space.
Assert.Equal(image.GetPixel(110, 180), image.GetPixel(130, 180)); // Double/triple material, same alpha.
Assert.NotEqual(Color.White.ToArgb(), image.GetPixel(130, 180).ToArgb());
Assert.Equal(Color.White.ToArgb(), image.GetPixel(70, 180).ToArgb());
});
[Theory]
[InlineData(false)]
[InlineData(true)]
public void HandleDestructionOrDisposalNeverResurrectsRequests(bool dispose) => RunSta(() =>
{
using var run = new OverlayRun();
run.Finish();
using var image = new Bitmap(100, 100);
using var graphics = Graphics.FromImage(image);
run.View.OverlapOverlay.Draw(graphics);
Assert.NotNull(run.View.OverlapOverlay.CachedPath);
var task = run.Start();
var work = run.Next();
if (dispose)
run.View.Dispose();
else
run.View.Recreate();
Assert.True(work.Token.IsCancellationRequested);
Assert.Null(run.View.OverlapOverlay.CachedPath);
work.Complete();
run.Pump(task);
Assert.Null(run.View.OverlapReport);
Assert.False(run.View.IsOverlapCheckRunning);
});
[Fact]
public void CompletedPathIsReleasedOnHandleRecreationAndCannotReappear() => RunSta(() =>
{
using var run = new OverlayRun();
run.Finish();
using var image = new Bitmap(100, 100);
using var graphics = Graphics.FromImage(image);
run.View.OverlapOverlay.Draw(graphics);
Assert.NotNull(run.View.OverlapOverlay.CachedPath);
run.View.Recreate();
Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
Assert.Null(run.View.OverlapOverlay.CachedPath);
run.View.OverlapOverlay.Draw(graphics);
Assert.Null(run.View.OverlapOverlay.CachedPath);
Assert.Null(run.View.OverlapReport);
Assert.Equal(1, run.Calls);
});
[Fact]
public void FailureAndIncompleteAreVisibleAndNeverReportedAsClear() => RunSta(() =>
{
using var run = new OverlayRun();
var task = run.Start();
run.Next().Fail();
run.Pump(task);
Assert.Equal(OverlapCheckStatus.Failed, run.View.OverlapStatus);
Assert.Contains("failed", run.View.Status);
Assert.Null(run.View.OverlapReport);
run.View.Plate.Parts.Add(new Part(new Drawing("invalid", new Program())));
run.Finish();
Assert.Equal(OverlapCheckStatus.Incomplete, run.View.OverlapStatus);
Assert.Contains("incomplete: 1 overlapping pairs; 1 parts", run.View.Status);
});
[Fact]
public void SnapshotKeepsOriginalLabelsWhenLiveNamesChange() => RunSta(() =>
{
using var run = new OverlayRun();
var task = run.Start();
var work = run.Next();
run.View.Plate.Parts[0].BaseDrawing.Name = "new label";
work.Complete();
run.Pump(task);
Assert.Equal("same name", Assert.Single(run.View.OverlapReport.Pairs).PartAName);
Assert.Equal(OverlapCheckStatus.Current, run.View.OverlapStatus);
});
[Theory]
[InlineData("add")]
[InlineData("remove")]
[InlineData("replace")]
[InlineData("clear")]
public void CollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() =>
{
using var run = new OverlayRun();
run.Finish();
using var image = new Bitmap(100, 100);
using var graphics = Graphics.FromImage(image);
run.View.OverlapOverlay.Draw(graphics);
Assert.NotNull(run.View.OverlapOverlay.CachedPath);
switch (edit)
{
case "add": run.View.Plate.Parts.Add(Rectangle()); break;
case "remove": run.View.Plate.Parts.RemoveAt(0); break;
case "replace": run.View.Plate.Parts[0] = Rectangle(); break;
case "clear": run.View.Plate.Parts.Clear(); break;
}
Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
Assert.Null(run.View.OverlapReport);
Assert.Null(run.View.OverlapOverlay.CachedPath);
});
[Fact]
public void MenusFollowMdiActivationDocumentDisplayAndRunningState() => RunSta(() =>
{
using var host = new MenuHost();
host.Show();
var check = Menu(host, "mnuOverlapCheckActive");
var cancel = Menu(host, "mnuOverlapCancel");
var off = Menu(host, "mnuOverlapOff");
var areas = Menu(host, "mnuOverlapAreas");
var centroids = Menu(host, "mnuOverlapCentroids");
var both = Menu(host, "mnuOverlapBoth");
Assert.False(check.Enabled);
Assert.False(cancel.Enabled);
Assert.False(off.Enabled);
Assert.False(areas.Enabled);
Assert.False(centroids.Enabled);
Assert.False(both.Enabled);
Assert.All(new[] { check, cancel, off, areas, centroids, both }, item => Assert.Equal(Keys.None, item.ShortcutKeys));
using var first = new EditNestForm(new Nest("first")) { MdiParent = host };
first.Show();
Assert.True(check.Enabled);
Assert.True(areas.Checked);
off.PerformClick();
Assert.Equal(OverlapDisplayMode.Off, first.OverlapDisplay);
using var second = new EditNestForm(new Nest("second")) { MdiParent = host };
second.Show();
Assert.True(areas.Checked);
Assert.False(off.Checked);
first.Activate();
Assert.True(off.Checked);
Assert.False(areas.Checked);
Assert.Equal(OverlapDisplayMode.Areas, second.OverlapDisplay);
centroids.PerformClick();
Assert.True(centroids.Checked);
Assert.False(off.Checked);
Assert.Equal(OverlapDisplayMode.Centroids, first.OverlapDisplay);
second.Activate();
both.PerformClick();
Assert.True(both.Checked);
Assert.False(centroids.Checked);
Assert.Equal(OverlapDisplayMode.Both, second.OverlapDisplay);
first.Activate();
Assert.True(centroids.Checked);
Assert.False(both.Checked);
off.PerformClick();
var previous = SynchronizationContext.Current;
var context = new PumpContext();
using var pending = new BlockingCollection<Work>();
SynchronizationContext.SetSynchronizationContext(context);
try
{
first.PlateView.OverlapOverlay.Analyze = (snapshot, token) =>
{
var request = new Work(snapshot, token);
pending.Add(request);
return request.WaitForResult();
};
var task = first.CheckOverlapsAsync();
Assert.True(pending.TryTake(out var request, TimeSpan.FromSeconds(15)));
Assert.True(cancel.Enabled);
Assert.True(areas.Checked); // Checking from Off selects Areas.
cancel.PerformClick();
Assert.False(cancel.Enabled);
Assert.True(request!.Token.IsCancellationRequested);
request.Complete();
context.Pump(task);
Assert.Equal(OverlapCheckStatus.Canceled, first.PlateView.OverlapStatus);
}
finally
{
SynchronizationContext.SetSynchronizationContext(previous);
}
first.Dispose();
second.Dispose();
Assert.False(check.Enabled);
Assert.False(cancel.Enabled);
});
[Fact]
public void CheckCommandCapturesUnitsAndNamesUntilTheNextCheck() => RunSta(() =>
{
using var form = new EditNestForm(new Nest("units") { Units = Units.Millimeters });
form.Show();
form.PlateView.Plate.Parts.Add(Rectangle());
form.PlateView.Plate.Parts.Add(Rectangle(1));
form.OverlapDisplay = OverlapDisplayMode.Both;
var previous = SynchronizationContext.Current;
var context = new PumpContext();
var started = new TaskCompletionSource<PlateOverlapSnapshot>(TaskCreationOptions.RunContinuationsAsynchronously);
var result = new TaskCompletionSource<PlateOverlapReport>(TaskCreationOptions.RunContinuationsAsynchronously);
Task? task = null;
SynchronizationContext.SetSynchronizationContext(context);
try
{
form.PlateView.OverlapOverlay.Analyze = (snapshot, _) =>
{
started.TrySetResult(snapshot);
return WaitForWorkerResult(result.Task);
};
task = form.CheckOverlapsAsync();
var snapshot = WaitForSnapshot(started.Task);
form.Nest.Units = Units.Inches;
form.PlateView.Plate.Parts[0].BaseDrawing.Name = "changed";
result.SetResult(PlateOverlapAnalyzer.Analyze(snapshot));
context.Pump(task);
var center = form.PlateView.PointWorldToControl(form.PlateView.OverlapReport.Pairs[0].Centroid);
form.PlateView.OverlapOverlay.UpdateHover(center);
Assert.Contains(" mm²", form.PlateView.OverlapOverlay.HoverText);
Assert.Contains("same name / same name", form.PlateView.OverlapOverlay.HoverText);
Assert.DoesNotContain("changed", form.PlateView.OverlapOverlay.HoverText);
form.PlateView.OverlapOverlay.Analyze = PlateOverlapAnalyzer.Analyze;
task = form.CheckOverlapsAsync();
Assert.Null(form.PlateView.OverlapOverlay.HoverText);
context.Pump(task);
form.PlateView.OverlapOverlay.UpdateHover(center);
Assert.Contains(" in²", form.PlateView.OverlapOverlay.HoverText);
Assert.Contains("changed / same name", form.PlateView.OverlapOverlay.HoverText);
Assert.Equal(OverlapDisplayMode.Both, form.OverlapDisplay);
}
finally
{
form.CancelOverlapCheck();
result.TrySetCanceled();
try { if (task != null) context.Pump(task); }
finally { SynchronizationContext.SetSynchronizationContext(previous); }
}
});
[Fact]
public void CleanupReleasesDequeuedWorkersEvenWithoutCompleteOrFail() => RunSta(() =>
{
using var run = new OverlayRun();
var first = run.Start();
var old = run.Next();
var second = run.Start();
var current = run.Next();
run.Dispose();
Assert.True(old.Result.Task.IsCompleted);
Assert.True(current.Result.Task.IsCompleted);
Assert.True(first.IsCompletedSuccessfully);
Assert.True(second.IsCompletedSuccessfully);
});
[Theory]
[InlineData(OverlapDisplayMode.Areas)]
[InlineData(OverlapDisplayMode.Centroids)]
[InlineData(OverlapDisplayMode.Both)]
public void RecheckPreservesEachVisibleModeAndDisplayOnlyRepaints(OverlapDisplayMode mode) => RunSta(() =>
{
using var run = new OverlayRun();
run.View.OverlapDisplay = mode;
run.Finish();
Assert.Equal(mode, run.View.OverlapDisplay);
run.Finish();
Assert.Equal(mode, run.View.OverlapDisplay);
var report = run.View.OverlapReport;
using var image = new Bitmap(300, 300);
using var graphics = Graphics.FromImage(image);
foreach (var display in Enum.GetValues<OverlapDisplayMode>())
{
run.View.OverlapDisplay = display;
run.View.OverlapOverlay.Draw(graphics);
Assert.Same(report, run.View.OverlapReport);
}
Assert.Equal(2, run.Calls);
});
[Fact]
public void CentroidMarkerUsesGraphCoordinatesAndFixedScreenSizeAcrossZoom() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.OverlapDisplay = OverlapDisplayMode.Centroids;
run.Finish();
var pair = Assert.Single(run.View.OverlapReport.Pairs);
using var image = new Bitmap(500, 500);
using var graphics = Graphics.FromImage(image);
foreach (var zoom in new[] { 20f, 2f })
{
run.View.ZoomToPoint(new Vector(), zoom);
run.View.SetOrigin(60, 320);
graphics.ResetTransform();
graphics.Clear(Color.White);
graphics.TranslateTransform(60, 320);
run.View.OverlapOverlay.Draw(graphics);
Assert.Null(run.View.OverlapOverlay.CachedPath);
var center = run.View.PointWorldToControl(pair.Centroid);
var radius = (int)OverlapPairPresentation.MarkerHalfSize(run.View.DeviceDpi);
Assert.NotEqual(Color.White.ToArgb(), image.GetPixel(center.X - radius + 1, center.Y).ToArgb());
Assert.Equal(Color.White.ToArgb(), image.GetPixel(center.X - radius - 4, center.Y).ToArgb());
Assert.Equal(Color.White.ToArgb(), image.GetPixel(center.X - 60, center.Y).ToArgb());
run.View.MoveTo(new Point(center.X + (int)OverlapPairPresentation.HitRadius(run.View.DeviceDpi), center.Y));
Assert.Contains("Pair 1/2", run.View.OverlapOverlay.HoverText);
run.View.MoveTo(new Point(center.X + (int)OverlapPairPresentation.HitRadius(run.View.DeviceDpi) + 2, center.Y));
Assert.Null(run.View.OverlapOverlay.HoverText);
}
Assert.Equal(1, run.Calls);
});
[Fact]
public void MarkerAndHoverUsePairMaterialCentroidRatherThanBoundsCenter() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.Plate.Parts.Clear();
var program = new Program(Mode.Absolute);
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(6, 0));
program.Codes.Add(new LinearMove(0, 3));
program.Codes.Add(new LinearMove(0, 0));
var drawing = new Drawing("triangle", program);
run.View.Plate.Parts.Add(new Part(drawing));
run.View.Plate.Parts.Add(new Part(drawing));
run.View.OverlapDisplay = OverlapDisplayMode.Centroids;
run.Finish();
run.View.ZoomToPoint(new Vector(), 30);
run.View.SetOrigin(60, 220);
using var image = new Bitmap(300, 300);
using var graphics = Graphics.FromImage(image);
graphics.Clear(Color.White);
graphics.TranslateTransform(60, 220);
run.View.OverlapOverlay.Draw(graphics);
var materialCenter = run.View.PointWorldToControl(new Vector(6.0 / 3, 3.0 / 3));
var boundsCenter = run.View.PointWorldToControl(new Vector(6.0 / 2, 3.0 / 2));
Assert.NotEqual(Color.White.ToArgb(), image.GetPixel(materialCenter.X, materialCenter.Y).ToArgb());
Assert.Equal(Color.White.ToArgb(), image.GetPixel(boundsCenter.X, boundsCenter.Y).ToArgb());
run.View.MoveTo(materialCenter);
Assert.Contains("Pair 1/2", run.View.OverlapOverlay.HoverText);
run.View.MoveTo(boundsCenter);
Assert.Null(run.View.OverlapOverlay.HoverText);
});
[Fact]
public void BothRetainsAreaPathAndCoincidentHoverListsEveryPairWithoutChangingSelection() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.Plate.Parts.Clear();
run.View.Plate.Parts.Add(Rectangle());
run.View.Plate.Parts.Add(Rectangle());
run.View.Plate.Parts.Add(Rectangle());
run.Finish();
run.View.ZoomToPoint(new Vector(), 20);
run.View.SetOrigin(60, 220);
using var image = new Bitmap(300, 300);
using var graphics = Graphics.FromImage(image);
graphics.TranslateTransform(60, 220);
run.View.OverlapOverlay.Draw(graphics);
var path = run.View.OverlapOverlay.CachedPath;
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.View.OverlapOverlay.Draw(graphics);
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
var center = run.View.PointWorldToControl(new Vector(2, 2));
run.View.MoveTo(center);
var text = run.View.OverlapOverlay.HoverText!;
Assert.True(text.IndexOf("Pair 1/2", StringComparison.Ordinal) < text.IndexOf("Pair 1/3", StringComparison.Ordinal));
Assert.True(text.IndexOf("Pair 1/3", StringComparison.Ordinal) < text.IndexOf("Pair 2/3", StringComparison.Ordinal));
Assert.Empty(run.View.SelectedParts);
run.View.ClickAt(center);
Assert.NotEmpty(run.View.SelectedParts); // Informational hover does not consume the selection click.
Assert.Null(run.View.OverlapOverlay.HoverText);
Assert.Equal(1, run.Calls);
});
[Fact]
public void PairDetailsOnlySuppressNormalTooltipWhileVisible() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.Finish();
run.View.ZoomToPoint(new Vector(), 20);
run.View.SetOrigin(60, 220);
run.View.MoveTo(run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid));
var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic;
var hoverCheck = typeof(PlateView).GetMethod("HoverCheck", flags)!;
var normalVisible = typeof(PlateView).GetField("showTooltip", flags)!;
hoverCheck.Invoke(run.View, null); // Fire the existing normal-tooltip timer deterministically.
Assert.True((bool)normalVisible.GetValue(run.View)!);
using var image = new Bitmap(300, 300);
using var graphics = Graphics.FromImage(image);
Assert.True(run.View.OverlapOverlay.DrawHover(graphics));
Assert.True((bool)normalVisible.GetValue(run.View)!);
run.View.OverlapDisplay = OverlapDisplayMode.Areas;
Assert.False(run.View.OverlapOverlay.DrawHover(graphics));
Assert.True((bool)normalVisible.GetValue(run.View)!); // PlateView now draws the original tooltip.
run.View.LeaveView();
hoverCheck.Invoke(run.View, null); // A queued timer cannot resurrect it after leave.
Assert.False((bool)normalVisible.GetValue(run.View)!);
});
[Theory]
[InlineData("leave")]
[InlineData("stale-move")]
[InlineData("stale-paint")]
[InlineData("invalidate")]
[InlineData("mode")]
[InlineData("zoom")]
[InlineData("pan")]
[InlineData("resize")]
[InlineData("plate")]
[InlineData("restart-cancel")]
[InlineData("recreate")]
public void HoverClearsBeforeStaleDetailsCanBeShown(string change) => RunSta(() =>
{
using var run = new OverlayRun();
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.Finish();
run.View.ZoomToPoint(new Vector(), 20);
run.View.SetOrigin(60, 220);
var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid);
run.View.MoveTo(center);
Assert.NotNull(run.View.OverlapOverlay.HoverText);
switch (change)
{
case "leave": run.View.LeaveView(); break;
case "stale-move":
run.View.Plate.Parts[0].Offset(1e-10, 0);
run.View.MoveTo(center);
break;
case "stale-paint":
run.View.Plate.Parts[0].Offset(1e-10, 0);
using (var image = new Bitmap(300, 300))
using (var graphics = Graphics.FromImage(image))
Assert.False(run.View.OverlapOverlay.DrawHover(graphics));
break;
case "invalidate": run.View.InvalidateOverlapCheck(); break;
case "mode": run.View.OverlapDisplay = OverlapDisplayMode.Centroids; break;
case "zoom": run.View.ZoomToPoint(new Vector(), 2); break;
case "pan": run.View.Pan(20, 10); break;
case "resize": run.View.Size = new System.Drawing.Size(300, 200); break;
case "plate": run.View.Plate = new Plate(); break;
case "restart-cancel":
var task = run.Start();
var work = run.Next();
Assert.Null(run.View.OverlapOverlay.HoverText);
run.View.CancelOverlapCheck();
work.Complete();
run.Pump(task);
break;
case "recreate": run.View.Recreate(); break;
}
Assert.Null(run.View.OverlapOverlay.HoverText);
});
[Fact]
public void CrowdedHoverPagesStayInViewportAndReachLastPairWithoutSelectionOrWheelCapture() => RunSta(() =>
{
using var run = new OverlayRun();
PrepareCrowdedHover(run);
var report = run.View.OverlapReport;
var positions = run.View.Plate.Parts.Select(p => p.Location).ToArray();
run.View.SelectAll();
var selection = run.View.SelectedParts.ToArray();
using var image = new Bitmap(run.View.Width, run.View.Height);
using var graphics = Graphics.FromImage(image);
var overlay = run.View.OverlapOverlay;
Assert.True(overlay.DrawHover(graphics));
var pages = overlay.HoverPages;
Assert.True(pages.PageCount > 1);
var content = new List<string>();
for (var page = 0; page < pages.PageCount; page++)
{
Assert.True(overlay.DrawHover(graphics));
Assert.True(new RectangleF(PointF.Empty, run.View.ClientSize).Contains(overlay.HoverBounds));
Assert.Equal(page, pages.PageIndex);
content.AddRange(pages.Lines);
Assert.Contains("PgUp/PgDn", string.Join("", pages.NavigationLines));
Assert.True(run.View.Command(Keys.PageDown));
}
Assert.Contains("Pair 11/12", string.Concat(content));
Assert.Contains(" in²", string.Concat(content));
Assert.Equal(overlay.HoverText.Replace("\n", ""), string.Concat(content));
Assert.Equal(pages.PageCount - 1, pages.PageIndex);
Assert.True(run.View.Command(Keys.PageUp));
Assert.Equal(pages.PageCount - 2, pages.PageIndex);
var lastIndex = pages.PageIndex;
Assert.False(run.View.Command(Keys.Control | Keys.PageDown));
Assert.Equal(lastIndex, pages.PageIndex);
run.View.MoveTo(run.View.PointWorldToControl(report.Pairs[0].Centroid));
Assert.Equal(lastIndex, pages.PageIndex); // Moving within the same hit group retains the page.
Assert.Equal(selection, run.View.SelectedParts);
Assert.Equal(positions, run.View.Plate.Parts.Select(p => p.Location));
Assert.Same(report, run.View.OverlapReport);
Assert.Equal(1, run.Calls);
var scale = run.View.ViewScale;
run.View.WheelAt(run.View.PointWorldToControl(report.Pairs[0].Centroid));
Assert.True(run.View.ViewScale > scale); // Wheel remains zoom, never paging.
Assert.Null(overlay.HoverText);
Assert.Null(overlay.HoverPages);
Assert.False(run.View.Command(Keys.PageDown));
Assert.Equal(selection, run.View.SelectedParts);
Assert.Equal(1, run.Calls);
});
[Fact]
public void MovingDirectlyToAnotherCoincidentGroupStartsItsFirstPage() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.Size = new System.Drawing.Size(300, 160);
run.View.Plate.Parts.Clear();
for (var i = 0; i < 12; i++)
run.View.Plate.Parts.Add(Rectangle(i < 6 ? 0 : 8));
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.Finish();
run.View.ZoomToPoint(new Vector(), 15);
run.View.SetOrigin(60, 120);
run.View.MoveTo(run.View.PointWorldToControl(new Vector(2, 2)));
using var image = new Bitmap(300, 160);
using var graphics = Graphics.FromImage(image);
var overlay = run.View.OverlapOverlay;
Assert.True(overlay.DrawHover(graphics));
Assert.True(run.View.Command(Keys.PageDown));
Assert.Equal(1, overlay.HoverPages.PageIndex);
run.View.MoveTo(run.View.PointWorldToControl(new Vector(10, 2)));
Assert.Null(overlay.HoverPages);
Assert.True(overlay.DrawHover(graphics));
Assert.NotNull(overlay.HoverPages);
Assert.Equal(0, overlay.HoverPages.PageIndex);
Assert.Contains("Pair 7/8", string.Concat(overlay.HoverPages.Lines));
Assert.Equal(1, run.Calls);
});
[Theory]
[InlineData("hover")]
[InlineData("leave")]
[InlineData("mode")]
[InlineData("stale-key")]
[InlineData("report")]
[InlineData("resize")]
[InlineData("font")]
[InlineData("click")]
[InlineData("drag")]
public void PagingResetsWithHoverLifetimeAndInactiveKeysAreNotConsumed(string change) => RunSta(() =>
{
using var run = new OverlayRun();
Assert.False(run.View.Command(Keys.PageDown));
PrepareCrowdedHover(run);
using var image = new Bitmap(run.View.Width, run.View.Height);
using var graphics = Graphics.FromImage(image);
var overlay = run.View.OverlapOverlay;
Assert.True(overlay.DrawHover(graphics));
Assert.True(run.View.Command(Keys.PageDown));
Assert.Equal(1, overlay.HoverPages.PageIndex);
var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid);
using var largerFont = new Font(run.View.Font.FontFamily, run.View.Font.Size + 1);
switch (change)
{
case "hover": run.View.MoveTo(new Point(5, 5)); break;
case "leave": run.View.LeaveView(); break;
case "mode": run.View.OverlapDisplay = OverlapDisplayMode.Areas; break;
case "stale-key":
run.View.Plate.Parts[0].Offset(1e-10, 0);
Assert.False(run.View.Command(Keys.PageDown));
break;
case "report": run.Finish(); break;
case "resize": run.View.Width += 10; break;
case "font": run.View.Font = largerFont; break;
case "click":
run.View.ClickAt(center);
Assert.NotEmpty(run.View.SelectedParts);
break;
case "drag": run.View.DragAt(center); break;
}
Assert.Null(overlay.HoverText);
Assert.Null(overlay.HoverPages);
Assert.False(run.View.Command(Keys.PageDown));
if (change == "stale-key")
run.Finish();
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.View.MoveTo(center);
Assert.True(overlay.DrawHover(graphics));
Assert.NotNull(overlay.HoverPages);
Assert.Equal(0, overlay.HoverPages.PageIndex);
});
[Fact]
public void LongNameAndTinyViewNeverSilentlyElideTheOnlyPair() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.Size = new System.Drawing.Size(150, 120);
run.View.Plate.Parts[0].BaseDrawing.Name = new string('W', 250);
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.Finish();
run.View.ZoomToPoint(new Vector(), 10);
run.View.SetOrigin(30, 90);
var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid);
run.View.MoveTo(center);
using var image = new Bitmap(150, 120);
using var graphics = Graphics.FromImage(image);
var overlay = run.View.OverlapOverlay;
Assert.True(overlay.DrawHover(graphics));
Assert.False(overlay.HoverPages.NeedsLargerViewport);
Assert.True(overlay.HoverPages.PageCount > 1);
var content = new List<string>();
for (var i = 0; i < overlay.HoverPages.PageCount; i++)
{
Assert.True(overlay.DrawHover(graphics));
Assert.True(new RectangleF(PointF.Empty, run.View.ClientSize).Contains(overlay.HoverBounds));
content.AddRange(overlay.HoverPages.Lines);
run.View.Command(Keys.PageDown);
}
Assert.Equal(overlay.HoverText.Replace("\n", ""), string.Concat(content));
run.View.Size = new System.Drawing.Size(40, 20);
overlay.UpdateHover(run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid));
Assert.True(overlay.DrawHover(graphics));
Assert.True(overlay.HoverPages.NeedsLargerViewport);
Assert.False(run.View.Command(Keys.PageDown));
});
private static void PrepareCrowdedHover(OverlayRun run)
{
run.View.Size = new System.Drawing.Size(260, 160);
run.View.Plate.Parts.Clear();
for (var i = 0; i < 12; i++)
run.View.Plate.Parts.Add(Rectangle());
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.Finish();
run.View.ZoomToPoint(new Vector(), 15);
run.View.SetOrigin(60, 120);
run.View.MoveTo(run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid));
}
// These waits are deliberate: only Task.Run workers use the result gate; the test's
// STA waits for capture delivery, then pumps every UI continuation explicitly.
private static PlateOverlapReport WaitForWorkerResult(Task<PlateOverlapReport> task) => task.GetAwaiter().GetResult();
private static PlateOverlapSnapshot WaitForSnapshot(Task<PlateOverlapSnapshot> task)
{
Assert.True(task.Wait(TimeSpan.FromSeconds(15)));
return task.GetAwaiter().GetResult();
}
private static ToolStripMenuItem Menu(MainForm host, string name) =>
(ToolStripMenuItem)host.MainMenuStrip!.Items.Find(name, true).Single();
private sealed class MenuHost : MainForm
{
// Do not run startup migrations/automatic new-document creation or save test window settings.
protected override void OnLoad(EventArgs e) { }
protected override void OnClosing(System.ComponentModel.CancelEventArgs e) { }
}
private sealed class TestView : PlateView
{
public void Pan(float x, float y) { origin.X += x; origin.Y += y; }
public void SetOrigin(float x, float y) => origin = new PointF(x, y);
public void Recreate() => RecreateHandle();
public void MoveTo(Point point) => OnMouseMove(new MouseEventArgs(MouseButtons.None, 0, point.X, point.Y, 0));
public void LeaveView() => OnMouseLeave(EventArgs.Empty);
public bool Command(Keys keys)
{
var message = new Message();
return ProcessCmdKey(ref message, keys);
}
public void WheelAt(Point point) => OnMouseWheel(new MouseEventArgs(MouseButtons.None, 0, point.X, point.Y, 120));
public void DragAt(Point point) => OnMouseMove(new MouseEventArgs(MouseButtons.Left, 0, point.X, point.Y, 0));
public void ClickAt(Point point)
{
OnMouseDown(new MouseEventArgs(MouseButtons.Left, 1, point.X, point.Y, 0));
OnMouseUp(new MouseEventArgs(MouseButtons.Left, 1, point.X, point.Y, 0));
}
}
private sealed class OverlayRun : IDisposable
{
private readonly PumpContext context = new();
private readonly SynchronizationContext? previous;
private readonly BlockingCollection<Work> work = new();
private readonly List<Work> outstanding = new();
private readonly List<Task> tasks = new();
private bool disposed;
public TestView View { get; } = new();
public int Calls;
public OverlayRun()
{
View.CreateControl();
previous = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(context);
View.Plate.Parts.Add(Rectangle());
View.Plate.Parts.Add(Rectangle(1));
View.OverlapOverlay.Analyze = (snapshot, token) =>
{
Interlocked.Increment(ref Calls);
var request = new Work(snapshot, token);
lock (outstanding)
{
outstanding.Add(request);
if (disposed)
request.Release();
else
work.Add(request);
}
return request.WaitForResult();
};
}
public Task Start()
{
var task = View.CheckOverlapsAsync(Units.Inches);
tasks.Add(task);
return task;
}
public Work Next()
{
Assert.True(work.TryTake(out var result, TimeSpan.FromSeconds(15)), "Worker did not start.");
return result!;
}
public void Pump(Task task) => context.Pump(task);
public void Finish()
{
var task = Start();
Next().Complete();
Pump(task);
}
public void Dispose()
{
if (disposed)
return;
try
{
View.Dispose();
}
finally
{
lock (outstanding)
{
disposed = true;
foreach (var request in outstanding)
request.Release();
}
try { context.Pump(Task.WhenAll(tasks)); }
finally
{
SynchronizationContext.SetSynchronizationContext(previous);
work.Dispose();
}
}
}
}
private sealed class Work(PlateOverlapSnapshot snapshot, CancellationToken token)
{
public CancellationToken Token { get; } = token;
public TaskCompletionSource<PlateOverlapReport> Result { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
// Only called by Task.Run workers; the STA supplies the result and pumps continuations.
public PlateOverlapReport WaitForResult() => Result.Task.GetAwaiter().GetResult();
public void Complete() => Result.SetResult(PlateOverlapAnalyzer.Analyze(snapshot));
public void Fail() => Result.SetException(new InvalidOperationException("test failure"));
// Cancellation is deliberately ignored while testing late completions. Cleanup alone
// releases every gate, including requests already taken from the pending queue.
public void Release() => Result.TrySetCanceled();
}
private sealed class PumpContext : SynchronizationContext
{
private readonly BlockingCollection<System.Action> callbacks = new();
public override void Post(SendOrPostCallback callback, object? state) => callbacks.Add(() => callback(state));
public void Pump(Task task)
{
while (!task.IsCompleted)
{
Assert.True(callbacks.TryTake(out var callback, TimeSpan.FromSeconds(15)), "UI continuation did not arrive.");
callback!();
}
task.GetAwaiter().GetResult();
}
}
private static Part Rectangle(double x = 0)
{
var program = new Program(Mode.Absolute);
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(4, 0));
program.Codes.Add(new LinearMove(4, 4));
program.Codes.Add(new LinearMove(0, 4));
program.Codes.Add(new LinearMove(0, 0));
return new Part(new Drawing("same name", program), new Vector(x, 0));
}
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { failure = ex; }
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The STA test did not complete.");
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
}