Files
OpenNest/OpenNest.WinForms.Tests/Forms/UndoRedoTests.cs
T
ajandClaude Opus 5.5 223bfea47c feat(desktop): undo fills, pattern tiles, auto nest and clear nest
Placement edits were only caught by the fingerprint safety net, so they
undid as an anonymous "Change" or folded into the next edit. Each now
records one labelled step: Fill (at the commit, so a stopped fill that
keeps nothing records nothing), Fill Area, Pattern Tile, Auto Nest (from
the kept proposal only; Make Nests tabs too), Clear Nest, Remove Empty
Plates and Center Parts. Clear Nest no longer warns that it cannot be
undone.

Pattern Tile onto a new plate created a plate beside the empty sentinel,
which the plate manager dropped at once, so the pattern landed on a
detached plate; it now fills the sentinel.

The undo tests share one harness that compares a text description of the
nest before an edit and after Undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:47 -04:00

357 lines
13 KiB
C#

using System.Drawing;
using System.Reflection;
using System.Windows.Forms;
using OpenNest.Controls;
using OpenNest.Forms;
namespace OpenNest.WinForms.Tests.Forms;
// Undo swaps whole nests into the window. Reads Application.OpenForms through the busy
// guards, so it must not overlap fill-lifetime tests.
[Collection("Fill operation lifetime")]
public class UndoRedoTests
{
[Fact]
public void UndoKeepsThePlateTheViewportAndTheSelection() => Run(env =>
{
var editor = env.Editor;
var view = editor.PlateView;
editor.PlateManager.LoadAt(1);
view.SelectAll();
var before = view.Plate.Parts.Select(p => p.Location).ToArray();
view.Selection.NudgeSelected(5, 0);
view.RestoreViewport((2.5f, new PointF(13, 17)));
Assert.True(env.Host.TryUndo(editor));
Assert.Equal(1, editor.PlateManager.CurrentIndex);
Assert.Same(editor.Nest.Plates[1], view.Plate);
Assert.Equal((2.5f, new PointF(13, 17)), view.CaptureViewport());
Assert.Equal(before, view.Plate.Parts.Select(p => p.Location).ToArray());
Assert.Equal(view.Plate.Parts, view.SelectedParts.Select(p => p.BasePart));
Assert.All(view.SelectedParts, layout => Assert.True(layout.IsSelected));
});
[Fact]
public void UndoKeepsTheNestIdentityAndTheDocument() => Run(env =>
{
var editor = env.Editor;
var document = editor.Document;
editor.PlateManager.LoadAt(0);
editor.PlateView.SelectAll();
editor.PlateView.Selection.DeleteSelected();
editor.Nest.Name = "renamed";
editor.Nest.Status = NestStatus.HasBeenCut;
Assert.True(env.Host.TryUndo(editor));
Assert.Same(document, editor.Document);
Assert.Equal("renamed", editor.Nest.Name);
Assert.Equal(NestStatus.HasBeenCut, editor.Nest.Status);
Assert.Equal(2, editor.Nest.Plates[0].Parts.Count);
});
[Fact]
public void EachCommandRecordsOneEntryAndRedoRepeatsIt() => Run(env =>
{
var editor = env.Editor;
var view = editor.PlateView;
editor.PlateManager.LoadAt(0);
view.SelectAll();
view.RotateSelectedParts(System.Math.PI / 2);
Assert.Equal(1, editor.History.UndoCount);
Assert.Equal("Rotate", editor.History.UndoLabel);
view.PushSelected(PushDirection.Left);
view.AlignSelected(AlignType.Bottom);
view.Selection.DeleteSelected();
Assert.Equal(4, editor.History.UndoCount);
Assert.Equal("Delete", editor.History.UndoLabel);
Assert.Empty(editor.Nest.Plates[0].Parts);
Assert.True(env.Host.TryUndo(editor));
Assert.Equal(2, editor.Nest.Plates[0].Parts.Count);
Assert.Equal("Delete", editor.History.RedoLabel);
Assert.True(env.Host.TryRedo(editor));
Assert.Empty(editor.Nest.Plates[0].Parts);
});
[Fact]
public void ACommandThatChangesNothingRecordsNothing() => Run(env =>
{
var editor = env.Editor;
editor.PlateManager.LoadAt(0);
editor.PlateView.DeselectAll();
editor.PlateView.Selection.DeleteSelected();
editor.PlateView.RotateSelectedParts(1);
Assert.Equal(0, editor.History.UndoCount);
});
[Fact]
public void NudgesOfOneSelectionUndoAsOne() => Run(env =>
{
var editor = env.Editor;
editor.PlateManager.LoadAt(0);
editor.PlateView.SelectAll();
var start = editor.Nest.Plates[0].Parts[0].Location;
for (var i = 0; i < 3; i++)
editor.PlateView.Selection.NudgeSelected(1, 0);
Assert.Equal(1, editor.History.UndoCount);
Assert.Equal("Nudge", editor.History.UndoLabel);
Assert.True(env.Host.TryUndo(editor));
Assert.Equal(start, editor.Nest.Plates[0].Parts[0].Location);
});
[Fact]
public void ADragRecordsOneEntry() => Run(env =>
{
var editor = env.Editor;
var view = editor.PlateView;
editor.PlateManager.LoadAt(0);
view.ZoomToFit();
var part = view.Plate.Parts[0];
var start = part.Location;
var grab = view.PointWorldToControl(part.BoundingBox.Center);
Mouse(view, "OnMouseMove", MouseButtons.None, grab);
Mouse(view, "OnMouseDown", MouseButtons.Left, grab);
for (var step = 1; step <= 5; step++)
Mouse(view, "OnMouseMove", MouseButtons.Left, new Point(grab.X + step * 4, grab.Y));
Mouse(view, "OnMouseUp", MouseButtons.Left, new Point(grab.X + 20, grab.Y));
Assert.NotEqual(start, part.Location);
Assert.Equal(1, editor.History.UndoCount);
Assert.Equal("Move", editor.History.UndoLabel);
Assert.True(env.Host.TryUndo(editor));
Assert.Equal(start, editor.Nest.Plates[0].Parts[0].Location);
});
[Fact]
public void AClickWithoutMovingRecordsNothing() => Run(env =>
{
var editor = env.Editor;
var view = editor.PlateView;
editor.PlateManager.LoadAt(0);
view.ZoomToFit();
var grab = view.PointWorldToControl(view.Plate.Parts[0].BoundingBox.Center);
Mouse(view, "OnMouseMove", MouseButtons.None, grab);
Mouse(view, "OnMouseDown", MouseButtons.Left, grab);
Mouse(view, "OnMouseUp", MouseButtons.Left, grab);
Assert.Single(view.SelectedParts);
Assert.Equal(0, editor.History.UndoCount);
});
[Fact]
public void AScopeLeftIncompleteRollsBack() => Run(env =>
{
var editor = env.Editor;
var start = editor.Nest.Plates[0].Parts[0].Location;
using (var change = editor.BeginChange("Broken"))
{
using (var inner = editor.BeginChange("Inner"))
{
inner.MarkChanged();
editor.Nest.Plates[0].Parts[0].Offset(7, 0);
inner.Complete();
}
// Not completed: an exception or a cancellation.
}
Assert.Equal(start, editor.Nest.Plates[0].Parts[0].Location);
Assert.Equal(0, editor.History.UndoCount);
Assert.False(editor.IsChangeInProgress);
});
[Fact]
public void AnIncompleteInnerScopeRollsBackTheWholeEdit() => Run(env =>
{
var editor = env.Editor;
var start = editor.Nest.Plates[0].Parts[0].Location;
using (var change = editor.BeginChange("Outer"))
{
change.MarkChanged();
editor.Nest.Plates[0].Parts[0].Offset(7, 0);
using (editor.BeginChange("Inner")) { }
change.Complete();
}
Assert.Equal(start, editor.Nest.Plates[0].Parts[0].Location);
Assert.Equal(0, editor.History.UndoCount);
});
[Fact]
public void AnUnrecordedEditBecomesItsOwnEntryBeforeUndo() => Run(env =>
{
var editor = env.Editor;
editor.PlateManager.LoadAt(0);
editor.PlateView.SelectAll();
editor.PlateView.RotateSelectedParts(System.Math.PI);
var rotated = editor.Nest.Plates[0].Parts[0].Location;
editor.Nest.Plates[0].Parts[0].Offset(3, 0); // An edit no scope recorded.
Assert.True(env.Host.TryUndo(editor));
Assert.Equal(rotated, editor.Nest.Plates[0].Parts[0].Location);
Assert.Equal("Rotate", editor.History.UndoLabel);
Assert.Equal("Change", editor.History.RedoLabel);
});
[Fact]
public void UndoWaitsForAFillAndABackgroundSave() => Run(env =>
{
var editor = env.Editor;
editor.PlateManager.LoadAt(0);
editor.PlateView.SelectAll();
editor.PlateView.Selection.DeleteSelected();
SetBackingField(editor.PlateView, "IsFillInProgress", true);
try
{
Assert.True(env.Host.IsUndoBusy(editor));
Assert.False(env.Host.TryUndo(editor));
}
finally
{
SetBackingField(editor.PlateView, "IsFillInProgress", false);
}
var storage = typeof(MainForm).GetField("storage", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(env.Host)!;
SetBackingField(storage, "IsSaving", true);
try
{
Assert.False(env.Host.TryUndo(editor));
Assert.Empty(editor.Nest.Plates[0].Parts);
}
finally
{
SetBackingField(storage, "IsSaving", false);
}
// A selection alone is not busy work for undo.
editor.PlateView.SelectAll();
Assert.True(env.Host.TryUndo(editor));
Assert.Equal(2, editor.Nest.Plates[0].Parts.Count);
});
[Fact]
public void TextEditorsKeepTheirOwnUndo()
{
using var panel = new Panel();
var text = new TextBox();
panel.Controls.Add(text);
using var numeric = new System.Windows.Forms.NumericUpDown();
var edit = numeric.Controls.OfType<TextBoxBase>().Single();
using var list = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList };
using var view = new PlateView();
Assert.True(MainForm.KeepsOwnUndo(text));
Assert.True(MainForm.KeepsOwnUndo(edit));
Assert.True(MainForm.KeepsOwnUndo(numeric));
Assert.False(MainForm.KeepsOwnUndo(list));
Assert.False(MainForm.KeepsOwnUndo(view));
Assert.False(MainForm.KeepsOwnUndo(null));
}
[Fact]
public void CtrlZInAFocusedTextBoxLeavesTheNestAlone() => Run(env =>
{
var editor = env.Editor;
editor.PlateManager.LoadAt(0);
editor.PlateView.SelectAll();
editor.PlateView.Selection.DeleteSelected();
var box = new TextBox();
editor.Controls.Add(box);
box.Focus();
Application.DoEvents();
if (!box.Focused)
return; // No interactive focus in this session; KeepsOwnUndo covers the rule.
var handled = (bool)typeof(MainForm).GetMethod("ProcessUndoKeys", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(env.Host, new object[] { Keys.Control | Keys.Z })!;
Assert.False(handled);
Assert.Empty(editor.Nest.Plates[0].Parts);
Assert.Equal(1, editor.History.UndoCount);
});
[Fact]
public void UndoWithAnEmptyPlateInTheMiddleLandsOnTheSamePlate() => Run(env =>
{
var editor = env.Editor;
var nest = editor.Nest;
// Plates: 0 parts, 1 parts, 2 empty, 3 parts, then the empty sentinel.
Assert.Empty(nest.Plates[2].Parts);
editor.PlateManager.LoadAt(3);
editor.PlateView.SelectAll();
var start = nest.Plates[3].Parts[0].Location;
editor.PlateView.Selection.NudgeSelected(0, 4);
Assert.True(env.Host.TryUndo(editor));
// A copy that dropped the empty plate would shift this one to index 2.
Assert.Equal(5, editor.Nest.Plates.Count);
Assert.Equal(3, editor.PlateManager.CurrentIndex);
Assert.Same(editor.Nest.Plates[3], editor.PlateView.Plate);
Assert.Equal(start, Assert.Single(editor.PlateView.Plate.Parts).Location);
Assert.Empty(editor.Nest.Plates[2].Parts);
});
[Fact]
public void AddingAndRemovingPlatesUndo() => Run(env =>
{
var editor = env.Editor;
var count = editor.Nest.Plates.Count;
editor.AddPlate();
Assert.Equal("Add Plate", editor.History.UndoLabel);
Assert.True(env.Host.TryUndo(editor));
Assert.Equal(count, editor.Nest.Plates.Count);
editor.PlateManager.LoadAt(1);
editor.RemoveCurrentPlate();
Assert.Equal(count - 1, editor.Nest.Plates.Count);
Assert.True(env.Host.TryUndo(editor));
Assert.Equal(count, editor.Nest.Plates.Count);
Assert.Equal(1, editor.PlateManager.CurrentIndex);
});
[Fact]
public void TheEditMenuNamesTheNextUndoAndRedo() => Run(env =>
{
var editor = env.Editor;
editor.PlateManager.LoadAt(0);
editor.PlateView.SelectAll();
editor.PlateView.Selection.DeleteSelected();
var undo = MenuItem(env.Host, "mnuEditUndo");
var redo = MenuItem(env.Host, "mnuEditRedo");
Assert.Equal("Undo Delete", undo.Text);
Assert.True(undo.Enabled);
Assert.False(redo.Enabled);
env.Host.TryUndo(editor);
Assert.Equal("Undo", undo.Text);
Assert.False(undo.Enabled);
Assert.Equal("Redo Delete", redo.Text);
Assert.True(redo.Enabled);
});
private static void Run(Action<UndoTestEnv> body) => UndoTestEnv.Run(body);
private static ToolStripMenuItem MenuItem(MainForm host, string name) => UndoTestEnv.MenuItem(host, name);
private static void Mouse(PlateView view, string method, MouseButtons button, Point location) =>
UndoTestEnv.Mouse(view, method, button, location);
private static void SetBackingField(object target, string property, object value) =>
UndoTestEnv.SetBackingField(target, property, value);
}