Files
OpenNest/OpenNest.WinForms.Tests/Forms/AutoNestFormStockOptionsTests.cs
T
aj fc53c7732e fix(ui): format stock sizes invariantly and bound validation notices
Address Task 3b quality review:
- FormatSize now emits invariant decimal notation so a comma-decimal
  locale cannot produce stock text its own invariant parser rejects.
- Validation notices embed a bounded single-line preview instead of the
  full cell value.
- Documented GetPlateOptions' all-or-nothing throwing contract.
- STA test helpers run the worker as a background thread with a
  diagnostic timeout message; added a de-DE fractional stock round-trip
  regression.
2026-09-29 23:29:20 -04:00

250 lines
8.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections;
using System.ComponentModel;
using System.Globalization;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Text.RegularExpressions;
using System.Windows.Forms;
using OpenNest.Forms;
namespace OpenNest.WinForms.Tests.Forms;
public class AutoNestFormStockOptionsTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public void StockOptionsKeepAnAddRowAfterAddingAPlate(bool loadSavedOptions)
{
RunSta(() =>
{
using var form = new AutoNestForm(new Nest());
if (loadSavedOptions)
form.LoadPlateOptions(new List<PlateOption>
{
new() { Width = 48, Length = 96, Cost = 25 },
}, 0.5);
form.OptimizePlateSize = true;
var grid = GetGrid(form);
var initialCount = form.GetPlateOptions().Count;
AssertNewRow(grid);
var rows = Assert.IsAssignableFrom<IBindingList>(grid.DataSource);
Assert.True(rows.AllowNew);
var added = rows.AddNew()!;
SetValue(added, "Size", "84 x 168");
SetValue(added, "Cost", 123.5);
((ICancelAddNew)rows).EndNew(rows.Count - 1);
var options = form.GetPlateOptions();
Assert.Equal(initialCount + 1, options.Count);
var option = options.Last();
Assert.Equal(84, option.Width);
Assert.Equal(168, option.Length);
Assert.Equal(123.5, option.Cost);
AssertNewRow(grid);
});
}
[Fact]
public void BlankAndCancelledRowsDoNotBecomeStockOptions()
{
RunSta(() =>
{
using var form = new AutoNestForm(new Nest());
var saved = new List<PlateOption>
{
new() { Width = 48, Length = 96, Cost = 25 },
};
form.LoadPlateOptions(saved, 0.5);
var grid = GetGrid(form);
var rows = Assert.IsAssignableFrom<IBindingList>(grid.DataSource);
var addNew = Assert.IsAssignableFrom<ICancelAddNew>(rows);
rows.AddNew();
Assert.Single(form.GetPlateOptions());
addNew.CancelNew(rows.Count - 1);
Assert.Single((IEnumerable)rows);
rows.Clear();
Assert.Empty(form.GetPlateOptions());
AssertNewRow(grid);
Assert.Single(saved);
Assert.Equal(48, saved[0].Width);
Assert.Equal(96, saved[0].Length);
Assert.Equal(25, saved[0].Cost);
});
}
[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();
});
[Fact]
public void FractionalStockSizesRoundTripUnderCommaDecimalCulture() => RunSta(() =>
{
var original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
using var form = new StockTestForm();
// FormatSize must emit invariant dots or the reloaded collection would be
// rejected as invalid under the invariant parser.
form.LoadPlateOptions(new List<PlateOption>
{
new() { Width = 48.5, Length = 96.25, Cost = 25 },
}, 0.5);
Assert.True(form.TryGetPlateOptions(out var options, out var error));
Assert.Null(error);
Assert.Equal(48.5, options[0].Width);
Assert.Equal(96.25, options[0].Length);
}
finally
{
CultureInfo.CurrentCulture = original;
}
});
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.
form.BindingContext = new BindingContext();
var grid = Assert.IsType<DataGridView>(form.Controls.Find("plateGrid", true).Single());
grid.CreateControl();
return grid;
}
private static void AssertNewRow(DataGridView grid)
{
Assert.True(grid.AllowUserToAddRows);
Assert.True(grid.NewRowIndex >= 0);
Assert.True(grid.Rows[grid.NewRowIndex].IsNewRow);
}
private static void SetValue(object row, string property, object value) =>
TypeDescriptor.GetProperties(row)[property]!.SetValue(row, value);
private static void RunSta(Action action)
{
Exception? error = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { error = ex; }
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(15)), "The STA test did not complete.");
if (error != null)
ExceptionDispatchInfo.Capture(error).Throw();
}
}