diff --git a/OpenNest.WinForms.Tests/Forms/PlateExportTests.cs b/OpenNest.WinForms.Tests/Forms/PlateExportTests.cs new file mode 100644 index 0000000..63a2db9 --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/PlateExportTests.cs @@ -0,0 +1,465 @@ +using System.Drawing; +using System.Globalization; +using System.Reflection; +using System.Runtime.ExceptionServices; +using OpenNest.CNC; +using OpenNest.Data; +using OpenNest.Forms; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.WinForms.Tests.Forms; + +[Collection("Fill operation lifetime")] +public class PlateExportTests +{ + [Fact] + public void CancelWritesNothingAndNotifiesNothing() => RunSta(() => + { + using var files = new ExportFiles(); + using var form = ShowJob(); + var path = files.PathFor("cancel.txt"); + form.DialogResults.Enqueue(null); + + Assert.False(form.Export()); + + Assert.Single(form.DialogRequests); + Assert.Empty(form.WriteRequests); + Assert.Empty(form.Failures); + Assert.False(File.Exists(path)); + Assert.Empty(Directory.GetFiles(files.DirectoryPath)); + }); + + [Theory] + [InlineData("en-US")] + [InlineData("fr-FR")] + public void CoordinatesPreserveFormatAndReleaseFile(string cultureName) => RunSta(() => + { + var originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); + using var files = new ExportFiles(); + using var form = ShowJob(); + var path = files.PathFor("coordinates.txt"); + var expected = ExpectedLines(form.PlateView.Plate); + form.DialogResults.Enqueue((path, 3)); + + Assert.True(form.Export()); + + Assert.True(File.Exists(path)); + Assert.Equal(expected, File.ReadAllLines(path)); + Assert.Empty(form.Failures); + AssertFileReleased(path); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + }); + + [Fact] + public void JpegRendersShownPlateAndReleasesFile() => RunSta(() => + { + using var files = new ExportFiles(); + using var form = ShowJob(); + var path = files.PathFor("plate.jpg"); + Assert.True(form.Visible); + Assert.True(form.PlateView.IsHandleCreated); + Assert.True(form.PlateView.Width > 0); + Assert.True(form.PlateView.Height > 0); + form.DialogResults.Enqueue((path, 2)); + + Assert.True(form.Export()); + + var bytes = File.ReadAllBytes(path); + Assert.True(bytes.Length > 2); + Assert.Equal(0xff, bytes[0]); + Assert.Equal(0xd8, bytes[1]); + using (var image = new Bitmap(path)) + { + Assert.Equal(System.Drawing.Imaging.ImageFormat.Jpeg.Guid, image.RawFormat.Guid); + Assert.Equal(form.PlateView.Width, image.Width); + Assert.Equal(form.PlateView.Height, image.Height); + } + Assert.Empty(form.Failures); + AssertFileReleased(path); + }); + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void MissingParentDirectoryReportsOneFailure(int filterIndex) => RunSta(() => + { + using var files = new ExportFiles(); + using var form = ShowJob(); + var path = files.PathFor(Path.Combine("missing", "plate")); + form.DialogResults.Enqueue((path, filterIndex)); + + Assert.False(form.Export()); + + var failure = Assert.Single(form.Failures); + Assert.Equal(path, failure.Destination); + Assert.False(string.IsNullOrWhiteSpace(failure.Error.Message)); + Assert.Single(form.WriteRequests); + Assert.False(File.Exists(path)); + Assert.False(Directory.Exists(Path.GetDirectoryName(path))); + }); + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void FailureAfterPartialWriteIsNotSuccess(int filterIndex) => RunSta(() => + { + using var files = new ExportFiles(); + using var form = ShowJob(); + var path = files.PathFor("partial-export"); + var error = new IOException("Injected failure after partial write."); + form.WriteAction = (_, destination, _) => + { + File.WriteAllText(destination, "partial"); + throw error; + }; + form.DialogResults.Enqueue((path, filterIndex)); + + Assert.False(form.Export()); + + var failure = Assert.Single(form.Failures); + Assert.Equal(path, failure.Destination); + Assert.Same(error, failure.Error); + Assert.Equal("partial", File.ReadAllText(path)); + AssertFileReleased(path); + }); + + [Fact] + public void FlushFailureDuringWriterDisposalIsNotSuccess() => RunSta(() => + { + using var files = new ExportFiles(); + using var form = ShowJob(); + var path = files.PathFor("flush-failure.txt"); + FlushFailureStream? stream = null; + var writeBodyCompleted = false; + form.WriteAction = (_, destination, _) => + { + using (stream = new FlushFailureStream(destination)) + using (var writer = new StreamWriter(stream)) + { + writer.WriteLine("partial"); + writeBodyCompleted = true; + Assert.False(stream.FlushAttempted); + } + }; + form.DialogResults.Enqueue((path, 3)); + + Assert.False(form.Export()); + + Assert.True(writeBodyCompleted); + Assert.NotNull(stream); + Assert.True(stream.FlushAttempted); + Assert.True(stream.Disposed); + var failure = Assert.Single(form.Failures); + Assert.Equal(path, failure.Destination); + Assert.IsType(failure.Error); + Assert.Equal("Injected flush failure during disposal.", failure.Error.Message); + Assert.Equal("partial" + Environment.NewLine, File.ReadAllText(path)); + AssertFileReleased(path); + }); + + [Fact] + public void UnsupportedFilterReportsFailureWithoutCreatingFile() => RunSta(() => + { + using var files = new ExportFiles(); + using var form = ShowJob(); + var path = files.PathFor("unsupported"); + + Assert.False(form.TryExportPlate(form.PlateView.Plate, path, 99)); + + var failure = Assert.Single(form.Failures); + Assert.Equal(path, failure.Destination); + var error = Assert.IsType(failure.Error); + Assert.Equal("filterIndex", error.ParamName); + Assert.False(File.Exists(path)); + Assert.Empty(form.DialogRequests); + }); + + [Fact] + public void CoordinateExportLeavesDocumentAndJobStateUntouched() => RunSta(() => + { + using var files = new ExportFiles(); + using var form = ShowJob(); + form.PlateManager.LoadAt(1); + var nest = form.Nest; + var savedPath = files.PathFor("saved-job.nest"); + form.Document.SaveAs(savedPath); + form.Document.BindRemote(Guid.NewGuid(), "https://nest-storage.example.test"); + var remoteSession = (NestSaveSession)typeof(Document) + .GetField("remoteSession", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(form.Document)!; + var remoteId = form.Document.RemoteId; + var serverUrl = remoteSession.ServerUrl; + var lastSaveDate = form.Document.LastSaveDate; + var lastModified = nest.DateLastModified; + var name = nest.Name; + var title = form.Text; + var savedBytes = File.ReadAllBytes(savedPath); + var plates = nest.Plates.ToArray(); + var plateQuantities = plates.Select(plate => plate.Quantity).ToArray(); + var drawings = nest.Drawings.ToArray(); + var drawingQuantities = drawings.Select(drawing => drawing.Quantity).ToArray(); + var parts = plates.SelectMany(plate => plate.Parts).ToArray(); + var poses = parts.Select(part => (part.Location.X, part.Location.Y, part.Rotation)).ToArray(); + var partCounts = plates.Select(plate => plate.Parts.Count).ToArray(); + var layoutDirty = form.PlateView.Parts.Select(part => part.IsDirty).ToArray(); + var plateChanged = 0; + form.PlateChanged += (_, _) => plateChanged++; + var path = files.PathFor("read-only-export.txt"); + form.DialogResults.Enqueue((path, 3)); + + Assert.True(form.Export()); + + Assert.Equal(ExpectedLines(plates[1]), File.ReadAllLines(path)); + Assert.Equal(savedPath, form.Document.LastSavePath); + Assert.True(form.Document.HasSavePath); + Assert.Equal(savedBytes, File.ReadAllBytes(savedPath)); + Assert.Equal(remoteId, form.Document.RemoteId); + Assert.Equal(serverUrl, remoteSession.ServerUrl); + Assert.Equal(lastSaveDate, form.Document.LastSaveDate); + Assert.Equal(lastModified, nest.DateLastModified); + Assert.Equal(name, nest.Name); + Assert.Equal(title, form.Text); + Assert.Equal(1, form.PlateManager.CurrentIndex); + Assert.Equal(plates, nest.Plates.ToArray()); + Assert.Equal(plateQuantities, nest.Plates.Select(plate => plate.Quantity).ToArray()); + Assert.Equal(drawings, nest.Drawings.ToArray()); + Assert.Equal(drawingQuantities, nest.Drawings.Select(drawing => drawing.Quantity).ToArray()); + Assert.Equal(partCounts, nest.Plates.Select(plate => plate.Parts.Count).ToArray()); + Assert.Equal(parts, nest.Plates.SelectMany(plate => plate.Parts).ToArray()); + Assert.Equal(poses, parts.Select(part => (part.Location.X, part.Location.Y, part.Rotation)).ToArray()); + Assert.Equal(layoutDirty, form.PlateView.Parts.Select(part => part.IsDirty).ToArray()); + Assert.Equal(0, plateChanged); + Assert.Empty(form.Failures); + }); + + [Fact] + public void ExportAllWritesEveryPlateOnceAndAdvancesToLast() => RunSta(() => + { + using var files = new ExportFiles(); + using var form = ShowJob(); + // EditNestForm adds an empty sentinel, and LoadNext visits it too. Preserve that behavior. + var plates = form.Nest.Plates.ToArray(); + Assert.Equal(4, plates.Length); + Assert.All(plates.Take(3), plate => Assert.NotEmpty(plate.Parts)); + Assert.Empty(plates[^1].Parts); + var paths = plates.Select((_, index) => files.PathFor($"plate-{index + 1}.txt")).ToArray(); + var expected = plates.Select(ExpectedLines).ToArray(); + foreach (var path in paths) + form.DialogResults.Enqueue((path, 3)); + form.PlateManager.LoadAt(1); // Export All must start at the first, not the selected plate. + + form.ExportAll(); + + Assert.Equal(plates, form.WriteRequests.Select(request => request.Plate).ToArray()); + Assert.Equal(paths, form.WriteRequests.Select(request => request.Destination).ToArray()); + Assert.All(form.WriteRequests, request => Assert.Equal(3, request.FilterIndex)); + Assert.Equal(Enumerable.Range(1, plates.Length).Select(index => $"export job-P{index}"), + form.DialogRequests); + Assert.Empty(form.DialogResults); + Assert.Empty(form.Failures); + Assert.Equal(plates.Length - 1, form.PlateManager.CurrentIndex); + Assert.Same(plates[^1], form.PlateView.Plate); + for (var i = 0; i < paths.Length; i++) + { + Assert.Equal(expected[i], File.ReadAllLines(paths[i])); + AssertFileReleased(paths[i]); + } + }); + + [Fact] + public void ExportAllStopsWhenSecondDialogIsCanceled() => RunSta(() => + { + using var files = new ExportFiles(); + using var form = ShowJob(); + var first = files.PathFor("plate-1.txt"); + var second = files.PathFor("plate-2.txt"); + var third = files.PathFor("plate-3.txt"); + form.DialogResults.Enqueue((first, 3)); + form.DialogResults.Enqueue(null); + form.DialogResults.Enqueue((third, 3)); + + form.ExportAll(); + + Assert.Equal(new[] { "export job-P1", "export job-P2" }, form.DialogRequests); + Assert.Single(form.DialogResults); + Assert.Same(form.Nest.Plates[0], Assert.Single(form.WriteRequests).Plate); + Assert.Equal(ExpectedLines(form.Nest.Plates[0]), File.ReadAllLines(first)); + Assert.False(File.Exists(second)); + Assert.False(File.Exists(third)); + Assert.Equal(1, form.PlateManager.CurrentIndex); + Assert.Same(form.Nest.Plates[1], form.PlateView.Plate); + Assert.Empty(form.Failures); + }); + + [Fact] + public void ExportAllStopsAtFirstWriteFailure() => RunSta(() => + { + using var files = new ExportFiles(); + using var form = ShowJob(); + var first = files.PathFor(Path.Combine("missing", "plate-1.txt")); + var second = files.PathFor("plate-2.txt"); + form.DialogResults.Enqueue((first, 3)); + form.DialogResults.Enqueue((second, 3)); + + form.ExportAll(); + + Assert.Equal("export job-P1", Assert.Single(form.DialogRequests)); + Assert.Single(form.DialogResults); + Assert.Same(form.Nest.Plates[0], Assert.Single(form.WriteRequests).Plate); + var failure = Assert.Single(form.Failures); + Assert.Equal(first, failure.Destination); + Assert.IsType(failure.Error); + Assert.False(File.Exists(first)); + Assert.False(File.Exists(second)); + Assert.Equal(0, form.PlateManager.CurrentIndex); + Assert.Same(form.Nest.Plates[0], form.PlateView.Plate); + }); + + private static TestEditNestForm ShowJob() + { + var form = new TestEditNestForm(CreateJob()); + form.PlateView.SetOverlapAutoCheck(null); + form.Show(); + return form; + } + + private static Nest CreateJob() + { + var nest = new Nest("export job") { Units = Units.Inches }; + var drawing = new Drawing("rect", Rectangle()); + drawing.Source.Path = @"C:\drawings\rect.dxf"; + drawing.Source.Offset = new Vector(1.234567891, -0.987654321); + drawing.Quantity.Required = 12; + nest.Drawings.Add(drawing); + var spare = new Drawing("spare", Rectangle()); + spare.Source.Path = @"C:\drawings\spare.dxf"; + spare.Source.Offset = new Vector(-0.25, 0.5); + spare.Quantity.Required = 5; + nest.Drawings.Add(spare); + for (var i = 0; i < 3; i++) + { + var plate = new Plate(24, 48) { Quantity = i + 1 }; + var part = new Part(drawing, new Vector(6.123456789 + i, 8.987654321 + i)); + part.Rotate(Angle.ToRadians(15.25 + i * 30)); + plate.Parts.Add(part); + plate.Parts.Add(new Part(spare, new Vector(14.5 + i, 16.25 + i))); + nest.Plates.Add(plate); + } + return nest; + } + + private static Program Rectangle() + { + var program = new Program(); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(4, 0)); + program.Codes.Add(new LinearMove(4, 2)); + program.Codes.Add(new LinearMove(0, 2)); + program.Codes.Add(new LinearMove(0, 0)); + return program; + } + + private static string[] ExpectedLines(Plate plate) => plate.Parts.Select(part => + { + var pt = part.BaseDrawing.Source.Offset.Rotate(part.Rotation); + return string.Format("{0}|{1},{2}|{3}", part.BaseDrawing.Source.Path, + System.Math.Round(part.Location.X - pt.X, 8), + System.Math.Round(part.Location.Y - pt.Y, 8), + Angle.ToDegrees(part.Rotation)); + }).ToArray(); + + private static void AssertFileReleased(string path) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + Assert.True(stream.CanRead); + } + + private sealed class TestEditNestForm(Nest nest) : EditNestForm(nest) + { + public Queue<(string Destination, int FilterIndex)?> DialogResults { get; } = []; + public List DialogRequests { get; } = []; + public List<(Plate Plate, string Destination, int FilterIndex)> WriteRequests { get; } = []; + public List<(string Destination, Exception Error)> Failures { get; } = []; + public System.Action? WriteAction { get; set; } + + internal override (string Destination, int FilterIndex)? ShowPlateExportDialog(string suggestedFileName) + { + DialogRequests.Add(suggestedFileName); + return DialogResults.Dequeue(); + } + + internal override void WritePlateExport(Plate plate, string destination, int filterIndex) + { + WriteRequests.Add((plate, destination, filterIndex)); + if (WriteAction != null) + WriteAction(plate, destination, filterIndex); + else + base.WritePlateExport(plate, destination, filterIndex); + } + + internal override void ReportPlateExportFailure(string destination, Exception error) => + Failures.Add((destination, error)); + } + + private sealed class FlushFailureStream(string path) : FileStream(path, FileMode.Create) + { + public bool FlushAttempted { get; private set; } + public bool Disposed { get; private set; } + + public override void Flush() + { + FlushAttempted = true; + base.Flush(); + throw new IOException("Injected flush failure during disposal."); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + Disposed = true; + } + } + + private sealed class ExportFiles : IDisposable + { + public string DirectoryPath { get; } = Path.Combine(Path.GetTempPath(), $"opennest-plate-export-{Guid.NewGuid():N}"); + + public ExportFiles() => Directory.CreateDirectory(DirectoryPath); + + public string PathFor(string name) => Path.Combine(DirectoryPath, name); + + public void Dispose() => Directory.Delete(DirectoryPath, true); + } + + 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(90)), "The STA test did not complete."); + if (failure != null) + ExceptionDispatchInfo.Capture(failure).Throw(); + } +} diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index b84abbb..fe9cde7 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -403,47 +403,63 @@ namespace OpenNest.Forms public bool Export() { - var dlg = new SaveFileDialog(); - dlg.Filter = - "DXF file (*.dxf)|*.dxf|" - + "Image as displayed (*.jpg)|*.jpg|" - + "Locations and rotations (*.txt)|*.txt"; + var suggestedFileName = string.Format("{0}-P{1}", Nest.Name, PlateManager.CurrentIndex + 1); + var target = ShowPlateExportDialog(suggestedFileName); + if (target == null) + return false; - dlg.FileName = string.Format("{0}-P{1}", Nest.Name, PlateManager.CurrentIndex + 1); - dlg.AddExtension = true; - dlg.DefaultExt = "."; + return TryExportPlate(PlateView.Plate, target.Value.Destination, target.Value.FilterIndex); + } - if (dlg.ShowDialog() == DialogResult.OK) + internal virtual (string Destination, int FilterIndex)? ShowPlateExportDialog(string suggestedFileName) + { + using var dlg = new SaveFileDialog { - if (dlg.FilterIndex == 1) - { - Dxf.ExportPlate(PlateView.Plate, dlg.FileName); - return true; - } - else if (dlg.FilterIndex == 2) - { - try + Filter = "DXF file (*.dxf)|*.dxf|" + + "Image as displayed (*.jpg)|*.jpg|" + + "Locations and rotations (*.txt)|*.txt", + FileName = suggestedFileName, + AddExtension = true, + DefaultExt = ".", + }; + return dlg.ShowDialog(this) == DialogResult.OK ? (dlg.FileName, dlg.FilterIndex) : null; + } + + internal bool TryExportPlate(Plate plate, string destination, int filterIndex) + { + try + { + WritePlateExport(plate, destination, filterIndex); + return true; + } + catch (Exception ex) + { + ReportPlateExportFailure(destination, ex); + return false; + } + } + + internal virtual void WritePlateExport(Plate plate, string destination, int filterIndex) + { + switch (filterIndex) + { + case 1: + Dxf.ExportPlate(plate, destination); + break; + case 2: + using (var img = new Bitmap(PlateView.Width, PlateView.Height)) { - var img = new Bitmap(PlateView.Width, PlateView.Height); PlateView.DrawToBitmap( img, new Rectangle(0, 0, PlateView.Width, PlateView.Height) ); - img.Save(dlg.FileName); + img.Save(destination, System.Drawing.Imaging.ImageFormat.Jpeg); } - catch { } - - return true; - } - else if (dlg.FilterIndex == 3) - { - StreamWriter writer = null; - - try + break; + case 3: + using (var writer = new StreamWriter(destination)) { - writer = new StreamWriter(dlg.FileName); - - foreach (var part in PlateView.Plate.Parts) + foreach (var part in plate.Parts) { var pt = part.BaseDrawing.Source.Offset.Rotate(part.Rotation); @@ -456,18 +472,16 @@ namespace OpenNest.Forms ); } } - catch { } - finally - { - if (writer != null) - writer.Dispose(); - } - } + break; + default: + throw new ArgumentException("Unsupported plate export format.", nameof(filterIndex)); } - - return false; } + internal virtual void ReportPlateExportFailure(string destination, Exception error) => + MessageBox.Show(this, $"Could not export plate to '{destination}': {error.Message}", + "Export Plate", MessageBoxButtons.OK, MessageBoxIcon.Error); + public void ExportAll() { PlateManager.LoadFirst();