fix(ui): reject timed-out stock and shape input

This commit is contained in:
aj
2026-09-29 22:27:05 -04:00
parent bf608fa271
commit 43ebd61fdf
7 changed files with 405 additions and 38 deletions
+65
View File
@@ -0,0 +1,65 @@
using System.Drawing;
using System.Globalization;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Text.RegularExpressions;
using System.Windows.Forms;
namespace OpenNest.WinForms.Tests;
public class ArchUnitsTests
{
[Fact]
public void ArchitecturalRegexHasExplicitTimeout()
{
var regex = Assert.IsType<Regex>(typeof(ArchUnits)
.GetField("UnitRegex", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null));
Assert.Equal(TimeSpan.FromMilliseconds(250), regex.MatchTimeout);
Assert.Equal(RegexOptions.None, regex.Options);
}
[Theory]
[InlineData("5' 6.5\"", 66.5)]
[InlineData("2 feet 3 1/2 inches", 27.5)]
[InlineData("1-1/2\"", 1.5)]
[InlineData("48", 48)]
[InlineData("", 0)]
public void OrdinaryArchitecturalInputsKeepTheirMeaning(string input, double expected)
{
var original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Assert.Equal(expected, ArchUnits.ParseToInches(input));
}
finally { CultureInfo.CurrentCulture = original; }
}
[Fact]
public void InvalidFieldIsRedAndNaNAndCorrectionRestoresIt() => RunSta(() =>
{
using var field = new TextBox { Text = "invalid dimension" };
Assert.True(double.IsNaN(ArchUnits.GetLengthInches(field)));
Assert.Equal(Color.Red, field.ForeColor);
field.Text = "12\"";
Assert.Equal(12, ArchUnits.GetLengthInches(field));
Assert.Equal(SystemColors.WindowText, field.ForeColor);
});
// Actual timeout execution is covered by the shared Regex mechanism in FractionTests;
// these desktop tests deliberately avoid timing-sensitive oversized-input fixtures.
internal static void RunSta(Action action)
{
Exception? error = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { error = ex; }
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(15)));
if (error != null)
ExceptionDispatchInfo.Capture(error).Throw();
}
}
@@ -1,6 +1,8 @@
using System.Collections;
using System.ComponentModel;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Text.RegularExpressions;
using System.Windows.Forms;
using OpenNest.Forms;
@@ -73,6 +75,117 @@ public class AutoNestFormStockOptionsTests
});
}
[Fact]
public void StockSizeRegexHasExplicitTimeout()
{
var regex = Assert.IsType<Regex>(typeof(AutoNestForm)
.GetField("SizePattern", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null));
Assert.Equal(TimeSpan.FromMilliseconds(250), regex.MatchTimeout);
Assert.Equal(RegexOptions.None, regex.Options);
}
[Theory]
[InlineData("48 x")]
[InlineData("abc")]
[InlineData("0 x 96")]
[InlineData("-1 x 96")]
[InlineData("١ x 96")]
public void NonblankInvalidRowRejectsWholeCollection(string invalid) => RunSta(() =>
{
using var form = new StockTestForm();
form.LoadPlateOptions(new List<PlateOption> { new() { Width = 48, Length = 96, Cost = 25 } }, 0.5);
var grid = GetGrid(form);
var rows = Assert.IsAssignableFrom<IBindingList>(grid.DataSource);
var row = rows.AddNew()!;
SetValue(row, "Size", invalid);
((ICancelAddNew)rows).EndNew(rows.Count - 1);
Assert.False(form.TryGetPlateOptions(out var options, out var error));
Assert.Empty(options);
Assert.Contains(invalid, error);
Assert.Contains("row 2", error);
Assert.Throws<FormatException>(() => form.GetPlateOptions());
Assert.NotEmpty(grid.Rows[1].ErrorText);
SetValue(row, "Size", "72.5 × 144.25");
Assert.True(form.TryGetPlateOptions(out options, out error));
Assert.Null(error);
Assert.Equal(2, options.Count);
Assert.Equal(72.5, options[1].Width);
Assert.Equal(144.25, options[1].Length);
Assert.Equal(25, options[0].Cost);
Assert.Empty(grid.Rows[1].ErrorText);
});
[Fact]
public void TimeoutAfterValidRowPublishesNoPrefixAndDoesNotAcceptDialog() => RunSta(() =>
{
using var form = new StockTestForm();
form.LoadPlateOptions(new List<PlateOption> { new() { Width = 48, Length = 96 } }, 0.5);
var grid = GetGrid(form);
var rows = Assert.IsAssignableFrom<IBindingList>(grid.DataSource);
var row = rows.AddNew()!;
SetValue(row, "Size", "timeout row");
((ICancelAddNew)rows).EndNew(rows.Count - 1);
form.FailOn = "timeout row";
Assert.False(form.TryGetPlateOptions(out var options, out var error));
Assert.Empty(options);
Assert.Contains("timeout row", error);
Assert.True(form.TryClose(DialogResult.OK));
Assert.Equal(DialogResult.None, form.DialogResult);
Assert.Contains("timeout row", Assert.Single(form.Notices));
Assert.True(form.MatchCalls > 1);
form.Notices.Clear();
Assert.False(form.TryClose(DialogResult.Cancel));
Assert.Empty(form.Notices);
Assert.False(Assert.IsType<Button>(form.Controls.Find("cancelButton", true).Single()).CausesValidation);
});
[Fact]
public void InvalidPendingEditCannotPublishOldBoundValue() => RunSta(() =>
{
using var form = new StockTestForm();
form.LoadPlateOptions(new List<PlateOption> { new() { Width = 48, Length = 96 } }, 0.5);
var grid = GetGrid(form);
form.Show();
grid.CurrentCell = grid.Rows[0].Cells[0];
Assert.True(grid.BeginEdit(false));
grid.EditingControl.Text = "48 x";
Assert.False(form.TryGetPlateOptions(out var options, out var error));
Assert.Empty(options);
Assert.Contains("48 x", error);
grid.CancelEdit();
});
private sealed class StockTestForm : AutoNestForm
{
public StockTestForm() : base(new Nest()) { }
public string? FailOn { get; set; }
public int MatchCalls { get; private set; }
public List<string> Notices { get; } = new();
internal override Match MatchStockSize(string value)
{
MatchCalls++;
if (value == FailOn)
throw new RegexMatchTimeoutException();
return base.MatchStockSize(value);
}
internal override void ReportStockValidationFailure(string error) => Notices.Add(error);
public bool TryClose(DialogResult result)
{
DialogResult = result;
var closing = new FormClosingEventArgs(CloseReason.UserClosing, false);
OnFormClosing(closing);
return closing.Cancel;
}
}
private static DataGridView GetGrid(AutoNestForm form)
{
// Binding needs a context even when the test does not show the form.
@@ -0,0 +1,85 @@
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 InvalidShapeInputClearsOldPreviewAndBlocksAddUntilCorrected() => ArchUnitsTests.RunSta(() =>
{
using var form = new ShapeLibraryForm();
var list = Find<ListBox>(form, "shapeListBox");
list.SelectedItem = list.Items.Cast<object>().Single(entry =>
(Type)entry.GetType().GetProperty("ShapeType")!.GetValue(entry)! == typeof(RectangleShape));
var preview = Find<ShapePreviewControl>(form, "previewBox");
var add = Find<Button>(form, "addButton");
var input = Find<Panel>(form, "parametersPanel").Controls.OfType<TextBox>().First();
var valid = input.Text;
Assert.NotEmpty(preview.Plate.Parts);
Assert.True(add.Enabled);
input.Text = "invalid dimension";
Assert.Equal(Color.Red, input.ForeColor);
Assert.Empty(preview.Plate.Parts);
Assert.False(add.Enabled);
InvokeAdd(form);
Assert.Empty(form.GetDrawings());
input.Text = valid;
Assert.Equal(SystemColors.WindowText, input.ForeColor);
Assert.NotEmpty(preview.Plate.Parts);
Assert.True(add.Enabled);
InvokeAdd(form);
Assert.Single(form.GetDrawings());
});
private static T Find<T>(Control form, string name) where T : Control =>
Assert.IsType<T>(form.Controls.Find(name, true).Single());
private static void InvokeAdd(ShapeLibraryForm form) => typeof(ShapeLibraryForm)
.GetMethod("AddButton_Click", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(form, new object?[] { null, EventArgs.Empty });
}
+3 -1
View File
@@ -10,7 +10,9 @@ namespace OpenNest
public static class ArchUnits
{
private static readonly Regex UnitRegex = new Regex(
"^(?<Feet>\\d+\\.?\\d*\\s*')?\\s*(?<Inches>\\d+\\.?\\d*\\s*\")?$"
"^(?<Feet>\\d+\\.?\\d*\\s*')?\\s*(?<Inches>\\d+\\.?\\d*\\s*\")?$",
RegexOptions.None,
TimeSpan.FromMilliseconds(250)
);
public static double ParseToInches(string input)
+82 -21
View File
@@ -10,11 +10,16 @@ namespace OpenNest.Forms
{
public partial class AutoNestForm : Form
{
private static readonly Regex SizePattern = new(@"^(\d+\.?\d*)\s*[xX×]\s*(\d+\.?\d*)$");
private static readonly Regex SizePattern = new(
@"^(\d+\.?\d*)\s*[xX×]\s*(\d+\.?\d*)$",
RegexOptions.None,
TimeSpan.FromMilliseconds(250)
);
public AutoNestForm(Nest nest)
{
InitializeComponent();
cancelButton.CausesValidation = false;
SetupPartsGrid();
SetupPlateGrid();
LoadEngines();
@@ -195,30 +200,65 @@ namespace OpenNest.Forms
public List<PlateOption> GetPlateOptions()
{
plateGrid.EndEdit();
var result = new List<PlateOption>();
if (!TryGetPlateOptions(out var options, out var error))
throw new FormatException(error);
return options;
}
public bool TryGetPlateOptions(out List<PlateOption> options, out string error)
{
options = new List<PlateOption>();
error = null;
if (!plateGrid.EndEdit())
{
var value = plateGrid.EditingControl?.Text ?? plateGrid.CurrentCell?.Value?.ToString();
error = $"Invalid stock size '{value}'. Enter positive dimensions as W x L.";
return false;
}
var gridItems = plateGrid.DataSource as BindingList<PlateOptionItem>;
if (gridItems == null)
return result;
return true;
foreach (var item in gridItems)
var validated = new List<PlateOption>();
for (var index = 0; index < gridItems.Count; index++)
{
var item = gridItems[index];
if (string.IsNullOrWhiteSpace(item.Size))
continue;
if (!TryParseSize(item.Size, out var width, out var length))
continue;
if (width <= 0 || length <= 0)
continue;
result.Add(
new PlateOption
{
Width = width,
Length = length,
Cost = item.Cost,
}
);
error = $"Invalid stock size '{item.Size}' in row {index + 1}. Enter positive dimensions as W x L.";
if (index < plateGrid.Rows.Count)
plateGrid.Rows[index].ErrorText = error;
return false;
}
return result;
if (index < plateGrid.Rows.Count)
plateGrid.Rows[index].ErrorText = "";
validated.Add(new PlateOption { Width = width, Length = length, Cost = item.Cost });
}
// Publish only a completely validated collection, never a valid prefix.
options = validated;
return true;
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (DialogResult == DialogResult.OK && OptimizePlateSize
&& !TryGetPlateOptions(out _, out var error))
{
e.Cancel = true;
DialogResult = DialogResult.None;
ReportStockValidationFailure(error);
}
base.OnFormClosing(e);
}
internal virtual void ReportStockValidationFailure(string error)
{
MessageBox.Show(this, error, "Auto Nest", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
public void LoadPlateOptions(List<PlateOption> options, double salvageRate)
@@ -300,7 +340,10 @@ namespace OpenNest.Forms
var value = e.FormattedValue?.ToString();
if (string.IsNullOrWhiteSpace(value))
{
plateGrid.Rows[e.RowIndex].ErrorText = "";
return;
}
if (!TryParseSize(value, out _, out _))
{
@@ -313,25 +356,43 @@ namespace OpenNest.Forms
}
}
private static bool TryParseSize(string value, out double width, out double length)
private bool TryParseSize(string value, out double width, out double length)
{
width = 0;
length = 0;
if (string.IsNullOrWhiteSpace(value))
return false;
var match = SizePattern.Match(value.Trim());
try
{
var match = MatchStockSize(value.Trim());
if (!match.Success)
return false;
width = double.Parse(
var parsedWidth = double.Parse(
match.Groups[1].Value,
System.Globalization.CultureInfo.InvariantCulture
);
length = double.Parse(
var parsedLength = double.Parse(
match.Groups[2].Value,
System.Globalization.CultureInfo.InvariantCulture
);
if (!double.IsFinite(parsedWidth) || !double.IsFinite(parsedLength)
|| parsedWidth <= 0 || parsedLength <= 0)
return false;
width = parsedWidth;
length = parsedLength;
return true;
}
catch (RegexMatchTimeoutException)
{
return false;
}
catch (FormatException)
{
return false;
}
}
internal virtual Match MatchStockSize(string value) => SizePattern.Match(value);
private static string FormatSize(double width, double length)
{
+6 -1
View File
@@ -1381,10 +1381,15 @@ namespace OpenNest.Forms
var items = form.GetNestItems();
if (!items.Any(it => it.Quantity > 0))
return;
var plateOptions = default(List<PlateOption>);
if (form.OptimizePlateSize && !form.TryGetPlateOptions(out plateOptions, out var stockError))
{
MessageBox.Show(this, stockError, "Auto Nest", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var engineName = form.EngineName ?? EngineSelection.EngineName;
EngineSelection.EngineName = engineName;
engineComboBox.SelectedItem = engineName;
var plateOptions = form.OptimizePlateSize ? form.GetPlateOptions() : null;
using var cts = new CancellationTokenSource();
nestingCts = cts;
using var progressForm = new NestProgressForm(cts, showPlateRow: true)
+39 -3
View File
@@ -13,6 +13,12 @@ namespace OpenNest.Forms
{
public partial class ShapeLibraryForm : Form
{
private static readonly Regex FriendlyNamePattern = new(
@"(?<=[a-z0-9])([A-Z])",
RegexOptions.None,
TimeSpan.FromMilliseconds(100)
);
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
@@ -272,9 +278,13 @@ namespace OpenNest.Forms
{
var shape = CreateShapeFromInputs();
if (shape == null)
{
InvalidatePreview();
return;
}
var drawing = shape.GetDrawing();
addButton.Enabled = drawing?.Program != null;
nameTextBox.Text = shape.GenerateName();
previewBox.ShowDrawing(drawing);
@@ -298,10 +308,17 @@ namespace OpenNest.Forms
}
catch
{
previewBox.ShowDrawing(null);
InvalidatePreview();
}
}
private void InvalidatePreview()
{
addButton.Enabled = false;
previewBox.ShowDrawing(null);
previewBox.SetInfo("Invalid shape input");
}
private void UpdatePipeSizeFilter()
{
// Find the PipeSize combo and the numeric inputs it depends on.
@@ -424,7 +441,10 @@ namespace OpenNest.Forms
{
var shape = CreateShapeFromInputs();
if (shape == null)
{
InvalidatePreview();
return;
}
var drawing = shape.GetDrawing();
drawing.Name = GetUniqueName(drawing.Name);
@@ -461,12 +481,28 @@ namespace OpenNest.Forms
}
}
private static string FriendlyName(string name)
internal static string FriendlyName(
string name,
Func<string, string> replace = null,
Action<string> report = null
)
{
if (name.EndsWith("Shape"))
name = name.Substring(0, name.Length - 5);
return Regex.Replace(name, @"(?<=[a-z0-9])([A-Z])", " $1");
try
{
return replace != null ? replace(name) : FriendlyNamePattern.Replace(name, " $1");
}
catch (RegexMatchTimeoutException)
{
var diagnostic = $"Friendly-name formatting timed out for '{name}'; using the unchanged name.";
if (report != null)
report(diagnostic);
else
System.Diagnostics.Trace.TraceWarning(diagnostic);
return name;
}
}
private class ShapeEntry