Files
OpenNest/OpenNest.WinForms.Tests/Forms/ShapeLibraryFormTests.cs
T
ajandClaude Fable 5.2 17c496795e feat(shape-library): give the preview's part and dimensions more room
The fitted drawing and its dimensions now keep 30 px from the preview's
border instead of 10, and at least 20 px from the info lines and note
chips. Linear dimensions sit 50 px off the part by default instead of 18,
and leader labels 30 px instead of 20.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-15 10:53:25 -05:00

433 lines
18 KiB
C#

using System.Drawing;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Windows.Forms;
using OpenNest.Controls;
using OpenNest.Forms;
using OpenNest.Shapes;
namespace OpenNest.WinForms.Tests.Forms;
public class ShapeLibraryFormTests
{
[Fact]
public void FriendlyNameRegexHasExplicitTimeout()
{
var regex = Assert.IsType<Regex>(typeof(ShapeLibraryForm)
.GetField("FriendlyNamePattern", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null));
Assert.Equal(TimeSpan.FromMilliseconds(100), regex.MatchTimeout);
Assert.Equal(RegexOptions.None, regex.Options);
}
[Theory]
[InlineData("PipeFlangeShape", "Pipe Flange")]
[InlineData("HolePatternDiameter", "Hole Pattern Diameter")]
[InlineData("NgonShape", "Ngon")]
[InlineData("ABCShape", "ABC")]
public void FriendlyNamesKeepSuffixAndWordBoundaryBehavior(string input, string expected)
{
Assert.Equal(expected, ShapeLibraryForm.FriendlyName(input));
}
[Theory]
[InlineData("PipeFlangeShape", "PipeFlange")]
[InlineData("HolePatternDiameter", "HolePatternDiameter")]
public void FriendlyNameTimeoutDiscardsPartialFormatting(string input, string expected)
{
var notices = new List<string>();
var result = ShapeLibraryForm.FriendlyName(input, name =>
{
Assert.Equal(expected, name);
// Simulate a replacement failing after it has transformed a prefix internally.
var partial = name.Insert(4, " ");
Assert.NotEqual(expected, partial);
throw new RegexMatchTimeoutException();
}, notices.Add);
Assert.Equal(expected, result);
Assert.Contains(expected, Assert.Single(notices));
}
[Fact]
public void ParametersAreDimensionsOnTheDrawingNotASidePanel() => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
SelectShape(form, typeof(RectangleShape));
var preview = Find<ShapePreviewControl>(form, "previewBox");
Assert.Empty(form.Controls.Find("parametersPanel", true));
Assert.Equal(new[] { "Length", "Width" }, preview.LabelKeys);
Assert.NotNull(preview.LabelBounds("Length"));
});
[Fact]
public void TypingADimensionResizesTheShape() => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
SelectShape(form, typeof(RectangleShape));
var preview = Find<ShapePreviewControl>(form, "previewBox");
var box = OpenEditor(form, "Length");
Assert.Equal("Length", preview.ActiveKey);
box.Text = "20 1/2";
Press(box, Keys.Enter);
Assert.Null(form.ActiveEditor);
Assert.Equal(20.5, preview.Plate.Parts.Single().BoundingBox.Length, 6);
InvokeAdd(form);
Assert.Equal(20.5, form.GetDrawings().Single().Program.BoundingBox().Length, 6);
});
[Fact]
public void InvalidEntryStaysOpenInRedAndKeepsTheLastShape() => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
SelectShape(form, typeof(RectangleShape));
var preview = Find<ShapePreviewControl>(form, "previewBox");
var length = preview.Plate.Parts.Single().BoundingBox.Length;
var box = OpenEditor(form, "Length");
box.Text = "invalid dimension";
Press(box, Keys.Enter);
Assert.Same(box, form.ActiveEditor);
Assert.Equal(Color.Red, box.ForeColor);
box.Text = "0";
Press(box, Keys.Enter);
Assert.Same(box, form.ActiveEditor);
Press(box, Keys.Escape);
Assert.Null(form.ActiveEditor);
Assert.Equal(length, preview.Plate.Parts.Single().BoundingBox.Length, 6);
Assert.True(Find<Button>(form, "addButton").Enabled);
});
[Fact]
public void TabCommitsAndMovesToTheNextDimension() => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
SelectShape(form, typeof(RectangleShape));
var preview = Find<ShapePreviewControl>(form, "previewBox");
var box = OpenEditor(form, "Length");
box.Text = "15";
Press(box, Keys.Tab);
Assert.Equal("Width", preview.ActiveKey);
Assert.Equal(15, preview.Plate.Parts.Single().BoundingBox.Length, 6);
});
[Theory]
[InlineData(typeof(PipeFlangeShape), "HolePatternDiameter")]
[InlineData(typeof(PipeFlangeShape), "HoleDiameter")]
[InlineData(typeof(RoundedRectangleShape), "Radius")]
public void ADraggedDiameterOrRadiusLeaderStillPointsAtTheCenter(Type shapeType, string property) => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
SelectShape(form, shapeType);
var preview = Find<ShapePreviewControl>(form, "previewBox");
// Off the default radius, but still within a corner arc's quarter turn.
foreach (var offset in new[] { PointF.Empty, new PointF(-25, -60), new PointF(-70, 10) })
{
preview.SetDragOffset(property, offset);
var (tip, end, center) = preview.RadialLeader(property)!.Value;
var toTip = new PointF(tip.X - center.X, tip.Y - center.Y);
var toEnd = new PointF(end.X - center.X, end.Y - center.Y);
var cross = toTip.X * toEnd.Y - toTip.Y * toEnd.X;
var lengths = System.Math.Sqrt(toTip.X * toTip.X + toTip.Y * toTip.Y) * System.Math.Sqrt(toEnd.X * toEnd.X + toEnd.Y * toEnd.Y);
Assert.True(lengths > 0);
Assert.True(System.Math.Abs(cross) / lengths < 0.01, $"{property} at {offset}: leader misses the center");
Assert.True(toTip.X * toEnd.X + toTip.Y * toEnd.Y > 0);
}
});
[Fact]
public void FlangeHoleCountSitsBesideTheHoleDiameter() => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
SelectShape(form, typeof(PipeFlangeShape));
var preview = Find<ShapePreviewControl>(form, "previewBox");
var diameter = preview.LabelBounds("HoleDiameter")!.Value;
var count = preview.LabelBounds("HoleCount")!.Value;
Assert.Equal(diameter.Right, count.Left);
Assert.Equal(diameter.Top, count.Top);
int ArcsWith(int holes)
{
var box = OpenEditor(form, "HoleCount");
box.Text = holes.ToString();
Press(box, Keys.Enter);
Assert.Null(form.ActiveEditor);
return Arcs(preview);
}
var ten = ArcsWith(10);
var perHole = ArcsWith(11) - ten;
Assert.True(perHole > 0);
Assert.Equal(ten + 2 * perHole, ArcsWith(12));
});
[Fact]
public void BlindTogglesOnClickAndDropsTheBore() => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
SelectShape(form, typeof(PipeFlangeShape));
var preview = Find<ShapePreviewControl>(form, "previewBox");
Assert.Contains("Blind", preview.LabelKeys);
var arcs = Arcs(preview);
form.EditParameter("Blind");
Assert.Null(form.ActiveEditor);
Assert.True(Arcs(preview) < arcs);
});
[Fact]
public void DraggingADimensionLabelMovesItAndAClickStillEdits() => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
SelectShape(form, typeof(RectangleShape));
var preview = Find<ShapePreviewControl>(form, "previewBox");
var before = preview.LabelBounds("Length")!.Value;
var grab = new Point(before.X + before.Width / 2, before.Y + before.Height / 2);
Mouse(preview, "OnMouseDown", MouseButtons.Left, grab);
Mouse(preview, "OnMouseMove", MouseButtons.Left, new Point(grab.X + 30, grab.Y + 40));
Mouse(preview, "OnMouseUp", MouseButtons.Left, new Point(grab.X + 30, grab.Y + 40));
var after = preview.LabelBounds("Length")!.Value;
Assert.Null(form.ActiveEditor);
Assert.InRange(after.Y, before.Y + 39, before.Y + 41);
Assert.InRange(after.X, before.X + 29, before.X + 31);
var moved = new Point(after.X + after.Width / 2, after.Y + after.Height / 2);
Mouse(preview, "OnMouseDown", MouseButtons.Left, moved);
Mouse(preview, "OnMouseUp", MouseButtons.Left, moved);
Assert.Equal("Length", preview.ActiveKey);
Assert.Equal(after.Location, form.ActiveEditor!.Location);
});
[Fact]
public void ALinearDimensionCannotBeDraggedOntoTheDrawing() => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
SelectShape(form, typeof(RectangleShape));
var preview = Find<ShapePreviewControl>(form, "previewBox");
var before = preview.LabelBounds("Length")!.Value;
preview.SetDragOffset("Length", new PointF(0, -500));
// The length sits below the drawing: it may come closer, never above its bottom edge.
var after = preview.LabelBounds("Length")!.Value;
Assert.True(after.Y < before.Y);
Assert.True(after.Y > before.Y - 50);
});
[Fact]
public void ASlidLabelKeepsItsPlaceBetweenTheArrowsAsTheViewZooms() => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
SelectShape(form, typeof(RectangleShape));
var preview = Find<ShapePreviewControl>(form, "previewBox");
var box = preview.Plate.Parts.Single().BoundingBox;
// Where the label's center sits along the drawing's width: 0 at the left arrow, 1 at the right.
double Place()
{
var bounds = preview.LabelBounds("Length")!.Value;
var left = preview.PointWorldToControl(box.X, box.Y).X;
var right = preview.PointWorldToControl(box.X + box.Length, box.Y).X;
return (bounds.X + bounds.Width / 2.0 - left) / (right - left);
}
var width = preview.PointWorldToControl(box.X + box.Length, box.Y).X - preview.PointWorldToControl(box.X, box.Y).X;
preview.SetDragOffset("Length", new PointF(width * 0.3f, 0));
var place = Place();
Assert.InRange(place, 0.75, 0.85);
for (var i = 0; i < 4; i++)
Mouse(preview, "OnMouseWheel", MouseButtons.None, new Point(40, 40), -120);
Assert.InRange(Place(), place - 0.02, place + 0.02);
// Dragging again starts from where the label is now shown.
var bounds = preview.LabelBounds("Length")!.Value;
var grab = new Point(bounds.X + bounds.Width / 2, bounds.Y + bounds.Height / 2);
Mouse(preview, "OnMouseDown", MouseButtons.Left, grab);
Mouse(preview, "OnMouseMove", MouseButtons.Left, new Point(grab.X + 10, grab.Y));
Mouse(preview, "OnMouseUp", MouseButtons.Left, new Point(grab.X + 10, grab.Y));
Assert.InRange(preview.LabelBounds("Length")!.Value.X, bounds.X + 9, bounds.X + 11);
});
[Theory]
[InlineData(120, false)]
[InlineData(-120, false)]
[InlineData(120, true)]
[InlineData(-120, true)]
public void DimensionsKeepTheirPlaceOnTheDrawingAsTheViewZooms(int wheel, bool dragged) => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
SelectShape(form, typeof(RectangleShape));
var preview = Find<ShapePreviewControl>(form, "previewBox");
var box = preview.Plate.Parts.Single().BoundingBox;
if (dragged)
{
preview.SetDragOffset("Length", new PointF(0, 25));
preview.SetDragOffset("Width", new PointF(30, 0));
}
// Each dimension line's distance from the drawing's corner, in drawing units.
(double Length, double Width) Standoffs()
{
var corner = preview.PointWorldToControl(box.X, box.Y);
var length = preview.LabelBounds("Length")!.Value;
var width = preview.LabelBounds("Width")!.Value;
return ((length.Y + length.Height / 2.0 - corner.Y) / preview.ViewScale,
(width.X + width.Width / 2.0 - corner.X) / preview.ViewScale);
}
var before = Standoffs();
var fitted = preview.ViewScale;
for (var i = 0; i < 2; i++)
Mouse(preview, "OnMouseWheel", MouseButtons.None, new Point(60, 60), wheel);
var after = Standoffs();
Assert.NotEqual(fitted, preview.ViewScale);
// Label bounds round to whole pixels.
var pixel = 1.5 / System.Math.Min(fitted, preview.ViewScale);
Assert.InRange(after.Length, before.Length - pixel, before.Length + pixel);
Assert.InRange(after.Width, before.Width - pixel, before.Width + pixel);
});
[Fact]
public void WheelZoomIsKeptWhileEditingAndFitResetsIt() => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
SelectShape(form, typeof(RectangleShape));
var preview = Find<ShapePreviewControl>(form, "previewBox");
var fitted = preview.ViewScale;
Mouse(preview, "OnMouseWheel", MouseButtons.None, new Point(100, 100), 120);
var zoomed = preview.ViewScale;
Assert.True(zoomed > fitted);
Assert.True(preview.IsUserView);
var box = OpenEditor(form, "Length");
box.Text = "20";
Press(box, Keys.Enter);
Assert.Equal(zoomed, preview.ViewScale);
Assert.True(preview.IsUserView);
preview.ZoomToFit();
Assert.False(preview.IsUserView);
Mouse(preview, "OnMouseWheel", MouseButtons.None, new Point(100, 100), 120);
SelectShape(form, typeof(CircleShape));
Assert.False(preview.IsUserView);
});
[Theory]
[InlineData(MouseButtons.Left)]
[InlineData(MouseButtons.Middle)]
public void DraggingTheViewPansItAndMovesTheLabels(MouseButtons button) => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
SelectShape(form, typeof(RectangleShape));
var preview = Find<ShapePreviewControl>(form, "previewBox");
var scale = preview.ViewScale;
var before = preview.LabelBounds("Length")!.Value;
var start = new Point(preview.Width / 2, preview.Height / 2);
var end = new Point(start.X + 25, start.Y - 15);
Mouse(preview, "OnMouseDown", button, start);
Mouse(preview, "OnMouseMove", button, end);
Mouse(preview, "OnMouseUp", button, end);
var after = preview.LabelBounds("Length")!.Value;
Assert.Null(form.ActiveEditor);
Assert.True(preview.IsUserView);
Assert.Equal(scale, preview.ViewScale);
Assert.InRange(after.X, before.X + 24, before.X + 26);
Assert.InRange(after.Y, before.Y - 16, before.Y - 14);
// A changed parameter keeps the panned view.
var box = OpenEditor(form, "Width");
box.Text = "7";
Press(box, Keys.Enter);
Assert.True(preview.IsUserView);
});
[Fact]
public void ShapeGridShowsAThumbnailForEveryShape() => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
var grid = Find<ListView>(form, "shapeListView");
var shapeCount = typeof(ShapeDefinition).Assembly.GetTypes()
.Count(t => t.IsClass && !t.IsAbstract && typeof(ShapeDefinition).IsAssignableFrom(t));
Assert.Equal(shapeCount, grid.Items.Count);
Assert.Equal(shapeCount, grid.LargeImageList!.Images.Count);
foreach (ListViewItem item in grid.Items)
{
using var thumbnail = (Bitmap)grid.LargeImageList.Images[item.ImageIndex];
Assert.True(HasInk(thumbnail), $"'{item.Text}' has a blank thumbnail");
}
});
[Fact]
public void SelectingATileShowsThatShape() => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
var name = Find<TextBox>(form, "nameTextBox");
SelectShape(form, typeof(CircleShape));
var circleName = name.Text;
SelectShape(form, typeof(RectangleShape));
Assert.NotEqual(circleName, name.Text);
Assert.NotEmpty(Find<ShapePreviewControl>(form, "previewBox").Plate.Parts);
});
private static void SelectShape(ShapeLibraryForm form, Type shapeType)
{
var grid = Find<ListView>(form, "shapeListView");
// Selection events come from the native list, so it needs a window.
_ = grid.Handle;
grid.Items.Cast<ListViewItem>().Single(item =>
(Type)item.Tag!.GetType().GetProperty("ShapeType")!.GetValue(item.Tag)! == shapeType).Selected = true;
}
private static bool HasInk(Bitmap bitmap)
{
for (var x = 0; x < bitmap.Width; x += 4)
for (var y = 0; y < bitmap.Height; y += 4)
if (bitmap.GetPixel(x, y).A > 0)
return true;
return false;
}
private static T Find<T>(Control form, string name) where T : Control =>
Assert.IsType<T>(form.Controls.Find(name, true).Single());
private static int Arcs(ShapePreviewControl preview) =>
preview.Plate.Parts.Single().Program.Codes.OfType<OpenNest.CNC.ArcMove>().Count();
private static TextBox OpenEditor(ShapeLibraryForm form, string key)
{
form.EditParameter(key);
return Assert.IsAssignableFrom<TextBox>(form.ActiveEditor);
}
private static void Mouse(Control control, string method, MouseButtons button, Point location, int delta = 0) =>
control.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(control, new object[] { new MouseEventArgs(button, 1, location.X, location.Y, delta) });
private static void Press(Control control, Keys key) => typeof(Control)
.GetMethod("OnKeyDown", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(control, new object[] { new KeyEventArgs(key) });
private static void InvokeAdd(ShapeLibraryForm form) => typeof(ShapeLibraryForm)
.GetMethod("AddButton_Click", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(form, new object?[] { null, EventArgs.Empty });
}